斯坦福-CS106A-B-L-X-编程入门笔记-全-

斯坦福 CS106A/B/L/X 编程入门笔记(全)

课程01:认识Karel与编程基础 🚀

在本节课中,我们将学习计算机科学导论课程CS106A的基本信息、课程结构,并初步接触编程。我们将通过一个名为Karel的机器人环境,学习如何编写简单的指令来控制机器人完成任务。


课程概述与人员介绍

我是Marty Stepp,这门课的讲师。你可以叫我Marty。我的助教是Nick,他将协助处理课程的后勤与问题。此外,我们还有一组本科生担任“部分负责人”,他们将在每周的小组讨论中带领大家练习,并在实验室提供帮助、批改作业。

这门课是编程方法论,是计算机科学与编程的入门课程。我们将使用Java语言编写软件。本课程不要求任何编程经验,如果你知道如何打开电脑并使用浏览器,你就已经准备好了。


课程选择与替代方案

如果你不确定自己是否应该选择CS106A,可以参考课程网页上的常见问题解答。对于已有一些编程经验的学生,可以考虑CS106P课程。此外,本学期还提供了一个实验性的替代课程CS106G,它使用不同的编程语言(JavaScript),专注于网络开发。如果你对这些选项感兴趣,可以联系相关讲师了解更多信息。


什么是计算机科学?

计算机科学是研究算法的学科。算法是解决问题的一系列有序步骤或指令。计算机科学包含许多子领域,如人工智能、图形学、机器人学、数据挖掘等。如今,计算机科学几乎影响着所有领域,从医学到生物科学,强大的计算能力和算法技能都至关重要。


课程政策与评分

以下是本课程的主要政策与评分构成:

  • 作业 (40%):你将编写程序并在电脑上运行,然后在线提交。作业将由部分负责人评分。
  • 参与 (5%):这取决于你在每周讨论部分中的参与度。
  • 考试 (55%):本学期有两次考试。请务必记下考试日期,补考政策非常严格。

关于评分等级,课程结束后会根据分数百分比进行排序。通常,排名前40-50%的学生可以获得A或A-,接下来的30%左右获得B,其余获得C或更低。在实践中,实际成绩通常比这些百分比更好。


教材与资源

课程教材是Eric Roberts教授编写的《The Art and Science of Java》。虽然作业不强制要求教材,但它是一个极佳的参考资料,并且考试时允许携带此书。此外,第一周我们将使用一本较短的在线免费书籍《Karel the Robot Learns Java》。


作业、合作与学术诚信

本课程大约有七次作业。第一次作业需要独立完成,之后的作业可以两人合作完成(结对编程)。作业评分分为功能(程序是否完成任务)和风格(代码是否优雅、易读)。

我们使用强大的软件检查提交的作业是否存在抄袭。严禁复制他人的代码或从互联网上搜索解决方案。如果你遇到困难,请向助教、部分负责人或我寻求帮助,而不是寻找现成的答案。


软件安装与获取帮助

我们需要使用Eclipse软件来编写Java程序。请务必按照课程网站上的专用说明安装斯坦福定制版本的Eclipse,不要自行从网络下载通用版本。

如果你在安装或学习中遇到困难,我们提供了多种帮助渠道:

  • LaIR帮助实验室:晚上开放的实验室,有工作人员提供帮助。
  • 课程论坛:可以在线提问。
  • 办公时间:我和Nick会定期安排办公时间。
  • 电子邮件:随时可以联系我们。

什么是编程?

编程是给计算机一系列指令让其执行的过程。计算机理解二进制语言,而我们使用编程语言(如Java)来编写人类可读的指令,再由计算机翻译成二进制。

Java语言诞生于1995年,至今仍是世界上最流行、最重要的编程语言之一。本课程将在一个名为Karel the Robot的教学环境中开始学习Java。这是一个二维网格世界,我们可以编写程序控制一个名为Karel的机器人移动、转向、拾取和放置“蜂鸣器”。


开始编程:认识Karel

在Karel的世界里,我们可以发出几个基本命令:

  • move(); - Karel向前移动一格。
  • turnLeft(); - Karel向左转90度。
  • pickBeeper(); - Karel拾起当前位置的一个蜂鸣器。
  • putBeeper(); - Karel放下一个蜂鸣器到当前位置。

注意:没有turnRight();命令。如果你想右转,可以通过三次turnLeft();来实现。


编写你的第一个Karel程序

在Eclipse中,一个基本的Karel程序模板如下:

public class MeetKarel extends Karel {
    public void run() {
        // 在这里写下你的命令
        move();
        move();
        pickBeeper();
        turnLeft();
        // ... 更多命令
    }
}

你需要将MeetKarel替换为你自己的程序名。在run()方法的大括号{}内,按顺序写下给Karel的命令。每个命令后都必须加上分号;

例如,要让Karel移动两次,程序如下:

public class MoveTwice extends Karel {
    public void run() {
        move();
        move();
    }
}

编写完成后,点击Eclipse中的“Run”按钮,Karel的世界就会出现并执行你的指令。


本节总结

本节课中,我们一起学习了CS106A课程的基本框架、政策与资源。我们探讨了什么是计算机科学和编程,并初次接触了Java编程环境。通过Karel机器人,我们学会了如何发出move()turnLeft()等基本命令来控制一个虚拟角色。这是你编程之旅的第一步,重点是理解如何将任务分解为计算机可以执行的一系列简单指令。

课后挑战(可选):尝试安装Eclipse,并编写一个程序,让Karel机器人完成拾取三个蜂鸣器的任务。

课程10:文件处理 📂

在本节课中,我们将学习Java中文件处理的基础知识。我们将了解如何从文件中读取数据,这是编写能够处理真实世界数据的程序的关键一步。课程内容包括字符与字符串的区别、使用Scanner对象读取文件、处理文件读取时可能出现的异常,以及通过实际例子(如计算温度变化和分析选举数据)来巩固这些概念。


字符与字符串 🔤

上一节我们介绍了文件处理的重要性,本节中我们来看看处理文本数据时两个核心概念的区别:字符(char)和字符串(String)。

  • char 代表单个字符,使用单引号声明,例如:char letter = 'A';
  • String 代表一系列字符的集合,使用双引号声明,例如:String word = "Hello";

它们之间一个关键区别是,char值在内部以整数形式存储,因此你可以对其进行算术运算。例如,将字符'A'加1会得到字符'B'

char ch = 'A';
ch++; // 现在 ch 的值是 'B'

而字符串的+操作是连接(拼接),不是数值加法。

String str = "A";
str = str + 1; // 现在 str 的值是 "A1",而不是 "B"

凯撒密码示例 🔐

理解了字符的基本操作后,我们可以利用它来构建一个简单的加密程序——凯撒密码。其核心思想是将消息中的每个字母在字母表中移动固定的位数(称为密钥)。

以下是实现凯撒密码编码方法的步骤:

  1. 提示用户输入消息和移位密钥。
  2. 遍历消息中的每个字符。
  3. 判断字符是否为字母('A''Z'之间)。
  4. 如果是字母,则将其移动密钥指定的位数。
  5. 如果移动后超出'Z',则绕回字母表开头(例如,'Y'移动5位变成'D')。
  6. 将处理后的字符拼接成新的编码字符串。
  7. 返回编码后的字符串。
public static String encodeCaesarCipher(String message, int shiftKey) {
    String encoded = "";
    for (int i = 0; i < message.length(); i++) {
        char ch = message.charAt(i);
        if (ch >= 'A' && ch <= 'Z') {
            ch = (char)(ch + shiftKey);
            if (ch > 'Z') {
                ch = (char)(ch - 26); // 绕回字母表开头
            }
        }
        encoded += ch;
    }
    return encoded;
}


文件与对象 📁

现在,让我们进入本节课的核心主题:文件处理。文件是存储数据的持久化载体。在Java中,我们通过“对象”来与文件交互。

对象是程序中的一个实体,它封装了数据(状态)和能对这些数据进行的操作(方法)。例如,一个String对象的数据是字符序列,方法有.length().toUpperCase()等。

要读取文件,我们主要会用到两种对象:

  1. File对象:代表磁盘上的一个文件。你可以用它检查文件是否存在、获取文件名等。
  2. Scanner对象:一个通用的数据读取器。我们可以让它从一个File对象中读取内容。

使用前需要导入相应的包:

import java.io.*;   // 用于File对象
import java.util.*; // 用于Scanner对象

创建并使用它们读取文件的基本模式如下:

File file = new File("res/weather.txt"); // 创建一个指向文件的File对象
Scanner input = new Scanner(file);       // 创建一个从该文件读取数据的Scanner对象
// 现在可以使用 input 来读取文件内容了


读取文件数据 📖

创建了Scanner对象后,我们就可以从文件中读取数据了。Scanner提供了多种方法来读取不同类型的数据,例如.nextInt().nextDouble().nextLine()

读取文件时,Scanner内部有一个“光标”指向当前位置。每次调用读取方法,它都会从光标处读取数据,然后将光标移动到已读取内容之后。

通常,我们不知道文件里有多少数据,所以更常见的做法是使用while循环配合.hasNext...()方法(如.hasNextDouble())来读取,直到文件末尾。

以下是一个读取文件中所有双精度数字并打印的例子:

while (input.hasNextDouble()) {
    double number = input.nextDouble();
    System.out.println("Number: " + number);
}

处理异常 ⚠️

在尝试打开和读取文件时,可能会出错(例如文件不存在)。Java使用“异常”机制来处理这类运行时错误。

当我们编写可能抛出异常的代码时,需要使用try-catch语句块。将可能出错的代码放在try块中,然后在catch块中指定捕获哪种异常并处理它。

对于文件读取,常见的异常是FileNotFoundException

try {
    File file = new File("res/weather.txt");
    Scanner input = new Scanner(file);
    // ... 读取文件的代码
} catch (FileNotFoundException e) {
    System.out.println("文件未找到: " + e.getMessage());
    // 或者进行其他错误处理,如提示用户重新输入文件名
}

实战:分析温度数据 🌡️

让我们应用所学知识,解决一个实际问题:读取一份每日最高温度记录文件,并计算每日的温差。

文件weather.txt内容可能如下:

16.2
23.2
19.5
...

以下是计算温差的程序逻辑要点:

  1. 使用try-catch打开文件并创建Scanner
  2. 首先读取第一个温度值作为基准。
  3. while循环中,读取下一个温度值。
  4. 计算当前温度与前一个温度的差值并打印。
  5. 将当前温度设为下一个循环的“前一个温度”。

这个模式解决了经典的“栅栏柱”问题,确保我们正确比较连续的数据对。

// 假设已成功创建 Scanner input
if (input.hasNextDouble()) {
    double prevTemp = input.nextDouble(); // 读取第一个温度(柱子)
    while (input.hasNextDouble()) {
        double currentTemp = input.nextDouble(); // 读取下一个温度
        double diff = currentTemp - prevTemp;
        System.out.println("温度变化: " + diff);
        prevTemp = currentTemp; // 为下一次比较更新“前一个温度”
    }
}


实战:解析选举数据 🗳️

最后,我们看一个更结构化的数据解析例子。假设有一个选举数据文件election.txt,每行格式为:州代码 候选人1得票率 候选人2得票率 选举人票数

目标:统计两位候选人各自获得的总选举人票数。

处理思路:

  1. 使用while (input.hasNextLine())逐行读取。
  2. 对于每一行,创建一个新的Scanner来解析该行内容。
  3. 使用next()nextInt()方法提取出所需字段。
  4. 比较两位候选人的得票率,将本行的选举人票数加到获胜者的总票数中。
int candidate1Votes = 0;
int candidate2Votes = 0;

while (input.hasNextLine()) {
    String line = input.nextLine();
    Scanner lineScanner = new Scanner(line);
    
    String state = lineScanner.next(); // 读取州代码(本例中未使用)
    int votes1 = lineScanner.nextInt();
    int votes2 = lineScanner.nextInt();
    int electoralVotes = lineScanner.nextInt();
    
    if (votes1 > votes2) {
        candidate1Votes += electoralVotes;
    } else {
        candidate2Votes += electoralVotes;
    }
    lineScanner.close(); // 关闭行扫描器
}
// 最后输出 candidate1Votes 和 candidate2Votes

总结 ✨

本节课中我们一起学习了Java文件处理的核心知识。我们首先区分了charString,并利用字符操作实现了凯撒密码。接着,我们引入了“对象”的概念,并重点学习了如何使用FileScanner对象来读取文件中的数据。我们还探讨了如何使用try-catch语句处理文件读取中可能出现的异常。最后,通过分析温度变化和解析选举数据两个实战例子,我们巩固了循环读取、数据解析和逻辑处理的能力。掌握文件处理是使程序能够与真实世界数据交互的关键一步。

课程11:图形入门 🎨

在本节课中,我们将学习如何使用Java进行图形绘制。我们将了解如何创建窗口、绘制基本形状(如矩形、椭圆形和线条)、设置颜色,以及如何通过循环和方法来组织图形代码。课程内容将结合文件处理的知识,并逐步引导你创建自己的图形程序。


文件处理回顾与图形引入

上一节我们介绍了如何使用Scanner处理文件数据。本节中,我们来看看如何将编程的焦点转向图形界面。

在图形编程中,我们不再仅仅在控制台输出文本,而是在一个独立的窗口中绘制形状和颜色。你的第四项作业将是图形化的,这涉及到绘制颜色、形状、线条等元素。

为了在程序中使用图形,你需要扩展一个特定的类并导入必要的库。基本的设置如下:

import acm.graphics.*;
import acm.program.*;
import java.awt.*;

public class MyGraphicsProgram extends GraphicsProgram {
    public void run() {
        // 在这里添加图形绘制代码
    }
}

理解坐标系统

在屏幕上绘制图形时,我们使用一个基于像素的坐标系统。

  • 窗口的左上角是坐标原点 (0, 0)
  • X坐标向右移动时增加。
  • Y坐标向下移动时增加(这与常见的数学坐标系相反,是因为计算机屏幕的绘制方式是从上到下的)。

窗口的尺寸(宽度和高度)可以自定义,通常设置为数百像素,例如500像素宽,300像素高。


绘制基本形状

以下是你可以添加到屏幕上的几种基本图形对象:

  • GRect:矩形
  • GOval:椭圆形(包括圆形)
  • GLine:线条
  • GLabel:文本标签

创建并添加一个形状到屏幕的语法是类似的。你需要使用 new 关键字来创建对象,并指定其位置和尺寸。

例如,绘制一个矩形和一条线的代码如下:

add(new GRect(100, 50, 70, 45)); // 在(100,50)位置,绘制宽70、高45的矩形
add(new GLine(200, 100, 300, 150)); // 从点(200,100)到点(300,150)画一条线

参数含义:对于GRect,参数是左上角X坐标、左上角Y坐标、宽度、高度。对于GLine,参数是起点X坐标、起点Y坐标、终点X坐标、终点Y坐标


使用循环绘制图形

你可以结合循环来绘制多个形状。例如,如果你想在一个矩形内绘制10条等间距的水平线(形成阴影效果),可以使用for循环。

以下是实现此效果的思路:

  1. 确定矩形的边界(起始Y坐标和高度)。
  2. 在循环中,每次计算一条新线的Y坐标。
  3. 线的起点和终点的X坐标对应矩形的左右边界。

for (int i = 0; i < 10; i++) {
    int y = 50 + i * 10; // 从Y=50开始,每条线间隔10像素
    add(new GLine(100, y, 170, y)); // 绘制从X=100到X=170的水平线
}


设置颜色与图形属性

要改变形状的颜色,你不能直接在add语句中设置。你需要先创建图形对象,设置其属性,然后再添加它。

设置颜色的基本步骤:

  1. 创建一个图形对象(如GRect)并赋值给一个变量。
  2. 使用该变量调用方法设置颜色(例如setColor用于边框色,setFillColor用于填充色)。
  3. 如果需要填充形状,还需调用setFilled(true)
  4. 最后,使用add方法将对象添加到窗口。

// 1. 创建矩形对象
GRect myRect = new GRect(100, 50, 70, 45);

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/44956c65143abd52afc718c13e39f1ac_22.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/44956c65143abd52afc718c13e39f1ac_24.png)

// 2. 设置属性
myRect.setFillColor(Color.RED);
myRect.setFilled(true);
myRect.setColor(Color.GREEN);

// 3. 添加到屏幕
add(myRect);

Java提供了一些预定义颜色,如Color.RED, Color.BLUE等。你也可以通过指定红、绿、蓝(RGB)分量值(每个值范围0-255)来创建自定义颜色。

Color myOrange = new Color(255, 165, 0); // 创建橙色
myRect.setFillColor(myOrange);

创建方法与参数化图形

当你想绘制多个相似但位置或大小不同的图形时,将绘制代码封装到一个方法中是很好的做法。你可以给这个方法添加参数,比如x, y坐标,来控制图形的绘制位置。

例如,一个绘制汽车的方法:

private void drawCar(int x, int y) {
    // 车身:位置基于传入的 (x, y)
    GRect body = new GRect(x, y, 100, 50);
    body.setFillColor(Color.BLUE);
    body.setFilled(true);
    add(body);

    // 轮子:位置相对于车身计算 (x + 偏移量, y + 偏移量)
    GOval wheel1 = new GOval(x + 10, y + 40, 20, 20);
    add(wheel1);
    GOval wheel2 = new GOval(x + 70, y + 40, 20, 20);
    add(wheel2);
}

然后,你可以在run方法中多次调用drawCar,并传入不同的坐标来绘制多辆汽车。

public void run() {
    drawCar(50, 100);
    drawCar(200, 100);
}

通过进一步添加参数(如carWidth, carHeight),你还可以控制所绘制汽车的大小。


总结

本节课中我们一起学习了Java图形编程的基础。

  • 我们了解了图形坐标系统。
  • 学习了如何绘制GRectGOvalGLine等基本形状。
  • 掌握了通过先创建对象、设置属性(如颜色)、再添加的方式来绘制更丰富的图形。
  • 探索了如何利用循环来生成重复或规律的图形图案。
  • 最后,我们运用方法封装绘图逻辑,并通过参数控制图形的位置,这使得绘制复杂场景变得更有条理和高效。

图形编程将我们之前学到的变量、循环、方法等概念与视觉输出结合起来,是创建游戏、模拟和交互式应用的重要第一步。

课程12:图形入门 🎨

在本节课中,我们将要学习Java中的图形编程。我们将从回顾ArrayList开始,然后深入探讨如何在屏幕上绘制形状、线条和文本,以及如何为它们添加颜色和动画效果。


回顾ArrayList 📚

上一节我们介绍了ArrayList,它是一种用于存储数据集合的强大工具。本节中我们来看看它的一些细节和注意事项。

ArrayList的基本概念

ArrayList就像一个可以容纳多个元素的“公寓楼”。当你声明一个ArrayList时,需要使用特定的语法来指定它将存储的数据类型。

声明一个存储字符串的ArrayList:

ArrayList<String> list = new ArrayList<String>();

声明一个存储整数的ArrayList:

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

需要注意的是,ArrayList不能直接存储像intdouble这样的原始类型。你必须使用对应的包装类,如IntegerDouble。这是因为ArrayList设计为存储对象类型

ArrayList的添加与删除操作

以下是ArrayList的一些核心操作方法。

添加元素到末尾:

list.add(value);

在指定索引处插入元素:

list.add(index, value);

此操作会将指定索引及之后的元素向右移动。

删除指定索引处的元素:

list.remove(index);

此操作会删除该元素,并将其后的所有元素向左移动以填补空缺。

一个需要注意的循环陷阱

考虑以下代码,它试图清空一个列表:

for (int i = 0; i < list.size(); i++) {
    list.remove(i);
}

这段代码不会清空列表。因为在循环过程中,每删除一个元素,列表的大小(size())就会减小,同时剩余元素的索引会发生变化,导致循环提前终止并只删除了一半的元素。

正确的清空方法之一是使用clear()方法:

list.clear();

或者使用从后向前的循环:

for (int i = list.size() - 1; i >= 0; i--) {
    list.remove(i);
}

进入图形世界 🖼️

现在,让我们转向一个完全不同的主题——图形编程。与处理文本的控制台程序不同,图形程序允许我们在窗口中绘制形状和颜色。

图形程序的基础

一个图形程序扩展自GraphicsProgram类。它的窗口就像一块画布,由许多称为像素的小点组成。坐标系的原点(0, 0)位于窗口的左上角。X轴向右延伸,Y轴向下延伸。

绘制基本形状

在斯坦福库中,有多种图形对象可供使用,它们都以字母G开头。

以下是创建并添加一个矩形到屏幕的示例:

add(new GRect(x, y, width, height));
  • x, y: 矩形左上角的坐标。
  • width, height: 矩形的宽度和高度。

类似地,你可以绘制椭圆形和线条:

// 绘制椭圆形(指定其外接矩形)
add(new GOval(x, y, width, height));
// 绘制线条(指定起点和终点)
add(new GLine(x1, y1, x2, y2));
// 绘制文本标签
add(new GLabel(“文本内容”, x, y));

组合形状:绘制笑脸

我们可以组合使用这些基本形状来绘制更复杂的图形,例如一个笑脸。

// 1. 绘制头部(黄色矩形)
GRect head = new GRect(30, 10, 200, 200);
head.setFilled(true);
head.setFillColor(Color.YELLOW);
add(head);

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/04f254bd7e715597f912bd238fb01b65_42.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/04f254bd7e715597f912bd238fb01b65_44.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/04f254bd7e715597f912bd238fb01b65_46.png)

// 2. 绘制左眼(蓝色实心圆)
GOval leftEye = new GOval(50, 30, 30, 30);
leftEye.setFilled(true);
leftEye.setFillColor(Color.BLUE);
add(leftEye);

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/04f254bd7e715597f912bd238fb01b65_48.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/04f254bd7e715597f912bd238fb01b65_50.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/04f254bd7e715597f912bd238fb01b65_52.png)

// 3. 绘制右眼
GOval rightEye = new GOval(150, 30, 30, 30);
rightEye.setFilled(true);
rightEye.setFillColor(Color.BLUE);
add(rightEye);

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/04f254bd7e715597f912bd238fb01b65_54.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/04f254bd7e715597f912bd238fb01b65_56.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/04f254bd7e715597f912bd238fb01b65_58.png)

// 4. 绘制鼻子(红色实心圆)
GOval nose = new GOval(110, 70, 20, 20);
nose.setFilled(true);
nose.setFillColor(Color.RED);
add(nose);

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/04f254bd7e715597f912bd238fb01b65_60.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/04f254bd7e715597f912bd238fb01b65_62.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/04f254bd7e715597f912bd238fb01b65_64.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/04f254bd7e715597f912bd238fb01b65_66.png)

// 5. 绘制嘴巴(三条线段)
add(new GLine(50, 160, 210, 160));
add(new GLine(50, 160, 70, 140));
add(new GLine(210, 160, 190, 140));

绘制顺序很重要:后添加的图形会覆盖在先添加的图形之上。因此,通常先画背景,再画前景物体。

使用循环绘制图形

你可以利用for循环来绘制一系列图形,通过改变每次循环中传递给形状构造函数的坐标参数。

例如,绘制一排逐渐右移的文本标签:

for (int i = 0; i < 10; i++) {
    add(new GLabel(“Hello”, 70 + i * 14, 250 + i * 20));
}

定制图形对象

在将图形对象添加到屏幕之前,可以将其存储在变量中,并调用方法来设置其属性。

以下是可用的方法示例:

  • setFilled(boolean filled): 设置形状是否填充颜色。
  • setFillColor(Color color): 设置填充颜色。
  • setColor(Color color): 设置线条或轮廓的颜色。
  • setFont(Font font): (针对GLabel)设置文本字体。

颜色:可以使用Color类中预定义的颜色常量,如Color.RED, Color.BLUE, Color.GREEN等。

示例——设置一个带颜色的标签:

GLabel label = new GLabel(“我喜欢CS106A”, 70, 250);
label.setFont(new Font(“Serif”, Font.BOLD, 40));
label.setColor(Color.MAGENTA);
add(label);

窗口设置

你可以在run方法中设置窗口的标题和大小。

public void run() {
    setTitle(“我的图形程序”);
    setSize(400, 300);
    // ... 其余的绘图代码
}


总结 ✨

本节课中我们一起学习了两个主要部分。

首先,我们更深入地探讨了ArrayList,了解了其存储对象类型的特点、添加删除元素的操作,以及在使用循环修改列表时需要特别注意的陷阱。

然后,我们进入了图形编程的世界。我们学习了如何创建图形程序窗口,并使用GRectGOvalGLineGLabel等对象绘制基本形状。我们还掌握了如何通过setFilledsetFillColor等方法定制这些图形的外观,并利用循环来创建有规律的图案。

图形编程为你的程序打开了可视化的大门,让你能够创建出比控制台文本更丰富、更互动的用户体验。

课程13:动画 🎬

在本节课中,我们将学习如何为图形程序添加动画效果。我们将从回顾事件处理开始,然后深入探讨动画的核心概念,即通过循环不断更新图形对象的位置并短暂暂停,从而创造出运动的效果。我们还将学习如何使用 GCompound 来分组管理多个图形对象。


公告与回顾

首先是一些课程公告。我通常会在周一课后安排办公时间,但本周一我有一个重要冲突,因此今天的办公时间取消。如果有简单问题,可以在讲座后找我。我会在周三下午安排补上的办公时间。你也可以随时通过电子邮件或课程留言板联系我。

现在,让我们回顾一下上节课的内容。上周五,尼克讲解了事件处理。

事件是程序中发生的事情,通常由用户触发,例如点击鼠标或按下键盘。程序可以等待事件发生,然后运行相应的代码来响应。在图形编程中,这是一个核心概念。

尼克展示了一个“打地鼠”程序。当用户点击鼠标时,程序会运行一个特定的方法(如 mouseClicked),该方法可以获取点击的坐标,并对该位置的图形对象进行操作。

为了演示,我修改了尼克的程序,将地鼠图片换成了我的狗ABI的图片,并将程序重命名为“PetABI”。我还调整了图片的大小。

// 示例:设置图片尺寸
GOval abiImage = new GOval(x, y, width, height);
abiImage.setSize(50, 50); // 设置宽度和高度为50像素

然而,这个程序有一个问题:如果点击空白区域(没有ABI图片的地方),程序会崩溃并抛出 NullPointerException 错误。

这是因为代码尝试获取点击位置的图形对象,如果该位置没有对象,方法会返回 null。尝试对 null 执行操作(如删除或修改)就会导致程序出错。

解决方法是在操作前检查对象是否为 null

public void mouseClicked(MouseEvent e) {
    GObject obj = getElementAt(e.getX(), e.getY());
    if (obj != null) {
        // 执行操作,例如移除对象
        remove(obj);
    } else {
        // 点击了空白区域,可以执行其他操作,例如改变背景色
        setBackground(Color.RED);
    }
}


动画的核心概念 🌀

上一节我们介绍了事件,本节中我们来看看如何创建动画。

动画的基本思想很简单:在循环中不断更新图形对象(例如位置、颜色),然后短暂暂停,如此反复。这种快速的连续更新在人眼中就形成了平滑的运动。

例如,许多电子游戏以每秒30帧或60帧的速度运行,这意味着每帧之间暂停约33毫秒或16毫秒。

以下是实现动画所需的关键方法:

  • 更新形状:使用 getX(), getY(), setLocation(x, y), setColor(color) 等方法。
  • 移动形状move(dx, dy) 方法可以将对象在X和Y方向上移动指定的像素数。
  • 暂停程序pause(milliseconds) 方法可以让程序暂停指定的毫秒数。

一个典型的动画循环结构如下:

while (true) { // 无限循环,或设置终止条件
    // 更新所有图形对象的状态
    updateAllShapes();
    // 暂停一小段时间
    pause(FRAME_DELAY); // 例如 pause(50) 表示暂停50毫秒
}

案例一:下雨动画 🌧️

让我们通过一个“下雨”程序来实践动画。目标是让雨滴(圆形)从屏幕顶部随机位置出现,并以恒定速度下落。

首先,我们创建一个雨滴并让它下落。

// 创建雨滴
GOval raindrop = new GOval(x, 0, RAINDROP_SIZE, RAINDROP_SIZE);
raindrop.setFilled(true);
raindrop.setColor(Color.CYAN);
add(raindrop);

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/f157a2c20468f572469807af46c75d51_25.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/f157a2c20468f572469807af46c75d51_27.png)

// 动画循环:让雨滴下落
while (true) {
    raindrop.move(0, RAINDROP_SPEED); // 在Y方向移动
    pause(FRAME_DELAY);
}

但是,我们想要多个雨滴。我们需要一种方法来管理屏幕上的所有图形对象。

这时,我们可以使用 for-each 循环来遍历窗口中的每个图形对象:

// 遍历窗口中的每个图形对象并移动它们
for (GObject obj : this) {
    obj.move(0, RAINDROP_SPEED);
}

为了定期生成新雨滴,我们可以在循环中跟踪时间。每过500毫秒(半秒),就在顶部随机位置创建一个新雨滴。

int elapsedTime = 0;
while (true) {
    // 移动所有现有雨滴
    for (GObject obj : this) {
        obj.move(0, RAINDROP_SPEED);
    }
    pause(FRAME_DELAY);
    elapsedTime += FRAME_DELAY;

    // 每500毫秒添加一个新雨滴
    if (elapsedTime % 500 == 0) {
        int randomX = rgen.nextInt(0, getWidth() - RAINDROP_SIZE);
        GOval newDrop = new GOval(randomX, 0, RAINDROP_SIZE, RAINDROP_SIZE);
        // ... 设置颜色等
        add(newDrop);
    }
}

我们还可以结合事件处理。例如,让用户点击鼠标时,在点击位置生成一个雨滴。这需要编写 mouseClicked 方法,并在其中调用创建雨滴的函数。


案例二:移动的汽车 🚗

现在,我们尝试制作一个更复杂的动画:让一辆汽车在屏幕上移动。一辆汽车由多个图形部件(车身、车轮、车窗)组成。

如果我们分别移动每个部件,代码会变得冗长且难以管理,尤其是当有多辆汽车时。

这时,GCompound 就派上用场了。GCompound 是一个容器,可以将多个图形对象组合成一个逻辑整体。你可以将整个复合体作为一个对象来添加、移动或操作。

以下是使用 GCompound 创建汽车的示例:

private GCompound drawCar(int x, int y) {
    GCompound car = new GCompound();

    GRect body = new GRect(x, y, 60, 20);
    body.setFilled(true);
    body.setColor(Color.BLUE);
    car.add(body); // 将车身添加到复合体中

    GOval frontWheel = new GOval(x+10, y+15, 10, 10);
    frontWheel.setFilled(true);
    car.add(frontWheel);
    // ... 添加其他部件(后轮、车窗等)

    return car; // 返回整辆汽车(复合体)
}

在主程序中,我们可以创建多辆汽车,并轻松地移动其中任何一辆:

// 创建汽车
GCompound car1 = drawCar(50, 100);
GCompound car2 = drawCar(150, 200);
add(car1);
add(car2);

// 在动画循环中只移动 car1
while (true) {
    car1.move(CAR_SPEED, 0);
    pause(FRAME_DELAY);
}

你还可以扩展这个程序,让汽车在碰到屏幕边缘时反弹或掉头,这需要通过 getX()getWidth() 等方法检测汽车的位置。


总结 📝

本节课中我们一起学习了图形编程中的动画技术。

我们首先回顾了事件处理,并修复了因处理 null 对象而导致的程序崩溃问题。接着,我们深入探讨了动画的原理:在循环中更新图形状态并短暂暂停。我们通过“下雨”程序实践了基础动画和多对象管理。最后,我们引入了 GCompound 这个强大工具,它能将多个图形对象分组,从而简化复杂图形(如汽车)的动画控制。

记住动画的核心公式:更新 -> 暂停 -> 循环。结合事件处理和 GCompound,你就能创造出丰富交互的图形应用程序。

课程14:事件与字段 🖱️📝

在本节课中,我们将学习Java图形编程中的两个核心概念:事件字段。我们将了解如何让程序响应用户的交互(如鼠标点击),以及如何在程序的不同部分之间共享数据。


概述

上一节我们介绍了基本的图形绘制。本节中,我们来看看如何让图形程序变得“交互式”。我们将学习如何监听和响应事件(例如鼠标点击),以及如何使用字段在不同方法之间共享信息。


事件驱动编程

在图形程序中,大部分代码通常用于等待和响应用户的操作,比如点击按钮或移动鼠标。这种编程模式称为事件驱动编程

一个事件是程序中发生的、需要代码去响应的事情。常见的例子包括鼠标点击、按键或定时器触发。

要处理事件,你需要做两件事:

  1. 告诉程序你希望监听哪种类型的事件。
  2. 编写当事件发生时需要执行的代码。

监听鼠标事件

以下是一个简单的例子,展示了如何让程序在鼠标点击的位置绘制一个矩形。

首先,你需要在代码顶部导入事件库:

import java.awt.event.*;

在你的 run 方法中,添加一行代码来启用鼠标监听:

addMouseListeners();

这行代码来自斯坦福库,它的作用是“打开”鼠标事件的监听功能。

接下来,你需要编写一个具有特定名称的方法。Java会在相应事件发生时自动调用这个方法。例如,要响应鼠标按下事件,你需要编写一个名为 mousePressed 的方法。

public void mousePressed(MouseEvent e) {
    // 当鼠标按下时,这里的代码会被执行
    // e.getX() 和 e.getY() 可以获取鼠标点击的坐标
    int x = e.getX();
    int y = e.getY();
    GRect rect = new GRect(x, y, 50, 50);
    add(rect);
}

关键点:在事件驱动程序中,run 方法通常只做一些初始化工作(如设置窗口大小、开启事件监听),然后就结束了。程序并不会退出,而是进入“等待”状态。当事件(如鼠标点击)发生时,Java会自动跳转到你编写的对应方法(如 mousePressed)中执行代码,执行完毕后再回到等待状态。这与我们之前编写的顺序执行程序有很大不同。

不同类型的事件

除了 mousePressed,你还可以监听其他类型的鼠标事件:

  • mouseReleased:鼠标按钮释放时触发。
  • mouseClicked:鼠标完成一次点击(按下并释放)时触发。
  • mouseMoved:鼠标移动时触发(不按按钮)。
  • mouseDragged:鼠标按下按钮并移动时触发。

以下是使用 mouseDragged 实现一个简单涂鸦程序的例子:

public void mouseDragged(MouseEvent e) {
    int x = e.getX();
    int y = e.getY();
    GRect rect = new GRect(x-10, y-10, 20, 20); // 让矩形在鼠标位置居中
    rect.setFilled(true);
    add(rect);
}

与已绘制的图形交互

有时,你不仅想绘制新图形,还想与屏幕上已经存在的图形进行交互,例如点击一个图形将其删除。

你可以使用 getElementAt(x, y) 方法。它接收一个坐标 (x, y),并返回屏幕上位于该坐标最顶层的图形对象。如果没有图形,则返回一个特殊值 null

null 在Java中表示“没有对象”或“空引用”。如果你尝试对一个 null 值进行操作,程序会崩溃并抛出 NullPointerException 异常。

因此,在操作返回的对象前,必须检查它是否为 null

public void mousePressed(MouseEvent e) {
    int x = e.getX();
    int y = e.getY();
    GObject obj = getElementAt(x, y); // 获取点击位置的图形

    if (obj != null) { // 关键:检查是否真的点击到了一个图形
        remove(obj);   // 如果点击到了,就删除它
    }
}

字段:在方法间共享数据

现在,我们面临一个新的问题:如何在不同的方法之间共享信息?例如,在 mousePressed 方法中创建了一个图形,如何在 mouseDragged 方法中修改它?

方法内部声明的变量(局部变量)只能在该方法内部使用。为了让多个方法都能访问同一个变量,我们需要将它声明为字段(Field),也称为实例变量。

字段声明在类内部,但在所有方法的外部。这样,该类中的所有方法就都能看到并使用这个变量。

public class Doodler extends GraphicsProgram {
    // 声明一个字段(实例变量)
    private GRect currentRect;

    public void mousePressed(MouseEvent e) {
        int x = e.getX();
        int y = e.getY();
        currentRect = new GRect(x-25, y-25, 50, 50); // 创建矩形并赋值给字段
        add(currentRect);
    }

    public void mouseDragged(MouseEvent e) {
        if (currentRect != null) {
            // 可以在这里根据鼠标位置更新 currentRect 的位置或大小
            // 所有方法都能访问 currentRect 这个字段
        }
    }
}

注意:虽然字段非常有用,但应谨慎使用。如果过多地使用字段(全局数据),会导致程序状态难以追踪,容易引入难以发现的错误。通常,只在确实需要多个方法共享数据时才使用字段。


总结

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

  1. 事件驱动编程:程序通过监听和响应事件(如鼠标点击)来与用户交互。
  2. 鼠标事件:如何使用 mousePressed, mouseDragged 等方法,并通过 MouseEvent 参数获取事件详情(如坐标)。
  3. 图形交互:使用 getElementAt() 方法获取屏幕上特定位置的图形,并理解 null 值的含义及检查的重要性。
  4. 字段:通过将变量声明为类的字段,实现在不同方法之间共享数据。

掌握事件和字段是构建复杂、交互式图形应用程序的基础。接下来,你可以尝试组合这些概念,创建更有趣的程序,比如简单的绘图软件或交互式游戏。

📘 课程名称:CS 106a Java教程 - 第15讲:布尔逻辑与数组进阶

📋 概述

在本节课中,我们将要学习布尔数据类型的基本概念及其在Java编程中的应用,同时回顾并深化对数组的理解,特别是如何将数组作为参数传递给方法以及从方法中返回数组。


🧠 布尔数据类型

上一节我们介绍了期中考试的相关安排,本节中我们来看看布尔逻辑这一重要概念。

布尔数据类型是一种用于存储逻辑测试结果的数据类型。这种测试通常会产生 truefalse 的结果。例如,在 if 语句或 while 循环中使用的条件表达式,其本质就是布尔值。

你可以创建布尔类型的变量。例如:

boolean isMinor = age < 21;

这行代码的含义是:测试变量 age 是否小于21。如果为真,则变量 isMinor 的值为 true;如果为假,则其值为 false

布尔值不是字符串。字符串是由引号包围的字符序列,而布尔值 false 是一个具有特定含义的独立数据类型。

布尔数据类型的有用之处在于,我们可以将一个复杂逻辑测试的结果保存下来,并在后续代码中重复使用,就像我们将算术表达式的结果保存在 intdouble 变量中一样。

以下是如何初始化和使用布尔变量的代码示例:

boolean isProfessor = name.contains("Prof");
boolean likesCS = true;

在后续代码中,使用变量 isProfessor 就等同于直接执行测试 name.contains("Prof")

布尔值作为方法参数和返回值

方法可以接受布尔值作为参数,也可以返回布尔值。你已经使用过许多返回布尔值的方法,例如 Karel 中的 frontIsClear()Scanner 中的 hasNextLine()。这些方法返回的 truefalse 可以直接用于 ifwhile 的条件判断。

使用布尔值的好处

使用布尔变量或返回布尔值的方法可以使代码更具可读性。例如,在一个复杂的决策程序中,将多个条件测试的结果分别保存在有意义的布尔变量中,然后在主逻辑中组合使用这些变量,会比一个庞大而复杂的 if 条件语句更清晰易懂。

编写返回布尔值的方法

你可以编写返回布尔值的方法,并在条件语句中直接调用它们。例如,一个判断数字是否为偶数的方法:

public static boolean isEven(int n) {
    return n % 2 == 0;
}

使用时,可以这样写:

if (isEven(42)) {
    // 执行操作
}

注意,不需要写成 if (isEven(42) == true),因为 isEven(42) 本身就会返回一个布尔值,直接将其作为条件即可。这种简洁的写法是“布尔禅”的一部分。

同样,如果你想检查条件是否为假,也不应写 == false,而应使用逻辑非运算符 !

if (!isEven(57)) {
    // 执行操作
}

布尔返回方法的进阶示例

有时,方法的布尔返回值并非来自一行简单的测试,而是需要一些计算。例如,判断一个数是否为质数的方法:

public static boolean isPrime(int n) {
    int factors = 0;
    for (int i = 1; i <= n; i++) {
        if (n % i == 0) {
            factors++;
        }
    }
    return factors == 2; // 质数恰好有两个因子:1和自身
}

德摩根定律

在编程中,经常需要取反一个布尔表达式。德摩根定律提供了如何正确取反由 && (与) 和 || (或) 组成的表达式的规则:

  • !(A && B) 等价于 !A || !B
  • !(A || B) 等价于 !A && !B
    理解这一定律有助于避免在翻转复杂条件时引入逻辑错误。


🧪 实践练习:编写押韵检查方法

让我们通过一个练习来巩固对布尔方法的理解。我们将编写一个方法,检查两个单词是否“押韵”(这里简单定义为以相同的最后两个字母结尾)。

以下是方法的核心实现步骤:

  1. 方法签名:public static boolean rhyme(String s1, String s2)
  2. 获取两个字符串的最后两个字母。使用 substring 方法:s1.substring(s1.length() - 2)
  3. 比较这两个子字符串是否相等。
  4. 处理边缘情况:如果字符串长度不足2,则无法押韵。
  5. 处理大小写问题:在比较前,将字符串转换为统一的小写形式。

考虑所有因素后的完整方法可能如下所示:

public static boolean rhyme(String s1, String s2) {
    if (s1.length() < 2 || s2.length() < 2) {
        return false; // 长度不足,无法比较
    }
    String lastTwo1 = s1.substring(s1.length() - 2).toLowerCase();
    String lastTwo2 = s2.substring(s2.length() - 2).toLowerCase();
    return lastTwo1.equals(lastTwo2);
}

在主程序中,你可以这样使用它:

if (rhyme(word1, word2)) {
    System.out.println("They rhyme!");
}

这种将复杂测试封装成方法的方式,使得主程序逻辑清晰易读。


🔁 关于方法返回值的思考

编写返回布尔值(或其他类型)的方法时,需要仔细考虑返回的时机。例如,一个模拟抽奖的方法:随机生成10个数字,如果抽到幸运数字7就立即返回 true,如果10次都没抽到则返回 false

错误的实现可能会在第一次没抽中时就错误地返回 false。正确的逻辑是:在循环内部,一旦抽中7就立即返回 true;只有循环完全结束(尝试了10次都未成功)后,才在循环外部返回 false。这强调了在方法中合理安排返回语句位置的重要性。


📊 数组进阶:作为方法参数和返回值

现在,让我们回到数组主题,并学习如何更好地在方法间传递数组数据。

问题背景

假设有一个程序,需要:1. 读取一系列温度值到数组;2. 计算平均温度;3. 计算有多少天温度高于平均值。理想的程序结构是将这三个任务分解成独立的方法。

将数组作为参数传递

如果你想编写一个计算数组平均值的方法,你需要将数组传递给它。语法如下:

public static double calculateAverage(int[] temps) {
    int sum = 0;
    for (int i = 0; i < temps.length; i++) {
        sum += temps[i];
    }
    return (double) sum / temps.length;
}

这里,int[] temps 声明了一个名为 temps 的整型数组参数。

从方法返回数组

同样,一个方法也可以返回一个数组。例如,读取温度数据的方法可以创建并返回一个数组:

public static int[] readTemperatures() {
    // ... 创建数组,从用户输入填充数据 ...
    return temperatures; // 返回数组变量名,不带括号
}

在调用方,你可以这样接收返回的数组:

int[] tempArray = readTemperatures();

整合示例

通过结合参数传递和返回值,我们可以重构原始程序:

  1. readTemperatures() 方法负责创建并返回填充好的温度数组。
  2. calculateAverage(int[] temps) 方法接收温度数组,计算并返回平均值。
  3. daysAboveAverage(int[] temps, double average) 方法接收数组和平均值,计算高于平均值的天数。
  4. run 方法协调调用这些方法,传递必要的参数,并打印结果。

这种方式使得代码模块化程度更高,每部分功能明确,易于理解和维护。


📝 总结

本节课中我们一起学习了两个核心主题:

  1. 布尔逻辑:深入理解了布尔数据类型,学习了如何声明布尔变量、编写返回布尔值的方法,并掌握了“布尔禅”的简洁写法以及德摩根定律,这些都有助于编写更清晰、更正确的条件逻辑。
  2. 数组的传递:复习了数组的基础,并重点学习了如何将数组作为参数传递给方法,以及如何从方法中返回数组。通过将程序分解为多个方法,并使用参数和返回值在它们之间传递数组数据,我们可以构建出结构更优、更易维护的程序。

理解这些概念对于构建更复杂的Java程序至关重要。

课程名称:多维数组与图像处理 🖼️

课程编号:P16

在本节课中,我们将要学习多维数组,特别是二维数组的概念、语法及其应用。我们将通过一个具体的例子——使用二维数组来表示和操作图像像素——来深入理解这一数据结构。课程内容将涵盖二维数组的声明、遍历、以及如何利用它进行基础的图像处理,例如调整图像亮度和大小。


上周,我有一些助教帮忙代课。我离开的原因是妻子出现健康问题,需要住院。她现在情况好转。问题恰好发生在周一、周三和周五。我开玩笑说希望事情能发生在周二或周四。我们目前正在尝试要孩子,经历了一个医疗程序,这引起了一些并发症和疼痛,但她现在好多了。好消息是其他方面进展顺利,但她尚未怀孕。我们正在努力。有学生开玩笑说想让我以他的名字给孩子命名。

有几个简短的公告。本周四有期中考试。学习材料已发布,包含许多练习题和答案。可以通过下载Eclipse项目或访问代码分发网站来练习和测试答案。提高考试成绩的最佳方法是大量练习。考试时会提供语法参考纸。请提前查看。

从管理角度看,请合理安排时间。期中复习会议将于明晚7点到9点在教室举行,由课代表带领,进行练习题讲解和答疑。如果需要特定住宿安排,请今天联系我们。

关于考试范围,期中考试涵盖截至上周五的内容,包括数组。今天讲的多维数组不在期中考试范围内,但属于课程内容,会在期末考试中出现。现在开始今天的讲座。


多维数组简介 📊

上一节我们提到了课程安排,本节中我们来看看什么是多维数组。

多维数组指的是二维或三维数组等。本课程将专注于二维数组。数组是用于存储值的索引集合。二维数组则按行和列组织数据。

为什么需要数组?数组可以避免声明大量单独的变量。例如,无需声明七个变量,只需创建一个包含七个元素的数组。数组还能对数据进行排序和搜索。有时,程序运行前无法确定需要存储多少数据(例如,用户输入游戏场数),数组允许动态处理数据量。

二维数组的声明方式是在类型后使用两对方括号 [][]

int[][] grid = new int[3][5]; // 一个3行5列的整数二维数组

第一个索引代表行数,第二个索引代表列数。访问元素需使用两组方括号,例如 grid[0][0] 访问左上角元素。

关于数组的动态性:程序启动时,可以根据需要(如学生数量)创建特定大小的数组。但数组一旦创建,其大小就固定了。后续课程会学习可调整大小的 ArrayList


遍历二维数组 🔄

上一节我们介绍了二维数组的声明,本节中我们来看看如何遍历它。

遍历一维数组使用从0到 array.length - 1 的循环。遍历二维数组需要在两个维度上循环,通常使用嵌套循环,并以行主序(先遍历行,再遍历每行中的列)进行。

for (int row = 0; row < array2D.length; row++) {
    for (int col = 0; col < array2D[row].length; col++) {
        // 处理 array2D[row][col]
    }
}

array2D.length 获取行数,array2D[row].length 获取指定行的列数。这种写法便于在数组尺寸变化时自动调整。

也可以按列主序遍历,只需交换循环顺序。选择哪种方式取决于具体任务。

打印二维数组内容时,使用 Arrays.deepToString(array2D) 可得到可读的输出。


二维数组的内存表示与锯齿数组 🧩

从技术上讲,二维数组是“数组的数组”。即使我们将其画成矩形,内存中每一行都是一个独立的一维数组。

这引出了“锯齿数组”的概念,即每一行可以有不同长度。

int[][] jagged = new int[3][];
jagged[0] = new int[2];
jagged[1] = new int[4];
jagged[2] = new int[3];

何时使用锯齿数组?例如,用行代表学生,列代表作业,每个学生提交的作业数量可能不同。帕斯卡三角形也是锯齿数组的一个应用实例,可以避免浪费内存。


图像与像素表示 🎨

上一节我们探讨了二维数组的一般概念,本节中我们将其应用于图像处理。

计算机图像由像素点组成。每个像素的颜色由红(R)、绿(G)、蓝(B)三个值混合而成,每个值范围是0到255。

我们可以使用二维数组来表示这些像素。斯坦福 acm.graphics 库中的 GImage 类可以加载和显示图像。

GImage image = new GImage("filename.png");

GImage 对象提供了处理像素的方法:

  • int[][] pixels = image.getPixelArray(); 获取代表像素颜色的二维数组。
  • image.setPixelArray(pixels); 将修改后的像素数组设置回图像。

像素在数组中的位置 pixels[row][col] 对应图像上的行和列。注意,这通常与 (x, y) 坐标相反,x 对应列,y 对应行。在代码中,可以暂时忽略 (x, y) 的叫法,直接用 (row, col) 思考以避免混淆。


图像处理实战:调整亮度 💡

现在,让我们编写代码来修改图像。我们将创建一个方法,使被点击的图像变亮。

思路是:

  1. 获取图像的像素数组。
  2. 遍历每个像素。
  3. 增加该像素的R、G、B值。
  4. 将修改后的像素数组设置回图像。

以下是实现步骤的代码框架:

private void brightenImage(GImage image) {
    int[][] pixels = image.getPixelArray();
    for (int row = 0; row < pixels.length; row++) {
        for (int col = 0; col < pixels[row].length; col++) {
            int pixel = pixels[row][col];
            int red = GImage.getRed(pixel);
            int green = GImage.getGreen(pixel);
            int blue = GImage.getBlue(pixel);
            // 增加亮度,但不超过255
            red = Math.min(255, red + 10);
            green = Math.min(255, green + 10);
            blue = Math.min(255, blue + 10);
            // 将新的RGB值重新打包成一个整数像素值
            pixels[row][col] = GImage.createRGBPixel(red, green, blue);
        }
    }
    image.setPixelArray(pixels);
}

在事件处理中调用:

GObject obj = getElementAt(e.getX(), e.getY());
if (obj instanceof GImage) {
    brightenImage((GImage) obj);
}

注意:单个像素值是一个整数,它封装了透明度(Alpha)和R、G、B四个通道的信息。因此,我们需要使用 GImage.getRedgetGreengetBlue 方法来提取颜色分量,修改后再用 GImage.createRGBPixel 方法打包回去。


更多图像处理:颜色滤镜与缩放 🔍

我们可以修改算法来实现不同的效果。例如,创建一个“橙色”滤镜,可以增加红色,略微减少蓝色:

private void makeOrange(GImage image) {
    int[][] pixels = image.getPixelArray();
    for (int row = 0; row < pixels.length; row++) {
        for (int col = 0; col < pixels[row].length; col++) {
            int pixel = pixels[row][col];
            int red = GImage.getRed(pixel);
            int green = GImage.getGreen(pixel);
            int blue = GImage.getBlue(pixel);
            red = Math.min(255, red + 20);
            blue = Math.max(0, blue - 10); // 确保不低于0
            pixels[row][col] = GImage.createRGBPixel(red, green, blue);
        }
    }
    image.setPixelArray(pixels);
}

如果想放大图像(例如放大两倍),我们不能直接调整原数组大小。需要创建一个新的、更大的二维数组,然后将原图像的像素值映射到新数组中。

基本思路:

  1. 创建一个长宽均为原图两倍的新像素数组 newPixels
  2. 遍历原图 oldPixels 的每个像素 (r, c)
  3. 将该像素的颜色值,填充到新数组 newPixels(2*r, 2*c), (2*r, 2*c+1), (2*r+1, 2*c), (2*r+1, 2*c+1) 这四个位置。
  4. newPixels 设置为图像的像素数组。

实现此算法需要仔细处理索引映射,这是作业五中的一个核心任务。


总结 📝

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

  1. 二维数组的概念、声明和遍历方法。
  2. 二维数组在内存中是“数组的数组”,并由此引出锯齿数组
  3. 如何利用二维数组表示图像的像素
  4. 使用 GImage 类进行基础的图像处理,包括调整亮度和应用颜色滤镜。
  5. 通过创建新数组来实现图像缩放的基本思路。

核心在于掌握嵌套循环遍历二维数组的模式,以及理解像素颜色值的拆分与组合。这些知识是完成图像处理类作业的基础。请结合发布的练习题和作业进行实践,以巩固理解。

课程 17:数组(三)引用语义与计数 📊

在本节课中,我们将深入学习数组的两个重要概念:引用语义使用数组进行数据计数。理解引用语义对于掌握数组和对象在Java中的行为至关重要,而计数技巧则是解决许多实际问题的强大工具。

概述

上一节我们介绍了数组的基础和多维数组。本节中,我们来看看数组作为参数传递时的独特行为——引用语义,并学习如何利用数组来高效地统计数据(例如,统计数字出现的频率)。


数组作为参数与返回值

在编写方法时,可以将数组作为参数传递,也可以让方法返回一个数组。这增加了代码的灵活性。

例如,一个对数组所有元素求和的方法:

public int sumArray(int[] arr) {
    int total = 0;
    for (int i = 0; i < arr.length; i++) {
        total += arr[i];
    }
    return total;
}

注意,方法参数 int[] arr 没有指定长度。这是因为调用者会传入一个具体长度的数组,方法内部使用 arr.length 来获取其大小,这使得代码能处理任意长度的数组。

同样,方法也可以返回一个数组:

public int[] createArrayOfSize(int size) {
    return new int[size];
}

引用语义

这是本节课的核心概念。在Java中,基本数据类型(如 int, double)使用值语义,而数组和对象使用引用语义

值语义示例:

int x = 5;
int y = x; // 将x的值5复制给y
y = 17;    // 修改y
// 此时 x 仍然是5,y是17。两者独立。

引用语义示例(数组):

int[] a1 = {4, 5, 8};
int[] a2 = a1; // a2 现在与 a1 引用同一个数组对象
a2[0] = 7;     // 修改a2的第一个元素
// 此时 a1[0] 也变成了7,因为a1和a2指向同一块内存。

当我们将一个数组变量赋值给另一个时,并没有创建新的数组副本,而是创建了一个新的引用(或别名),指向同一个数组对象。因此,通过任何一个引用修改数组,另一个引用看到的数组也会改变。

为什么Java要这样设计?
主要有两个原因:

  1. 效率:复制大型数组(尤其是包含大量元素或对象时)开销很大。引用赋值非常快速。
  2. 共享:有时我们确实希望方法能修改传入的数组,让调用者看到变化。

引用语义的应用:输出参数

利用引用语义,我们可以将数组参数作为“输出参数”使用。方法的目的是填充或修改这个数组,而不是从中读取信息。

例如,一个用随机数填充数组的方法:

public void fillWithRandomNumbers(int[] arr) {
    Random rand = new Random();
    for (int i = 0; i < arr.length; i++) {
        arr[i] = rand.nextInt(100);
    }
}
// 调用
int[] myArray = new int[10];
fillWithRandomNumbers(myArray);
// myArray 现在充满了随机数

调用者提供一个“空”数组,方法负责填充它。由于引用语义,myArray 在方法调用后被修改。


对象数组与 null

对象数组也遵循引用语义,但有一个关键点:新创建的对象数组,每个元素默认值是 null,表示“没有对象”。

示例:

BankAccount[] accounts = new BankAccount[3]; // 创建可存放3个BankAccount引用的数组
// 此时 accounts[0], accounts[1], accounts[2] 都是 null
accounts[0] = new BankAccount(); // 现在第一个位置有了一个实际的对象

在使用数组元素(如调用 accounts[0].deposit(100))之前,必须确保该位置不是 null,否则程序会抛出 NullPointerException 异常。


实践:编写数组处理方法

让我们编写一个方法,交换数组中相邻元素的位置。

以下是实现 switchPairs 方法的步骤:

  1. 方法接收一个字符串数组。
  2. 遍历数组,每次步进2个索引(i += 2)。
  3. 交换索引 ii+1 位置的元素。
  4. 注意处理数组长度为奇数的情况,最后一个元素保持不变。

public void switchPairs(String[] arr) {
    for (int i = 0; i < arr.length - 1; i += 2) {
        // 交换 arr[i] 和 arr[i+1]
        String temp = arr[i];
        arr[i] = arr[i+1];
        arr[i+1] = temp;
    }
}

这个方法返回类型为 void,因为它直接修改了传入的数组(引用语义)。


使用数组进行计数

一个常见的编程技巧是使用数组作为计数器。例如,统计一个整数中各个数字(0-9)出现的次数。

解决思路如下:

  1. 创建一个长度为10的数组 counts,索引0-9分别对应数字0-9。
  2. 初始时所有计数器为0。
  3. 使用循环和 % 10/ 10 操作逐个提取整数中的数字。
  4. 每提取一个数字 d,就执行 counts[d]++
  5. 最后,遍历 counts 数组,找到值最大的那个索引,即为出现最频繁的数字。

核心代码片段:

public int mostFrequentDigit(int n) {
    int[] counts = new int[10]; // 步骤1&2
    // 步骤3&4:提取并计数数字
    while (n > 0) {
        int digit = n % 10; // 获取最后一位数字
        counts[digit]++;    // 对应计数器加1
        n = n / 10;         // 去掉最后一位
    }
    // 步骤5:寻找最大计数
    int maxIndex = 0;
    for (int i = 1; i < counts.length; i++) {
        if (counts[i] > counts[maxIndex]) {
            maxIndex = i;
        }
    }
    return maxIndex; // 返回出现最频繁的数字
}

总结

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

  1. 引用语义:数组和对象变量存储的是对内存中对象的引用,而非对象本身。赋值操作是复制引用,导致多个变量共享同一对象。
  2. 利用引用语义,可以将数组作为输出参数,让方法填充或修改其内容。
  3. 新创建的对象数组元素初始为 null,使用前需初始化。
  4. 数组的经典应用之一——计数:通过将数据值映射到数组索引,可以高效地统计频率、出现次数等问题。

理解引用语义是掌握Java中数组和对象操作的关键。计数数组的技巧则是一种非常实用的算法思想,在后续编程中会经常用到。

课程18:更多关于类与继承 🧩

在本节课中,我们将深入学习Java中类和对象的更多细节,并引入一个重要的新概念——继承。我们将探讨如何通过类来构建更模块化、更易维护的程序,以及如何通过继承来建立类之间的关系,从而复用和扩展代码。


回顾:类与对象

上一节我们介绍了类和对象的基本概念。本节中,我们将更详细地回顾它们,并探讨一些关键细节。

类就像一个蓝图或模板,它描述了某种类型的事物应该具有的数据(字段)和行为(方法)。例如,一个“银行账户”类定义了账户应有的属性(如户主姓名、余额)和操作(如存款、取款)。

对象则是根据这个蓝图创建的具体实例。每个对象都拥有类中定义的字段的独立副本,并可以执行类中定义的方法。

以下是一个简单的“银行账户”类示例:

public class BankAccount {
    private String name;
    private double balance;

    public BankAccount(String accountName, double initialBalance) {
        name = accountName;
        balance = initialBalance;
    }

    public void deposit(double amount) {
        balance += amount;
    }

    public void withdraw(double amount) {
        balance -= amount;
    }

    public double getBalance() {
        return balance;
    }
}

在这个类中:

  • namebalance私有字段,用于存储对象的状态。
  • BankAccount构造函数,用于在创建新对象时初始化这些字段。
  • depositwithdrawgetBalance方法,定义了对象的行为。

当我们创建对象时,例如 BankAccount ba1 = new BankAccount(“Marty”, 1.0);,就会在内存中生成一个独立的BankAccount实例,拥有自己的namebalance


关键字 this

有时,在类的方法或构造函数中,我们需要明确指代“当前这个对象”。这时就可以使用关键字 this

最常见的用法是区分同名的参数和字段。例如,在构造函数中:

public BankAccount(String name, double balance) {
    this.name = name; // `this.name` 指当前对象的字段
    this.balance = balance; // 参数`balance`赋值给当前对象的字段`balance`
}

这里,this.name 明确表示要设置当前对象的 name 字段,而不是指构造函数参数 name


封装:publicprivate

封装是面向对象编程的核心原则之一,它通过访问修饰符来控制对类内部细节的访问。

  • private(私有):标记为private的字段或方法只能在定义它的类内部被访问。这是默认的、推荐的做法,可以保护对象的数据不被外部代码随意修改。
  • public(公共):标记为public的字段或方法可以被任何其他类访问。

在之前的BankAccount类中,balance字段是private的。这意味着在另一个类(客户端程序)中,你不能直接写 ba1.balance = 1000; 来修改余额。这就像现实世界中,你不能随意走进银行更改自己账户的余额一样。

如果你需要让外部代码读取但不修改某个值,标准的做法是提供一个公共的“getter”方法:

public double getBalance() {
    return balance;
}

这样,客户端代码可以通过 ba1.getBalance() 来查询余额,但无法直接修改它,从而保证了数据的安全性和一致性。


方法 toString

当我们尝试直接使用 println 打印一个对象时,例如 System.out.println(ba1);,Java默认会输出一个看似无意义的字符串(如 BankAccount@3d1cf84),这实际上是对象的内存地址。

为了让对象能以更友好、更有意义的方式打印出来,我们可以在类中定义一个特殊的 toString 方法。当打印对象时,Java会自动调用这个方法。

以下是toString方法的示例:

public String toString() {
    return “Account: name=” + name + “, balance=$” + balance;
}

定义了这个方法后,再次打印 ba1 就会输出:Account: name=Marty, balance=$1.0

toString方法应该返回一个描述对象的字符串,而不是直接在方法内部打印它。这样其他代码可以灵活使用这个字符串(例如打印、显示在图形界面或发送到网络)。


引入继承

随着程序规模扩大,你可能会创建许多相似的类。继承允许我们表达类之间的“是一种(is-a)”关系,并实现代码的复用。

继承意味着一个类(称为子类派生类)可以基于另一个类(称为父类超类基类)来构建。子类会自动获得父类的所有字段和方法,并可以添加自己特有的字段和方法,或修改(覆盖)父类的方法。

你其实已经在使用继承了!例如,我们写的每个程序 public class MyProgram extends ConsoleProgram,其中的 MyProgram 就是 ConsoleProgram 的子类,因此自动获得了显示窗口、处理输入输出等方法。


继承的语法与示例

使用关键字 extends 来声明继承关系。

假设我们有一个律师事务所的员工管理系统。所有员工都有一些共同属性:

public class Employee {
    private int hoursWorked = 40;
    private double salary = 40000.0;
    private int vacationDays = 10;
    private String vacationForm = “Yellow”;

    public int getHours() { return hoursWorked; }
    public double getSalary() { return salary; }
    public int getVacationDays() { return vacationDays; }
    public String getVacationForm() { return vacationForm; }
}

现在,我们需要表示“秘书”这种员工。秘书具有员工的所有共性,但可能还有一个额外技能(比如“打字”)。我们可以通过继承来避免重复编写代码:

public class Secretary extends Employee { // Secretary 继承自 Employee
    public void takeDictation() {
        System.out.println(“I can take dictation.”);
    }
}

Secretary 类通过 extends Employee 自动获得了 Employee 的所有方法(getHours, getSalary等)。我们只需在 Secretary 类中添加其特有的 takeDictation 方法即可。


方法覆盖 (Override)

有时,子类可能想改变从父类继承来的某些行为。例如,“律师”也是员工,但他们可能有更长的假期,或者使用不同的假期申请表。

这时,子类可以覆盖父类的方法。只需在子类中重新定义一个与父类方法名称、返回类型、参数列表完全相同的方法。

public class Lawyer extends Employee {
    // 覆盖:律师有15天假期,而不是10天
    public int getVacationDays() {
        return 15;
    }

    // 覆盖:律师使用粉色假期申请表
    public String getVacationForm() {
        return “Pink”;
    }

    // 律师特有的行为
    public void sue() {
        System.out.println(“I’ll see you in court!”);
    }
}

现在,当我们调用 Lawyer 对象的 getVacationDays() 方法时,将执行子类中覆盖后的版本,返回15。


使用 super 调用父类方法

在覆盖方法时,我们有时希望基于父类的行为进行扩展,而不是完全替换。关键字 super 允许我们调用父类中被覆盖的方法或访问父类的字段。

例如,假设公司政策改变,所有员工的基准假期增加了。我们希望律师的假期总是在员工基准假期上再加5天,而不是写死一个固定数字15。

public class Lawyer extends Employee {
    public int getVacationDays() {
        // 先获取父类(Employee)的假期天数,然后加5
        return super.getVacationDays() + 5;
    }
}

这样,无论 Employee 类的 getVacationDays() 方法如何变化(比如从10天改为20天),Lawyer 的假期天数都会自动调整为(父类假期 + 5)天,代码更易维护。


总结

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

  1. 类与对象的深入理解:类作为蓝图,对象作为实例,每个对象拥有独立的字段副本。
  2. 关键字 this:用于在类内部指代当前对象,常用于区分同名参数和字段。
  3. 封装与访问控制:使用 private 保护数据,通过公共的 getter 方法提供受控的访问。
  4. toString 方法:用于返回对象的字符串表示,便于调试和输出。
  5. 继承的概念与语法:使用 extends 建立类之间的父子关系,实现代码复用。
  6. 方法覆盖:子类可以重新定义父类的方法以改变其行为。
  7. 关键字 super:用于在子类中调用父类的方法或构造函数,以便在扩展功能时复用父类逻辑。

掌握这些概念是构建复杂、模块化Java程序的基础。通过合理地使用类和继承,你可以创建出结构清晰、易于理解和维护的代码。

课程19:继承(续)与多态 🧬

在本节课中,我们将继续深入学习面向对象编程中的核心概念——继承,并引入一个强大的新概念——多态。我们将通过具体的代码示例,探讨如何通过继承减少代码冗余,以及如何利用多态编写更通用、更灵活的代码。


公告与课程信息

期中考试评分已经完成。你的原始分数和调整后的成绩已公布在成绩网站上。本次考试平均分约为49分(满分66分),中位数目标设定在80%左右。我们已对分数进行了调整。

如果你认为评分有误,可以填写表格申请重新评分。我们会重新评估整个试卷,并修正任何错误。请注意,重新评分也可能导致分数降低。申请截止日期为下周一。

关于课程评分,在学期末我们会进行调整,使大约一半的班级获得A或更好的成绩,约30%获得B,其余大部分为C。本周内,我将发布更多关于作业和期中考试的统计数据,帮助你了解自己的相对表现。

周五是退课的最后期限。在做出决定前,请确保你已充分了解自己的学习状况。如有任何匿名反馈,可通过课程员工页面上的链接提交。


回顾继承

上一节我们介绍了继承的基本概念。继承允许我们创建一个通用(父类或超类),然后创建更具体的(子类),子类会自动拥有父类的属性和方法。

例如,我们可以有一个通用的 Employee(雇员)类,然后创建 Lawyer(律师)、Secretary(秘书)等子类。这样,所有雇员共有的代码(如工作小时数、基本工资)只需在 Employee 类中编写一次。

// 父类:Employee
public class Employee {
    public double getSalary() {
        return 40000.0; // 基本工资
    }
}

// 子类:Lawyer
public class Lawyer extends Employee {
    // 继承自Employee的方法
}


使用 super 关键字优化设计

如果我们想给所有雇员统一加薪,直接在父类 Employee 中修改 getSalary 方法似乎可行。但如果某些子类(如 Marketer)已经重写了 getSalary 方法以提供不同的薪资计算方式,那么直接修改父类会导致这些子类的特殊逻辑被覆盖。

不理想的设计:需要手动修改每一个子类的 getSalary 方法。

更好的设计:在子类重写的方法中,使用 super 关键字调用父类的版本,然后在此基础上添加子类特有的逻辑。这样,当父类的通用逻辑改变时,所有子类会自动受益。

// 子类:Marketer
public class Marketer extends Employee {
    @Override
    public double getSalary() {
        // 调用父类的getSalary方法,然后加上营销人员特有的津贴
        return super.getSalary() + 3000.0;
    }
}

现在,如果我们将 Employee 的基本工资从 40000 改为 50000,Marketer 的工资会自动变为 53000,而无需修改 Marketer 类的代码。

super 关键字指代父类,常用于在子类中调用被重写的父类方法。


字段、构造函数与继承

当我们为类添加字段(用于存储对象数据)和构造函数时,继承会变得稍微复杂一些。

假设我们想在 Employee 类中添加 name(姓名)和 years(工作年限)字段。

public class Employee {
    private String name;
    private int years;

    // 构造函数
    public Employee(String name, int years) {
        this.name = name;
        this.years = years;
    }
}

一旦父类定义了构造函数,其子类(如 Lawyer)就必须显式地调用父类的构造函数,否则会编译错误。这是因为子类在构建自身时,需要先构建其父类部分。

public class Lawyer extends Employee {
    private String lawSchool;

    // 子类构造函数
    public Lawyer(String name, int years, String lawSchool) {
        // 必须首先调用父类构造函数
        super(name, years);
        // 然后初始化子类特有的字段
        this.lawSchool = lawSchool;
    }
}

关键点

  • 子类不继承父类的构造函数。
  • 子类构造函数必须通过 super(...) 调用父类构造函数,且必须是第一条语句。
  • 子类可以拥有比父类更多的参数和字段。

私有字段的访问与 Getter 方法

子类虽然继承了父类的所有私有字段,但不能直接访问它们。这是为了保持封装性,防止子类随意修改父类的内部状态。

例如,在 Lawyer 类中,不能直接使用 years 字段来计算基于工作年限的奖金。

public class Lawyer extends Employee {
    @Override
    public double getSalary() {
        // 错误!无法直接访问父类的私有字段 years
        // return super.getSalary() + 5000 * years;
    }
}

正确的做法是在父类中提供公共的 Getter 方法(“读取器”),让子类通过该方法来获取字段的值。

public class Employee {
    private int years;
    // ...
    public int getYears() {
        return years;
    }
}

public class Lawyer extends Employee {
    @Override
    public double getSalary() {
        // 正确!通过公共的Getter方法访问
        return super.getSalary() + 5000 * getYears();
    }
}

多态性

多态(Polymorphism)意为“多种形态”。在编程中,它指的是同一段代码可以根据所操作对象的具体类型,执行不同的行为。

一个常见的多态例子是 println 方法,它可以接受任何类型的参数(数字、字符串、对象),并都能以合适的方式打印出来。

多态的核心:父类引用指向子类对象

在Java中,你可以声明一个父类类型的变量,却让它引用一个子类类型的对象。

Employee emp = new Lawyer("Lisa", 7, "Stanford");

这为什么有用?它允许我们编写通用的代码来处理多种不同的具体类型。

例如,与其为每种雇员类型都写一个打印信息的方法:

printInfo(Lawyer l) { ... }
printInfo(Secretary s) { ... }

不如只写一个接受通用 Employee 参数的方法:

public void printInfo(Employee emp) {
    System.out.println("Salary: " + emp.getSalary());
    System.out.println("Vacation: " + emp.getVacationDays());
    // 只能调用Employee类中定义的方法
}

现在,我们可以将 LawyerSecretary 或任何 Employee 子类的对象传递给 printInfo 方法。

动态方法绑定

多态最强大的地方在于:当通过父类引用调用一个方法时,Java 会自动执行该对象实际类型(子类)所重写的方法版本

Employee emp1 = new Lawyer(...);
Employee emp2 = new Secretary(...);

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/b497486b81898eefe9f4cdad39847416_27.png)

printInfo(emp1); // 调用的是Lawyer的getSalary和getVacationDays
printInfo(emp2); // 调用的是Secretary的getSalary和getVacationDays

尽管 printInfo 方法中的代码看起来一样,但 emp.getSalary() 这行代码会根据 emp 具体是律师、秘书还是其他雇员,产生不同的结果。这就是“多种形态”的体现。

限制:通过父类引用,你只能调用在父类中声明过的方法。子类特有的方法(如 SecretarytakeDictation)无法通过父类引用调用。


总结

本节课我们一起深入探讨了继承和多态。

  • 我们学习了如何使用 super 关键字来调用父类的方法和构造函数,从而构建出更易于维护的类层次结构。
  • 我们理解了子类如何通过构造函数初始化父类部分,以及如何通过公共的 Getter 方法访问继承来的私有字段。
  • 最重要的是,我们引入了多态的概念。多态允许我们使用父类类型编写通用的代码,而实际运行时,程序会根据对象的真实类型执行相应的行为。这是实现代码复用和灵活设计的关键工具。

掌握继承和多态,是理解面向对象编程强大之处的核心。在接下来的作业和项目中,你将有机会实践这些概念。

课程 02:编程 Karel 🧑‍💻

在本节课中,我们将学习如何为 Karel 机器人编写更复杂的程序。我们将深入探讨 Java 编程的核心概念,包括方法、循环和条件语句,并学习如何使用 Eclipse 集成开发环境。

概述

我们将从回顾上一讲的内容开始,然后介绍如何组织代码、消除冗余以及让程序根据环境做出决策。这些技能是构建更复杂、更智能程序的基础。


公告与课程设置

上一节我们介绍了 Karel 的基本命令。在深入新内容之前,有几个重要的课程公告。

以下是关于课程设置和讨论部分的说明:

  • 助教尼克将于明天下午 3 点到 5 点在盖茨大厦地下室的 VO 房间提供帮助,协助你在计算机上设置 Java 和 Eclipse 软件。
  • 本课程将有由小组长带领的每周讨论部分。你需要在班级网页上的“注册表单”链接中报名,选择你有空的时间段。该表格将于明天(周四)上线,开放至周日。请务必在本周末前填写。

关于课程材料,每次讲座的内容都会发布在网站上。这包括讲座视频、我编写的程序代码以及课程中使用的项目文件。通常,我会在课前发布“启动项目”,这是一个空项目,方便你跟着课程一起编写代码。


在 Eclipse 中开始项目

为了编写和运行 Java 程序,我们需要使用 Eclipse。本节中我们来看看如何开始一个 Eclipse 项目。

首先,你需要从课程网站下载项目压缩文件(zip 文件),并将其保存到电脑上的某个文件夹中。然后,你需要解压这个 zip 文件。

在 Eclipse 中,点击“导入项目”按钮(或通过“文件”菜单选择“导入”)。浏览到你解压项目文件的目录,选择名为 programming-karel 的文件夹并导入。导入成功后,该项目就会出现在你的 Eclipse 工作区中。项目的源代码位于 src 文件夹内,你可以在这里打开并编辑 Java 文件。

这就是开始处理 Eclipse 项目的基本流程,也是你开始做作业的方式。


剖析 Java 程序

现在,让我们回顾并深入分析上一讲我们编写的第一个程序。

我们的程序名为 MeetKarel。一个 Java 程序有几个关键元素:

  • public class MeetKarel extends Karel:这定义了我们的程序。class 在 Java 中类似于“程序”这个词。extends Karel 告诉 Java,我们的程序基于现有的 Karel 库的功能。
  • 花括号 {}:Java 使用花括号作为分组机制,表示一段代码的开始和结束。我们程序的所有命令都放在 public void run() 方法的花括号之间。
  • 命令:如 move();pickBeeper();turnLeft();。每个命令以分号结束。
  • 空格和缩进:Java 不关心代码中的空格(只要不在单词中间添加),但良好的缩进能让代码更易读。例如,以下两段代码的行为完全相同:
    // 版本一
    public void run() {
        move();
        pickBeeper();
    }
    // 版本二
    public void run() { move(); pickBeeper(); }
    

然而,Java 在某些方面非常严格。例如,如果你将 move() 错误地拼写为 Move()(大写 M),程序将无法运行,因为 Java 会报告“未定义”的错误。这类错误被称为语法错误,即代码不符合编程语言的规则。


编译与运行

计算机执行的是由 0 和 1 组成的二进制指令。我们编写的 Java 代码(人类可读)需要被翻译成二进制,这个过程称为编译

在 Eclipse 中,每当你保存代码时,它会自动编译你的程序。点击“运行”按钮(一个绿色小人的图标)时,Eclipse 会先编译程序(如果必要),然后执行它。编译成功后,会生成一个 .class 文件,其中包含了计算机可以执行的二进制代码。

语法错误会阻止程序成功编译,因此它们也被称为编译错误


标识符与关键字

程序、方法等的名称被称为标识符。Java 标识符的规则是:

  • 可以包含字母、数字、下划线 _ 或美元符号 $
  • 必须以字母、_$ 开头(通常以字母开头)。
  • 例如,go49ers 是合法的,但 49ers 不合法,因为它以数字开头。

此外,有些单词是 Java 语言的关键字,具有特殊含义,不能用作标识符。例如,你不能将你的程序命名为 public。我们本课程不会用到所有的关键字。


代码注释

注释是写在代码中,用于给程序员自己或他人做笔记的文本。注释不会影响程序的运行行为。

编写注释有两种方式:

  1. 单行注释:使用两个斜杠 //
    // CS106A, Spring 2017
    // Author: Marty
    // This program makes Karel move and pick up a beeper.
    
  2. 多行注释:使用 /* 开始,*/ 结束。
    /*
     * CS106A, Spring 2017
     * Author: Marty
     * This program makes Karel move and pick up a beeper.
     */
    

注释的一个重要用途是临时“禁用”一部分代码。如果你在某行代码前加上 //,这行代码就会变成注释,运行时将被跳过。

move();
// pickBeeper(); // 这行代码现在不会执行
turnLeft();

定义与使用方法

我们已经学会了基本的命令,但如何让程序执行更复杂的操作呢?本节中我们来看看方法

方法是一组被命名并组合在一起的命令。你可以将方法理解为给 Karel 添加新的、自定义的命令。

定义方法有两个步骤:

  1. 声明方法:编写方法的名称和它包含的命令。
  2. 调用方法:在程序中使用该方法的名称来执行它。

例如,Karel 没有 turnRight() 命令。我们可以自己创建一个:

// 1. 声明方法:定义 turnRight 做什么
public void turnRight() {
    turnLeft();
    turnLeft();
    turnLeft(); // 向左转三次等于向右转
}

// 2. 在 run 方法中调用它
public void run() {
    move();
    turnRight(); // 使用我们自定义的命令
    move();
}

当程序运行到 turnRight(); 这一行时,Java 会跳转到 turnRight 方法的定义处,执行其中的三条 turnLeft() 命令,然后返回。

使用方法的好处是:

  • 消除冗余:如果需要在多个地方右转,只需调用 turnRight(),而不用重复写三次 turnLeft()
  • 组织代码:将程序分解成逻辑块,使结构更清晰。

方法可以调用其他方法。例如:

public void shake() { // 摇晃:左转然后右转
    turnLeft();
    turnRight();
}

public void tooMuchCoffee() { // 喝太多咖啡:摇晃三次
    shake();
    shake();
    shake();
}

public void run() 是一个特殊的方法,它是程序开始执行时 Java 自动寻找并运行的入口。


使用循环进行重复

如果你需要让 Karel 重复执行某个动作很多次,一遍遍写同样的命令非常冗余。这时,我们可以使用循环

for 循环用于重复执行一系列命令特定的次数。其语法如下:

for (int i = 0; i < 5; i++) {
    // 要重复执行的命令放在这里
    move();
}

这个循环的意思是:“对于 i 从 0 开始,只要 i 小于 5,就执行一次花括号里的命令,然后将 i 增加 1。” 最终,花括号内的命令会被执行 5 次。

你可以把多条命令放在循环的花括号里:

for (int i = 0; i < 3; i++) {
    move();
    turnLeft(); // 这将执行:移动,左转,移动,左转,移动,左转。
}

循环也可以嵌套(一个循环 inside 另一个循环),以实现更复杂的重复模式。

方法与循环的选择

  • 方法适用于在程序不同位置重复一个逻辑单元(如 turnRight)。
  • 循环适用于在同一个位置连续重复一系列命令。

使用条件语句做出决策

为了让 Karel 更智能,能根据周围环境做出不同反应,我们需要条件语句

if 语句允许程序进行条件判断:只有当一个条件为“真”时,才执行特定的命令。其语法是:

if (condition) {
    // 如果条件为真,则执行这里的命令
}

Karel 提供了一些测试条件的方法,例如:

  • frontIsClear():前面是否畅通?
  • beepersPresent():当前位置是否有蜂鸣器?
  • facingNorth():是否面朝北方?

一个常见的例子是:让 Karel 移动并只在有蜂鸣器时才捡起它,避免在空地上执行 pickBeeper() 导致程序崩溃。

public void run() {
    move();
    if (beepersPresent()) { // 检查脚下是否有蜂鸣器
        pickBeeper(); // 只有条件为真时才执行
    }
    move();
}

总结

本节课中我们一起学习了 Java 和 Karel 编程的几个核心概念:

  1. 项目设置:如何在 Eclipse 中导入和开始一个项目。
  2. 程序结构:理解了类、方法、注释和语法错误。
  3. 方法:通过将命令分组并命名来创建自定义指令,从而消除冗余和组织代码。
  4. 循环:使用 for 循环来重复执行代码块,提高效率。
  5. 条件语句:使用 if 语句让程序能够根据环境(如是否有蜂鸣器)做出决策,编写更灵活、健壮的程序。

这些是构建所有程序的基础模块。通过组合使用这些技术,你将能够指挥 Karel 完成越来越复杂的任务。

课程20:小动物模拟器 🐾

在本节课中,我们将学习如何完成作业六——“小动物”模拟器。这是一个关于类和对象、继承以及状态管理的综合练习。我们将创建一个二维世界,其中不同种类的“小动物”会移动、战斗、进食和繁殖。你需要通过编写Java类来定义这些动物的行为。


概述

作业六的核心是理解并实现一个名为“小动物”的模拟系统。你将编写多个扩展自基础Critter类的子类,每个子类代表一种具有特定行为的动物。模拟器会管理一个世界,让这些动物实例在其中互动。你的任务是重写父类中的方法,以定义每种动物如何移动、战斗、进食、显示颜色和外观。


小动物世界的基本概念

上一节我们介绍了本作业的目标。本节中,我们来看看小动物世界的基本构成和运行机制。

模拟器是一个二维网格世界。动物们在这个世界中以文本字符的形式显示和移动。每种动物都由一个Java类表示,例如Bird类或Wolf类。你编写的这些类将扩展我们提供的Critter类。

所有小动物都能执行五种主要行为:

  • 进食:决定是否吃掉当前格子上的食物。
  • 战斗:当与另一只动物相遇时,决定如何攻击。
  • 颜色:决定自己在屏幕上显示的颜色。
  • 外观:决定代表自己的文本字符(如“A”或“S”)。
  • 移动:决定每一步移动的方向。

当你编写一个小动物类时,你需要重写这些方法来定义其独特行为。例如,我们提供的Stone类就非常简单:它总是返回Attack.ROAR(咆哮),颜色是灰色,外观是“S”,并且从不移动。


实现第一个小动物:法国斗牛犬 🐕

上一节我们了解了小动物的基本行为。本节中,我们通过创建一个FrenchBulldog类来实践如何实现它们。

首先,创建一个扩展Critter的新类。即使你不重写任何方法,程序也能运行,但动物会使用默认行为(黑色、问号“?”、不移动、战斗失败)。

以下是逐步添加行为的方法:

1. 定义外观
要改变动物显示的文字,你需要重写toString方法。

public String toString() {
    return "A";
}

现在,FrenchBulldog在屏幕上会显示为字母“A”。

2. 实现移动
动物通过重写getMove方法来移动。该方法需要返回一个Direction枚举值(如Direction.WEST)。

public Direction getMove() {
    return Direction.WEST; // 始终向左(西)移动
}

3. 处理战斗
当两只动物相遇,模拟器会调用它们的fight方法。攻击方式有三种:Attack.ROAR(咆哮)、Attack.POUNCE(猛扑)、Attack.SCRATCH(抓挠)。它们遵循“石头剪刀布”的规则:咆哮击败抓挠,抓挠击败猛扑,猛扑击败咆哮。如果攻击方式相同,则随机决定胜负。
已知Stone总是咆哮。为了让FrenchBulldog击败Stone,它应该总是猛扑。

public Attack fight(String opponent) {
    return Attack.POUNCE; // 总是使用猛扑
}

4. 决定是否进食
当动物移动到有食物的格子上,模拟器会调用eat方法。返回true表示吃掉食物,false表示不吃。吃食物可以得分,但吃太多会导致动物“睡觉”,在睡觉时被其他动物碰到则会死亡。

public boolean eat() {
    return true; // 总是吃掉食物
}

管理状态:实现复杂行为 🔄

上一节我们实现了简单、固定的行为。本节中我们来看看如何实现更复杂的、依赖于历史状态的行为。这是本作业的关键和难点。

模拟器通过循环调用你的方法(如getMove)来驱动世界。你不能在方法内部使用循环来控制多步行动。你一次只能决定下一步做什么。

核心思想:你需要使用私有字段(实例变量) 来记录动物的状态,以便在下次方法被调用时“记住”接下来该做什么。

示例1:实现“移动三步向西,再移动三步向东”的模式
你不能说“我要向西走三步”。你只能说“我下一步向西走”。为了记住整个模式,你需要一个计数器。

public class FrenchBulldog extends Critter {
    private int moveCount; // 记录已移动的次数

    public FrenchBulldog() {
        moveCount = 0; // 初始化
    }

    public Direction getMove() {
        moveCount++; // 移动次数加1
        // 如果 moveCount 对 6 取模的结果小于3,则向西走,否则向东走
        if (moveCount % 6 < 3) {
            return Direction.WEST;
        } else {
            return Direction.EAST;
        }
    }
    // ... 其他方法
}

通过调整moveCount的初始值和判断逻辑,你可以精确控制移动模式。

示例2:根据是否战斗过来改变颜色
规范要求:FrenchBulldog初始为白色,一旦参与过战斗,就永久变为红色。

public class FrenchBulldog extends Critter {
    private boolean hasFought; // 记录是否战斗过

    public FrenchBulldog() {
        hasFought = false; // 初始未战斗
    }

    public Color getColor() {
        if (!hasFought) {
            return Color.WHITE;
        } else {
            return Color.RED;
        }
    }

    public Attack fight(String opponent) {
        hasFought = true; // 一旦战斗,标记为true
        return Attack.POUNCE;
    }
    // ... 其他方法
}

每个FrenchBulldog对象都有自己的hasFought变量副本,因此它们可以有不同的状态和行为。


挑战案例:实现蛇的移动模式 🐍

上一节我们学习了用状态字段实现模式。本节我们分析一个更复杂的案例——Snake的移动模式。

Snake的移动模式是:水平移动一段长度,然后向南一步,再反方向水平移动一段增加的长度,再向南一步,如此反复。
例如:东1,南1,西2,南1,东3,南1,西4...

实现思路
你需要跟踪多个状态:

  1. runLength:当前水平移动需要走的步数。
  2. stepsInCurrentRun:在当前水平移动中已经走了多少步。
  3. currentDirection:当前水平移动的方向(东或西)。
  4. 或者,可以用一个总移动计数器moveCount结合逻辑推导出上述状态。

以下是概念性代码框架:

public class Snake extends Critter {
    private int runLength;
    private int stepsTaken;
    private boolean movingEast;

    public Snake() {
        runLength = 1; // 初始水平移动长度为1
        stepsTaken = 0;
        movingEast = true; // 初始向东
    }

    public Direction getMove() {
        // 如果还在当前水平移动中
        if (stepsTaken < runLength) {
            stepsTaken++;
            return movingEast ? Direction.EAST : Direction.WEST;
        } else {
            // 水平移动结束,先向南走一步
            stepsTaken = 0;
            runLength++; // 下次水平移动长度增加
            movingEast = !movingEast; // 调转方向
            return Direction.SOUTH;
        }
    }
    // ... toString 等方法
}

这是一个简化的逻辑,实际实现可能需要调整初始值和边界条件。关键在于用私有字段记住“进行到哪一步了”。


调试与测试技巧 🐛

编写小动物类是一个迭代过程。以下是一些有用的调试技巧:

  • 使用“单步”按钮:不要总是点击“开始”。使用“前进一次”按钮,观察你的动物每一步的行为是否符合预期。
  • 启用调试模式:模拟器有“调试”选项,开启后会在控制台输出详细信息,帮助你跟踪动物的决策过程。
  • 使用 System.out.println:在你的方法中插入打印语句,输出私有字段的值,这是理解程序状态的经典方法。
public Direction getMove() {
    System.out.println("moveCount: " + moveCount);
    // ... 你的逻辑
}
  • 专注于一个动物:在测试时,只添加你正在编写的动物类型,并观察其行为。

总结

本节课中我们一起学习了作业六“小动物模拟器”的核心内容。我们回顾了如何通过继承Critter类并重写方法来定义动物的行为(eat, fight, getColor, toString, getMove)。我们重点探讨了如何使用私有实例变量来管理对象的状态,从而实现依赖于历史的复杂行为模式,这是面向对象编程中“对象拥有独立状态”这一概念的绝佳实践。记住,模拟器控制主循环,你的代码只需决定“下一步”做什么,并通过内部状态来“记忆”未来的计划。

课程21:ArrayLists 📚

在本节课中,我们将要学习一个非常强大的数据结构——ArrayList。我们将了解它是什么,它与我们之前学过的数组有何不同,以及如何使用它来编写更灵活、功能更强大的程序。


概述 📖

ArrayList 是 Java 中一个可变大小的列表。与固定大小的数组不同,ArrayList 可以根据需要动态地增长和缩小。这使得它在处理用户输入或未知数量的数据时特别有用。本节课我们将学习如何创建 ArrayList,如何向其中添加、获取和删除元素,并通过两个实际例子来巩固理解。


回顾数组的局限性 🔍

上一节我们介绍了数组,本节我们来看看它的局限性。数组是一个固定大小的容器,用于存储单一类型的元素。

创建数组的公式:

int[] scores = new int[5]; // 创建一个大小为5的整数数组

数组的主要限制在于其静态尺寸。你必须在创建时就指定其大小,之后无法更改。例如,在“大型游戏”程序中,我们必须先询问用户要输入多少年的数据,然后才能创建相应大小的数组来存储分数。如果用户后来想输入更多数据,程序就无法轻松处理。

另一个限制是,数组本身没有内置的方法来搜索元素或方便地打印所有内容,这些都需要我们手动编写循环代码来实现。


引入 ArrayList 🆕

正是为了克服数组的这些限制,ArrayList 应运而生。ArrayList 也是一个列表,你可以通过索引访问其中的单个项目。

创建 ArrayList 的公式:

import java.util.*; // 必须导入这个包
ArrayList<String> myList = new ArrayList<String>();

与数组类似,ArrayList 也存储单一类型的对象(注意是对象,我们稍后会详细说明)。它的酷炫之处在于:

  • 可以调整大小:你可以随时添加或删除元素。
  • 内置实用方法:例如,可以搜索列表是否包含某个元素。

以下是 ArrayList 的一些基本操作:

添加元素:

myList.add("Hello"); // 在列表末尾添加 "Hello"

获取元素:

String firstItem = myList.get(0); // 获取索引为0的元素

获取大小:

int size = myList.size(); // 获取列表当前包含的元素数量

遍历 ArrayList 🔄

与数组一样,我们可以使用循环来遍历 ArrayList

使用 for 循环:

for (int i = 0; i < myList.size(); i++) {
    String item = myList.get(i);
    System.out.println(item);
}

使用 for-each 循环(更简洁):

for (String item : myList) {
    System.out.println(item);
}

for-each 循环是遍历集合中每个元素的简写语法,当你不需要使用索引时非常方便。


实践示例一:反转故事文件 📄

现在让我们通过一个实际例子来运用 ArrayList。我们将编写一个程序来反转一个文本文件中的行序。这样,一个故事从前往后读和从后往前读会呈现出不同的情节。

思路分析:

  1. 读取文件中的每一行。
  2. 将每一行按顺序存储到一个 ArrayList 中。
  3. 从后向前遍历这个列表,并打印每一行。

核心代码片段:

ArrayList<String> lines = new ArrayList<String>();
while (scanner.hasNextLine()) {
    String line = scanner.nextLine();
    lines.add(line); // 将每一行添加到列表
}
// 反向打印
for (int i = lines.size() - 1; i >= 0; i--) {
    System.out.println(lines.get(i));
}

这个程序的关键在于 ArrayList 帮助我们“记住”了所有行的顺序,之后我们可以按任何顺序(包括反向)来处理它们。


ArrayList 的更多方法 🛠️

除了 addgetArrayList 还提供了许多其他有用的方法。

以下是几个常用的方法:

  • 在指定索引处添加myList.add(2, "插入的元素");
  • 查找元素索引int index = myList.indexOf("某值");
  • 移除指定索引处的元素myList.remove(5);
  • 移除特定值的元素myList.remove("某人");
  • 替换元素myList.set(1, "新值");

重要提示: 当你在列表中间添加或删除元素时,其后的所有元素索引都会发生移动。你需要小心处理正在跟踪的索引。


实践示例二:任务规划程序 📝

我们将构建一个更复杂的程序——“任务规划器”。这个程序允许用户输入一系列当天想完成的任务,然后按照用户希望的顺序重新排列这些任务。

程序步骤:

  1. 提示用户输入任务列表,直到输入空行为止。将这些任务存储在一个 ArrayList 中。
  2. 显示剩余任务,反复提示用户选择下一个要完成的任务。
  3. 将用户选择的任务从原始列表移到另一个“已排序”的 ArrayList 中。
  4. 当所有任务都被选择后,按用户指定的顺序打印最终任务列表。

核心逻辑片段:

// 步骤1:读取任务列表
ArrayList<String> tasks = new ArrayList<String>();
String task = readTask();
while (task.length() > 0) {
    tasks.add(task);
    task = readTask();
}

// 步骤2 & 3:让用户排序任务
ArrayList<String> orderedTasks = new ArrayList<String>();
while (tasks.size() > 0) {
    System.out.println("剩余任务: " + tasks);
    String next = readTask();
    if (tasks.contains(next)) {
        orderedTasks.add(next); // 加入有序列表
        tasks.remove(next);    // 从剩余列表中移除
    } else {
        System.out.println("该任务不在你的列表中。");
    }
}

// 步骤4:打印最终顺序
System.out.println("今日计划: " + orderedTasks);

在这个例子中,ArrayListcontainsremove 方法极大地简化了逻辑,让我们能轻松检查输入是否有效并更新列表。


对比:Array 与 ArrayList ⚖️

既然 ArrayList 如此强大,为什么我们还需要数组呢?让我们来比较一下。

特性 数组 (Array) 列表 (ArrayList)
创建 int[] arr = new int[10]; ArrayList<Integer> list = new ArrayList<>();
大小 固定,创建时指定 动态,可增长和缩小
访问元素 arr[0] (简洁) list.get(0) (稍显冗长)
添加元素 无法直接添加(需手动管理) list.add(item) (自动扩容)
内置方法 很少 丰富 (contains, remove, indexOf等)
存储类型 任何类型(包括基本类型) 主要存储对象

关键区别与选择:

  • 数组 语法更简洁,性能在极少数场景下可能略优,并且可以直接存储 int, double 等基本类型。
  • ArrayList 功能更强大,特别适合数据量未知或需要频繁增删元素的场景。对于存储基本类型,Java 使用了“包装类”(如 Integer, Double)来自动处理,通常无需我们操心。

简单来说,如果你提前确切知道元素数量且不需要改变大小,数组可能更合适。否则,ArrayList 通常是更灵活、更省力的选择。


总结 🎯

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

  • 我们了解了 ArrayList 是一个可变大小的列表,解决了数组固定大小的主要限制。
  • 我们学会了如何创建 ArrayList,以及使用 add, get, size, remove 等核心方法来操作它。
  • 我们通过反转文本文件任务规划器两个程序,实践了 ArrayList 在存储动态数据和重新排序方面的强大能力。
  • 最后,我们对比了 ArrayArrayList,理解了它们各自的优缺点和适用场景。

现在,你已经掌握了另一个重要的数据结构工具,可以让你在编程时更加得心应手!

课程22:小动物模拟进阶与静态数据 🐾

在本节课中,我们将深入学习“小动物世界”模拟作业的更多细节,并探讨一个重要的Java概念——静态数据。我们将通过具体的代码示例,理解如何为不同的小动物编写复杂的行为逻辑,以及如何使用静态变量让多个对象共享信息。


作业安排与课程概述

这是课程的第八周。本节课将继续围绕继承、对象和类等概念展开,并重点讲解作业六的基础——“小动物世界”模拟。

关于作业安排,请注意:作业六的完成时间为一周,随后作业七将发布。春季学期时间较短,因此课程安排较为紧凑。作业七是一个更大的项目,包含更具挑战性的算法内容。作业六的截止日期已确定,作业七将提供较晚的提交时间选项,以方便大家规划期末周的复习。


回顾小动物世界模拟

上一节我们介绍了小动物模拟的基本框架。本节中,我们来看看这个模拟的具体目标和你的任务。

在这个模拟世界中,有多种小动物(如鸟、蟹、火蚁、河马、秃鹫),它们的目标是生存:寻找食物、繁殖后代并避免被其他动物杀死。你的任务是编写七种动物的行为,其中前六种已有指定行为,而“狼”的行为完全由你设计。

模拟器运行时,动物们会根据你编写的逻辑行动、竞争。最终,一些动物种群会增长,另一些则会减少。在课程最后一课,我们将举办一场“小动物锦标赛”,让所有同学提交的“狼”一决高下。


小动物类的核心方法

要为一个生物编程,你需要通过继承 Critter 类并重写(覆盖)其五个关键方法,来定义它的行为。以下是这五个方法及其默认行为:

  • public boolean eat():当动物移动到食物上时被调用。返回 true 表示吃掉食物,返回 false 表示忽略。默认返回 false
  • public Attack fight(String opponent):当与另一个动物相邻时被调用,决定攻击方式。默认返回 Attack.FORFEIT(放弃战斗)。
  • public Color getColor():决定动物在图形界面中显示的颜色。默认返回 Color.BLACK
  • public Direction getMove():决定动物每一步移动的方向。默认返回 Direction.CENTER(不动)。
  • public String toString():决定动物在图形界面中显示的文本符号。默认返回 "?"

重要提示:模拟器控制着整个流程。你的代码不是主动运行的“主程序”,而是通过重写上述方法,来“响应”模拟器的询问。你不能在方法内部使用循环来控制多步移动,而需要通过私有字段(实例变量)来记录状态,从而决定每一步的行为。


实现复杂行为:状态记录与私有字段

为了演示如何实现依赖历史状态的行为,我们以两种自定义动物为例。

示例一:伯克利学生 (BerkeleyStudent)

假设我们希望 BerkeleyStudent 先向左移动五步,然后向右移动五步,如此循环。

核心思路:我们需要一个私有字段来记录已经移动了多少步。

import java.awt.*;
public class BerkeleyStudent extends Critter {
    // 私有字段,记录移动次数
    private int moves;

    public BerkeleyStudent() {
        moves = 0; // 初始化
    }

    public Direction getMove() {
        moves++; // 移动次数加1
        if (moves % 10 < 5) {
            return Direction.WEST; // 前5次(0-4)向左
        } else {
            return Direction.EAST; // 后5次(5-9)向右
        }
    }
}

代码说明

  1. 定义私有整型字段 moves
  2. 在构造函数中将其初始化为 0
  3. getMove() 方法中,每次调用先将 moves 加1。
  4. 利用取模运算 % 实现每10步一个循环,并决定前5步向左,后5步向右。

行为升级:寻找食物并转向

现在,我们希望修改行为:动物一直向左移动,直到找到并吃掉一块食物,然后改为向右移动;找到下一块食物后,再改为向左移动,如此交替。

核心思路:我们需要一个布尔类型的私有字段来记录“最近一次是否吃过食物”,从而决定移动方向。

public class BerkeleyStudent extends Critter {
    private boolean hasEaten; // 记录是否刚吃过食物

    public BerkeleyStudent() {
        hasEaten = false; // 初始状态未吃过
    }

    public boolean eat() {
        // 无论遇到什么食物都吃
        // 吃完后,翻转 hasEaten 的状态
        hasEaten = !hasEaten;
        return true;
    }

    public Direction getMove() {
        if (!hasEaten) {
            return Direction.WEST; // 没吃过,向左找
        } else {
            return Direction.EAST; // 刚吃过,向右找
        }
    }
}

关键点

  • eat() 方法在动物落到食物上时被模拟器调用。我们在这里改变内部状态 hasEaten
  • getMove() 方法根据 hasEaten 的状态决定移动方向。
  • 切勿在 getMove() 方法内部调用 eat()。行为的改变应通过修改私有字段的状态来实现,由模拟器在适当时机调用相应方法。


实现更复杂的模式:蛇 (Snake)

假设我们希望 Snake 按以下模式移动:东1,南1;西2,南2;东3,南3;西4,南4…… 即水平移动步数递增的“蛇形”路径。

核心思路:我们需要多个私有字段来跟踪状态:当前水平移动方向、在当前方向上已移动的步数、当前水平移动的总长度。

public class Snake extends Critter {
    private int moves;      // 在当前水平行内已移动的步数
    private int length;     // 当前水平行需要移动的总长度
    private boolean goEast; // 当前水平移动方向是否为东

    public Snake() {
        moves = 0;
        length = 1; // 从长度为1开始
        goEast = true; // 第一步向东
    }

    public String toString() {
        return "S"; // 显示为 S
    }

    public Direction getMove() {
        moves++;
        if (moves <= length) {
            // 还在水平移动中
            return goEast ? Direction.EAST : Direction.WEST;
        } else {
            // 水平移动结束,先向下走一步
            moves = 0; // 重置步数计数器
            length++;  // 下一行的长度增加
            goEast = !goEast; // 切换水平方向
            return Direction.SOUTH;
        }
    }
}

开发技巧:这是一个迭代开发过程。可以先实现基础循环,再逐步添加向南移动和长度递增的逻辑,并通过模拟器的“单步调试”功能反复测试和调整条件判断(如 <=<),直到行为符合预期。


静态数据:让对象共享信息

上一节我们实现了单个对象的复杂行为。本节中,我们来看看如何让多个对象共享信息,这就需要用到 static 关键字。

考虑一个场景:创建一种 FraternityBoy(兄弟会成员)动物,所有成员需要前往同一个随机生成的派对地点。

初始问题:各自为政

如果只在构造函数中为每个对象随机生成坐标,那么每个成员都会去往不同的地点。

public class FraternityBoy extends Critter {
    private int partyX; // 派对X坐标
    private int partyY; // 派对Y坐标

    public FraternityBoy() {
        Random rand = new Random();
        partyX = rand.nextInt(60); // 每个对象独立生成
        partyY = rand.nextInt(50);
    }
    // ... getMove() 方法根据 partyX, partyY 移动
}

解决方案:使用静态变量

使用 static 修饰的变量属于类本身,而不是任何一个对象实例。所有该类的对象共享同一份静态变量。

public class FraternityBoy extends Critter {
    // 静态变量,所有 FraternityBoy 对象共享同一份
    private static int partyX;
    private static int partyY;

    public FraternityBoy() {
        // 问题:每个新对象都会重新赋值,覆盖之前的派对地点!
        Random rand = new Random();
        partyX = rand.nextInt(60);
        partyY = rand.nextInt(50);
    }
}

上述代码仍有问题:后创建的对象会覆盖派对地点。更完善的写法是只设置一次

public class FraternityBoy extends Critter {
    private static int partyX = -1; // 初始标记值
    private static int partyY = -1;

    public FraternityBoy() {
        if (partyX == -1) { // 如果还没设置过
            Random rand = new Random();
            partyX = rand.nextInt(60);
            partyY = rand.nextInt(50);
        }
        // 如果已经设置过,则不再改变
    }

    public Direction getMove() {
        // 所有对象都根据共享的 partyX, partyY 来移动
        if (getY() != partyY) {
            return Direction.NORTH;
        } else if (getX() != partyX) {
            return Direction.EAST;
        } else {
            return Direction.CENTER; // 到达派对地点
        }
    }
}

现在,所有 FraternityBoy 对象都会前往第一个对象创建时确定的同一个地点,实现了信息共享。


总结与核心要点

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

  1. 小动物模拟的核心:通过继承 Critter 类并重写五个关键方法(eat, fight, getColor, getMove, toString)来定义生物行为。
  2. 状态管理:使用私有实例字段来记录每个对象的内部状态(如计数器、方向标志),这是实现依赖历史行为的关键。
  3. 响应式编程:你的方法是供模拟器调用的“回调函数”,切勿在方法间相互调用以实现逻辑,而应通过修改和读取私有字段来联动。
  4. 静态变量:使用 static 关键字修饰的变量属于类,被该类的所有对象共享。常用于存储需要跨对象保持一致的信息。

记住,编写小动物行为是一个迭代和测试的过程。充分利用模拟器的调试功能,从简单行为开始,逐步增加复杂度,并善用私有字段来存储所需的各种状态信息。

课程23:图形用户界面(GUI)第一部分 🖥️

在本节课中,我们将开始学习如何创建图形用户界面(GUI)。我们将了解GUI的基本概念、核心组件以及如何让它们响应用户的操作。这与之前学习的图形绘制和动画有所不同,GUI更侧重于按钮、文本框等交互式元素。

概述

图形用户界面是现代应用程序与用户交互的主要方式。Java通过其AWT和Swing库提供了强大的GUI开发能力。本节课我们将学习如何创建一个基本的GUI程序,添加按钮、标签等组件,并让它们响应用户的点击事件。

GUI的历史与背景

上一节我们介绍了GUI的基本概念,本节中我们来看看Java GUI的发展背景。Java在20世纪90年代中期问世,其革命性功能之一就是可以编写能在不同操作系统上运行的GUI代码。最初的GUI系统称为抽象窗口工具包(AWT),它试图为所有操作系统提供一个通用的界面。但由于它只支持各平台功能的交集,功能有限。后来,Swing系统被创建出来,它功能更强大、更丰富。如今,我们编写代码时会混合使用来自AWT和Swing的库。

GUI的核心术语与组件

在编写GUI时,屏幕上弹出的窗口通常被称为“框架”(Frame)或“对话框”(Dialog)。窗口内放置的可交互元素被称为“组件”(Component)或“小部件”(Widget),例如按钮、复选框。Java中常见的组件包括:

  • JButton:按钮
  • JLabel:标签,用于显示文本
  • JTextField:文本框,用于输入文本
  • JCheckBox:复选框

创建第一个GUI程序

以下是创建一个基本GUI程序的模板。与之前的控制台程序或图形程序不同,GUI程序扩展自 Program 类,并将初始化代码放在 init 方法中。

import acm.program.*;
import javax.swing.*;

public class MyFirstGUI extends Program {
    public void init() {
        // 在此初始化窗口并添加组件
        JButton button = new JButton("点击我");
        add(button, NORTH);
    }
}

代码解释

  • extends Program:表示这是一个GUI程序。
  • public void init():此方法在窗口显示之前运行,用于设置窗口和添加组件。
  • new JButton("点击我"):创建一个显示文本为“点击我”的按钮。
  • add(button, NORTH):将按钮添加到窗口的北部(上方)区域。NORTHSOUTHEASTWESTCENTER 是用于指定组件位置的常量。

组件布局与尺寸管理

当你向窗口添加组件时,Java会自动管理它们的位置和大小,以适应不同操作系统和屏幕尺寸。这是通过将组件添加到 NORTHSOUTHEASTWESTCENTER 这些区域来实现的。CENTER 区域会占据所有剩余空间,通常用于放置需要放大的主内容区域(如画布或编辑器),而不是按钮。

处理用户交互:动作事件

到目前为止,我们创建的按钮还不会做任何事情。为了让按钮响应用户点击,我们需要处理“动作事件”(Action Event)。

以下是让程序监听和处理按钮点击事件的步骤:

  1. 导入事件包:在程序顶部添加 import java.awt.event.*;
  2. 添加动作监听器:在 init 方法的最后,调用 addActionListeners();。这行代码会告诉程序去监听所有按钮的点击。
  3. 编写 actionPerformed 方法:当用户点击任何按钮时,这个方法会被自动调用。

import acm.program.*;
import javax.swing.*;
import java.awt.event.*;

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/21c07c164e54e59a33e32d8b50feb338_34.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/21c07c164e54e59a33e32d8b50feb338_36.png)

public class InteractiveGUI extends Program {
    private JLabel statusLabel;

    public void init() {
        JButton redButton = new JButton("变红");
        JButton blueButton = new JButton("变蓝");
        statusLabel = new JLabel("你好,世界!");
        statusLabel.setHorizontalAlignment(JLabel.CENTER);

        add(redButton, NORTH);
        add(blueButton, NORTH);
        add(statusLabel, CENTER);

        addActionListeners(); // 关键步骤:添加监听器
    }

    public void actionPerformed(ActionEvent e) {
        String command = e.getActionCommand(); // 获取被点击按钮的文本
        if (command.equals("变红")) {
            statusLabel.setForeground(Color.RED);
        } else if (command.equals("变蓝")) {
            statusLabel.setForeground(Color.BLUE);
        }
    }
}

核心概念解析

  • addActionListeners():必须调用,否则程序不会监听按钮事件。
  • actionPerformed(ActionEvent e):事件处理方法。
  • e.getActionCommand():返回触发事件的组件(如按钮)上显示的文本,用于判断是哪个按钮被点击。
  • 需要被事件代码修改的组件(如本例中的 statusLabel),应声明为类的私有字段,以便在 initactionPerformed 方法中都能访问。

使用文本框(JTextField)

文本框(JTextField)允许用户输入文本。你可以获取其中的文本进行计算或处理。

以下是一个简单的小费计算器示例的核心思路:

public void init() {
    // ... 添加标签和按钮的代码 ...
    subtotalField = new JTextField(10); // 创建一个宽度约为10个字符的文本框
    add(subtotalField, NORTH);
    addActionListeners();
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/21c07c164e54e59a33e32d8b50feb338_44.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/21c07c164e54e59a33e32d8b50feb338_46.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/21c07c164e54e59a33e32d8b50feb338_48.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/21c07c164e54e59a33e32d8b50feb338_50.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/21c07c164e54e59a33e32d8b50feb338_52.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/21c07c164e54e59a33e32d8b50feb338_54.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/21c07c164e54e59a33e32d8b50feb338_56.png)

public void actionPerformed(ActionEvent e) {
    if (e.getActionCommand().equals("计算小费")) {
        // 1. 从文本框获取文本(字符串)
        String subtotalText = subtotalField.getText();
        // 2. 将字符串转换为数字(双精度浮点数)
        double subtotal = Double.parseDouble(subtotalText);
        // 3. 进行计算
        double tip = subtotal * 0.15;
        // 4. 将结果(数字)转换回字符串并显示
        amountLabel.setText("$" + tip);
    }
}

关键方法

  • JTextField.getText():获取文本框中的文本(返回 String)。
  • Double.parseDouble(String):将字符串解析为 double 类型的数字。
  • 将数字与空字符串连接(如 "$" + tip)是将其快速转换为字符串的简便方法。

总结

本节课中我们一起学习了Java GUI编程的基础知识。我们了解了从扩展 Program 类、在 init 方法中初始化窗口,到添加按钮、标签、文本框等基本组件的过程。最重要的是,我们学会了如何通过 addActionListeners()actionPerformed 方法让程序响应用户的交互,例如处理按钮点击事件。我们还通过小费计算器的例子,演示了如何从文本框获取用户输入、进行数据处理并更新界面显示。这些是构建交互式应用程序的基石。

课程 24:图形用户界面 (GUI) 第二部分 🖥️

在本节课中,我们将继续学习图形用户界面编程。我们将探索更多实用的GUI组件,学习如何响应用户在文本框中的操作,并了解如何将图形动画与GUI控件结合起来。最后,我们会接触一个重要的编程概念:模型-视图分离。


公告与课程安排 📅

有几则关于课程安排的公告需要说明。

  • 周一有假期,因此当天没有课程。
  • 斯坦福大学计算机科学系大楼将在周日(28号)关闭,周一(29号)放假。
  • 由于假期,原定周一的讲座将被取消。我会在周三额外增加一次办公时间来弥补。
  • 如果你在假期期间需要帮助,可以通过班级留言板发帖,或给助教和我发送电子邮件。
  • 关于作业提交:如果你使用了“延迟提交”功能,截止日期将顺延至假期后的下一个上课日(周三)。这意味着你将有额外的时间来完成作业。
  • 第七次也是最后一次作业将于今天晚餐时间前后发布。它计划在第七周的周一截止,以便大家有时间为第十周周五的期末考试做准备。
  • 请合理利用作业的延迟提交机会,以便将本周时间用于期末复习。
  • 今天是退课的最后一天。

以上就是所有的公告。


回顾与引言 🔄

上一节我们介绍了GUI的基础知识,并与2D图形绘制进行了区分。本节中我们来看看如何增强GUI程序的功能。

我们上次创建了一个计算餐厅小费的程序。它包含一个用于输入餐费的文本框和一个“计算小费”按钮。程序会计算并显示15%的小费金额。

现在,我想为这个程序添加新功能:让用户可以选择不同的小费百分比(例如15%、18%、20%),而不是固定使用15%。

为了实现选择功能,我们通常不会使用另一个让用户自由输入百分比的文本框。更好的用户体验是提供一组固定的选项供用户选择。这引出了我们将要学习的新组件:单选按钮

此外,我还会讲解如何为组件添加图标和边框,如何让文本框响应回车键事件,以及如何将GUI与图形动画混合编程。


为组件添加图标与边框 🎨

在深入新组件之前,我们先学习两个美化界面的小技巧:添加图标和边框。

添加图标

你可以为按钮或标签等组件设置图标。方法是创建一个 ImageIcon 对象,然后将其设置给组件。

代码示例:为按钮设置图标

// 假设项目里有一个图片文件 "rubberduck.png"
ImageIcon icon = new ImageIcon("images/rubberduck.png");
JButton calculateButton = new JButton("Calculate Tip");
calculateButton.setIcon(icon);

更简洁的写法是直接将创建 ImageIcon 的代码放入 setIcon 方法中:

calculateButton.setIcon(new ImageIcon("images/rubberduck.png"));

添加边框

组件都有一个 setBorder 方法,用于设置边框。Java通过 BorderFactory 类来创建各种边框。

代码示例:为按钮设置蓝色实线边框

calculateButton.setBorder(BorderFactory.createLineBorder(Color.BLUE, 5));

BorderFactory.createLineBorder 方法可以接受颜色和边框厚度(像素)作为参数。BorderFactory 还提供了创建其他样式边框的方法,如凸起边框、标题边框等。如果你想了解更多,可以查阅Java官方API文档。


响应用户在文本框中的操作 ⌨️

回到我们的小费计算器。目前,用户必须点击“计算小费”按钮才能得到结果。一个更便捷的设计是:当用户在餐费文本框中输入数字后,直接按下回车键就能触发计算。

默认情况下,JTextField 不会响应回车键事件。我们需要手动为其添加事件监听器。

实现步骤:

  1. 获取文本框对象(例如 subtotalField)。
  2. 调用其 addActionListener 方法,并传入监听器(通常是程序本身 this)。
  3. 为了在事件触发后能区分是按钮点击还是文本框回车,我们可以为文本框设置一个“动作命令”字符串。
  4. actionPerformed 方法中,检查事件的动作命令。如果它等于我们为文本框设置的命令或按钮的文本,则执行计算逻辑。

代码示例:让文本框响应回车键

// 在初始化方法中
subtotalField.addActionListener(this); // 添加监听器
subtotalField.setActionCommand("Calculate from Field"); // 设置动作命令

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/be883ba6fc75b68c9c16fcf53d726f2c_39.png)

// 在 actionPerformed 方法中
public void actionPerformed(ActionEvent e) {
    if (e.getActionCommand().equals("Calculate Tip") ||
        e.getActionCommand().equals("Calculate from Field")) {
        // 执行计算小费的代码
        calculateTip();
    }
}

这样,无论是点击按钮还是在文本框中按回车,都会调用相同的 calculateTip() 方法。


使用复选框与单选按钮 ☑️⚪

现在,我们来为小费计算器添加百分比选择功能。这涉及到两种新组件:复选框和单选按钮。

复选框 (JCheckBox)

  • 用途:代表独立的“是/否”、“开/关”选项,用户可以同时选择多个或不选。
  • 类比:就像在线订餐时选择配料(生菜、番茄、芝士),可以多选。

单选按钮 (JRadioButton)

  • 用途:代表一组互斥的选项,用户只能从中选择一个。
  • 类比:就像选择主菜类型(鸡肉、牛肉、素食),只能选一个。
  • 关键:必须将互斥的单选按钮添加到一个 ButtonGroup 对象中,Java才会确保它们互斥。

实现小费百分比选择

我们希望15%、18%、20%这三个选项是互斥的,所以使用单选按钮。

代码示例:创建互斥的单选按钮组

// 1. 创建单选按钮(作为私有字段,以便在actionPerformed方法中访问)
private JRadioButton fifteenPercentButton = new JRadioButton("15%");
private JRadioButton eighteenPercentButton = new JRadioButton("18%");

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/be883ba6fc75b68c9c16fcf53d726f2c_45.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/be883ba6fc75b68c9c16fcf53d726f2c_47.png)

// 2. 创建按钮组并添加按钮,实现互斥
ButtonGroup tipGroup = new ButtonGroup();
tipGroup.add(fifteenPercentButton);
tipGroup.add(eighteenPercentButton);

// 3. 将按钮添加到窗口的某个区域(如南部)
add(fifteenPercentButton, SOUTH);
add(eighteenPercentButton, SOUTH);

// 4. 设置一个默认选中的按钮
fifteenPercentButton.setSelected(true);

现在,界面上的单选按钮可以正常工作且互斥了。接下来,我们需要在计算小费时,判断哪个按钮被选中。

代码示例:根据选中按钮计算小费

private void calculateTip() {
    // ... 获取餐费 subtotal ...
    double tipPercent;
    if (fifteenPercentButton.isSelected()) {
        tipPercent = 0.15;
    } else { // 假设只有两个按钮,选中另一个
        tipPercent = 0.18;
    }
    double tip = subtotal * tipPercent;
    // ... 显示结果 ...
}

通过调用单选按钮的 isSelected() 方法,我们可以知道用户选择了哪个百分比。


混合GUI与图形动画 🚗

有时,我们希望在同一个程序中既包含按钮等GUI控件,又包含动态的图形和动画。例如,一个程序,左边和右边有按钮,中间有一辆可以左右移动的汽车。

核心思路:使用 GCanvas

我们的图形库中有一个 GCanvas 类。它不是一个完整的程序,而是一个可以在GUI程序中嵌入的“画布”区域,专门用于绘制图形和动画。

实现步骤

  1. 创建主GUI程序:像往常一样,创建一个扩展 Program 的类,添加按钮等控件,并设置事件监听。
  2. 创建画布类:创建一个新的类(如 CarCanvas),让它扩展 GCanvas。在这个类的构造函数中,绘制你需要的所有图形(例如一辆汽车)。你还可以在这个类中编写控制图形移动的方法(如 goLeft(), goRight())。
  3. 在主程序中嵌入画布:在主GUI程序中,创建画布类的实例,并将其添加到窗口的中心区域。
  4. 连接事件与画布动作:当按钮被点击时,在主GUI程序的 actionPerformed 方法中,调用画布对象的移动方法(如 canvas.goRight())。

关键点:为了让主GUI程序能调用画布的方法,画布对象必须是一个私有字段,而不能只是初始化方法中的局部变量。同时,画布类中控制移动的方法必须是 public 的,以便其他类调用。

这种设计将图形逻辑(画布类)与用户交互逻辑(主GUI类)分离,使代码结构更清晰。


模型-视图概念 🧱

在编写图形程序时,一个重要的设计概念是模型-视图分离

  • 模型:代表程序的核心数据和业务逻辑。它不关心数据如何显示。例如,在一个银行管理程序中,模型就是管理银行账户数据的类(存储账户、查找账户、存取款计算)。
  • 视图:代表数据的呈现方式,即用户界面。它负责从用户那里接收输入,并将模型处理后的结果显示给用户。GUI、控制台输出都可以是视图。

工作流程

  1. 视图接收用户操作(点击、输入)。
  2. 视图将用户意图告知模型(例如,“查找ID为1234的账户”)。
  3. 模型执行核心逻辑,更新数据或返回查询结果。
  4. 模型将结果返回给视图。
  5. 视图更新显示,向用户反馈结果。

实践示例:银行账户GUI

假设我们要构建一个银行账户管理GUI,功能包括通过ID查找账户、存款、取款。

  1. 模型类 (BankDatabase)
    • 包含一个 HashMap<Integer, BankAccount> 来存储账户数据。
    • 提供 readFromFile 方法从文件加载账户。
    • 提供 findAccount 方法根据ID查找并返回账户对象。
    • 提供 deposit, withdraw 方法来修改账户余额。
  2. 视图类 (主GUI程序)
    • 包含文本框用于输入ID和金额,包含“查找”、“存款”、“取款”按钮,包含标签用于显示账户信息。
    • 持有一个 BankDatabase 模型对象的引用(作为私有字段)。
    • 在按钮的事件处理中,调用模型对象的方法(如 database.findAccount(id)),并根据返回结果更新界面显示。

这种分离使代码更易于维护和测试。你可以修改视图(如将GUI改为网页)而不影响模型逻辑,也可以修改模型(如更换数据存储方式)而不影响视图。


总结 📝

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

  1. 美化组件:如何使用 ImageIcon 为按钮添加图标,以及如何使用 BorderFactory 为组件添加边框。
  2. 增强交互:如何通过 addActionListenerJTextField 响应回车键事件。
  3. 新组件:了解了 JCheckBox(复选框)和 JRadioButton(单选按钮)的用途与区别,并掌握了使用 ButtonGroup 实现单选按钮互斥的方法。
  4. 混合编程:学习了如何通过创建扩展 GCanvas 的类,将图形动画嵌入到GUI程序中,并通过在主程序中调用画布的方法来实现交互控制。
  5. 设计概念:初步了解了模型-视图分离的设计思想,认识到将程序的数据逻辑与显示逻辑分离的重要性,并通过银行账户程序的例子看到了其基本实现方式。

掌握这些知识后,你将能够构建出功能更丰富、交互更友好、结构更清晰的图形用户界面程序。

课程25:GUI组件与布局管理 🖥️

在本节课中,我们将学习更多关于Java图形用户界面(GUI)的知识,包括各种组件(如文本框、滑块、下拉框等)的使用,以及如何通过布局管理器来组织这些组件在窗口中的位置。我们还将探讨如何设计良好的程序结构,让数据模型与用户界面清晰分离。


公告与课程目标 📢

首先,有几个课程相关的公告。本季度已接近尾声,作业提交政策有所调整。每位同学现在拥有五个额外的“晚提交”额度,可用于追溯性地免除之前作业的迟交扣分,也可用于未来的作业(如作业六或七)。此外,作业七的截止日期也略有放宽。

期末考试安排在下周五上午,相关信息已发布在课程网站上。虽然现在无需过度担心,但建议提前查看学习材料。

今天,我们将专注于GUI编程。与往常详细讲解每个类和方法不同,本节课的目标是让大家了解有哪些组件可用、何时使用它们,以及如何设计用户界面。重点是学会如何查找和使用这些组件,而非死记硬背。


如何查找Java文档 🔍

在编写Java GUI程序时,你不需要记住所有组件和方法。关键在于知道如何查找信息。

Java提供了完整的API文档。你可以访问课程网站上的Java API文档链接。该页面列出了所有Java自带的类库,内容非常庞大。

查找特定组件(如JButton)的方法:

  1. 在API文档页面使用 Ctrl+FCommand+F 进行搜索。
  2. 输入“JButton”并跳转到相应条目。
  3. 查看该类的构造方法、常用方法等详细信息。

公式/代码示例:查找文档是解决问题的关键。

// 例如,如果你想知道如何改变标签的对齐方式,可以:
// 1. 在API文档中搜索JLabel。
// 2. 查找 setHorizontalAlignment 方法。

此外,像谷歌搜索“如何改变JLabel对齐方式”并参考Stack Overflow等社区答案,也是完全合理且高效的学习手段。

记住,我们的目标是学会如何解决问题和构建程序,而不是记忆所有细节。


常用GUI组件介绍 🧩

上一节我们介绍了如何查找组件信息,本节中我们来看看一些常用的GUI组件及其用途。

JTextArea(文本区域)

JTextField 是单行文本框,而 JTextArea 是多行文本框,适用于输入较长文本,如邮件正文或聊天信息。

代码示例:创建文本区域

// 创建一个20行、30列的文本区域
JTextArea textArea = new JTextArea(20, 30);
// 设置自动换行
textArea.setLineWrap(true);
// 获取用户输入的文本
String userInput = textArea.getText();

要为 JTextArea 添加滚动条,需要使用 JScrollPane 组件:

JScrollPane scrollPane = new JScrollPane(textArea);
add(scrollPane); // 将带滚动条的面板添加到窗口

JSlider(滑块)

滑块允许用户通过拖动在一个范围内选择数值。

代码示例:创建滑块

// 创建一个范围从0到100,初始值为10的滑块
JSlider slider = new JSlider(0, 100, 10);
// 设置主刻度间隔(每10个单位一个长刻度)
slider.setMajorTickSpacing(10);
// 设置次刻度间隔(每5个单位一个短刻度)
slider.setMinorTickSpacing(5);
// 显示刻度
slider.setPaintTicks(true);
// 显示刻度标签
slider.setPaintLabels(true);

JComboBox(下拉框)与 JList(列表)

JComboBox 是下拉列表,节省空间。JList 是平铺列表,同时显示多项。

如何选择?

  • 当屏幕空间有限或选项很多时,使用 JComboBox
  • 当选项较少,且你希望用户能同时看到所有选项时,使用 JList
  • JList 还支持选择多个项目。

代码示例:创建下拉框

// 创建一个字符串类型的下拉框
JComboBox<String> comboBox = new JComboBox<>();
// 添加选项
comboBox.addItem("选项一");
comboBox.addItem("选项二");
// 获取当前选中的项目
String selected = (String) comboBox.getSelectedItem();

弹出对话框:JOptionPane

用于快速弹出提示、确认或输入对话框。

代码示例:使用对话框

// 1. 显示消息对话框
JOptionPane.showMessageDialog(frame, "操作已完成!");

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/6270691d419989b00b5390eafde16770_9.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/6270691d419989b00b5390eafde16770_11.png)

// 2. 显示确认对话框 (返回用户点击的选项,如 YES_OPTION)
int choice = JOptionPane.showConfirmDialog(frame, "确定要删除吗?");
if (choice == JOptionPane.YES_OPTION) {
    // 执行删除操作
}

// 3. 显示输入对话框
String name = JOptionPane.showInputDialog(frame, "请输入您的名字:");

其他实用对话框

  • JColorChooser:颜色选择器。
    Color selectedColor = JColorChooser.showDialog(frame, "选择颜色", Color.WHITE);
    
  • JFileChooser:文件选择器。
    JFileChooser chooser = new JFileChooser();
    int result = chooser.showOpenDialog(frame);
    if (result == JFileChooser.APPROVE_OPTION) {
        File selectedFile = chooser.getSelectedFile();
        // 处理文件
    }
    

实战:改进银行账户GUI程序 💳

前面我们介绍了一系列组件,现在通过一个实战例子来看看如何将它们整合到一个程序中,并思考良好的程序设计。

我们有一个之前编写的银行账户管理程序 BankGUI。它目前使用 JTextField 让用户输入账户ID来查询。现在我们想将其改为使用 JComboBox 下拉框,让用户直接从已有账户ID中选择。

初始思路:直接传递组件

一种直接的方法是修改 BankDatabase(数据模型)类的读文件方法,将 JComboBox 作为参数传入,并在读取数据时直接向其中添加ID。

代码示例(不推荐的设计):

// 在BankGUI中
database.readFile("bankdata.txt", idBox); // 传递下拉框组件

// 在BankDatabase的readFile方法中
public void readFile(String filename, JComboBox<Integer> box) {
    // ... 读取文件
    while (scanner.hasNextInt()) {
        int id = scanner.nextInt();
        box.addItem(id); // 直接操作GUI组件
        // ... 存储数据
    }
}

这种方法虽然功能上可行,但将GUI组件(视图)直接传递给了数据处理类(模型),破坏了模型与视图的分离原则。这使得 BankDatabase 类依赖于GUI库,难以独立测试或重用于控制台程序。

改进设计:模型返回数据,视图负责显示

更好的设计是让模型专注于数据管理,并提供获取数据的方法。视图则调用这些方法,并负责更新GUI组件。

步骤:

  1. 在模型中添加获取数据的方法:在 BankDatabase 类中添加一个方法,返回所有账户ID的列表。
    public ArrayList<Integer> getAllIds() {
        ArrayList<Integer> idList = new ArrayList<>();
        for (int id : accounts.keySet()) { // 假设数据存储在Map中
            idList.add(id);
        }
        return idList;
    }
    
  2. 在视图中调用并更新组件:在 BankGUI 的初始化方法中,调用上述方法,并手动将ID添加到下拉框。
    // 在BankGUI的init方法中
    ArrayList<Integer> allIds = database.getAllIds();
    for (int id : allIds) {
        idBox.addItem(id);
    }
    
  3. 修改事件处理:将查找按钮的事件处理代码,从读取 JTextField 改为读取 JComboBox 的选中值。
    // 之前:String idText = idField.getText();
    // 之后:
    int selectedId = (int) idBox.getSelectedItem();
    

这种设计的好处:

  • 清晰的责任分离BankDatabase 只处理数据,不知道也不关心GUI。
  • 可重用性高BankDatabase 类可以轻松地被其他类型的用户界面(如控制台界面)使用。
  • 易于维护:修改GUI或数据逻辑时,影响范围更小。

布局管理 📐

当我们向窗口添加多个组件时,需要决定它们的位置和大小。Java使用布局管理器(Layout Manager)来自动处理这些事宜,以适应不同的窗口大小和操作系统。

布局管理器简介

使用 setLayout 方法可以为容器(如窗口、面板)设置布局策略。

核心概念: 布局管理器决定组件的位置尺寸。每个组件有自己首选的尺寸,但布局管理器可能会拉伸或压缩它们。

常用布局策略

以下是几种常见的布局管理器:

  1. FlowLayout(流式布局)

    • 将组件从左到右排列,像段落中的文字。
    • 如果一行放不下,会自动换到下一行。
    • 组件保持其首选大小。
    setLayout(new FlowLayout());
    
  2. BorderLayout(边框布局)

    • 将容器分为五个区域:北(NORTH)、南(SOUTH)、东(EAST)、西(WEST)、中(CENTER)。
    • 这是 Program 类默认的布局。
    • 边缘的组件会沿一个方向拉伸,中心的组件会填充剩余空间。
    setLayout(new BorderLayout());
    add(new JButton("North"), BorderLayout.NORTH);
    add(new JButton("Center"), BorderLayout.CENTER);
    

  1. GridLayout(网格布局)
    • 将容器划分为固定行数和列数的网格。
    • 每个单元格大小相同,组件会被拉伸以填满单元格。
    // 创建一个3行2列的网格布局
    setLayout(new GridLayout(3, 2));
    

  1. TableLayout(表格布局 - 斯坦福库提供)
    • GridLayout 类似,但组件不会拉伸,保持其首选大小,外观更自然。

复合布局

复杂的界面通常需要组合使用多种布局。这可以通过 JPanel(面板)来实现。

JPanel 本身是一个不可见的容器,可以设置自己的布局管理器,并添加组件。然后,可以将这个 JPanel 作为一个整体组件添加到另一个使用不同布局的容器(如主窗口)中。

设计思路示例:
假设你想设计一个界面,顶部是一排按钮(流式布局),底部是状态栏(边框布局的南部),中间是主要内容区域。

  1. 创建一个 JPanel(称为topPanel),为其设置 FlowLayout,并将顶部按钮添加进去。
  2. 主窗口使用 BorderLayout
  3. topPanel 添加到主窗口的 BorderLayout.NORTH
  4. 将状态栏组件添加到 BorderLayout.SOUTH
  5. 将主要内容组件添加到 BorderLayout.CENTER

通过这种“容器嵌套”的方式,你可以构建出任意复杂的界面布局。


总结 🎯

本节课我们一起深入学习了Java GUI编程的两个核心方面:

  1. GUI组件:我们了解了 JTextAreaJSliderJComboBoxJListJOptionPane 等常用组件的用途和基本用法。关键在于学会在需要时通过API文档查找具体信息,而不是记忆所有细节。
  2. 程序设计与布局
    • 我们通过银行账户程序的案例,实践了模型-视图分离的良好设计原则,让数据处理逻辑与用户界面逻辑各司其职,提高代码的可维护性和可重用性。
    • 我们学习了布局管理器的概念,包括 FlowLayoutBorderLayoutGridLayout 等,并了解了如何通过 JPanel 组合使用它们来构建复杂的窗口布局。

记住,GUI编程是实践性很强的技能。多动手编写代码,尝试组合不同的组件和布局,并查阅官方文档,是掌握它的最佳途径。

课程26:多态性与接口 🧬

在本节课中,我们将学习面向对象编程中两个核心且强大的概念:多态性接口。我们将通过具体的代码示例来理解它们如何工作,以及它们如何使我们的程序更加灵活和可扩展。


概述

我们已经接近课程尾声。今天是第九周的星期五,只剩下几节课和期末考试。希望大家已经完成了作业六,并且正在处理作业七。今天,我们将讲解课程材料中最后一部分新的、将在期末考试中测试的官方内容。下周的讲座将有所不同,周一会讨论从斯坦福Java库过渡到实际工作环境,周三则会举办一个有趣的“火山口锦标赛”活动。

今天的主题将回到继承,并深入探讨两个具体概念:多态性接口的价值。


多态性回顾

上一节我们介绍了继承的基本概念。本节中,我们来看看多态性。简单来说,多态性是指一段代码可以根据所处理对象类型的不同,而表现出不同的行为

例如:

  • 你可以调用 println 来打印任何类型的对象,它会自动调用该对象的 toString 方法。
  • 在图形程序中,你可以向窗口添加多种类型的图形对象(如圆形、矩形),它们都会以自己的方式被绘制出来。

理解继承层次结构中的类如何交互,对于掌握多态性至关重要。

多态性练习

为了理解多态性在继承中如何运作,我们通过一个练习来探讨。这类问题在考试中很常见:给你一组相互继承的类,然后询问当调用某些方法时,代码的具体行为是什么。

以下是练习中使用的类:

class Food {
    public void method1() {
        System.out.println("Food 1");
    }
    public void method2() {
        System.out.println("Food 2");
    }
}

class Bar extends Food {
    public void method2() {
        System.out.println("Bar 2");
    }
}

class Baz extends Food {
    public void method1() {
        System.out.println("Baz 1");
    }
    public String toString() {
        return "Baz";
    }
}

class Mumble extends Baz {
    public void method2() {
        System.out.println("Mumble 2");
    }
}

现在,考虑以下测试代码:

Food[] foods = {new Food(), new Bar(), new Baz(), new Mumble()};
for (Food item : foods) {
    System.out.println(item);
    item.method1();
    item.method2();
}

问题:这段代码的输出是什么?

解决方法
要解决此类问题,可以遵循以下步骤:

  1. 画出继承关系图:理清类之间的层次结构。
        Food
       /    \
     Bar    Baz
              \
             Mumble
    
  2. 列出每个类拥有的方法:包括自己定义的和继承来的。
    • Food: method1, method2
    • Bar: 继承 method1, 重写 method2
    • Baz: 重写 method1, 继承 method2, 重写 toString
    • Mumble: 继承 method1 (来自 Baz), 重写 method2, 继承 toString (来自 Baz)
  3. 动态查找方法:当调用 item.methodX() 时,Java 虚拟机会从对象的实际类型开始查找该方法。如果当前类没有定义,则沿着继承链向上查找,直到找到为止。
    • 对于 new Bar() 调用 method2:在 Bar 类中找到,输出 “Bar 2”。
    • 对于 new Mumble() 调用 method1:在 Mumble 中未找到,向上到 Baz 中找到,输出 “Baz 1”。

这个例子中的多态性体现在:数组 foods 的声明类型是 Food[],但它可以存放任何 Food 的子类对象。当循环中调用方法时,执行的是对象实际类型(如 Bar, Baz, Mumble)中定义的方法,而不是数组声明的 Food 类型中的方法。这就是“一段代码,多种行为”的体现。


深入多态性:super 关键字与链式调用

理解了基础的多态性后,我们来看一个更复杂的例子,它涉及方法内部调用其他方法(包括使用 super 关键字)。

考虑以下类结构:

class Ham {
    public void a() {
        System.out.print("Ham a ");
    }
    public void b() {
        System.out.print("Ham b ");
    }
    public String toString() {
        return "Ham";
    }
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/b7b572d119c01468d2ec4860dc818719_8.png)

class Lamb extends Ham {
    public void b() {
        System.out.print("Lamb b ");
    }
}

class Yam extends Lamb {
    public void a() {
        System.out.print("Yam a ");
        super.a();
    }
    public String toString() {
        return "Yam";
    }
}

class Spam extends Yam {
    public void b() {
        System.out.print("Spam b ");
    }
}

测试代码:

Ham[] food = {new Ham(), new Lamb(), new Yam(), new Spam()};
for (Ham item : food) {
    System.out.println(item);
    item.a();
    item.b();
    System.out.println();
}

关键点分析
这里的难点在于 Yam 类的 a() 方法中调用了 super.a()。需要记住的原则是:super 关键字指向的是当前类在继承链上的直接父类版本的方法

  • itemYam 对象时,调用 item.a()
    1. 执行 Yam.a(),打印 “Yam a “。
    2. 遇到 super.a(),跳转到其父类 Lamb 中查找 a() 方法。
    3. Lamb 没有定义 a(),继续向上到 Ham 中找到,执行 Ham.a(),打印 “Ham a “。
    4. Ham.a() 方法执行完毕,返回 Yam.a()
    5. Yam.a() 执行完毕。
  • 接着调用 item.b()
    1. Yam 没有重写 b(),向上查找。
    2. Lamb 中找到重写的 b(),执行 Lamb.b(),打印 “Lamb b “。

重要提示:当方法中调用另一个方法(如 b())时,它调用的是当前对象实际类型中的那个方法版本,而不是当前方法所在类中定义的那个版本。这同样是多态性的体现。

通过这类练习,你可以掌握分析复杂继承和多态行为的能力,这对理解和设计面向对象系统至关重要。


接口

上一节我们探讨了通过继承实现的多态性。本节中,我们来看看另一种实现多态性和定义契约的强大工具:接口

为什么需要接口?

假设你正在编写一个处理几何形状(如圆形、矩形、三角形)的程序。这些类似乎是相关的,你可能会考虑使用继承,创建一个公共的 Shape 父类。

但问题来了:不同形状计算面积和周长的方法完全不同。圆的面积公式是 π * r²,矩形是 width * height,三角形则是 0.5 * base * height。它们之间几乎没有可以共享的具体实现代码。在这种情况下,使用传统的继承来共享代码就不太合适。

我们需要的是一种方式来声明:“所有这些类都是‘形状’,因此它们必须能够计算面积和周长”,但我们并不关心(也无法统一)它们如何计算。这就是接口的用武之地。

接口的定义与实现

接口就像一个只有方法声明而没有方法体(实现)的契约。它定义了一组方法,任何类如果“实现”(implements)了这个接口,就必须为这些方法提供具体的实现。

定义一个 Shape 接口:

public interface Shape {
    double getArea();
    double getPerimeter();
}

实现 Shape 接口:

public class Circle implements Shape {
    private double radius;

    public Circle(double radius) { this.radius = radius; }

    // 必须实现接口中声明的所有方法
    @Override
    public double getArea() {
        return Math.PI * radius * radius;
    }

    @Override
    public double getPerimeter() {
        return 2 * Math.PI * radius;
    }
}

public class Rectangle implements Shape {
    private double width, height;
    // ... 构造函数和其他代码 ...

    @Override
    public double getArea() {
        return width * height;
    }

    @Override
    public double getPerimeter() {
        return 2 * (width + height);
    }
}

注意:如果一个类声明 implements 了某个接口,但没有实现接口中的所有方法,编译器将会报错。

接口的好处:实现多态

接口的核心优势在于它实现了多态性,而无需共享实现代码

现在,我们可以编写处理通用“形状”的代码:

public class ShapeTest {
    public static void printInfo(Shape s) {
        // 因为s是Shape类型,我们知道它一定有getArea和getPerimeter方法
        System.out.println("Area: " + s.getArea());
        System.out.println("Perimeter: " + s.getPerimeter());
    }

    public static void main(String[] args) {
        Shape[] shapes = new Shape[3];
        shapes[0] = new Circle(5.0);
        shapes[1] = new Rectangle(4.0, 6.0);
        shapes[2] = new Triangle(3.0, 4.0, 5.0);

        for (Shape shape : shapes) {
            printInfo(shape); // 多态:同一个调用,不同行为
            System.out.println("---");
        }
    }
}

好处

  1. 代码复用与灵活性printInfo 方法可以接受任何实现了 Shape 接口的对象。未来添加新的形状(如 Ellipse),也无需修改 printInfo 方法。
  2. 统一处理:我们可以创建 Shape 类型的数组或集合,将不同的形状对象放在一起管理。
  3. 定义契约:接口明确规定了类必须提供哪些功能,使得代码设计更清晰,协作更规范。

接口的其他用途:定义常量

接口还有一个常见的用法是定义常量。你可以在接口中声明 public static final 的变量(这些修饰符通常可以省略),然后通过实现该接口或直接通过接口名来使用这些常量。

public interface PhysicsConstants {
    double GRAVITY = 9.81;
    double SPEED_OF_LIGHT = 299792458;
    // ... 其他常量
}

// 使用方式
public class Calculator implements PhysicsConstants {
    public double calculateForce(double mass) {
        return mass * GRAVITY; // 直接使用常量
    }
}
// 或者
double c = PhysicsConstants.SPEED_OF_LIGHT;

总结

本节课中,我们一起深入学习了面向对象编程的两个高级概念。

  • 多态性:我们通过具体的继承示例,分析了方法调用的动态查找过程,理解了“一段代码,多种行为”的精髓。关键在于掌握对象实际类型决定方法执行版本这一原则。
  • 接口:我们探讨了接口作为“契约”的角色,它定义了一组方法而不提供实现。类通过 implements 关键字来实现接口,并承诺完成这些方法。接口的主要价值在于实现多态性和定义规范,它允许我们编写更通用、更灵活的代码,将“做什么”(接口定义)与“怎么做”(类实现)分离开来。

继承和接口是构建复杂、可扩展Java程序的基石。继承侧重于代码的复用和“是一个(is-a)”关系的建模,而接口侧重于行为的规范和“能做什么(can-do)”能力的声明。结合使用它们,可以设计出强大而清晰的面向对象系统。

📚 课程名称:CS 106a Java教程 - P27:脱离斯坦福库的“真实”Java编程

📖 概述

在本节课中,我们将学习如何在不依赖斯坦福大学提供的便捷库(如acm.programacm.graphics等)的情况下,编写标准的Java程序。我们将对比两种编程方式,理解库为我们简化了什么,并学习如何用“真实”的Java语法实现相同的功能,包括控制台输入输出、图形用户界面(GUI)和事件处理。


🗓️ 期末考试安排与复习

在开始新内容之前,先简要说明期末考试的相关安排。期末考试复习会安排在周三晚上或下午。具体细节和房间安排会发布在课程网站上。

本学期是最后一周,希望大家在忙于项目、论文和期末考试的同时,能坚持完成课程。本周三将举行有趣的“小动物锦标赛”,希望大家能参与。期末考试将在周五上午进行。

关于期末考试:

  • 考试是累积性的,但重点考察课程后半部分的新内容。
  • 考试形式为开卷,会提供参考表格。
  • 考试时长3小时,旨在让大家有充足时间作答。
  • 已发布模拟试题和答案,供大家复习。

可能考察的核心概念包括:

  1. 参数传递与引用语义:理解基本类型(传值)和对象/数组(传引用)的区别。
    // 基本类型传递副本
    void modify(int x) { x = 10; }
    // 对象传递引用
    void modifyArray(int[] arr) { arr[0] = 10; }
    
  2. 数组操作:编写操作一维或多维数组的方法。
    // 例如:图像处理(二维数组)
    void flipImage(int[][] pixels) { ... }
    
  3. 继承与多态:解决涉及类继承和方法的谜题。
  4. 集合框架:使用ArrayListHashMap读写数据。
    HashMap<String, Integer> map = new HashMap<>();
    map.put("Alice", 30);
    
  5. 图形用户界面(GUI):实现包含按钮等组件的简单GUI程序。

建议大家通过练习模拟试题来备考,如有疑问可在课程论坛或办公时间提问。


🔍 斯坦福库 vs. 标准Java

上一节我们介绍了期末安排,本节中我们来看看课程中一直使用的斯坦福库与标准Java的区别。

课程中使用的库(如acm.program, acm.graphics)由斯坦福大学的教授(如Eric Roberts)创建,主要目的是简化Java编程的学习曲线,平滑Java语言中一些对初学者不必要的复杂部分,并让学生能够编写图形、动画等有趣程序。

控制台程序的对比

以下是使用斯坦福库和标准Java编写“Hello World”程序的对比。

使用斯坦福库 (acm.program):

import acm.program.*;
public class Hello extends ConsoleProgram {
    public void run() {
        println("Hello, world!");
    }
}

使用标准Java:

public class Hello {
    public static void main(String[] args) {
        System.out.println("Hello, world!");
    }
}

核心变化:

  1. 类不再extends特定库类。
  2. 入口方法名为main,而非run
  3. main方法必须声明为public static void,并接受一个String[]参数。
  4. 输出使用完整的System.out.println

许多教师认为标准Java的“Hello World”程序包含了太多初学者暂时不需要理解的复杂概念(如publicstaticvoidString[] args),而斯坦福库将其简化了。


⌨️ 控制台输入的处理

在控制台程序中,我们经常需要读取用户输入。下面我们看看两种方式如何处理。

使用斯坦福库 (ConsoleProgram):
库提供了readLine(String prompt)等方法,能自动提示用户并处理输入。

String name = readLine("What is your name? ");
int age = readInt("How old are you? ");

使用标准Java:
在标准Java中,需要用到Scanner类,并且提示和读取是分开的步骤。

import java.util.Scanner; // 需要导入Scanner

public class AgeProgram {
    public static void main(String[] args) {
        Scanner keyboard = new Scanner(System.in);
        System.out.print("What is your name? ");
        String name = keyboard.nextLine();
        System.out.print("How old are you? ");
        int age = keyboard.nextInt();
        // ... 后续计算
        keyboard.close(); // 记得关闭Scanner
    }
}

注意: 标准Java的Scanner不会自动验证输入格式。如果用户输入非数字时调用nextInt(),程序会崩溃(抛出InputMismatchException),而斯坦福库的readInt()会持续提示直到输入有效。这使得斯坦福库对初学者更友好。


🎲 随机数生成

生成随机数是另一个常见操作,两种方式也有所不同。

使用斯坦福库 (RandomGenerator):

import acm.util.*;
RandomGenerator rg = RandomGenerator.getInstance();
int dieRoll = rg.nextInt(1, 6); // 生成1到6之间的整数

使用标准Java (java.util.Random):

import java.util.Random;

public class DiceRoll {
    public static void main(String[] args) {
        Random rand = new Random();
        // nextInt(n) 生成 [0, n) 的整数
        int dieRoll = rand.nextInt(6) + 1; // 转换为1到6
        System.out.println("You rolled: " + dieRoll);
    }
}

标准Java的Random类需要手动进行范围偏移来获得特定区间的值。


🖼️ 图形用户界面(GUI)编程

图形界面是斯坦福库简化最多的部分。我们以一个简单的变色窗口程序为例。

使用斯坦福库 (Program):

import acm.program.*;
import java.awt.*;
import javax.swing.*;
public class ColorFun extends Program {
    public void init() {
        add(new JButton("Red"), SOUTH);
        add(new JButton("Blue"), SOUTH);
        addActionListeners();
    }
    public void actionPerformed(ActionEvent e) {
        String cmd = e.getActionCommand();
        if (cmd.equals("Red")) {
            setBackground(Color.RED);
        } else if (cmd.equals("Blue")) {
            setBackground(Color.BLUE);
        }
    }
}

使用标准Java (JFrame, JButton):
转换为标准Java需要更多步骤:

import java.awt.*;
import java.awt.event.*;
import javax.swing.*;

public class ColorFunReal implements ActionListener {
    private JFrame frame;
    private JButton centerButton; // 用于演示背景色变化

    public void init() {
        frame = new JFrame("Color Fun");
        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        frame.setSize(500, 300);

        // 创建按钮并添加监听器
        JButton redButton = new JButton("Red");
        redButton.addActionListener(this);
        JButton blueButton = new JButton("Blue");
        blueButton.addActionListener(this);

        // 使用面板容纳多个按钮
        JPanel southPanel = new JPanel(new FlowLayout());
        southPanel.add(redButton);
        southPanel.add(blueButton);

        centerButton = new JButton("Hi");
        frame.add(centerButton, BorderLayout.CENTER);
        frame.add(southPanel, BorderLayout.SOUTH);

        frame.setVisible(true);
    }

    public void actionPerformed(ActionEvent e) {
        String cmd = e.getActionCommand();
        if (cmd.equals("Red")) {
            centerButton.setBackground(Color.RED);
        } else if (cmd.equals("Blue")) {
            centerButton.setBackground(Color.BLUE);
        }
    }

    public static void main(String[] args) {
        // 需要创建对象并调用初始化方法
        ColorFunReal gui = new ColorFunReal();
        gui.init();
    }
}

关键区别:

  1. 窗口管理:必须显式创建JFrame,设置大小(setSize),并使其可见(setVisible(true))。
  2. 布局管理:直接向JFrame添加多个组件到同一区域会覆盖,通常需要中间容器(如JPanel)。
  3. 事件监听:必须让主类implements ActionListener,并显式地为每个按钮调用addActionListener(this)
  4. 程序入口:必须有main方法,并在其中创建GUI对象来启动程序。
  5. 背景设置:不能直接设置JFrame的背景,通常需要设置其中某个组件的背景。

🎨 图形绘制

斯坦福库的acm.graphics包提供了面向对象的图形模型(如GRect, GOval等对象),可以轻松地移动和修改图形。这在标准Java中实现起来更为复杂。

标准Java绘图方式:
在标准Java中,绘图通常通过重写paintComponent方法,使用Graphics对象(像一支笔)直接绘制像素。

public class Drawing extends JPanel {
    @Override
    protected void paintComponent(Graphics g) {
        super.paintComponent(g);
        g.setColor(Color.RED);
        g.fillOval(50, 50, 100, 100); // 绘制一个实心圆
    }
}

如果需要制作动画或管理多个可交互的图形对象,标准Java需要开发者自行维护所有图形状态(坐标、颜色等),复杂度大大增加。斯坦福库的面向对象图形模型在此优势明显。


⚖️ 总结:利弊权衡

本节课中我们一起学习了如何脱离斯坦福库编写标准Java程序,并对比了两种方式的差异。

使用斯坦福库的优点:

  • 简化学习:隐藏了public static void main等初始复杂度。
  • 友好输入readInt等方法内置输入验证和提示。
  • 强大图形GObject体系让图形编程和动画更直观。
  • 快速上手:能让学生更快地编写出有趣的图形程序(如“突破”游戏)。

使用斯坦福库的潜在顾虑:

  • 技能迁移:学生可能担心所学知识不能直接用于其他环境。
  • 理解底层:库可能掩盖了Java标准API的工作原理。

最终思考:
本课程的核心目标是教授编程的基本概念(变量、循环、方法、对象、事件处理等),这些概念是通用的,不依赖于特定库。在掌握了这些概念和基于库的编程后,过渡到标准Java主要是学习新的API和少量不同的语法模式,这个过程会相对快速。

对于希望分发自己程序的同学,可以查阅课程网站上关于创建可执行JAR文件的讲义,以便将程序分享给他人运行。

课程 03:与 Karel 一起解决问题 🧩

在本节课中,我们将学习如何利用方法、循环和条件语句来解决更复杂的 Karel 机器人问题。我们将通过编写一个“扫地”程序来实践,并深入理解 while 循环和“栅栏柱”问题等核心概念。


课程公告与作业说明

以下是本课程相关的几则简短公告。

第一,关于讨论部分的注册。请在周日晚上五点前完成注册,并告知我们你有空的时间。我会在今晚或明天发送提醒邮件。请尽快使用你的手机或电脑提交注册信息。下周初,我们会通知你被分配到的讨论部分。如果你忘记在周日之前提交,很可能无法获得你的第一选择。

第二,我已经发布了第一份家庭作业。这是一套 Karel 机器人问题,我希望你们尝试解决。下课时我会再详细说明。作业包含了所有必需的材料。本周末,你们可以开始处理这门课的大部分作业。

对于第一份作业,允许两人一组合作完成。虽然我希望你们能与朋友交流想法、代码和课程材料,但我不希望你们直接分享解决方案或一起解决问题。因此,请不要结对完成第一个作业。

总的来说,我给你们的成功建议是:不要等到最后一刻才开始。我知道这听起来像是老生常谈,但这些作业往往具有一定挑战性,这门课程本身也具有挑战性。如果你提早开始,遇到困难时可以从周日或周一开始来我们的实验室寻求帮助。你等待的时间越长,实验室里的人就会越多。如果你想成功,我建议尽早开始作业。

每项作业都基于讲座材料。下周要交的作业不需要用到今天之后的内容。理论上,凭借今天教授的知识,你已经可以开始并完成整个作业。作业已经发布,我稍后会详细讨论。


回顾核心编程概念

上一节课我们介绍了一些编程基础。本节中,我们来快速总结一下方法、for 循环和 if 语句。

方法 (Methods)

方法是什么?它有什么用?为什么要在程序中使用方法?

方法可以将一系列现有命令组合成一个新命令。它有助于清理代码,避免重复编写相同的语句组。如果你需要反复做同一组操作,只需写下新方法的名称并反复调用它,这样代码会更简短、更清晰。

For 循环

for 循环用于将一个命令或一组命令重复执行给定的次数。虽然其初始语法可能看起来有些复杂,但它功能强大。例如,如果你想做某件事 1000 次,使用 for 循环比粘贴代码行 1000 次要好得多。

方法和 for 循环都有助于避免冗余,但它们的用途略有不同。方法适合将相关事物分组,而 for 循环适合重复执行固定次数的操作。

If 语句

if 语句使程序更加灵活。它检查当前世界中某个条件是否为真(例如,Karel 所站的位置有蜂鸣器吗?前面有墙吗?),然后根据结果决定是否执行一段代码。这允许你的程序对不同情况做出反应,从而编写出能在多种不同世界上成功运行的通用程序。


实践:编写扫地程序

现在,让我们应用这些概念来编写一个程序。我们将写一个“扫地”程序,让 Karel 向前走并捡起所有蜂鸣器。

我们之前写过一个程序,让 Karel 走五步,并在每一步检查并捡起蜂鸣器。代码如下:

for (int i = 0; i < 5; i++) {
    move();
    if (beepersPresent()) {
        pickBeeper();
    }
}

但这个世界有九个方格。如果 Karel 从位置 1 开始,走到位置 9 需要移动 8 次。所以,我应该将循环改为 8 次。

然而,如果世界的大小不同呢?例如,一个更大的世界有 15 个方格,我需要移动 14 次。但如果我把循环改为 14 次,然后在原来的小世界上运行,Karel 会在尝试第 9 次移动时撞墙并报错。

我们希望 Karel 一直走,直到无法前进为止。for 循环并不适合这种情况,因为它要求确切知道循环次数。


引入 While 循环

当我们想重复执行某操作,但不知道具体需要多少次,只要某个条件为真就继续时,我们需要另一种结构:while 循环

while 循环的语法与 if 语句相似:

while (condition) {
    // 要重复执行的代码
}

区别在于:if 语句检查条件,如果为真就执行一次代码块;while 循环则会反复检查条件,只要条件为真,就重复执行代码块。

对于扫地程序,我们可以这样写:

while (frontIsClear()) {
    move();
    if (beepersPresent()) {
        pickBeeper();
    }
}

这样,Karel 就会一直向前走并捡起蜂鸣器,直到前面被挡住为止。


解决“栅栏柱”问题

但上面的代码还有一个问题:它假设每个方格最多只有一个蜂鸣器。如果一个方格有多个蜂鸣器(显示为数字),Karel 每次执行 pickBeeper() 只会捡起一个。

我们需要修改代码,确保捡起一个方格上的所有蜂鸣器。我们可以将 if 语句改为 while 循环:

while (frontIsClear()) {
    move();
    while (beepersPresent()) {
        pickBeeper();
    }
}

现在,对于每个方格,只要上面有蜂鸣器,Karel 就会一直捡,直到捡完为止。

然而,这又引入了另一个经典问题——“栅栏柱”问题。在这个比喻中,“柱子”是“捡蜂鸣器”操作,“电线”是“移动”操作。在一个有 N 个方格的世界里,我们需要 N 次“捡蜂鸣器”(从起点方格开始),但只需要 N-1 次“移动”(在方格之间)。

我们当前的算法将“移动”和“捡蜂鸣器”配对在一起,这永远无法正确处理起点方格的蜂鸣器,因为 Karel 总是先移动再检查。

解决方案是调整顺序,在循环外先处理第一个“柱子”(起点方格的蜂鸣器),然后在循环内执行“电线-柱子”对(移动,然后处理新方格的蜂鸣器)。修改后的代码如下:

// 处理起点方格(第一个“柱子”)
while (beepersPresent()) {
    pickBeeper();
}
while (frontIsClear()) {
    move(); // “电线”
    // 处理新方格的蜂鸣器(下一个“柱子”)
    while (beepersPresent()) {
        pickBeeper();
    }
}

这样,Karel 就能正确捡起所有方格(包括起点)的所有蜂鸣器了。


扩展挑战:清理矩形世界

假设我们想让 Karel 清理一个矩形世界的所有四条边。我们可以利用刚才写好的 sweep 方法(它清理一行直到墙边),然后重复四次:清扫一行,右转。

代码如下:

for (int i = 0; i < 4; i++) {
    sweep(); // 假设 sweep() 是我们封装好的清扫一行的方法
    turnRight();
}

这里我们使用了 for 循环,因为我们确切地知道需要清扫四条边。将 sweep 定义为独立方法,使得主程序逻辑非常清晰。


另一个例子:跨栏

现在来看一个更难的问题:让 Karel 跳过一系列高度未知的栅栏。我们首先将大问题分解:先编写一个跳过单个栅栏的方法 jumpHurdle

编写方法时,明确前置条件后置条件是很好的实践:

  • 前置条件:假设 Karel 在栅栏底部,面朝东。
  • 后置条件:代码运行后,Karel 将跳过栅栏,并仍然面朝东。

这样,我们就可以在 run 方法中简单地多次调用 jumpHurdle 来跳过所有栅栏。一个跳过单个栅栏的 jumpHurdle 方法可能如下所示:

public void jumpHurdle() {
    turnLeft();
    while (rightIsBlocked()) {
        move();
    }
    turnRight();
    move();
    turnRight();
    while (frontIsClear()) {
        move();
    }
    turnLeft();
}

然后,跳过 8 个栅栏的主程序就是:

for (int i = 0; i < 8; i++) {
    jumpHurdle();
}

作业概览

最后,我来简要介绍下已发布的第一份作业。你们需要下载作业包并导入 Eclipse。作业包含以下几个 Karel 问题:

  1. 收集报纸:让 Karel 去某个方格取回报纸。
  2. 石匠:查找缺失的砖块并用石头填充,以建造石柱。
  3. 棋盘:在世界上填充棋盘图案。
  4. 终点线:将 Karel 移动到世界的正中间并放置一个蜂鸣器(这可能具有欺骗性)。
  5. 双重蜂鸣器(可选):一个可以自由发挥创意的附加问题。

作业截止日期是下周五晚上。请愉快地开始吧!


总结

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

  • 回顾了方法for 循环if 语句的核心概念。
  • 引入了 while 循环,用于在条件为真时重复执行代码。
  • 通过编写“扫地”程序,实践了循环和条件判断。
  • 理解并解决了经典的“栅栏柱”问题。
  • 学习了通过定义方法(如 jumpHurdle)来分解复杂问题的策略。
  • 了解了第一份作业的内容和要求。

记住,编程的关键在于将大问题分解为小步骤,并经常测试你的代码。祝你们作业顺利!

课程04:控制台与表达式 📟

在本节课中,我们将要学习控制台编程的基础知识,以及如何使用表达式进行数学计算。我们将从图形化的Karel世界转向更通用的Java程序,学习如何通过简单的文本与控制台与用户交互。


概述

上一节我们介绍了Karel机器人编程,本节中我们来看看如何编写更通用的Java程序。我们将学习一种称为“控制台程序”的简单程序类型,它通过文本与用户交互。同时,我们将深入理解Java中的基本数据类型和表达式,学习如何让计算机进行计算。


什么是控制台程序? 🖥️

控制台程序是一种非常简单的程序,它通过一个纯文本的矩形窗口(称为控制台或终端)与用户交互。这个窗口可以显示文本信息,并接收用户输入的文本命令。

虽然它没有华丽的图形界面,但它是学习编程基础、理解程序逻辑和计算过程的绝佳起点。

控制台程序的基本结构

public class MyProgram extends ConsoleProgram {
    public void run() {
        // 你的代码写在这里
    }
}

与之前扩展 Karel 不同,现在我们扩展 ConsoleProgram


输出信息:println 语句

在控制台上显示信息是程序与用户沟通的主要方式。我们可以使用 println 语句(意为“打印一行”)来输出文本。

语法

println("你想要显示的消息");

消息必须用双引号 " " 包围,这被称为字符串

示例

println("CS106A是最棒的!");

运行程序后,控制台窗口会弹出并显示这行文字。


转义序列

有时我们需要在字符串中包含一些特殊字符,例如引号本身或反斜杠。这时就需要使用转义序列,即在特殊字符前加上反斜杠 \

以下是常见的转义序列:

  • \":在字符串中表示一个双引号。
  • \\:在字符串中表示一个反斜杠。
  • \n:在字符串中表示换行。

示例

println("Marty说:\"CS106A每天都很棒!\""); // 输出包含引号
println("我最喜欢的符号是:\\"); // 输出一个反斜杠

进行计算:表达式与数据类型 🧮

计算机的核心功能之一是计算。在Java中,我们使用表达式来进行计算。表达式由(字面量或变量)和运算符(如 +, -, *, /)组成。

基本数据类型

为了有效地处理数据,Java将数据分为不同的类型。我们首先关注两种用于数字的类型:

  1. int:表示整数(没有小数部分的数字),例如:42, -7, 0
  2. double:表示实数(带有小数部分的数字),例如:3.14, -0.5, 2.0

为什么有两种数字类型?
这与计算机底层处理整数和实数的方式不同有关。int 运算效率高,且能明确表示“只能是整数”的场景(如人数)。double 则可以表示更精确的小数计算结果。

算术运算符

以下是用于 intdouble 的基本算术运算符:

  • +:加法
  • -:减法
  • *:乘法
  • /:除法
  • %:取模(求余数)

整数除法 (int / int) 有一个重要特点:结果会丢弃小数部分,直接向下取整。

println(7 / 2); // 输出 3,而不是 3.5
println(14 / 4); // 输出 3

取模运算符 (%) 可以获取除法运算后的余数,非常实用。

println(14 % 4); // 输出 2,因为 14 ÷ 4 = 3...2
println(8 % 2);  // 输出 0,用于判断偶数

运算符优先级

和数学中一样,表达式中的运算有先后顺序(优先级):

  1. 括号 () 拥有最高优先级。
  2. 然后是乘法 *、除法 / 和取模 %
  3. 最后是加法 + 和减法 -

示例

int result = 6 + 8 / 2 * 3;
// 计算顺序:8 / 2 = 4 -> 4 * 3 = 12 -> 6 + 12 = 18
println(result); // 输出 18

混合类型计算与字符串连接

类型混合运算

当表达式中同时出现 intdouble 时,Java会将 int 自动提升为 double,然后进行 double 类型的运算。

double result = 7 / 2 * 1.2 + 3 / 2;
// 计算顺序:7/2=3 (int) -> 3*1.2=3.6 (double) -> 3/2=1 (int) -> 3.6+1=4.6 (double)
println(result); // 输出 4.6

字符串连接

我们可以使用 + 号将字符串与表达式的计算结果连接起来。

println("1 + 1 = " + (1 + 1)); // 输出:1 + 1 = 2

注意:由于 + 对于字符串是“连接”操作,对于数字是“加法”操作,优先级和结合顺序可能导致意外结果。使用括号可以确保先进行数学计算。

println("结果是: " + 3 * 5); // 输出:结果是: 15 (乘法优先)
println("结果是: " + 1 + 1); // 输出:结果是: 11 (从左到右连接)
println("结果是: " + (1 + 1)); // 输出:结果是: 2 (括号优先)


避免冗余:使用变量 💾

在程序中,我们经常需要重复使用某个计算结果。反复书写相同的表达式不仅麻烦,而且容易出错。这时,我们可以使用变量

变量就像是一个有名字的存储盒子,可以用来保存一个值(如表达式的结果),之后在程序中通过名字来引用这个值。

使用变量的步骤

  1. 声明变量:指定变量的类型名字
  2. 赋值:将一个值存储到变量中。

语法

// 分开写
int total; // 1. 声明一个名为total的整数变量
total = 38 + 40 + 30; // 2. 将计算结果赋值给total

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/13644810aaefd685dc9a52acb86809de_39.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/13644810aaefd685dc9a52acb86809de_41.png)

// 合并为一行(更常见)
int total = 38 + 40 + 30; // 声明并立即赋值
double taxRate = 0.08;

示例:改进的账单程序

public class Receipt extends ConsoleProgram {
    public void run() {
        // 使用变量存储中间结果
        int subtotal = 38 + 40 + 30;
        double tax = subtotal * 0.08;
        double tip = subtotal * 0.15;
        double total = subtotal + tax + tip;

        // 输出清晰的账单
        println("小计:$" + subtotal);
        println("税费:$" + tax);
        println("小费:$" + tip);
        println("总计:$" + total);
    }
}

这样,如果需要修改菜品价格,只需更新 subtotal 的计算一处即可,程序更易维护。

关于变量的重要规则

  • 变量必须先声明,后使用。
  • 变量通常只在声明它的 { } 代码块内有效(例如,在某个方法内部)。我们将在后续课程详细讨论。

总结

本节课中我们一起学习了控制台编程的基础。我们了解了如何通过 println 语句在控制台输出信息,如何使用转义序列处理特殊字符。更重要的是,我们深入探讨了如何使用 intdouble 数据类型进行数学计算,理解了运算符优先级、整数除法和取模运算的特点。最后,我们引入了变量的概念,它帮助我们存储和复用计算结果,让程序变得更加简洁和健壮。

通过控制台程序,我们掌握了程序与用户交互、处理数据的基本模式,这是学习所有更复杂编程概念的重要基石。

课程 05:控制语句 🎮

在本节课中,我们将学习如何让程序变得更加灵活和智能。我们将重点介绍如何从用户那里获取输入,以及如何使用条件判断(if/else)和循环(for)来控制程序的执行流程。这些是构建交互式程序的基础。


交互式程序与用户输入 💬

上一节我们介绍了变量,它们用于存储数据。但一个每次运行都做同样事情的程序并不实用。本节中,我们来看看如何让程序根据用户的输入做出不同的反应。

一个交互式程序会向用户提问,等待用户输入信息(例如一个数字),然后将这个值存储到变量中供程序后续使用。这避免了每次都需要修改源代码的麻烦。

在Java中,我们可以使用 readIntreadDoublereadBoolean 等命令来获取用户输入。它们的语法有些特别。

int subtotal = readInt("小计是多少?");

当程序执行到 readInt 这一行时,它会暂停,在控制台显示提示信息,并等待用户输入一个整数。用户输入的数字会被“粘贴”到代码中,相当于直接给变量 subtotal 赋值。之后,程序的剩余部分就会使用这个新值来运行。

核心概念readInt("提示信息") 会暂停程序,等待用户输入一个整数,并将该值返回。


关系运算符与条件判断 ⚖️

我们已经见过 if 语句,它让程序可以根据条件选择执行不同的代码块。现在,我们来更深入地了解可以放在 if 条件里的各种测试。

我们经常需要根据变量的值来提问。例如,询问用户年龄后,根据年龄打印不同的消息。这需要使用关系(比较)运算符

以下是常用的关系运算符:

  • < (小于):10 < 5 的结果是 false
  • > (大于):10 > 5 的结果是 true
  • <= (小于或等于):5 <= 5 的结果是 true
  • >= (大于或等于):10 >= 5 的结果是 true
  • == (等于):10 == 5 的结果是 false。注意,比较相等需要使用两个等号。
  • != (不等于):10 != 5 的结果是 true

注意:单个等号 = 用于给变量赋值,所以比较相等必须用双等号 ==

这些运算符的结果是 true(真)或 false(假),这被称为布尔值if 语句会根据这个布尔值来决定是否执行其代码块。


嵌套的 if/else 结构 🌳

有时我们需要进行一系列互斥的测试(例如成绩评级)。如果简单地连续使用多个 if 语句,可能会导致程序打印出多条消息,因为每个条件都会被独立检查。

以下是实现互斥测试的正确结构——嵌套的 if/else

if (score >= 90) {
    println("A");
} else if (score >= 80) {
    println("B");
} else if (score >= 70) {
    println("C");
} else {
    println("D");
}

这种结构的执行逻辑是:从上到下依次检查条件,只执行第一个true 的条件所对应的代码块,然后直接跳到整个结构结束。else 块用于处理所有前面条件都不满足的情况。

重要区别

  • else 结尾:保证会执行其中一个分支。
  • else if 结尾:有可能所有条件都不满足,从而不执行任何分支。

逻辑运算符:与、或、非 🔗

我们可以在条件测试中组合多个问题,这就需要用到逻辑运算符

以下是三种基本逻辑运算符:

  • && (逻辑与):要求两边条件都为 true,结果才为 true。例如:(2 == 3) && (1 < 5) 结果为 false
  • || (逻辑或):要求至少一边条件为 true,结果就为 true。例如:(2 == 3) || (1 < 5) 结果为 true
  • ! (逻辑非):将布尔值反转。truefalsefalsetrue。例如:!(2 == 3) 结果为 true

这些运算符可以帮你构建更复杂的条件。例如,一个“约会决策程序”可能要求对方同时满足多个条件(使用 &&),或者至少满足一个关键条件(使用 ||)。


深入 for 循环与增量运算符 🔄

之前我们学习了 for 循环来重复执行代码。现在,我们来仔细看看它的工作原理以及循环内部变量的变化。

一个 for 循环的头部由三部分组成:

  1. 初始化:在循环开始时执行一次(例如 int i = 0)。
  2. 测试条件:每次循环开始前检查,如果为 true 则执行循环体(例如 i < 5)。
  3. 更新:每次循环体执行完毕后运行(例如 i++)。

增量运算符 ++ 是一个常见更新操作。i++ 等价于 i = i + 1,意思是将变量 i 的值增加 1。同样,还有 +=-= 等简便写法。

for (int i = 0; i < 5; i++) {
    println(i + "...");
}
// 输出:0... 1... 2... 3... 4...

在循环体内,我们可以使用循环变量 i 的值。循环变量就像其他变量一样,可以被读取和用于计算。


累积循环模式 📊

一个非常实用的编程模式是累积循环,用于计算一系列值的总和(或其他累积结果)。

其关键在于:将存储结果的变量(如 sum)声明在循环之外,在循环内部不断修改它的值。

int sum = 0; // 在循环外声明并初始化
for (int i = 1; i <= 1000; i++) {
    sum += i; // 在循环内不断累加
}
println("总和是:" + sum);

为什么要在循环外声明? 如果在循环内声明 sum = 0,那么每次循环都会将 sum 重置为 0,无法实现累积效果。

这个模式可以用于解决很多问题,例如:求一个数的所有因数之和。思路是循环遍历所有可能的因数,如果整除(余数为0),则将该数加到累积总和中。


总结 🎯

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

  1. 使用 readInt 等命令创建交互式程序,从用户获取输入。
  2. 利用关系运算符<, >, == 等)在条件语句中进行比较。
  3. 使用嵌套的 if/else 结构来实现一系列互斥的选择。
  4. 逻辑运算符&&, ||, !)组合多个条件。
  5. 剖析了 for 循环的执行步骤和增量运算符 ++ 的作用。
  6. 掌握了累积循环这一常见模式,用于计算总和等任务。

这些控制语句是编程的核心,它们让程序能够做出决策、重复任务并处理动态的输入,从而变得真正有用和强大。

课程06:更多循环、常量与随机数 🎯

在本节课中,我们将深入学习循环的更多用法,并介绍常量与随机数的概念。我们将通过具体的代码示例,帮助你理解如何在实际编程中应用这些知识。

概述

本节课将涵盖以下核心内容:哨兵循环、嵌套循环、变量的作用域、类常量以及如何在Java中生成随机数。我们将通过编写和修改代码来巩固这些概念。


哨兵循环 🚩

上一节我们介绍了基础的累积循环。本节中,我们来看看一种特殊的循环模式——哨兵循环。哨兵循环会持续运行,直到用户输入一个特定的“哨兵值”来终止循环。

以下是一个示例程序,它要求用户输入一系列数字,当输入数字0时,程序停止并计算所有已输入数字的总和。

import acm.program.*;

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/04b25c28ea5f0c70a295b609f4a0712c_14.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/04b25c28ea5f0c70a295b609f4a0712c_16.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/04b25c28ea5f0c70a295b609f4a0712c_18.png)

public class Sentinel extends ConsoleProgram {
    public void run() {
        println("This program adds a list of numbers.");
        println("Enter values, one per line, using " + SENTINEL);
        println("to signal the end of the list.");
        int total = 0;
        int value = readInt(" ? ");
        while (value != SENTINEL) {
            total += value;
            value = readInt(" ? ");
        }
        println("The total is " + total + ".");
    }
    private static final int SENTINEL = 0;
}

关键点分析:

  1. 我们使用 while (value != SENTINEL) 作为循环条件。
  2. 在循环开始前,我们需要先读取一次用户输入来初始化 value 变量。
  3. 在循环体内,我们累加总和,并再次读取用户输入以更新 value,为下一次条件判断做准备。
  4. 这种“先读取一次,再在循环内读取”的模式,是解决“栅栏柱问题”的常见策略。

嵌套循环 🔄

理解了单层循环后,我们来看看循环的嵌套使用。嵌套循环是指在一个循环体内包含另一个完整的循环结构。

以下代码演示了如何使用嵌套循环打印一个矩形星号图案。

for (int i = 1; i <= 5; i++) {
    for (int j = 1; j <= 10; j++) {
        print("*");
    }
    println();
}

执行流程:

  1. 外层循环(i 循环)控制行数,这里会执行5次。
  2. 对于外层循环的每一次迭代,内层循环(j 循环)都会完整地执行10次,打印10个星号。
  3. 内层循环结束后,执行 println() 换行,然后开始外层循环的下一次迭代。

通过修改内层循环的边界条件,我们可以打印出不同的形状,例如三角形:

// 打印直角三角形
for (int i = 1; i <= 5; i++) {
    for (int j = 1; j <= i; j++) { // 内循环次数与行号 i 相关
        print("*");
    }
    println();
}


变量的作用域 🏷️

在编写包含多个方法或复杂循环的程序时,理解变量的作用域至关重要。作用域决定了变量在程序的哪些部分可以被访问。

核心规则: 在Java中,变量的作用域通常被限定在声明它的那一对花括号 {} 内。

以下是需要注意的几种情况:

  • 方法内声明的变量: 只能在该方法内部使用。
  • 循环内声明的变量: 只能在该循环内部使用。
  • 非重叠作用域: 在不同且不嵌套的作用域内,可以使用相同的变量名。

public void run() {
    // 变量`count`的作用域开始
    int count = 0;
    for (int i = 0; i < 10; i++) { // 变量`i`的作用域仅限于这个for循环
        count++;
    }
    // 这里可以访问`count`,但不能访问`i`
    // 变量`count`的作用域结束
}

限制作用域有助于保持代码模块化,防止不同部分的代码意外干扰。


类常量 📏

有时,我们希望一个值在程序的多个地方都能被使用,但又不想使用风格不佳的“全局变量”。这时,可以使用类常量

类常量使用 private static final 关键字声明。final 意味着它的值一旦被赋予就永不可更改,这保证了程序其他部分不会被意外修改。

public class Pattern extends GraphicsProgram {
    // 声明一个类常量
    private static final int SIZE = 8;

    public void run() {
        drawUpperPart();
        drawLowerPart();
        // 两个方法都可以安全地使用SIZE
    }

    private void drawUpperPart() {
        for (int i = 0; i < SIZE; i++) {
            // 使用SIZE绘制图案...
        }
    }
    private void drawLowerPart() {
        for (int i = 0; i < SIZE; i++) {
            // 使用SIZE绘制图案...
        }
    }
}

命名约定: 类常量的名称通常全部使用大写字母,并用下划线分隔单词(例如:MAX_VALUE),以便在代码中清晰识别。


生成随机数 🎲

让程序产生随机行为可以增加趣味性。在Java中,我们可以使用ACM库中的 RandomGenerator 类来轻松获取随机数。

基本语法如下:

RandomGenerator rg = RandomGenerator.getInstance();
int randomNum = rg.nextInt(min, max); // 生成一个在[min, max]区间内的随机整数

应用示例: 模拟掷两个骰子,直到点数和达到目标值。

import acm.program.*;
import acm.util.*;

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/04b25c28ea5f0c70a295b609f4a0712c_59.png)

public class RollTwoDice extends ConsoleProgram {
    public void run() {
        int targetSum = readInt("What sum do you want to roll? ");
        RandomGenerator rg = RandomGenerator.getInstance();

        int die1, die2, sum;
        do {
            die1 = rg.nextInt(1, 6);
            die2 = rg.nextInt(1, 6);
            sum = die1 + die2;
            println("Rolled: " + die1 + " and " + die2 + " = " + sum);
        } while (sum != targetSum);

        println("Got it!");
    }
}

代码说明:

  1. import acm.util.*; 引入了包含 RandomGenerator 的包。
  2. RandomGenerator.getInstance() 获取随机数生成器的实例。
  3. rg.nextInt(1, 6) 模拟了掷出1到6点的情况。
  4. 使用 do...while 循环确保至少掷一次骰子,并持续进行直到点数和等于目标值。


总结

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

  1. 哨兵循环:通过特定输入值控制循环终止。
  2. 嵌套循环:在循环内部使用循环,常用于处理多维数据或打印复杂图案。
  3. 变量作用域:理解了变量在程序中的可见性范围,这是编写清晰、健壮代码的基础。
  4. 类常量:使用 private static final 定义全局可访问且不可更改的值,提升了代码的可维护性和安全性。
  5. 随机数:利用 RandomGenerator 类为程序引入随机性,并应用于模拟掷骰子等场景。

掌握这些概念将极大地增强你解决复杂编程问题的能力。请务必通过课后练习和作业来巩固这些知识。

课程07:参数 📊

在本节课中,我们将要学习Java编程中一个非常核心且强大的概念——参数。参数允许我们编写更通用、更灵活、可重复使用的代码,从而消除冗余。我们将通过绘制不同尺寸的方框和计算复利投资等具体例子,来理解参数的工作原理和实际应用。


概述:从具体到通用

在之前的课程中,我们学习了如何编写执行特定任务的代码。例如,编写一段代码来绘制一个10x4的方框。然而,如果我们想绘制一个7x6的方框,就需要编写另一段非常相似但数字不同的代码。这种重复不仅低效,也使得代码难以维护。

参数的概念就是为了解决这个问题。它允许我们将代码中可变的部分(如方框的宽度和高度)提取出来,作为“输入”传递给一个通用的方法。这样,同一段代码就可以处理多种不同的情况。

接下来,我们将通过一个绘制方框的例子,逐步引入参数的概念。


绘制方框:冗余代码的问题

假设我们想用星号*在控制台绘制一个方框。首先,我们编写绘制一个10x4方框的代码。

绘制方框的逻辑可以分为三部分:

  1. 绘制顶部的一行星号。
  2. 绘制中间部分(星号、空格、星号)。
  3. 绘制底部的一行星号(与顶部相同)。

以下是绘制10x4方框的代码示例:

// 绘制顶部
for (int i = 0; i < 10; i++) {
    System.out.print("*");
}
System.out.println();

// 绘制中间部分 (高度为4,所以有2行中间行)
for (int i = 0; i < 2; i++) {
    System.out.print("*");
    for (int j = 0; j < 8; j++) { // 10 - 2 = 8个空格
        System.out.print(" ");
    }
    System.out.println("*");
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/e339ec51a601fbe9368fa415cd1f1e26_21.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/e339ec51a601fbe9368fa415cd1f1e26_23.png)

// 绘制底部 (与顶部相同)
for (int i = 0; i < 10; i++) {
    System.out.print("*");
}
System.out.println();

现在,如果我们需要绘制一个7x6的方框,我们可能会复制上面的代码,然后手动修改其中的数字(如将循环的10改为7,将8改为5,将中间行循环的2改为4)。这导致了代码的冗余。

上一节我们看到了编写具体功能代码的局限性,本节中我们来看看如何通过变量来初步实现通用性。


使用变量实现初步通用化

我们可以使用变量来代表方框的宽度和高度,从而让同一段代码能够绘制不同尺寸的方框。

以下是使用变量widthheight的通用版本:

int width = 10;
int height = 4;

// 绘制顶部
for (int i = 0; i < width; i++) {
    System.out.print("*");
}
System.out.println();

// 绘制中间部分
for (int i = 0; i < height - 2; i++) { // 中间行数 = 总高度 - 2 (顶部和底部)
    System.out.print("*");
    for (int j = 0; j < width - 2; j++) { // 中间空格数 = 总宽度 - 2 (两边的星号)
        System.out.print(" ");
    }
    System.out.println("*");
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/e339ec51a601fbe9368fa415cd1f1e26_39.png)

// 绘制底部
for (int i = 0; i < width; i++) {
    System.out.print("*");
}
System.out.println();

通过更改变量widthheight的值(例如改为7和6),同一段代码就可以绘制不同尺寸的方框。这比复制粘贴代码前进了一大步。

然而,这段绘制方框的代码仍然直接写在主程序(如run方法)里。如果我们想在程序的不同位置绘制多个不同尺寸的方框,或者想清晰地组织代码,最好的方式是将绘制方框的逻辑封装成一个独立的方法。


引入方法:封装功能

我们希望创建一个名为drawBox的方法,专门负责绘制方框。这样,主程序只需要调用这个方法即可。

我们最初的尝试可能是这样的:

public void drawBox() {
    // ... (将上面使用变量的绘制代码放在这里)
}

public void run() {
    drawBox(); // 希望绘制一个方框
}

但这里遇到了一个问题:drawBox方法内部的代码需要知道widthheight的值,而这些变量是在run方法中声明和赋值的。在Java中,一个方法内部不能直接访问另一个方法中声明的变量(这涉及到变量的“作用域”概念)。

为了解决这个问题,我们需要一种机制,让run方法在调用drawBox时,能够把widthheight的值“告诉”它。这种机制就是参数


参数:向方法传递信息

参数允许我们在调用方法时,向方法内部传递数据。定义和使用参数需要两个匹配的步骤:

  1. 声明方法时:在方法名后的括号内,声明需要接收的参数变量及其类型。这就像是告诉Java:“我这个方法工作需要这些信息,请调用者提供。”
  2. 调用方法时:在方法名后的括号内,提供具体的值或表达式。这些值会按照顺序传递给方法内部声明的参数变量。

以下是使用参数改造后的drawBox方法:

// 1. 声明方法时定义参数
public void drawBox(int width, int height) {
    // 方法内部可以直接使用参数变量 width 和 height
    // ... (绘制方框的通用代码,使用width和height变量)
}

public void run() {
    // 2. 调用方法时传递参数值
    drawBox(10, 4); // 绘制10x4的方框
    drawBox(7, 6);  // 绘制7x6的方框

    // 也可以传递变量或表达式
    int w = 15;
    int h = 3;
    drawBox(w, h);
    drawBox(w * 2, h + 1);
}

执行过程:当程序执行到drawBox(10, 4);时,Java会跳转到drawBox方法,并将数值10赋值给参数变量width,将数值4赋值给参数变量height,然后执行方法体内的代码。执行完毕后,返回到run方法继续执行下一行。

通过参数,我们成功创建了一个高度通用化的drawBox方法,可以在任何需要的地方调用它来绘制任意尺寸的方框。


深入理解:参数与用户输入

参数和之前学过的用户输入(如readInt)都是为程序提供数据的方式,但它们的来源和用途不同:

  • 参数:通常由程序员在编写代码时决定传递什么值。它用于在程序内部的不同方法之间传递数据,使方法变得通用。
  • 用户输入:在程序运行时由用户决定输入什么值。它用于让程序与外部用户交互。

两者可以结合使用,创造出更灵活的程序。例如,我们可以先通过用户输入获取尺寸,再将这个值作为参数传递给绘制方法:

public void run() {
    int w = readInt("请输入宽度: ");
    int h = readInt("请输入高度: ");
    drawBox(w, h); // 将用户输入的值作为参数传递
}


实践案例:投资利润计算器

为了巩固对参数的理解,我们来看一个更复杂的例子:编写一个计算复利投资利润的程序。

首先,我们编写处理单个投资者的代码流程:

  1. 获取初始金额、利率和月份数。
  2. 通过循环计算复利后的最终金额。
  3. 计算利润百分比。
  4. 根据利润百分比打印投资评价(如“弱”、“中”、“强”)。

以下是计算最终金额的核心循环逻辑:

double finalAmount = initialAmount; // 从初始金额开始
for (int i = 0; i < months; i++) {
    // 每月增加当月利息:当前金额 * 月利率
    finalAmount += finalAmount * (interestRate / 100);
}
double profit = finalAmount - initialAmount;
double profitPercentage = (profit / initialAmount) * 100;

现在,如果程序需要处理多个投资者,我们不应该复制粘贴整段代码。相反,我们应该创建一个通用的processInvestor方法。

以下是需要传递给该方法的信息(参数):

  • 投资者编号(用于提示用户)
  • 不需要传递初始金额等,因为它们可以通过方法内的readDouble获取,但利润计算和评价逻辑可以独立成方法。

我们可以创建两个方法:

  1. processInvestor(int investorNumber): 处理单个投资者的完整流程,包括输入和计算。
  2. printInvestmentQuality(double profitPercentage): 根据利润百分比打印评价。这个方法接收一个profitPercentage参数。

public void printInvestmentQuality(double profitPercentage) {
    if (profitPercentage < 10) {
        System.out.println("投资评价: 弱");
    } else if (profitPercentage < 50) {
        System.out.println("投资评价: 中");
    } else {
        System.out.println("投资评价: 强");
    }
}

public void processInvestor(int investorNumber) {
    System.out.println("--- 投资者 " + investorNumber + " ---");
    double initialAmount = readDouble("请输入初始金额: ");
    double interestRate = readDouble("请输入年利率 (%): ");
    int months = readInt("请输入月份数: ");

    // 计算复利
    double finalAmount = initialAmount;
    for (int i = 0; i < months; i++) {
        finalAmount += finalAmount * (interestRate / 100 / 12); // 将年利率转为月利率
    }

    double profitPercentage = ((finalAmount - initialAmount) / initialAmount) * 100;
    System.out.printf("最终金额: $%.2f\n", finalAmount);
    System.out.printf("利润百分比: %.2f%%\n", profitPercentage);

    // 调用方法,传递利润百分比作为参数
    printInvestmentQuality(profitPercentage);
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/e339ec51a601fbe9368fa415cd1f1e26_69.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/e339ec51a601fbe9368fa415cd1f1e26_71.png)

public void run() {
    processInvestor(1);
    processInvestor(2);
}

通过这个例子,我们可以看到参数如何帮助我们将一个复杂程序分解成多个逻辑清晰、职责单一的小方法,并通过参数在这些方法之间传递必要的数据,从而大大提高了代码的可读性和可维护性。


总结

在本节课中,我们一起学习了Java中参数的核心概念与应用。

我们首先从绘制特定尺寸方框的冗余代码出发,认识到编写通用代码的必要性。接着,通过引入变量,我们初步实现了代码的通用化。然后,为了更好地组织代码和实现功能复用,我们学习了如何将功能封装成方法

为了解决方法间数据传递的问题,我们深入探讨了参数机制。我们了解到:

  • 参数在方法声明时定义,指定需要接收的数据类型和变量名。
  • 参数在方法调用时传递,提供具体的值,这些值会被赋值给对应的参数变量。
  • 参数使得方法变得灵活和通用,同一段代码可以处理不同的输入数据。

最后,通过投资计算器的综合案例,我们实践了如何使用参数来分解复杂问题、在方法间传递数据,并区分了参数与用户输入的不同角色。

掌握参数是成为高效Java程序员的关键一步,它将帮助你从编写单一功能的脚本,转向构建结构良好、易于扩展的复杂程序。


课程08:返回值与布尔逻辑 🔄

在本节课中,我们将学习两个核心概念:返回值布尔逻辑。返回值允许方法将计算结果发送回调用它的地方,而布尔逻辑则用于处理真/假判断。掌握这两个概念对于编写灵活、强大的程序至关重要。


返回值:从方法中“带回”结果

上一节我们介绍了参数,它允许我们将信息“发送”给方法。本节中我们来看看返回值,它恰恰相反,允许方法将信息“发送回”调用它的地方。

什么是返回值?

返回值是指一个方法执行完毕后,将一个计算结果“带回”到调用它的代码位置。这与我们之前使用的void方法不同,void方法只执行操作,不返回任何结果。

为什么需要返回值?

想象一下,你有一个计算数学公式的方法。如果它只是将结果打印到屏幕上,那么这个结果就无法被程序的其他部分再次使用。但如果它返回这个结果,你就可以将其存储在变量里,用于后续的计算、比较或任何其他操作,这使得代码更加模块化和灵活。

如何使用返回值?

要编写一个返回值的方法,需要在方法声明时指定返回的数据类型(如intdoubleboolean),而不是void。在方法内部,使用return语句来发送回结果。

代码示例:计算斜率

public double slope(int x1, int y1, int x2, int y2) {
    double dy = y2 - y1;
    double dx = x2 - x1;
    double result = dy / dx;
    return result; // 将计算结果返回
}

调用这个方法时,可以这样使用:

double s = slope(1, 2, 8, 4); // s 将存储计算出的斜率值

重要注意事项

以下是关于返回值需要理解的几个关键点:

  • 返回值是“值”,不是“变量名”return result; 返回的是result变量中存储的数值(例如0.33),而不是result这个名字本身。调用处的代码需要自己决定如何接收和使用这个值。
  • 一个方法只能返回一个值:这与参数不同,参数可以有多个,但返回值只能有一个。如果需要返回多个相关数据,可以考虑将它们组合成一个对象再返回。
  • return是方法的出口:执行return语句后,方法会立即结束,其后的代码不会被执行。
  • 你已经用过返回值:例如,readInt(“Enter a number: “)rg.nextInt(1, 10) 这些库方法都会返回一个值,你将其存储在变量中(如int n = readInt(...)),这正是在使用返回值。


实践:改造投资利润计算程序

让我们运用返回值来解决一个实际问题。假设我们有一个计算投资利润的程序,现在需要比较两项投资的利润差。

问题:原程序中的investor方法直接打印利润,导致主程序无法获取利润数值来进行比较。
解决方案:修改investor方法,使其从void改为返回double类型,并在方法末尾return profit;。这样,主程序就可以调用该方法并获得利润值。

修改后的核心逻辑

// 在run方法中
double profit1 = investor(); // 调用第一个投资者的计算,并接收返回的利润值
double profit2 = investor(); // 调用第二个投资者的计算
double difference = Math.abs(profit1 - profit2); // 使用Math.abs()确保差值为正
println(“利润差额为:$” + difference);

通过这个改造,我们成功地将计算逻辑(investor方法)与结果的使用逻辑(比较和打印)分离开,程序结构更清晰,功能也更强大。


布尔逻辑与布尔类型 ⚖️

接下来,我们学习布尔逻辑。布尔逻辑是程序进行判断和决策的基础。

布尔数据类型

Java中有一个名为boolean的基本数据类型。它只有两个可能的值:true(真) 和 false(假)。它的主要用途是存储逻辑测试的结果。

布尔变量

你可以将逻辑表达式的结果存储在一个布尔变量中。这样做的好处是让代码更易读,尤其是当某个逻辑条件很复杂或需要多次使用时。

代码示例

boolean isMinor = (age < 21);
boolean isFull = (students >= capacity);
// 后续可以清晰地在if语句中使用
if (isMinor || isProfessor || !likesCS) {
    println(“不能进入俱乐部。”);
}

返回布尔值的方法

一个非常常见的模式是编写一个进行某种测试并返回布尔结果的方法。这类方法的名字通常像isEven(是偶数吗)、isPrime(是质数吗)。

代码示例:判断奇偶

public boolean isEven(int n) {
    return (n % 2 == 0); // 如果n除以2余0,返回true,否则返回false
}

使用这个方法可以让主代码非常清晰:

if (isEven(number)) {
    println(“这个数是偶数。”);
}

你已经在库方法中见过这种模式,例如frontIsBlocked()方法就返回一个boolean值。

布尔方法的简洁写法

对于返回布尔值的方法,可以利用逻辑表达式直接返回结果,使代码非常简洁。

示例:检查三个数是否均为奇数

// 详细写法
public boolean allOdd(int a, int b, int c) {
    boolean test = (a % 2 == 1) && (b % 2 == 1) && (c % 2 == 1);
    if (test == true) {
        return true;
    } else {
        return false;
    }
}
// 简洁的“禅意”写法
public boolean allOdd(int a, int b, int c) {
    return (a % 2 == 1) && (b % 2 == 1) && (c % 2 == 1);
}

实践:判断质数

让我们尝试编写一个isPrime方法来判断一个数是否为质数(只能被1和自身整除)。

思路

  1. 质数必须大于1。
  2. 对于大于1的数n,检查从2到n-1的所有整数。
  3. 如果发现任何一个数i能整除nn % i == 0),则n不是质数,立即返回false
  4. 如果循环结束都没有找到能整除的数,则n是质数,返回true

代码框架

public boolean isPrime(int n) {
    if (n <= 1) {
        return false; // 1及以下的数不是质数
    }
    for (int i = 2; i < n; i++) {
        if (n % i == 0) {
            return false; // 发现因子,不是质数
        }
    }
    return true; // 循环完毕未发现因子,是质数
}

请注意return false;在循环内部的位置,它允许我们一旦确定不是质数就立刻结束方法。循环后的return true;只有在所有可能因子都检查过后才会执行。


总结 🎯

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

  1. 返回值:允许方法将计算结果送回调用处。通过将方法声明中的void替换为具体类型(如double),并使用return语句,我们可以创建能嵌入表达式中的有用方法。
  2. 布尔逻辑boolean类型用于表示truefalse。我们可以创建布尔变量来存储逻辑测试结果,也可以编写返回布尔值的方法(如isEven, isPrime),这能让我们的条件判断代码读起来更清晰、更接近自然语言。

返回值和布尔逻辑是构建复杂程序逻辑的基石。理解它们需要一些练习,当你开始在自己的程序中主动使用它们时,你会发现代码的控制能力和表达能力都得到了显著提升。

课程09:字符串(Strings)📝

在本节课中,我们将要学习一种新的数据类型——字符串。字符串是编程中用于表示和处理文本的基础。我们将学习如何创建、操作字符串,以及如何使用printf命令来格式化输出。


格式化输出:printf命令

上一节我们介绍了基本的输出方法,本节中我们来看看一个更强大的格式化输出命令:printf

printf命令允许你以自定义的格式输出文本和数字,特别是控制小数点后的位数。其基本语法如下:

printf("格式化字符串", 变量1, 变量2, ...);

在格式化字符串中,使用占位符来指定插入变量的位置和格式。例如:

  • %d 用于整数。
  • %f 用于浮点数(小数)。

如果你想控制浮点数显示的小数位数,可以在%f中间加入.数字。例如,%.2f表示保留两位小数。

以下是一个使用printf格式化数字的例子:

double gpa = 3.14159;
System.out.printf("GPA 是:%.2f", gpa); // 输出:GPA 是:3.14

你还可以指定输出内容的总宽度,用于对齐文本。例如,%10.2f表示总宽度为10个字符,其中包含两位小数。


字符串基础

字符串是由零个或多个字符组成的序列,用双引号括起来。在Java中,字符串是一种对象,拥有许多内置的方法供我们调用。

你可以声明一个字符串变量并赋值:

String message = "Hello, World!";

字符串中的每个字符都有一个索引位置,索引从0开始。例如,在字符串"Hello"中,'H'的索引是0,'o'的索引是4。


字符串的常用方法

字符串对象可以通过“点”操作符调用各种方法。以下是几个最常用的方法:

  • .length():返回字符串的字符个数。
  • .toUpperCase() / .toLowerCase():将字符串转换为全大写或全小写。
  • .substring(start, end):提取从索引start开始,到索引end(不包括end)之间的子字符串。
  • .indexOf(str):返回指定子字符串str首次出现的索引位置。
  • .equals(str):比较两个字符串的内容是否完全相同。注意:在Java中比较字符串内容是否相等必须使用.equals(),而不是==

需要特别注意:像.toUpperCase()这样的方法不会修改原始字符串,而是返回一个新的字符串。如果你想改变原变量,需要将结果赋值给它:

String name = "martin";
name = name.toUpperCase(); // 现在 name 的值是 "MARTIN"


读取用户输入的字符串

我们可以使用readLine方法从控制台读取用户输入的一整行文本,并将其存储为字符串。

String userName = readLine("请输入你的名字:");

字符串累积算法

我们可以像累积数字一样,通过循环来构建或修改一个字符串。通常从一个空字符串""开始,然后使用+=运算符在循环中不断连接新的内容。

例如,以下方法将一个字符串中的每个字符重复一次:

String stutter(String s) {
    String result = ""; // 初始化一个空字符串用于累积结果
    for (int i = 0; i < s.length(); i++) {
        String letter = s.substring(i, i + 1); // 获取当前索引的字符
        result += letter + letter; // 将该字符连接两次到结果中
    }
    return result; // 返回累积完成的新字符串
}


字符(char)类型

Java中还有一个与字符串密切相关的原始数据类型:char(字符)。它用于表示单个字符,使用单引号括起来。

char grade = 'A';

字符串和字符的主要区别在于:

  1. 字符用单引号,字符串用双引号。
  2. 字符是基本类型,字符串是对象类型。
  3. 可以对字符使用==<>等比较运算符,这对字符串无效。

你可以从字符串中获取指定位置的字符:

String str = "Hello";
char firstChar = str.charAt(0); // firstChar 的值为 'H'

由于字符在计算机内部对应着数字(ASCII码),你可以对其进行算术运算。一个简单的应用是凯撒密码(位移加密):

String message = "hello";
String encoded = "";
for (int i = 0; i < message.length(); i++) {
    char ch = message.charAt(i);
    ch++; // 将字符向后移动一位(例如 ‘a’ 变成 ‘b’)
    encoded += ch;
}
System.out.println(encoded); // 输出:ifmmp

总结

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

  1. 使用printf命令进行格式化输出,特别是控制浮点数的小数位数。
  2. 字符串的基本概念、创建和索引规则。
  3. 调用字符串的常用方法,如获取长度、转换大小写、提取子串和比较内容。
  4. 从控制台读取字符串输入。
  5. 实现字符串累积算法来构建新的字符串。
  6. 了解了char字符类型及其与字符串的区别,并看到了字符运算的一个简单应用——凯撒密码。

掌握字符串的操作是处理文本数据的关键,在未来的编程任务中会经常用到。

课程01:CS106B C++抽象编程入门 🚀

在本节课中,我们将学习CS106B课程的基本信息、课程政策以及C++编程语言的初步介绍。课程将涵盖从课程结构到编写第一个简单C++程序的所有内容。

课程概述与政策 📋

上一节我们介绍了课程的基本框架,本节中我们来看看具体的课程安排和需要遵守的规则。

课程网站是 CS106B.stanford.edu。所有课程资料,包括讲义、作业和程序,都会在课后发布在该网站上。

讲师的名字是Martita,联系邮箱是 step@cs.stanford.edu。课程有两位助教:Amy和Ashley,他们将负责主持讨论环节。

课程讨论部分由课程负责人(Section Leaders)组织,他们是上过106课程的本科生。每周他们会组织一次50分钟的讨论课,学生以10-12人为一组进行练习。参与讨论课可以获得分数。

以下是关于课程选择的一些要点:

  • 如果你已经上过106A,那么106B可能是适合你的课程。
  • 106B假设学生具备变量、数据类型、if语句、循环、方法、参数、返回、数组等基础知识。
  • 106B使用C++语言,而106A使用Java。
  • 106X是106B的进阶版本,适合已有丰富编程经验的学生。
  • 还有一门1学分的实验课程106L,专注于深入讲解C++语言本身。

课程教材是《Programming Abstractions in C++》,由Eric Roberts教授编写。教材的PDF版本可在课程网站免费获取。考试是闭卷考试,但允许携带教材纸质版进入考场。

本季度共有7次作业,每次作业大约有一周或一周半的完成时间。大多数作业允许与一名伙伴合作完成。

作业评分采用“检查制”(Check System):

  • 一个完全正确的程序会获得“Check”。
  • 一个良好但仍有改进空间的程序会获得“Check +”。
  • 一个需要大量工作的程序会获得“Check -”。

整个季度学生共有4个“迟到单元”可以使用,用于免除作业迟交的处罚。一个“迟到单元”相当于一次课(约两天)的延期。

课程成绩由作业/参与度(占50%)和考试(占50%)共同决定。期末成绩会映射到A、B、C等等级,评分曲线相对宽松。

编程作业将使用名为“Qt Creator”的集成开发环境(IDE)。学生需要按照课程网站上的指引安装此软件。

学生可以通过多种渠道获得帮助:课程负责人会在“The Nest”实验室提供帮助、课程论坛(Piazza)、教师的办公时间以及助教的办公时间。

课程严格遵守荣誉准则(Honor Code)。这意味着学生必须独立完成作业,不得抄袭他人代码或从互联网上复制解决方案。课程会使用软件检测代码相似度。如果学生不慎提交了包含非原创代码的作业,可以立即联系课程负责人要求撤回该次提交,但这会导致该次作业得零分。

C++编程初体验 💻

上一节我们了解了课程的各项规定,本节中我们来看看如何开始编写C++程序。

C++语言诞生于1983年,是一种接近硬件的系统编程语言,广泛应用于操作系统、游戏开发等领域。它与Java在语法上有很多相似之处。

一个基本的C++程序结构如下:

// 注释语法与Java/JavaScript相同
#include <iostream>
using namespace std;

int main() {
    cout << "Hello, world!" << endl;
    return 0;
}
  • #include 语句类似于Java的import,用于引入库。尖括号<>用于引入系统库,引号""用于引入项目本地库。
  • using namespace std; 使得我们可以直接使用标准库中的名称(如cout),而不必每次都写std::cout
  • int main() 是程序的入口函数。
  • cout 是用于向控制台输出的对象。<< 是输出操作符,用于连接要输出的内容。
  • endl 表示换行。
  • return 0; 是主函数的返回语句,向操作系统返回一个状态码。

程序文件通常以.cpp为扩展名。C++代码需要先编译成机器码(在Windows上生成.exe文件,在Mac/Linux上生成无扩展名的可执行文件),然后才能运行。

在C++中,变量声明必须指定类型,例如:

int age = 29;       // 整数
double price = 19.99; // 浮点数
bool isReady = true; // 布尔值

控制流语句(如ifwhilefor循环)的语法与Java/JavaScript非常相似。

为了简化输入操作并使其更健壮,课程提供了斯坦福C++库。例如,使用getInteger函数可以确保用户输入的是一个有效的整数:

#include “simpio.h” // 引入斯坦福输入输出库
#include <iostream>
using namespace std;

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/d0f2dae0872738aa1431d1f6e17fa129_41.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/d0f2dae0872738aa1431d1f6e17fa129_43.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/d0f2dae0872738aa1431d1f6e17fa129_45.png)

int main() {
    int age = getInteger(“How old are you? “);
    cout << “Marty wishes he were “ << age << “!” << endl;
    return 0;
}

getInteger函数会持续提示用户,直到输入合法的整数为止。

函数用于组织代码、避免冗余和提高可读性。在C++中定义函数的方式如下:

void printSong() {
    cout << “Now this is the story all about how” << endl;
    cout << “My life got flipped turned upside down” << endl;
}

然后可以在main函数中调用它:printSong();。需要注意的是,在C++中,函数需要在使用前声明或定义。

关于Qt Creator编辑器,它提供了一些便捷功能,例如:

  • Ctrl+R 可以编译并运行程序。
  • 右键点击函数名,选择“Follow Symbol Under Cursor”可以跳转到该函数的定义处查看源代码。

总结 📝

本节课中我们一起学习了CS106B课程的基本信息和政策,包括课程网站、评分标准、作业规范和荣誉准则。同时,我们也初步接触了C++编程,学习了程序的基本结构、输入输出方法以及如何使用斯坦福库来简化编程。请务必在下次课前安装好Qt Creator,为接下来的编程实践做好准备。

课程10:递归回溯与八皇后问题 👑

在本节课中,我们将深入学习递归回溯算法,并通过解决“子集生成”和经典的“八皇后”问题来实践这一概念。我们将重点关注如何构建递归解决方案,并避免一种称为“臂长递归”的不良编程风格。

概述 📋

递归回溯是一种强大的算法设计范式,它通过尝试所有可能的选择来寻找问题的解决方案。当一条路径失败时,算法会“回溯”到上一个决策点,尝试其他选项。本节课我们将通过具体例子来掌握其核心思想。

关于学术诚信的提醒 ⚠️

在开始课程内容之前,需要提醒关于学术诚信的重要性。课程使用相似性检查系统来查找违反荣誉准则的行为。绝大多数同学没有风险,但需要明确:分享、复制或粘贴他人的作业解决方案(包括来自网络或往届学生)是不被允许的。如果你在作业中遇到困难,我们鼓励你向助教或讲师寻求帮助。如果你已经提交了非独立完成的作业,可以选择“作业撤销”选项,这将导致该作业得零分,但不会面临更严重的纪律处分。与伙伴合作应在规定范围内进行,共同讨论思路,而非直接分享代码。


臂长递归:一种应避免的风格 🚫

上一节我们介绍了递归的基本概念,本节中我们来看看一种需要避免的递归实现风格——“臂长递归”。

这是一种低效的编程风格,程序员因为担心进行不必要的递归调用,而在调用前添加了大量冗余的条件检查。这通常导致代码复杂、重复且不优雅。

迷宫问题的对比

以我们之前实现的迷宫逃脱函数为例。良好的递归实现会先进行递归调用,然后在函数开头集中处理所有“失败”的基础情况(如碰到墙或已访问过的格子)。

相比之下,臂长递归的实现方式则会在每次递归调用前,都重复检查目标方向是否有效。代码如下所示(请勿模仿):

// 臂长递归的糟糕示例 - 在每次递归调用前都进行检查
if (right is within bounds && grid[right] is not a wall && !visited[right]) {
    explore(right);
}
if (left is within bounds && grid[left] is not a wall && !visited[left]) {
    explore(left);
}
// ... 对其他方向重复类似检查

核心问题:这段代码的缺点是重复了多次相同的条件判断。每个递归调用应该只关心自己当前所处的状态,而不是替它的“子调用”操心。基础情况的检查应该集中在函数开头,让无效的调用自己快速返回。

正确理念:你的递归函数应该勇敢地进行调用,让被调用的函数自己去处理它那一部分问题(包括判断自身是否处于无效状态)。这种设计更加清晰,减少了代码冗余。


子集生成问题 🔄

现在,让我们应用递归回溯来解决一个新的问题:生成一个集合的所有可能子集。

给定一个字符串向量(例如 {"Jane", "Bob", "Matt", "Sara"}),目标是打印出该向量所有可能的子集。注意,子集的顺序不重要。

从排列问题中获得启发

回想我们之前解决的排列问题,其代码结构是:选择一个元素,放入“已选”集合,递归处理剩余元素,然后取消选择。

然而,子集问题与排列问题有本质区别。排列关注元素的顺序,而子集关注元素的成员资格(是否在集合中)。

设计思路

对于子集问题,每个递归调用的责任是处理当前索引的一个特定元素,并决定包含排除它。

以下是实现的关键步骤:

  1. 定义辅助函数:我们需要一个辅助函数来跟踪原始列表、当前处理索引以及当前已构建的子集。
  2. 基本情况:当处理完所有元素(当前索引越界)时,打印当前已构建的子集。
  3. 递归情况:对于当前元素,我们有两个选择:
    • 选择一:包含当前元素。将其加入“已选”列表,然后递归处理下一个元素。
    • 选择二:排除当前元素。直接递归处理下一个元素,不修改“已选”列表。
  4. 回溯:在“包含”分支的递归调用返回后,需要将当前元素从“已选”列表中移除,以恢复到之前的状态,从而尝试“排除”分支。

代码框架

void sublistHelper(Vector<string>& v, int index, Vector<string>& chosen) {
    // 基本情况:处理完所有元素
    if (index == v.size()) {
        cout << chosen << endl;
        return;
    }
    // 递归情况
    string element = v[index];
    // 选择1:包含 element
    chosen.add(element);               // 选择
    sublistHelper(v, index + 1, chosen); // 探索
    chosen.removeBack();               // 取消选择(回溯)
    // 选择2:排除 element
    sublistHelper(v, index + 1, chosen); // 直接探索(不包含)
}

要点:注意这个问题的递归结构中没有for循环。这是因为在每个决策点,我们只有两个固定的选择(包含/排除),而不是在一组动态选项中进行迭代。理解何时需要循环,何时不需要,是掌握递归回溯的关键。


八皇后问题 ♟️

最后,我们来挑战经典的“八皇后问题”:在一个8x8的国际象棋棋盘上放置8个皇后,使得它们彼此之间无法相互攻击(即不能处于同一行、同一列或同一对角线上)。

优化搜索策略

最朴素的回溯法是尝试在棋盘的64个格子中逐一放置皇后,但这样搜索空间太大。我们可以利用一个关键观察进行优化:由于每一列最终必须有一个皇后,我们可以按列来放置皇后

这样,每个递归调用的责任就是:在指定的某一列中,找到一个安全的位置(行)来放置皇后。

算法设计

  1. 函数定义solveQueens(Board& board, int col)
    • board:表示当前棋盘状态的对象,应能放置皇后、移除皇后、检查某个位置是否安全。
    • col:当前函数调用需要处理的列号。
  2. 基本情况:如果col超过了棋盘的最大列数(例如 >= 8),说明所有皇后都已成功放置,打印或记录当前棋盘解决方案。
  3. 递归情况:在当前列col中,遍历所有可能的行(0到7)。
    • 对于每一行row,检查位置(row, col)是否安全(即不与已放置的任何皇后冲突)。
    • 如果安全,则在该位置放置一个皇后(做出选择)。
    • 然后递归调用solveQueens(board, col + 1),尝试在下一列放置皇后(进行探索)。
    • 递归调用返回后,移除刚才在(row, col)放置的皇后(取消选择,回溯),以尝试当前列的其他行。

代码框架

bool solveQueens(Board& board, int col) {
    // 基本情况:所有皇后都已放置
    if (col >= board.size()) {
        cout << board.toString() << endl; // 找到一个解
        return true; // 如果只找一个解就返回true
    }
    // 尝试当前列的每一行
    for (int row = 0; row < board.size(); row++) {
        if (board.isSafe(row, col)) { // 检查是否安全
            board.placeQueen(row, col); // 选择
            if (solveQueens(board, col + 1)) { // 探索
                return true; // 如果找到解,提前返回
            }
            board.removeQueen(row, col); // 取消选择(回溯)
        }
    }
    return false; // 当前列的所有行都尝试过,未找到解
}

通过这种按列放置的策略,我们极大地缩减了搜索空间,使得算法可以在合理的时间内找到解决方案。


总结 🎯

本节课我们一起深入学习了递归回溯算法:

  1. 我们首先了解了应避免的“臂长递归”风格,它通过添加不必要的预检查使代码变得冗余。
  2. 接着,我们解决了子集生成问题,学习了当每个决策点只有固定几个选择(如包含/排除)时,递归代码可能不需要循环。
  3. 最后,我们探讨了经典的八皇后问题,实践了如何通过优化搜索策略(按列放置)来高效地应用回溯法,并理解了选择、探索、取消选择(回溯)这一完整流程在复杂问题中的应用。

递归回溯的核心在于系统地枚举所有可能性,并在遇到死胡同时优雅地退回上一步。掌握这一技巧,你将能解决许多复杂的组合搜索问题。

课程11:C++中的类与数组实现栈 🧱

在本节课中,我们将学习如何在C++中定义类,并利用数组这一基础数据结构来实现一个简单的栈(Stack)集合。我们将从面向对象编程的基本概念讲起,逐步构建一个功能完整的栈。


课程概述 📋

上一节我们探讨了递归与回溯算法。本节中,我们将转向一个新的主题:数据结构的底层实现。我们将学习C++中“类”(Class)的概念,这是构建自定义数据类型的基础。然后,我们将运用这些知识,使用数组来实现一个我们熟悉的集合——栈。

第一部分:C++中的类与对象

在编程中,我们常常需要表示语言本身没有提供的实体,例如银行账户、日历事件或游戏角色。C++中的“类”允许我们创建这种新的数据类型。

一个“类”是对象的蓝图或模板。而“对象”是类的具体实例,它同时包含数据(称为成员变量或字段)和行为(称为成员函数或方法)。

类的组成

以下是定义一个类时的主要部分:

  • 成员变量:每个对象独有的数据。例如,一个BankAccount类可能有balance(余额)和ownerName(户主姓名)变量。
  • 成员函数:定义在对象上可以执行的操作。例如,BankAccount类可以有deposit(存款)和withdraw(取款)函数。
  • 构造函数:一种特殊的成员函数,在创建对象时自动调用,用于初始化对象的状态。

访问控制:公有与私有

在定义类时,我们需要决定哪些部分对外部代码可见。

  • 公有(public):标记为public的成员(变量或函数)可以被类外部的任何代码访问和调用。
  • 私有(private):标记为private的成员只能被类内部的成员函数访问。这实现了封装,是保护对象内部数据、防止被意外修改的关键机制。

通常,我们将所有成员变量设为私有,然后提供公有的成员函数(如getBalance)来安全地访问或修改它们。

类的代码结构

在C++中,一个类通常被拆分到两个文件中:

  1. 头文件(.h):用于声明类,包括其成员变量和成员函数的原型(仅声明,不写具体实现)。
  2. 源文件(.cpp):用于实现头文件中声明的所有成员函数的具体逻辑。

客户端代码(如main函数所在的文件)只需包含头文件(#include “ClassName.h”),即可使用这个类。

一个简单的BankAccount类示例

头文件 (BankAccount.h) 内容如下:

#ifndef BANKACCOUNT_H
#define BANKACCOUNT_H

#include <string>

class BankAccount {
public:
    // 构造函数
    BankAccount(std::string name, double initialBalance);
    // 成员函数
    void deposit(double amount);
    void withdraw(double amount);
    double getBalance() const;
    std::string getOwnerName() const;

private:
    // 成员变量
    std::string ownerName;
    double balance;
};

#endif

源文件 (BankAccount.cpp) 中实现成员函数:

#include “BankAccount.h”

BankAccount::BankAccount(std::string name, double initialBalance) {
    ownerName = name;
    balance = initialBalance;
}

void BankAccount::deposit(double amount) {
    balance += amount; // 这里的 balance 指的是调用此方法的那个对象的 balance
}

void BankAccount::withdraw(double amount) {
    balance -= amount;
}

double BankAccount::getBalance() const {
    return balance;
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/f551f72e08d685734ad983ae03b7fd1f_13.png)

std::string BankAccount::getOwnerName() const {
    return ownerName;
}

客户端代码使用该类:

#include “BankAccount.h”
#include <iostream>

int main() {
    BankAccount marty(“Marty”, 100.0); // 调用构造函数
    marty.deposit(50.0);
    std::cout << marty.getBalance() << std::endl; // 输出 150
    return 0;
}

核心概念:当成员函数(如deposit)被调用时(marty.deposit(50.0)),函数体内访问的成员变量(balance)是属于调用该函数的那个特定对象(marty)的。这就是“隐式参数”的概念。


第二部分:使用数组实现栈

现在,我们运用关于类的知识,来实现一个栈数据结构。我们将使用数组作为底层存储。

数组基础

数组是内存中一块连续的存储空间。在C++中,动态数组可以通过以下方式创建:

int* arr = new int[10]; // 创建一个可容纳10个整数的数组

使用[]运算符访问元素:

arr[0] = 5; // 设置第一个元素
int x = arr[0]; // 读取第一个元素

需要注意的是,基础数组不知道自己的长度,也不会自动初始化元素。

栈的设计思路

栈遵循后进先出(LIFO)原则。我们的目标是实现push(入栈)、pop(出栈)、peek(查看栈顶)和isEmpty(判断是否为空)等操作。

直接使用固定大小的数组效率不高,因为当数组满时,每次添加元素都需要创建新数组并复制所有数据,这很慢。因此,我们采用一种更聪明的策略:动态数组

动态数组的核心思想

  1. 内部维护一个数组(elements)。
  2. 记录当前栈中实际有多少个元素(size)。
  3. 记录数组总共能容纳多少个元素(capacity),capacity通常大于size
  4. size即将达到capacity(数组将满)时,我们才执行昂贵的“扩容”操作:创建一个更大的新数组(例如,两倍于原容量),将旧数据复制过去,然后替换旧数组。这样,大多数push操作都非常快。

实现栈类

以下是栈类ArrayStack的简化实现框架。

头文件 (ArrayStack.h):

#ifndef ARRAYSTACK_H
#define ARRAYSTACK_H

#include <string>

class ArrayStack {
public:
    ArrayStack(); // 构造函数
    ~ArrayStack(); // 析构函数(用于释放数组内存)
    void push(int value);
    int pop();
    int peek() const;
    bool isEmpty() const;
    std::string toString() const;

private:
    int* elements; // 指向底层数组的指针
    int size;      // 栈中当前元素数量
    int capacity;  // 数组的总容量
    void expandCapacity(); // 私有辅助函数:扩容
};

#endif

源文件 (ArrayStack.cpp) 中的关键实现:

#include “ArrayStack.h”
#include <string>
#include <sstream>

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/f551f72e08d685734ad983ae03b7fd1f_17.png)

ArrayStack::ArrayStack() {
    capacity = 10; // 初始容量
    elements = new int[capacity](); // 分配并初始化数组
    size = 0; // 栈初始为空
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/f551f72e08d685734ad983ae03b7fd1f_19.png)

void ArrayStack::push(int value) {
    // 检查是否需要扩容
    if (size == capacity) {
        expandCapacity();
    }
    // 新元素放在数组的 size 索引位置
    elements[size] = value;
    size++; // 栈大小增加
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/f551f72e08d685734ad983ae03b7fd1f_21.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/f551f72e08d685734ad983ae03b7fd1f_23.png)

int ArrayStack::pop() {
    if (isEmpty()) {
        // 错误处理:可以抛出异常或简单退出
        throw “Cannot pop from an empty stack!”;
    }
    size--; // 先减小size,指向当前栈顶元素
    int topValue = elements[size];
    // 可选:将原栈顶位置清零(非必须)
    // elements[size] = 0;
    return topValue;
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/f551f72e08d685734ad983ae03b7fd1f_25.png)

int ArrayStack::peek() const {
    if (isEmpty()) {
        throw “Cannot peek an empty stack!”;
    }
    return elements[size - 1]; // 返回栈顶元素,但不修改size
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/f551f72e08d685734ad983ae03b7fd1f_27.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/f551f72e08d685734ad983ae03b7fd1f_29.png)

bool ArrayStack::isEmpty() const {
    return size == 0;
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/f551f72e08d685734ad983ae03b7fd1f_31.png)

std::string ArrayStack::toString() const {
    std::ostringstream oss;
    for (int i = 0; i < size; ++i) {
        oss << elements[i];
        if (i < size - 1) oss << ” “;
    }
    return oss.str();
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/f551f72e08d685734ad983ae03b7fd1f_33.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/f551f72e08d685734ad983ae03b7fd1f_35.png)

// 扩容函数实现
void ArrayStack::expandCapacity() {
    int newCapacity = capacity * 2; // 常见的策略是翻倍
    int* newArray = new int[newCapacity]();
    for (int i = 0; i < size; ++i) {
        newArray[i] = elements[i]; // 复制旧数据
    }
    delete[] elements; // 释放旧数组内存
    elements = newArray; // 指向新数组
    capacity = newCapacity; // 更新容量
}

关键点说明

  • push操作将新元素存储在elements[size],然后size++
  • pop操作先size--,然后返回elements[size](即原栈顶)。
  • peek操作直接返回elements[size - 1]
  • toString只遍历从0size-1的有效元素,忽略数组中多余的空位。


课程总结 🎯

本节课中我们一起学习了两个核心内容。

首先,我们深入了解了C++中类与对象的概念。类作为自定义数据类型的蓝图,通过成员变量存储数据,通过成员函数定义行为。利用公有私有访问控制,我们可以实现封装,保护对象内部状态。

其次,我们运用这些知识,从零开始使用数组实现了一个数据结构。我们引入了“动态数组”的概念,通过维护sizecapacity,使得栈在大多数情况下都能高效工作,仅在必要时才进行扩容操作。

通过本课的学习,你不仅掌握了定义类的方法,也理解了常见集合类(如栈、向量)底层是如何工作的。这为后续学习更复杂的数据结构打下了坚实的基础。

课程12:指针与链表基础 🧠

在本节课中,我们将学习C++中一个核心且强大的概念——指针,并了解如何利用指针来实现一种称为“链表”的数据结构。理解指针是掌握C++内存管理和构建复杂数据结构的关键一步。

概述:为何学习指针?

上一节我们介绍了使用数组来实现集合。本节中,我们来看看另一种实现策略:链表。链表由一系列通过指针相互连接的独立“节点”组成。为了理解并构建链表,我们必须首先掌握指针这一基础概念。

指针:内存地址的导航员

指针是一个变量,其存储的值是另一个变量的内存地址。你可以把它想象成一张存储着朋友家地址的纸条,而不是朋友本人。

获取地址与声明指针

在C++中,你可以使用取地址运算符 & 来获取任何变量的内存地址。

int x = 42;
cout << &x; // 输出变量x的内存地址,例如 0x7ffee2b5c5c8

要声明一个指针,你需要指定它所指向的数据类型,并在类型后加上星号 *

int* p; // 声明一个指向整数的指针p
p = &x; // 让指针p存储变量x的地址

现在,指针 p “指向”变量 x

解引用:访问指针指向的值

使用解引用运算符 *,你可以访问或修改指针所指向位置的值。

cout << *p; // 输出 42,即x的值
*p = 99;    // 将x的值修改为99
cout << x;  // 输出 99

这里,*p 就像是变量 x 的一个别名。

空指针与未初始化指针

  • 空指针 (nullptr):这是一个不指向任何有效内存地址的指针。在尝试使用指针前,检查它是否为空是一个好习惯。
    int* p1 = nullptr;
    if (p1 == nullptr) { /* 安全处理 */ }
    // 或者更简洁的写法
    if (!p1) { /* p1是空指针 */ }
    
  • 未初始化指针:声明后未赋值的指针包含随机垃圾值,跟随它会导致不可预知的行为(通常是程序崩溃)。
    int* p2; // 危险!p2指向一个随机内存地址
    

动态内存分配:创建持久对象

当我们构建链表时,需要创建许多独立的小节点,并希望它们在函数调用结束后依然存在。这就需要用到 new 运算符进行动态内存分配

以下是两种创建对象的方式:

// 方式一:自动存储期(栈内存)
void foo() {
    Date d1; // d1在函数foo结束时被自动销毁
    d1.month = 7;
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/ad5fecea6308dd38ec4430702493dc26_5.png)

// 方式二:动态存储期(堆内存)
void foo() {
    Date* d2 = new Date; // d2指向一个在堆上创建的Date对象
    d2->month = 7;       // 使用箭头运算符 `->` 访问成员
    // 对象会一直存在,直到我们使用 `delete` 手动释放它
}

对于链表节点,我们必须使用第二种方式(new),以确保节点在连接成链后不会被自动清理。

箭头运算符 -> 是解引用和成员访问的组合。d2->month 等价于 (*d2).month

构建链表:连接节点

理解了指针和 new 之后,我们就可以构建链表了。链表的基本单元是节点,每个节点包含两部分:

  1. 数据:存储的实际信息。
  2. 下一个指针:指向链表中下一个节点的指针。

我们可以用一个结构体来定义节点:

struct ListNode {
    int data;          // 节点存储的数据
    ListNode* next;    // 指向下一个节点的指针
};

链表可视化

假设我们想创建一个存储 {42, -3, 17} 的链表,其内存结构大致如下:

[front] -> [data:42 | next:] -> [data:-3 | next:] -> [data:17 | next:nullptr]

front 是一个 ListNode* 类型的指针,指向链表的第一个节点。最后一个节点的 next 指针是 nullptr,标志着链表的结束。

遍历链表:访问每个元素

要对链表进行操作(如打印),我们需要遍历它。关键技巧是使用一个临时指针 current 来遍历,而不移动原始的 front 指针。

以下是遍历并打印链表的函数:

void printList(ListNode* front) {
    ListNode* current = front; // 用current从头开始,保护front指针
    while (current != nullptr) {
        cout << current->data << endl; // 打印当前节点数据
        current = current->next;       // current移动到下一个节点
    }
}

循环逻辑

  1. 检查 current 是否已到达链表末尾(nullptr)。
  2. 打印当前节点数据。
  3. current 更新为当前节点的 next 指针,即指向下一个节点。

在链表头部插入节点

在链表头部插入新节点非常高效,只需调整几个指针。

步骤如下:

  1. 使用 new 创建新节点。
  2. 将新节点的 next 指针指向原来的第一个节点(即 front 当前指向的节点)。
  3. front 指针更新为指向这个新节点。

void insertAtFront(ListNode*& front, int value) {
    ListNode* newNode = new ListNode; // 1. 创建新节点
    newNode->data = value;
    newNode->next = front;            // 2. 新节点指向原头节点
    front = newNode;                  // 3. front指向新节点
}

注意:函数参数 ListNode*& front 是一个指针的引用,这允许我们修改调用者传入的 front 指针本身。如果只是 ListNode* front,我们只能修改指针的副本,调用者的指针不会改变。

总结

本节课我们一起学习了C++编程中的两个核心概念:

  1. 指针:存储内存地址的变量,通过 & 取址,通过 * 解引用。nullptr 表示空指针。
  2. 动态内存:使用 new 在堆上分配内存,创建生命周期由我们控制的对象。
  3. 链表基础:利用指针将独立的节点连接起来,形成链表。我们掌握了如何遍历链表以及在链表头部插入新节点的方法。

指针和链表是理解更复杂数据结构(如树、图)的基石。虽然初学可能有些挑战,但通过后续的练习和实际编码,你会逐渐熟悉并掌握它们。下一节,我们将深入探讨更多链表的操作,例如在特定位置插入或删除节点。

课程13:链表操作与内存管理 🧠

在本节课中,我们将学习链表的基本操作,包括在链表头部和尾部添加节点,以及删除节点。我们还将探讨如何通过引用传递指针来修改链表,并介绍C++中内存管理的基本概念,特别是如何避免内存泄漏。


概述

链表是一种动态数据结构,由一系列节点组成,每个节点包含数据和指向下一个节点的指针。本节课我们将重点学习如何操作链表,包括添加和删除节点,并理解指针在链表操作中的关键作用。


链表回顾 📝

上一节我们介绍了链表的基本概念和结构。链表由节点组成,每个节点包含数据和一个指向下一个节点的指针。我们编写了遍历链表的代码,例如打印链表中的所有元素。

以下是遍历链表并打印元素的代码示例:

void printList(ListNode* front) {
    ListNode* current = front;
    while (current != nullptr) {
        cout << current->data << " ";
        current = current->next;
    }
    cout << endl;
}

在这段代码中,我们使用一个临时变量 current 来遍历链表,而不是直接移动 front 指针,以保持链表头部的完整性。


在链表头部添加节点 ➕

本节中我们来看看如何在链表的头部添加一个新节点。这是一种高效的操作,因为它只需要常数时间,而不需要遍历整个链表。

基本思路

  1. 创建一个新节点。
  2. 将新节点的 next 指针指向当前链表的头部。
  3. 将链表的头部指针更新为新节点。

以下是实现这一操作的代码:

void addFront(ListNode*& front, int value) {
    ListNode* newNode = new ListNode;
    newNode->data = value;
    newNode->next = front;
    front = newNode;
}

关键点

  • 函数参数 front 是一个指向指针的引用(ListNode*&),这样我们才能修改调用函数中的 front 指针。
  • 如果 front 是通过值传递的,修改它只会影响函数内部的局部变量,而不会影响原始链表。


在链表尾部添加节点 ➕

现在,我们来看看如何在链表的尾部添加一个新节点。这需要遍历链表以找到最后一个节点,然后将新节点附加在其后。

基本思路

  1. 如果链表为空,直接将新节点设置为头部。
  2. 否则,遍历链表直到最后一个节点。
  3. 将最后一个节点的 next 指针指向新节点。

以下是实现这一操作的代码:

void addBack(ListNode*& front, int value) {
    if (front == nullptr) {
        front = new ListNode(value);
        return;
    }
    ListNode* current = front;
    while (current->next != nullptr) {
        current = current->next;
    }
    current->next = new ListNode(value);
}

关键点

  • 必须处理链表为空的情况,否则在遍历时会引发错误。
  • 循环条件是 current->next != nullptr,这样 current 会停在最后一个节点,而不是越过它变为 nullptr

从链表头部删除节点 ➖

接下来,我们学习如何从链表的头部删除一个节点。这涉及到更新头部指针并释放被删除节点的内存。

基本思路

  1. 如果链表为空,则无需操作。
  2. 否则,保存当前头部节点到一个临时指针。
  3. 将头部指针移动到下一个节点。
  4. 删除临时指针指向的节点以释放内存。

以下是实现这一操作的代码:

void removeFront(ListNode*& front) {
    if (front == nullptr) {
        return;
    }
    ListNode* garbage = front;
    front = front->next;
    delete garbage;
}

关键点

  • 必须使用 delete 释放内存,否则会导致内存泄漏。
  • 删除节点的顺序很重要:先移动头部指针,再删除旧节点。

内存管理 🗑️

在C++中,动态分配的内存(使用 new)必须手动释放(使用 delete),否则会导致内存泄漏。内存泄漏是指程序不再使用的内存没有被释放,从而无法被重新利用。

避免内存泄漏

  • 每次使用 new 分配内存后,确保在适当的时候使用 delete 释放它。
  • 在链表操作中,删除节点时务必释放该节点的内存。

例如,在删除链表节点时:

ListNode* garbage = front;
front = front->next;
delete garbage; // 释放内存


总结

本节课我们一起学习了链表的基本操作,包括在头部和尾部添加节点,以及从头部删除节点。我们还探讨了如何通过引用传递指针来修改链表,并介绍了C++中内存管理的基本概念,特别是如何避免内存泄漏。

通过绘制指针图和编写代码,我们加深了对链表操作的理解。记住,链表操作的关键在于正确处理指针和内存管理,尤其是在修改链表结构时。


注意:本教程根据提供的视频内容整理,旨在帮助初学者理解链表操作的基本概念和实现方法。在实际编程中,请根据具体需求调整代码,并始终注意内存管理。

课程14:C++中的集合实现与优先级队列 🧩

在本节课中,我们将学习如何将集合(如栈和链表)实现为C++类,并初步了解一种称为“优先级队列”的特殊数据结构。我们将探讨使用数组和链表作为内部存储的实现方式,并介绍一些关键的C++概念,如const关键字、运算符重载和析构函数。


常量(Const)关键字的使用 🔒

上一节我们介绍了集合的基本概念,本节中我们来看看如何通过const关键字来提高代码的安全性和清晰度。const意味着“常量”,即不可更改。在C++中,它有多种用途。

首先,可以声明常量变量或参数。例如:

void printStack(const ArrayStack& stack);

这表示printStack函数承诺不会修改传入的stack对象。

其次,在编写类时,可以将成员方法声明为const。这意味着该方法不会修改调用它的对象的状态。例如,一个查看栈顶元素的peek方法就应该是const的。

以下是判断哪些方法应为const的步骤:

  1. 检查该方法是否会修改对象的任何成员变量。
  2. 如果不会,则在其声明和定义末尾添加const关键字。
  3. 这有助于编译器捕获意外的修改错误,并使代码意图更清晰。


运算符重载 ✨

我们已经学会了如何安全地使用对象,现在来看看如何让对象的使用更加直观,比如直接使用cout打印它们。C++的运算符重载功能允许我们为自定义类型定义运算符的行为。

例如,向量(Vector)类重载了+=运算符,使得v += “Marty”;这样的操作成为可能。虽然功能强大,但应谨慎使用,仅当运算符的意义对于该数据类型显而易见时才进行重载。

一个非常普遍且合理的用途是重载输出运算符<<,使对象可打印。其通用语法如下:

// 在 .h 文件中声明
friend std::ostream& operator<<(std::ostream& out, const MyClass& obj);

// 在 .cpp 文件中定义
std::ostream& operator<<(std::ostream& out, const MyClass& obj) {
    out << obj.toString(); // 或其他打印逻辑
    return out; // 必须返回输出流以支持链式调用
}

通过这种方式,我们就可以像使用内置类型一样,使用cout << myObject;来打印自定义对象了。


析构函数与内存管理 🧹

当我们使用new关键字在堆上动态分配内存时,必须负责在对象不再需要时释放它,否则会导致内存泄漏。析构函数就是完成这个清理工作的特殊成员函数。

析构函数的名称是在类名前加波浪号~。当对象离开其作用域或被delete时,析构函数会自动调用。

例如,我们的ArrayStack类在构造函数中使用new int[capacity]分配了一个数组。因此,我们需要在析构函数中释放这块内存:

ArrayStack::~ArrayStack() {
    delete[] elements; // 释放动态数组
}

注意:释放数组时必须使用delete[],而非单独的delete。对于类中其他未使用new分配的成员(如size, capacity),编译器会自动管理它们的生命周期,无需在析构函数中处理。


将链表实现为类 🔗

之前我们以过程式风格操作链表,需要将头指针作为参数传递给各个函数。现在,我们将数据和操作封装到一个LinkedList类中,这是更符合面向对象编程的方式。

在这个类中,链表的头指针front将作为一个私有成员变量。所有操作链表的方法(如addFront, removeFront, printList)都成为该类的成员方法,它们可以直接访问front,而无需将其作为参数传递。

这种封装的好处是:

  • 客户端代码更简洁:使用者只需创建LinkedList对象并调用其方法,无需关心节点、指针等底层细节。
  • 实现细节被隐藏:复杂的指针操作和内存管理被封装在类内部,降低了使用者的认知负担。

这就像雇用一个承包商来粉刷房子:你只需要指定颜色,而不需要亲自去挑选油漆罐和处理所有刷漆的细节。


优先级队列简介 🚑

最后,我们来初步了解一种新的抽象数据类型(ADT)——优先级队列。它类似于普通队列,但出队的顺序不是“先进先出”,而是基于元素的“优先级”。优先级更高(通常用更小的数字表示)的元素会先被处理。

优先级队列的核心操作是:

  • enqueue(value, priority): 将带有指定优先级的元素加入队列。
  • dequeue(): 移除并返回优先级最高的元素。
  • peek(): 查看优先级最高的元素但不移除。

考虑如何实现它?我们可以使用:

  1. 未排序的数组/链表:入队快(直接加在末尾),但出队慢(需要遍历查找优先级最高的元素)。
  2. 已排序的数组/链表:出队快(优先级最高的在头部),但入队慢(需要找到正确位置插入以维持顺序)。
  3. 堆(Heap):一种特殊的树形结构(通常用数组实现),它能使入队和出队操作的时间复杂度都达到高效的 O(log n)。这是实现优先级队列的经典且高效的方法。

在后续的作业中,你将会实践使用不同的内部数据结构来实现优先级队列。


总结 📚

本节课我们一起学习了多个核心主题:

  1. 使用const关键字来保护数据不被意外修改,并明确方法职责。
  2. 通过运算符重载(特别是<<)使自定义类更易于使用。
  3. 利用析构函数管理动态分配的内存,防止内存泄漏。
  4. 将链表封装成类,隐藏实现复杂性,提供清晰的客户端接口。
  5. 初步认识了优先级队列的概念及其不同的实现策略(未排序/已排序结构、堆)。

掌握这些知识,你将能够设计出更安全、更易用且更高效的C++自定义数据类型。

课程15:算法复杂度分析与二叉树入门 🌳

在本节课中,我们将学习两个核心主题:算法复杂度(Big O)的分析方法和二叉树数据结构的基本概念。首先,我们会回顾如何评估代码的运行效率,理解为什么算法复杂度对处理大规模数据至关重要。接着,我们将初步探索二叉树的结构、术语及其递归特性,为后续实现高效集合(如Set)打下基础。

Big O 复杂度分析回顾 📊

上一节我们提到了期中考试,现在我们来重点复习算法复杂度分析的核心思想。Big O 表示法帮助我们描述算法运行时间随输入规模增长的变化趋势,这对于选择高效算法至关重要。

基本执行模型

我们采用一个简化的模型来分析代码:假设每条语句执行需要一个单位时间。这虽然不完全精确,但能帮助我们快速估算复杂度。

例如,一个执行10次的循环,若循环体内有5条语句,则总执行时间单位可估算为:

总时间 ≈ 循环次数 × 循环体内语句数 = 10 × 5 = 50 个单位时间

函数调用的时间则取决于函数内部所有语句的执行时间总和。

关注增长趋势

计算机科学家更关注运行时间的增长趋势,而非绝对时间。我们通常会忽略常数因子和低次项,只保留最高次项。例如,对于一个运行时间表达式 4n³ + 20n² + 100,当 n 很大时, 项占主导地位,因此其 Big O 记为 O(n³)

集合操作的复杂度

了解不同数据结构的操作复杂度对于编写高效程序非常重要。以下是部分关键操作的复杂度:

  • 向量(Vector):在末尾添加元素(add)和按索引访问(get/set)是 O(1)。在开头或中间插入/删除元素可能需要移动其他元素,是 O(n)
  • 集合(Set)与映射(Map):基于树实现的集合(如 TreeSet),其 addremovecontains 等核心操作通常是 O(log n)。基于哈希表实现的集合(如 HashSet),这些操作在平均情况下是 O(1)

代码复杂度分析示例

以下是分析代码段复杂度的思路:

  1. 嵌套循环:若外层循环执行 n 次,内层循环也执行 n 次,则通常为 O(n²)
    for (int i = 0; i < n; i++) {
        for (int j = 0; j < n; j++) {
            // 常数时间操作
        }
    }
    
  2. 顺序循环:多个依次执行的循环,复杂度相加,取最高阶项。
    for (int i = 0; i < n; i++) { ... } // O(n)
    for (int j = 0; j < n; j++) { ... } // O(n)
    // 总复杂度仍为 O(n)
    
  3. 循环中的函数调用:需考虑函数自身的复杂度。若循环 n 次,每次调用一个 O(n) 的函数,则总复杂度为 O(n²)

注意:递归函数的复杂度分析通常更复杂,可能涉及递归关系式求解,本课程期中考试不要求掌握递归的 Big O 分析。

二叉树数据结构初探 🌲

理解了算法效率后,我们来看一个能高效实现集合操作的数据结构——二叉树。二叉树是递归定义的,每个节点最多有两个子节点(左和右),这种结构非常适合用于快速搜索。

二叉树的定义与术语

一棵二叉树要么是空的,要么由一个根节点以及其下方的左子树和右子树(均为二叉树)组成。

以下是二叉树的基本术语:

  • 节点(Node):树中的每个元素。
  • 根节点(Root):树的顶层节点。
  • 子节点(Child):一个节点下方的直接相连节点。
  • 父节点(Parent):一个节点上方的直接相连节点。
  • 叶节点(Leaf):没有子节点的节点。
  • 子树(Subtree):一个节点及其所有后代构成的树。
  • 树的高度(Height):从根到最远叶节点的路径长度。

二叉树的代码表示

我们可以用结构体(struct)来定义一个二叉树节点:

struct TreeNode {
    int data;           // 节点存储的数据
    TreeNode* left;     // 指向左子树的指针
    TreeNode* right;    // 指向右子树的指针

    // 构造函数
    TreeNode(int value) {
        data = value;
        left = nullptr;
        right = nullptr;
    }
};

使用该结构体,我们可以手动构建一棵树:

TreeNode* root = new TreeNode(17);
root->left = new TreeNode(9);
root->right = new TreeNode(14);
root->left->left = new TreeNode(-3);
// ... 以此类推构建其他节点

二叉树的递归遍历

处理二叉树最自然的方式是递归。例如,要打印树中的所有节点,我们可以利用树自身的递归结构。

一种直观但不够简洁(非“递归禅意”)的打印方法是:

void printTree(TreeNode* node) {
    if (node == nullptr) {
        return; // 基础情况:空树不打印
    }
    cout << node->data << endl;
    if (node->left != nullptr) {
        printTree(node->left);
    }
    if (node->right != nullptr) {
        printTree(node->right);
    }
}

更优雅(更具“递归禅意”)的版本是,相信递归调用能处理好子节点,无需显式检查子节点是否为空:

void printTreeZen(TreeNode* node) {
    if (node == nullptr) {
        return; // 基础情况:空树
    }
    cout << node->data << endl; // 处理当前节点
    printTreeZen(node->left);   // 递归处理左子树
    printTreeZen(node->right);  // 递归处理右子树
}

计算二叉树的大小

同样地,计算树的节点总数也可以优雅地用递归实现:

int treeSize(TreeNode* node) {
    if (node == nullptr) {
        return 0; // 空树大小为0
    }
    // 当前节点(1) + 左子树大小 + 右子树大小
    return 1 + treeSize(node->left) + treeSize(node->right);
}

总结 🎯

本节课中我们一起学习了两个重要主题。

首先,我们深入回顾了 Big O 复杂度分析,明白了它是衡量算法随输入规模增长而耗时变化的标准方法。我们学会了简化分析模型,关注最高阶项,并了解了常见集合操作的复杂度,这对于编写高效程序至关重要。

其次,我们初步探索了 二叉树数据结构。我们了解了其递归定义、基本术语,并用代码实现了树节点。最重要的是,我们掌握了使用递归来遍历和操作二叉树的基本模式,例如打印所有节点和计算树的大小,这为我们后续学习利用二叉树实现高效集合打下了坚实基础。

理解这些概念将帮助你在面对大量数据时选择合适的数据结构和算法,并为你解决更复杂的编程问题(包括面试中常见的二叉树问题)做好准备。

课程16:二叉搜索树(BST)基础 🧮

在本节课中,我们将学习一种特殊的二叉树——二叉搜索树(Binary Search Tree, BST)。我们将了解它的排序特性,以及如何利用这些特性实现高效的查找、插入等操作。这些操作正是斯坦福的 MapSet 类库如此快速和有序的原因。


回顾:树的遍历 🌳

上一节我们介绍了二叉树的基本概念。本节中,我们来看看如何系统地访问树中的每一个节点,这个过程称为遍历

遍历是解决几乎所有树问题的核心方法。本质上,你需要访问节点本身,并查看其子节点。根据访问节点的顺序,有三种主要的遍历方式:

  • 前序遍历:先访问当前节点,再递归访问左子树,最后递归访问右子树。
  • 中序遍历:先递归访问左子树,再访问当前节点,最后递归访问右子树。
  • 后序遍历:先递归访问左子树,再递归访问右子树,最后访问当前节点。

另一种理解方式是:想象你沿着树的边缘行走,每次经过一个节点时,根据遍历规则决定是否“处理”它(例如打印其值)。

以下是遍历一个示例树的结果:

  • 前序遍历:17, 41, 29, 6, 9, 81, 40
  • 中序遍历:29, 41, 6, 17, 81, 9, 40
  • 后序遍历:29, 6, 41, 81, 40, 9, 17

请记住这些遍历方式,因为几乎任何树问题都可以通过某种遍历的变体来解决。


为普通二叉树实现 contains 函数 🔍

现在,让我们讨论如何为二叉树编写一个 contains 函数。这个函数的目标是判断一个值是否存在于树中的任意位置。如果存在则返回 true,否则返回 false

以下是实现这个函数的一种思路:

我们知道需要检查树中的每一个值,因此仍然需要使用遍历。我们不是打印节点的值,而是检查节点的值是否等于我们要找的值。

bool contains(TreeNode* node, int value) {
    if (node == nullptr) {
        return false; // 基本情况:空树不包含任何值
    }
    // 检查当前节点
    if (node->data == value) {
        return true;
    }
    // 递归检查左子树和右子树
    return contains(node->left, value) || contains(node->right, value);
}

这个函数使用了前序遍历的思想(先检查当前节点,再检查子树)。对于普通的、无序的二叉树,我们必须检查所有节点,因此这个函数的时间复杂度是 O(n),其中 n 是节点总数。


引入二叉搜索树(BST) 🚀

上一节我们实现了一个通用的 contains 函数,但它的效率不高。本节中,我们来看看如何通过一种特殊的树结构来大幅提升效率。

二叉搜索树 在普通二叉树的基础上增加了一个排序属性:

  • 对于树中的任意节点,其左子树中的所有节点值都小于该节点的值。
  • 右子树中的所有节点值都大于该节点的值。
  • 这个属性递归地适用于所有子树。

这个属性带来了巨大的优势:当我们在 BST 中搜索一个值时,在每一步,我们都可以根据当前节点的值决定是去左子树还是右子树继续搜索,从而跳过整棵子树


为 BST 实现高效的 contains 函数 ⚡

利用 BST 的排序属性,我们可以优化之前的 contains 函数。

以下是优化后的思路:
我们不再需要同时检查左右子树。如果要找的值小于当前节点值,我们只需搜索左子树;如果要找的值大于当前节点值,我们只需搜索右子树。

bool containsBST(TreeNode* node, int value) {
    if (node == nullptr) {
        return false; // 未找到
    }
    if (value == node->data) {
        return true; // 找到
    } else if (value < node->data) {
        // 目标值更小,只搜索左子树
        return containsBST(node->left, value);
    } else { // value > node->data
        // 目标值更大,只搜索右子树
        return containsBST(node->right, value);
    }
}

这个优化后的函数时间复杂度是 O(log n)(在平衡的树中),比 O(n) 快得多。注意,这里的遍历顺序是中序遍历的一种应用,因为我们先根据节点值做判断(类似于“访问”),再决定递归方向。


BST 的其他优势操作 ✨

BST 的排序属性还使得其他一些操作变得非常高效。

查找最小/最大值

  • 最小值:沿着树的最左侧路径一直向下,直到没有左子节点的节点。
  • 最大值:沿着树的最右侧路径一直向下,直到没有右子节点的节点。

int findMin(TreeNode* node) {
    // 假设树非空
    while (node->left != nullptr) {
        node = node->left;
    }
    return node->data;
}

这个操作的时间复杂度也是 O(log n),而在无序的数据结构中(如链表),你需要 O(n) 的时间来检查所有元素。


向 BST 中插入节点 ➕

现在,我们来看看如何向 BST 中添加一个新值。目标是找到正确的位置插入,同时保持 BST 的排序属性不变。

插入算法的思路与 contains 函数类似:

  1. 从根节点开始,比较要插入的值与当前节点的值。
  2. 如果值更小,则走向左子树;如果值更大,则走向右子树。
  3. 重复此过程,直到到达一个 nullptr(空位置)。这个空位置就是新节点应该插入的地方。
  4. 创建新节点并将其链接到树中。

这里有一个关键点:为了修改树的结构(将新节点链接到其父节点上),我们需要通过引用传递节点指针,或者处理返回值来更新父节点的指针。

void insertBST(TreeNode*& node, int value) {
    if (node == nullptr) {
        // 找到插入位置,创建新节点
        node = new TreeNode(value);
    } else if (value < node->data) {
        // 插入到左子树
        insertBST(node->left, value);
    } else if (value > node->data) {
        // 插入到右子树
        insertBST(node->right, value);
    }
    // 如果 value == node->data,说明值已存在(假设不允许重复),什么也不做
}

注意参数 TreeNode*& node,这是一个指向指针的引用,它允许我们修改调用者传来的指针变量本身(例如,将 nullptr 改为指向新节点的指针)。


释放树的内存 🗑️

当我们使用完一棵树(尤其是用 new 创建的树)后,应该释放其内存以避免内存泄漏。

释放树内存的合适算法是后序遍历

  • 必须先递归释放所有子节点的内存,然后才能安全地释放当前节点本身。
  • 如果使用前序或中序遍历,在释放当前节点后,就无法再访问其子节点,从而导致内存泄漏。

void freeTree(TreeNode* node) {
    if (node == nullptr) {
        return;
    }
    // 后序遍历:先释放孩子
    freeTree(node->left);
    freeTree(node->right);
    // 最后释放自己
    delete node;
}

总结 📚

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

  1. 树的遍历:前序、中序、后序遍历是处理树问题的基础模式。
  2. 二叉搜索树:一种具有排序属性(左子树 < 根节点 < 右子树)的二叉树,该属性递归成立。
  3. BST 的高效操作
    • contains:利用排序属性,时间复杂度可优化至 O(log n)。
    • findMin/findMax:通过一直向左/右查找,时间复杂度为 O(log n)。
    • insert:通过类似搜索的过程找到插入位置,并注意通过引用传递指针以正确修改树结构。
  4. 内存管理:使用后序遍历来安全地释放整棵树的内存。

BST 是实现高效查找和动态集合的基石数据结构。在接下来的课程中,我们将继续探索 BST 的更多操作,例如删除节点。

课程名称:CS106B C++中的抽象编程 · 第17讲:高级树结构 🌳

概述

在本节课中,我们将结束对树结构的讨论,学习一些高级的树操作和概念。我们将完成二叉搜索树的删除操作,探讨如何使用二叉搜索树来实现集合和映射,并介绍树平衡的重要性。最后,我们将了解一种非二叉树结构——Trie树,并简要介绍与作业六相关的霍夫曼编码。


二叉搜索树的删除操作

上一节我们介绍了二叉搜索树的基本概念和添加、查找操作。本节中,我们来看看如何从二叉搜索树中删除一个节点。

删除节点时,需要确保不违反二叉搜索树的属性。根据要删除节点的子节点情况,我们可以分为四种情况来处理。

以下是删除节点时可能遇到的四种情况及其处理方法:

  1. 删除叶子节点:如果要删除的节点是叶子节点(没有子节点),操作很简单。我们只需将其父节点指向该节点的指针设置为 nullptr,然后释放该节点的内存。

    // 伪代码示例:删除叶子节点
    parent->child = nullptr;
    delete nodeToRemove;
    
  2. 删除只有一个左子节点的节点:如果要删除的节点只有一个左子节点,我们可以用它的左子节点来替代它。

    // 伪代码示例:用左子节点替代待删除节点
    Node* temp = nodeToRemove;
    nodeToRemove = nodeToRemove->left;
    delete temp;
    
  3. 删除只有一个右子节点的节点:与情况2类似,如果要删除的节点只有一个右子节点,我们可以用它的右子节点来替代它。

    // 伪代码示例:用右子节点替代待删除节点
    Node* temp = nodeToRemove;
    nodeToRemove = nodeToRemove->right;
    delete temp;
    
  4. 删除有两个子节点的节点:这是最复杂的情况。我们不能简单地用其中一个子节点替代,因为这样会破坏BST的结构。常见的策略是:

    • 找到该节点右子树中的最小节点(或左子树中的最大节点)。
    • 用这个最小节点的值覆盖要删除的节点的值。
    • 然后,递归地在右子树中删除那个值最小的节点(此时它最多只有一个右子节点,属于情况2或3,处理起来很简单)。

    例如,要删除值为5的节点(有两个子节点),我们找到其右子树中的最小节点6,将5替换为6,然后在右子树中删除原来的节点6。


使用二叉搜索树实现集合与映射

理解了二叉搜索树的核心操作后,我们来看看它如何作为更高级数据结构的基础。

集合 是一个不包含重复元素的数据结构。我们可以使用二叉搜索树来实现它,树中只存储键(key)。addcontainsremove 操作直接对应于我们刚才讨论的BST操作。

映射 是键值对的集合。实现映射时,二叉搜索树的每个节点不仅存储一个键,还存储一个与该键关联的值。树仍然根据键来排序,而不是值。因此,像 containsKeyget(根据键获取值)这样的操作可以高效地实现,但 containsValue 则不那么高效。

在实际的库实现(如斯坦福的库)中,会用一个类(如 TreeSetTreeMap)来封装二叉搜索树的根节点,对用户隐藏实现细节,只提供简洁的接口。


树的平衡 ⚖️

我们之前提到,二叉搜索树的操作时间复杂度理想情况下是 O(log n)。但这依赖于树是平衡的。

一个不平衡的二叉搜索树可能会退化成类似链表的结构。例如,如果按顺序插入 1, 2, 3, 4, 5,树会变成一条向右倾斜的链,此时查找的时间复杂度会退化到 O(n)

平衡树 的目标是确保树的左右子树高度大致相等,从而维持对数级的时间复杂度。实现平衡的算法有很多,例如红黑树或AVL树。它们通过“旋转”等操作在插入和删除时动态调整树的结构。虽然我们不需要在本课程中手动实现这些复杂结构,但理解平衡的概念至关重要,因为它是保证高效操作的基础。


Trie树:一种用于字符串的非二叉树 📚

之前我们讨论的树每个节点最多有两个子节点。Trie树(前缀树)是一种特殊的树,用于高效存储和检索字符串集合。

在Trie树中:

  • 每个节点代表一个字符(或字符串的前缀)。
  • 从根节点到某个节点的路径构成了一个字符串。
  • 每个节点包含一个指针数组(例如,对于小写英文字母,是大小为26的数组),指向可能的下一字符。
  • 节点中的一个布尔标志(如 isWord)用于标记从根到该节点的路径是否构成一个完整的单词。

例如,存储单词 “ate”:

  1. 从根节点开始,跟随 ‘a’ 的指针。
  2. 到达代表 ‘a’ 的节点,再跟随 ‘t’ 的指针。
  3. 到达代表 “at” 的节点,再跟随 ‘e’ 的指针。
  4. 到达代表 “ate” 的节点,并将其 isWord 标记为 true

查找一个单词或判断一个前缀是否存在非常高效,时间复杂度为 O(m),其中 m 是单词长度。


作业六:霍夫曼编码简介

今天发布的作业六涉及实现一种文件压缩方案——霍夫曼编码。

其核心思想是使用可变长度编码来替换固定的ASCII编码。出现频率高的字符(如空格、’e’)用较短的二进制串表示,出现频率低的字符用较长的二进制串表示,从而在整体上减小文件大小。

实现步骤概述:

  1. 统计频率:读取文件,计算每个字符出现的次数。
  2. 构建优先队列:将每个字符及其频率作为节点放入最小堆(优先队列)。
  3. 构建霍夫曼树
    • 反复从优先队列中取出两个频率最小的节点。
    • 创建一个新的内部节点,其频率为这两个节点频率之和,并将这两个节点作为其左右子节点。
    • 将这个新节点放回优先队列。
    • 重复此过程,直到队列中只剩一个节点,这个节点就是霍夫曼树的根。
  4. 生成编码表:遍历霍夫曼树,向左走代表 ‘0’,向右走代表 ‘1’。到达叶节点时,就得到了该叶节点字符对应的二进制编码。
  5. 压缩:读取原文件,根据编码表将每个字符转换为对应的二进制位流,并写入输出文件。
  6. 解压:读取压缩后的位流,从霍夫曼树根开始,根据每一位是 ‘0’ 还是 ‘1’ 决定向左或向右移动,到达叶节点时输出对应的字符,然后回到根节点继续处理下一位。

霍夫曼编码的一个关键特性是前缀码:任何一个字符的编码都不是另一个字符编码的前缀。这保证了在解码时不会产生歧义。


总结

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

  1. 二叉搜索树中删除节点的四种情况及其处理方法。
  2. 如何利用二叉搜索树作为底层实现来构建集合映射抽象数据类型。
  3. 平衡的重要性,它是保证二叉搜索树操作效率(O(log n))的关键。
  4. Trie树的结构与应用,它是一种高效处理字符串集合的非二叉树。
  5. 作业六相关的霍夫曼编码基本原理,这是一种利用二叉树进行数据压缩的有效方法。

通过掌握这些高级树结构的概念,你对于如何利用树来解决复杂问题有了更深入的理解。

课程18:图论基础与算法 🗺️

在本节课中,我们将学习一种新的数据结构——图。图是一种强大的工具,可以用来表示和解决许多现实世界中的问题,例如社交网络、地图导航和任务调度。我们将从图的基本概念开始,了解其构成和术语,然后学习如何使用代码库来操作图,最后介绍两种基础的图搜索算法。

图的基本概念

上一节我们介绍了本课程的主题。本节中,我们来看看图到底是什么。

图由顶点组成。顶点(有时也称为节点)是图中的基本单位。边是连接一对顶点的线,表示它们之间存在某种关系。在某些语境下,边也被称为弧。

以下是图的一些核心术语:

  • :一个顶点的度是指与其相连的边的数量。
  • 路径:路径是从一个顶点到另一个顶点所经过的一系列顶点。路径的长度是指路径中包含的边的数量。
  • 相邻:如果两个顶点之间存在一条直接的边,则称它们是相邻的。
  • 可达性:如果从顶点A到顶点B存在一条路径,则称B是从A可达的。
  • 连通图:如果一个图中的任意两个顶点之间都是可达的,则称该图为连通图。
  • :环是一条起点和终点是同一个顶点的路径。不包含任何环的图称为无环图
  • 自环:一条连接一个顶点到其自身的边。

图的变体

了解了图的基本构成后,我们来看看图的一些常见变体,它们能帮助我们为不同问题建模。

图的属性可以根据具体问题进行调整和组合:

  • 加权图:图中的每条边都被赋予一个数值(权重),可以代表距离、成本或时间等。公式表示为:边 = (顶点A, 顶点B, 权重W)
  • 有向图:图中的边具有方向性,从起点指向终点。这表示关系是单向的,例如Twitter的关注关系。在代码中,这通常表示为 addEdge(起点, 终点)
  • 无向图:边没有方向,关系是双向的,例如Facebook的好友关系。它可以看作是一种特殊的有向图,其中每条边都有两个方向。

图的代码表示

理论需要实践来巩固。现在,我们来看看如何在程序中使用图。

斯坦福CS106B课程提供了一个名为 BasicGraph 的库,用于表示和操作加权有向图。以下是如何使用它的一些基本操作:

#include “basicgraph.h”
// 创建一个图
BasicGraph graph;
// 添加顶点
graph.addVertex(“A”);
graph.addVertex(“B”);
// 添加有向边,并可选择添加权重
graph.addEdge(“A”, “B”); // 默认权重为1
graph.addEdge(“C”, “D”, 4.5); // 添加权重为4.5的边
// 获取顶点的所有邻居(出边指向的顶点)
Set<Vertex*> neighbors = graph.getNeighbors(“A”);
// 获取图中所有顶点
Set<Vertex*> allVertices = graph.getVertexSet();

图搜索算法简介

掌握了图的基本操作后,我们就可以探索如何在图中寻找路径了。本节将介绍两种基础的搜索策略。

图搜索的核心目标通常是找到从一个顶点到另一个顶点的路径。根据需求不同,我们可能希望找到任何一条路径、最短路径或代价最小的路径。

深度优先搜索 (DFS)

深度优先搜索的策略是“一条路走到黑”。它从起点开始,随机选择一条边深入探索,直到无法继续或找到目标。如果失败,则回溯到上一个分岔点,尝试另一条未探索的边。

以下是DFS的伪代码思路:

算法 DFS(当前顶点 v, 目标顶点 target, 已访问集合 visited, 当前路径 path):
    如果 v 等于 target:
        将 v 加入 path
        返回 true (找到路径)
    标记 v 为已访问 (加入 visited)
    将 v 加入 path
    对于 v 的每一个邻居 n:
        如果 n 未被访问:
            如果 DFS(n, target, visited, path) 为真:
                返回 true
    从 path 中移除 v // 回溯
    返回 false (未找到路径)

DFS实现简单,能找到一个解,但不保证是最短路径。

广度优先搜索 (BFS)

与DFS相反,广度优先搜索的策略是“层层推进”。它从起点开始,先探索所有直接相邻的顶点,然后再探索这些相邻顶点的邻居,以此类推,像涟漪一样扩散开来。

以下是BFS的伪代码思路:

算法 BFS(起点 start, 目标 target):
    初始化队列 q
    初始化集合 visited 记录已访问顶点
    将 start 入队,并标记为已访问
    while (队列 q 不为空):
        当前顶点 curr = q.dequeue() // 从队首取出
        如果 curr 等于 target:
            重构并返回路径 // 通常需要额外记录父节点信息
        对于 curr 的每一个邻居 n:
            如果 n 未被访问:
                标记 n 为已访问
                记录 n 的父节点为 curr // 用于最后重构路径
                将 n 入队 q
    返回 “未找到路径”

BFS的优势在于它保证能找到从起点到目标的最短路径(以边的数量计)。


本节课中我们一起学习了图数据结构的基础知识。我们了解了图的顶点、边、连通性等核心概念,认识了加权图、有向图等常见变体。我们还初步接触了BasicGraph库的基本用法,并学习了深度优先搜索和广度优先搜索这两种基础图算法的工作原理。在接下来的课程中,我们将更深入地应用这些算法解决实际问题。

课程19:图算法进阶:Dijkstra算法与A*搜索算法 🧭

在本节课中,我们将学习两种重要的图算法:Dijkstra算法和A搜索算法。我们将从回顾广度优先搜索(BFS)的路径重建问题开始,然后深入探讨如何在带权重的图中找到成本最低的路径,最后介绍结合了启发式思想的A算法。

回顾广度优先搜索(BFS)的路径重建

上一节我们介绍了广度优先搜索(BFS),它可以找到两个节点之间边数最少的路径。本节中我们来看看BFS在路径重建方面的一个挑战。

BFS在存储待查找节点时使用队列。当访问一个新节点时,会查看其所有邻居。主要区别在于,如果我们有一个顶点队列,仅仅访问节点本身不足以重建从起点到终点的具体路径。

为了重建路径,我们需要为每个节点记录其“前驱”节点。例如,如果知道节点A的邻居是B、E和D,并且从A搜索到了F,那么我们就需要知道F是从哪个节点访问过来的。这是在作业中需要实现的关键点。

以下是关于BFS的一些观察:

  • BFS总能找到边数最短的路径。
  • 如果不考虑边的权重(成本),BFS找到的就是最优路径。
  • 在BFS中,重建实际遍历的边序比深度优先搜索(DFS)需要更多思考,因为DFS可以通过栈或递归调用来自然追踪路径。
  • BFS找到的是最短路径,而DFS可能占用更少内存,但找到的路径不一定最短。因此,在选择算法时需要权衡。

在继续讨论Dijkstra算法之前,如果有人好奇BFS的运行时间:基本上是遍历每个顶点和每条边各一次,所以时间复杂度是 O(V + E)

引入带权图与最短成本路径问题

我一直暗示的一个想法是:在有权重的图中,我们如何找到成本最低的路径?在现实世界中,这是一个常见问题。

例如,在规划旅行时,直飞纽约可能比在芝加哥中转更贵。如果你更关心金钱而非时间,可能就愿意选择中转以节省机票费用。现实世界中的图,每条边(例如路段、网络连接)都有特定的权重(如距离、时间、成本),我们通常希望最小化路径的总权重。

另一个例子是城市地图导航,不同街道的通行时间(权重)可能差异很大。服务器处理请求时,也可能根据延迟(权重)来选择路径。

那么,为什么BFS对于这类问题不够好呢?让我们看一个例子。

假设我们想找到从A到F成本最低的路径。BFS会找到路径 A -> B -> E -> F,总成本是 2 + 3 + 4 = 9。
然而,存在一条更好的路径:A -> D -> G -> F,总成本是 1 + 1 + 4 = 6。
BFS找到的是边数最少的路径,但不一定是权重和最小的路径。当图变得非常庞大时,肉眼寻找最优路径将非常困难。

Dijkstra算法:寻找带权图的最短路径

Edsger Dijkstra提出了一种算法来解决这个问题,他因此获得了图灵奖(计算机领域的诺贝尔奖)。传说中,编程中常用ijk作为循环变量就是因为Dijkstra的名字。

Dijkstra算法用于在带权有向图中,找到从一个给定起点到图中其他所有顶点的最低成本路径。它的基本思想是:我们不再盲目地将所有邻居加入队列,而是会跟踪到达每个节点的当前已知最低成本,并优先探索成本最低的节点。

算法伪代码与核心概念

算法从起点开始,设其成本为0,其他所有节点的成本初始化为无穷大。我们使用一个优先队列(通常是最小堆),根据当前已知的到达成本对节点进行排序。

以下是算法步骤:

  1. 将起点以成本0加入优先队列。
  2. while 优先队列不为空:
    a. 从队列中取出当前成本最低的节点 u,标记为“已访问”。
    b. 对于 u 的每个未访问的邻居 v
    计算通过 u 到达 v新成本 = u的成本 + 边(u, v)的权重。
    if 新成本 < v的当前记录成本:
    更新 v的成本为新成本。
    v(以新成本)重新加入优先队列。
    记录 v的“前驱”节点为 u(用于最终路径重建)。

算法示例演示

让我们用之前的图例,一步步看Dijkstra算法如何找到从A到F的最低成本路径。

  1. 初始化:起点A成本=0(黄色)。其他节点成本=∞。
    • 优先队列 PQ: [(A, 0)]
  2. 处理A:取出A(标记为绿色)。查看邻居B和D。
    • 到B的新成本 = 0 + 2 = 2。更新B成本为2,前驱为A。B入队。
    • 到D的新成本 = 0 + 1 = 1。更新D成本为1,前驱为A。D入队。
    • PQ: [(D,1), (B,2)]
  3. 处理D:取出D(成本最低)。查看邻居C和G。
    • 到C的新成本 = 1 + 2 = 3。更新C成本为3,前驱为D。C入队。
    • 到G的新成本 = 1 + 1 = 2。更新G成本为2,前驱为D。G入队。
    • PQ: [(G,2), (B,2), (C,3)]
  4. 处理G:取出G(与B成本相同,任取一个)。查看邻居F。
    • 到F的新成本 = 2 + 4 = 6。更新F成本为6,前驱为G。F入队。
    • PQ: [(B,2), (C,3), (F,6)]
  5. 处理B:取出B。查看邻居E(D已访问,忽略)。
    • 到E的新成本 = 2 + 3 = 5。更新E成本为5,前驱为B。E入队。
    • PQ: [(C,3), (E,5), (F,6)]
  6. 处理C:取出C。查看邻居F。
    • 到F的新成本 = 3 + 5 = 8。8 > F的当前成本6,因此不更新F。
    • PQ: [(E,5), (F,6)]
  7. 处理E:取出E。查看邻居F。
    • 到F的新成本 = 5 + 4 = 9。9 > 6,不更新。
    • PQ: [(F,6)]
  8. 处理F:取出F。发现F就是目标节点,算法可以提前终止(优化)。
    • 通过回溯前驱节点(F <- G <- D <- A),得到最低成本路径 A -> D -> G -> F,总成本为6。

算法特性与注意事项

  • 贪婪算法:Dijkstra算法是一种贪婪算法,它在每一步都做出当前看来最优的选择(访问成本最低的节点),并希望这能导致全局最优解。
  • 正确性保证:算法之所以有效,是因为它始终维护两个关键属性:
    1. 对于任何已访问的节点,我们记录的成本就是从起点到该节点的最低成本
    2. 对于优先队列中的节点,其优先级是当前已知的、通过已访问节点可达的临时最短路径成本。
  • 路径重建:必须额外存储每个节点的“前驱”信息,才能在算法结束后重建完整路径。
  • 无穷大的表示:在编程中,通常用一个非常大的数(如INT_MAX)来代表无穷大。
  • 运行时间:由于使用了优先队列,每次插入、删除或更新优先级都是 O(log V) 操作。算法大致会检查每条边,因此总时间复杂度约为 O((V+E) log V)。对于稠密图,这比BFS的O(V+E)要慢。
  • 局限性:Dijkstra算法要求边的权重非负。如果存在负权边,算法可能无法得到正确结果。

A*搜索算法:启发式引导的Dijkstra

Dijkstra算法会均匀地向所有方向探索,直到找到目标。但如果我们有关于目标位置的信息,能否让搜索更“智能”地朝向目标呢?这就是A*搜索算法的思想。

设想一个网格,起点在左下,终点在右上。Dijkstra会像圆形波纹一样扩散,探索许多不必要的节点。A*算法通过引入一个启发函数来估算从当前节点到目标节点的剩余成本,从而引导搜索方向。

启发函数

启发函数 h(n) 是对从节点 n 到目标节点代价的估计。一个关键要求是,启发函数必须是可采纳的,即它永远不能高估实际成本。在路径规划中,直线距离(欧几里得距离或曼哈顿距离)常被用作可采纳的启发函数。

例如,在只能上下左右移动的网格中,从当前点到目标的曼哈顿距离就是一个很好的启发函数。

A*算法原理

A*算法与Dijkstra非常相似,区别在于优先队列中节点的优先级计算方式:

  • Dijkstra:优先级 = 从起点到当前节点的已知成本 g(n)
  • A*:优先级 = g(n) + h(n),其中 h(n) 是从当前节点到目标的估计成本。

这样,A*会优先探索那些已知成本低看起来离目标近的节点,从而有望更快地找到目标。

伪代码对比

Dijkstra优先级设置:

priority = cost_so_far[current_node]

A*优先级设置:

priority = cost_so_far[current_node] + heuristic(current_node, goal)

算法主体结构与Dijkstra一致,只是在节点入队时计算优先级的方式不同。

总结

本节课中我们一起学习了两种重要的图算法:

  1. Dijkstra算法:用于在边权重非负的图中,找到从单一源点到所有其他顶点的最短(最低成本)路径。其核心是使用优先队列进行贪婪搜索,时间复杂度约为 O((V+E) log V)
  2. A*搜索算法:Dijkstra算法的改进版,通过引入一个可采纳的启发函数 h(n) 来估算到目标的剩余成本,从而引导搜索方向,在实践中往往能更快地找到目标路径。其优先级计算为 f(n) = g(n) + h(n)

理解这些算法,关键在于掌握它们如何利用数据结构(队列、优先队列)来管理待探索节点,以及它们保证正确性的核心思想(Dijkstra的已访问节点成本确定性,A*的可采纳启发函数)。在具体实现时,别忘了记录前驱节点以重建最终路径。

📘 课程名称:CS106B C++中的抽象编程 · 第02讲

📋 概述

在本节课中,我们将学习C++函数的核心概念,包括函数原型、参数传递机制(值语义与引用语义)、默认参数以及字符串的基本操作。我们还将通过编写一个二次方程求解函数和一个字符串菱形打印函数来实践这些概念。


🔧 函数原型与声明

上一节我们介绍了基本的函数定义和调用。本节中我们来看看C++中一个特有的要求:函数在使用前必须被声明。

在C++中,如果你先调用一个函数,然后在文件后面才定义它,编译器会报错,提示该函数“未声明”。这与Java或JavaScript等语言不同。

解决这个问题的方法是使用函数原型。函数原型就是函数的声明,它只包含返回类型、函数名和参数类型,并以分号结尾。它相当于向编译器承诺:“我稍后会定义这个函数”。

代码示例:

// 函数原型声明
void printMessage();

int main() {
    printMessage(); // 调用函数
    return 0;
}

// 函数定义
void printMessage() {
    cout << "Hello, World!" << endl;
}

许多程序员的常见做法是:先写完函数定义,然后将其函数头(即返回类型、函数名和参数列表)复制到文件顶部,并加上分号,作为原型声明。


⚙️ 参数传递:值语义与引用语义

理解了函数声明后,我们来看看C++中参数是如何传递的。这涉及到两个核心概念:值语义引用语义

值语义

默认情况下,C++使用值语义传递参数。这意味着当你将一个变量传递给函数时,传递的是该变量值的一个副本。函数内部对参数的任何修改都不会影响原始的变量。

代码示例:

void swap(int a, int b) {
    int temp = a;
    a = b;
    b = temp;
}
int main() {
    int x = 5, y = 10;
    swap(x, y);
    // x 仍然是 5, y 仍然是 10
}

在上面的swap函数中,交换的只是ab这两个副本,main函数中的xy并未改变。

引用语义

如果你希望函数能够修改调用者传递的原始变量,就需要使用引用语义。通过在参数类型后添加&符号,可以将参数声明为引用。这样,函数内的参数就是原始变量的一个“别名”,对它的修改会直接影响原变量。

代码示例:

void swap(int &a, int &b) { // 注意这里的 & 符号
    int temp = a;
    a = b;
    b = temp;
}
int main() {
    int x = 5, y = 10;
    swap(x, y);
    // x 现在是 10, y 现在是 5
}

引用参数的一个典型用途是输出参数,即函数需要“返回”多个值。由于return语句只能返回一个值,我们可以通过引用参数将结果“填充”到调用者提供的变量中。

代码示例(输出参数):

// 计算允许的约会年龄范围(基于一个虚构公式)
void datingRange(int age, int &min, int &max) {
    min = age / 2 + 7;
    max = (age - 7) * 2;
}
int main() {
    int young, old;
    datingRange(48, young, old);
    // young 和 old 现在被赋予了计算后的值
}

注意:引用参数必须传递一个变量(即内存位置),不能直接传递一个字面量(如42)。


🧮 实践:编写二次方程求解函数

让我们运用引用参数来编写一个实用的函数:求解二次方程。

二次方程的标准形式是:ax² + bx + c = 0。其求根公式为:
x = [-b ± √(b² - 4ac)] / (2a)

我们的函数需要接收系数a, b, c,并通过两个引用参数root1root2返回两个实根(本示例假设判别式大于等于0)。

代码实现:

#include <cmath> // 用于 sqrt 函数

void quadratic(double a, double b, double c, double &root1, double &root2) {
    double discriminant = b * b - 4 * a * c;
    root1 = (-b + sqrt(discriminant)) / (2 * a);
    root2 = (-b - sqrt(discriminant)) / (2 * a);
}

int main() {
    double r1, r2;
    quadratic(1, -3, -4, r1, r2); // 解方程 x² - 3x - 4 = 0
    // r1 约为 4.0, r2 约为 -1.0
}

思考:为什么系数a, b, c没有使用引用传递?因为在这个函数中,我们只需要它们的值进行计算,并不打算修改它们。


📝 字符串操作

现在,我们将注意力转向C++中的字符串。C++标准库提供了string类型,但使用时需要包含头文件<string>

基本操作

字符串的许多操作与Java/JavaScript类似:

  • 索引从0开始,使用[]访问单个字符。
  • 使用+进行字符串连接。
  • 使用==, !=, <, >等运算符直接比较字符串(按字典序)。
  • 使用.length()获取字符串长度。
  • 使用.substr(start, length)获取子串。

代码示例:

#include <string>
using namespace std;

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/05167a97b06793063028b57545526660_27.png)

string name = "Marty";
char firstChar = name[0]; // ‘M’
string greeting = "Hello, " + name; // “Hello, Marty”
bool isLater = name > "Cynthia"; // true,因为 “Marty” 在 “Cynthia” 之后

斯坦福库扩展

C++标准字符串库缺少一些常用功能(如大小写转换)。为此,斯坦福课程提供了一个扩展库“strlib.h”,它包含一些有用的函数,如:

  • toUpperCase(str): 返回大写字符串。
  • toLowerCase(str): 返回小写字符串。
  • stringToInteger(str): 将字符串转换为整数。
  • integerToString(num): 将整数转换为字符串。

注意:这些是独立的函数,而不是字符串对象的方法。

代码示例:

#include “strlib.h”
string name = “Marty”;
string shout = toUpperCase(name); // 返回 “MARTY”
int num = stringToInteger(“42”);
string numStr = integerToString(123);

读取用户输入

从控制台读取一行字符串,推荐使用课程库提供的getLine(prompt)函数,它会读取用户输入的全部内容(直到回车),而不仅仅是第一个单词。

代码示例:

#include “simpio.h”
string name = getLine(“What is your name? “);

💎 实践:编写字符串菱形打印函数

最后,我们通过一个练习来巩固对字符串和循环的理解:编写一个nameDiamond函数,它接收一个字符串,并打印出该字符串字母组成的菱形图案。

例如,对于输入”Marty”,输出应为:

M
Ma
Mar
Mart
Marty
 arty
  rty
   ty
    y

实现思路:

  1. 上半部分(递增):循环从索引0到字符串末尾,每次打印从开头到当前索引的子串。
  2. 下半部分(递减):另一个循环,打印逐渐增加的空格,然后打印从当前索引到末尾的子串。

这个练习留给大家课后完成,重点在于熟练运用字符串的.substr()方法和循环控制。


📚 总结

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

  1. 函数原型:在C++中必须先声明后使用,通过函数原型解决。
  2. 参数传递:理解了值语义(传递副本)和引用语义(传递别名,使用&符号)的区别,以及输出参数的用途。
  3. 字符串基础:学习了C++string的基本操作、比较、连接,以及斯坦福strlib库提供的扩展功能。
  4. 实践应用:通过编写二次方程求解函数和构思字符串菱形打印函数,将理论应用于实际问题。

掌握这些函数和字符串的基础知识,是构建更复杂C++程序的基石。

课程20:最小生成树与图的实现 🌳

在本节课中,我们将学习图论中的两个核心概念:最小生成树图的实现方式。我们将首先理解什么是生成树和最小生成树,然后学习一个寻找最小生成树的经典算法。最后,我们将探讨如何在计算机中表示和实现图这种数据结构。


什么是生成树? 🤔

上一节我们介绍了图的基本概念。本节中我们来看看生成树。

生成树是一个无向连通图的子图。它必须满足以下条件:

  1. 包含原图的所有顶点。
  2. 是一个树(即无环且连通)。
  3. 边的数量为 V - 1(其中 V 是顶点数)。

以下是一些判断示例:

  • 如果一个子图包含环,则它不是生成树。
  • 如果一个子图没有连通所有顶点,则它不是生成树。
  • 如果一个子图的边数不是 V - 1,则它不是生成树。

什么是最小生成树? ⚖️

理解了生成树后,我们来看看它的一个特殊变体。

最小生成树是所有生成树中,边的权重总和最小的那一棵。在带权图中,我们通常希望找到成本最低的连接方案。

例如,考虑一个图,如果选择权重为8的边而不是权重为4的边,那么生成树的总成本就会增加,因此它就不是最小生成树。

以下是寻找最小生成树的思路:

  • 目标:连接所有顶点,同时使总边权最小。
  • 它与寻找两点间最短路径的算法不同,最小生成树关注的是整个图的全局最优连接。

克鲁斯卡尔算法 🛠️

为了高效地找到最小生成树,我们需要一个算法。本节介绍克鲁斯卡尔算法。

克鲁斯卡尔算法的工作方式如下:

  1. 将图中所有边按权重从小到大排序(通常使用优先队列)。
  2. 初始化一个空的边集,用于存放最小生成树的边。
  3. 按顺序检查每条边,如果这条边连接了两个当前尚未连通的子图(或集群),则将其加入最小生成树的边集。
  4. 重复步骤3,直到最小生成树中包含 V - 1 条边。

算法的核心在于如何快速判断一条边的两个端点是否已经连通。这通常可以使用并查集这种数据结构高效实现。

以下是算法的一个关键检查步骤:

if (!isConnected(edge.start, edge.end)) {
    addEdgeToMST(edge);
    union(edge.start, edge.end); // 合并两个集群
}

图的实现方式 💻

我们已经讨论了图的算法,现在来看看如何在代码中表示图。

图需要存储顶点和边。有三种常见的实现方式,各有优劣,选择哪种取决于你需要频繁进行哪些操作。

以下是三种主要的实现方法:

  1. 边列表

    • 描述:简单地存储一个包含所有边的列表。
    • 优点:实现简单;遍历所有边很高效。
    • 缺点:查找某条边是否存在或查找某个顶点的所有邻居效率很低,需要遍历整个边列表。
  2. 邻接表

    • 描述:为每个顶点维护一个列表,记录与其相邻的所有顶点(或边)。
    • 优点:查找一个顶点的所有邻居非常高效;添加顶点和边也很快。
    • 缺点:检查任意两个顶点间是否有边存在,需要遍历其中一个顶点的邻居列表。
  3. 邻接矩阵

    • 描述:使用一个 V×V 的二维矩阵。如果顶点 i 和 j 之间有边,则 matrix[i][j] 为1(或边的权重),否则为0。
    • 优点:检查任意两个顶点间是否有边存在速度极快(O(1))。
    • 缺点:占用空间大(O(V²));添加新顶点成本高;查找一个顶点的所有邻居需要遍历整行。

实现选择与算法适配 🤝

不同的图算法适合不同的底层实现。

  • 广度优先搜索:适合使用邻接表,因为需要频繁访问每个顶点的所有邻居。
  • 克鲁斯卡尔算法:适合使用边列表邻接表,因为算法需要遍历所有边。使用邻接矩阵则需要检查 V² 个位置,效率较低。
  • 添加顶点边列表邻接表表现良好,而邻接矩阵则非常低效。

在实际的课程库(如CS106B的 Graph 类)中,通常采用一种混合策略:同时维护一个边集合(便于全局边遍历)和每个顶点的邻接边集合(便于查找邻居),在易用性和常见操作效率之间取得平衡。


总结 📚

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

  1. 生成树 是连通无环且包含所有顶点的子图。
  2. 最小生成树 是权重总和最小的生成树,用于解决如网络布线、道路规划等最低成本连通问题。
  3. 克鲁斯卡尔算法 通过按权重排序边,并逐步合并未连通的集群来构建最小生成树,其高效实现依赖于并查集。
  4. 图的实现 主要有边列表、邻接表和邻接矩阵三种方式,它们在不同的操作(如查找邻居、检查连通性、添加顶点)上各有优劣,需要根据具体算法需求进行选择。

理解这些概念和权衡,将帮助你为不同的问题选择最合适的数据结构和算法。

课程21:哈希表 🗂️

在本节课中,我们将要学习一种称为“哈希表”的数据结构。这是一种非常高效的数据结构,能够实现常数时间复杂度的添加、删除和查找操作。我们将从最简单的想法开始,逐步解决实现过程中遇到的各种问题,最终理解现代编程语言中哈希集和哈希映射背后的核心原理。

上一节我们介绍了多种集合的实现方式及其性能。本节中,我们来看看如何通过哈希表实现近乎完美的常数时间复杂度操作。

概述:哈希的核心思想

这里的背景是,我们一直在学习如何实现各种不同的抽象数据类型(ADT)。我们学习了如何实现向量和链表,也学习了如何使用二叉搜索树来实现集合。

今天,我们将讨论如何实现哈希集哈希映射。这是一个非常巧妙的想法。还记得吗?在哈希集中,添加、删除和搜索操作的时间复杂度都是 O(1)。这是最好的时间复杂度。你可能会好奇,如何能实现一个能“瞬间”找到元素的集合?这正是我们今天要探讨的。

从简单想法开始:直接地址表

假设我们正在尝试实现一个存储整数的集合。为了简单起见,我们先从一组整数开始,之后再扩展到字符串等其他数据类型。

如果我们用之前学过的方式存储,比如用一个未排序的动态数组来实现集合,那么操作的效率如何?

  • 添加:很快,只需放到数组末尾。时间复杂度为 O(1)。
  • 包含(搜索):必须遍历所有元素。时间复杂度为 O(n)。
  • 删除:需要找到元素,然后可能需要移动其他元素来填补空缺。时间复杂度为 O(n)。

对于集合来说,contains(查找)操作是最重要的。如果搜索速度很慢,即使添加很快,体验也会很差。当数据量 n 很大时,O(n) 的搜索时间是不可接受的。

我们学过用二叉搜索树实现集合,其添加、删除和查找操作的时间复杂度都是 O(log n)。这比 O(n) 好得多,但我们能否做得更好呢?

让我们思考一个奇特的想法:如果你把值 n 存储在数组的索引 n 上会怎样?

例如,添加元素 7,就把它放在数组索引 7 的位置。添加元素 2,就放在索引 2 的位置。这样,所有操作会变得非常简单:

  • 添加:直接跳到对应索引,放入值。时间复杂度 O(1)
  • 包含:检查对应索引处是否有值。时间复杂度 O(1)
  • 删除:将对应索引处的值清零。时间复杂度 O(1)

这听起来是个完美的 O(1) 方案!但显然,它存在很多问题。

直面问题与初步解决方案

这个“完美”想法存在哪些问题呢?以下是一些关键的反对意见及其初步解决方案:

  1. 问题:无法存储负数(没有负的数组索引)。

    • 方案:维护两个数组,一个存正数,一个存负数。或者使用绝对值函数。
  2. 问题:无法存储非整数类型(如字符串、浮点数)。

    • 方案:我们稍后再解决这个问题。先专注于让整数版本工作。
  3. 问题:如果要存储的值非常大(比如一百万),就需要一个极其庞大的数组,其中绝大部分空间是空的,造成巨大浪费。

    • 方案:使用一个固定大小的数组,并通过取模运算将大范围的数值映射到小范围的索引上。例如,对于容量为 10 的数组,值 1543 可以存放在索引 1543 % 10 = 3 的位置。

这个“取模”操作引出了哈希的核心概念。

哈希函数与哈希表

我们将一个较大值域的元素映射到一个较小固定范围(通常是数组索引)的过程称为 哈希(Hashing)
用来存储这些值的数组结构称为 哈希表(Hash Table)
执行这种映射的函数称为 哈希函数(Hash Function)

对于我们简单的整数集合,一个基础的哈希函数可以是:
hashCode(value) = abs(value) % arrayCapacity

这个版本的哈希函数仍然有问题:多个不同的值可能会被哈希到同一个索引中。例如,3747 在容量为 10 的数组中,都会映射到索引 7。这个问题称为 冲突(Collision)

冲突是哈希表必须解决的核心问题。我们不能简单地忽略它,否则新值会覆盖旧值,导致数据丢失。

冲突解决策略

有两种主要的冲突解决策略:开放寻址法链地址法

策略一:开放寻址法(线性探测)

这种方法在发生冲突时,将元素放入哈希表中的“下一个”空闲位置。

  • 类比:就像去宿舍,发现你的房间有人,你就搬到隔壁的空房间。
  • 问题:这会使搜索、删除操作变复杂。查找元素时,如果哈希到的位置不是它,就必须线性地向后探测,直到找到该元素或遇到空位。这可能导致元素聚集形成“簇”,最坏情况下,操作时间复杂度会退化到 O(n)。

策略二:链地址法(单独链接)

这是更常用且更好的方法。它不直接在数组的每个槽位存储单个值,而是存储一个链表
哈希表变成了一个“链表数组”。

  • 工作原理:所有哈希到同一索引的元素都被放入该索引对应的链表中。
  • 操作
    • 添加:计算哈希值,找到对应链表,将新节点插入链表头部(O(1))。
    • 包含:计算哈希值,遍历对应链表查找元素。
    • 删除:计算哈希值,在对应链表中找到并删除节点。
  • 优势:即使发生冲突,也只需要在短链表中进行操作。只要链表足够短(后面会讨论如何保证),这些操作的平均时间复杂度仍然是 O(1)

现在,我们来看看如何用代码实现一个基于链地址法的简单哈希集。

代码实现:整数哈希集

以下是一个简化版的整数哈希集(HashIntSet)核心实现框架,展示了添加和查找操作。

class HashNode {
public:
    int data;
    HashNode* next;
    HashNode(int d, HashNode* n = nullptr) : data(d), next(n) {}
};

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/299802e69524603e9c2f7dfb0a7f3212_25.png)

class HashIntSet {
private:
    HashNode** hashTable; // 指针数组,每个元素指向一个链表
    int capacity;         // 数组容量(桶的数量)
    int size;             // 集合中元素总数

    // 哈希函数
    int hashCode(int value) const {
        return abs(value) % capacity;
    }

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/299802e69524603e9c2f7dfb0a7f3212_27.png)

public:
    HashIntSet(int cap = 10) : capacity(cap), size(0) {
        hashTable = new HashNode*[capacity](); // 初始化数组指针为nullptr
    }

    ~HashIntSet() { /* 需要释放所有链表和数组内存 */ }

    // 添加元素
    void add(int value) {
        // 1. 检查是否已存在(避免重复)
        if (contains(value)) {
            return;
        }
        // 2. 计算哈希值
        int index = hashCode(value);
        // 3. 在链表头部插入新节点
        HashNode* newNode = new HashNode(value, hashTable[index]);
        hashTable[index] = newNode;
        size++;
    }

    // 检查元素是否存在
    bool contains(int value) const {
        int index = hashCode(value);
        HashNode* current = hashTable[index];
        // 遍历链表
        while (current != nullptr) {
            if (current->data == value) {
                return true;
            }
            current = current->next;
        }
        return false;
    }

    // 删除元素(略,需处理链表节点删除)
    void remove(int value) { /* ... */ }
};

保证性能:负载因子与重哈希

链地址法在链表较短时能保证 O(1) 性能。但如果所有元素都哈希到同一个桶中,哈希表就退化成了一个链表,性能变为 O(n)。

如何避免这种情况?

  1. 良好的哈希函数:目标是让元素均匀分布到各个桶中。对于整数,取模运算的除数(即数组容量)最好选择一个质数,这可以减少某些模式导致的聚集。
  2. 合适的桶数量(容量):如果元素数量(size)远大于桶数量(capacity),每个链表的平均长度就会变长。我们定义 负载因子(Load Factor) = size / capacity。
  3. 动态重哈希(Rehashing):当负载因子超过某个阈值(例如 0.75)时,我们需要扩大哈希表的容量(例如翻倍),并重新计算所有现有元素的哈希值(因为 capacity 变了),然后将它们插入到新的、更大的哈希表中。这个过程虽然耗时(O(n)),但能显著降低平均负载因子,保证后续操作的效率。

总结与扩展

本节课中我们一起学习了哈希表的核心原理。

  • 我们从“直接地址表”的理想化 O(1) 操作出发。
  • 识别了其存在的问题:负数、大数值、非整数类型。
  • 引入了哈希函数的概念,将大范围数据映射到固定大小的表。
  • 认识到冲突是不可避免的,并学习了两种解决策略,重点是链地址法
  • 通过代码实现了基于链地址法的哈希集,看到了 addcontains 操作如何工作。
  • 最后,我们了解了维持哈希表高性能的关键:良好的哈希函数通过重哈希控制负载因子

哈希表是计算机科学中极其重要和高效的数据结构,它是许多语言中 HashSetHashMapdict 等类型的基础。理解其原理,不仅能帮助你有效使用它们,也能在需要时构建自己的高效存储方案。


附注

  • 哈希表的迭代顺序是不可预测的,因为它取决于哈希函数和桶的遍历顺序。
  • 作业7是一个关于图算法的作业,请及时开始。
  • 哈希表的内容非常丰富,我们还有更多细节(如删除操作、重哈希的具体实现、字符串的哈希函数等)可以在后续深入学习。

课程22:哈希表实现与哈希函数详解 🔍

在本节课中,我们将深入探讨哈希表的实现细节,特别是重新哈希(rehashing)的过程,以及如何设计一个良好的哈希函数。我们将从哈希表的基本概念出发,逐步讲解如何通过重新哈希来维持哈希表的性能,并讨论哈希函数的关键属性及其在实践中的应用。


哈希表与负载系数 📊

上一节我们介绍了哈希表的基本结构,本节中我们来看看如何通过负载系数来评估哈希表的性能。负载系数是一个数字,代表哈希表中链表的平均长度。具体来说,它是哈希表中元素的总数除以桶(bucket)的数量。公式如下:

负载系数 = 元素总数 / 桶的数量

我们希望负载系数保持较小,这样每个桶中的链表平均长度就很短。因为哈希表的目标是实现常数时间的查找和插入操作,如果链表过长,我们就需要遍历很长的链表,从而影响性能。


重新哈希的实现 🔄

当负载系数超过某个阈值时,我们需要执行重新哈希操作。重新哈希的目的是增加桶的数量,从而降低负载系数,保持哈希表的高效性。以下是重新哈希的基本步骤:

  1. 创建一个新的哈希表,其桶的数量通常是原哈希表的两倍。
  2. 遍历原哈希表中的所有元素,根据新的桶数量重新计算每个元素的哈希值。
  3. 将元素插入到新哈希表的对应桶中。
  4. 删除原哈希表,释放内存。

以下是重新哈希的伪代码示例:

void rehash() {
    int newCapacity = capacity * 2;
    HashNode** newTable = new HashNode*[newCapacity];
    for (int i = 0; i < newCapacity; i++) {
        newTable[i] = nullptr;
    }
    for (int i = 0; i < capacity; i++) {
        HashNode* current = table[i];
        while (current != nullptr) {
            int newHash = hashCode(current->data) % newCapacity;
            HashNode* next = current->next;
            current->next = newTable[newHash];
            newTable[newHash] = current;
            current = next;
        }
    }
    delete[] table;
    table = newTable;
    capacity = newCapacity;
}

在重新哈希过程中,我们需要注意内存管理。每次创建新的哈希表时,都需要确保在适当的时候释放旧哈希表的内存,以避免内存泄漏。


哈希函数的设计原则 🛠️

哈希函数是哈希表的核心组成部分。一个好的哈希函数需要满足以下两个关键属性:

  1. 一致性:如果两个对象相等,那么它们的哈希值必须相同。这是因为在查找元素时,我们需要根据哈希值定位到正确的桶。
  2. 均匀分布:哈希函数应该将元素均匀地分布到所有桶中,以避免某些桶过长,从而影响性能。

以下是一些哈希函数的示例及其评价:

  • 返回常数(如42):虽然有效(一致性满足),但分布极差,所有元素都会进入同一个桶。
  • 使用字符串长度:分布较差,因为不同长度的字符串数量有限。
  • 使用字符串的第一个字符:分布仍然不理想,因为只有26种可能(假设为小写字母)。
  • 加权求和:将字符串中每个字符的值乘以一个质数的幂,然后求和。这种方法通常能提供较好的分布。

以下是加权求和哈希函数的示例:

int hashCode(const string& str) {
    int hash = 0;
    for (char c : str) {
        hash = hash * 31 + c;
    }
    return hash;
}

这种哈希函数在Java中被广泛使用,因为它能有效减少冲突,并提供较好的分布。


哈希表的扩展应用 🌐

哈希表不仅用于集合(HashSet),还可以用于映射(HashMap)。在HashMap中,每个节点需要存储键和值两个字段。以下是HashMap与HashSet的主要区别:

  • 添加操作:在HashMap中,添加操作需要检查键是否已存在。如果存在,则更新对应的值;如果不存在,则插入新的键值对。
  • 查找操作:在HashMap中,查找操作根据键返回对应的值。

以下是HashMap中查找操作的伪代码:

Value get(Key key) {
    int hash = hashCode(key) % capacity;
    HashNode* current = table[hash];
    while (current != nullptr) {
        if (current->key == key) {
            return current->value;
        }
        current = current->next;
    }
    return nullptr; // 键不存在
}

布谷鸟哈希 🐦

布谷鸟哈希是哈希表的另一种实现方式,它通过使用两个不同的哈希函数和两个数组来避免链表的使用。在布谷鸟哈希中,每个元素可以放在两个可能的位置之一。查找操作只需要检查这两个位置,因此查找速度非常快。

然而,布谷鸟哈希的插入操作可能比较复杂,因为可能需要踢出已存在的元素并将其移到另一个位置。如果发生循环冲突,可能需要重新哈希或调整哈希函数。


哈希在密码学中的应用 🔐

哈希函数在密码学中也有重要应用。例如,密码通常以哈希值的形式存储,而不是明文。一个好的密码哈希函数需要满足以下要求:

  1. 单向性:从哈希值很难反推出原始密码。
  2. 抗碰撞性:很难找到两个不同的密码具有相同的哈希值。

常用的密码哈希函数包括SHA-256等。这些函数通过添加随机盐(salt)来进一步增加安全性,防止字典攻击。


总结 📝

本节课中我们一起学习了哈希表的实现细节,包括重新哈希的过程和哈希函数的设计原则。我们还探讨了哈希表在集合、映射以及密码学中的应用。哈希表是计算机科学中非常重要的数据结构,掌握其原理和实现方式对于编写高效的程序至关重要。

通过本节课的学习,你应该能够理解如何通过重新哈希来维持哈希表的性能,以及如何设计一个良好的哈希函数。同时,你也了解了哈希表在不同场景下的应用,为后续的学习和实践打下了坚实的基础。

课程23:排序算法详解 🧮

在本节课中,我们将学习几种经典的排序算法。我们会从简单的算法开始,逐步深入到更高效、更巧妙的算法。理解这些算法的工作原理、时间复杂度和适用场景,对于解决编程问题和优化程序性能至关重要。

概述

排序是计算机科学中的一个基本问题,目标是将一组数据按照某种特定顺序重新排列。不同的数据类型有不同的“自然”顺序,例如数字可以按大小排序,字符串可以按字母顺序排序。但有时我们也需要根据其他标准(如长度)进行排序。今天,我们将介绍几种不同的排序算法,分析它们的运行时间,并理解其背后的思想。

冒泡排序 (Bogo Sort)

首先,我们来看一个非常低效但有趣的算法——冒泡排序。这个算法并非真正的实用算法,但它能帮助我们理解排序问题的本质。

冒泡排序的思路是:随机打乱数组,然后检查数组是否已经排好序。如果是,则停止;如果不是,则重复这个过程。这就像从一副扑克牌中随机抽取,期望某次恰好抽到完全按顺序排列的牌。

以下是该算法的核心逻辑描述:

while (!isSorted(array)) {
    shuffle(array);
}

时间复杂度分析

  • 最好情况:如果输入数组已经是排序好的,那么只需要一次检查,时间复杂度为 O(n)
  • 最坏/平均情况:算法可能永远无法得到正确排序,或者需要极长的时间。理论上,单次随机打乱得到正确顺序的概率是 1/(n!),因此平均时间复杂度是 O(n!),这是非常糟糕的。

显然,我们永远不会在实际中使用冒泡排序。介绍它只是为了对比和引出更高效的算法。

选择排序 (Selection Sort)

上一节我们看了一个“碰运气”的算法,本节我们来看第一个实用的算法——选择排序。它的思想直观且易于实现。

选择排序的工作方式是:在数组中扫描一遍,找到最小的元素,将其与数组第一个位置的元素交换。然后,从第二个位置开始扫描,找到剩余元素中的最小值,将其交换到第二个位置。如此重复,直到整个数组排序完成。

以下是该过程的伪代码描述:

for i from 0 to n-1:
    minIndex = i
    for j from i+1 to n:
        if array[j] < array[minIndex]:
            minIndex = j
    swap(array[i], array[minIndex])

时间复杂度分析
选择排序需要进行大约 n + (n-1) + ... + 2 + 1 次比较。这个数列的和是 n(n+1)/2,因此选择排序的时间复杂度是 O(n²)。无论输入数据是已经排序、完全逆序还是随机,它都需要同样的时间,即最好、最坏和平均情况都是 O(n²)

插入排序 (Insertion Sort)

理解了选择排序后,我们来看另一个 O(n²) 级别的算法——插入排序。它模拟了人们手动排序的方式,例如整理一手扑克牌。

插入排序将数组视为两部分:前面是已排序部分,后面是未排序部分。算法开始时,已排序部分只包含第一个元素。然后,它依次将未排序部分的每个元素“插入”到已排序部分的正确位置,从而逐步扩大已排序区域。

以下是该过程的步骤描述:

  1. 从第二个元素开始(索引 i = 1)。
  2. 将该元素(称为 key)与前面已排序的元素从后向前比较。
  3. 如果 key 比前面的元素小,则将前面的元素向后移动一位。
  4. 重复步骤3,直到找到 key 的正确位置,将其放入。
  5. 对下一个未排序元素重复上述过程。

时间复杂度分析

  • 最坏/平均情况:需要嵌套循环,时间复杂度为 O(n²)。但由于其内部循环的移动操作比选择排序的交换操作更高效,所以实际常数因子通常比选择排序小。
  • 最好情况:如果输入数组已经基本有序或完全有序,插入排序只需要进行少量的比较和移动,此时时间复杂度接近 O(n)。这是插入排序的一个显著优点。

归并排序 (Merge Sort)

前面介绍的算法都是 O(n²) 级别的。现在,我们来看一个更高效、采用“分治法”的算法——归并排序。它的性能有了质的飞跃。

归并排序的核心思想是“分而治之”:

  1. :将数组递归地分成两半。
  2. :对每一半分别进行排序(递归调用归并排序本身)。
  3. :将两个已排序的半部分合并成一个完整的已排序数组。

合并两个已排序数组是归并排序的关键步骤。我们可以使用两个指针分别指向两个数组的起始位置,比较指针所指的元素,将较小的元素放入结果数组,并移动相应的指针,直到所有元素都合并完毕。

以下是归并排序的递归框架:

vector<int> mergeSort(vector<int>& v) {
    if (v.size() <= 1) return v; // 基本情况
    int mid = v.size() / 2;
    vector<int> left = mergeSort(v的前半部分);
    vector<int> right = mergeSort(v的后半部分);
    return merge(left, right); // 合并两个已排序数组
}

时间复杂度分析
归并排序不断地将问题规模减半,需要进行大约 log₂n 层分割。在每一层,合并所有子数组的总工作量是 O(n)。因此,归并排序的总时间复杂度是 O(n log n)。与 O(n²) 相比,当 n 很大时,O(n log n) 的效率要高得多。

额外优势
归并排序的“分”步骤天然适合并行计算。我们可以将不同的子数组分配给不同的处理器同时进行排序,最后再合并结果,这能极大地提高在大规模数据上的排序速度。

总结

本节课我们一起学习了四种排序算法:

  1. 冒泡排序:一个低效的理论示例,时间复杂度为 O(n!)
  2. 选择排序:直观但效率不高,在任何情况下的时间复杂度都是 O(n²)
  3. 插入排序:模拟人工排序,平均为 O(n²),但对近乎有序的数据效率很高,最好情况可达 O(n)
  4. 归并排序:采用分治法的高效算法,时间复杂度为 O(n log n),并且易于并行化。

理解这些算法的差异和适用场景,是成为一名优秀程序员的重要基础。在接下来的课程中,我们将继续探索C++中其他的编程抽象概念。

课程名称:CS106B C++中的抽象编程 · 第24讲:继承

概述 📚

在本节课中,我们将要学习面向对象编程中的一个核心概念——继承。我们将探讨如何通过继承来建立类之间的关系、减少代码冗余,并允许客户端以统一的方式处理不同类型的对象。课程将通过一个员工系统的例子,详细讲解继承的语法、覆盖方法、构造函数调用以及需要注意的潜在问题。


什么是继承? 🔗

上一节我们介绍了类的基本概念,本节中我们来看看如何让不同的类之间建立联系。继承是一种表明两个类相关的方法,也是一种在类之间共享代码、最小化冗余的手段。它允许我们创建一个层次结构。

在继承中,我们有一个超类(或父类)和一个子类(或派生类)。子类会继承超类的所有数据和成员函数(行为)。通常,子类可以做超类能做的所有事情,并且可能以不同的方式或更好的方式实现某些功能。

一个常见的例子是图形对象库。例如,GOval(椭圆)、GLine(线)、GLabel(标签)都是GObject的子类。它们共享一些共同行为,比如知道自己在屏幕上的位置(getX, getY)、拥有颜色等。但每个子类也有自己独特的行为,比如GLabel有字体属性,而GOval则没有。

通过创建这种层次结构,我们可以利用通用代码,同时为特定类实现独有的方法。


为何使用继承? 🤔

我们使用继承主要有两个原因:

  1. 表明类之间的关系:明确表达“律师是一种员工”这样的关系。
  2. 减少代码冗余:将通用的代码放在超类中,避免在多个子类中重复编写。

为了说明这一点,我们将实现一个简单的员工系统。


员工系统示例 👥

假设我们有一个Employee(员工)基类,所有员工都有一些共同属性,比如姓名、时薪、休假天数。

class Employee {
public:
    virtual std::string getName();
    virtual double getHourlyWage();
    virtual int getVacationDays();
    virtual std::string getVacationForm();
    // ... 其他方法
};

现在,我们想创建Lawyer(律师)类。律师是一种员工,因此他们拥有员工的所有属性和行为。此外,律师还拥有自己独特的行为,比如sue(起诉)。

如果不使用继承,我们可能需要在Lawyer类中重新定义所有Employee已有的方法,这会导致大量代码重复。

以下是使用继承的Lawyer类定义:

class Lawyer : public Employee {
public:
    void sue(std::string person);
    // 不需要重新定义 getName, getHourlyWage 等方法,它们已从 Employee 继承。
};

这里的语法 class Lawyer : public Employee 表示 Lawyer 类公开继承自 Employee 类。这意味着 Lawyer 对象自动拥有了 Employee 的所有公共成员函数。


覆盖方法 ✏️

上一节我们让子类继承了超类的行为,但有时我们希望子类对某些行为有不同的实现。例如,假设所有员工的休假表格默认是黄色的,但律师的休假表格是粉红色的。

我们可以在 Lawyer 类中重新定义 getVacationForm 方法。这个过程称为覆盖

在C++中,为了正确地覆盖超类中的方法,我们需要在超类的方法声明前使用 virtual 关键字。

Employee 类中:

virtual std::string getVacationForm();

Lawyer 类中:

// 在 .h 文件中声明
virtual std::string getVacationForm();

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/c3a6b5f1165f14d59087748c9cf1bf26_24.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/c3a6b5f1165f14d59087748c9cf1bf26_25.png)

// 在 .cpp 文件中实现
std::string Lawyer::getVacationForm() {
    return “pink”;
}

重要提示:在C++中,为了启用覆盖机制,超类中的方法必须声明为 virtual。一个好的习惯是,对于任何可能被子类覆盖的方法,都将其声明为虚拟函数。


调用超类方法 📞

有时,在覆盖一个方法时,我们并不是要完全替换超类的实现,而是在此基础上添加或修改一些功能。

例如,假设EmployeegetSalary方法返回$40,000,而Lawyer的薪水是普通员工的两倍。我们可以在LawyergetSalary方法中调用EmployeegetSalary方法,然后将其结果乘以2。

double Lawyer::getSalary() {
    // 调用超类 Employee 的 getSalary 方法
    return Employee::getSalary() * 2;
}

使用 Employee:: 来明确指定我们调用的是超类的方法,避免递归调用自身的 getSalary 方法。这样做的好处是,如果未来Employee的薪水计算方式改变了(例如,加入了工龄系数),Lawyer的薪水计算会自动基于新的规则调整,无需修改Lawyer类的代码。


构造函数与继承 🏗️

当创建子类对象时,实际上也同时创建了其超类部分的对象。因此,子类的构造函数需要负责初始化其超类部分。

如果超类没有默认构造函数(即不需要参数的构造函数),或者我们希望用特定参数初始化超类,就必须在子类构造函数的初始化列表中显式调用超类的构造函数。

假设Employee类有一个构造函数,接受工作年限作为参数:

Employee::Employee(int yearsWorked) { /* ... */ }

那么,Lawyer类的构造函数需要这样写:

Lawyer::Lawyer(int yearsWorked, std::string lawSchool)
    : Employee(yearsWorked) { // 调用超类构造函数
    // 初始化 Lawyer 特有的成员
    myLawSchool = lawSchool;
}

初始化列表位于构造函数参数列表之后,函数体之前,以冒号开头。


继承的潜在危险与替代方案 ⚠️

虽然继承功能强大,但并非所有“是一种”的关系都适合用继承来实现。滥用继承可能导致违反“里氏替换原则”。

里氏替换原则指出:程序中,超类对象出现的地方,都应该可以透明地替换为其子类对象,而程序的行为不变。

以下是几个不适合使用继承的例子:

  1. Point2D 继承 Point3D:虽然二维点可以看作是z坐标为0的三维点,但Point2D对象不应该有setZ这样的方法。这可能导致意外的行为。
  2. Square 继承 Rectangle:正方形是矩形的一种,但正方形要求长宽相等。如果Square继承Rectangle,并覆盖了setWidthsetHeight方法以保持相等,那么当客户端代码以一个Rectangle的引用操作Square对象时,可能会破坏正方形的约束。
  3. SortedVector 继承 Vector:有序向量在插入元素时会自动排序。如果它继承自普通Vector,那么从Vector继承来的insert方法(在指定位置插入)就会破坏有序性,导致客户端困惑。

在这些情况下,可以考虑以下替代方案:

  • 私有继承:使用 class Lawyer : private Employee。这样,Employee的公共成员在Lawyer中变成了私有成员,客户端无法直接调用。这可以用于“按实现继承”,即只想复用代码,而不想暴露接口。
  • 组合:这是更常用、更灵活的方式。不在类之间建立“是一种”的关系,而是建立“有一个”的关系。例如,SortedVector类内部可以持有一个Vector类型的私有成员变量,然后提供自己的公共接口,在内部调用这个Vector对象的方法。
class SortedVector {
private:
    Vector myElements; // 组合:SortedVector “有一个” Vector
public:
    void insert(int value) {
        // 在 myElements 中插入 value 并保持排序
        // 不暴露 Vector 的非排序插入接口
    }
    // ... 其他方法
};

总结 🎯

本节课中我们一起学习了C++中继承的核心概念。

我们首先了解了继承如何用于表达类之间的关系和共享代码。然后,我们通过员工系统的例子,实践了如何定义继承关系、使用virtual关键字覆盖方法、在子类方法中调用超类实现,以及正确编写子类的构造函数。

最后,我们探讨了继承可能带来的设计问题,特别是违反里氏替换原则的情况,并介绍了私有继承和组合这两种替代方案,以帮助我们构建更健壮、更易维护的面向对象系统。

记住,继承是一个强大的工具,但应当谨慎使用,优先考虑组合而非继承,这通常是更灵活的设计选择。

课程名称:CS106B C++中的抽象编程 · 第25讲:多态性与继承复习 🧩

概述

在本节课中,我们将学习C++中多态性与继承的核心概念,并复习期末考试的相关准备事项。课程内容包括理解多态性的工作原理、如何分析涉及继承的代码问题,以及期末考试的形式和重点。


期末考试信息与复习建议 📝

期末考试将于近期举行。为了帮助大家准备,我们安排了复习会议,并发布了练习题。

复习会议将于本周四晚上7点至8点30分在370教室举行。会议内容不会被录制,但相关幻灯片会在线发布。主讲人是助教Zack和Nolan。

我们发布了一系列练习题,其形式与期中考试的练习题非常相似。建议尽可能多地练习,并模拟真实考试环境进行答题,因为这是最有效的备考方式。

以下是关于期末考试的一些具体信息:

  • 考试形式:考试是开卷的,允许携带参考材料。考试时长等信息将另行通知。
  • 参考表:考试时会提供语法参考表。与期中考试的主要区别在于,不会考察标准库容器的具体实现细节,但你需要理解其用法。此外,参考表会包含本学期新学的图形(Graph)相关内容,例如顶点(Vertex)、边(Edge)的定义和一些算法的伪代码。
  • 考试内容:考试包含阅读代码和编写代码的题目。可能涉及以下主题:
    • 二分搜索、选择排序、归并排序等算法的原理和追踪。
    • 链表操作。
    • 指针的使用。
    • 二叉搜索树(BST)和二叉树遍历。
    • 堆(Heap)的插入与删除操作。
    • 图(Graph)的基本术语(如连通图、有向图)和遍历算法(如BFS广度优先搜索、Dijkstra算法)。
    • 继承与多态性。
    • 递归与递归回溯(如绘制分形图)。
  • 考试重点:重点将放在期中考试之后学习的内容上,特别是链表、树、图、继承和多态性。虽然递归是树结构的基础,但考试可能侧重于这些数据结构的应用。
  • 不考察的内容:多重继承、私有继承、运算符重载以及斯坦福特定库的深入细节将不在考试范围内。

对于期末主题有任何疑问,请务必参加复习会议、完成练习题,并参加本周的常规办公时间进行咨询。


多态性(Polymorphism)的核心概念 🔄

上一节我们介绍了期末考试的概况,本节中我们来看看一个核心考点:多态性。

多态性允许我们以统一的方式处理不同类型的对象。从客户(使用类的代码)的角度来看,多态性的主要优点是:客户可以用相同的方式处理不同的对象,而无需关心对象的具体类型。

例如,可以定义一个“动物”基类,它有speak()方法。然后,“狗”和“鸭”子类各自以不同的方式(汪汪叫或嘎嘎叫)重写speak()方法。客户代码只需要知道它在处理一个“动物”对象,并调用speak()方法,具体发出什么声音由对象的实际类型决定。

在C++中,这通常通过基类(超类)指针或引用来实现。这个指针可以指向任何派生类(子类)的对象。当通过基类指针调用一个被声明为virtual(虚函数)的方法时,程序会在运行时决定调用哪个类(基类或子类)的实现。这就是动态绑定

关键公式/代码描述

class Animal {
public:
    virtual void speak() { cout << "Some sound" << endl; }
};

class Dog : public Animal {
public:
    void speak() override { cout << "Woof!" << endl; } // 重写基类方法
};

class Duck : public Animal {
public:
    void speak() override { cout << "Quack!" << endl; } // 重写基类方法
};

// 客户端代码
Animal* myPet = new Dog();
myPet->speak(); // 输出 "Woof!",尽管 myPet 是 Animal* 类型

myPet = new Duck();
myPet->speak(); // 输出 "Quack!"

virtual关键字使得speak()函数的行为是多态的。override关键字(C++11)明确表示此函数是重写基类的虚函数。


类型转换(Casting)与继承 ⚙️

有时,你可能需要将基类指针转换为其派生类类型,以访问派生类特有的方法。这通过类型转换实现。

类型转换会暂时改变编译器对变量类型的认知。但是,它不会改变对象的实际类型。如果转换是不安全的(例如,试图将一个Employee对象转换为Lawyer,而该对象实际上并不是Lawyer),可能导致程序崩溃或未定义行为。

安全转换的前提是:转换的目标类型必须是对象实际类型的基类或相同类型(即“向上转换”或“平级转换”)。

关键代码描述

Employee* e = new PatentLawyer(); // e 静态类型是 Employee*,动态类型是 PatentLawyer*
// e->filePatent(); // 错误!编译器只知道 e 是 Employee,而 Employee 没有 filePatent 方法

PatentLawyer* pl = static_cast<PatentLawyer*>(e); // 显式类型转换
pl->filePatent(); // 正确,因为 pl 是 PatentLawyer* 类型

需要注意的是,在上面的例子中,转换之所以安全,是因为我们知道e实际指向一个PatentLawyer对象。如果e指向的是一个普通的Employee,那么转换就是错误的。


如何分析继承与多态代码题 📊

在考试中,你可能会遇到需要分析类继承层次和判断程序输出的题目。以下是解决这类问题的系统方法:

  1. 绘制继承图:首先,根据代码理清类之间的继承关系。标出哪个是基类,哪个是派生类。
  2. 列出方法:为每个类列出它显式定义的方法。同时,标出它从基类继承的方法(即未重写的方法)。
  3. 分析调用:对于每一行函数调用代码,确定:
    • 声明类型:等号左侧的变量类型。它决定了编译器在编译时认为可以调用哪些方法。
    • 初始化/实际类型new关键字后面创建的对象类型(等号右侧)。它决定了在运行时,虚函数(virtual)会调用哪个版本。
    • 转换类型(如果存在):在类型转换操作中指定的目标类型。
  4. 应用规则
    • 如果要调用的方法在声明类型中不存在 → 编译错误
    • 如果存在类型转换,且转换类型不是实际类型的基类或相同类 → 运行时崩溃(不安全的转换)。
    • 对于虚函数的调用(普通调用,如 obj->method()),使用实际类型来决定执行哪个版本的方法。
    • 对于非虚函数的调用,或使用了作用域解析运算符的调用(如 BaseClass::method()),使用声明类型或指定的类来决定执行哪个版本的方法。

示例分析框架
假设有类 SnowSleet (继承自 Snow)、Fog (继承自 Sleet)、Rain (继承自 Snow)。
对于代码 Snow* var = new Fog(); var->method1();

  • 声明类型:Snow*
  • 实际类型:Fog*
  • 检查:Snow 是否有 method1?如果有,则编译通过。
  • 调用:因为 method1 是虚函数,所以调用 Fog 类中定义的 method1

总结

本节课中我们一起学习了多态性的核心思想,即“同一接口,不同实现”。我们深入探讨了virtual关键字如何实现动态绑定,使得通过基类指针调用方法时能执行正确的派生类版本。我们还学习了类型转换在继承体系中的用法与风险,并掌握了一套系统的方法来分析涉及继承和多态的复杂代码,以判断其输出或错误。这些概念和技巧是理解面向对象编程高级特性的基础,也是应对期末考试相关题目的关键。

祝大家复习顺利!

课程名称:CS106B C++中的抽象编程 · 第26讲:现实世界中的C++ 🚀

在本节课中,我们将学习C++在实际工业环境中的应用。我们将探讨一些高级C++特性,如模板、标准库容器、迭代器和算法,并了解它们如何使代码更强大、更通用。课程旨在为你提供一个从学术C++到工业C++的过渡视角。

概述:为什么学习C++? 💡

C++是一门功能强大且非常流行的编程语言。它允许你以多种范式进行编程,例如面向对象编程和函数式编程。在行业标准索引中,C++始终位列最受欢迎语言的前五名。它被广泛应用于浏览器、游戏开发、高性能系统,甚至用于实现Java运行时环境。学习C++能让你掌握一门在现实世界中极具价值的工具。

模板:编写通用代码的蓝图 📐

上一节我们介绍了学习C++的动机,本节中我们来看看如何编写更通用、可复用的代码。模板是C++中实现泛型编程的核心特性。

模板的动机

假设你需要一个函数来返回两个整数中的较小值。你可能会这样写:

int min(int a, int b) {
    return (a < b) ? a : b;
}

但如果你还需要处理doublefloatstring类型呢?为每种类型都重写一个几乎相同的函数会导致代码冗余和维护困难。

模板解决了这个问题。它允许你编写一个函数或类的“蓝图”,编译器会根据你使用的具体类型来生成相应的代码。

函数模板

以下是如何将min函数改写为模板函数:

template <typename T>
T min(T a, T b) {
    return (a < b) ? a : b;
}

这里,typename T(或class T)声明了一个通用类型T。当你调用min(3, 5)时,编译器会推断Tint并生成对应的函数。调用min(3.14, 2.71)时,T则为double

核心概念:模板函数依赖于类型的“隐式接口”。只有当传入的类型支持模板函数内部的所有操作(例如比较运算符<)时,代码才能编译通过。

类模板

模板同样适用于类。例如,你之前可能实现过一个仅支持整数的ArrayStack类。使用模板,可以使其适用于任何类型。

以下是修改ArrayStack类头文件(.h)的关键步骤:

  1. 在类声明前添加template <typename ValueType>
  2. 将类内部所有特定的int类型替换为通用的ValueType

在实现文件(.cpp)中,每个成员函数定义前也需要添加template <typename ValueType>,并且类名需要改为ArrayStack<ValueType>

重要细节:在C++中,模板的声明和实现通常必须放在同一个头文件(.h)中,不能分离到.cpp文件。这就是为什么斯坦福库(如VectorMap)的代码都直接写在头文件里。

标准库容器:从“斯坦福库”到“标准库” 📦

在课程中,你一直使用的是斯坦福大学提供的简化版容器库(如VectorMap)。本节我们将了解它们在C++标准库(STL)中的对应物。

斯坦福库的设计初衷是提供更好的错误信息和更简单的接口,以帮助你专注于核心概念的学习。然而,在现实世界的C++编程中,使用的是标准库。

以下是主要容器的对应关系:

  • 斯坦福库Vector, Map, Set, Stack, Queue, PriorityQueue
  • C++标准库vector, map / unordered_map, set / unordered_set, stack, queue, priority_queue

标准库容器的方法名称略有不同。例如,向vector末尾添加元素是push_back,而不是斯坦福Vectoradd。在索引处插入元素需使用insert方法并配合迭代器。

迭代器:遍历容器的通用指针 🧭

我们已经了解了标准库容器,但如何遍历像setmap这样没有天然索引的非顺序容器呢?本节引入迭代器的概念。

迭代器是抽象了“位置”概念的对象,它提供了一种统一的方式来遍历和访问任何容器中的元素,无论其内部结构如何。你可以将迭代器想象成一个智能指针,它知道如何在特定容器中移动。

使用迭代器

以下是使用迭代器遍历一个set<int>的基本模式:

std::set<int> mySet = {1, 2, 3};
// 获取指向第一个元素的迭代器
std::set<int>::iterator it = mySet.begin();
// 获取尾后迭代器(最后一个元素之后的位置)
std::set<int>::iterator end = mySet.end();

while (it != end) {
    std::cout << *it << std::endl; // 解引用迭代器以访问元素
    ++it; // 将迭代器前进到下一个位置
}
  • .begin():返回指向容器第一个元素的迭代器。
  • .end():返回指向容器“尾后”位置的迭代器,这是一个哨兵值,用于判断循环结束。
  • *it:解引用迭代器,获取它当前指向的元素。
  • ++it:将迭代器向前移动一位。

基于范围的for循环:你熟悉的for (int x : mySet)语法实际上是迭代器遍历的语法糖,编译器会将其转换为上述形式的代码。

算法:搭配迭代器的强大工具 ⚙️

既然我们已经掌握了迭代器,本节来看看如何将它们与标准库算法结合使用。标准库提供了一系列通用算法(如排序、查找),它们通过迭代器操作容器,从而与容器类型解耦。

这些算法定义在<algorithm>头文件中。它们之所以能处理各种容器,正是因为它们只依赖于迭代器提供的通用接口(如++*!=),而不关心底层是vectorlist还是set

以下是一些常用算法的例子:

  • std::sort(vec.begin(), vec.end()):对vector进行排序。
  • auto maxIt = std::max_element(vec.begin(), vec.end()):查找范围内的最大元素,返回指向它的迭代器。
  • std::copy(source.begin(), source.end(), dest.begin()):将一个范围复制到另一个位置。

使用这些预定义的算法可以极大提高开发效率,并减少自己实现时可能出现的错误。

其他实用特性:auto 与 Lambda 表达式 ✨

最后,我们来快速了解两个在现代C++中非常实用的特性,它们能让代码更简洁、更清晰。

auto 类型推导

auto关键字让编译器自动推导变量的类型。这在类型名称很长或很复杂时特别有用,尤其是在使用迭代器时。

std::vector<std::string> names = {"Alice", "Bob"};
// 无需写出冗长的类型
for (auto it = names.begin(); it != names.end(); ++it) {
    std::cout << *it << std::endl;
}
// 或者更简洁的基于范围的for循环
for (const auto& name : names) {
    std::cout << name << std::endl;
}

Lambda 表达式

Lambda表达式允许你内联定义一个匿名函数对象,常用于需要向算法传递简短逻辑的场景,例如自定义排序规则。

std::vector<int> numbers = {3, 1, 4, 1, 5};
// 使用lambda表达式按降序排序
std::sort(numbers.begin(), numbers.end(),
          [](int a, int b) { return a > b; });

[](int a, int b) { return a > b; }就是一个lambda表达式,它定义了一个接受两个整数并返回比较结果的函数。

总结 🎯

本节课我们一起学习了如何将学术C++知识过渡到工业实践。我们探讨了模板如何帮助我们编写通用代码,认识了C++标准库容器与其斯坦福版本的对应关系。我们深入了解了迭代器作为遍历容器通用抽象的重要性,并看到了标准库算法如何与迭代器配合提供强大功能。最后,我们简要介绍了auto类型推导Lambda表达式这两个现代C++的实用特性。

记住,这些高级特性的目标是编写更健壮、更可复用和更高效的代码。鼓励你在未来的项目和深入学习中探索这些强大的工具。

课程名称:CS106B C++中的抽象编程 · 第27讲:课程总结与考试指南 📚

在本节课中,我们将回顾整个季度的学习内容,并详细说明期末考试的形式、范围以及备考策略。我们还将探讨完成本课程后,在计算机科学领域的后续学习路径和发展机会。

期末考试安排与概述 📝

期末考试将于周一上午8:30举行。考试地点根据学生姓氏的首字母进行分配。请务必准时到场,迟到者不会获得额外考试时间。强烈建议设置多个闹钟以确保准时起床。

考试内容与主题 📖

期末考试将涵盖本学期所学的核心主题。虽然考试重点在于期中考试之后的新材料,但期中之前的基础知识(如递归、指针)仍然是解决新问题的必备工具,因此同样需要掌握。

以下是期末考试的主要主题列表:

  • 链表:包括链表代码阅读与编写问题。
  • 二叉树与二叉搜索树:包括树的构建、遍历、节点操作以及平衡性判断。
  • 回溯算法:用于尝试所有可能解决方案的问题。
  • :包括图的属性(如有向/无向、连通性)、表示方法(邻接表/矩阵)以及搜索算法(如BFS, DFS, Dijkstra)的追踪。A*算法若涉及,会提供启发式函数。
  • 哈希表:包括插入元素、处理冲突以及重新哈希后的结构变化。
  • 集合
  • 排序算法:如选择排序、插入排序、归并排序。不涉及快速排序。
  • 继承:包括代码阅读(分析类层次结构、方法覆盖与调用)和代码编写(扩展现有类)。

考试将提供参考手册,其中包含基本ADT(如图、集合)的常用方法以及部分算法(如A*)的伪代码。

具体题型与备考策略 🎯

上一节我们介绍了考试的整体范围,本节中我们来看看各类主题可能出现的具体题型以及备考建议。

以下是各类主题的典型考查方式:

  • 链表:可能会提供一段操作链表的代码,要求你分析其功能或结果。也可能要求你编写一个函数来执行特定的链表操作。
  • 二叉树:对于二叉搜索树,可能会要求你根据给定序列构建一棵树,或进行遍历、删除节点等操作。对于普通二叉树,可能会要求你编写递归函数进行搜索或修改。
  • :问题可能包括:判断图的性质、写出图的某种表示、或者追踪图搜索算法的执行步骤。
  • 继承(阅读):会给出具有继承关系的类定义和一段客户端代码,要求你分析哪些调用能通过编译,以及运行时实际执行的是哪个类的方法。
  • 继承(写作):要求你扩展现有类,通过重写或添加方法来改变或增加其行为。
  • 哈希:要求你展示向哈希表中插入一系列元素后,哈希表(包括可能的链式桶)的状态。

备考的最佳方式是仔细研究发布的所有模拟考试题。实际考试题型将与模拟题类似。务必动手练习代码编写和追踪。

本季度核心概念总结 💡

在深入考试细节后,让我们退一步,回顾一下本季度我们共同学习的核心编程概念。

我们学习了C++编程语言的基础,并深入理解了多种数据结构和算法。这些是计算领域的基石,未来在任何编程工作中都会频繁使用。

以下是本季度涵盖的核心知识领域:

  • 数据结构:我们不仅学习了如何使用向量、网格、栈、队列、集合、映射,还深入理解了链表、二叉树、堆、图、哈希表等结构的内部实现原理。理解这些有助于你权衡不同结构的性能(时间复杂度)。
  • 算法:我们学习了递归、回溯、多种搜索(如图搜索)、排序算法以及排列生成算法。递归是解决特定类型问题(如树形结构处理)的强大工具。
  • 面向对象编程:我们初步接触了类、对象和继承的概念。这为将来学习如何将大型问题分解为类与对象的系统奠定了基础。

后续课程与学习路径 🚀

掌握了CS106B的内容后,你为学习更高级的计算机科学课程做好了准备。接下来常见的两门课程是CS107和CS103。

CS107: 计算机组成与系统
这门课程深入研究计算机底层工作原理,包括处理器、内存、数据表示以及C/C++程序如何被编译和执行为机器指令。它将强化你对指针和内存管理的理解。虽然课程挑战性较大,需要更强的独立调试能力,但它为后续众多高级课程(如操作系统、编译原理)奠定了基础。此外,还有CS107E(嵌入式系统)版本,专注于为树莓派等微型计算机编程。

CS103: 计算理论基础
这是一门偏重数学的课程,探讨计算的数学基础。内容包括形式化逻辑、可计算性、算法复杂性以及证明技术。它帮助你理解计算机能力的根本极限,以及不同计算模型之间的关系。这门课由多位优秀的讲师任教,能培养严谨的计算思维。

其他机会与资源 🌟

除了核心课程,斯坦福大学还提供丰富的资源来支持你的成长。

以下是一些值得探索的方向:

  • CS9: 编程面试准备课程:在秋季学期开设,专门帮助学生准备技术面试,提供简历修改、模拟面试和算法练习。
  • CS课程目录:可以关注CS109(概率论)、CS110(操作系统)、CS193系列(专题课程,如移动应用开发)等课程。
  • 专业与辅修:可以考虑主修或辅修计算机科学。结合其他领域的“CS+X”联合专业也极具价值。
  • 自学与在线资源:互联网上有大量优质的编程教程和实践平台,你可以自学Web开发、移动应用开发等特定技能。
  • 实习:许多公司提供针对低年级学生的实习机会。可以关注斯坦福的职业发展平台和邮件列表。
  • 成为课程助教:如果你热爱这门课程并乐于助人,未来可以考虑申请成为课程助教,这是一个非常有价值的经历。

课程总结与祝福 ✨

本节课中,我们一起回顾了期末考试的详细安排、核心考点以及备考策略。我们也总结了本季度在数据结构、算法和面向对象编程方面取得的主要成果,并展望了后续在CS107、CS103等课程中的学习路径,以及通过实习、自学和参与教学来深化计算机科学技能的各种机会。

感谢大家本季度的努力与参与。祝愿各位在期末考试中取得优异成绩,并在计算机科学的道路上继续探索前行。我们周一考场见!

课程03:C++中的文件读取与集合基础 📚

在本节课中,我们将学习如何在C++中读取文件,并初步了解两种重要的数据结构:向量(Vector)和网格(Grid)。这些是构建更复杂程序的基础工具。

文件读取 📄

上一节我们讨论了字符串,本节中我们来看看如何从外部文件读取数据。在C++中,文件读取是通过称为“流”(stream)的对象完成的。

文件读取的基本模式

以下是使用ifstream库读取文件的典型示例。您需要声明一个ifstream类型的变量,打开文件,然后从中读取数据。

#include <fstream>
#include <iostream>
#include <string>
using namespace std;

int main() {
    ifstream input;
    input.open("filename.txt");
    string line;
    while (getline(input, line)) {
        cout << line << endl;
    }
    input.close();
    return 0;
}

在这段代码中,getline函数会尝试从输入流中读取一行。如果成功读取,则返回true,循环继续;如果到达文件末尾或读取失败,则返回false,循环停止。

检查文件是否成功打开

如果您尝试打开一个不存在的文件,程序不会崩溃,但读取操作会立即失败。您可以使用.fail()方法来检查最后一次操作是否失败。

if (input.fail()) {
    cout << "无法打开文件。" << endl;
} else {
    // 正常读取文件
}

按单词(令牌)读取

有时您需要按单词而不是按行读取文件。这可以通过流提取运算符>>来实现,它会自动按空白字符(空格、制表符、换行符)分割输入。

string token;
while (input >> token) {
    cout << token << endl;
}

这段代码会逐个读取文件中的单词并打印出来。while (input >> token)这个条件本身就在尝试读取并将结果存入token,如果读取成功则循环继续。

处理混合数据类型

如果文件中混合了字符串和整数,而您尝试将非数字内容作为整数读取,操作会失败。一种策略是先以字符串形式读取所有内容,然后判断哪些可以转换为整数。

#include "strlib.h" // 假设有字符串转换工具库
string token;
while (input >> token) {
    if (stringIsInteger(token)) {
        int num = stringToInteger(token);
        cout << "找到整数: " << num << endl;
    }
}

集合简介:向量(Vector) 📦

现在,我们转向用于存储多个数据元素的数据结构,即“集合”。首先介绍的是向量(Vector)。

向量是一个可以动态改变大小的元素序列,类似于其他语言中的ArrayList。在C++中,原生数组功能有限且不安全,因此我们主要使用向量。

向量的声明与基本操作

要使用向量,需要包含头文件#include "vector.h"

Vector<int> numbers; // 声明一个整数向量
numbers.add(42);     // 在末尾添加元素
numbers.insert(2, 99); // 在索引2处插入99
int val = numbers[1]; // 获取索引1处的元素
numbers.remove(0);   // 删除索引0处的元素

遍历向量

有多种方法可以遍历向量中的所有元素。

  1. 使用传统的for循环和索引:

    for (int i = 0; i < numbers.size(); i++) {
        cout << numbers[i] << endl;
    }
    
  2. 使用“增强型for循环”(范围for循环):

    for (int num : numbers) {
        cout << num << endl;
    }
    

    这种语法更简洁,适用于只需从头到尾顺序访问元素的情况。

向量的insertremove操作可能会导致元素移位,如果向量很大,这可能影响效率,我们将在后续课程中讨论这一点。

集合简介:网格(Grid) 🗺️

接下来,我们看看另一种集合——网格(Grid)。网格是一个二维数据结构,非常适合表示棋盘、图像像素或电子表格等矩形数据。

网格的声明与基本操作

要使用网格,需要包含头文件#include "grid.h"

Grid<int> matrix(3, 4); // 声明一个3行4列的整数网格
matrix[0][1] = 5;       // 在第0行、第1列设置值
int value = matrix[2][3]; // 获取第2行、第3列的值
int rows = matrix.numRows(); // 获取行数
int cols = matrix.numCols(); // 获取列数

访问元素时,第一组方括号是行索引,第二组是列索引。

遍历网格

遍历网格通常需要嵌套循环。

  1. 按行主序遍历(逐行遍历):

    for (int r = 0; r < grid.numRows(); r++) {
        for (int c = 0; c < grid.numCols(); c++) {
            cout << grid[r][c] << " ";
        }
        cout << endl;
    }
    
  2. 使用增强型for循环遍历所有元素:

    for (int value : grid) {
        cout << value << endl;
    }
    

    这种遍历方式不关心行列位置,只是按存储顺序访问每个元素。

向函数传递集合

集合可能包含大量数据,按值传递会导致完整的复制,效率低下。因此,我们通常通过引用来传递集合。如果函数不需要修改集合的内容,可以加上const关键字将其声明为常量引用,这样更安全且能表达意图。

// 计算网格中所有元素之和,不需要修改网格,所以使用const引用
int sumGrid(const Grid<int>& grid) {
    int total = 0;
    for (int value : grid) {
        total += value;
    }
    return total;
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/c19076093600f9f1000b7b4bf22e053b_41.png)

// 反转网格的内容,需要修改原网格,所以使用普通引用
void invertGrid(Grid<int>& grid) {
    // ... 反转逻辑
}

本节课总结 🎯

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

  1. 文件读取:如何使用ifstream打开文件,按行或按单词读取内容,并处理可能的错误。
  2. 向量(Vector):一种动态的一维数组,学习了其基本操作和遍历方法。
  3. 网格(Grid):一种二维数据结构,适用于存储矩形数据,学习了其声明、访问和遍历方式。

这些知识是完成后续作业(如“生命游戏”)的基础,在该作业中,您将需要从文件读取数据到网格中,并对其进行处理。请利用这些工具开始您的编程实践。

课程04:栈与队列 🧱🚶

在本节课中,我们将学习两种重要的数据结构:栈(Stack)和队列(Queue)。它们是抽象数据类型(ADT)的典型代表,虽然功能看似简单,但在特定场景下能提供高效的操作。我们将了解它们的基本概念、操作方法,并通过实际编程问题来掌握其应用。

向量回顾与抽象数据类型

上一节我们介绍了向量(Vector),它是一种支持通过索引随机访问元素的动态数组。本节中我们来看看抽象数据类型(ADT)的概念。

抽象数据类型(ADT)指的是一组操作(如添加、删除、查询大小等)的规范,而不关心其内部如何实现。例如,向量和链表(Linked List)都支持相同的“列表操作”ADT,但内部实现不同,这导致了它们在性能上的差异。

  • 向量:内部通常使用数组存储。在中间插入或删除元素时,需要移动后续元素,时间复杂度可能为 O(n)。
  • 链表:内部由相互链接的节点组成。插入或删除元素时,只需改变节点间的链接,时间复杂度为 O(1),但随机访问元素效率较低。

这种“相同的操作,不同的内部实现”正是ADT的核心思想。选择哪种实现,往往需要在操作的灵活性、时间效率和内存使用之间进行权衡。

栈(Stack)📚

栈是一种“后进先出”(LIFO)的数据结构,就像一摞盘子。你只能从最顶部(栈顶)放入(推入)或拿走(弹出)盘子。

以下是栈支持的核心操作:

  • push(value):将元素推入栈顶。
  • pop():移除并返回栈顶元素。
  • peek():返回栈顶元素但不移除它。
  • isEmpty():检查栈是否为空。
  • size():返回栈中元素数量。

栈在计算机科学中应用广泛,例如:

  • 函数调用栈:记录函数调用和返回地址。
  • 表达式求值:处理运算符优先级。
  • 撤销操作:文字处理软件中的撤销功能通常使用栈来实现。

使用栈时需要注意,不能通过索引(如 stack[i])访问中间元素。遍历栈的唯一方式是不断弹出元素,但这会清空栈。

// 遍历栈(会清空栈)
while (!stack.isEmpty()) {
    string top = stack.pop();
    // 处理 top
}

栈的应用示例:括号匹配

让我们通过一个编程问题来理解栈的用途:检查一段代码中的圆括号 () 和花括号 {} 是否匹配正确。

算法思路如下:

  1. 遍历字符串中的每个字符。
  2. 如果遇到开括号(({),将其推入栈中。
  3. 如果遇到闭括号()}):
    • 检查栈是否为空。若为空,说明没有对应的开括号,匹配失败。
    • 检查栈顶元素是否是与当前闭括号匹配的开括号。若不匹配,则失败。
    • 若匹配,则将栈顶元素弹出。
  4. 遍历结束后,检查栈是否为空。若不为空,说明有开括号未被关闭,匹配失败。

以下是核心代码框架:

int checkBalance(string code) {
    Stack<char> s;
    for (int i = 0; i < code.length(); i++) {
        char ch = code[i];
        if (ch == '(' || ch == '{') {
            s.push(ch); // 开括号入栈
        } else if (ch == ')' || ch == '}') {
            if (s.isEmpty()) {
                return i; // 栈空,无匹配开括号
            }
            char top = s.peek();
            if ((ch == ')' && top != '(') || (ch == '}' && top != '{')) {
                return i; // 栈顶括号不匹配
            }
            s.pop(); // 匹配成功,弹出栈顶
        }
    }
    // 遍历结束,检查栈是否为空
    if (s.isEmpty()) {
        return -1; // 完全匹配
    } else {
        return code.length(); // 有未关闭的开括号
    }
}

队列(Queue)🛒

队列是一种“先进先出”(FIFO)的数据结构,就像排队等待。新来的人排在队尾(入队),服务从队头的人开始(出队)。

以下是队列支持的核心操作:

  • enqueue(value):将元素加入队尾。
  • dequeue():移除并返回队头元素。
  • peek():返回队头元素但不移除它。
  • isEmpty():检查队列是否为空。
  • size():返回队列中元素数量。

队列的典型应用场景包括:

  • 打印任务队列:管理等待打印的文档。
  • 消息队列:在网络通信或分布式系统中缓冲消息。
  • 广度优先搜索:在图算法中常用。

与栈类似,队列也不支持通过索引随机访问元素。遍历队列通常意味着不断出队处理元素。

// 遍历队列(会清空队列)
while (!queue.isEmpty()) {
    string front = queue.dequeue();
    // 处理 front
}

队列与栈的联合应用示例:镜像队列

假设我们有一个队列,内容为 ["A", "B", "C"]。我们希望修改这个队列,使其内容变为 ["A", "B", "C", "C", "B", "A"],即原内容后接其镜像反转。

我们可以借助一个栈来完成这个任务:

  1. 首先,我们需要保留原队列的顺序。一种方法是先获取队列的原始大小。
  2. 然后,将队列中的每个元素依次出队,同时做两件事:将其重新入队(以保留原序),并将其推入一个栈(以反转顺序)。
  3. 此时,队列恢复了原始顺序 ["A", "B", "C"],而栈中从顶到底为 ["C", "B", "A"]
  4. 最后,将栈中的所有元素依次弹出并加入队尾,即可得到最终结果。

以下是实现代码:

void mirror(Queue<string>& q) {
    Stack<string> s;
    int originalSize = q.size();

    // 第一遍:出队、重新入队、同时入栈
    for (int i = 0; i < originalSize; i++) {
        string front = q.dequeue();
        q.enqueue(front); // 重新入队,保持原序
        s.push(front);    // 入栈,为反转做准备
    }

    // 第二遍:将栈中元素(反转顺序)加入队尾
    while (!s.isEmpty()) {
        q.enqueue(s.pop());
    }
}
// 操作后,队列 q 变为 ["A", "B", "C", "C", "B", "A"]

注意:在第一个循环中,我们使用固定的 originalSize 来控制循环次数,而不是 while (!q.isEmpty())。这是因为我们在循环内部会将元素重新加入队列,如果使用 isEmpty() 判断,循环将永远不会结束。

总结 🎯

本节课中我们一起学习了两种基础但强大的数据结构:栈和队列。

  • 遵循后进先出(LIFO)原则,核心操作是 pushpoppeek,常用于需要“回溯”或“撤销”的场景。
  • 队列遵循先进先出(FIFO)原则,核心操作是 enqueuedequeuepeek,常用于管理需要按序处理的任务。
  • 它们都是抽象数据类型(ADT)的具体实现,提供了受限但高效的操作接口。理解其特性有助于我们在解决特定问题时选择最合适的数据结构,例如用栈检查括号匹配,用队列和栈结合来生成镜像序列。

课程05:集合与映射 🗂️

在本节课中,我们将学习两种新的抽象数据类型(ADT):集合(Set)映射(Map)。我们还将初步了解如何分析算法的效率,即 大O表示法(Big O Notation)。这些数据结构能帮助我们更高效地解决编程问题,例如统计文本中不重复的单词数量。


作业与合作伙伴 👥

在深入课程内容之前,先提一下第二项作业。作业允许两人合作完成。我们强烈建议你找一个合作伙伴。合作编程能帮助你更好地讨论问题、共同解决难题,并且通常能写出更优质的程序,学到更多知识。

如果你需要寻找合作伙伴,可以在课程论坛上发帖,或者向你的课程助教寻求帮助。关于合作编程的具体策略,网站上提供了详细的指南,建议你阅读。


问题引入:统计唯一单词数 📖

让我们从一个具体问题开始:如何统计一篇文章(例如《白鲸记》或《圣经》)中不重复的单词数量?如果一个单词出现了十次,我们只计为一次。

首先,我们可以尝试使用我们已经学过的数据结构——向量(Vector)——来解决这个问题。

使用向量的方法

思路是:读取文件中的每一个单词,检查它是否已经存在于我们维护的向量中。如果不存在,则将其加入向量。

Vector<string> allWords;
// 读取单词
if (!allWords.contains(word)) {
    allWords.add(word);
}
// 最终,allWords.size() 就是唯一单词的数量

我们运行这个程序来统计《白鲸记》。程序运行了大约11秒。当我们尝试统计更大的《圣经》文本时,程序运行得非常慢,可能需要近一分钟。这个速度对于实际应用来说是不可接受的。


解决方案:集合(Set)数据结构 ⚡

为什么向量方案这么慢?因为向量的 contains 操作是顺序搜索的。随着向量中元素增多,检查一个单词是否存在所需的时间会线性增长。

我们需要一个能更快回答“某物是否存在”这个问题的数据结构,这就是集合(Set)

集合的核心特性

  • 不允许重复:如果你尝试添加一个已经存在于集合中的元素,集合不会存储第二个副本。
  • 高效的核心操作add(添加)、remove(删除)、contains(检查包含)。为了换取这些操作的高效性,集合不支持通过索引访问元素

集合的类型

在我们的库中,有两种主要的集合:

  1. Set:元素按排序顺序存储(例如,字符串按字母顺序,整数按数字顺序)。
  2. HashSet:元素不按特定顺序存储,但操作速度通常比 Set 更快。

选择哪种取决于你的需求:如果你需要元素有序,使用 Set;如果你只追求最快速度且不关心顺序,使用 HashSet

使用集合改进单词统计

让我们将程序中的 Vector 替换为 HashSet

HashSet<string> allWords;
// 读取单词
allWords.add(word); // 集合会自动处理重复
// 最终,allWords.size() 就是唯一单词的数量

再次运行程序,统计《圣经》中的唯一单词。这次,程序在约300毫秒内就完成了!速度有了巨大的提升。如果使用常规的 Set,大约需要700毫秒,依然远快于向量方案。

遍历集合

由于集合没有索引,你不能使用 for (int i =0; i < set.size(); i++) 这样的循环。遍历集合的标准方法是使用 for-each 循环:

for (string word : allWords) {
    cout << word << endl;
}

上一节我们介绍了用于存储独立元素并确保唯一性的集合。本节中,我们来看看另一种强大的数据结构——映射,它用于存储成对的关联数据。

映射(Map)数据结构 🗺️

映射(Map),有时也称为字典(Dictionary),用于存储键值对(Key-Value Pairs)

  • 键(Key):用于查找的唯一标识符。
  • 值(Value):与键关联的数据。

一个典型的例子是电话簿:将人名(键)与电话号码(值)关联起来。

映射的核心操作

  1. put(key, value):添加或更新一对键值。如果键已存在,则用新值替换旧值。
  2. get(key):查找并返回与给定键关联的值。如果键不存在,则返回该类型的默认值(如0、空字符串等)。
  3. remove(key):删除指定的键及其关联的值。
  4. containsKey(key):检查映射中是否包含指定的键。

映射的类型

与集合类似,映射也有两种主要类型:

  1. Map:按键的排序顺序存储键值对。
  2. HashMap:不按顺序存储,但操作速度通常更快。

映射的便捷语法

除了使用 getput,你还可以使用类似数组的方括号语法:

// 以下两行等价
map.put(“Marty”, “685-2180”);
map[“Marty”] = “685-2180”;

// 以下两行等价
string phone = map.get(“Marty”);
string phone = map[“Marty”];

应用示例:统计单词频率

现在,我们解决一个更复杂的问题:不仅统计有哪些唯一单词,还要统计每个单词出现的次数

这时映射就派上用场了。我们可以建立一个映射,其中:

  • 是单词(string)。
  • 是该单词出现的次数(int)。

算法如下:

  1. 读取每个单词。
  2. 如果单词不在映射中,则添加它,并将次数设为1。
  3. 如果单词已在映射中,则获取其当前次数,加1,再存回映射。
HashMap<string, int> wordCounts;
// 读取单词
if (!wordCounts.containsKey(word)) {
    wordCounts[word] = 1; // 第一次出现
} else {
    wordCounts[word] = wordCounts[word] + 1; // 出现次数加1
}

一个更简洁的写法是直接递增,因为如果键不存在,map[key] 会返回默认值0:

wordCounts[word]++; // 无论单词是否存在,这一行都能正确增加计数

程序完成后,我们可以快速查询任何单词的出现频率:

cout << “The word ‘whale’ appears “ << wordCounts[“whale”] << “ times.” << endl;

数据结构嵌套 🎁

集合和映射的元素类型可以是任何类型,包括另一个集合或映射。这允许你构建复杂的数据结构来模拟现实问题。

例如,如果你想维护一个社交网络中的好友列表:

  • 每个人有一组好友。
  • 你需要根据人名快速找到他的好友集合。

这可以通过一个 从字符串到字符串集合的映射 来实现:

Map<string, Set<string>> friendLists;
// 为 Marty 添加好友
friendLists[“Marty”].add(“Mariana”);
friendLists[“Marty”].add(“Clyde”);

// 获取 Marty 的好友列表并遍历
for (string friendName : friendLists[“Marty”]) {
    cout << friendName << endl;
}

算法效率简介:大O表示法 ⏱️

我们注意到,使用向量、集合或映射解决同一个问题,速度差异巨大。我们需要一种方法来描述和比较不同算法或操作的效率。

这就是 大O表示法(Big O Notation)。它描述了算法运行时间或所需空间如何随输入数据规模(通常用 n 表示)的增长而增长。

一个简单例子

观察以下代码:

for (int i = 0; i < n; i++) {
    for (int j = 0; j < n; j++) {
        // 执行某些操作(语句1)
    }
}
for (int k = 0; k < n; k++) {
    // 执行某些操作(语句2)
    // 执行某些操作(语句3)
}
  • 第一个嵌套循环中的操作大约执行了 n * n = n² 次。
  • 第二个循环中的两个操作大约执行了 n * 2 = 2n 次。
  • 总的操作次数大致与 n² + 2n 成正比。

n 很大时, 项起主导作用。因此,我们说这段代码的时间复杂度是 O(n²)

应用到我们的数据结构

  • 向量的 contains 操作:在最坏情况下需要检查向量中的每一个元素。如果向量有 n 个元素,可能需要 n 次比较。我们说它的时间复杂度是 O(n)(线性时间)。
  • 集合的 contains 操作:实现方式(如二叉搜索树或哈希表)使其效率高得多。在平均情况下,它可能只需要检查 log n 个元素(对于树)或甚至接近常数个元素(对于哈希表)。因此,它的时间复杂度可以是 O(log n)O(1)(常数时间)。

这就是为什么用集合统计单词比用向量快几个数量级的原因。我们将在后续课程中更深入地探讨大O表示法和不同数据结构的内部实现。


总结 📝

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

  1. 集合(Set):一种存储唯一元素的高效数据结构,核心操作是 add, remove, contains。分为有序的 Set 和无序但更快的 HashSet
  2. 映射(Map):一种存储键值对的数据结构,用于基于键快速查找值。核心操作是 put, get, remove, containsKey。同样分为 MapHashMap
  3. 应用:我们使用集合高效统计了文本中的唯一单词,并使用映射统计了每个单词的出现频率。
  4. 嵌套结构:集合和映射可以嵌套使用,以构建更复杂的数据模型。
  5. 效率概念:引入了大O表示法作为描述算法效率的工具,并解释了为什么集合/映射的操作比向量的类似操作快得多。

理解这些抽象数据类型及其适用场景,对于编写高效、清晰的程序至关重要。

课程06:递归 🌀

在本节课中,我们将要学习编程中的一个核心概念——递归。这是一种通过函数调用自身来解决问题的方法。我们将从基本概念入手,通过多个例子来理解递归的工作原理、如何编写递归函数以及需要注意的关键点。


概述

递归是一种用事物自身来描述事物的思想。在编程中,这意味着一个函数可以通过调用自身来解决更小或更简单的子问题。理解递归需要掌握两个关键部分:递归情况(函数调用自身)和基本情况(函数停止调用自身,直接返回结果)。本节课我们将通过具体的编程示例来探索这一概念。


什么是递归?

递归的核心思想是自我引用。一个过程或算法在其定义中包含了解决同一问题的更小版本。

例如,在字典中查找单词的算法:你翻到那个词所在的页面并阅读定义。如果定义中包含你不认识的词,你就需要再去查找那些词。因此,“在字典中查找单词”这个过程,本身就包含了“在字典中查找(其他)单词”这个步骤。

自然界和计算机科学中有许多自相似的结构,例如文件夹目录、家谱、分形图案等。递归编程就是利用这种自相似性来解决问题。


一个非编程的例子:分享糖果

假设我们有一大碗糖果(比如M&M‘s),并且我们想在不直接数数的情况下,将碗中糖果的数量“翻倍”。我们有一群人,每个人都可以遵循一个简单的规则来协作完成这个任务。

以下是每个人可以遵循的算法:

  1. 如果碗是空的,什么也不做。
  2. 否则,从碗中取出一颗糖果。
  3. 将这颗糖果传递给下一个人,并请他帮忙“翻倍”。
  4. 当糖果被传回时,你会得到两颗糖果。
  5. 将这两颗糖果都放入目标容器中。

这个过程中,每个人都执行了少量工作(取一颗糖,传递请求,放回两颗糖),并且通过互相调用(请求他人“翻倍”)共同完成了整体任务。这就是递归思维:将一个大问题分解为许多相同的小问题,每个小问题都由一个相同的、更简单的过程来处理,并最终组合出答案。其中,“碗空”就是停止继续调用的基本情况


编程中的递归:阶乘函数

上一节我们介绍了递归的思维模式,本节中我们来看看如何在代码中实现递归。一个经典的入门例子是计算阶乘。

阶乘的数学定义是:n! = n * (n-1) * ... * 1。例如,5! = 5*4*3*2*1 = 120。我们注意到一个关键关系:n! = n * (n-1)!。这正是递归所需要的自相似性:要计算n的阶乘,可以先计算n-1的阶乘。

让我们尝试编写这个函数:

int factorial(int n) {
    return n * factorial(n - 1); // 递归调用
}

这段代码有一个严重的问题:它永远不会停止。调用factorial(5)会调用factorial(4),接着调用factorial(3),如此下去,没有尽头,最终会导致程序因“栈溢出”而崩溃。

因此,我们需要一个基本情况来终止递归。对于阶乘,最简单的情况是0! = 11! = 1

以下是修正后的递归阶乘函数:

int factorial(int n) {
    if (n == 0 || n == 1) { // 基本情况
        return 1;
    } else {                // 递归情况
        return n * factorial(n - 1);
    }
}

执行过程解析
当我们调用factorial(3)时:

  1. n=3,不是0或1,进入else分支,计算3 * factorial(2)。但需要先得到factorial(2)的结果。
  2. 调用factorial(2)n=2,进入else分支,计算2 * factorial(1)。需要先得到factorial(1)的结果。
  3. 调用factorial(1)n=1,满足基本情况,直接返回1
  4. factorial(2)调用收到返回值1,计算2 * 1 = 2,并返回2
  5. factorial(3)调用收到返回值2,计算3 * 2 = 6,并返回6

每个函数调用都会等待其内部的递归调用返回后,才能计算并返回自己的结果。理解函数调用会创建独立的“副本”并拥有自己的变量空间,这一点非常重要。


深入递归:一个“神秘”的函数

为了更深入地理解递归的执行流程,我们分析下面这个函数。它接收一个整数,并返回一个“奇怪”的结果。

int mystery(int n) {
    if (n < 10) {
        return (10 * n) + n;
    } else {
        int a = mystery(n / 10);
        int b = mystery(n % 10);
        return (100 * a) + b;
    }
}

让我们追踪mystery(348)的执行:

  1. n=348,不小于10,进入else分支。
    • a = mystery(34) (因为 348 / 10 = 34
    • b = mystery(8) (因为 348 % 10 = 8
  2. 先计算a = mystery(34)
    • n=34,不小于10,进入else分支。
      • a1 = mystery(3)34 / 10 = 3
      • b1 = mystery(4)34 % 10 = 4
    • 计算a1 = mystery(3)n=3<10,返回 10*3 + 3 = 33
    • 计算b1 = mystery(4)n=4<10,返回 10*4 + 4 = 44
    • mystery(34) 返回 100 * 33 + 44 = 3344。所以 a = 3344
  3. 再计算b = mystery(8)
    • n=8<10,返回 10*8 + 8 = 88。所以 b = 88
  4. 最后,最初的mystery(348)返回 100 * 3344 + 88 = 334488

这个函数的效果是将每个数字“复制”了一遍(3变成33,4变成44,8变成88),然后按原顺序拼接。通过追踪调用,你可以清晰地看到递归如何分解问题以及结果如何组合。


递归实战:判断回文串

上一节我们分析了现有递归函数的执行,本节中我们来动手编写一个递归函数:判断一个字符串是否是回文(正读反读都一样的字符串,如 “racecar”)。

递归思路:

  1. 自相似性:一个字符串是回文,如果它的首尾字符相同,并且去掉首尾字符后的子串也是回文。
  2. 基本情况:非常短(长度<=1)的字符串自然是回文。
  3. 工作单元:每个递归调用只负责比较当前字符串的首尾字符,剩余部分交给下一次递归调用。

以下是实现代码:

bool isPalindrome(string s) {
    // 基本情况:长度<=1的字符串是回文
    if (s.length() <= 1) {
        return true;
    }
    // 工作单元:比较首尾字符
    char first = s[0];
    char last = s[s.length() - 1];
    // 递归情况:如果首尾相同,则检查去掉首尾后的子串
    if (first == last) {
        string middle = s.substr(1, s.length() - 2);
        return isPalindrome(middle); // 递归调用
    } else {
        return false; // 首尾不同,肯定不是回文
    }
}

代码优化:可以将多个判断合并,使代码更简洁。

bool isPalindrome(string s) {
    if (s.length() <= 1) {
        return true;
    }
    return (s[0] == s[s.length() - 1]) && isPalindrome(s.substr(1, s.length() - 2));
}

这个版本直接返回比较结果与递归结果的逻辑与(&&)。如果首尾不同,表达式短路,直接返回false


递归的经典难题:汉诺塔

最后,我们来看一个著名的递归问题——汉诺塔。问题描述:有三根柱子,其中一根柱子上有N个从小到大的圆盘。目标是将所有圆盘移动到另一根柱子上,规则是:一次只能移动一个圆盘,且大盘不能放在小盘上。

递归解决方案的思维过程:

  1. 自相似性:要将N个盘子从“起始柱”移到“目标柱”,可以看作三步:
    • 将上面 N-1 个盘子从“起始柱”移到“辅助柱”(这是一个更小的同类问题)。
    • 将最大的第N个盘子从“起始柱”直接移到“目标柱”(这是简单的一步)。
    • 再将那 N-1 个盘子从“辅助柱”移到“目标柱”(这又是一个更小的同类问题)。
  2. 基本情况:当只需要移动0个或1个盘子时,问题非常简单(直接移动或不需移动)。

以下是递归函数框架:

void moveTower(int disks, string start, string end, string temp) {
    if (disks == 0) { // 基本情况:没有盘子需要移动
        return;
    }
    // 递归情况
    // 1. 将上面 disks-1 个盘子从 start 移到 temp,使用 end 作为临时柱子
    moveTower(disks - 1, start, temp, end);

    // 2. 移动最大的盘子(第 disks 个)从 start 到 end
    cout << "Move disk from " << start << " to " << end << endl;

    // 3. 将 disks-1 个盘子从 temp 移到 end,使用 start 作为临时柱子
    moveTower(disks - 1, temp, end, start);
}

这个算法完美地展示了递归的“分而治之”思想:假设我们已有解决disks-1层问题的函数(递归的“魔法”),我们就能轻松解决disks层的问题。基本情况确保了递归的终止。


总结

本节课中我们一起学习了递归的核心概念。我们了解到:

  • 递归是一种通过函数调用自身来解决问题的方法,关键在于发现问题的自相似性
  • 每个递归函数必须包含基本情况,以防止无限递归。
  • 递归思维通常涉及将大问题分解为更小的、结构相同的子问题,每个递归调用完成一小部分工作。
  • 我们通过阶乘计算字符串回文判断汉诺塔等例子,实践了如何设计和分析递归函数。

递归是一种强大的编程工具,初学时可能颇具挑战性,但通过持续的练习和追踪代码执行过程,你一定能掌握这种优雅的问题解决方式。

课程07:递归绘图与表达式求值 🎨➗

在本节课中,我们将继续深入学习递归,并探索两个特别有趣的应用:使用递归绘制分形图形,以及递归地求值数学表达式。我们将通过具体的代码示例来理解这些概念。


分形绘图:康托尔集 📏

上一节我们介绍了递归的基本思想。本节中,我们来看看如何使用递归绘制一种称为“分形”的自相似图形。分形是一种数学集合,其图形模式在不同尺度上重复出现。

以下是绘制“康托尔集”分形的步骤。康托尔集从一条水平线段开始,不断移除线段中间的三分之一,并在剩下的两段上重复此过程。

  1. 定义函数参数:我们的绘图函数需要接收以下参数:图形窗口、线段起点的x和y坐标、线段的长度以及要绘制的层级。

    void drawCantorSet(GWindow& window, int x, int y, int length, int level)
    
  2. 确定基本情况:当层级为0时,我们不需要绘制任何东西,这是最简单的情况。

    if (level == 0) {
        return; // 什么都不做
    }
    
  3. 实现递归步骤:如果层级大于0,我们首先在当前位置绘制一条水平线段。然后,我们在当前线段下方、长度三分之一处的左右两个位置,递归地绘制层级减一的康托尔集。

    // 绘制当前层级的线段
    window.drawLine(x, y, x + length, y);
    
    // 计算下一层级的参数
    int newY = y + 20; // 向下移动20像素
    int newLength = length / 3;
    
    // 递归绘制左右两个更小的康托尔集
    drawCantorSet(window, x, newY, newLength, level - 1); // 左边
    drawCantorSet(window, x + 2 * newLength, newY, newLength, level - 1); // 右边
    

通过调整递归调用的顺序,你可以改变图形绘制的动画效果,这有助于理解递归的执行流程。


递归求值数学表达式 🧮

理解了递归绘图后,我们来看看另一个经典问题:如何递归地求值一个完全括号化的数学表达式(例如 (1+((2+3)*(4*5))))。我们假设表达式格式正确,数字均为个位数,运算符只有加号(+)和乘号(*)。

  1. 定义函数签名:我们创建一个辅助函数来遍历字符串。它接收表达式字符串和一个表示当前读取位置的索引(通过引用传递,以便所有递归调用共享进度)。
    int evaluateHelper(const string& expr, int& index)
    

  1. 处理基本情况:最简单的情况是当前字符是一个数字。我们将其转换为整数并返回。
    if (isdigit(expr[index])) {
        int num = expr[index] - '0'; // 将字符转换为整数
        index++; // 消耗掉这个数字字符
        return num;
    }
    

  1. 处理递归情况:如果遇到左括号 (,意味着后面是一个子表达式。我们跳过左括号,递归求值左操作数,然后读取运算符,再递归求值右操作数,最后跳过右括号,并根据运算符计算结果。
    if (expr[index] == '(') {
        index++; // 跳过左括号
        int left = evaluateHelper(expr, index); // 递归求值左表达式
        char op = expr[index]; // 读取运算符
        index++; // 跳过运算符
        int right = evaluateHelper(expr, index); // 递归求值右表达式
        index++; // 跳过右括号
        if (op == '+') {
            return left + right;
        } else { // 假设是 '*'
            return left * right;
        }
    }
    

这个例子展示了递归如何自然地处理嵌套结构。通过在函数开头添加输出语句来跟踪递归调用和参数变化,可以更直观地理解其执行过程。


总结 📝

本节课中我们一起学习了递归的两个高级应用。

  • 我们使用递归绘制了康托尔集分形,理解了如何通过递归调用在更小的尺度上重复图形模式。
  • 我们实现了数学表达式求值器,看到了递归如何优雅地解析和计算嵌套的表达式结构。

递归的核心在于识别问题的自相似性,并定义好清晰的基本情况。通过练习这些例子,你应该能更好地掌握递归思维,并将其应用到更复杂的问题中去。

课程08:回溯与穷举搜索 🔍

在本节课中,我们将学习递归的一种特殊应用——回溯。这是一种用于穷举搜索所有可能选项以解决问题的强大技术。我们将通过具体的编程示例,理解其核心思想与实现方法。


概述 📋

回溯是递归的一种形式,它系统地探索所有可能的“选择”或“路径”,以找到问题的解决方案。当某条路径被证明无效(即进入“坏状态”)时,算法会“回溯”到上一个决策点,尝试其他选项。这就像在迷宫中探索所有岔路,遇到死胡同时就返回上一个路口。

我们将从简单的打印所有二进制数问题开始,逐步深入到更典型的回溯问题,例如寻找骰子组合。通过这个过程,你将理解如何构建递归函数来探索决策空间。


穷举搜索与递归 🌳

上一节我们介绍了回溯的概念。本节中,我们来看看如何利用递归实现穷举搜索。

穷举搜索的核心是检查所有可能的选择,以确定哪一个能解决问题。递归非常适合这类问题,因为它允许我们优雅地描述“尝试所有可能性”这一过程。

例如,要递归地搜索一个目录中的所有文件,或者生成所有可能的密码组合,都可以使用这种思路。通常,搜索空间由一系列待做的决策构成。

以下是解决这类问题的一般策略伪代码:

void explore(decisions) {
    if (no more decisions to make) {
        // 基础情况:处理结果(例如打印或存储)
    } else {
        // 递归情况:做出一个选择,然后探索后续的所有可能性
        for (each available choice) {
            make(choice);
            explore(remaining decisions); // 递归调用
            unmake(choice); // 回溯:撤销选择,为尝试下一个选项做准备
        }
    }
}

这个模式通常被称为 “选择-探索-取消选择”范式


示例一:打印所有二进制数 💻

为了理解上述范式,让我们先解决一个具体问题:编写一个函数 printAllBinary,它接收一个数字 digits,并打印出所有具有该位数的二进制数(例如,digits=2 时,打印 00, 01, 10, 11)。

问题分析与初次尝试

我们注意到,所有三位二进制数(如 000, 001...)其实是在所有两位二进制数前面分别加上 01 形成的。这揭示了问题的自相似性:打印 n 位二进制数 的任务,可以转化为 先打印一个数字(0或1),然后打印 (n-1) 位二进制数

根据这个思路,我们可能写出如下代码框架:

void printAllBinary(int digits) {
    if (digits == 0) {
        // 基础情况:打印什么?我们还没有累积数字。
    } else {
        // 尝试前面加0
        cout << 0;
        printAllBinary(digits - 1);
        // 尝试前面加1
        cout << 1;
        printAllBinary(digits - 1);
    }
}

但这段代码有问题:它在递归过程中就打印数字,导致输出混杂,并且无法正确地在数字间换行。关键在于,我们需要在做出所有选择(即确定所有位)之后,再一次性打印整个数字。

引入辅助函数与累积路径

我们需要一个方法来累积当前正在构建的数字字符串,直到它达到指定长度。为此,我们引入一个辅助函数,它接收一个额外的参数——用于累积结果的字符串。

// 这是被要求编写的函数
void printAllBinary(int digits) {
    printAllBinaryHelper(digits, "");
}

// 实际完成工作的辅助函数
void printAllBinaryHelper(int digits, string soFar) {
    if (digits == 0) {
        // 基础情况:没有更多位需要决定,打印已累积的字符串
        cout << soFar << endl;
    } else {
        // 递归情况:尝试在当前字符串后添加0,并递归处理剩余位数
        printAllBinaryHelper(digits - 1, soFar + "0");
        // 尝试在当前字符串后添加1,并递归处理剩余位数
        printAllBinaryHelper(digits - 1, soFar + "1");
    }
}

代码解释

  • soFar 参数记录了从递归起点到当前调用所做的所有选择。
  • 在基础情况(digits == 0)下,选择已完成,soFar 就是一个完整的二进制数,将其打印出来。
  • 在递归情况下,函数会分叉成两个新的递归调用,分别探索在当前路径后添加 01 所形成的两条新路径。

这个过程形成了一棵递归调用树。每个函数调用负责处理一位数字,并派生出两个子调用处理后续位。最终,所有从根到叶子的路径就对应了所有可能的二进制数。


示例二:寻找骰子组合 🎲

理解了打印二进制数后,我们来看一个更典型的回溯问题:编写函数 diceSum,接收骰子数量 dice 和目标总和 desiredSum,找出并打印所有能掷出该总和的骰子组合(每个骰子值为1到6)。

应用“选择-探索-取消选择”范式

这个问题与二进制问题类似,但每个决策点的选项从2个(0或1)变成了6个(1到6)。我们同样需要累积当前已选择的骰子值。

以下是实现框架:

void diceSum(int dice, int desiredSum) {
    vector<int> chosen; // 用于存储当前选择的骰子值
    diceSumHelper(dice, desiredSum, chosen);
}

void diceSumHelper(int dice, int desiredSum, vector<int>& chosen) {
    if (dice == 0) {
        // 基础情况:没有更多骰子要掷。
        // 只有当当前选择的总和等于目标值时,才打印结果。
        if (sumVector(chosen) == desiredSum) {
            printVector(chosen);
        }
    } else {
        // 递归情况:对于这个骰子,尝试所有可能的值(1到6)
        for (int value = 1; value <= 6; ++value) {
            chosen.push_back(value); // 选择:将当前值加入路径
            diceSumHelper(dice - 1, desiredSum, chosen); // 探索:基于此选择继续递归
            chosen.pop_back(); // 取消选择:回溯,移除最后加入的值,尝试下一个选项
        }
    }
}

关键点

  1. 选择 (chosen.push_back): 在递归调用前,将当前尝试的值加入 chosen 向量,记录这条路径。
  2. 探索 (diceSumHelper): 进行递归调用,处理剩余的骰子和更新后的目标总和(理论上应为 desiredSum - value,当前版本稍后优化)。
  3. 取消选择 (chosen.pop_back): 递归调用返回后,必须将刚才加入的值移除。这是回溯的核心步骤,它确保了 chosen 向量在尝试同一层的下一个选项时,状态是干净的。

优化:提前剪枝

上面的代码有一个效率问题:它探索了所有可能的骰子组合(6^dice 种),最后才在基础情况检查总和是否匹配。这就像走完了整条死胡同才掉头。

我们可以通过提前剪枝来优化:在递归过程中,如果发现当前路径已经不可能达到目标(例如,当前总和已经太大或太小),就立即停止在这条路径上的深入探索(即提前返回)。

优化后的辅助函数逻辑如下:

void diceSumHelper(int dice, int desiredSum, vector<int>& chosen) {
    // 提前剪枝条件:如果不可能达到目标,则返回
    if (desiredSum < 0 || desiredSum > dice * 6) { // 简化示例,实际需计算当前最小/最大可能总和
        return;
    }
    if (dice == 0) {
        // 此时 desiredSum 应为 0,因为每次递归我们都减去了已选骰子的值
        if (desiredSum == 0) {
            printVector(chosen); // 找到一组解
        }
    } else {
        for (int value = 1; value <= 6; ++value) {
            chosen.push_back(value);
            // 探索时,目标总和减去当前骰子值
            diceSumHelper(dice - 1, desiredSum - value, chosen);
            chosen.pop_back();
        }
    }
}

通过传递更新后的 desiredSum 并在递归开始处检查其合理性,我们避免了大量无用的递归调用,极大地提升了算法效率。


总结 🎯

本节课中我们一起学习了回溯算法。

  • 核心思想:回溯是一种通过递归进行穷举搜索的方法,采用 “选择-探索-取消选择” 的范式来系统地遍历所有可能解。
  • 关键步骤
    1. 做出一个选择,记录在当前路径中。
    2. 基于这个选择进行递归探索
    3. 探索完成后撤销这个选择(回溯),以便尝试同一层次的其他选项。
  • 实现模式:通常需要一个辅助函数,它包含用于累积当前路径的额外参数(如字符串或向量)。
  • 优化手段:通过提前剪枝,避免在不可能得到解的路径上继续深入,这是编写高效回溯算法的关键。

回溯是解决组合问题、约束满足问题(如八皇后、数独)和搜索问题的强大工具。掌握它需要练习,但一旦理解其模式,你就能将其应用于众多场景。

课程09:递归回溯(二)与迷宫求解 🧩

在本节课中,我们将继续深入学习递归回溯算法。我们将通过优化“掷骰子求和”问题来理解“剪枝”的重要性,并探索一个经典的回溯应用——迷宫求解。最后,我们会尝试编写生成所有排列的算法。


回顾与优化:掷骰子求和 🎲

上一节我们介绍了如何使用递归回溯来找出所有能使骰子总和达到目标值的组合。我们以此结束了课堂,并完成了代码使其正常工作。

以下是当时编写的代码核心部分,它通过一个向量 choices 来跟踪已做出的选择:

void diceSumHelper(int dice, int desiredSum, Vector<int>& choices) {
    if (dice == 0) {
        if (sum(choices) == desiredSum) {
            cout << choices << endl;
        }
    } else {
        for (int i = 1; i <= 6; i++) {
            choices.add(i);           // 选择
            diceSumHelper(dice - 1, desiredSum, choices); // 探索
            choices.remove(choices.size() - 1); // 取消选择
        }
    }
}

这个解决方案可以正确工作,但它有一个明显的缺点:它会检查每一种可能性,即使某些选择明显不可能导向有效结果。这导致了大量不必要的递归调用。

为了量化这个问题,我们引入了一个全局变量来统计函数调用次数。对于“3个骰子总和为7”的情况,需要 259 次调用;对于总和为11的情况,则需要 1555 次调用。函数调用会产生开销,过多的调用会影响性能。

引入剪枝优化 ✂️

问题的关键在于,当我们做出一个选择后,可以立即判断剩余的选择是否有可能达到目标。例如,在掷3个骰子求总和为7的问题中,如果第一个骰子掷出了5,那么即使剩余两个骰子都掷出最小值1,总和也至少是7(5+1+1)。但我们的目标是7,这看起来是可能的。然而,更精确的判断是:剩余骰子能贡献的最小值和最大值

我们可以通过一个条件进行“剪枝”,提前终止无效的搜索路径:

void diceSumHelper(int dice, int desiredSum, Vector<int>& choices) {
    if (dice == 0) {
        if (sum(choices) == desiredSum) {
            cout << choices << endl;
        }
    } else {
        for (int i = 1; i <= 6; i++) {
            // 剪枝条件:判断当前选择下,未来是否可能达到目标
            int minFuture = dice * 1; // 剩余骰子全为1时的最小和
            int maxFuture = dice * 6; // 剩余骰子全为6时的最大和
            int currentSum = sum(choices) + i;

            if (currentSum + minFuture <= desiredSum && currentSum + maxFuture >= desiredSum) {
                choices.add(i);
                diceSumHelper(dice - 1, desiredSum, choices);
                choices.remove(choices.size() - 1);
            }
        }
    }
}

应用此优化后,对于“3个骰子总和为7”的情况,调用次数从259次大幅下降到 127 次。这种提前识别无效路径并回溯的技术,被称为 剪枝。它通过“修剪”递归调用树中不可能结果的分支,极大地提升了算法效率,在处理更大的搜索空间(例如12个骰子)时尤为重要。


迷宫求解:回溯的经典应用 🧱

现在,我们来看一个能直观体现回溯思想的经典问题:迷宫求解。假设我们有一个网格表示的迷宫,可以穿过走廊,但不能穿过墙壁。目标是找到一条从起点到出口的路径。

我们拥有一个封装好的 Maze 类,它提供以下方法供我们使用:

  • getNumRows(), getNumCols(): 获取迷宫尺寸。
  • isInBounds(row, col): 判断位置是否在迷宫内。
  • isWall(row, col): 判断位置是否是墙。
  • mark(row, col): 标记该位置为路径的一部分(留下“面包屑”)。
  • isMarked(row, col): 判断该位置是否已被标记。
  • taint(row, col): 标记该位置为死路(不再探索)。

我们的任务是编写一个函数 escapeMaze,它接受一个 Maze 对象和当前位置坐标,返回一个布尔值表示是否能从该位置逃脱。

算法设计思路

递归回溯的通用策略是:做出选择,探索后果,必要时撤销选择。对于迷宫问题:

  • 选择:向一个方向移动一步(上、下、左、右)。
  • 探索:递归地从新位置尝试逃脱。
  • 取消选择:如果从新位置无法逃脱,则回溯(移除“面包屑”或标记为死路)。

以下是实现步骤:

  1. 基本情况1(成功逃脱):如果当前位置超出了迷宫边界,意味着我们已经逃出,返回 true
  2. 基本情况2(无效位置):如果当前位置是墙,或者已经被标记为探索过/死路,则返回 false
  3. 做出选择并标记:将当前位置标记为路径的一部分。
  4. 递归探索所有方向:依次尝试向上、下、左、右四个方向移动。如果任何一个方向的递归调用返回 true,则意味着找到了一条路径,当前函数也应返回 true
  5. 撤销选择:如果所有方向都探索失败,说明当前格子是死路的一部分。取消其“路径”标记(或标记为“污染”),然后返回 false

以下是核心代码框架:

bool escapeMaze(Maze& maze, int row, int col) {
    // 1. 基本情况:成功逃脱
    if (!maze.isInBounds(row, col)) {
        return true;
    }
    // 2. 基本情况:撞墙或已探索
    if (maze.isWall(row, col) || maze.isMarked(row, col)) {
        return false;
    }

    // 3. 选择:标记当前为路径
    maze.mark(row, col);

    // 4. 探索所有可能的方向
    // 利用逻辑运算符的短路特性:任一方向成功则返回true
    if (escapeMaze(maze, row - 1, col) || // 上
        escapeMaze(maze, row + 1, col) || // 下
        escapeMaze(maze, row, col - 1) || // 左
        escapeMaze(maze, row, col + 1)) { // 右
        return true;
    }

    // 5. 取消选择:所有方向都失败,标记为死路并回溯
    maze.taint(row, col);
    return false;
}

这个算法生动地演示了“回溯”:它沿着一条路探索到底,如果遇到死胡同,就退回上一个岔路口尝试另一条路。通过标记已访问和死路格子,避免了陷入循环或重复探索。


生成所有排列 🔄

接下来,我们挑战一个不同的问题:给定一个字符串向量(例如 {"A", "B", "C", "D"}),打印出所有可能的排列(例如 ABCD, ABDC, ACBD...)。

直接通过循环解决这个问题会非常繁琐。递归则提供了优雅的思路:n个元素的排列 = 依次选择每一个元素作为第一个元素 + 剩余 (n-1) 个元素的所有排列

递归回溯实现

我们需要一个辅助函数来跟踪已做出的选择(当前排列的前缀)和剩余可选的元素。

以下是实现步骤:

  1. 基本情况:当没有剩余元素可选时(rest 为空),意味着我们已经完成了一个完整排列,打印 chosen 向量。
  2. 递归情况:对于剩余向量 rest 中的每一个元素:
    • 选择:将该元素从 rest 中移除,并添加到 chosen 中。
    • 探索:递归调用函数,处理新的 restchosen
    • 取消选择:为了尝试下一个元素作为当前位置,需要将刚才选择的元素从 chosen 中移除,并重新加回 rest 中原来的位置。

以下是核心代码:

void permuteHelper(Vector<string>& rest, Vector<string>& chosen) {
    // 基本情况:没有剩余元素,打印当前排列
    if (rest.isEmpty()) {
        cout << chosen << endl;
    } else {
        // 遍历所有剩余元素作为下一个选择
        for (int i = 0; i < rest.size(); i++) {
            string s = rest[i];          // 选择元素
            chosen.add(s);               // 加入已选序列
            rest.remove(i);              // 从剩余中移除

            permuteHelper(rest, chosen); // 递归探索剩余部分

            // 取消选择,为下一次循环做准备
            rest.insert(i, s);           // 将元素插回原位置
            chosen.remove(chosen.size() - 1); // 从已选中移除
        }
    }
}

// 主函数
void permute(Vector<string>& v) {
    Vector<string> chosen;
    permuteHelper(v, chosen);
}

处理重复元素

如果输入向量包含重复元素(例如 {"B", "B", "C"}),上述代码会生成重复的排列(如 BBC 会出现两次)。为了避免重复打印,我们可以使用一个集合来记录已经打印过的排列,在打印前进行检查。

void permuteHelper(Vector<string>& rest, Vector<string>& chosen, Set<Vector<string>>& printed) {
    if (rest.isEmpty()) {
        if (!printed.contains(chosen)) { // 检查是否已打印
            cout << chosen << endl;
            printed.add(chosen);         // 记录已打印
        }
    } else {
        for (int i = 0; i < rest.size(); i++) {
            string s = rest[i];
            chosen.add(s);
            rest.remove(i);

            permuteHelper(rest, chosen, printed); // 传递集合

            rest.insert(i, s);
            chosen.remove(chosen.size() - 1);
        }
    }
}

总结 📝

本节课中我们一起深入探索了递归回溯算法:

  1. 优化与剪枝:我们通过“掷骰子求和”的例子,学习了如何通过提前判断搜索状态的有效性来“剪枝”,从而避免大量不必要的递归调用,显著提升算法效率。
  2. 迷宫求解:我们实现了一个经典的迷宫回溯算法,直观地展示了选择、探索、撤销选择的过程,并理解了如何通过标记来避免循环和重复搜索。
  3. 生成排列:我们设计了生成所有排列的递归回溯算法,掌握了在递归过程中动态维护“已选”和“剩余”列表的技巧,并简单探讨了处理重复结果的方法。

递归回溯是一种强大的问题解决范式,关键在于清晰地定义“选择”,并管理好状态的变化与恢复。多加练习是掌握它的最佳途径。

课程01:编程抽象方法 CS106X 2017 - 引言 🎯

在本节课中,我们将学习课程的基本信息、课程结构、评分政策以及所需的软件工具。我们将确保你了解这门课的要求,并为你后续的学习做好准备。

课程概述与结构 📚

我们在课堂上所做的一切都会被发布在课程网站上,包括常见问题解答。课程内容会被录制,但录制方式仅限于捕捉电脑屏幕和电脑麦克风的声音。这意味着视频中不会出现讲师的面部,并且如果讲师远离麦克风,声音质量可能会受到影响。尽管如此,这些视频仍会提供给你作为学习资源,但我们仍然鼓励你亲自来上课。

教学团队介绍

我不是一个人在教这门课。我的助教艾米才华横溢,她坐在教室前面。艾米目前在斯坦福工作,她将和我一起负责这门课程。如果你有任何与课程相关的问题,正确的做法是同时给我们两人发送电子邮件,这样我们可以分工合作,更高效地回复。

计算机科学入门课程体系

很多人想知道这门课到底是什么,以及是否应该选择它。让我们先了解一下斯坦福计算机科学的三门主要入门课程:

  • CS106A:这是第一门课程,面向广泛的受众。它教授编程基础知识,如变量、if语句、循环、数组、方法参数、基本问题解决和图形绘制。这是一门有趣且不需要预备知识的课程。
  • CS106B:这门课在CS106A之后,更侧重于数据处理和算法。你会学习如何存储数据到不同的集合中(如向量、列表、映射、集合、栈和队列),学习递归算法,解决复杂问题,并处理大型数据文件。这门课使用C++语言。
  • CS106X:这门课涵盖了CS106B的所有内容,但难度更高。我们会更深入地探索某些主题,并可能添加一些额外内容。家庭作业量更大,考试更难,评分曲线更严格。这门课适合那些已有一定编程经验、并希望接受更多挑战的学生。

选择CS106X的理由应该是你希望接受更严格的挑战,而不是因为它能简化你的课程安排。如果你觉得CS106B对你来说可能太简单或太慢,那么CS106X是一个合适的选择。反之,如果你希望以更平稳的节奏学习这些概念,CS106B是一个很好的选择。两门课在同一时间开设,如果你在CS106X中感到进度太快,可以随时换到CS106B。

如果你不确定哪门课适合你,请先查看课程网站上的FAQ(常见问题解答)部分,以及名为“哪门课程适合我”的讲义。这些资源包含了详细信息,可以帮助你做出决定。

课程教材与资源 📖

我们使用的教材是Eric Roberts编写的《C++中的编程抽象》。购买这本书不是强制要求,你可以在不购买的情况下在课程中取得好成绩。拥有这本书的主要优势是可以在考试时查阅。考试期间,允许携带教材,但不允许使用其他资源,如笔记、打印件或幻灯片。

为了公平起见,考试时不允许携带备忘单。我倾向于在考试中提出与以往试题相似的问题,并提供旧试卷供你练习。如果允许携带大量笔记,考试就失去了挑战性。因此,只允许携带教材,我会在考试时提供你可能需要的语法参考。

教材有多个版本,新旧版本差异不大,都可以使用。课程网站上也有该书的PDF版本,供你平时学习参考,但PDF版本不能用于考试。

作业与评分政策 📝

你的大部分成绩将来自作业。本学期大约有8个编程任务,每个任务你有一周或稍多一点的时间完成。有些作业你可以选择与搭档合作完成,但这不是强制要求。

作业评分主要基于两个方面:

  1. 功能性:程序是否能正常运行并产生正确结果。
  2. 代码风格:代码是否以优雅、简洁、高效的方式编写,包括良好的注释、变量命名、缩进以及避免冗余代码。

评分通常使用几个等级来表示:✓-(有一些问题)、(良好)、✓+(优秀)。在极少数情况下,会有更高或更低的等级。你会与你的小组负责人会面,以互动的方式取回作业成绩并讨论代码风格。

迟交政策

每个作业都有明确的截止日期。迟交作业以“讲课日”为单位计算(例如,周一至周三,周三至周五等)。你有三次免费的迟交机会(即迟交三个“讲课日”不会扣分)。你可以将这些机会用于不同的作业,或者在同一作业上使用多次(例如,迟交两天)。一旦用完这三次机会,之后迟交的作业将不予接受,除非有特殊情况。

建议你保留这些迟交机会,以备季度后期事务繁忙时使用。这些机会旨在帮助你应对生活中的意外情况,如生病、家庭事务等。除了这些免费机会,通常不会批准额外的延期。

学术诚信与协作

在个人作业上,你可以与同学进行概念性的讨论,例如寻求一般性的策略建议或讨论课程中的例子。但是,严禁分享具体的解题步骤、代码或答案。同样,请勿将你的代码公开在GitHub等公共代码仓库或任何可能被搜索引擎索引的地方。

讨论课与考试 📅

你的成绩构成如下:

  • 作业:占成绩的大部分。
  • 讨论课参与:每周参加由本科生小组负责人带领的小组讨论课,解决问题和练习,可以获得参与分数。
  • 考试:包括一次期中考试和一次期末考试。
    • 期中考试:第六周,星期四晚上(具体日期请查看课程网站)。
    • 期末考试:期末考试周的星期一上午(具体时间请查看课程网站)。

请务必提前将这些日期标记在你的日历中,并检查是否有时间冲突。斯坦福的政策不允许期末考试时间冲突。如果你有与斯坦福课程相关的考试冲突,请与我联系解决。对于非斯坦福相关的冲突(如个人事务),通常不会安排补考。

评分与等级

最终成绩会基于曲线评定。我保证,如果你获得总分的90%或以上,你至少会得到A-;如果获得80%或以上,至少会得到B-。通常,大约一半的学生会获得A-到A+的等级,约30%的学生获得B-到B+的等级。CS106X的学生整体水平较高,因此获得高等级的比例通常也较高。

软件设置与要求 💻

在本课程中,我们将使用Qt Creator作为C++程序的开发环境。你需要在自己的电脑(笔记本电脑或台式机)上安装此软件。

请务必按照课程网站上提供的专用指南进行安装(在网站查找“Qt Creator”链接)。不要自行通过搜索引擎下载,以免版本或配置不正确。指南中包含了针对Windows、Mac和Linux操作系统的详细步骤和截图。

建议你在今天或明天就完成Qt Creator的安装和配置,并尝试运行一个简单的程序(例如打印一行文字)。这样可以确保你在作业发布前解决所有技术问题。如果你在安装过程中遇到困难,可以联系我或艾米寻求帮助,但请尽早开始,以免耽误作业。

我们强烈建议你使用Qt Creator来完成本课程的作业。虽然我不能禁止你使用其他开发环境(如Xcode),但为了确保一致性并获得最佳支持,请使用课程指定的工具。


在本节课中,我们一起学习了CS106X课程的基本框架:了解了课程定位、教学团队、教材与资源、详细的作业与考试评分政策、学术诚信要求,以及必需的软件工具设置。请务必查看课程网站获取最新信息,并尽快完成开发环境的配置。准备好迎接挑战吧!

课程10:穷举搜索 🔍

在本节课中,我们将学习一种特殊的递归应用——回溯。这是一种通过递归来探索所有可能解决方案的方法。当我们发现某个路径行不通时,算法会“回溯”到之前的状态,并尝试另一条路径。本周我们将重点练习这种方法。


概述 📋

回溯是递归的一种特殊形式,用于解决需要探索多种可能性的问题,例如排列组合、密码破解等。其核心思想是:在每一步做出一个选择,然后递归地探索后续选择;如果发现当前路径无效,则撤销(回溯)上一步的选择,尝试其他选项。


穷举搜索的基本概念

上一节我们介绍了回溯的基本思想。本节中,我们来看看如何实现一种称为“穷举搜索”的具体方法。

穷举搜索意味着系统地探索所有可能的选项或值。对于简单的线性结构(如数组),一个 for 循环就足够了。但对于更复杂的嵌套或层次化结构(如目录树),递归则更为有效。

通用算法模式

以下是实现穷举搜索的通用伪代码模式:

void search(决策集合) {
    if (没有更多决策需要做) {
        // 基本情况:处理或输出当前累积的选择
        输出或记录当前结果;
    } else {
        // 递归情况:尝试每一个可能的单个选择
        for (每一个可能的选择) {
            做出该选择;
            search(剩余的决策集合); // 递归探索后续选择
            撤销该选择; // 回溯,为尝试下一个选择做准备
        }
    }
}

关键点

  • 选择:每个递归调用负责做出一个小的、局部的选择。
  • 探索:通过递归调用,探索在当前选择之后的所有可能性。
  • 基本情况:当所有决策都已做出(即没有剩余选择)时,我们到达“叶子节点”,此时处理累积的结果(如打印、存储)。
  • 回溯:在 for 循环中,当一个选择及其所有后续可能性都被探索完毕后,算法会自然地返回到当前调用,然后循环会尝试下一个选择。这隐式地实现了“撤销选择,尝试下一个”的回溯过程。


示例一:打印所有二进制数字

让我们通过一个具体问题来理解上述模式:编写一个函数 printBinary,打印所有指定位数的二进制数字(由 01 组成)。

问题分析

对于 printBinary(3),应输出:

000
001
010
011
100
101
110
111

我们可以将问题看作是一系列选择:为每一位数字选择 01。递归调用负责填充下一位。

代码实现

void printBinary(int digits, string prefix = "") {
    if (digits == 0) {
        // 基本情况:所有数位都已选择完毕,打印结果
        cout << prefix << endl;
    } else {
        // 递归情况:为当前位尝试两种选择
        printBinary(digits - 1, prefix + "0"); // 选择 0
        printBinary(digits - 1, prefix + "1"); // 选择 1
    }
}

代码解释

  • digits 参数表示还需要做出选择的位数
  • prefix 参数累积了到目前为止已做出的选择(即已生成的前缀字符串)。
  • digits 为 0 时,意味着前缀 prefix 已经是一个完整的二进制数,直接打印。
  • 否则,函数会尝试在当前位添加 “0”,并递归生成剩余位;然后尝试添加 “1”,再次递归。这覆盖了所有可能性。

调用树示意图 (printBinary(2)):

printBinary(2, “”)
├── printBinary(1, “0”)
│   ├── printBinary(0, “00”) -> 输出 “00”
│   └── printBinary(0, “01”) -> 输出 “01”
└── printBinary(1, “1”)
    ├── printBinary(0, “10”) -> 输出 “10”
    └── printBinary(0, “11”) -> 输出 “11”


示例二:打印所有十进制数字

理解了二进制后,打印所有指定位数的十进制数字就非常类似了。唯一的区别是每位有 10 种选择(0-9)。

代码实现

void printDecimal(int digits, string prefix = "") {
    if (digits == 0) {
        cout << prefix << endl;
    } else {
        // 使用循环来枚举当前位的所有可能选择
        for (int i = 0; i < 10; i++) {
            printDecimal(digits - 1, prefix + to_string(i));
        }
    }
}

重要说明
这里在递归函数内部使用了一个 for 循环。这个循环的作用是枚举当前递归层级的所有可能选择,它并没有替代递归本身去解决“剩余位数”这个子问题。在回溯算法中,这种用于枚举局部选项的循环是常见且允许的。


示例三:打印字符串的所有排列

现在我们来解决一个更复杂的问题:打印给定字符串的所有可能排列(即所有字符顺序的重排)。

问题分析

对于字符串 “Marty”,其排列包括 “Marty”, “Matry”, “Mayrt” 等等。
思路依然是做出一系列选择:第一个位置选哪个字符?第二个位置选剩下的哪个字符?……

代码实现

void printPermutations(string str, string prefix = "") {
    if (str.empty()) {
        // 基本情况:原字符串已无字符可选,前缀即为一个完整排列
        cout << prefix << endl;
    } else {
        // 递归情况:尝试将原字符串中的每一个字符放在下一个位置
        for (int i = 0; i < str.length(); i++) {
            char chosen = str[i];
            // 从原字符串中移除被选中的字符
            string remaining = str.substr(0, i) + str.substr(i + 1);
            // 递归构建剩余部分
            printPermutations(remaining, prefix + chosen);
        }
    }
}

代码解释

  • str 表示尚未被选择的字符集合
  • prefix 表示已确定的字符顺序
  • 循环遍历 str 中的每个字符 chosen,将其加入 prefix,然后从 str 中移除该字符得到 remaining,再对 remaining 进行递归。
  • str 为空时,prefix 就是一个完整的排列。

进阶:收集结果而非直接打印

前面的例子都是直接将结果打印到控制台。有时我们需要将结果(如所有排列)收集到一个数据结构(如向量 vector)中,以便后续处理。

实现方法

直接修改递归函数来返回集合可能很繁琐。一个更清晰的方法是使用一个辅助函数,并通过引用参数来传递结果集合。

// 辅助函数,通过引用参数 results 收集所有排列
void permuteHelper(string str, string prefix, vector<string>& results) {
    if (str.empty()) {
        results.push_back(prefix);
    } else {
        for (int i = 0; i < str.length(); i++) {
            char chosen = str[i];
            string remaining = str.substr(0, i) + str.substr(i + 1);
            permuteHelper(remaining, prefix + chosen, results);
        }
    }
}

// 主函数,提供干净的接口
vector<string> getPermutations(string str) {
    vector<string> allPermutations;
    permuteHelper(str, "", allPermutations);
    return allPermutations;
}

设计思路

  1. getPermutations 是面向用户的接口,它创建一个空向量,然后调用辅助函数。
  2. permuteHelper 执行实际的递归和回溯逻辑,并通过引用 results 修改同一个向量,将所有找到的排列添加进去。
  3. 这种方式避免了使用全局变量,并且适用于多种编程语言。


总结 🎯

本节课中我们一起学习了回溯穷举搜索的核心技术:

  1. 核心模式:通过递归做出系列选择,用循环枚举当前步骤的所有选项,通过函数参数传递累积状态,在无选择时处理结果。
  2. 关键区别:此类递归的基本情况通常意味着“已构建完一个完整候选解”,而非解决一个原子问题。
  3. 应用:我们实现了打印所有二进制数、十进制数以及字符串排列的算法。
  4. 结果收集:学习了如何通过辅助函数和引用参数将递归产生的结果收集到容器中,而不是直接打印。

回溯是解决组合问题、约束满足问题(如数独、N皇后)的强大工具。掌握它的关键在于大量练习,亲自动手在空白屏幕上实现算法,理解每一步选择如何展开成一棵搜索树,以及如何在这棵树上进行深度优先探索和回溯。

课程11:回溯法 🧩

在本节课中,我们将要学习一种名为“回溯法”的算法策略。这是一种通过尝试部分解决方案来寻找问题答案的方法。如果尝试的路径行不通,我们就“撤销”选择,回到之前的状态,尝试其他可能性。这就像在迷宫中探路,遇到死胡同时就退回来换条路走。我们将通过具体的编程例子来理解这个概念。


概述

回溯法是“穷举搜索”的一种特殊形式。穷举搜索意味着检查所有可能的选项来解决问题。而回溯法则在此基础上增加了“过滤”机制:我们尝试构建解决方案,但如果发现当前路径不可能导向正确答案,就立即放弃并返回(即“回溯”),从而避免无谓的探索。

上一节我们介绍了递归和穷举搜索,本节中我们来看看如何在此基础上实现更智能的搜索——回溯法。


回溯法与穷举搜索

回溯法建立在穷举搜索的模板之上。两者的核心递归结构相似,但目的不同。

  • 穷举搜索:目标是枚举或列出所有可能的组合(例如,所有二进制数字、所有字符串排列)。每个“终点”(即完成所有选择后)都是有效的,我们只需记录或输出它。
  • 回溯法:目标是找到符合条件的解决方案(例如,和为特定值的骰子组合、迷宫出口)。许多“终点”可能是无效的,我们希望尽早识别并放弃那些不可能成功的路径。

关键区别在于,回溯法在递归调用返回后,通常需要一个“撤销选择”的步骤,以恢复状态,尝试其他选项。


示例一:枚举所有骰子组合 🎲

首先,我们看一个接近穷举搜索的例子:列出投掷 numDice 个骰子时,所有可能的点数组合。

以下是实现此功能的递归辅助函数的核心逻辑:

void diceRollHelper(int numDice, vector<int>& chosen) {
    if (numDice == 0) {
        // 基本情况:所有骰子已选定,输出结果
        cout << chosen << endl;
    } else {
        // 递归情况:为当前骰子做选择
        for (int i = 1; i <= 6; i++) {
            chosen.push_back(i);                // 选择:加入当前点数
            diceRollHelper(numDice - 1, chosen); // 探索:递归处理剩余骰子
            chosen.pop_back();                  // 撤销选择:移除当前点数,尝试下一个
        }
    }
}

代码解释

  1. 参数numDice 表示还需投掷的骰子数,chosen 向量(通过引用传递)记录已选择的点数。
  2. 基本情况:当没有骰子需要处理时(numDice == 0),打印当前组合。
  3. 递归情况:对于当前骰子,尝试所有可能的点数(1到6)。
    • chosen.push_back(i) 做出选择。
    • 递归调用 diceRollHelper 处理剩下的骰子。
    • chosen.pop_back() 在递归返回后撤销选择,以便循环尝试下一个点数。

注意:这里通过引用传递 chosen 向量是为了避免在每次递归调用时复制整个向量,从而提高效率。正因如此,我们在递归返回后必须显式地 pop_back() 来撤销选择。


示例二:寻找和为特定值的骰子组合 🔍

现在,我们修改问题:不再列出所有组合,而是只列出那些点数之和等于目标值 desiredSum 的组合。这引入了“无效路径”的概念,是回溯法的典型场景。

初始实现(低效)

一个直接的想法是沿用上面的代码,只在打印前检查总和:

if (sum(chosen) == desiredSum) {
    cout << chosen << endl;
}

但这种方法效率低下,因为它会探索所有可能的组合(包括那些总和已经远超或远低于目标值的路径),最后才进行过滤。

优化一:传递当前和

为了避免在每次到达终点时都重新计算向量总和,我们可以额外传递一个参数 sumSoFar,记录已选择点数的累计和。

void diceSumHelper(int numDice, int desiredSum, int sumSoFar, vector<int>& chosen) {
    if (numDice == 0) {
        if (sumSoFar == desiredSum) {
            cout << chosen << endl;
        }
    } else {
        for (int i = 1; i <= 6; i++) {
            chosen.push_back(i);
            diceSumHelper(numDice - 1, desiredSum, sumSoFar + i, chosen);
            chosen.pop_back();
        }
    }
}

优化二:剪枝(Pruning)

这是回溯法的关键优化。在做出选择(chosen.push_back(i))之前,我们先判断:以当前选择为基础,后续是否还有可能达到目标? 如果不可能,则放弃这条分支,不进行递归调用。

我们需要计算当前路径可能达到的最小和与最大和:

  • 最小和 = sumSoFar + i + 1 * (numDice - 1) (剩余骰子全掷出1)
  • 最大和 = sumSoFar + i + 6 * (numDice - 1) (剩余骰子全掷出6)

仅当 desiredSum 介于这个最小和与最大和之间时,我们才继续探索。

void diceSumHelper(int numDice, int desiredSum, int sumSoFar, vector<int>& chosen) {
    if (numDice == 0) {
        if (sumSoFar == desiredSum) {
            cout << chosen << endl;
        }
    } else {
        for (int i = 1; i <= 6; i++) {
            // 剪枝判断
            int minPossible = sumSoFar + i + 1 * (numDice - 1);
            int maxPossible = sumSoFar + i + 6 * (numDice - 1);
            if (desiredSum >= minPossible && desiredSum <= maxPossible) {
                chosen.push_back(i);
                diceSumHelper(numDice - 1, desiredSum, sumSoFar + i, chosen);
                chosen.pop_back();
            }
        }
    }
}

通过剪枝,我们显著减少了递归调用的次数,让算法更加高效。


示例三:求集合的所有子集 📦

这个问题能更好地体现回溯法的决策树与排列问题的不同。给定一个集合(用向量表示),我们想列出它的所有子集(包括空集和自身)。

关键洞察:对于集合中的每个元素,我们面临的选择不是“顺序”,而是“包含”或“不包含”。因此,决策树在每个元素处会分叉为两支,而不是像排列那样有 n! 个分支。

以下是递归辅助函数的一种实现思路:

void sublistsHelper(vector<string>& v, vector<string>& chosen) {
    if (v.empty()) {
        // 基本情况:所有元素都已处理,输出当前子集
        cout << chosen << endl;
    } else {
        // 取出第一个元素
        string first = v[0];
        v.erase(v.begin());

        // 选择一:包含该元素
        chosen.push_back(first);
        sublistsHelper(v, chosen);
        chosen.pop_back(); // 撤销包含

        // 选择二:不包含该元素
        sublistsHelper(v, chosen);

        // 重要:在返回前,需要将元素插回原向量,以供上层调用尝试其他可能性
        v.insert(v.begin(), first);
    }
}

代码解释

  1. 每次调用处理当前列表 v 中的第一个元素。
  2. 两条递归路径分别对应 包含该元素不包含该元素
  3. 在尝试了两种选择后,必须将元素 first 重新插入回向量 v 的开头。这是“撤销选择”的另一种形式,它确保了当递归调用返回到当前函数的上一层时,列表状态得以恢复,以便处理其他分支。

注意:这个例子中,我们通过修改输入向量 v 来推进递归(移除已处理的元素)。因此,在递归返回前,我们必须恢复 v 的状态。这与之前例子中修改 chosen 向量并在返回后撤销的逻辑是相辅相成的。


总结

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

  1. 核心思想:通过递归尝试部分解决方案,遇到“死胡同”时撤销选择并返回,系统地搜索解空间。
  2. 与穷举搜索的关系:回溯法是增加了剪枝状态管理的智能穷举搜索。
  3. 关键步骤
    • 做出选择:更新状态(如向 chosen 添加元素)。
    • 递归探索:基于当前选择,继续深入。
    • 撤销选择:递归返回后,恢复状态,以便尝试其他选项。
  4. 重要优化剪枝——在递归调用前,判断当前路径是否可能达到目标,从而避免无用的探索。
  5. 典型模式:决策树的结构取决于问题。对于子集问题,是二叉分支(包含/不包含);对于排列问题,是多分支(选择哪个元素放在当前位置)。

掌握回溯法的关键在于清晰地定义“选择”,并小心地管理递归过程中的状态(做出选择和撤销选择)。通过练习,你将能熟练运用这种强大的策略来解决许多复杂的搜索问题。

编程抽象方法 CS106X 2017 - 第12课:回溯算法 2 🧩

在本节课中,我们将继续深入学习递归与回溯算法。我们将通过修复一个遗留的程序错误开始,然后探讨两个经典的回溯问题:八皇后问题和路径探索问题。通过这些例子,你将理解如何构建回溯算法、如何调试它们,以及如何根据问题特性进行优化。


修复子列表问题中的错误 🔧

上一节我们介绍了如何打印一个向量的所有子集,但程序存在一个错误,导致输出不完整。本节中,我们来看看如何定位并修复这个错误。

回溯算法的核心模板包括“选择”、“探索”和“撤销选择”三个步骤。在之前的代码中,我们遗漏了“撤销选择”这一步。

以下是修复后的关键代码部分。在递归调用探索了包含当前元素的路径后,我们必须将该元素重新放回原处,以正确探索不包含该元素的路径。

// 假设 v 是原始向量,selected 是当前已选择的元素集合
void sublists(Vector<int>& v, Vector<int>& selected, int index) {
    if (index == v.size()) {
        // 打印或处理 selected
        return;
    }
    // 选择1:包含当前元素
    int element = v[index];
    selected.add(element);
    v.remove(index); // 从原向量中移除
    sublists(v, selected, index); // 注意:移除后索引不变
    // 撤销选择
    v.insert(index, element);
    selected.remove(selected.size() - 1);

    // 选择2:不包含当前元素
    sublists(v, selected, index + 1);
}

关键点:在回溯算法中,任何在递归调用前对状态做出的修改,都必须在调用结束后撤销,以确保每次选择都是独立的。


八皇后问题 ♟️

八皇后问题要求在一个8x8的棋盘上放置8个皇后,使得它们彼此之间无法相互攻击。这是一个典型的回溯问题,我们可以通过优化搜索空间来高效求解。

问题分析与优化

一个朴素的算法会尝试在棋盘的64个格子中逐个放置皇后,这会导致巨大的组合爆炸,运行时间不可接受。

通过观察,我们可以利用皇后的攻击规则进行优化:

  1. 每行只能有一个皇后
  2. 每列只能有一个皇后

因此,我们可以将问题重构为:为每一列分配一个皇后,并决定她应该放在该列的哪一行。这样,每次递归调用只需要处理8个可能的行位置,而不是64个格子。

算法实现

以下是解决八皇后问题的主要递归函数。我们使用一个 Board 类来管理棋盘状态,它提供了 isSafeplaceremove 等方法。

bool solveQueens(Board& board, int col) {
    // 基本情况:所有皇后都已成功放置
    if (col >= board.size()) {
        board.print();
        return true; // 找到一个解
    }
    // 尝试当前列的每一行
    for (int row = 0; row < board.size(); row++) {
        if (board.isSafe(row, col)) {
            // 选择:在 (row, col) 放置皇后
            board.place(row, col);
            // 探索:在下一列放置皇后
            if (solveQueens(board, col + 1)) {
                return true; // 如果找到解,提前返回
            }
            // 撤销选择:移除皇后,回溯
            board.remove(row, col);
        }
    }
    // 当前列的所有行都尝试过,未找到解
    return false;
}

代码解析

  • solveQueens(board, col) 负责在第 col 列放置皇后。
  • 循环遍历所有行,寻找安全位置。
  • 如果找到安全位置,放置皇后并递归调用自身处理下一列。
  • 如果递归调用返回 true,表示找到了一个完整解,我们立即层层返回,不再尝试其他位置。
  • 如果递归调用返回 false,或者循环结束,意味着当前路径无解,需要撤销当前选择(移除皇后),回溯到上一步。

寻找单个解与所有解

上述代码在找到第一个解后就停止。如果你想找到所有解,只需移除提前返回的逻辑,让算法穷尽所有可能:

void solveAllQueens(Board& board, int col) {
    if (col >= board.size()) {
        board.print(); // 打印每一个解
        return;
    }
    for (int row = 0; row < board.size(); row++) {
        if (board.isSafe(row, col)) {
            board.place(row, col);
            solveAllQueens(board, col + 1); // 继续寻找,不关心返回值
            board.remove(row, col);
        }
    }
}

路径探索问题 🧭

现在,我们来看一个不同风格的回溯问题:找出从坐标原点 (0,0) 到达目标点 (x, y) 的所有路径,每次移动只能向东 (E)、向北 (N) 或向东北 (NE) 走一步。

算法设计

我们通过递归模拟所有可能的移动序列。需要跟踪当前所在位置和已经做出的移动选择。

void travel(GPoint current, GPoint target, Vector<string>& path) {
    // 基本情况:到达目标点
    if (current == target) {
        cout << path << endl; // 输出路径
        return;
    }
    // 递归情况:尝试三种移动
    // 1. 向东移动
    if (current.getX() + 1 <= target.getX()) {
        path.add("E");
        travel(GPoint(current.getX() + 1, current.getY()), target, path);
        path.remove(path.size() - 1); // 回溯
    }
    // 2. 向北移动
    if (current.getY() + 1 <= target.getY()) {
        path.add("N");
        travel(GPoint(current.getX(), current.getY() + 1), target, path);
        path.remove(path.size() - 1);
    }
    // 3. 向东北移动
    if (current.getX() + 1 <= target.getX() && current.getY() + 1 <= target.getY()) {
        path.add("NE");
        travel(GPoint(current.getX() + 1, current.getY() + 1), target, path);
        path.remove(path.size() - 1);
    }
}

算法流程

  1. 如果当前位置等于目标位置,则打印当前路径。
  2. 否则,依次尝试向东、向北、向东北移动(前提是不超过目标边界)。
  3. 每次尝试,将移动方向记录到 path 中,然后递归调用。
  4. 递归返回后,从 path 中移除最后一次移动,以尝试其他方向。

可视化路径

为了让算法过程更直观,我们可以引入图形窗口,在递归时画线,回溯时擦除。这需要传递一个图形窗口对象,并在移动时绘制线段,在撤销选择时清除它。


总结 📚

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

  1. 回溯算法的调试:通过打印状态和检查“选择-探索-撤销”模板的完整性来定位错误。
  2. 八皇后问题的求解:通过将问题优化为“每列放置一个皇后”,大幅减少了搜索空间,并实现了寻找单个解和所有解的代码。
  3. 路径探索问题:设计了一个回溯算法来枚举所有到达目标点的路径,并讨论了将其可视化的思路。

回溯是一种强大的算法范式,适用于需要穷举并剪枝的搜索问题。掌握其核心模板——做出选择、递归探索、撤销选择——是解决此类问题的关键。在接下来的作业中,你将有机会应用这些技巧解决更复杂的问题。

课程13:指针与节点 🧠

在本节课中,我们将学习C++中一个核心的新概念——指针,并了解如何利用它来实现链表。我们将从一个跨越数周的材料单元开始,通过亲手实践,深入理解哈希集、映射、向量等众多集合的内部工作原理。


概述 📋

指针是C++中用于直接操作内存地址的强大工具。理解指针是构建更复杂数据结构(如链表)的基础。链表与数组(或向量)不同,它不依赖连续的内存块,而是通过一系列相互链接的“节点”来存储数据。本节课我们将学习如何定义这些节点,并使用指针将它们串联起来。


结构体(Struct)🏗️

在深入指针之前,我们需要一种方式来创建存储数据的小容器,即“节点”。在C++中,我们可以使用结构体(struct)来定义这种轻量级的自定义数据类型。

结构体与类(class)非常相似,主要用于定义包含少量数据的简单类型。它们之间的主要区别在于默认的访问权限:结构体的成员默认是公共的(public),而类的成员默认是私有的(private)。

以下是如何定义一个结构体:

struct Date {
    int month;
    int day;
};

这段代码定义了一个名为 Date 的新类型。它本身并不创建任何变量,而是一个创建 Date 对象的模板。每个 Date 对象都包含 monthday 两个整型成员。

我们可以像下面这样创建和使用 Date 对象:

Date today;          // 创建一个 Date 变量
today.month = 10;    // 设置月份
today.day = 25;      // 设置日期

结构体也可以包含方法,用于操作其内部数据。


指针(Pointers)📍

上一节我们介绍了用于封装数据的结构体。本节中,我们来看看如何将这些独立的结构体实例连接起来。这就需要用到指针

指针是一种变量,其存储的值是另一个变量的内存地址。你可以把它想象成一张指向内存中某个特定位置的“地图”。

获取地址:& 运算符

在C++中,使用 &(取地址)运算符可以获取一个变量的内存地址。

int x = 42;
cout << &x << endl; // 输出变量 x 的内存地址,例如 0x7ffee2b5c9fc

声明指针:* 运算符

要声明一个指针变量,需要在类型名后加上 * 号。指针的类型必须与其指向的变量类型匹配。

int* p = &x; // p 是一个指向整型(int)的指针,其值为 x 的地址

此时,我们说“p 指向 x”。

解引用指针:* 运算符(再次使用)

要访问或修改指针所指向的内存地址中存储的值,需要使用 * 运算符对指针进行解引用

cout << *p << endl; // 输出 42,即 p 所指向地址的值
*p = 99;            // 通过 p 修改 x 的值
cout << x << endl;  // 输出 99

注意:声明指针时的 * 和解引用时的 * 虽然符号相同,但含义不同,初学者容易混淆。


特殊指针:空指针与野指针 ⚠️

空指针(Null Pointer)

空指针不指向任何有效的内存地址。在C++中,通常用 nullptr 关键字表示。

int* p = nullptr; // p 是一个空指针

尝试解引用一个空指针会导致程序崩溃(段错误),因此在操作指针前检查其是否为空是一个好习惯。

if (p != nullptr) {
    cout << *p << endl; // 安全的操作
}

野指针(Garbage Pointer)

如果一个指针被声明但未被初始化,它将包含一个随机的内存地址,这就是“野指针”。跟随野指针是危险的,可能导致程序崩溃或产生难以预测的行为。

最佳实践:始终初始化指针,即使只是将其设为 nullptr


内存布局:栈(Stack)与堆(Heap)🗂️

要理解指针的威力,我们需要了解程序运行时内存的两个主要区域:

栈内存

  • 用于存储函数的局部变量、参数等。
  • 内存的分配和回收由系统自动管理(函数调用时分配,函数返回时回收)。
  • 访问速度快,但生命周期受限于函数作用域。

堆内存

  • 用于动态分配的内存。
  • 内存的分配和回收需要程序员手动管理(通过 newdelete)。
  • 生命周期独立于函数作用域,可以在函数之间传递。

new 运算符

使用 new 运算符可以在堆上动态分配内存,并返回指向该内存的指针。

int* heapInt = new int(49); // 在堆上分配一个 int,初始值为 49
Date* heapDate = new Date;  // 在堆上分配一个 Date 结构体
heapDate->month = 12;       // 使用 -> 运算符访问堆上对象的成员

关键区别:在函数中创建的局部变量(在栈上)会在函数结束时消失。而在堆上使用 new 创建的对象会一直存在,直到使用 delete 显式释放它。这允许我们创建在函数调用结束后依然存活的数据结构。


构建链表 🔗

现在,我们具备了构建链表所需的所有知识。回顾最初的问题:我们想定义一个“节点”结构体,它包含数据和指向下一个节点的“链接”。

如果我们错误地让“链接”直接存储另一个节点,会导致逻辑上的无限嵌套。正确的做法是让“链接”存储下一个节点的内存地址,即一个指针。

定义链表节点

struct ListNode {
    int data;           // 节点存储的数据
    ListNode* next;     // 指向下一个节点的指针
};

手动创建一个小链表

以下代码创建了一个包含三个节点(42, -3, 17)的简单链表:

// 在堆上创建第一个节点
ListNode* node1 = new ListNode;
node1->data = 42;

// 创建第二个节点
ListNode* node2 = new ListNode;
node2->data = -3;

// 将第一个节点的 next 指针指向第二个节点
node1->next = node2;

// 创建第三个节点
ListNode* node3 = new ListNode;
node3->data = 17;
node3->next = nullptr; // 第三个节点是链表末尾

// 将第二个节点的 next 指针指向第三个节点
node2->next = node3;

现在,node1 指向链表头部。可以通过 node1->data 访问 42,通过 node1->next->data 访问 -3,通过 node1->next->next->data 访问 17。

链表的优势:高效插入/删除

链表的主要优势在于,在已知位置插入或删除节点时,不需要移动大量元素,只需修改相关节点的指针即可。

例如,要删除上面链表中的第一个节点(42),只需:

ListNode* temp = node1;      // 临时保存原头节点,以便后续释放内存
node1 = node1->next;         // 将头指针指向第二个节点
// 现在链表从 -3 开始
// ... 之后需要 delete temp 来释放原头节点的内存

总结 🎯

本节课我们一起学习了:

  1. 结构体:用于定义包含多个成员的轻量级自定义数据类型,是构建节点的基石。
  2. 指针:存储内存地址的变量。我们学习了如何用 & 获取地址,用 * 声明和解引用指针。
  3. 空指针与野指针:理解了未初始化或指向无效地址的指针带来的风险。
  4. 栈与堆内存:区分了自动管理的栈内存和手动管理的堆内存,并学会了使用 new 在堆上动态创建对象。
  5. 链表实现:综合运用结构体和指针,定义了 ListNode,并手动创建了一个小型链表,理解了其通过指针连接的基本原理。

指针是C++中强大但也需要谨慎使用的工具。理解它们是如何在幕后工作的,将为我们接下来实现完整的链表操作(如遍历、插入、删除)以及学习更复杂的数据结构打下坚实的基础。下节课我们将继续深入链表的世界。

课程14:链表 📚

在本节课中,我们将要学习链表这一数据结构。链表由一系列节点组成,每个节点包含数据和指向下一个节点的指针。我们将探讨如何创建、遍历和操作链表,并理解指针在其中扮演的关键角色。


部门领导计划介绍

在开始今天的讲座之前,我想介绍科林,他是我们的组长之一。他将用几分钟时间介绍我们的部门领导计划。

我叫科林,是部门领导计划的协调员之一。今天来到这里,我很兴奋能和你们谈谈部门领导的事情,并鼓励你们申请。作为CS106X的学生,你们现在都有资格申请部门领导。

部门领导没有标准的形象,无论你的专业或身份如何,如果你有兴趣申请,我们都很想看到你的申请。

以下是关于部门领导计划的一些要点:

  • 这是一份有报酬的工作。
  • 它至少是两个季度的承诺。
  • 你可以通过幻灯片或课后咨询了解更多信息。

成为部门领导的理由有很多。对于有教育兴趣的人来说,这是极好的工作经验。你可以学到很多关于计算机科学的知识,并与学生一对一地工作,看着他们成长,这是非常不可思议的体验。

部门领导团队由一群优秀的人组成,他们不仅善良,而且非常聪明。我们有很多活动,包括一些有趣的传统,以及与谷歌等公司举办的活动。

申请开放还有一个多星期。如果你对申请过程有任何疑问,欢迎通过电子邮件联系我们。


指针回顾与链表引入

上一节我们介绍了部门领导计划,本节中我们来看看链表。首先,让我们回顾一下指针。

指针存储其他值的内存地址。这个“其他值”可以是任何东西,比如一个int、一个对象或一个数组。

指针与构建链表密切相关。在链表中,每个元素(节点)都存储一个指向下一个元素的指针。我们用这些指针将节点链接在一起,形成链表。

创建链表节点时,必须使用new关键字在堆上分配内存。语法是:ListNode* n = new ListNode;。使用new关键字会导致系统在堆上分配一块足够大的内存来存放该类型的对象,并返回其起始内存地址。

将节点存储在堆上至关重要,因为堆上的内存在函数返回后仍然存在。如果只在栈上创建节点,当函数返回时,这些节点所占用的内存会被释放,链表将无法持久存在。

通常,我们只在栈上保留一个指向链表第一个节点(头部)的指针。通过这个头指针,我们可以沿着节点中的next指针访问链表中的所有元素。


指针赋值与链表操作

上一节我们介绍了链表的基本概念和内存分配,本节中我们来看看如何通过指针操作来构建和修改链表。

当代码中涉及指针的赋值语句时,需要建立正确的直觉。画图对此非常有帮助。

赋值语句a->next = p;意味着:让a所指向节点的next指针,指向p所指向的地方。
赋值语句p = a->next;意味着:让指针p指向a所指向节点的next指针所指向的地方。

本质上,这些操作是在改变指针变量中存储的内存地址。构建链表的核心就是操纵这些“箭头”(指针),让它们指向正确的位置。

让我们通过一个练习来加深理解。假设我们想将一个新节点(存储值30)添加到链表头部。

以下是正确的步骤:

  1. 创建新节点:ListNode* temp = new ListNode(30);
  2. 让新节点的next指针指向原链表头部:temp->next = list;
  3. 让头指针指向新节点:list = temp;

注意语句顺序。如果先执行list = temp;,就会丢失指向原链表的指针,导致内存泄漏(无法再访问那些节点)。


遍历链表

上一节我们练习了在链表头部添加节点,本节中我们来看看如何遍历一个链表。

链表可能很长,我们甚至可能不知道其确切长度,因为长度信息并没有直接存储。如果我们只有一个指向链表头部的指针,我们想打印所有值,该怎么做呢?

我们不能像数组或向量那样使用for循环和索引。我们需要一个循环,沿着指针逐个访问节点,直到遇到表示链表结束的nullptr

一个错误的方法是直接移动头指针:

while (list != nullptr) {
    cout << list->data << " ";
    list = list->next; // 错误!这会使头指针丢失
}

这样做会丢失对链表头部的引用,打印一次后链表就“丢”了。

正确的方法是使用一个临时指针(例如current)来遍历:

ListNode* current = list; // current指向链表开头
while (current != nullptr) {
    cout << current->data << " ";
    current = current->next; // current移动到下一个节点,list保持不变
}

这样,头指针list始终指向链表起始位置,我们可以多次遍历链表。


在链表末尾添加元素

上一节我们学习了如何遍历链表,本节中我们利用这个技巧来实现一个在链表末尾添加元素的操作。

基本思路是:遍历链表,找到最后一个节点,然后让它的next指针指向新创建的节点。

以下是实现步骤:

  1. 创建新节点:ListNode* newNode = new ListNode(value);
  2. 处理链表为空的情况:如果头指针frontnullptr,则直接将front指向新节点。
  3. 如果链表不为空,则遍历到最后一个节点(其next指针为nullptr)。
  4. 将最后一个节点的next指针指向新节点。

代码框架如下:

void addToEnd(ListNode*& front, int value) { // 注意参数类型
    ListNode* newNode = new ListNode(value);
    if (front == nullptr) {
        front = newNode; // 链表为空,新节点成为头节点
    } else {
        ListNode* current = front;
        while (current->next != nullptr) { // 找到最后一个节点
            current = current->next;
        }
        current->next = newNode; // 在末尾添加新节点
    }
}

关键点:为了使函数能修改调用方的头指针(例如处理空链表时),需要将头指针参数声明为对指针的引用ListNode*&)。我们将在下一节课详细解释其原因。


总结

本节课中我们一起学习了链表。我们回顾了指针的概念,理解了链表节点通过指针连接的方式,以及为什么必须在堆上分配节点内存。我们练习了通过指针赋值来操作链表,学习了如何正确地遍历链表而不丢失头节点,并初步实现了在链表末尾添加元素的功能。记住,在编写链表代码时,画图是理解和调试的极佳工具。下一节课我们将继续深入,探讨如何将链表操作封装为函数,并解释为何需要传递指针的引用。

课程15:链表操作进阶 🧩

在本节课中,我们将深入学习链表数据结构,并编写一系列操作链表的函数。我们将探讨如何计算链表大小、获取元素、在链表头部和尾部添加节点,以及如何安全地删除节点。理解这些操作对于掌握链表这一基础数据结构至关重要。


链表基础回顾

上一节我们介绍了链表的基本概念和结构。本节中,我们来看看如何对链表进行一些基本操作。

链表由一系列节点组成,每个节点包含数据和指向下一个节点的指针。

struct ListNode {
    int data;
    ListNode* next;
};

计算链表大小 📏

首先,我们来编写一个计算链表大小的函数。思路是从链表头节点开始,遍历每个节点并进行计数,直到遇到空指针。

以下是计算链表大小的步骤:

  1. 检查链表是否为空(头指针是否为 nullptr)。
  2. 如果不为空,则使用一个临时指针遍历链表。
  3. 每移动到一个节点,计数器加一。
  4. 当临时指针变为 nullptr 时,遍历结束,返回计数器的值。
int size(ListNode* front) {
    int count = 0;
    ListNode* current = front;
    while (current != nullptr) {
        count++;
        current = current->next;
    }
    return count;
}

获取链表元素 🔍

接下来,我们实现一个获取链表中指定索引处元素的函数。由于链表不能像数组一样直接通过索引访问,我们需要从头开始遍历。

以下是获取元素的步骤:

  1. 使用一个循环,让临时指针向前移动 index 次。
  2. 移动完成后,临时指针指向的节点即为目标节点。
  3. 返回该节点中存储的数据。

int get(ListNode* front, int index) {
    ListNode* current = front;
    for (int i = 0; i < index; i++) {
        current = current->next;
    }
    return current->data;
}

注意:此代码假设传入的索引是有效的。在实际应用中,应添加索引越界检查。

向链表添加元素 ➕

现在,我们学习如何向链表添加元素。这里有两种常见情况:在链表末尾添加和在链表头部添加。

在链表末尾添加

在链表末尾添加节点需要遍历到最后一个节点,然后将其 next 指针指向新节点。

关键点在于,如果函数需要修改链表本身(例如改变头指针的指向),则需要通过引用传递头指针。

void add(ListNode*& front, int value) {
    ListNode* newNode = new ListNode{value, nullptr};
    if (front == nullptr) {
        // 链表为空,新节点成为头节点
        front = newNode;
    } else {
        // 遍历到链表末尾
        ListNode* current = front;
        while (current->next != nullptr) {
            current = current->next;
        }
        // 将新节点链接到末尾
        current->next = newNode;
    }
}

在链表头部添加

在链表头部添加节点更为简单。我们创建一个新节点,让其 next 指针指向当前的头节点,然后更新头指针指向这个新节点。

void addFront(ListNode*& front, int value) {
    ListNode* newNode = new ListNode{value, front};
    front = newNode;
}

从链表中删除元素 ➖

最后,我们探讨如何从链表中删除元素。删除操作也需要考虑多种情况,并注意避免内存泄漏。

删除头节点

删除头节点相对简单:将头指针指向第二个节点,并释放原头节点的内存。

void removeFront(ListNode*& front) {
    if (front != nullptr) {
        ListNode* trash = front; // 保存待删除节点的指针
        front = front->next;     // 头指针指向下一个节点
        delete trash;            // 释放原头节点的内存
    }
}

删除指定位置的节点

删除链表中间或末尾的节点需要找到目标节点的前一个节点,然后修改其 next 指针以跳过目标节点。

void removeAt(ListNode*& front, int index) {
    if (index == 0) {
        removeFront(front);
    } else {
        ListNode* current = front;
        // 移动到要删除节点的前一个位置
        for (int i = 0; i < index - 1; i++) {
            current = current->next;
        }
        ListNode* trash = current->next; // 要删除的节点
        current->next = current->next->next; // 跳过该节点
        delete trash; // 释放内存
    }
}

核心概念:在C++中,使用 new 在堆上分配的内存必须使用 delete 手动释放,否则会导致内存泄漏。


总结 📚

本节课中我们一起学习了链表的一系列核心操作:

  • 我们回顾了链表的结构。
  • 实现了计算链表大小和获取元素的函数。
  • 重点学习了如何通过引用传递指针来修改链表,实现了在链表头部和尾部添加节点的函数。
  • 最后,我们掌握了如何安全地删除链表节点,并理解了在C++中手动管理内存、避免内存泄漏的重要性。

通过理解这些基础操作,你已为进一步学习更复杂的数据结构和算法打下了坚实的基础。

课程16:C++ 类与对象 🧱

在本节课中,我们将学习如何在C++中创建和使用类与对象。我们将从链表操作的传统函数式写法过渡到面向对象的封装写法,理解类如何将数据和行为组合在一起,并提供抽象和封装的好处。


概述

到目前为止,我们编写了许多操作链表的函数,这些函数都需要将链表的头指针作为参数传递。然而,在标准的库(如向量、哈希映射)中,数据结构通常被实现为对象,其内部包含了数据和操作数据的方法。本节课的目标就是学习如何用C++构建这样的类,将我们的链表功能封装成一个LinkedList类。

上一节我们介绍了链表的手动操作,本节中我们来看看如何用面向对象的方式重新组织这些代码。


什么是类与对象? 🤔

类是创建对象的模板或蓝图。它定义了对象将包含的数据(成员变量)和行为(成员函数)。对象则是类的一个具体实例,它包含了类中定义的数据的实际值,并可以执行类中定义的行为。

面向对象编程的一个核心优势是抽象。使用者无需了解类内部的具体实现细节,只需知道类提供了哪些方法以及这些方法的作用,就可以使用它。例如,整个学期我们都在使用VectorHashMap,而不需要知道它们内部是如何工作的。

在C++中:

  • 存储在对象内部的变量称为成员变量(或实例变量、字段)。
  • 与对象关联的函数称为成员函数(或方法)。
  • 构造函数是一种特殊的成员函数,在创建对象时被调用,用于初始化对象的状态。

C++ 类的文件结构 📁

在C++中,一个类通常被分为两个文件:

  1. 头文件 (.h):包含类的声明,即类名、成员变量和成员函数的原型。
  2. 实现文件 (.cpp):包含类成员函数的具体实现。

这种分离符合“接口与实现分离”的原则。使用该类的其他代码只需包含头文件,了解有哪些可用的方法,而无需关心其实现细节。

头文件 (.h) 的基本结构

头文件通常以“包含守卫”开始,以防止同一个头文件被多次包含时引发的重复定义错误。

// BankAccount.h
#ifndef BANKACCOUNT_H
#define BANKACCOUNT_H

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/4294ba04f37d7a79a5b6fe42f6d0fc99_15.png)

#include <string>

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/4294ba04f37d7a79a5b6fe42f6d0fc99_17.png)

class BankAccount {
public: // 公共接口
    // 构造函数
    BankAccount(std::string name, double balance = 0.0);

    // 成员函数(方法)
    void deposit(double amount);
    void withdraw(double amount);
    double getBalance() const; // const 成员函数,承诺不修改对象状态

private: // 私有实现细节
    // 成员变量(字段)
    std::string name;
    double balance;
};

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/4294ba04f37d7a79a5b6fe42f6d0fc99_19.png)

#endif // BANKACCOUNT_H

核心概念解释

  • #ifndef (if not defined), #define, #endif 是预处理器指令,构成了“包含守卫”。
  • public: 之后的成员可以被类外的代码访问。
  • private: 之后的成员只能被类自身的成员函数访问,这实现了封装
  • 构造函数名与类名相同,没有返回类型。
  • 成员函数原型以分号结尾。


实现文件 (.cpp) 与成员函数定义 🛠️

实现文件需要包含对应的头文件,然后定义头文件中声明的所有成员函数。

// BankAccount.cpp
#include "BankAccount.h"
#include <iostream>
using namespace std;

// 构造函数定义
BankAccount::BankAccount(string n, double bal) {
    name = n;
    if (bal < 0) {
        // 可以在此处添加错误处理,例如抛出异常
    }
    balance = bal;
}

// deposit 成员函数定义
void BankAccount::deposit(double amount) {
    if (amount > 0) {
        balance += amount;
    }
}

// withdraw 成员函数定义
void BankAccount::withdraw(double amount) {
    if (amount > 0 && balance >= amount) {
        balance -= amount;
    } else {
        // 处理余额不足或非法金额的情况
        cerr << "Withdrawal failed for: " << name << endl;
    }
}

// getBalance 成员函数定义
double BankAccount::getBalance() const {
    return balance; // 返回余额的副本,保护原始数据
}

核心概念解释

  • #include "BankAccount.h" 是必须的,这样实现文件才知道类的结构。
  • 定义成员函数时,需要使用作用域解析运算符 :: 来指明这个函数属于哪个类(例如 BankAccount::deposit)。
  • 在成员函数内部,可以直接访问该对象的成员变量(如 balance)。调用该函数的对象提供了这些变量的上下文。
  • const 关键字在函数声明和定义中都必须出现,它向编译器承诺此函数不会修改调用它的对象。

使用类创建对象 🎯

一旦定义了类,就可以在程序(如 main 函数)中创建并使用该类的对象。

// main.cpp
#include "BankAccount.h"
#include <iostream>
using namespace std;

int main() {
    // 创建对象,调用构造函数进行初始化
    BankAccount account1("Alice", 100.0);
    BankAccount account2("Bob"); // 使用默认余额 0.0

    // 调用对象的公共方法
    account1.deposit(50.0);
    cout << "Alice's balance: " << account1.getBalance() << endl; // 输出 150

    account2.withdraw(10.0); // 会失败,因为余额为0
    cout << "Bob's balance: " << account2.getBalance() << endl; // 输出 0

    // 错误示例:无法直接访问私有成员
    // account1.balance = 1000000; // 编译错误!balance 是私有的
    // double b = account1.balance; // 编译错误!

    return 0;
}

核心概念解释

  • 对象创建语法:ClassName objectName(arguments);
  • 使用点运算符 . 来调用对象的成员函数。
  • 由于 balance 是私有变量,客户端代码无法直接访问或修改它,必须通过公共接口(如 deposit, withdraw, getBalance)。这强制了数据的安全性,类可以在这些方法中添加必要的验证逻辑(如检查取款金额是否超过余额)。

封装与 const 成员函数 🔒

以下是封装和 const 关键字的几个关键点:

封装的好处

  • 数据保护:防止外部代码随意修改对象内部状态,导致数据不一致。
  • 实现隐藏:可以更改类的内部实现(例如改变数据存储方式),而不会影响使用该类的客户端代码。
  • 简化接口:为用户提供一组清晰、安全的操作方式。

const 成员函数的作用

  1. 对内的承诺:在函数体内,不能修改对象的任何成员变量(除非变量被 mutable 修饰,这很罕见)。
  2. 对外的契约:如果一个对象被声明为常量(例如 const BankAccount& account),那么只能在其上调用被标记为 const 的成员函数。这保证了不会通过常量引用意外修改对象。

void printBalance(const BankAccount& acc) {
    // acc 是一个常量引用
    cout << acc.getBalance() << endl; // 正确,getBalance 是 const 方法
    // acc.deposit(10); // 错误!deposit 不是 const 方法,不能通过常量引用调用
}


总结

本节课中我们一起学习了C++中类与对象的核心概念:

  1. 是蓝图,对象是实例。类将数据(成员变量)和行为(成员函数)封装在一起。
  2. C++类通常分为头文件 (.h)实现文件 (.cpp)
  3. 使用 publicprivate 访问修饰符来实现封装,保护内部数据,并通过公共方法提供安全可控的访问途径。
  4. 成员函数在类外定义时,需要使用类名和作用域解析运算符 ::
  5. 构造函数用于初始化新创建的对象。
  6. const 成员函数承诺不修改对象状态,允许在常量对象或常量引用上调用。

通过将数据结构(如链表)实现为类,我们可以创建更模块化、更易维护且更安全的代码,这也是现代C++编程的基石。在接下来的课程中,我们将运用这些知识来构建更复杂的数据结构。

课程17:类与对象(二)及跳表 🧩

在本节课中,我们将继续深入学习C++中的类与对象,并探讨链表的高级变体——双链表和跳表。我们将重点介绍运算符重载、析构函数等高级概念,并了解如何将这些概念应用于更复杂的数据结构。


运算符重载

上一节我们介绍了类的基本概念和常量方法。本节中,我们来看看如何通过运算符重载,让自定义类型支持像内置类型一样的操作。

运算符重载是C++的一个独特特性,它允许你为自定义类型定义运算符的行为。例如,你可以为矩阵类定义乘法运算符 *,或者为自定义集合类定义下标访问运算符 []

以下是C++中允许重载的部分运算符列表:

  • 算术运算符:+, -, *, /, %
  • 关系运算符:==, !=, <, >, <=, >=
  • 逻辑运算符:!, &&, ||
  • 赋值运算符:=, +=, -=
  • 下标运算符:[]
  • 函数调用运算符:()
  • 流插入/提取运算符:<<, >>

注意:运算符重载应谨慎使用,仅当运算符的含义对于你的类来说直观明了时才进行重载。滥用会导致代码难以理解。

重载等号运算符(==

如果你想比较两个自定义对象是否相等,可以重载 == 运算符。其函数原型通常如下:

bool operator==(const BankAccount& ba1, const BankAccount& ba2);

实现时,你需要比较两个对象的关键状态(如成员变量)是否一致。

bool operator==(const BankAccount& ba1, const BankAccount& ba2) {
    return ba1.getName() == ba2.getName() && ba1.getBalance() == ba2.getBalance();
}

注意:由于 operator== 是一个全局函数而非类的成员函数,它默认不能访问类的私有成员。如果需要访问,必须在类声明中使用 friend 关键字将其声明为友元函数。

class BankAccount {
    // ... 其他成员 ...
    friend bool operator==(const BankAccount& ba1, const BankAccount& ba2);
};

重载流插入运算符(<<

在C++中,要使对象能够像内置类型一样用 cout 打印,需要重载 << 运算符。

其函数原型和实现通常如下:

// 在头文件中声明
std::ostream& operator<<(std::ostream& os, const BankAccount& ba);

// 在源文件中实现
std::ostream& operator<<(std::ostream& os, const BankAccount& ba) {
    os << ba.getName() << ": $" << ba.getBalance();
    return os; // 必须返回流引用以支持链式调用,如 cout << a << b;
}

同样,如果 operator<< 需要访问私有成员,也需要在类中将其声明为 friend


析构函数

构造函数在对象创建时被调用,用于初始化。与之对应,析构函数在对象生命周期结束时被调用,用于执行清理工作。

析构函数的语法是在类名前加波浪号 ~

class BankAccount {
public:
    ~BankAccount(); // 析构函数声明
    // ... 其他成员 ...
};

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/8205d0a2d45d6f769f576c25181da173_15.png)

// 析构函数定义
BankAccount::~BankAccount() {
    // 清理代码,例如释放动态分配的内存
    std::cout << "Destructor called for " << name << std::endl;
}

何时使用析构函数:当你的类管理着动态分配的资源(例如,使用 new 在堆上分配了内存)时,必须在析构函数中使用 delete 来释放这些资源,以避免内存泄漏。对于仅包含基本类型或标准库对象(如 std::string,它们有自己的析构函数)的简单类,通常不需要编写显式的析构函数。

栈对象与堆对象:对于在栈上创建的对象(如 BankAccount ba;),当其作用域结束时,析构函数会自动调用。对于在堆上创建的对象(如 BankAccount* ba = new BankAccount();),只有在对其指针使用 delete 操作时,析构函数才会被调用。


将链表实现为类

之前我们实现的链表操作函数(如 add, remove)都是全局函数,并接收一个指向链表头节点的指针作为参数。我们可以将这些功能封装到一个 LinkedList 类中。

以下是重构的基本思路:

  1. 私有成员:类内部维护一个指向头节点的私有指针 head,可能还有一个记录链表大小的 size 变量。
  2. 公有方法:之前全局的链表操作函数(如 addToFront, removeAt, get)现在变为类的公有成员方法。它们不再需要 head 参数,因为可以直接访问类的私有成员 head
  3. 析构函数:在 LinkedList 类的析构函数中,需要遍历整个链表,删除所有节点,以防止内存泄漏。
class LinkedList {
private:
    struct Node {
        int data;
        Node* next;
    };
    Node* head;
    int size;

public:
    LinkedList(); // 构造函数,初始化 head 为 nullptr, size 为 0
    ~LinkedList(); // 析构函数,删除所有节点
    void addToFront(int value);
    int get(int index);
    void removeAt(int index);
    // ... 其他方法 ...
};

链表的变体

我们已经掌握了基本的单链表。现在,我们来看看两种功能更强的链表变体。

双链表

在双链表中,每个节点不仅包含指向下一个节点的指针(next),还包含指向前一个节点的指针(prev)。此外,链表类通常还会维护一个指向尾节点的指针(tail)。

优点

  • 可以双向遍历链表。
  • 在已知某个节点的情况下,删除该节点的操作可以在 O(1) 时间内完成(单链表需要从头查找前驱节点)。

缺点

  • 每个节点需要额外的内存来存储前驱指针。
  • 插入和删除节点时,需要维护更多的指针关系,代码稍显复杂。

核心操作变化

  • 插入:在指定节点前或后插入时,需要正确设置新节点与前后节点的 nextprev 指针,共需修改最多4个指针。
  • 删除:删除一个节点时,需要将其前驱节点的 next 指向其后继节点,并将其后继节点的 prev 指向前驱节点。

跳表

跳表是一种用于快速查找的有序数据结构。它通过在原始有序链表的基础上添加多级“索引”来实现近似二分查找的效率。

结构

  • 跳表由多层链表组成。底层是包含所有元素的有序链表。
  • 每一层都是下一层的“快速通道”,元素更稀疏。
  • 每个节点包含一个数据域和一个指针数组(或向量),数组中的第 i 个指针指向该节点在第 i 层链表中的下一个节点。

搜索:从最高层开始,向右遍历。如果右侧节点的值小于目标值,则继续向右;否则,下降到下一层继续查找。重复此过程,直到找到目标或确定目标不存在。这个过程的时间复杂度平均为 O(log n)。

插入

  1. 使用类似搜索的过程,找到新节点在每一层中应该插入的位置(即每一层中最后一个值小于新节点值的节点)。
  2. 随机决定新节点的“高度”(即拥有多少层指针)。一种常见策略是“抛硬币”,从第1层开始,每次有50%的概率增加一层。
  3. 根据步骤1找到的位置和步骤2决定的高度,将新节点插入到每一层的链表中,更新相关指针。

优点:实现相对简单,且能提供有序数据结构的高效查找、插入和删除操作(平均 O(log n)),其性能与平衡树相当。

应用:跳表是Redis等系统中实现有序集合的底层数据结构之一。


总结

本节课我们一起深入学习了C++类的两个高级特性:运算符重载和析构函数。我们了解了如何让自定义类型支持直观的运算符,以及如何安全地管理对象的资源生命周期。接着,我们将之前所学的链表知识封装成类,并探索了其两种高级变体——支持双向遍历的双链表和利用多级索引实现快速查找的跳表。理解这些概念将帮助你设计出更强大、更高效的数据结构和程序。

课程18:数组 📚

在本节课中,我们将学习C++中数组的基础知识,包括其声明、初始化、内存管理,并动手实现一个基于数组的栈数据结构。我们将重点关注数组的核心概念和常见陷阱。


概述

数组是C++中一种基础且高效的数据结构,用于存储相同类型的元素集合。与链表不同,数组在内存中是连续存储的,这使得元素的访问速度非常快。然而,数组的大小在创建时是固定的,这带来了一些挑战。本节我们将学习如何声明和使用数组,并探讨如何利用数组来实现更高级的数据结构,如栈。


数组的声明与内存

在C++中,数组可以在栈上或堆上声明,这决定了数组的生命周期和内存管理方式。

栈上数组

栈上数组在函数作用域内存在,函数结束时自动释放。其声明语法如下:

int arr[10]; // 声明一个包含10个整数的数组,元素值为随机垃圾值

此数组的大小固定为10,其元素初始值为内存中的随机数据。

堆上数组

堆上数组使用new关键字分配,其生命周期由程序员控制,必须使用delete[]手动释放。

int* arr = new int[10]; // 在堆上分配一个包含10个整数的数组
// ... 使用数组 ...
delete[] arr; // 使用完毕后必须释放内存

堆上数组允许我们动态管理内存,例如在需要时重新分配更大的数组。

数组初始化

默认情况下,数组元素不会被初始化。我们可以使用以下语法将数组元素初始化为零:

int* arr = new int[10](); // 所有元素初始化为0

对于栈上数组,C++不允许在声明时使用空括号进行初始化,但可以通过循环手动设置。


数组与指针的关系

在C++中,数组名本质上是指向数组第一个元素的指针。这意味着我们可以使用指针算术来访问数组元素。

int arr[5] = {1, 2, 3, 4, 5};
int* ptr = arr; // ptr指向arr的第一个元素
cout << *(ptr + 2); // 输出arr[2]的值,即3

这种关系使得数组和指针在许多情况下可以互换使用,但也容易导致错误,例如越界访问。


实现基于数组的栈

上一节我们介绍了数组的基本概念,本节我们将利用数组来实现一个栈数据结构。栈是一种后进先出(LIFO)的数据结构,支持推入(push)、弹出(pop)和查看顶部元素(peek)等操作。

栈的设计

我们的栈类将包含以下私有成员:

  • elements:指向堆上数组的指针,用于存储栈元素。
  • size:当前栈中元素的数量。
  • capacity:数组的总容量,即最多可存储的元素数量。

构造函数与析构函数

构造函数负责初始化成员变量,析构函数负责释放堆上数组的内存。

ArrayStack::ArrayStack() {
    size = 0;
    capacity = 10;
    elements = new int[capacity](); // 初始容量为10,元素初始化为0
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/1fddf6d1296d09995d5f80653c33a952_27.png)

ArrayStack::~ArrayStack() {
    delete[] elements; // 释放数组内存
}

推入操作(push)

推入操作将元素添加到栈顶。如果数组已满,我们需要扩展数组容量。

void ArrayStack::push(int value) {
    if (size >= capacity) {
        // 扩展数组容量
        capacity *= 2;
        int* larger = new int[capacity]();
        for (int i = 0; i < size; i++) {
            larger[i] = elements[i];
        }
        delete[] elements;
        elements = larger;
    }
    elements[size] = value;
    size++;
}

弹出操作(pop)

弹出操作移除并返回栈顶元素。如果栈为空,应抛出异常。

int ArrayStack::pop() {
    if (isEmpty()) {
        throw "Stack is empty";
    }
    size--;
    return elements[size];
}

查看顶部元素(peek)

查看操作返回栈顶元素但不移除它。

int ArrayStack::peek() const {
    if (isEmpty()) {
        throw "Stack is empty";
    }
    return elements[size - 1];
}

判断栈是否为空

bool ArrayStack::isEmpty() const {
    return size == 0;
}

打印栈内容

我们可以重载输出运算符来打印栈的内容。

ostream& operator<<(ostream& out, const ArrayStack& stack) {
    out << "{";
    for (int i = 0; i < stack.size; i++) {
        if (i > 0) out << ", ";
        out << stack.elements[i];
    }
    out << "}";
    return out;
}

浅拷贝问题与三法则

在C++中,如果类包含动态分配的内存(如堆上数组),默认的拷贝构造函数和赋值运算符会导致浅拷贝问题。浅拷贝仅复制指针值,使得多个对象共享同一块内存,可能导致内存泄漏或重复释放。

三法则

三法则指出,如果一个类需要自定义析构函数、拷贝构造函数或赋值运算符中的任何一个,那么它很可能需要全部三个。以下是解决浅拷贝问题的方法:

  1. 禁止拷贝:将拷贝构造函数和赋值运算符声明为私有,防止外部调用。
    private:
        ArrayStack(const ArrayStack& other);
        ArrayStack& operator=(const ArrayStack& other);
    

  1. 实现深拷贝:自定义拷贝构造函数和赋值运算符,复制动态内存的内容。
    ArrayStack::ArrayStack(const ArrayStack& other) {
        size = other.size;
        capacity = other.capacity;
        elements = new int[capacity];
        for (int i = 0; i < size; i++) {
            elements[i] = other.elements[i];
        }
    }
    
    ArrayStack& ArrayStack::operator=(const ArrayStack& other) {
        if (this != &other) {
            delete[] elements;
            size = other.size;
            capacity = other.capacity;
            elements = new int[capacity];
            for (int i = 0; i < size; i++) {
                elements[i] = other.elements[i];
            }
        }
        return *this;
    }
    

总结

本节课我们一起学习了C++中数组的基础知识,包括声明、初始化、内存管理及其与指针的关系。通过实现一个基于数组的栈数据结构,我们深入理解了数组的实际应用。此外,我们还探讨了浅拷贝问题及其解决方案——三法则。掌握这些概念对于编写高效、安全的C++程序至关重要。

在接下来的课程中,我们将继续探索更多数据结构及其实现。祝你学习愉快! 🚀

📚 课程名称:编程抽象方法 CS106X 2017 - 第19讲:图(全)与深度优先搜索

📖 概述

在本节课中,我们将学习一种新的数据结构——图。图由顶点和边组成,能够有效建模计算机科学中的许多问题,例如社交网络、地图导航和课程先决条件等。我们将介绍图的基本概念、术语,并学习如何使用斯坦福库中的BasicGraph类。最后,我们将探讨一种基础的图搜索算法:深度优先搜索。


🧩 什么是图?

图是一种数据结构,包含两个主要部分:一组顶点和一组边。顶点有时被称为节点,边则是顶点之间的连接。

在计算机科学中,图用于建模许多问题。例如,社交网络中的用户可以作为顶点,用户之间的好友关系可以作为边。航班路线图中,城市是顶点,城市间的航线是边。

核心概念

  • 顶点/节点:图中的基本元素。
  • 边/弧:连接两个顶点的关系。
  • 路径:一系列边或顶点,将一个顶点连接到另一个顶点。
  • 连通图:图中任意两个顶点之间都存在路径。
  • :一条起点和终点是同一顶点的路径。
  • 自环:一条连接顶点到自身的边。

🔄 图的变体与属性

上一节我们介绍了图的基本构成,本节中我们来看看图的一些常见变体和属性。

加权图

在加权图中,每条边都有一个关联的数值,称为权重。权重可以表示距离、成本、时间等。

公式/代码表示边 e = (顶点A, 顶点B, 权重 w)

有向图

在有向图中,边具有方向,从源顶点指向目标顶点。例如,Twitter的关注关系是有向的。

代码表示边 e = (源顶点 -> 目标顶点)

图可以同时具有多种属性,例如,可以是一个加权有向图。


💻 使用BasicGraph

斯坦福库提供了一个BasicGraph类,方便我们操作图。它默认是一个有向加权图,顶点通常用字符串表示。

以下是BasicGraph的一些常用方法:

  • addVertex(name): 添加一个顶点。
  • addEdge(v1, v2): 添加一条从v1指向v2的边。
  • getNeighbors(v): 获取顶点v的所有邻居(即从v出发能直接到达的顶点)。
  • getVertexSet(): 获取图中所有顶点的集合。
  • getEdgeSet(): 获取图中所有边的集合。

示例:寻找“最酷”的人

假设我们有一个文件,记录了Twitter的关注关系。每行包含两个名字,表示“名字1关注名字2”。我们定义“最酷”的人是拥有最多“追随者的追随者”的人。

思路

  1. 读取文件,构建有向图。为了便于计算“追随者的追随者”,我们让边从被关注者指向关注者(即 addEdge(名字2, 名字1))。
  2. 遍历图中每个顶点(用户)。
  3. 对于每个用户,收集其所有邻居(直接追随者),再收集这些邻居的所有邻居(追随者的追随者),放入一个集合以去重。
  4. 集合的大小即为该用户的“间接追随者”数量。找出拥有最大集合的用户。

核心代码逻辑

// 伪代码示例
BasicGraph graph;
// ... 读取文件构建图 ...

string coolestName;
int maxFollowers = 0;

for (Vertex* v : graph.getVertexSet()) {
    Set<string> allFollowers;
    for (Vertex* neighbor : graph.getNeighbors(v)) {
        for (Vertex* secondNeighbor : graph.getNeighbors(neighbor)) {
            allFollowers.add(secondNeighbor->name);
        }
    }
    if (allFollowers.size() > maxFollowers) {
        maxFollowers = allFollowers.size();
        coolestName = v->name;
    }
}
cout << "最酷的人是: " << coolestName << endl;

🧭 深度优先搜索

现在,我们来看看如何在图中寻找路径。一个基础的算法是深度优先搜索。

深度优先搜索从一个起始顶点开始,沿着一条路径尽可能深入地探索,直到无法继续,然后回溯并尝试其他路径。这本质上是一种递归回溯算法。

算法思路

  1. 从起点开始,将其标记为“已访问”。
  2. 检查当前顶点是否是目标顶点。如果是,则成功找到路径。
  3. 如果不是,则递归地对每一个“未访问”的邻居顶点,执行深度优先搜索。
  4. 如果所有邻居都探索完毕仍未找到目标,则回溯。

特点

  • DFS不保证找到的路径是最短路径。
  • 它可能首先找到一条较长的路径。

伪代码

bool dfs(Vertex* current, Vertex* target, Set<Vertex*>& visited) {
    if (current == target) return true; // 找到目标
    visited.add(current); // 标记为已访问

    for (Vertex* neighbor : current->neighbors) {
        if (!visited.contains(neighbor)) {
            if (dfs(neighbor, target, visited)) {
                return true; // 通过这个邻居找到了
            }
        }
    }
    return false; // 从当前顶点出发未找到
}

📝 总结

本节课中我们一起学习了图的基础知识。我们了解了图的定义、顶点和边的概念,以及加权图、有向图等变体。我们实践了如何使用BasicGraph类来构建图并解决实际问题(如寻找社交网络中最有影响力的人)。最后,我们介绍了深度优先搜索算法,这是一种利用递归回溯在图中寻找路径的基本方法。下节课我们将学习另一种搜索算法——广度优先搜索,它能找到最短路径。

编程抽象方法 CS106X 2017 - 课程02:函数 🧮

在本节课中,我们将学习C++中的函数,包括其语法、参数传递机制(值传递与引用传递)、函数原型以及一些实用的编程技巧。我们还将探讨如何设计函数来处理多个返回值。


概述 📋

C++的许多语法与Java相似。本节课将重点介绍C++函数特有的概念,例如默认参数、函数原型以及引用参数。理解这些概念对于编写高效且结构清晰的C++程序至关重要。


C++程序的基本结构

一个典型的C++程序从main函数开始,其中包含输出语句。例如:

#include <iostream>
using namespace std;

int main() {
    cout << "Hello, CS106X!" << endl;
    return 0;
}
  • #include <iostream> 是预处理指令,用于引入输入输出流库。
  • using namespace std; 允许我们直接使用std命名空间中的标识符(如cout),而无需添加std::前缀。
  • cout 用于向控制台输出文本。
  • endl 用于结束当前行。


库与命名空间

C++中有两种库:语言标准库(如iostream)和项目特定库(如斯坦福课程库)。引入它们的方式略有不同。

  • 语言库:使用尖括号 #include <library_name>
  • 项目库:使用双引号 #include "library_name.h"

namespace(命名空间)用于组织代码,避免名称冲突。using namespace std; 语句使得在后续代码中可以直接使用std命名空间内的名称。

注意:如果多个命名空间包含同名的标识符,且都使用了using语句,可能会引发冲突。通常,我们只为最常用的命名空间(如std)使用using语句。


用户输入与更好的方法

可以使用cin进行控制台输入,但其错误处理能力较弱。

int age;
cout << "Please enter your age: ";
cin >> age; // 如果用户输入非数字,此处会出错

更稳健的方法是使用斯坦福库中的simpio.h,它提供了如getInteger()这样的函数,能够持续提示用户直到获得有效输入。

#include "simpio.h"
int score = getInteger("Stanford score: ");


函数的定义与调用

C++中定义函数的语法与其他语言类似。

// 函数定义
double circleArea(double radius) {
    return 3.14159 * radius * radius;
}

int main() {
    double area = circleArea(5.0); // 函数调用
    cout << area << endl;
    return 0;
}

默认参数

C++允许为函数参数指定默认值。所有带有默认值的参数必须位于参数列表的末尾。

void printLine(int length, char ch = '*') {
    for (int i = 0; i < length; i++) {
        cout << ch;
    }
    cout << endl;
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/8abe80c1bcccfd672b6473741c95c60d_31.png)

// 调用示例
printLine(10);      // 打印10个 '*'
printLine(5, '?');  // 打印5个 '?'

函数原型

在C++中,函数必须在使用前被声明或定义。如果希望main函数位于文件开头,可以使用函数原型进行前置声明。

// 函数原型(声明)
double circleArea(double radius);
void singASong(int times = 5);

int main() {
    double a = circleArea(2.0);
    singASong();
    return 0;
}

// 函数定义
double circleArea(double radius) {
    return 3.14159 * radius * radius;
}
void singASong(int times) {
    for (int i = 0; i < times; i++) {
        cout << "La la la..." << endl;
    }
}

注意:默认参数只需在函数原型中指定一次。


参数传递:值与引用

这是本节课的核心概念。C++允许程序员选择参数传递的方式。

  • 值传递:函数获得参数的副本。修改副本不影响原始变量。
  • 引用传递:函数参数是原始变量的别名(使用&符号)。修改参数会直接影响原始变量。

以下示例展示了两种方式的区别:

// 值传递 - 无法交换main中的变量
void swapBad(int a, int b) {
    int temp = a;
    a = b;
    b = temp;
}

// 引用传递 - 可以成功交换
void swapGood(int &a, int &b) {
    int temp = a;
    a = b;
    b = temp;
}

int main() {
    int x = 3, y = 4;
    swapBad(x, y); // x, y 未被交换
    swapGood(x, y); // x, y 的值被交换
    return 0;
}

引用传递常用于以下场景:

  1. 修改传入的变量(如swapGood)。
  2. 实现“输出参数”,使函数能够返回多个值。
  3. 传递大型对象,避免复制的开销。

设计建议:除非有必要(如上述场景),否则应优先使用值传递,以使代码行为更清晰、更易于推理。


示例:二次方程求解器

假设我们需要一个函数来求解二次方程 ( ax^2 + bx + c = 0 ) 的实根。由于需要返回两个根,我们可以使用引用参数作为输出。

// 函数设计:a, b, c为值参数(输入),root1和root2为引用参数(输出)
void quadraticRoots(double a, double b, double c, double &root1, double &root2) {
    double discriminant = b * b - 4 * a * c;
    // 此处假设判别式 >= 0
    root1 = (-b + sqrt(discriminant)) / (2 * a);
    root2 = (-b - sqrt(discriminant)) / (2 * a);
}

int main() {
    double r1, r2; // 无需初始化,将由函数填充
    quadraticRoots(1, -3, 2, r1, r2); // 解方程 x^2 - 3x + 2 = 0
    cout << "Roots are: " << r1 << " and " << r2 << endl;
    return 0;
}

思考:如何改进此函数以处理无实根的情况?(例如,可以返回一个根的数量状态码)。


总结 🎯

本节课我们一起学习了C++函数的关键知识:

  1. C++程序的基本结构和输入输出。
  2. 如何使用库和命名空间。
  3. 如何定义和调用函数,包括使用默认参数。
  4. 函数原型的作用及其在代码组织中的重要性。
  5. 参数传递的两种方式:值传递与引用传递,理解了它们各自的用途和优缺点。
  6. 通过引用参数实现函数的多返回值输出。

掌握值传递与引用传递的区别是理解C++函数行为的基础。在后续课程中,当我们学习类和对象时,这些概念将变得更加重要。请记住,良好的函数设计是构建可读、可维护程序的关键。

课程20:图搜索算法进阶 - BFS与Dijkstra算法 🧭

在本节课中,我们将继续学习图论中的路径搜索算法。上一节我们介绍了图的基本概念、术语以及深度优先搜索(DFS)。本节中,我们将重点探讨两种更高效的算法:广度优先搜索(BFS)和Dijkstra算法。我们将学习它们的工作原理、实现方式以及各自的适用场景。

从DFS到BFS 🔄

上一节我们介绍了深度优先搜索(DFS),它是一种沿着单一路径尽可能深入探索的算法。虽然DFS能判断图中是否存在路径,但它不保证找到的是最短路径。本节中我们来看看广度优先搜索(BFS),它采用了一种不同的搜索策略。

BFS的核心思想是从起点开始,逐层向外探索所有可能的路径。它首先检查所有距离起点为1步的顶点,然后是距离为2步的顶点,依此类推,直到找到目标顶点。这种方法保证了找到的路径是边数最少的(即最短路径)。

以下是BFS算法的基本伪代码描述:

queue.enqueue(startVertex);
while (!queue.isEmpty()) {
    current = queue.dequeue();
    mark current as visited;
    if (current == target) {
        // 找到路径
        break;
    }
    for (each neighbor of current) {
        if (neighbor is not visited) {
            queue.enqueue(neighbor);
            record predecessor of neighbor as current; // 用于重建路径
        }
    }
}

BFS的实现细节与路径重建 🛠️

在实现BFS时,我们需要记录每个顶点的“前驱”信息,以便在找到目标后能够重建完整的路径。这与DFS不同,因为BFS是同时探索多条路径,而非单一路径。

以下是实现BFS时需要注意的几个关键点:

  • 避免重复访问:必须标记已访问的顶点,否则在存在环的图中算法可能陷入无限循环。
  • 路径重建:通常使用一个映射(如 map<Vertex, Vertex>)来记录每个顶点是从哪个顶点访问过来的(即其前驱)。找到目标后,从目标顶点开始,沿着前驱指针回溯到起点,即可得到路径(注意路径是反向的)。
  • 数据结构:使用队列(Queue)来管理待访问的顶点,确保“先进先出”的访问顺序,从而实现层序遍历。

BFS的时间复杂度通常为 O(V + E),其中V是顶点数,E是边数。这意味着算法访问每个顶点和每条边的次数是常数级的。

引入权重:Dijkstra算法 ⚖️

BFS能找到边数最少的路径,但现实中的图(如道路网、网络)的边往往带有权重(如距离、成本、时间)。这时,我们关心的是总权重最小的路径,而非边数最少的路径。本节中我们来看看Dijkstra算法,它解决了带权图中的最短路径问题。

Dijkstra算法以科学家Edsger Dijkstra的名字命名。它的核心思想与BFS类似,但使用优先级队列(通常是最小堆)代替普通队列。顶点按照从起点到该顶点的当前已知最小成本(距离)进行优先级排序。

以下是Dijkstra算法的基本步骤:

  1. 初始化:将起点成本设为0,其他所有顶点成本设为无穷大(表示尚未到达)。将所有顶点放入优先级队列(PQ)。
  2. 循环:从PQ中取出当前成本最小的顶点 u,标记为已访问。
  3. 松弛操作:检查 u 的所有未访问邻居 v。计算通过 u 到达 v 的新成本:newCost = cost[u] + weight(u, v)。如果 newCost 小于 v 的当前记录成本,则更新 v 的成本为 newCost,并记录 v 的前驱为 u(同时调整 v 在PQ中的优先级)。
  4. 重复步骤2-3,直到从PQ中取出的顶点是目标顶点(此时可以确定找到了最小成本路径),或者PQ为空。

关键公式:松弛操作的核心是 if (cost[u] + weight(u, v) < cost[v]) { cost[v] = cost[u] + weight(u, v); }

Dijkstra算法图解与特性 🧩

让我们通过一个简单例子理解Dijkstra算法的工作过程。假设我们寻找从A到F的最小成本路径。

  1. 起点A成本为0,其他顶点成本为∞。PQ: [(A, 0)]。
  2. 取出A,更新其邻居B(成本1)、D(成本2)。PQ: [(B,1), (D,2), ...]。
  3. 取出B(成本最低),更新其邻居E(成本1+2=3)。PQ: [(D,2), (E,3), ...]。
  4. 取出D,更新其邻居C(成本2+1=3)、F(成本2+7=9)。PQ: [(C,3), (E,3), (F,9), ...]。
  5. 取出C(或E,成本相同),发现通过C到F的成本为3+5=8,优于当前的9,因此更新F的成本为8,前驱为C。
  6. 继续此过程,最终当F从PQ中取出时,其成本即为最小成本,通过前驱链可重建路径。

Dijkstra算法的重要前提是图中不能有负权边。负权边会破坏“一旦顶点从PQ中取出,其成本就已确定最小”的性质,可能导致算法失效。其时间复杂度取决于优先级队列的实现,使用二叉堆时约为 O((V+E) log V)

BFS与Dijkstra的对比 🤔

为了更清晰地理解,我们来总结一下BFS和Dijkstra算法的核心区别:

  • 优化目标:BFS寻找边数最少的路径;Dijkstra寻找总权重最小的路径。
  • 数据结构:BFS使用队列;Dijkstra使用优先级队列
  • 边权处理:BFS忽略边权(或视所有边权为1);Dijkstra显式处理边权。
  • 共同点:两者都需要标记已访问顶点,并记录前驱信息以重建路径。它们都属于“单源最短路径”算法。

选择哪种算法取决于具体问题。如果只关心连通性或不考虑权重,DFS或BFS更简单高效。如果边权代表真实成本(如距离、票价),则需要使用Dijkstra算法。

总结与预告 📚

本节课中我们一起学习了两种重要的图路径搜索算法。我们首先深入探讨了广度优先搜索(BFS),理解了它逐层搜索的特性以及如何找到边数最少的最短路径。接着,我们引入了边权的概念,并学习了Dijkstra算法,它利用优先级队列,总能找到从起点到目标点的总成本最低的路径。

我们比较了BFS和Dijkstra,明确了它们分别针对“最短跳数”和“最小成本”的不同优化目标。这两种算法是理解更复杂图算法的基础。

下节课(周五),我们将探讨对Dijkstra算法的进一步优化——A*搜索算法。它会利用启发式信息来智能地引导搜索方向,从而在特定场景下(如地图导航)大幅提升搜索效率。我们还将更详细地分析这些算法的时间复杂度。敬请期待!

课程21:A*搜索与Kruskal算法 🧭🌲

在本节课中,我们将学习两种重要的图算法:A搜索算法和Kruskal最小生成树算法。我们将了解A搜索如何利用启发式信息优化路径查找,以及Kruskal算法如何构建最小生成树。


Dijkstra算法回顾

上一节我们介绍了Dijkstra算法,它用于在加权图中找到从起点到所有其他顶点的最小成本路径。

Dijkstra算法的核心是使用一个优先级队列(Priority Queue),该队列根据从起点到当前顶点的已知最小成本进行排序。算法总是优先探索成本最低的路径,从而确保找到最小权重路径。

伪代码核心思想

将起点加入优先级队列,成本为0。
当优先级队列不为空时:
    取出当前成本最低的顶点u。
    如果u是目标顶点,则结束。
    对于u的每个邻居v:
        计算从起点经过u到达v的新成本 = cost[u] + weight(u, v)。
        如果新成本小于已知的cost[v]:
            更新cost[v]为新成本。
            将v及其新成本加入优先级队列。

在非加权图(或边权均为1的图)中,Dijkstra算法的行为退化为广度优先搜索(BFS),因为所有路径的成本增长与路径长度成正比。


A*搜索算法介绍 🎯

本节中,我们来看看Dijkstra算法的一个优化变体——A搜索算法。A搜索在Dijkstra的基础上,加入了启发式信息来引导搜索方向,从而在某些情况下大幅减少需要探索的顶点数量。

启发式函数

启发式函数 h(v) 是对从顶点 v 到目标顶点 G剩余成本的估计。它是一种“有根据的猜测”。

一个关键的启发式类型是可接受启发式。可接受启发式永远不会高估从当前顶点到目标的实际成本。即对于所有顶点 v,满足:

h(v) ≤ 实际从 v 到 G 的最小成本

例如,在网格地图中,从当前点到目标的直线距离(曼哈顿距离或欧几里得距离)就是一个常见的可接受启发式。

A*搜索的工作原理

A*搜索与Dijkstra的唯一区别在于优先级队列的排序依据。

在Dijkstra中,优先级依据是 g(v),即从起点 S 到顶点 v 的已知最小成本。
在A*中,优先级依据是 f(v) = g(v) + h(v),即已知成本加上到目标的估计成本。

A*搜索伪代码修改部分

当优先级队列不为空时:
    取出当前 f(v) = g(v) + h(v) 值最小的顶点u。
    ...(其余步骤与Dijkstra相同)

通过将启发式值 h(v) 加入优先级计算,算法会倾向于探索那些“看起来”更接近目标的顶点。

A*搜索的优势与注意事项

A*搜索在拥有良好启发式信息的图上效率显著高于Dijkstra。它访问的顶点更少,能更快地找到目标。

然而,使用A*搜索有两个重要前提:

  1. 图必须包含启发式信息:例如地图坐标、单词阶梯的编辑距离等。对于普通的抽象图,可能没有合适的启发式。
  2. 启发式必须是可接受的:如果启发式高估了实际成本,可能导致算法找不到最小成本路径。当启发式函数为 h(v) = 0 时,A*搜索即退化为Dijkstra算法。

核心公式

f(v) = g(v) + h(v)

其中:

  • f(v):顶点v的优先级分数。
  • g(v):从起点到v的实际最小成本。
  • h(v):从v到目标的启发式估计成本(必须可接受)。

最小生成树与Kruskal算法 🌲

现在,我们转向一个不同类型的图问题——寻找最小生成树。这在创建网络连接、设计电路或生成随机迷宫等问题中非常有用。

什么是生成树?

一个图的生成树是包含原图所有顶点的子图,并且是一棵树(即连通且无环)。生成树中的边数恰好是顶点数减一。

什么是最小生成树?

在加权图中,最小生成树是所有可能的生成树中,边的权重总和最小的那一棵。

Kruskal算法步骤

Kruskal算法是一种贪心算法,用于寻找最小生成树。其思路非常直观。

以下是Kruskal算法的步骤:

  1. 初始化:将原图的所有边放入一个优先级队列(最小堆),按边权重从小到大排序。
  2. 创建独立集合:将每个顶点视为一个独立的连通分量(或“集群”)。
  3. 迭代加边
    • 从优先级队列中取出当前权重最小的边。
    • 检查这条边连接的两个顶点是否属于同一个连通分量(使用并查集数据结构高效判断)。
    • 如果不属于同一个分量,则加入这条边(合并两个连通分量)。
    • 如果属于同一个分量,加入它会形成环,因此舍弃这条边。
  4. 终止条件:当加入的边数达到 (顶点数 - 1) 时,算法结束,此时已构成最小生成树。

算法核心思想:始终尝试添加当前可用的、不会形成环的最便宜的边。

Kruskal算法示例

假设我们有一个图,其边和权重如下:

边: A-B (1), A-C (4), B-C (2), B-D (6), C-D (3)

以下是执行过程:

  1. 选择最便宜的边 A-B (1),加入。
  2. 选择下一个最便宜的边 B-C (2),加入。
  3. 选择下一个最便宜的边 C-D (3),加入。此时已连接所有顶点,且边数为3(4个顶点-1)。算法结束。
  4. 边 A-C (4) 和 B-D (6) 被跳过,因为加入它们会在已连通的顶点间形成环。

最终得到的最小生成树总权重为 1 + 2 + 3 = 6。


总结

本节课中我们一起学习了两种强大的图算法。

首先,我们深入探讨了A*搜索算法。它是Dijkstra算法的优化版本,通过引入一个可接受的启发式函数 h(v) 来估计到目标的成本,并将 f(v) = g(v) + h(v) 作为优先级,从而引导搜索方向,显著提升效率。记住,启发式的质量至关重要。

接着,我们学习了Kruskal算法,用于求解加权图的最小生成树。该算法采用贪心策略,不断选取权重最小且不会构成环的边,直到将所有顶点连通。实现的关键在于使用优先级队列排序边,以及使用并查集来高效管理顶点间的连通性。

掌握这两种算法,你将能够解决更广泛的路径规划和网络优化问题。

课程22:继承与哈希 🧬🔍

在本节课中,我们将深入学习面向对象编程中的继承概念,并探讨数据结构哈希表的基本原理与实现。我们将从继承的语法和注意事项开始,然后转向哈希表如何实现高效的集合操作。


继承详解

上一节我们介绍了继承的基本概念。本节中,我们来看看继承在C++中的具体语法和一些关键细节。

继承语法与构造函数

在C++中,使用冒号:表示一个类继承自另一个类。例如,Lawyer类继承自Employee类:

class Lawyer : public Employee {
    // ...
};

当子类需要调用父类的构造函数时,使用初始化列表语法。这是因为子类不能直接初始化从父类继承来的私有成员。

Lawyer::Lawyer(string name, int years, string lawSchool)
    : Employee(name, years), myLawSchool(lawSchool) {
    // 子类特有的初始化代码
}

方法重写与virtual关键字

为了允许子类重写父类的方法,需要在父类中将该方法声明为virtual。这确保了在运行时能正确调用子类重写的方法,而不是父类的版本。

class Employee {
public:
    virtual void speak() {
        cout << "I'm an employee." << endl;
    }
};

调用父类方法

在子类中,若想调用父类被重写的方法,需要使用父类名和作用域解析运算符::

void Lawyer::speak() {
    Employee::speak(); // 调用父类的speak方法
    cout << "I'm a lawyer." << endl;
}


何时不应使用继承

继承并非适用于所有“是一个”的关系。如果不加选择地使用,可能导致设计问题。

以下是几个不应使用继承的典型场景:

  • Point3D 继承自 Point2D:三维点并非二维点的简单扩展,许多方法(如距离计算)需要完全不同的实现,强行继承会导致逻辑混乱和潜在错误。
  • Square 继承自 Rectangle:正方形虽然是矩形,但修改宽度时高度也应同步修改。如果Rectangle有独立的setWidthsetHeight方法,Square重写它们会导致使用者感到意外,违反了“里氏替换原则”。
  • SortedVector 继承自 Vector:排序向量重写insert方法以保持顺序,但使用者按索引插入时,元素可能出现在其他位置,这违背了使用者对Vector行为的预期。

在这些情况下,更推荐使用组合(即在类内部声明一个私有成员对象)而非继承,或者让两个类共同继承自一个更抽象的基类。


纯虚函数与抽象类

纯虚函数是在基类中声明但不实现的虚函数,语法是在声明后加上= 0。包含纯虚函数的类称为抽象类,不能直接实例化。

class Shape { // 抽象类
public:
    virtual double area() = 0; // 纯虚函数
    virtual double perimeter() = 0;
};

子类必须实现所有的纯虚函数,否则它也会成为抽象类。这类似于Java中的接口或抽象方法,用于定义一套必须实现的行为规范。


多重继承

C++支持多重继承,即一个类可以同时继承多个父类。

class TeachingAssistant : public Student, public Employee {
    // ...
};

然而,多重继承容易引入复杂性,例如当多个父类拥有同名方法时会产生歧义(菱形继承问题)。大多数现代编程语言(如Java)不支持多重继承,而是通过接口来实现类似功能。


哈希表原理

现在,让我们从继承转向另一个核心主题:哈希表。哈希表是实现集合(Set)映射(Map) 的高效数据结构。

为什么需要哈希表?

我们首先思考几种实现集合的方式及其效率:

  • 无序数组/向量:添加快O(1),但查找和删除慢O(n)
  • 有序数组:使用二分查找,查找快O(log n),但添加和删除需要移动元素,慢O(n)
  • 二叉搜索树:平衡时,添加、查找、删除都快O(log n)

哈希表的目标是让添加、查找、删除都达到平均情况下的常数时间复杂度 O(1)

哈希表的核心思想

哈希表的核心是使用一个数组(称为哈希表)来存储元素。关键是通过一个哈希函数,将任意类型的元素(键)转换成一个数组索引(哈希码)。

理想情况:每个元素都通过哈希函数计算出一个唯一索引,直接存入数组对应位置。这样,查找时只需计算一次哈希函数就能定位,实现O(1)操作。

// 一个简单的哈希函数示例(仅适用于非负整数)
int hashFunction(int value, int arraySize) {
    return value % arraySize; // 取模运算确保索引在数组范围内
}

哈希冲突

现实中的问题是哈希冲突:不同的元素可能被哈希函数映射到同一个数组索引。

以下是两种主要的冲突解决方法:

1. 线性探测

如果目标索引已被占用,则顺序检查下一个索引,直到找到空位。

  • 优点:实现简单,数据直接存储在数组中。
  • 缺点:容易产生聚集现象,即连续的被占位置形成长簇,降低查找效率。删除操作需要特殊标记(如标记为“已删除”而非直接置空),以避免中断查找链。

2. 分离链接法

数组的每个索引位置不再存储单个元素,而是存储一个链表(或其他容器)。所有映射到同一索引的元素都放入该链表中。

  • 优点:解决了聚集问题,删除操作简单。
  • 缺点:需要额外的内存存储链表指针。如果某个链表过长,性能会下降。

在实际应用中(如C++ STL的unordered_set、Java的HashSet),分离链接法是更常用的策略。通过设置合理的数组大小和哈希函数,可以保证链表平均长度很短,从而维持O(1)的平均时间复杂度。

哈希函数的设计

对于非整型对象(如字符串、自定义类),需要设计哈希函数将其状态转换为一个整型哈希码。

例如,字符串的哈希函数可以将其字符的ASCII码相加或使用更复杂的多项式滚动哈希:

int hashString(string s, int tableSize) {
    int hash = 0;
    for (char c : s) {
        hash = hash * 31 + c; // 一个简单的多项式哈希
    }
    return abs(hash) % tableSize;
}

一个好的哈希函数应尽可能将不同的输入均匀地映射到整个索引范围,以减少冲突。


总结

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

  1. 继承的深入语法,包括构造函数调用、方法重写、虚函数和纯虚函数。
  2. 理解了滥用继承可能带来的设计问题,并学习了“组合优于继承”的原则。
  3. 探讨了C++中多重继承的存在及其潜在复杂性。
  4. 引入了哈希表的概念,理解了其追求O(1)操作时间复杂度的核心目标。
  5. 学习了哈希表通过哈希函数将元素映射到数组索引的基本原理。
  6. 分析了哈希冲突的必然性,并掌握了两种主要的冲突解决方法:线性探测分离链接法

哈希表是现代编程中极其重要的数据结构,理解其原理对于编写高效程序至关重要。下节课我们将继续深入哈希的更多细节。

课程23:哈希表(二)与继承(二) 🧮➡️🧬

在本节课中,我们将继续深入学习哈希表的工作原理,特别是如何处理冲突和调整哈希表的大小。同时,我们也会回顾继承的概念,并通过一些练习来加深理解。


哈希表冲突解决与调整大小

上一节我们介绍了哈希表的基本概念,即通过哈希函数将数据元素映射到数组索引。本节中,我们来看看当多个元素映射到同一索引(即发生冲突)时,以及当哈希表需要扩容时,我们该如何处理。

处理冲突:链地址法

当两个不同的值通过哈希函数计算出相同的索引时,就会发生冲突。一种常见的解决方法是链地址法。在这种方法中,数组的每个索引位置不直接存储单个值,而是存储一个值的集合(例如一个链表)。

核心思想

  • 每个数组位置(称为“桶”)指向一个链表。
  • 当发生冲突时,新元素被添加到对应索引的链表中。

代码示例(概念性)

// 假设有一个哈希表数组,每个元素是一个链表头指针
LinkedList* buckets[TABLE_SIZE];

// 添加元素时
int index = hashFunction(value) % TABLE_SIZE;
buckets[index].addToFront(value); // 通常添加到链表头部以提高效率

这种方法允许我们在同一个索引下存储多个元素,从而解决了冲突问题。

哈希表的调整大小

与向量类似,哈希表在元素数量达到一定阈值时也需要调整大小,以保持高效的操作性能。然而,哈希表的调整大小过程比向量更复杂。

为什么需要调整大小?
理论上,链表可以无限增长,哈希表永远不会“满”。但是,如果链表变得过长,查找、添加和删除操作的时间复杂度将退化为 O(n),这与使用普通链表无异,失去了哈希表的优势。

负载因子
为了决定何时调整大小,我们引入负载因子的概念。

公式
负载因子 = 元素数量 / 哈希表数组长度

例如,如果哈希表数组长度为10,存储了6个元素,那么负载因子就是0.6。

调整大小的时机
当负载因子超过一个预设的阈值(例如0.75或0.66)时,我们就需要调整哈希表的大小。

调整大小的步骤

  1. 创建一个新的、更大的数组(通常是原大小的两倍左右)。
  2. 遍历旧数组中的所有桶(即所有链表)。
  3. 对于旧数组中的每一个元素,使用新的数组长度重新计算其哈希索引
  4. 将元素插入到新数组对应的桶中。

重要原因:因为索引计算通常依赖于数组长度(例如 hash(value) % newSize),所以数组长度改变后,元素在新数组中的位置很可能与旧数组中不同。不能简单地复制旧数组。


哈希函数的设计

为了使用哈希表,我们需要为各种数据类型(如字符串、自定义对象)设计良好的哈希函数。

优秀哈希函数的特性

  1. 一致性:如果两个对象相等(根据 ==.equals() 判断),那么它们的哈希码必须相等。
  2. 高效性:计算速度应尽可能快。
  3. 均匀分布:哈希码应尽可能均匀地分布在整型值域内,以减少冲突。

字符串的哈希函数示例

一个简单但效果不佳的方法是只返回字符串长度。虽然一致,但分布极差(很多短字符串会冲突)。

一个更好的方法是结合字符串中所有字符的信息:

代码示例(类似Java的字符串哈希算法)

int hashString(string s) {
    int hash = 0;
    for (int i = 0; i < s.length(); i++) {
        hash = 31 * hash + s[i]; // 31是一个经验质数乘子
    }
    return hash;
}

这种方法考虑了每个字符及其位置,使得不同字符串(即使是相同字符的不同排列)更有可能产生不同的哈希码。

自定义对象的哈希函数

对于一个包含多个字段(如 x, y)的 Point 类,可以将其所有字段的哈希码组合起来:

代码示例

struct Point {
    int x, y;
    int hashCode() {
        int hash = 0;
        hash = 31 * hash + x;
        hash = 31 * hash + y;
        return hash;
    }
};

核心思想是:获取对象每个关键字段的哈希码,然后用一个乘数(如31)将它们混合在一起,生成最终的哈希码。


哈希集合与哈希映射的实现关系

哈希集合(HashSet)和哈希映射(HashMap)的实现非常相似。事实上,一种常见的实现技巧是用哈希映射来实现哈希集合

核心思想

  • 哈希集合可以看作一个所有值都映射到 true(或某个固定值)的哈希映射。
  • 在哈希集合内部,实际存储的是一个 HashMap<ValueType, bool>,其中键是集合元素,值恒为 true

这样做避免了代码重复,因为哈希映射已经实现了所有核心逻辑(添加、删除、查找、处理冲突、调整大小),哈希集合只需调用这些逻辑即可。


继承回顾与多态练习

现在,让我们将注意力转回到继承上。理解继承和多态的关键在于区分变量的编译时类型对象的运行时类型

核心规则

  1. 编译检查:编译器根据变量的声明类型来检查方法调用是否合法(即该类型是否有此方法)。
  2. 运行时行为:程序运行时,实际执行的是对象实际类型中定义的方法版本。

类型转换的注意事项

当使用强制类型转换(如 (Subclass) variable)时:

  • 转换本身是否合法(能否编译)取决于转换操作。
  • 即使转换能编译,如果对象的实际类型并不是转换目标类型的子类(或本身),程序在运行时也会崩溃(例如 std::bad_cast 或访问不存在的成员)。

练习建议

课程提供的“多态神秘问题”幻灯片和讲义中包含了一系列经典的继承题目。建议大家:

  1. 仔细阅读每个类的定义和方法。
  2. 对于每行代码,先判断其是否能够编译(看变量/转换后的类型)。
  3. 如果能编译,再判断其运行结果(看对象实际类型的方法实现)。
  4. 特别注意强制类型转换可能带来的运行时错误。

通过反复练习这类问题,可以牢固掌握继承和多态的动态行为。


总结

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

  1. 哈希表冲突的解决:深入探讨了使用链地址法来处理冲突,即在每个桶中使用链表(或其他集合)存储多个元素。
  2. 哈希表的调整大小:引入了负载因子的概念,解释了为何及何时需要调整哈希表大小,并描述了重新哈希(rehashing)的过程。
  3. 哈希函数设计:学习了为字符串和自定义对象设计良好哈希函数的原则和通用模式(混合各字段哈希码)。
  4. 哈希集合与映射的关系:了解到哈希集合可以通过内部封装一个哈希映射来实现。
  5. 继承与多态:回顾了继承的核心规则,并强调了通过练习“多态神秘问题”来巩固对变量类型、对象类型及强制转换的理解。

掌握这些内容,你将能更深入地理解现代编程中这些基础且强大的抽象工具是如何工作的。

编程抽象方法 CS106X 2017 - 第24讲:排序算法 🧮

在本节课中,我们将要学习几种经典的排序算法。排序是计算机科学中的一个基本问题,它涉及将一组数据按照特定顺序重新排列。我们将从简单但低效的算法开始,逐步深入到更高效、更巧妙的算法,并分析它们的性能。


什么是排序?🤔

排序是将数据重新安排成有序序列的过程。例如,对整数排序通常意味着按数字大小排列,对字符串排序则通常按字母顺序排列。但排序的顺序可以根据需求定义,例如按字符串长度排序。虽然在实际编程中我们通常调用库函数来完成排序,但学习排序算法有助于理解算法设计、时间复杂度分析等核心概念。


低效的排序算法

在探讨高效算法之前,我们先来看两个虽然简单但效率较低的算法,以建立对比的基础。

Bogo排序 🎲

Bogo排序是一个非常低效的算法。其基本思想是:检查列表是否已排序,如果是则完成;如果不是,则随机打乱列表中的元素,然后重复此过程。

算法描述

  1. 检查列表是否已排序。
  2. 如果已排序,算法结束。
  3. 如果未排序,则随机打乱列表中的所有元素。
  4. 返回步骤1。

由于其依赖随机性,在最坏情况下可能永远无法完成。平均情况下,其时间复杂度是 O(n!),因为找到正确排序的概率是 1/(n!)

选择排序 🔍

选择排序是一种直观的算法。它反复从未排序的部分中找到最小(或最大)元素,并将其放到已排序序列的末尾。

算法步骤

  1. 在未排序序列中找到最小元素。
  2. 将其与未排序序列的第一个元素交换。
  3. 将序列的已排序部分边界向后移动一位。
  4. 重复上述步骤,直到所有元素均排序完毕。

选择排序包含两层嵌套循环,其时间复杂度为 O(n²)。无论输入数据的初始状态如何,它都需要进行近似 n²/2 次比较。


插入排序 📝

上一节我们介绍了基于选择的最小值查找排序。本节中我们来看看另一种直观的算法——插入排序。它模拟了人们手动整理物品(如扑克牌)的过程。

插入排序的工作方式类似于整理一手牌:从第二张牌开始,将每张新牌插入到手中已排序部分的正确位置。

算法过程

  1. 将第一个元素视为已排序序列。
  2. 取出下一个元素,在已排序序列中从后向前扫描。
  3. 如果该元素(已排序)大于新元素,则将该元素移到下一位置。
  4. 重复步骤3,直到找到已排序元素小于或等于新元素的位置。
  5. 将新元素插入到该位置后。
  6. 重复步骤2~5,直到所有元素处理完毕。

插入排序的时间复杂度也是 O(n²)。但是,它有一个有趣的特性:如果输入数据已经基本有序,它的效率会非常高,甚至接近 O(n),因为需要进行的“插入”操作很少。


高效的排序算法

前面介绍的算法在数据量较大时效率不高。现在,我们来看看两种采用“分治”策略的更高效算法。

归并排序 ⚔️

归并排序的核心思想是“分而治之”。它将一个大问题分解成小问题,解决小问题后,再将结果合并起来。

算法步骤

  1. 分解:将待排序的序列递归地分成两半,直到每个子序列只包含一个元素(此时自然有序)。
  2. 解决:递归地对两个子序列进行归并排序。
  3. 合并:将两个已排序的子序列合并成一个完整的有序序列。合并时,依次比较两个子序列前端元素,将较小的放入结果序列。

合并两个已排序列表的时间复杂度是 O(n)。由于序列被递归地对半分解,分解的深度是 log₂n。因此,归并排序的总时间复杂度为 O(n log n),这比 O(n²) 要高效得多。

核心合并操作的伪代码描述

// 假设 left 和 right 是两个已排序的向量
vector<int> result;
int i = 0, j = 0;
while (i < left.size() && j < right.size()) {
    if (left[i] <= right[j]) {
        result.add(left[i]);
        i++;
    } else {
        result.add(right[j]);
        j++;
    }
}
// 将剩余元素添加到结果中
while (i < left.size()) { result.add(left[i]); i++; }
while (j < right.size()) { result.add(right[j]); j++; }

快速排序 ⚡

快速排序是另一种高效的分治算法,在实践中通常比归并排序稍快。它的核心思想是选择一个“基准”元素,将数组分为两个子数组:小于基准的元素和大于基准的元素,然后递归地对这两个子数组进行排序。

算法步骤

  1. 选择基准:从序列中挑出一个元素作为基准。
  2. 分区操作:重新排列序列,所有比基准值小的元素放在基准前面,所有比基准值大的元素放在基准后面。操作结束后,基准就位于序列的中间位置。
  3. 递归排序:递归地将小于基准的子序列和大于基准的子序列进行快速排序。

理想情况下,如果每次分区都能将序列均分,那么快速排序的时间复杂度也是 O(n log n)。但如果每次选择的基准都是最大或最小值,导致分区极度不平衡,最坏时间复杂度会退化到 O(n²)。因此,基准的选择策略(如随机选择或选择中位数)很重要。

快速排序通常比归并排序快一些,但它不是稳定排序。稳定排序意味着相等元素的相对位置在排序前后保持不变,这在某些应用场景中很重要,而归并排序是稳定的。


算法对比与总结 📊

本节课中我们一起学习了多种排序算法:

  • Bogo排序:理论上可行但效率极低,时间复杂度为 O(n!)。
  • 选择排序:简单直观,但效率不高,时间复杂度为 O(n²),且与输入数据状态无关。
  • 插入排序:对于小规模或基本有序的数据效率较高,平均时间复杂度为 O(n²),但在最好情况下可接近 O(n)。
  • 归并排序:采用分治策略,稳定排序,时间复杂度为 O(n log n),是许多标准库的实现基础。
  • 快速排序:同样采用分治策略,平均性能优异,时间复杂度为 O(n log n),通常比归并排序稍快,但不是稳定排序。

理解这些算法的原理和性能差异,有助于我们在不同场景下选择合适的工具,并深化对算法复杂度分析的认识。在实际编程中,我们虽然直接使用标准库的排序函数,但了解其背后的原理至关重要。

课程25:模板、STL与智能指针 🧩

在本节课中,我们将学习C++中三个高级但实用的特性:模板、标准模板库(STL)以及智能指针。这些是编写现代、高效且安全的C++代码的关键工具。


模板:编写通用代码 🔄

上一节我们介绍了课程的整体目标,本节中我们来看看模板。模板允许我们编写不依赖于特定数据类型的通用函数和类。

模板函数

模板函数可以接受任意类型的参数。编译器会根据调用时提供的具体类型,自动生成该类型对应的函数版本。

公式/代码描述:

template <typename T>
T max(T a, T b) {
    return (a < b) ? b : a;
}

此函数可以用于比较intdoublestring等任何支持<运算符的类型。

模板类

模板类允许我们定义可以处理多种数据类型的类。例如,VectorHashMap等集合类通常都是模板类。

公式/代码描述:

template <typename T>
class ArrayList {
private:
    T* elements;
    int size;
    int capacity;
public:
    void add(T value);
    T get(int index);
    // ... 其他方法
};

使用时,我们通过尖括号指定具体类型:ArrayList<int>ArrayList<string>

注意事项:

  • 模板中使用的类型T必须支持代码中调用的所有操作(例如,如果代码中有a < b,则T必须支持<运算符)。
  • 在模板类中,只有与类型T相关的成员(如存储的值)才需要替换为T,而像sizeindex这类与类型无关的成员应保持不变。

标准模板库(STL)📚

了解了模板的基本概念后,我们来看看C++标准库中基于模板构建的强大工具集——STL。

STL包含容器(如vectormapset)、算法(如sortfind)以及其他实用组件。它与我们在课程中使用的斯坦福库功能类似,但接口和设计哲学有所不同。

STL vs. 斯坦福库

以下是两者的一些主要区别:

  • 命名风格:STL类名通常为小写(如vectormap),而斯坦福库使用大写驼峰式(如VectorMap)。
  • 方法名称:部分方法名称不同。例如,在末尾添加元素,STL使用push_back,斯坦福库使用add
  • 核心抽象:迭代器:STL大量使用“迭代器”来遍历和操作容器,而斯坦福库更倾向于直接使用索引(对于支持索引的容器)。

迭代器

迭代器是一个对象,它抽象了在容器中移动和访问元素的过程。它为所有STL容器提供了一种统一的遍历方式。

公式/代码描述:

std::vector<int> vec = {1, 2, 3, 4, 5};
for (std::vector<int>::iterator it = vec.begin(); it != vec.end(); ++it) {
    std::cout << *it << std::endl; // 使用 * 操作符解引用迭代器
}

begin()返回指向第一个元素的迭代器,end()返回指向“末尾后一位”的迭代器。++it将迭代器移动到下一个元素,*it访问当前元素。

为什么使用迭代器?
虽然对于vector,使用索引循环可能更直观,但迭代器的优势在于它能以相同的方式遍历所有容器(包括setmap这类没有索引的容器)。此外,它也是STL算法的基础。

STL算法

STL提供了一系列强大的通用算法,它们通过迭代器与容器协作。

以下是部分常用算法:

  • std::sort(first, last):对指定范围内的元素排序。
  • std::find(first, last, value):在范围内查找特定值。
  • std::count(first, last, value):计算范围内特定值出现的次数。
  • std::reverse(first, last):反转范围内的元素顺序。
  • std::copy(source_first, source_last, dest_first):复制一个范围内的元素到另一个位置。

示例:

std::sort(vec.begin(), vec.end()); // 对整个vector排序
std::sort(vec.begin() + vec.size()/2, vec.end()); // 仅对后半部分排序
int numFives = std::count(vec.begin(), vec.end(), 5); // 计算5的个数

为何课程未首选STL?
尽管STL功能强大,但其错误信息对初学者不友好,且需要较早理解指针和迭代器概念。斯坦福库在设计上更注重教学性和易用性。


智能指针:自动化内存管理 🤖

最后,我们来探讨如何用智能指针简化C++中最棘手的问题之一——手动内存管理。

手动newdelete容易导致内存泄漏、重复释放或访问已释放内存等问题。智能指针是包装了原始指针的类,利用对象的析构函数自动管理所指向内存的生命周期。

unique_ptr

std::unique_ptr是一种独占所有权的智能指针。同一时间只能有一个unique_ptr指向某个对象。当unique_ptr被销毁(例如离开作用域)时,它会自动删除其指向的对象。

公式/代码描述:

#include <memory>

void myFunction() {
    // 创建一个unique_ptr,管理一个新分配的ListNode
    std::unique_ptr<ListNode> p(new ListNode);
    p->data = 42; // 使用 -> 操作符,就像使用普通指针一样
    p->next = nullptr;

    // 重置指针,指向新对象,旧对象被自动释放
    p.reset(new ListNode);

    // 函数结束时,p离开作用域,其管理的最后一个ListNode被自动释放
    // 无需手动调用 delete
}

关键特性:

  • 独占所有权:不能直接复制unique_ptr(避免多个指针试图释放同一内存)。
  • 移动语义:可以通过std::move转移所有权。
  • 获取原始指针:在需要时,可以使用p.get()获取其管理的原始指针(但不建议轻易这样做)。

参数传递与返回:

  • 通常以值传递unique_ptr参数意味着所有权的转移,需谨慎使用。
  • 函数可以返回unique_ptr,编译器会优化以避免不必要的拷贝和释放。

总结 🎯

本节课中我们一起学习了C++的三个核心高级特性:

  1. 模板:使我们能够编写独立于数据类型的通用函数和类,是构建可复用代码库的基石。
  2. 标准模板库(STL):C++内置的、基于模板的强大库,提供了丰富的容器、算法和迭代器,但需要适应其特定的设计模式和语法。
  3. 智能指针(以unique_ptr为例):通过自动化内存的释放,极大地减少了手动内存管理带来的错误和负担,是现代C++编程中推荐的内存管理方式。

掌握这些概念将帮助你从“课程C++”平滑过渡到“工业级C++”编程。

课程26:下一步是什么? 🚀

在本节课中,我们将回顾本季度的学习内容,并探讨完成CS106X课程后可以继续探索的计算机科学领域。我们将讨论期末考试的相关信息,并介绍斯坦福大学后续的计算机科学课程、可能的专业方向以及一些自学和实习资源。


期末考试安排与准备 📝

上一节我们介绍了本课程的整体回顾,本节中我们来看看期末考试的具体安排和如何有效准备。

期末考试将于周一上午举行。考试资源已发布在课程考试页面上,包括语法参考表和三次模拟考试。模拟考试包含问题、答案和分步代码解析,可用于练习。

以下是期末考试可能涉及的核心主题列表:

  • 链表:可能需要编写方法,接收头指针并操作一系列节点。
  • 递归与回溯
  • 二叉树:操作以根指针表示的树结构。
  • 数据结构实现:例如实现栈、队列或列表等类。
  • 图论问题:包括路径搜索算法(如Dijkstra、A*)和最小生成树算法(如Kruskal)。
  • 后期材料:如搜索排序算法和继承的使用。

以下是一些明确不会考查的主题:

  • 绘制分形图。
  • 操作符重载。
  • STL(标准模板库)的具体实现。
  • 高级二叉树算法(如自平衡、旋转树、红黑树)。

考试时,关于实现数据结构的问题通常会提供头文件(.h),说明公共方法和私有数据成员,以提供足够的上下文。考试主要考察概念理解和算法应用,而非死记硬背语法细节。评分重点在于解决问题的思路,而非代码风格(如变量命名、缩进)。除非特别说明,通常不限制使用辅助数据结构,但若要求“不使用辅助结构”或必须在特定时间复杂度(如O(n²))内解决,则是为了考察对特定概念(如链表操作)的掌握,并避免低效的暴力解法。


后续计算机科学课程路径 🗺️

在完成了CS106X这门基础课程后,你可以选择多条路径深入学习计算机科学。本节我们将介绍斯坦福大学提供的核心后续课程。

CS106X教授了编程基础(如循环、变量、数组)以及本课程重点——数据与算法(如如何存储和处理大量数据)。接下来,主要的两条进阶路径是CS107(计算机系统导论)CS103(计算机科学数学基础)

CS107:计算机组织与系统

这门课程让你更接近硬件,深入理解计算机如何工作。核心学习内容包括:

  • 低级编程:学习处理器使用的原始机器指令和汇编语言。
  • C语言:学习比C++更简洁、更底层的C语言编程,理解在高级语言中看似简单的任务(如字符串数组排序)在C语言中如何实现。
  • 程序执行原理:了解代码如何被编译、链接成可执行程序,以及程序运行时内存的布局(如堆栈、堆)。

这门课以富有挑战性著称,需要投入大量时间。它的挑战部分源于减少了像CS106X中“The Layer”那样密集的助教支持,更需要学生自主学习、查阅文档和调试。然而,课程材料组织良好,师资优秀(如Julian Shun和Chris Gregg),能帮助学生顺利掌握内容。

CS107E:嵌入式系统导论

这是CS107的一个变体,专注于在树莓派(Raspberry Pi)这样的嵌入式设备上编程。与在普通电脑上编程不同,你的程序将直接运行在硬件上,控制LED、蜂鸣器等。你会遇到更独特的硬件相关调试问题。这门课较新,可能探索性内容更多,由Julian Shun教授,适合对嵌入式系统感兴趣的学生。

CS103:计算机科学数学基础

这门课属于理论计算机科学轨道,侧重于数学和证明,而非编码。课程内容至关重要,探讨计算的根本问题:

  • 计算基础:学习集合、逻辑、证明技巧。
  • 算法与计算极限:研究哪些问题可以被计算,以及计算的速度极限。
  • 前沿问题:例如著名的 P vs NP问题,即判定哪些问题可以在合理时间内被解决。

这门课由Keith Schwarz和Cynthia Lee等优秀教师授课,虽然涉及少量代码用于演示概念,但核心是数学推理。


更广泛的课程与专业选择 🌟

在完成CS107和CS103之后,更多的课程选择将向你开放。本节我们来看看其他一些有趣的课程和专业方向。

以下是一些后续的高阶课程示例:

  • CS108:面向对象系统设计:使用Java构建更大型的应用程序,涉及更多文件和类,有时包括安卓开发。
  • CS109:概率论与计算机科学:由Chris Piech教授,提供概率统计的直观理解。
  • CS142:Web应用:学习Web开发,建议先修CS107。
  • CS147:人机交互导论:学习设计并原型化大型应用程序的用户界面,通常以团队项目形式进行。
  • CS193系列:专题课程,如CS193P(iPhone应用开发)非常受欢迎。
  • CS9系列:由学生讲授的课程,涵盖JavaScript、Python、Web编程等实用技能,是学习新工具的好地方。
  • CS50:计算机科学与公益:探索如何利用技术帮助社会。

斯坦福大学的计算机科学系不仅研究实力雄厚,教学也备受重视,师资力量强大。除了主修计算机科学,你还可以考虑:

  • 辅修CS:需完成CS103、CS107及另外两门课程。
  • 联合专业:如符号系统,融合计算机科学、语言学、哲学、心理学,研究语言、智能与交互。
  • 其他跨学科专业:如数学与计算机科学、管理科学与工程等。

自学资源与职业机会 💼

学习计算机科学的一大优势是可以通过大量资源进行自学。本节将介绍一些保持技能、探索兴趣和寻找实践机会的途径。

如果你想在课外继续提升:

  • 在线课程:利用Coursera、edX或YouTube上的讲座。
  • 编程练习平台:如HackerRank,通过解决代码挑战和参与竞赛来保持编程技能。
  • 自学项目:尝试用HTML创建网页,或用Python重写你的课程作业,这些都是不错的起步项目。

对于实习机会,特别是低年级学生:

  • Stanford CFC:访问计算机论坛,注册邮件列表以获取行业招聘信息。
  • 新生实习计划:许多公司提供针对只修读过少量CS课程学生的实习岗位。可以在线搜索“freshman internship CS”或“sophomore internship CS”寻找机会。

总结与祝福 ✨

本节课中,我们一起回顾了期末考试的范围与准备策略,了解了CS106X之后的核心课程路径——深入系统的CS107/CS107E和侧重理论的CS103。我们还浏览了更广泛的高阶课程选择、跨学科专业可能性,并介绍了一些自学平台和实习资源。

计算机科学领域广阔且充满活力,现在正是探索的绝佳时机。感谢大家本季度的努力,祝大家在期末考试中取得好成绩,并在未来的计算机科学之旅中继续前行!

祝你好运,星期一考试见!

课程03:字符串、流与网格 🧵📁

在本节课中,我们将学习C++中的字符串(String)、文件流(Stream)以及网格(Grid)数据结构。这些是构建程序、处理数据和存储信息的基础工具。我们将从字符串的基本操作开始,接着学习如何读写文件,最后介绍二维网格的使用方法。


字符串基础

上一节我们概述了本课内容,本节中我们来看看C++中字符串的基本概念和操作。

在C++中使用字符串,必须包含名为 <string> 的系统库。字符串本质上是一系列字符(char),索引从0开始。

#include <string>
using namespace std;
string greeting = "Hello";

字符串中的单个字符可以通过方括号 [] 访问,这与许多语言中的数组访问类似。

char thirdChar = greeting[2]; // 获取索引为2的字符,即 'l'

以下是字符串的一些基本操作:

  • 连接:使用 + 运算符可以将两个字符串连接起来。
  • 比较:可以使用 ==, !=, <, > 等运算符直接比较字符串,顺序基于字符的ASCII值。
  • 修改:C++字符串是可变的(mutable),可以直接修改其内容,例如使用 append 方法或 += 运算符。

与Java等语言不同,C++的 + 运算符主要用于连接两个字符串对象。若要将其他类型(如整数)与字符串连接,更常见的做法是使用输出流运算符 <<


C++字符串与C风格字符串

上一节我们介绍了C++字符串的基本操作,本节中我们来看看一个需要特别注意的地方:C++中两种字符串类型的区别。

C++语言兼容其前身C语言。因此,存在两种字符串:

  1. C++字符串:即 std::string 类型,功能丰富,是我们推荐使用的。
  2. C风格字符串:本质是字符数组(char[]),功能非常有限。

声明一个用引号包裹的字符串字面量(如 "Hello")时,其类型是C风格字符串。但通常我们将其赋值给 std::string 变量,从而自动转换为好用的C++字符串。

核心问题:当混合使用两种字符串时,尤其是使用 + 运算符,可能导致意外错误或程序崩溃,因为编译器可能不会报错,但运行时行为是未定义的。

// 可能出错的例子
// string s1 = "Hello" + "World"; // 错误:两个C风格字符串不能直接相加
// string s2 = "Hello" + 42;      // 错误:C风格字符串与整数相加无意义

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/f2627df11c3090d02a6ac56b95d6e7fd_13.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/f2627df11c3090d02a6ac56b95d6e7fd_15.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/f2627df11c3090d02a6ac56b95d6e7fd_17.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/f2627df11c3090d02a6ac56b95d6e7fd_19.png)

// 正确的做法
string s3 = string("Hello") + "World"; // 显式转换其中一个
string s4 = "Hello"s + "World";        // C++14后可使用字面量后缀 s
string s5 = "Hello" + string("World");

重要建议:尽可能始终使用 std::string 类型,并避免直接操作C风格字符串,以减少错误。


文件输入输出流

上一节我们了解了字符串的注意事项,本节中我们来看看如何使用流(Stream)来读写文件,这是处理外部数据的关键。

C++使用 fstream 库处理文件。主要类包括:

  • ifstream:用于从文件读取输入(Input File Stream)。
  • ofstream:用于向文件写入输出(Output File Stream)。

它们的用法与标准输入输出流 cincout 非常相似。

以下是读取文件每一行的标准模式:

#include <fstream>
#include <string>
using namespace std;

ifstream inputFile;
inputFile.open("myfile.txt");
string line;
while (getline(inputFile, line)) { // 当成功读取一行时循环
    // 处理这一行数据
    cout << line << endl;
}
inputFile.close();

代码解释

  • getline(inputFile, line) 函数尝试从 inputFile 中读取一行,并存储到字符串 line 中。
  • 该函数同时返回一个布尔值,指示读取是否成功。因此可以直接作为 while 循环的条件。
  • 使用 >> 运算符可以从流中读取被空格分隔的“令牌”(token),例如单词或数字。

有时我们需要先读取整行,再对该行进行细致解析。这时可以结合 istringstream(输入字符串流):

#include <sstream>
string data = "John Doe 25";
istringstream iss(data);
string firstName, lastName;
int age;
iss >> firstName >> lastName >> age; // 从字符串中提取数据

类似地,ostringstream 可以用于高效地构建字符串。


网格数据结构

上一节我们学习了如何通过流处理文件数据,本节中我们来看看最后一个核心概念:网格(Grid),它是一种二维数据结构。

网格类似于二维数组,但更安全、易用。它来自斯坦福的库,使用时需要包含头文件并指定网格中存储的元素类型。

创建一个网格需要指定行数和列数:

#include "grid.h"
using namespace std;

Grid<int> matrix(3, 4); // 创建一个3行4列的网格,用于存储整数

访问或设置网格中的元素使用 grid[row][col] 的语法:

matrix[0][0] = 42; // 设置第0行第0列的元素为42
int value = matrix[1][2]; // 获取第1行第2列的元素

以下是网格对象的一些常用成员函数:

  • grid.numRows():返回网格的行数。
  • grid.numCols():返回网格的列数。
  • grid.inBounds(row, col):检查给定的行列索引是否在网格有效范围内。
  • grid.fill(value):将网格中的所有元素设置为指定值。
  • grid.resize(rows, cols):调整网格的尺寸。

重要提示:将网格作为参数传递给函数时,通常应使用引用传递(在参数类型前加 &),以避免复制整个网格带来的性能开销。如果函数不会修改网格,最好同时使用 const 修饰。

void processGrid(const Grid<int>& grid) { // 通过常量引用传递,避免复制且保护数据
    // 可以读取 grid 的内容,但不能修改
}

总结

本节课中我们一起学习了C++编程中的三个重要组成部分:

  1. 字符串:我们了解了 std::string 的用法,并重点区分了C++字符串与C风格字符串,以避免常见的陷阱。
  2. :我们掌握了使用 ifstreamofstream 读写文件的方法,以及使用 istringstreamostringstream 在内存中处理字符串数据。
  3. 网格:我们介绍了 Grid 这一二维数据结构,学习了如何创建、访问和操作网格,并理解了在函数间传递网格时使用引用的重要性。

这些工具是完成数据输入、处理和存储的基础,将在接下来的作业和项目中反复使用。

编程抽象方法 CS106X 2017 - 课程04:Vector与Big-Oh 📚

在本节课中,我们将要学习C++中的Vector集合以及用于分析算法效率的Big-Oh表示法。我们会先回顾上节课的Grid,然后深入探讨Vector的用法和内部原理,最后学习如何用Big-Oh来思考和描述代码的运行时间。


回顾:Grid的传递与常量引用 🔙

上一节我们介绍了Grid集合。本节中,我们来看看如何将Grid作为参数传递给函数。

当传递GridFileStream这类大型对象时,应始终通过引用来传递,以避免不必要的内存复制,提高效率。语法是使用&符号。

void processGrid(Grid<int>& grid) {
    // 函数可以修改传入的grid
}

如果希望函数不修改传入的Grid,可以传递常量引用,使用const关键字。这既能保证效率,又能保证数据安全。

void readGrid(const Grid<int>& grid) {
    // 函数不能修改传入的grid
}

需要注意:函数原型和函数定义中的引用(&)和常量(const)声明必须完全一致,否则会导致编译错误。


引入:Vector集合 📦

现在,让我们来看看另一个重要的集合:Vector。它在其他语言中可能被称为ArrayList(Java)或list(Python),是一个可以动态增长和缩小的元素序列。

要使用斯坦福库中的Vector,需要包含头文件:

#include "vector.h"

以下是创建和初始化Vector的几种方式:

Vector<int> vec; // 创建一个空的int类型Vector
Vector<int> vec = {1, 2, 3, 4, 5}; // 创建并初始化一个包含5个元素的Vector
vec.add(6); // 在末尾添加元素

与原生数组相比,Vector的优势在于:

  • 知道自己的大小(vec.size())。
  • 能进行边界检查,访问越界会报错。
  • 提供了丰富的操作方法(如add, insert, remove)。

Vector的常用操作与遍历 🔄

以下是Vector最常用的一些操作。

访问与修改元素

vec[0] = 10; // 使用下标操作符
vec.set(0, 10); // 使用set方法
int value = vec.get(0); // 使用get方法

遍历Vector有三种常见方式:

  1. 标准for循环(可以获取索引):

    for (int i = 0; i < vec.size(); i++) {
        cout << vec[i] << endl;
    }
    
  2. 基于范围的for循环(只读)

    for (int value : vec) {
        cout << value << endl;
    }
    
  3. 基于范围的for循环(可修改)

    for (int& value : vec) {
        value *= 2; // 可以修改Vector中的元素
    }
    

注意:在遍历过程中,如果使用insertremove等方法改变了Vector的大小,可能会引发错误或导致意外行为。


实战:从Vector中移除所有特定元素 🧹

让我们通过一个例子来巩固对Vector操作的理解。我们将编写一个函数,移除Vector中所有等于特定值的元素。

一个常见的陷阱是正向遍历时进行删除。因为删除元素后,后续元素的索引会前移,可能导致漏删或越界。更安全的方法是反向遍历

void removeAll(Vector<string>& v, string s) {
    for (int i = v.size() - 1; i >= 0; i--) {
        if (v[i] == s) {
            v.remove(i); // 从后往前删,索引稳定
        }
    }
}

深入:Vector的内部机制与效率 ⚙️

Vector内部使用一个原生数组来存储数据,并维护两个整数:

  • size:当前存储的元素数量。
  • capacity:内部数组的总容量。

当添加元素导致size即将超过capacity时,Vector会执行以下操作:

  1. 分配一个更大的新数组(例如,容量翻倍)。
  2. 将旧数组的所有元素复制到新数组。
  3. 使用新数组,并更新capacity

这种策略类似于“预先购买更大的房子”,虽然会暂时浪费一些空间,但避免了每次添加元素都要“搬家”(复制全部数据),从而提高了平均效率。


核心:算法效率与Big-Oh表示法 📈

本节中我们来看看如何分析算法的效率。我们主要关心运行时间如何随着输入数据规模(记为n,如Vector的元素数量)的增长而增长。

我们使用一个简化的计算模型:每条简单语句执行耗时一个单位时间。循环的耗时是循环次数 × 循环体内的语句数

基于此模型分析代码,我们可能会得到一个像 3n² + 10n + 5 这样的表达式。为了抓住本质,我们采用 Big-Oh 表示法:

  1. 忽略表达式中的常数项和系数(如3, 10, 5)。
  2. 只保留增长最快的项(这里是 )。

于是我们说,该算法的时间复杂度是 O(n²)。它的含义是:当输入规模n很大时,运行时间的增长趋势与 成正比。


Vector操作的Big-Oh分析 ⏱️

以下是Vector各种操作的时间复杂度分析:

  • add(value) 在末尾添加:通常为 O(1)。仅在需要扩容时是 O(n),但平均分摊下来仍是 O(1)
  • [index]get(index) 按索引访问:O(1)
  • insert(index, value) 在指定位置插入:O(n)。因为可能需要移动其后所有元素。
  • remove(index) 删除指定位置元素:O(n)。因为可能需要移动其后所有元素。

这里的 O(n) 指的是最坏情况平均情况。例如,在末尾插入或删除就是 O(1)


总结 ✨

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

  1. Grid的传递:应通过引用传递,若不想被修改则使用常量引用。
  2. Vector集合:一个动态数组,提供了丰富且安全的方法来操作元素序列。
  3. Vector遍历:可以使用标准for循环或基于范围的for循环,注意在遍历中修改结构可能带来的风险。
  4. Vector内部原理:基于数组实现,通过维护sizecapacity来高效管理内存。
  5. 算法效率与Big-Oh:一种描述算法运行时间随输入规模增长趋势的表示法,帮助我们理解和比较不同算法的效率。
  6. Vector操作的效率:访问和尾部添加很快(O(1)),而在中间插入或删除则较慢(O(n))。

理解这些概念有助于你编写出更高效、更可靠的程序,并为学习更复杂的数据结构和算法打下基础。

课程05:栈与队列 🧱➡️📚

在本节课中,我们将学习两种重要的数据结构:栈(Stack)和队列(Queue)。我们将探讨它们的基本概念、核心操作、应用场景,并通过代码示例来理解如何使用它们。同时,我们会回顾大O表示法,并理解不同数据结构实现同一操作时可能带来的效率差异。


回顾:大O表示法与效率 📈

上一节我们介绍了向量(Vector)及其操作的大O复杂度。本节中,我们来看看大O表示法的核心意义。

大O表示法用于描述算法的运行时间如何随输入规模的增长而扩展。它关注的是增长率的趋势,而非精确的运行时间。例如,O(n)表示线性增长,O(n²)表示平方级增长。

以下是算法运行时的一些主要类别:

  • O(1):常数时间,运行时间不随输入规模变化。
  • O(log n):对数时间,运行时间随输入规模对数增长。
  • O(n):线性时间,运行时间与输入规模成正比。
  • O(n²):平方时间,运行时间与输入规模的平方成正比。

对于向量,在任意位置(非末尾)添加或删除元素是O(n)操作,因为可能需要移动大量元素。


抽象数据类型(ADT)与不同实现 🔄

相同的一组操作(抽象数据类型,ADT)可以通过不同的内部结构来实现,这会导致效率上的差异。

例如,“列表”这个ADT,既可以用向量(基于数组)实现,也可以用链表实现。向量在中间插入/删除元素成本高(O(n)),但支持快速随机访问。链表在开头插入/删除元素很快(O(1)),但按索引访问元素慢(O(n))。

核心概念List ADT = Vector Implementation | Linked List Implementation

选择哪种实现,取决于你的程序最常执行哪些操作。


栈(Stack):后进先出(LIFO) 🥞

栈是一种受限的线性数据结构,只允许在一端(称为栈顶)进行添加和删除操作。这遵循“后进先出”(LIFO)的原则。

栈的核心操作

栈支持以下三个核心操作:

  • push(value):将元素value压入栈顶。
  • pop():从栈顶移除并返回元素。
  • peek():查看栈顶元素但不移除它。

栈的应用场景

栈在计算机科学中应用广泛:

  • 函数调用栈:管理函数调用和返回。
  • 表达式求值:编译器处理算术表达式。
  • 撤销操作:文字处理器中保存操作历史。
  • 深度优先搜索:如迷宫求解。

在C++中使用栈

在Stanford C++库中,使用栈需要包含头文件,并声明栈的类型。

#include "stack.h"
Stack<int> myStack;
myStack.push(42);
myStack.push(-3);
int top = myStack.pop(); // top = -3

注意:栈不支持通过索引访问元素,也不支持for-each循环。通常使用while循环来清空并处理栈中元素。

while (!myStack.isEmpty()) {
    int value = myStack.pop();
    // 处理 value
}

队列(Queue):先进先出(FIFO) 🚶‍♂️➡️🚶‍♀️

队列是另一种受限的线性数据结构,它允许在一端(队尾)添加元素,在另一端(队首)移除元素。这遵循“先进先出”(FIFO)的原则,就像现实生活中的排队一样。

队列的核心操作

队列也支持三个核心操作:

  • enqueue(value)add(value):将元素value加入队尾。
  • dequeue()remove():从队首移除并返回元素。
  • peek():查看队首元素但不移除它。

队列的应用场景

队列同样有丰富的应用:

  • 任务调度:如打印机作业队列。
  • 数据缓冲:网络数据包收发。
  • 广度优先搜索:按层次遍历树或图。

在C++中使用队列

使用队列需要包含相应的头文件。

#include "queue.h"
Queue<string> printerQueue;
printerQueue.enqueue("Document1.pdf");
printerQueue.enqueue("Document2.pdf");
string nextJob = printerQueue.dequeue(); // nextJob = "Document1.pdf"

遍历队列而不清空它需要一点技巧:可以记录初始大小,进行固定次数的出队、处理、再入队操作。

int originalSize = myQueue.size();
for (int i = 0; i < originalSize; i++) {
    string elem = myQueue.dequeue();
    // 处理 elem
    myQueue.enqueue(elem); // 将其加回队尾以保持队列内容
}

栈与队列的联合应用实例 🧩

栈和队列经常携手解决问题。例如,我们可以利用栈来反转队列中元素的顺序。

目标:编写一个函数mirror,使一个字符串队列在操作后,既包含原始顺序的元素,也包含反向顺序的元素。

思路

  1. 遍历原始队列,将每个元素出队后,同时压入一个辅助栈,并重新入队回原队列。此步骤结束后,队列恢复原状,栈中保存了逆序的元素。
  2. 将栈中所有元素弹出,并依次加入队列尾部。
void mirror(Queue<string>& q) {
    Stack<string> s;
    int size = q.size();
    for (int i = 0; i < size; i++) {
        string elem = q.dequeue();
        s.push(elem);  // 压栈以获得逆序
        q.enqueue(elem); // 重新入队以保持原队列
    }
    while (!s.isEmpty()) {
        q.enqueue(s.pop()); // 将逆序元素加入队尾
    }
}
// 操作前队列:["a", "b", "c"]
// 操作后队列:["a", "b", "c", "c", "b", "a"]


总结 📝

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

  1. 大O表示法的核心是描述算法运行时间的增长率。
  2. 抽象数据类型(ADT) 的概念,以及同一ADT(如列表)可以有不同实现(向量、链表),从而影响操作效率。
  3. 栈(Stack) 是一种LIFO(后进先出)结构,核心操作是pushpoppeek,适用于需要“反转”或“回溯”的场景。
  4. 队列(Queue) 是一种FIFO(先进先出)结构,核心操作是enqueuedequeuepeek,适用于需要“公平排队”或“按序处理”的场景。
  5. 栈和队列因其操作受限,通常能保证所有操作都是高效(O(1))的。
  6. 通过mirror函数的例子,我们看到了如何结合使用栈和队列来解决实际问题。

记住,选择合适的数据结构是写出高效程序的关键。当你只需要在一端添加和删除时,考虑栈或队列,它们可能是比功能更强大的向量更简洁、更高效的选择。

课程06:集合与映射 🗂️

在本节课中,我们将学习两种重要的数据结构:集合(Set)映射(Map)。我们将了解它们是什么、如何使用,以及它们如何帮助我们更高效地解决编程问题。


什么是集合? 🤔

上一节我们介绍了向量和链表等线性集合。本节中我们来看看集合

集合是一种存储唯一值的集合,其中没有重复元素。集合的设计通常围绕一小部分核心操作进行优化,这些操作是:

  • 添加元素
  • 删除元素
  • 搜索元素或测试成员资格(即询问某个元素是否在集合中)

集合与向量或链表的一个关键区别是,它放弃了索引和插入顺序的概念。当你向集合中添加三个元素时,不应期望它们以任何特定顺序存储。我们通常认为集合是无序的。

集合的两种类型

在我们的库中,有两种C++集合实现,它们是同一抽象数据类型(ADT)的不同实现:

  1. Set:基于二叉搜索树构建,元素按排序顺序(如字母或数字顺序)存储。
  2. HashSet:基于哈希表(一种特殊数组)构建,元素的存储顺序不可预测,但通常速度稍快。

两者的核心方法(添加、删除、包含)都很快。Set 的方法通常具有 O(log n) 的运行时复杂度,而 HashSet 的方法通常具有 O(1) 的(平均)运行时复杂度。

一个使用场景:统计唯一单词

假设我们需要编写代码来计算一个大文本文件中不同单词的数量。如果一个单词(如“hello”)出现多次,它只应被计数一次。

以下是使用向量的解决方案(效率较低):

int countUniqueWords(string filename) {
    ifstream input(filename);
    Vector<string> words;
    string word;
    while (input >> word) {
        // 需要检查单词是否已在向量中
        if (!vectorContains(words, word)) {
            words.add(word);
        }
    }
    return words.size();
}
// 辅助函数:线性搜索
bool vectorContains(Vector<string>& vec, string& target) {
    for (string elem : vec) {
        if (elem == target) return true;
    }
    return false;
}

问题:每次读取新单词,都需要调用 vectorContains 遍历整个向量。随着向量变大,搜索会越来越慢。处理一个3万词的文件可能耗时数秒。

现在,让我们看看使用集合的解决方案:

int countUniqueWords(string filename) {
    ifstream input(filename);
    Set<string> words; // 或者 HashSet<string>
    string word;
    while (input >> word) {
        word = toLowerCase(word);
        words.add(word); // 集合自动忽略重复项
    }
    return words.size();
}

优势words.add(word) 和内部的成员检查非常高效。同样的任务,运行时间可能从数秒降至几百毫秒。这清晰地展示了集合对于需要快速成员检查任务的适用性。


关于结构的补充说明 ⚙️

在深入映射之前,我们先简要了解一个C++特性,它在你使用自定义类型作为集合元素时会很重要。

C++允许你使用 struct 创建新类型来组合数据。例如,可以创建一个 Date 类型:

struct Date {
    int month;
    int day;
};

但是,如果你尝试将 Date 对象放入 Set 中,编译器会报错。这是因为 Set(基于排序)需要知道如何比较两个 Date 对象的大小以进行排序。

解决方法:你需要为你的结构重载小于运算符 (<)

bool operator <(const Date& d1, const Date& d2) {
    if (d1.month != d2.month) {
        return d1.month < d2.month;
    }
    return d1.day < d2.day;
}

定义了这个运算符后,Set<Date> 就能正常工作了。HashSet 则需要不同的处理方式(如哈希函数),我们将在后续课程中介绍。


什么是映射? 🗺️

集合用于存储和查找单个值。现在,我们来看看映射,它用于存储和查找键值对(Key-Value Pairs)

映射有时在其他语言中被称为字典(如Python)。你向映射中添加一个及其关联的。之后,你可以通过来快速检索其对应的。这是一个单向查找(由键找值)。

一个经典例子是电话簿:将姓名(键)映射到电话号码(值)。

映射的核心操作

以下是映射的核心操作:

  • put(key, value):添加或更新键值对。如果键已存在,新值会覆盖旧值。
  • get(key):获取与键关联的值。
  • containsKey(key):检查映射是否包含某个键。
  • remove(key):移除键及其关联值。

在我们的库中,同样有两种实现:

  1. Map:基于排序树实现,按键的顺序存储。
  2. HashMap:基于哈希表实现,顺序不可预测但速度更快。

映射的使用场景:词频统计

假设我们不仅想知道文件中有多少不同单词,还想知道每个单词出现的具体次数。

我们可以使用一个 Map<string, int> 来解决,其中键是单词,值是该单词出现的次数。

以下是解决方案:

Map<string, int> wordCounts; // 或者 HashMap<string, int>
string word;
while (input >> word) {
    word = toLowerCase(word);
    // 如果单词不存在,get 返回该类型的默认值(int 为 0)
    int oldCount = wordCounts.get(word);
    wordCounts.put(word, oldCount + 1);
}

我们的库提供了一个更简洁的语法,使用方括号 []

Map<string, int> wordCounts;
string word;
while (input >> word) {
    word = toLowerCase(word);
    wordCounts[word]++; // 等价于 wordCounts.put(word, wordCounts.get(word) + 1);
}

程序完成后,你可以查询任何单词的出现次数:cout << wordCounts[“the”] << endl;

另一个例子:查找变位词

变位词是指字母重新排列后能形成另一个单词,如 “listen” 和 “silent”。

问题:给定一个词典文件,如何快速找出一个单词的所有变位词?

思路:如果两个单词是变位词,那么它们字母排序后的字符串是相同的(如 “listen” 和 “silent” 排序后都是 “eilnst”)。

我们可以利用映射:

  • :单词字母排序后的字符串(规范形式)。
  • :具有相同规范形式的所有原始单词的集合

以下是构建变位词映射的代码框架:

Map<string, Set<string>> anagramMap; // 键:排序后的字符串,值:变位词集合
// 假设有一个函数 string sortLetters(string word) 返回字母排序后的单词

while (从词典读取单词 w) {
    string sorted = sortLetters(w);
    anagramMap[sorted].add(w); // 将单词添加到对应集合中
}

之后,要查找某个单词的所有变位词,只需:

  1. 对该单词的字母进行排序,得到键。
  2. anagramMap 中查找该键对应的集合。


总结 📚

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

  1. 集合(Set):一种存储唯一元素的无序集合,支持高效的添加、删除和成员检查操作。有 Set(有序)和 HashSet(无序,通常更快)两种实现。
  2. 映射(Map):一种存储键值对的集合,允许通过键快速检索对应的值。它是解决计数、关联查找等问题的强大工具。同样有 MapHashMap 两种实现。
  3. 关键区别:当你只需要知道“是否存在”时使用集合;当你需要关联“某个键对应什么值”时使用映射。
  4. 性能:这些数据结构之所以有用,是因为它们为特定操作(如查找)提供了比向量或链表更高效的实现。

理解并熟练运用集合和映射,将极大地提升你解决复杂编程问题的能力。

编程抽象方法 CS106X 2017 - 课程07:递归 🌀

在本节课中,我们将要学习一个名为“递归”的新主题。递归是一种编程抽象方法,它允许函数调用自身来解决问题。我们将从基础概念开始,通过多个例子来理解递归的工作原理、结构以及如何应用它来解决特定类型的问题。


什么是递归?

递归是一种计算过程,它在定义自身时引用了自身。换句话说,递归函数通过调用自身来解决更小版本的同一问题。

一个常见的现实世界例子是查字典:为了理解一个词的定义,你可能需要查找定义中不理解的词,而“查找”这个过程本身又包含了“查找”这个动作。

在编程中,递归不仅仅是循环的替代品。它是一种识别问题“自我相似”性质的思维方式:要解决一个大问题,可以先解决一个相同但规模更小的问题。


递归的基本结构

上一节我们介绍了递归的概念,本节中我们来看看递归函数通常如何组织。

一个典型的递归函数包含两个部分:

  1. 基本情况:这是问题最简单、可以直接解决而无需进一步递归的版本。
  2. 递归情况:这是更复杂的版本,函数通过调用自身来解决一个规模更小的子问题,并利用其结果来构建最终答案。

以下是递归函数的一个通用模板:

ReturnType function(parameters) {
    if (/* 满足基本情况的条件 */) {
        // 直接返回结果
        return baseCaseValue;
    } else {
        // 将问题分解,进行递归调用
        return combine(parameters, function(smallerParameters));
    }
}

递归示例:阶乘

让我们通过一个经典的例子——计算阶乘——来具体理解递归。阶乘的数学定义是:n! = n * (n-1) * ... * 1,并且规定 0! = 1

迭代版本(使用循环)

首先,我们看看通常用循环(迭代)如何实现:

int factorialIterative(int n) {
    int result = 1;
    for (int i = 1; i <= n; i++) {
        result *= i;
    }
    return result;
}

递归版本

现在,我们思考如何用递归实现。我们注意到阶乘的自我相似性:n! = n * (n-1)!。这直接引导我们写出递归代码。

以下是递归版本的实现:

int factorialRecursive(int n) {
    // 基本情况:0! 或 1! 都等于 1
    if (n <= 1) {
        return 1;
    }
    // 递归情况:n! = n * (n-1)!
    else {
        return n * factorialRecursive(n - 1);
    }
}

代码解析

  • n 为 0 或 1 时,函数直接返回 1(基本情况)。
  • 对于更大的 n,函数返回 n 乘以 (n-1) 的阶乘。计算 (n-1)! 的任务通过调用函数自身来完成(递归情况)。

执行流程(以 factorialRecursive(4) 为例):

  1. factorialRecursive(4) 调用 factorialRecursive(3)
  2. factorialRecursive(3) 调用 factorialRecursive(2)
  3. factorialRecursive(2) 调用 factorialRecursive(1)
  4. factorialRecursive(1) 满足基本情况,返回 1
  5. factorialRecursive(2) 收到 1,计算 2 * 1 = 2 并返回。
  6. factorialRecursive(3) 收到 2,计算 3 * 2 = 6 并返回。
  7. factorialRecursive(4) 收到 6,计算 4 * 6 = 24 并作为最终结果返回。

递归的思考方式:分治与合作

理解递归的一种有效方式是将其视为一种“分治”或“合作”策略。每个递归调用只处理整个问题的一小部分,然后将剩余工作委托给另一个“工人”(即另一个函数调用),所有“工人”都遵循相同的算法。

比喻:将一碗 M&M 豆的数量翻倍,但不能数数。

  • 算法:每个“工人”从碗中取出一颗豆,然后请其他“工人”将剩下的豆子数量翻倍。等碗传回来后,这个“工人”再放入一颗新豆。
  • 自我相似性:“将一碗豆子翻倍”的任务,包含了“将少了一颗豆的碗翻倍”这个更小的相同任务。
  • 基本情况:如果碗是空的,直接传回空碗。

递归示例:计算幂

上一节我们通过阶乘熟悉了递归结构,本节中我们来看看另一个数学运算:计算幂(base^exponent)。

我们注意到幂运算的自我相似性:base^exponent = base * base^(exponent-1)

以下是递归实现:

int power(int base, int exponent) {
    // 处理非法输入(指数为负)
    if (exponent < 0) {
        throw exponent; // 抛出异常,表示调用错误
    }
    // 基本情况:任何数的0次幂都是1
    if (exponent == 0) {
        return 1;
    }
    // 递归情况:base^exponent = base * base^(exponent-1)
    return base * power(base, exponent - 1);
}

注意:我们添加了对非法输入(负指数)的检查。在递归函数中验证先决条件并抛出异常是一种良好实践。

优化思路:上述实现需要进行 exponent 次递归调用。我们可以利用 base^exponent = (base^(exponent/2))^2 这一性质(当 exponent 为偶数时)来减少递归深度,将时间复杂度从 O(n) 优化到 O(log n)。


递归示例:判断回文

现在,我们来看一个非数值的例子:判断一个字符串是否是回文(正读反读都一样)。这展示了递归如何应用于数据结构(如字符串)。

递归思路

  1. 检查字符串的首尾字符是否相同。
  2. 如果相同,则问题转化为判断“去掉首尾字符后的子字符串”是否是回文。
  3. 重复此过程。

以下是实现代码:

bool isPalindrome(string s) {
    // 基本情况:长度为0或1的字符串必然是回文
    if (s.length() <= 1) {
        return true;
    }
    // 获取首尾字符
    char first = s[0];
    char last = s[s.length() - 1];
    // 如果首尾字符不同,肯定不是回文
    if (first != last) {
        return false;
    }
    // 递归情况:检查去掉首尾后的中间部分
    string middle = s.substr(1, s.length() - 2);
    return isPalindrome(middle);
}

代码解析

  • 基本情况是字符串长度小于等于1。
  • 递归情况是:如果首尾字符相等,则递归判断中间部分。substr(1, s.length() - 2) 用于获取从索引1开始、长度为 总长-2 的子串。

关于递归的注意事项与常见问题

在编写递归函数时,需要注意以下几点:

以下是初学者常遇到的问题:

  • 忘记返回递归调用的结果:递归调用会返回一个值,必须用 return 语句将这个值传递回上层调用者。
  • 缺少或错误的基本情况:这可能导致无限递归,最终引发“栈溢出”错误。
  • 递归深度过大:对于某些语言(如C++),非常深的递归调用可能消耗大量栈内存,导致程序崩溃。对于此类问题,迭代解法可能更安全。
  • 不必要的额外情况:例如在阶乘函数中,n == 1 的情况可以被 n <= 1 的基本情况涵盖。

递归并不总是最优解(例如在计算阶乘或简单幂运算时,循环可能更高效),但它对于解决某些本质上是递归的问题(如遍历树形结构、解决汉诺塔、生成排列组合等)非常强大和优雅。


总结 🎯

本节课中我们一起学习了递归的核心概念。我们了解到递归是一种通过函数调用自身来解决问题的方法,其关键在于识别问题的“自我相似”性,并定义好基本情况递归情况

我们通过阶乘幂运算回文判断三个例子,实践了如何将递归思维转化为代码。记住,编写递归函数时,要确保每个递归调用都朝向基本情况前进,并且要妥善处理非法输入。

递归是一个需要练习才能熟练掌握的工具。起初它可能显得有些抽象和棘手,但随着实践,你会逐渐发现它对于分解复杂问题的强大之处。在接下来的课程中,我们将继续探索更多递归的应用场景。

课程08:递归(二)🔁

在本节课中,我们将继续深入学习递归。我们将通过解决几个具体问题来练习递归思维,包括打印二进制数、反转文件行、遍历目录结构以及优化斐波那契数列的计算。通过实践,你将更好地掌握如何识别递归的基本情况和自相似结构。


打印二进制数 🖨️

上一节我们介绍了递归的基本概念,本节中我们来看看如何将一个十进制整数递归地转换为其二进制表示并打印出来。

我们的目标是编写一个函数 printBinary,它接收一个整数 n 并打印其二进制形式。我们不会使用循环、库函数或字符串转换。

核心思路

  1. 基本情况:最简单的情况是当数字 n 为 0 或 1 时,其二进制表示就是它本身,直接打印即可。
  2. 递归情况:对于更大的数字 n,其二进制表示可以看作是 (n / 2) 的二进制表示,后接 (n % 2) 的结果。这里,n / 2 是整数除法。

以下是实现代码:

void printBinary(int n) {
    // 处理负数:先打印负号,然后递归处理其绝对值
    if (n < 0) {
        cout << "-";
        printBinary(-n);
    }
    // 基本情况:n 为 0 或 1
    else if (n < 2) {
        cout << n;
    }
    // 递归情况
    else {
        // 先递归打印 n/2 的二进制
        printBinary(n / 2);
        // 然后打印最后一位 (n % 2)
        cout << (n % 2);
    }
}

代码解释

  • 函数首先处理负数。
  • n 小于 2 时,直接输出,这是递归的终止条件。
  • 否则,函数递归调用自身处理 n/2,待递归返回后,再输出 n%2(即最后一位二进制数字)。这种“先递归,后操作”的顺序确保了数字从左到右正确打印。


反转文件行内容 ↩️

接下来,我们尝试一个处理输入流的问题:如何递归地将一个文件中的所有行以相反顺序打印出来。

我们需要编写函数 reverseLines,它接受一个输入流(如文件),并递归地逆序打印其中的每一行,而不使用任何集合(如 vector)来存储所有行。

核心思路

  1. 基本情况:当文件流中没有任何行可读时(即到达文件末尾),函数无需做任何事,直接返回。
  2. 递归情况:如果成功读取一行,那么问题可以转化为:先递归地逆序打印剩余的所有行,然后再打印当前这一行

以下是实现步骤:

void reverseLines(istream& input) {
    string line;
    // 尝试读取一行
    if (getline(input, line)) {
        // 递归情况:先逆序打印剩余的行
        reverseLines(input);
        // 然后打印当前行
        cout << line << endl;
    }
    // 基本情况:无法读取行(文件结束),隐式返回
}

过程分析
想象一个包含三行的文件:A、B、C。

  1. 第一次调用读取 A,然后递归调用。
  2. 第二次调用读取 B,然后递归调用。
  3. 第三次调用读取 C,然后递归调用。
  4. 第四次调用尝试读取,失败(文件结束),立即返回。
  5. 控制权回到第三次调用,它打印 C。
  6. 控制权回到第二次调用,它打印 B。
  7. 控制权回到第一次调用,它打印 A。
    最终输出顺序为 C、B、A,实现了反转。递归调用栈在这里起到了“后进先出”的栈数据结构作用。

遍历目录结构 📁

现在,我们来看一个更复杂的递归问题:遍历一个目录及其所有子目录,并以缩进格式展示文件结构。

我们需要编写函数 crawl。给定一个路径名,如果是文件,则打印其名;如果是目录,则打印其名,并递归地打印其内部所有内容,子级内容需要缩进以显示层级关系。

核心思路

  1. 基本情况:当路径指向一个普通文件时,直接打印其名称(附带当前层级的缩进)。
  2. 递归情况:当路径指向一个目录时:
    • 打印目录名。
    • 获取目录下的所有条目。
    • 对每个条目,递归调用 crawl 函数,但传入一个增加了缩进量的参数。

以下是实现代码,其中使用了辅助函数(如 isDirectory, listDirectory):

void crawl(const string& path, const string& indent = "") {
    // 打印当前条目(带缩进)
    cout << indent << getTail(path) << endl;

    if (isDirectory(path)) {
        // 是目录,获取其内容列表
        vector<string> entries;
        listDirectory(path, entries);
        // 对目录下的每个条目进行递归遍历,缩进增加4个空格
        for (const string& entry : entries) {
            // 构建子条目的完整路径
            crawl(path + "/" + entry, indent + "    ");
        }
    }
    // 如果是文件,上面的打印语句已经足够,无需额外操作
}

关键点

  • 我们引入了一个额外的参数 indent(缩进字符串),默认值为空。每次进入一个子目录时,递归调用会传递一个增加了四个空格的 indent
  • getTail(path) 函数用于从完整路径中提取文件名或最后一级目录名。
  • 在递归情况下,我们使用循环来迭代处理目录下的每个条目,这是被允许的,因为问题的“自相似”部分在于对每个条目自身的处理(是文件则终止,是目录则继续深入)。


优化递归:斐波那契数列与记忆化 ⚡

最后,我们探讨递归的一个常见性能问题及其优化方案。以计算斐波那契数列为例。

斐波那契数列定义为:F(1) = 1, F(2) = 1, F(n) = F(n-1) + F(n-2) (n > 2)

朴素递归实现

递归实现非常直观:

int fib(int n) {
    if (n <= 2) return 1; // 基本情况
    return fib(n - 1) + fib(n - 2); // 递归情况
}

性能问题:这种实现效率极低。例如计算 fib(6),需要计算 fib(5)fib(4);而计算 fib(5) 又需要计算 fib(4)fib(3)…… 这里 fib(4) 被计算了两次,fib(3) 被计算了三次。随着 n 增大,重复计算呈指数级增长,运行时间是 O(2^n) 级别。

优化:记忆化(Memoization)

记忆化是一种优化技术,通过缓存(存储)已计算过的函数结果来避免重复计算。

以下是使用 unordered_map(哈希表)实现记忆化的斐波那契函数:

int fibMemo(int n) {
    // 静态哈希表,用于缓存 (n -> fib(n)) 的结果
    static unordered_map<int, int> cache;

    // 基本情况
    if (n <= 2) return 1;

    // 检查结果是否已缓存
    if (cache.find(n) != cache.end()) {
        return cache[n];
    }

    // 计算并缓存结果
    int result = fibMemo(n - 1) + fibMemo(n - 2);
    cache[n] = result;
    return result;
}

优化效果

  • 单次计算加速:在计算 fibMemo(n) 的过程中,每个子问题(如 fibMemo(k))只会被计算一次并缓存。后续需要时直接从缓存中读取,将时间复杂度从指数级 O(2^n) 降低到近似线性 O(n)
  • 跨调用加速:由于 cache 是静态变量,其生命周期贯穿整个程序运行。因此,首次计算 fibMemo(40) 后,再次计算 fibMemo(40) 或任何小于 40 的值都将是常数时间 O(1) 的查找操作。

记忆化是一种强大的技术,不仅适用于递归函数,任何计算成本高昂且可能被多次以相同参数调用的函数都可以受益于此。


总结 🎯

本节课中我们一起学习了递归的多种应用和一项重要优化技术:

  1. 打印二进制数:我们通过识别“n 的二进制是 n/2 的二进制后接 n%2”这一自相似性,并结合处理 0/1 的基本情况解决了问题。
  2. 反转文件行:我们利用“先递归处理剩余行,再处理当前行”的策略,借助递归调用栈天然的反转特性完成了任务。
  3. 遍历目录:我们处理了具有嵌套结构的目录树,通过增加缩进参数来传递层级信息,并结合循环与递归完成了遍历。
  4. 记忆化优化:我们分析了朴素递归计算斐波那契数列时的指数级重复计算问题,并引入记忆化(缓存)技术,通过存储中间结果将效率提升至线性级别。

递归的核心在于将复杂问题分解为相似的、更小的子问题。多加练习是掌握它的关键。下节课我们将探索递归在图形化分形中的应用。

课程09:递归3 - 分形与表达式求值 🌀

在本节课中,我们将深入学习递归的更多应用,特别是如何利用递归来绘制分形图形和解析求值数学表达式。我们将通过具体的代码示例,帮助你理解递归在解决复杂问题时的强大能力。


表达式求值

上一节我们介绍了递归的基本概念和设计思路。本节中,我们来看看如何应用这些思路来解析和求值一个包含括号和运算符的数学表达式字符串。

我们的目标是编写一个函数,它能计算像 (2+3)((1+2)*3) 这样的表达式。为了简化问题,我们做出以下假设:

  • 所有运算符和操作数都被括号包围。
  • 字符串是有效的。
  • 数字均为个位数。
  • 运算符目前只考虑 +*

设计思路

要设计递归函数,我们需要找到问题的自我相似性并确定基本情况。

  • 自我相似性:一个复杂的表达式(如 (2+3))的求值过程,与求值其子表达式(如 23)的过程是相似的。括号内的任何内容本身就是一个可以独立求值的表达式。
  • 基本情况:最简单的表达式就是一个单独的数字字符,我们可以直接将其转换为整数并返回。

然而,直接处理字符串索引可能比较混乱。一个有效的方法是使用一个辅助函数,它接收原始字符串和一个整数索引的引用,该索引表示当前处理到的字符位置。随着函数的递归调用,这个索引会不断向前移动,“消耗”掉已经处理过的字符。

代码实现

以下是求值函数的核心实现。evaluate 是主函数,它初始化索引并调用辅助函数 evalHelper

int evaluate(string expr) {
    int index = 0;
    return evalHelper(expr, index);
}

int evalHelper(const string& expr, int& index) {
    // 情况1:当前字符是数字(基本情况)
    if (isdigit(expr[index])) {
        int result = expr[index] - '0'; // 将字符数字转换为整数
        index++; // 消耗掉这个数字字符
        return result;
    }
    // 情况2:当前字符是左括号 '('
    else if (expr[index] == '(') {
        index++; // 跳过左括号
        // 递归求值左操作数
        int left = evalHelper(expr, index);
        // 读取运算符
        char op = expr[index];
        index++; // 消耗掉运算符
        // 递归求值右操作数
        int right = evalHelper(expr, index);
        // 跳过右括号
        index++; // 此时 index 应指向右括号 ')'
        // 根据运算符计算结果
        if (op == '+') {
            return left + right;
        } else { // 根据假设,此处为 '*'
            return left * right;
        }
    }
    // 根据假设,不会遇到其他情况
    return 0;
}

代码解析

  1. evaluate 函数设置起始索引 0,并启动递归过程。
  2. evalHelper 是递归函数。参数 index 是引用,确保所有递归调用共享并更新同一个索引。
  3. 如果当前字符是数字,直接转换并返回,同时前移索引。
  4. 如果当前字符是左括号 (,则:
    • 跳过它。
    • 递归求值左操作数(可能是一个数字或另一个带括号的表达式)。
    • 读取运算符。
    • 递归求值右操作数。
    • 跳过右括号 )
    • 根据运算符计算并返回最终结果。
  5. 通过递归调用,函数能够处理任意深度的嵌套表达式,如 ((1+2)*(3+4))


递归绘制分形

理解了表达式求值后,我们来看看递归另一个有趣的应用:绘制分形图形。分形是一种在不同尺度上表现出自相似性的几何图形。

康托尔集

我们将以康托尔集为例。康托尔集的构造规则如下:

  • 0阶:一条完整的线段。
  • n阶:先绘制一条线段,然后在该线段下方绘制两个 n-1 阶的康托尔集,分别位于原线段前1/3和后1/3的位置。

以下是绘制康托尔集的递归函数实现:

void cantor(GWindow& window, double x, double y, double length, int level) {
    // 基本情况:至少绘制一条线段
    if (level >= 1) {
        // 绘制当前层级的线段
        window.drawLine(x, y, x + length, y);
        // 如果层级大于1,则递归绘制两个更小的子集
        if (level > 1) {
            double newY = y + 20; // 下一层线段的垂直位置
            double newLength = length / 3.0; // 子线段长度是原长的1/3
            // 递归绘制左半部分的子集
            cantor(window, x, newY, newLength, level - 1);
            // 递归绘制右半部分的子集
            cantor(window, x + 2 * newLength, newY, newLength, level - 1);
        }
    }
}

代码解析

  1. 函数参数包括绘图窗口、线段起点坐标 (x, y)、线段长度 length 和当前层级 level
  2. 如果 level >= 1,我们就在当前位置绘制一条水平线段。
  3. 如果 level > 1,说明还需要绘制更小层级的图形。我们计算下一层线段的Y坐标(下移20像素)和子线段的长度(原长的1/3)。
  4. 然后进行两次递归调用:
    • 一次针对原线段的前1/3部分(起点 x 不变)。
    • 一次针对原线段的后1/3部分(起点为 x + 2 * newLength)。
  5. 每次递归调用都将层级 level 减1。当 level 减至1时,递归调用只会绘制线段而不再产生新的子调用,递归过程终止。

通过调用 cantor(window, 50, 50, 700, 5),你可以看到一个5阶的康托尔集图形被绘制出来。


课程总结

本节课中我们一起学习了递归的两个高级应用。

  • 首先,我们实现了表达式求值器,通过递归辅助函数和索引引用,优雅地处理了嵌套括号的解析问题。
  • 接着,我们探索了分形绘制,以康托尔集为例,展示了如何用递归描述自相似图形,并通过代码将其可视化。

递归的核心在于将复杂问题分解为相似的、更小的子问题。无论是处理数据结构、解析语言还是生成图形,这种“分而治之”的思想都是极其强大的工具。在接下来的课程中,我们将继续利用递归解决更多类型的问题。

课程1:C++编程入门与课程概述 🚀

在本节课中,我们将学习C++编程语言的重要性、CS106L课程的目标与结构,并初步了解C++的历史、设计哲学以及一个简单的“Hello World”程序。


为什么选择C++?🤔

C++是一种非常重要且流行的编程语言。尽管有人认为它已经过时,但事实并非如此。近年来,C++经历了重大改进,使其更易于使用,其受欢迎程度仍在增长。

C++的应用非常广泛,尤其是在对性能要求高的领域。以下是使用C++的一些场景:

  • 大学课程:许多涉及并行计算、计算机网络等高性能计算的课程都使用C++。
  • 行业公司:几乎每家公司都在某些地方使用C++,掌握C++对求职有帮助。
  • 关键软件:许多浏览器(如Chrome)因其对性能的高要求而使用C++编写。
  • 基础工具:一些编程语言(如Java)的实现本身就用到了C++。
  • 游戏开发:许多对内存和性能要求密集的游戏使用C++开发。
  • 嵌入式系统:例如,一些火星探测器的代码就是用C++编写的。

C++的核心优势之一在于其高性能,这意味着它能高效地利用计算资源(时间和空间)。


CS106L 与 CS106B 的区别 🎯

上一节我们介绍了C++的重要性,本节中我们来看看CS106L课程在斯坦福课程体系中的定位,特别是它与CS106B的区别。

CS106B名为“编程抽象”,其核心目标是教授适用于所有编程语言的通用概念(如递归、抽象数据类型)。C++只是实现这一目标的工具,并非课程重点。因此,CS106B使用的是较旧的C++98标准,并提供了“斯坦福库”来简化复杂的C++细节(例如,用 getInteger 代替原生的 cin)。

相比之下,CS106L的目标是教授标准C++。我们将深入探讨C++语言本身,特别是其独特之处(如模板),并专注于现代C++(C++17/20)的特性。课程旨在让你能够:

  1. 了解C++的功能及其存在原因。
  2. 学会阅读C++官方文档。
  3. 熟悉现代C++的设计哲学和惯用法。

重点是理解概念和知道如何查找语法,而非死记硬背。


课程结构与要求 📋

了解了课程目标后,我们来看看CS106L的具体安排和要求。

CS106L是一个1学分的课程,评分方式为“通过/不通过”。课程将持续到第9周,第10周留给大家完成其他课程的项目。

以下是课程的核心结构,包含四个主要单元:

  1. C++基础:包括流、类型等。
  2. 标准模板库(STL):涵盖容器、迭代器和算法,这对技术面试非常有帮助。
  3. 面向对象编程(OOP)
  4. 现代C++概念

课程要求如下:

  • 作业:共有3个作业,必须完成其中2个,且必须包含最后一个作业。作业旨在练习C++语法。
  • 延迟政策:通过完成课程调查,最多可获得3天的作业延期。
  • 开发环境:前两个作业需使用 Qt Creator(与CS106B相同),第三个作业可能允许使用其他环境。
  • 学术诚信:必须独立完成作业,禁止抄袭。
  • 沟通平台:使用 Piazza 进行课程问答和通知,请务必加入。

    注意:请不要使用CS106B的“Lair”答疑时间解决CS106L的C++问题,因为那里的助教可能不熟悉标准C++。


C++的历史与设计哲学 📜

现在我们对课程有了整体认识,接下来让我们深入了解C++背后的历史与设计哲学,这有助于理解其特性。

编程语言的发展是一个追求效率与表达力平衡的过程:

  1. 汇编语言:计算机能直接理解,速度快,控制力强,但难以编写和维护,且不可移植。
    ; 一个简单的汇编代码示例
    section .text
        global _start
    _start:
        mov edx, len
        mov ecx, msg
        mov ebx, 1
        mov eax, 4
        int 0x80
    
  2. C语言:由肯·汤普森和丹尼斯·里奇发明。它通过编译器将人类可读的代码转换为机器码,在效率、可读性和可移植性之间取得了平衡。但C语言缺乏对“类”和“对象”的直接支持,构建大型复杂程序比较繁琐。
  3. C++:由比雅尼·斯特劳斯特鲁普于1983年创建。它在C语言的基础上增加了对类等高级特性的支持,并逐渐发展成一个功能丰富且灵活的语言。现代C++(如C++11/14/17/20)引入了许多重要更新。

C++的设计哲学主要包括:

  • 赋予程序员控制权:程序员可以管理内存等底层资源(与Java/Python的自动管理不同)。
  • 直接表达意图:代码应清晰表达编程意图。例如,求向量和的三种方法中,使用STL的 accumulate 算法意图最明确。
    // 方法1:基础循环
    int sum = 0;
    for (int i = 0; i < vec.size(); ++i) {
        sum += vec[i];
    }
    // 方法2:使用const和引用,表达不修改元素的意图
    int sum = 0;
    for (const int& num : vec) {
        sum += num;
    }
    // 方法3:使用STL算法,最佳地表达“累加”意图
    #include <numeric>
    int sum = std::accumulate(vec.begin(), vec.end(), 0);
    
  • 编译时安全:编译器应尽可能在编译阶段发现错误。
  • 零开销抽象:核心原则是“不浪费任何时间或空间”,高级特性不应带来额外的运行时开销。
  • 封装复杂性:通过类、模板等机制将复杂实现细节封装起来,提供简洁的接口。


第一个C++程序:Hello World 👋

最后,让我们通过一个简单的“Hello World”程序来直观感受C++。C++具有高度的灵活性和向后兼容性,甚至可以用C风格或内联汇编来写“Hello World”,但我们将专注于标准的现代C++写法。

以下是标准C++的“Hello World”程序:

#include <iostream>

int main() {
    std::cout << "Hello World!" << std::endl;
    return 0;
}

对代码中可能陌生的部分解释如下:

  • #include <iostream>:引入输入输出流标准库。尖括号<>用于包含标准库头文件。
  • int main():程序的主入口函数。
  • std::cout:标准输出流对象,用于向控制台打印信息。std::命名空间作用域解析符,表明 coutendl 定义在标准(std)命名空间中。
  • <<:流插入运算符,用于将数据发送到 cout
  • std::endl:输出换行符并刷新输出缓冲区。
  • return 0;:主函数返回值,0通常表示程序正常退出。在现代C++中,main 函数可以省略 return 0;

这个程序展示了C++标准库的用法。作为对比,C语言风格的写法是 printf(“Hello World!\n”);,而汇编语言则复杂得多且不可移植。C++的语法虽然有时看起来更繁琐,但这是为了提供强大的表达能力和控制力。


总结与预告 📚

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

  1. C++在现代计算中的重要性和应用领域。
  2. CS106L课程的目标是深入教授标准现代C++,与CS106B的侧重点不同。
  3. 课程的结构、要求以及使用的工具(Qt Creator, Piazza)。
  4. C++从汇编语言、C语言演变而来的历史及其核心设计哲学(控制力、表达意图、零开销抽象等)。
  5. 编写并分析了一个标准的C++ “Hello World” 程序。

下节课,我们将开始深入C++基础部分,首先学习流(Streams) 的概念,包括 std::cinstd::cout 的详细用法。我们也会了解如何实现类似斯坦福库中 getInteger 的函数。

请记得填写课程简介调查问卷,以帮助我们更好地调整教学,并为你赢得作业延期机会。

斯坦福大学《CS106L:C++编程》课程笔记 - 第9讲:STL总结与综合示例 🎯

概述

在本节课中,我们将对标准模板库(STL)进行总结,并通过一个综合性的编程示例——分析《联邦党人文集》的作者身份——来巩固所学知识。我们将运用STL的容器、迭代器、算法和函数对象来解决实际问题。


STL 核心概念总结

上一节我们介绍了STL的各个组件,本节我们来回顾并总结这些核心概念。

容器 (Containers)

容器用于存储和管理数据集合。STL提供了多种容器类型:

  • 序列容器vector, deque, list
  • 关联容器set, map
  • 无序关联容器unordered_set, unordered_map
  • 容器适配器stack, queue

迭代器 (Iterators)

迭代器是访问容器元素的通用接口,是STL算法的“动力源”。迭代器有不同的能力级别:

  • 输入迭代器:只读,单次遍历
  • 输出迭代器:只写,单次遍历
  • 前向迭代器:可读写,可多次遍历
  • 双向迭代器:可前后移动
  • 随机访问迭代器:支持随机访问(如 vector 的迭代器)

以下是迭代器的基本操作:

auto it = vec.begin(); // 获取起始迭代器
*it;                   // 解引用,访问元素
++it;                  // 递增迭代器
it != vec.end();       // 判断是否到达末尾

算法 (Algorithms)

STL算法通过迭代器对容器进行操作,实现了数据与操作的分离。常用算法包括:

  • std::sort:排序
  • std::accumulate:累积计算
  • std::find:查找元素
  • std::copy:复制元素

函数对象与Lambda表达式

函数对象(仿函数)和Lambda表达式允许我们将行为作为参数传递给算法,提供了极大的灵活性。

// 使用Lambda表达式作为谓词
std::find_if(vec.begin(), vec.end(), [](int x){ return x > 5; });

STL的设计哲学:抽象与效率

STL的核心思想是抽象效率

  1. 抽象:通过模板,STL将算法从特定的数据类型和容器结构中抽象出来。
  2. 效率:STL的实现经过了高度优化,在保证通用性的同时追求运行速度。

从基本类型到容器,再到通过迭代器操作的算法,STL构建了一层层的抽象,让我们能够编写通用、高效的代码。


综合示例:《联邦党人文集》作者分析

现在,我们将运用所学的STL知识来解决一个实际问题:通过分析写作风格,推测《联邦党人文集》中匿名文章的作者。

问题背景

《联邦党人文集》由亚历山大·汉密尔顿、詹姆斯·麦迪逊和约翰·杰伊合著,但具体文章的归属存在争议。我们的目标是编写一个程序,通过比较功能词(如 “I”, “the”, “their”)的频率,来量化未知文本与已知作者文本的相似度。

核心思路:向量空间模型

我们将每篇文章视为一个多维向量,每个维度对应一个功能词的出现频率。通过计算两个向量之间的余弦相似度,我们可以衡量其写作风格的相似性。

余弦相似度公式
[
\text{similarity} = \cos(\theta) = \frac{\mathbf{A} \cdot \mathbf{B}}{|\mathbf{A}| |\mathbf{B}|} = \frac{\sum_{i=1}^{n} A_i B_i}{\sqrt{\sum_{i=1}^{n} A_i^2} \sqrt{\sum_{i=1}^{n} B_i^2}}
]
其中,( \mathbf{A} \cdot \mathbf{B} ) 是点积,( |\mathbf{A}| ) 是向量A的模长。

程序结构设计

我们的程序将遵循以下步骤:

  1. 读取并预处理文本:将文件内容读入字符串,并统一转换为小写。
  2. 计算词频向量:针对给定的功能词列表,统计它们在文本中出现的次数。
  3. 计算相似度:使用余弦相似度公式,比较未知文本与每位已知作者文本的词频向量。
  4. 输出结果:显示未知文本与各位作者的相似度分数。

代码实现详解

以下是实现过程中的关键代码片段和思路。

1. 预处理文本:转换为小写字符串

我们需要一个函数来读取文件流,并返回一个所有单词均为小写、由空格分隔的单一字符串。

std::string processText(std::ifstream& file) {
    std::string result;
    std::string line;
    while (std::getline(file, line)) {
        // 使用 std::transform 将整行转换为小写
        std::transform(line.begin(), line.end(), line.begin(),
                       [](unsigned char c) { return std::tolower(c); });
        result += line + " "; // 用空格替换换行符
    }
    return result;
}

说明

  • 使用 std::getline 逐行读取。
  • 使用 std::transform 算法结合 std::tolower 和 Lambda 表达式,将每一行转换为小写。这避免了手写循环,是STL风格的代码。
  • 将处理后的行加空格拼接到结果字符串中。

2. 计算词频向量

这是程序的核心部分。我们需要统计功能词列表中每个词在文本中出现的次数。

std::vector<int> createFrequencyVector(const std::string& text) {
    std::vector<int> frequencies;
    for (const std::string& word : FEATURE_WORDS) { // FEATURE_WORDS 是功能词列表
        int count = 0;
        // 关键步骤:计算单词 `word` 在 `text` 中出现的次数
        // ... (计数逻辑将在下节课完成)
        frequencies.push_back(count);
    }
    return frequencies;
}

挑战与思考
在实现计数逻辑时,我们需要注意:

  • 子串匹配问题:例如,统计 “the” 时,不能把 “there” 中的 “the” 也算进去。我们需要进行全词匹配
  • 标点符号:例如,“the.” 可能无法匹配 “the”。一个更健壮的实现需要处理标点符号(可作为课后练习)。
  • 性能:在长文本中多次遍历可能较慢。我们可以探索更高效的算法,例如使用 std::istringstream 配合 std::unordered_map 一次性统计所有词频,再提取所需功能词。

3. 计算相似度

一旦有了两个词频向量,计算相似度就变得直接。

double computeSimilarity(const std::string& text1, const std::string& text2) {
    std::vector<int> freq1 = createFrequencyVector(text1);
    std::vector<int> freq2 = createFrequencyVector(text2);

    double dotProduct = std::inner_product(freq1.begin(), freq1.end(), freq2.begin(), 0);
    double magnitude1 = std::sqrt(std::inner_product(freq1.begin(), freq1.end(), freq1.begin(), 0.0));
    double magnitude2 = std::sqrt(std::inner_product(freq2.begin(), freq2.end(), freq2.begin(), 0.0));

    if (magnitude1 == 0 || magnitude2 == 0) return 0.0; // 避免除以零
    return dotProduct / (magnitude1 * magnitude2);
}

说明

  • std::inner_product 算法完美地计算了两个向量的点积。
  • 向量模长的计算也通过 std::inner_product 实现(向量与自身的点积即各分量平方和)。
  • 整个计算过程简洁,充分利用了STL算法。

主函数与流程

int main() {
    // 1. 读取已知作者和未知作者的文件
    std::ifstream madisonFile("madison.txt");
    std::ifstream jayFile("jay.txt");
    std::ifstream hamiltonFile("hamilton.txt");
    std::ifstream unknownFile("unknown.txt");

    // 2. 预处理文本
    std::string madisonText = processText(madisonFile);
    std::string jayText = processText(jayFile);
    std::string hamiltonText = processText(hamiltonFile);
    std::string unknownText = processText(unknownFile);

    // 3. 计算并输出相似度
    std::cout << "Similarity with Madison: "
              << computeSimilarity(madisonText, unknownText) << std::endl;
    std::cout << "Similarity with Jay: "
              << computeSimilarity(jayText, unknownText) << std::endl;
    std::cout << "Similarity with Hamilton: "
              << computeSimilarity(hamiltonText, unknownText) << std::endl;

    return 0;
}

总结

本节课我们一起回顾了STL的核心组件——容器、迭代器、算法和函数对象,并深入理解了其抽象效率的设计哲学。随后,我们通过一个生动的综合示例,将理论知识应用于实践,使用STL工具构建了一个文本风格分析程序。

通过这个示例,我们学习了如何:

  1. 使用 std::transform 等算法处理数据。
  2. 设计函数来计算词频和向量相似度。
  3. 将复杂问题分解为多个使用STL解决的子问题。

这充分展示了STL的强大之处:它提供了一套丰富、高效且通用的工具集,能让我们以简洁的代码解决复杂的问题。在接下来的课程和作业中,请继续实践和探索STL的更多功能。

斯坦福大学《CS106L:C++编程》课程笔记 - 第10讲:类与常量正确性 🧱

在本节课中,我们将学习C++中类的核心概念,并深入探讨const关键字的各种用法和重要性。理解const是编写安全、高效且易于维护的C++代码的关键。

课程回顾:STL算法应用

上一节我们介绍了如何使用STL算法解决“联邦论文”作者归属问题。本节中,我们来看看该解决方案的总结与实现细节。

我们尝试解决的问题是:给定一系列已知作者的文章和一篇未知作者的文章,能否通过计算特定“常见词”在文本中出现的频率(即特征向量),并使用向量夹角(通过点积计算)来判断未知文章的作者?夹角越小,文本越相似。

以下是实现过程中的关键步骤:

  1. 读取文件并转换为字符串:首先,我们需要从文件中读取文本内容。

  2. 计算词频:对于特征向量中的每个词,计算它在文本字符串中出现的次数。这里不能使用std::count算法,因为它只能统计单个字符。

  3. 使用std::search算法:为了统计一个字符串在另一个字符串中出现的次数,我们使用了std::search算法。其核心逻辑是一个while循环,不断在文本中搜索目标词的下一次出现。

    // 伪代码逻辑
    while (未到达文本末尾) {
        found_pos = std::search(文本起始, 文本结束, 目标词起始, 目标词结束);
        if (未找到) break;
        计数增加;
        将搜索起始位置设为找到位置之后;
    }
    
  4. 计算相似度:通过计算两个特征向量的点积来衡量相似度。这里可以直接使用STL中的std::inner_product算法。

  5. 计算向量模长:一个向量的模长等于该向量与自身点积的平方根,即 magnitude = sqrt(dot_product(vec, vec))

至此,我们完成了对STL容器和算法单元的总结。接下来,我们将开启新的主题。

面向对象编程与类基础回顾

在进入新内容前,我们先简要回顾面向对象编程(OOP)和类的基础知识。C++支持多种编程范式,OOP是其最常用的方式之一。

以下是OOP的核心概念,我们将在后续课程中深入探讨:

  • 类、对象与封装:将数据和对数据的操作封装在一起。
  • 运算符重载:赋予自定义类型与内置类型相似的操作符行为。
  • 继承:实现代码复用和层次化设计。
  • 多态与虚函数:实现接口统一,运行时动态绑定。

关于类的实现,有两个关键文件:

  • 头文件 (.h/.hpp):用于声明类的接口(API),即公有成员函数和变量。这是类对外的承诺。
  • 源文件 (.cpp/.cc):用于实现头文件中声明的函数,隐藏复杂的内部细节。

此外,还有几个基本概念:

  • 构造函数/析构函数:用于对象的初始化和清理。
  • const成员函数:承诺该函数不会修改类的成员变量。

深入理解const关键字 🔒

const是C++中一个历史悠久且功能强大的关键字,它能显著提升代码的安全性和可读性。本节我们将详细解析const的各种用法。

为什么使用const

使用const主要基于以下几点原因:

  1. 安全性:防止意外修改不应改变的数据。
  2. 接口清晰:明确告知函数调用者,哪些参数或返回值是只读的。
  3. 编译器优化:编译器可以利用const信息进行更好的优化。
  4. 设计约束:强制实施设计意图,避免全局变量等难以推理的用法。

考虑以下场景:一个函数本应只计算地球的人口,但其内部实现却错误地修改了地球对象。如果将参数声明为const引用,编译器就能在编译期捕获这个错误,避免运行时灾难。

// 错误:函数可能意外修改planet
int countPeople(Planet& p);

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/918ecf73de3563c49c625cc13afa9fce_49.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/918ecf73de3563c49c625cc13afa9fce_51.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/918ecf73de3563c49c625cc13afa9fce_53.png)

// 正确:函数承诺不修改planet
int countPeople(const Planet& p); // 安全!

const与指针

const与指针结合时,需要仔细区分两种常量性:

  • 指针本身是常量(指针不能指向别的地址)。
  • 指针所指向的数据是常量(不能通过该指针修改数据)。

以下是三种常见情况:

int* const p1 = &a; // p1是常量指针,指向非常量int(指针不能变,值能变)
const int* p2 = &a; // p2是非常量指针,指向常量int(指针能变,值不能变)
const int* const p3 = &a; // p3是常量指针,指向常量int(指针和值都不能变)

记忆口诀:从右向左读。const修饰它左边最近的东西(如果左边没东西,则修饰它自己)。

const与迭代器

迭代器的const行为与指针类似,但需要注意STL中专门定义了const_iterator类型。

  • const std::vector::iterator itr; 相当于常量指针,迭代器本身(itr++)不能移动,但可以修改它指向的元素(*itr = 5;)。
  • std::vector::const_iterator itr;指向常量的迭代器,迭代器本身可以移动(itr++),但不能修改它指向的元素。

const成员函数

在类中,可以将成员函数声明为const,这表示该函数不会修改类的任何成员变量(除非成员被mutable修饰)。这对于在常量对象上调用函数至关重要。

class MyClass {
public:
    int getValue() const; // 常量成员函数,可在常量对象上调用
    void setValue(int v); // 非常量成员函数,不能在常量对象上调用
};

const MyClass obj;
obj.getValue(); // 正确
obj.setValue(10); // 编译错误!

常量正确性规则:一个常量对象只能调用其常量成员函数。

总结与最佳实践

本节课中我们一起学习了C++中类的初步知识,并深入探讨了const关键字的强大功能。

以下是关于const的最佳实践总结:

  1. 尽可能使用const:标记所有不应被修改的变量、参数和成员函数为const。这是一种良好的设计和承诺。
  2. 优先按const引用传递:对于非基本类型(如自定义类、std::stringstd::vector),应优先按const引用传递,以避免不必要的拷贝,同时保证数据不被修改。基本类型(如int, double)通常按值传递即可。
  3. 理解指针/迭代器的双重const:始终清楚你希望限制的是指针本身,还是指针指向的数据。
  4. 使用const成员函数设计类接口:明确区分会修改对象状态的函数和不会修改的函数。这使你的类更安全、更易用。

掌握const正确性是成为熟练C++程序员的重要一步。它不仅是编译器的检查工具,更是表达程序设计意图、编写健壮代码的核心机制。

课程11:运算符重载 🧮

在本节课中,我们将要学习C++中的运算符重载。我们将探讨如何为自定义类型定义运算符的行为,理解重载运算符时的设计原则,并学习如何正确选择成员函数与非成员函数来实现运算符。


概述

运算符重载允许我们为自定义类型(如类或结构体)定义运算符的行为。就像C++知道如何对基本类型(如intdouble)使用+-等运算符一样,我们可以通过重载来告诉编译器,当这些运算符用于我们自己的类时应该执行什么操作。本节课的核心在于理解重载的语法、设计选择以及应遵循的最佳实践。


什么是运算符重载?

对于C++内置的基本类型,运算符有明确的含义。例如,算术运算符用于数学计算,关系运算符用于比较。

然而,对于用户自定义的类型(如std::vectorstd::string),C++本身并不知道这些运算符的含义。必须有人通过编写特定的函数来定义这些行为。这个过程就是运算符重载。

例如,当我们写vec[0]来索引一个向量时,C++实际上将其解释为调用一个名为operator[]的成员函数:vec.operator[](0)

同样,流运算符<<>>也是重载的运算符,它们被定义为将数据输出到流或从流输入数据。


如何重载运算符:语法与示例

运算符重载本质上是一个特殊命名的函数。函数名由关键字operator后接要重载的运算符符号组成。

上一节我们介绍了运算符重载的概念,本节中我们来看看具体的语法和实现方式。

成员函数形式

许多运算符可以作为类的成员函数来重载。此时,运算符的左操作数是调用该成员函数的对象(通过this指针访问),右操作数是函数的参数。

以下是重载+=运算符的示例,用于向自定义的StringVector类添加元素:

class StringVector {
public:
    // 重载 += 运算符,用于添加一个字符串
    StringVector& operator+=(const std::string& element) {
        // 将元素添加到向量中
        push_back(element);
        // 返回对当前对象的引用,以支持链式调用
        return *this;
    }
private:
    // ... 其他成员,如存储字符串的数组 ...
};

关键点分析:

  • 参数类型:使用const std::string&const确保不会修改传入的字符串,引用&避免了不必要的拷贝,提高了效率。
  • 返回值:返回StringVector&(引用)。这是为了模拟内置类型的行为(如int),使得vec += s1 += s2这样的链式调用成为可能。如果返回值不是引用,链式调用将作用于对象的副本,而非原对象。
  • *this:在成员函数中,this是指向当前对象的指针。*this解引用后得到当前对象本身,我们返回它的引用。

非成员函数形式

有些运算符必须,或者更适合作为非成员函数(自由函数)来重载。这通常发生在运算符的左侧操作数不是当前类的对象时,例如重载流运算符<<

以下是为一个Fraction(分数)类重载输出流运算符<<的示例:

class Fraction {
private:
    int num; // 分子
    int den; // 分母
    // 声明 operator<< 为友元函数,使其能访问私有成员
    friend std::ostream& operator<<(std::ostream& os, const Fraction& f);
public:
    // ... 构造函数和其他方法 ...
};

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/fc37c1fe01692e4bd06353b7a0e9002a_37.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/fc37c1fe01692e4bd06353b7a0e9002a_39.png)

// 实现 operator<< 为非成员函数
std::ostream& operator<<(std::ostream& os, const Fraction& f) {
    os << f.num << "/" << f.den; // 友元声明允许访问私有成员 num 和 den
    return os; // 返回流的引用以支持链式输出
}

关键点分析:

  • 必须为非成员函数:因为左侧的std::ostream对象(如std::cout)不是我们Fraction类的对象,我们不能修改std::ostream类来添加成员函数。
  • 返回值:返回std::ostream&,以便支持std::cout << f1 << f2这样的链式操作。
  • 友元(friend:由于operator<<需要访问Fraction的私有成员变量(numden),但它是非成员函数,因此需要在类内将其声明为friend。这使得该函数拥有访问类私有部分的特权。


成员函数 vs. 非成员函数:如何选择?

在重载二元运算符时,一个常见的问题是应该将其实现为成员函数还是非成员函数。以下是基本的指导原则:

上一节我们看了两种实现形式,本节中我们来看看做出选择的一般规则。

以下是选择时应考虑的主要因素:

  1. 必须为成员函数的运算符
    • 赋值运算符 =
    • 下标运算符 []
    • 函数调用运算符 ()
    • 成员访问运算符 ->
    • 这些运算符在语义上天然与一个对象的状态紧密相关。

  1. 必须为非成员函数的运算符
    • 流运算符 <<>>(因为左操作数是流对象,非自定义类)。
    • 当运算符的左侧操作数不是自定义类的对象时。

  1. 对于对称的二元运算符(如 +, -, ==, <
    • 推荐实现为非成员函数。这能保证运算符对两个操作数的处理是平等的,并支持更自然的隐式类型转换。
    • 例如:如果operator+Fraction的成员函数,那么fraction + 1可以工作(整数1被隐式转换为Fraction),但1 + fraction则不行(整数1没有对应的operator+成员函数)。非成员函数形式能同时处理这两种情况。

  1. 对于修改左操作数的运算符(如 +=, -=
    • 通常实现为成员函数。因为它们需要直接修改左侧对象的状态,作为成员函数访问其私有成员更为方便。


重载运算符的设计原则:最少惊讶原则(POLA)

最少惊讶原则(Principle of Least Astonishment, POLA)是软件设计,尤其是运算符重载中的核心原则。它要求设计的功能应该符合用户的直觉,不会让他们感到“惊讶”或困惑。

以下是应用POLA的一些具体规则:

  • 模仿内置类型的行为:自定义类型的运算符行为应尽量与内置类型(如int)保持一致。例如,+=应该修改左操作数并返回其引用。
  • 提供完整的运算符集合:如果你重载了关系运算符,最好提供全套(==, !=, <, <=, >, >=)。如果你重载了算术运算符+,通常也应该重载对应的复合赋值运算符+=
  • 避免模糊语义:如果一个运算符对你的类没有明确、直观的含义,就不要重载它。例如,为Time类重载+= 10,是表示增加10小时还是10分钟?如果不明确,就使用命名的成员函数(如addMinutes(10))来代替。
  • 不要滥用运算符:永远不要重载运算符使其做出完全不符合常规预期的事情(例如,让<<运算符执行网络请求)。这会极大地破坏代码的可读性和可维护性。

其他可重载的运算符简介

除了常见的算术、关系、流运算符外,C++还允许重载许多其他运算符,用于实现更高级的功能。

  • newdelete:可以重载内存分配和释放运算符,用于实现自定义的内存管理策略(如内存池)。
  • 类型转换运算符:允许定义从自定义类型到其他类型的隐式或显式转换。
  • 函数调用运算符 ():重载此运算符的类被称为“函数对象”或“仿函数”,它们可以像函数一样被调用,是C++泛型编程和标准库算法中的重要组成部分。
  • 三路比较运算符 <=> (C++20):也称为“太空船运算符”,它一次性定义所有比较关系(<, <=, >, >=, ==, !=),简化了比较运算符的重载。

总结与下节预告

本节课中我们一起学习了C++运算符重载的核心知识。我们理解了运算符重载的本质是定义特殊的成员函数或非成员函数。我们掌握了重载运算符的基本语法,并深入探讨了如何根据运算符的语义(是否对称、是否修改左操作数)来选择将其实现为成员函数或非成员函数。最重要的是,我们学习了最少惊讶原则(POLA),这是指导我们正确、合理地进行运算符重载的黄金法则。

然而,我们注意到一个遗留问题:默认的赋值运算符=有时会导致意外的行为(浅拷贝问题)。这引出了下两节课的核心主题:拷贝控制。

下节预告:我们将深入探讨拷贝构造函数拷贝赋值运算符,学习如何通过“深拷贝”来正确管理包含动态分配资源的类,避免浅拷贝带来的陷阱。这是理解C++资源管理的关键一步。

课程12:特殊成员函数 🧩

在本节课中,我们将学习C++中的特殊成员函数。这些函数之所以“特殊”,是因为如果你不在类中声明它们,编译器会自动为你生成。然而,编译器生成的版本有时并不符合我们的需求,因此理解如何正确声明和实现它们至关重要。我们将重点讨论与“拷贝”相关的特殊成员函数。


回顾:运算符重载

上一节我们介绍了运算符重载,它允许我们为自定义类型定义运算符的行为。本节中,我们来看看如何确保这些运算符的行为符合预期。

以下是实现运算符重载时的一些关键原则:

  • 遵守运算符的通常语义:例如,重载 += 运算符时,应返回对原始对象的引用(*this),以保持与基本类型行为的一致性。
  • 保持对称性:对于二元对称运算符(如 +),最好将其实现为非成员函数,以确保操作数被平等对待。
  • 考虑 const 正确性:为可能被 const 对象调用的运算符(如 [])同时提供 const 和非 const 版本。
  • 遵循最小惊讶原则:确保重载的运算符行为直观。例如,如果重载了 +,通常也应重载 +=

构造函数与析构函数复习

在深入特殊成员函数之前,我们先快速回顾构造函数和析构函数的基础知识。

初始化列表

在构造函数中初始化成员变量时,应优先使用初始化列表,而非在构造函数体内赋值。

代码示例:使用初始化列表

class StringVector {
private:
    std::string* elems;
    size_t logicalSize;
    size_t allocatedSize;
public:
    // 使用初始化列表
    StringVector(size_t initSize = 0) : logicalSize(initSize), allocatedSize(initSize + 10) {
        elems = new std::string[allocatedSize];
    }
};

使用初始化列表的主要原因有两个:

  1. const 成员和引用成员:对于 const 成员或引用成员,必须在声明时初始化,不能先声明后赋值。初始化列表是唯一的方法。
  2. 效率:对于类类型成员,使用初始化列表直接调用其拷贝构造函数,通常比先调用默认构造函数再赋值更高效。

注意:初始化列表中成员的初始化顺序应与它们在类中声明的顺序一致。


特殊成员函数:拷贝操作

编译器通常会自动生成四个特殊成员函数:默认构造函数、拷贝构造函数、拷贝赋值运算符和析构函数。本节我们聚焦于拷贝构造函数拷贝赋值运算符

拷贝构造函数 vs. 拷贝赋值运算符

两者的核心区别在于调用的时机:

  • 拷贝构造函数:在创建新对象并将其初始化为另一个对象的副本时调用。
    • 例如:StringVector v2 = v1;StringVector v2(v1);
  • 拷贝赋值运算符:在已存在对象被赋予另一个对象的值时调用。
    • 例如:v2 = v1;

默认拷贝的问题(浅拷贝)

如果我们不声明自己的拷贝操作,编译器会生成默认版本。默认的拷贝操作执行浅拷贝(按成员拷贝)。对于指针成员,这会导致多个对象指向同一块内存。

问题场景

StringVector original; // 拥有一个动态数组
StringVector copy = original; // 默认浅拷贝:copy.elems 指向 original.elems 的同一数组
// 当 original 和 copy 析构时,同一块内存会被释放两次,导致未定义行为!

实现深拷贝

为了解决浅拷贝的问题,我们需要自己实现拷贝操作,进行深拷贝,即为指针指向的内容创建全新的副本。

1. 拷贝构造函数

拷贝构造函数接受一个对同类型对象的 const 引用作为参数。

代码示例:实现拷贝构造函数

class StringVector {
public:
    // 拷贝构造函数
    StringVector(const StringVector& other) noexcept
        : logicalSize(other.logicalSize), allocatedSize(other.allocatedSize) {
        // 1. 为数组分配新内存
        elems = new std::string[allocatedSize];
        // 2. 复制元素(使用 std::copy)
        std::copy(other.begin(), other.end(), begin());
    }
private:
    std::string* elems;
    size_t logicalSize;
    size_t allocatedSize;
};

2. 拷贝赋值运算符

拷贝赋值运算符重载 = 操作符。它需要处理自我赋值,并确保在分配新资源前释放旧资源。

代码示例:实现拷贝赋值运算符

class StringVector {
public:
    // 拷贝赋值运算符
    StringVector& operator=(const StringVector& other) {
        // 1. 检查自我赋值
        if (this != &other) {
            // 2. 释放旧资源
            delete[] elems;
            // 3. 分配新资源并复制成员
            allocatedSize = other.allocatedSize;
            logicalSize = other.logicalSize;
            elems = new std::string[allocatedSize];
            std::copy(other.begin(), other.end(), begin());
        }
        // 4. 返回 *this 以支持链式赋值 (a = b = c)
        return *this;
    }
};

关键点

  • 自我赋值检查if (this != &other) 防止将对象赋给自己时导致资源被提前释放。
  • 返回引用:返回 *this 的引用以支持连续赋值。
  • 异常安全:更健壮的实现可能会先分配新内存,复制成功后再释放旧内存并替换指针,以保证异常安全。


三法则与五法则

C++ 中有一个重要的设计原则:

  • 三法则:如果一个类需要显式定义拷贝构造函数、拷贝赋值运算符、析构函数中的任何一个,那么它很可能需要定义全部三个。这是因为它们通常共同管理着同一份资源(如动态内存)。
  • 五法则:随着 C++11 引入了移动语义(下节课内容),规则扩展为五法则,增加了移动构造函数和移动赋值运算符。
  • 零法则:如果一个类不需要管理任何资源,依赖编译器生成的默认特殊成员函数就足够了,这样代码更简洁安全。

禁止拷贝

有时,类的设计决定了其对象不可拷贝(例如,表示唯一文件句柄的类)。这时,可以将拷贝操作声明为 = delete

代码示例:禁止拷贝

class NonCopyable {
public:
    NonCopyable() = default;
    // 禁止拷贝
    NonCopyable(const NonCopyable&) = delete;
    NonCopyable& operator=(const NonCopyable&) = delete;
};


拷贝的代价与优化前瞻

考虑以下函数:

StringVector getAllWords() {
    StringVector words; // 局部对象
    // ... 填充 words
    return words; // 返回时触发拷贝
}
int main() {
    StringVector myWords = getAllWords(); // 再次触发拷贝(或移动)
}

在这个过程中,可能会发生多次昂贵的深拷贝。编译器会尝试进行返回值优化 (RVO)拷贝省略来避免不必要的拷贝。但我们可以做得更好吗?

一个更高效的想法是“移动”而非“拷贝”:将临时对象(如 words)的资源“窃取”过来,直接交给新对象,而无需复制大量数据。这正是下节课要讲的移动语义的核心思想。


总结

本节课中我们一起学习了C++中的特殊成员函数,重点是拷贝构造函数和拷贝赋值运算符。

  • 我们理解了编译器生成的默认拷贝操作执行浅拷贝,对于管理资源的类(如拥有动态数组)会导致问题。
  • 我们学会了如何通过实现深拷贝来正确管理资源,包括在拷贝构造函数中分配新内存并复制数据,在拷贝赋值运算符中处理自我赋值和资源释放。
  • 我们了解了三/五法则,知道何时需要自己定义这些特殊成员函数。
  • 最后,我们看到了多次深拷贝可能带来的性能开销,并引出了通过移动语义来优化资源转移的概念。

掌握这些知识是编写安全、正确且高效的C++类的基石。下节课,我们将深入探讨移动语义,学习如何让我们的类“移动”而非“拷贝”,从而进一步提升性能。

课程13:移动语义 🚀

在本节课中,我们将要学习C++中一个非常现代且强大的特性——移动语义。这个特性完美体现了C++“不牺牲效率”的核心哲学。我们将从理解左值(L-value)和右值(R-value)开始,然后学习如何实现移动构造函数和移动赋值运算符,最后利用这些知识编写一个高效的交换函数。


概述:为什么需要移动语义?

在上一节中,我们介绍了拷贝构造函数和拷贝赋值运算符,它们通过创建资源的完整副本来确保对象的独立性。然而,这种拷贝操作有时是低效的,尤其是当源对象是一个即将被销毁的临时对象时。

本节中,我们来看看如何通过“移动”而非“拷贝”来优化这种情况,从而显著提升程序性能。


左值(L-value)与右值(R-value)

为了理解移动语义,我们首先需要区分两种表达式:左值和右值。这是一种简化的理解,但足以让我们编写高效的代码。

  • 左值:一个有名称和身份的表达式。你可以使用取地址运算符 & 获取其地址。
  • 右值:一个没有名称或身份的临时表达式。你不能获取其地址。

直观理解:左值通常可以出现在赋值运算符的左侧,而右值只能出现在右侧。

以下是几个例子:

  • int val = 2; 中,val 是左值,2 是右值。
  • vector<int> v4 = v1 + v2; 中,v1 + v2 的结果是一个右值(临时向量)。
  • v[1] 通常返回一个左值引用,因此它是左值。
  • v.size() 返回一个 size_t 类型的值,这是一个右值。


左值引用与右值引用

理解了值的类别后,我们来看看引用。

  • 左值引用:使用单个 & 声明,只能绑定到左值。
    int a = 10;
    int& lref = a; // 正确:左值引用绑定到左值
    // int& bad_ref = 5; // 错误:不能将左值引用绑定到右值
    
  • 右值引用:使用双 && 声明,只能绑定到右值。它可以“延长”临时对象的生命周期。
    int&& rref = 5; // 正确:右值引用绑定到右值
    // int&& bad_rref = a; // 错误:不能将右值引用绑定到左值
    
  • 常量左值引用是一个例外,它可以绑定到右值,因为承诺了不会修改它。
    const int& cref = 5; // 正确
    

核心思想:右值是“可丢弃的”临时值。既然它马上就要消失,我们就可以安全地“窃取”它的资源,而不是进行昂贵的拷贝。这就是移动语义的基础。


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

现在,我们引入两个新的特殊成员函数,它们是拷贝操作的“高效亲戚”。

  • 移动构造函数:从一个右值(临时对象)构造新对象。
  • 移动赋值运算符:将一个右值的内容赋值给一个已存在的对象。

它们的函数签名与拷贝版本类似,但参数是右值引用

以下是 StringVector 类的移动构造函数实现示例:

// 移动构造函数
StringVector::StringVector(StringVector&& other) noexcept
    : elements(other.elements), logicalSize(other.logicalSize), allocatedSize(other.allocatedSize) {
    // “窃取” other 的资源
    other.elements = nullptr; // 关键步骤:使 other 处于有效但空的状态
    other.logicalSize = 0;
    other.allocatedSize = 0;
}

关键区别:移动操作不是分配新内存并拷贝数据,而是直接“接管”源对象(other)内部的指针等资源,然后将源对象的指针置为 nullptr,使其成为一个安全的空对象。

移动赋值运算符逻辑类似,但需要先释放当前对象持有的旧资源:

// 移动赋值运算符
StringVector& StringVector::operator=(StringVector&& rhs) noexcept {
    if (this != &rhs) { // 自赋值检查
        delete[] elements; // 释放当前资源
        // 窃取 rhs 的资源
        elements = rhs.elements;
        logicalSize = rhs.logicalSize;
        allocatedSize = rhs.allocatedSize;
        // 将 rhs 置于有效空状态
        rhs.elements = nullptr;
        rhs.logicalSize = 0;
        rhs.allocatedSize = 0;
    }
    return *this;
}

std::move 与成员变量的移动

有一个常见的陷阱:即使一个对象本身是右值引用(如 rhs),它的成员变量在函数内部仍然是左值

例如,在一个包含 std::vector 成员的类中:

class MyClass {
    std::vector<int> data;
public:
    // 移动构造函数
    MyClass(MyClass&& other) : data(other.data) { // 错误!这里调用的是拷贝构造函数!
        // ...
    }
};

other.data 是一个有名字的表达式,因此是左值,所以 data(other.data) 会调用 std::vector 的拷贝构造函数。

为了正确移动成员,我们需要使用 std::move 将其强制转换为右值:

MyClass(MyClass&& other) noexcept : data(std::move(other.data)) {
    // 现在调用的是 std::vector 的移动构造函数
}

std::move 的作用:它不做任何实际的移动操作,只是无条件地将其参数转换为右值引用,告诉编译器“这个对象可以被移动”。调用 std::move(x) 后,你应该假设 x 的内容已被移走,不再使用它(除非重新赋值)。


应用:实现高效的交换(Swap)函数

利用移动语义,我们可以编写一个通用且高效的 swap 函数,它不会产生任何不必要的拷贝。

以下是实现步骤:

  1. 首先,我们写出一个朴素的、基于拷贝的版本。
  2. 然后,我们使用 std::move 将其优化为基于移动的版本。
template <typename T>
void swap(T& a, T& b) {
    T temp = std::move(a); // 移动构造 temp
    a = std::move(b);      // 移动赋值 a
    b = std::move(temp);   // 移动赋值 b
}

过程分析

  • T temp = std::move(a);:将 a 的内容移动到新变量 temp 中,a 变空。
  • a = std::move(b);:将 b 的内容移动到 a 中,b 变空。
  • b = std::move(temp);:将 temp(原 a 的内容)移动到 b 中。
    整个过程中,资源只发生了三次“所有权转移”,没有任何深拷贝,效率极高。


五大法则(Rule of Five)

之前我们学过“三大法则”:如果一个类需要自定义析构函数、拷贝构造函数或拷贝赋值运算符中的任何一个,那么它很可能需要全部三个。

现在,加上移动操作,它扩展为“五大法则”:如果你声明了拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符或析构函数中的任何一个,你应该仔细考虑是否需要声明全部五个,以确保资源管理的正确性。


总结

本节课中我们一起学习了C++移动语义的核心内容:

  1. 左值与右值:区分了有身份的持久对象和可丢弃的临时对象。
  2. 右值引用:通过 && 语法,允许我们绑定并延长临时对象的生命周期。
  3. 移动语义:通过移动构造函数和移动赋值运算符,我们可以“窃取”临时对象的资源,避免不必要的拷贝,大幅提升性能。
  4. std::move:用于将左值强制转换为右值,指示编译器可以对其进行移动操作。
  5. 高效交换:利用移动语义,可以实现零拷贝的通用 swap 函数。
  6. 五大法则:管理资源时,需要协调好五个特殊成员函数。

移动语义是现代C++高效编程的基石,它使得在返回大型对象、容器操作等场景下,代码既能保持清晰安全,又能拥有接近手写C语言的效率。

课程14:命名空间与继承 🧩

在本节课中,我们将学习C++中的两个重要概念:命名空间继承。命名空间帮助我们组织代码并避免命名冲突,而继承则是面向对象编程的核心,允许我们创建类之间的层次关系。我们将通过简单的例子来理解它们的基本用法和核心思想。


课程回顾与公告

上一节我们深入探讨了移动语义和特殊成员函数。本节开始前,我们先回顾一下课程进度并发布一些公告。

课程进度:我们已经涵盖了C++的基础知识、标准模板库、特殊成员函数和移动语义。今天是关于继承的四节课中的第一讲。

重要公告

  1. 作业2将于本周四截止,请合理使用迟交天数。
  2. 作业1成绩即将发布。
  3. 作业3将于本周四发布。
  4. 请关注后续关于期末主题的投票调查。

运算符重载参考

在深入新内容之前,我们提供一个有用的资源。关于运算符重载(包括拷贝和移动赋值运算符),cppreference.com 是一个极佳的参考网站。

以下是该网站提供的一些关键指导:

  • 自我赋值检查:在赋值运算符中,应检查 this 是否不等于 &other,以避免自我赋值。
  • 基于其他运算符定义:许多运算符(如关系运算符)可以基于其他运算符(如 ==<)来定义,这是一种良好的编程风格。
  • 模板示例:该网站提供了运算符重载应满足的假设性模板,有助于编写与C++标准库良好协作的代码。


移动语义的重要性

有人提出了一个很好的问题:既然有了复制语义,为什么还需要移动语义?

移动语义的核心优势在于效率。它通过将资源从一个对象“移动”到另一个对象,避免了不必要的深层拷贝和额外的内存分配。这不仅在内存受限的嵌入式系统中至关重要,在普通程序中也意义重大。

例如,在C++11引入移动语义后,许多程序仅通过重新编译就能获得显著的性能提升,因为标准库类型都实现了移动构造函数和移动赋值运算符,使得许多原有的拷贝操作自动升级为更高效的移动操作。


const 关键字挑战回顾

现在,我们来回顾一个关于 const 关键字位置的挑战。理解每个 const 的含义对于编写正确的代码至关重要。

考虑以下函数声明:

const int* const& Function(const int* const& param) const;

以下是每个 const 关键字的含义:

  1. 第一个 const(在 int 前):指针指向的 int 是常量,不可修改。
  2. 第二个 const(在 * 后):指针本身是常量,其指向的地址不可改变。
  3. 函数末尾的 const:这是一个常量成员函数,意味着该函数不能修改调用它的对象实例的任何成员变量。

这些 const 分别约束了返回值、参数和成员函数的行为,是基于不同需求所做的不同选择。


命名空间

上一节我们回顾了 const 的复杂用法。本节中,我们来看看如何用命名空间来管理代码中的名称。

为什么需要命名空间?

命名空间的主要目的是避免命名冲突。C++标准库使用了大量常见的名称(如 vector, count, max)。如果我们自己编写的类或函数也使用了这些名称,就需要一种机制来区分它们。

命名冲突示例

以下代码演示了一个潜在的冲突:

#include <vector>
#include <algorithm>
using namespace std;

int main() {
    vector<int> v = {1, 2, 1, 3};
    int count = 0; // 局部变量 count
    // ... 手动计数逻辑 ...
    // 稍后想使用标准库的 std::count
    auto result = count(v.begin(), v.end(), 1); // 编译错误!
}

编译器会将 count 解释为局部变量 int count,并尝试对其使用函数调用运算符 (),从而导致错误。

解决方案:即使使用了 using namespace std;,在存在冲突时也应明确使用作用域解析运算符 ::

auto result = std::count(v.begin(), v.end(), 1); // 正确

这也说明了为什么我们更推荐使用 using std::vector; 等具体声明,而非 using namespace std;

定义自己的命名空间

我们可以创建自己的命名空间来组织代码:

namespace lecture {
    size_t count(const std::vector<int>& vec) {
        return vec.size();
    }
}

// 使用自定义命名空间中的函数
size_t c = lecture::count(my_vector);

命名空间可以嵌套,并帮助将代码逻辑分组。

命名空间与头文件

一个重要的风格建议是:避免在头文件(.h)中使用 using namespace。因为这会将命名空间强加给所有包含该头文件的代码。在源文件(.cpp)中酌情使用是更可取的。


继承

了解了如何用命名空间组织代码后,我们进入面向对象编程的另一个基石——继承

继承要解决什么问题?

假设我们有两个类 FileStreamStringStream,它们都需要一个相似的 print 函数。我们不想在两个类中重复编写相同的代码。

一种解决方案是使用模板,它通过隐式接口实现代码复用。继承提供了另一种解决方案,通过显式接口建立类之间的层次关系。

接口与纯虚函数

在C++中,通过纯虚函数来定义接口(类似于Java中的interface)。

class Drink { // 作为“接口”的基类
public:
    virtual void make() = 0; // 纯虚函数
};
  • virtual 关键字表示这是一个虚函数。
  • = 0 使其成为纯虚函数,意味着任何继承自 Drink 的类必须实现 make() 方法。
  • 包含纯虚函数的类称为抽象类,它不能被实例化。

实现接口

类通过继承来实现接口:

class Tea : public Drink { // 公有继承
public:
    void make() override { // 实现纯虚函数
        std::cout << "Steeping the tea.\n";
    }
};

class Coffee : public Drink {
public:
    void make() override {
        std::cout << "Brewing the coffee.\n";
    }
};

现在,TeaCoffee 都是可以实例化的具体类,它们共享 Drink 接口所规定的 make() 行为。

继承中的访问控制

继承时使用的访问说明符(public, protected, private)决定了基类成员在派生类中的访问权限:

  • class Tea : public Drink:基类 Drink 中的 public 成员在 Tea 中仍是 publicprotected 成员仍是 protected
  • 默认情况下(对于 class 关键字),继承是 private 的;而对于 struct,默认是 public 继承。

成员隐藏

在继承体系中,如果派生类定义了一个与基类同名的成员(即使是不同类型),它会隐藏基类中的那个成员。

struct A { int value; };
struct B : A { double value; }; // 隐藏了 A::value
B b;
b.value = 5.5; // 访问的是 B::value
// int 类型的 A::value 被隐藏了

总结

本节课我们一起学习了两个关键概念:

  1. 命名空间:用于组织代码和避免名称冲突的工具。我们应谨慎使用 using namespace,优先使用作用域解析运算符 :: 或具体的 using 声明。
  2. 继承:面向对象编程的核心概念,允许类从基类继承接口和实现。我们重点介绍了:
    • 通过纯虚函数定义接口。
    • 使用 class Derived : public Base 语法实现继承。
    • 理解抽象类与具体类的区别。

继承为我们提供了一种强大的代码复用和抽象建模方式。在下节课中,我们将探讨另一种复用代码的机制——模板,并比较它与继承的适用场景。

课程15:继承与模板类 🧬🔧

在本节课中,我们将要学习C++中两个核心的高级特性:继承和模板类。我们将首先完成对继承的讨论,包括纯虚函数、抽象类以及构造函数和析构函数在继承中的使用。然后,我们将进入模板的世界,学习如何将类模板化以创建通用的数据结构,并简要了解C++20中的“概念”。


概述

上一节我们介绍了虚函数和抽象类的基本概念。本节中,我们来看看继承的更多细节,并学习如何将模板应用于类。

纯虚函数与非纯虚函数的区别

纯虚函数与非纯虚函数在语法和功能上都有区别。

以下是它们的主要区别:

  • 语法区别:纯虚函数在声明末尾添加 = 0,例如 virtual void func() = 0;。非纯虚函数则没有这个标记。
  • 功能区别:任何继承包含纯虚函数的类(抽象类)的派生类,都必须实现该纯虚函数。非纯虚函数在基类中提供了一个通用实现,派生类可以选择重写它,但不是必须的。

抽象类与重写规则

抽象类是包含至少一个纯虚函数的类。它不能被直接实例化。

抽象类可以拥有普通类的所有成员,如变量和非虚函数。关于重写,有一个重要的规则:重写一个非虚函数在语法上是合法的,但这是不推荐的做法。如果你希望一个函数能被派生类重写,应该将其声明为 virtual

继承中的关键术语与最佳实践

在讨论继承时,会用到一些特定术语。

以下是需要了解的关键术语:

  • 基类:被继承的类,也称为父类或超类。
  • 派生类:继承自基类的类。

当设计自己的继承体系时,有几项最佳实践需要注意。

以下是关于构造函数和析构函数的实践建议:

  • 构造函数:在派生类的构造函数中,应通过初始化列表调用基类的构造函数,以正确初始化继承来的成员。
  • 析构函数:如果一个类打算被继承,应将其析构函数声明为虚函数(virtual ~ClassName())。这可以确保通过基类指针删除派生类对象时,能正确调用派生类的析构函数,避免内存泄漏。

访问控制:private, protected, public

成员访问说明符决定了类成员的可见性。

以下是三种访问说明符的区别:

  • private:仅能被该类自身的成员函数访问。
  • protected:能被该类及其任何派生类的成员函数访问。
  • public:能被任何代码访问。

继承语法示例

让我们通过一个简单的例子来看看如何定义和使用继承。

// 基类:Drink
class Drink {
public:
    Drink() = default;
    Drink(const std::string& flavor) : _flavor(flavor) {}
    virtual ~Drink() = default; // 虚析构函数

    // 纯虚函数,派生类必须实现
    virtual void make() const = 0;

private:
    std::string _flavor;
};

// 派生类:Tea
class Tea : public Drink {
public:
    // 调用基类构造函数来初始化继承来的部分
    Tea(const std::string& type) : Drink(type) {}

    // 实现基类的纯虚函数
    void make() const override {
        std::cout << "Making tea from the Tea class." << std::endl;
    }
};

int main() {
    Tea t("Green");
    t.make(); // 输出: Making tea from the Tea class.
    // Drink d; // 错误!Drink是抽象类,不能实例化
}

模板与继承:静态多态与动态多态

我们之前用模板解决了为不同容器编写相同逻辑函数的问题。继承提供了另一种解决方案。这两者代表了两种不同的“多态”方式。

  • 模板(静态多态):在编译时,编译器会为每种使用的类型生成一份独立的代码。这可能导致“代码膨胀”,但运行时效率高。
  • 继承(动态多态):通过虚函数表在运行时决定调用哪个函数。只有一个函数体,但运行时有一次间接寻址的开销。它更节省代码空间,并能在运行时处理未知的具体类型。

选择哪种方式取决于你的需求:追求极致运行时性能可考虑模板;希望代码更紧凑或需要运行时类型灵活性时可考虑继承。

类模板

就像我们可以创建函数模板一样,我们也可以创建类模板。这使得类能够处理多种数据类型。

假设我们有一个只处理 int 类型的简单 PriorityQueue 类。我们可以通过以下步骤将其模板化:

  1. 在类定义前添加模板声明:template <typename T>
  2. 将类内部代码中特定的 int 类型替换为模板参数 T

// 模板化前的类
class PriorityQueueInt {
    std::vector<int> heap;
    // ... 成员函数操作 int ...
};

// 模板化后的类
template <typename T>
class PriorityQueue {
    std::vector<T> heap;
public:
    void push(const T& value);
    T top() const;
    // ... 其他成员函数 ...
};

// 使用
int main() {
    PriorityQueue<int> intPQ;
    PriorityQueue<std::string> stringPQ;
}

C++20 概念简介

在早期C++中,模板对类型的要求是隐式的,这可能导致难以理解的错误信息。C++20引入了“概念”来定义这些要求的显式约束。

概念是一组命名的约束,可以在模板声明中使用 requires 子句来指定。它使得编译器能提供更清晰的错误信息。

// 一个概念示例:要求类型T可递增且可比较
template<typename T>
concept IncrementableAndComparable = requires(T a) {
    { ++a } -> std::same_as<T&>; // 可前缀递增
    { a++ } -> std::same_as<T>;  // 可后缀递增
    { a < a } -> std::convertible_to<bool>; // 可比较
};

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/a4b980998b6225eedb4acbba68ffddf3_26.png)

// 使用概念约束模板函数
template <IncrementableAndComparable T>
T findMax(T a, T b) {
    return (a < b) ? b : a;
}

总结

本节课中我们一起学习了C++继承的收尾知识,包括纯虚函数、抽象类以及构造/析构函数在继承中的正确用法。接着,我们探索了如何将类模板化,从而创建出像 PriorityQueue<T> 这样的通用容器。最后,我们预览了C++20中“概念”这一特性,它能让模板的类型要求更加清晰和安全。掌握这些特性将帮助你构建更灵活、更强大的C++程序。

课程16:RAII与智能指针 🧠

在本节课中,我们将学习C++中一个至关重要的编程范式——RAII(资源获取即初始化),以及它的重要应用:智能指针。我们将探讨如何利用这一技术来编写异常安全、无内存泄漏的健壮代码。

概述:代码路径与资源泄漏

上一节我们讨论了函数的基本控制流。本节中,我们来看看一个看似简单的函数背后隐藏的复杂性。

考虑以下函数:

void exampleFunction(Employee e) {
    if (e.title == "Manager" || e.salary > 100000) {
        std::cout << e.first + " " + e.last << std::endl;
    }
    return;
}

一个关键问题是:这个函数有多少条可能的代码路径(即控制流进入和退出的不同方式)?

除了if条件判断带来的3条明显路径外,代码中许多操作都可能抛出异常(例如,拷贝构造函数、运算符重载、流操作等),这至少会额外产生20条异常退出路径。因此,该函数至少有23条代码路径。

这之所以重要,是因为在涉及资源管理的代码中,异常可能导致资源无法被释放。

资源泄漏问题

假设我们有以下分配堆内存的代码:

void processEmployee() {
    Employee* e = new Employee("John", "Doe", 80000);
    // ... 一些可能抛出异常的操作 ...
    delete e; // 释放内存
}

对于正常的3条路径,delete会被执行,内存得以释放。但对于其他20条因异常而退出的路径,程序会直接跳转到异常处理部分,delete语句将被跳过,从而导致内存泄漏

内存泄漏是指程序在堆上分配了内存,但在使用完毕后未能将其释放回操作系统,导致该内存无法被再次使用。长期运行的程序(如服务器)若存在内存泄漏,最终可能耗尽所有可用内存。

以下是内存泄漏不好的几个原因:

  • 可能导致安全漏洞。
  • 在长时间运行或高频调用的程序中,会逐渐耗尽系统内存(RAM)。

在C++中,除了堆内存,还有其他需要手动管理生命周期的资源,例如:

  • 文件:使用open()获取,需要使用close()释放。
  • 锁(Mutex):使用lock()获取,需要使用unlock()释放。
  • 网络套接字

RAII:资源获取即初始化

为了解决资源泄漏问题,C++引入了RAII(Resource Acquisition Is Initialization)惯用法。其核心思想是:将资源的生命周期与对象的生命周期绑定

  • 资源获取(初始化):在对象的构造函数中获取资源(分配内存、打开文件、加锁)。
  • 资源释放:在对象的析构函数中释放资源(释放内存、关闭文件、解锁)。

由于C++保证,当对象离开其作用域时,其析构函数一定会被调用(无论是因为正常返回还是异常抛出),因此资源总能被正确释放。

RAII有时也被更直观地称为CADRe(Constructor Acquires, Destructor Releases)。

RAII实践示例

1. 文件流 (std::ifstream)
不符合RAII的旧式写法:

std::ifstream infile;
infile.open("data.txt"); // 资源获取不在构造函数中
// ... 读取文件 ...
infile.close();          // 资源释放不在析构函数中

符合RAII的现代写法:

{ // 进入一个作用域
    std::ifstream infile("data.txt"); // 资源在构造函数中获取
    // ... 读取文件 ...
} // 离开作用域,infile的析构函数被调用,自动关闭文件

std::ifstream的析构函数会自动调用close()。显式调用close()不是必须的,有时甚至是多余的。

2. 互斥锁与锁守卫 (std::lock_guard)
直接操作锁不符合RAII:

std::mutex mtx;
mtx.lock();   // 获取资源
// ... 访问受保护的数据 ...
mtx.unlock(); // 释放资源 (可能被异常跳过!)

使用锁守卫符合RAII:

std::mutex mtx;
{
    std::lock_guard<std::mutex> guard(mtx); // 构造函数中调用 mtx.lock()
    // ... 访问受保护的数据 ...
} // 离开作用域,guard的析构函数被调用,自动调用 mtx.unlock()

std::lock_guard是一个RAII包装器,它在其构造函数中加锁,在析构函数中解锁,确保锁在任何情况下都会被释放。

智能指针:RAII管理动态内存

将RAII思想应用于堆内存管理,就产生了智能指针。它们封装了原始指针,并在析构函数中自动调用delete

C++标准库提供了几种智能指针,我们将重点介绍两种最常用的。

独占指针:std::unique_ptr

std::unique_ptr独占其所指向对象的所有权。同一时间只能有一个unique_ptr指向一个给定对象。当unique_ptr被销毁时,它所指向的对象也会被自动销毁。

关键特性:不可复制
unique_ptr删除了拷贝构造函数和拷贝赋值运算符,防止被意外复制,从而避免多个指针管理同一内存导致的“双重释放”问题。它只能通过移动语义转移所有权。

std::unique_ptr<Employee> e = std::make_unique<Employee>("Alice", "Smith", 90000);
// std::unique_ptr<Employee> e2 = e; // 错误!禁止拷贝
std::unique_ptr<Employee> e2 = std::move(e); // 正确:所有权转移,e变为nullptr

共享指针:std::shared_ptr

std::shared_ptr允许多个指针共享同一个对象的所有权。它使用引用计数来跟踪有多少个shared_ptr指向同一对象。当最后一个指向该对象的shared_ptr被销毁时,对象才会被自动销毁。

实现机制
每个shared_ptr关联一个控制块,其中包含引用计数器。

  • 构造或拷贝赋值时,引用计数加1。
  • 析构或重置时,引用计数减1。
  • 引用计数变为0时,销毁并释放对象内存。
{
    std::shared_ptr<int> p1 = std::make_shared<int>(42); // 引用计数 = 1
    {
        std::shared_ptr<int> p2 = p1; // 拷贝构造,引用计数 = 2
        // 使用 p1 和 p2
    } // p2 离开作用域被销毁,引用计数 = 1
} // p1 离开作用域被销毁,引用计数 = 0,内存被释放

使用智能指针重写示例

使用std::unique_ptr修复最初的内存泄漏问题:

void processEmployee() {
    std::unique_ptr<Employee> e = std::make_unique<Employee>("John", "Doe", 80000");
    // ... 一些可能抛出异常的操作 ...
    // 无需手动 delete!当 e 离开作用域时,无论是否发生异常,内存都会自动释放。
}

现代C++内存管理准则

现代C++编程风格强烈建议:

  • 避免直接使用newdelete
  • 优先使用智能指针(unique_ptr, shared_ptr)来管理动态内存。
  • 让析构函数、容器和智能指针来自动处理资源的释放。

这与一些带垃圾回收机制的语言(如Java、Python)不同。RAII提供了确定性析构,你能准确知道资源何时被释放,从而使资源管理更可预测、更高效。

总结

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

  1. RAII(资源获取即初始化) 是C++的核心惯用法,通过将资源生命周期绑定到对象生命周期,确保资源自动、正确地释放。
  2. 智能指针std::unique_ptrstd::shared_ptr)是RAII用于动态内存管理的具体实现。
    • unique_ptr用于独占所有权,轻量且高效。
    • shared_ptr用于共享所有权,通过引用计数管理生命周期。
  3. 采用RAII和智能指针可以编写出异常安全的代码,从根本上避免资源泄漏(如内存泄漏、文件未关闭、锁未释放)。
  4. 现代C++最佳实践是优先使用智能指针和容器,而非裸指针和显式的new/delete

掌握RAII是编写健壮、可靠C++程序的关键一步。

斯坦福大学《CS106L:C++编程》课程笔记 - P18:多线程编程 🧵

在本节课中,我们将要学习C++中的多线程编程。多线程允许程序同时执行多个任务,这对于提高程序效率、处理I/O等待等场景至关重要。我们将从基本概念入手,通过代码示例理解线程的创建、同步以及如何避免常见问题。


概述:为什么需要多线程? 🤔

在开始之前,我们先思考一个问题:如果计算机只有一个CPU,为什么还需要多线程?答案在于等待。程序经常需要等待某些操作完成,例如从磁盘读取文件、等待网络响应或等待用户输入。在这些等待期间,CPU可以转而执行其他任务,而不是空闲。多线程使得一个线程在等待时,其他线程可以继续工作,从而更有效地利用计算资源。

上一节我们介绍了多线程的基本动机,本节中我们来看看C++标准库中提供的多线程工具。


C++标准库中的多线程支持 📚

C++标准库在 <thread> 头文件中提供了对多线程的核心支持。此外,还有其他相关头文件用于处理同步和原子操作。

以下是标准库中与多线程相关的主要部分:

  • <atomic>:提供了原子类型(如 std::atomic<int>)和原子操作。它们保证了针对单个变量的操作是不可分割的,从而避免数据竞争。
  • <thread>:包含 std::thread 类,用于创建和管理线程。
  • <mutex>:提供了互斥锁(如 std::mutex)以及锁包装器(如 std::lock_guardstd::unique_lock),用于保护共享数据。
  • <condition_variable>:提供了线程间通信的机制,允许线程等待特定条件成立或向其他线程发出信号。
  • <future>:支持异步操作,类似于其他语言中的 async/await 模式。

接下来,我们将重点学习如何使用 std::thread 和锁来编写多线程程序。


创建与运行线程 🚀

创建一个线程非常简单:你需要提供一个函数(或可调用对象)作为线程的“工作”,然后启动它。

#include <iostream>
#include <thread>

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/64b1bb3e614ededb837725c35b26ce80_19.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/64b1bb3e614ededb837725c35b26ce80_21.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/64b1bb3e614ededb837725c35b26ce80_23.png)

void greet(int id) {
    std::cout << "Hello, my name is " << id << std::endl;
}

int main() {
    // 创建两个线程,分别执行greet函数,并传入参数1和2
    std::thread thread1(greet, 1);
    std::thread thread2(greet, 2);

    // 等待两个线程执行完毕
    thread1.join();
    thread2.join();

    std::cout << "All greetings done!" << std::endl;
    return 0;
}

代码解释

  1. std::thread thread1(greet, 1); 这行代码创建了一个名为 thread1 的线程。它的工作是执行 greet 函数,并传入参数 1线程在创建后会立即开始尝试执行其任务
  2. thread1.join(); 这行代码告诉主线程(main 函数)等待 thread1 完成其工作。如果没有 join,主线程可能会在子线程结束前就退出,导致程序异常终止。

数据竞争与锁的保护 🛡️

多线程编程的核心挑战之一是数据竞争。当多个线程同时访问和修改同一份共享数据,且访问顺序不确定时,就会发生数据竞争,导致程序结果不可预测。

考虑以下看似简单的输出语句:

std::cout << "Hello, my name is " << id << std::endl;

实际上,这行代码包含了多个操作(插入 "Hello, my name is ",插入 id,插入 std::endl),它们可能被其他线程的操作打断,导致输出内容交错混乱。

为了解决这个问题,我们需要使用互斥锁(Mutex)来确保某一时刻只有一个线程能执行受保护的代码块。为了使锁的管理符合RAII(资源获取即初始化)原则,避免忘记解锁,我们使用 std::lock_guard

#include <iostream>
#include <thread>
#include <mutex>

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/64b1bb3e614ededb837725c35b26ce80_40.png)

std::mutex cout_mutex; // 创建一个全局互斥锁

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/64b1bb3e614ededb837725c35b26ce80_42.png)

void safe_greet(int id) {
    std::lock_guard<std::mutex> lock(cout_mutex); // 构造时加锁
    std::cout << "Hello, my name is " << id << std::endl;
    // lock_guard析构时自动解锁
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/64b1bb3e614ededb837725c35b26ce80_44.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/64b1bb3e614ededb837725c35b26ce80_46.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/64b1bb3e614ededb837725c35b26ce80_48.png)

int main() {
    std::thread t1(safe_greet, 1);
    std::thread t2(safe_greet, 2);

    t1.join();
    t2.join();
    return 0;
}

现在,safe_greet 函数中的输出语句成为了一个临界区,保证了输出的完整性。


管理多个线程 📝

我们通常需要创建和管理多个线程。使用容器(如 std::vector)可以方便地做到这一点。

以下是创建并等待多个线程完成的示例:

#include <iostream>
#include <thread>
#include <vector>
#include <mutex>

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/64b1bb3e614ededb837725c35b26ce80_60.png)

std::mutex mtx;
const int kNumThreads = 10;

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/64b1bb3e614ededb837725c35b26ce80_62.png)

void worker(int id) {
    std::lock_guard<std::mutex> lock(mtx);
    std::cout << "Thread " << id << " is working." << std::endl;
}

int main() {
    std::vector<std::thread> threads;

    // 启动所有线程
    for (int i = 0; i < kNumThreads; ++i) {
        // 将线程对象放入向量。注意:std::thread不可复制,必须使用std::move
        threads.emplace_back(worker, i);
    }

    // 等待所有线程完成
    for (std::thread& t : threads) {
        t.join(); // 必须通过引用访问vector中的线程对象
    }

    std::cout << "All threads finished." << std::endl;
    return 0;
}

关键点

  • threads.emplace_back(worker, i); 直接在向量末尾构造一个 std::thread 对象,避免了拷贝操作(因为 std::thread 不可拷贝)。
  • 必须使用引用 for (std::thread& t : threads) 来遍历线程向量,以便调用 join() 方法。
  • 先启动所有线程,再统一等待它们结束,这样才能实现真正的并行。如果启动一个线程后立即 join,就会变成串行执行。

原子操作 ⚛️

对于简单的共享变量操作,除了使用锁,还可以使用原子类型。原子操作保证该操作从任何线程的角度看都是瞬间完成的,不会被中断。

#include <iostream>
#include <thread>
#include <atomic>

std::atomic<int> counter(0); // 原子整数

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/64b1bb3e614ededb837725c35b26ce80_68.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/64b1bb3e614ededb837725c35b26ce80_70.png)

void increment() {
    for (int i = 0; i < 1000; ++i) {
        counter.fetch_add(1); // 原子加法
        // 等价于 counter++
    }
}

int main() {
    std::thread t1(increment);
    std::thread t2(increment);

    t1.join();
    t2.join();

    std::cout << "Counter = " << counter << std::endl; // 总是2000
    return 0;
}

使用 std::atomic 通常比使用锁的性能开销更小,但它只适用于对单个变量的操作。对于复杂的、涉及多个变量的逻辑,仍然需要锁来保护。


总结与后续学习路径 🎓

本节课中我们一起学习了C++多线程编程的基础:

  1. 动机:多线程能有效利用CPU资源,尤其在存在I/O等待的场景下。
  2. 线程创建:使用 std::thread 类,并传递要执行的函数及参数。
  3. 线程同步:必须使用 join() 等待子线程结束,防止主线程提前退出。
  4. 数据竞争:多个线程无序访问共享数据会导致未定义行为。
  5. :使用 std::mutexstd::lock_guard(RAII包装器)保护临界区,确保线程安全。
  6. 原子操作:使用 std::atomic 类型对简单变量进行线程安全的操作。
  7. 管理线程:使用容器(如 std::vector)来管理多个线程对象。

多线程编程是一个复杂但强大的主题。要深入掌握,建议:

  • 多实践:在个人项目或工作中尝试使用多线程。
  • 阅读经典书籍:如《Effective Modern C++》。
  • 关注专家博客:如C++标准委员会成员Herb Sutter的博客。
  • 学习系统课程:如CS110,深入了解操作系统层面的线程调度、同步原语等。

恭喜你完成了CS106L课程的核心内容学习!希望这些知识能为你的编程之旅打下坚实的基础。

课程19:模板元编程入门 🧠

在本节课中,我们将学习模板元编程(Template Metaprogramming, TMP)的核心概念。这是一种在C++编译时对类型而非进行计算的高级技术。我们将从理解其动机开始,逐步学习元函数、模板特化等概念,并最终实现一个能根据迭代器类型进行优化的distance函数。

概述:从值计算到类型计算

我们通常编写的程序是在上进行计算。例如,处理数字、字符串等数据,并返回处理后的结果。然而,模板元编程改变了这个模型,它允许我们在类型上进行计算。这意味着我们可以操作intdouble、引用、const等类型本身,并根据类型信息在编译时做出决策和生成不同的代码。

上一节我们介绍了模板元编程的基本思想,本节中我们来看看如何具体实现这种“类型计算”。

元函数:操作类型的“函数”

元函数是模板元编程的核心构建块。它类似于普通函数,但输入和输出可以是类型编译时常量值。关键的是,元函数在C++中通常通过一个结构体(struct)来实现。

身份元函数示例

让我们从一个简单的元函数开始:identity。它的作用是接收一个输入(类型或值),然后原封不动地返回它。虽然它不做任何实际工作,但能帮助我们理解元函数的结构和用法。

以下是identity元函数的实现,分别针对类型和值:

// 处理类型的元函数
template <typename T>
struct identity {
    using type = T; // 输出类型
};

// 处理值的元函数
template <auto V>
struct identity_value {
    static constexpr auto value = V; // 输出值
};

如何使用元函数:

  • 对于类型元函数,我们通过 typename identity<T>::type 来获取结果类型。
  • 对于值元函数,我们通过 identity_value<V>::value 来获取结果值。

例如:

using MyType = typename identity<int>::type; // MyType 就是 int
constexpr auto myVal = identity_value<42>::value; // myVal 就是 42

请注意,我们从不实例化这些结构体对象。我们直接访问结构体内部的静态成员(typevalue)。按照惯例,返回类型的元函数使用type作为成员别名,返回值的元函数使用value作为静态成员。

实现更复杂的元函数:is_same

仅仅返回输入还不够,我们经常需要判断类型之间的关系。is_same元函数用于判断两个类型是否完全相同,它返回一个布尔值(truefalse)。

实现is_same的关键在于利用模板特化技术。

模板特化简介

模板特化允许我们为模板类或函数提供特定类型或条件下的特殊实现。编译器会尝试匹配最特化的版本。

以下是一个模板特化的简单示例:

// 通用模板
template <typename T>
struct MyStruct {
    static constexpr char* value = "generic";
};

// 针对 int 类型的特化版本
template <>
struct MyStruct<int> {
    static constexpr char* value = "int";
};

// 使用
std::cout << MyStruct<double>::value; // 输出 "generic"
std::cout << MyStruct<int>::value;    // 输出 "int"

实现 is_same

我们可以利用特化来实现is_same:当两个类型相同时,匹配特化版本并返回true;否则,匹配通用版本并返回false

// 通用版本:默认两个类型不同,值为 false
template <typename T, typename U>
struct is_same {
    static constexpr bool value = false;
};

// 特化版本:当两个类型 T 和 U 完全相同时,匹配此版本,值为 true
template <typename T>
struct is_same<T, T> { // 注意这里的模板参数列表是 <T, T>
    static constexpr bool value = true;
};

// 使用示例
bool test1 = is_same<int, double>::value; // false
bool test2 = is_same<int, int>::value;    // true

工作原理:

  1. 当调用is_same<int, double>时,编译器首先尝试匹配特化版本is_same<T, T>。它需要让第一个Tint,第二个Tdouble,这失败了。因此编译器回退到通用版本,其valuefalse
  2. 当调用is_same<int, int>时,编译器匹配特化版本成功(T被推导为int),因此其valuetrue

这种“尝试特化,失败则回退通用”的机制,本质上在编译时实现了一个if-else分支逻辑。

综合应用:实现智能的 distance 函数

现在,让我们回到课程开始的动机示例:实现一个高效的distance函数,它能根据迭代器类型选择最优算法。

目标与挑战

我们的目标是:

  • 对于支持随机访问的迭代器(如vectordeque的迭代器),使用 last - first 的O(1)算法。
  • 对于其他迭代器(如setlist的迭代器),使用 while (first != last) 循环递增的O(n)算法。

直接编写代码会遇到问题:last - first这行代码对于非随机访问迭代器无法编译,即使它所在的if分支永远不会被执行。

解决方案:if constexpr 与类型萃取

C++17引入了if constexpr,它能在编译时判断条件,并只将符合条件的分支代码包含到最终程序中。这解决了“无效代码导致编译错误”的问题。

我们需要在条件中判断迭代器类型。C++标准库提供了std::iterator_traits这个元函数来获取迭代器的各种属性,其中就包括迭代器类别。

以下是最终的my_distance实现:

template <typename Iter>
size_t my_distance(Iter first, Iter last) {
    // 使用 iterator_traits 获取迭代器类别
    using category = typename std::iterator_traits<Iter>::iterator_category;

    // 编译时判断:是否是随机访问迭代器?
    if constexpr (std::is_same_v<category,
                                 std::random_access_iterator_tag>) {
        // 此分支仅当迭代器为随机访问时才会被编译
        return last - first; // O(1) 算法
    } else {
        // 此分支用于其他所有迭代器类型
        size_t result = 0;
        while (first != last) {
            ++first;
            ++result;
        }
        return result; // O(n) 算法
    }
}

代码解析:

  1. std::iterator_traits<Iter>::iterator_category 是一个类型,表示迭代器的类别(如随机访问、双向、前向等)。
  2. std::is_same_v是C++17提供的is_same元函数的便捷别名(_v代表value),直接返回布尔值。
  3. if constexpr在编译时计算条件。如果迭代器是随机访问的,则else分支的代码根本不会进入编译阶段;反之亦然。这确保了代码总能通过编译。

通过结合类型萃取(iterator_traits类型判断(is_same编译时分支(if constexpr,我们成功实现了一个既通用又高效的函数。这正是模板元编程在标准库和许多高性能库中广泛应用的一个缩影。

总结

本节课中我们一起学习了模板元编程的基础知识:

  1. 核心理念:将计算从运行时的值转移到编译时的类型上。
  2. 核心工具元函数,通常实现为包含typevalue成员的类模板,用于操作和返回类型/编译时常量。
  3. 关键技巧模板特化,通过为特定模式提供特殊实现,在编译时实现条件逻辑(如is_same)。
  4. 现代应用:结合类型萃取(如iterator_traits)和 if constexpr,可以根据类型信息在编译期选择不同的代码路径,编写出既通用又高度优化的代码。

模板元编程是C++中强大而复杂的特性,它使得库作者能够构建极其灵活和高效的抽象。虽然日常编程中可能不会直接编写复杂的TMP代码,但理解其原理对于读懂高级库的代码、分析模板编译错误至关重要。

斯坦福大学《CS106L:C++编程》课程笔记 - 第2讲:流 (Streams) 📚

在本节课中,我们将要学习C++中一个非常重要的概念:流 (Streams)。流是程序与外部世界(如控制台、文件、网络等)进行数据交互的统一接口。我们将从高层次概述开始,然后深入探讨字符串流、状态位以及输入/输出流,最后简要了解流操作符。


概述:为什么需要流? 🤔

我们通常希望程序能与各种外部设备交互,例如键盘、控制台、文件、网络等。流提供了一个统一的接口来处理所有这些交互。无论你是向屏幕打印内容、从文件读取数据,还是通过网络发送信息,都可以使用相似的操作方式。

例如,向控制台输出内容使用 cout,其操作符是 <<。这个统一的接口抽象了底层复杂的读写过程,让我们只需关注数据的转换和传输。


C++字符串快速入门 🚀

在深入流之前,我们先快速回顾一下C++中的字符串,因为流经常处理字符串数据。

在C++中,我们使用 std::string 类型来表示字符串。

std::string str = "hello world";

访问和修改字符:你可以像访问数组一样访问和修改字符串中的单个字符。C++字符串是零索引的。

std::cout << str[1]; // 输出 'e'
str[1] = 'i'; // 将字符串修改为 "hillo world"

重要区别:C++中有两种字符串:C风格字符串(char*char[])和C++字符串(std::string)。在C++编程中,应优先使用 std::string,因为它更安全、功能更强大。


流的高层次概念 🌊

流的核心理念是将程序与外部设备的交互抽象为对一个字符缓冲区的读写操作。

当你向流写入数据(例如 cout << 3.14)时:

  1. 数据(如双精度浮点数 3.14)被转换为字符串形式。
  2. 字符串被放入流的内部缓冲区。
  3. 流负责将缓冲区的内容传输到目标设备(如控制台)。

当你从流读取数据时,过程相反。流会从缓冲区读取字符,并将其转换为你指定的类型(如整数、浮点数)。

这种抽象让我们无需关心数据如何具体地显示到屏幕或从文件读取,只需关注与缓冲区的交互。


字符串流 (String Streams) 🔤

字符串流是最简单的流类型,它不与任何外部设备关联,只用于在内存中进行字符串的转换和格式化。它完美地展示了流的“转换”功能。

主要有两种字符串流:

  • std::ostringstream:用于输出(构建字符串)。
  • std::istringstream:用于输入(解析字符串)。

输出字符串流 (ostringstream)

输出字符串流允许你将各种类型的数据“写入”到一个字符串缓冲区中。

以下是使用 ostringstream 的基本步骤:

首先,你需要包含头文件并声明一个 ostringstream 对象。

#include <sstream> // 字符串流所需的头文件
std::ostringstream oss;

接着,你可以使用插入运算符 << 向流中写入内容。

oss << "The answer is: ";
oss << 42;

最后,你可以使用 .str() 成员函数获取缓冲区中的所有内容,并将其作为一个完整的 std::string 返回。

std::string result = oss.str();
std::cout << result; // 输出: The answer is: 42

一个重要概念——位置指针:流内部有一个“位置指针”,指示下一个读写操作发生的位置。当你在一个全新的 ostringstream 上写入时,会从开头覆盖。如果你希望从末尾追加,可以在构造时指定模式。

// 从末尾开始写入(追加模式)
std::ostringstream oss(std::ios::ate);
oss << "Initial";
oss << "Appended";
std::cout << oss.str(); // 输出: InitialAppended

输入字符串流 (istringstream)

输入字符串流允许你从一个已有的字符串中“读取”并解析出各种类型的数据。

以下是使用 istringstream 的基本步骤:

首先,用一个字符串来初始化 istringstream

std::string data = "123 45.6 hello";
std::istringstream iss(data);

然后,使用提取运算符 >> 从流中读取数据到变量中。流会自动跳过空白字符(空格、换行、制表符等),并尝试将读取到的“标记”转换为目标变量的类型。

int num;
double val;
std::string word;

iss >> num;   // 读取整数 123
iss >> val;   // 读取浮点数 45.6
iss >> word; // 读取字符串 "hello"

类型转换是关键>> 操作符会根据你提供的变量类型进行转换。如果你尝试将 "hello" 读入一个 int 变量,操作会失败。


流的状态位 (State Bits) 🚦

当我们从流中读取数据时,可能会失败(例如类型不匹配、到达末尾)。流使用四个状态位来记录其当前状态:

  1. goodbit:一切正常,没有错误。
  2. failbit:上一次读取/写入操作失败(例如,尝试将 "abc" 读入 int)。
  3. eofbit:已到达流的末尾(End Of File)。
  4. badbit:发生了与流缓冲区相关的严重错误(很少见)。

状态位的重要性:一旦 failbitbadbit 被设置,所有后续的流操作都会被忽略,直到你清除了这些错误状态。

你可以通过成员函数来检查这些状态:

  • stream.good()
  • stream.fail()
  • stream.eof()
  • stream.bad()

一个关键点good() 并非 fail() 的简单对立面。good() 仅在所有错误位都未设置时为真。而 fail() 为真时,good() 必然为假。通常我们更关心 fail()eof()

实践:实现 stringToInteger 函数

利用字符串流和状态位,我们可以实现一个健壮的字符串转整数函数。

基本思路:

  1. 用输入字符串创建一个 istringstream
  2. 尝试从中读取一个整数。
  3. 检查读取操作后流的状态。
    • 如果 failbit 被设置,说明根本没能读取整数。
    • 如果读取成功后,流中还有非空白字符,说明输入不“纯净”(如 "123abc")。
  4. 根据状态决定是返回整数还是抛出错误。

以下是核心代码框架:

int stringToInteger(const std::string& str) {
    std::istringstream iss(str);
    int result;
    iss >> result; // 尝试读取

    // 检查是否读取失败,或者读取后还有多余内容
    if (iss.fail() || !iss.eof()) {
        // 抛出异常或进行错误处理
        throw std::domain_error("字符串无法转换为整数,或包含额外字符。");
    }
    return result;
}

更简洁的写法:流对象本身在布尔上下文中会被转换为 true(如果 !fail())或 false(如果 fail())。因此,检查 if (iss >> result) 可以判断读取是否成功。

int stringToInteger(const std::string& str) {
    std::istringstream iss(str);
    int result;
    char remaining;
    // 如果读取整数成功,并且下一个字符读取失败(说明后面只有空白或已到末尾)
    if (!(iss >> result) || (iss >> remaining)) {
        throw std::domain_error("无效的整数格式。");
    }
    return result;
}


输入/输出流 (I/O Streams) 与缓冲 🐢⚡

我们最熟悉的 std::coutstd::cin 就是标准的输出/输入流。它们连接到控制台。

缓冲 (Buffering)

为了提高效率,像 cout 这样的输出流通常是缓冲的。这意味着你写入的数据不会立即显示在屏幕上,而是先存储在一个内存缓冲区中。缓冲区会在以下情况被“刷新”到实际设备:

  • 缓冲区已满。
  • 遇到换行符 \n(但 \n 本身不一定触发刷新)。
  • 显式使用 std::endl(它输出换行符刷新缓冲区)。
  • 显式使用 std::flush 操控符。
  • 在读取 std::cin 之前,cout 的缓冲区会被自动刷新,以确保用户能看到提示信息。

示例:endl vs \n

// 方法一:使用 endl,每次都会刷新缓冲区,可能较慢
for (int i = 0; i < 1000; ++i) {
    std::cout << i << std::endl;
}

// 方法二:使用 \n,最后一次性刷新,通常更快
for (int i = 0; i < 1000; ++i) {
    std::cout << i << '\n';
}
// 循环结束后,缓冲区可能在程序退出时自动刷新

在需要高性能的场景,应避免频繁使用 std::endl。但在调试或需要确保输出立即可见时,std::endl 很有用。

流操作符 (Manipulators) 🎛️

流操作符是像 std::endlstd::flush 这样的特殊对象,当用 <<>> 将它们放入流时,会改变流的格式或状态,而不是输出字符本身。

常见的操作符包括:

  • std::endl:换行并刷新。
  • std::flush:仅刷新缓冲区。
  • std::setw(n):设置下一个输出字段的宽度。
  • std::setprecision(n):设置浮点数输出精度。

它们的使用方式如下:

#include <iomanip> // 对于 setw, setprecision
std::cout << std::setw(10) << std::left << "Hello" << std::setprecision(2) << 3.14159 << std::endl;


总结 📝

本节课我们一起学习了C++中流的核心概念:

  1. 流的角色:作为程序与外部设备交互的统一、抽象的接口。
  2. 字符串流std::istringstream 用于从字符串解析数据,std::ostringstream 用于将数据格式化为字符串。它们是内存中强大的类型转换工具。
  3. 状态位goodbitfailbiteofbitbadbit 用于诊断流操作的成功与否,是编写健壮代码的关键。
  4. I/O流与缓冲cout/cin 是标准I/O流,了解缓冲机制和 std::endl\n 的区别对程序性能和调试有重要意义。
  5. 流操作符:像 std::endl 这样的特殊对象,可以控制流的格式和行为。

理解流是掌握C++输入输出的基础。在接下来的课程中,我们将继续探索C++的其他特性,并实现更复杂的库函数。

斯坦福大学《CS106L:C++编程》课程笔记 - 第3讲:类型与高级流操作 🧠

在本节课中,我们将要学习C++中输入输出流(I/O)的高级用法,以及现代C++中的一些核心类型概念。我们将从分析一个常见的输入错误程序开始,逐步探讨如何稳健地处理用户输入,并介绍autopairstruct等有用的类型工具。

流操作回顾与问题引入

上一节我们介绍了字符串流(stringstream)用于解析字符串。本节中我们来看看标准输入流cin的工作原理及其常见陷阱。

cin与提取运算符>>结合使用时,其行为类似于字符串流,但有一个关键区别:当缓冲区为空时,cin会使程序等待用户输入。

std::string name;
int age;
std::cin >> name; // 程序在此等待用户输入
std::cin >> age;

然而,这种简单的结合使用会带来几个问题。让我们通过一个“糟糕的欢迎程序”来演示。

分析“糟糕的欢迎程序” 🚫

以下程序尝试读取用户名和年龄,但其实现存在缺陷。

void badWelcome() {
    std::string name;
    int age;
    std::string response;

    std::cout << "What's your name? ";
    std::cin >> name;
    std::cout << "What's your age? ";
    std::cin >> age;
    std::cout << "Hello, " << name << " of age " << age << std::endl;
    std::cout << "Try again? ";
    std::cin >> response;
}

程序存在的三个核心问题

以下是该程序输入机制的主要缺陷:

  1. 逐标记读取cin >>只读取到下一个空白字符(如空格、换行),不消耗该空白字符。如果用户输入“Avery Wang”,name变量只会得到“Avery”,“Wang”会留在缓冲区。
  2. 缓冲区残留导致提示错乱:由于上一个问题留下的残留数据,后续的cin操作可能不会在预期时刻暂停并提示用户,因为缓冲区非空。
  3. 失败状态蔓延:一旦某次cin操作失败(例如尝试将“abc”读入int),流的失败标志位会被设置,所有后续的cin操作都会立即失败,不会尝试读取。

实现稳健的输入:使用getlinegetInteger

为了解决上述问题,我们需要采用更稳健的输入方法。

使用getline读取整行

getline函数可以读取一整行输入,直到遇到换行符,并且会消耗掉这个换行符。这解决了逐标记读取和缓冲区残留的问题。

std::string line;
std::getline(std::cin, line); // 读取整行到line变量中

注意:混合使用cin >>getline时需要小心。因为cin >>在读取后会在缓冲区留下换行符,紧接着的getline会立刻读到空行。解决方法是在cin >>后使用cin.ignore()忽略一个字符。

实现安全的getInteger函数

对于读取整数,我们需要一个能处理错误输入并重新提示的函数。其思路是:读取整行字符串,然后尝试用stringstream将其转换为整数。

以下是getInteger函数的一个实现框架:

int getInteger(const std::string& prompt) {
    int result;
    std::string line;
    while (true) {
        std::cout << prompt;
        std::getline(std::cin, line);
        std::istringstream iss(line);
        // 尝试转换,并确保字符串后面没有多余字符
        if (iss >> result && !(iss >> std::ws).eof()) {
            std::cout << "Invalid input. Please enter only an integer." << std::endl;
        } else if (iss.fail()) {
            std::cout << "Invalid input. Please enter an integer." << std::endl;
        } else {
            return result; // 成功读取
        }
        // 循环继续,重新提示
    }
}

现代C++类型简介 🆕

在解决了流输入的问题后,我们转向现代C++中一些有用的类型概念。

无符号类型与size_t

C++中整数有有符号(如int,可正可负)和无符号(如unsigned int,仅非负)之分。像string.size()这样的函数返回的就是无符号类型size_t

std::string str = "hello";
// 警告:有符号与无符号整数比较
for (int i = 0; i < str.size(); ++i) { /* ... */ }
// 正确:使用 size_t
for (size_t i = 0; i < str.size(); ++i) { /* ... */ }

常见错误:对无符号数进行size() - 1操作时,如果size()为0,会下溢变成一个非常大的正数,导致访问越界。

类型别名与auto关键字

类型别名 (using) 可以为复杂的类型名创建简短的别名。
auto 关键字让编译器自动推导变量类型。

// 类型别名
using MapIterator = std::map<std::string, int>::iterator;

// auto 示例
auto x = 3.14; // x 被推导为 double
auto name = std::string("Avery"); // name 是 std::string
const auto& ref = x; // ref 是 const double&

何时使用auto:当类型名称很长(如迭代器),或者你不关心具体类型时。对于intdouble等简单类型,直接声明可能更清晰。

复合类型:pair, tuplestruct

  • std::pair:将两个值组合成一个单元。
    auto price = std::make_pair(19.99, "USD");
    std::cout << price.first << " " << price.second << std::endl;
    
  • 结构化绑定 (C++17):方便地从pairtuple中提取值。
    auto [amount, currency] = price; // amount=19.99, currency="USD"
    
  • struct:将多个可能不同类型的成员组合成一个自定义类型。它比pair更清晰,因为成员有名字。
    struct Coupon {
        double discount;
        std::string expiryDate;
    };
    Coupon c = {0.2, "2024-12-31"};
    

参数传递的惯用法

函数参数的类型传达了其用途,这是一种自我文档化的方式。

  • void func(int x):值传递。用于廉价拷贝的类型(如int, double)。函数内修改x不影响外界。
  • void func(const std::vector<int>& x):常量引用传递。用于不想修改的大型输入数据,避免拷贝开销。
  • void func(std::vector<int>& x):非常量引用传递。用于需要修改的输入输出参数。
  • std::vector<int> func():直接返回值。现代C++中,返回一个局部容器是高效且推荐的做法。

总结与挑战 🎯

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

  1. cin与提取运算符>>结合使用的三大陷阱:逐标记读取、缓冲区残留和失败状态蔓延。
  2. 如何使用getline和自定义的getInteger函数实现稳健的用户输入。
  3. 现代C++中的关键类型概念:无符号类型与size_t、类型别名、auto类型推导、以及pair/struct等复合类型。
  4. 通过函数参数类型(值传递、常量引用、非常量引用)来表达意图的编程惯用法。

本节挑战:尝试编写一个函数promptUserForFile,该函数能稳健地提示用户输入一个有效的文件名,直到用户输入成功或选择退出。这对于处理文件操作的程序非常有用。

斯坦福大学《CS106L:C++编程》课程笔记 - 第3讲:序列容器 🧱

在本节课中,我们将学习C++标准模板库(STL)中的序列容器,特别是std::vectorstd::deque。我们将了解它们与斯坦福库中对应容器的区别,探讨其性能特点,并学习如何根据实际需求选择合适的容器。


1. 结构体与结构化绑定

上一节我们介绍了std::pair,它允许将两个不同类型的值打包在一起。本节中我们来看看更通用的结构体以及C++17引入的结构化绑定特性。

结构体是对或元组的更一般形式,其组件可以是不同类型,并且每个组件都有名称,这提供了自文档化的能力。

struct PriceRange {
    int min;
    int max;
};

结构化绑定允许在函数返回一个结构体(或std::pair)时,自动将其成员解包到单独的变量中。

auto [minVal, maxVal] = findMinMax(vec); // 假设函数返回一个PriceRange或pair

注意:在CS106B中应避免使用结构化绑定,因为评分系统可能使用较旧的C++版本。


2. 统一初始化

C++有多种初始化变量的方法,这可能导致混淆。为了解决这个问题,C++引入了统一初始化,它使用花括号 {},旨在提供一种通用且一致的初始化方式。

统一初始化适用于多种类型,包括结构体、基本类型和容器。

// 初始化结构体
Course cs106l = {"CS106L", {15, 30}, {16, 30}, {"Avery", "Anna"}};

// 初始化向量
std::vector<int> vec = {1, 2, 3, 4, 5};

当使用统一初始化时,编译器会尝试调用对象的“初始化列表构造函数”。对于std::vector,花括号初始化会创建一个包含这些元素的向量,而圆括号初始化则可能调用其他构造函数(例如,指定大小)。

std::vector<int> v1(3);    // 创建一个大小为3的向量,元素默认初始化(对int是0)
std::vector<int> v2{3};    // 创建一个包含单个元素3的向量

3. 字符串流的适用场景

我们之前介绍了字符串流(std::stringstream),它是一个强大的工具,但并非所有字符串处理都需要它。

以下是字符串流最适合的三种应用场景:

  1. 路径操作:处理文件系统路径等流式数据。
  2. 格式化I/O:使用操控器(如std::uppercase, std::hex)对字符串进行特定格式化。
  3. 类型转换:在字符串和其他类型(如整数、浮点数)之间进行转换。

另一方面,如果只是进行简单的字符串连接,使用std::stringappend方法或+运算符是更直接、更高效的选择。


4. 标准模板库(STL)简介

STL是C++标准库的核心组成部分,其设计哲学是将算法和数据结构抽象到最通用的形式。STL包含以下几个主要部分:

  • 容器:用于存储数据的集合(如vector, deque)。
  • 迭代器:用于遍历容器中的元素。
  • 算法:作用于容器上的通用函数(如sort, find)。
  • 仿函数与Lambda:可调用的对象,用于自定义算法行为。

STL的强大之处在于其通用性和效率。下面的例子展示了使用STL算法如何将复杂的机械代码简化为清晰、高级的表述:

// 传统方式:生成、排序、打印
std::vector<int> vec(n);
for (int i = 0; i < n; ++i) vec[i] = rand();
bubbleSort(vec); // 自己实现的排序
for (int num : vec) std::cout << num << " ";

// STL方式:
std::vector<int> vec(n);
std::generate(vec.begin(), vec.end(), std::rand);
std::sort(vec.begin(), vec.end());
std::copy(vec.begin(), vec.end(), std::ostream_iterator<int>(std::cout, " "));

5. 序列容器:std::vector

std::vector是C++中最常用的序列容器,表示一个可以动态增长的数组。

以下是std::vector与斯坦福库Vector的一些关键区别:

操作 斯坦福库 Vector C++ 标准库 std::vector
添加元素 v.add(value) v.push_back(value)
获取元素 v.get(i)v[i] v.at(i)v[i]
设置元素 v.set(i, value) v.at(i) = valuev[i] = value

最重要的区别在于边界检查

  • v.at(i):会进行边界检查,如果索引i越界,会抛出std::out_of_range异常。
  • v[i]不进行边界检查。如果索引越界,行为是未定义的(可能访问到垃圾数据,但不会直接崩溃)。这是为了追求极致的运行效率。

结论:默认使用v[i]以获得最佳性能,但必须确保索引在有效范围内。如果安全性更重要,或者无法确定索引是否有效,则使用v.at(i)


6. 序列容器:std::deque

std::vector在尾部添加元素非常高效,但在头部插入元素需要移动所有后续元素,效率很低。std::deque(双端队列)解决了这个问题。

std::deque支持在头部和尾部进行常数时间的插入和删除操作。

std::deque<int> dq;
dq.push_back(10);  // 在尾部添加
dq.push_front(5);  // 在头部添加,对于vector这是低效的

然而,这种灵活性带来了权衡。与std::vector相比,std::deque在随机访问元素(即使用dq[i])时速度稍慢。

选择指南

  • 默认使用 std::vector:它对于大多数用例(尤其是尾部操作和随机访问)都是最快、最缓存友好的。
  • 当需要频繁在序列头部进行插入/删除操作时,使用 std::deque


总结

本节课中我们一起学习了C++ STL中序列容器的核心知识。

我们首先回顾了结构体和统一初始化,理解了更安全、更通用的数据打包与初始化方法。接着,我们明确了字符串流的适用场景,避免滥用。

然后,我们正式引入了强大的标准模板库(STL),并重点探讨了两种基本的序列容器:std::vectorstd::deque。我们详细比较了std::vector与斯坦福库Vector的区别,特别是访问元素时的边界检查行为。最后,我们通过性能对比,理解了std::vectorstd::deque各自的优势与权衡,并掌握了根据实际需求选择合适容器的原则。

记住,std::vector是大多数情况下的默认选择,而当你需要一个高效的“双端队列”时,std::deque是你的得力工具。

斯坦福大学《CS106L:C++编程》课程笔记 - 第4讲:关联容器与迭代器 🗺️➡️

在本节课中,我们将要学习C++标准库中两个核心概念:关联容器迭代器。我们将了解映射(map)和集合(set)的工作原理,并探索如何使用迭代器来遍历这些非线性结构的数据。这是理解现代C++编程范式的关键一步。


课程回顾:序列容器与容器适配器

上一节我们介绍了序列容器(如vectordeque)以及容器适配器(如stackqueue)。本节中我们来看看另一种强大的容器类型。


关联容器简介

关联容器与序列容器不同,它们没有“索引”的概念。数据以键值对的形式存储,你可以通过一个“键”来快速访问其关联的“值”。

在C++标准库中,主要有四种关联容器:

  • map:存储键值对,键是唯一的。
  • set:只存储键,键是唯一的。
  • unordered_map:功能同map,但内部元素不排序。
  • unordered_set:功能同set,但内部元素不排序。

mapset在底层会根据键进行排序。这意味着如果你想用自定义的类(例如Student)作为键,你需要为这个类定义小于运算符(<,以便容器知道如何比较和排序。

unordered_mapunordered_set不排序,这使得通过键访问单个元素通常更快,但遍历元素时没有顺序保证。


映射(Map)的核心操作

以下是使用std::map时需要掌握的几个核心操作和区别。

1. 访问元素:[].at()

使用[](方括号)访问键时,如果键不存在,会自动创建该键并进行默认初始化(例如,int类型会初始化为0)。

std::map<std::string, int> wordCount;
wordCount["hello"] = 1; // 如果"hello"不存在,会先创建
int count = wordCount["world"]; // "world"不存在,自动创建并返回0

使用.at()访问键时,如果键不存在,会抛出异常

int count = wordCount.at("hello"); // 仅当"hello"存在时安全
// 如果"hello"不存在,抛出 std::out_of_range 异常

2. 检查键是否存在:.count()

由于map中键是唯一的,.count(key)函数只会返回0(不存在)或1(存在)。因此,它常被用作隐式的布尔检查。

if (myMap.count(someKey)) {
    // 键存在
}

注意:在C++20中,引入了更直观的.contains(key)成员函数。


迭代器(Iterator)详解

迭代器是C++中用于遍历容器元素的通用工具。它提供了一种抽象的方法来访问容器中的元素,无论容器的内部结构如何(数组、链表、树等)。

迭代器的核心操作

可以将迭代器想象成一个指向容器中某个元素的“智能指针”。以下是四个基本操作:

  1. 获取起始迭代器container.begin() 返回指向第一个元素的迭代器。
  2. 解引用迭代器*iterator 获取迭代器当前指向的元素的值。
  3. 移动迭代器++iterator 将迭代器移动到下一个元素。
  4. 判断结束iterator != container.end() container.end()返回的是“尾后迭代器”(最后一个元素的下一个位置),用于判断循环是否结束。

使用迭代器遍历容器

以下是使用迭代器遍历一个std::set<int>的两种常见方式:

使用while循环:

std::set<int> mySet = {1, 2, 3, 4};
std::set<int>::iterator it = mySet.begin(); // 1. 获取起始迭代器
while (it != mySet.end()) {                 // 4. 判断是否结束
    std::cout << *it << std::endl;          // 2. 解引用获取值
    ++it;                                   // 3. 移动到下一个元素
}

使用for循环(更常见):

for (std::set<int>::iterator it = mySet.begin(); it != mySet.end(); ++it) {
    std::cout << *it << std::endl;
}

C++11引入了更简洁的范围for循环,它在底层自动使用迭代器:

for (const auto& element : mySet) {
    std::cout << element << std::endl;
}

为什么迭代器如此强大?

迭代器的真正威力在于其通用性。同一套迭代器逻辑可以应用于vectorlistsetmap等几乎所有容器。这使得我们可以编写与容器类型无关的通用算法。

例如,一个计算容器中某个值出现次数的函数:

template <typename Container, typename T>
int countOccurrences(const Container& container, const T& value) {
    int count = 0;
    for (auto it = container.begin(); it != container.end(); ++it) {
        if (*it == value) {
            ++count;
        }
    }
    return count;
}
// 这个函数可以用于 std::vector<int>, std::list<std::string>, std::set<double> 等等。

这为下一讲要学习的模板泛型算法打下了坚实的基础。


课程总结与作业提示

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

  1. 关联容器mapset用于存储和通过键快速访问数据。理解了[].at()访问方式的区别,以及如何使用.count()检查键是否存在。
  2. 迭代器:作为遍历容器的通用“指针”,掌握了其四个核心操作(begin, *, ++, end),并理解了其实现通用算法的强大能力。

关于作业1的提示

  • 作业中会用到类似讲座中展示的getline函数来处理用户输入。
  • 作业规范较长,但实际需要编写的代码量并不大。请仔细阅读“建议、技巧和窍门”部分。
  • 本作业要求使用标准C++库,而非斯坦福库,以熟悉工业级编程环境。
  • 合理使用常量(如PI)和分解函数来保持代码清晰。
  • 助教办公时间已公布,遇到问题请随时在Piazza上提问或参加办公时间。


下节预告:在掌握了容器和迭代器之后,我们将进入C++最强大的特性之一——模板,学习如何编写真正通用的代码。

斯坦福大学《CS106L:C++编程》课程笔记 P6:高级迭代器与容器 🚀

在本节课中,我们将深入学习C++标准模板库(STL)中迭代器和容器的高级用法。我们将探讨迭代器的不同类型、它们与算法的配合,以及如何在实际编程中灵活运用这些工具。


课程回顾与迭代器基础 🔄

上一节我们介绍了关联容器(如mapset)以及迭代器的基本概念。迭代器就像一个“爪子机”,可以遍历容器中的元素。其基本语法包括创建、解引用、递增和比较。

以下是迭代器的四个基本操作:

  1. 创建:通过容器的.begin().end()方法获取迭代器。
    std::vector<int>::iterator it = vec.begin();
    
  2. 解引用:使用*运算符获取迭代器指向的值。
    int value = *it;
    
  3. 递增:使用++运算符移动到下一个元素。
    ++it; // 或 it++;
    
  4. 比较:使用==!=判断迭代器是否到达终点。
    while (it != vec.end()) { ... }
    

迭代器的强大之处在于,它允许我们编写通用的代码来处理不同的容器(如vectorlistset),而无需关心其底层实现。


映射(Map)迭代器的特殊性 🗺️

本节中我们来看看map迭代器的一个特殊之处。与其他容器不同,解引用一个map迭代器不会直接得到一个值,而是得到一个std::pair对象。

std::pair是一个模板类,用于将两个值组合成一个单元。在map中,pair.first是键(key),pair.second是值(value)。

创建pair的两种常见方法:

// 方法一:统一初始化
std::pair<std::string, int> p1 = {"Stanford", 6507232300};

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/0a95bd144f2647b8324d3a24c1c161f7_24.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/0a95bd144f2647b8324d3a24c1c161f7_26.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/0a95bd144f2647b8324d3a24c1c161f7_28.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/0a95bd144f2647b8324d3a24c1c161f7_30.png)

// 方法二:使用 std::make_pair (可自动推断类型)
auto p2 = std::make_pair("Stanford", 6507232300);

遍历map的示例:

std::map<std::string, int> freqMap = {{"apple", 2}, {"banana", 5}};
for (auto it = freqMap.begin(); it != freqMap.end(); ++it) {
    // 注意括号:先解引用迭代器,再访问pair成员
    std::cout << (*it).first << ": " << (*it).second << std::endl;
    // 等价简写:it->first 和 it->second
}

对于multimap(允许重复键),插入元素通常使用insert方法配合make_pair


迭代器与STL算法 ⚙️

迭代器是STL算法(如排序、查找)能够工作的基础。这些算法定义在<algorithm>头文件中。

以下是几个常用算法的示例:

1. 排序(std::sort)
std::sort要求随机访问迭代器,因此可用于vectordeque等,但不能直接用于setlist(它们已排序或需要特殊算法)。

std::vector<int> vec = {5, 3, 8, 1};
std::sort(vec.begin(), vec.end()); // 排序整个向量
std::sort(vec.begin(), vec.begin() + 2); // 只排序前两个元素

2. 查找(std::find)
std::find只要求输入迭代器,因此可用于几乎所有容器,甚至输入流。它返回一个指向找到元素的迭代器,若未找到则返回.end()

std::set<int> mySet = {3, 1, 4, 1, 5, 9};
auto it = std::find(mySet.begin(), mySet.end(), 5);
if (it != mySet.end()) {
    std::cout << "Found: " << *it << std::endl;
}

检查元素是否在map/set中,除了用.count(),也可用.find(),后者效率略高。

3. 范围操作
我们可以使用像std::lower_boundstd::upper_bound这样的函数来获取迭代器范围,从而只处理容器的一部分。

  • lower_bound(k): 返回指向第一个不小于k的元素的迭代器。
  • upper_bound(k): 返回指向第一个大于k的元素的迭代器。
std::set<int> s = {1, 4, 5, 6, 9};
auto low = s.lower_bound(4); // 指向4
auto high = s.upper_bound(6); // 指向9
for (auto it = low; it != high; ++it) {
    std::cout << *it << " "; // 输出:4 5 6
}


基于范围的for循环与迭代器 🔄

在遍历整个容器时,基于范围的for循环(range-based for loop)语法更简洁,它是基于迭代器实现的语法糖。

std::map<std::string, int> freqMap;
// 使用迭代器遍历
for (auto it = freqMap.begin(); it != freqMap.end(); ++it) { ... }
// 使用基于范围的for循环遍历
for (const auto& entry : freqMap) { ... }
// 使用结构化绑定(C++17)直接解包pair
for (const auto& [key, value] : freqMap) {
    std::cout << key << ": " << value << std::endl;
}

当需要部分遍历使用迭代器本身(如传递给算法)时,传统的迭代器循环更灵活。


迭代器的五种类型 🏷️

并非所有迭代器都支持相同的操作。C++定义了五种迭代器类型,其功能由弱到强:

  1. 输入迭代器(Input Iterator)

    • 只读,且通常只能单次遍历(遍历一次后迭代器可能失效)。
    • 例如:从std::cin读取数据的迭代器。
    • std::find要求输入迭代器。
  2. 输出迭代器(Output Iterator)

    • 只写,通常也是单次遍历。
    • 例如:向std::cout写入数据的迭代器。

  1. 前向迭代器(Forward Iterator)
    • 支持读写,且支持多次遍历(可以保存迭代器并重新遍历)。
    • 例如:std::forward_list的迭代器。

  1. 双向迭代器(Bidirectional Iterator)
    • 在前向迭代器基础上,支持递减--操作)。
    • 例如:std::liststd::setstd::map的迭代器。

  1. 随机访问迭代器(Random Access Iterator)
    • 功能最强大,支持在常数时间内任意跳跃(如it + 5it - 3)。
    • 例如:std::vectorstd::dequestd::string的迭代器,以及原生指针

重要关系:随机访问迭代器 > 双向迭代器 > 前向迭代器 > (输入/输出迭代器)。一个函数如果要求输入迭代器,那么传入更强大的迭代器(如前向、双向迭代器)也是可以的。


迭代器与指针的关系 ⚖️

指针可以看作是随机访问迭代器的一种具体实现。更广义地说:

  • 指针:是一种具体的变量类型,存储内存地址。
  • 迭代器:是一个抽象概念,定义了遍历容器的一组操作接口。
    所有随机访问迭代器(包括指针)都满足这个接口。因此,在许多STL算法中,原生指针可以直接当作迭代器使用。

总结 📚

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

  1. 迭代器的高级应用:如何与std::pair配合使用map迭代器。
  2. STL算法:如何使用迭代器配合std::sortstd::find等强大算法。
  3. 迭代器范围:如何使用lower_bound等函数操作容器的子范围。
  4. 迭代器类型体系:理解了五种迭代器类型及其能力差异,明白了为何某些算法(如sort)不能用于所有容器。
  5. 迭代器与指针:了解了指针是迭代器概念的一种具体实现。

掌握迭代器是理解和使用C++ STL的关键。它们提供了统一的方式来处理和操作各种数据结构,使代码更加通用和强大。在下一讲中,我们将学习模板,这是将迭代器、容器和算法的通用性发挥到极致的核心技术。

课程名称:CS106L C++编程 - 第6讲:模板 🧩

概述

在本节课中,我们将学习C++中的模板函数。模板是泛型编程的核心工具,它允许我们编写独立于特定数据类型的代码,从而提高代码的复用性和灵活性。我们将从理解为什么需要模板开始,学习其基本语法,并通过实例来掌握如何创建和使用模板函数。


1. 为什么需要模板?🤔

在编程中,我们经常需要为不同类型的数据编写功能相似的函数。例如,一个找出两个值中较小和较大值的函数,我们可能需要对整数、浮点数甚至字符串都实现它。

如果不使用模板,传统的做法是为每种类型都编写一个几乎完全相同的函数。这会导致代码冗余,难以维护。

示例:冗余的函数

// 为整数编写的函数
std::pair<int, int> myMinMax(int a, int b) {
    if (a < b) return {a, b};
    else return {b, a};
}
// 为双精度浮点数编写的函数
std::pair<double, double> myMinMax(double a, double b) {
    if (a < b) return {a, b};
    else return {b, a};
}
// 为字符串编写的函数
std::pair<std::string, std::string> myMinMax(std::string a, std::string b) {
    if (a < b) return {a, b};
    else return {b, a};
}

可以看到,除了类型声明,函数体完全一样。模板就是为了解决这种重复劳动而生的。


2. 模板函数基础 🛠️

模板函数允许我们定义一个“蓝图”,编译器会根据调用时提供的具体类型来生成对应的函数代码。

2.1 基本语法

创建一个模板函数,需要使用 template 关键字,后面跟着用尖括号 <> 括起来的模板参数列表。通常使用 typename T(或 class T)来声明一个类型参数 T

通用格式:

template <typename T>
返回类型 函数名(参数列表) {
    // 函数体,使用类型 T
}

2.2 第一个模板函数

让我们将上面的 myMinMax 函数改造成一个模板函数。

代码示例:

#include <iostream>
#include <utility>
#include <string>

// 声明一个模板函数
template <typename T>
std::pair<T, T> myMinMax(T a, T b) {
    if (a < b) return {a, b};
    else return {b, a};
}

// 辅助打印函数
template <typename T>
void printMinMax(const std::pair<T, T>& result) {
    std::cout << "Min: " << result.first << ", Max: " << result.second << std::endl;
}

int main() {
    // 用于整数
    auto result1 = myMinMax(3, -2);
    printMinMax(result1); // 输出: Min: -2, Max: 3

    // 用于双精度浮点数
    auto result2 = myMinMax(8.3, 7.4);
    printMinMax(result2); // 输出: Min: 7.4, Max: 8.3

    // 用于字符串
    auto result3 = myMinMax(std::string("Anna"), std::string("Avery"));
    printMinMax(result3); // 输出: Min: Anna, Max: Avery (按字母顺序)

    return 0;
}

现在,一个函数就能处理多种类型的数据。


3. 模板实例化 ⚙️

模板本身不是真正的函数,它只是一个蓝图。当编译器在代码中看到对模板函数的调用时,它会根据提供的具体类型来“实例化”出一个具体的函数。这发生在编译时。

3.1 显式实例化

你可以在调用时明确指定模板参数 T 的类型。

示例:

auto result = myMinMax<double>(3, 5.2); // 明确告诉编译器 T 是 double

在这种情况下,3 会被隐式转换为 double

3.2 隐式实例化

更常见的是让编译器根据传入的参数自动推导类型。

示例:

auto result = myMinMax(3, 5); // 编译器推导出 T 是 int

编译器会查看所有可能的匹配(包括重载函数和模板),并应用一系列规则来选择“最佳匹配”。


4. 模板的威力与注意事项 💪

上一节我们介绍了模板的基本用法,本节中我们来看看使用模板时的一些高级概念和潜在问题。

4.1 多个模板参数

一个函数模板可以有多个类型参数。

示例:一个(可能不实用的)混合类型比较函数

template <typename T, typename U>
void printTwoTypes(T a, U b) {
    std::cout << "First: " << a << ", Second: " << b << std::endl;
}

需要注意的是,对于 myMinMax 这样的函数,让两个参数类型不同通常没有意义,因为不同类型之间可能无法直接比较。

4.2 对类型的假设(隐式接口)

模板函数对其操作的类型做出了隐式假设。例如,我们的 myMinMax 函数假设类型 T 支持 < 运算符。

如果传入一个不支持 < 运算符的自定义类型,代码将无法编译。

struct MyStruct { int x; };
MyStruct s1{1}, s2{2};
auto result = myMinMax(s1, s2); // 编译错误!MyStruct 没有定义 operator<

这就要求我们在设计模板时,必须清楚地知道并对调用者声明函数对类型的要求。C++20引入了“概念(Concepts)”来更明确地表达这些约束,我们将在后续课程中讨论。

4.3 模板与重载

当存在普通重载函数和模板函数时,编译器有一套复杂的规则来决定调用哪一个。基本原则是:非模板函数优先于模板函数,更特化的模板优先于更通用的模板。

最佳实践: 避免创建与模板函数签名完全相同(仅类型不同)的非模板重载函数,以免引起混淆。


5. 实践练习:通用输入函数 ✍️

让我们通过一个练习来巩固所学。我们将模板化一个常见的 getInteger 函数,使其能获取任意类型的值。

原始函数(获取整数):

int getInteger(const std::string& prompt) {
    int value;
    std::cout << prompt;
    std::cin >> value;
    return value;
}

模板化后的通用函数:

template <typename T>
T getValue(const std::string& prompt) {
    T value;
    std::cout << prompt;
    std::cin >> value;
    return value;
}

int main() {
    // 获取整数
    int i = getValue<int>("Enter an integer: ");
    // 获取浮点数
    double d = getValue<double>("Enter a double: ");
    // 获取字符串(注意:会读取到第一个空白字符为止)
    std::string s = getValue<std::string>("Enter a string: ");

    std::cout << "You entered: " << i << ", " << d << ", " << s << std::endl;
    return 0;
}

这个模板函数现在可以用于任何定义了 >> 输入运算符的类型。


6. 迈向泛型编程:概念提升 🚀

泛型编程的核心思想是编写不依赖于具体数据结构的算法。模板是实现这一思想的工具。

考虑一个计算某个值在集合中出现次数的函数:

初始版本(针对 vector<int>):

int countOccurrences(const std::vector<int>& vec, int value) {
    int count = 0;
    for (size_t i = 0; i < vec.size(); ++i) {
        if (vec[i] == value) ++count;
    }
    return count;
}

这个函数做了很多限制性假设:

  1. 容器必须是 std::vector
  2. 容器元素必须是 int
  3. 使用索引访问,这要求容器支持随机访问。

我们可以通过模板和迭代器来逐步“提升”这个函数,移除这些假设:

第一步:模板化元素类型和容器类型

template <typename Container, typename DataType>
int countOccurrences(const Container& collection, const DataType& value) {
    int count = 0;
    for (size_t i = 0; i < collection.size(); ++i) { // 问题依然存在:假设有 .size() 和 []
        if (collection[i] == value) ++count;
    }
    return count;
}

第二步:使用迭代器(更通用的方式)

template <typename InputIt, typename DataType>
int countOccurrences(InputIt begin, InputIt end, const DataType& value) {
    int count = 0;
    for (InputIt it = begin; it != end; ++it) { // 只假设迭代器支持 !=, *, ++
        if (*it == value) ++count;
    }
    return count;
}
// 使用示例
std::vector<int> vec = {1, 2, 2, 3, 2};
int cnt = countOccurrences(vec.begin(), vec.end(), 2); // 计算2出现的次数

现在,这个函数可以用于任何提供前向迭代器的容器(如 vector, list, set 的一部分等),并且不关心容器的具体类型和内存布局。这就是标准库算法(如 std::count)的工作方式。


总结

本节课中我们一起学习了C++模板函数的基础知识。我们从解决代码冗余的需求出发,学习了如何声明和使用模板函数,理解了模板实例化在编译时发生的过程。我们探讨了模板对类型的隐式接口要求,并通过实践练习加深了理解。最后,我们看到了如何利用模板和迭代器进行“概念提升”,编写出真正通用、灵活的算法,这正是泛型编程的强大之处。

模板是C++强大功能的基石之一,掌握它将为你打开编写高效、复用库代码的大门。在接下来的课程中,我们将继续探索标准模板库(STL)中的泛型组件。

课程 7:模板与函数 🧩

在本节课中,我们将学习如何编译和运行C++程序,并深入探讨模板函数、隐式接口以及如何使用Lambda表达式来编写更通用的代码。

编译与运行C++程序

上一节我们介绍了C++编程的基础,本节中我们来看看如何将代码转换为可执行程序。

要编译代码,你需要选择一个编译器。例如,使用G++编译器。你需要输入g++命令,并指定一些标志来控制编译过程。

以下是编译步骤:

  1. 指定编译器:g++
  2. 指定C++标准版本:例如 -std=c++17
  3. 指定要编译的源文件:例如 HelloWorld.cpp
  4. 使用 -o 标志指定输出文件名:例如 -o HelloWorld
  5. 运行生成的可执行文件:./HelloWorld

完整的编译命令如下:

g++ -std=c++17 HelloWorld.cpp -o HelloWorld

这个命令将HelloWorld.cpp文件编译成一个名为HelloWorld的二进制可执行文件。运行ls命令,你可以看到新生成的HelloWorld文件。执行./HelloWorld即可运行程序。

注意:不要随意运行从网络下载的未知可执行文件,尤其是在终端中使用sudo(root权限)运行,这可能对系统造成危险。

模板与隐式接口

现在,让我们回顾并深化对模板的理解。上周我们编写了一个通用的minmax函数模板。

模板通过template <typename T>声明,它定义了一个自定义类型T,使得函数可以处理多种数据类型。这个过程称为“概念提升”,即我们不断泛化函数,减少对参数类型的假设。

考虑以下计算元素出现次数的泛化过程:

  1. 最初,函数只能计算vector<int>中某个整数的出现次数。
  2. 然后,我们将其泛化为可以计算任何类型Tvector<T>
  3. 进一步,我们使其能处理任何容器类型。
  4. 最终,我们使其能处理任何迭代器范围。

当我们调用模板函数时,编译器会尝试推断类型。代码本身对类型提出了“隐式接口”要求。例如,在遍历迭代器时,要求迭代器可以被解引用、递增,并且解引用的结果可以与另一个值进行比较。

如果传入的类型不满足这些隐式要求,编译将失败,并可能产生冗长复杂的错误信息。C++20引入了“概念(Concepts)”特性,允许程序员显式地指定模板参数的接口要求,这能使错误信息更清晰。

从函数到Lambda表达式

我们之前编写的countOccurrences函数计算的是等于某个特定值的元素个数。我们可以将其进一步泛化。

我们可以不检查“等于某个值”,而是检查元素是否满足某个更通用的条件(谓词)。谓词是一个返回布尔值的函数。

例如,我们可以计算范围内有多少个元素小于5。这不再局限于相等性比较。我们可以定义一个谓词函数isLessThan5,然后将其传递给泛化的countOccurrences函数。

然而,使用独立的谓词函数有两个问题:

  1. 需要为每个不同的条件(如小于5、大于10)单独编写函数,很繁琐。
  2. 难以向谓词传递额外的参数(例如,我们想检查是否小于一个变量limit,而不是固定的5)。

C++11引入了Lambda表达式来解决这些问题。Lambda允许你内联地定义一个匿名函数对象。

一个Lambda表达式的基本语法如下:

auto lambda = [/*捕获列表*/](/*参数列表*/) -> /*返回类型*/ {
    // 函数体
};

例如,创建一个检查是否小于某值的Lambda:

int limit = 5;
auto isLessThan = [limit](int value) -> bool {
    return value < limit;
};
// 使用
bool result = isLessThan(3); // 返回 true

Lambda的“捕获列表”[limit]允许Lambda访问其定义作用域中的变量limit。变量可以通过值(默认)或引用(使用&)被捕获。

这使得创建高度可定制的谓词变得非常方便,无需定义独立的函数,并且可以轻松封装所需的状态。

总结

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

  1. 如何使用g++编译器和相关标志来编译和运行C++程序。
  2. 模板函数的隐式接口概念,以及代码如何对类型提出要求。
  3. 如何通过谓词将函数泛化,以及使用独立函数作为谓词的局限性。
  4. Lambda表达式的语法和强大功能,它允许我们内联创建可捕获外部状态的函数对象,从而编写出更简洁、更灵活的通用代码。

掌握Lambda表达式是有效使用C++标准库算法的基础,我们将在后续课程中进一步探索。

斯坦福大学《CS106L:C++编程》课程笔记 - 第8讲:函数与算法 🧮

在本节课中,我们将学习如何通过谓词(Predicate)Lambda表达式来编写更通用的函数,并探索C++标准模板库(STL)中一些强大的算法。我们将从回顾模板函数开始,逐步深入到如何利用STL算法高效地处理数据集合。


回顾:模板与迭代器失效

上一节我们讨论了在STL容器(如vector)中删除元素时,迭代器可能失效的问题。例如,在vector中删除一个元素会导致其后的所有迭代器失效。我们通过谨慎管理迭代器的递增操作来解决这个问题。

// 示例:安全地删除元素
for (auto it = vec.begin(); it != vec.end(); /* 不在循环内递增 */) {
    if (/* 删除条件 */) {
        it = vec.erase(it); // erase 返回下一个有效迭代器
    } else {
        ++it;
    }
}

本节中,我们将在此基础上,看看如何将这些概念应用到更通用的算法中。


从具体到通用:引入谓词

我们之前编写了一个计算特定值出现次数的函数countOccurrences

template <typename InputIt, typename DataType>
int countOccurrences(InputIt begin, InputIt end, const DataType& val) {
    int count = 0;
    for (auto it = begin; it != end; ++it) {
        if (*it == val) { // 核心比较操作
            ++count;
        }
    }
    return count;
}

这个函数解决了“值等于val的元素出现了多少次”的问题。但我们能否解决更广泛的问题,例如“满足某个条件的元素出现了多少次”?

这个“条件”就是一个谓词(Predicate)——一个接受参数并返回布尔值的函数。例如,“等于3”本身就是一个谓词。我们可以将函数通用化,使其接受一个谓词作为参数。

template <typename InputIt, typename UnaryPredicate>
int countOccurrencesIf(InputIt begin, InputIt end, UnaryPredicate pred) {
    int count = 0;
    for (auto it = begin; it != end; ++it) {
        if (pred(*it)) { // 使用谓词判断条件
            ++count;
        }
    }
    return count;
}

这里,UnaryPredicate(一元谓词)意味着该函数对象只接受一个参数。这使得我们的函数能够统计任何满足给定条件的元素数量。


谓词的局限性与Lambda的登场

假设我们想统计“小于5”的元素。最初,我们可能需要写一个独立的函数:

bool isLessThanFive(int val) {
    return val < 5;
}
// 调用
countOccurrencesIf(begin, end, isLessThanFive);

但如果我们想统计“小于limit”的元素呢?limit是一个变量。我们无法通过修改isLessThanFive来接受第二个参数,因为countOccurrencesIf要求谓词是一元的。

此时,Lambda表达式提供了完美的解决方案。Lambda允许我们在需要的地方内联定义一个匿名函数对象,并捕获外部作用域的变量。

int limit = 5;
// 定义一个Lambda,捕获外部变量 limit
auto isLessThanLimit = [limit](auto val) {
    return val < limit;
};
// 调用
countOccurrencesIf(begin, end, isLessThanLimit);

深入理解Lambda表达式

一个Lambda表达式的基本结构如下:

[捕获子句] (参数列表) -> 返回类型 { 函数体 }

以下是各个部分的解析:

  • 捕获子句 []:指定哪些外部变量可以在Lambda体内使用。[limit]表示按值捕获limit[&limit]表示按引用捕获。应避免使用[=](捕获所有变量)或[&](捕获所有引用),以保持代码清晰。
  • 参数列表 ():与普通函数参数列表类似。在C++14及以上,参数可以使用auto
  • 返回类型 ->:可选。编译器通常可以推断返回类型。
  • 函数体 {}:包含要执行的代码。

Lambda在幕后会生成一个匿名的类(函数对象),这就是为什么我们通常用auto来接收它的原因——我们不知道编译器生成的类名。


探索STL算法

我们刚刚实现的countOccurrencesIf,在STL中已经有了对应物:std::count_if。STL提供了大量此类算法,让我们无需重复造轮子。

以下是几个常用算法的示例,我们以一个Course结构体的向量为例:

struct Course {
    std::string name;
    double rating;
};
std::vector<Course> courses = { ... };

1. 排序 (std::sort)

对自定义类型排序需要提供比较谓词。

// 按评分排序
std::sort(courses.begin(), courses.end(),
          [](const Course& c1, const Course& c2) {
              return c1.rating < c2.rating; // 定义“小于”
          });

2. 查找中位数 (std::nth_element)

要找到中位数,可以先排序,然后取中间元素。但更高效的方法是使用std::nth_element,它能在O(n)时间内将第n小的元素放到正确位置。

auto mid = courses.begin() + courses.size() / 2;
std::nth_element(courses.begin(), mid, courses.end(),
                 [](const Course& a, const Course& b) { return a.rating < b.rating; });
// *mid 现在是评分的中位数

3. 稳定分区 (std::stable_partition)

将满足条件的元素移到前面,不满足的移到后面,并保持同类元素的原始相对顺序。

// 将所有CS课程移到前面
auto it = std::stable_partition(courses.begin(), courses.end(),
                                [](const Course& c) {
                                    return c.name.substr(0, 2) == "CS";
                                });
// it 指向第一个非CS课程的迭代器

4. 条件复制 (std::copy_if)

将满足条件的元素复制到另一个容器。注意,目标容器需要有足够空间,通常使用std::back_inserter

std::vector<Course> csCourses;
std::copy_if(courses.begin(), courses.end(),
             std::back_inserter(csCourses), // 输出迭代器适配器,负责插入
             [](const Course& c) { return c.name.substr(0, 2) == "CS"; });

5. 移除-擦除惯用法 (std::remove_iferase)

这是一个非常重要的惯用法。std::remove_if 并不会真正从容器中删除元素,它只是将不满足条件(即要保留的)元素移动到范围前面,并返回一个指向新逻辑末尾的迭代器。要真正删除元素,需要结合容器的erase方法。

// 错误:不会改变容器大小,末尾留下“垃圾”值
std::remove_if(courses.begin(), courses.end(),
               [](const Course& c) { return c.name.substr(0, 2) == "CS"; });

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/ce52e5dbc6430322de37e7b421c4a430_121.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/stf-cs106-prog/img/ce52e5dbc6430322de37e7b421c4a430_123.png)

// 正确:移除-擦除惯用法
auto newEnd = std::remove_if(courses.begin(), courses.end(),
                             [](const Course& c) { return c.name.substr(0, 2) == "CS"; });
courses.erase(newEnd, courses.end()); // 真正删除元素

6. 查找 (std::find 与成员函数 find)

对于序列容器(如vector),使用std::find。对于关联容器(如set, map),使用其自身的find成员函数,后者效率更高(通常是O(log n))。


总结

本节课我们一起学习了如何利用谓词Lambda表达式来编写灵活、通用的函数。Lambda通过捕获子句解决了函数作用域和状态传递的难题。我们还探索了STL算法库中的几个强大工具:sortnth_elementstable_partitioncopy_if以及至关重要的移除-擦除惯用法

记住,STL算法的力量在于抽象。当你需要对数据进行某种常见操作时,先查查STL是否已经提供了现成的、经过高度优化的算法。掌握这些工具能极大提升你的C++编程效率和代码质量。


课程资源提示:本教程内容整理自斯坦福大学《CS106L: C++ Programming》课程第8讲。演示代码可在课程提供的起始代码中找到。

posted @ 2026-02-05 08:54  绝不原创的飞龙  阅读(2)  评论(0)    收藏  举报