UCB-CS61B-数据结构笔记-全-

UCB CS61B 数据结构笔记(全)

1:Java入门与类设计 🚀

在本节课中,我们将学习如何设计一个表示课程的Java类。我们将从定义一个简单的学生类开始,逐步构建一个更复杂的课程类,并在这个过程中理解静态变量、实例变量、构造函数以及方法的设计。


概述 📋

本节课基于UCB CS61B课程第二周的讨论内容。我们将创建一个名为 CS61B 的类,它代表一个学期的课程。这个类需要管理学生信息、课程容量以及大学名称等属性。我们将学习如何声明变量、编写构造函数以及实现方法,并最终考虑如何扩展课程容量。


第一部分:变量声明 🧱

首先,我们需要为 CS61B 类声明三个变量:大学名称、学期和学生列表。

以下是需要声明的三个变量及其考虑因素:

  • university:大学名称。对于所有 CS61B 课程实例,这个值都是 “UC Berkeley”。因此,它应该是一个静态变量,类型为 String
  • semester:课程开设的学期,例如 “Fall 2023”。每个课程实例的学期可能不同,因此它是一个实例变量,类型为 String
  • students:注册该学期课程的学生列表。课程有固定容量,因此我们可以使用一个大小固定的数组来存储。它是一个实例变量,类型为 CS61BStudent[]

根据以上分析,变量声明如下:

public static String university;
public String semester;
public CS61BStudent[] students;

第二部分:构造函数 🏗️

上一节我们声明了类的变量,本节中我们来看看如何初始化它们。我们需要编写一个构造函数,它接收三个参数:课程容量 capacity、报名学生数组 signups 和学期名称 semester

构造函数需要完成以下任务:

  1. 将传入的 semester 参数赋值给实例变量 this.semester
  2. 根据 capacity 初始化 students 数组。
  3. signups 数组中按顺序录取前 capacity 个学生到 students 数组中。

假设 signups 数组的长度至少为 capacity,构造函数的实现如下:

public CS61B(int capacity, CS61BStudent[] signups, String semester) {
    this.semester = semester;
    this.students = new CS61BStudent[capacity];
    for (int i = 0; i < capacity; i++) {
        this.students[i] = signups[i];
    }
}

第三部分:方法实现 ⚙️

现在我们已经构建了课程的基本框架,本节中我们将为它添加行为。我们需要实现两个方法。

以下是需要实现的两个方法及其设计思路:

  • makeStudentsWatchLecture():让所有注册本课程的学生“观看讲座”。

    • 返回类型void(无返回值)。
    • 参数:无。
    • 静态/实例:这是一个实例方法,因为它操作的是特定课程实例的学生列表 (this.students)。
    • 实现:遍历 students 数组,对每个学生调用其 watchLecture() 方法。
    public void makeStudentsWatchLecture() {
        for (CS61BStudent student : this.students) {
            student.watchLecture();
        }
    }
    
  • changeUniversity(String newUniversity):更改所有 CS61B 课程实例的大学名称。

    • 返回类型void
    • 参数String newUniversity
    • 静态/实例:这是一个静态方法,因为它修改的是所有实例共享的静态变量 university
    • 实现:将静态变量 university 重新赋值为 newUniversity
    public static void changeUniversity(String newUniversity) {
        university = newUniversity;
    }
    

第四部分:扩展课程容量 💡

最后,我们来思考如何修改现有的设计,以支持课程容量的扩展。核心需求是:当课程扩容后,应从原始的报名列表 (signups) 中录取更多学生,直到达到新的容量。

有两种主要的思路可以实现扩容:

  1. 数组扩容法:这是后续项目中会深入实践的方法。我们可以创建一个 resize() 辅助方法。当需要扩容时,该方法会创建一个新的、容量更大的 students 数组,将原有学生复制进去,然后再从 signups 数组中录取新的学生加入末尾。

  2. 容量追踪法:在类中新增一个实例变量 capacity 来记录当前允许的最大学生数。同时,保留完整的 signups 数组。我们只将 signups 中前 capacity 个学生视为已注册。当容量扩大时,我们只需增加 capacity 的值,逻辑上就有更多学生被“纳入”课程,而无需立即移动数组中的元素。


总结 🎯

本节课中我们一起学习了如何设计一个Java类来模拟大学课程。我们从变量声明开始,区分了静态变量和实例变量的使用场景。然后,我们编写了构造函数来初始化对象的状态。接着,我们实现了实例方法和静态方法,为类添加了特定的行为。最后,我们探讨了如何设计数据结构以支持未来“课程扩容”的功能,为更复杂的数据管理打下了基础。

2:讨论课1更新版(c部分)解析 🧩

在本节课中,我们将学习CS 61B课程中讨论课1更新版的核心内容,重点关注Student类中的watchLecture方法和CS61B类中的makeStudentsWatchLecture方法。我们将理解如何利用方法的返回值,并编写代码来统计实际观看讲座的学生人数。


方法概述与设计思路

上一节我们介绍了讨论课的基本背景,本节中我们来看看具体更新的两个方法。主要变化在于watchLecture方法现在会返回一个布尔值(boolean),用于表示学生是否实际观看了讲座。这样设计是为了更好地演示如何利用其他方法的返回值。

CS61B类的makeStudentsWatchLecture方法中,我们需要让本学期所有注册的CS61B学生观看讲座,并返回实际观看了讲座的学生总数。

方法头设计与实现

以下是makeStudentsWatchLecture方法的设计步骤:

  1. 访问权限与类型:该方法需要被其他类的用户访问,因此应设置为public。它应该是一个实例方法,因为它与特定的CS61B课程对象相关联,并且需要使用实例变量students

    public int makeStudentsWatchLecture()
    
  2. 返回值:该方法需要返回一个整数,即实际观看讲座的学生数量。

  1. 实现逻辑:我们需要一个计数器变量来跟踪数量。然后,遍历students数组,为每个学生调用watchLecture方法,并根据其返回值更新计数器。

以下是完整的代码实现:

public int makeStudentsWatchLecture() {
    int totalWatched = 0; // 初始化计数器
    for (Student s : students) { // 遍历所有学生
        if (s.watchLecture()) { // 调用方法并检查返回值
            totalWatched++; // 如果返回true,计数器加1
        }
    }
    return totalWatched; // 返回最终计数
}

代码说明

  • int totalWatched = 0;:创建并初始化一个整数变量用于计数。
  • for (Student s : students):使用增强型for循环遍历students数组中的每个Student对象。
  • if (s.watchLecture()):调用每个学生对象的watchLecture方法。如果该方法返回true,则执行if语句块内的代码。
  • totalWatched++:将计数器totalWatched的值增加1。
  • return totalWatched;:循环结束后,返回计数器的值。

替代方案:你也可以先将watchLecture的返回值存储在一个布尔变量中,再判断该变量。但直接放在if条件中使用可以使代码更简洁。两种方式是等效的。


本节课中我们一起学习了CS 61B讨论课1更新版中makeStudentsWatchLecture方法的实现。我们掌握了如何设计一个实例方法来遍历对象数组、调用其他对象的方法,并利用其返回值(本例中是布尔值)来执行逻辑(计数)并最终返回一个结果(整数)。理解如何组合运用这些基本编程构件是学习面向对象编程和数据结构的重要一步。

3:1 - 作用域:快速数学 🧮

在本节中,我们将通过一个具体的代码示例,学习Java中作用域引用传递的核心概念。我们将分析一个数组在不同函数调用下的变化过程,理解基本类型与引用类型在参数传递时的区别。

概述

我们将分析一段Java代码,其中包含对数组的几种操作。通过逐步执行,我们将看到:

  1. 使用增强型for循环遍历数组时,为何不会修改原数组。
  2. 使用索引直接访问数组元素时,如何修改原数组。
  3. 在方法内部交换基本类型变量时,为何不会影响方法外部的变量。

理解这些行为是掌握Java中数据传递机制的关键。

代码与初始状态

首先,在main方法中,我们创建了一个数组。在Java中,数组是一种引用类型。

int[] arr = {2, 3, 3, 4};

我们可以将其在内存中的关系可视化:变量arr持有一个指向内存中数组对象地址的引用(或指针)。

函数调用一:multiplyBy3

接下来,我们调用第一个函数multiplyBy3(arr)

public static void multiplyBy3(int[] a) {
    for (int x : a) {
        x = x * 3;
    }
}

当调用此方法时,参数a被设置为指向与arr相同的数组地址。然而,在方法内部,我们使用了增强型for循环for (int x : a))。

以下是此循环的关键点:

  • 变量x在每次迭代中会被赋值为数组a中当前元素的值(如2, 3, 3, 4)。
  • 语句x = x * 3仅仅改变了局部变量x自身的值。
  • x只是原始数组元素值的一个副本,修改x并不会影响数组a中的实际元素。

因此,在multiplyBy3函数执行完毕后,原数组arr的内容没有发生任何变化,仍然是{2, 3, 3, 4}

函数调用二:multiplyBy2

现在,我们调用第二个函数multiplyBy2(arr)

public static void multiplyBy2(int[] a) {
    int[] b = a;
    for (int i = 0; i < b.length; i++) {
        b[i] = b[i] * 2;
    }
}

在这个函数中:

  1. 参数a同样接收了arr的引用,指向同一个数组。
  2. 我们声明了另一个数组引用b,并令b = a。此时,ab指向内存中的同一个数组对象
  3. 我们使用普通for循环,通过索引i直接访问并修改b[i]的值。

由于b[i]直接定位到数组在内存中的具体位置,操作b[i] = b[i] * 2会直接改变该位置存储的数据。又因为aarr都指向同一个数组,所以原数组arr的内容被成功修改。

执行后,数组内容变为:{4, 6, 6, 8}

函数调用三:swap

最后,我们看一个处理基本类型的函数调用。在main方法中,我们有两个整型变量:

int a = 6;
int b = 7;
swap(a, b);

swap函数的定义如下:

public static void swap(int a, int b) {
    int temp = a;
    a = b;
    b = temp;
}

这里的关键在于参数传递机制:

  • ab基本类型int)。当调用swap(a, b)时,传递给函数的是变量ab值的副本(即6和7)。
  • swap函数内部进行的交换操作,只作用于这两个局部副本。
  • 函数执行结束时,局部副本被丢弃。原始变量abmain方法中的作用域内,其值从未被触及。

因此,在调用swap之后,main方法中的变量ab的值保持不变,仍然是6和7。

总结

本节课我们一起分析了Java中作用域和数据传递的关键行为:

  1. 增强型for循环for-each)提供的是数组元素的值副本,修改循环变量不会影响原数组。
  2. 通过索引访问数组元素(如arr[i])是直接操作内存中的数据,会修改原数组。
  3. 引用类型(如数组、对象)作为参数传递时,传递的是引用的副本,但多个引用副本可以指向同一个对象,从而通过其中一个修改对象内容。
  4. 基本类型(如int, double)作为参数传递时,传递的是值的副本,在方法内部修改参数不会影响原始变量。

理解值传递(对于基本类型和引用副本)与通过引用操作对象本身之间的区别,对于编写正确的Java程序至关重要。

4:2:使用包装类交换整数值 🔄

在本节课中,我们将学习如何通过创建一个简单的包装类来解决Java中无法直接交换基本类型变量值的问题。我们将通过一个具体的例子,演示如何交换两个整数变量的值。

上一节我们介绍了基本类型在Java中按值传递的特性,这导致无法通过简单的函数调用来交换两个基本类型变量的值。本节中,我们来看看如何通过创建一个包装类来巧妙地解决这个问题。

问题背景与思路

我们有两个整数变量 ab,其值分别为6和7。由于Java是按值传递的,直接调用一个 swap 函数无法改变 main 函数中 ab 的值。为了解决这个问题,我们引入一个名为 IntWrapper 的包装类。

IntWrapper 类非常简单,它只包含一个实例变量 x 来存储整数值。其构造函数接收一个值并将其赋给 x

以下是 IntWrapper 类的代码定义:

class IntWrapper {
    int x;
    public IntWrapper(int value) {
        this.x = value;
    }
}

实现步骤

以下是实现交换功能的具体步骤。

首先,在 main 方法中,我们创建两个 IntWrapper 对象来“包装”我们的整数 ab

IntWrapper x = new IntWrapper(a);
IntWrapper y = new IntWrapper(b);

接着,我们需要编写一个 swap 方法,它接收两个 IntWrapper 类型的参数。在Java中,对象引用也是按值传递的,这意味着传递给 swap 方法的是指向这些对象的指针的副本。

swap 方法的核心逻辑是交换两个 IntWrapper 对象内部 x 的值,而不是交换引用本身。以下是 swap 方法的实现代码:

public static void swap(IntWrapper first, IntWrapper second) {
    int temp = first.x;      // 保存 first.x 的值
    first.x = second.x;      // 将 second.x 的值赋给 first.x
    second.x = temp;         // 将保存的原始 first.x 值赋给 second.x
}

然后,在 main 方法中调用这个 swap 方法,传入我们创建的两个包装对象。

swap(x, y);

调用 swap 方法后,对象 xy 内部的 x 值已经成功交换。最后一步是将交换后的值重新赋给原始的 ab 变量,以反映交换结果。

a = x.x;
b = y.x;

核心概念总结

本节课中我们一起学习了如何利用包装类在Java中实现基本类型变量的交换。关键在于理解Java的按值传递机制,并通过操作包装类对象的内部字段来间接改变原始变量的值。我们定义了一个简单的 IntWrapper 类,并在 swap 方法中交换了其内部字段的值,最终成功交换了 ab 的值。

5:静态书籍 📚

概述

在本节课中,我们将深入学习Java中的static关键字。我们将通过分析一个关于BookLibrary类的练习题,来理解静态变量、静态方法与实例变量、实例方法之间的核心区别。课程分为两部分:第一部分探讨修改代码中static关键字的影响;第二部分则通过执行一段主程序来观察静态成员在程序运行时的具体行为。


第一部分:理解静态成员 🧠

在深入具体问题之前,让我们先花几分钟时间理解静态方法和静态变量。

静态变量 vs. 实例变量

请看以下代码片段:

public class Book {
    private String title; // 实例变量
    private static Book last; // 静态变量
}
  • 实例变量(如title)属于类的每个实例。每创建一个Book对象,该对象都有自己独立的title
  • 静态变量(如last)属于类本身。类的所有实例共享同一个静态变量。如果一个实例修改了last,其他所有实例访问到的last都会是这个修改后的值。

静态方法 vs. 实例方法

public class Book {
    public static String lastBookTitle() { // 静态方法
        return last.title;
    }
    public String getTitle() { // 实例方法
        return title;
    }
}
  • 实例方法属于类的实例。
  • 静态方法属于类本身。

这里有几个关键点:

  1. 一个类的实例可以访问所有内容:实例变量/方法和静态变量/方法。
  2. 但是,类名本身(如Book只能访问静态变量和静态方法。尝试用类名访问实例变量或方法会导致编译错误。
  3. 当一个方法与特定实例无关,而是与整个类相关时,通常将其声明为静态方法。

以上是对静态成员的基本介绍。接下来,我们将在具体问题中探索更多细节。


第二部分:代码修改分析 🔍

现在,我们开始分析练习题的第一部分。我们将考虑对原始代码进行一系列修改,并判断每项修改是否会导致编译错误。

以下是需要判断的修改项:

1. 将 totalBooks 变量改为非静态

修改内容:移除Library类中totalBooks变量的static修饰符。
分析:我们需要检查totalBooks变量在何处被调用。它只在实例方法addABook中被访问。实例方法允许访问实例变量。因此,这项修改是允许的,代码可以编译。

2. 将 lastBookTitle 方法改为非静态

修改内容:移除Book类中lastBookTitle方法的static修饰符,使其成为实例方法。
分析:我们需要判断在实例方法lastBookTitle中访问的内容是否合法。该方法内部只访问了静态变量last。实例可以访问静态变量。因此,这项修改是允许的,代码可以编译。

3. 将 addABook 方法改为静态

修改内容:为Library类中的addABook方法添加static关键字。
分析:这项修改不允许。静态方法中不能访问实例变量。addABook方法内部访问了实例变量index。直观上理解,我们可以通过类名(如Library.addABook)调用静态方法,但类本身并不知道任何特定实例的index值。因此,这会导致编译错误。

4. 将 last 变量改为非静态

修改内容:移除Book类中last变量的static修饰符,使其成为实例变量。
分析last变量在两个地方被访问:

  • 在构造函数中:构造函数中访问实例变量是允许的。
  • 在静态方法lastBookTitle中:这里会出现问题。静态方法不能访问实例变量。因此,这项修改会导致编译错误。

5. 将 library 变量改为静态

修改内容:为Book类中的library变量添加static关键字。
分析:这是一个有趣的情况。

  • 在构造函数中设置library = null是允许的,因为构造函数中可以访问静态变量。
  • addABook方法中,通过一个Book实例(b.library = this;)来访问静态变量library,这在语法上是允许的,代码可以编译。
  • 然而,这不是良好的编程实践。为了代码清晰,访问静态变量时应该使用类名(如Book.library),而不是通过实例。

第一部分内容具有一定挑战性,恭喜你完成!接下来,让我们进入第二部分。


第三部分:程序执行与输出 📝

在第二部分,我们将使用之前的BookLibrary类,分析下面主方法的输出。如果某行代码导致错误,请写出具体的错误原因,然后继续执行后续代码。

为了清晰地分析,我们可以画出对象和静态变量的状态图。以下是代码执行的逐步分析:

初始状态Book.last 被初始化为 nullLibrary.totalBooks 被初始化为 0

  1. System.out.println(Library.totalBooks);

    • 通过类名访问静态变量totalBooks,其值为0
    • 输出:0
  2. System.out.println(Book.lastBookTitle());

    • 通过类名调用静态方法lastBookTitle()。该方法尝试返回last.title
    • 此时lastnull,尝试访问null对象的属性(title)会抛出 NullPointerException
    • 输出:错误(运行时错误)
  3. System.out.println(Book.getTitle());

    • 尝试通过类名Book调用实例方法getTitle()。这是不允许的。
    • 输出:错误(编译错误)

创建两个Book对象

  • Book goneGirl = new Book(“Gone Girl”);
    • 设置goneGirl.title = “Gone Girl”
    • 设置静态变量last = this(即指向goneGirl对象)。
    • 设置goneGirl.library = null
  • Book fightClub = new Book(“Fight Club”);
    • 设置fightClub.title = “Fight Club”
    • 设置静态变量last = this(现在last指向fightClub对象,覆盖了之前指向goneGirl的引用)。
    • 设置fightClub.library = null
  1. System.out.println(goneGirl.getTitle());

    • 访问goneGirl实例的title
    • 输出:Gone Girl
  2. System.out.println(Book.lastBookTitle());

    • 调用静态方法lastBookTitle(),它返回last.title
    • 此时last指向fightClub,所以返回fightClub.title
    • 输出:Fight Club
  3. System.out.println(fightClub.lastBookTitle());

    • 通过实例fightClub调用静态方法lastBookTitle()。这与通过类名调用效果相同,返回last.title(即Fight Club)。
    • 输出:Fight Club
  4. System.out.println(goneGirl.last.title);

    • goneGirl.last 访问的是静态变量 last,该变量目前指向 fightClub 对象。
    • 因此,这相当于访问 fightClub.title
    • 输出:Fight Club

创建两个Library对象并添加书籍

  • Library a = new Library(1); 创建长度为1的数组,a.index = 0
  • Library b = new Library(2); 创建长度为2的数组,b.index = 0
  1. a.addABook(goneGirl);

    • goneGirl加入数组a.books[0]
    • a.index 自增为 1
    • 静态变量 Library.totalBooks 自增为 1
    • 设置 goneGirl.library = a(指向图书馆A)。
  2. System.out.println(a.index + ” ” + a.totalBooks);

    • a.index 是实例变量,值为 1
    • a.totalBooks 通过实例访问静态变量(允许但不推荐),值为 1
    • 输出:1 1
  3. a.totalBooks = 0;

    • 通过实例a将静态变量totalBooks设置为0
  4. b.addABook(fightClub);

    • fightClub加入数组b.books[0]
    • b.index 自增为 1
    • 静态变量 Library.totalBooks 自增为 1(从0变为1)。
    • 设置 fightClub.library = b
  5. b.addABook(goneGirl);

    • goneGirl加入数组b.books[1]
    • b.index 自增为 2
    • 静态变量 Library.totalBooks 自增为 2
    • 设置 goneGirl.library = b(现在goneGirl的图书馆从a变成了b)。
  6. System.out.println(b.index + ” ” + b.totalBooks);

    • b.index 是实例变量,值为 2
    • b.totalBooks 是静态变量,当前值为 2
    • 输出:2 2
  7. System.out.println(goneGirl.library.books[0].getTitle());

    • goneGirl.libraryb
    • b.books[0]fightClub
    • fightClub.getTitle() 返回 ”Fight Club”
    • 输出:Fight Club


总结 🎯

本节课我们一起深入学习了Java中static关键字的核心概念。我们通过分析代码修改,明确了静态成员与实例成员在访问权限上的根本区别:类名只能访问静态成员,而实例可以访问所有成员。随后,我们通过跟踪一段复杂程序的执行过程,观察了静态变量被所有实例共享的特性如何影响程序状态,并练习了如何预测包含静态成员的代码的输出结果。掌握这些概念对于理解面向对象程序的设计与行为至关重要。

6:内容回顾 🧠

在本节课中,我们将回顾 CS 61B 讨论课 03 的核心概念,包括变量的作用域、static 关键字、链表和数组。我们将通过简单的比喻和示例来解释这些概念,确保初学者能够理解。


概述 📋

本次内容回顾将涵盖以下几个关键主题:

  1. “等号黄金法则”:理解 Java 中变量赋值的工作原理。
  2. 原始类型与引用类型:区分两种变量类型及其在内存中的存储方式。
  3. 静态与实例:理解 static 关键字以及它与类及对象实例的关系。
  4. 数组:学习数组的基本结构、索引和限制。
  5. 链表:了解链表的结构、操作及其与数组的区别。

等号黄金法则 ⚖️

上一节我们介绍了课程概述,本节中我们来看看 Java 中变量赋值的核心规则——“等号黄金法则”。

该法则指出:给定变量 yx,执行 y = x 会将 x 的所有比特位复制到 y 中。这意味着 Java 是按值传递的。当你调用一个函数并传入参数时,被调用的函数会收到这些参数的精确副本,并绑定到它自己的局部变量上。

为了便于理解,可以将 Java 中的变量想象成一个个小盒子。盒子里装的是什么“值”,取决于变量的类型。


原始类型与引用类型 📦

理解了赋值的基本规则后,我们需要知道 Java 中有两种主要的变量类型,它们决定了“盒子”里装的是什么。

在 Java 中,变量分为两大类:

  • 原始类型:变量在内存中的位置直接存储了特定字节数表示的值。Java 中只有 8 种原始类型。

    • 示例int, float, boolean, char, double, long, byte
  • 引用类型:变量在内存中的位置存储的是一个内存地址(指针),该地址指向实际对象所在的位置。所有对象都存储在内存地址中。

    • 示例String, 数组, LinkedList, 自定义类(如 Dog)等。

一个有用的比喻是:引用类型就像通讯录中的家庭住址。地址本身告诉你房子在哪里,但它并不是房子本身。同样,引用变量存储的是对象的“地址”(指针),而不是对象本身。


两种类型的赋值差异

根据“等号黄金法则”,这两种类型的赋值行为不同:

  • 原始类型赋值是直接复制值

    • 例如:int x = 5; 变量 x 的“盒子”里直接存放了值 5
    • 代码表示:x 的盒子 = 5
  • 引用类型赋值是浅拷贝指针

    • 复制的是内存地址(指针),内存中的对象本身并没有被复制。
    • 例如:对于一个数组变量 r,它的“盒子”里存放的是一个指向数组对象的箭头(地址)。

示例分析

考虑以下代码:

int x = 5;
int[] r = {1, 2, 3, 5};
  • x 是原始类型,其盒子直接包含 5
  • r 是引用类型,其盒子包含一个指向内存中数组 {1, 2, 3, 5} 的指针。

现在,假设有一个函数:

void doSomething(int y, int[] other) {
    y = 9;
    other[2] = 4;
}

我们调用 doSomething(x, r);

  • 参数 y 获得了 x5 的副本。y = 9; 只改变了 y 自己盒子里的值,x 仍然是 5xy 是独立的。
  • 参数 other 获得了 r 的指针副本。因此,otherr 指向同一个数组对象。执行 other[2] = 4; 会修改该共享数组索引 2 位置的值。所以,r[2] 也变成了 4

静态(Static)与实例 🔧

上一节我们探讨了变量类型,本节我们来看看另一个容易混淆的概念:static 关键字。

static 用于修饰类中的变量和方法,它表示该成员属于类本身,而不是类的任何一个具体实例。

以下是静态成员与实例成员的区别:

  • 静态变量和方法

    • 属于整个类,被所有实例共享。
    • 比喻:所有 CS 61B 学生共享同一位教授(static 变量)。如果教授的姓名更改了,对所有学生都生效。
    • 无需创建类的对象实例即可访问(通过类名访问,如 ClassName.staticVariable)。
  • 实例变量和方法

    • 属于每个具体的对象实例。
    • 比喻:每个 CS 61B 学生有自己的学号(实例变量)。更改一个学生的学号,不会影响其他学生的学号。
    • 必须通过类的某个实例对象来访问(如 studentInstance.ID)。

this 关键字

this 是一个特殊的关键字,在非静态(实例)方法内部使用,它指向当前正在执行该方法的对象实例。可以把它理解为 Python 中的 self

  • 在静态方法中不能使用 this,因为静态方法不依赖于任何特定实例。
  • 在实例方法中可以引用静态变量,但不推荐这样做,最好通过类名或专门的静态方法来操作静态变量。

数组 📊

现在,让我们转向具体的数据结构。首先介绍数组,它是一种基础且重要的存储方式。

数组是一种数据结构,只能存储相同类型(原始类型或引用类型)的元素。

  • 索引:使用 array[i] 访问第 i 个位置的元素。Java 数组是零索引的(第一个元素索引为 0)。
  • 多维数组:例如二维数组 int[][] a = new int[3][2]; 可以看作 3 行 2 列的网格。通过 a[行][列] 访问元素。
  • 数组中的类型
    • 原始类型数组:值直接存储在数组的“格子”里。
    • 引用类型数组:每个“格子”存储的是指向对象的指针。
    • 重要:数组内所有元素必须是同一类型。
  • 固定长度:数组一旦创建,其长度就固定了,无法直接扩展或缩短。
    • 调整大小:需要创建一个新数组,并将旧数组的所有元素复制过去。

链表 🔗

与数组不同,链表提供了更灵活的动态存储方式。本节我们来看看链表的基本概念。

链表是由节点组成的模块化列表。每个节点包含两部分:

  1. 值(value
  2. 指向下一个节点的指针(next

与数组对比,链表有以下特点:

  • 访问元素:不能像数组那样直接用索引 [i] 访问。必须使用类似 list.get(i) 的方法,从链表头部开始,沿着 next 指针逐个节点遍历,直到第 i 个位置。这对于长链表来说比数组直接访问要慢。
  • 动态大小:链表可以通过改变节点的 next 指针来轻松地扩展(添加节点)或缩短(删除节点),无需像数组那样复制所有数据。
  • 哨兵节点:这是一种特殊的节点,通常作为空占位符,用于简化在链表头部或尾部添加/删除节点的操作。
    • 循环双向链表的实现中,哨兵节点的 next 指针指向第一个节点,prev 指针指向最后一个节点。

总结 🎯

本节课我们一起回顾了 CS 61B 讨论课 03 的核心内容:

  1. 等号黄金法则:理解了 Java 按值传递的本质,以及赋值操作如何复制“比特位”。
  2. 类型区分:掌握了原始类型(直接存储值)与引用类型(存储指向对象的指针)的关键区别及其在函数调用中的影响。
  3. Static 的作用域:清楚了 static 成员属于类且被所有实例共享,而实例成员属于各个独立对象。同时了解了 this 关键字的用途。
  4. 数组的特性:学习了数组的索引、固定长度和同质类型要求,明白了其内存存储方式。
  5. 链表的结构:认识了链表由节点构成、动态调整大小的能力,以及它与数组在访问效率上的差异,并初步了解了哨兵节点的概念。

这些概念是后续学习更复杂数据结构和算法的基础,请务必理解透彻。

7:静态与实例变量解析 🧩

在本节课中,我们将通过分析一个关于Pokemon类的Java程序,来学习静态变量、实例变量以及方法调用的核心概念。我们将逐步执行代码,并绘制环境图来可视化变量的状态变化。


概述 📋

我们将分析一个包含Pokemon类的Java程序。该类具有实例变量(如namelevel)、静态变量(如trainerpartySize),以及实例方法和静态方法。通过逐步执行main方法,我们将理解不同变量的作用域、生命周期以及它们如何被修改。


第一部分:绘制环境图与代码执行 🗺️

为了清晰地追踪变量状态,我们首先绘制一个环境图。图中将包含main方法的局部变量、创建的Pokemon实例对象,以及Pokemon类的静态变量区。

以下是Pokemon类的关键部分:

public class Pokemon {
    private String name;
    private int level;
    private static String trainer = "Ash";
    private static int partySize = 0;

    // 构造方法、main方法、change静态方法、printStats实例方法...
}

main方法开始前,静态变量已初始化:trainer为“Ash”,partySize为0。

逐步执行main方法

  1. 第14行:创建第一个Pokemon

    Pokemon p = new Pokemon("Pikachu", 17);
    
    • 创建一个新的Pokemon实例,其name为“Pikachu”,level为17。
    • 变量p指向该对象。
    • 执行构造方法,partySize增加1,变为1。
  2. 第15行:创建第二个Pokemon

    Pokemon j = new Pokemon("Jolteon", 99);
    
    • 创建另一个Pokemon实例,其name为“Jolteon”,level为99。
    • 变量j指向该对象。
    • 执行构造方法,partySize再次增加1,变为2。
  3. 第16行:第一次打印

    System.out.println("party size: " + Pokemon.partySize);
    
    • 直接通过类名Pokemon访问静态变量partySize,其值为2。
    • 输出: party size: 2
  4. 第17行:调用实例方法printStats

    p.printStats();
    
    • 在实例p上调用printStats()方法。该方法打印该实例的namelevel和静态变量trainer
    • p.name 是 “Pikachu”, p.level 是 17, trainer 是 “Ash”。
    • 输出: Pikachu 17 Ash

上一节我们介绍了对象的创建和实例方法的调用,本节中我们来看看当方法涉及参数传递和变量作用域时会发生什么。

  1. 第18-19行:调用静态方法change

    int level = 18;
    Pokemon.change(p, level);
    
    • 声明局部变量level并赋值为18。
    • 调用静态方法change,传入实例p和变量level的值。

    为了理解change方法内部的操作,我们需要为其创建新的局部变量框架:

    • 参数传递:
      • 参数poke接收的是指向p所指向对象的指针的副本。因此,poke也指向内存中同一个“Pikachu”对象。
      • 参数level是基本类型int,因此接收的是值18的副本。
    • change方法内部操作(第27-30行):
      • poke.level = level;:这将修改poke指向的对象(即“Pikachu”)的level属性。由于poke.level指向实例变量,而传入的level参数值为18,因此“Pikachu”的level被改为18。
      • level = 50;:这修改的是change方法局部变量level的值,将其从18改为50。这并不影响main方法中的level变量,也不影响任何Pokemon对象的level
      • poke = new Pokemon("Luxray", 1);:这创建了一个新的Pokemon实例(“Luxray”, 1)。poke局部变量现在改为指向这个新对象。注意:不会改变main方法中p的指向,p仍然指向原来的“Pikachu”对象。同时,构造方法使partySize增加为3。
      • poke.trainer = "Team Rocket";trainer是静态变量。通过任何实例(此处是poke指向的新“Luxray”对象)修改静态变量,都会影响整个类。因此,静态变量trainer被改为“Team Rocket”。
    • 方法返回: change方法结束,其局部变量pokelevel被销毁。新创建的“Luxray”对象由于没有其他引用指向它,随后会被Java垃圾回收。
  2. 第20行:再次调用p.printStats()

    p.printStats();
    
    • 再次在实例p上调用printStats()
    • p.name 仍是 “Pikachu”。
    • p.level 在上一步中被改为 18。
    • trainer 静态变量在上一步中被改为 “Team Rocket”。
    • 输出: Pikachu 18 Team Rocket
  3. 第21-22行:直接修改静态变量和通过实例修改

    Pokemon.trainer = "Ash";
    j.trainer = "Cynthia";
    
    • Pokemon.trainer = "Ash";:直接通过类名将静态变量trainer改回“Ash”。
    • j.trainer = "Cynthia";:通过实例j访问并修改静态变量trainer。这同样会修改整个类的静态变量,将其从“Ash”改为“Cynthia”。
  4. 第23行:第三次调用p.printStats()

    p.printStats();
    
    • p.name 仍是 “Pikachu”。
    • p.level 仍是 18。
    • trainer 静态变量现在是 “Cynthia”。
    • 输出: Pikachu 18 Cynthia

第一部分输出总结

以下是执行main方法后控制台的完整输出:

party size: 2
Pikachu 17 Ash
Pikachu 18 Team Rocket
Pikachu 18 Cynthia

第二部分:理解变量作用域 🔍

现在,我们来回答一个关于变量作用域的具体问题。

问题:change方法的第28行level = 50;中,level指的是哪个变量?

分析与答案:
change方法内部,当引用level时,Java首先在方法的局部作用域内查找。参数level就是一个局部变量。因此,第28行的level指的是作为参数传入change方法的局部变量level。这行代码改变了这个局部变量的值,但不会影响main方法中的level变量,也不会影响任何Pokemon对象的实例变量level


第三部分:辨析静态方法与实例方法调用 ⚠️

最后,我们探讨一个关于方法调用的关键问题。

问题: 如果在main方法的末尾尝试调用Pokemon.printStats(),会发生什么?

分析与答案:
printStats()方法在类中被定义为实例方法(没有static关键字)。实例方法的设计必须在一个具体的对象实例上调用,因为其方法体中通常会访问该实例特有的属性(如this.name, this.level)。

Pokemon.printStats()这种调用方式试图在类本身上调用实例方法。类Pokemon是一个蓝图,它本身并没有namelevel这样的具体值。因此,Java编译器或运行时无法确定printStats()方法中的namelevel应该指向什么。

所以,这样的调用会导致编译错误(或某些语言中的运行时错误),错误信息大致意为“无法从静态上下文中引用非静态方法”。


总结 🎯

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

  1. 静态变量 vs 实例变量: 静态变量属于类,所有实例共享同一份拷贝;实例变量属于对象,每个对象拥有独立拷贝。
  2. 环境图可视化: 通过绘制环境图,可以清晰追踪对象创建、引用传递和变量状态变化的过程。
  3. 参数传递: 对象引用传递的是指针副本,修改对象内容会影响原对象;基本类型传递的是值副本。
  4. 变量作用域: 局部变量优先于外部变量,方法内部的操作可能只影响局部副本。
  5. 方法调用限制: 实例方法必须在对象实例上调用,不能直接在类上调用。

通过这个具体的例子,你应该对Java中静态与实例成员的区别、方法调用机制以及参数传递行为有了更直观的理解。

8:数组旋转问题详解 🔄

在本节课中,我们将学习如何实现一个数组旋转函数。该函数接收一个数组和一个整数 k,返回一个新数组,其内容相对于原数组向右循环移动了 k 个位置。如果 k 为负数,则向左移动。我们将逐步解析问题,并使用模运算来简化处理过程。


问题描述与示例

我们需要编写一个函数 rotate。给定一个数组 A 和一个整数 k,函数返回一个新数组。新数组的内容是原数组元素向右移动 k 个位置后的结果,必要时元素会从数组末尾循环回到开头。

例如,如果数组 a 包含值 [0, 1, 2, 3, 4, 5, 6, 7],且 k = 12,那么调用 rotate(a, k) 后返回的数组如下所示:

原数组: [0, 1, 2, 3, 4, 5, 6, 7]

旋转后数组: [4, 5, 6, 7, 0, 1, 2, 3]

让我们以元素 0 为例。我们希望将 0 向右移动 12 个位置。由于数组长度为 8,移动 12 次后,0 最终会落在索引 4 的位置上。这个过程对所有元素都适用:每个元素都向右移动,并根据 k 值计算其最终位置,必要时进行回绕。


核心挑战与思路

上一节我们明确了问题目标,本节中我们来看看实现时需要解决的核心挑战。

参数 k 可以是任意大或任意小的正负数。如果 k 为负,则向左移动 |k| 个位置。调用 rotate 后,原数组 a 应保持不变。

一个关键的提示是:模运算符 % 会很有用。需要注意的是,在 Java 中,负数的模运算结果仍然是负数。


处理正数 k 与模运算

k 为正数且大于数组长度时,直接移动 k 位没有意义,因为元素会移出数组边界。这时就需要“回绕”。

观察示例,k = 12,数组长度 n = 8。元素 0 从索引 0 出发,移动 12 位后到达索引 4。实际上,移动 12 位等价于移动 12 % 8 = 4 位。模运算帮助我们得到了一个在 [0, n) 范围内的有效位移量。

因此,我们可以定义一个有效右移量
int rightShift = k % a.length;

这个公式将任意大的 k 值转换为一个在 0n-1 之间的数,代表了等效的循环右移步数。


处理负数 k

上一节我们利用模运算处理了正数位移,本节中我们需要解决负数位移的情况。

根据提示,负数的模运算结果仍为负数。例如,若 k = -1,则 rightShift = -1 % 8 在 Java 中结果为 -1

但向左移动 1 位,在效果上等价于向右移动 n - 1 位。以数组 [0,1,2,3,4,5,6,7] 为例,左移1位(k=-1)后得到 [1,2,3,4,5,6,7,0]。观察元素 0,它从开头移动到了末尾,相当于向右移动了 7 位。

因此,当计算出的 rightShift 为负数时,我们可以通过加上数组长度 n 来将其转换为一个等效的正向右移量:
if (rightShift < 0) { rightShift += a.length; }

这样,对于 k = -1,我们先得到 rightShift = -1,然后加上 8,得到 rightShift = 7。这正确地表示了等效的右移步数。


算法实现步骤

明确了位移量的计算方法后,现在我们可以开始构建新数组了。

我们将创建一个与原数组 a 长度相同的新数组 newArr。然后,遍历原数组的每个元素,计算它在新数组中的目标位置,并放入。

以下是填充新数组的具体步骤:

  1. 计算有效右移量
    int rightShift = k % a.length;
    if (rightShift < 0) { rightShift += a.length; }

  2. 创建新数组
    int[] newArr = new int[a.length];

  3. 遍历并放置元素
    对于原数组 a 中的每一个索引 i0 <= i < a.length):

    • 计算目标索引:(i + rightShift) % a.length
    • a[i] 的值赋给 newArr[目标索引]

代码描述

public static int[] rotate(int[] a, int k) {
    int n = a.length;
    int rightShift = k % n;
    if (rightShift < 0) {
        rightShift += n;
    }
    int[] newArr = new int[n];
    for (int i = 0; i < n; i++) {
        int newIndex = (i + rightShift) % n;
        newArr[newIndex] = a[i];
    }
    return newArr;
}

总结

本节课中我们一起学习了数组旋转算法的实现。我们首先理解了问题要求:根据给定的位移量 k 循环移动数组元素。然后,我们利用模运算 % 来处理超出数组长度的位移,并通过判断和修正将负数位移统一转换为等效的正数右移量。最后,我们通过遍历原数组,使用公式 (i + rightShift) % n 计算每个元素在新数组中的正确位置,从而完成了新数组的构建。整个算法保证了原数组不被修改,同时高效地实现了循环旋转功能。

9:4:Spring 2023 讨论 03 问题 3

在本节中,我们将一起分析问题三“基本方向”。我们将逐步执行给定的代码,并绘制出对应的盒指针图。我们将使用一个自定义的双向链表节点类 DLLStringNode,它包含三个实例变量:prevsnext

概述

我们将分析一段操作 DLLStringNode 对象的 Java 代码。DLLStringNode 是一个双向链表节点,其构造函数接收三个参数:prev(前驱节点)、s(字符串数据)和 next(后继节点)。我们的目标是理解代码执行后,内存中各个对象之间的引用关系,并绘制出最终的盒指针图。过程中会涉及引用赋值、节点插入以及垃圾回收的概念。

节点结构与绘图约定

首先,我们明确 DLLStringNode 的结构。为了方便绘图,我们将一个节点表示为包含三个部分的盒子:prevsnextprevnext 是指向其他 DLLStringNode 对象的引用,我们用箭头表示。字符串 s 是基本数据类型(或不可变对象),为节省空间,我们直接将其值写在 s 的框内,但请理解在概念上它也是一个独立的对象。

节点图示例如下:

     +-------+
prev |   •---+--->
     +-------+
  s  | "Hi"  |
     +-------+
next |   •---+--->
     +-------+

箭头 •---> 表示指向另一个节点对象,而 null 引用则用穿过框的斜线 / 表示。

逐步执行与绘图

现在,让我们开始逐行执行 main 方法中的代码,并绘制相应的盒指针图。

第10行:创建第一个节点

DLLStringNode L = new DLLStringNode(null, "eat", null);

这行代码创建了第一个节点,其 prevnext 均为 nulls"eat"。变量 L 指向这个新节点。

L
 •
 |
 v
+-------+       +-------+
| prev  | null  |  s    | "eat"
|   •---+------>|       |
+-------+       +-------+
| next  | null  |       |
|   •---+------>|       |
+-------+       +-------+

第11行:在链表头部插入节点

L = new DLLStringNode(null, "bananas", L);

这行代码创建了一个新节点(s="bananas", prev=null),并将其 next 指向当前 L 所指向的节点(即 "eat" 节点)。然后,更新 L 使其指向这个新节点。这相当于在链表头部插入。

L(更新后)
 •
 |
 v
+-------+       +-------+       +-------+
| prev  | null  |  s    |"bananas"      | prev  | null  |  s    | "eat"
|   •---+------>|       |       |   •---+------>|       |
+-------+       +-------+       +-------+
| next  |   •---+------>| next  | null  |       |
|   •---+------>|   •---+------>|       |
+-------+       +-------+       +-------+

第12行:再次在头部插入

L = new DLLStringNode(null, "never", L);

同理,创建新节点(s="never"),其 next 指向当前链表头("bananas" 节点),然后 L 指向新节点。

L(更新后)
 •
 |
 v
+-------+       +-------+       +-------+       +-------+
| prev  | null  |  s    |"never"        | prev  | null  |  s    |"bananas"       | prev  | null  |  s    | "eat"
|   •---+------>|       |       |   •---+------>|       |       |   •---+------>|       |
+-------+       +-------+       +-------+       +-------+
| next  |   •---+------>| next  |   •---+------>| next  | null  |       |
|   •---+------>|   •---+------>|   •---+------>|       |
+-------+       +-------+       +-------+       +-------+

第13行:继续在头部插入

L = new DLLStringNode(null, "sometimes", L);

再次执行相同操作,插入 "sometimes" 节点。

L(更新后)
 •
 |
 v
["sometimes"] -> ["never"] -> ["bananas"] -> ["eat"]
 (prev=null)     (prev=null)  (prev=null)   (prev=null)

第14行:创建变量 M

DLLStringNode M = L.next;

L 当前指向 "sometimes" 节点。L.next"never" 节点。因此,变量 M 也指向 "never" 节点。

M
 •
 |
 v
["never"]

第15-16行:创建另一个链表 R

DLLStringNode R = new DLLStringNode(null, "shredded", null);
R = new DLLStringNode(null, "wheat", R);

这两行代码创建了另一个以 R 为头指针的链表。首先创建 "shredded" 节点,然后在其头部插入 "wheat" 节点。执行后结构如下:

R
 •
 |
 v
["wheat"] -> ["shredded"]
(prev=null)  (prev=null)

第17行:操作 R 的 next 指针

R.next.next = R;

让我们分解这个操作:

  1. R 指向 "wheat" 节点。
  2. R.next"wheat" 节点的 next,指向 "shredded" 节点。
  3. R.next.next"shredded" 节点的 next。我们将其赋值为 R,即指向 "wheat" 节点。

这形成了一个从 "shredded" 指回 "wheat" 的环。

R
 •
 |
 v
+-------+       +-------+
| prev  | null  |  s    |"wheat" <---+
|   •---+------>|       |            |
+-------+       +-------+            |
| next  |   •---+------>|            |
|   •---+------>|       |            |
+-------+       +-------+            |
                | prev  | null  |  s    |"shredded"|
                |   •---+------>|       |          |
                +-------+       +-------+          |
                | next  |   •----------------------+
                |   •---+------>|
                +-------+       +-------+

第18行:连接两个链表

M.next.next.next = R.next;

分解操作:

  1. M 指向 "never" 节点。
  2. M.next"never" 节点的 next,指向 "bananas" 节点。
  3. M.next.next"bananas" 节点的 next,指向 "eat" 节点。
  4. M.next.next.next"eat" 节点的 next。我们将其赋值为 R.next
  5. R.next"wheat" 节点的 next,指向 "shredded" 节点。

因此,这行代码将 "eat" 节点的 next 指向了 "shredded" 节点,将两个链表连接起来。同时,"bananas" 节点现在没有任何变量引用它(L 指向 "sometimes""never"next 指向 "bananas",但 "bananas"next 指向的 "eat" 被重新赋值,"bananas" 本身未被任何变量直接引用,在图中成为孤岛)。Java 的垃圾回收器会回收它。我们在图中将其移除。

["sometimes"] -> ["never"] -> ["eat"] -> ["shredded"]
                                     ^         |
                                     |         |
                                     +----["wheat"]

第19行:调整链表结构

L.next.next = L.next.next.next;

分解操作:

  1. L 指向 "sometimes" 节点。
  2. L.next"sometimes" 节点的 next,指向 "never" 节点。
  3. L.next.next"never" 节点的 next。我们将其赋值为 L.next.next.next
  4. L.next.next.next 需要计算:从 "never" 节点开始,next 目前指向 "eat"(上一步之后),next.next"eat"next,指向 "shredded"

所以,这行代码将 "never" 节点的 next 直接指向了 "shredded" 节点。这使得 "eat" 节点暂时失去引用(但后面还会被引用)。同时,"sometimes" 节点现在也失去了引用(L 即将改变,M 指向 "never")。"sometimes" 节点将被垃圾回收。

L
 •
 |
 v
["sometimes"]  (将被回收)
["never"] -> ["shredded"]
         ^         |
         |         |
         +----["wheat"]
["eat"] (暂时失去引用)

第20行:更新 L

L = M.next;

M 指向 "never" 节点。M.next 现在指向 "shredded" 节点(根据上一步)。因此,L 现在指向 "shredded" 节点。

L
 •
 |
 v
["shredded"]

第21行:操作 prev 指针

M.next.next.prev = R;

这是代码中第一次操作 prev 指针。分解操作:

  1. M 指向 "never" 节点。
  2. M.next"never" 节点的 next,指向 "shredded" 节点。
  3. M.next.next"shredded" 节点的 next,指向 "wheat" 节点(见第17行形成的环)。
  4. M.next.next.prev"wheat" 节点的 prev。我们将其赋值为 R
  5. R 指向 "wheat" 节点。

因此,这行代码将 "wheat" 节点的 prev 指向它自己。

["never"] -> ["shredded"] -> ["wheat"] <-+
      ^         |              ^         |
      |         |              |         |
      +---------+--------------+---------+

第22行:设置 L 的 prev

L.prev = M;

L 指向 "shredded" 节点。将其 prev 指向 M 所指向的节点,即 "never" 节点。

["never"] <-> ["shredded"] -> ["wheat"] <-+
      ^         |              ^         |
      |         |              |         |
      +---------+--------------+---------+

第23行:设置 L.next 的 prev

L.next.prev = L;

L 指向 "shredded" 节点。L.next"shredded" 节点的 next,指向 "wheat" 节点。因此,这行代码将 "wheat" 节点的 prev 指向 "shredded" 节点(覆盖了第21行的自指赋值)。

["never"] <-> ["shredded"] <-> ["wheat"] <-+
      ^         |              ^         |
      |         |              |         |
      +---------+--------------+---------+

第24行:设置 R 的 prev

R.prev = L.next.next;

分解操作:

  1. R 指向 "wheat" 节点。
  2. L 指向 "shredded" 节点。
  3. L.next"shredded" 节点的 next,指向 "wheat" 节点。
  4. L.next.next"wheat" 节点的 next,指向 "shredded" 节点(见第17行的环)。

因此,这行代码将 "wheat" 节点的 prev 指向 "shredded" 节点。这与上一步的结果一致,是冗余操作。
最终,"wheat" 节点的 prev 指向 "shredded"

最终结构与总结

执行完所有代码后,内存中存活的对象及其引用关系如下(忽略被垃圾回收的 "bananas""sometimes" 节点):

变量引用:
L -> ["shredded"]
M -> ["never"]
R -> ["wheat"]

节点关系:
["never"] <-> ["shredded"] <-> ["wheat"]
      ^                           |
      |                           |
      +---------------------------+

M"never")开始,跟随 next 指针:"never" -> "shredded" -> "wheat" -> "shredded"... 形成了一个包含三个节点的循环链表。

本节课中,我们一起学习了如何通过盒指针图来分析复杂的链表操作。我们逐步跟踪了引用变量的赋值和节点字段的修改,观察了链表结构的动态变化,包括节点的插入、链表的连接、循环结构的形成以及垃圾回收的影响。理解这些操作对于掌握链表数据结构和调试相关代码至关重要。本题的趣味之处在于,从 M 开始遍历 next 指针得到的字符串序列 "never" -> "shredded" -> "wheat",正好是英语中记忆基本方向(北、东、南、西)的常用口诀 “Never Eat Shredded Wheat” 的一部分。

10:5 - 将单链表元素填充到二维数组

概述

在本节课中,我们将学习如何将一个循环哨兵单链表中的元素,按行主序填充到一个二维整数数组中。我们将理解“行主序”的概念,并编写递归函数来完成这一转换。


问题描述与核心概念

我们被要求考虑一个使用循环哨兵实现的单链表。对于前 rows * columns 个节点,我们需要将每个节点的 item 按行主序放入一个 rowscolumns 列的二维数组中。

行主序意味着元素按顺序添加,先填满一整行,再移动到下一行。

例如,如果单链表包含元素 5 -> 3 -> 7 -> 2 -> 8,并且我们有一个 2 行 3 列的网格,调用 gridtify 应返回以下网格:

[ [5, 3, 7],
  [2, 8, 0] ]

注意,单链表包含的元素数量可能多于或少于二维数组的容量。未填充的位置将由 Java 数组的默认值填充(对于 int 数组是 0)。


数据结构与初始设置

我们有一个 SLList 类和一个 Node 内部类。每个 Node 包含一个 int item 和一个指向下一个节点的 next 指针。这是一个循环哨兵实现,意味着最后一个节点的 next 指向哨兵节点,而不是 null

我们的目标是实现 gridtify 方法,它接收 rowscolumns 两个参数,并返回填充好的二维数组。为了简化实现,我们还将使用一个辅助方法 gridtifyHelper


实现 gridtify 方法

gridtify 方法的主要任务是初始化二维数组,并启动递归填充过程。

上一节我们介绍了问题背景和数据结构,本节中我们来看看如何开始实现 gridtify 方法。

public int[][] gridtify(int rows, int columns) {
    int[][] grid = new int[rows][columns]; // 步骤1:初始化二维数组
    gridtifyHelper(grid, sentinel.next, 0); // 步骤2:调用辅助函数,从第一个有效节点开始
    return grid; // 步骤3:返回填充好的数组
}

以下是关键步骤的说明:

  1. 初始化数组:使用 new int[rows][columns] 创建一个指定行数和列数的二维 int 数组。所有元素默认初始化为 0
  2. 启动递归:调用辅助函数 gridtifyHelper,传入刚创建的 grid、链表的第一个有效节点(sentinel.next),以及已填充元素计数 0
  3. 返回结果:递归填充完成后,返回 grid 数组。

实现递归辅助函数 gridtifyHelper

gridtifyHelper 是完成实际填充工作的递归函数。它接收当前网格、当前节点和已填充数量作为参数。

上一节我们设置了递归的起点,本节中我们深入探讨递归函数的核心逻辑。

private void gridtifyHelper(int[][] grid, Node cur, int numFilled) {
    // 递归终止条件:如果已处理完所有节点或网格已满,则停止
    if (cur == sentinel || numFilled >= grid.length * grid[0].length) {
        return;
    }

    // 计算当前元素应放入的行和列
    int row = numFilled / grid[0].length;
    int col = numFilled % grid[0].length;

    // 将当前节点的值放入网格
    grid[row][col] = cur.item;

    // 递归处理下一个节点,已填充数量加1
    gridtifyHelper(grid, cur.next, numFilled + 1);
}

以下是该函数各部分的作用:

  • 终止条件:当 cur 指针回到哨兵节点(意味着链表已遍历完),或者 numFilled(已填充数量)达到网格总容量(行数 * 列数)时,递归结束。
  • 计算位置:这是关键步骤。我们利用 numFilled(从0开始)来计算当前元素在二维数组中的位置。
    • 行索引公式row = numFilled / 列数
    • 列索引公式col = numFilled % 列数
      这两个公式确保了元素按行主序填充。
  • 赋值:将当前节点 cur.item 的值放入计算出的 grid[row][col] 位置。
  • 递归调用:使用下一个节点 (cur.next) 和递增的 numFilled 调用自身,继续处理链表中的后续元素。

为什么需要辅助函数?

在实现中,我们使用了一个公有的 gridtify 方法和一个私有的 gridtifyHelper 方法。你可能会问,为什么不把所有逻辑都放在一个方法里?

上一节我们完成了核心算法的编写,本节我们来探讨这种设计模式的好处。

以下是使用辅助函数的主要原因:

  1. 接口简洁:对于使用 SLList 类的用户(客户端)来说,他们只需要关心 gridtify(rows, columns) 这个简单的接口。他们不需要了解链表内部节点、哨兵或递归计数器等实现细节。这提供了良好的抽象。
  2. 代码模块化与可维护性:将复杂的递归逻辑分离到辅助函数中,使主方法 gridtify 保持清晰简洁。这使代码更易于阅读、调试和修改。
  3. 隐藏实现细节:辅助函数通常是 private 的,这强制实施了封装原则。用户无法(也不应该)直接调用 gridtifyHelper 并传递诸如当前节点、已填充数量等内部状态参数,这防止了误用并保证了数据一致性。

总结

本节课中我们一起学习了如何将一个循环哨兵单链表的元素填充到二维数组中。我们掌握了以下核心内容:

  • 行主序填充的概念。
  • 利用 递归 遍历链表,这是处理链式结构的自然方式。
  • 使用公式 row = index / columnscol = index % columns 将一维索引映射到二维网格坐标。
  • 理解了通过 公有方法包装私有辅助函数 来提供清晰接口和良好封装性的重要设计模式。

这是一个综合性的问题,结合了链表操作、递归思维和数组索引计算,是理解递归和数据结构转换的绝佳练习。

11:从双链表中移除重复元素

在本教程中,我们将学习如何从一个双链表中移除所有重复的元素。我们将通过分析一个具体的编程问题来理解其背后的逻辑和实现步骤,并学习如何使用指针操作来安全地删除节点。

概述

本节我们将探讨一个双链表问题:移除所有重复的元素。核心挑战在于,重复的元素可能并不相邻,因此我们需要一种方法来识别并删除列表中所有重复出现的值。我们将使用嵌套循环的策略来遍历和比较元素,并通过调整节点指针来移除重复项。

问题分析与策略

首先,让我们思考如何在列表中查找重复项。一个直观的方法是使用两个嵌套循环。

  • 外层循环遍历列表中的每一个元素。
  • 内层循环检查当前元素之后的所有元素,看是否存在重复。

以下是这个思路的伪代码表示:

for (Node ref = sentinel.next; ref != sentinel; ref = ref.next) {
    for (Node checker = ref.next; checker != sentinel; checker = checker.next) {
        if (ref.item.equals(checker.item)) {
            // 找到重复项,需要移除checker指向的节点
        }
    }
}

在上面的代码中,ref代表我们当前正在检查的元素,checker则用于遍历ref之后的所有元素以寻找重复。

实现节点移除

当我们通过checker找到一个重复节点时,需要将其从双链表中移除。这涉及到调整其前驱节点和后继节点的指针,使其“跳过”当前节点。

假设我们有以下节点关系:A <-> B <-> C,其中B是我们要删除的重复节点(即checker指向的节点)。

我们需要进行两步指针操作:

  1. Anext指针指向CA.next = C
  2. Cprev指针指向AC.prev = A

完成这两步后,节点B就不再被列表中的任何节点引用,在Java中将被垃圾回收机制自动清理。

在代码中,如果我们将checker的前驱节点和后继节点分别存储在变量checkerPrevcheckerNext中,那么移除操作可以表示为:

checkerPrev.next = checkerNext; // 步骤1
checkerNext.prev = checkerPrev; // 步骤2

完整代码与流程整合

现在,我们将移除逻辑整合到之前的嵌套循环框架中。关键点在于,在内层循环中移除一个节点后,checker变量需要被正确地更新到下一个待检查的节点。

以下是整合后的核心代码逻辑:

Node ref = sentinel.next;
while (ref != sentinel) {
    Node checker = ref.next;
    while (checker != sentinel) {
        if (ref.item.equals(checker.item)) {
            // 找到重复,移除checker节点
            Node checkerPrev = checker.prev;
            Node checkerNext = checker.next;
            checkerPrev.next = checkerNext;
            checkerNext.prev = checkerPrev;
            // 将checker移动到被删除节点的下一个节点,继续循环
            checker = checkerNext;
        } else {
            // 不是重复,正常移动到下一个节点
            checker = checker.next;
        }
    }
    // 检查完与当前ref元素相关的所有可能重复项后,移动ref到下一个元素
    ref = ref.next;
}

总结

本节课我们一起学习了如何从双链表中移除所有重复元素。

我们首先分析了使用嵌套循环遍历和比较元素的策略。接着,我们深入探讨了双链表中删除节点的核心操作,即调整前驱和后继节点的指针以“绕过”要删除的节点。最后,我们将这些步骤整合成一个完整的解决方案。

记住,在处理链表问题时,绘制节点和指针的示意图是理解指针操作和验证逻辑的极佳方法。

12:2 - 2023春季考试第三级问题2解析 🧩

在本教程中,我们将学习如何解决一个链表分区问题。具体来说,我们需要将一个整数链表(IntList)尽可能均匀地分割成一个由多个链表组成的数组。我们将逐步分析问题,并通过绘制指针图来辅助理解,最终将其转化为代码。


概述

问题要求我们编写一个名为 partition 的方法。该方法接收一个 IntList 和一个整数 k,并返回一个 IntList 数组。数组的长度为 k,其中包含将原链表尽可能均匀分割后的 k 个子链表。我们可以假设链表长度能被 k 整除。题目提供了一个 reverse 方法,并提示我们应该反向构建每个子链表。


第一步:理解问题与反转链表

首先,我们注意到题目提供了 reverse 方法,并建议反向构建链表。查看代码框架,我们有一个数组变量、一个索引 i 和链表 L。一个合理的猜测是,我们首先应该反转输入的链表。

代码示例:

L = reverse(list);

反转后,链表 L 的头部将指向原链表的最后一个元素。例如,原链表 6 -> 5 -> 4 -> 3 -> 2 -> 1 反转后变为 1 -> 2 -> 3 -> 4 -> 5 -> 6


第二步:通过示例理解分区过程

为了更好地理解分区逻辑,我们通过一个具体例子来演示。假设 k = 2,链表为 1 -> 2 -> 3 -> 4 -> 5 -> 6(反转后)。初始时,结果数组 array 的两个位置均为 null,索引 i 从 0 开始。

以下是手动分区的步骤:

  1. 索引 i = 0:取出当前链表头节点 1。将 array[0](当前为 null)附加到 1 之后,然后将 array[0] 设置为以 1 为头的新链表。移动 L 到下一个节点 2,并将索引 i 更新为 (0 + 1) % 2 = 1
  2. 索引 i = 1:取出当前链表头节点 2。将 array[1](当前为 null)附加到 2 之后,然后将 array[1] 设置为以 2 为头的新链表。移动 L 到节点 3,并将索引 i 更新为 (1 + 1) % 2 = 0
  3. 索引 i = 0:取出节点 3。将当前 array[0](即链表 1)附加到 3 之后,然后将 array[0] 更新为以 3 为头的新链表(3 -> 1)。移动 L 到节点 4,更新索引 i1
  4. 重复此过程,依次处理节点 4, 5, 6

最终,array[0]5 -> 3 -> 1array[1]6 -> 4 -> 2。每个子链表包含3个元素,且顺序与原链表一致。


第三步:将过程转化为代码

现在,我们将上述逻辑转化为代码。关键在于在修改指针之前,保存必要的引用。

以下是核心步骤的代码实现:

public static IntList[] partition(IntList list, int k) {
    IntList[] array = new IntList[k];
    IntList L = reverse(list);
    int index = 0;

    while (L != null) {
        // 1. 保存当前数组索引位置的链表
        IntList prevAtIndex = array[index];

        // 2. 保存当前链表节点的下一个节点,防止丢失
        IntList next = L.rest;

        // 3. 将当前节点作为新头,连接到之前保存的链表前面
        array[index] = L;
        array[index].rest = prevAtIndex;

        // 4. 移动到链表的下一个节点
        L = next;

        // 5. 更新索引,使用取模运算实现循环
        index = (index + 1) % array.length;
    }
    return array;
}

代码解释:

  • prevAtIndex 保存了当前数组位置已构建的链表。
  • next 保存了当前处理节点的后续节点,这是关键步骤,防止在修改 L.rest 后丢失链表其余部分。
  • array[index] = Larray[index].rest = prevAtIndex 实现了将新节点“前置”到已有链表前面的操作。
  • 索引更新使用 (index + 1) % array.length 确保在数组范围内循环。

第四步:总结与考试技巧

本节课我们一起学习了如何解决链表均匀分区的问题。我们首先反转链表以便反向构建,然后通过循环遍历链表节点,依次将它们放入结果数组的不同位置,并使用取模运算实现索引的循环。

核心要点总结:

  1. 利用提示:题目给出的 reverse 方法是解题的关键起点。
  2. 保存引用:在修改链表指针前,务必保存后续节点的引用(如 next 变量),这是处理链表问题的常见技巧。
  3. 循环索引:使用 % 运算符实现索引在固定范围内的循环,是处理此类“轮转”分配问题的标准方法。
  4. 画图辅助:对于复杂的指针操作,绘制盒状指针图(box and pointer diagram)能极大地帮助理清思路,即使题目没有明确要求。

希望本教程能帮助你理解链表分区问题的解法。在编程考试中,先理清逻辑再动手编码,通常能事半功倍。祝你学习顺利!

13:3 - 盒状图与指针问题解析 🧩

在本节课中,我们将学习如何绘制和分析盒状图与指针图。这是一种用于可视化链表等数据结构内部连接关系的重要技能。掌握这项技能不仅有助于直接解答考试中的相关问题,更能帮助你理解和调试代码。

问题概述

我们将解析一个来自2023年春季CS61B考试第三级的问题。这个问题涉及一个名为 IntList 的链表结构,它包含两个属性:first(存储整数值)和 rest(指向下一个 IntList 节点的指针)。我们将通过逐步执行代码来绘制最终的指针图。

逐步解析

上一节我们介绍了问题的背景,本节中我们来看看具体的代码执行步骤。

步骤一:初始化链表 L1

首先,我们创建链表 L1,其值为 [1, 2, 3]。在盒状图中,每个节点被表示为一个包含 firstrest 两个框的盒子。rest 指针指向下一个节点,最后一个节点的 rest 指向 null

IntList L1 = IntList.list(1, 2, 3);

绘制结果如下:

  • L1 指向第一个节点,其 first 为 1,rest 指向下一个节点。
  • 第二个节点的 first 为 2,rest 指向第三个节点。
  • 第三个节点的 first 为 3,restnull

步骤二:创建链表 L2 并建立连接

接下来,我们创建链表 L2,其 first 值为 4,并将其 rest 设置为 L1.rest。这意味着 L2rest 指针将指向 L1 链表的第二个节点(值为2的节点)。

IntList L2 = new IntList(4, L1.rest);

步骤三:修改 L2 所指向链表的值

然后,我们执行 L2.rest.first = 13;。这行代码的意思是:沿着 L2rest 指针找到下一个节点,并将该节点的 first 值从 2 改为 13。

步骤四:修改 L1 链表的尾部连接

这一步是关键操作。我们执行 L1.rest.rest.rest = L2;。这需要我们从 L1 开始,连续跟随三次 rest 指针:

  1. L1.rest 指向第二个节点(值已变为13)。
  2. L1.rest.rest 指向第三个节点(值为3)。
  3. L1.rest.rest.rest 原本是第三个节点的 rest 指针(指向 null),现在被修改为指向 L2 节点本身。

步骤五:创建链表 L3

我们创建一个新的单节点链表 L3,其 first 值为 50,restnull

IntList L3 = new IntList(50, null);

步骤六:将 L3 接入链表

最后,我们执行 L2.rest.rest = L3;。这需要我们从 L2 开始跟随指针:

  1. L2.rest 指向第二个节点(值为13)。
  2. L2.rest.rest 原本指向第三个节点(值为3),现在被修改为指向 L3 节点。

核心技巧与总结

以下是解答此类问题时的一些实用技巧:

  • 耐心跟踪指针:对于每一行代码,都要清晰地画出指针的指向变化。使用箭头明确表示 rest 指针的指向。
  • 区分节点与值:记住,rest 指针指向的是整个节点(盒子),而不仅仅是 first 值。
  • 逐步绘制:严格按照代码执行顺序更新你的图表,避免跳跃步骤导致混淆。

本节课中我们一起学习了如何通过盒状图与指针图来逐步分析和可视化链表的操作。我们跟踪了指针的创建、重定向和值的修改,最终得到了反映代码执行后内存状态的完整图示。掌握这一方法将极大地提升你理解和设计链表相关代码的能力。

14:1 - 继承与接口内容回顾

概述

在本节课中,我们将要学习面向对象编程中的核心概念:继承与接口。我们将了解类如何通过继承建立关系,以及接口如何定义行为契约。同时,我们将深入探讨静态类型与动态类型的区别,以及动态方法选择的工作原理。这些概念是理解Java多态性的基础。

类:子类与超类

上一节我们概述了课程内容,本节中我们来看看类的继承关系。

子类(或称为子类/派生类)是继承自另一个类的类。这意味着子类可以访问其父类(超类)的所有函数和变量,同时还可以定义自己的函数和变量。这里有一个微小的注意事项:子类不继承父类的私有变量或函数。目前我们尚未深入学习访问修饰符,因此暂时无需担心这一点。目前可以理解为,子类拥有访问其父类函数和变量的能力(私有成员除外)。

在示例中,Dog 类有两个箭头指向 CorgiPitBull。在这个例子中,CorgiPitBull 就是 Dog 类的子类。

反之,超类(或称为父类/基类)是被其他类继承的类。在上图中,Dog 是超类,CorgiPitBull 是继承自 Dog 的子类。

方法重载与方法重写

在类中,我们经常会看到不同类型的方法,有时会看到签名相似的方法。

方法重载

方法重载发生在同一个类中存在多个同名但参数不同的方法时。

以下是方法重载的示例:

public void barkAt(Dog otherDog) {
    System.out.println("Woof!");
}

public void barkAt(CS61BStaff staff) {
    System.out.println("Woof! What is this?");

这两个方法都叫 barkAt,但接收不同类型的参数。

我们通常在同一个类的上下文中考虑方法重载。例如,假设这些 barkAt 方法属于 Dog 类,那么 Dog 类可能还有一个对 CS61BStaff 对象吠叫的 barkAt 方法。

思考一下,为什么我们需要方法重载?它的用途是什么?一个很好的例子是 System.out.print。你可能已经注意到,调用 print 时,我们通常传入一个字符串,但如果你传入一个整数,System.out.print 也会为你打印这个整数。在Python中,打印整数需要先将其转换为字符串,例如 print(str(integer))。但在Java中,print 方法可以打印整数、字符串、浮点数、双精度数等。其背后的思想是,并不存在一个能接收所有类型变量的“神奇”的 System.out.print 方法。实际上,System.out.print 是Java中的一个重载方法。存在一个接收整数的 print 方法,一个接收字符串的,一个接收字符的,一个接收双精度数的,等等。Java根据传入变量的类型来决定调用哪个函数。当我们想要像 print 这样常用的函数能够通用化地处理多种不同类型的变量时,这非常有用。

方法重写

方法重写发生在子类拥有一个与超类方法函数签名完全相同的方法时,并且通常用 @Override 标签标记。

记住,方法重载通常在同一类的上下文中考虑,而方法重写则通常涉及超类和子类拥有两个签名相同的方法。

例如,回想之前的幻灯片,我们有一个 Dog 类,CorgiDog 的子类。如果在 Dog 类中有 public void speak(),在继承自 DogCorgi 类中也可能有一个 public void speak()。你会注意到它们具有完全相同的函数签名:都叫 speak,且不接受参数。

关于 @Override 标签,本周讨论中有一位学生提出了一个很好的问题,我认为值得与大家分享。他问 @Override 标签的目的是什么。Java中 @Override 标签的目的基本上是告诉Java:在这个 @Override 标签下面,我们期望在父类中找到与此处方法签名完全匹配的方法。这意味着,如果你在 Corgi 类中放置了一个 @Override 标签,Java会期望你在此处定义的函数能在父类中找到完全匹配的函数签名。

例如,如果我们在编写 Corgi 类,而 Corgi 继承自 Dog,我们写了 @Override public void bark(),但 Dog 类中并没有 bark 方法,Java就会说:“等等,这说不通。你告诉我你要直接重写一个叫 bark 的函数,但我在 Dog 类里没看到 bark 方法。” 这基本上是一个为你设置的安全机制,确保你确切地知道在重写什么。话虽如此,@Override 标签只是一个安全机制,并非重写方法时的强制要求。实际上重写方法时并不需要 @Override 标签,但这是良好的实践,无论如何你都应该使用它。

接口

接口与类有些不同。接口由类来实现。当从接口继承时,我们使用 implements 关键字。

接口描述了一种可以应用于许多可能相关也可能不相关的类的狭窄能力。接口通常不实现它们所指定的方法(稍后会讨论这意味着什么),但可以使用 default 关键字来实现。接口方法本质上是 public 的,这必须在实现它们的子类中指明。记住,子类必须重写并实现非默认的接口方法。最后一件重要的事情是:接口不能被实例化。如果我们写 Friendly f = new Friendly(),而 Friendly 是一个接口,这行代码将无法编译,因为Java会认为:“这是一个接口。我知道一个对象可以是 Friendly 的,但实例化一个 Friendly 对象是什么意思?这说不通。” 所以Java会说:你不能实例化一个接口。

在下面的示例中,我们看到有两个类 CS61BStaffDog,以及两个用虚线框表示的接口 FriendlyCuteCS61BStaff 实现了 Friendly 接口,而 Dog 同时实现了 FriendlyCute 接口。这就是我们所说的“描述一种可以应用于许多可能相关也可能不相关的类的狭窄能力”。CS61BStaffDog 表面上彼此无关,但它们都是 Friendly 的,所以我们说这是一种可能属于多个看似没有其他实际关系的类的能力。

我可以就接口设计、在接口和类之间选择、何时从类或抽象类继承等问题展开讨论,但这里不打算深入。你现在真正需要知道的是,接口只是定义某种行为。一个你已经见过的很好的例子是抽象数据类型,你在第二次讨论和作业零中见过。例如 List 接口,有许多不同类型的列表,如 ArrayListLinkedList 等等。它们都具有相同的行为,只是实现方式不同。你知道在 ArrayList 上可以调用 getadd,在 LinkedList 上也可以调用 getadd,因为我们知道这些是列表应该具备的行为类型。这就是我们所说的“能力”。接口告诉我们想要做什么,但不告诉我们如何做。

接口与类的区别

我认为这个区别有时会有点模糊。

一个类可以实现多个接口,但只能扩展一个类。在上面我们看到,Dog 类实现了 FriendlyCute 两个接口。我认为Java设计者背后的考虑是:我们可以让一个类附加任意多个接口,因为接口描述的是非常狭窄的能力;而扩展一个类则是直接继承,例如 Corgi 是一种 Dog,让 Corgi 扩展另一个类是没有意义的。

如前所述,接口告诉我们想要做什么,但不告诉我们如何做;而类告诉我们如何做。类实际上实现方法,而接口可以有必须由子类填充的空方法体,或者不需要子类重写的默认方法。我们将在工作表的问题一中看到这一点。

使用 extends 关键字时,子类继承其父类的实例变量和静态变量(不包括私有变量,如前所述,但你现在不需要知道细节)。它们也继承其方法(这些方法可以被重写)以及嵌套类,但它们不继承其构造函数。如果我们想引用父类中的任何东西,可以使用 super 关键字。我们将在工作表的问题二中看到这一点。

我的意思是,我们不继承构造函数。假设我们有:

public class Corgi extends Dog {

假设 Dog 的构造函数接收一个字符串,比如名字。如果 Dog 的构造函数已经做了我们希望在实例化 Corgi 时做的一切,那么我们就没有必要重复自己,没有必要重写 Dog 构造函数的主体。我们可以这样做:

public Corgi(String name) {
    super(name);

这里 super 指的是 Corgi 的父类。这基本上是在说:“嘿,为我调用父类在 name 上的构造函数。” 这就是你本周关于 super 真正需要知道的全部内容,虽然本周你不会确切地使用它,但当你看到工作表上的问题二时,这只是一点术语。

实现关系图

如果我们有这张图,我们会看到一个叫 Cute 的接口和一个叫 Friendly 的接口(用虚线框表示)。我们会说 class CS61BStaff implements Friendly,因为 Friendly 是一个接口,我们想要实现一个接口。然后 class Dog implements Cute, Friendly。我们看到 Dog 这里有来自接口 FriendlyCute 的箭头,因为接口可以被类实现多个,但类只能扩展一个其他类,这就是我们在下面看到的:class Corgi extends Dogclass PitBull extends Dog

快速提醒一下,当我们实现一个接口时,实现该接口的类必须填充该接口中任何非默认的方法。我的意思是,假设 Friendly 定义了一个函数叫 isFriendly(我知道这很傻,但只是一个简单的例子)。假设这个函数在 Friendly 接口体内声明,你会看到它以分号结尾,没有大括号,方法体内没有任何内容(它没有方法体)。这意味着,因为 CS61BStaffDog 都实现了 Friendly,它们必须定义并实现自己的 isFriendly 版本。也许在 CS61BStaff 中我们会看到这样的东西:

@Override
public boolean isFriendly() {
    // ... 实现内容

这就是我们所说的“实现接口的类需要填充那些空的接口方法体”。

静态类型与动态类型

既然我们已经学习了一点关于继承、接口和类的知识,当我们考虑静态类型与动态类型时,事情开始变得有趣起来。

在课程开始时(可能在第二次讨论中),我们谈到Java是一种静态类型语言,也是一种编译型语言。基本上,这意味着当我们在Java中编写代码时,我们必须告诉编译器每个变量的确切类型、每个方法的返回类型等等。这就是为什么我们有这些带有类型声明的变量声明。

我们考虑的静态类型(在编译时相关)和变量的动态类型(在运行时或代码实际执行时相关)之间存在区别。变量的静态类型在声明时指定(如我们在这里看到的),而其动态类型在实例化时指定(例如,使用 new 时)。我喜欢这样想:变量的静态类型是等号左边的任何东西,而变量的动态类型是等号右边的任何东西。

在这里我们看到,变量 d 的静态类型是 Dog(等号左边),而等号右边我们看到它的动态类型是 Corgi。变量的静态和动态类型必须相互兼容,否则代码会出错。我们的意思是,在这里我们想用一个经验法则来概括:给定“左边 = 右边”,右边是否保证是左边?如果你遵循上面的例子或者凭直觉知道,你会看到右边是一个 Corgi,我们会问自己:Corgi 是否保证是一个 Dog?是的,如果你知道 Corgi 是一种 Dog,也因为我们在上面明确说过 CorgiDog 的子类。所以 Corgi 是一个 Dog,正如Josh可能在讲座中说的那样,或者它们处于超类/子类、父类/子类的关系中。

回到这里,我过去和学生们讨论过的另一种思考方式是:假设我在这里重新绘制继承层次结构。我喜欢这样想:在你的继承树的最顶端,你有一个非常大的盒子,每次你沿着继承树往下走,一切都用一个更小的盒子来表示。假设我们有这个变量 d,我们说它的类型是 Dog,所以它是这里的这个大盒子。当你问自己:我能把一个 Corgi 放进我的 Dog 盒子里吗?如果 Corgi 在我的变量右边,而左边是 Dog,它能放进去吗?我喜欢这样想:我知道 Corgi 在继承树中更靠下,所以我有一个更小的盒子,因此我可以把更小的 Corgi 盒子放进更大的 Dog 盒子里。我对 PitBull 也可以做同样的事情,我可以把更小的 PitBull 盒子放进更大的 Dog 盒子里。

然而,我不能做的是:假设我们有 Corgi c = new Dog()。例如,下面这行代码。如果我们有这样的东西,我们会有这个小 Corgi 变量,我们会问自己:Dog 是否保证是一个 Corgi?我能把我非常大的 Dog 盒子放进这个 Corgi 盒子里吗?然后你会意识到,实际上我的 Corgi 盒子比我的 Dog 盒子小,CorgiDog 更具体,因此我不能这样做,这行代码将无法编译。

还有一个注意事项:虽然接口不能被实例化,但它们可以作为静态类型。例如,你可以说 Cute c = new Corgi()。记住,在之前的例子中,我们定义 Cute 是一个接口。这就是我们在这里的意思,接口是静态类型(等号左边的类型),它只是不能是等号右边的类型(不能是动态类型),因为我们不能实例化一个接口。这是可行的,因为 Corgi 继承自 CuteCorgi 实现了 Cute),所以我们知道 Corgi 是可爱的东西,这就是为什么这行得通。

类型转换

接下来我们稍微谈谈类型转换。这不属于期中考试一的范围,但我在这次讨论中涵盖它,因为不幸的是,这是我们唯一能正式学习类型转换的一周,而它有点重要。

类型转换允许我们告诉编译器,将某个变量的静态类型视为我们想要的任何类型。重要的是,转换类型必须具有超类或子类关系(当我们通过这个例子时,我会稍微谈谈这一点)。如果转换对该变量有效,我们就会将被转换变量的静态类型视为我们转换成的类型。

让我们稍微画一下。在这个例子中,我有变量 adc。然后我要画一个小层次结构,所以在这里做层次结构:Animal 是这个父类,它的子类是 DogCat。然后我要说 Animal aDog dCat c。这个 Animal 指向某个地方的某个 DogDog d 指向 a 指向的任何东西(实际上我不画那个箭头,因为它会乱,但我会谈谈为什么),然后 Cat c 指向某个地方的 new Cat()

首先,在这行代码中,我们有 Animal a = new Dog()。如果我们问:给定这里的层次结构,右边是否保证是左边?DogAnimal 吗?是的,它是。所以我们没问题。哦,我刚刚意识到我的缩放方块可能挡住了这个,让我稍微移一下。好了,我想这样好多了。

我们可以说,即使 a 的静态类型是 Animal,因为 Dog 是一种 Animal,我们可以让 a 指向内存中的一个 Dog

然而,如果我们看到这行代码 Dog d = a,我们会得到一个编译错误。原因是,在编译时,我们只关心变量的静态类型。我的意思是,在这里我们知道 a 的静态类型是 Animal。即使它的动态类型是 Dog,在编译时,我们只看变量的静态类型。所以这里我们试图将这个 Dog d 设置为内存中的某个 Animal,在编译时我们不知道它是 Dog,我们只知道 a 的静态类型是 Animal。所以这实际上是在说:右边有一个 Animal,左边有一个 Dog,我们要问自己:一个 Animal 是否保证是一个 Dog?答案将是否定的。所以我们会得到一个编译错误。基本上,这行代码不起作用。

但是,如果我们这样做:Dog d = (Dog) a,我们是在告诉Java:仅针对这一行,我希望你把 a 当作一个 Dog 来对待。这看起来会是:它会看到 a 的静态类型是 Animal,它会看到转换类型是 Dog,编译器会问自己:一个 Animal 有可能是一个 Dog 吗?一个 Animal 可能是 Dog 吗?我所说的超类/子类关系是指,转换类型和你试图转换的变量的静态类型只需要存在某种层次上的祖先关系。所以我们问自己:这个 Animal 可能是 Dog 吗?它不一定是 Dog,但它可能是 Dog 吗?所以在编译时,我们让这个转换通过。

然后,在代码编译后的运行时,我们很幸运,因为我们发现:哦,很酷,即使在编译时你告诉我把 a 当作 Dog 对待,但事实证明 a 实际上指向内存中的一个 Dog。所以我们没问题,一个 Dog 被转换成 Dog,对我来说听起来很好。

在下一张幻灯片中,如果我们做 d = new Dog(),所以这指向内存中另一个不同的 Dog。如果我们做下一行 a = (Animal) d,我们会看到 d 在编译时的静态类型是 Dog。如果我们试图将一个 Dog 转换成 Animal,这绝对可行,因为 AnimalDog 处于层次上的父类/子类关系中。我们知道 DogAnimal,所以这个转换在编译时对我们来说是好的,在运行时它也有效,因为我们发现:嘿,很酷,这里我们有一个 Animala 的静态类型是 Animal,你告诉我 d 在编译时是一个 Animal,这对我来说没问题,AnimalAnimal。然后在运行时,一切顺利,因为即使 d 实际上是一个 DogDogAnimal,很酷。

然后在这行,我们设置 Cat c = new Cat()。现在,当我们到达这行 d = (Dog) c 时,我们遇到了问题,因为 c 的静态类型是 Cat,而我们试图将一个 Cat 转换成 Dog。但 DogCat 是兄弟关系,即使它们都是 Animal 类型,它们也不在超类/子类关系中。所以这个转换无法编译,我们会得到一个编译错误。

在下一张幻灯片中,如果我们做 a = c,让我们检查一下它的有效性。a 的静态类型是 Animalc 的静态类型是 Cat。如果我们设置 a 等于 c,这对我们来说是可行的,因为我们知道 Cat 是一种 Animal,所以我们完全可以这样做。

最后,当我们到达最后一张幻灯片时,我们会遇到一些非常有趣的东西,叫做运行时错误,这只有在进行这种变量匹配的类型转换时才可能发生。

这个转换在编译时通过,因为一个 Animal 可能合理地是一个 Dog。我的意思是,在这里我们知道静态类型是 Dog。然后在这里,我们有这个变量 a,它的静态类型是 Animal,我们试图告诉编译器把它当作 Dog 来对待。所以当编译器遇到这个时,它看到 a 是一个 Animal,我们试图把它转换成 Dog,我们问:是否存在超类/子类关系?一个 Animal 可能是一个 Dog 吗?我们说,是的,一个 Animal 可能是一个 Dog,这不一定是保证,但它是可能的。所以在编译时,Java说:好吧,我让你通过,我们以后再解决这个问题,但现在你通过了,你可以编译。

然而,在运行时,当我们查看变量的动态类型时,我们会重新审视这个转换,并说:嘿,我有这个变量 a,我知道它通过了编译时检查(编译器说你现在没问题),但在运行时,当我们关心 a 的动态类型时,a 的动态类型实际上是一个 Cat(它指向内存中的一个 Cat),而我们试图将一个 Cat 转换成 Dog,这行不通,就像我们在上面做的那样,因为 DogCat 是兄弟关系。所以那个转换在运行时不起作用。这就是区别:它通过了编译时检查,但没有通过运行时检查。我喜欢这样想:在编译时,我们问自己:这个转换可能吗?然后在运行时,我们问自己:这个转换是真的吗?这就是我对类型转换的思考方式。

动态方法选择

我们讨论的静态和动态类型的匹配相当复杂,但当我们进行所谓的动态方法选择时,它基本上可以总结为一套规则。这往往是61B学生觉得最困难的话题之一,因为它真的很令人困惑。老实说,这主要源于Java和静态类型语言的事实,以及随之而来的限制。

在编译时,你的计算机只关心调用实例的静态类型。我的意思是,假设我们有 Animal a = new Animal()。当我们进行动态方法选择时,我们关心的是像 a.greet() 这样的东西。假设 cnew Cat()(我知道语法很糟糕,但这只是一个例子)。

在动态方法选择的编译时,我们只关心调用实例的静态类型。所以我们只关心这里 a 的静态类型,也就是 Animal。实际上,我要加点料,让它变成 Dog 之类的。

在编译时,我们首先检查有效的变量赋值(这有点像我们上面用类型转换做的:右边是否保证是左边?),然后我们只考虑静态类型和静态类型的超类来检查有效的方法调用。这一点非常重要,因为我认为这是学生们有时会迷失的地方。他们会想:等等,那动态类型呢?不,在编译时,我们只关心变量的静态类型。

然后,一旦我们找到足够的方法签名(必要时遍历父类),我们就想锁定确切的方法签名。我们需要否认的是,如果我们在调用变量的静态类型中没有找到合适的方法,那么我们会转到该变量的父类,直到找到我们想要的方法。如果我们没有找到想要的方法,就会抛出编译错误。

然后在运行时,我们关心调用实例的动态类型。如果我们在编译时锁定的方法是静态的,我们将跳过以下步骤,直接运行编译时锁定的方法。如果它不是静态的,那么我们要检查调用实例的动态类型中是否有被重写的方法。在这个例子中,我们会查看 Dog 类内部。

当我们检查被重写的方法时,我们的意思是:锁定的方法签名在动态类或动态类的父类中是否有完全相同的方法签名?如果我们确实找到了一个,那么我们希望根据运行时的动态类型运行我们找到的新方法。如果我们没有找到,那完全没问题,我们就运行编译时锁定的那个方法。

最后,我们还要确保转换后的对象可以赋值给它们的变量,这基本上又是我们上面用这些类型转换所做的。

动态方法选择非常令人困惑。上学期我做了一个关于变量赋值规则和方法调用规则的巨大流程图。这需要消化很多,而且其中一些规则对于我们本学期学习DMS的方式来说有点过度。我可能会在做实际的动态方法选择问题(工作表上的问题二)时拿出这个图表,这样我们就可以按照流程工作,熟悉我们在这里寻找的东西。我相信,这就是内容回顾的结束。

总结

本节课中我们一起学习了面向对象编程中的继承与接口。我们明确了子类与超类的关系,区分了方法重载与重写,理解了接口定义行为契约的方式。我们深入探讨了静态类型与动态类型的关键区别,以及类型转换的规则和潜在风险。最后,我们概述了动态方法选择的复杂过程,这是理解Java运行时多态性的核心。掌握这些概念对于构建灵活、可维护的面向对象程序至关重要。

15:接口与继承实战

在本节课中,我们将通过一个具体的编程问题,学习如何实现接口、理解继承关系,并掌握静态类型与动态类型在Java中的运用。我们将分析一个关于“猫巴士”和“鹅”的示例,逐步完成代码的填充与修改。

概述

我们将处理一个包含VehicleHonker接口的编程问题。目标是让CatBus类能够同时表现出车辆和鸣笛者的行为,并使其能与另一个同样会鸣笛的Goose类进行交互。我们将分步完成类的实现、方法签名的修改以及类型兼容性的判断。

第一部分:实现CatBus类 🚌

上一节我们介绍了问题的背景,本节中我们来看看如何让CatBus类实现指定的接口。

给定VehicleHonker接口,我们需要填写CatBus类,使其能够像车辆一样发动引擎,并像鸣笛者一样鸣笛。

我们知道,当一个类需要具备某个接口定义的行为时,需要使用implements关键字。一个类可以实现多个接口,但只能继承一个父类。

以下是实现CatBus类的关键步骤:

  • 在类声明中使用implements Vehicle, Honker
  • 由于接口中的方法(revEnginehonk)没有默认实现(即没有方法体),CatBus类必须提供这两个方法的具体实现。
  • 接口方法默认是public的,因此在实现时也需要显式声明为public

核心实现代码如下:

public class CatBus implements Vehicle, Honker {
    @Override
    public void revEngine() {
        // CatBus revs its engine (implementation not shown)
    }

    @Override
    public void honk() {
        // CatBus honks (implementation not shown)
    }
}

第二部分:修改对话方法 🗣️

在上一节我们实现了CatBus的基本功能,本节中我们来看看如何扩展其交互能力。

现在,我们遇到了一只同样实现了Honker接口的Goose。我们需要修改CatBusconversation方法签名,使其参数target既能接受CatBus对象,也能接受Goose对象。

分析类之间的关系:CatBusGoose没有直接的继承关系,但它们都实现了Honker接口。这意味着它们都是Honker类型,并且都拥有honk方法。

因此,解决方案是将方法参数的类型从具体的CatBus改为其共同的接口类型Honker。这样,任何实现了Honker接口的对象都可以作为参数传入。

修改后的方法签名如下:

public void conversation(Honker target) {
    this.honk();
    target.honk();
}

第三部分:判断代码可编译性 ✅

在上一节我们修改了方法以支持多态,本节中我们通过几个赋值语句来巩固对类型系统的理解。

假设CatBusGoose都使用默认构造函数,我们需要判断以下三行代码能否通过编译。

以下是各代码行的分析:

  1. Honker cb = new CatBus();
    • 分析:静态类型是Honker,动态类型是CatBus。由于CatBus实现了Honker接口,一个CatBus对象保证是一个Honker。因此,右侧对象可以安全赋值给左侧变量。
    • 结论可以编译
  2. Honker h = new Honker();
    • 分析:尝试使用new关键字实例化一个接口。接口定义了一组能力规范,本身不能被直接实例化。接口只能作为静态类型,不能作为动态类型。
    • 结论无法编译
  3. CatBus g = new Goose();
    • 分析:静态类型是CatBus,动态类型是Goose。虽然GooseCatBus都实现了Honker接口,但它们是两个独立的类,彼此之间没有继承关系。一个Goose对象不保证是一个CatBus对象。
    • 结论无法编译

总结

本节课中我们一起学习了接口实现与多态的应用。我们首先让CatBus类实现了VehicleHonker接口,并提供了必要的方法实现。接着,我们通过将方法参数类型改为公共接口Honker,使得方法能够接受多种不同类型的对象,体现了“面向接口编程”的灵活性。最后,我们通过分析赋值语句,加深了对Java静态类型、动态类型以及接口实例化规则的理解。记住:接口不能被new实例化,但可以作为引用类型指向实现了该接口的类的对象。

16:3 - 讨论3问题2详解 🐕🐈

在本节课中,我们将详细解析UCB CS 61B课程中关于“Java会怎么做”类型的问题。我们将通过一个具体的代码示例,逐步分析静态类型、动态类型、方法重写以及类型转换等核心概念,帮助你理解Java在编译时和运行时的行为。


代码初始化与类型分析

首先,我们来看代码的第3至6行,这里创建了几个对象。

Animal a = new Dog();
Animal b = new Animal();
Cat c = new Cat();
Dog d = new Dog();

我们需要确定每个变量的静态类型和动态类型。

  • 静态类型:声明变量时写在等号左边的类型。
  • 动态类型:等号右边new关键字实际创建的对象类型。

以下是各变量的类型分析:

变量 静态类型 动态类型
a Animal Dog
b Animal Animal
c Cat Cat
d Dog Dog


逐行代码分析

上一节我们分析了变量的初始类型,本节中我们来看看后续每一行代码的执行情况。

第8行:Cat e = new Animal();

这行代码尝试将一个Animal对象赋值给一个Cat类型的变量。
赋值规则是:右侧对象的类型必须是左侧变量类型的子类型(或相同类型)
这里,Animal是比Cat更通用的类型,Animal不一定是Cat。因此,这会在编译时产生错误。运行时不会执行。

结果:编译错误。


第9行:a.greet(c);

调用方法时,Java会分两步走:编译时检查和运行时执行。

  1. 编译时:编译器只关心静态类型。这里a的静态类型是Animal。编译器检查Animal类中是否有greet(Cat c)方法。Animal类中定义了greet(Animal a)方法。由于CatAnimal的子类,所以greet(c)调用是合法的。编译器记录下将调用Animal.greet(Animal)
  2. 运行时:JVM关心动态类型。a的动态类型是DogDog类重写了greet(Animal a)方法。因此,实际执行的是Dog类中的greet方法。

执行过程

Dog.greet(Animal) 被调用。
输出:“Woof! My name is Pluto.”


第10行:a.sleep();

这行代码调用sleep方法。

  1. 编译时a的静态类型是AnimalAnimal类中有静态方法sleep(),编译通过。
  2. 运行时对于静态方法,不适用动态方法查找(Dynamic Method Lookup)规则。无论对象的动态类型是什么,都直接根据静态类型来决定调用哪个方法。因此,调用的是Animal.sleep()

执行过程

Animal.sleep() 被调用。
输出:“Nap time.”

第11行:c.play();

这行代码在Cat对象上调用play方法。

  1. 编译时c的静态类型是CatCat类中有play()方法,编译通过。
  2. 运行时c的动态类型也是Cat,没有涉及父类引用,直接调用Cat.play()

执行过程

Cat.play() 被调用。
输出:“That was fun! Meow.”


第12行:c.greet(d);

这行代码让一只猫向一只狗打招呼。

  1. 编译时c的静态类型是CatCat类中有greet(Animal a)方法。d的静态类型是Dog,而DogAnimal的子类,因此参数匹配,编译通过。
  2. 运行时c的动态类型是Cat,因此执行Cat.greet(Animal)方法。

执行过程

Cat.greet(Animal) 被调用。
输出:“Meow! My name is Garfield.”


第13行:((Animal) c).greet(d);

这行代码先将c强制转换为Animal类型,再调用greet

  1. 编译时:转换后,表达式的静态类型被视为Animal。编译器检查Animal.greet(Dog),同样找到greet(Animal)方法,编译通过。
  2. 运行时:强制转换不改变对象的动态类型。c的动态类型仍然是CatCat重写了greet(Animal)方法,因此实际执行的还是Cat.greet(Animal)

执行过程:与第12行完全相同。

Cat.greet(Animal) 被调用。
输出:“Meow! My name is Garfield.”


第14行:d.sleep();

Dog对象上调用sleep

  1. 编译时d的静态类型是DogDog类中有静态方法sleep(),编译通过。
  2. 运行时:同样是静态方法,根据静态类型Dog决定调用Dog.sleep()

执行过程

Dog.sleep() 被调用。
输出:“I love napping.”

第15行:a = c;

这是一个赋值操作。

  1. 编译时:检查类型兼容性。左侧a的静态类型是Animal,右侧c的静态类型是CatCatAnimal的子类,因此赋值合法。
  2. 运行时:赋值后,变量a现在指向c所指向的同一个对象(一只猫)。因此,a的动态类型从Dog变为Cat

执行结果a的动态类型更新为Cat


第16行:a.play(14);

尝试用参数14调用aplay方法。

  1. 编译时a的静态类型是Animal。编译器在Animal类中查找play(int)方法。Animal类中只有无参数的play()方法,没有play(int)。因此,编译器找不到匹配的方法,产生编译错误

结果:编译错误。


第17行:((Cat) b).play();

b强制转换为Cat后调用play

  1. 编译时:转换后静态类型被视为CatCat类中有无参的play()方法,编译通过。
  2. 运行时:检查b的动态类型。b的动态类型是Animal。我们试图将一个通用的Animal对象强制转换为更具体的Cat类型。由于Animal对象不一定就是Cat,这种“向下转型”在运行时可能不安全。此处,b实际指向一个Animal对象,不是Cat,因此会抛出ClassCastException(类转换异常)。

结果:运行时错误 (ClassCastException)。


第18行:d = (Dog) a;

a强制转换为Dog后赋值给d

  1. 编译时d的静态类型是Dog(Dog) aa的静态类型视为Dog。编译器允许在父类(Animal)和子类(Dog)之间进行强制转换的声明,因此编译通过。
  2. 运行时:检查a的动态类型。执行此语句时,a的动态类型是Cat(来自第15行的赋值)。我们试图将Cat转换为DogCatDog是兄弟类(同级),没有继承关系,这种转换是不允许的。因此,同样会抛出ClassCastException

结果:运行时错误 (ClassCastException)。d的赋值不会发生。


第19行:c = a;

尝试将a赋值给c

  1. 编译时:左侧c的静态类型是Cat,右侧a的静态类型是Animal。编译器发现右侧类型比左侧更通用,无法保证Animal一定是Cat,因此会报编译错误。除非像下面这样显式地告诉编译器你的意图。

结果:编译错误。


补充:c = (Cat) a;

如果我们显式地进行强制转换,如c = (Cat) a;

  1. 编译时(Cat) a使右侧表达式静态类型被视为CatCat赋值给Cat,编译通过。
  2. 运行时:检查a的动态类型。此时a的动态类型就是Cat(来自第15行)。强制转换(Cat)对一个本来就是Cat的对象是安全的,因此转换成功。c被赋值为a所指向的同一个Cat对象。

结果:赋值成功,c指向原来的猫对象。


总结

本节课中我们一起学习了如何分析Java程序在编译时和运行时的行为,核心要点如下:

  1. 静态类型与动态类型:静态类型决定编译时的方法查找和类型检查;动态类型决定运行时实际执行的方法(实例方法)。
  2. 方法调用规则
    • 对于实例方法,编译时根据静态类型确定方法签名,运行时根据动态类型执行具体的方法实现(动态绑定)。
    • 对于静态方法,无论编译时还是运行时,都只根据静态类型决定调用哪个方法。
  3. 赋值与类型兼容性:可以将子类对象赋值给父类变量(向上转型,安全),反之则需要强制转换(向下转型,可能不安全)。
  4. 强制转换的风险:编译时允许声明父类与子类间的转换,但运行时若实际对象类型与转换目标类型不兼容(如兄弟类之间转换,或父类对象转非实际子类),则会抛出ClassCastException

理解这些概念是掌握Java多态性和面向对象编程的关键。

17:Spring 2023 考试 Level 04 问题 2 解析 🧩

在本节课中,我们将学习如何解决一个结合了链表和继承概念的复杂问题。我们将分析一个名为 DMSList 的单链表结构,并实现其 max 方法,使其能正确处理空链表并返回最大值。


问题概述 📋

我们有一个 DMSList 类,它是一个带有哨兵节点的单链表。链表节点由 IntNode 类表示,每个节点包含一个 item(数据项)和一个指向下一个节点的 next 指针。链表的初始结构如下图所示。

我们的目标是让 max 方法正常工作。该方法在链表为空时应返回 0,否则返回链表中的最大元素。需要注意两个关键点:

  1. 所有插入 DMSList 的数字都是正数。
  2. 我们只使用 insertFront 方法在链表头部插入元素,这意味着我们永远不会在链表尾部添加节点。

我们需要填写代码中的几个空白部分:DMSList 的构造函数,以及 LastIntNode 类的构造函数和 max 方法。


理解递归与基例 🔍

上一节我们介绍了问题的基本要求,本节中我们来看看 max 方法的具体实现逻辑。

观察 IntNode 类中的 max 方法,我们发现它是递归的:

return Math.max(item, next.max());

它调用 Math.max 来比较当前节点的 item 和链表剩余部分(next.max())的最大值。然而,递归需要一个基例来终止,否则会无限递归下去,直到遇到 null 并抛出 NullPointerException。但题目要求我们不能修改 IntNode 中的 max 方法。

这就是 LastIntNode 类发挥作用的地方。我们可以让 LastIntNode 继承 IntNode,并重写 max 方法,从而优雅地处理递归的终止条件。我们的思路是:不让链表的末尾是 null,而是用一个 LastIntNode 实例作为“终止哨兵”。这个节点本身不存储实际数据,但能帮助我们安全地结束递归。


实现 DMSList 构造函数 🛠️

现在,我们开始填写代码。首先处理 DMSList 的构造函数。

对于一个新创建的、空的 DMSList,它应该包含两个特殊节点:起始哨兵节点和末尾的 LastIntNode 节点。起始哨兵节点的值不重要(这里设为 -1000),它的 next 应该指向代表链表末尾的 LastIntNode

因此,构造函数的第二行应填写为:

sentinel = new IntNode(-1000, new LastIntNode());

这样,一个空链表的结构就是:哨兵 -> LastIntNode


实现 LastIntNode 类 📝

接下来,我们实现 LastIntNode 类。首先看其构造函数。

LastIntNode 继承自 IntNode,我们可以使用 super 关键字调用父类的构造函数。作为链表的“终止哨兵”,它的 item 值应该不影响正常计算。根据 max 方法在空链表时返回 0 的要求,将其 item 设为 0 是合理且安全的。同时,作为最后一个节点,它的 next 应该是 null

因此,LastIntNode 的构造函数应填写为:

public LastIntNode() {
    super(0, null);
}

重写 max 方法 ✅

最后,也是最关键的一步,是重写 LastIntNode 中的 max 方法。

当递归进行到 LastIntNode 时,意味着我们已经遍历完了所有存储实际数据的节点,到达了“空链表”的状态。根据题目要求,此时 max 方法应返回 0。这正是我们需要的递归基例。

因此,LastIntNodemax 方法非常简单:

public int max() {
    return 0;
}

这个方法重写了父类 IntNodemax 方法,阻止了无限递归,并正确返回了空链表情况下的结果。


总结与考试技巧 💡

本节课中我们一起学习了如何通过继承和重写,为链表的递归操作添加基例。我们创建了一个特殊的 LastIntNode 类作为“终止哨兵”,它不存储有效数据,但能优雅地处理边界条件,使得原有的递归 max 方法能够正常工作。

对于这类涉及链表递归的问题,一个非常实用的技巧是:始终明确区分递归情况基例情况。我们从分析原有 max 方法缺少基例入手,进而设计出 LastIntNode 来提供这个基例。在考试或日常编程中,理清这个思路是解决问题的关键。

祝大家在 CS 61B 的后续学习中顺利!如有任何疑问,欢迎留言讨论。

18:Spring 2023 考试第4级问题1解析

在本节课程中,我们将一起学习继承和多态的核心概念,并通过分析Spring 2023考试中的一个具体问题来加深理解。我们将重点关注接口实现、类继承、编译时与运行时类型,以及类型转换。

问题概述与类结构

首先,我们来了解问题的背景和涉及的类结构。这个问题主要围绕三个部分展开:Person接口、Athlete类和SoccerPlayer类。

  • Person 是一个接口。
  • Athlete 类实现了 Person 接口。
  • SoccerPlayer 类继承了 Athlete 类。

以下是它们的关键方法定义:

  • Person 接口声明了 speakTo(Person p)watch() 方法。
  • Athlete 类实现了这些方法:speakTo(Person p) 打印 "I love sports"watch() 打印 "Ball is life"
  • SoccerPlayer 类重写了 speakTo(Person p) 方法,打印 "Join 61B ballers"

变量声明与类型分析

现在,我们开始逐行分析代码,判断每行代码的编译时类型、运行时类型以及输出结果。

以下是第一组变量声明的分析:

  1. Person AyI = new Person();

    • 分析:左侧声明 AyI 的编译时类型为 Person。右侧试图实例化一个 Person 接口。接口不能被实例化
    • 结果:编译错误。
  2. Athlete Drev = new SoccerPlayer();

    • 分析:左侧声明 Drev 的编译时类型为 Athlete。右侧实例化了一个 SoccerPlayer 对象,因此其运行时类型为 SoccerPlayer
    • 结果:声明成功。编译时类型为 Athlete,运行时类型为 SoccerPlayer
  3. SoccerPlayer V = Drev;

    • 分析:编译器只知道 Drev 的编译时类型是 Athlete。不能将一个父类 (Athlete) 类型的变量直接赋值给子类 (SoccerPlayer) 类型的变量,因为 Drev 可能并不是 SoccerPlayer
    • 结果:编译错误。
  4. Person Eric = new Athlete();

    • 分析:左侧编译时类型为 Person。右侧运行时类型为 Athlete。这是有效的向上转型。
    • 结果:声明成功。编译时类型为 Person,运行时类型为 Athlete
  5. Athlete Surya = new Athlete();

    • 分析:编译时类型和运行时类型都是 Athlete
    • 结果:声明成功。两者均为 Athlete
  6. SoccerPlayer YaoFu = new SoccerPlayer();

    • 分析:编译时类型和运行时类型都是 SoccerPlayer
    • 结果:声明成功。两者均为 SoccerPlayer

方法调用与多态行为

上一节我们分析了变量的声明和类型。本节中,我们来看看方法调用,这是理解多态的关键。

以下是具体的方法调用分析:

  1. Eric.watch();

    • 编译时Eric 的编译时类型是 Person,因此选择 Person.watch() 方法声明。
    • 运行时Eric 的运行时类型是 Athlete,因此执行 Athlete.watch() 方法。
    • 输出"Ball is life"
  2. Surya.speakTo(YaoFu);

    • 编译时/运行时Surya 的编译时和运行时类型都是 Athlete,因此始终选择 Athlete.speakTo(Person p) 方法。
    • 输出"I love sports"
  3. YaoFu.speakTo(Surya);

    • 编译时/运行时YaoFu 的编译时和运行时类型都是 SoccerPlayer,因此始终选择 SoccerPlayer.speakTo(Person p) 方法。
    • 输出"Join 61B ballers"

类型转换详解

理解了基本的方法调用后,我们引入类型转换。类型转换可以“欺骗”编译器,临时改变变量在某一行代码中的编译时类型。

以下是涉及类型转换的代码分析:

  1. ((Athlete) YaoFu).speakTo(Eric);

    • 编译时:通过强制转换,编译器将 YaoFu 在此行的编译时类型视为 Athlete,因此选择 Athlete.speakTo(Person p) 方法。
    • 运行时YaoFu 的运行时类型始终是 SoccerPlayer,未改变。因此执行 SoccerPlayer.speakTo(Person p) 方法。
    • 输出"Join 61B ballers"
  2. ((Person) YaoFu).speakTo(Eric);

    • 编译时:编译器将 YaoFu 视为 Person 类型,选择 Person.speakTo(Person p) 方法声明。
    • 运行时YaoFu 的运行时类型是 SoccerPlayer,执行 SoccerPlayer.speakTo(Person p) 方法。
    • 输出"Join 61B ballers"
  3. ((Athlete) Eric).speakTo(YaoFu);

    • 编译时:编译器将 Eric 视为 Athlete 类型,选择 Athlete.speakTo(Person p) 方法。
    • 运行时Eric 的运行时类型本就是 Athlete,转换合法,因此执行 Athlete.speakTo(Person p) 方法。
    • 输出"I love sports"
  4. ((SoccerPlayer) Eric).speakTo(YaoFu);

    • 分析Eric 的运行时类型是 Athlete。尝试将其向下转型为子类 SoccerPlayer。由于一个 Athlete 对象不一定是 SoccerPlayer,这种转换在运行时无法安全进行。
    • 结果:运行时错误 (ClassCastException)。

核心要点:类型转换只改变编译时类型的判断,用于通过编译检查。它永远不会改变对象的实际运行时类型。如果试图将一个对象转换为它运行时类型不兼容的类型,就会引发运行时错误。

总结与考试技巧

本节课中,我们一起学习了继承体系下的方法调用规则和类型转换。

我们来总结一下核心步骤:

  1. 确定类型:明确变量的编译时类型和运行时类型。
  2. 编译时选择:根据编译时类型确定要调用的方法签名。
  3. 运行时执行:根据运行时类型,找到实际要执行的方法体(遵循重写规则)。
  4. 谨慎转换:使用强制转换时,需确保运行时类型兼容,否则会导致运行时错误。

一个实用的考试技巧是:在解题时,像本教程一样,清晰地标注出每一行代码涉及的变量的编译时和运行时类型。这能帮助你系统地推理,避免混淆,准确判断输出或错误类型。

祝你在CS 61B后续的学习和考试中一切顺利!

19:讨论课 05 内容回顾

概述

在本节课中,我们将要学习迭代器、可迭代对象和多态性。这些是Java编程中用于处理集合和创建通用、灵活代码的核心概念。

多态性简介

多态性描述了方法能够处理多种类型的能力。这使我们能够编写更通用的代码,或者说,创建一个能与多种类型协同工作的统一接口。

在Java中,我们常使用泛型来实现这一点,用一个占位符(如字母 T)来代表某种通用的对象类型。

代码示例:

public class LinkedListDeck<T> {
    // T 可以是 String, Integer, 或任何其他对象类型
}

上一节我们介绍了多态性的基本概念,本节中我们来看看一种更具体的多态形式。

子类型多态性

子类型多态性描述了一个类或接口的子类型也是该类或接口的实例这一事实。

例如,如果我们有一个 Animal 父类,以及 DogCat 子类。那么,任何接受 Animal 类型参数的函数,也可以接受一个 DogCat 类型的实例,因为狗和猫本身也是动物。

公式描述:
如果 Dog extends Animal,那么 Dog 的实例也是 Animal 的实例。

一个更具体的例子是使用泛型绑定。我们可以创建一个 ComparableArray<T> 类,其中 T 被限定为实现 Comparable 接口的类型。这样,ComparableArray 就可以与任何可比较的类型一起工作。

代码示例:

public class ComparableArray<T extends Comparable<T>> implements Comparable<ComparableArray<T>> {
    // 类内部可以安全地调用 T 类型的 compareTo 方法
    public int compareElements(T item1, T item2) {
        return item1.compareTo(item2); // 可行,因为 T 是 Comparable 的
    }
}

理解了子类型多态性后,我们来看一个在Java中非常常见的应用实例。

比较器接口

比较器是Java中展示多态性的一个典型例子。Comparator<T> 是一个接口,它定义了一种比较两个 T 类型对象的方法。

以下是 Comparator 接口的核心方法:

代码示例:

public interface Comparator<T> {
    int compare(T o1, T o2);
}

compare 方法返回一个整数:

  • 如果 o1 小于 o2,返回负整数。
  • 如果 o1 大于 o2,返回正整数。
  • 如果 o1 等于 o2,返回 0。

这里的“小于”、“大于”和“等于”的含义由实现 Comparator 接口的类来定义。例如,对于 Dog 类,我们可以定义按名字字母顺序比较,或者按年龄比较。接口只规定“如何调用”,而具体类决定“如何实现”。

在深入迭代器之前,我们先快速回顾一下类和接口的关系,这有助于理解后续内容。

接口与继承回顾

  • 一个类可以实现多个接口,但只能继承一个父类。
  • 接口告诉我们“要做什么”,但不提供具体实现;类则告诉我们“如何去做”。
  • 实现接口的类必须填充接口中定义的方法体(除非是默认方法)。
  • 子类通过 extends 关键字继承父类。它们会继承父类所有的非私有实例变量、静态变量和方法(可被重写)以及嵌套类。
  • 子类不继承父类的构造器。要调用父类的构造器或方法,需使用 super 关键字。

代码示例:

public class Dog extends Animal {
    public Dog(String name) {
        super(name); // 调用父类 Animal 的构造器
    }
    
    public void barkTwice() {
        super.bark(); // 调用父类 Animal 的 bark 方法
        super.bark();
    }
}

掌握了这些基础后,我们现在可以探讨两个对本课至关重要的接口。

迭代器与可迭代对象

迭代器是可以在Java循环中遍历的对象。Iterator<T> 是一个接口。

任何实现 Iterator 接口的类都必须实现以下两个方法:

代码示例:

public interface Iterator<T> {
    boolean hasNext();
    T next();
}
  • hasNext(): 如果迭代器中还有剩余元素可供遍历,则返回 true
  • next(): 返回迭代器中的下一个 T 类型的元素。

可迭代对象是能够生成迭代器的对象。Iterable<T> 也是一个接口。

实现 Iterable 接口的类需要实现以下方法:

代码示例:

public interface Iterable<T> {
    Iterator<T> iterator();
}

那么,这两者如何协同工作呢?我们通过增强型for循环来理解。

增强型for循环的原理

你可能见过这样的语法:

for (String x : listOfStrings) {
    // 处理 x
}

这种语法之所以能工作,是因为 ListSet 和数组都实现了 Iterable 接口。

上面的增强型for循环实际上是以下代码的简写:

代码示例:

Iterator<String> iter = listOfStrings.iterator();
while (iter.hasNext()) {
    String x = iter.next();
    // 处理 x
}

当我们使用冒号 : 时,Java会自动调用可迭代对象(listOfStrings)的 iterator() 方法来获取一个迭代器,然后使用 hasNext()next() 方法进行遍历。

为了检验理解,以下是几个概念性问题:

概念检查:

  1. 定义一个实现 Iterable<Dog> 接口的类,需要实现什么方法?
    • public Iterator<Dog> iterator()
  2. 定义一个实现 Iterator<Integer> 接口的类,需要实现什么方法?
    • public boolean hasNext()
    • public Integer next()
  3. IteratorIterable 的区别是什么?
    • 迭代器 是实际进行遍历的对象,你可以把它想象成在集合上移动并逐个“吐出”元素的指针。
    • 可迭代对象 是能够产生迭代器的对象,通常是某种集合(如列表、数组)。数组是可迭代的,而数组的迭代器可以逐个访问数组的每个索引。

最后,我们来区分一个在Java中容易混淆的操作。

==.equals() 的区别

==.equals() 各有用途。

== 操作符比较的是变量存储位置的原始比特位。它通常用于基本数据类型(如 int, char)。

对于引用类型(对象),== 比较的是两个变量指向的内存地址是否相同。这可以用来判断两个引用是否指向内存中的同一个对象。

例外情况: null 是一个特殊的指针,几乎总是使用 == 来比较。

.equals() 方法是所有Java对象都从 Object 类继承的方法。它的默认行为与 == 相同,即比较内存地址。

但是,.equals() 方法可以在每个类中被重写,以定义什么样的两个对象应该被视为“逻辑上相等”。例如,即使两个 List 对象位于不同的内存地址,如果它们包含相同的元素序列,我们可能希望认为它们相等。

示例说明:
假设我们定义 Dog 类的 .equals() 方法:当且仅当两只狗的名字相同时返回 true

Dog fido = new Dog("Fido");
Dog otherFido = new Dog("Fido");

boolean test1 = (fido == otherFido); // false,指向两个不同的内存对象
boolean test2 = fido.equals(otherFido); // true,因为我们重写的 equals 方法只比较名字

总结

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

  1. 多态性:编写通用代码的能力,通过泛型 T 和接口实现。
  2. 子类型多态:子类实例可被视作父类类型使用。
  3. 比较器Comparator<T> 接口允许我们自定义对象的比较逻辑。
  4. 迭代器与可迭代对象Iterator<T> 用于遍历,Iterable<T> 用于生成迭代器,它们是增强型for循环的基础。
  5. ==.equals()== 比较引用(内存地址),.equals() 默认比较引用,但可被重写为比较对象内容。

理解这些概念对于编写灵活、可重用和高效的Java代码至关重要。

20:2 - Spring 2023 Discussion 05 问题 1

概述 📋

在本节课中,我们将学习如何实现一个自定义的迭代器(Iterator)和可迭代对象(Iterable),用于处理办公时间(Office Hours)队列中的请求。我们将从基础迭代器开始,逐步构建更复杂的功能,包括处理重复请求和自定义比较器。通过这个过程,你将理解迭代器模式、继承、方法重写以及接口实现的核心概念。


第一部分:实现 OHIterator 🛠️

上一节我们介绍了问题背景,本节中我们来看看如何实现一个基础的迭代器 OHIterator。这个迭代器需要遍历 OHRequest 对象,但只返回那些描述(description)符合“良好”标准的请求。

核心概念

OHIterator 类需要实现 Java 的 Iterator<OHRequest> 接口。这意味着它必须提供 hasNext()next() 方法的具体实现。

代码实现

以下是 OHIterator 类的骨架和构造函数:

import java.util.Iterator;
import java.util.NoSuchElementException;

public class OHIterator implements Iterator<OHRequest> {
    private OHRequest cur;

    public OHIterator(OHRequest q) {
        this.cur = q;
    }
}

实现 hasNext() 方法

hasNext() 方法需要判断是否还有下一个“良好”的请求。我们需要跳过描述不符合要求的请求。

以下是 hasNext() 方法的实现逻辑:

  1. 使用 while 循环跳过描述“不好”的请求。
  2. 如果遍历完所有请求仍未找到“良好”请求,则返回 false
  3. 否则,返回 true
@Override
public boolean hasNext() {
    while (cur != null && !isGood(cur.description)) {
        cur = cur.next;
    }
    return cur != null;
}

实现 next() 方法

next() 方法返回下一个“良好”请求。如果已经没有下一个元素,则抛出异常。此外,在返回当前请求后,需要将内部指针 cur 移动到下一个位置,为下一次调用做准备。

以下是 next() 方法的实现步骤:

  1. 检查是否还有下一个元素(可调用 hasNext())。
  2. 如果没有,抛出 NoSuchElementException
  3. 保存当前请求。
  4. 将内部指针 cur 移动到下一个请求。
  5. 返回保存的请求。
@Override
public OHRequest next() {
    if (!hasNext()) {
        throw new NoSuchElementException();
    }
    OHRequest req = cur;
    cur = cur.next;
    return req;
}

第二部分:实现可迭代的 OHQueue 🔄

上一节我们成功创建了迭代器,本节中我们来看看如何创建一个可迭代的队列类 OHQueue。这个类将使用我们刚刚实现的迭代器。

核心概念

OHQueue 类需要实现 Iterable<OHRequest> 接口。这要求它提供一个返回 Iterator<OHRequest>iterator() 方法。

代码实现

以下是 OHQueue 类的完整实现:

import java.util.Iterator;

public class OHQueue implements Iterable<OHRequest> {
    private OHRequest queue;

    public OHQueue(OHRequest q) {
        this.queue = q;
    }

    @Override
    public Iterator<OHRequest> iterator() {
        return new OHIterator(this.queue);
    }
}

第三部分:处理重复请求的 TYIterator 🔄➡️➡️

假设系统存在一个漏洞:如果请求描述包含“thank you”,它会被重复放入队列一次。我们需要创建一个新的迭代器 TYIterator 来跳过这些重复项。

核心概念

TYIterator 应该继承自 OHIterator,以复用其“筛选良好描述”的逻辑。然后,我们通过重写 next() 方法,在返回请求前检查并跳过重复的“thank you”请求。

代码实现

以下是 TYIterator 类的实现:

public class TYIterator extends OHIterator {
    public TYIterator(OHRequest q) {
        super(q); // 调用父类构造函数
    }

    @Override
    public OHRequest next() {
        OHRequest result = super.next(); // 获取父类认为的下一个“良好”请求
        if (result != null && result.description.contains("thank you")) {
            // 如果描述包含“thank you”,则跳过它,取下一个
            result = super.next();
        }
        return result;
    }
}

为了使 OHQueue 使用新的 TYIterator,只需修改其 iterator() 方法:

@Override
public Iterator<OHRequest> iterator() {
    return new TYIterator(this.queue); // 替换为 TYIterator
}

第四部分:使用迭代器遍历队列 🚶‍♂️

现在,我们可以像遍历标准集合一样,使用 for-each 循环来遍历我们的 OHQueue 对象。

使用示例

以下是如何创建队列并打印所有符合条件(良好描述且处理了重复项)的请求者姓名:

public static void main(String[] args) {
    // 假设 s1, s2, s3... 是已连接的 OHRequest 对象,s1 是队首
    OHQueue q = new OHQueue(s1);

    for (OHRequest o : q) {
        System.out.println(o.name);
    }
}

这段代码会隐式调用 q.iterator()(返回 TYIterator),然后循环调用迭代器的 hasNext()next() 方法,打印出每个请求的名字。


第五部分:实现请求比较器 ⚖️

(注:此部分与主迭代器逻辑相对独立,是关于如何为 OHRequest 定义排序规则。)

OHRequestComparator 类实现了 Comparator<OHRequest> 接口,用于根据 isSetup 字段和描述内容来比较两个请求的优先级。

比较规则

  1. 如果只有一个请求的 isSetuptrue,则该请求优先级更高(应排在队列更前面)。
  2. 如果两个请求的 isSetup 状态相同(都为 true 或都为 false),则检查描述是否完全等于字符串 "setup"
  3. 描述匹配 "setup" 的请求优先级更高。
  4. 如果上述条件均无法区分优先级,则返回 0(表示平局)。

代码实现

以下是 OHRequestComparator 的实现:

import java.util.Comparator;

public class OHRequestComparator implements Comparator<OHRequest> {
    @Override
    public int compare(OHRequest o1, OHRequest o2) {
        // 规则1:比较 isSetup
        if (o1.isSetup && !o2.isSetup) {
            return -1; // o1 优先级高
        } else if (!o1.isSetup && o2.isSetup) {
            return 1;  // o2 优先级高
        }

        // 规则2:isSetup 状态相同,比较描述
        boolean o1DescIsSetup = o1.description.equals("setup");
        boolean o2DescIsSetup = o2.description.equals("setup");

        if (o1DescIsSetup && !o2DescIsSetup) {
            return -1;
        } else if (!o1DescIsSetup && o2DescIsSetup) {
            return 1;
        }

        // 规则3:无法区分
        return 0;
    }
}

注意:比较字符串内容时,应使用 .equals() 方法,而不是 == 操作符。


总结 🎉

本节课中我们一起学习了迭代器模式的完整实现流程:

  1. 我们首先创建了一个基础的 OHIterator,它能够遍历链表结构并筛选出符合特定条件(良好描述)的元素。
  2. 接着,我们创建了 OHQueue 类,通过实现 Iterable 接口,使其可以使用 Java 的 for-each 循环语法进行遍历。
  3. 然后,我们通过继承创建了 TYIterator,演示了如何在复用父类逻辑的基础上,通过重写方法添加新的功能(跳过重复请求)。
  4. 最后,我们实践了如何使用这个自定义的可迭代对象。
  5. 此外,我们还了解了如何实现一个 Comparator 来定义对象的自定义排序规则。

通过这个练习,你掌握了实现自定义迭代器、利用继承扩展功能以及使自定义类可迭代的核心技能,这些都是构建复杂数据结构的重要基础。

21:Spring 2023 考试第5级问题1解析 🛸

在本教程中,我们将学习如何为一个“外星字母表”实现一个自定义的比较器。这个比较器的特殊之处在于,字母的排序规则与我们熟悉的英文字母表不同。我们的目标是理解如何根据给定的非标准字母顺序,来比较两个字符串的字典序。

上一节我们介绍了问题的背景和目标,本节中我们来看看具体的实现步骤。

理解比较器接口

首先,我们需要实现 Comparator<String> 接口。比较器有一个核心方法 compare,它用于比较两个对象:

  • 如果第一个对象 a 小于第二个对象 b,则返回一个负数
  • 如果 a 等于 b,则返回 0
  • 如果 a 大于 b,则返回一个正数

在我们的问题中,ab 就是需要比较的两个单词 word1word2

处理不同长度的单词

在开始逐字母比较之前,我们必须考虑单词长度可能不同的情况。为了避免数组越界错误,我们需要确定一个安全的循环比较范围。

以下是确定最小长度的步骤:

  1. 获取 word1 的长度。
  2. 获取 word2 的长度。
  3. 取两者中较小的值作为 minLength

这样,我们的循环就只需要比较到较短单词的末尾为止。相关逻辑可以用以下代码表示:

int minLength = Math.min(word1.length(), word2.length());
for (int i = 0; i < minLength; i++) {
    // 逐字母比较逻辑
}

逐字母比较

确定了循环范围后,我们就可以开始逐个字母地比较两个单词了。核心思想是:找到每个字母在给定“外星字母表”中的位置(索引),索引值小的字母排序靠前。

以下是逐字母比较的步骤:

  1. 在循环中,分别获取 word1word2 在第 i 个位置上的字符。
  2. 使用外星字母表字符串 orderindexOf 方法,查找这两个字符在字母表中的索引,分别记为 rank1rank2
  3. 比较 rank1rank2
    • 如果 rank1 < rank2,说明 word1 的当前字母更靠前,因此 word1 整个单词更小,直接返回 -1
    • 如果 rank1 > rank2,说明 word1 的当前字母更靠后,因此 word1 整个单词更大,直接返回 1
    • 如果 rank1 == rank2,说明当前字母相同,无法决定单词顺序,需要继续比较下一个字母。

处理循环结束后的情况

如果整个循环比较完所有 minLength 个字母后都没有返回(即所有对应位置的字母都相同),那么会出现两种情况:

  1. 两个单词完全相等(长度也相同)。
  2. 一个单词是另一个单词的前缀(例如 “bad” 和 “badly”)。

根据规则,较短的单词应该排在前面。因此,循环结束后,我们只需返回两个单词长度的差值:

return word1.length() - word2.length();

这个表达式巧妙地满足了我们的需求:

  • 如果 word1 更短,结果为负,符合“更小”的定义。
  • 如果两者长度相等,结果为 0,表示两个单词相等。
  • 如果 word1 更长,结果为正,表示“更大”。

总结

本节课中我们一起学习了如何为一个非标准字母表实现自定义的字符串比较器。我们回顾一下关键点:首先,通过取最小长度来安全地逐字母比较;其次,利用 indexOf 方法将字母转换为其在自定义字母表中的位置索引进行比较;最后,通过比较单词长度来处理前缀特殊情况。记住比较器的核心契约:负值代表小于,零代表等于,正值代表大于。掌握这个模式,你就能应对各种自定义对象的比较需求了。

22:迭代器的迭代器实现教程 🧩

在本教程中,我们将学习如何实现一个“迭代器的迭代器”。这个特殊的迭代器会接收一个整数迭代器的列表,并以轮询的方式依次从每个迭代器中取出一个元素,直到所有迭代器中的元素都被耗尽。我们将通过一个具体的例子来理解其工作原理,并逐步编写代码实现它。


迭代器基础回顾 🔄

在深入问题之前,我们先回顾一下迭代器的基本概念。一个迭代器必须实现两个核心方法:hasNextnext

  • hasNext() 方法返回一个布尔值,指示迭代器中是否还有剩余元素。
  • next() 方法返回迭代器中的下一个元素。

用代码表示,一个迭代器的接口大致如下:

interface Iterator<T> {
    boolean hasNext();
    T next();
}

理解了这些基础后,我们就可以开始构建我们的“迭代器的迭代器”了。


问题理解与示例分析 🧠

我们的目标是创建一个 IteratorOfIterators 类,它本身也是一个迭代器。它接收一个 List<Iterator<Integer>> 作为输入,并以轮询的方式输出整数。

让我们通过题目提供的具体例子来理解这个过程。假设我们有三个迭代器:

  • 迭代器 A 包含元素:[1, 3, 4, 5]
  • 迭代器 B 是空的:[]
  • 迭代器 C 包含元素:[2]

IteratorOfIterators 应该按以下顺序输出元素:

  1. 从 A 取 1
  2. 跳过空的 B
  3. 从 C 取 2
  4. 轮询回到 A,取 3
  5. 再次检查 B 和 C,它们都已空,所以继续从 A 取 45

因此,最终的输出序列是:1, 2, 3, 4, 5


实现思路与步骤 🛠️

上一节我们通过例子理解了需求,本节中我们来看看如何用代码实现它。核心思路是使用一个链表来管理这些非空的迭代器,并模拟轮询行为。

步骤一:定义类与实例变量

首先,我们定义类并声明一个实例变量来存储传入的迭代器列表。为了方便后续的轮询操作,我们选择使用 LinkedList

import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.NoSuchElementException;

public class IteratorOfIterators implements Iterator<Integer> {
    private LinkedList<Iterator<Integer>> iterators;

    // 构造函数将在下一步实现
    public IteratorOfIterators(List<Iterator<Integer>> a) {
        // 初始化代码
    }

    @Override
    public boolean hasNext() {
        // 实现代码
    }

    @Override
    public Integer next() {
        // 实现代码
    }
}

步骤二:构造函数与初始化

在构造函数中,我们需要过滤掉输入列表中所有一开始就是空的迭代器,因为它们在轮询中毫无作用。以下是构造函数的实现:

public IteratorOfIterators(List<Iterator<Integer>> a) {
    iterators = new LinkedList<>();
    for (Iterator<Integer> iterator : a) {
        if (iterator.hasNext()) { // 只添加还有元素的迭代器
            iterators.add(iterator);
        }
    }
}

经过这个步骤,iterators 链表中的所有迭代器都保证至少有一个元素。

步骤三:实现 next() 方法

next() 方法是实现轮询逻辑的核心。其算法如下:

  1. 检查是否还有下一个元素(依赖 hasNext)。
  2. 从链表头部移除第一个迭代器。
  3. 从该迭代器中取出一个元素。
  4. 如果该迭代器取完后还有元素(hasNext 为真),则将其重新放回链表的尾部
  5. 返回取出的元素。

这样,链表就形成了一个“队列”,迭代器在被处理后会排到队尾,等待下一轮被处理。耗尽的迭代器则不会被加回队列。

@Override
public Integer next() {
    if (!hasNext()) {
        throw new NoSuchElementException();
    }
    // 1. 从链表头部移除第一个迭代器
    Iterator<Integer> currentIterator = iterators.removeFirst();
    // 2. 从该迭代器中取出下一个元素
    Integer nextValue = currentIterator.next();
    // 3. 如果该迭代器还有元素,则将其加回链表尾部
    if (currentIterator.hasNext()) {
        iterators.addLast(currentIterator);
    }
    // 4. 返回取出的值
    return nextValue;
}

步骤四:实现 hasNext() 方法

hasNext() 方法的逻辑相对简单。当我们的 iterators 链表为空时,意味着所有迭代器中的元素都已被取出,此时 hasNext 应返回 false

@Override
public boolean hasNext() {
    return !iterators.isEmpty();
}

核心要点总结 📝

本节课中我们一起学习了如何实现一个“迭代器的迭代器”。我们回顾了迭代器的两个基本方法 hasNextnext,并通过一个轮询的例子理解了问题需求。实现的关键步骤包括:

  1. 过滤空迭代器:在构造函数中只保留非空迭代器。
  2. 使用队列管理:用 LinkedList 存储迭代器,利用其 removeFirstaddLast 方法实现轮询。
  3. 实现轮询逻辑:在 next() 方法中,从队列头取出迭代器、获取值,如果迭代器未耗尽则放回队尾。
  4. 判断终止条件:当队列为空时,hasNext() 返回 false

记住,当处理迭代器相关问题时,先明确 hasNextnext 方法各自的责任,并通过画图或举例来梳理逻辑,是解决问题的有效方法。

23:渐进分析与并查集内容回顾

在本节课中,我们将学习两个核心概念:渐进分析并查集。渐进分析帮助我们评估算法的性能,特别是当输入规模变得非常大时。并查集是一种高效的数据结构,用于处理元素的分组与连通性问题。我们将逐一探讨这些主题,并通过示例加深理解。

渐进分析

上一节我们介绍了本节课的两个主题。本节中,我们来看看第一个主题:渐进分析。

渐进分析允许我们使用数学方法评估程序的性能。当我们谈论性能时,通常指的是程序运行所需的时间量。我们也可以讨论内存使用,这被称为空间复杂度,但在CS 61B中我们主要关注时间复杂度。

在渐进分析中,我们忽略所有常数,只关心随着输入规模(通常定义为 n)变得非常大时完成的总工作量。有三个重要的符号需要关注。

以下是三个核心的渐进符号:

  1. 大O符号 (Big O):这是输入的上界。如果一个函数是 O(f(n)),我们说它至多增长得像 f(n) 一样快,但也可能增长得更慢。例如,线性函数是 O(n²),因为线性函数增长得不会比二次函数快。
  2. 大Ω符号 (Big Omega):这是输入的下界。如果一个函数是 Ω(f(n)),我们说它至少增长得像 f(n) 一样慢,但也可能增长得更快。例如,二次函数是 Ω(n),因为二次函数增长得不会比线性函数慢。
  3. 大Θ符号 (Big Theta):这是紧确界。当最紧的上界和最紧的下界收敛到同一个值时存在。如果一个函数既是 O(f(n)) 又是 Ω(f(n)),并且 f(n) 是可能的最紧边界,那么我们可以说该函数是 Θ(f(n))

我们经常在编程中看到一些常见的增长阶数。

以下是常见的增长阶数,从慢到快排列:

  • 常数阶O(1)
  • 对数阶O(log n)
  • 线性阶O(n)
  • 线性对数阶O(n log n)
  • 二次方阶O(n²)
  • 指数阶O(cⁿ) (c为常数)

常数在长期运行中无关紧要。例如,n log n + 1,000,000n 非常大时,其增长仍然比 慢。

一些有用的求和公式可以帮助你分析循环等结构的运行时间。

以下是一些常见的求和及其渐进结果:

  • 1 + 2 + 3 + ... + n 的和是 Θ(n²)
  • 1 + 2 + 4 + 8 + ... + n 的和是 Θ(n)

关于紧确界,它指的是最具体的可能边界。举例来说,给定函数 f(n) = 2n + 5,我们可以说它是 O(nⁿ),但这没有提供太多信息。一个更好、更紧的边界是 Θ(n),因为这是一个线性函数。

有时我们会讨论最好情况最坏情况。需要注意的是,最好与最坏情况不一定基于输入的大小,而是基于导致特定行为的输入。我们用紧确界 Θ 来表示它们,因为最好和最坏情况下的运行时间在各自特定的输入范围内是确定的。

识别函数是否具有不同的最好和最坏情况的一个简单方法是留意分支语句、循环条件和break语句

用一个类比来区分最好/最坏情况与上/下界:考虑“在一家餐厅吃饭要花多少钱?”最好/最坏情况的思路是:菜单上最便宜的菜是5美元,最贵的是50美元。而下界和上界则说:我们至少会花5美元,最多不会超过50美元。下界和上界更像一个范围,而最好和最坏情况是该范围的具体端点。

为了说明这一点,请看下面的例子。

void example(int n) {
    while (n > 0) {
        if (func(n)) {
            break;
        }
        n--;
    }
}
  • 最好情况:如果 func(n) 在第一次循环时就为真,我们立即跳出循环。这需要常数时间 Θ(1)
  • 最坏情况:如果 func(n) 对于 n, n-1, ..., 1 都为假,循环将运行 n 次直到结束。这需要线性时间 Θ(n)

并查集

上一节我们深入探讨了渐进分析。本节中,我们将注意力转向第二个核心并查集。

并查集,也称为Union-Find,是一个支持两种操作的接口。

以下是并查集支持的两个主要操作:

  1. connect(x, y):连接节点 xy(也称为 union)。
  2. isConnected(x, y):如果 xy 是连通的(即在同一个集合中),则返回 true

有几种实现方式。Quick Find使用一个整数数组来跟踪每个元素属于哪个集合。Quick Union存储每个节点的父节点(而不是所属集合),并通过将一个集合的根节点的父节点设置为另一个集合的根节点来合并集合。然而,我们主要关注更高效的两种实现。

以下是两种更高效的并查集实现:

  1. 加权Quick Union:与Quick Union类似,但它根据集合的大小决定哪个集合合并到哪个集合中。我们总是将较小的集合合并到较大的集合中。这有助于减少结构的“高度”,避免形成类似链表的瘦长结构,从而降低查找操作的运行时间。
  2. 带路径压缩的加权Quick Union:在加权Quick Union的基础上,每当对某个节点调用 find 操作时,就将该节点的父节点直接设置为所在集合的根节点。这能进一步扁平化结构。

find 是一个辅助操作,用于查找给定输入节点的(最终)父节点(即根节点)。

从渐进分析的角度看,不同实现的性能对比如下。

以下是不同并查集实现的操作时间复杂度:

  • 构造函数:所有实现都是 Θ(N)
  • connect
    • Quick Find: Θ(N)
    • Quick Union: O(N)
    • 加权Quick Union: O(log N)
    • 带路径压缩的加权Quick Union: O(α(N)) (极快,α是反阿克曼函数)
  • isConnected
    • Quick Find: Θ(1)
    • Quick Union: O(N)
    • 加权Quick Union: O(log N)
    • 带路径压缩的加权Quick Union: O(α(N))

尽管加权Quick Union的 isConnected 比Quick Find的常数时间慢,但我们通常更倾向于使用它(或其带路径压缩的版本),因为它的 connect 操作要快得多。

并查集的数组表示

在实现并查集时,我们使用一个单独的数组来高效地表示它。

数组的长度等于并查集中元素的数量。数组的索引 i 处的值表示元素 i 的父节点。如果一个节点是它所在集合的根节点,那么在该索引处存储的值是该根节点所在集合的元素总数的负数

让我们通过一个例子来理解。假设我们有一个包含9个元素的并查集,经过一系列 connect 操作后,其数组表示如下:

索引:   [0]  [1]  [2]  [3]  [4]  [5]  [6]  [7]  [8]
值:     -9    0    0    0    0    1    1    3    4
  • 索引 0:值是 -9。负号表示节点0是一个根节点。绝对值 9 表示以节点0为根的集合中共有9个元素。
  • 索引 1, 2, 3, 4:值分别是 0, 0, 0, 0。这表示节点1、2、3、4的父节点都是节点0。
  • 索引 5:值是 1。这表示节点5的父节点是节点1(注意,在普通的加权Quick Union中,它不直接指向根节点0)。
  • 索引 6:值是 1。父节点是节点1。
  • 索引 7:值是 3。父节点是节点3。
  • 索引 8:值是 4。父节点是节点4。

这种表示方法紧凑且高效,允许我们快速执行 findunion 操作。


本节课中我们一起学习了渐进分析并查集。我们了解了如何使用大O、大Ω和大Θ符号来分析算法的效率,并探讨了并查集的不同实现及其性能差异。理解这些概念对于设计和分析高效算法至关重要。

24:渐进分析与运行时分析 🧮

在本节课中,我们将学习渐进分析(Asymptotics)的核心概念,并通过一系列问题来练习如何确定算法的最佳情况、最坏情况和平均情况下的时间复杂度。


第一部分:渐进复杂度排序 📊

上一节我们介绍了渐进分析的基本思想,本节中我们来看看如何比较不同函数的增长速率。

以下是问题A:将以下大O运行时复杂度按从小到大排序。

  • O(2^1000000)
  • O(n^n)
  • O(n^3 + 4)
  • O(log n)
  • O(0.0005n)
  • O(n!)
  • O(2^n)
  • O(n^2 log n)

在比较渐进复杂度时,我们忽略常数因子和低阶项。简化后,我们可以根据标准的增长阶层次进行排序。

  1. 常数时间O(2^1000000) 是一个巨大的常数,但仍然是常数,增长最慢。
  2. 对数时间O(log n) 增长慢于线性时间。
  3. 线性时间O(0.0005n) 忽略常数系数后为 O(n)
  4. 线性对数时间O(n log n)
  5. 平方对数时间O(n^2 log n)。因为 log n 的增长慢于 n,所以它比 O(n^3) 慢。
  6. 立方时间O(n^3 + 4) 忽略常数项后为 O(n^3)
  7. 指数时间O(2^n)
  8. 阶乘时间O(n!)。当 n 很大时,n! 的增长远快于 2^n
  9. n的n次方时间O(n^n)。这是列表中增长最快的函数。

因此,最终排序为:O(2^1000000) < O(log n) < O(0.0005n) < O(n log n) < O(n^2 log n) < O(n^3 + 4) < O(2^n) < O(n!) < O(n^n)


第二部分:函数 findMax 的边界分析 🔍

上一节我们比较了抽象函数的复杂度,本节中我们来看看如何为一个具体的函数确定其渐进边界。

问题B:函数 findMax 遍历一个未排序的整数数组一次,并返回找到的最大元素。请给出该函数最紧的下界(Big Ω)和上界(Big O),并判断是否可以定义其紧确界(Big Θ)。

  • 下界 (Big Ω):要找到数组中的最大值,我们至少需要检查数组中的每一个元素一次。因此,最紧的下界是 Ω(n)
  • 上界 (Big O):该函数只对数组进行一次线性遍历,没有额外的操作。因此,最紧的上界也是 O(n)
  • 紧确界 (Big Θ):当一个函数的最紧下界和最紧上界相同时,我们可以定义其紧确界。由于 findMax 的下界和上界都是 n,因此其紧确界是 Θ(n)。这意味着该函数的运行时间平均而言与数组长度成线性关系。

第三部分:嵌套循环的最佳与最坏情况分析 ⚙️

上一节我们分析了单层循环的边界,本节中我们来看看包含嵌套循环和条件语句的代码段。

问题C:分析以下代码片段,给出其最佳情况和最坏情况下的运行时复杂度(用 mn 表示)。假设 ping(i, j) 是常数时间操作。

for (int i = n; i >= 1; i--) {
    for (int j = 0; j <= m; j++) {
        if (ping(i, j) > 64) {
            break;
        }
    }
}

以下是分析过程:

最佳情况:当 ping(i, j) > 64 在每次内层循环的第一次迭代时就为真,会立即触发 break。这样,对于外层循环的每个 i,内层循环只执行常数次工作(1次 ping 调用和 break)。外层循环执行 n 次,所以总工作量为 n * 1 = n。因此,最佳情况运行时间为 Θ(n)

最坏情况:当 ping(i, j) > 64 永远不为真,内层循环永远不会提前中断。对于外层循环的每个 i,内层循环需要完整执行 m+1 次。总工作量为 n * (m+1) = mn + n。在渐进分析中,mn 是主导项,因此最坏情况运行时间为 Θ(mn)


第四部分:包含排序和早期返回的函数分析 📝

上一节我们分析了循环内的 break 语句,本节中我们来看一个包含函数调用和早期 return 语句的更复杂例子。

问题D:分析以下函数 noUniques 的最佳和最坏情况运行时复杂度,其中 n 是数组长度。假设 sortArray 的运行时间为 Θ(n log n)

boolean noUniques(int[] array) {
    array = sortArray(array); // Θ(n log n)
    int n = array.length;
    for (int i = 0; i < n; i++) {
        boolean hasDuplicate = false;
        for (int j = 0; j < n; j++) {
            if (array[i] == array[j] && i != j) {
                hasDuplicate = true;
            }
        }
        if (!hasDuplicate) {
            return false; // 早期返回
        }
    }
    return true;
}

以下是分析过程:

最佳情况:我们希望函数尽早返回 false。假设排序后的数组第一个元素 (i=0) 就是唯一的(没有重复项)。那么,在外层循环的第一次迭代中,内层 j 循环需要完整遍历 n 个元素来确定它没有重复项,工作量为 n。随后,if (!hasDuplicate) 条件为真,函数立即返回 false。因此,总工作量是排序的 n log n 加上第一次外层迭代的 nn log n 是主导项,所以最佳情况运行时间为 Θ(n log n)

最坏情况:函数必须返回 true,即数组中每个元素都有重复项,因此永远不会触发早期返回。外层循环需要执行 n 次。对于每一次外层迭代 i,内层 j 循环都需要完整执行 n 次来检查重复项。因此,循环部分的总工作量为 n * n = n²。加上排序的 n log n 是主导项。所以最坏情况运行时间为 Θ(n²)


总结 📚

本节课中我们一起学习了渐进分析的核心技巧:

  1. 通过忽略常数和低阶项来比较不同复杂度函数的增长阶。
  2. 为算法确定最紧的渐进上界(Big O)、下界(Big Ω)和紧确界(Big Θ)。
  3. 通过识别循环、条件语句(特别是 break 和早期 return)来分析代码段的最佳情况和最坏情况运行时复杂度。
    掌握这些分析方法是理解和设计高效算法的基石。

25:并查集问题详解

在本节课中,我们将学习并查集数据结构,并通过一个具体问题来理解其工作原理。我们将使用加权快速合并(无路径压缩)的实现方式,逐步执行一系列连接和查找操作,并绘制出对应的树形结构和底层数组表示。最后,我们将探讨不使用加权策略时的最坏情况,以及如何通过路径压缩来优化查找操作。


问题描述

假设有9个元素,用整数0到8表示。初始时,所有元素互不连接,各自构成独立的集合。我们需要执行一系列 connectfind 操作,并绘制出操作后的并查集树形结构及其底层数组表示。实现方式为加权快速合并(无路径压缩)。当两个集合大小相同时,选择较小的整数作为根节点。

底层数组表示回顾

底层数组的长度为9,每个索引对应一个节点。数组中的每个元素表示以下两种情况之一:

  • 如果值为负数,则表示该节点是所在集合的根,其绝对值代表该集合中的元素数量。
  • 如果值为非负数,则表示该节点的父节点索引。

初始时,所有节点都是自己集合的根,因此数组所有位置的值均为 -1


逐步执行操作

以下是需要执行的操作序列:

  1. connect(2, 3)
  2. connect(1, 2)
  3. connect(5, 7)
  4. connect(8, 4)
  5. connect(7, 2)
  6. find(3)
  7. connect(0, 6)
  8. connect(6, 4)
  9. connect(6, 3)
  10. find(8)
  11. find(6)

操作详解与数组变化

1. connect(2, 3)
节点2和3最初都在大小为1的独立集合中。根据规则,选择较小的整数2作为根。因此,节点3的父节点变为2。

  • 数组变化:array[3]-1 变为 2array[2]-1 变为 -2(表示以2为根的集合有2个元素)。

2. connect(1, 2)
节点1所在集合大小为1,节点2所在集合大小为2。根据加权规则,将较小的集合(含1)合并到较大的集合(含2和3)中。因此,节点1的父节点变为2。

  • 数组变化:array[1]-1 变为 2array[2]-2 变为 -3

3. connect(5, 7)
节点5和7都在大小为1的集合中。选择较小的整数5作为根。

  • 数组变化:array[7]-1 变为 5array[5]-1 变为 -2

4. connect(8, 4)
节点8和4都在大小为1的集合中。选择较小的整数4作为根。

  • 数组变化:array[8]-1 变为 4array[4]-1 变为 -2

5. connect(7, 2)
此操作连接的是节点7和2所在集合的根。首先查找根:节点7的根是5,节点2的根是2。比较集合大小:以5为根的集合大小为2,以2为根的集合大小为3。根据加权规则,将较小的集合(根为5)合并到较大的集合(根为2)中。因此,节点5的父节点变为2。

  • 数组变化:array[5]-2 变为 2array[2]-3 变为 -5。注意,array[7] 的值保持为 5 不变,因为这不是路径压缩。

6. find(3)
find 操作查找节点所在集合的根。从节点3开始,父节点是2,而节点2的父节点是它自身,所以根是2。

  • 结果:find(3) 返回 2

7. connect(0, 6)
节点0和6都在大小为1的集合中。选择较小的整数0作为根。

  • 数组变化:array[6]-1 变为 0array[0]-1 变为 -2

8. connect(6, 4)
连接节点6和4所在集合的根。节点6的根是0,节点4的根是4。两个集合大小均为2。根据附加规则(大小相同时选较小整数为根),选择0作为根。因此,节点4的父节点变为0。

  • 数组变化:array[4]-2 变为 0array[0]-2 变为 -4

9. connect(6, 3)
连接节点6和3所在集合的根。节点6的根是0,节点3的根是2。比较集合大小:以0为根的集合大小为4,以2为根的集合大小为5。将较小的集合(根为0)合并到较大的集合(根为2)中。因此,节点0的父节点变为2。

  • 数组变化:array[0]-4 变为 2array[2]-5 变为 -9

10. find(8)
查找节点8的根。路径:8 -> 4 -> 0 -> 2。节点2是根。

  • 结果:find(8) 返回 2

11. find(6)
查找节点6的根。路径:6 -> 0 -> 2。节点2是根。

  • 结果:find(6) 返回 2

最终数组状态

所有操作执行完毕后,底层数组如下:

索引:  0  1  2  3  4  5  6  7  8
值:    2  2 -9  2  0  2  0  5  4

解释:

  • array[2] = -9:节点2是根,其集合包含全部9个元素。
  • 非负数值表示父节点索引,如 array[3] = 2 表示节点3的父节点是2。
  • 负数值表示该索引的节点是根,其绝对值表示集合大小。

最坏情况结构与运行时间分析

上一节我们使用加权快速合并完成了操作。现在,我们考虑一个不使用加权策略的简单并查集实现。

在这种实现中,connect 操作可能随意地将一个集合的根连接到另一个集合,而不考虑集合大小。这可能导致树结构退化成一条长链(类似于链表)。

以下是这种最坏情况的一个例子:
假设按顺序连接 (1,2), (2,3), (3,4)... 并且总是将新节点作为子节点连接到现有链的末端。最终形成的树将是一条从根到叶子的线性路径。

对于这种退化的结构,find 操作在最坏情况下需要遍历从目标节点到根节点的整条路径。如果集合中有 N 个元素,find 操作的时间复杂度将是 O(N),即线性时间。这与使用加权策略后近似 O(log N) 的复杂度相比,效率要低得多。


使用路径压缩优化查找

我们之前已经看到了加权快速合并如何优化连接操作。现在,我们探讨如何通过修改 find 函数来进一步优化,这种方法称为路径压缩

路径压缩的核心思想是:在 find 操作寻找某个节点的根时,顺便将该节点(以及沿途经过的节点)直接指向根节点。这样可以扁平化树结构,使得后续对同一节点或沿途节点的 find 操作更快。

假设我们有一个 setParent(int val, int newParent) 函数,可以将 val 的父节点设置为 newParent。我们需要修改 find 函数,最多添加一行代码来实现路径压缩。

修改思路如下:
在递归或循环找到根节点 root 之后,在返回 root 之前,将当前节点 val 的父节点直接设置为 root

伪代码示例(递归版):

public int find(int val) {
    if (parent[val] < 0) {
        return val; // val 就是根
    }
    int root = find(parent[val]); // 递归查找根
    parent[val] = root; // 路径压缩:将当前节点的父节点直接设为根
    return root;
}

添加的关键一行是 parent[val] = root;。这行代码确保了在查找路径上的节点在下次查找时能直接跳转到根。

通过结合加权快速合并(优化 connect)和路径压缩(优化 find),并查集操作的均摊时间复杂度可以变得极快,接近常数时间(具体为 O(α(n)),其中 α 是反阿克曼函数,增长极其缓慢)。


总结

本节课中我们一起学习了并查集数据结构的核心操作。

  1. 我们使用加权快速合并(无路径压缩) 逐步执行了连接和查找操作,绘制了树形结构并跟踪了底层数组的变化。
  2. 我们分析了在不使用加权策略时,树结构可能退化成链表,导致 find 操作的最坏时间复杂度为 O(N)
  3. 我们介绍了路径压缩技术,通过在 find 操作中增加一行代码,将沿途节点直接链接到根,可以大幅优化后续查找的效率。结合加权策略,能获得近乎常数的均摊运行时间。

理解并查集的这些优化策略,对于设计高效算法至关重要。

26:渐进分析入门 🚀

在本节课中,我们将学习如何分析简单迭代函数的运行时间复杂度。我们将通过两个具体的函数示例,使用表格法来直观地计算其大O表示法。


函数 F1 的分析

首先,我们分析函数 F1。对于迭代的渐进分析,一个有效的方法是绘制一个表格,列出每个循环所做的工作。通常我们只有两层嵌套循环,因此可以将其格式化为一个二维表格。

以下是 F1 的代码结构:

for (int i = 1; i < n; i++) {
    for (int j = 1; j < i; j++) {
        // 常数时间操作
    }
}

我们可以将 i 的值列在左侧,j 的值列在内部。i 的取值范围是从 1n-1。对于每一个 i 的值,内层循环 j 会从 1 迭代到 i-1

以下是每个 i 值对应的内层循环迭代次数(即工作量):

  • i = 1 时,内层循环不执行,工作量为 0
  • i = 2 时,j1 迭代到 1,工作量为 1
  • i = 3 时,j1 迭代到 2,工作量为 2
  • ...
  • i = n-1 时,j1 迭代到 n-2,工作量为 n-2

因此,总工作量是所有这些行的工作量之和:
总工作量 = 0 + 1 + 2 + ... + (n-2)

这个求和公式让我们联想到一个已知的求和:1 + 2 + ... + n 的结果是 Θ(n²)。我们的求和从 0 开始,并且缺少最后两项 (n-1)n,但从渐进分析的角度看,它仍然是 Θ(n²)

所以,函数 F1 的时间复杂度是 Θ(n²)


函数 F2 的分析

上一节我们分析了线性递增的循环,本节中我们来看看指数递增的情况。现在分析函数 F2,我们将使用相同的方法:绘制表格,列出每行工作量,然后求和。

以下是 F2 的代码结构:

for (int i = 1; i < n; i *= 2) {
    for (int j = 1; j < i; j++) {
        // 常数时间操作
    }
}

外层循环的 i 值以2的倍数增长:1, 2, 4, 8, ...,直到超过 n 之前停止(即 n/2)。对于每一个 i 的值,内层循环 j 仍然从 1 迭代到 i-1

以下是每个 i 值对应的内层循环迭代次数:

  • i = 1 时,工作量为 0
  • i = 2 时,工作量为 1
  • i = 4 时,工作量为 3
  • i = 8 时,工作量为 7
  • ...
  • i = n/4 时,工作量为 n/4 - 1
  • i = n/2 时,工作量为 n/2 - 1

因此,总工作量是:
总工作量 = 0 + 1 + 3 + 7 + ... + (n/4 - 1) + (n/2 - 1)

观察这个序列,它近似于一个几何级数:1, 2, 4, 8, ..., n/2。在渐进分析中,一个几何级数的和主要由其最大项决定。在这个求和中,最大项是 n/2。忽略常数因子后,其时间复杂度为 Θ(n)

所以,函数 F2 的时间复杂度是 Θ(n)


每周考试技巧 💡

对于迭代渐进分析问题,绘制如上所示的表格总是一个好主意。将外层循环变量 i 列在左侧,内层循环变量 j 的迭代范围列在行内。你只需要计算每行的工作量,然后将其求和,最终结果通常会归结为你所熟悉的两种求和形式之一:算术级数求和几何级数求和


总结

本节课中我们一起学习了如何使用表格法分析简单迭代函数的时间复杂度。我们通过两个例子实践了这种方法:

  1. 对于外层循环线性递增、内层循环与 i 相关的嵌套循环,其时间复杂度通常是 Θ(n²)
  2. 对于外层循环指数递增(如翻倍)的情况,其时间复杂度通常由最大项决定,例如 Θ(n)

掌握这种方法能帮助你快速而准确地分析大多数基础迭代代码的渐进性能。

27:并查集有效性验证 🧩

在本节课中,我们将学习如何判断一个给定的数组表示是否是一个有效的“带权快速合并”并查集。我们将通过分析几个具体例子,掌握验证并查集有效性的核心规则。

并查集是一种用于处理不相交集合的数据结构。在“带权快速合并”的实现中,我们通常使用一个数组来表示,其中每个索引代表一个元素,其值代表该元素的父节点。根节点用一个负数表示,其绝对值代表该树的大小。


数组表示法回顾

在数组表示法中:

  • 每个索引 i 代表一个元素。
  • array[i] 代表元素 i 的父节点。
  • 如果 array[i] < 0,则表示 i 是一个根节点,并且 -array[i] 是该树的大小。

例如,在数组 [1, -2, 1, 3, 1, 5, 5, 5, 9, -10] 中:

  • 索引 4 的父节点是 1
  • 索引 0 的父节点是 1
  • 索引 9 的父节点是 5
  • 索引 1 的值是 -2,表示 1 是根节点,其树的大小为 2
  • 索引 9 的值是 -10,表示 9 是根节点,其树的大小为 10

我们的任务是检查给定的数组表示是否可能由一个“带权快速合并”操作序列生成。接下来,我们将逐一分析题目中的例子。


问题分析

以下是需要检查的六个数组表示。我们将把它们转换成图形,以便更直观地判断其有效性。

示例 A: 存在环

数组 A: [1, 2, 3, 0, 1, 5, 5, 5, 9, 9]

首先,我们将数组转换为父子关系图:

  • 0 -> 1
  • 1 -> 2
  • 2 -> 3
  • 3 -> 0

在绘制过程中,我们立即发现了一个问题:0 -> 1 -> 2 -> 3 -> 0 形成了一个环。

结论:无效。因为并查集必须由树构成,而树是无环的。


示例 B: 违反带权规则

数组 B: [9, 0, 0, 0, 0, 0, 9, 9, 9, -10]

让我们绘制其结构:

  • 根节点是 9(值为 -10)。
  • 节点 0 的父节点是 9
  • 节点 1, 2, 3, 4, 5 的父节点都是 0
  • 节点 6, 7, 8 的父节点都是 9

图形看起来像两棵子树:一棵以 0 为根(包含 0,1,2,3,4,5,共6个节点),另一棵以 9 为根(包含 9,6,7,8,共4个节点),而 09 的子节点。

结论:无效。在“带权快速合并”中,当合并两棵树时,总是将节点数较多的树的根作为新根。这里,较大的树(0,大小6)却成为了较小树(9,大小4)的子节点,违反了规则。


示例 C: 树高过高

数组 C: [1, 2, 3, 4, 5, 6, 7, 8, 9, -10]

这个数组表示一条链:

  • 0 -> 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 -> 8 -> 9(根)

这棵树的高度是 9(从 0 到根 9 的边数)。

结论:无效。“带权快速合并”保证树的高度不超过 ⌊log₂(n)⌋,其中 n 是树中节点总数。这里 n=10⌊log₂(10)⌋ = 3。实际高度 9 > 3,违反了高度限制。


示例 D: 有效结构

数组 D: [-5, 0, 0, 0, 0, 1, 1, 1, 6, 2]

绘制结构:

  • 根节点是 0(大小5)。
  • 节点 1, 2, 3, 40 的直接子节点。
  • 节点 5, 6, 71 的子节点。
  • 节点 86 的子节点。
  • 节点 92 的子节点。

检查规则:

  1. 无环:是。
  2. 带权规则:所有合并看起来都合理(例如,将 5,6,7 的树合并到 1 下时,需要检查当时 1 的树是否更大或等大,从最终形态反推是合理的)。
  3. 树高:树高为 3(例如路径 8->6->1->0)。3 <= ⌊log₂(10)⌋ = 3,符合要求。

结论:有效。


示例 E: 树高过高(另一种情况)

数组 E: [-5, 0, 0, 0, 0, 1, 1, 1, 6, 8]

结构与 D 类似,但节点 9 成为了 8 的子节点:

  • 9 -> 8 -> 6 -> 1 -> 0

这使得树高变为 4

结论:无效。因为 4 > ⌊log₂(10)⌋ = 3,违反了高度限制。


示例 F: 多棵树与高度检查

数组 F: [-3, 0, 0, 1, 1, 2, 7, -3, 7, 7]

这个数组表示了两棵独立的树:

  • 树 1:根为 0(大小3)。包含节点 0, 1, 2, 3, 4, 5。其结构为 3->1->0, 4->1->0, 5->2->0。树高为 2
  • 树 2:根为 7(大小3)。包含节点 7, 8, 9。结构为 8->7, 9->7。树高为 1

检查规则:

  • 树 2:高度 1 <= ⌊log₂(3)⌋ = 1,有效。
  • 树 1:高度 2。需要检查 2 <= ⌊log₂(6)⌋ 吗?注意,规则中的 n该棵树的节点数。树 1 有 6 个节点,⌊log₂(6)⌋ = 2。高度 2 刚好满足。
    • 但是,让我们仔细看路径。最长的路径可能是 3->1->04->1->0,长度是 2(边数)。然而,题目中认为 5->2->0 也是长度 2。看起来是有效的?等等,原题解析认为无效。我们需要检查是否有一条路径更长。根据数组:3的父节点是11的父节点是05的父节点是22的父节点是0。确实没有更长的路径。树高应为 2
    • 原视频解析指出此树高为 3 并判断无效,这可能源于对具体路径 5->2->0 的计数方式不同(将根节点也计为一层)。如果我们定义高度为根节点到最远叶节点的边数,那么 0(根)到 3(叶)是2条边,高度是 2。如果定义为节点层数(根为第0层),那么 3 在第2层。通常我们使用边数定义。2 <= ⌊log₂(6)⌋ = 2,应属有效。
    • 然而,根据原题答案,F 被判定为无效。矛盾可能在于对“高度”的理解或题目有特定约定。我们遵循原题逻辑:如果任何一棵树的高度超过 ⌊log₂(该树节点数)⌋,则整体无效。假设原题认为此树高为 3,则 3 > ⌊log₂(6)⌋ = 2,无效。

结论:基于原题解析,无效。因为(按照其计算)树高 3 > ⌊log₂(6)⌋ = 2


核心规则总结

本节课中我们一起学习了验证“带权快速合并”并查集有效性的三个核心规则:

  1. 无环规则:并查集是森林(多棵树),每棵树都必须无环。
  2. 带权规则:在合并后的树中,任何子树的大小都不能大于其父节点所在的子树。换句话说,从任何节点到根的路径上,子树大小不会减小。更直接地说:如果两个节点在一个连通分量中,那么节点数更多的那个集合的根应该成为最终的根
  3. 树高限制:任何一棵包含 n 个节点的树,其高度 h 必须满足 h ≤ ⌊log₂(n)⌋。这是“带权”特性带来的保证。

公式总结

  • 设某棵树节点数为 n,高度为 h(边数定义)。
  • 有效性条件:h ≤ ⌊log₂(n)⌋

在解决问题时,先将数组转换为图形表示,然后依次检查上述三条规则,即可判断其有效性。


祝你 CS61B 学习顺利!

28:3 - 带权快速合并的渐进分析 🧮

在本节课中,我们将学习带权快速合并(Weighted Quick Union)数据结构中不同操作的渐进时间复杂度分析。我们将结合本周讨论的两个主题:带权快速合并和渐进分析,通过分析一个具体问题来理解其最佳和最坏情况下的性能。

带权快速合并基础

上一节我们介绍了带权快速合并的基本概念,本节中我们来看看其核心操作的时间复杂度。

在带权快速合并(不进行路径压缩)中,树的高度始终是 O(log n),其中 n 代表元素的数量。这意味着树的最大高度是元素数量的对数因子。

因此,isConnectedconnect 操作的最坏情况运行时间也是 O(log n),因为在最坏情况下,执行这些操作时需要遍历到树的底部。

以下是 isConnectedconnect 操作的时间复杂度总结:

  • 最坏情况O(log n)
  • 最佳情况O(1)(例如,当所有元素都直接连接到根节点时)

分析 addToWeightedQuickUnion 方法

现在,我们来分析一个名为 addToWeightedQuickUnion 的方法。该方法接收一个元素列表,并以随机顺序连接它们,直到所有元素都连通为止。

方法的核心逻辑如下:

  1. 生成所有可能的元素对(顺序随机)。
  2. 遍历这些元素对并调用 connect 方法。
  3. 当最大连通分量的大小等于元素总数时停止。

我们可以假设 pairs(生成所有元素对)和 size(计算最大分量大小)方法都在常数时间内运行。

最佳情况运行时间(Ω)

为了尽快终止循环,我们可能遇到一个非常幸运的情况:所有连接操作都将一个新元素直接连接到同一个根节点(例如元素0)。

在这种情况下,树始终保持高度为1,每次 connect 操作都是常数时间 O(1)。我们只需要 n 次连接(每次添加一个新元素)就能连通所有元素。

因此,最佳情况下的总运行时间是 n * O(1) = O(n),即 Ω(n)

最坏情况运行时间(O)

考虑最坏情况:有一个节点(例如节点0)在所有包含它的元素对中都出现在列表的最后。这意味着在最后 n 次连接之前,这个节点一直处于孤立状态。

首先,元素对的总数是组合数 C(n, 2),即 n*(n-1)/2,其数量级为 Θ(n²)。在合并孤立节点之前,我们需要遍历 n*(n-1)/2 - n 个元素对。

其次,在最坏情况下,每次 connect 操作需要遍历树的高度,即 O(log n)

因此,最坏情况下的总运行时间是元素对数量乘以每次连接的成本:Θ(n²) * O(log n) = O(n² log n)

以下是运行时间总结:

  • 最佳情况(下界)Ω(n)
  • 最坏情况(上界)O(n² log n)

分析匹配大小连接的数量

接下来,我们分析在执行 addToWeightedQuickUnion 后,可能发生的“匹配大小连接”的最小和最大数量。匹配大小连接指的是连接两个大小相等的分量。

最小匹配大小连接数

最小数量情况类似于 addToWeightedQuickUnion 的最佳情况:我们总是将新元素连接到同一个主树上。

在这种情况下,只有第一次连接(连接两个大小为1的孤立节点)是匹配大小连接。此后的所有连接都是将一个孤立节点(大小为1)连接到一个更大的树上,因此不再是匹配大小连接。

所以,匹配大小连接的最小数量是 1

最大匹配大小连接数

为了最大化匹配大小连接的数量,我们应该以“配对”的方式连接元素,使得每次连接都发生在两个大小相同的树上。

这个过程可以描述为:

  1. 首先,将所有 n 个孤立节点两两配对,产生 n/2 次匹配大小连接(大小均为1)。
  2. 然后,将这些大小为2的树两两配对,产生 n/4 次匹配大小连接。
  3. 接着,将大小为4的树两两配对,产生 n/8 次匹配大小连接。
  4. 以此类推,直到最后将两棵大小为 n/2 的树连接起来,产生 1 次匹配大小连接。

匹配大小连接的总数是一个等比数列的和:n/2 + n/4 + n/8 + ... + 1

这个等比数列的和等于 n - 1。因此,匹配大小连接的最大数量是 n - 1

以下是匹配大小连接数量总结:

  • 最小数量1
  • 最大数量n - 1

总结与考试技巧 📝

本节课中我们一起学习了带权快速合并操作的渐进分析。我们分析了 isConnectedconnect 操作的时间复杂度,深入探讨了 addToWeightedQuickUnion 方法在不同场景下的运行时间上界(O)和下界(Ω),并计算了匹配大小连接可能发生的最小和最大次数。

对于此类需要分析特定情况的问题,一个非常有效的技巧是:绘制具体实例。就像我们本节课所做的那样,尝试画出每种问题的最佳情况和最坏情况图例。通过具体的例子来思考,而不是停留在抽象概念上,这能极大地帮助你理解和解决考试中的问题。

祝你在 CS 61B 的后续学习中顺利!

29:1 - 抽象数据类型、渐进分析与二叉搜索树内容回顾

概述

在本节课中,我们将要学习抽象数据类型、渐进分析以及二叉搜索树的核心概念。我们将了解不同抽象数据类型的特性,掌握分析算法运行时间的基本方法,并探索二叉搜索树的结构与操作。

抽象数据类型

抽象数据类型是一种定义了数据操作但未指定具体实现方式的数据结构。在Java中,它们通常以接口或抽象类的形式呈现。

以下是几种常见的抽象数据类型:

  • 列表:一种有序且允许重复元素的数据结构。例如,你可以通过 get(0) 获取列表的第一个元素。
  • 集合:一种无序且不允许重复元素的数据结构。你可以将其想象成一个袋子,里面的元素没有特定顺序,且每个元素只出现一次。
  • 映射:一种将键与值关联起来的数据结构。它不允许重复的键,但允许重复的值。你可以将其类比为Python中的字典。
  • 队列:一种遵循“先进先出”原则的有序数据结构。就像在餐厅排队,先来的人先得到服务。
  • :一种遵循“后进先出”原则的有序数据结构。就像一叠纸,你通常会拿走最上面(最后放上去)的那一张。

在Java中,这些ADT都有对应的接口和实现,例如 List 接口有 ArrayListLinkedList 实现,Map 接口有 HashMapTreeMap 实现。

渐进分析建议

上一节我们介绍了抽象数据类型,本节中我们来看看如何分析使用这些数据结构时算法的效率,即渐进分析。

渐进分析关注的是输入规模非常大时算法的运行时间。以下是一些重要的分析建议:

  • 关注大规模输入:渐进分析只在输入规模非常大时有效。我们关心的是当输入值(如 n)趋近于无穷大时的行为,而不是小规模输入下的运行时间。
  • 使用紧确界:尽可能使用 Θ 来表示算法的紧确界。如果无法找到紧确界(即上界和下界不同),则默认使用大 O 表示法来描述最坏情况的上界。
  • 计算总工作量:算法的总运行时间是每次迭代或递归调用所做工作的总和。
  • 警惕经验法则:像“嵌套循环总是 O(n²)”这样的经验法则可能具有误导性。必须仔细检查循环的终止条件和变量更新方式。
  • 简化表达式:在得出最终渐进复杂度时,应忽略低阶项和常数因子。例如,n³ + 10000n² - 5000000 应简化为 Θ(n³)
  • 递归问题的可视化:对于递归算法,绘制递归调用树通常很有帮助。需要关注树的高度(到达基本情况所需的层数)、分支因子(每次递归调用自身的次数)以及每个节点的工作量(每次函数调用完成的工作)。
  • 数列求和公式
    • 对于等差数列(相邻项之差为常数),其和的渐进复杂度为末项函数的平方。例如,1 + 2 + 3 + ... + log n 的和为 Θ((log n)²)
    • 对于等比数列(相邻项之比为常数),其和的渐进复杂度由最大项主导。例如,1 + 2 + 4 + 8 + ... + n 的和为 Θ(n)
  • 图形化辅助:如果你是视觉型学习者,可以尝试绘制变量值的变化图,并通过计算面积来近似估算运行时间,这对于理解某些循环的行为尤其有用。

二叉搜索树

现在,让我们将注意力转向一种具体的数据结构——二叉搜索树。它是一种允许我们快速访问有序元素的数据结构。

二叉搜索树是一种树形结构,其中每个节点最多有两个子节点(因此称为“二叉”)。它必须满足以下不变性条件:

  1. 子树性质:BST中的每个节点本身都是一个更小的BST的根。
  2. 左子树性质:节点左子树中的所有节点值都小于该节点的值。
  3. 右子树性质:节点右子树中的所有节点值都大于该节点的值。

叶子节点(没有子节点的节点)也视为一个合法的BST。

BST的形状会影响其操作效率:

  • 茂密型BST:节点分布均衡,树的高度约为 log n。在此类树中进行查找、插入等操作的时间复杂度为 O(log n)
  • 细长型BST:节点退化成类似链表的结构,树的高度约为 n。在此类树中进行操作的时间复杂度为 O(n)

BST 操作

插入:新节点总是作为叶子节点被插入。插入过程从根节点开始,通过比较大小决定向左子树还是右子树移动,直到找到合适的空位。

删除:采用“嫁接删除”法,有三种情况:

  1. 删除叶子节点:直接将其从父节点移除。
  2. 删除有一个子节点的节点:用其唯一的子节点替代它。
  3. 删除有两个子节点的节点:选择其右子树中的最小节点左子树中的最大节点来替代被删除的节点。这是因为这两个节点最接近被删除节点的值,能够保持BST的性质。

请注意,本次讨论课的重点在于插入操作,但了解删除算法也很有益。

总结

本节课中我们一起学习了抽象数据类型的基本分类与特点,回顾了进行渐进分析时的关键原则和实用技巧,并深入探讨了二叉搜索树的定义、性质以及插入和删除操作。理解这些概念对于选择合适的数据结构和分析算法效率至关重要。

30:抽象数据类型匹配 🧩

在本节讨论中,我们将学习如何为不同的任务选择合适的抽象数据类型。我们将分析五个具体场景,并为每个场景匹配最合适的ADT:列表、映射、队列、集合或栈。每个ADT只会被使用一次。

场景一:追踪唯一登录用户 👤

我们需要追踪所有登录过系统的唯一用户。这里的“唯一”关键词提示我们不应包含重复项。

以下是选择集合的原因:

  • 集合不允许存储重复元素。
  • 即使用户登录、登出、再登录,其用户名在集合中也只会出现一次。
  • 这确保了我们的追踪记录只包含不同的用户个体。

场景二:版本控制系统中的文件关联 📁

我们正在创建一个版本控制系统,希望将每个文件名与一个“blob”值关联起来。这里的“关联”是关键词。

以下是选择映射的原因:

  • 映射通过键值对存储数据。
  • 我们可以将文件名作为键,将对应的blob值作为其关联的值进行存储。

场景三:从顶部开始批改试卷 📚

我们需要批改一堆试卷,并希望从这堆试卷的顶部开始批改。这涉及到元素的添加和移除顺序。

以下是选择的原因:

  • 栈遵循后进先出原则。
  • 想象一叠纸:最后放上去的纸在顶部,最先放上去的在底部。
  • 从顶部开始批改,意味着我们取出最后添加的试卷(顶部)先批改,这正符合栈的操作模式。

场景四:按到达顺序服务客户 🏦

我们运行一个服务器,希望按照客户到达的顺序为他们提供服务。“按到达顺序”是此场景的关键。

以下是选择队列的原因:

  • 队列遵循先进先出原则。
  • 这就像排队买电影票:先到的人先得到服务,后到的人后得到服务。
  • 队列能保证客户按照他们到达的先后顺序被处理。

场景五:图书馆网站按排序显示书籍 📖

我们的图书馆有很多书,希望网站在显示时能按某种顺序排列。我们有些书有多个副本,并且希望每个副本都单独列出。

以下是选择列表的原因:

  • 列表保持元素的顺序。无论是添加顺序还是排序后的顺序,列表都能维持。
  • 列表允许重复元素。这与“多个副本”且“每个都单独列出”的需求完全吻合。
  • 相比之下,集合不保证顺序且不允许重复,因此不适合此场景。

总结:本节课中,我们一起学习了如何根据具体任务的需求来匹配抽象数据类型。我们分析了五个场景,并分别为其选择了最合适的ADT:用集合处理唯一性,用映射处理关联关系,用处理后进先出,用队列处理先进先出,以及用列表处理有序且允许重复的数据集合。理解这些ADT的核心特性是正确应用它们的关键。

31:3 - 算法运行时间分析

在本节课中,我们将学习如何分析给定代码片段的运行时间,并理解如何通过调整循环条件来达到特定的时间复杂度目标。我们将通过一系列示例,从常数时间到指数时间,逐步深入探讨。

第一部分:实现对数时间(Θ(log n))

上一部分我们讨论了如何实现线性时间。本节中,我们来看看如何实现对数时间。

我们有一个与之前几乎相同的代码结构,但这次希望运行时间为 Θ(log n)。回顾之前,我们需要循环进行 n 次迭代,每次执行常数工作,才能达到线性时间。这里,我们需要 log n 次迭代,每次执行常数工作,因为代码只是打印内容。

我们知道循环变量 i 从 1 开始,在达到 n 之前停止。我们需要在 log n 个时间步内从 1 增长到 n。实现这一点的最简单方法是让 i 每次乘以一个常数因子,而不是递增。

例如,我们可以执行 i *= 2。这样,i 将依次取值为 1, 2, 4, 8, 16, ...,直到大约 n/2。从 1 增长到 n 所需的项数将是 log₂(n)。由于我们忽略常数因子,可以将其概括为 Θ(log n)。你可以将其类比为树的高度,思考如何从树顶到树底。无论是乘以 2、3 还是 4,都需要 log n 的时间,因为这是一个从 1 到 2 再到 4 的跳跃过程。

第二部分:实现常数时间(Θ(1))

接下来,我们看看如何实现常数运行时间。这有些不同。

我们再次遇到一个 for 循环,i 从 1 开始,每次递增 1,并且每次迭代执行常数工作。在我们的分析表中,i 将取值 1, 2, 3, ...,直到一个由停止条件决定的未知值。每次循环迭代执行 1 单位工作。

如何确保它在常数时间内运行?

解决方案是让停止条件基于一个常数,而不是输入 n。只要停止条件不依赖于 n,无论 i 递增多少次,循环都只会运行常数次。例如,无论传入的 n 是 0、-5 还是 100 亿,这个循环最多只运行 999 次,因此我们将其视为常数时间。

所以,解决方案就是设置 i < C,其中 C 是独立于输入 n 的某个常数。核心在于停止条件不能基于 n

第三部分:实现指数时间(Θ(2ⁿ))

最后,我们来看一个非常棘手的问题,目标是实现 Θ(2ⁿ) 的运行时间。

我们有一个函数 f4,它接受一个数字 n,并包含一个嵌套的 for 循环。正如在内容回顾中提到的,嵌套循环并不总是给出 n² 的时间复杂度,这取决于停止条件和索引更新条件。

这里确实很巧妙。提示是:思考级数 1 + 2 + 4 + 8 + 16 + ... + f(n) 中的主导项,其中 f 是 n 的某个函数。

我们知道外循环的 i 从 1 开始,每次迭代翻倍。所以它将计数为 1, 2, 4, 8, ...,直到某个未知数。

内层 for 循环的运行次数取决于 i 的值。当 i 为 1 时,它运行 1 次;i 为 2 时,运行 2 次;i 为 4 时,运行 4 次,依此类推。

这意味着 f4(n) 所做的总工作量将是 1 + 2 + 4 + 8 + 16 + ... 一直加到某个值。要确定这个值是什么,需要回想一下这个每次翻倍的级数 1 + 2 + 4 + ...,其主导项实际上就是该级数的最后一项 f(n)。这个和的主导项最终收敛于 f(n)。

因此,如果我们希望收敛到 2ⁿ,我们可以简单地将外循环迭代的最后一个值设置为 2ⁿ。因为外循环每次迭代所做的工作取决于外循环的索引值。

如果我们设置 i < Math.pow(2, n)(这是在 Java 中写 2ⁿ 的方式),我们将得到一个形如 1 + 2 + 4 + 8 + 16 + ... + 2ⁿ⁻¹ 的和。这个和被 2ⁿ⁻¹ 所主导。忽略常数因子后(例如,2ⁿ⁻¹ 约等于 0.5 * 2ⁿ),这就能给我们期望的 Θ(2ⁿ) 运行时间。

第四部分:最佳与最坏情况分析(附加练习)

现在,我们来看一个额外的练习题,它非常适合练习最坏情况和最佳情况下的运行时间分析。

我们想要给出最坏情况和最佳情况下的运行时间,用大 Θ 记号表示,涉及变量 nm。我们假设 kachow() 函数耗时常数,并返回一个布尔值。

这里有两个嵌套的 for 循环。外循环从 0 计数到 n,每次递增。内层 for 循环有点意思:j 从 1 开始,只要 j < m 就运行循环体,但这里没有为 j 设定固定的递增或变化条件。相反,我们看到 j 的变化条件取决于 kachow() 的结果。

我将像之前的问题一样画一个分析表。首先列出外循环变量 i 可能取的值:0, 1, 2, 3, 4, ..., n-1(不包括 n)。然后,我们需要将每次迭代的工作量分为最佳情况和最坏情况。

通常,在寻找最佳和最坏情况时,我会关注分支条件或 if 语句。在这里,根据这个 if 语句的结果,j 会以不同的方式更新。我将重点关注这些更新条件:

  • 如果 kachow() 为真,则 j += 1
  • 否则,j *= 2

在最佳情况下,我们希望找到能使函数最快执行完毕的路径。那么,是 j 每次递增 1 直到 m 更快,还是 j 每次翻倍直到 m 更快?

答案是:j 翻倍到达 m 只需要 log m 个时间步。而如果 j 必须每次递增,则需要线性时间。这意味着,在最佳情况下,我们希望 kachow() 总是返回 false,这样我们就会进入 j *= 2 的分支。j 会翻倍大约 log₂(m) 次(概括为 log m)直到达到 m

因此,在最佳情况下,外循环 i 的每次迭代,我们都会进入 j 翻倍的情况,每次迭代产生 log m 的工作量。

现在来看最坏情况,这基本上与我们刚才做的相反。我们试图找出什么会导致函数执行最慢。

在这里,最慢的情况就是 j += 1 这一行。如果 kachow() 每次都为真,那么我们将执行 j += 1 直到 m,这需要相对于 m 的线性时间。这意味着,对于外循环 i 的每一次迭代,都需要线性时间(具体是 m-1 次,但概括为相对于 m 的线性时间)。

现在我们来思考总的运行时间:

  • 最佳情况:每次 i 迭代做 log m 的工作,共有 n 次迭代。总工作量是 n * log m
  • 最坏情况:每次 i 迭代做 m 的工作,共有 n 次迭代。总工作量是 n * m

这是一个有趣的练习题,运行时间取决于两个不同大小的变量。

总结

本节课中,我们一起学习了如何通过分析循环结构和更新条件来确定代码的时间复杂度。我们从实现特定时间复杂度(对数、常数、指数)的循环条件设计入手,然后探讨了在存在条件分支时,如何分析算法的最佳和最坏情况运行时间。理解这些概念对于设计和评估高效算法至关重要。

32:4 - 递归与渐进分析

概述

在本节课中,我们将学习如何分析递归函数的渐进时间复杂度。我们将通过三个具体的例子,逐步讲解如何通过确定递归树的分支因子、树高以及每次方法调用的工作量,来推导出函数的整体时间复杂度。这对于理解算法效率至关重要。


第一部分:递归函数 curse 的分析

上一节我们介绍了渐进分析的基本概念,本节中我们来看看第一个递归函数 curse

该函数接收一个整数 n。如果 n 小于或等于 0,则返回 0。否则,返回 n 加上 curse(n - 1) 的结果。

以下是分析递归函数时间复杂度的三个核心要素:

  • 分支因子:函数体内递归调用自身的次数。对于 curse,只调用了一次 curse(n - 1),因此分支因子为 1
  • 树高:从初始调用到达到基准情况所需的递归调用次数。每次调用 n 减 1,直到 n <= 0,因此树高为 n
  • 每次方法调用的工作量:每次调用 curse 时,执行的操作(比较和加法)是常数时间,记为 O(1)

现在我们可以绘制递归树。根节点(curse(n))的工作量为 1。由于分支因子为 1,它只有一个子节点 curse(n-1),其工作量也为 1。此模式一直持续到 curse(0)

因此,总工作量是每层工作量的总和。由于有 n 层,每层做 1 个单位的工作,总运行时间为 O(n)

由于该函数没有条件分支导致不同情况的工作量差异,其平均、最好和最坏情况时间复杂度一致,因此我们使用 Θ(n) 表示。


第二部分:递归函数 silly 的分析

上一节我们分析了一个线性递归的例子,本节中我们来看一个更复杂的函数 silly,它涉及数组分割。

函数 silly 接收一个整数数组。如果数组长度小于等于 1,则打印并返回。否则,它将数组分成两半,分别复制到两个新数组中,然后递归地对这两个新数组调用 silly。我们假设 System.arraycopy 方法需要线性时间。

以下是 silly 函数的分析:

  • 分支因子:函数体内两次递归调用 silly,因此分支因子为 2
  • 树高:每次递归调用时,输入的数组长度减半(n -> n/2 -> n/4 ... -> 1)。这是一个几何序列,达到基准情况所需的步数为 log₂ n(渐进记法中常省略底数,记为 log n)。
  • 每次方法调用的工作量:主要工作量来自两次 System.arraycopy 调用,每次复制 n/2 个元素,因此每次调用的总工作量为 n(其中 n 是当前调用时数组的长度)。

现在绘制递归树。根节点(silly(原始数组))的工作量为 n。它有两个子节点,分别对应数组的前半部分和后半部分,每个子节点的工作量为 n/2。下一层有四个节点,每个节点的工作量为 n/4,以此类推。

观察每一层的工作量总和:

  • 第 0 层(根):n
  • 第 1 层:n/2 + n/2 = n
  • 第 2 层:n/4 + n/4 + n/4 + n/4 = n
  • ...

每一层的工作量总和都是 n。树共有 log n 层。

因此,总运行时间是 n * log n,即 O(n log n)


第三部分:递归函数 lucy 的分析

上一节我们分析了分治递归,本节我们来看最后一个挑战性的例子 lucy,它结合了多路递归和指数级工作量。

函数 lucy 接收一个整数 n。如果 n <= 1,则返回。否则,它执行一个运行时间为 Θ(3ⁿ)exponentialWork 操作,并三次递归调用 lucy(n-2)

以下是 lucy 函数的分析:

  • 分支因子:函数体内三次递归调用 lucy,因此分支因子为 3
  • 树高:每次递归调用 n 减 2,因此从 n 减少到 <=1 所需的步数约为 n/2
  • 每次方法调用的工作量:主要工作量来自 exponentialWork,其运行时间为 3ⁿ(其中 n 是当前调用的输入值)。

绘制递归树。根节点 lucy(n) 的工作量为 3ⁿ。它有 3 个子节点,每个都是 lucy(n-2),每个子节点的工作量为 3^(n-2)。下一层有 9 个节点,每个节点的工作量为 3^(n-4),以此类推。

计算每一层的工作量总和:

  • 第 0 层:3ⁿ
  • 第 1 层:3 * 3^(n-2) = 3^(n-1)
  • 第 2 层:9 * 3^(n-4) = 3² * 3^(n-4) = 3^(n-2)
  • 第 3 层:27 * 3^(n-6) = 3³ * 3^(n-6) = 3^(n-3)
  • ...

总工作量是一个几何级数:3ⁿ + 3^(n-1) + 3^(n-2) + 3^(n-3) + ... + 3^(n - n/2)

在几何级数中,最大项主导了整个和的数量级。这里最大项是 3ⁿ

因此,函数 lucy 的总体运行时间为 Θ(3ⁿ)


总结

本节课中我们一起学习了如何分析递归函数的渐进时间复杂度。我们通过三个例子实践了关键步骤:确定分支因子、树高和每次方法调用的工作量,并利用递归树模型求和来计算总时间复杂度。我们分析了线性递归(curse, O(n))、分治递归(silly, O(n log n))以及具有指数工作量的多路递归(lucy, Θ(3ⁿ))。掌握这些方法对于评估算法效率至关重要。

33:5 - 二叉搜索树渐近分析与构建

概述

在本节课中,我们将要学习二叉搜索树(BST)中find操作的运行时间分析,并探讨如何通过特定的插入顺序来构建不同形态的BST,从而影响find操作的性能。


二叉搜索树find操作的运行时间分析

上一节我们介绍了BST的基本概念,本节中我们来看看find操作在特定BST形态下的运行时间。

find是一个递归函数,它接收一个树的根节点和一个键值S作为参数。函数在树为空时停止,这意味着已经遍历了整个树。

find的工作原理是比较键值S与当前树节点的键值。如果S等于当前节点的键值,则返回该节点。否则,根据比较结果决定递归搜索左子树或右子树。

代码描述如下:

if (tree == null) return null;
if (S == tree.key) return tree;
if (S < tree.key) return find(tree.left, S);
else return find(tree.right, S);

对于一个“完全茂密”的BST,其形态近似于一个金字塔,每个非叶子节点都有两个子节点。这种树的层数约为 log₂(n),其中n是树中的节点数。

然而,find操作的运行时间并非总是O(log n)。它可能在找到目标元素时提前停止。例如,如果要查找的键值恰好是根节点的值,那么操作将在常数时间内完成。

因此,find操作的最佳情况运行时间下界是常数时间,最坏情况运行时间上界是O(log n)时间。最坏情况发生在要查找的键值不在树中,或者是树中的一个叶子节点时,这两种情况都需要遍历从根到叶子的整条路径。

由于下界(常数时间)和上界(对数时间)不同,我们无法用Θ记号来描述find操作的紧确界。通常,我们使用O记号来描述其最坏情况下的运行时间上界。


构建线性时间find操作的BST

理解了find操作的运行时间范围后,我们来看看如何构建一个BST,使得find操作在最坏情况下需要线性时间。

BST可以是茂密的,也可以是细长的。茂密的BST高度为O(log n),而细长的BST可能导致find操作需要线性时间,即需要遍历树中几乎所有的节点才能得出结论。

插入顺序在BST中至关重要,因为所有新节点都是作为叶子节点插入的。为了构建一个细长的树,使得find操作在最坏情况下达到O(n),最直接的方法是按照升序或降序的排序顺序插入键值。

以下是构建过程:
首先,对给定的键值{6, 2, 5, 0, 9, -3}进行排序,得到{-3, 0, 2, 5, 6, 9}。然后按照此升序序列依次插入到空的BST中。

  1. 插入-3作为根节点。
  2. 插入0。与-3比较,0 > -3,因此0成为-3的右子节点。
  3. 插入2。与-3比较(2 > -3),再与0比较(2 > 0),因此2成为0的右子节点。
  4. 后续插入569的过程类似,每次新节点都成为前一个节点的右子节点。

最终形成的BST将类似于一个链表。如果此时调用find(10),算法将从根节点开始,依次与每个节点比较并向右移动,直到遍历完所有节点才发现10不存在。这就导致了最坏情况下的线性运行时间。

按照键值的升序或降序顺序插入,是构建一个find操作具有线性最坏情况运行时间的细长BST的简单方法。


总结

本节课中我们一起学习了BST中find操作的渐近分析。我们了解到,在完全茂密的BST中,find的最佳情况是常数时间,最坏情况是对数时间,因此无法用Θ记号定义紧确界。此外,我们还学习了通过按排序顺序插入键值来构建细长BST的方法,这种树会导致find操作在最坏情况下需要线性时间。理解这些概念有助于我们在实际应用中根据数据特点选择合适的结构或优化策略。

34:Spring 2023 考试第7级问题3 - 这是二叉搜索树吗? 🌳

在本节中,我们将学习如何判断一棵二叉树是否为二叉搜索树。我们将分析一个存在缺陷的验证函数,理解其错误所在,并最终编写一个正确的版本。

概述

本节课我们将要学习如何正确验证二叉搜索树。首先,我们会分析一个常见的错误实现,然后学习如何通过追踪最小值和最大值边界来修复它,从而确保整棵树都满足BST的性质。

分析有缺陷的函数

上一节我们介绍了BST的基本概念,本节中我们来看看题目中给出的有问题的 isBST 函数。

一个二叉搜索树有两个主要性质:

  1. 对于任意节点,其左子树中的所有节点值都小于该节点的值。
  2. 对于任意节点,其右子树中的所有节点值都大于该节点的值。

这些性质赋予了BST高效的搜索能力。

让我们查看这个有缺陷的函数:

public static boolean isBST(TreeNode t) {
    if (t == null) {
        return true;
    }
    if (t.left != null && t.left.val > t.val) {
        return false;
    }
    if (t.right != null && t.right.val < t.val) {
        return false;
    }
    return isBST(t.left) && isBST(t.right);
}

这个函数采用递归方式:

  • 基准情况1:如果树为空(null),则返回 true
  • 基准情况2 & 3:如果当前节点的左子节点值大于当前节点,或右子节点值小于当前节点,则立即返回 false
  • 递归情况:递归检查左子树和右子树,并返回两者的逻辑与结果。

这个函数看似合理,但它只检查了每个节点与其直接子节点的关系。这会导致问题。

理解函数的缺陷

为了理解问题所在,我们来看一个具体的例子。

考虑以下二叉树:

        10
       /  \
      5    15
     / \
    3   12

不是一个有效的BST,因为节点 12(在节点 5 的右子树中)的值 12 大于其祖先节点 10。根据BST定义,节点 10 左子树中的所有节点都必须小于 10

然而,有缺陷的函数会错误地判断它为BST:

  1. 检查根节点 10:左子节点 5 < 10,右子节点 15 > 10,通过。
  2. 递归检查右子树 15:无子节点,通过。
  3. 递归检查左子树 5:左子节点 3 < 5,右子节点 12 > 5,通过。

因此,函数返回 true。问题的根源在于,函数没有追踪从根节点到当前节点的路径上所允许的值范围

编写正确的验证函数

上一节我们指出了原函数的问题在于缺乏全局范围约束,本节中我们来看看如何通过一个辅助函数来追踪最小值和最大值边界。

我们需要一个递归辅助函数,它在遍历时携带当前节点允许的最小值最大值边界。

以下是正确的实现思路:

  1. 主函数 isBST 初始化边界为负无穷(Integer.MIN_VALUE)和正无穷(Integer.MAX_VALUE),并调用辅助函数。
  2. 辅助函数 isBSTHelper 接收当前节点 t、当前允许的最小值 min 和最大值 max
  3. 如果节点为 null,返回 true
  4. 如果当前节点的值 t.key 不在 (min, max) 区间内,则违反BST规则,返回 false
  5. 递归检查左子树时,最大值更新为当前节点的值 t.key,最小值不变。
  6. 递归检查右子树时,最小值更新为当前节点的值 t.key,最大值不变。

以下是实现代码:

public static boolean isBST(TreeNode t) {
    // 主函数:初始化边界为整型最小和最大值,调用辅助函数
    return isBSTHelper(t, Integer.MIN_VALUE, Integer.MAX_VALUE);
}

private static boolean isBSTHelper(TreeNode t, int min, int max) {
    // 基准情况:空树是有效的BST
    if (t == null) {
        return true;
    }
    // 检查当前节点值是否在允许的(min, max)范围内
    if (t.key <= min || t.key >= max) {
        return false;
    }
    // 递归检查左子树和右子树,并更新边界
    // 左子树的所有节点必须小于当前节点值(t.key)
    // 右子树的所有节点必须大于当前节点值(t.key)
    return isBSTHelper(t.left, min, t.key) && isBSTHelper(t.right, t.key, max);
}

关键点解释

  • 对于左子树的递归调用 isBSTHelper(t.left, min, t.key)t.key 成为了新的最大值边界。
  • 对于右子树的递归调用 isBSTHelper(t.right, t.key, max)t.key 成为了新的最小值边界。
  • 条件 t.key <= min || t.key >= max 使用了 <=>=,这确保了BST中不允许有重复值(通常定义如此)。如果允许重复值,则需根据具体定义调整。

本周考试技巧 💡

在处理需要编写代码的BST问题时,请记住以下模式:

  • 你几乎总是会使用一个在左子树和右子树上进行递归的函数。
  • 常见的结构是:一个主函数搭配一个真正执行递归操作的辅助函数,辅助函数通常携带额外的状态信息(如本例中的 minmax)。

总结

本节课中我们一起学习了如何正确验证二叉搜索树。我们首先分析了一个只检查父子节点关系的常见错误实现,然后通过引入一个递归辅助函数来追踪每个节点允许的值范围(最小值和最大值),从而修复了该错误。掌握这种携带边界信息的递归模式,对于解决许多BST相关问题至关重要。

35:Spring 2023 考试第7级问题1解析 🧩

在本教程中,我们将学习如何根据给定的时间复杂度要求,反向设计出符合该复杂度的嵌套循环代码。我们将通过一个具体的考试题目,逐步分析四个部分,并利用算术和与几何和的核心概念来构造循环。


概述 📋

本节课我们将解析一个反向工程问题。通常,我们根据代码分析其时间复杂度(Big O)。但在这个问题中,我们已知目标时间复杂度,需要设计出能达成该复杂度的循环代码。我们将重点关注两个关键求和公式,并学习如何将它们应用到循环设计中。


核心概念回顾 🧠

在开始解题前,我们需要回顾两个在本课程中至关重要的求和公式:

  1. 算术和:从1累加到n的和是 Θ(n²)

    • 公式1 + 2 + 3 + ... + n = Θ(n²)
  2. 几何和:一个几何级数的和是 Θ(该级数的最后一项)

    • 公式1 + 2 + 4 + ... + Q = Θ(Q)。例如,如果最后一项是 2ⁿ,则和为 Θ(2ⁿ);如果最后一项是 ,则和为 Θ(n²)

掌握这两个公式是解决此类问题的关键。


第一部分:实现 Θ(n²) 复杂度 ⏱️

上一节我们回顾了核心公式,本节中我们来看看如何实现 Θ(n²) 的时间复杂度。

我们的目标是让总运行时间符合算术和 Θ(n²)。观察给定的代码框架,外层循环 i 从1递增到 n。为了实现 的复杂度,我们可以让内层循环的工作量随 i 线性增长。

以下是实现这一目标的具体方法:

  • 设置内层循环 j 从1迭代到 i
  • 这样,当 i=1 时,内层循环执行1次;i=2 时执行2次,依此类推,直到 i=n 时执行 n 次。
  • 总工作量即为 1 + 2 + 3 + ... + n,根据算术和公式,其时间复杂度为 Θ(n²)

代码示例

for (int i = 1; i <= n; i++) {
    for (int j = 1; j <= i; j++) {
        // 常数时间操作
    }
}

第二部分:实现 Θ(log n) 复杂度 ⏱️

完成了平方复杂度的设计后,现在我们转向对数复杂度 Θ(log n)

观察发现,外层循环的 i 每次乘以2(i *= 2),这本身就会产生 Θ(log n) 次迭代(因为 i 从1增长到 n,每次翻倍)。为了保持总复杂度为 Θ(log n),内层循环必须只执行常数时间的工作。

以下是实现这一目标的具体方法:

  • 内层循环执行固定的、与 n 无关的少量迭代即可,例如2次。
  • 总运行时间将是 常数 C * log₂n,这仍然是 Θ(log n)

代码示例

for (int i = 1; i <= n; i *= 2) {
    for (int j = 1; j <= 2; j++) { // 常数次迭代
        // 常数时间操作
    }
}

第三部分:实现 Θ(2ⁿ) 复杂度 ⏱️

接下来,我们处理一个更具挑战性的目标:指数级复杂度 Θ(2ⁿ)。这里需要运用几何和的知识。

我们希望总工作量形成一个几何级数,其和是最后一项的 Θ,即 Θ(2ⁿ)。我们可以让内层循环的工作量以 2^i 增长。

以下是实现这一目标的具体方法:

  • 让内层循环 j 从1迭代到 2^i。这需要使用 Math.pow(2, i) 来计算上限。
  • 这样,外层循环 i 从1到 n 时,内层循环工作量依次为:2¹, 2², 2³, ..., 2ⁿ
  • 总工作量 2¹ + 2² + ... + 2ⁿ 根据几何和公式,其时间复杂度为 Θ(2ⁿ)

代码示例

for (int i = 1; i <= n; i++) {
    for (int j = 1; j <= Math.pow(2, i); j++) {
        // 常数时间操作
    }
}

第四部分:实现 Θ(n³) 复杂度 ⏱️

最后,我们来看最复杂的一部分:实现立方复杂度 Θ(n³)。这需要结合前几部分的思路。

我们需要外层循环迭代约 n 次,同时内层循环每次迭代做约 的工作,这样 n * n² 即可得到 。题目中内层循环上限已经是 n * n(即 ),所以内层循环单次工作量已是 Θ(n²)。关键在于如何让外层循环恰好执行 n 次。

以下是实现这一目标的具体方法:

  • 注意,外层循环 i 是倍增的(i *= 2)。为了让迭代次数达到 n 次,循环的终止条件 i 需要达到大约 2ⁿ 的量级(因为从1开始倍增,大约 log₂(2ⁿ) = n 次迭代后达到 2ⁿ)。
  • 因此,将外层循环条件设为 i <= Math.pow(2, n)
  • 此时,外层循环执行约 n 次,内层循环每次执行 次,总时间复杂度为 Θ(n * n²) = Θ(n³)

代码示例

for (int i = 1; i <= Math.pow(2, n); i *= 2) {
    for (int j = 1; j <= n * n; j++) {
        // 常数时间操作
    }
}

总结 🎯

本节课中,我们一起学习了如何根据指定的时间复杂度反向工程嵌套循环结构。我们通过四个案例实践了该方法:

  1. 利用算术和实现 Θ(n²)
  2. 对数级外层循环中保持内层常数工作以实现 Θ(log n)
  3. 利用几何和,使内层循环工作量呈指数增长(2^i)以实现 Θ(2ⁿ)
  4. 结合对数级迭代次数平方级内层工作量来实现 Θ(n³)

解决这类问题的通用技巧是:始终分别分析外层和内层循环的迭代次数或工作量,并牢记算术和与几何和两个核心公式。无论是分析复杂度还是反向设计代码,这两个公式都是强大的工具。


36:渐进分析实战

在本节课中,我们将学习如何分析递归函数的运行时间复杂度。我们将通过一个具体的例题,逐步讲解分析递归算法时间复杂度的四个核心步骤,并区分其最佳情况和最坏情况。


概述:递归分析的四个步骤

分析递归函数的时间复杂度,可以遵循以下四个步骤:

  1. 绘制递归调用树。
  2. 计算每个节点的工作量。
  3. 计算每一层的工作量。
  4. 将所有层的工作量求和,得到总工作量。

接下来,我们将通过例题来实践这些步骤。


第一部分:分析函数 G(n, x)

我们首先分析一个递归函数 G(n, x)。其伪代码如下:

def G(n, x):
    if n == 0:
        return
    for i in range(1, x+1):
        G(n-1, i)

情况 A: G(n, 1)

当第二个参数 x 为 1 时,函数的行为非常简单。

绘制递归树
调用 G(n, 1) 会产生一个线性的调用链:G(n,1) -> G(n-1,1) -> ... -> G(0,1)。树没有分支。

计算每个节点的工作量
在每个节点上,for 循环只运行一次(因为 x=1),加上 if 判断,工作量是常数。我们用 1 表示常数工作量。

计算每层工作量
由于每层只有一个节点,每层的工作量也是 1

计算总工作量
递归树共有 n 层(从 n 递减到 0)。因此,总工作量为 n * 1 = n

结论
G(n, 1) 的时间复杂度为 Θ(n)


情况 B: G(n, 2) 与最佳/最坏情况分析

现在,函数 G 稍有变化,第二个参数由一个随机函数 f(x) 决定,其返回 1x 之间的一个随机整数。

def G(n, x):
    if n == 0:
        return
    for i in range(1, f(x)+1): # f(x) 返回 [1, x] 的随机数
        G(n-1, x) # 注意:这里递归调用时第二个参数固定为 x

我们需要分析 G(n, 2) 的最佳和最坏情况。

最佳情况分析

最佳情况发生在 f(x) 总是返回 1 时。
这与我们刚才分析的 G(n, 1) 情况完全一致(尽管参数是 x=2,但循环只跑一次)。因此,它产生一个线性的递归链。

结论
最佳情况时间复杂度为 Ω(n)


最坏情况分析

最坏情况发生在 f(x) 总是返回 2 时。

绘制递归树

  • 根节点 G(n,2) 会产生 2 个递归调用:G(n-1,2)G(n-1,2)
  • 下一层的每个节点又会各自产生 2 个调用。
  • 以此类推,直到参数 n 递减为 0。

这形成了一个深度为 n 的完全二叉树。

计算每个节点的工作量
在每个节点上,for 循环运行 2 次(常数),加上 if 判断,工作量是常数 1

计算每层工作量

  • 第 0 层(根节点):有 1 个节点,工作量 = 1 = 2⁰
  • 第 1 层:有 2 个节点,工作量 = 2 = 2¹
  • 第 2 层:有 4 个节点,工作量 = 4 = 2²
  • ...
  • 第 n 层:有 2ⁿ 个节点,工作量 = 2ⁿ

计算总工作量
总工作量是所有层工作量之和:2⁰ + 2¹ + 2² + ... + 2ⁿ
这是一个等比数列求和,其结果由最后一项主导。

结论
最坏情况时间复杂度为 Θ(2ⁿ)


情况 C: G(n, n) 的最坏情况分析

现在分析 G(n, n) 的最坏情况,即 f(x) 总是返回 n

绘制递归树

  • 根节点 G(n,n) 会产生 n 个递归调用:G(n-1,n), G(n-1,n), ... (共 n 个)。
  • 下一层的每个节点又会各自产生 n 个调用。
  • 以此类推,直到 n 递减为 0。

这形成了一个深度为 n 的树,但每个节点的分支因子是 n

计算每个节点的工作量
在每个节点上,for 循环需要运行 n 次。因此,每个节点的工作量为 n

计算每层工作量

  • 第 0 层:1 个节点,工作量 = 1 * n = n¹
  • 第 1 层:n 个节点,工作量 = n * n = n²
  • 第 2 层:n² 个节点,工作量 = n² * n = n³
  • ...
  • 第 k 层:nᵏ 个节点,工作量 = nᵏ * n = nᵏ⁺¹
  • 第 n-1 层(最后一层有实际工作):nⁿ⁻¹ 个节点,工作量 = nⁿ⁻¹ * n = nⁿ

计算总工作量
总工作量是:n¹ + n² + n³ + ... + nⁿ
这是一个增长极快的数列,其最高阶项 nⁿ 主导了整个求和。

结论
G(n, n) 的最坏情况时间复杂度为 O(nⁿ)


总结

本节课我们一起学习了递归算法时间复杂度的分析方法。我们通过一个例题,实践了分析的四个步骤:

  1. 绘制递归树。
  2. 确定每个节点的工作量(忽略递归调用本身)。
  3. 确定每一层的工作量(节点数 × 单节点工作量)。
  4. 所有层的工作量求和

我们分析了三种情况:

  • G(n,1) 产生线性链,时间复杂度为 Θ(n)
  • G(n,2) 在最佳情况(f(x)=1)下为 Ω(n),在最坏情况(f(x)=2)下形成二叉树,时间复杂度为 Θ(2ⁿ)
  • G(n,n) 在最坏情况(f(x)=n)下形成分支因子为 n 的树,时间复杂度为 O(nⁿ)

掌握这个四步框架,你就能系统地分析绝大多数递归算法的时间复杂度。祝你在后续的数据结构学习中顺利!

37:1 - Spring 2023 Discussion 08 内容回顾

在本节课中,我们将要学习两种重要的B树与左倾红黑树,以及哈希的基本原理。我们将探讨它们如何工作,以及如何通过特定的操作来维持其高效性。

B树:确保茂密结构 🌳

上一节我们介绍了课程安排,本节中我们来看看B树。B树是一种树形数据结构,其功能类似于二叉搜索树,但能确保树的结构是茂密的。作为思考,请回想为什么二叉搜索树通常不能保证茂密结构。

在CS 61B课程中,我们经常将B树与2-3树互换使用。每个节点最多可以包含两个数据项和三个子节点,因此得名2-3树。也存在节点可容纳更多数据项和子节点的变体,例如2-3-4树。B树的所有叶子节点到根节点的距离相同,这使得查找等操作的时间复杂度为 Θ(log n)

回到刚才的思考题:为什么二叉搜索树不一定茂密?上周我们讨论过,即使二叉搜索树在理想情况下能提供 O(log n) 的运行时操作,我们并不能保证它总是茂密的。例如,如果我们按顺序 1, 2, 3, 4, 5 将键插入二叉搜索树,最终会得到一个类似链表的结构,而非平衡的树。在这种情况下,二叉搜索树是“细长”的,其查找、插入、删除等操作在最坏情况下需要近似线性时间。这就是我们使用B树的动机。B树属于平衡搜索树这一大类数据结构,它们在保持二叉搜索树性质的同时,确保操作时间复杂度为 O(log n),避免了二叉搜索树在细长情况下的线性时间开销。

B树的插入操作

当我们向B树添加元素时,首先会尝试添加到叶子节点。如果添加后导致节点数据项超出限制(例如,在2-3树中超过两个),我们会将多余的数据项(通常是中间项)向上推送到父节点,直到整棵树重新满足我们之前讨论的规则。

以下是插入操作的步骤说明:

  1. 从根节点开始,根据值的大小找到合适的叶子节点。
  2. 将新元素插入该叶子节点。
  3. 如果插入后叶子节点数据项超限(例如,在2-3树中出现三个数据项),则将其中间项上推到父节点。
  4. 如果上推导致父节点数据项超限,则递归重复步骤3,直至满足所有规则。

左倾红黑树:B树的代码友好表示 🔴⚫

上一节我们介绍了B树,本节中我们来看看其在代码中更易实现的表示形式——左倾红黑树。左倾红黑树是B树(特别是2-3树)的一种表示方法,它使用红黑链接来编码B树中的多键节点。

在LLRB中,2-3树中的每个多键节点都被表示为一个由红色左链接连接的结构。具体来说,多键节点中较小的元素会成为较大元素的红色左子节点。黑色链接则表示普通的父子关系。这种表示法在LLRB和2-3树之间建立了一一对应的映射关系。

LLRB必须遵循的不变量

LLRB必须遵循以下几个关键不变量,以维持其与2-3树的对应关系及平衡性:

  1. 从根节点到任意空链接的路径上,黑色链接的数量必须相同。请注意,这与“到任意叶子节点”不同,是一个需要特别注意的细微差别。
  2. 一个节点不能有两个红色子链接(即不能有两个通过红色链接连接的孩子)。
  3. 由于这是左倾红黑树,所有红色链接必须是左倾的。
  4. LLRB的高度(从根到叶子的最大链接数)大约是其对应2-3树高度的两倍。
  5. 新插入的元素总是作为带有红色链接的叶子节点。

这些不变量共同作用,有时会导致LLRB变得不平衡(即违反某条规则),因此我们需要一些修复操作。

LLRB的平衡操作

当LLRB违反上述不变量时,我们需要通过以下三种基本操作来恢复平衡:

  1. 左旋:当出现一个右红色链接时(违反左倾原则),我们需要在父节点上进行左旋。
    • 代码/逻辑描述:假设节点A有一个指向节点B的右红链。左旋操作将使B成为新的父节点,A成为B的左子节点(红链),同时B原来的左子节点(如果有)成为A的右子节点。
  2. 右旋:当出现两个连续的左红色链接时,我们需要在较高的节点上进行右旋。
    • 代码/逻辑描述:假设节点A有一个指向节点B的左红链,且B有一个指向节点D的左红链。右旋操作将使B成为新的父节点,D成为B的右子节点(红链),A成为B的左子节点(红链),同时B原来的右子节点(如果有)成为A的左子节点。
  3. 颜色翻转:当一个节点的两个子链接都是红色时,我们需要进行颜色翻转。
    • 代码/逻辑描述:假设节点A的两个子链接(到B和C)都是红色的。颜色翻转操作会将A到B和C的链接变为黑色,同时将A与其父节点(假设为P)的链接(如果是黑色)变为红色。

这些平衡操作可能会级联触发。例如,一次左旋可能导致需要一次右旋,而一次右旋又可能导致需要一次颜色翻转。

哈希:实现快速查找的基石 🗝️

上一节我们讨论了B树和LLRB,现在我们将话题转向哈希。哈希是实现哈希集合和哈希映射等数据结构快速操作的基础。

哈希函数是将对象表示为整数的函数。这使得我们可以高效地使用哈希集合和哈希映射等数据结构,实现快速的添加、查找等操作。回想链表,其添加、删除、查找操作在最坏情况下与元素数量成线性关系。而哈希集合和哈希映射允许我们以近似常数时间进行查找,这主要归功于哈希。

哈希的工作原理

一旦我们通过哈希函数为对象计算出一个哈希码(整数),我们使用取模运算来确定它应该放入哪个“桶”中。

例如,假设我们有一个Dog类,并重写了它的hashCode方法:

@Override
public int hashCode() {
    return 37 * this.size + 42; // 一个简单的哈希函数示例
}

当我们尝试将一只狗放入哈希集合时,集合内部可能会这样计算目标桶:bucketIndex = dog.hashCode() % numberOfBuckets,然后将这只狗添加到计算得到的桶中。

为什么需要取模? 因为Java中int的范围很大(约-21亿到21亿),我们不可能为每个可能的哈希值都预留一个桶,那样会消耗巨大内存。因此,我们只维护一个较小数量的桶(比如初始为4个),通过取模运算将哈希码映射到有限的桶索引范围内(0 到 numberOfBuckets - 1)。

处理冲突:外部链接法

由于桶的数量远小于可能的哈希值范围,不同的对象很可能被映射到同一个桶中,这种现象称为“冲突”。我们通过“外部链接法”来处理冲突:每个桶内部并不直接存储单个元素,而是存储一个集合(如链表、数组列表或另一个哈希集合),所有映射到该桶的元素都链在这个集合里。

当我们在哈希映射中查找一个键(例如"tofu")对应的值时,过程如下:

  1. 计算键"tofu"的哈希码。
  2. 通过取模运算确定目标桶索引。
  3. 在该桶内部的集合中,遍历所有键值对,使用equals方法比较键,直到找到匹配的键,然后返回其关联的值。

重哈希与负载因子

为了保持操作的常数时间复杂度,我们希望各个桶中的元素链长度大致均匀。如果某些链变得过长,查找效率就会下降。因此,当哈希表变得“太满”时,我们需要调整大小(扩容)。

负载因子 是决定何时扩容的关键指标,其计算公式为:负载因子 = 元素总数 / 桶的总数。当负载因子超过某个预设阈值(例如0.75)时,我们就增加桶的数量(例如翻倍)。

重要提示:扩容时需要重哈希。因为元素最初是根据旧桶数量取模后放入桶中的。扩容后桶数量变了,所有现有元素必须根据新的桶数量重新计算哈希并放入新的桶中,否则后续的查找操作可能会失败。虽然单次扩容(包括重哈希)是线性时间操作,但将其分摊到多次常数时间的插入操作中,哈希表的操作仍能保持摊还常数时间复杂度

优秀哈希函数的特性

一个有效的哈希函数必须满足:

  1. 返回值必须是整数。
  2. 对同一个对象的多次调用,必须返回相同的哈希值(确定性)。
  3. 如果两个对象通过equals方法比较是相等的,那么它们必须具有相同的哈希码。

然而,有效并不等于优秀。一个优秀的哈希函数还必须能将元素尽可能均匀地分布到各个桶中,以减少冲突和长链的出现。例如,一个总是返回0的哈希函数是有效的(满足上述三点),但却是极其糟糕的,因为它会导致所有元素都进入同一个桶,使哈希表退化为一个链表。

反向思考:两个具有相同哈希码的对象必须相等吗?不必要。哈希冲突是允许的(即不同对象哈希码相同),只要它们能被equals方法区分开即可。这正是外部链接法要处理的情况。


本节课中我们一起学习了B树与左倾红黑树的关系,以及哈希表的核心原理。我们了解了B树如何通过规则保持平衡,LLRB如何用红黑链接表示B树并通过旋转和颜色翻转维持平衡。在哈希部分,我们探讨了哈希函数、冲突解决、扩容机制以及优秀哈希函数的重要性。掌握这些概念对于理解和使用高效的数据结构至关重要。

38:2-3树与左倾红黑树操作详解

在本节课中,我们将学习2-3树的基本插入操作,以及如何将2-3树转换为对应的左倾红黑树。我们还将探讨这两种数据结构在查找和插入时的性能与平衡操作。

2-3树的插入操作

上一节我们介绍了课程概述,本节中我们来看看2-3树的具体插入过程。我们从一个给定的2-3树开始,依次插入元素18、38、12、13和20。

以下是插入元素18的步骤:

  1. 从根节点8开始,因为18 > 8,进入右子树。
  2. 到达节点14,因为18 > 14,进入其右子树。
  3. 到达叶节点15,将18插入,与15形成多节点 [15, 18]。此时未违反2-3树性质(每个节点最多两个元素,三个子节点)。

插入18后,树结构如下:

         [8]
        /   \
       /     \
     [4,6]  [14]
    /  |  \    \
   3   5   7   [15,18]

接下来插入元素38:

  1. 38 > 8,进入右子树。
  2. 38 > 14,进入其右子树。
  3. 尝试插入到叶节点 [15, 18],但会导致该节点有三个元素 [15, 18, 38],违反规则。
  4. 将中间元素18上推至父节点14,形成新的多节点 [14, 18]
  5. 重新分配子节点:[14, 18] 的左子节点为15,右子节点为38。

插入38后,树结构更新为:

         [8]
        /   \
       /     \
     [4,6]  [14,18]
    /  |  \   /   \
   3   5   7 15   38

然后插入元素12:

  1. 12 > 8,进入右子树。
  2. 12 < 14,进入其左子树。
  3. 到达叶节点10,将12插入,形成多节点 [10, 12]

插入12后,树结构如下:

         [8]
        /   \
       /     \
     [4,6]  [14,18]
    /  |  \   /   \
   3   5   7 [10,12] 38

接着插入元素13:

  1. 13 > 8,进入右子树。
  2. 13 < 14,进入其左子树。
  3. 尝试插入到叶节点 [10, 12],形成 [10, 12, 13],违反规则。
  4. 将中间元素12上推至父节点 [14, 18],形成 [12, 14, 18],这再次违反了节点最多两个元素的规则。
  5. 需要递归上推:将 [12, 14, 18] 的中间元素14上推至根节点8,形成新的根节点 [8, 14]
  6. 重新分配子节点:[8, 14] 的左子节点为原左子树 [4,6],中子节点为以12为根的子树 [10, 12](其右子节点为13),右子节点为以18为根的子树(其左子节点为15,右子节点为38)。

插入13后,最终的2-3树结构为:

         [8,14]
        /   |   \
       /    |    \
     [4,6]  [12]  [18]
    /  |  \   / \    / \
   3   5   7 10  13 15  38

最后插入元素20:

  1. 20 > 14,进入右子树(以18为根的节点)。
  2. 20 > 18,进入其右子树。
  3. 到达叶节点38,将20插入,与38形成多节点 [20, 38]

插入所有元素后的最终2-3树为:

         [8,14]
        /   |   \
       /    |    \
     [4,6]  [12]  [18]
    /  |  \   / \    / \
   3   5   7 10  13 15 [20,38]

将2-3树转换为左倾红黑树

上一节我们完成了2-3树的构建,本节中我们来看看如何将其转换为左倾红黑树。左倾红黑树是2-3树的一种二叉树表示形式,其中红链接表示2-3树中的多节点关系。

转换的核心思想是:将2-3树中的每个多节点表示为一个“父节点-左子节点”对,并通过左倾的红链接连接。多节点中的右元素成为父节点,左元素成为其左子节点。

以下是转换步骤:

  1. 识别并绘制红链接:首先处理所有多节点,用红链接表示它们。
    • 根节点 [8,14]:14是父节点,8是其左子节点,用左倾红链接连接。
    • 节点 [4,6]:6是父节点,4是其左子节点,用左倾红链接连接。
    • 节点 [20,38]:38是父节点,20是其左子节点,用左倾红链接连接。
  2. 用黑链接构建剩余部分:剩下的所有链接都应是黑链接,并需满足二叉搜索树性质。
    • 处理左子树 [4,6]:3是4的左黑子节点;5是4的右黑子节点(因为6的左红子节点已是4);7是6的右黑子节点。最后,从8到6画一条左黑链接。
    • 处理中子节点 [12]:从8到12画一条右黑链接。10是12的左黑子节点,13是12的右黑子节点。
    • 处理右子树 [18]:从14到18画一条右黑链接。15是18的左黑子节点。对于多节点 [20,38],38是父节点(已通过红链接连到20),它作为18的右黑子节点。

最终得到的左倾红黑树结构如下(R表示红链接,B表示黑链接):

           14(B)
          /    \
         /      \
       8(R)     18(B)
      /   \     /   \
    6(B)  12(B)15(B) 38(B)
    / \   / \        /
   4(R)7 10 13      20(R)
  / \
 3   5

2-3树深度与红黑树查找比较次数

上一节我们探讨了结构转换,本节中我们来进行一个概念性分析。对于一个深度为H的2-3树(叶子节点到根节点的距离为H),在其对应的左倾红黑树中,查找一个特定键值最多需要进行多少次比较?

分析如下:

  1. 2-3树层面:在2-3树中,每个节点最多包含2个元素。因此,在从根节点到叶节点的路径上,每向下移动一层(即经过一个节点),最多需要进行2次比较(与该节点中的两个元素比较)。
  2. 深度定义:树深度H表示从根节点到叶节点的最长路径上的边数。这意味着从根节点到叶节点需要经过 H+1 层节点(包括根层和叶层)。
  3. 最大比较次数:结合以上两点,最坏情况下,总比较次数为 2 * (H + 1)

公式最大比较次数 = 2 * (H + 1)
例如,一个只有根节点(深度为0)的2-3树,也需要进行2次比较来确定键值是否存在。

左倾红黑树的插入与再平衡

上一节我们分析了查找复杂度,本节我们通过一个实例来学习左倾红黑树的插入与再平衡操作。给定一个左倾红黑树,我们需要描述插入元素9后,为维持左倾红黑树性质所需的所有平衡操作。

初始左倾红黑树结构如下(为简化,假设这是从某个2-3树转换而来):

       7(B)
      /   \
     /     \
   5(B)    10(B)
          /
         8(R)

插入元素9

  1. 像普通BST一样插入:9 > 7,进入右子树;9 < 10,进入10的左子树;8是叶节点,将9作为8的右子节点插入。
  2. 在左倾红黑树中,新插入的节点总是通过红链接与其父节点连接。因此,初始插入后,9成为8的右红子节点。
       7(B)
      /   \
     /     \
    

5(B) 10(B)
/
8(R)

9(R)
```
这违反了左倾红黑树“红链接必须左倾”的性质。

平衡操作序列

  1. 左旋:对节点8进行左旋操作,将右红链接变为左红链接。这使得9成为父节点,8成为9的左红子节点。
       7(B)
      /   \
     /     \
    

5(B) 10(B)
/
9(R)
/
8(R)
现在出现了新的问题:节点10有两个连续的红链接子节点(9和它的左子节点8),这违反了“不能有两个连续的红链接”的性质(在2-3树中相当于一个临时3元素节点)。 2. **右旋**:对节点10进行右旋操作。这使得9成为新的父节点,10成为9的右红子节点,8仍是9的左红子节点。
7(B)
/
/
5(B) 9(R)
/
8(R)10(R)
现在节点9有两个红链接子节点,这仍然代表一个过大的节点。 3. **颜色翻转**:对节点9进行颜色翻转。这将节点9与父节点7的链接颜色由黑翻红,同时将节点9与两个子节点8和10的链接颜色由红翻黑。
7(B)
/
/
5(B) 9(B)
/
8(R)10(R)
翻转后,节点7现在有两个红链接子节点(5和9)。 4. **再次颜色翻转**:对节点7进行颜色翻转。这将节点7与两个子节点5和9的链接颜色由红翻黑。由于节点7是根节点,根据规则,根链接始终为黑(有时这步会伴随将根节点重新染黑的操作,但在此结构中,翻转后子链接变黑即满足条件)。
7(B)
/
/
5(B) 9(B)
/
8(R)10(R)
```

最终得到的树是一棵合法的左倾红黑树。这个例子展示了插入操作如何可能引发一系列连锁的旋转和颜色翻转操作,以递归的方式维护树的平衡性。

总结

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

  1. 2-3树的插入算法:始终插入到叶节点,若导致节点溢出(超过2个元素),则递归地将中间元素上推至父节点,直至满足所有性质。
  2. 2-3树到左倾红黑树的转换:多节点通过左倾红链接表示,其余为黑链接,并需保持BST性质。
  3. 深度与查找性能:深度为H的2-3树,其对应左倾红黑树的最大查找比较次数为 2*(H+1)
  4. 左倾红黑树的插入再平衡:通过左旋纠正右倾红链接,通过右旋处理临时性结构,通过颜色翻转分解过大的节点,这些操作可能递归地连锁发生以维持树的平衡。

39:3 - 哈希函数与哈希映射

在本节课中,我们将学习哈希函数的核心概念,分析不同哈希函数实现的有效性与优劣,并探讨在哈希映射中修改键或值所带来的影响。


哈希函数:P39:3.1 - 哈希函数有效性分析

上一节我们介绍了哈希的基本概念,本节中我们来看看如何判断一个哈希函数是否有效。一个有效的哈希函数必须满足以下三个条件:

  1. 返回值必须是整数。
  2. 对同一个对象多次调用,其哈希值必须始终相同(一致性)。
  3. 如果两个对象通过 equals 方法判断为相等,那么它们必须具有相同的哈希值。

以下是题目中给出的五个哈希函数实现,我们将逐一分析。

实现一:返回固定值 -1

public int hashCode() {
    return -1;
}
  • 有效性有效。它返回整数 -1,对同一对象始终返回相同值,且任意两个相等的整数对象(如 Integer(5))的哈希值都是 -1,满足所有条件。
  • 优劣分析:这是一个极差的哈希函数。因为它为所有对象返回相同的哈希值,导致所有元素都被放入同一个桶中,引发大量碰撞,完全丧失了哈希表高效分布数据的优势。

实现二:返回 intValue 的平方

public int hashCode() {
    return intValue() * intValue();
}
  • 有效性有效。它返回整数,对同一对象结果一致。两个相等的整数(如 Integer(10))会计算出相同的哈希值 100
  • 优劣分析存在缺陷。该函数会导致碰撞,因为互为相反数的整数(如 5-5)会计算出相同的哈希值(25)。虽然碰撞概率相对较低,但并非理想方案。

实现三:调用父类(Object)的 hashCode

public int hashCode() {
    return super.hashCode();
}
  • 有效性无效Object 类的默认 hashCode 通常基于对象的内存地址。虽然它满足前两个条件,但会违反第三条:两个值相等的 Integer 对象(如 new Integer(5)new Integer(5))是不同的对象,位于不同的内存地址,因此会返回不同的哈希值,尽管它们通过 equals 比较是相等的。

实现四:返回当前时间戳

public int hashCode() {
    return (int) System.currentTimeMillis();
}
  • 有效性无效。它严重违反了一致性原则。每次对同一个对象调用此方法,都会因为时间的变化而返回不同的哈希值。

实现五:返回 intValue + 3

public int hashCode() {
    return intValue() + 3;
}
  • 有效性有效。它返回整数,对同一对象结果一致,且相等的对象哈希值也相等。
  • 优劣分析:在假设桶数量无限多(无需取模)的理想情况下,这是一个优秀的哈希函数。它为每个不同的整数值生成唯一且确定的哈希值,完全避免了碰撞

哈希映射:P39:3.2 - 修改键值对的影响

理解了哈希函数的原理后,我们来看看在哈希映射的实际使用中,修改已插入的键或值会带来什么后果。

问题一:修改键(Key)

:修改一个已插入哈希映射的键后,我们是否还能检索到该条目?
有时可以(Sometimes)

  • 原因:检索条目时,哈希映射会计算当前键的哈希值来确定查找的桶。如果你修改了键,它的哈希值很可能随之改变,导致后续 get 操作去错误的桶中查找,从而找不到原来的值。
  • “有时”的可能性:存在一种极小的巧合,即修改后的新键恰好与旧键哈希到同一个桶。在这种情况下,你仍然能在那个桶的链表中找到原来的条目。但这完全依赖于运气,并非设计使然。

问题二:修改值(Value)

:修改一个已插入哈希映射的值后,我们是否还能检索到该条目?
总是可以(Always)

  • 原因:哈希映射在存储和查找时,只依赖于键(Key)的哈希值来确定位置。值(Value)不参与哈希计算。因此,修改值不会影响条目在哈希表中的存储位置。只要使用原来的键进行查找,就能定位到对应的桶和条目,并获取到更新后的值。

本节课中我们一起学习了哈希函数有效性的三个核心判定标准,分析了五种具体实现的优劣,并明确了在哈希映射中修改键和修改值所带来的不同影响。记住,一个好的哈希函数应能均匀分布元素以减少碰撞,而在使用哈希映射时,应避免修改作为键的对象,以确保数据可被正确检索。

40:哈希表操作与扩容详解 🧮

在本节课中,我们将学习哈希表的基本操作,包括插入元素、处理冲突、以及当负载因子达到阈值时如何进行扩容和重哈希。我们将通过一个具体的例子,一步步地绘制出哈希表在多次插入操作后的状态变化。


概述

我们将操作一个将字符串映射到整数的哈希表。为了简化,初始时哈希表只有4个桶。我们使用一个简单的哈希函数:取字符串的首字母,将其转换为字母表中的位置(A=0, B=1, ..., Z=25)作为哈希值。例如,“Hash browns”的首字母是H,H是字母表中的第7个字母(从0开始计数),因此其哈希值为7。

哈希表使用外部链接法处理冲突,即每个桶是一个链表。当负载因子(元素数量 / 桶数量)达到 3/4 时,我们将进行扩容,将桶的数量加倍,并对所有现有元素进行重哈希。


初始状态与第一次插入

初始哈希表有4个桶。我们首先插入键值对 (“hash browns”, 7)

  1. 计算键 “hash browns” 的哈希值:首字母 H 对应整数 7
  2. 确定桶索引:7 mod 4 = 3
  3. 将条目 (“hash browns”, 7) 放入桶3

此时,哈希表中有1个元素,4个桶。负载因子为 1/4 = 25%


后续插入与冲突处理

上一节我们插入了第一个元素,本节中我们来看看连续插入时如何处理冲突。

以下是接下来的插入操作序列:

  • 插入 (“dim sum”, ?):

    • 哈希值:首字母 D 对应 3
    • 桶索引:3 mod 4 = 3
    • 结果:与 “hash browns” 发生冲突,均位于桶3。使用外部链接法,将 (“dim sum”, ?) 作为链表节点添加到桶3中 “hash browns” 的后面。
  • 插入 (“escargot”, ?):

    • 哈希值:首字母 E 对应 4
    • 桶索引:4 mod 4 = 0
    • 结果:放入桶0

插入这三个元素后,哈希表状态如下:

  • 桶0: (“escargot”, ?)
  • 桶3: (“hash browns”, 7) -> (“dim sum”, ?)

元素总数 = 3,桶数量 = 4。负载因子 = 3/4 = 75%,达到了扩容阈值。


第一次扩容与重哈希

当负载因子达到3/4时,我们需要扩容。这个过程分为两步:

  1. 将桶的数量加倍(从4个变为8个)。
  2. 将所有现有元素重哈希到新的桶数组中。

为什么需要重哈希?因为元素存放的桶索引是通过 哈希值 mod 桶数量 计算的。桶数量改变后,同一个键计算出的新索引可能不同,必须重新放置。

让我们对现有元素进行重哈希:

  • “escargot” (哈希值4): 4 mod 8 = 4 -> 移至新桶4
  • “hash browns” (哈希值7): 7 mod 8 = 7 -> 移至新桶7
  • “dim sum” (哈希值3): 3 mod 8 = 3 -> 留在新桶3

重哈希后,负载因子更新为 3/8 = 37.5%


继续插入与第二次扩容

扩容后,我们继续插入新的元素。

以下是接下来的插入操作:

  • 插入 (“brown bananas”, ?):

    • 哈希值:首字母 B 对应 1
    • 桶索引:1 mod 8 = 1 -> 放入桶1
  • 插入 (“burritos”, 2):

    • 哈希值:首字母 B 对应 1
    • 桶索引:1 mod 8 = 1 -> 与 “brown bananas” 冲突,链接到桶1链表的末尾。
  • 插入 (“buffalo wings”, ?):

    • 哈希值:首字母 B 对应 1
    • 桶索引:1 mod 8 = 1 -> 再次冲突,链接到桶1链表的末尾。

此时,元素总数 = 6,桶数量 = 8。负载因子 = 6/8 = 75%,再次触发扩容。

我们将桶数量从8加倍到16,并对所有6个元素进行重哈希。计算过程如下:

  • 所有以 B 开头的键(哈希值1): 1 mod 16 = 1 -> 仍留在新桶1
  • “dim sum” (哈希值3): 3 mod 16 = 3 -> 留在新桶3
  • “escargot” (哈希值4): 4 mod 16 = 4 -> 留在新桶4
  • “hash browns” (哈希值7): 7 mod 16 = 7 -> 留在新桶7

注意:此次扩容后,所有元素的桶索引都没有改变,因为它们的哈希值模16的结果与模8的结果相同。但这只是巧合。

扩容后负载因子为 6/16 = 37.5%


插入重复键与最终状态

现在,我们在扩容后的哈希表中进行最后两次操作。

  • 插入 (“bánh mì”, ?):

    • 哈希值:首字母 B 对应 1
    • 桶索引:1 mod 16 = 1 -> 链接到桶1链表的末尾。
  • 插入 (“burritos”, 10):

    • 哈希值:首字母 B 对应 1
    • 桶索引:1 mod 16 = 1
    • 关键操作:遍历桶1的链表,发现键 “burritos” 已经存在。在哈希表中,键必须是唯一的。因此,我们不会添加新节点,而是更新已存在键 “burritos” 对应的值,将原来的 2 替换为 10

由于最后一次 put 操作是更新而非新增,哈希表中的元素数量保持为7个。最终负载因子为 7/16 = 43.75%

最终哈希表状态如下(链表顺序表示插入顺序):

  • 桶1: (“brown bananas”, ?) -> (“burritos”, 10) -> (“buffalo wings”, ?) -> (“bánh mì”, ?)
  • 桶3: (“dim sum”, ?)
  • 桶4: (“escargot”, ?)
  • 桶7: (“hash browns”, 7)

哈希函数的重要性与总结

本节课中我们一起学习了哈希表的插入、冲突解决、扩容和重哈希机制。通过这个例子,我们可以发现一个潜在问题:我们使用的简单哈希函数(仅取首字母)性能很差。

由于只有26个字母,哈希结果只有26种可能。无论我们将桶数组扩容到多大(比如1000个桶),所有元素都只会被散列到前26个桶对应的索引中(即 0 mod 桶数25 mod 桶数)。这会导致严重的元素聚集,使得许多操作退化为链表上的线性查找,效率低下。

结论:一个好的哈希函数对于哈希表的性能至关重要。它应该能将键均匀地分布到整个桶数组中,尽量减少冲突。在实际应用中(如Java的 String.hashCode()),会使用更复杂的算法来计算哈希值。

核心公式与代码概念总结

  • 负载因子 = 元素数量 / 桶数量
  • 桶索引 = hash(key) % number_of_buckets
  • 扩容条件:负载因子 ≥ 阈值(本例为0.75)。
  • 重哈希:扩容后,对每个元素执行 new_index = hash(key) % new_number_of_buckets
  • 键唯一性:插入已存在的键时,执行更新操作而非添加。

41:Spring 2023 考试第8级问题3解析

概述

在本节中,我们将学习如何判断哈希码函数是否有效。我们将通过分析两个具体的类及其哈希码实现,来理解有效哈希码必须遵守的两条核心规则。

有效哈希码的核心规则

在判断哈希码是否有效时,需要牢记以下两条基本规则:

  1. 一致性:同一个对象必须始终返回相同的哈希码。
  2. 等值性:如果两个对象通过 .equals() 方法判断为相等,那么它们必须返回相同的哈希码。

问题分析:TimeZoneOne 类

上一节我们介绍了有效哈希码的两条核心规则。本节中,我们来看看第一个类 TimeZoneOne 的哈希码实现。

TimeZoneOne 类的 hashCode 方法返回当前时间。具体来说,它调用了 currentTime() 方法,该方法返回当前时区的时间。

这个实现违反了规则一(一致性)。因为同一个对象在不同时间调用 hashCode() 方法,可能会返回不同的值(时间在变化)。哈希码必须基于对象的内部状态,且对于不可变对象,其哈希码在生命周期内应保持不变。

问题分析:Course 类

接下来,我们分析第二个类 Course。这个例子违反了另一条规则。

Course 类的 hashCodeyearOffered(开设年份)和 courseCode(课程代码)的组合。然而,它的 .equals() 方法只比较了 courseCode

以下是导致问题的场景:

  • 假设有两个 Course 对象:
    • 对象 A:courseCode = 5yearOffered = 2001
    • 对象 B:courseCode = 5yearOffered = 2002
  • 根据 .equals() 方法(仅比较 courseCode),这两个对象是相等的。
  • 但是,它们的哈希码计算如下:
    • 对象 A 的哈希码:2001 + 5
    • 对象 B 的哈希码:2002 + 5
  • 显然,两个哈希码不相等。

这违反了规则二(等值性):通过 .equals() 判断相等的两个对象,必须具有相同的哈希码。

总结

本节课中,我们一起学习了判断哈希码有效性的方法。我们分析了两个无效的哈希码实现:

  1. TimeZoneOne 类因其哈希码随时间变化,违反了一致性规则。
  2. Course 类因其 .equals() 方法与 hashCode() 方法所依赖的属性不一致,导致相等对象可能拥有不同哈希码,违反了等值性规则。

核心技巧:解决此类问题时,只需严格对照上述两条规则,仔细检查 hashCode() 方法的实现逻辑以及它与 .equals() 方法的关系,即可判断其有效性。

08:左倾红黑树插入操作详解 🌳

在本节课中,我们将学习左倾红黑树的插入操作。我们将通过一个具体的例子,逐步演示如何向树中插入新节点,并利用三种基本操作来维护树的平衡性质。最后,我们还会将得到的左倾红黑树转换为对应的2-3树。

左倾红黑树通过三种核心操作来维持其平衡特性:左旋转、右旋转和颜色翻转。在插入新节点后,如果违反了左倾红黑树的规则,我们就需要运用这些操作来修复树的结构。

以下是三种需要修复的违规情况及其对应的操作:

  1. 存在右倾的红链接:这违反了“所有红链接必须左倾”的规则。修复方法是进行左旋转

    • 操作rotateLeft(a)。想象将子节点B向上推,与父节点A形成一个临时节点,然后将A向下推,使其成为B的左子节点。
    • 结果:右倾的红链接被转换为左倾。
  2. 存在两个连续的红链接:这违反了“不能有连续的红链接”的规则。修复方法是进行右旋转

    • 操作rotateRight(a)。想象将子节点B和它的左子节点D向上推,形成一个临时节点,然后将A向下推,使其成为B的右子节点。
    • 结果:连续的红链接结构被改变。
  3. 一个节点有两个红色的子节点:这违反了“一个节点不能有两个红子节点”的规则。修复方法是进行颜色翻转

    • 操作colorFlip(a)。将节点A的两个红子链接变为黑色,同时将指向A的链接(从A的父节点来)变为红色。
    • 结果:红色被向上传递,避免了同一节点的两个红子节点。

掌握了这些基本操作后,我们现在可以开始解决具体的插入问题。

我们将对给定的初始树依次插入数字 7, 6, 2, 8, 8.5。初始树结构如下图所示(图中波浪线代表红链接):

        5 (黑)
       / \
   (红)3   9 (黑)
     /   \
 (黑)1   (黑)4

以下是逐步插入和修复的过程:

  • 插入 7:7 应插入在 5 的右边,9 的左边。插入后没有引发任何违规,树暂时保持平衡。
  • 插入 6:6 应插入在 5 的右边,9 的左边,7 的左边。插入后,出现了“两个连续的红链接”(6-7 和 7-9)。我们先对节点 7 进行右旋转。旋转后,节点 7 有了两个红子节点(6 和 9),因此需要对节点 7 进行颜色翻转。颜色翻转后,节点 5 又有了两个红子节点(3 和 7),因此再对节点 5 进行颜色翻转。最终,整棵树暂时没有红链接。
  • 插入 2:2 应插入在 5 的左边,3 的左边,1 的右边。插入后,出现了“右倾的红链接”(1-2)。我们对节点 2 进行左旋转,修复此违规。
  • 插入 8:8 应插入在 9 的左边。插入后没有引发违规。
  • 插入 8.5:8.5 应插入在 8 的右边,9 的左边。插入后,首先出现“右倾的红链接”(8-8.5),对节点 8 进行左旋转。接着出现“两个连续的红链接”(8.5-9 和 9-?),对节点 9 进行右旋转。然后节点 8.5 有了两个红子节点,进行颜色翻转。最后,节点 7 出现了“右倾的红链接”,对节点 7 进行左旋转(这是一个涉及多个节点的大旋转)。

经过所有插入和修复操作后,我们得到最终的左倾红黑树。

上一节我们完成了左倾红黑树的构建,本节中我们来看看如何将其转换为等价的2-3树。

转换规则非常简单:将所有由红链接连接的节点“合并”到同一个2-3树节点中。

根据最终的红黑树结构:

  • 节点 7 和 8.5 由红链接连接,它们合并为一个2-3树节点 [7, 8.5]
  • 节点 1 和 2 由红链接连接,它们合并为一个2-3树节点 [1, 2]
  • 其余黑链接连接的节点各自成为独立的2-3树节点。

按照父子关系组合这些节点,即可绘制出对应的2-3树。

本节课中我们一起学习了左倾红黑树的插入操作。关键在于牢记三种可能的违规情况(右倾红链接、连续红链接、节点有两个红子节点)以及对应的修复方法(左旋转、右旋转、颜色翻转)。通过逐步应用这些操作,我们可以在插入后始终保持树的平衡。最后,我们还学会了如何将左倾红黑树转换为等价的2-3树视图。掌握这些核心概念,你就能应对大多数相关的题目了。

43:Spring 2023 考试第8级问题2 - 哈希问题详解 🧮

在本教程中,我们将学习一个关于哈希映射的考试题目。我们将逐步分析代码执行过程,理解外部链接哈希表的工作原理,以及 equals 方法和 hashCode 方法如何影响键的存储和查找。


问题设置与初始状态

首先,我们处理一个固定大小为 4 且永不扩容的外部链接哈希映射。这意味着每个桶(bucket)可以存储一个链表来处理哈希冲突。

以下是初始的哈希映射结构,包含四个桶(索引 0 到 3):

索引 0: [空]
索引 1: [空]
索引 2: [空]
索引 3: [空]

创建对象与首次插入

我们创建了两个 TA 类的对象:

  • sherryname = "Sherry the goat", semester = 10
  • noahname = "Noah", semester = 20

TA 类的 hashCode() 方法定义为直接返回 semester 的值。

现在,我们执行第一个插入操作 map.put(sherry, 1)

计算哈希值并确定桶索引:
sherrysemester 为 10。哈希映射大小为 4。
桶索引计算公式为:hashCode % numberOfBuckets
因此,10 % 4 = 2sherry 将被放入索引为 2 的桶中,其关联值为 1。

此时哈希映射状态更新为:

索引 0: [空]
索引 1: [空]
索引 2: [sherry -> 1]
索引 3: [空]

插入第二个对象

接下来,执行 map.put(noah, 2)

计算哈希值并确定桶索引:
noahsemester 为 20。
20 % 4 = 0noah 将被放入索引为 0 的桶中,其关联值为 2。

此时哈希映射状态更新为:

索引 0: [noah -> 2]
索引 1: [空]
索引 2: [sherry -> 1]
索引 3: [空]

修改对象属性与哈希冲突

现在,我们执行 noah.semester += 2noah 对象的 semester 值变为 22。请注意,仅仅修改对象的属性不会触发哈希映射的重新哈希或重新定位。 对象 noah 仍然位于索引 0 的桶中。

紧接着,我们执行 map.put(noah, 3)。此时需要重新计算 noah 的桶索引。

重新计算哈希值:
noahsemester 现在为 22。
22 % 4 = 2。因此,noah 的目标桶是索引 2。

索引 2 的桶中已存在键 sherry。此时发生哈希冲突。外部链接哈希表通过链表解决冲突。但在添加新节点前,必须使用 equals 方法检查两个键是否相等。

TA 类的 equals 方法定义为比较两个对象 name 属性的首字母。

  • sherry.name 的首字母是 'S'
  • noah.name 的首字母是 'N'

由于首字母不同,sherry.equals(noah) 返回 false。因此,它们被视为不同的键。我们将在索引 2 的链表中添加一个新节点。

此时哈希映射状态更新为:

索引 0: [noah -> 2]
索引 1: [空]
索引 2: [sherry -> 1] -> [noah -> 3]
索引 3: [空]

键相等时的值替换

执行 sherry.name = "n Harry"。现在 sherry.name 的首字母变为 'n'

接着执行 map.put(noah, 4)noah 的哈希值未变(22 % 4 = 2),因此我们再次查看索引 2 的桶。

我们需要将 noah 与链表中现有的每个键进行比较:

  1. 比较 noahsherry
    • noah.name 首字母是 'N'
    • sherry.name 首字母现在是 'n'
    • TA.equals 方法中,字母比较是区分大小写的吗?题目未明确说明,但通常 Java 的 char 比较是区分大小写的('N' != 'n')。然而,为了与后续步骤逻辑一致,我们假设此处 equals 方法不区分大小写,或题目意图是它们相等。根据视频讲解,此时 sherrynoah 的首字母被视为相同(可能因为 equals 方法被设计为不区分大小写,或 "n Harry" 的首字母 'n' 被忽略?)。关键点在于: 如果 equals 返回 true,则视为两个键相同。
    • 假设此时 sherry.equals(noah) 返回 true

put 操作遇到一个与现有键 equals 的新键时,它不会添加新节点,而是会更新该现有键对应的值。因此,链表中第一个节点(sherry 节点)的值将从 1 更新为 4。键的引用本身保持不变。

此时哈希映射状态为:

索引 0: [noah -> 2]
索引 1: [空]
索引 2: [sherry -> 4] -> [noah -> 3] // 注意:第一个节点的值已更新
索引 3: [空]

再次修改与替换

执行 sherry.semester += 2sherrysemester 变为 12。

接着执行 map.put(sherry, 5)。现在需要计算 sherry 的新哈希值。

重新计算哈希值:
sherrysemester 现在为 12。
12 % 4 = 0。因此,sherry 的目标桶是索引 0。

索引 0 的桶中已存在键 noah。我们需要比较 sherrynoah

  • sherry.name 首字母是 'n'(来自 "n Harry"
  • noah.name 首字母是 'N'
  • 根据之前的假设(equals 不区分大小写或视作相等),sherry.equals(noah) 返回 true

因此,再次发生键相等的情况。我们更新索引 0 桶中 noah 节点对应的值,从 2 更新为 5。注意: 虽然我们调用 put(sherry, ...),但因为 sherrynoah 相等,实际更新的是 noah 节点的值。键的引用仍然是 noah

此时哈希映射状态为:

索引 0: [noah -> 5] // 值被更新,键仍是 noah
索引 1: [空]
索引 2: [sherry -> 4] -> [noah -> 3]
索引 3: [空]

一个重要的观察是:现在 sherrynoah 这两个对象在哈希映射中作为键,出现在不同的桶里(索引 2 和 索引 0),但根据 equals 方法,它们被认为是“相等”的键。这在设计不佳的 equalshashCode 方法中会发生,并可能导致混乱。


最终插入

执行 sherry.name = "Sherry"。将 sherry 的名字改回。

创建一个新的 TA 对象 cheeseGuyname = "Sam", semester = 24

执行 map.put(cheeseGuy, 6)

计算哈希值:
cheeseGuysemester 为 24。
24 % 4 = 0。目标桶是索引 0。

比较 cheeseGuy 与索引 0 桶中的键 noah

  • cheeseGuy.name 首字母是 'S'
  • noah.name 首字母是 'N'
  • 'S' != 'N',因此 equals 返回 false

由于键不相等,我们在索引 0 的链表中添加一个新节点。

最终哈希映射状态如下:

索引 0: [noah -> 5] -> [cheeseGuy -> 6]
索引 1: [空]
索引 2: [sherry -> 4] -> [noah -> 3]
索引 3: [空]

关键点回顾:

  1. 对象 sherrynoah 作为键,分别出现在桶 2 和 桶 0 的链表中。
  2. 根据 TA.equals 方法的定义(比较名字首字母),在某些时刻它们被认为是相等的键(例如当 sherry.name"n Harry" 时)。
  3. noah 对象在桶 0 和桶 2 的链表中都被引用(注意桶 2 中的 [noah -> 3] 节点,这是在 semester 修改后、equals 判断为不相等时插入的)。
  4. cheeseGuy 对象作为一个不同的键被添加到桶 0 的链表中。

总结 🎯

本节课我们一起分析了哈希映射的一个复杂用例。我们深入理解了以下核心概念:

  1. 哈希与桶定位:键的存储位置由 hashCode() % numberOfBuckets 决定。
  2. 外部链接:哈希冲突通过在每个桶中维护链表来解决。
  3. equals 方法的核心作用:在 put 操作中,当哈希冲突发生时,equals 方法用于判断两个键是否“相等”。如果相等,则更新值;如果不相等,则添加新节点。
  4. 键对象可变性的风险:如果作为键的对象其 hashCodeequals 所依赖的字段被修改,会导致该键在映射中定位错误或行为不一致,这是一个重要的设计禁忌。本例中修改 semestername 属性清晰地展示了这种风险。

通过逐步绘制哈希映射的状态变化,我们直观地看到了这些规则是如何应用的。记住,在设计类并将其用作哈希映射的键时,必须确保 equalshashCode 方法遵循一致的契约,并且键对象最好是不可变的。

44:左倾红黑树 (LLRB) 实现指南 🌳

在本教程中,我们将学习如何实现左倾红黑树的核心再平衡操作。我们将从理解其理论基础——2-3树开始,然后详细探讨LLRB必须遵守的规则,以及当插入操作违反这些规则时,如何通过旋转和颜色翻转来修复树的结构。

从2-3树到左倾红黑树 🔄

上一节我们介绍了课程概述,本节中我们来看看LLRB的理论基础。左倾红黑树可以看作是2-3树的一种特定表示形式。理解2-3树对于掌握LLRB至关重要。

2-3树是一种B树,其中每个节点可以包含1个或2个值,并相应地拥有2个或3个子节点。其结构遵循二叉搜索树的原则:小于左值的元素进入左子树,介于两个值之间的元素进入中间子树,大于右值的元素进入右子树。2-3树的关键特性是所有叶子节点到根节点的距离相同,这保证了O(log n)的搜索和插入性能,避免了普通BST可能退化为O(n)的链状结构。

然而,2-3树的实现较为复杂。左倾红黑树提供了一种更易实现的二叉树形式来模拟2-3树。转换规则如下:对于2-3树中的每个3-节点(包含两个值的节点),我们将其拆分为两个二叉节点。较大的值成为一个黑色节点,较小的值则成为该黑色节点的红色左子节点。这就是“左倾”名称的由来。

需要说明的是,本实验中使用“红色节点”的概念,而在课程讲座中可能使用“红色链接”。两者本质等价,将红色链接指向的节点视为红色节点更易于代码实现。每个2-3树都唯一对应一个LLRB。

LLRB的核心规则 📜

上一节我们了解了LLRB的起源,本节中我们来看看维持其平衡性质必须遵守的具体规则。这些规则确保了树近似平衡。

以下是LLRB必须满足的规则,其中标星(*)的规则是我们插入节点时需要重点检查和维护的:

  1. 每个节点非红即黑。
  2. 根节点始终为黑色。*
  3. 红色节点不能有红色父节点(即没有两个连续的红色节点)。*
  4. 从任意节点到其所有后代叶子节点的路径包含相同数量的黑色节点。
  5. 如果一个节点只有一个红色子节点,那么这个子节点必须是左子节点。*
  6. 空叶子节点(null)被视为黑色。

当插入新节点(新插入的节点总是红色)导致违反上述标星规则时,我们需要通过一系列局部调整(旋转和颜色翻转)来修复树,使其重新满足所有规则。

旋转操作详解 🔁

在深入具体的违规案例之前,我们需要掌握修复工具:旋转操作。旋转是调整二叉树局部结构而不破坏BST性质(左小右大)的基本操作。

主要有两种旋转:右旋和左旋。我们可以通过两个关键步骤来理解旋转:

  1. 将当前节点“推”向下方的子节点方向。
  2. 将那个子节点“提”到当前节点的位置。

以对节点D进行右旋为例:

  • 步骤1:将D节点向下推至其左子节点B的右侧
  • 步骤2:将B节点向上提升到原来D的位置。

在旋转过程中,还需要注意B节点原有右子树的处理。它需要被重新连接到新的父节点D上。旋转后,参与旋转的两个节点(本例中的B和D)的颜色需要交换。左旋是对称的操作。

规则违反案例及修复方案 🛠️

现在,我们将结合插入过程,逐一分析最常见的规则违反情况及其修复方法。假设我们总是从一个合法的LLRB开始,然后插入一个新的红色节点。

以下是插入后可能遇到的三种主要违规场景及修复策略:

场景一:红色右子节点(违反规则5)
当在一个黑色节点A的右侧插入红色节点X时,会形成“红色右子节点”,这违反了左倾原则。

  • 修复方法:对节点A进行左旋。旋转后,原来的子节点X上升,原来的父节点A下降并成为X的左子节点,同时交换A和X的颜色。

场景二:节点有两个红色子节点(违反规则3的潜在状态)
当一个黑色节点B已经有一个红色左子节点A,此时又在B的右侧插入红色节点X,则B拥有了两个红色子节点。

  • 修复方法:对节点B进行颜色翻转。将B的颜色由黑变红,同时将其两个子节点A和X的颜色由红变黑。节点位置保持不变。

场景三:红色节点有红色父节点(违反规则3)
这是最复杂的情况,有两个子场景。

  • 子场景A(左左红色):在红色节点A的左侧插入红色节点X,导致X和A连续为红色。
    1. 首先,对A的父节点(黑色节点B)进行右旋。B下降为A的右子节点,A上升。旋转后交换B和A的颜色。
    2. 此时,新上升的节点A(现为黑色)拥有了两个红色子节点(B和X),这回到了场景二。因此需要对A进行颜色翻转
  • 子场景B(左右红色):在红色节点A的右侧插入红色节点X,导致X和A连续为红色。
    1. 首先,对红色节点A进行左旋。A下降为X的左子节点,X上升。颜色不变(因为都是红色)。
    2. 旋转后,形成了子场景A(左左红色) 的状态。接着按子场景A的步骤处理:对X的父节点B进行右旋,然后进行颜色翻转。

总结 📝

本节课中我们一起学习了左倾红黑树的核心实现逻辑。我们从其对应的2-3树结构出发,理解了LLRB的五条核心规则。重点在于,当插入新节点破坏这些规则时,我们需要通过左旋右旋颜色翻转这三种操作来局部调整树结构,使其恢复平衡。整个修复过程是递归或迭代进行的,从插入点向上回溯,直至根节点。掌握这些案例的识别与修复,是完成本次实验的关键。请仔细思考每种情况是如何发生的,以及相应的修复步骤如何将其转化为合法状态。

45:图与堆内容回顾

在本节课中,我们将一起回顾图与堆的核心概念。我们将学习图的定义、表示方法、搜索算法,以及堆的结构、操作和性能分析。内容将涵盖广度优先搜索、深度优先搜索、堆的插入与删除等关键主题。


图的基本定义

上一节我们介绍了树,本节中我们来看看图。图是比树更通用的结构,它允许循环。

图由节点组成。边可以是有向的,也可以是无向的。有向边用箭头表示,表示从一个节点到另一个节点的单向连接。无向边则代表双向连接,可以看作一条双向街道。

以下是图的一些基本规则:

  1. 如果图中有 n 个节点,则有 n-1 条边。
  2. 从根节点到其他任意节点,有且仅有一条路径。
  3. 图是完全连通的,并且不包含环。环是指存在多条路径连接同一组节点的情况。

在树中,父节点指向其子节点根节点没有父节点,叶节点没有子节点。


图的类型与检查

现在,让我们通过几个例子来检查图的有向性和是否存在环。

以下是判断图类型的几个要点:

  • 有向图:所有边都带有箭头,表示单向连接。
  • 无向图:边没有箭头,表示双向连接。
  • :存在一条路径,可以从一个节点出发,经过若干节点后回到起点。

例如,一个所有边都有箭头且无法形成回路的图是有向无环图。而一个边无箭头且可以形成回路的图是无向有环图。本课程主要讨论简单图,即没有平行边和自环的图。


图的表示方法

理解了图是什么之后,我们来看看如何在计算机中表示图。图的表示方法主要有两种:邻接表和邻接矩阵。

邻接表为图中的每个节点维护一个列表,记录与其直接相连的所有节点。这可以看作一个映射(Map),键是节点,值是其邻居节点的列表。

邻接矩阵是一个 n x n 的二维数组(n 为节点总数)。如果存在从节点 A 到节点 B 的边,则矩阵中 AB 列的位置标记为 true(或 1),否则为 false(或 0)。对于有向图,矩阵通常不对称。

两种方法各有优缺点,邻接表在稀疏图中更节省空间,而邻接矩阵在查询两点间是否存在边时更快。


图的搜索算法:广度优先搜索

既然我们有了图,那么如何遍历或搜索图中的节点呢?这就引出了搜索算法。首先介绍广度优先搜索

BFS 按照节点距离起点的远近,逐层访问节点。它通常使用队列来实现。

以下是 BFS 的伪代码步骤:

  1. 将起始节点加入队列。
  2. 当队列不为空时:
    a. 从队列前端取出一个节点并“访问”它(标记为已处理)。
    b. 将该节点的所有未被访问过的邻居节点加入队列后端

以树为例,BFS 的访问顺序就是从上到下、从左到右的层级顺序。一个快速的记忆方法是:从根节点开始,先访问同一层的所有节点,再访问下一层。


图的搜索算法:深度优先搜索

与 BFS 逐层探索不同,深度优先搜索会沿着一条路径尽可能深地探索,直到尽头再回溯。

DFS 通常使用来实现(或递归)。对于树,有三种常见的 DFS 遍历顺序:前序、中序和后序。

前序遍历的顺序是:先访问父节点,然后递归地前序遍历左子树,最后递归地前序遍历右子树。
伪代码表示如下:

preorder(node):
    if node is null: return
    visit(node)
    preorder(node.left)
    preorder(node.right)

中序遍历的顺序是:先递归地中序遍历左子树,然后访问父节点,最后递归地中序遍历右子树。
伪代码表示如下:

inorder(node):
    if node is null: return
    inorder(node.left)
    visit(node)
    inorder(node.right)

注意:中序遍历通常只用于二叉树,对于多叉图难以定义“左”和“右”。

后序遍历的顺序是:先递归地后序遍历左子树,然后递归地后序遍历右子树,最后访问父节点。
伪代码表示如下:

postorder(node):
    if node is null: return
    postorder(node.left)
    postorder(node.right)
    visit(node)

对于一般的图,DFS 可以通过栈来模拟,核心思想是尽可能深地探索一条路径,并通过“已访问”集合来避免重复访问。


堆:定义与结构

现在,让我们转换话题,讨论另一种重要的。堆是一种特殊的完全二叉树。

堆必须满足两个主要性质:

  1. 结构性质:堆是一棵完全二叉树。这意味着除了最后一层,其他层都是满的,并且最后一层的节点尽可能靠左排列。
  2. 堆序性质
    • 最小堆中,每个节点的值都小于或等于其所有子节点的值。因此,根节点是整个堆中的最小元素。
    • 最大堆中,每个节点的值都大于或等于其所有子节点的值。根节点是最大元素。

堆与二叉搜索树不同。BST 要求左子树所有节点小于根,右子树所有节点大于根。而堆只要求父节点和子节点之间有大小关系,兄弟节点之间没有固定顺序。


堆的表示与操作

堆通常使用数组来高效存储。我们约定:

  • 根节点存储在索引 1 的位置(索引 0 空置或不使用)。
  • 对于数组中索引为 i 的节点:
    • 其左子节点的索引为 2 * i
    • 其右子节点的索引为 2 * i + 1
    • 其父节点的索引为 i // 2 (整数除法)

这种存储方式下,数组元素的顺序恰好对应树的层序遍历(BFS)结果。

堆的核心操作是插入和删除最小元素(对于最小堆)。

插入

  1. 将新元素添加到堆的末尾(维持完全树结构)。
  2. 将该元素与其父节点比较。如果它比父节点小(对于最小堆),则交换它们的位置。
  3. 重复步骤 2,直到该元素不再小于其父节点,或者到达根节点。这个过程称为“上浮”。

删除最小元素(根)

  1. 将堆的最后一个元素移到根节点位置。
  2. 移除原来的最后一个元素(现在已被移动)。
  3. 将新的根节点与其子节点比较。如果它比某个子节点大,则与较小的那个子节点交换。
  4. 重复步骤 3,直到该元素不大于其任何子节点,或者到达叶节点。这个过程称为“下沉”。

堆的性能分析

最后,我们来分析堆操作的时间复杂度。

以下是堆操作在最坏情况下的时间复杂度:

  • 插入Θ(log n)。最坏情况下,新元素需要从底部一直上浮到根部,而堆的高度是 log n
  • 删除最小元素Θ(log n)。最坏情况下,新的根元素需要一直下沉到叶子节点。
  • 查找最小元素Θ(1)。最小元素始终在根节点,可以直接访问。

更一般地说,插入和删除操作的时间复杂度是 O(log n),因为存在不需要上浮/下沉的最佳情况(常数时间),但最坏情况受 log n 限制。查找最小元素则可以严格界定为 Θ(1)


本节课中我们一起学习了图与堆的核心内容。我们了解了图的定义、表示方法以及BFS和DFS两种搜索策略。接着,我们探讨了堆作为一种完全二叉树的特性,学习了其数组表示法以及插入、删除等关键操作及其时间复杂度。掌握这些基础数据结构对于解决更复杂的算法问题至关重要。

46:树、图与遍历

在本节课中,我们将学习二叉搜索树(BST)的四种遍历方式:中序、广度优先、前序和后序遍历。同时,我们也会探讨图的两种表示方法:邻接矩阵和邻接列表,并学习如何在图上执行深度优先搜索(DFS)的前序与后序遍历。


中序遍历

上一节我们介绍了课程目标,本节中我们来看看中序遍历。中序遍历遵循“左-根-右”的顺序访问二叉树的节点。其核心思想是递归地处理左子树,然后访问根节点,最后递归地处理右子树。

以下是中序遍历的伪代码:

inorder(T):
    if T is null:
        return
    inorder(T.left)
    visit(T)
    inorder(T.right)

让我们对示例树执行中序遍历。我们从根节点 10 开始。

  1. 递归进入 10 的左子树(节点 3)。
  2. 递归进入 3 的左子树(节点 1)。
  3. 节点 1 没有左孩子,因此访问节点 1
  4. 节点 1 没有右孩子,返回。
  5. 回到节点 3,访问节点 3
  6. 递归进入 3 的右子树(节点 7)。
  7. 节点 7 没有左孩子,因此访问节点 7
  8. 节点 7 没有右孩子,返回。
  9. 回到节点 10,访问节点 10
  10. 递归进入 10 的右子树(节点 12)。
  11. 递归进入 12 的左子树(节点 11)。
  12. 节点 11 没有左孩子,因此访问节点 11
  13. 节点 11 没有右孩子,返回。
  14. 回到节点 12,访问节点 12
  15. 递归进入 12 的右子树(节点 14)。
  16. 递归进入 14 的左子树(节点 13)。
  17. 节点 13 没有左孩子,因此访问节点 13
  18. 节点 13 没有右孩子,返回。
  19. 回到节点 14,访问节点 14
  20. 递归进入 14 的右子树(节点 15)。
  21. 节点 15 没有左孩子,因此访问节点 15
  22. 节点 15 没有右孩子,返回。遍历结束。

因此,中序遍历的结果序列是:1, 3, 7, 10, 11, 12, 13, 14, 15。一个有趣的现象是,对二叉搜索树进行中序遍历,输出的节点值恰好是升序排列的。这是因为BST的性质保证了左子树的所有节点值小于根节点,右子树的所有节点值大于根节点,而中序遍历的顺序恰好先访问左子树,再访问根节点,最后访问右子树。


广度优先遍历

理解了中序遍历后,我们来看看广度优先遍历。广度优先遍历(BFS)按层级访问树的节点,从根节点开始,逐层向下。它使用队列(先进先出)数据结构来辅助实现。

以下是BFS的伪代码:

BFS(start):
    queue = new Queue()
    queue.enqueue(start)
    while queue is not empty:
        node = queue.dequeue()
        visit(node)
        for each neighbor in node.unvisited_neighbors:
            queue.enqueue(neighbor)

让我们对同一棵树执行BFS,从根节点 10 开始。

  1. 初始队列:[10]
  2. 出队 10 并访问。将其邻居 312 入队。队列:[3, 12]
  3. 出队 3 并访问。将其邻居 17 入队。队列:[12, 1, 7]
  4. 出队 12 并访问。将其邻居 1114 入队。队列:[1, 7, 11, 14]
  5. 出队 1 并访问。它没有孩子,不添加新节点。队列:[7, 11, 14]
  6. 出队 7 并访问。它没有孩子。队列:[11, 14]
  7. 出队 11 并访问。它没有孩子。队列:[14]
  8. 出队 14 并访问。将其邻居 1315 入队。队列:[13, 15]
  9. 出队 13 并访问。它没有孩子。队列:[15]
  10. 出队 15 并访问。它没有孩子。队列为空,遍历结束。

因此,广度优先遍历的结果序列是:10, 3, 12, 1, 7, 11, 14, 13, 15


前序遍历与后序遍历

接下来,我们快速浏览前序和后序遍历。这两种遍历方式可以推广到更一般的图结构,但在树上同样适用。

前序遍历遵循“根-左-右”的顺序。

以下是前序遍历的伪代码:

preorder(T):
    if T is null:
        return
    visit(T)
    preorder(T.left)
    preorder(T.right)

对示例树执行前序遍历:

  1. 访问根节点 10
  2. 递归处理左子树(以 3 为根):访问 3,然后访问 3 的左孩子 1,再访问 3 的右孩子 7。序列目前为:10, 3, 1, 7
  3. 递归处理右子树(以 12 为根):访问 12,然后访问 12 的左孩子 11
  4. 接着处理 12 的右子树(以 14 为根):访问 14,然后访问 14 的左孩子 13,再访问 14 的右孩子 15

最终前序遍历序列为:10, 3, 1, 7, 12, 11, 14, 13, 15

后序遍历遵循“左-右-根”的顺序。

以下是后序遍历的伪代码:

postorder(T):
    if T is null:
        return
    postorder(T.left)
    postorder(T.right)
    visit(T)

对示例树执行后序遍历:

  1. 递归处理左子树(以 3 为根):先访问 1,然后访问 7,最后访问 3。序列目前为:1, 7, 3
  2. 递归处理右子树(以 12 为根):先处理 12 的左子树(11),访问 11
  3. 再处理 12 的右子树(以 14 为根):先访问 13,然后访问 15,最后访问 14。序列新增:11, 13, 15, 14
  4. 最后访问 12
  5. 回到整棵树的根,访问 10

最终后序遍历序列为:1, 7, 3, 11, 13, 15, 14, 12, 10

后序遍历的核心思想是:在处理一个节点之前,必须先完全处理它的所有子节点。


图的表示:邻接矩阵与邻接列表

现在,我们将目光从树转向更一般的图。图的表示方法主要有两种:邻接矩阵和邻接列表。我们以一个包含节点 A 到 G 的有向图为例。

邻接矩阵是一个二维表格,行和列都代表图中的节点。如果存在从行节点指向列节点的有向边,则在对应位置标记为 1(或打勾),否则为 0

以下是构建邻接矩阵的步骤:

  • 行 A:有边指向 B 和 D。在 (A,B) 和 (A,D) 位置标记。
  • 行 B:有边指向 C。在 (B,C) 位置标记。
  • 行 C:有边指向 F。在 (C,F) 位置标记。
  • 行 D:有边指向 B, E, F。在 (D,B), (D,E), (D,F) 位置标记。
  • 行 E:有边指向 F。在 (E,F) 位置标记。
  • 行 F:没有出边。该行全为0。
  • 行 G:有边指向 F。在 (G,F) 位置标记。

邻接列表则是一种更节省空间的方式,它为每个节点维护一个列表,记录该节点直接指向的所有邻居节点。

以下是邻接列表的表示:

  • A -> [B, D]
  • B -> [C]
  • C -> [F]
  • D -> [B, E, F]
  • E -> [F]
  • F -> []
  • G -> [F]

图的深度优先遍历

最后,我们学习在图上执行深度优先搜索,并区分前序和后序访问顺序。我们使用栈(后进先出)来实现DFS,并规定按字母顺序处理邻居。

以下是结合了前序和后序记录的DFS算法核心步骤:

DFS(start):
    stack.push(start)
    while stack is not empty:
        node = stack.peek() // 查看栈顶元素但不弹出
        if node is not visited:
            mark node as visited
            add node to preorder_list // 入栈时记录前序
        if node has an unvisited neighbor:
            push the next unvisited neighbor onto stack
        else:
            stack.pop() // 弹出栈顶
            add node to postorder_list // 出栈时记录后序

我们从节点 A 开始执行这个算法。

以下是详细的访问过程:

  1. A 入栈,标记为已访问,加入前序列表。栈:[A]。前序:[A]。
  2. A 有未访问邻居 B 和 D(按字母顺序选 B)。B 入栈,标记,加入前序。栈:[A, B]。前序:[A, B]。
  3. B 有未访问邻居 C。C 入栈,标记,加入前序。栈:[A, B, C]。前序:[A, B, C]。
  4. C 有未访问邻居 F。F 入栈,标记,加入前序。栈:[A, B, C, F]。前序:[A, B, C, F]。
  5. F 没有未访问邻居。F 出栈,加入后序列表。栈:[A, B, C]。后序:[F]。
  6. C 没有其他未访问邻居。C 出栈,加入后序列表。栈:[A, B]。后序:[F, C]。
  7. B 没有其他未访问邻居。B 出栈,加入后序列表。栈:[A]。后序:[F, C, B]。
  8. A 还有未访问邻居 D。D 入栈,标记,加入前序。栈:[A, D]。前序:[A, B, C, F, D]。
  9. D 有未访问邻居 E。E 入栈,标记,加入前序。栈:[A, D, E]。前序:[A, B, C, F, D, E]。
  10. E 的邻居 F 已访问,无其他未访问邻居。E 出栈,加入后序列表。栈:[A, D]。后序:[F, C, B, E]。
  11. D 的所有邻居(B, E, F)均已访问。D 出栈,加入后序列表。栈:[A]。后序:[F, C, B, E, D]。
  12. A 的所有邻居(B, D)均已访问。A 出栈,加入后序列表。栈:[]。后序:[F, C, B, E, D, A]。

最终结果:

  • DFS 前序序列(节点入栈时记录):A, B, C, F, D, E
  • DFS 后序序列(节点出栈时记录):F, C, B, E, D, A

需要注意的是,节点 G 从 A 出发不可达,因此在此次遍历中未被访问。如果算法允许从未访问节点重新启动DFS,那么 G 将被单独遍历。


本节课中我们一起学习了二叉搜索树的四种遍历方式(中序、广度优先、前序、后序),掌握了图的两种表示法(邻接矩阵和邻接列表),并通过一个详细的例子理解了如何在图上执行深度优先搜索并得到其前序和后序遍历序列。理解这些遍历的顺序和实现机制是学习图论算法的重要基础。

47:堆操作与堆的转换

在本节课中,我们将学习堆(Heap)的基本操作,包括插入和删除最小值,并探讨如何利用已有的最小堆来模拟最大堆的行为。我们将通过一个具体的例子,逐步执行操作并观察堆及其底层数组的变化。


堆操作详解:插入与删除最小值

上一节我们介绍了堆的基本概念。本节中,我们来看看如何在一个最小堆上执行一系列插入和删除最小值的操作,并跟踪每一步后堆的状态。

我们从一个空的最小堆开始。

操作1:插入字符 F

由于堆是空的,我们创建一个包含字符 F 的节点作为根节点。

  • 底层数组状态:[F]

操作2:插入字符 H

插入新元素时,首先将其放在最底层最右边的可用位置。

  1. H 插入为 F 的右子节点。
  2. 检查堆属性:父节点 F 是否大于子节点 HF < H,因此无需交换(“上浮”操作)。
  • 底层数组状态(按层级顺序遍历):[F, H]

操作3:插入字符 D

  1. D 插入到最底层最右边的可用位置(H 的左侧)。
  2. 检查堆属性:父节点 H 是否大于当前节点 DH > D,因此需要交换。
  3. 交换 DH
  4. 继续检查:新的父节点 F 是否大于当前节点 DF > D,因此需要交换。
  5. 交换 DF。现在 D 成为根节点,堆属性满足。
  • 底层数组状态:[D, H, F]

操作4:插入字符 B

  1. B 插入到最底层最右边的可用位置(F 的左侧)。
  2. 检查堆属性:父节点 F 是否大于 BF > B,交换。
  3. B 上浮,新父节点为 HH > B,交换。
  4. B 继续上浮,新父节点为 DD > B,交换。
  5. B 成为新的根节点。
  • 底层数组状态:[B, D, F, H]

操作5:插入字符 C

  1. C 插入到最底层最右边的可用位置(H 的右侧)。
  2. 检查堆属性:父节点 D 是否大于 CD > C,交换。
  3. C 上浮,新父节点为 BB < C,无需交换。
  • 底层数组状态:[B, C, F, H, D]

操作6:删除最小值(removeMin

删除最小值时,首先移除根节点,然后用堆中最后一个元素填充根节点的位置,并进行“下沉”操作以恢复堆属性。

  1. 移除根节点 B
  2. 将最后一个元素 D 移到根节点位置。
  3. 开始“下沉”:比较新的根节点 D 与其子节点 CF
  4. D 大于较小的子节点 C,因此交换 DC
  5. 现在 D 位于 C 原来的位置。比较 D 与其新的子节点 HD < H,满足堆属性,停止下沉。
  • 底层数组状态:[C, D, F, H]

操作7:再次删除最小值

  1. 移除根节点 C
  2. 将最后一个元素 H 移到根节点位置。
  3. 开始“下沉”:比较根节点 H 与其子节点 DF
  4. H 大于两个子节点。选择较小的子节点 D 进行交换。
  5. 交换 HDH 现在没有子节点,下沉停止。
  • 底层数组状态:[D, H, F]

利用最小堆模拟最大堆

上一节我们实践了最小堆的具体操作。本节中,我们来看看一个有趣的问题:如何在不重写代码的情况下,让你已经实现好的最小堆表现得像一个最大堆。

核心需求是:能在常数时间内获取最大元素,并以对数时间复杂度进行插入和删除操作。

解决方案的关键在于利用数值的相反数。以下是具体步骤:

  1. 插入操作:当需要向“最大堆”插入一个值 x 时,我们实际上向底层的最小堆插入其相反数 -x
    • 代码表示:minHeap.insert(-value)
  2. 获取/删除最大元素操作:当需要从“最大堆”中获取或删除最大元素时,我们调用底层最小堆的 getMin()removeMin() 方法,然后对返回的结果再次取反,即可得到原始的最大值。
    • 代码表示:maxValue = -minHeap.removeMin()

原理:最小堆总是让最小的元素在顶部。如果我们插入所有元素的相反数,那么原本最大的数(例如8)就变成了最小的数(-8),从而会被最小堆排列到顶部。当我们需要取出这个“最大值”时,只需要对取出的“最小值”(-8)再次取反,就得到了原始的最大值(8)。


总结

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

  1. 最小堆的操作流程:包括插入元素时的“上浮”和删除最小元素时的“下沉”过程,并跟踪了底层数组的实时变化。
  2. 堆的灵活应用:通过对插入值取反,再利用底层最小堆排序,最后对取出值再次取反的方法,可以巧妙地用最小堆模拟出最大堆的所有行为。这体现了数据抽象和代码复用的思想。

48:4 - 三叉搜索树降序遍历算法

在本节课中,我们将学习一种名为“三叉搜索树”的数据结构变体,并设计一个算法,用于以降序打印树中的所有元素。我们将从熟悉的二叉搜索树遍历方法中获得灵感,并对其进行调整以适应允许重复值的新结构。


三叉搜索树简介

上一节我们介绍了二叉搜索树的基本概念。本节中,我们来看看一种允许重复值的变体——三叉搜索树。

三叉搜索树的行为与二叉搜索树类似,但包含一个额外的规则来处理重复值。其不变式如下:

  1. TST中的每个节点都是一个更小的TST的根节点。
  2. 根节点左侧的每个节点的值都小于根节点的值。
  3. 根节点右侧的每个节点的值都大于根节点的值。
  4. 根节点中间的每个节点的值都等于根节点的值。

下图展示了一个TST的示例:

在这个例子中,值为10的节点有一个中间子链(值均为10),其左侧是更小的值,右侧是更大的值。


算法设计思路

我们的目标是设计一个算法,对给定的TST进行遍历,并以降序输出所有节点的值。例如,对上图的树运行算法,应输出:15, 14, 13, 12, 12, 11, 10, 10, 10, 7, 7, 3, 1

以下是设计此算法的关键思路:

我们回顾一下二叉搜索树的中序遍历。在BST中,中序遍历(访问顺序:左子树 -> 节点自身 -> 右子树)会以升序输出节点值。这是因为BST的排序性质保证了左子节点 < 当前节点 < 右子节点。

为了获得降序输出,一个直观的想法是反转中序遍历的访问顺序:先访问右子树,再访问节点自身,最后访问左子树。这样就能先处理更大的值,再处理更小的值。

然而,TST引入了“中间子节点”来处理重复值。因此,我们的算法需要在访问右子树和左子树之间,处理所有值相等的中间子节点。


算法伪代码描述

基于以上分析,我们可以设计出以下递归算法。该算法可以看作是BST“反向中序遍历”的扩展。

以下是算法的核心伪代码:

def traverse_descending(root):
    if root is None:          # 基础情况:空树
        return
    traverse_descending(root.right)   # 1. 递归遍历右子树(更大的值)
    print(root.value)                 # 2. 打印当前节点的值
    traverse_descending(root.middle)  # 3. 递归遍历中间子树(相等的值)
    traverse_descending(root.left)    # 4. 递归遍历左子树(更小的值)

算法步骤解析:

  1. 遍历右子树:首先递归地处理当前节点右侧的所有节点,它们包含比当前节点更大的值。
  2. 打印当前值:处理完所有更大的值后,打印当前节点自身的值。
  3. 遍历中间子树:接着递归地处理中间子链,它们包含与当前节点相等的值。
  4. 遍历左子树:最后递归地处理左侧的所有节点,它们包含比当前节点更小的值。

关于顺序的说明:在上述伪代码中,步骤2(打印)和步骤3(遍历中间子树)可以互换顺序,因为打印的值和中间子树的值是相等的,最终输出序列中这些相等值的相对顺序不会影响整体的降序排列。但如果需要严格保持“先父节点后子节点”的打印顺序,则应保持先打印再遍历中间子树的顺序。


总结

本节课中我们一起学习了三叉搜索树的结构特点,并设计了一个以降序打印其所有元素的算法。我们利用了对二叉搜索树遍历的理解,通过调整访问顺序(右 -> 中 -> 左)并加入对重复值链(中间子树)的处理,成功解决了问题。这个算法是递归思想在树结构上的一个典型应用。

49:最小堆核心概念与真题解析 🧠

在本节课中,我们将学习最小堆(Min Heap)的核心概念,并通过一道来自UCB CS 61B 2023年春季考试的真题(第9级,问题2)进行深入解析。我们将重点关注堆的操作复杂度、遍历顺序以及堆中元素排名的特性。


第一部分:堆操作的时间复杂度 ⏱️

上一节我们介绍了最小堆的基本概念,本节中我们来看看其核心操作的时间复杂度分析。

移除最小元素(removeMin

removeMin 操作的最佳情况时间复杂度是 Θ(1),最坏情况时间复杂度是 Θ(log n)

原理分析:当执行 removeMin 时,算法会将堆中最后一个节点(最右下角的节点)与根节点(最小节点)交换,然后通过“下沉”(sink down)操作将这个新根节点调整到正确位置,以恢复堆的性质。最坏情况下,节点需要从根一直下沉到叶子,路径长度与树的高度成正比,即 log n。最佳情况下,交换后的新根节点已经满足堆性质,无需任何下沉操作。

插入元素(insert

insert 操作的最佳情况时间复杂度是 Θ(1),最坏情况时间复杂度是 Θ(log n)

原理分析:插入操作总是将新节点添加到堆的末尾(最右下角),然后通过“上浮”(swim up)操作将其向上交换,直到堆性质恢复。最坏情况下,新节点需要从底部一直上浮到根节点。最佳情况下,新节点无需移动。


第二部分:输出有序序列的堆遍历 🔄

了解了基本操作后,我们来看看如何通过遍历堆来获得一个有序序列。一个关键点是,在最小堆中,根节点始终是最小元素

因此,任何能首先输出根节点的遍历方式,都可能(在特定条件下)按升序输出元素。以下是两种满足此条件的遍历方式:

  • 层序遍历(Level Order):按层级从左到右访问节点,自然从根节点开始。
  • 前序遍历(Preorder):按照“根-左-右”的顺序访问节点,也是从根节点开始。

注意:这两种遍历方式能“输出根节点第一”,但要得到完全排序的序列,通常需要反复执行 removeMin 操作。题目此处探讨的是单次遍历的起始特性。


第三部分:堆中第K小元素的位置 📍

现在,我们进入一个更深入的问题:在最小堆中,第K小的元素可能出现在哪些位置?解决这个问题需要利用堆的两个核心性质。对于堆中的任意节点:

  1. 大于其所有直系祖先节点(父节点、祖父节点等)。
  2. 小于其所有后代节点(子节点、孙子节点等)。

由于题目假设堆中所有元素互异,因此上述关系是严格的大于或小于,而非大于等于或小于等于。

问题:第四小元素的位置

对于第四小的元素:

  • 不可能是根节点(第一层),因为根节点是最小的。
  • 可能在第二层。因为兄弟节点之间没有大小约束,可以构造出第四小元素在第二层的情况。
  • 同理,它也可能在第三层。
  • 它也可能在第四层。
  • 但是,它最多只能出现在第四层。如果它在第五层,那么它必须大于从根到第四层路径上的至少4个祖先节点,这意味着它至少是第五小的元素,而不可能是第四小的。

因此,我们需要计算第二、三、四层的节点总数,即为所有可能的位置。以下是各层节点数:

  • 第二层:2个节点
  • 第三层:4个节点
  • 第四层:8个节点

所以,可能的位置总数为:2 + 4 + 8 = 14


第四部分:堆中节点的大小关系约束 🔢

最后,我们推广上面的思路,分析在一个具有 2^n - 1 个元素(即满二叉树,共 n 层)的最小堆中,特定位置节点的约束条件。这里使用小写 n 代表层数,以区别于之前表示节点总数的大写 N

对于第二层的节点

考虑一个位于第二层的节点(例如根节点的左孩子):

  • 必须大于的元素数量:它只有一个直系祖先(根节点)。因此,它必须严格大于 1 个元素。

  • 必须小于的元素数量:它必须小于其所有后代节点。在一个 n 层的满二叉树中,以该节点为根的子树包含的节点总数计算如下:

    1. 总节点数:2^n - 1
    2. 减去根节点(不属于该子树):2^n - 2
    3. 该子树节点数是整棵树的一半(因为二叉树性质):(2^n - 2) / 2 = 2^(n-1) - 1
    4. 最后,减去该节点自身(我们只计算它的后代):(2^(n-1) - 1) - 1 = 2^(n-1) - 2

    因此,它必须小于 2^(n-1) - 2 个元素。

对于最底层的节点(叶子节点)

考虑一个位于第 n 层(最底层)的叶子节点:

  • 必须小于的元素数量:0(因为它没有子节点)。它甚至可以是堆中最大的元素。
  • 必须大于的元素数量:它必须大于从自身到根节点路径上的所有直系祖先。这条路径上的节点数正好等于其深度,即 n - 1。因此,它必须大于 n - 1 个元素。

总结与考试建议 📚

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

  1. 最小堆 removeMininsert 操作的最佳与最坏时间复杂度。
  2. 能首先输出最小堆根节点的两种遍历方式:层序和前序遍历。
  3. 如何利用堆的排序性质,推断第K小元素可能出现的层级位置,并进行数量计算。
  4. 在满二叉堆中,定量分析特定层级节点必须大于和小于的元素数量。

考试建议:这类堆的概念性问题在考试中很常见。提升的最佳方法是进行大量练习。许多概念题都是以往学期题目的变体,因此尝试完成至少几套过去的试卷,以熟悉其中的模式和解题思路。祝你在期中考试和CS 61B的后续学习中顺利!

50:2 - 堆操作与关系推理 🧩

在本教程中,我们将学习如何分析对二叉堆执行的一系列操作,并根据操作结果推断堆中元素之间的相对大小关系。我们将通过一个具体的、具有挑战性的例题来掌握这一推理过程。

概述

我们从一个初始的最小堆开始,其中每个元素都是唯一的,但具体数值未知,仅用字母表示。我们的目标是:分析一系列“删除最小值”和“插入”操作后,堆如何转变为最终形态,并据此推断某些元素对之间的大小关系(大于、小于或未知)。理解堆的“上浮”和“下沉”操作是解决本问题的关键。

初始与最终堆结构

首先,我们明确堆的表示方式。堆是一个完全二叉树,可以方便地用数组表示。数组按层级从左到右填充。

  • 初始堆(数组表示): [A, B, C, D, E, F, G]
    对应的树形结构为:

          A
        /   \
       B     C
      / \   / \
     D   E F   G
    
  • 最终堆(数组表示): [B, D, X, G, E, F, A]
    对应的树形结构为:

          B
        /   \
       D     X
      / \   / \
     G   E F   A
    

我们的任务就是找出将初始堆变为最终堆所执行的操作序列。

第一部分:推导操作序列 🔍

上一节我们明确了初始和最终的堆状态,本节中我们来看看如何通过比较两者的差异,推导出导致这一变化的具体操作序列。

通过观察,我们可以发现三个主要变化:

  1. 初始堆的根节点 A 在最终堆中出现在了底部。
  2. 初始堆中的节点 C 在最终堆中消失了。
  3. 最终堆中出现了一个新节点 X

基于堆的操作特性,我们可以得出以下初步结论:

  • A 出现在底部,意味着它曾被移除,然后又被重新插入(因为插入总是发生在底部)。
  • C 消失,意味着它被“删除最小值”操作移除了。
  • X 是新节点,意味着执行了一次“插入 X”操作。

因此,我们涉及的操作包括:removeMin (移除A), removeMin (移除C), insert X, insert A。接下来需要确定它们的顺序。

以下是推理关键步骤:

  1. 首次 removeMin:首先执行 removeMin(),移除根节点 A。此时堆顶变为 G(最后一个元素),然后 G 会下沉。
  2. C 必须成为堆顶:为了后续能移除 C,在某个时刻 C 必须位于堆顶。这通常发生在一次 removeMin 操作后,C 通过交换和下沉过程被提升到根节点位置。
  3. A 的插入时机A 必须在第二次 removeMin(即移除 C之后才被插入。因为如果 A 先被插回,那么它作为全局最小值,下一次 removeMin 会立刻移除它,而不是 C
  4. X 的插入时机与路径:观察最终堆,X 位于树的右侧。新节点 X 总是被插入到堆的底部最右侧。然而,在最终堆中,X 却出现在了左侧。要让一个节点从右侧“穿越”到左侧,唯一的方法是让它先被交换到堆顶,再下沉到左侧。这只能发生在一次 removeMin 操作中:当移除堆顶元素时,我们用堆的最后一个元素(此时是 X)替换堆顶,然后让这个 X 下沉。因此,在移除 C 时,X 必须已经是堆的最后一个节点。这意味着 insert X 必须在 removeMin(C) 之前发生

综合以上推理,我们得到最终的操作序列:

  1. removeMin() // 移除 A
  2. insert(X) // 插入 X
  3. removeMin() // 移除 C
  4. insert(A) // 重新插入 A

第二部分:推断元素关系 ⚖️

上一节我们成功推导出了操作序列,本节中我们来看看如何利用这个序列和堆的性质来推断元素之间的相对大小关系。

以下是需要判断的四组关系及其推理过程:

  • X 和 D 的关系
    在整个操作序列中,XD 从未被直接比较过。X 的移动路径(从底部右侧到顶部再下沉到左侧)并未经过与 D 的交换。因此,我们无法确定它们的大小关系。
    结论:未知。

  • X 和 C 的关系
    在操作序列的第3步,我们执行 removeMin() 移除了 C,而不是 X。根据最小堆的性质,执行 removeMin() 时,堆顶元素必须是当前堆中的最小值。既然移除的是 C 而不是 X,说明在当时 CX 小。
    结论:X > C。

  • C 和 B 的关系
    同样基于第3步的 removeMin() 操作。在移除 C 时,BC 的兄弟节点(在最终堆中 B 是根节点)。由于移除的是 C,说明 C 是当时堆的最小值,因此 C 一定小于 B
    结论:C < B。

  • X 和 G 的关系
    这个推理更为精妙。在操作序列的第2步 insert(X) 后,我们希望 X 停留在底部最右侧的位置,以便在第3步 removeMin(C) 时能被交换到堆顶。根据堆的插入算法,新节点 X 插入后,会与其父节点进行比较,如果 X 更小,则会上浮(交换)。为了阻止 X 上浮,必须确保其父节点 GX 小,这样就不需要进行交换。因此,G 必须小于 X
    结论:G < X。

总结

本节课中我们一起学习了如何逆向分析对二叉堆的操作。我们首先通过对比初始与最终堆的差异,结合“删除最小值”和“插入”操作的特性,逻辑严密地推导出了操作序列。接着,我们利用推导出的序列和堆本身的性质(如removeMin总是移除当前最小值,插入时的上浮条件等),成功地推断出了部分元素之间的大小关系。解决此类问题的核心在于深刻理解堆操作的每一步对树结构的影响,并进行细致的逻辑推理。虽然例题难度较高,但它很好地锻炼了我们对数据结构的深层理解能力。

51:图论基础概念与算法

在本节课中,我们将学习图论中的一些基础概念,并通过真/假判断题和算法设计题来加深理解。我们将探讨树的性质、深度优先搜索(DFS)和广度优先搜索(BFS)的行为,以及如何检测图中的环。

第一部分:真/假判断题

上一节我们介绍了课程概述,本节中我们来看看一系列关于图论基础概念的真/假判断题。

题目1: 如果一个具有 n 个顶点的图有 n-1 条边,那么它一定是一棵树。

答案: 错误。

解释: 树必须满足两个条件:1. 连通;2. 无环。下图展示了一个反例:该图有4个顶点和3条边,但它包含一个环(A-B-C-A)和一个孤立的节点D,因此它不满足树的条件。

A --- B
|     |
C --- D (孤立)

题目2: 在连通无向图上执行DFS时,每条边是否恰好被查看两次?

答案: 正确。

解释: 回顾DFS算法,它从一个节点开始,递归地探索其所有邻居。对于任意一条边 (U, V),我们会在从节点 U 调用DFS时查看它一次,也会在从节点 V 调用DFS时查看它一次。

题目3: 在BFS的执行过程中,队列中是否可能同时存在距离起点为2和距离起点为4的节点?

答案: 错误。

解释: BFS使用队列,并且严格按照节点到起点的距离顺序进行处理。队列始终按距离排序,并且从队首(距离最小的节点)出队。因此,在距离为2的节点全部处理完毕之前,不可能有距离为4的节点进入队列。这违反了BFS按层遍历的基本原则。

第二部分:算法设计题

在理解了图的基本性质后,本节我们将探讨一个具体的算法问题:如何在图中检测环。

问题: 设计一个算法来检测图中是否存在环。

解决方案: 使用深度优先搜索(DFS)。

算法描述:

  1. 从任意一个未访问的节点开始进行DFS。
  2. 在遍历过程中,记录已访问过的节点。
  3. 如果在探索某个节点的邻居时,发现该邻居已经在当前的DFS路径中被访问过(而不仅仅是全局访问过),则说明图中存在环。
  4. 如果DFS遍历完所有节点都未发现此类“回边”,则图中无环。

以下是该算法的核心思路演示:

情况一:有环图

A --- B
|     |
C --- D

假设从A开始DFS:A -> B -> C -> D。当从D探索邻居时,发现A已被访问(且在当前路径中),因此检测到环(A-B-C-D-A)。

情况二:无环图(树)

A --- B
|
C
|
D

从A开始DFS:A -> BA -> C -> D。在整个过程中,从未遇到一个已存在于当前路径中的邻居,因此判定无环。

算法复杂度: 该算法的时间复杂度为 O(V + E),其中V是顶点数,E是边数。在最坏情况下(即图是一棵树或无环图),我们需要访问所有的顶点和边才能得出结论。

总结与考试技巧

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

  1. 树的确切定义(连通且无环),仅凭边数 n-1 无法断定是树。
  2. DFS遍历无向图时,每条边会被访问两次。
  3. BFS队列严格按节点到起点的距离顺序维护。
  4. 利用DFS通过检测“回边”来高效判断图中是否存在环。

每周考试小贴士: 对于图论问题,最有效的策略是动手画图。正如我们在这里所做的一样,一个具体的例子虽然不能证明一个普遍性质,但它能帮助你思考反例,或者在小型图上测试你的算法逻辑。善用图表可以极大地帮助理解和解题。

祝大家在期中考试和CS61B的后续学习中一切顺利!

52:HashMap 实现指南 🗺️

在本教程中,我们将学习如何实现一个基于外部链式法(External Chaining)的哈希映射(HashMap)。我们将从核心概念入手,然后逐步讲解实现的关键步骤,包括如何处理键值对、解决哈希冲突以及动态调整哈希表大小。


核心概念与定义 📚

在开始实现之前,我们先明确几个核心概念。

  • 项(Item):代表一个包含键值对的对象。哈希映射的核心就是建立键(Key)到值(Value)的映射关系。
  • 桶(Bucket):指支撑哈希映射的底层数组。为了实现高效的(摊销)常数时间操作,我们将使用数组。每个数组位置就是一个“桶”。
  • 哈希函数(Hash Function):一个接收键(Key)作为输入,并返回一个对应桶索引(整数)的函数。它决定了对象应该被放入哪个桶。
  • 负载因子(Load Factor):表示每个桶中平均存储的项数。计算公式为:负载因子 = N / M,其中 N 是哈希映射中的项数,M 是桶的数量(即底层数组的长度)。当负载因子超过预设阈值时,我们需要对底层数组进行扩容(Resize),这是保证操作效率的关键。

工作原理与示例 🔍

上一节我们介绍了基本概念,本节中我们来看看哈希映射是如何具体工作的。

为了处理哈希冲突(即不同的键被哈希到同一个桶),我们将采用外部链式法。这意味着每个桶内部存储的是一个集合(Collection),例如链表(LinkedList),用于存放所有哈希到该位置的键值对。

下面是一个简单的操作示例。假设我们有一个初始大小为2的底层数组,负载因子扩容阈值为1.75。

以下是我们要插入的键值对序列:

  1. (“first”, 5)
  2. (“apple”, 10)
  3. (“first”, 12) // 更新已存在的键
  4. (“ya”, 3)
  5. (“capache”, 7)

操作过程如下:

  1. 插入 (“first”, 5)。哈希 “first” 得到索引0,放入桶0。
  2. 插入 (“apple”, 10)。哈希 “apple” 得到索引1,放入桶1。
  3. 插入 (“first”, 12)。哈希 “first” 得到索引0。检查桶0中的集合,发现键 “first” 已存在,于是将其对应的值更新为12。
  4. 插入 (“ya”, 3)。哈希 “ya” 得到索引1,放入桶1。
  5. 此时负载因子 = 4个项 / 2个桶 = 2.0,超过了阈值1.75,触发扩容。

扩容操作

  1. 将底层数组大小乘以一个几何因子(例如2),新数组长度为4。
  2. 重新哈希(Rehash) 所有已存在的键值对。因为数组长度改变,哈希函数的结果可能不同,项会被重新分布到新的桶中。
  3. 扩容后,负载因子变为 4/4 = 1.0,低于阈值,可以继续正常操作。


实现详解:从骨架代码到方法 🛠️

理解了工作原理后,我们进入实现部分。请务必先完整阅读实验说明(spec)并查看提供的骨架代码(skeleton code)。

关键代码结构

在骨架代码中,请特别注意:

  • buckets 实例变量:这是支撑整个数据结构的底层数组,其类型是 Collection<Node>[],即一个存储 Node(节点,代表键值对)集合的数组。
  • createBucket() 方法:任何需要实例化桶内集合(如 new LinkedList())的时候,都应调用此方法。这是为了在测试时能灵活替换不同的集合类型(如BST、栈等)。在你自己实现时,可以简单地在 createBucket() 中返回 new LinkedList<>()

核心方法实现逻辑

以下是需要实现的核心方法的伪代码思路:

1. put(K key, V value) 方法
此方法用于插入或更新键值对。

1. 计算键的哈希值,得到目标桶索引。
2. 通过索引从 `buckets` 数组获取对应的集合(桶)。
3. 检查该集合中是否已包含给定的键:
   a. 如果包含,则找到该节点,将其值更新为新值。
   b. 如果不包含,则创建一个新节点(包含此键值对)并添加到集合中。
4. 更新哈希映射的项数(size)。
5. 检查当前负载因子(size / buckets.length)是否超过阈值,如果超过,则调用扩容方法。

2. get(K key) 方法
此方法根据键获取对应的值。

1. 计算键的哈希值,得到目标桶索引。
2. 通过索引从 `buckets` 数组获取对应的集合(桶)。
3. 检查该集合中是否包含给定的键:
   a. 如果包含,则返回该键对应的值。
   b. 如果不包含,则返回 `null`。

可以看到,前两步与 put 方法的前两步逻辑完全相同。

3. containsKey(K key) 方法
此方法判断哈希映射中是否包含某个键。

1. 计算键的哈希值,得到目标桶索引。
2. 通过索引从 `buckets` 数组获取对应的集合(桶)。
3. 检查该集合中是否包含给定的键,并返回布尔结果。

其逻辑与 get 方法的前三步高度一致。

代码优化提示:由于 putgetcontainsKey 都需要“根据键找到对应桶并检查”的公共操作,可以考虑将其抽取为一个辅助方法,例如 private Collection<Node> getBucketForKey(K key),以提高代码复用性和清晰度。

其他方法

  • size():通常维护一个实例变量(如 private int size)来记录项数,此方法直接返回该变量。
  • clear():将哈希映射重置为空初始状态。这包括将 buckets 数组重新初始化为空的集合,并将 size 等实例变量重置为0。
  • keySet()remove()iterator():本实验为可选实现。如果暂不实现,可以直接抛出 UnsupportedOperationException

总结 📝

本节课中我们一起学习了如何实现一个简单的哈希映射。我们首先明确了项、桶、哈希函数和负载因子等核心概念。接着,通过一个示例了解了外部链式法如何处理冲突,以及何时及如何进行扩容以维持高效性。最后,我们详细剖析了 putgetcontainsKey 等核心方法的实现逻辑,并强调了代码复用和辅助方法的重要性。记住,良好的实现始于对骨架代码的理解和对公共逻辑的抽象。祝你顺利完成实验!

53:最短路径与最小生成树内容回顾 🧭

在本节课中,我们将要学习两个重要的图算法:用于寻找最短路径的迪杰斯特拉算法,以及用于构建最小生成树的普里姆算法克鲁斯卡尔算法。我们将通过具体的例子,一步步理解这些算法的工作原理。

本周安排 📅

本周的实验室是项目二的工作日,因此没有单独的实验作业。大家可以在实验室中专注于项目二,如果需要帮助,可以前往实验室寻求协助。项目二A部分的截止日期是本周五,即10月28日。此外,期中调查问卷将于下周二(10月25日)截止,请务必填写。

迪杰斯特拉算法 🧮

上一节我们介绍了本周的安排,本节中我们来看看最短路径问题。迪杰斯特拉算法是一种路径查找算法,它解决的问题是:给定一个源点,如何找到从该源点到图中所有其他顶点的最短路径。

无权图与广度优先搜索

首先,考虑一个所有边权重都为1的简单图。在这种情况下,从源点A到其他顶点的“最短路径”就是经过边数最少的路径。例如,从A到E的最短路径是 A -> B -> E,总共经过两条边。

此时,我们可以直接使用广度优先搜索算法。BFS从源点A开始,按照距离源点的边数(层数)依次访问顶点。访问顺序本身就代表了基于边数的最短路径。

加权图与迪杰斯特拉算法

然而,当图中的边具有不同的权重(或成本)时,情况就不同了。此时,路径的成本是路径上所有边权重的总和,而不仅仅是边的数量。

例如,考虑一个加权图。路径 A -> C -> F -> D -> B -> E 的总成本是 1 + 2 + 1 + 1 + 2 = 7,而另一条路径 A -> B -> E 的成本是 7 + 2 = 9。因此,最短路径变成了前者。BFS无法处理这种加权边的情况,这就需要迪杰斯特拉算法。

迪杰斯特拉算法的核心思想是:使用一个优先队列,根据顶点到源点的当前已知最短距离来排序顶点。算法会不断从队列中取出距离最小的顶点,并尝试通过它来更新其邻居顶点的距离。

以下是算法的关键步骤概述:

  1. 初始化:将源点距离设为0,其他所有顶点距离设为无穷大(∞)。将所有顶点加入优先队列。
  2. 循环,直到队列为空:
    a. 从优先队列中取出当前距离最小的顶点 u(即出队)。此时,到 u 的最短距离就已确定。
    b. 遍历 u 的所有邻居顶点 v
    c. 计算通过 u 到达 v 的候选距离:distance[u] + weight(u, v)
    d. 如果这个候选距离小于 v 当前记录的距离,则更新 v 的距离,并将 v(或其新距离)重新加入优先队列。

算法演示

让我们通过一个具体的加权图来演示迪杰斯特拉算法的执行过程。假设源点为A。

初始化

  • 距离:A=0, B=∞, C=∞, D=∞, E=∞, F=∞
  • 优先队列:[A(0), B(∞), C(∞), D(∞), E(∞), F(∞)]

第一步:取出A。更新邻居:

  • B: 0 + 7 = 7 < ∞,更新 B=7
  • C: 0 + 1 = 1 < ∞,更新 C=1
  • 队列排序后:[C(1), B(7), D(∞), E(∞), F(∞)]

第二步:取出C。更新邻居F:

  • F: 1 + 2 = 3 < ∞,更新 F=3
  • 队列:[F(3), B(7), D(∞), E(∞)]

第三步:取出F。更新邻居D:

  • D: 3 + 1 = 4 < ∞,更新 D=4
  • 队列:[D(4), B(7), E(∞)]

第四步:取出D。更新邻居B:

  • B: 4 + 1 = 5 < 7,更新 B=5
  • 队列:[B(5), E(∞)]

第五步:取出B。更新邻居E:

  • E: 5 + 2 = 7 < ∞,更新 E=7
  • 队列:[E(7)]

第六步:取出E。队列空,算法结束。

最终,我们得到了从源点A到所有顶点的最短距离:A=0, B=5, C=1, D=4, E=7, F=3。通过记录每个顶点是由哪个顶点更新而来的,我们就可以重构出完整的最短路径树。

A* 搜索算法 ⭐

了解了迪杰斯特拉算法后,我们来看它的一个优化变体——A搜索算法。迪杰斯特拉算法寻找从源点到所有其他点的最短路径,而A算法则专注于找到从源点到某个特定目标点的最短路径。

A*算法的核心改进在于引入了一个启发函数 h(n)。这个函数用于估计从当前顶点 n 到目标顶点的剩余距离。在优先队列中,顶点排序的依据不再是单纯的从源点出发的实际距离 g(n),而是 f(n) = g(n) + h(n),即实际距离加上启发式估计。

这意味着,A*算法会优先探索那些看起来更接近目标的路径,从而可能更快地收敛到目标点。然而,启发函数的选择至关重要:

  • 可采纳性:如果启发函数 h(n) 永远不会高估从当前点到目标点的实际成本,那么A*算法保证能找到最短路径。
  • 一致性(或单调性):一个更强的性质,能保证算法效率更高。

如果启发函数设计得不好(例如估计值远高于实际值),算法可能会绕远路,甚至可能无法找到最优解。在极端情况下,如果 h(n) = 0,A*算法就退化成了迪杰斯特拉算法。

最小生成树 🌲

前面我们学习了寻找点之间最短路径的算法,现在我们将目光转向连接所有点的另一种方式——最小生成树。最小生成树是指一个无向连通图中,连接所有顶点且总边权之和最小的子图,并且这个子图是一棵树(即无环且连通)。

割性质

普里姆和克鲁斯卡尔算法都基于一个关键原理:割性质

对于图中的任意一个割(将顶点分成两个不相交的集合),横跨这个割的最小权重的边,必然属于图的最小生成树。

普里姆算法

普里姆算法从一个任意顶点开始,逐步“生长”出一棵最小生成树。它的思路与迪杰斯特拉算法非常相似,但目标不同:迪杰斯特拉关心的是到源点的距离,而普里姆关心的是到当前已构建的树的距离。

以下是普里姆算法的步骤:

  1. 选择任意一个顶点作为起始点,加入最小生成树集合。
  2. 在所有一端在树中、另一端不在树中的边里,选择权重最小的那条边。
  3. 将这条边以及它连接的那个新顶点加入最小生成树集合。
  4. 重复步骤2和3,直到所有顶点都包含在树中。

算法演示(从顶点A开始):

  1. 树集合:{A}。候选边:A-B(7), A-C(1), A-D(5)。选最小边 A-C,加入C。
  2. 树集合:{A, C}。候选边:A-B(7), A-D(5), C-F(2)。选最小边 C-F,加入F。
  3. 树集合:{A, C, F}。候选边:A-B(7), A-D(5), F-D(1)。选最小边 F-D,加入D。
  4. 树集合:{A, C, F, D}。候选边:A-B(7), D-B(1), D-E(1)。选最小边 D-B,加入B。
  5. 树集合:{A, C, F, D, B}。候选边:B-E(2), D-E(1)。选最小边 D-E,加入E。
  6. 所有顶点都已加入,算法结束。得到的最小生成树总权重为 1+2+1+1+1 = 6

克鲁斯卡尔算法

克鲁斯卡尔算法采用了另一种“按边构建”的思路。它先将所有边按权重从小到大排序,然后依次考虑每条边,如果加入这条边不会在已形成的森林中构成环,就将其加入最小生成树。

以下是克鲁斯卡尔算法的步骤:

  1. 将图中所有边按权重升序排序。
  2. 初始化,使每个顶点自成一个独立的连通分量(通常使用并查集数据结构高效实现)。
  3. 按顺序遍历排序后的边。对于每条边 (u, v)
    a. 如果 uv 当前不在同一个连通分量中(即加入这条边不会形成环),则将这条边加入最小生成树,并合并 uv 所在的连通分量。
    b. 否则,跳过这条边。
  4. 当最小生成树中包含 |V| - 1 条边时,算法结束。

算法演示

  1. 排序后,权重为1的边有:A-C, D-F, B-D, D-E
  2. 依次加入 A-C, B-D, D-E, D-F(加入时检查均不构成环)。
  3. 接下来考虑权重为2的边:C-F, A-F, B-E
    • 加入 C-F,不构成环。
    • 加入 A-F?此时A和F已在同一连通分量中(通过A-C-F),加入会形成环,故跳过。
    • 加入 B-E?此时B和E已在同一连通分量中(通过B-D-E),加入会形成环,故跳过。
  4. 已加入 5 条边 (|V|=6),算法结束。得到的最小生成树与普里姆算法结果一致(可能因边权重相同、选择顺序不同而有结构差异,但总权重相同,均为6)。

注意:由于图中可能存在多条权重相同的边,普里姆和克鲁斯卡尔算法在具体执行时,可能会因为处理边的顺序( tie-breaking )不同而产生结构不同的最小生成树,但只要算法正确,它们的总权重一定都是最小的。

总结 📝

本节课中我们一起学习了图论中的两个核心问题及其经典算法:

  1. 最短路径问题:我们深入探讨了迪杰斯特拉算法,它利用优先队列,能有效找到从单一源点到图中所有其他顶点的最短路径。我们还了解了其优化版本A*搜索算法,它通过引入启发函数来加速向特定目标点的搜索过程。
  2. 最小生成树问题:我们学习了两种构建最小生成树的贪心算法:普里姆算法(从点出发,逐步生长)和克鲁斯卡尔算法(从边出发,按权排序并避免环)。两者都基于割性质,并能保证找到总权重最小的连接方案。

理解这些算法的步骤、异同以及它们背后的原理(如贪心选择性质),对于解决许多实际的网络优化、路径规划问题至关重要。

54:最短路径算法详解

在本节课中,我们将学习两种重要的最短路径算法:Dijkstra算法A*算法。我们将通过一个具体的图例,逐步演示它们的运行过程,并比较两者的异同。课程分为两部分:第一部分(A)专注于理解Dijkstra算法,第二部分(B)则探讨A*算法及其启发式函数。

概述

我们将分析一个给定的图,从起点 A 到终点 G 寻找最短路径。首先,我们会使用Dijkstra算法,该算法会系统地探索所有节点以找到全局最短路径。接着,我们将使用A*算法,它通过引入启发式函数来优化搜索过程,旨在更快地找到目标。


Dijkstra算法详解

上一节我们介绍了课程目标,本节中我们来看看Dijkstra算法的具体执行步骤。Dijkstra算法使用两个核心一个记录已知最短距离的表格,和一个用于选择下一个探索节点的优先队列

表格包含两列:

  • distanceTo: 记录从起点 A 到当前节点的已知最短距离。
  • edgeTo: 记录到达当前节点之前,经过的上一个节点。这用于最终重构出完整路径。

初始时,我们只知道起点 A,其 distanceTo 为0。其他所有节点的距离未知,因此标记为无穷大(∞)。优先队列最初也只包含 A

以下是算法的执行步骤:

  1. 起点初始化
    将起点 A 加入已知集合。从 A 出发,我们可以更新其邻居节点 BDE 的信息。

    • distanceTo[B] = 1 (A->B), edgeTo[B] = A
    • distanceTo[D] = 2 (A->D), edgeTo[D] = A
    • distanceTo[E] = 7 (A->E), edgeTo[E] = A
      同时,优先队列更新这些节点的距离值。
  2. 探索节点 B
    从优先队列中弹出距离最小的节点,即 B (distanceTo=1)。将其标记为已确认(绿色)。探索 B 的邻居 C

    • 路径 A->B->C 的总距离为 1 + 3 = 4。
    • 由于 distanceTo[C] 原为 ∞,现在更新为 4,edgeTo[C] = B

  1. 探索节点 D
    下一个优先队列中距离最小的节点是 D (distanceTo=2)。确认 D。探索 D 的邻居 E

    • 路径 A->D->E 的总距离为 2 + 3 = 5。
    • 这比之前直接 A->E 的距离 7 更短。因此,我们更新 E 的信息:distanceTo[E] = 5, edgeTo[E] = D。这体现了Dijkstra算法能找到更优路径的关键步骤。
  2. 探索节点 C
    接下来弹出节点 C (distanceTo=4)。确认 C。探索 C 的邻居 FG

    • distanceTo[F] = 4 + 2 = 6, edgeTo[F] = C
    • distanceTo[G] = 4 + 4 = 8, edgeTo[G] = C
  3. 探索节点 E
    弹出节点 E (distanceTo=5)。确认 E。探索 E 的邻居 G

    • 路径 A->D->E->G 的总距离为 5 + 4 = 9。
    • 这比当前已知的到 G 的距离 8 要长,因此不更新 G 的信息。算法会忽略非更优的路径。

  1. 探索节点 F 并找到最短路径
    弹出节点 F (distanceTo=6)。确认 F。探索 F 的邻居 G

    • 路径 A->B->C->F->G 的总距离为 6 + 1 = 7。
    • 这比当前到 G 的距离 8 更短。因此,更新 G 的信息:distanceTo[G] = 7, edgeTo[G] = F
  2. 算法终止与路径回溯
    当优先队列为空时,算法结束。最终表格给出了从 A 到所有节点的最短距离。

    • G 的最短距离是 7
    • 通过 edgeTo 链可以回溯出路径:edgeTo[G]=F -> edgeTo[F]=C -> edgeTo[C]=B -> edgeTo[B]=A。因此,最短路径为 A -> B -> C -> F -> G

总结:Dijkstra算法通过不断从优先队列中取出距离最小的节点进行探索和松弛操作,最终计算出从起点到所有其他节点的最短路径。它保证找到的路径是最短的,但需要遍历所有节点。


A* 算法与启发式函数

上一节我们详细了解了Dijkstra算法,本节中我们来看看A*算法如何利用启发式信息来优化搜索。A*算法与Dijkstra的主要区别在于优先队列的优先级计算方式

在A*中,节点在优先队列中的优先级(priority)不再仅仅是 distanceTo,而是:
priority = distanceFromStart + heuristicEstimate
其中 heuristicEstimate 是从当前节点到目标节点 G估计成本(图中蓝色数字)。对于目标节点 G,其启发值通常为0。

A*算法的终止条件是:当目标节点 G 从优先队列中被弹出时,算法立即结束,因为我们相信一个好的启发式函数能引导我们直接找到最短路径。

以下是A*算法的执行步骤:

  1. 起点初始化
    起点 AdistanceTo = 0,启发值 h(A)=7,因此其在优先队列中的优先级为 0 + 7 = 7。弹出 A
    更新邻居 B, D, E

    • B: distanceTo=1, h(B)=6, priority = 1+6=7
    • D: distanceTo=2, h(D)=6, priority = 2+6=8
    • E: distanceTo=7, h(E)=3, priority = 7+3=10
  2. 探索节点 D
    优先队列中优先级最低的是 D (priority=8)。弹出并确认 D。更新其邻居 E

    • 路径 A->D->E 的 distanceTo = 5
    • priority = 5 + 3 = 8 (优于之前的10,因此更新)。

  1. 探索节点 B
    下一个优先级最低的是 B (priority=7)。弹出并确认 B。更新其邻居 C

    • distanceTo[C] = 4, h(C)=3, priority = 4+3=7
  2. 探索节点 C
    弹出 C (priority=7)。确认 C。更新其邻居 FG

    • F: distanceTo=6, h(F)=1, priority = 6+1=7
    • G: distanceTo=8, h(G)=0, priority = 8+0=8

  1. 探索节点 F 并抵达目标
    弹出 F (priority=7)。确认 F。更新其邻居 G

    • 路径 A->B->C->F->G 的 distanceTo = 7
    • priority = 7 + 0 = 7。这比队列中 G 原来的优先级 8 更低。
      现在,优先队列中优先级最低的节点就是 G (priority=7)。
  2. 算法终止
    弹出 G。因为 G 是目标节点,A*算法立即终止。它没有去探索从 EG 的路径(优先级为9),从而节省了计算。
    最终路径与Dijkstra算法找到的一致:A -> B -> C -> F -> G,总距离为7。

总结:A*算法通过将“到达起点的实际成本”与“到达终点的估计成本”相加来指导搜索方向。一个好的启发式函数能有效缩小搜索范围,更快地找到目标。


启发式函数的评估:可采纳性与一致性

在上一节我们运行了A*算法,本节中我们来探讨如何判断一个启发式函数是否“好”。一个好的启发式函数必须保证A*算法能找到最短路径。这通常通过两个性质来检验:可采纳性一致性

以下是这两个性质的定义:

  • 可采纳性:对于图中任意节点 n,启发式函数值 h(n) 必须小于或等于从节点 n 到目标节点 G真实最短距离(即“乐观估计”)。
    h(n) <= trueDistance(n, G)
  • 一致性(或单调性):对于图中任意相邻的节点 vw,启发式函数需满足三角不等式。即,从 v 到目标 G 的估计值,不能大于从 vw 的实际成本加上从 wG 的估计值。
    h(v) <= dist(v, w) + h(w)
    其中 dist(v, w) 是连接 vw 的边的权重。

让我们用课程中的启发式函数来检验:

  1. 检验可采纳性

    • 对于节点 F: h(F)=1,真实最短距离 F->G=11 <= 1,成立。
    • 对于节点 D: h(D)=6,真实最短距离 D->E->G=3+4=76 <= 7,成立。
    • 对于节点 C: h(C)=3,真实最短距离 C->F->G=2+1=33 <= 3,成立。
      检查所有节点后,可发现该启发式函数满足可采纳性。
  2. 检验一致性
    我们检查几对相邻节点:

    • C 和 F: h(C)=3, dist(C,F)=2, h(F)=13 <= 2 + 1 成立。
    • A 和 E: h(A)=7, dist(A,E)=7 (直接边权重), h(E)=37 <= 7 + 3 成立。
      检查所有边后,可发现该启发式函数也满足一致性。

由于该启发式函数同时满足可采纳性一致性,因此它是一个“好”的启发式函数。使用它的A*算法保证能找到从 AG 的最短路径。


本节课总结

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

  1. Dijkstra算法:通过维护一个到起点的最短距离表和一个优先队列,系统地探索所有节点,最终找到从起点到所有节点的最短路径。其核心是每次选择当前距离最小的节点进行“松弛”操作。
  2. A*算法:在Dijkstra的基础上引入启发式函数,将 (实际成本 + 估计成本) 作为优先队列的优先级。这能有效引导搜索方向,在满足条件时更快地找到目标路径。
  3. 启发式函数的性质:为了保证A*算法找到最优解,启发式函数需要具备可采纳性(乐观估计)和一致性(满足三角不等式)。我们通过实例验证了给定启发式函数的有效性。

通过对比,我们看到Dijkstra算法是一种“盲目”但保证最优的全局搜索,而A*算法是一种“有信息指导”的、更高效的搜索,但其最优性依赖于启发式函数的质量。

55:最小生成树算法应用

在本节课中,我们将学习如何应用克鲁斯卡尔算法和普里姆算法来求解给定图的最小生成树,并探讨最小生成树的唯一性问题。

概述

我们将分析一个具体的图例,分别使用克鲁斯卡尔算法和普里姆算法来构建其最小生成树。过程中会应用特定的边选择规则(平局决胜规则),并最终讨论该图的最小生成树是否唯一。


克鲁斯卡尔算法求解

上一节我们介绍了最小生成树的概念,本节中我们来看看如何使用克鲁斯卡尔算法求解。

克鲁斯卡尔算法的核心是:反复添加不构成环的最轻边,直到所有顶点都包含在生成树中

以下是应用该算法的具体步骤:

  1. 初始时,最小生成树不包含任何顶点和边。
  2. 当前最轻的边是 A-C,权重为1。添加此边,并将顶点A和C加入生成树集合。
  3. 下一个最轻的边是 C-E,权重为2。添加此边,并将顶点E加入生成树集合。
  4. 下一个最轻的边权重为3。此时有两条候选边:C-DD-E。根据平局决胜规则(选择连接字母顺序较低顶点的边),我们选择 C-D。添加此边,并将顶点D加入集合。
  5. 下一个最轻的边是 D-E(权重3),但添加它会形成环(C-D-E),因此跳过。
  6. 下一个最轻的边权重为4。候选边为 A-BB-C。根据平局决胜规则,选择 A-B。添加此边,并将顶点B加入集合。
  7. 此时所有顶点(A, B, C, D, E)都已包含在生成树中,算法结束。

通过克鲁斯卡尔算法得到的最小生成树包含的边为:A-C, C-E, C-D, A-B


普里姆算法求解

了解了克鲁斯卡尔算法的过程后,我们再来看看普里姆算法如何从另一个角度构建最小生成树。

普里姆算法的核心是:从任意一个顶点开始,不断添加连接“已访问集合”与“未访问集合”的最轻边

以下是应用该算法的具体步骤:

  1. 从顶点 A 开始,将其加入已访问集合。
  2. 从A出发的边中,最轻的是 A-C(权重1)。添加此边,并将C加入已访问集合。
  3. 从集合 {A, C} 出发,连接未访问顶点的最轻边是 C-E(权重2)。添加此边,并将E加入集合。
  4. 从集合 {A, C, E} 出发,连接未访问顶点的最轻边权重为3。候选边为 C-DE-D。根据平局决胜规则,选择 C-D。添加此边,并将D加入集合。
  5. 从集合 {A, C, D, E} 出发,连接最后一个未访问顶点B的最轻边权重为4。候选边为 A-BC-B。根据平局决胜规则,选择 A-B。添加此边,并将B加入集合。
  6. 所有顶点均已访问,算法结束。

通过普里姆算法得到的最小生成树包含的边同样为:A-C, C-E, C-D, A-B


最小生成树的唯一性讨论

我们已经看到,在给定的平局决胜规则下,两种算法找到了相同的最小生成树。现在我们来探讨一个更一般的问题:这个图的最小生成树是唯一的吗?

观察该图,我们可以发现:

  • 存在两条权重为3的边:C-DD-E
  • 存在两条权重为4的边:A-BB-C

这意味着在构建最小生成树的过程中,当需要在相同权重的边之间做选择时,如果采用不同的平局决胜规则(例如随机选择),就可能得到不同的最小生成树。

例如:

  • 一个有效的MST可能包含 A-BC-D
  • 另一个有效的MST可能包含 B-CC-D
  • 再一个有效的MST可能包含 A-BD-E

因此,该图的最小生成树并不唯一。它存在多个有效的解,具体结果取决于算法中处理等权重边时所采用的规则。


总结

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

  1. 如何使用克鲁斯卡尔算法逐步构建最小生成树。
  2. 如何使用普里姆算法从起点扩展构建最小生成树。
  3. 在给定的特定平局决胜规则下,两种算法得出了相同的结果。
  4. 通过分析图中的等权重边,我们认识到一个图的最小生成树可能不是唯一的,其最终形态会受到边选择规则的影响。

56:常规讨论 09 问题 3 解析 🛫

在本教程中,我们将学习如何为一个算法设计问题构建解决方案。我们将把实际问题抽象成一个图论问题,并探讨如何调整图的结构以纳入特定约束条件,最终使用最短路径算法找到最优解。


问题概述与抽象

上一节我们介绍了本教程的目标。本节中,我们来看看具体的问题描述。

你的航空公司需要将一批蜂蜜从 Honeysville 运送到 Op City。飞机燃料不足以直飞,因此必须在沿途的 n 个机场中至少选择一个进行加油。加油本身耗时 1 小时。然而,如果降落的机场属于 K 个特定机场(K < n),飞机将因宵禁而被扣留总共 6 小时(加油时间包含在内)。目标是设计一个算法,找到一条能让飞机在总小时数最少的路径到达 Op City。

以下是帮助我们理解问题的关键提示:

  • n 个机场视为一个,机场之间的航线是,其权重等于从机场 A 飞到机场 B 所需的小时数。
  • 可以假设从 A 飞到 B 的时间等于从 B 飞到 A 的时间。

构建基础图模型

上一节我们明确了问题目标。本节中,我们来构建基础的图模型。

根据提示,我们首先将问题建模为一个图(Graph)。

  • 节点(Nodes):代表每一个机场,包括起点 Honeysville、终点 Op City 和沿途的 n 个机场。
  • 边(Edges):连接两个机场的航线。每条边有一个权重,代表飞行时间。
  • 无向图(Undirected Graph):由于飞行时间双向相等,这意味着图是无向的,边没有方向性。

此时,我们的图只包含了飞行时间。但问题中还有额外的约束:在 K 个特定机场停留会带来额外的 6 小时滞留。我们需要在图中表示这个成本。


整合额外约束的解决方案

上一节我们建立了只包含飞行时间的基础图。本节中,我们探讨如何将机场的滞留时间整合进图模型,以便应用最短路径算法。

核心挑战在于,滞留时间是附加在节点(机场)上的,而 Dijkstra 等经典最短路径算法通常只处理的权重。因此,我们需要对图进行改造。以下是几种可行的解决方案:

方案一:调整边权重

此方案的核心思想是将节点的成本转移到与之相连的边上。

  • 对于每个属于 K 集合的机场(即有宵禁的机场),增加所有进入该机场的边的权重。具体来说,在原有飞行时间上增加 6 小时(宵禁时间)或 5 小时(如果加油的 1 小时已单独计算,则增加额外的 5 小时)。
  • 由于原图是无向的,我们需要先将其视为有向图来处理“进入”的概念,或者统一增加连接该节点的所有边的权重。

方案二:引入节点权重

此方案允许图同时拥有边权重和节点权重。

  • 为每个节点分配一个权重:普通机场权重为 1(加油时间),K 类机场权重为 6(总滞留时间)。
  • 当算法遍历图时,路径的总成本 = 经过的所有边的权重之和 + 途径的所有节点的权重之和(起点节点的权重通常不计入,因为尚未加油)。
  • 这种方法类似于我们学过的 A* 算法 中处理启发式函数和实际成本的方式。

方案三:拆分节点

此方案通过增加节点和边来显式地表示滞留过程。

  • 对于每一个属于 K 集合的机场,将其拆分成两个节点:“到达”节点和“离开”节点。
  • 在这两个节点之间创建一条有向边,其权重等于 6 小时的滞留时间。
  • 原图中所有指向该机场的边,现在指向其“到达”节点;所有从该机场出发的边,现在从其“离开”节点出发。
  • 这样,任何需要在此机场停留的路径都必须经过这条代表滞留的高权重边,成本自然被计入。

应用最短路径算法

上一节我们讨论了如何将节点成本融入图结构。本节中,我们来看看如何使用改造后的图来找到最终答案。

无论采用上述哪种方案对图进行调整,我们最终都得到了一个所有成本(飞行时间和滞留时间)都体现在边权重上的图。此时,我们就可以运行标准的最短路径算法。

  1. 选择算法:由于所有权重均为正数,我们可以使用 Dijkstra 算法
  2. 设置起点与终点:起点设置为代表 Honeysville 的节点,终点设置为代表 Op City 的节点。
  3. 执行算法:运行 Dijkstra 算法。我们持续运行算法,直到代表 Op City 的节点从优先队列(fringe)中被弹出。这是因为当其被弹出时,算法保证我们找到了到达它的最短距离。
  4. 获取路径与时间:算法结束后,从 Op City 节点回溯到 Honeysville 节点,即可得到耗时最短的路径。到达 Op City 的最短距离值就是所需的最少总小时数。

总结与回顾

本节课中,我们一起学习了解答一个算法设计问题的完整流程。

我们从具体的物流问题出发,将其抽象为图论中的最短路径问题。我们识别出核心难点在于处理附加在节点上的成本(滞留时间),并探讨了三种主要的图模型调整方案:调整边权重引入节点权重以及拆分节点。最后,我们说明了如何在改造后的图上应用 Dijkstra 最短路径算法 来求得最终的最优路线和最少时间。

通过这个问题,我们掌握了如何将复杂约束条件编码到图数据结构中,这是解决许多实际算法问题的关键一步。

57:最短路径问题解析 🧭

在本节课中,我们将学习关于加权无向图中最短路径的几个核心概念。我们将通过分析四个判断题,来理解不同条件下最短路径算法的行为。


问题A:权重相等时BFS是否有效?

上一节我们介绍了问题的背景,本节中我们来看看第一个具体问题。

如果图中所有边的权重都相等且为正数,那么广度优先搜索(BFS)能否找到最短路径?答案是正确

核心原因
BFS算法总是能找到边数最少的路径。当所有权重相等时,我们可以将图视为无权图。此时,边数最少的路径,其总权重也必然最小,因此就是最短路径。

公式化理解
设每条边权重为常数 w,路径 P 的边数为 n,则总权重 W(P) = n * w。BFS找到边数 n 最小的路径,也就最小化了 W(P)


问题B:权重不同是否意味着最短路径唯一?

在理解了BFS在等权图中的应用后,我们接下来探讨权重与路径唯一性的关系。

如果图中所有边的权重都互不相同,那么任意两点间的最短路径是否唯一?答案是错误

反例说明
我们可以构造一个简单的反例来证明其错误性。

以下是该反例的图示描述(节点A、B、C构成三角形):

  • A --5--> C
  • A --2--> B
  • B --3--> C

在这个例子中:

  • 路径 A -> C 的权重为 5
  • 路径 A -> B -> C 的权重为 2 + 3 = **5**

虽然所有权重(2, 3, 5)互不相同,但节点A到C存在两条总权重相同的最短路径。因此,权重不同并不能保证最短路径唯一。


问题C:为所有权重增加一个常数会改变最短路径吗?

接下来,我们研究对图进行整体修改会产生什么影响。

如果为图中的每一条边的权重都增加一个相同的常数 K,最短路径会改变吗?答案是正确,最短路径可能会改变。

反例说明
考虑以下初始图:

  • A --1--> B
  • A --3--> C
  • B --1--> C

最初,最短路径 A -> CA -> B -> C,总权重为 1 + 1 = 2,小于直接路径 A -> C 的权重3。

现在,为每条边权重增加常数 K=1

  • A --2--> B
  • A --4--> C
  • B --2--> C

增加常数后:

  • 路径 A -> B -> C 的新权重为 2 + 2 = 4
  • 路径 A -> C 的新权重为 4

此时,两条路径权重相等。但关键在于,这种操作惩罚了边数更多的路径。如果常数 K 足够大,原先边数少但权重和稍大的路径,可能会成为新的唯一最短路径。


问题D:将所有权重乘以一个正常数会改变最短路径吗?

最后,我们来看另一种常见的权重变换。

如果将图中每一条边的权重都乘以一个相同的正常数 k,最短路径会改变吗?答案是错误,最短路径不会改变。

证明思路
设原图中最优(最短)路径为 P*,其权重为 W(P*)。任意其他路径 P 的权重为 W(P)。根据定义,对于所有其他路径 P,都有:
W(P*) < W(P)

将所有权重乘以正常数 k 后:

  • 新路径 P* 的权重变为 k * W(P*)
  • 新路径 P 的权重变为 k * W(P)

由于 k > 0,不等式两边同乘 k 不会改变不等号方向,因此:
k * W(P*) < k * W(P)

这意味着原最短路径 P* 在新图中仍然是权重最小的路径,即最短路径没有改变。


总结与应试技巧 📝

本节课中我们一起学习了加权无向图中最短路径的四个重要性质:

  1. 在等权图中,BFS可以找到最短路径。
  2. 边权重互不相同,不能保证最短路径唯一。
  3. 为所有权重增加一个常数,可能改变最短路径,因为它会惩罚边数多的路径。
  4. 将所有权重乘以一个正常数,不会改变最短路径。

应对此类概念题的技巧

  • 若要证明某个陈述正确,可以提供一个简要的证明草图(如问题A和D)。
  • 若要证明某个陈述错误,最有效的方法是构造一个反例(如问题B和C)。
  • 在构造反例时,应尽量使用简单的图(例如包含2-3个节点的图)来清晰地展示矛盾。

掌握这些基础概念和解题方法,将有助于你在图论相关问题中做出准确判断。祝你在后续的学习中顺利!

58:2 - 图遍历与最短路径算法详解

在本节课中,我们将学习图论中的三种基本遍历算法(DFS前序、DFS后序、BFS)以及两种重要的最短路径算法(Dijkstra算法和A*算法)。我们将通过一个具体的图例,逐步演示每种算法的执行过程,并理解它们的工作原理和适用场景。

图遍历算法:DFS与BFS

上一节我们介绍了课程背景,本节中我们来看看三种针对无权图(不关心边权重)的遍历方法。它们分别是DFS前序遍历、DFS后序遍历和广度优先搜索。

  • DFS前序遍历:记录深度优先搜索中对节点进行调用的顺序。
  • DFS后序遍历:记录深度优先搜索中从节点返回的顺序。
  • BFS:一种迭代算法,按照从起点出发的边数递增的顺序访问节点。

我们从一个具体的图例开始,起点为节点A,并在遇到多个可选节点时按字母顺序决定访问顺序。

以下是执行DFS遍历的步骤:

  1. 调用DFS(A)。
  2. 访问A的邻居B(按字母顺序先于E)。
  3. 访问B的邻居C(按字母顺序先于F)。
  4. 访问C的邻居D。
  5. 访问D的邻居G。
  6. 访问G的邻居H。
  7. H无未访问邻居,返回H(后序第1个)。
  8. 返回G(后序第2个)。
  9. 返回D(后序第3个)。
  10. 回到F,访问其未访问邻居E。
  11. E无未访问邻居,返回E(后序第4个)。
  12. 返回F(后序第5个)。
  13. 返回C(后序第6个)。
  14. 返回B(后序第7个)。
  15. 返回A(后序第8个)。

根据以上调用顺序,我们得到:

  • DFS前序:A, B, C, D, G, H, E, F
  • DFS后序:H, G, D, E, F, C, B, A

接下来,我们进行BFS遍历。BFS按与起点A的边数(距离)分组访问节点,同组内按字母顺序排序。

以下是节点到起点A的距离分组:

  • 距离0: A
  • 距离1: B, E
  • 距离2: C, F
  • 距离3: D, G
  • 距离4: H

因此,BFS访问顺序为:A, B, E, C, F, D, G, H。

最短路径算法:Dijkstra

上一节我们完成了基础的图遍历,本节中我们来看看在带权图中寻找最短路径的Dijkstra算法。Dijkstra算法用于在带权图中找到从单个起点到所有其他节点的最短路径,其访问节点的顺序是按到起点的实际距离递增的。

我们继续使用同一个图,但这次考虑边的权重,并仍以A为起点。

通过观察,我们可以直接确定从A到各节点的最短距离:

  • A: 0
  • B: 1
  • C: 3 (A->B->C)
  • E: 3 (A->E)
  • F: 4 (A->B->F)
  • D: 6 (A->B->C->D)
  • G: 7 (A->B->C->D->G)
  • H: 10 (A->B->C->D->H)

按距离从小到大(同距离按字母顺序)访问节点,得到 Dijkstra访问顺序:A, B, C, E, F, D, G, H。

所有从A出发的最短路径构成了一个最短路径树

启发式搜索:A*算法

上一节我们学习了能找到全局最短路径的Dijkstra算法,本节中我们来看看结合了启发式信息的A算法。A算法在Dijkstra的基础上,增加了一个启发函数H(n)来估计从当前节点n到目标节点的代价,从而优先探索更有希望的路径。其优先级由Cost = Distance + H决定,其中Distance是从起点到当前节点的实际距离。

我们以找到从A到G的最短路径为目标,并使用图中提供的启发值H(估计到G的代价)。

我们通过一个表格来手动执行A*算法,表格包含以下列:节点前驱节点实际距离启发值H总代价Cost。初始时,只有起点A在队列中,其Distance=0Cost=0+H(A)=9

以下是算法执行的每一步:

  1. 弹出Cost最小的节点A(Cost=9)。将其邻居B、E加入队列。
    • B: Pre=A, Distance=1, Cost=1+7=8
    • E: Pre=A, Distance=3, Cost=3+10=13
  2. 弹出Cost最小的节点B(Cost=8)。处理其未访问邻居C、F。
    • C: Pre=B, Distance=1+2=3, Cost=3+4=7
    • F: Pre=B, Distance=1+4=5, Cost=5+3=8
  3. 弹出Cost最小的节点C(Cost=7)。发现经C到F的路径更短(Distance=3+2=5 < 原5),更新F。
    • F: Pre=C, Distance=5, Cost=5+3=8 (Cost未变,但路径更新)
  4. 弹出Cost最小的节点F(Cost=8)。处理其邻居D、E。
    • D: Pre=F, Distance=5+1=6, Cost=6+1=7 (新增)
    • 到E的路径A->B->F->E距离为8,比现有A->E的3大,故不更新E。
  5. 弹出Cost最小的节点D(Cost=7)。处理其邻居G、H。
    • G: Pre=D, Distance=6+1=7, Cost=7+0=7
    • H: Pre=D, Distance=6+4=10, Cost=10+5=15
  6. 弹出Cost最小的节点G(Cost=7)。G是目标节点,算法结束

A*访问顺序为:A, B, C, F, D, G。

通过回溯前驱节点列,得到最短路径:A -> B -> C -> F -> D -> G。

本节课中我们一起学习了图的基本遍历方法(DFS前序、后序,BFS)以及两种关键的最短路径算法(Dijkstra和A)。我们通过实例演练,理解了DFS通过递归调用与返回记录顺序,BFS按层遍历,Dijkstra按实际距离贪心扩展,而A则利用启发式函数引导搜索方向以提高效率。掌握这些算法的核心思想和手动执行步骤,对于理解和应用图论算法至关重要。

59:3 - 最小生成树算法与概念

在本节课中,我们将学习最小生成树(MST)的两种基本算法——普里姆算法和克鲁斯卡尔算法,并通过一个具体问题来实践。随后,我们将探讨几个关于MST的重要概念判断题。

算法回顾

上一节我们提到了MST,本节中我们来看看两种构建MST的经典算法。

普里姆算法

普里姆算法基于切分定理。其核心思想是:在每一步,算法维护一个已包含在MST中的顶点集合。通过一个“切分”将该集合与图中其余顶点分开,然后选择连接这两个部分的最小权重边加入MST。

算法步骤简述

  1. 从任意一个顶点开始,将其加入MST集合。
  2. 在所有连接“MST集合内顶点”与“集合外顶点”的边中,选择权重最小的边。
  3. 将该边及其连接的集合外顶点加入MST集合。
  4. 重复步骤2和3,直到所有顶点都被包含。

克鲁斯卡尔算法

与普里姆算法不同,克鲁斯卡尔算法直接对边进行操作。它按权重对所有边进行排序,然后依次考虑每条边,如果加入该边不会在已形成的子图中构成环,则将其加入MST。

算法步骤简述

  1. 将图中所有边按权重从小到大排序。
  2. 初始化一个空的边集合作为MST。
  3. 按顺序检查每条边,如果将其加入当前MST边集合不会形成环,则加入。
  4. 重复步骤3,直到MST包含 V-1 条边(V为顶点数)。

问题演练

现在,我们应用这两种算法来解决一个具体问题。给定一个带权无向图,我们将分别使用普里姆算法(从顶点A开始)和克鲁斯卡尔算法找出其MST。

以下是使用普里姆算法的步骤:

  1. 起始:从顶点A开始。当前MST集合为 {A}。跨越切分(集合内 vs 集合外)的边是 A-B(6)A-G(12)。选择权重最小的边 A-B
  2. 第二步:MST集合更新为 {A, B}。候选边为 B-C(4), B-E(5), B-F(8), B-G(5), A-G(12)。选择最小边 B-C
  3. 第三步:MST集合更新为 {A, B, C}。候选边为 C-D(7), C-E(10), B-E(5), B-F(8), B-G(5), A-G(12)。选择最小边 B-E(与B-G权重相同,按字母顺序B-E优先)。
  4. 第四步:MST集合更新为 {A, B, C, E}。候选边为 E-F(2), C-D(7), C-E(10), B-F(8), B-G(5), A-G(12)。选择最小边 E-F
  5. 第五步:MST集合更新为 {A, B, C, E, F}。候选边为 C-D(7), B-G(5), A-G(12)。选择最小边 B-G
  6. 第六步:MST集合更新为 {A, B, C, E, F, G}。剩余唯一候选边为 C-D(7),将其加入。
  7. 完成:所有顶点 {A, B, C, D, E, F, G} 均被包含。最终MST包含的边及其顺序为:A-B, B-C, B-E, E-F, B-G, C-D

接下来,我们使用克鲁斯卡尔算法。以下是按权重排序后的边列表(括号内为权重),我们将依次判断是否加入:

  1. E-F (2):加入,不形成环。
  2. B-C (4):加入,不形成环。
  3. B-E (5):加入,不形成环。
  4. B-G (5):加入,不形成环。
  5. C-E (10):检查。此时顶点 B, C, E, G, F 已连通,加入 C-E 会在 B-C-E-B 路径上形成环,因此跳过
  6. A-B (6):加入,不形成环。
  7. C-D (7):加入,不形成环。此时已加入6条边 (V-1),算法终止。
  8. B-F (8), A-G (12) 等边不再考虑。

最终,克鲁斯卡尔算法得到的MST与普里姆算法结果相同,但边的加入顺序不同。

概念判断题

在实践了算法之后,我们来探讨几个关于MST性质的理论问题。

以下是三个判断题及其解析:

  1. 命题:在一个所有边权均唯一的图中,将最小权重的边的权重加1,一定会改变该图最小生成树的总权重。

    • 判断正确
    • 解析:设原最小权重边为 e,原MST为 T,新MST为 T*。分两种情况讨论:
      • 情况一:eT* 中。那么 T* 的总权重比 T 增加了1。
      • 情况二:e 不在 T* 中。由于MST必须包含 V-1 条边,且边权唯一,T* 中必然有一条不同于 e 的边 e‘ 替换了它。因此 T* 的总权重不等于 T
        无论哪种情况,新MST的总权重都发生了变化。
  2. 命题:如果图中所有边的权重都不同(唯一),那么该图只有唯一一棵最小生成树。

    • 判断正确
    • 解析:这基于切分定理。对于图的任何一个切分,跨越该切分的最小权重边必须包含在任意MST中。由于所有权重唯一,对于每个切分,“最小权重边”有且仅有一条。因此,在构建MST的每一步,要加入的边都是唯一确定的,最终只会得到一棵MST。
  3. 命题:图中任意两顶点 uv 之间的最短路径,一定包含在该图的最小生成树中。

    • 判断错误
    • 解析:MST的目标是连接所有顶点的总权重最小,而非保证任意两点间的路径权重最小(即最短路径)。反例很容易找到。考虑本课例题中的顶点 CE。在MST中,CE 的路径是 C-B-E,总权重为 4+5=9。然而,图中存在直接边 C-E,权重为 10。虽然此例中MST路径更短,但可以构造其他图,使得直接边(或非MST路径)比MST中的路径更短,从而证明最短路径不一定在MST中。更一般地说,最短路径树最小生成树是解决不同问题的两种结构,通常不相等。

总结

本节课中我们一起学习了最小生成树的核心内容。我们首先实践了普里姆算法和克鲁斯卡尔算法这两种构建MST的方法,普里姆算法基于顶点和切分定理逐步扩展,而克鲁斯卡尔算法则通过对边排序并避免环来构建。随后,我们通过三个理论问题深化了对MST性质的理解:边权唯一时MST的唯一性、特定边权变化对MST的影响,以及MST与最短路径树的区别。掌握这些算法和概念是理解和应用图论中最小生成树的基础。

60:4 - 算法设计问题解析 🚂

在本节课中,我们将学习如何解决一个算法设计问题。我们将逐步分析问题描述,理解其核心概念,并设计出满足特定时间复杂度要求的算法。通过本教程,你将掌握如何通过修改图结构来简化问题,并运用已知的图算法作为“黑盒”来求解。

问题背景

两个国家,莫斯塔德和方丹,位于虚构的世界Tva中。Tva的铁路系统可以建模为一个加权有向图,其中包含 V 个顶点和 E 条边,边的权重代表铁路的长度。旅行者希望找到从莫斯塔德到方丹的最短铁路距离。

定义集合 M 为莫斯塔德的所有城市,集合 F 为方丹的所有城市。两国之间的最短距离,就是任意城市 c_M ∈ M 到任意城市 c_F ∈ F 之间的最短距离。

对于后续的每个子问题,需要描述一个算法,在 O((V + E) log V) 时间复杂度内计算出从莫斯塔德到方丹的最短铁路距离。你可以将课堂上学到的所有图算法当作“黑盒”来调用。提示:对于某些部分,可以考虑修改图结构,使得运行一个图算法能得到与原问题等价的答案。

问题分解与解决

上一节我们介绍了问题的基本设定和目标。接下来,我们将针对三种不同的场景,逐一设计算法。

场景一:M有一个城市,F有多个城市

在这种情况下,莫斯塔德只有一个城市,而方丹有多个城市。

以下是解决此场景的步骤:

  1. 将莫斯塔德唯一的城市作为起点。
  2. 以该城市为源点,运行迪杰斯特拉算法
  3. 算法会计算出从该源点到图中所有其他顶点的最短距离。
  4. 遍历方丹集合 F 中的所有城市,从迪杰斯特拉算法的结果中找出到这些城市的最小距离,该距离即为所求答案。

算法核心distance = min(Dijkstra(source_city_M)[city] for city in F)

场景二:M有多个城市,F有一个城市

现在,莫斯塔德有多个城市,而方丹只有一个城市。

我们不能像场景一那样简单地指定一个起点。这里需要运用提示中的技巧:修改图结构。我们引入一个虚拟节点 D

以下是具体的操作步骤:

  1. 创建一个新的虚拟节点 D
  2. 从虚拟节点 D 向莫斯塔德集合 M 中的每一个城市添加一条有向边,并将这些边的权重都设置为 0。这样就得到了一个新图 G‘
  3. 以虚拟节点 D 为源点,运行迪杰斯特拉算法
  4. 算法会计算出从 D 到图中所有顶点的最短距离。由于从 DM 中任何城市的距离都是0,因此从 D 到方丹唯一城市 F 的最短距离,就等于原图中从任意 M 城市到 F 城市的最短距离。
  5. 返回 distance[D][city_F] 作为答案。

算法核心:通过添加零权重的边,将多源起点问题转化为单源起点问题。

场景三:M和F都有多个城市

这是最一般的情况,两个国家都包含多个城市。

解决思路结合了前两个场景的方法。我们仍然需要引入虚拟节点来处理多起点的问题,同时需要处理多终点的问题。

以下是解决此场景的步骤:

  1. 创建一个新的虚拟节点 D
  2. 从虚拟节点 D 向莫斯塔德集合 M 中的每一个城市添加一条有向边,并将这些边的权重都设置为 0。得到新图 G‘
  3. 以虚拟节点 D 为源点,运行迪杰斯特拉算法
  4. 算法会计算出从 D 到图中所有顶点的最短距离。
  5. 遍历方丹集合 F 中的所有城市,从迪杰斯特拉算法的结果中找出到这些城市的最小距离,即 min(distance[D][city] for city in F),该值即为最终答案。

算法核心distance = min(Dijkstra(dummy_node_D)[city] for city in F)

总结

本节课中,我们一起学习了如何为一个复杂的图论问题设计算法。我们面对了三种不同的起点和终点配置场景,并通过以下关键策略解决了问题:

  1. 在单起点、多终点时,直接运行迪杰斯特拉算法并比较终点距离。
  2. 在多起点时,引入虚拟节点并添加权重为0的边,巧妙地将多源问题转化为单源问题。
  3. 综合运用虚拟节点和最小值比较,解决了最通用的多起点、多终点问题。

所有这些算法都满足 O((V + E) log V) 的时间复杂度要求,核心在于将迪杰斯特拉算法作为可靠的基础工具进行调用和适配。

61:图与字典树

在本节课中,我们将学习两种重要的字典树和图。我们将首先了解字典树的结构与用途,然后探讨图的拓扑排序算法及其相关概念,最后回顾常见图算法的时间复杂度。

字典树(Trie)简介

上一节我们介绍了本节课的主题,本节中我们来看看字典树。字典树是一种特殊类型的树形数据结构,专门用于存储单词。这种结构特别擅长处理基于单词前缀的操作。

在字典树中,每个单词按字母逐个存储,而不是将整个单词作为一个字符串。每个字母节点指向单词中的下一个字母,作为其子节点。我们使用一个特殊的“单词结束”节点来标记某个字母是一个单词的结尾。

例如,在下图的字典树中,橙色节点表示一个单词的结束。因此,单词“cat”(C, A, T)存在于该字典树中。单词“catch”(C, A, T, C, H)也同样存在。

由于“cat”和“catch”共享共同的前缀“C-A-T”,我们无需重复存储该前缀,从而提高了存储效率。我们还可以看到该字典树包含单词“dog”(D, O, G)和“dig”(D, I, G)。同样,因为它们共享前缀“D”,我们也无需重复存储。

此字典树不包含单词“do”(D, O),因为字母“D”下的“O”节点没有橙色标记,这意味着它不是我们字典树中某个单词的结尾。我们将在本工作表的第一个问题中对字典树进行更多操作。

图的拓扑排序

在了解了字典树之后,我们转向图的算法。拓扑排序是一种仅适用于有向无环图的图算法。这意味着图中的每条边都有方向,并且图中不包含任何环。

在拓扑排序中,我们的目标是以某种顺序对顶点进行排序,使得每个顶点都排在其所有邻居之前。例如,因为节点3有一条指向节点1的边,所以在拓扑排序中,节点3应排在节点1之前。同样,因为节点2有一条指向节点3的边,节点2应排在节点3之前。

需要注意的是,对于任何给定的有向无环图,可能存在多种可能的拓扑排序结果。本幻灯片上的拓扑排序结果与上一幻灯片所示的不同,但你仍然可以看到节点2排在节点3之前,节点3排在节点1之前,因此它仍然是一个有效的拓扑排序。你可以验证所有其他节点的顺序也是正确的。

有向无环图的相关术语

当我们讨论有向无环图时,会使用一些特定术语。

以下是相关术语的定义:

  • 源节点:指没有入边(只有出边)的节点。
  • 汇节点:指没有出边的节点,与源节点相反。

图算法时间复杂度回顾

最后,我们来回顾一些图算法的时间复杂度。以下是目前讨论过的所有常见图算法的时间复杂度。

这些内容值得记录在你的备忘单上。尝试理解为什么每个算法具有屏幕上所写的时间复杂度是很有益处的。

总结

本节课中我们一起学习了字典树和图拓扑排序。我们了解了字典树如何高效存储单词,以及拓扑排序如何对有向无环图的顶点进行排序。我们还回顾了源节点、汇节点等术语以及常见图算法的时间复杂度,为后续的习题和应用打下了基础。

62:2 - 实现Trie树的最长前缀方法 🧩

在本节课中,我们将学习如何为Trie树数据结构实现一个名为 longestPrefixOf 的方法。该方法的目标是,对于一个给定的输入字符串,返回Trie树中包含的、能作为该字符串前缀的最长字符串。

问题概述与示例

问题要求我们在一个Trie类中实现 longestPrefixOf 方法。该方法将返回给定字符串在该Trie中的最长前缀。

例如,考虑右侧所示的Trie树,它包含字符串 “cry”、“crystal” 和 “trie”。如果我们尝试获取字符串 “crystal” 的最长前缀,方法应返回 “crys”。如果我们尝试获取字符串 “chris” 的最长前缀,由于Trie中只有 “cry” 与之匹配,方法应返回 “cry”。

关于代码框架的说明

本问题的骨架代码使用了一个名为 StringBuilder 的类。StringBuilder 的功能与Java中的基本字符串类似,但有一个关键区别:它不是使用加号 + 来拼接字符串,而是使用 .append() 方法。这种差异使得 StringBuilder 在频繁向字符串追加内容时效率稍高。

算法思路解析

现在是一个好时机,建议您先暂停视频,查看问题描述和骨架代码,并尝试自己思考如何解决这个问题。我将给您一些时间。

好的,希望您已经暂停视频并尝试构思了算法。接下来,我们将一起探讨解决方案。

基本思路是:我们想要遍历键(key)字符串中的所有字母,同时根据每个字符在Trie树中向下遍历。循环将持续进行,直到我们遍历完键的所有字符,或者在Trie中到达一个节点,该节点没有与下一个字符匹配的子节点。在后一种情况下,我们将提前结束循环并返回。在此过程中,我们会记录所有已成功匹配的字符,它们将构成我们的返回值。

算法逐步演示

让我们通过一个具体的例子来观察这个算法的执行过程。假设我们有右侧所示的Trie树。

我们尝试获取字符串 “crystal” 的最长前缀,期望返回 “crys”。我们将从根节点开始遍历,当前指针 curr 指向根节点。

  1. 我们获取给定键字符串的第一个字符,即字母 c
  2. 我们检查当前节点的子节点映射(curr.map)是否包含这个字符 c。因为包含,所以我们不执行 break 语句。
  3. 我们将当前指针 curr 更新为那个子节点(c节点),并将该字符 c 追加到输出结果 StringBuilder 中。此时输出为 "c"
  4. 循环继续,我们获取键中的下一个字母 r
  5. 检查c节点是否有一个包含字母 r 的子节点。因为存在,我们再次将当前指针前进到r节点,并将 r 追加到结果中。现在结果是 "cr"
  6. 我们继续这个过程:检查y是否是r节点的子节点(是),前进并追加 y,结果变为 "cry"
  7. 检查s是否是y节点的子节点(是),前进并追加 s,结果变为 "crys"
  8. 检查t是否是s节点的子节点(是),前进并追加 t,结果变为 "cryst"
  9. 当我们处理到 “crystal” 中的字母 a 时,会检查当前的t节点是否有一个包含字母 a 的子节点。因为不存在,我们将触发 if 语句中的 break,从而退出循环。
  10. 最后,方法返回我们迄今为止构建的前缀,即字符串 "cryst"

核心逻辑总结

本节课中,我们一起学习了如何为Trie树实现 longestPrefixOf 方法。其核心算法是同步遍历输入字符串和Trie树,在每一步检查当前Trie节点是否拥有与输入字符匹配的子节点。如果匹配,则继续向下遍历并记录字符;如果不匹配,则立即停止遍历。最终,所有成功匹配的字符序列就构成了最长前缀。这个方法充分利用了Trie树高效前缀查询的特性。

63:3 - 图论陈述真伪判断

在本节中,我们将学习如何判断关于图论和算法的几个陈述是否正确。我们将逐一分析每个陈述,并通过构造反例或逻辑推理来验证其真伪。


陈述一:所有边权相同的图总是有多个最小生成树

上一节我们介绍了本节的目标,本节中我们来看看第一个陈述。该陈述声称,如果一个图中所有边的权重都相同,那么它总是存在多个最小生成树。

请思考一下这个陈述的反例。如果图本身没有环,即它已经是一棵树,那么它唯一的生成树就是它自身。在这种情况下,即使所有边权相同,也只有一个最小生成树。

以下是关键点:

  • 如果图是无环的(即一棵树),则其本身是唯一的生成树。
  • 只有当图包含环时,才有可能因为边权相同而存在多个最小生成树。
  • 由于该陈述针对的是所有图,而并非总是成立,因此该陈述为

陈述二:无论使用何种启发函数,A*搜索总能返回正确的最短路径

接下来,我们分析第二个关于A搜索算法的陈述。该陈述认为,无论使用什么启发函数,A算法总能找到正确的最短路径。

回忆课程内容,A算法要能保证找到最短路径,其使用的启发函数必须满足特定条件,主要是可采纳性一致性。如果启发函数不满足这些条件,A可能无法返回正确结果。

以下是一个反例:
考虑一个简单图,节点A、B、C、D。从A到D的最短路径是 A->C->D(总成本为4)。假设我们使用一个不合理的启发函数,它严重高估了到达C的成本(例如,h(C) = 100),导致算法优先探索其他路径。最终,A*可能返回路径 A->B->D(总成本为5),而这并非最短路径。

因此,该陈述为。启发函数的质量直接影响A*算法的正确性。


陈述三:为图中每条边增加一个常数后,Dijkstra算法将返回相同的最短路径树

最后,我们探讨第三个关于Dijkstra算法的陈述。该陈述说,如果给图中的每条边权重都加上一个相同的常数值,Dijkstra算法产生的最短路径树将保持不变。

让我们思考这是否总是成立。给所有边增加相同常数,会改变不同路径之间的成本比较。对于边数较多的路径,其总成本会增加得更多,这可能导致原先非最短的路径变成新的最短路径。

以下是一个反例:
考虑一个三角形图,节点为A、B、C,边权如下:

  • AB = 1
  • BC = 1
  • AC = 5
    最初,从A到C的最短路径是 A->B->C,总成本为 1+1=2

现在,给每条边权重都加上常数2:

  • AB = 3
  • BC = 3
  • AC = 7
    此时,从A到C的最短路径变成了直接的 A->C,成本为7,因为路径 A->B->C 的成本变成了 3+3=6。最短路径树因此改变了。

所以,该陈述为。增加常数会偏袒边数更少的路径。


总结

本节课中我们一起学习了如何判断图论陈述的真伪:

  1. 边权均相同的图并非总有多个MST,反例是树状图。
  2. A*搜索的正确性依赖于启发函数,不合理的启发函数会导致错误结果。
  3. 为所有边增加常数会改变Dijkstra算法的结果,因为它改变了不同路径间的成本平衡关系。

通过构造具体的反例,我们可以有效地验证这些普遍性陈述是否正确。

64:判断二分图与DFS错误分析

在本节课中,我们将学习如何判断一个图是否为二分图,并分析一个深度优先搜索(DFS)伪代码实现中的错误。

什么是二分图?🤔

一个图是二分图,当且仅当我们可以将其所有顶点分割成两个不相交的集合 UV。其中,集合 U 中的所有顶点只与集合 V 中的顶点相连,反之亦然。

用公式描述,即对于图中的任意一条边 (u, v),都有 u ∈ U, v ∈ Vu ∈ V, v ∈ U

二分图示例

为了更直观地理解,我们来看两个例子。

以下是两个图的示例:

  • 左侧图:是二分图。因为所有标记为 U 的顶点,其邻居都只标记为 V;所有标记为 V 的顶点,其邻居都只标记为 U
  • 右侧图:不是二分图。无论将图中标记为问号的顶点分配到集合 U 还是 V,都会违反规则。如果将其分配到 U,则会出现两个 U 集合的顶点相邻;如果分配到 V,则会出现两个 V 集合的顶点相邻。

判断二分图的算法

上一节我们介绍了二分图的定义,本节中我们来看看如何用算法来判断一个图是否为二分图。

一种解决方法是使用广度优先搜索(BFS)或深度优先搜索(DFS)的变体。我们需要遍历图中的每一个节点。在遍历过程中,我们将每个节点标记为属于集合 U 或集合 V

以下是算法的基本步骤:

  1. 从任意一个未访问的节点开始遍历,将其标记为集合 U
  2. 访问其所有邻居节点,并将这些邻居节点标记为集合 V
  3. 接着,从这些邻居节点出发,继续遍历,并将其邻居节点标记为集合 U
  4. 如此反复,在遍历过程中不断切换标记的集合。
  5. 如果在遍历过程中,发现某个节点的某个邻居节点已经被标记,且标记的集合与该节点相同,则说明图中存在一条边连接了同一集合的两个顶点,该图不是二分图。

例如,在右侧的非二分图上运行此算法:我们会从左边的节点(标记为 U)开始,将其邻居(中间节点)标记为 V,再将中间节点的邻居(右边节点)标记为 U。此时,当我们检查右边节点的邻居(即左边的节点)时,会发现它已被标记为 U,从而得出存在两个相邻的 U 节点,因此该图不是二分图。

分析DFS伪代码中的错误

理解了如何判断二分图后,我们来看一个与图遍历相关的具体问题。题目提供了一个深度优先搜索(DFS)的伪代码实现,但这个实现存在一个小错误。

这个伪代码实现与课程讲义中给出的标准实现有一个关键区别:它在将邻居节点推入栈(fringe)时,就立即将其标记为已访问,而标准的DFS实现是在从栈中弹出节点时才将其标记为已访问

让我们通过一个示例图来看看这个错误会导致什么后果。

假设我们有以下简单图:A 连接 BCB 连接 D

以下是错误实现下的遍历顺序分析:

  1. 从节点 A 开始搜索,将其推入栈。
  2. 弹出 A 并访问。然后访问 A 的所有邻居 BC错误发生在这里:伪代码在将 BC 推入栈时,就立即将它们标记为已访问。
  3. 弹出栈顶元素 B 并访问。接着,尝试访问 B 的邻居 D。然而,由于 D 未被访问,将其推入栈并立即标记
  4. 弹出栈顶元素 C 并访问。C 没有未访问的邻居。
  5. 弹出栈顶元素 D 并访问。此时 D 的邻居 B 已被访问。
    因此,在此错误实现下,节点的访问顺序是:A -> B -> C -> D

然而,正确的DFS实现应该产生不同的顺序。因为在标准实现中,节点是在弹出时才被标记,所以在访问 B 时,CD 都还未被标记。正确的访问顺序应该是:A -> B -> D -> C

总结

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

  1. 判断二分图:我们学习了二分图的定义,并掌握了一种基于BFS/DFS遍历的算法。该算法通过为节点交替标记两个集合,并在遍历过程中检查是否有相邻节点被标记到同一集合,从而判断图的二分性。
  2. 分析DFS错误:我们分析了一个DFS伪代码中的常见错误——过早标记节点。通过对比错误实现与标准实现在一个具体图上的遍历顺序,我们理解了“在入栈时标记”与“在出栈时标记”这一区别对深度优先搜索遍历顺序产生的实际影响。

65:课程依赖关系与拓扑排序 📚

在本节课中,我们将学习如何分析课程之间的先修关系,并使用图算法来找到一个满足所有先修条件的课程学习顺序。我们将通过一个具体的例子,介绍如何构建依赖图,以及如何使用拓扑排序算法来解决课程安排问题。


构建课程依赖图 🗺️

上一节我们介绍了问题的背景。本节中,我们来看看如何将课程和先修条件表示为一个有向图。

每个课程被表示为一个节点。如果一门课程是另一门课程的先修课,我们就从先修课程节点画一条有向边指向后续课程节点。

以下是初始的课程先修关系:

  • CS 61A 是 CS 61B 的先修课。
  • CS 61B 是 CS 61C 的先修课。
  • CS 70 没有先修课。
  • CS 70 和 CS 61B 都是 CS 170 的先修课。
  • CS 61C 和 CS 70 都是 CS 161 的先修课。

根据这些关系,我们可以绘制出对应的依赖图。


处理新的依赖关系与循环依赖 ⚠️

现在,假设引入了一些新的先修条件。

新的依赖关系是:

  • 必须先修 CS 161,才能修 CS 170。
  • 必须先修 CS 170,才能修 CS 61C。

如果我们像之前一样,将这些新依赖关系表示为图中的有向边,会发现这些新边与原有边共同形成了一个循环。

因为图中存在循环,我们无法找到一个满足所有先修条件的学习顺序。例如,要修 CS 61C,必须先修 CS 170;要修 CS 170,必须先修 CS 161;而要修 CS 161,又必须先修 CS 61C。这就形成了一个无法打破的闭环。


应用拓扑排序算法 🔄

上一节我们看到循环依赖会导致问题。本节中,我们回到最初的先修关系图,看看如何找到一个有效的学习顺序。我们可以通过执行一个叫做拓扑排序的算法来实现。

拓扑排序的工作原理是进行深度优先遍历(DFS),并在完成访问一个节点的所有后继节点后,将该节点放入一个列表中。注意,我们不是在首次访问节点时放入,而是在其所有邻居节点都被访问完毕后放入。

算法结束时,我们将得到的列表反转,即可得到拓扑排序的结果。

让我们通过一个例子来演示这个算法的执行过程。

我们从节点 CS 61A 开始深度优先搜索,将其放入栈中。DFS 会访问 CS 61A 的邻居,首先是 CS 61B。

接着,DFS 访问 CS 61B 的所有邻居。我们选择先访问 CS 61C。

然后,访问 CS 61C 的邻居,即 CS 161。

由于 CS 161 没有邻居,访问完成,我们将其放入结果列表。

现在我们回到 CS 61C。CS 61C 没有更多未访问的邻居,因此访问完成,我们将其放入结果列表。

现在我们回到 CS 61B。CS 61B 还有一个邻居 CS 170 未访问,我们去访问它。

CS 170 没有邻居,访问完成,将其放入结果列表。

现在我们回到 CS 61B。CS 61B 的所有邻居都已访问完毕,因此将其放入结果列表。

最后回到 CS 61A,其所有邻居也已访问完毕,将其放入结果列表。

至此,我们从 CS 61A 出发可到达的所有节点都已访问,但图中还有节点 CS 70 未探索。我们继续从 CS 70 开始 DFS。

CS 70 的所有邻居(CS 170 和 CS 161)都已被访问过,因此直接将其放入结果列表。

现在,我们得到了一个后序访问列表。将其反转,就得到了一个有效的拓扑排序顺序。

在这个例子中,反转后的顺序是:CS 70, CS 61A, CS 61B, CS 170, CS 61C, CS 161。

你可以自行验证,按照这个顺序学习课程,不会违反任何先修条件。


总结 📝

本节课中,我们一起学习了如何将课程依赖关系建模为有向图。我们看到了循环依赖会导致无法找到合法的学习顺序。最后,我们详细演示了如何使用深度优先搜索和拓扑排序算法,在一个无环的依赖图中找到一个满足所有先修条件的课程学习序列。掌握这一方法对于解决任务调度、依赖管理等实际问题非常有帮助。

66:1 - Spring 2023 考试级问题 2 的运行时分析 🧮

在本节中,我们将详细分析一个特定算法的时间复杂度。我们将逐步拆解代码逻辑,计算其大O表示法,并解释每个步骤对总运行时间的影响。


概述

我们即将分析的算法旨在解决一个在N×N网格中搜索字符串的问题。其设计时间复杂度为O(N³)。接下来,我们将通过伪代码来验证这一复杂度。

算法结构与运行时拆解

上一节我们介绍了算法的基本思路,本节中我们来看看其具体的运行时构成。以下是算法核心步骤的伪代码描述:

对于网格中的每个字符 (共 N² 个):
    对于八个方向中的每一个:
        获取字符串 S
        对 S 运行 LPS(最长前缀搜索)
        从 Trie 树中移除

外层循环:遍历网格

首先,算法需要遍历整个N×N网格中的每一个字符。网格总共有 个字符,因此仅这一部分就构成了一个 O(N²) 的循环。

中层循环:检查八个方向

对于网格中的每个字符,算法需要检查其八个可能的延伸方向(上、下、左、右及四个对角线方向)。这是一个固定的常数循环,共 8 次迭代。因此,中层循环为外层循环增加了常数倍的迭代次数。

内层操作分析

现在,我们需要分析在内层循环中执行的三个主要操作各自的时间复杂度。

以下是每个操作的具体分析:

  1. 获取字符串 S:此操作沿着一个方向回溯网格并收集字母。在最坏情况下,字符串S的长度等于网格的边长 N(例如,从一边到另一边)。因此,构建字符串S是一个 O(N) 的操作。
  2. 对 S 运行 LPS (最长前缀搜索):此操作在Trie树中搜索字符串S的最长前缀。由于需要逐个字符遍历S(长度最多为N)并在Trie树中进行匹配,在最坏情况下(例如,整个S都是某个单词的前缀),需要进行 N 次比较。因此,这也是一个 O(N) 的操作。
  3. 从 Trie 树中移除:此操作与LPS搜索类似,需要沿着Trie树的路径遍历字符串S的每个字符以进行删除。其时间复杂度同样为 O(N)

由于这三个操作是顺序执行的,整个内层代码块在最坏情况下的时间复杂度是 O(N) + O(N) + O(N),简化为 O(N)

总时间复杂度计算

现在,我们将所有层次的复杂度结合起来计算总运行时间。

  • 外层循环:O(N²) 次迭代。
  • 中层循环:每层外层迭代中,有 8 次(常数)迭代。
  • 内层操作:每次中层迭代中,执行 O(N) 的工作量。

因此,总时间复杂度为:
O(N²) × 8 × O(N) = 8 × O(N³)

在大O表示法中,我们忽略常数系数。所以,算法的最终时间复杂度为 O(N³)。这与我们最初预期的复杂度约束相符。

总结

本节课中我们一起学习了如何系统地分析一个复杂算法的时间复杂度。我们通过将算法分解为嵌套循环和内部操作,逐步计算了每部分的开销,并最终得出其大O表示为 O(N³)。关键点在于识别出最耗时的操作(获取字符串、Trie树搜索/删除)其成本与网格维度 N 呈线性关系,且这些操作被嵌套在遍历整个网格(N²)的循环中,从而形成了立方级的复杂度。

67:拓扑排序算法设计与证明 🧠

在本节课中,我们将学习拓扑排序的另一种算法设计思路。我们将从一个已知的算法出发,通过证明有向无环图(DAG)的性质,逐步推导出一个新的、基于移除源节点的拓扑排序算法。整个过程将展示算法设计中的逻辑推理和分步构建思想。

拓扑排序:第一部分:已知算法回顾 🔄

上一节我们介绍了课程背景,本节中我们来看看问题的第一部分:回顾课堂上已学的拓扑排序算法。

拓扑排序的一种常见方法是:首先反转图中的所有边,然后对图进行后序遍历(Post-order Traversal)。后序遍历输出的顶点顺序的逆序,即为一个有效的拓扑排序。

该算法的时间复杂度与深度优先搜索(DFS)相同,为 O(V + E),其中 V 是顶点数,E 是边数。

拓扑排序:第二部分:DAG性质的证明 📐

在了解了基础算法后,我们需要为设计新算法奠定理论基础。本节将证明一个关键性质:每个有向无环图(DAG)都至少有一个源节点和一个汇节点。

首先明确定义:

  • 源节点:没有入边的节点。
  • 汇节点:没有出边的节点。

我们将使用反证法来证明“每个DAG至少有一个汇节点”。反证法的思路是:先假设结论不成立,然后推导出矛盾,从而证明原结论必须成立。

  1. 假设:存在一个没有汇节点的DAG。
  2. 推理:既然没有汇节点,意味着图中每个顶点都至少有一条出边。
  3. 构造路径:从任意顶点出发,沿着出边不断移动。因为每个顶点都有出边,这个过程可以无限进行下去。
  4. 发现矛盾:由于图的顶点数量 V 是有限的,在访问了 V 个不同的顶点后,下一个被访问的顶点必然是之前已经访问过的某个顶点。这就形成了一个环。
  5. 得出结论:这与我们最初的假设“该图是一个无环的有向图(DAG)”相矛盾。因此,假设不成立,原命题得证:每个DAG都至少有一个汇节点

对于“每个DAG至少有一个源节点”的证明,只需将图中所有边反向。此时,原图中的所有汇节点都变成了新图的源节点。由于DAG反向后的图依然是DAG,根据已证明的结论,新图至少有一个汇节点(即原图的源节点)。因此,原DAG也至少有一个源节点。

拓扑排序:第三部分:寻找所有源节点的算法 🔍

基于上一节证明的性质,我们知道了源节点必然存在。本节中,我们来看看如何在一个图中高效地找出所有的源节点。

算法核心是统计每个顶点的入度。以下是具体步骤:

  1. 初始化一个大小为 V 的数组 inDegree,用于记录每个顶点的入度,所有值初始为0。
  2. 遍历图的邻接表。对于每条从顶点 u 指向顶点 v 的边 (u, v),将 v 的入度 inDegree[v] 加1。
  3. 遍历 inDegree 数组,将所有入度为0的顶点加入“源节点集合”。

以下是一个简单的代码描述:

// 假设 graph 为邻接表表示的有向图
int[] inDegree = new int[V];
for (int u = 0; u < V; u++) {
    for (int v : graph[u]) {
        inDegree[v]++;
    }
}

Queue<Integer> sources = new LinkedList<>();
for (int i = 0; i < V; i++) {
    if (inDegree[i] == 0) {
        sources.add(i);
    }
}
// 此时 sources 中包含了图中所有的源节点

拓扑排序:第四部分:基于移除源节点的拓扑排序算法 🧩

现在,我们将前几部分的结论结合起来,构建新的拓扑排序算法。关键观察是:从一个DAG中移除所有当前的源节点后,剩下的子图仍然是一个DAG,并且会产生新的源节点。

以下是完整的算法步骤:

  1. 计算图中所有顶点的初始入度,并找到所有初始的源节点,将它们加入一个队列(或集合)中。
  2. 当源节点队列不为空时:
    a. 从队列中取出一个源节点 u,并将其输出(作为拓扑排序的下一个元素)。
    b. 对于 u 的每一个邻接点 v
    * 将 v 的入度减1(相当于移除边 (u, v))。
    * 如果 v 的入度因此变为0,则将 v 加入源节点队列。
  3. 当算法结束时,输出的顶点序列即为一个拓扑排序。

如果输出的顶点数量少于图中总顶点数 V,则说明图中存在环(这与DAG的前提矛盾)。

算法示例与效率分析
我们通过一个例子来演示此算法。算法的时间复杂度主要花费在初始入度计算(O(E))和后续的队列操作上。每个顶点和每条边都被处理一次,因此总时间复杂度依然是 O(V + E),与基于DFS的算法效率相同。这种方法的优势在于其思路直观,并且无需使用递归或显式地进行图反转。

总结 📝

本节课中我们一起学习了拓扑排序的算法设计过程。我们从熟悉的DFS后序遍历方法出发,通过证明DAG必包含源节点和汇节点的性质,设计并理解了一种基于不断移除源节点的新算法。这个问题的核心价值在于展示了算法设计如何从基本定义和证明出发,通过逻辑推理逐步构建出可行的解决方案。掌握这种分步推导的思维,对于解决更复杂的算法问题至关重要。

68:3 - 多重最小生成树问题解析 🧩

在本节课中,我们将学习关于图论中一个有趣的现象:一个图可能拥有多个最小生成树。我们将通过分析一个具体的考试题目,探讨在什么条件下图会拥有多个MST,并学习如何确保两种经典算法(Prim和Kruskal)总能找到相同的MST。


第一部分:多重MST的条件分析 🔍

上一节我们介绍了多重MST的概念,本节中我们来看看决定一个图是否拥有多个MST的具体条件。题目要求我们分析在给定属性下,一个无向连通图G是否“从不”、“总是”或“有时”拥有多个MST。

1. 如果所有边的权重都不同

答案:从不拥有多个MST。

解释:我们可以利用割性质来理解这一点。割性质指出,对于一个图中的任意割,如果所有跨越该割的边的权重都唯一,那么其中权重最小的跨越边必定属于某个MST。

公式if crossing edges in a cut are unique in weight -> min-weight crossing edge is in some MST

如果图G中所有边的权重都不同,那么对于图中的任何割,其所有跨越边的权重也必然不同。这意味着每个割中权重最小的跨越边是唯一确定的。因此,在构建MST时,对于每一个割,我们都必须选择那条唯一的最小权重边,没有其他选择余地。这就排除了存在多个不同MST的可能性。

2. 如果部分边的权重相同

答案:有时拥有多个MST。

以下是两种情况的具体示例:

  • 情况一:拥有多个MST的图
    考虑一个三角形图,三条边的权重分别为 1, 2, 2。运行Kruskal算法时,首先选择权重为1的边。接下来,算法可以在两个权重为2的边中任选一条,而两种选择都会产生一个有效的MST。因此,这个图拥有多个MST。

  • 情况二:只拥有一个MST的图
    考虑另一个三角形图,边的权重分别为 1, 1, 3。运行Kruskal算法时,会先加入两条权重为1的边,此时所有顶点都已连通,算法结束。权重为3的边不会被加入。在这个过程中没有出现选择歧义,因此该图只有一个唯一的MST。

3. 如果所有边的权重都相同

答案:有时拥有多个MST。

这个答案的微妙之处在于存在一个边界情况。

  • 情况一:只拥有一个MST的图(边界情况)
    如果图G本身就是一个树(即拥有n-1条边),那么它只有一个MST,就是它自身。因为要构成生成树,你必须选择图中所有的边,没有其他选择。

  • 情况二:拥有多个MST的图
    如果图G不是树(即边数大于等于顶点数),并且所有边权重相同,那么任何由n-1条边构成的生成树都是一个MST。因为所有边权重相等,所以任何生成树的总权重都相同且为最小值。因此,这样的图通常会有很多个MST。


第二部分:特定图中的MST数量极值 📊

上一节我们分析了不同权重条件下的MST数量,本节中我们来看一个更具体的问题:对于一个具有n个顶点、n条边且所有边权重相同的连通无向图G,其MST数量的最小值和最大值是多少?

核心思路:这样的图可以看作是在一棵树(n-1条边)的基础上添加了一条边。添加的这条边必然会在图中创建一个环

关键结论:在图G中,每一个可能的MST都对应于从该环中移除(即不选择)一条不同的边。因此,MST的数量就等于该环中所包含的边的数量

以下是确定最小值和最大值的推理:

  1. 确定环中边数的范围

    • 一个环至少需要3条边才能构成。
    • 在最极端的情况下,添加的那条边可能与原有树的所有边形成一个巨大的环,即环包含图中所有n条边
  2. 计算MST数量

    • 最小值:当环只有3条边时,MST的数量为 3
    • 最大值:当环包含所有n条边时,MST的数量为 n

总结:对于满足条件的图G,其MST数量的最小值是3,最大值是n


第三部分:确保算法结果一致 ⚙️

上一节我们探讨了MST的数量,本节中我们来解决一个实际问题:给定一个具有整数边权的图G,如何修改G(不能修改Prim或Kruskal算法本身),以确保这两个算法总能找到相同的MST?

解决方案的核心思想:利用第一部分第1点的结论——如果图中所有边的权重都唯一,则MST唯一。因此,我们的目标是将图G转换为一个新图G‘,使得:

  1. G‘中所有边的权重唯一
  2. G‘中的MST同时也是G中的一个MST。

具体修改方法
我们为G中的每一条边e添加一个唯一的、小于1的偏移量offset。具体步骤如下:

  1. 初始化一个变量 offset = 0
  2. 设图G的总边数为 E
  3. 遍历G中的每条边(可以按任意顺序,但通常按原权重排序遍历):
    • 将该边的新权重设置为:原权重 + offset
    • offset 增加 1 / E

代码描述

def make_edge_weights_unique(G):
    E = total_number_of_edges_in(G)
    offset = 0
    for each edge in G (sorted by original weight):
        edge.new_weight = edge.original_weight + offset
        offset += 1 / E
    return G_prime # 新的图

为什么这个方法有效?

  • 唯一性:每条边获得的偏移量(0, 1/E, 2/E, ..., (E-1)/E)都是唯一且小于1的。因此,新图G‘中所有边的权重都变得唯一。
  • 保持MST不变:由于每条边增加的偏移量都小于1,而原边权都是整数,所以边的相对大小顺序不会改变。即,如果在G中边u的权重小于边v,那么在G‘中,u的新权重仍然小于v的新权重。因此,Kruskal算法处理边的顺序不变,找到的MST也与在原图G中找到的相同。

总结 📝

本节课中我们一起学习了关于多重最小生成树的重要概念:

  1. 权重唯一性决定MST唯一性:当图中所有边权重都不同时,MST是唯一的。
  2. 环与MST数量的关系:在边权全相同的图中,MST的数量等于图中某个特定环的边数,其范围在3到n之间。
  3. 确保算法一致性的技巧:通过为每条边添加一个唯一且足够小的偏移量,可以使图中边权唯一化,从而保证Prim和Kruskal算法找到相同的(也是唯一的)MST,而不需要修改算法本身。

理解这些条件有助于我们深入把握最小生成树算法的行为以及图本身的结构特性。

69:排序算法综述

在本节课中,我们将学习几种不同的排序算法。排序是计算机科学中一个非常重要且有趣的主题,因为它是一个简单但日常应用广泛的问题,例如如何按字母顺序或数字大小排列列表。计算机需要明确的指令来完成排序,我们将探讨几种不同的排序方法及其时间复杂度。

插入排序

上一节我们介绍了排序的重要性,本节中我们来看看第一种算法:插入排序。插入排序通过遍历列表,并在必要时将元素向后交换以维持有序性。

核心过程:从数组起始位置开始,将每个元素与其左侧元素比较。如果当前元素小于左侧元素,则交换它们的位置,并继续向左比较,直到该元素处于正确位置。

以下是插入排序的步骤示例,对列表 [3, 5, 1, 2, 4] 进行升序排序:

  1. 3 开始,左侧无元素,位置正确。
  2. 55 > 3,位置正确。
  3. 11 < 5,交换得到 [3, 1, 5, 2, 4]1 继续与左侧 3 比较,1 < 3,交换得到 [1, 3, 5, 2, 4]
  4. 22 < 5,交换得到 [1, 3, 2, 5, 4]2 继续与左侧 3 比较,2 < 3,交换得到 [1, 2, 3, 5, 4]
  5. 44 < 5,交换得到 [1, 2, 3, 4, 5]

时间复杂度

  • 最坏情况:当列表完全逆序时(如 [5,4,3,2,1]),需要进行约 1+2+...+(n-1) 次交换,总时间复杂度为 O(n²)
  • 最好情况:当列表已完全有序时,只需进行一次线性扫描,时间复杂度为 Θ(n)

选择排序

了解了插入排序后,我们来看另一种直观的排序方法:选择排序。选择排序在未排序部分中反复查找最小元素,并将其放到已排序部分的末尾。

核心过程:在数组的未排序部分中进行线性扫描,找到最小元素,将其与未排序部分的第一个元素交换,然后该位置被视为已排序。

以下是选择排序的步骤示例,对列表 [3, 5, 1, 2, 4] 进行升序排序:

  1. 扫描整个数组,找到最小值 1,与第一个元素 3 交换,得到 [1, 5, 3, 2, 4]。位置0已排序。
  2. 在剩余部分 [5, 3, 2, 4] 中找到最小值 2,与第二个元素 5 交换,得到 [1, 2, 3, 5, 4]。位置0-1已排序。
  3. 在剩余部分 [3, 5, 4] 中找到最小值 3,它已在正确位置。
  4. 在剩余部分 [5, 4] 中找到最小值 4,与第四个元素 5 交换,得到 [1, 2, 3, 4, 5]
  5. 最后剩余 5,已在末尾。

时间复杂度:无论输入如何,选择排序每次都需要扫描剩余部分寻找最小值。总工作量为 n + (n-1) + ... + 1,因此其时间复杂度始终为 Θ(n²)

归并排序

前面讨论的插入和选择排序都是原地排序算法。现在我们将学习一种使用递归的算法:归并排序。归并排序采用分治策略,将列表不断二分,对子列表排序后再合并。

核心过程

  1. 分割:递归地将列表分成两半,直到每个子列表只有一个元素(自然有序)。
  2. 合并:将两个已排序的子列表像拉链一样合并成一个新的有序列表。比较两个子列表的头部元素,将较小的放入结果列表,并移动该子列表的指针。

以下是归并排序的递归合并示例,对列表 [3, 5, 1, 2, 4] 进行排序:

分割: [3,5,1,2,4] -> [3,5,1] 和 [2,4]
        [3,5,1] -> [3,5] 和 [1]
        [3,5] -> [3] 和 [5] (基例)
        [2,4] -> [2] 和 [4] (基例)
合并: 合并[3]和[5] -> [3,5]
      合并[3,5]和[1] -> 比较1和3,取1;比较3和(空),取3,5 -> [1,3,5]
      合并[2]和[4] -> [2,4]
      最后合并[1,3,5]和[2,4] -> [1,2,3,4,5]

时间复杂度:归并排序总是将列表分成两半,这会产生 log n 层递归。在每一层,合并操作需要线性时间 O(n)。因此,归并排序的时间复杂度为 Θ(n log n)

快速排序

接下来我们看看快速排序,它使用了一种独特的划分过程。快速排序选择一个“基准”元素,通过划分将小于基准的元素放在其左侧,大于基准的放在其右侧,然后对左右子列表递归排序。

核心过程(Hoare划分法)

  1. 选择基准(例如第一个元素)。
  2. 设置左指针 L(指向基准后第一个元素)和右指针 G(指向最后一个元素)。
  3. L 向右移动,直到找到大于等于基准的元素;G 向左移动,直到找到小于等于基准的元素。
  4. 交换 LG 所指的元素,然后指针各向内移动一步。
  5. 重复步骤3-4,直到 LG 指针交错。
  6. 将基准与 G 指针当前所指元素交换。此时,基准处于其最终正确位置。

以下是Hoare划分法示例,对列表 [3, 5, 1, 2, 4]3 为基准进行划分:

  1. L 指向 5 (>=3,停),G 指向 4 (>3,移动) -> 指向 2 (<=3,停)。交换 52,得到 [3, 2, 1, 5, 4]。指针移动:L指向1G指向1
  2. L1 开始 (❤️,移动) -> 指向 5 (>=3,停)。G1 (<=3,停)。指针已交错。
  3. 交换基准 3G 所指的 1,得到 [1, 2, 3, 5, 4]。划分完成,3 左侧元素都小于它,右侧元素都大于它。
  4. 递归地对子列表 [1,2][5,4] 进行快速排序。

时间复杂度

  • 平均/最好情况:如果每次划分都能将列表大致平分,递归深度为 log n,每层划分工作量为 O(n),总时间复杂度为 O(n log n)
  • 最坏情况:如果每次选择的基准都是当前子列表的最小或最大值(例如列表已有序),那么每次划分只能减少一个元素,递归深度为 n,总时间复杂度退化为 O(n²)。因此,快速排序的性能依赖于基准的选择

堆排序

最后,我们学习堆排序。堆排序首先将数组“堆化”成一个最大堆,然后反复将堆顶(最大元素)弹出并放到数组末尾,直到堆为空。

核心过程

  1. 堆化:从最后一个非叶子节点开始,以从下到上、从右到左的顺序,对每个节点执行“下沉”操作,确保其满足最大堆性质(父节点值大于子节点值)。
  2. 排序:将堆顶元素(最大值)与堆的最后一个元素交换,堆大小减一(最大值已就位)。然后对新的堆顶元素执行“下沉”操作以恢复堆性质。重复此过程。

以下是堆排序示例,对列表 [3, 5, 1, 2, 4] 进行排序(视为二叉堆的层序遍历 [3,5,1,2,4]):

  1. 构建最大堆
    • 从最后一个非叶子节点开始调整。节点 2 (值4) 和 1 (值5) 已满足。
    • 节点 0 (值3):子节点 5 更大,交换 35,得到 [5,3,1,2,4]
    • 节点 1 (新值3):子节点 4 更大,交换 34,得到 [5,4,1,2,3]。堆化完成。
  2. 排序
    • 交换堆顶 5 与末尾 3,弹出 5 到末尾,堆变为 [3,4,1,2],对 3 下沉得到 [4,3,1,2]
    • 交换堆顶 4 与末尾 2,弹出 4 到倒数第二位置,堆变为 [2,3,1],对 2 下沉得到 [3,2,1]
    • 重复此过程,最终得到有序数组 [1,2,3,4,5]

时间复杂度:构建堆的时间为 O(n)。每次弹出堆顶并下沉调整需要 O(log n),共进行 n 次。因此,堆排序的时间复杂度为 O(n log n)

排序算法的稳定性与原地性

在比较了各种排序算法后,我们还需要了解两个重要概念:稳定性和原地性。

稳定性:如果一个排序算法能保证相等元素的相对顺序在排序后保持不变,则该算法是稳定的。

  • 稳定:插入排序、归并排序。(插入排序只进行相邻交换;归并排序按顺序合并。)
  • 不稳定:选择排序、快速排序、堆排序。(它们可能进行非相邻元素的交换,破坏原有顺序。)

示例:对 [2_a, 2_b, 1] 进行稳定排序,结果应为 [1, 2_a, 2_b]。选择排序可能将 2_b 与第一个 2_a 交换,导致结果为 [1, 2_b, 2_a],破坏了稳定性。

原地性:在CS61B中,如果一个排序算法除了输入数组外,使用的额外空间小于对数级别(通常指 O(log n)),则被认为是原地排序。

  • :插入排序、选择排序、堆排序、快速排序(通常实现是原地的)。
  • :归并排序(通常需要与输入数组等大的额外空间进行合并)。

本节课中我们一起学习了五种经典的比较排序算法:插入排序、选择排序、归并排序、快速排序和堆排序。我们分析了它们的工作原理、时间复杂度的最好与最坏情况,并了解了算法的稳定性与原地性概念。掌握这些算法的特点有助于我们在不同场景下选择合适的排序工具。

70:排序算法详解 🧮

在本节课中,我们将学习四种基础排序算法:插入排序、选择排序、归并排序和堆排序。我们将通过一个具体的整数列表 [0, 4, 2, 7, 6, 1, 3, 5] 来逐步演示每种算法的执行过程,理解它们如何将无序列表变为有序。


插入排序详解 🔄

上一节我们介绍了课程概述,本节中我们来看看第一种算法——插入排序。插入排序的核心思想是:遍历数组,将每个元素“插入”到其左侧已排序部分的正确位置。具体做法是,将当前元素与其左侧邻居比较,如果它更小,则交换位置,并持续向左比较和交换,直到它不小于其左侧邻居或到达数组开头。

以下是插入排序在示例列表 [0, 4, 2, 7, 6, 1, 3, 5] 上的逐步执行过程:

  1. 从索引1(元素4)开始。4 > 0,无需交换。数组状态:[0, 4, 2, 7, 6, 1, 3, 5]
  2. 处理索引2(元素2)。2 < 4,交换2和4。交换后2 > 0,停止交换。数组状态:[0, 2, 4, 7, 6, 1, 3, 5]
  3. 处理索引3(元素7)。7 > 4,无需交换。数组状态:[0, 2, 4, 7, 6, 1, 3, 5]
  4. 处理索引4(元素6)。6 < 7,交换6和7。交换后6 > 4,停止交换。数组状态:[0, 2, 4, 6, 7, 1, 3, 5]
  5. 处理索引5(元素1)。1 < 7,交换。1 < 6,交换。1 < 4,交换。1 < 2,交换。1 > 0,停止交换。数组状态:[0, 1, 2, 4, 6, 7, 3, 5]
  6. 处理索引6(元素3)。3 < 7,交换。3 < 6,交换。3 < 4,交换。3 > 2,停止交换。数组状态:[0, 1, 2, 3, 4, 6, 7, 5]
  7. 处理索引7(元素5)。5 < 7,交换。5 < 6,交换。5 > 4,停止交换。最终排序数组:[0, 1, 2, 3, 4, 5, 6, 7]

插入排序的伪代码可以描述为:

for i in range(1, len(array)):
    j = i
    while j > 0 and array[j] < array[j-1]:
        swap(array[j], array[j-1])
        j -= 1


选择排序详解 🎯

理解了插入排序后,我们来看选择排序。选择排序的思路是:重复地在未排序部分中寻找最小(或最大)元素,并将其放到已排序部分的末尾。具体来说,第一次遍历找到全局最小元素放到位置0,第二次在剩余部分找到最小元素放到位置1,依此类推。

以下是选择排序在同一个列表上的执行步骤:

  1. 第一次遍历,找到最小元素0,它已在位置0。已排序部分:[0],未排序部分:[4, 2, 7, 6, 1, 3, 5]
  2. 在未排序部分 [4, 2, 7, 6, 1, 3, 5] 中找到最小元素1,将其与位置1的元素4交换。数组状态:[0, 1, 2, 7, 6, 4, 3, 5]
  3. 在剩余未排序部分 [2, 7, 6, 4, 3, 5] 中,最小元素2已在位置2。数组状态不变。
  4. 在剩余未排序部分 [7, 6, 4, 3, 5] 中,找到最小元素3,将其与位置3的元素7交换。数组状态:[0, 1, 2, 3, 6, 4, 7, 5]
  5. 在剩余未排序部分 [6, 4, 7, 5] 中,找到最小元素4,将其与位置4的元素6交换。数组状态:[0, 1, 2, 3, 4, 6, 7, 5]
  6. 在剩余未排序部分 [6, 7, 5] 中,找到最小元素5,将其与位置5的元素6交换。数组状态:[0, 1, 2, 3, 4, 5, 7, 6]
  7. 在剩余未排序部分 [7, 6] 中,找到最小元素6,将其与位置6的元素7交换。得到最终排序数组:[0, 1, 2, 3, 4, 5, 6, 7]

选择排序的伪代码可以描述为:

for i in range(len(array)):
    min_index = i
    for j in range(i+1, len(array)):
        if array[j] < array[min_index]:
            min_index = j
    swap(array[i], array[min_index])


归并排序详解 🤝

前面介绍的插入和选择排序比较直观,现在我们来探讨更高效的归并排序。归并排序采用“分而治之”的策略:首先递归地将列表拆分成只有一个元素的子列表(自然有序),然后反复将两个有序子列表“合并”成一个新的有序列表,直到整个列表有序。

以下是归并排序在示例列表上的执行过程,我们用树形图表示拆分与合并:

  1. 拆分阶段:将列表 [0, 4, 2, 7, 6, 1, 3, 5] 不断对半拆分。
    • 第一层:[0,4,2,7][6,1,3,5]
    • 第二层:[0,4], [2,7], [6,1], [3,5]
    • 第三层:[0], [4], [2], [7], [6], [1], [3], [5]。每个子列表都已有序。

  1. 合并阶段(自底向上)
    • 合并 [0][4]:比较0和4,得到 [0,4]
    • 合并 [2][7]:比较2和7,得到 [2,7]
    • 合并 [0,4][2,7]:比较两个子列表的头部(0和2),0小,输出0;再比较4和2,2小,输出2;再比较4和7,4小,输出4;最后输出7。得到 [0,2,4,7]
    • 同理,合并 [6][1] 得到 [1,6]
    • 合并 [3][5] 得到 [3,5]
    • 合并 [1,6][3,5]:比较1和3,输出1;比较6和3,输出3;比较6和5,输出5;输出6。得到 [1,3,5,6]
    • 最后,合并两个大子列表 [0,2,4,7][1,3,5,6]:依次比较头部,按顺序输出 [0,1,2,3,4,5,6,7]

归并排序的核心操作是合并两个有序数组,其伪代码如下:

def merge(left, right):
    result = []
    i = j = 0
    while i < len(left) and j < len(right):
        if left[i] <= right[j]:
            result.append(left[i])
            i += 1
        else:
            result.append(right[j])
            j += 1
    result.extend(left[i:])
    result.extend(right[j:])
    return result


堆排序详解 ⛰️

最后,我们学习堆排序。堆排序基于“二叉堆”这种数据结构。它首先将列表构建成一个最大堆(每个节点的值都大于或等于其子节点的值),然后重复将堆顶的最大元素与堆的最后一个元素交换并移除,再重新调整堆结构,直到堆为空,从而得到一个升序排列的列表。

我们使用一个较小的例子 [0, 6, 2, 7, 4] 来演示堆排序,以避免过于复杂的堆图。

  1. 构建最大堆
    • 初始列表可视作一个完全二叉树:根0,左孩子6,右孩子2;6的左孩子7,右孩子4。
    • 调整过程(从最后一个非叶子节点向上):
      • 节点6:其子节点7更大,交换6和7。树变为:[0, 7, 2, 6, 4]
      • 根节点0:其子节点7更大,交换0和7。树变为:[7, 0, 2, 6, 4]
      • 节点0(新的左孩子):其子节点6更大,交换0和6。最终得到最大堆:[7, 6, 2, 0, 4]。对应的数组是 [7, 6, 2, 0, 4]

  1. 排序阶段
    • 第一轮:堆顶7是最大值,与堆尾元素4交换。将7移出堆(放入数组末尾)。数组变为 [4, 6, 2, 0, 7]。调整新堆顶4:与较大的孩子6交换。堆恢复为 [6, 4, 2, 0],对应数组 [6, 4, 2, 0, 7]
    • 第二轮:堆顶6与当前堆尾0交换。移出6。数组变为 [0, 4, 2, 6, 7]。调整堆顶0:与较大的孩子4交换。堆恢复为 [4, 0, 2],对应数组 [4, 0, 2, 6, 7]
    • 第三轮:堆顶4与当前堆尾2交换。移出4。数组变为 [2, 0, 4, 6, 7]。调整堆顶2:其孩子0更小,无需交换。堆为 [2, 0]
    • 第四轮:堆顶2与堆尾0交换。移出2。数组变为 [0, 2, 4, 6, 7]
    • 第五轮:堆中只剩0,移出。最终得到排序数组 [0, 2, 4, 6, 7]

堆排序中关键的“下沉”操作伪代码如下(用于调整堆):

def sink(array, k, end):
    # k: 当前需要下沉的节点索引
    # end: 堆的边界
    while 2*k + 1 <= end: # 如果有左孩子
        j = 2*k + 1 # 左孩子索引
        if j < end and array[j] < array[j+1]: # 如果右孩子存在且更大
            j += 1 # 指向更大的孩子
        if array[k] >= array[j]:
            break
        swap(array[k], array[j])
        k = j

总结 📝

本节课中我们一起学习了四种经典的排序算法。

  • 插入排序:通过构建左侧有序序列,将新元素插入正确位置。
  • 选择排序:通过在未排序部分中反复选择最小元素来构建有序序列。
  • 归并排序:采用分治思想,先拆分再合并,是稳定的高效排序算法。
  • 堆排序:利用最大堆数据结构,通过反复移除堆顶元素来完成排序。

每种算法都有其独特的思路和适用场景,理解它们的手动执行步骤是掌握其原理的关键。

71:排序算法应用与比较

在本节课中,我们将学习如何为自定义对象(例如助教)实现比较器,并探讨不同排序算法(特别是快速排序)在不同场景下的表现。我们将分析最佳与最差枢轴选择、排序算法的稳定性,以及如何高效处理近乎有序的列表。


实现比较器

上一节我们回顾了排序的基本概念,本节中我们来看看如何为自定义的 TA 类实现一个比较器。

我们有一个 TA 类,它包含姓名和身高属性。我们的目标是实现一个 TAComparator,使其能够根据身高比较两个 TA 对象。compare 方法的规则是:当第一个对象小于第二个时返回负数,大于时返回正数,相等时返回零。

以下是 TAComparator 的实现代码:

public class TAComparator implements Comparator<TA> {
    @Override
    public int compare(TA o1, TA o2) {
        if (o1.height < o2.height) {
            return -1; // o1 比 o2 矮
        } else if (o1.height > o2.height) {
            return 1;  // o1 比 o2 高
        } else {
            return 0;  // 身高相同
        }
    }
}

快速排序中的枢轴选择

现在我们已经有了比较器,可以开始排序了。Jenny 建议使用快速排序。快速排序的核心是选择一个“枢轴”(pivot)元素来分割列表。理想情况下,每次递归都应大致将列表平分为两部分。

以下是关于最佳和最差枢轴选择的要点:

  • 最佳枢轴:近似于列表的中位数。选择这样的枢轴能使快速排序达到 O(n log n) 的最佳运行时间。
  • 最差枢轴:列表中的极值(最小值或最大值)。选择这样的枢轴会导致分割极度不平衡,使快速排序退化为 O(n²) 的运行时间。

假设助教们按身高排序后的列表是:Audit (5‘0“), Haley (5’5“), Kenneth (5’8“), Anisha (6’0“), Sri (6’3“), Eric (6’8“), Austin (6’8“), Judy (6’10“), Noah (6’11“), Sherry (7’0“)。

  • 最差枢轴:Audit(最矮)或 Sherry(最高)。选择他们会导致分割后一侧列表为空。
  • 最佳枢轴:身高接近中位数的助教,例如 Sri、Eric 或 Austin。他们能将列表大致均分。

排序算法的稳定性

Austin 注意到,尽管他和 Eric 身高相同(都是 6‘8“),并且他原本排在 Eric 后面,但快速排序后他却跑到了 Eric 前面。这是因为快速排序不是稳定的排序算法。

稳定性是指:如果排序前两个相等元素的相对顺序是 A 在 B 前,那么排序后这个顺序依然保持不变。

以下是几种排序算法的稳定性:

  • 稳定排序:插入排序、归并排序。
  • 不稳定排序:快速排序(使用 Hoare 分区时)、选择排序。

因此,如果我们想保持身高相同的助教间的原始顺序,应该使用稳定的排序算法,例如归并排序插入排序


处理近乎有序的列表

所有助教已按身高排好队,这时 Audit 和 Angelina 迟到了。我们需要以最小的代价将他们插入到正确的位置。

问题是:哪种排序算法对近乎有序的列表进行排序时,所需额外工作量最小?

以下是常见排序算法在此场景下的分析:

  • 选择排序:无论列表是否有序,总是进行 O(n²) 次比较和交换。
  • 归并排序与快速排序:它们会递归地分割列表,不关心是否已有序,通常需要 O(n log n) 时间。
  • 堆排序:最佳情况(所有元素相同)下是线性的,但在此场景下并不突出。
  • 插入排序这是最佳选择。对于一个近乎有序的列表,插入排序只需进行少量比较和交换。在最坏情况下(例如两位新助教是最矮的),将他们插入到已排序列表前端也仅需大约 2n 次操作,即 O(n) 的额外时间。

因此,使用插入排序将新元素插入已排序列表的效率最高。


总结

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

  1. 如何为自定义的引用类型实现 Comparator 接口,以便进行排序。
  2. 快速排序中枢轴选择的重要性,以及最佳和最差枢轴对算法效率的影响。
  3. 排序算法稳定性的概念,以及为何在某些场景下需要稳定排序。
  4. 当列表近乎有序时,插入排序是进行增量排序的最高效选择。

理解这些概念有助于我们在实际编程中根据数据特性和需求,选择最合适的排序算法。

72:4 - 荷兰国旗问题排序算法

在本节课中,我们将学习一个非常酷的排序问题:如何在线性时间内原地排序一个仅包含0、1、2的数组。这是一种常见的设计模式,尤其在面试中。

问题描述与思路

我们有一个仅包含0、1、2的数组,目标是设计一个线性时间复杂度的算法对其进行排序,且不使用额外数组(原地排序)。我们可以使用提供的 swap 辅助方法。

以下是算法骨架代码:

public void specialSort(int[] arr) {
    int front = 0;
    int back = arr.length - 1;
    int cur = 0;
    while (cur <= back) {
        if (arr[cur] < 1) {
            // 处理0的情况
        } else if (arr[cur] > 1) {
            // 处理2的情况
        } else {
            // 处理1的情况
        }
    }
}

上一节我们介绍了问题背景和代码框架,本节中我们来看看具体的算法直觉。

这个算法的灵感来源于快速排序中的霍尔分区法。在霍尔分区中,我们选取一个基准值,将小于基准的元素移到左边,大于基准的移到右边。由于数组中只有0、1、2三种元素,我们可以选择 1 作为基准值。这样,所有 0 都应该在 1 的左边,所有 2 都应该在 1 的右边。

我们使用三个指针:

  • front:指向下一个应该放置 0 的位置。
  • back:指向下一个应该放置 2 的位置。
  • cur:当前正在检查的元素索引。

初始时,frontcur 都指向数组开头,back 指向数组末尾。算法会逐步移动 cur 指针,根据遇到的元素值,与 frontback 指针位置的元素进行交换,直到 cur 指针超过 back 指针。

算法步骤详解

现在,让我们深入算法的三个核心分支,看看如何处理不同的元素值。

以下是处理三种情况的逻辑:

  1. 如果 arr[cur] < 1 (即元素为0)

    • 这意味着这个 0 应该被放到数组的前部。
    • 执行 swap(arr, cur, front),将当前的 0front 指针位置的元素交换。
    • 交换后,front 位置已放置好一个 0,因此将 front 指针向后移动一位:front++
    • 同时,cur 指针也向后移动一位:cur++。因为交换到 cur 位置的新元素(来自原 front 位置)只可能是 01,且其左侧区域已处理完毕。
  2. 如果 arr[cur] > 1 (即元素为2)

    • 这意味着这个 2 应该被放到数组的后部。
    • 执行 swap(arr, cur, back),将当前的 2back 指针位置的元素交换。
    • 交换后,back 位置已放置好一个 2,因此将 back 指针向前移动一位:back--
    • 关键点:此时 cur 指针不移动。因为从 back 位置交换过来的元素可能是 012,我们需要在下一轮循环中重新检查这个新元素,以确保它被放到正确的位置。
  3. 否则 (即元素为1)

    • 元素 1 本身就是基准值,它应该留在中间区域。
    • 我们不需要进行交换,只需简单地将 cur 指针向后移动一位:cur++,继续检查下一个元素。

算法局限性与原地排序

我们刚刚编写了一个线性时间的排序算法,非常高效。但为什么我们不能总是使用这种排序,即使它的时间复杂度比归并排序或快速排序更好?

答案在于其适用条件的特殊性。这种算法之所以高效,是因为它利用了数组中只有三种特定元素这一强约束条件。在现实世界中,我们面对的数据通常是任意数字,无法预先确定一个完美的“中间”基准值来如此简洁地分区。因此,该算法无法推广到通用排序场景。

最后一个问题是,我们编写的排序算法是“原地”的。什么是原地排序?我们为什么需要它?

原地排序是指算法在排序过程中只占用常数或对数级别的额外空间,而不需要创建与输入数据规模成比例的额外数组(如归并排序通常需要)。在我们的算法中,只使用了 frontbackcur 等几个固定变量,无论输入数组多大,额外空间消耗都是恒定的,因此它是原地排序。

在61B课程中,我们通常重点关注时间复杂度,但必须记住,空间复杂度同样重要。一个占用大量内存的程序会降低效率,并限制同时运行其他任务的能力。这就是我们希望算法是原地排序的原因。像选择排序、插入排序、堆排序和快速排序都可以实现为原地排序。

总结

本节课中我们一起学习了“荷兰国旗问题”的排序算法。我们掌握了如何利用三个指针(frontbackcur)在线性时间内原地对一个仅包含0、1、2的数组进行排序。我们分析了算法的三个核心分支,理解了为何在处理元素 2cur 指针不能立即前进。最后,我们探讨了该算法的局限性(仅适用于有限种类的元素),并复习了原地排序的概念及其重要性(节省内存,提升效率)。这是一个将分区思想应用于特殊约束条件的经典范例。

73:排序算法识别实战 🧩

在本节课中,我们将学习如何通过观察排序算法的中间步骤,来识别具体使用的是哪种排序算法。这是一个常见的考试题型,掌握识别技巧对理解算法运行过程至关重要。

我们将要分析的算法包括:插入排序选择排序归并排序快速排序堆排序


问题A:识别归并排序 🧩

首先,我们来看问题A。观察中间步骤时,一个非常清晰的模式是:如果将列表从中间分开,可以看到两半在最终合并之前,彼此基本不交互。

在最后一步,虽然整体趋于有序,但元素仍大致保留在各自的一半中。这是归并排序的典型特征。

归并排序分为两个主要阶段:递归分割合并。在递归阶段,数组被不断对半分割,元素停留在各自子数组中,直到最后才开始合并操作。

因此,问题A的答案是 归并排序


问题B:识别快速排序 🧩

上一节我们介绍了有明显模式的归并排序,本节中我们来看看问题B。它的模式不那么直观。当遇到这种情况时,一个有效的技巧是快速验证它是否为快速排序

题目提示快速排序以第一个元素为基准(pivot)。初始数组的第一个元素是 1429

以下是验证步骤:

  1. 第一步:所有小于 1429 的元素被移到了其左侧,大于 1429 的元素移到了右侧。这与快速排序的分区(partition)操作一致。
  2. 第二步:以 1337 为基准,其左右两侧的分区同样正确。
  3. 第三步:以 192 为基准,分区结果也正确。

通过逐步验证基准元素的位置,可以确认问题B使用的是 快速排序


问题C:识别插入排序 🧩

问题C的模式非常明显。观察发现,数组的后半部分在多个步骤中完全没有变化,只有前半部分的元素在发生交换和移动。

具体来说,将每一步与初始数组对比,末尾部分始终保持原样,仅开头的少数元素在调整位置。

这符合插入排序的特点:算法从前往后处理,将当前元素向前交换到合适位置。因此,数组前部会不断变化,而后部则要等到处理的后期才会被触及。

所以,问题C的答案是 插入排序


问题D:识别堆排序 🧩

最后,我们来看问题D。观察第二和第三步,会发现一个清晰的模式:最大元素被依次放置到数组末尾。这强烈暗示了堆排序

但第一步似乎不符合这个规律,因为此时最大元素并未在末尾。这需要我们更深入地理解堆排序的两个阶段:

  1. 建堆(Heapify):将无序数组调整成一个有效的堆(Heap)数据结构。
  2. 排序:重复从堆顶取出最大元素,并将其放到数组末尾。

因此,第一步实际上是建堆阶段,此时堆尚未开始输出最大值。随后的步骤才进入排序阶段,将最大值依次放到末尾。

所以,问题D的答案是 堆排序


总结与识别技巧 📝

本节课中我们一起学习了如何通过中间步骤识别五种经典排序算法。

以下是快速识别的小技巧:

  • 归并排序:观察是否先对半分割、各自排序,最后合并。
  • 堆排序:观察后期是否将最大元素依次交换到末尾(前期可能是建堆)。
  • 插入排序:观察数组末尾是否长期保持不变,仅前端在调整。
  • 快速排序:当模式不清晰时,可以尝试验证第一个元素(或指定基准)是否完成了正确的分区操作。

掌握这些模式特征,将帮助你更轻松地应对相关考题。祝你在数据结构的学习中一切顺利!

74:2 - 熊与床的匹配问题 🐻🛏️

在本节课中,我们将学习如何解决一个名为“熊与床”的匹配问题。我们的目标是将一组熊与一组床进行配对,并确保它们的相对顺序保持一致。同时,算法需要在平均情况下以 O(n log n) 的时间复杂度运行。

问题概述与约束

上一节我们介绍了问题的基本目标,本节中我们来看看具体的约束条件。

我们有两个数组:一个代表熊(Bears),用大写字母B表示;另一个代表床(Beds),用小写字母b表示。每个熊和床都有一个特定的“尺寸”,但这个尺寸本身没有具体含义,我们只关心它们之间的相对大小顺序。核心约束是:我们不能直接比较两只熊之间或两张床之间的大小,只能比较一只熊和一张床的大小。

例如,给定熊数组 [A, B, C, D] 和床数组 [a, b, c, d],我们需要找到正确的配对,使得配对后的熊和床具有相同的“尺寸”顺序。

算法设计思路

一个重要的提示是算法要求平均 O(n log n) 的复杂度。我们熟知的、平均复杂度为 O(n log n) 的排序算法是快速排序。这强烈暗示我们应该对快速排序进行一些修改来解决此问题。

我们不能简单地分别对熊数组和床数组排序,因为规则禁止直接比较同类物品。因此,我们需要一种方法,在排序过程中利用熊和床之间的比较来同时确定两个数组的顺序。

核心算法步骤

以下是解决该问题的“互枢轴”快速排序算法步骤。

第一步:选取枢轴熊
从熊数组中随机选取一只熊作为枢轴(Pivot Bear)。假设我们选择了熊 B

第二步:用熊枢轴划分床数组
使用选中的枢轴熊 B 来划分床数组。我们将床分为三部分:

  • 小于熊 B 的床
  • 等于熊 B 的床
  • 大于熊 B 的床

通过这一步,我们找到了与枢轴熊 B 相匹配的那张床(即“等于”部分中的那张床)。记这张床为床 a

第三步:用床枢轴划分熊数组
现在,我们使用找到的枢轴床 a 来划分熊数组。同样,将熊分为三部分:

  • 小于床 a 的熊
  • 等于床 a 的熊
  • 大于床 a 的熊

此时,枢轴熊 B 和枢轴床 a 已经正确配对,并且它们将各自数组划分成了对应的“较小”和“较大”部分。

第四步:递归处理
对“较小”部分的熊子数组和床子数组递归地应用上述算法。同样地,对“较大”部分的熊子数组和床子数组也进行递归处理。“等于”部分的单个配对已经完成,无需进一步处理。

这个过程与快速排序完全类似,只是我们同时在两个数组上操作,并用一个数组的枢轴来划分另一个数组。

复杂度分析

该算法本质上是快速排序的一个变体。每次递归调用平均将问题规模减半,并且划分操作需要线性时间 O(n)。因此,其平均时间复杂度与快速排序相同,为 O(n log n),满足题目要求。

总结与技巧

本节课中我们一起学习了如何利用修改后的快速排序算法解决“熊与床”的匹配问题。关键点在于使用“互枢轴”技术,通过交叉比较来同时排序两个数组。

对于算法设计问题,题目给出的运行时复杂度通常是重要的线索。例如,本题中 O(n log n) 的平均复杂度直接指向了快速排序或其变体的使用。在解决类似问题时,请务必关注这一提示。

75:3 - 排序算法概念复习 🧠

在本节课中,我们将一起复习2023年春季考试第13级的第2题。这是一系列关于排序算法的判断题和概念题。由于在考试中测试排序的代码题较为困难,因此这类概念性或算法设计题是常见的考察形式。我们将逐一解析这些问题,帮助你巩固对排序算法的理解。

插入排序的性能分析

上一节我们介绍了本课程的目标,本节中我们来看看第一个具体问题。

题目指出,如果一个系统运行插入排序的速度比预期快得多,我们可以得出什么结论?

根据课堂所学,插入排序在数组已排序或接近排序时运行速度非常快。这是因为插入排序的运行时间为 θ(n + k),其中 k 是逆序对的数量。如果数组接近排序,k 的值会非常小,算法运行时间基本是线性的。因此,我们可以得出结论:该数组是已排序或接近排序的,这正是插入排序运行如此之快的原因。

插入排序的最坏情况输入

接下来,题目要求我们构造一个能引发插入排序最坏情况运行时间的5整数数组。

我们刚刚讨论了插入排序的最佳情况是数组已排序。那么,最坏情况自然与之相反,即数组反向排序。这是因为在反向排序的数组中,逆序对的数量达到最大。

以下是5整数最坏情况输入的示例:

[5, 4, 3, 2, 1]

在这个数组中,每一对数字之间都存在逆序,因此逆序对数量最大,导致插入排序运行最慢。

堆排序的稳定性

现在,我们来看一个关于堆排序的简单判断题:堆排序是稳定的吗?

答案是错误。稳定性是指排序后相等元素的相对顺序保持不变。例如,如果有两个元素 20A20B,在最终排序数组中,20A 应仍在 20B 之前。

然而,堆排序由于堆操作(如上浮或下沉)可能会打乱相等元素的相对顺序。虽然这里不展开完整示例,但你可以通过运行堆排序算法验证,相等的元素在最终数组中的顺序可能发生交换。因此,堆排序不是稳定排序。

一般来说,对于稳定性,你最好记住哪些排序是稳定的,哪些不是,因为在考试中自行证明可能比较困难。

选择归并排序而非快速排序的理由

题目要求我们给出一些选择归并排序而非快速排序的理由。

在课堂上我们了解到,快速排序在经验上通常更快,但我们并不总是使用它。以下是几个归并排序可能更优的场景(本题答案不唯一):

以下是几个可能的原因:

  1. 时间复杂度保证:归并排序在任何情况下都是 θ(n log n)。而快速排序在最坏情况下可能是 θ(n²)。如果我们希望保证总是 n log n 的时间复杂度,可能会选择归并排序。
  2. 稳定性:归并排序是稳定的,而快速排序(特别是使用霍尔分区时)不是。
  3. 可并行化:归并排序可以并行执行,因为它的两个子部分在最后合并前互不干扰。如果计算机有多个核心,可以同时排序两个子部分。
  4. 链表排序:归并排序常用于链表排序。快速排序的分区操作涉及元素位置的交换,这在链表上很难实现,因为链表节点不能轻易地交换位置。

排序算法属性匹配

接下来是一个匹配题。题目给出了五种排序算法(快速排序、归并排序、选择排序、插入排序、堆排序)以及一个“以上都不是”的选项。我们需要将每个属性与匹配的算法字母连线,一个属性可能对应多个算法,也可能没有。

属性一:下界为 Ω(n log n)

这个属性意味着该排序算法即使在最好情况下,运行时间的下界也是 n log n 或更慢。

匹配的算法是:A (快速排序)、B (归并排序)、C (选择排序)

  • 快速排序和归并排序的平均和最坏情况都是 n log n 量级。
  • 选择排序无论如何都是 θ(n²),比 n log n 慢。
  • 插入排序在数组已排序时是线性的 (θ(n)),优于 n log n
  • 堆排序在堆化(线性时间)且所有元素相等时,可能不需要进行上浮或下沉操作,也可能达到接近线性的性能。

属性二:最坏情况运行时间渐进优于快速排序的最坏情况

这个问题分两步:首先确认快速排序的最坏情况是 θ(n²),然后找出哪些排序的最坏情况严格优于它。

各算法最坏情况:

  • 快速排序:θ(n²) (自身不优于自身)
  • 归并排序:θ(n log n)
  • 选择排序:θ(n²)
  • 插入排序:θ(n²)
  • 堆排序:θ(n log n)

因此,答案是:B (归并排序) 和 E (堆排序)

属性三:永远不会比较同一对元素两次

这个属性比较 tricky,最好逐个算法分析。

以下是分析过程:

  1. 快速排序 (A):选择枢轴 P,将元素与 P 比较以分到左右两侧。一旦一个元素与 P 比较过,P 在后续递归排序中就不再参与比较。左右两侧的元素只在各自子数组内比较,不会跨子数组重复比较。因此,快速排序满足条件
  2. 归并排序 (B):递归排序两半,然后合并。合并时,只比较来自两个不同子数组前端元素的大小,以决定谁先放入结果数组。来自同一子数组的元素在递归过程中已被排序,在合并时不会相互比较。因此,归并排序也满足条件
  3. 选择排序 (C):寻找最大值时需要反复遍历未排序部分,并将当前元素与已知最大值比较。在这个过程中,同一个元素可能会被多次与其他元素比较(例如,在寻找最大值的不同轮次中)。因此,选择排序不满足条件
  4. 插入排序 (D):将元素向前交换到正确位置时,只与该元素之前的已排序部分进行比较。一旦一个元素被放置好,就不会再与后面的元素进行比较。因此,插入排序满足条件
  5. 堆排序 (E):在堆化或元素上浮/下沉过程中,同一对父子节点可能会在调整堆结构的不同阶段被多次比较。因此,堆排序不满足条件

最终答案是:A (快速排序)、B (归并排序)、D (插入排序)

排序算法的最佳情况时间复杂度

最后一个部分:哪些排序算法在最好情况下能以 θ(log n) 的时间运行?

答案是以上都不是。根据课堂证明,任何基于比较的排序算法都不可能优于 θ(n)。原因很简单:为了正确排序,算法必须至少查看数组中的每一个元素一次。如果不查看所有元素,就无法知道数组的全部内容,因此不可能有运行时间优于 θ(n) 的排序算法。

总结

本节课中我们一起学习了多个关于排序算法的核心概念:

  1. 分析了插入排序在已排序或接近排序数据上的优异性能,及其最坏情况输入。
  2. 明确了堆排序不是稳定排序。
  3. 探讨了在时间复杂度保证、稳定性、可并行化和链表排序等场景下,选择归并排序而非快速排序的理由。
  4. 通过匹配题,深入理解了不同排序算法在时间复杂度下界、最坏情况性能以及比较行为上的特性。
  5. 重申了基于比较的排序算法其最好情况时间复杂度不可能优于 θ(n) 这一重要结论。

希望本次复习能帮助你更好地掌握这些排序算法的核心概念,祝你在61B课程后续学习中顺利!

76:排序算法进阶

在本节课中,我们将学习两种非比较排序算法:计数排序和基数排序。我们还将回顾快速排序的另一种分区方案。这些内容将帮助我们理解不同排序算法的适用场景和性能特点。

基数与计数排序

上一节我们介绍了比较排序,本节中我们来看看非比较排序。首先,我们需要理解“基数”的概念。

基数可以被视为一个数字系统的基础,或者一个可供选择的字母表或数字集合。例如,英文字母表的基数是26,阿拉伯数字的基数是10。基数本质上是一组不同的元素。

基数排序通过使用计数排序,每次按一个数位对列表进行排序。这与我们之前学习的比较排序不同,比较排序直接比较列表中的元素。

为了建立直观理解,我们先看一个计数排序的例子。

假设我们要排序的列表是 [9, 2, 1, 0, 9]

如果我们使用比较排序(例如选择排序),我们会直接比较数组中的元素来找到最小值并交换。

计数排序则不同。它依赖于元素的基数。在这个例子中,我们只有一位数,所以基数(或字母表)是数字0到9,共10个可能的值。

使用计数排序时,我们需要一个计数数组。

计数数组的长度等于基数大小 R。这样,计数数组为每一个可能出现的不同元素预留了一个位置。因为我们的数字范围是0-9,所以计数数组长度为10。

接下来,我们遍历待排序数组中的每个数字,统计每个数字出现的次数。

以下是统计过程:

  • 看到数字9,在计数数组索引9的位置加1。
  • 看到数字2,在索引2的位置加1。
  • 看到数字1,在索引1的位置加1。
  • 看到数字0,在索引0的位置加1。
  • 再次看到数字9,在索引9的位置再加1,变为2。

最终,计数数组 counts 表示原始数组中每种元素的数量。从左到右读,它告诉我们有1个0,1个1,1个2,0个3...以及2个9。

得到计数数组后,我们遍历它,并根据每个索引(代表数字)的计数值,将相应数量的该数字放入最终的有序数组中。

排序过程如下:

  • 索引0计数值为1,放入一个0。
  • 索引1计数值为1,放入一个1。
  • 索引2计数值为1,放入一个2。
  • 索引3到8计数值为0,不放入任何元素。
  • 索引9计数值为2,放入两个9。

这样我们就得到了有序数组 [0, 1, 2, 9, 9]

计数排序能够工作的关键原因在于,我们预先知道数字的可能取值(0-9),并且这些值本身具有固有的顺序(0<1<2<...<9)。计数数组从左到右的遍历自然遵循了这个顺序。

在进入基数排序(本节课的核心)之前,我们先分析一下计数排序的运行时复杂度。

计数排序的运行时取决于两个因素:

  1. 我们必须遍历待排序数组中的 n 个元素,进行统计。
  2. 之后,我们必须遍历长度为 R(基数大小)的计数数组,来构建有序数组。

因此,计数排序的运行时复杂度为 Θ(n + R)

基数排序

现在,我们将计数排序的概念扩展到基数排序。基数排序是计数排序的一种,它通过从最低位到最高位(或相反)逐位排序数字来工作。

LSD基数排序 代表“最低有效位优先”。这意味着我们首先只根据数字的最低位(个位)进行排序,然后依次处理更高位。

例如,对于数字列表 [120, 923, 111, 014, 312],在LSD排序的第一轮,我们只关心每个数字的个位(用绿色下划线标出),完全忽略更高位。

LSD基数排序的一般运行时复杂度公式为 Θ(w * (n + R))

  • w 代表列表中最长键的宽度(即最大数字的位数)。在上例中,所有数字都是3位,所以 w = 3
  • n 是待排序元素的数量。
  • R 是基数大小。对于十进制数字,R = 10

需要指出的是,LSD基数排序本质上是多次计数排序(每次处理一位)。我们使用 w 是因为对于像23和155这样的数字,我们会进行“左填充”(将23视为023),使所有数字宽度一致,以便逐位处理。

MSD基数排序 代表“最高有效位优先”。它与LSD类似,但是从最高位开始向最低位排序。

同样以列表 [120, 923, 111, 014, 312] 为例,在MSD排序的第一轮,我们只关心每个数字的百位(用绿色下划线标出)。

MSD基数排序的运行时复杂度表示为 O(w * (n + R)),使用大O符号。

这与LSD使用大Θ符号不同。原因是MSD排序可以提前退出

例如,如果一个列表中的数字在最高位上已经各不相同(例如百位分别是1, 2, 3, 5, 6),那么经过第一轮排序后,整个列表的顺序就已经确定,无需再比较后续低位。因此,其实际运行时间可能优于最坏情况 w * (n + R)。而LSD排序由于从最低位开始,无法利用这种特性提前退出,必须处理所有 w 位。

快速排序分区方案回顾

现在让我们转换一下话题,回顾一下快速排序。上周我们讨论了快速排序,但没有深入探讨Hoare分区之外的其他方案。

三路扫描分区 是一种简单但不是“原地”的分区方法,不过它是稳定的。它围绕一个枢轴将数组分为三部分:小于枢轴的元素、等于枢轴的元素和大于枢轴的元素。

该技术通常需要创建一个单独的数组。操作步骤如下:

  1. 第一遍扫描,收集所有小于枢轴的元素。
  2. 第二遍扫描,收集所有等于枢轴的元素。
  3. 第三遍扫描,收集所有大于枢轴的元素。

例如,对数组 [3, 1, 2, 5, 4] 进行三路扫描分区,选择第一个元素 3 作为枢轴。

  • 小于3的元素:[1, 2]
  • 等于3的元素:[3]
  • 大于3的元素:[5, 4]

分区后得到 [1, 2, 3, 5, 4]。然后递归地对左子列表 [1, 2] 和右子列表 [5, 4] 进行快速排序。

Hoare分区 是一种不稳定但“原地”的分区算法。它使用两个指针,分别从数组左右两端开始(跳过枢轴),向中间移动。

步骤如下:

  1. 左指针 L 寻找不小于枢轴的元素(即大于或等于)。
  2. 右指针 G 寻找不大于枢轴的元素(即小于或等于)。
  3. 当两个指针都停下时(即 L 指向不小于枢轴的元素,G 指向不大于枢轴的元素),交换它们所指的元素。
  4. 指针继续向中间移动,重复步骤1-3,直到两指针交错
  5. 当指针交错后,将枢轴与原右指针所在位置的元素交换。

以同样的数组 [3, 1, 2, 5, 4] 为例,枢轴为3。

  • 初始化:L 在索引1(元素1),G 在索引4(元素4)。
  • L 右移:1<3,继续;2<3,继续;5>=3,停在索引3(元素5)。
  • G 左移:4>3,继续;5>3,继续;遇到 L 指针,交错。
  • 指针交错后,交换枢轴(3)与原右指针 G 当前位置的元素(2)。结果为 [2, 1, 3, 5, 4]

然后递归地对 [2, 1][5, 4] 进行快速排序。

排序算法总结

最后一张幻灯片总结了比较排序算法(实际上来自第13次讨论),并添加了“是否原地”一列。

需要快速记住:比较排序与计数排序不同。比较排序直接比较列表中的元素,而计数排序依赖于一个具有固有顺序的预定义基数集。

在本课程中,我们将“原地”定义为算法占用小于或等于对数级的额外空间。

以下是常见比较排序的总结:

算法 最佳情况 平均情况 最差情况 是否原地 是否稳定
插入排序 Θ(n) Θ(n²) Θ(n²)
选择排序 Θ(n²) Θ(n²) Θ(n²)
堆排序 Θ(n log n) Θ(n log n) Θ(n log n)
归并排序 Θ(n log n) Θ(n log n) Θ(n log n) 通常否
快速排序 (Hoare) Θ(n log n) Θ(n log n) Θ(n²)

一个重要结论是:比较排序在平均情况下无法快于 Θ(n log n)

原因简述是:每次比较最多能排除一半的可能排列。深入研究可能需要学习CS170等后续课程。

这与计数排序形成对比。例如,LSD基数排序的复杂度是 Θ(w * (n + R))。如果基数 R 和宽度 w 都很小,那么基数排序可能达到接近线性的时间复杂度 Θ(n)

因此,在某些特定场景下(如待排序键的范围已知且有限),可能会选择计数/基数排序而非比较排序,反之亦然。我们将在练习中进一步讨论其优缺点。

本节课中我们一起学习了基数排序和计数排序的原理与复杂度,回顾了快速排序的两种分区方案,并对比了比较排序与非比较排序的根本区别和性能边界。理解这些概念有助于在实际问题中选择最合适的排序工具。

77:快速排序与三路分区

在本节课中,我们将学习快速排序算法,特别是其“三路分区”的实现方式。我们将通过一个具体的例子,逐步演示排序过程,并分析算法在不同情况下的时间复杂度。最后,我们将探讨如何优化快速排序以避免最坏情况。


三路分区快速排序步骤演示

我们被要求展示对以下列表运行快速排序的步骤,选择第一个元素作为基准,并使用三路分区法。请注意,这里使用的是非原地分区方案,这有助于我们更直观地理解过程。

初始列表为:[18, 7, 11, 4, 22, 18, 34, 99]

第一轮分区

首先,我们选择第一个元素 18 作为基准。三路分区需要扫描列表三次:

  1. 第一次扫描,找出所有小于基准的元素。
  2. 第二次扫描,找出所有等于基准的元素。
  3. 第三次扫描,找出所有大于基准的元素。

以下是具体步骤:

  • 小于基准的元素:扫描列表,发现 7114 小于 18。我们将它们放入新数组的前部。
  • 等于基准的元素:扫描列表,发现基准 18 和另一个 18。我们将这两个 18 放入新数组的中部。
  • 大于基准的元素:扫描列表,发现 223499 大于 18。我们将它们放入新数组的后部。

经过第一轮分区后,我们得到以下数组:
[7, 11, 4, 18, 18, 22, 34, 99]

此时,基准 18 位于中间。所有小于 18 的元素在其左侧,所有大于 18 的元素在其右侧。然而,左侧的子数组 [7, 11, 4] 和右侧的子数组 [22, 34, 99] 内部并未排序。

递归处理左侧子数组

快速排序是递归算法,接下来我们需要对左侧和右侧的子数组分别进行排序。我们先处理左侧子数组 [7, 11, 4]

选择第一个元素 7 作为新的基准,并对这个子数组进行三路分区:

  • 小于基准的元素:扫描子数组,发现 4 小于 7
  • 等于基准的元素:扫描子数组,发现基准 7
  • 大于基准的元素:扫描子数组,发现 11 大于 7

分区后得到:[4, 7, 11]

现在,基准 7 的左侧只有一个元素 4,右侧只有一个元素 11。由于每个子数组仅包含一个元素,它们自然是有序的,无需进一步递归。至此,左侧子数组排序完成。

处理基准与右侧子数组

左侧子数组排序完成后,我们可以将已排序的左侧部分 [4, 7, 11]、基准部分 [18, 18] 组合起来。

接下来,我们需要递归处理原始列表分区后右侧的子数组 [22, 34, 99]

选择第一个元素 22 作为基准,并进行三路分区:

  • 小于基准的元素:扫描子数组,未发现小于 22 的元素。
  • 等于基准的元素:扫描子数组,发现基准 22
  • 大于基准的元素:扫描子数组,发现 3499 大于 22

分区后得到:[22, 34, 99]

基准 22 的右侧子数组 [34, 99] 包含多于一个元素,需要继续递归排序。

递归处理右侧子子数组

对子数组 [34, 99] 进行排序。选择第一个元素 34 作为基准,并进行三路分区:

  • 小于基准的元素:扫描子数组,未发现小于 34 的元素。
  • 等于基准的元素:扫描子数组,发现基准 34
  • 大于基准的元素:扫描子数组,发现 99 大于 34

分区后得到:[34, 99]。由于每个分区仅包含一个元素,排序完成。

最终排序结果

将所有已排序的部分组合起来,得到最终的排序结果:
[4, 7, 11, 18, 18, 22, 34, 99]


霍纳分区法的时间复杂度分析

上一节我们演示了三路分区的过程,本节中我们来看看另一种分区方法——霍纳(Hoare)分区法的时间复杂度。问题是:对于 n 个元素,快速排序使用霍纳分区法的最佳和最坏运行时间是多少?给定两个列表 [4,4,4,4,4][1,2,3,4,5](每次选择第一个元素作为基准),哪个列表会带来更好的运行时性能?

首先,回顾快速排序的时间复杂度:

  • 最佳情况O(n log n)。当每次分区都能将列表大致均匀地分成两部分时达到。
  • 最坏情况O(n^2)。当每次分区都极不均衡(例如,每次基准都是最小或最大元素)时达到。

现在,我们分析两个列表在霍纳分区下的表现。

列表一:全重复值 [4,4,4,4,4]

霍纳分区使用左右两个指针(L 和 G)。L 指针寻找严格小于基准的元素,G 指针寻找严格大于基准的元素。指针向中间移动,遇到不喜欢的元素则停止,然后交换两个指针所指元素,并继续移动。

对于全为 4 的列表,选择第一个 4 作为基准:

  1. L 指针发现 4 不严格小于 4,停止。
  2. G 指针发现 4 不严格大于 4,停止。
  3. 交换 L 和 G 所指元素(都是 4,无变化)。
  4. 指针向内移动一步,重复上述过程,直到指针交叉。
  5. 指针交叉后,将基准与 G 指针所指元素交换。

关键点:尽管所有值都相等,霍纳分区仍然成功地将基准放置在了中间位置,从而将列表分成了两个大小大致相等的子数组(实际上所有元素都等于基准,但分区逻辑仍将其分开)。这导致了近似 O(n log n) 的性能。

列表二:已排序列表 [1,2,3,4,5]

选择第一个元素 1 作为基准:

  1. L 指针指向 2,发现 2 不严格小于 1,立即停止。
  2. G 指针从右端开始移动,发现 5432 都严格大于 1,因此持续左移,直到越过 L 指针,指向 1
  3. 指针交叉,将基准(1)与 G 指针所指元素(1)交换,无变化。

关键点:这次分区实际上没有改变任何元素的位置。基准 1 被“放置”在正确位置,但右侧子数组包含了剩余的 n-1 个元素 [2,3,4,5]。下一次递归需要对这 n-1 个元素进行同样的操作。这导致了每次递归只减少一个待排序元素,形成了最坏情况的分区,时间复杂度为 O(n^2)

结论

  • 列表 [4,4,4,4,4] 使用霍纳分区会得到接近最佳情况的运行时 O(n log n)
  • 列表 [1,2,3,4,5] 使用霍纳分区(并选择首元素为基准)会得到最坏情况的运行时 O(n^2)

避免最坏情况的技术

从上一节的分析可知,快速排序的最坏情况发生在持续选择“坏”基准(如最小或最大值)时。本节我们探讨两种降低遭遇最坏情况概率的技术。

以下是两种常用技术:

  1. 随机选择基准
    在每次分区时,不总是选择第一个(或某个固定位置)的元素,而是从当前子数组中随机选择一个元素作为基准。这可以确保即使输入是已排序的,算法也不会总是选中最小或最大值,从而在概率上避免持续的最坏情况分区。

  2. 在排序前洗牌列表
    在执行快速排序之前,先将输入列表随机打乱。这相当于人为地引入了随机性,使得列表元素的分布变得随机,从而降低了选择到极端值作为基准的可能性。其效果与随机选择基准类似。

补充说明:理论上,每次都能选择中位数作为基准可以保证最佳性能 O(n log n)。然而,寻找中位数本身(例如通过“快速选择”算法)需要额外的时间开销,并不总是最高效的选择。因此,随机化方法(随机选基准或预先洗牌)在实践中是更简单有效的优化策略。


本节课中我们一起学习了快速排序的三路分区实现,并通过实例逐步理解了其排序过程。我们分析了霍纳分区法在最佳情况(O(n log n))和最坏情况(O(n^2))下的表现,并通过具体列表对比了其性能差异。最后,我们探讨了通过随机选择基准或预先洗牌列表来避免快速排序陷入最坏情况的实用技术。理解这些概念对于编写高效可靠的排序代码至关重要。

78:基数排序详解

在本节课中,我们将学习基数排序,包括其两种主要变体:最低有效位优先基数排序和最高有效位优先基数排序。我们将通过一个具体的例子来演示排序步骤,并分析它们的运行时复杂度、稳定性以及适用场景。


基数排序:14:问题2A - LSD基数排序步骤

上一节我们介绍了基数排序的基本概念,本节中我们来看看如何具体执行LSD基数排序。

我们被要求使用LSD基数排序对以下列表进行排序,并展示每一轮计数排序后的步骤。原始列表为:[43092, 3315, 3326, 3395]。排序过程从个位开始。

第一轮:基于个位排序
我们只关注每个数字的个位。个位数字分别是:2, 5, 6, 5。
根据个位数字排序,并保持稳定排序(即个位相同时,保持原列表中的相对顺序)。排序后列表为:
[43092, 3315, 3395, 3326]

第二轮:基于十位排序
现在,我们关注十位数字。当前列表的十位数字分别是:9, 1, 9, 2。
根据十位数字重新排序,同样保持稳定。排序后列表为:
[3315, 3326, 43092, 3395]

第三轮:基于百位排序
接下来,我们关注百位数字。当前列表的百位数字分别是:3, 3, 0, 3。
根据百位数字排序,百位相同的数字保持现有顺序。排序后列表为:
[43092, 3315, 3326, 3395]

第四轮:基于千位排序
然后,我们关注千位数字。当前列表的千位数字分别是:3, 3, 3, 0。
根据千位数字排序。排序后列表为:
[3315, 3326, 3395, 43092]

第五轮:基于万位排序
最后,我们必须检查万位数字以确保完全排序。当前列表的万位数字分别是:0, 0, 0, 4。
根据万位数字排序。由于LSD基数排序必须检查所有数位,即使列表看起来已排序,我们仍需完成这一轮。最终列表保持不变:
[3315, 3326, 3395, 43092]


基数排序:14:问题2B - MSD基数排序步骤

上一节我们完成了LSD排序,本节中我们来看看MSD基数排序的步骤。

我们使用MSD基数排序对同一个列表排序:[43092, 3315, 3326, 3395]。排序过程从最高位(万位)开始。

第一轮:基于万位排序
我们只关注每个数字的万位。万位数字分别是:4, 3, 3, 3。
根据万位数字排序。数字43092的万位是4,最大,因此被放置在最后,并设置一个“屏障”表示其位置已最终确定。排序后列表为:
[3315, 3326, 3395, | 43092]

第二轮:基于千位排序(对屏障前数字)
接下来,我们只对屏障前的数字(3315, 3326, 3395)检查千位。它们的千位数字都是3。
由于千位数字都相同,无法区分顺序,因此这部分的顺序保持不变。
[3315, 3326, 3395, | 43092]

第三轮:基于百位排序(对屏障前数字)
然后,对屏障前的数字检查百位。它们的百位数字也都是3。
同样,顺序保持不变。
[3315, 3326, 3395, | 43092]

第四轮:基于十位排序(对屏障前数字)
接着,对屏障前的数字检查十位。十位数字分别是:1, 2, 9。
这些数字是互异的,因此我们可以根据十位数字进行最终排序。由于十位数字已能决定顺序,且我们是从最高位开始排序的,因此排序可以在此停止。最终排序列表为:
[3315, 3326, 3395, | 43092]


基数排序:14:问题2C - 运行时与稳定性分析

前面我们演示了两种排序的过程,本节中我们来分析它们的运行时复杂度和稳定性。

以下是LSD和MSD基数排序的运行时与稳定性总结:

  • LSD基数排序
    • 最坏情况运行时Θ(w * (n + r))
    • 最好情况运行时Θ(w * (n + r))
    • 是否稳定:是
  • MSD基数排序
    • 最坏情况运行时O(w * (n + r))
    • 最好情况运行时Ω(n + r)
    • 是否稳定:是(在本课程讨论的版本中)

分析说明:

  • n 是元素数量。
  • r 是基数大小(例如,十进制数字为10)。
  • w 是元素的最大位数。
  • LSD排序必须遍历所有 w 个数位,因此最好和最坏情况相同。
  • MSD排序在最好情况下,如果高位数字互异,可能只需一轮排序即可结束。其一般情况运行时用大O表示。
  • 两种排序都可以实现为稳定排序,这对于LSD排序的正确性至关重要。

基数排序:14:问题2D - 基数排序是最佳选择吗?

上一节我们分析了基数排序高效的运行时,本节中我们探讨它是否总是最佳选择。

虽然基数排序在某些情况下非常快,但它并非总是最佳选择。原因如下:

以下是需要考虑的几个关键点:

  1. 依赖数据特性:基数排序的高效性依赖于较小的基数 r 和位数 w。如果 w 很大(例如,很长的数字),w * (n + r) 的运行时可能超过比较排序的 O(n log n)
  2. 有限的适用数据类型:基数排序最适合可以分解为数字或固定大小字母表元素的类型(如整数、字符串)。对于复杂的对象(如自定义类),难以直接映射到数字上进行排序。
  3. 与比较排序对比:比较排序(如归并排序、快速排序)具有 O(n log n) 的平均情况运行时,不依赖于数据的特定表示形式,因此更通用。

结论:基数排序在数据满足特定条件(位数少、基数小)时性能卓越,但它不是通用的“最佳”排序算法。选择排序算法时应考虑数据的具体特征。


本节课中我们一起学习了LSD和MSD基数排序的详细步骤,分析了它们的运行时复杂度与稳定性,并讨论了基数排序的适用场景与局限性。理解这些将帮助你在不同情况下选择合适的排序算法。

79:4 - 排序算法识别教程 🧩

在本教程中,我们将学习如何根据数组排序过程中的中间步骤序列,来识别所使用的具体排序算法。我们将分析五种常见的排序算法:快速排序、归并排序、堆排序、MSD基数排序和插入排序。通过对比算法特性和观察序列模式,我们将为每个给定的步骤序列匹配正确的算法。


第一部分:序列A分析

上一节我们介绍了本教程的目标和涉及的算法,本节中我们来看看第一个序列。

我们首先分析序列A。给定的中间步骤是:

  1. [12, 7, 8, 4, 10, 2, 5, 34, 14]
  2. [7, 8, 4, 10, 2, 5, 12, 34, 14]
  3. [4, 2, 5, 7, 8, 10, 12, 14, 34]

以下是针对序列A的算法排除过程:

  • 快速排序:题目假设快速排序的枢轴(pivot)总是子列表的第一个元素。以第一行的12为枢轴,第二行中12左侧的元素(7, 8, 4, 10, 2, 5)均小于12,右侧的元素(34, 14)均大于12,符合快速排序分区后的特征。在第三行,左侧子列表以7为枢轴,其左侧(4, 2, 5)小于7,右侧(8, 10)大于7;右侧子列表中14小于34。这符合快速排序的递归过程。因此,序列A可能是快速排序
  • 归并排序:归并排序会递归地将列表分成两半并排序,然后合并。如果这是归并排序的中间步骤,我们应该看到列表被分成已排序的两半。但在第二行,左侧部分[7, 8, 4, 10, 2, 5]并非有序,因此不是归并排序
  • 堆排序:堆排序的第一步是构建最大堆(max-heap),完成后最大值应位于数组首位(索引0)。从第一行到第二行,首元素从12变为7,而最大值34并未出现在首位,因此不是堆排序
  • MSD基数排序:MSD基数排序基于最高有效位进行排序。如果进行左补零,我们会期望看到所有最高位为0的数字排在最前,然后是1,以此类推。序列中数字的最高位顺序混乱(例如,7, 8, 4后面跟着34),不符合该模式,因此不是MSD基数排序
  • 插入排序:插入排序的特点是,经过k次迭代后,数组的前k个元素相对于彼此是有序的。如果这是插入排序,在第二行我们可能期望看到前两个或三个元素(如7, 8, 12)已经有序,但实际上第二个元素是8,第三个元素是4,这不符合插入排序“部分有序”的典型中间状态。因此不是插入排序

结论:序列A最符合快速排序的特征。


第二部分:序列B分析

上一节我们通过排除法确定了序列A的算法,本节中我们使用相同的方法分析序列B。

序列B的步骤如下:

  1. [23, 12, 4, 7, 12, 20, 65, 34]
  2. [4, 7, 12, 12, 20, 23, 65, 34]

以下是针对序列B的算法排除过程:

  • 快速排序:以23为枢轴,分区后所有小于23的元素应在其左侧,大于23的在其右侧。在第二行,23的右侧出现了20,这违反了快速排序的分区规则。因此不是快速排序
  • 归并排序:观察第二行,数组的左半部分[4, 7, 12, 12]看起来已经有序,这有点像归并排序对左半部分排序后的结果。但这并非对整个列表执行完整归并排序的典型中间步骤。
  • 堆排序:如果是堆排序,在构建最大堆后,最大值应位于索引0。第二行的首元素是4,而最大值65在末尾,不符合堆排序的特征。即使跳过了几步,如果4是当前堆的最大值,那么4之后的元素应该基本有序,但[20, 23, 65, 34]并非如此。因此不是堆排序
  • MSD基数排序:同样,对数字进行左补零后,最高有效位的顺序(0, 0, 1, 1, 2, 2, 6, 3)并不连贯(例如,6出现在3之前),不符合基数排序的规则。因此不是MSD基数排序
  • 插入排序:插入排序的关键特征是,数组前端已处理的部分是有序的。在第二行,前四个元素[4, 7, 12, 12]已经按升序排列,这正好符合插入排序经过若干次迭代(将1274等元素向左插入正确位置)后的状态。因此,序列B是插入排序

结论:序列B符合插入排序的特征。


第三部分:序列C分析

上一节我们识别出了插入排序的模式,本节我们来分析一个更简短的序列。

序列C只有两行:

  1. [12, 14, 11, 3, 7, 32, 20, 17]
  2. [11, 12, 14, 3, 7, 17, 20, 32]

以下是针对序列C的算法排除过程:

  • 快速排序:以12为枢轴,第二行中12左侧的11正确,但右侧的143都不满足条件(3小于12)。因此不是快速排序
  • 归并排序:归并排序通过合并相邻的有序子数组进行。如果这是合并过程,我们可能期望看到类似[11, 12, 14][3, 7, ...]这样的有序块被合并,但第二行中37被隔开了,顺序未被很好地保持。因此不是归并排序
  • 堆排序:如果是堆排序的中间步骤,我们可能期望看到最大值位于前端,或者后续元素部分有序。这里的情况不符合。因此不是堆排序
  • MSD基数排序:观察第二行,所有数字似乎按最高位粗略分组:首先是最高位为1的数字(11, 12, 14),然后是最高位为3的数字(3),接着是最高位为7、1、2、3的数字?这看起来不太对。但请注意,MSD基数排序是稳定的。观察第一行中最高位为1的数字顺序是12, 14, 11。在第二行中,最高位为1的数字变成了11, 12, 14,这正好是稳定排序的结果(按下一个有效位排序,同时保持了原始相对顺序)。这强烈暗示了序列C是MSD基数排序
  • 插入排序:如果是插入排序,在第二行我们应看到数组前部有一段连续有序的元素。这里的顺序(11, 12, 14, 3, ...)在3处中断了,不符合插入排序的部分有序特征。因此不是插入排序

结论:序列C符合MSD基数排序的特征,特别是其稳定性在数字分组中得以体现。


第四部分:序列D分析

上一节我们看到了基数排序的稳定性如何帮助识别,本节我们分析一个显示元素局部性变化的序列。

序列D有三行:

  1. [45, 23, 5, 65, 8, 34, 6, 20]
  2. [23, 45, 5, 65, 6, 8, 34, 20]
  3. [5, 23, 45, 65, 6, 8, 20, 34]

以下是针对序列D的算法排除过程:

  • 快速排序:以45为枢轴,在第二行中,45右侧的5小于45,违反了分区规则。因此不是快速排序
  • 归并排序:注意观察元素位置的变化。从第一行到第二行,数字似乎只在局部范围内移动。如果将第一行分成四个长度为2的子数组:[45, 23], [5, 65], [8, 34], [6, 20]。在第二行,这些“数对”内部被排序了:[23, 45], [5, 65], [6, 8], [20, 34],并且每个有序对仍然占据原来那对数字的索引范围。第三行看起来像是将这些有序对两两合并。这非常符合归并排序“先递归排序小块,再合并”的过程。因此,序列D很可能是归并排序
  • 堆排序:如果是堆排序,在某个中间步骤后,最大值应位于数组前端。这几行中均未出现此模式。因此不是堆排序
  • MSD基数排序:数字的最高位和后续数位顺序杂乱,没有显示出按数位分组的迹象。因此不是MSD基数排序
  • 插入排序:插入排序是线性向前推进的。很难解释为何在将23向左移动后,会跳过565去处理更后面的834。元素位置的变动不符合插入排序的典型模式。因此不是插入排序

结论:序列D展示了归并排序的典型特征,即元素在局部索引范围内被排序和合并。


第五部分:序列E分析

最后,我们来分析序列E,它呈现了一种最大值被“提取”到末尾的模式。

序列E有三行:

  1. [23, 54, 44, 12, 10, 18, 3, 7]
  2. [54, 44, 23, 12, 10, 18, 3, 7]
  3. [44, 23, 18, 12, 10, 7, 3, 54]

以下是针对序列E的算法排除过程:

  • 快速排序:以23为枢轴,第二行中23右侧的1210虽然小于23,但23左侧的54却大于23,这违反了规则。因此不是快速排序
  • 归并排序:元素位置发生了剧烈变化(例如,54从索引1移到了索引0),这不符合归并排序元素在局部范围内合并的特性。因此不是归并排序
  • 堆排序:观察第一行到第二行的变化:最大值54移动到了数组的首位(索引0)。这正符合堆排序构建最大堆(heapify) 后的结果。从第二行到第三行:最大值54被交换(或“弹出”)到了数组末尾,而新的最大值44则上升到了首位。这正好对应了堆排序的核心操作:将堆顶(最大值)与末尾元素交换,然后对剩余部分重新堆化(re-heapify)。因此,序列E是堆排序
  • MSD基数排序:数字的最高位顺序混乱,没有规律。因此不是MSD基数排序
  • 插入排序:插入排序不可能在一步之内就将最大值54从中间位置移动到数组最前面。因此不是插入排序

结论:序列E清晰地展示了堆排序的“构建最大堆-交换堆顶与末尾-重新堆化”的循环过程。


总结 🎯

本节课中我们一起学习了如何通过观察排序中间步骤来识别五种不同的排序算法。我们总结了每种算法的关键识别特征:

  1. 快速排序:寻找枢轴元素,并检查每一步是否满足“左侧全小于枢轴,右侧全大于枢轴”的分区特性。
  2. 归并排序:观察元素是否在原有的局部索引范围内形成有序块,并逐步合并这些块。
  3. 堆排序:关注最大值是否被移动到数组前端(建堆后),然后是否被交换到末尾,同时新的最大值浮到前端。
  4. MSD基数排序:观察数字是否按最高有效位(或后续数位)进行分组排序,并注意稳定性可能保持同组数字的相对顺序。
  5. 插入排序:检查数组前部是否已形成一段连续的、有序的元素序列。

掌握这些模式能帮助你更好地理解各种排序算法的内部工作机制。

80:5 - 排序算法概念比较

在本节课中,我们将学习并比较几种经典的排序算法。我们将探讨它们的最佳、最坏情况时间复杂度,分析其空间复杂度与稳定性,并通过具体问题来加深理解。课程内容基于对插入排序、快速排序、归并排序、选择排序和堆排序的讨论。

插入排序的最坏情况

上一节我们介绍了本课程的目标,本节中我们来看看第一个具体问题:如何构造一个能引发插入排序最坏情况运行的5个整数的数组。

插入排序的工作原理是,从数组起始位置开始,尝试将当前元素与左侧元素比较并交换,直到它到达正确位置。其最佳情况时间复杂度是 O(n),发生在数组已完全排序时,因为无需任何交换。

因此,最坏情况(时间复杂度为 O(n²))发生在需要进行最多交换时。这意味着数组应处于完全逆序状态。

以下是能引发最坏情况的5整数数组示例:

  • 5, 4, 3, 2, 1

在这种情况下,算法需要进行 n(n-1)/2 次交换,这是可能的最大交换次数。

选择归并排序而非快速排序的原因

了解了插入排序的特性后,我们来看看在不同场景下如何选择排序算法。本节中我们探讨为何有时会选择归并排序而非快速排序。

我们可以从运行时性能、空间复杂度和稳定性几个方面来比较。

以下是选择归并排序的一些关键原因:

  • 最坏情况运行时性能更优:归并排序的最坏情况时间复杂度是 O(n log n),而快速排序(使用Hoare分区法)的最坏情况是 O(n²)
  • 稳定性:归并排序是稳定的排序算法(即相等元素的相对顺序在排序后保持不变)。而快速排序通常是不稳定的(尽管使用三路扫描分区法可以实现稳定,但这会占用额外空间,并不常用)。
  • 易于并行化:归并排序可以轻松地分割成独立的子问题并行处理,因为其左右两半在合并前互不干扰。
  • 链表排序:归并排序更适用于对链表进行排序,这与内存访问和缓存局部性有关。

排序算法特性辨析

在比较了归并排序和快速排序之后,本节我们将通过一系列选择题,更全面地辨析各种排序算法的特性。题目涉及时间复杂度下界、最坏情况性能比较以及交换次数。

题目提供了一个答案选项池(A. 快速排序, B. 归并排序, C. 选择排序, D. 插入排序, E. 堆排序, F. 以上都不是),每个陈述可能有多个正确答案。

1. 时间复杂度下界为 Ω(n log n) 的排序算法

这里讨论的是算法运行时间的下界(即最佳情况)。虽然比较排序的平均情况下界是 Ω(n log n),但某些算法在特定输入下的最佳情况可以更好。

  • 插入排序在已排序数组上的最佳情况是 O(n)
  • 堆排序在包含大量重复元素的数组上,最佳情况也可能是 O(n)
  • 快速排序、归并排序和选择排序的最佳情况分别是 O(n log n)O(n log n)O(n²)。选择排序的 O(n²) 仍然满足 Ω(n log n) 的下界。

因此,满足该条件的算法是:A, B, C

2. 最坏情况时间复杂度渐近优于快速排序最坏情况的排序算法

快速排序的最坏情况时间复杂度是 O(n²)。我们需要找出最坏情况比这更好的算法。

  • 归并排序的最坏情况是 O(n log n)
  • 选择排序的最坏情况是 O(n²),并非更好。
  • 插入排序的最坏情况是 O(n²),并非更好。
  • 堆排序的最坏情况是 O(n log n)

因此,满足该条件的算法是:B, E

3. 在最坏情况下执行 Θ(n) 次元素两两交换的排序算法

这个问题关注的是算法执行过程中元素交换的次数,而非运行时间。我们需要逐一分析。

  • 归并排序:在合并过程中并不交换元素,而是将元素追加到新列表,交换次数为0。
  • 选择排序:每一轮迭代找到剩余未排序部分的最小值,并将其交换到正确位置。对于n个元素,正好需要进行 n-1 次交换,即 Θ(n) 次。
  • 插入排序:在最坏情况(逆序数组)下,交换次数约为 n²/2,即 Θ(n²) 次。
  • 堆排序:其操作涉及多次“堆化”,最坏情况下的交换次数为 Θ(n log n)
  • 快速排序:这有些反直觉。使用Hoare分区法时,最佳情况运行时间 (O(n log n)) 反而会导致大约 Θ(n log n) 次交换。而最坏情况运行时间 (O(n²)) 却只导致大约 Θ(n) 次交换。因此,快速排序的交换次数不是线性的。

因此,满足该条件的算法只有:C

总结

本节课中我们一起学习了多种排序算法的深入比较。我们明确了插入排序最坏情况的输入形式,分析了选择归并排序而非快速排序的若干场景(包括最坏情况性能、稳定性和并行化),并通过辨析题巩固了对各算法时间复杂度下界、最坏情况相对性能以及元素交换次数特性的理解。理解这些细微差别对于在实际问题中选择合适的排序算法至关重要。

81:排序算法运行时间分析 🧮

在本节课中,我们将一起分析几种排序算法在特定场景下的最佳和最坏情况运行时间。我们将重点关注算法修改对渐近时间复杂度的影响。


排序算法运行时间表 📊

在开始分析具体问题之前,我们先回顾一下常见排序算法的运行时间。下表列出了几种排序算法在最佳和最坏情况下的时间复杂度。

排序算法 最佳情况 最坏情况
插入排序 Θ(n) Θ(n²)
归并排序 Θ(n log n) Θ(n log n)
快速排序 Θ(n log n) Θ(n²)
堆排序 Θ(n log n) Θ(n log n)

问题一:排序与运行时间

我们需要对一个包含 n 个唯一数字的数组进行升序排序。请确定以下排序场景的最佳和最坏情况运行时间。

第一部分:带插入排序的归并排序

场景:在归并排序中,一旦子数组(run)的大小小于或等于 n/100,我们就对这些子数组执行插入排序。我们需要填写其最佳和最坏情况的时间复杂度。

为了理解这个场景,我们首先需要知道子数组何时会达到 n/100 的大小。归并排序通过递归地将数组对半分割来工作。经过 x 层递归后,子数组的大小为 n / 2^x

我们需要找到满足 n / 2^x ≤ n / 100x。解这个不等式,我们得到 2^x ≥ 100。当 x = 7 时,2^7 = 128,满足条件,因为 n/128 ≤ n/100

这意味着,无论输入规模 n 有多大,我们总是只需要进行 7 次分割/合并操作就能使子数组达到目标大小。这是一个常数级别的操作,不会影响算法的渐近时间复杂度。

接下来,我们对每个大小为 n/100 的子数组执行插入排序。插入排序在单个大小为 m 的数组上的时间复杂度是:

  • 最佳情况:Θ(m)
  • 最坏情况:Θ(m²)

m = n/100 代入,我们得到单个子数组的时间复杂度为 Θ(n)Θ(n²)。注意,除以常数 100 在渐近分析中不影响结果。

我们总共有大约 100 个子数组。对每个子数组执行插入排序的总时间,在渐近意义下,仍然是:

  • 最佳情况:Θ(n)
  • 最坏情况:Θ(n²)

因此,对于整个算法:

  • 最佳情况Θ(n)
  • 最坏情况Θ(n²)

第二部分:使用线性时间中位数查找的快速排序

场景:在快速排序中,我们使用一个线性时间(Θ(n))的算法来寻找中位数作为枢轴(pivot)。这如何影响运行时间?

首先,回顾标准快速排序。在最佳情况下(每次都能选中中位数),递归树的高度是 log n,每一层总共需要进行 Θ(n) 的比较和交换操作(分区操作),因此总时间为 Θ(n log n)。在最坏情况下(例如数组已排序且总选最小或最大元素作枢轴),递归树退化为链状,总时间为 Θ(n²)

现在,我们修改算法,强制每次分区都使用线性时间算法找到中位数作为枢轴。这意味着在每个递归节点上,我们除了进行 Θ(n) 的分区操作外,还需要额外进行 Θ(n) 的中位数查找操作。所以每个节点的总工作是 Θ(2n)

在渐近分析中,常数因子可以被忽略,因此每个节点的工作量仍然是 Θ(n)。递归树的高度因为总是选中中位数而保持为 log n。所以,总的最佳情况运行时间仍然是 Θ(n log n)

关键的变化在于最坏情况。由于我们总是选择中位数,我们永远不可能遇到像“总是选择最小元素”这样的糟糕分区情况。因此,最坏情况下的递归树形状与最佳情况相同(平衡树)。所以,最坏情况的运行时间也变成了 Θ(n log n)

总结:

  • 最佳情况Θ(n log n)
  • 最坏情况Θ(n log n)

第三部分:使用最小堆的堆排序

场景:我们实现一个使用最小堆(Min Heap)而非最大堆(Max Heap)的堆排序,同时保持常数空间复杂度。其运行时间是多少?

标准堆排序(使用最大堆)的步骤是:

  1. 堆化(Heapify):将无序数组构建成最大堆,耗时 Θ(n)
  2. 排序:重复将堆顶(最大元素)与堆末尾元素交换,然后对新的堆顶元素进行“下沉(sink)”操作以恢复堆性质,直到堆为空。这个过程需要 n 次操作,每次“下沉”耗时 Θ(log n),因此总时间为 Θ(n log n)。最终得到一个升序数组。

如果使用最小堆,步骤类似,但每次弹出的是最小元素并放到数组末尾。最终,数组将按降序排列。

为了得到升序数组,我们需要在堆排序完成后,对得到的降序数组进行一次反转。反转一个数组需要 Θ(n) 的时间。

因此,使用最小堆的堆排序总时间包括:

  • 堆化:Θ(n)
  • 弹出并调整堆:Θ(n log n)
  • 反转数组:Θ(n)

在渐近分析中,我们只保留增长最快的一项。Θ(n log n)Θ(n) 增长得更快,因此总时间由 Θ(n log n) 主导。

所以,无论是最佳还是最坏情况,时间复杂度都是:

  • 最佳情况Θ(n log n)
  • 最坏情况Θ(n log n)

第四部分:自定义最优排序算法

场景:我们运行一个自己选择的最优排序算法,针对以下特定情况。

D.1 最多有 n 次逆序对(Inversions)

逆序对是指数组中位置错误的元素对。对于这种情况,插入排序是一个非常好的选择,因为插入排序的运行时间可以表示为 Θ(n + k),其中 k 是数组中逆序对的数量。

  • k 最多为 n 时:
    • 最佳情况(k 很小,例如常数):时间为 Θ(n + 常数) = Θ(n)
    • 最坏情况(k = n):时间为 Θ(n + n) = Θ(n)
  • 因此,在两种情况下,时间复杂度都是 Θ(n)

D.2 恰好有一次逆序对

这意味着数组中只有一对元素需要交换。

  • 最佳情况:这对元素恰好位于数组开头。我们可能只需要常数时间就能发现并纠正它。例如,检查前两个元素,发现它们逆序,交换即可。时间为 Θ(1)
  • 最坏情况:这对元素位于数组末尾。为了找到这个逆序对,我们可能需要扫描几乎整个数组才能确认其他部分都是有序的,并定位到错误的位置。时间为 Θ(n)

D.3 恰好有 n(n-1)/2 次逆序对

n(n-1)/2 是包含 n 个元素的数组中所能拥有的最大逆序对数量。当一个数组完全逆序(即降序排列)时,就会达到这个最大值。

要将一个完全逆序的数组变为升序,最直接的方法之一就是将其反转。反转一个数组需要遍历它的一半元素并进行交换,这是一个 Θ(n) 的操作。

  • 因此,无论是考虑找到这个最优方法(反转)的过程,还是执行它,在最佳和最坏情况下,时间复杂度都是 Θ(n)

总结 📝

本节课我们一起分析了多种排序算法变体的运行时间:

  1. 混合排序:归并排序与插入排序结合,其渐近时间由插入排序部分决定。
  2. 确定性快速排序:通过线性时间中位数查找确保平衡分区,消除了最坏的 Θ(n²) 情况。
  3. 堆排序变体:改变堆的类型会影响最终顺序,但增加一个线性时间的反转步骤不会改变其 Θ(n log n) 的渐近复杂度。
  4. 基于逆序对的自定义排序:根据逆序对的特定数量,我们可以选择或设计算法来达到从常数时间到线性时间不等的效率,关键在于利用问题的特殊约束。

理解这些场景有助于深化对算法渐近分析核心原则的认识:关注增长最快的项,忽略常数因子和低阶项。

82:MSD基数排序实现教程 🧮

在本节课中,我们将学习如何实现最高有效位优先基数排序。这是一种非比较型排序算法,通过逐位比较数字来排序。我们将从算法原理开始,逐步深入到具体的代码实现。

算法原理概述

上一节我们介绍了基数排序的基本概念,本节中我们来看看MSD基数排序的具体工作流程。

基数排序不直接比较数值大小,而是比较每个数字的每一位。MSD基数排序从最高有效位开始比较,并逐步向右移动索引,直到最低有效位。

以下是MSD基数排序的核心步骤:

  1. 从最高有效位开始,根据该位的值对数组进行排序。
  2. 将当前位值相同的元素分组
  3. 对每个分组递归地调用MSD基数排序,但比较的索引向右移动一位。
  4. 当分组大小为1或索引超出数字长度时,达到基准情况,直接返回。

代码实现详解

理解了算法原理后,现在我们来探讨如何用代码实现它。我们被提供了一个骨架代码,包含两个名为MSD的方法,区别在于其中一个多了一个索引参数。

1. 初始调用与基准情况

我们首先需要确定初始索引。对于MSD排序,我们从最高有效位开始,因此初始索引为0。

public static List<String> MSD(List<String> items) {
    return MSD(items, 0); // 从最高位(索引0)开始
}

接下来,我们需要定义递归的基准情况。在两种情况下我们可以直接返回:

  • 当列表大小为0或1时,无需排序。
  • 当比较的索引超过所有字符串的长度时,意味着当前分组内的所有字符串都完全相同。

private static List<String> MSD(List<String> items, int index) {
    // 基准情况 1: 列表已自然有序
    if (items.size() <= 1) {
        return new ArrayList<>(items);
    }
    // 基准情况 2: 索引超出所有字符串长度(处理重复项)
    if (index >= items.get(0).length()) {
        return new ArrayList<>(items);
    }
    // ... 后续排序逻辑
}

2. 按当前位排序

处理完基准情况后,首要任务是根据当前索引位置的字符对列表进行稳定排序。这确保了在后续分组时顺序的正确性。

// 根据给定索引位置的字符进行稳定排序
stableSort(items, index);

3. 分组与递归

排序后,我们需要找出在当前位具有相同字符的连续元素,将它们分为一组,并进行递归排序。以下是实现这一过程的关键步骤:

我们使用两个指针startend来标记一个分组的范围。

  • start指向当前分组的开始。
  • endstart+1开始向后遍历。

核心逻辑是:当end指向的元素与start指向的元素在index位的字符不同时,说明从startend-1的所有元素构成了一个完整的分组。

List<String> answer = new ArrayList<>();
int start = 0;
int n = items.size();

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucb-cs61b-dsat/img/2278acda2e1af9105b1cd5305f1c723f_53.png)

while (start < n) {
    int end = start + 1;
    // 寻找当前分组的结束位置
    while (end < n && items.get(start).charAt(index) == items.get(end).charAt(index)) {
        end++;
    }
    // 获取当前分组 [start, end)
    List<String> sublist = items.subList(start, end);
    // 递归排序下一低位,并将结果加入最终列表
    answer.addAll(MSD(sublist, index + 1));
    // 移动start指针,处理下一个分组
    start = end;
}
return answer;

需要注意的边界情况:当end指针到达列表末尾时,while循环会终止,此时start = end,外层的while (start < n)循环也会结束,算法正确完成。

总结

本节课中我们一起学习了MSD基数排序的实现。我们首先明确了算法从最高位开始、分组、递归的核心思想。接着,我们逐步实现了代码:

  1. 设置了从索引0开始的初始调用。
  2. 定义了列表大小≤1和索引越界两种基准情况。
  3. 使用stableSort根据当前位排序。
  4. 利用双指针法找出相同字符的连续分组。
  5. 对每个分组递归调用MSD,并递增索引。
  6. 合并所有递归结果作为最终排序列表。

这个实现的关键在于理解分组的逻辑和递归索引递增的过程,它优雅地解决了按位排序的问题。

83:乱序考试问题

在本节课中,我们将学习如何解决一个关于“乱序考试”的问题。我们将处理两个数组:一个包含考试对象,另一个包含学生对象。每个对象都有一个SID(学生ID)属性。最初,两个数组在相同索引位置上的SID是匹配的,但后来学生数组被随机打乱了,而考试数组保持不变。我们的目标是在不改变考试数组的前提下,以 O(N) 的时间复杂度重新排列学生数组,使其SID再次与考试数组匹配。

问题背景与目标

上一节我们介绍了问题的基本设定。本节中我们来看看具体的视觉化示例。

假设我们有以下初始状态:

  • 考试数组:[499, 317, 351]
  • 学生数组:[409, 351, 317]

我们的目标是重新排列学生数组,使其变为 [499, 317, 351],这样在索引i处,考试SID就与学生SID匹配了。

解决方案思路

为了解决这个问题,我们需要一个方法来追踪每个考试对象原本在数组中的位置,以便将正确的学生对象放回对应的位置。以下是解决此问题的核心步骤。

步骤一:创建考试包装器

首先,我们创建一个ExamWrapper类。这个类有两个属性:

  1. exam: 考试对象本身。
  2. originalIndex: 该考试对象在原始考试数组中的索引。

代码示例

class ExamWrapper {
    Exam exam;
    int originalIndex;

    ExamWrapper(Exam e, int idx) {
        this.exam = e;
        this.originalIndex = idx;
    }

    // 为了方便比较,可以添加获取SID的方法
    int getSID() {
        return exam.SID;
    }
}

然后,我们为考试数组中的每个考试创建一个ExamWrapper对象,并将它们存储在一个新数组中。此时,学生数组保持不变。

步骤二:对SID进行排序

接下来,我们需要建立学生SID与考试包装器SID之间的对应关系。一个高效的方法是使用基数排序。

以下是需要执行的操作:

  1. 对学生数组中的学生SID进行基数排序,使其按升序排列。
  2. 对考试包装器数组中的SID(通过getSID()方法获得)也进行基数排序,使其按相同的升序排列。

由于SID是固定长度的十进制整数,基数排序的时间复杂度是 O(N),这满足我们的要求。

排序后,两个数组将基于SID一一对应。也就是说,排序后学生数组的第iSID,与排序后考试包装器数组的第iSID是相同的。

步骤三:重新排列学生数组

现在我们已经有了匹配关系,可以开始重新排列原始的学生数组了。我们需要创建一个新的学生数组(studentsCopy)来存放正确顺序的学生。

以下是重新排列的逻辑:
对于排序后学生数组中的第i个学生:

  1. 找到排序后考试包装器数组中与之对应的第i个包装器。
  2. 从该包装器中获取originalIndex(即该考试在原考试数组中的位置)。
  3. 将当前学生放入studentsCopy数组的originalIndex位置。

公式/逻辑描述
studentsCopy[examWrapper[i].originalIndex] = sortedStudents[i]

遍历所有学生后,studentsCopy数组中的学生SID顺序就会与原始考试数组的SID顺序完全匹配。

总结

本节课中我们一起学习了如何解决“乱序考试”问题。我们通过引入ExamWrapper来保存考试的原始索引信息,然后利用 O(N) 的基数排序对齐学生和考试的SID,最后根据包装器中的索引信息将学生重新排列到正确的位置。这个方法高效地满足了不修改考试数组且在线性时间复杂度内完成排序的要求。

posted @ 2026-03-29 09:26  布客飞龙II  阅读(10)  评论(0)    收藏  举报