UCSD-CSE12-基础数据结构和面向对象笔记-全-

UCSD CSE12 基础数据结构和面向对象笔记(全)

001:课程介绍与概览 🎯

在本节课中,我们将学习CSE 12课程的基本信息、课程结构、评分政策以及学术诚信要求。这是数据结构和面向对象设计课程的入门介绍。

课程基本信息

我是Paul Cao。这是我在UCSD的第十年。我从2014年开始在这里任教。我主要教授CSE 12这门课,同时也教授其他低年级课程,如CSE 8A、11、30等。我的办公室在EBU3B(CSE大楼)的2102室。我的办公时间是每周二上午10点到12点,地点在CSE大楼的3217会议室。我的研究兴趣是K-12计算机科学教育。

课程网站与资源

以下是本课程将使用的主要网站和资源:

  • Canvas:这是我们的课程管理系统。所有课程材料、成绩和公告都会发布在这里。所有注册的学生都会被自动加入Canvas站点。
  • Gradescope:这是提交编程作业的平台。你可以多次提交代码,系统会自动评分并提供即时反馈。
  • Piazza:这是课程的讨论论坛。你可以在这里提问,教学团队通常能在一小时内回复。
  • Autograder:这个网站用于向助教寻求帮助。本学期晚些时候,我们可能会组织可选的问题讨论环节。
  • Zybooks:这是我们的电子教科书。每周的阅读作业都在这里完成。

课程大纲和日程表可以在Canvas上找到。请注意,CSE系不允许为晚注册的学生补交作业。

课程先修要求与期望

本课程的先修课程是CSE 11或CSE 8B。CSE 8B现在的内容与CSE 11基本相同。核心要求是掌握Java编程和面向对象编程的基本概念。如果你是转学生,熟悉C++,需要快速适应Java的语法。

重要日期与政策

以下是关于作业、测验和考试的重要信息。

作业提交

所有编程作业(PA)的截止日期是周四晚上11:59。请务必在此时间前提交。网络问题不能作为迟交的理由。建议尽早完成并提交。你可以在Gradescope上多次提交,系统只记录最后一次提交的成绩。如果因紧急情况需要延期,请尽早与我沟通。

阅读作业

每周的阅读作业在Zybooks上完成,截止时间是当周周五晚上。迟交会有每天10%的罚分。请通过Canvas上的链接访问Zybooks,以确保成绩能同步。

二次机会政策

我相信在低年级课程中应该给予学生改正错误的机会。

  • 编程作业:在作业截止后的一周内,你可以重新提交以改进成绩。最终成绩取原始提交和重提交的平均分,但不会低于原始成绩。
  • 测验:每周的讨论课上会有测验。对于每次测验,下周都会有一次可选的“重做”机会。你的测验成绩取两次中的较高分。
  • 期中考试:期中考试在第五周进行。期末考试的第一部分覆盖与期中相同的内容。你的期中成绩将取原始期中考试和期末考试第一部分成绩中的较高分。
  • 期末考试:期末考试没有二次机会。

课程内容与学习目标

本课程是CSE 11的进阶课程,重点从“答案是否正确”转向“解决方案的效率如何”。我们将学习多种数据结构,并理解在不同场景下如何选择合适的数据结构。

本学期我们将涵盖以下核心主题:

  1. Java基础回顾:第一周进行,作为热身。
  2. 数据结构:包括数组、列表、栈、队列、哈希表、树、二叉搜索树等。
  3. 运行时分析:学习如何分析操作的效率。
  4. 排序算法:在学期末学习。

本课程的作业要求你亲自实现这些数据结构。这是你在整个计算生涯中可能唯一一次亲手实现它们的机会,反馈表明这非常有价值。

评分细则

课程评分由以下部分组成:

  • 阅读作业:占10%。我们会去掉成绩最低的一周。
  • 课堂参与:占5%。从第三周开始计分,我们会去掉成绩最低的三周。
  • 测验:三次测验共占10%。
  • 编程作业:占40%。
  • 期中考试:占10%。
  • 期末考试:占25%。

有两个硬性要求必须满足:总编程作业成绩必须达到55%以上,且期末考试成绩必须达到55%以上。

关于成绩复议:对于作业和测验,我们会在发布成绩后提供三天的复议期。对于期末考试,复议期只有24小时。

学术诚信

学术诚信至关重要。以下是允许和不允许的行为:

  • 允许:使用集成开发环境(如VS Code)的基本自动补全功能。
  • 不允许:使用Copilot、ChatGPT等能够生成代码的人工智能工具。软件工程师通常不信任这些工具生成的代码,因为它们可能不正确或效率低下。作为初学者,你不应依赖它们。
  • 不允许:在作业上进行合作。所有作业必须独立完成,包括编写自己的测试代码。分享测试代码也是不允许的,因为学习如何测试代码是本课程的学习目标之一。

我们通过运行代码相似性检查脚本以及使用AI工具生成代码进行比对来检测学术不端行为。需要教学团队中多人一致认定存在严重问题才会报告。对于低年级课程的学术不端行为,处罚通常是该次评估得零分。多次作弊可能导致课程不及格。请通过正当渠道寻求帮助,不要损害友谊和个人学术记录。

其他支持与安排

如果你有残疾并需要特殊安排,请尽早联系我。即使OSD的文件尚未办妥,你也可以先通过邮件与我沟通,我们可以从第一天开始为你提供便利。

课程讲座会被录制并发布在podcast.ucsd.edu网站上。课堂参与需要使用答题器(clicker),你需要在iClicker网站上注册。只要你的朋友本学期没有选修CSE 12,你们可以共享一个答题器。

本节课中,我们一起学习了CSE 12课程的总体框架、评分方式、核心内容以及必须遵守的学术规范。下节课我们将开始回顾Java基础知识,为学习数据结构做准备。

002:Java泛型入门教程 🚀

在本节课中,我们将要学习Java编程语言中的一个核心概念——泛型。泛型允许我们编写可以处理多种数据类型的类和方法,而无需为每种类型重复编写代码。这对于构建灵活且可重用的数据结构至关重要。


课程概述与学术诚信 📚

上一节我们介绍了课程的基本信息,本节中我们来看看课程的重要政策,特别是学术诚信部分。

首先,我们强调课堂环境的包容性与专业性。每个人都应感到舒适地学习。任何可能让他人不适的言论或行为都应避免。如果你有任何顾虑,请及时反馈。

关于学术诚信,以下是核心规定:

  • 禁止外部辅导:不允许使用如Chegg、CourseHero等外部辅导服务。
  • 保护代码知识产权:你的作业代码不应公开分享。即使将代码存储在GitHub上,也应设置为私有仓库。公开代码可能导致他人抄袭,并引发学术诚信问题。
  • 合理使用网络资源:在完成编程作业时,可以查阅官方文档(如Java Doc)学习如何使用某个类或方法。但是,严禁直接复制粘贴任何在线找到的代码。你可以阅读和理解概念,但必须自己重新编写代码。
  • 禁止查看他人作业代码:即使是查看他人的实现思路或测试用例,也可能导致你的代码与之过于相似,这同样违反规定。如果不小心看到,请立即关闭。

什么是泛型?🤔

现在,让我们进入今天的核心主题——Java泛型。在编程中,我们经常需要创建可以存储不同类型数据的数据结构。例如,一个列表可能需要存储整数、字符串或自定义的学生对象。如果没有泛型,我们就需要为每种数据类型编写一个单独的类,这非常低效。

泛型提供了一种解决方案:它允许我们创建一个“通用”的类,这个类在定义时使用一个占位符类型(通常用 TE 表示)。当实际使用这个类时,我们再指定具体的类型。

核心概念公式
类/接口名<T>,其中 T 是类型参数。

例如,一个简单的泛型列表类框架如下:

public class MyGenericList<T> {
    private T[] dataArray; // 声明一个类型为T的数组引用

    public MyGenericList(int size) {
        // 注意:不能直接创建泛型数组,如 `new T[size]` 是非法的
        dataArray = (T[]) new Object[size]; // 常见的变通方法
    }

    public void update(int index, T newData) {
        dataArray[index] = newData; // 使用类型T
    }

    public T get(int index) {
        return dataArray[index];
    }
}

使用这个泛型类时:

MyGenericList<Integer> intList = new MyGenericList<>(10); // T 被替换为 Integer
intList.update(0, 123); // 可以存储整数

MyGenericList<String> stringList = new MyGenericList<>(5); // T 被替换为 String
stringList.update(0, "Hello"); // 可以存储字符串

通过这种方式,MyGenericList 类变得非常灵活,可以用于任何数据类型。


泛型的限制与原理 ⚙️

理解了泛型的基本用法后,本节我们来看看它的一些限制以及背后的原理。这些限制主要源于Java处理泛型的方式——类型擦除

在Java中,泛型信息主要在编译阶段被处理。编译器会检查你的泛型代码是否正确使用。一旦通过编译,在生成的字节码中,所有的泛型类型参数(如 T)都会被替换为它们的上界,通常是 Object。这个过程就是“类型擦除”。

正因为如此,运行时(JVM)对泛型的具体类型一无所知,这导致了以下几个关键限制:

以下是三个主要的操作限制:

  1. 不能实例化泛型类型对象:你不能使用 new T() 来创建一个泛型对象,因为编译器在擦除类型后不知道 T 的构造函数。
  2. 不能创建泛型数组:你不能使用 new T[size] 来创建泛型数组。常见的替代方法是创建 Object 数组然后进行类型转换,如之前的例子所示。
  3. 不能对泛型类型使用 instanceof:例如 if (obj instanceof T) 是非法的,因为运行时 T 的信息已不存在。

为什么要有这些限制?
这是为了确保类型安全。编译器在编译时进行严格的类型检查,可以防止在运行时将错误类型的对象放入集合中,从而避免 ClassCastException 等错误。相比于早期直接使用 Object 类型(需要大量强制类型转换且容易出错),泛型提供了更安全、更清晰的编程方式。


泛型中的类型约束 🔗

有时,我们希望泛型类型不仅仅是任何类,而是满足特定条件的类,比如必须可比较。这时我们可以使用有界类型参数

核心概念公式
<T extends 父类或接口>

例如,如果我们想确保泛型类型 E 具有可比性,可以这样定义:

public class SortedContainer<E extends Comparable<E>> {
    // 在这个类里,我们可以安全地调用 e1.compareTo(e2) 方法
    // 因为编译器知道 E 是 Comparable 的子类型
}

这里,E extends Comparable<E> 表示类型参数 E 必须是实现了 Comparable<E> 接口的类。这为泛型类中的操作(如排序)提供了保证。


总结 🎯

本节课中我们一起学习了Java泛型。我们从了解其解决代码重复、提高复用性的目的出发,学习了如何定义和使用泛型类。接着,我们探讨了由于“类型擦除”机制带来的使用限制,并理解了这些限制是为了保障类型安全。最后,我们介绍了如何使用有界类型参数来约束泛型,使其具备特定的能力。

记住泛型的核心思想:编写一次,用于多种类型。在接下来的数据结构和算法学习中,泛型将是我们构建通用工具的强大武器。

003:泛型实践与数组操作

在本节课中,我们将通过一个泛型工作表(Generic Worksheet)的练习,来复习和巩固泛型、数组操作以及Java编程中的一些核心概念。我们将学习如何创建泛型数组、进行深浅拷贝、查找中位数和最后一个元素,以及移除数组中的第一个元素。

泛型数组的初始化与深浅拷贝

上一节我们讨论了泛型的基本概念,本节中我们来看看如何在类中声明和使用泛型数组。

首先,我们有一个泛型工作表类,它使用一个泛型类型 E,并包含一个泛型数组 data 作为实例变量。

public class GenericWorksheet<E> {
    private E[] data;
    // ... 其他代码
}

在构造函数中,我们需要初始化这个数组。以下是如何正确地进行初始化:

// 这是初始化实例变量的一种方式
this.data = (E[]) new Object[length];

请注意,由于Java的类型擦除机制,我们不能直接创建泛型数组(如 new E[length]),而需要先创建 Object 数组,然后将其类型转换为 E[]

接下来,我们探讨深浅拷贝的区别。浅拷贝意味着两个引用指向内存中的同一个数组对象。深拷贝则意味着创建一个全新的数组,并将原数组中的每个元素复制到新数组中。

以下是实现浅拷贝和深拷贝的方法:

浅拷贝: 直接将参数数组的引用赋值给实例变量。

public void shallowCopy(E[] paramData) {
    this.data = paramData; // 两个引用指向同一个数组
}

深拷贝: 创建一个新数组,并逐个复制元素。

public void deepCopy(E[] paramData) {
    // 1. 创建新数组
    this.data = (E[]) new Object[paramData.length];
    // 2. 复制元素
    for (int i = 0; i < paramData.length; i++) {
        this.data[i] = paramData[i];
    }
}

进行深拷贝时,必须首先初始化实例变量数组,否则会出现空指针异常。

查找数组的中位数

上一节我们处理了数组的拷贝,本节中我们来看看如何在一个泛型数组中查找中位数。

查找中位数的思路是:先对数组排序,然后根据数组长度的奇偶性返回中间的元素。但这里有几个重要的注意事项:

  1. 不要修改原数组: 应该先创建数组的一个副本,对副本进行排序和计算,以保持原数组不变。
  2. 泛型的局限性: 对于泛型数组,我们不能假设其元素支持算术运算(如加法、除法)。因此,当数组长度为偶数时,我们通常只返回排序后位于 length/2 索引处的元素,而不是计算两个中间元素的平均值。

以下是查找中位数方法的实现框架:

public E findMedian() {
    if (data == null || data.length == 0) {
        return null;
    }
    // 创建副本以避免修改原数组
    E[] copy = Arrays.copyOf(data, data.length);
    // 对副本进行排序
    Arrays.sort(copy);
    // 返回中位数(对于偶数长度数组,返回靠后的中间元素)
    return copy[copy.length / 2];
}

为了使 Arrays.sort() 能正常工作,泛型类型 E 必须实现 Comparable<E> 接口。在类声明时应进行约束:

public class GenericWorksheet<E extends Comparable<E>> {
    // ... 类体
}

查找与移除数组元素

现在,我们继续探讨对泛型数组的其他基本操作:查找最后一个元素和移除第一个元素。

查找最后一个元素: 这个操作相对简单,但必须进行严谨的错误检查。

以下是实现步骤:

  1. 检查数组引用是否为 null
  2. 检查数组是否为空(即长度为0)。
  3. 返回索引为 data.length - 1 的元素。

在组合条件判断时,要注意逻辑运算符的短路求值特性。|| 运算符会先计算左边的表达式,如果为真,则不再计算右边。因此,应该先检查 null,再检查长度,以避免对 null 引用调用 .length 导致空指针异常。

public E getLast() {
    if (data == null || data.length == 0) {
        return null;
    }
    return data[data.length - 1];
}

移除第一个元素: 移除操作需要创建一个比原数组小一个元素的新数组,并将剩余元素复制过去。

以下是实现步骤:

  1. 进行必要的空值和长度检查。
  2. 保存要移除的第一个元素。
  3. 创建一个大小为 data.length - 1 的新数组。
  4. 使用循环将原数组从索引1开始的元素复制到新数组。
  5. 将实例变量 data 指向这个新数组。
  6. 返回之前保存的第一个元素。
public E removeFirst() {
    if (data == null || data.length == 0) {
        return null;
    }
    E removedElement = data[0]; // 保存要返回的元素
    // 创建新数组
    E[] newArray = (E[]) new Object[data.length - 1];
    for (int i = 0; i < newArray.length; i++) {
        newArray[i] = data[i + 1]; // 从原数组的第二个元素开始复制
    }
    this.data = newArray; // 更新引用
    return removedElement;
}

实现toString方法

对于一个完整的类,实现 toString 方法是一个好习惯,它能方便地输出对象的内容。

对于我们的泛型工作表,toString 方法可以遍历数组,将每个元素的字符串表示连接起来。

@Override
public String toString() {
    if (data == null) {
        return "null";
    }
    StringBuilder result = new StringBuilder();
    for (E element : data) {
        result.append(element.toString()).append(" ");
    }
    return result.toString().trim();
}

实例化泛型类

最后,我们来看看如何实例化这个泛型类。由于类型擦除,在创建对象时,需要指定具体的类型参数。

例如,要创建一个处理 Integer 数组的 GenericWorksheet 对象:

Integer[] intArray = {1, 2, 3, 4, 5};
GenericWorksheet<Integer> worksheet = new GenericWorksheet<>(intArray);

注意,在构造函数的右侧(new GenericWorksheet<>),我们可以使用菱形运算符 <> 省略类型,编译器会根据左侧的声明进行推断。


本节课中我们一起学习了如何在一个泛型类中操作数组,包括数组的初始化、深浅拷贝、查找中位数、获取及移除元素等核心操作。我们特别强调了错误检查的重要性、避免修改原始数据的原则,以及泛型编程中类型约束(如 Comparable)和运算符短路求值的细节。掌握这些基础是进行更复杂数据结构设计和实现的关键。

004:抽象数据类型、测试与作业发布 🎯

在本节课中,我们将要学习抽象数据类型(ADT)与具体数据结构的区别,并介绍软件测试的基本概念,特别是黑盒测试与白盒测试。课程最后会发布第一项编程作业。

抽象数据类型 vs. 数据结构

上一节我们讨论了泛型,本节中我们来看看抽象数据类型(ADT)与具体数据结构的区别。在CSE 12课程中,我们会大量实现基础数据结构。因此,理解ADT与具体实现是两个完全不同的概念非常重要。

抽象数据类型(ADT) 是系统用户与系统实现者之间共同依赖的接口。它描述了系统应具备的功能,但不涉及具体实现方式。例如,烤箱的温度控制旋钮就是一个ADT,用户通过它来操作烤箱,而无需关心烤箱内部是使用电力还是燃气加热。

具体数据结构 则是ADT的一种特定实现方式。如果某种数据结构只有一种实现方式,那么它就更偏向于一个具体的数据结构,而非ADT。

两者的核心区别在于:ADT描述的是“做什么”,而数据结构定义了“如何做”。

ADT与API的区别

现在我们来澄清一个常见误解:ADT和API是相同的吗?答案是否定的。

API(应用程序编程接口) 是一组明确定义的函数、类或方法,供其他程序调用以使用某个工具箱或框架的功能。例如,Google的TensorFlow框架提供了一系列API函数。

ADT 则是对一个数据结构的抽象描述,定义了其行为规范,而不绑定到任何具体的代码接口。

因此,ADT更侧重于概念描述,而API是具体的编程接口,两者是不同的概念。

软件测试简介

在CSE 8A或11中,你们已经接触过测试。在CSE 12中,我们将更进一步,介绍工业界常用的测试理念和工具。

从第二次作业(PA2)开始,你需要提交自己编写的测试代码,这会被计入成绩。第一次作业(PA1)我们会提供测试代码,你只需要学会如何运行它来检验自己的程序。

黑盒测试与白盒测试

测试他人程序主要有两种方式:

以下是两种主要的测试方法:

  • 黑盒测试:测试者不了解也不关心程序内部如何实现。只根据给定的输入,检查程序是否产生预期的输出。例如,我们课程使用的自动评分系统就是黑盒测试。
  • 白盒测试(或透明盒测试):测试者就是代码的开发者,清楚代码的内部逻辑。可以根据代码的执行路径来设计定制化的测试用例。这是你应该为自己代码编写的测试类型。

理想情况下,你应该在开始编写程序之前就设计好测试用例,这有助于你更深入地理解问题。

测试覆盖与路径分析

如何设计好的白盒测试?关键在于实现测试覆盖,即让你的测试用例尽可能覆盖代码的所有执行路径。

考虑以下函数:

boolean foo(int x) {
    if (x > 5) {
        // 执行操作 A
    }
    for (int i = 0; i < x; i++) {
        if (i % 2 == 0) {
            // 执行操作 B
            break;
        }
    }
    return true;
}

设计测试时,你需要思考不同的 x 取值,如何让代码执行 if (x > 5) 分支和 分支,以及 for 循环执行 0次1次多次 等不同情况。你的目标是让测试用例覆盖从函数入口到出口的所有可能路径。

JUnit测试框架

在CSE 12中,我们将使用 JUnit 框架进行自动化测试。JUnit是一个用于Java编程语言的单元测试框架,可以方便地测试方法和类。

一个典型的JUnit测试类结构如下:

import org.junit.*;
import static org.junit.Assert.*;

public class CSE12Tester {
    CSE12 ref;

    @Before
    public void setUp() {
        ref = new CSE12();
    }

    @Test
    public void testDefaultConstructor() {
        assertEquals("Number of students should be 0", 0, ref.getNumStudents());
        assertTrue("Large class should be true by default", ref.isLargeClass());
    }

    @Test
    public void testParamConstructor() {
        CSE12 obj = new CSE12(200);
        assertEquals(200, obj.getNumStudents());
        assertFalse(obj.isLargeClass());
    }
}

以下是代码中关键部分的说明:

  • @Before 注解:标记一个方法在每个 @Test 方法运行前执行,常用于初始化测试对象,保证每个测试都在干净的环境下开始。
  • @Test 注解:标记一个方法为测试方法。
  • 断言方法:如 assertEquals(expected, actual),用于判断实际结果是否与预期一致。如果断言失败,测试即不通过。

全面的测试策略

编写测试时,一个常见的错误是只测试函数的返回值,而忽略了对象内部状态的完整性。

例如,测试一个从数组中获取元素的方法 get(int index)

  1. 你需要测试它是否返回了正确的值。
  2. 同样重要的是,你需要测试调用该方法后,原始数组的内容没有被意外修改,其他实例变量(如size)也保持正确。

因此,测试一个函数时,不仅要检查其返回值,还要验证该函数所依赖或影响的对象内部所有数据是否正确。

测试异常

对于会抛出异常的方法,也需要进行测试。在JUnit中,你可以使用 try-catch 块来捕获并验证异常。

@Test
public void testException() {
    CSE12 ref = new CSE12(-1); // 传入非法值
    boolean exceptionThrown = false;
    try {
        ref.officeHours(); // 此调用应抛出异常
    } catch (ArrayIndexOutOfBoundsException e) {
        exceptionThrown = true; // 成功捕获异常
    }
    assertTrue("Should have thrown an exception", exceptionThrown);
}

关于第一次编程作业(PA1)

本节课将发布第一次编程作业(PA1)。作业内容是实现一个“石头剪刀布”游戏的扩展版本。你会获得一个定义好的Java接口,你的任务是按照接口规范编写实现类。

我们会提供一个完整的JUnit测试文件。如果你的代码能通过所有提供的测试,那么你很有机会在该作业中获得满分。尽管如此,强烈建议你在此基础上添加自己的测试用例,例如测试更多自定义的“招式”组合,以确保代码在各种边界情况下都能正确运行。


本节课中我们一起学习了抽象数据类型(ADT)与具体数据结构的核心区别,明确了ADT与API的不同。我们深入探讨了黑盒测试与白盒测试的理念,并介绍了如何使用JUnit框架编写全面的单元测试,包括测试正常功能、对象状态完整性以及异常处理。最后,我们了解了第一次编程作业的要求。掌握这些测试技能对于后续实现和调试复杂数据结构至关重要。

005:Java集合、异常与JUnit测试入门

在本节课中,我们将学习Java集合框架的层次结构,回顾异常处理机制,并初步了解如何为类编写JUnit测试用例。这些知识对于完成后续的编程作业至关重要。

Java集合框架概述

Java、C++或Python等语言之所以流行,不仅因为它们提供了基础的编程工具,还因为它们内置了许多可以直接使用的功能。在Java中,集合框架就是一组可以免费使用的数据结构。

上一节我们介绍了课程的基本信息,本节中我们来看看Java集合框架的具体构成。

集合框架的整体层次结构如下图所示。虚线左侧的所有元素都是接口,它们作为父类,定义了抽象数据类型,规定了集合应具备的基本行为。从Collection接口衍生出SetListQueue等接口。

两条虚线之间的元素是抽象类。它们实现了对应接口中规定的部分功能,但并非全部,将一些具体实现留给了子类。

最右侧区域的元素是具体类,我们可以直接使用它们来创建对象。例如,TreeSetHashSetArrayListLinkedList等。

以下是这三类概念的区别总结:

  • 接口:不能实例化对象,但可以声明引用。只能包含方法声明。
  • 抽象类:不能实例化对象,但可以声明引用。可以包含构造器、具体方法和抽象方法。
  • 具体类:可以实例化对象,也可以声明引用。必须实现所有继承或实现的抽象方法。

理解这个层次结构有助于我们正确选择和使用集合类。你不需要记忆整个图表,但需要理解其设计原理。

集合框架理解练习

基于上述层次结构,请判断以下说法的正确性。

  1. LinkedList 是一个 List
  2. ArrayList 是一个 LinkedList
  3. List 是一个 ArrayList
  4. LinkedList 是一个 Collection

正确答案是1和4。 因为LinkedList实现了List接口,而List接口又继承自Collection接口,所以1和4正确。ArrayListLinkedList是并列的具体实现类,没有继承关系,故2错误。List是接口,ArrayList是实现类,是“父与子”的关系,因此“List是一个ArrayList”的表述是错误的,应为“ArrayList是一个List”,故3错误。

接下来,假设所有类都有默认构造器,请判断以下哪些代码行是合法的。

A. Collection c = new List();
B. ArrayList a = new LinkedList();
C. List myList = new ArrayList();
D. List myLinkedList = new LinkedList();
   Collection c = myLinkedList;

正确答案是C和D。 代码A错误,因为List是接口,不能使用new进行实例化。代码B错误,因为ArrayListLinkedList之间没有直接的继承关系,不能直接赋值。代码C正确,这是多态的典型应用,用父接口List的引用指向子类ArrayList的对象。代码D也正确,首先LinkedListList,所以第一行赋值合法;接着,List又是Collection,所以第二行将myLinkedList(此时编译时类型为List)赋给Collection引用也合法。

异常处理回顾

在CSE 11中我们已经接触过异常。异常是破坏程序正常执行流程的事件。在CSE 12中,我们将更频繁地使用异常,例如在实现ArrayList时,如果用户请求索引为-5的元素,我们就可以抛出一个异常来指示错误状态。

那么,为什么我们需要异常,而不是简单地返回一个特殊值(如-1或null)呢?原因有几个:首先,并非总能找到一个合适的“特殊值”来代表所有错误情况;其次,异常提供了一种独立于正常返回通道的错误信息传递机制,就像为消防车开辟了专用车道,使错误处理更加清晰和专一。

Java异常主要分为两类:受检异常非受检异常

以下是两者的核心区别:

  • 受检异常:编译器会强制检查的异常。如果代码可能抛出此类异常,则必须用try-catch块处理,或者在方法声明中用throws子句标明。常见的如IOException
  • 非受检异常:编译器不强制处理的异常,通常是程序逻辑错误导致的,如ArrayIndexOutOfBoundsExceptionNullPointerException。编译器不强制处理是因为这些错误本应通过修正代码来避免,而不是在运行时捕获并掩盖。

异常处理练习

让我们通过一些练习来巩固异常处理的知识。请完成以下代码片段。

  1. 生成并捕获一个ArithmeticException(算术异常)。

    try {
        int x = 7 / 0; // 除零操作会引发 ArithmeticException
    } catch (ArithmeticException e) {
        // 处理异常
    }
    
  2. 生成并捕获一个NumberFormatException(数字格式异常)。

    try {
        int x = Integer.parseInt("water"); // 解析非数字字符串会引发 NumberFormatException
    } catch (NumberFormatException e) {
        // 处理异常
    }
    
  3. 生成并捕获一个ArrayIndexOutOfBoundsException(数组索引越界异常)。

    try {
        int[] arr = new int[5];
        arr[20] = 7; // 访问索引20,远超数组长度,会引发 ArrayIndexOutOfBoundsException
    } catch (ArrayIndexOutOfBoundsException e) {
        // 处理异常
    }
    
  4. 处理多个catch块的情况。当有多个catch块时,异常会按顺序匹配第一个符合的catch块。因此,catch块的顺序很重要,应该将更具体的异常类型放在前面。

    try {
        // 抛出一个 ArrayIndexOutOfBoundsException
        throw new ArrayIndexOutOfBoundsException();
    } catch (ArithmeticException e) {
        // 不会匹配到这里
    } catch (Exception e) {
        // 会被这里捕获,因为 ArrayIndexOutOfBoundsException 是 Exception 的子类
    }
    

    可以在抛出异常时附加自定义信息:throw new ArithmeticException("这是我自定义的错误信息");,在catch块中可通过e.getMessage()获取。

  5. 处理受检异常。如果一个方法可能抛出受检异常,必须在方法声明中表明。

    public void checkedExceptionDemo() throws FileNotFoundException {
        // 可能抛出 FileNotFoundException 的代码
        throw new FileNotFoundException();
    }
    
    public static void main(String[] args) {
        try {
            checkedExceptionDemo(); // 调用可能抛出受检异常的方法,必须处理
        } catch (FileNotFoundException e) {
            // 处理异常
        }
    }
    

    如果main方法中不适用try-catch处理,则必须在main方法声明中也加上throws FileNotFoundException

JUnit测试入门实践

接下来,我们将练习编写JUnit测试。假设有一个Person类,其字段和构造器如下,我们将为其编写测试。

Person类概要:

  • 字段:age (int), name (String), height (String,格式为“英尺'英寸”,如"5'03"代表5英尺3英寸)。
  • 默认构造器:将age初始化为0,nameheight初始化为null
  • 带参构造器:接受age, name, height三个参数。
  • 方法:getNameLength(),返回名字的字符长度。

我们的任务是测试默认构造器。测试应验证通过默认构造器创建的对象,其所有字段是否被正确初始化。

以下是测试默认构造器的思路:

@Test
public void testDefaultConstructor() {
    Person person = new Person(); // 使用默认构造器创建对象
    assertEquals(0, person.getAge()); // 验证 age 应为 0
    assertNull(person.getName());     // 验证 name 应为 null
    assertNull(person.getHeight());   // 验证 height 应为 null
}

你需要为Person类编写类似的测试方法,并确保覆盖其所有行为。课后请尝试完成讲义中关于JUnit测试的其余部分。

课程总结

本节课中我们一起学习了三个主要内容:

  1. Java集合框架:了解了其接口、抽象类、具体类三层层次结构,以及如何根据多态性正确声明和使用集合引用。
  2. 异常处理:回顾了受检异常与非受检异常的区别,并通过练习掌握了抛出、捕获及声明异常的方法。
  3. JUnit测试入门:开始学习如何为类的行为编写单元测试,这是保证代码质量的重要手段。

下节课我们将深入探讨ArrayList数据结构的实现,这是你们下一个编程作业的核心。请确保理解本节课的内容,为后续学习打下基础。

006:JUnit测试与ArrayList入门 🚀

在本节课中,我们将要学习如何为代码编写JUnit测试,并开始探索我们的第一个数据结构——ArrayList。我们将了解其背后的核心概念、工作原理以及如何实现其基本方法。


JUnit测试回顾 📝

上一节我们介绍了JUnit测试的基本概念。本节中,我们来看看如何为一个可能抛出异常的类编写具体的测试用例。

我们有一个Person类,其构造函数和getNameLength方法在参数错误时可能抛出异常。在编写Person类本身之前,我们需要先为其编写测试。

测试默认构造函数

以下是测试默认构造函数的代码示例。我们调用默认构造函数创建一个对象,然后测试其变量是否正确。

@Test
public void testConstructor1() {
    Person p = new Person();
    // 断言测试对象的初始状态
}

测试可能抛出异常的构造函数

现在,我们需要编写测试来触发第二个构造函数(接收年龄、姓名和身高)的异常。以下是编写此类测试的方法。

  1. 测试单个错误参数:首先,应单独测试每个可能出错的参数。
  2. 测试错误参数组合:之后,可以测试多个参数同时错误的情况,以确保代码能正确处理复合错误。
  3. 测试正常情况:除了错误情况,也需要测试参数正确的正常情况,确保功能正常工作。

对于正常情况,可以将多个测试用例放在一个测试方法中。对于会抛出异常的错误情况,每个测试应单独编写,因为一旦抛出异常,其后的代码将不会被执行。

@Test
public void testConstructor2_WrongAge() {
    boolean thrown = false;
    try {
        Person p = new Person(-5, "CSE12", "5'11\"");
    } catch (IllegalArgumentException e) {
        thrown = true;
    }
    assertTrue("Age should be less than 0", thrown);
}

@Test
public void testConstructor2_NormalCase() {
    // 测试正常输入,不应抛出异常
    Person p = new Person(20, "CSE12", "5'11\"");
    // 可以进行其他断言,例如 assertNotNull(p);
}

重要提示:在编程作业中,请勿与他人分享你的测试代码,这被视为违反学术诚信的行为。


数据结构:ArrayList 🧱

现在,我们开始学习第一个数据结构——ArrayList。在CS11中,你们可能已经使用过它。ArrayList是一个实现了List接口的Java类,可以动态地插入和删除元素。

什么是List?

List是一个有顺序的集合。集合中的每个元素都有一个特定的位置,我们称之为索引(index)。这与Set(集合)不同,Set只是一组无序的元素。

ArrayList的本质

尽管ArrayList看起来是“动态的”,但其底层实现仍然是一个基础数组。数组在创建时大小是固定的。所谓“动态”,是我们通过创建新数组并复制旧数据来实现的假象。作为CSE12的学生,我们需要了解并实现这些“幕后工作”。

数组的核心特性

数组最重要的特性是其在内存中的连续性(Contiguous)。这意味着数组中的所有元素在内存中是相邻存储的。

连续性带来的好处:如果知道数组的起始内存地址,就可以通过简单的数学计算快速定位到任何一个元素的位置。这实现了常数时间(O(1)) 的访问速度。

计算公式为:
元素地址 = 数组起始地址 + 索引 * 元素大小

例如,一个整型数组(每个int占4字节)起始于内存地址200,那么第7个元素(索引为6)的地址是:
200 + 6 * 4 = 224

数组的缺点

连续内存的优点也是其缺点:插入和删除元素成本高昂

  • 在中间或开头插入:需要将该位置之后的所有元素向后移动一位。
  • 在中间或开头删除:需要将该位置之后的所有元素向前移动一位。
  • 在末尾操作:插入或删除末尾元素则不需要移动其他元素,效率最高。

因此,ArrayListadd(E e)方法默认将元素添加到列表末尾,因为这是最不“破坏性”的操作。

ArrayList的扩容

当数组已满,需要添加新元素时,ArrayList会进行“扩容”(resize)。这不是简单地增加一个位置,而是创建一个更大的新数组(通常是原容量的1.5倍或2倍),然后将旧数组的所有元素复制过去,最后再添加新元素。为了避免频繁扩容带来的性能损耗,通常会采用成倍扩容的策略。

实现ArrayList的基本方法

让我们来看一个简化的MyArrayList类的框架,并尝试实现其中两个基本方法。

public class MyArrayList<E> {
    private Object[] data; // 底层存储数组
    private int size;      // 列表中当前元素数量
    private static final int INITIAL_CAPACITY = 5; // 初始容量

    // 默认构造函数
    public MyArrayList() {
        data = new Object[INITIAL_CAPACITY];
        size = 0;
    }

    // 其他方法...
}

重要概念区分

  • 容量(Capacity)data.length,表示数组最多能容纳多少元素(房间里的椅子总数)。
  • 大小(Size)size,表示当前列表中有多少有效元素(坐在椅子上的学生数)。始终满足 size <= capacity

实现 append 方法

该方法将元素添加到列表末尾。

需要考虑的步骤:

  1. 检查元素是否为null(如果设计不允许)。
  2. 检查数组是否已满(size == data.length),如果已满则需要先扩容(本节课暂不实现扩容逻辑)。
  3. 将元素放入data[size]的位置。
  4. size加1。
public void append(E element) {
    if (element == null) {
        return; // 或抛出异常,取决于设计
    }
    // 注意:此处应检查并处理扩容,当前示例省略
    data[size] = element;
    size++;
}

实现 get 方法

该方法根据索引返回元素。

需要考虑的步骤:

  1. 检查索引是否越界。有效索引范围是 0 <= index < size
  2. 如果越界,抛出IndexOutOfBoundsException
  3. 返回对应索引的元素,由于dataObject[],需要将其转型为E
public E get(int index) {
    if (index < 0 || index >= size) {
        throw new IndexOutOfBoundsException("Index: " + index + ", Size: " + size);
    }
    // 需要类型转换
    return (E) data[index];
}

注意IndexOutOfBoundsException属于“unchecked”异常,在方法签名中不需要使用throws声明。


本节课中我们一起学习了如何编写更完善的JUnit测试来覆盖正常和异常情况,并深入探讨了ArrayList数据结构的核心原理——其基于连续内存数组的实现,以及由此带来的快速访问和插入删除成本高的特点。我们还初步实现了appendget两个基本方法。下节课我们将继续完善ArrayList的其他功能。

007:数组列表实现详解 🧱

在本节课中,我们将学习如何实现一个基础的数组列表数据结构。我们将从构造函数和基本方法开始,逐步深入到更复杂的操作,如删除和字符串表示,最后会介绍课程项目P2的要点。

数组列表基础

数组列表的后端是一个数组。我们有一个默认构造函数,其初始容量为5。size变量表示数组中实际被使用的元素数量。我们默认只使用数组从开头开始的连续位置。例如,如果容量为5但只使用了3个元素,那么索引0、1、2的位置被使用,其后的位置是空闲的。

追加元素方法

上一节我们介绍了数组的基本结构,本节中我们来看看如何向列表末尾添加元素。

append方法用于在列表末尾添加一个新元素。它的逻辑类似于C++中向量的push_back方法。

核心逻辑如下:

  1. 检查数组是否有足够容量。
  2. 如果有,则在size索引处插入新元素。
  3. 更新size
public void append(E element) {
    if (size == data.length) {
        // 需要扩容
        resize();
    }
    data[size] = element;
    size++;
}

当数组已满(即size == data.length)时,需要调用resize方法。这是一个代价较高的操作,通常会将数组容量翻倍,并复制所有旧元素到新数组中。

获取元素与删除首个元素

理解了添加元素后,我们来看看如何获取以及删除元素。

get方法根据索引返回元素,实现相对简单。

public E get(int index) {
    // 应包含索引越界检查
    if (index < 0 || index >= size) {
        throw new IndexOutOfBoundsException();
    }
    return (E) data[index]; // 注意类型转换
}

接下来,我们实现removeFirst方法,它用于移除并返回列表的第一个元素。这个操作效率较低,因为它需要将所有后续元素向左移动一位。

以下是实现步骤:

  1. 错误检查:如果列表为空(size == 0),则无法移除。
  2. 保存要返回的第一个元素。
  3. 执行左移循环,将每个元素复制到前一个位置。
  4. 更新size
  5. 返回保存的元素。

关键点: 在移位前保存对第一个元素的引用是有效的,因为引用指向的是对象本身,而不是数组中的位置。即使数组位置0的内容被覆盖,返回的引用依然指向原来的对象。

public E removeFirst() {
    if (size == 0) {
        throw new NoSuchElementException();
    }
    E toReturn = (E) data[0]; // 保存要返回的元素

    // 左移所有元素
    for (int i = 0; i < size - 1; i++) {
        data[i] = data[i + 1];
    }
    size--;
    // 可选:将最后一个空闲位置设为null
    data[size] = null;
    return toReturn;
}

转换为字符串

现在,我们来看看如何将列表内容转换为一个格式良好的字符串。

toString方法的目标是生成类似[元素1, 元素2, 元素3]的字符串。常见的错误包括使用数组长度length而不是实际大小size进行遍历,以及在最后一个元素后多添加一个逗号。

以下是优化的实现方法,它通过单独处理最后一个元素来避免在循环内进行冗余的条件判断:

public String toString() {
    if (size == 0) {
        return "[]";
    }
    StringBuilder sb = new StringBuilder("[");
    for (int i = 0; i < size - 1; i++) {
        sb.append(data[i].toString()).append(", ");
    }
    sb.append(data[size - 1].toString()).append("]");
    return sb.toString();
}

项目P2要点介绍

掌握了数组列表的基本操作后,我们简要了解一下即将发布的编程项目P2。

P2要求你完整实现一个自己的MyArrayList类。项目总分100分,其中75分用于实现,20分用于编写自定义测试,5分是代码风格分。

重要注意事项:

  • 禁止导入ArrayList 必须自己实现所有功能,导入Java内置的ArrayList会导致得零分。集成开发环境(IDE)的自动导入功能需格外小心。
  • 实例变量: 类中应只有两个实例变量:一个Object[]数组和一个int类型的size
  • 需要实现的方法: 包括expandCapacity(扩容)、insertappendprepend、各种removerotate(循环右移)、find等。
  • 编写测试: 除了提供的公开测试,你必须编写自己的测试用例,覆盖边界情况和异常输入。
  • 代码风格: 遵循提供的风格指南,包括文件头、类头、方法头注释,使用有意义的变量名,避免魔法数字,保持方法简短。鼓励创建私有辅助方法,但切勿创建公共辅助方法,否则会导致原型不匹配错误。
  • 尽早提交: 不要等到截止前一分钟才提交,以便有时间处理可能出现的编译或风格问题。

总结

本节课中我们一起学习了数组列表的核心实现。我们从基础的appendget方法开始,探讨了低效的removeFirst操作及其背后的引用原理,优化了toString方法的实现。最后,我们预览了项目P2的要求,强调了独立实现、充分测试和遵守代码规范的重要性。理解这些基础操作是掌握更复杂数据结构的基石。

008:链表基础

在本节课中,我们将要学习CSE 12课程中的第二个核心数据结构——链表。我们将了解链表的基本概念、它与数组的区别、其内部节点结构以及如何实现基本的插入操作。

概述

链表是一种动态数据结构,它通过节点和引用来组织数据。与数组不同,链表中的元素在内存中不一定是连续存储的。这使得链表在插入和删除元素时,尤其是在列表中间进行操作时,比数组更高效。本节课我们将重点学习单链表的基本原理和实现。

链表的基本概念

上一节我们提到了数组在处理动态数据时的局限性。本节中我们来看看链表是如何解决这些问题的。

链表的核心理念是将每个数据元素存储在一个独立的“节点”中。每个节点包含两部分:存储数据的部分(data)和指向下一个节点的引用(next)。通过这种方式,节点像链条一样被链接在一起。

节点类的代码表示

class Node<E> {
    E data;
    Node<E> next;
    // 构造函数等...
}

这里的 next 是一个引用(在Java中占用4字节),它指向另一个 Node 对象,而不是将整个节点对象嵌套进去。

链表与数组的对比

链表的设计旨在解决数组插入/删除元素时需要移动(“移位”)大量数据的问题。在链表中插入一个新节点,只需要改变相关节点的引用指向,无需移动其他节点。

然而,链表也有其缺点:访问(或称为“寻址”)效率较低。要访问链表中的第 i 个元素,必须从头部开始,逐个节点遍历 i 次,无法像数组那样通过索引直接访问。

链表的可视化与结构

一个链表通常由一个 LinkedList 类管理,该类包含指向第一个节点的引用(称为 head)和记录节点数量的 size 变量。

以下是链表的常见可视化形式:

  • head -> [data|next] -> [data|next] -> ... -> [data|null]
  • 最后一个节点的 next 引用为 null,表示链表结束。

有时,为了简化边界条件(如空链表)的处理,会在第一个真实节点前添加一个哑元节点(Dummy Node或Sentinel Node)。这个节点的 data 字段没有实际意义(常为 null),head 始终指向它,这样 head 永远不会是 null

双链表:每个节点不仅有指向下一个节点的引用(next),还有指向前一个节点的引用(prev)。这通常还会配合一个指向末尾节点的 tail 引用,使得从尾部进行操作更加方便。

链表的核心操作:连接节点

理解节点之间如何通过引用连接是掌握链表的关键。考虑以下代码:

Node n1 = new Node(5); // 节点1,数据为5
Node n2 = new Node(10); // 节点2,数据为10
n2.next = n1; // 将n2的next指向n1所指向的节点

执行 n2.next = n1; 后,n2.next 这个引用变量存储的值变得和 n1 相同,即它们指向内存中的同一个节点对象。引用赋值传递的是地址,而非对象本身

实现插入方法

现在,让我们尝试为单链表实现一个 add 方法,在指定索引处插入一个新元素。

以下是实现思路和关键步骤:

  1. 参数检查:确保索引 index 有效(0 <= index <= size)。如果无效,抛出异常。
  2. 定位前驱节点:为了在索引 index 处插入,我们需要找到位置 index-1 的节点(即新节点的前一个节点)。从 head(或 head.next,如果使用哑元节点)开始,使用一个临时引用(如 curr)通过循环向后移动 index 次。
    Node<E> curr = head;
    for (int i = 0; i < index; i++) {
        curr = curr.next;
    }
    // 循环结束后,curr指向索引为 index-1 的节点
    
    注意:不能直接移动 head 引用,否则会丢失对整个链表的访问。
  3. 创建并链接新节点
    • 方法A(使用特定构造函数):如果节点类有一个构造函数 Node(E data, Node<E> before),它可以将新节点插入到 before 节点之后。那么只需调用 new Node(newItem, curr)
    • 方法B(手动链接)
      Node<E> newNode = new Node<>(newItem); // 创建新节点
      newNode.next = curr.next; // 新节点指向原位置节点
      curr.next = newNode;      // 前驱节点指向新节点
      
  4. 更新大小:将链表的 size 加1。

总结

本节课中我们一起学习了链表的基础知识。我们了解到链表通过节点和引用来组织数据,解决了数组插入/删除时的“移位”问题,但牺牲了随机访问的效率。我们探讨了链表的结构(包括哑元节点和双链表),并通过内存模型理解了节点间的连接原理。最后,我们一步步分析了如何在单链表中实现插入操作,这是后续实现更复杂链表操作的基础。请务必动手练习绘制内存图和编写相关代码,以加深理解。

009:链表与迭代器 🧩

在本节课中,我们将要学习链表的剩余部分,特别是如何手动操作节点链接,并深入探讨迭代器的概念、工作原理及其在数据结构遍历中的应用。


链表插入操作的细节 🔗

上一节我们介绍了链表的基本插入操作。本节中,我们来看看如果不依赖节点的特殊构造函数,如何手动完成节点的链接。

假设我们有一个单链表,current指针指向我们想要插入新节点的位置之前。目标是创建一个新节点temp,并将其正确地插入到current之后。

以下是手动链接新节点的正确步骤:

  1. 首先,将新节点tempnext指针指向current节点原本的下一个节点。这确保了新节点之后链表的连续性。
    temp.next = current.next;
    
  2. 然后,将current节点的next指针指向新节点temp。这完成了新节点到链表中的链接。
    current.next = temp;
    

核心要点:这两个步骤的顺序至关重要。必须先保存current.next的引用(步骤1),然后再修改current.next(步骤2)。如果顺序颠倒,会导致链表断裂或形成循环引用。

这个逻辑同样适用于在链表末尾插入节点。当current指向最后一个节点时,current.nextnull。执行上述步骤后,temp.next将被设为null,而current.next指向temp,从而正确地将新节点添加到了链表尾部。


不同类型的链表 📚

链表有多种变体,以适应不同的需求。

以下是几种常见的链表类型:

  • 单链表:每个节点包含数据和指向下一个节点的引用(next)。通常有一个头节点(head)指向第一个节点(或哑元节点),并维护一个size变量记录长度。
  • 双链表:每个节点除了数据和next引用外,还包含一个指向前一个节点的引用(prev)。这种结构允许从两个方向遍历链表。实现时通常还会维护一个尾节点(tail)指针。
  • 循环链表:链表的最后一个节点的next指针指向头节点,形成一个环。双链表也可以构成循环结构。这些变体不如单链表和双链表使用广泛。

迭代器:遍历的通用工具 🚀

我们已经了解了如何组织数据(如链表)。现在,我们来看看用户如何访问这些数据,而无需了解其内部结构。这就是迭代器的作用。

迭代器充当了数据结构和用户之间的“中间人”。它提供了一种统一的方式来顺序访问一个集合(如链表、数组、树)中的元素,同时隐藏了底层数据组织的具体细节。例如,Scanner类本质上就是一个用于遍历输入流(如文件)的迭代器。

对于用户来说,他们只需要知道如何使用迭代器的几个标准方法(如next, hasNext),而不必关心数据是存储在数组、链表还是其他复杂结构中。


链表迭代器的实现细节 ⚙️

在CSE 12中,你将需要为链表实现一个迭代器。让我们深入了解一下它的设计。

一个链表迭代器本身是一个对象。它不直接“坐在”某个节点上,而是“跨坐”在两个节点之间。它通常包含以下实例变量来跟踪状态:

  • left: 指向迭代器当前位置左侧的节点。
  • right: 指向迭代器当前位置右侧的节点。
  • index: 指示迭代器在链表中的逻辑位置。
  • forward: 一个布尔值,记录上一次移动方向(调用next后为true,调用previous后为false)。
  • canRemove: 一个布尔值,指示当前是否允许调用remove方法。

当用户从链表获取一个迭代器时,链表会返回一个配置好初始状态(例如,left指向哑元头节点,right指向第一个实际节点)的迭代器对象。

重要概念:区分变量本身变量的值。例如,在迭代器内部,this.right这个引用变量存储的值是某个Node对象的地址。要访问该节点的next字段,需要使用this.right.nextthis.right是迭代器的实例变量,而this.right.nextNode对象的实例变量。


迭代器方法的行为与约束 ⚠️

使用迭代器遍历链表是简单的,但若要通过迭代器修改链表(插入、删除、替换),则必须遵守特定的规则,以保持迭代器状态的正确性。

以下是关键方法及其约束:

  • add(E element):在迭代器当前位置插入新元素。新元素被插入到next()方法将返回的元素之前,或previous()方法将返回的元素之后。插入后,迭代器位置不变,后续调用next()不受影响,而调用previous()将返回新插入的元素。
  • remove():移除由最近一次next()previous()调用所返回的元素。此方法只能在每次成功调用next()previous()之后调用一次。它依赖于forward标志来判断该移除left还是right所指向的节点。
  • set(E element):替换由最近一次next()previous()调用所返回的元素。与remove()类似,它也只能在调用next()previous()之后进行,并且不能与add()remove()操作混淆。

这些约束确保了在通过迭代器修改集合时,迭代器自身的位置和状态仍然是可预测且有效的。


迭代器操作示例 🧪

让我们通过一个具体问题来巩固理解。假设有一个双链表和一个处于初始位置的迭代器。

考虑调用迭代器的next()方法:

  • 返回值next()方法应返回right指针所指向节点的数据right.data),而不是节点引用本身。用户通过迭代器永远接触不到底层的Node对象。
  • 状态变化:调用next()后,迭代器的多个内部状态需要更新:
    1. left指针向右移动(指向原right节点)。
    2. right指针向右移动(指向原right.next节点)。
    3. index增加1。
    4. forward标志应设置为true
    5. canRemove标志应设置为true(因为刚刚通过next()移动过)。

理解这些内部状态的变化对于正确实现迭代器至关重要。


本节课中我们一起学习了链表插入链接的手动实现、不同类型链表的区别,并重点探讨了迭代器的核心概念。我们了解到迭代器如何作为数据结构和用户之间的抽象层,以及实现和使用迭代器时需要遵循的关键规则和状态管理逻辑。掌握这些知识将为后续实现链表迭代器打下坚实基础。

010:迭代器实现与运行时分析入门 🧠

在本节课中,我们将学习如何为一个自定义链表实现迭代器,并初步了解程序运行时分析的重要性。我们将通过一个具体的“FriendList”示例,逐步构建节点类、迭代器类和链表类,并理解它们如何协同工作。


节点类的实现 🔗

上一节我们讨论了迭代器的概念,本节我们首先来实现链表的基本构建块——节点(Node)。

节点类包含数据和指向下一个节点的引用。在我们的示例中,它是一个内部类,这意味着它可以访问外部链表类的私有成员,反之亦然。

以下是节点类的核心部分,我们需要完成其构造方法:

public class Node {
    String data;
    Node next;

    // 构造方法1:创建一个孤立节点
    public Node(String d) {
        data = d;
        next = null;
    }

    // 构造方法2:在指定节点‘before’之后插入新节点
    public Node(String d, Node before) {
        // 需要完成的三行代码:
        // 1. 将新节点的数据字段设置为 d
        // 2. 将新节点的 next 指向 before 节点原来指向的下一个节点
        // 3. 将 before 节点的 next 指向这个新节点
        data = d;
        next = before.next;
        before.next = this;
    }

    // 获取下一个节点的方法
    public Node next() {
        return next;
    }
}

代码解释

  • 第一行 data = d; 将传入的数据 d 存储到节点的 data 字段。
  • 第二行 next = before.next; 让新节点指向 before 节点原本指向的下一个节点。
  • 第三行 before.next = this;before 节点的 next 引用更新为指向这个新创建的节点,从而完成插入。


迭代器类的实现 🚶

现在,我们来看看如何为链表实现一个迭代器。迭代器的目的是让用户能够方便地遍历链表,而无需了解其内部结构。

我们的 FriendIterator 是一个内部类,它维护着几个关键状态:leftrightindexcanRemove

构造方法与初始化

首先,我们需要完成迭代器的构造方法,正确初始化其状态。

public class FriendIterator implements Iterator<String> {
    private Node left;
    private Node right;
    private int index;
    private boolean canRemove;

    // 构造方法
    public FriendIterator() {
        // 需要完成的四行初始化代码:
        left = head;          // left 指向头节点(哑元节点)
        right = left.next();  // right 指向第一个实际数据节点(如果存在)
        index = 0;            // 索引从0开始
        canRemove = false;    // 初始时不能删除,因为尚未调用 next() 或 previous()
    }
}

状态说明

  • leftright 定义了迭代器“光标”的位置,left 是刚访问过的节点,right 是将要访问的下一个节点。
  • index 记录当前迭代器在链表中的逻辑位置。
  • canRemove 是一个安全标志,只有调用 next()previous() 之后,它才变为 true,此时才能调用 remove() 方法。

核心遍历方法:hasNext 与 next

接下来,我们实现判断是否有下一个元素的 hasNext() 方法和获取下一个元素的 next() 方法。

    // 判断是否还有下一个元素
    public boolean hasNext() {
        // 如果当前索引小于链表大小,则存在下一个元素
        return index < size;
    }

    // 返回下一个元素,并移动迭代器
    public String next() {
        String result = null;
        if (size == 0) {
            return null; // 链表为空,返回null
        }
        // 这是一个定制化的next方法:只返回以字母‘A’开头的字符串
        if (right.data().startsWith("A")) {
            result = right.data();
        } else {
            result = null;
        }

        // 移动迭代器的四步操作:
        if (right.next() != null) {
            left = left.next();   // 1. left 移动到下一个节点
            right = right.next(); // 2. right 移动到下一个节点
            index++;              // 3. 索引增加
            canRemove = true;     // 4. 允许删除操作
            // 注意:实际还应设置 forward = true 以指示向前移动
        }
        return result;
    }

方法要点

  • hasNext() 通过比较当前 index 和链表总 size 来判断。
  • 这个示例中的 next() 方法被定制为只处理以‘A’开头的字符串,展示了迭代器行为的可定制性。
  • 移动迭代器需要同步更新 leftrightindexcanRemove 四个状态。


链表主类的实现 📝

最后,我们整合节点和迭代器,完成链表主类 FriendList 的框架。

链表初始化

链表包含头节点、尾节点和大小信息。初始化时需要建立头尾哑元节点之间的连接。

public class FriendList {
    private Node head;
    private Node tail;
    private int size;

    // 构造方法
    public FriendList() {
        head = new Node(null); // 创建头哑元节点
        tail = new Node(null); // 创建尾哑元节点
        // 连接头尾节点(对于双向链表)
        head.next = tail;
        tail.prev = head; // 假设Node类有prev字段
        size = 0;
    }
}

插入元素与获取迭代器

插入元素的方法与我们之前实现的 add 方法类似。此外,链表需要提供一个方法来获取迭代器实例。

    // 在链表末尾插入元素
    public void insert(String element) {
        if (element == null) return;
        Node current = head;
        // 遍历到最后一个实际节点之前(即tail的前一个)
        while (current.next != tail) {
            current = current.next;
        }
        // 使用Node的第二个构造方法,在current后插入新节点
        new Node(element, current);
        size++;
    }

    // 返回此链表的迭代器
    public Iterator<String> iterator() {
        return new FriendIterator(); // 创建并返回一个新的迭代器对象
    }

调试技巧:打印链表信息

在实现数据结构时,一个能打印节点数据和引用地址的 print 方法对于调试至关重要。

    public void print() {
        Node current = head.next; // 跳过头哑元节点
        while (current != tail) { // 遇到尾哑元节点停止
            // 打印:当前节点数据 + “ -> ” + 当前节点next引用指向的地址
            System.out.println(current.data + " -> " + current.next);
            current = current.next;
        }
    }

调试提示:打印出的引用地址(如 @4e50df2e)可以帮助你验证节点之间的连接是否正确。前一个节点的 next 输出地址应该等于下一个节点自身的地址。


使用迭代器遍历链表 🔄

链表和迭代器都实现后,我们可以这样使用它们:

    public static void main(String[] args) {
        FriendList myFriends = new FriendList();
        myFriends.insert("Bob");
        myFriends.insert("Abigail");
        myFriends.insert("Charlie");
        myFriends.insert("Alice");

        // 获取迭代器并遍历
        Iterator<String> itr = myFriends.iterator(); // 第一空:获取迭代器
        while (itr.hasNext()) {                     // 第二空:判断是否有下一个元素
            String name = itr.next();
            if (name != null) {
                System.out.println(name); // 只会打印出以‘A’开头的名字
            }
        }
    }


运行时分析简介 ⏱️

在实现了功能正确的数据结构后,我们必须考虑其效率。这就是运行时分析的意义。

衡量算法效率通常有两种方式:

  1. 基准测试:编写代码,在特定机器和输入上实际运行并计时。

    • 优点:结果真实可靠,是工业界和科研中验证性能的最终手段。
    • 挑战:结果受编程语言、程序员水平、机器性能、输入数据等多种因素影响,需要严格控制变量。
  2. 理论分析:我们将在课程中重点学习的方法。它不依赖于具体机器和实现,而是分析算法执行的基本操作数量(如比较、赋值)与输入规模 n 之间的关系,并用大O符号(如 O(n)O(n²))来描述其渐进时间复杂度

    • 优点:抽象、通用,便于在实现前比较不同算法思想的优劣。
    • 目标:编写不仅正确,而且高效的代码。

虽然基准测试至关重要,但在CSD12中,我们将从理论分析入手,建立评估算法效率的思维框架。


本节课中,我们一起学习了如何为自定义链表实现迭代器,包括节点、迭代器和链表主类的完整构建流程。我们还初步了解了程序运行时分析的重要性,以及基准测试与理论分析的区别。掌握这些知识,是编写高效、专业代码的基础。

011:算法运行时间分析入门

在本节课中,我们将要学习算法运行时间分析的基础概念,包括基准测试与渐近分析的区别、如何计算最坏情况运行时间,以及如何使用大O符号来描述算法的时间复杂度。

基准测试与渐近分析

上一节我们介绍了算法效率的重要性。本节中我们来看看评估算法效率的两种主要方法。

第一种方法是基准测试。这意味着将不同想法的代码实现出来,然后比较它们的实际运行时间。基准测试的好处是结果真实可靠。然而,它的一个潜在问题是,如果某个程序员编码能力更强,即使他的算法思想稍差,也可能获得更快的基准测试结果。

第二种方法是渐近分析。这种方法通过数学分析来估算程序需要执行的操作数量,而无需实际编写代码。你只需审视算法思路或伪代码,然后进行计数。这种方法的问题在于它并非基于真实运行数据。当两种算法的渐近分析结果相似时,我们仍需借助基准测试来决断。

在CSE12课程中,我们主要关注渐近分析。但请记住,在实际的计算机科学研究或工作中,这两种方法通常都需要。

理解最坏情况分析

当我们分析算法时,需要考虑不同的输入场景。以一个包含20首歌曲的播放列表为例,我们想查找其中一首特定的歌曲。

以下是可能的情况:

  • 最佳情况:第一首歌曲就是我们要找的。只需查看1次。
  • 最坏情况:要查找的歌曲不存在于列表中,或者它是列表中的最后一首。需要查看全部20次。

在计算机科学中,我们通常关注最坏情况。因为我们不能依赖运气,最坏情况给出了程序运行时间的上界,这有助于我们进行可靠的规划和预算。

计算操作步骤

让我们通过一个具体的代码示例来学习如何计数。假设我们有一个包含 n 个元素的列表,我们使用一个简单的循环来查找某个目标值。

boolean find(String[] list, String target) {
    for (int i = 0; i < list.length; i++) {
        if (list[i].equals(target)) {
            return true;
        }
    }
    return false;
}

在最坏情况下(目标不存在),我们来计数执行的操作:

  1. int i = 0 执行 1 次。
  2. i < list.length 比较执行 n+1 次(包括最后一次导致循环退出的比较)。
  3. i++ 自增操作执行 n 次。
  4. list[i].equals(target) 方法调用执行 n 次(注意:字符串比较本身可能很耗时,但此处我们暂时忽略其内部细节)。
  5. return false 执行 1 次。

因此,总操作数大致为 3n + 3。这是一个关于输入大小 n线性函数。

大O符号:忽略常数因子

上一节我们计算出了具体的操作数。本节中我们来看看如何用大O符号来简化描述。

观察以下两个查找函数:

// 函数A
boolean find(String[] list, String target) {
    for (int i = 0; i < list.length; i++) {
        if (list[i].equals(target)) {
            return true;
        }
    }
    return false;
}

// 函数B
boolean mysteryFind(String[] list, String target) {
    int count = 0;
    for (int i = 0; i < list.length; i++) {
        count = count + 1; // 一个额外的操作
        if (list[i].equals(target)) {
            return true;
        }
    }
    return false;
}

函数A的操作数约为 3n + 3,函数B由于多了一个计数操作,可能约为 4n + 4。在基准测试中,函数A可能更快。然而,在进行渐近分析时,我们使用大O符号来忽略常数因子和低阶项。因此,两者都被归类为 O(n),即线性时间复杂度。

大O符号的正式定义是:我们说函数 f(n) = O(g(n)),如果存在正常数 cn₀,使得对于所有 n ≥ n₀,都有 f(n) ≤ c · g(n)

这意味着当 n 足够大时,g(n) 乘以某个常数后,会成为 f(n) 的上界。我们只关心输入规模很大时的增长趋势。

如何确定时间复杂度

最后,我们来总结一下分析算法时间复杂度的步骤。

以下是基本流程:

  1. 计数:分析代码,计算在最坏情况下作为输入大小 n 的函数的基本操作次数。
  2. 找出主导项:简化计数结果,保留当 n 趋于无穷大时增长最快的项(主导项),并忽略其系数和常数。
  3. 用大O表示:用大O符号描述这个主导项。例如,如果操作数是 5n² + 3n + 10,则时间复杂度为 O(n²)

总结

本节课中我们一起学习了算法运行时间分析的核心概念。我们了解了基准测试与渐近分析的区别,明白了为什么需要关注最坏情况,并练习了通过计数操作步骤来估算运行时间。最重要的是,我们学习了大O符号的意义——它用于描述算法时间复杂度的渐进上界,并忽略常数因子和低阶项,使我们能够专注于算法随输入规模增长的根本效率。

012:算法复杂度分析(下)与常见误区

在本节课中,我们将完成对算法运行时间(Runtime)分析的探讨,并通过一系列练习来加深理解。我们将学习大O、大Ω和大Θ符号的确切含义,分析不同代码片段的复杂度,并了解一些在实际编程中常见的性能陷阱。

上一节我们介绍了大O符号的基本概念,它是一种描述算法在最坏情况下性能上界的渐进分析方法。本节中,我们将进一步学习大Ω和大Θ符号,并通过对比练习来掌握如何准确分析代码的复杂度。

大O符号的精确理解

首先,我们回顾一个关键点:大O符号表示的是函数的上界(小于等于)。这意味着,如果一个函数 f(n) 属于 O(g(n)),那么 f(n) 的增长速度不会比 g(n) 快。

考虑函数 f(n) = n log n。其主导项是 n log n

  • 最准确的描述(紧确界)是 O(n log n)
  • 但同时,f(n) 也属于 O(n²),因为 n log n 的增长速度确实不比 快。

这就像描述一个人的身高:“我不高于6英尺”是准确的信息,而“我不高于100万英尺”虽然正确,但信息量不足。在分析算法时,我们通常追求最紧确的界(即 O(n log n)),因为它提供了最精确的性能描述。

大Ω与大Θ符号

除了描述上界的大O符号,我们还有描述下界的大Ω符号和描述紧确界的大Θ符号。

大Ω符号 (Big Omega)

大Ω符号表示函数的下界(大于等于)。定义如下:

公式f(n) ∈ Ω(g(n)) 当且仅当存在正常数 cn₀,使得对于所有 n ≥ n₀,有 f(n) ≥ c * g(n)

这意味着 g(n)f(n) 性能的一个“地板”,f(n) 的增长速度不会比 g(n) 慢。

大Θ符号 (Big Theta)

大Θ符号表示函数的紧确界。定义如下:

公式f(n) ∈ Θ(g(n)) 当且仅当存在正常数 c₁, c₂n₀,使得对于所有 n ≥ n₀,有 c₁ * g(n) ≤ f(n) ≤ c₂ * g(n)

另一种理解方式是:如果 f(n) 同时属于 O(g(n))Ω(g(n)),那么 f(n) 属于 Θ(g(n))。大Θ描述的是函数的精确复杂度等级。

例如:

  • 3n + 20 ∈ Θ(n)
  • 5n² + 50n + 3 ∈ Θ(n²)

在实际讨论中,当人们问“这个算法的大O是多少”时,他们通常指的是大Θ所表示的紧确界。虽然用大O来近似表示紧确界并不完全错误,但大Θ才是更精确的描述方式。

复杂度分析练习与常见误区

以下是几个需要仔细分析的代码示例,它们揭示了复杂度分析中的常见陷阱。

示例1:循环次数与输入大小无关

考虑以下代码片段:

int sum = 0;
int start = ...; // 某个起始索引
int range = 100;
for (int i = start; i < start + range; i++) {
    sum += array[i];
}

问题:这段代码的 Θ 复杂度是多少?(假设数组长度为 n

分析:关键点在于内层循环的迭代次数由 range 决定,而 range 是一个固定值100。无论数组长度 n 多大,这个循环都只执行100次。因此,这是一个常数时间操作。

结论Θ(1)不要仅仅因为看到循环就认为是线性时间,必须检查循环的终止条件是否与输入规模 n 相关。

如果代码改为 int range = n / 100;,那么循环次数就与 n 相关,复杂度变为 Θ(n)

示例2:链表遍历的陷阱

在项目P3中,我们实现了链表。考虑以下两种遍历链表并打印所有元素的方式:

代码块1:使用迭代器 (Iterator)

LinkedList list = new LinkedList();
// ... 插入 n 个元素
Iterator iter = list.iterator();
while (iter.hasNext()) {
    System.out.println(iter.next());
}

代码块2:使用 get 方法

LinkedList list = new LinkedList();
// ... 插入 n 个元素
for (int i = 0; i < list.size(); i++) {
    System.out.println(list.get(i));
}

问题:这两种方式的运行时复杂度相同吗?

分析

  • 迭代器遍历:迭代器在链表中维护一个当前位置的指针。每次调用 next(),它只需将指针移动到下一个节点。因此,遍历整个链表需要 n 步。
    • 复杂度Θ(n)
  • get(i) 方法遍历:在单链表的典型实现中,get(i) 方法每次调用都需要从链表头部开始,逐个节点“跳” i 次才能到达目标索引。当在循环中调用 get(0), get(1), ..., get(n-1) 时,总的工作量是 0 + 1 + 2 + ... + (n-1) ≈ n²/2
    • 复杂度Θ(n²)

结论:两者复杂度不同。使用 get 方法遍历链表是一个性能极差的习惯,因为它导致了平方级的时间复杂度。遍历链表时,务必使用迭代器

对于 ArrayListget(i) 是常数时间操作,所以两种方式的复杂度都是 Θ(n)

常见复杂度等级排序

作为一名计算机科学学生,应该对常见的算法复杂度等级及其优劣有直观认识。以下是从最差到最优的常见复杂度类型:

  1. 阶乘级 Θ(n!):通常无法处理,属于“难解”问题。
  2. 指数级 Θ(cⁿ) (c > 1):如 Θ(2ⁿ), Θ(3ⁿ)。同样非常糟糕,基数越大越差。3ⁿ2ⁿ 增长更快。
  3. 多项式级
    • Θ(n³)
    • Θ(n²)
    • Θ(n log n):优于 Θ(n²),但差于 Θ(n)
    • Θ(n)
    • Θ(√n)
  4. 对数级 Θ(log n):非常高效。不同底数的对数(如 log₂n, log₁₀n)属于同一复杂度等级。
  5. 常数级 Θ(1):最优情况,运行时间与输入规模无关。

关于最好、最坏、平均情况的说明

最好情况、最坏情况、平均情况描述的是算法在不同输入下的表现
大O、大Ω、大Θ是用来描述这些情况下运行时间的工具。

它们之间没有一对一的匹配关系。例如:

  • 你可以用大O来描述最坏情况的上界。
  • 你也可以用大Ω来描述最好情况的下界。
  • 你同样可以用大Θ来描述平均情况的紧确界。

不要错误地认为“最坏情况一定用大O描述,最好情况一定用大Ω描述”。

总结

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

  1. 大Ω符号:表示算法运行时间的下界(“不会比...更好”)。
  2. 大Θ符号:表示算法运行时间的紧确界,是最精确的描述方式。
  3. 复杂度分析实践:通过具体代码示例,我们认识到分析时必须仔细检查循环条件(是否与 n 相关),并了解了链表遍历中使用迭代器与 get 方法的巨大性能差异。
  4. 复杂度等级:建立了从阶乘、指数、多项式到对数和常数级别的复杂度优劣排序直觉。
  5. 概念辨析:厘清了“最好/最坏/平均情况”与“大O/大Ω/大Θ”这两组概念之间的关系,它们是不同维度的描述工具。

掌握这些概念能帮助你在不实际编写代码的情况下,高效地评估和比较不同算法或设计方案的潜在性能。

013:期中复习与迭代器深入

在本节课中,我们将复习期中考试的相关信息,并深入探讨迭代器的实现细节和运行时分析。课程内容将围绕期中考试准备、迭代器方法的行为分析以及不同数据结构上操作的运行时复杂度展开。

期中考试信息

下周我们将进行期中考试。考试将涵盖从第一天到今天的全部内容,包括异常、泛型、数组、链表、迭代器和运行时分析等主题。

考试形式将与以往有所不同,将包含相当数量的编程题。这些编程题将基于编程作业中的方法,但会进行轻微修改。因此,理解并能够独立编写代码至关重要。

迭代器方法分析

上一节我们介绍了期中考试,本节中我们来看看迭代器内部方法的具体行为。理解迭代器中各个实例变量(如 leftrightindexcanMoveforward)在调用不同方法时的变化是关键。

以下是调用不同迭代器方法时,哪些变量会被修改的分析:

  • next() 方法:调用 next() 会返回当前 right 指针指向的元素。leftright 指针会向前移动一位,index 会增加1,canMoveforward 会被设置为 true
  • previous() 方法:其行为与 next() 类似,但方向相反。leftright 指针会向后移动,index 会减少1,canMove 会被设置为 trueforward 会被设置为 false
  • remove() 方法:此方法会移除最近一次 next()previous() 调用返回的元素。具体修改 left 还是 right 指针取决于 forward 的值。index 可能根据删除节点的位置而改变。调用后,canMove 必须被重置为 false,以防止连续删除。
  • add(E e) 方法:此方法将新元素插入到隐式游标之前。leftright 指针之一会更新以指向新节点,index 会增加1。调用 add 后,canMove 必须被设置为 false。此外,链表的 size 也需要更新。

编程作业注意事项

在开始编程作业4之前,请确保你的编程作业3(链表实现)是完全正确的。编程作业4的迭代器测试将使用我们提供的链表实现,如果你的链表有错误,可能会影响迭代器的测试结果,但不会因此被扣分。然而,为了顺利实现迭代器,一个功能正确的链表是基础。

编程作业4主要关注细节,没有特别复杂的逻辑。仔细阅读作业说明,并注意在实现各个方法时正确更新相关的实例变量。

运行时复杂度分析

我们之前讨论了遍历链表时应避免使用 get(i) 方法,因为其时间复杂度是 O(n),在循环中使用会导致 O(n²) 的总复杂度。使用迭代器进行遍历可以将复杂度优化到 O(n)。

现在,我们来分析在 ArrayList 上进行类似操作的时间复杂度。

  • 使用迭代器遍历 ArrayList:时间复杂度为 O(n)。迭代器内部通常使用索引,每次移动是常数时间操作。
  • 使用 get(i) 循环遍历 ArrayListArrayListget(i) 是常数时间操作 O(1)。因此,整个循环的时间复杂度仍然是 O(n)

关键在于,ArrayList 支持常数时间的随机访问,而链表不支持。

大O符号与数据结构操作复杂度

以下是关于算法复杂度大O符号和数据结构基本操作的一些判断题解析。我们默认讨论最坏情况下的时间复杂度。

  1. 若 f(n) 是 O(n),则 f(n) 也是 O(n²)正确。大O符号表示上界,一个属于 O(n) 的函数必然也属于更宽松的上界 O(n²)。
  2. 若 f(n) 是 O(n),g(n) 是 O(n²),则 f(n) 优于 g(n)错误。如果不假设紧确界,我们无法比较。例如,f(n)=3n+5 是 O(n),g(n)=100 是 O(n²),但显然对于较小的n,g(n) 更小。
  3. 大O符号关心输入规模n很大时的行为正确
  4. 在数组中插入一个元素的时间复杂度是 O(n)正确。最坏情况(在数组开头插入)需要移动其后所有元素。
  5. 在数组中搜索一个元素的时间复杂度是 O(n²)错误。顺序搜索的最坏情况是 O(n)。
  6. 在数组中删除一个元素的时间复杂度是 O(1)错误。类似插入,最坏情况(删除开头元素)需要移动元素,是 O(n)。
  7. 在链表中插入一个元素的时间复杂度是 O(1)错误。找到插入位置可能需要遍历链表,最坏情况是 O(n)。(注:在已知节点后插入是 O(1),但通常的 add(index, element) 操作需要先找到位置)。

代码段运行时分析

最后,我们分析几个代码段的时间复杂度。

  1. 嵌套循环 (i从0到n,j从0到i):时间复杂度为 O(n²)。内层循环次数总和约为 n*(n+1)/2。
  2. 顺序执行一个O(n)循环和一个O(n²)循环:时间复杂度为 O(n²)。总复杂度由其中最大的部分决定,即 O(max(f(n), g(n)))
  3. 外层循环 i=n; i>0; i/=2,内层循环 j=0; j<i; j++:时间复杂度为 O(n)。内层循环运行次数为 n + n/2 + n/4 + ... < 2n。

本节课中我们一起学习了期中考试的格式和重点,深入剖析了链表迭代器各方法的实现细节,比较了链表和数组列表在不同遍历方式下的性能差异,并复习了大O符号的含义以及常见数据结构操作的时间复杂度。理解这些概念对于准备考试和完成后续编程作业都至关重要。

014:哈希表简介

在本节课中,我们将要学习一种新的数据结构——哈希表。哈希表是一种能够实现快速查找、插入和删除操作的技术。我们将从理解哈希的基本概念开始,探讨其工作原理,并初步了解它所带来的挑战。

哈希的基本思想

上一节我们回顾了数组和链表等线性数据结构的查找操作。本节中我们来看看如何利用哈希技术来显著提升查找效率。

哈希的核心思想是将数据(例如一个学生对象)通过一个特定的函数(哈希函数)转换成一个整数。这个整数被用作数组的索引,从而可以直接定位到数据可能存储的位置。

核心公式index = hashFunction(data)

例如,我们有一个存储学生信息的数组。传统方法是按顺序存放学生,查找时需要遍历整个数组,时间复杂度为 O(n)。而使用哈希表时,给定一个学生,我们计算其哈希值,然后直接访问数组的对应索引位置。如果该位置有数据,则查找成功。理想情况下,这只需要 O(1) 的时间。

哈希碰撞问题

然而,哈希表面临一个关键问题:哈希碰撞。当两个不同的数据对象经过哈希函数计算后,得到了相同的整数值(即数组索引)时,就发生了碰撞。

核心概念data1 != data2,但 hash(data1) == hash(data2)

这就像在一个班级里,有两个不同的人拥有相同的生日。在实际中,碰撞发生的频率可能比我们想象的要高。例如,在一个90人的房间里,仅仅随机询问了约30个人的生日,就可能发现有两个人生日相同。这说明,只要数据量足够大,碰撞几乎是不可避免的。

因此,哈希表设计的重点不在于完全避免碰撞,而在于如何有效地处理碰撞,以及如何设计哈希函数来最小化碰撞发生的概率。

时间与空间的权衡

在引入哈希表之前,让我们先看一个例子,理解算法设计中常见的权衡策略。

以下是两种解决“两数之和”问题的方法:

  • 方法一:使用双重循环检查所有元素对。时间复杂度为 O(n²)
  • 方法二:使用一个大型布尔数组作为直接寻址表。遍历一次数组,将每个元素值对应的索引位置标记为true。然后再次遍历,检查 T - 当前元素值 对应的位置是否为true。时间复杂度为 O(n)

方法二虽然更快,但它牺牲了巨大的空间(需要一个与数据取值范围一样大的数组)来换取时间。这体现了计算机科学中经典的 “以空间换时间” 策略。同时,它也提醒我们,在实际应用中,内存空间也是有限的约束条件。

总结

本节课中我们一起学习了哈希表的初步概念。我们了解到哈希是一种通过哈希函数将数据映射到数组索引,从而实现快速访问的技术。其理想时间复杂度为 O(1)。我们也认识到哈希碰撞是其主要挑战,需要通过精心设计的哈希函数和碰撞解决策略来应对。此外,在算法设计中,经常需要在时间效率和空间消耗之间做出权衡。在接下来的课程中,我们将深入探讨如何设计好的哈希函数以及解决碰撞的具体方法。

015:哈希表基础

在本节课中,我们将要学习哈希表的基本概念,包括其核心思想、Java中的实现(HashMapHashSet)、哈希码的规范,以及处理哈希冲突的两种主要策略:分离链接法和线性探测法。

哈希的核心思想

上一节我们回顾了期中考试的情况,本节中我们来看看哈希表的核心思想。哈希的目的是为了高效地存储和检索数据。

传统上,我们将数据顺序存储在数组或链表中,然后进行线性搜索。这种方法的问题是时间复杂度为线性(O(n)),速度较慢。

哈希的思想是:首先通过一个哈希函数将数据(通常是键)转换成一个整数索引。然后,将这个数据项存储在一个称为“哈希表”的数组的对应索引位置上。这样,在理想情况下,我们可以通过计算键的哈希值直接定位到数据,实现接近常数时间的操作。

核心公式index = hash(key) % tableSize

然而,冲突是不可避免的。就像生日悖论一样,无论哈希函数设计得多好,只要数据量足够大,不同的键就有可能被映射到同一个索引上。

Java中的哈希实现:Map与Set

理解了哈希的基本思想后,我们来看看它在Java中的具体实现。Java主要通过MapSet接口及其实现(如HashMapHashSet)来应用哈希思想。

  • Map(映射):存储键值对。它基于键来组织值,每个键必须是唯一的。当你存储或查找一个值时,实际上是先对键进行哈希运算。
    • 示例:学生ID(键)映射到学生档案(值)。
  • Set(集合):只存储唯一的键,没有与之关联的值。它同样使用哈希来快速判断一个元素是否存在于集合中。

两者的本质都是将键通过哈希函数转换为索引,并将键(对于Map,连同其值)存储在哈希表的对应位置。MapSet多了一个关联的值。

以下是简单的代码示例:

// HashMap 示例:映射学生姓名到GPA
HashMap<String, Double> studentGPAs = new HashMap<>();
studentGPAs.put("Paul", 3.0); // 插入键值对
Double paulsGPA = studentGPAs.get("Paul"); // 通过键“Paul”查找值

// HashSet 示例:存储唯一的学生姓名
HashSet<String> studentNames = new HashSet<>();
studentNames.add("Paul"); // 插入键
boolean hasPaul = studentNames.contains("Paul"); // 检查键是否存在

哈希码的规范

在Java中,hashCode()方法负责将对象转换为一个int类型的哈希码。每个Java对象都有此方法。为了正确地在哈希集合或映射中使用自定义类,通常需要重写equalstoStringhashCode方法。

hashCode()方法必须遵守以下规范:

  1. 一致性:在程序的一次执行中,对同一个对象多次调用hashCode()必须返回相同的整数。
  2. 相等性:如果两个对象根据equals(Object)方法是相等的,那么调用它们各自的hashCode()方法必须产生相同的整数结果。
    • 公式:if (o1.equals(o2)) 则必须保证 o1.hashCode() == o2.hashCode()
  3. 不保证不等性:如果两个对象根据equals(Object)方法不相等,并不要求它们的hashCode()值一定不同。这暗示了哈希冲突是允许的。

一个简单的为重写hashCode()的策略是:先重写toString()方法,将对象所有重要字段拼接成一个字符串,然后返回这个字符串的哈希码。

@Override
public int hashCode() {
    return this.toString().hashCode();
}

重要提示hashCode()返回的整数值范围很大,不能直接用作哈希表的索引,否则会导致数组越界。必须对哈希表大小取模:index = hashCode(key) % tableSize

处理冲突:分离链接法

由于冲突不可避免,我们需要策略来处理它。第一种常见策略是分离链接法

在这种方法中,哈希表的每个位置(称为“桶”)不再直接存储一个元素,而是存储一个链表的头节点。当发生冲突时(即多个键被哈希到同一个索引),新的元素被添加到该索引对应的链表中。

以下是插入元素的步骤:

  1. 计算键的哈希值并取模得到索引。
  2. 找到该索引对应的链表。
  3. 遍历整个链表,检查是否已存在相同的键(避免重复)。
  4. 将新的键值对插入到链表末尾。

查找和删除操作类似:先定位到索引对应的链表,然后在链表中进行线性查找或删除。

性能分析

  • 最坏情况:所有元素都哈希到同一个桶中,整个哈希表退化为一个链表,操作时间复杂度为O(n)。
  • 平均情况:假设元素均匀分布,每个链表的平均长度为 α = n / m,其中n是元素数量,m是哈希表大小(桶的数量)。这个α称为负载因子。平均操作时间复杂度为O(1 + α)。为了保持高效,通常需要保持负载因子较低(例如小于0.75)。

处理冲突:线性探测法

第二种常见的冲突解决策略是线性探测法,它是一种开放寻址法。

在这种方法中,所有元素都直接存储在哈希表数组本身。当插入一个新元素发生冲突时,算法会从冲突发生的索引开始,按顺序(通常是依次检查下一个位置)遍历数组,直到找到一个空槽位,并将元素插入其中。

查找操作:从哈希计算出的索引开始查找,如果该位置不是要查找的键,则继续顺序查找,直到找到该键或遇到一个空槽位(说明键不存在)。

删除操作:删除较为复杂。不能简单地将槽位置空,因为这会切断后续查找的“探测路径”。通常的做法是标记该位置为“已删除”(例如使用一个特殊的墓碑对象)。在后续的插入操作中,这个标记位置可以被复用。

性能与考虑:线性探测法利用CPU缓存局部性好的特点,在实践中往往表现优异。但它对负载因子更敏感,当表较满时容易产生“聚集”现象,导致性能下降。同样需要监控负载因子并在必要时扩容(重哈希)。

字符串哈希函数示例

最后,我们简要看一下如何为字符串设计一个哈希函数。一个常见的方法是将其视为一个多进制数。

例如,字符串“Paul Cao”:

  1. 将每个字符转换为其ASCII值。
  2. 选择一个基数(如27,代表26个字母加一个空格)。
  3. 从字符串的末尾(或开头)开始,为每个字符位置赋予一个权重,即基数的幂次。
  4. 计算加权和:hash = char0 * base^0 + char1 * base^1 + char2 * base^2 + ...

为了避免直接计算大幂次,可以使用霍纳法则进行递归计算,提高效率。Java标准库中String类的hashCode()方法就采用了类似的思路。


本节课中我们一起学习了哈希表的基础知识。我们了解了哈希通过将键映射到索引来实现快速访问的核心思想,认识了Java中的HashMapHashSet。我们明确了hashCode()方法的规范,并深入探讨了解决哈希冲突的两种基本方法:分离链接法和线性探测法,包括它们的原理、操作步骤以及性能特点。理解这些内容是完成后续相关编程作业的基础。

016:栈与队列 🧱

在本节课中,我们将要学习两种新的线性数据结构:栈和队列。我们将了解它们的基本概念、操作、在Java中的实现方式,并通过两个经典应用场景来深入理解它们的工作原理和区别。


栈与队列的基本概念

上一节我们介绍了哈希表这种高效的数据结构。本节中,我们来看看两种新的线性结构:栈和队列。

栈和队列都是线性数据结构,但它们与数组和链表有一个关键区别:数据只能从边界访问。

栈:后进先出 (LIFO)

栈可以想象成一叠盘子。你只能从最顶部放入新盘子或拿走最顶部的盘子。在栈中,你只能访问最顶部的元素。

以下是栈的核心操作:

  • push(item): 将元素 item 压入栈顶。
  • pop(): 移除并返回栈顶的元素。
  • peek(): 查看栈顶的元素但不移除它。
  • isEmpty(): 检查栈是否为空。
  • size(): 返回栈中元素的数量。

栈遵循 后进先出 的原则。第一个进入栈的元素将是最后一个出来的。

队列:先进先出 (FIFO)

队列就像在星巴克排队。新来的人排在队伍末尾,队伍最前面的人先得到服务并离开。在队列中,数据从一端(队尾)插入,从另一端(队首)移除。

以下是队列的核心操作:

  • enqueue(item)offer(item): 将元素 item 插入队尾。
  • dequeue()poll(): 移除并返回队首的元素。
  • peek(): 查看队首的元素但不移除它。
  • isEmpty(): 检查队列是否为空。
  • size(): 返回队列中元素的数量。

队列遵循 先进先出 的原则。第一个进入队列的元素也将是第一个出来的。


在Java中使用栈和队列

理解了基本概念后,我们来看看如何在Java中实际使用它们。

在Java中,Stack 是一个类,可以直接使用。而 Queue 是一个接口,通常用 LinkedList 来实现,因为 LinkedList 实现了 Queue 接口的所有功能。

以下是使用示例:

// 使用 LinkedList 实现一个队列
Queue<String> names = new LinkedList<>();
names.offer("CSE"); // 入队
names.offer("12");
names.offer("107");
System.out.println(names.peek()); // 查看队首,输出 "CSE"
names.poll(); // 出队,移除 "CSE"
System.out.println(names.poll()); // 再次出队,输出 "12"

// 使用 Stack 类
Stack<Integer> stack = new Stack<>();
stack.push(11);
stack.push(22);
System.out.println(stack.peek()); // 查看栈顶,输出 22
stack.pop(); // 出栈,移除 22
System.out.println(stack.pop()); // 再次出栈,输出 11

栈的应用:括号匹配

栈和队列的用途可能看起来有些特定。下面我们通过两个应用来理解为什么需要它们。第一个应用是使用栈来检查括号是否匹配。

问题是:给定一个只包含圆括号 () 和方括号 [] 的字符串,判断它们是否正确地匹配闭合。

解决这个问题的关键在于:当我们遇到一个右括号(如 )])时,我们需要检查最近遇到的、尚未匹配的左括号是否与之对应。我们并不关心更早的左括号,它们会在后面被匹配。

因此,我们需要保存遇到的左括号,并且在需要检查时,总是使用最近保存的那一个。这正好符合栈的 后进先出 特性。

以下是解决思路:

  1. 创建一个空栈。
  2. 遍历字符串中的每个字符。
  3. 如果字符是左括号(([),将其压入栈中。
  4. 如果字符是右括号()]):
    • 如果栈为空,说明没有左括号与之匹配,返回 false
    • 查看栈顶的左括号。
    • 如果栈顶的左括号与当前的右括号匹配(即 ()[]),则将栈顶元素弹出。
    • 如果不匹配,直接返回 false
  5. 遍历结束后,如果栈为空,说明所有括号都正确匹配,返回 true;否则返回 false

代码实现如下:

public boolean isBalanced(String s) {
    Stack<Character> stack = new Stack<>();
    for (int i = 0; i < s.length(); i++) {
        char c = s.charAt(i);
        if (c == '(' || c == '[') {
            stack.push(c); // 遇到左括号,入栈
        } else { // 遇到右括号
            if (stack.isEmpty()) {
                return false; // 栈为空,无法匹配
            }
            char top = stack.peek(); // 查看栈顶的左括号
            if ((c == ')' && top == '(') || (c == ']' && top == '[')) {
                stack.pop(); // 匹配成功,弹出栈顶
            } else {
                return false; // 匹配失败
            }
        }
    }
    return stack.isEmpty(); // 最终栈必须为空才算完全匹配
}


栈与队列在迷宫遍历中的应用

第二个应用是在迷宫路径搜索中。这能帮助我们理解栈和队列在算法策略上的根本区别。

假设有一个网格迷宫,有起点、终点和障碍物。我们需要找到从起点到终点的路径,或者判断是否无法到达。

有两种经典的搜索策略:

  1. 深度优先搜索:选择一条路走到底,直到碰壁,然后回溯到上一个岔路口尝试另一条路。这就像在玉米迷宫里一直用右手摸着墙走。
  2. 广度优先搜索:从起点开始,一层一层地向外探索所有可能的位置。先探索所有距离起点为1步的点,再探索距离为2步的点,以此类推。这就像在水中投入石子,涟漪一圈圈扩散出去。

这两种策略都需要保存“待探索的位置”。区别在于从保存的位置集合中取出下一个探索位置的顺序

  • 深度优先搜索中,我们总是探索最新发现的路径的尽头。当走到死胡同时,我们回溯到最近的一个岔路口。这要求我们最后保存的位置最先被取出检查,这正是 的行为。
  • 广度优先搜索中,我们按发现顺序逐一探索。先发现的点(离起点近的)先被探索,后发现的点后被探索。这要求我们最先保存的位置最先被取出,这正是 队列 的行为。

因此:

  • 深度优先搜索 通常使用 栈 来实现。
  • 广度优先搜索 通常使用 队列 来实现。

这两种算法(DFS和BFS)是图论和许多其他算法的基础,理解它们底层依赖于栈或队列至关重要。


本节课中我们一起学习了栈和队列这两种重要的线性数据结构。我们明确了栈是LIFO(后进先出),队列是FIFO(先进先出),并通过括号匹配和迷宫搜索的例子,深入理解了它们各自的应用场景和背后的逻辑:栈用于需要优先处理最近数据的场景,而队列用于需要按到达顺序处理的场景。掌握这些概念将为学习更复杂的算法打下坚实基础。

017:迷宫遍历算法

在本节课中,我们将学习如何使用栈和队列这两种数据结构来解决迷宫寻路问题。我们将详细探讨深度优先搜索和广度优先搜索这两种遍历算法的原理、实现步骤以及它们之间的区别。


算法概述

栈和队列用于存储信息,并允许我们使用这些信息,但访问顺序并非随机。通常,我们需要使用最新的数据或最早的数据,这决定了我们选择栈还是队列。

上一节我们介绍了栈和队列的基本概念,本节中我们来看看如何将它们应用于迷宫遍历。

深度优先搜索

深度优先搜索使用栈来存储访问过的单元格。其核心思想是:从起点开始,探索一条路径直到尽头,如果遇到死胡同,则回溯到上一个岔路口,尝试另一条路径。这模拟了人类在迷宫中探索的行为。

以下是DFS算法的伪代码实现步骤:

  1. 创建一个栈 S
  2. 将起点 Start 压入栈 S
  3. 当栈 S 不为空时,执行循环:
    • current = S.pop() // 弹出栈顶元素作为当前单元格
    • current 标记为已访问。
    • 对于 current 的每一个邻居 nb
      • 如果 nb 是新的、未被访问的:
        • nb 压入栈 S
        • 记录 nb 的父节点为 current(用于后续路径回溯)。

关键点:邻居的探索顺序会影响路径。常见的顺序是:上、下、左、右。每次弹出栈顶元素后,我们总是先处理最新被发现的邻居,这体现了“后进先出”的原则。

广度优先搜索

广度优先搜索使用队列来存储访问过的单元格。其核心思想是:从起点开始,先访问所有距离起点为1步的邻居,然后是距离为2步的邻居,依此类推。这确保了找到的路径是最短的。

BFS算法与DFS非常相似,只需将数据结构从栈改为队列:

  1. 创建一个队列 Q
  2. 将起点 Start 入队 Q
  3. 当队列 Q 不为空时,执行循环:
    • current = Q.dequeue() // 从队首取出元素作为当前单元格
    • current 标记为已访问。
    • 对于 current 的每一个邻居 nb
      • 如果 nb 是新的、未被访问的:
        • nb 入队 Q
        • 记录 nb 的父节点为 current

关键点:队列遵循“先进先出”原则。我们总是先处理最早被发现的邻居,从而实现了按层(距离)遍历。

路径重建与已访问标记

无论使用DFS还是BFS,为了最终能输出从起点到终点的路径,我们需要在将邻居节点加入数据结构时,记录该邻居的“父节点”(即发现它的那个节点)。当找到终点后,我们可以从终点开始,沿着父节点指针一路回溯到起点,从而重建完整路径。

同时,必须维护一个“已访问”标记数组。如果不标记已访问的单元格,算法可能会将同一个单元格重复加入数据结构,导致无限循环或路径计算错误。

算法对比与应用选择

现在我们来对比一下DFS和BFS的主要特性。

以下是两种算法在几个关键维度的比较:

  • 运行时间:在最坏情况下(例如遍历整个网格),两者的时间复杂度都是 O(N),其中N是网格中的单元格总数。它们都需要访问每个可达的单元格。
  • 内存使用:DFS通常占用更少的内存。DFS的栈大小取决于探索路径的最大深度。而BFS的队列大小在最坏情况下可能与搜索树的宽度成正比,在网格中这可能接近 O(√N),通常大于DFS的深度。
  • 结果保证:BFS保证能找到从起点到终点的最短路径(按步数计算)。DFS找到的路径不一定是最短的。
  • 代码实现:DFS更常用,部分原因是其递归实现非常简洁,直接利用了系统的调用栈。

因此,选择哪种算法取决于具体需求:如果需要最短路径且内存充足,BFS是更好的选择;如果内存受限或只需要找到一条路径(不要求最短),DFS更合适。


本节课中我们一起学习了如何使用栈实现深度优先搜索,以及使用队列实现广度优先搜索来解决迷宫问题。我们理解了两种算法的步骤、路径重建的方法、已访问标记的重要性,并对比了它们在不同场景下的优缺点。掌握这些基础遍历算法是理解更复杂图算法的重要一步。

018:优先队列与堆基础 🎯

在本节课中,我们将要学习一种新的数据结构——优先队列,并深入探讨其最高效的实现方式:堆。我们将了解堆的定义、特性,以及它为何能高效地支持优先队列的操作。


优先队列的概念

上一节我们介绍了栈和队列。本节中我们来看看优先队列。

优先队列是一种抽象数据类型,其行为类似于常规队列,但元素的出队顺序不是由入队时间决定,而是由元素的“优先级”决定。优先级最高的元素总是最先出队。

一个现实世界的例子是医院急诊室。病人的就诊顺序并非基于到达时间,而是基于病情的严重程度。病情更严重的病人拥有更高的优先级,会优先得到处理。在计算机系统中,操作系统调度进程时也使用类似的机制,为不同进程分配优先级。

然而,优先队列的设计需要注意“饥饿”问题。如果一个低优先级的任务不断被新到来的高优先级任务插队,它可能永远得不到处理。

实现优先队列的几种思路

为了理解堆的优势,我们先探讨几种实现优先队列的简单方法及其时间复杂度。

以下是三种可能的实现方式及其入队和出队操作的最坏情况时间复杂度分析:

  1. 使用有序数组

    • 入队:为了保持数组有序,需要找到正确的插入位置(可优化至 O(log n)),但插入元素后可能需要移动后续所有元素,因此最坏情况为 O(n)
    • 出队:优先级最高的元素在数组末尾,直接移除即可,时间复杂度为 O(1)
  2. 使用无序链表

    • 入队:在链表头部或尾部插入新节点,时间复杂度为 O(1)
    • 出队:需要遍历整个链表以找到优先级最高的元素,然后将其移除,时间复杂度为 O(n)
  3. 使用有序链表

    • 入队:需要遍历链表找到正确的插入位置,时间复杂度为 O(n)
    • 出队:优先级最高的元素在链表头部,直接移除即可,时间复杂度为 O(1)

可以看到,上述方法都无法同时让入队和出队操作都保持高效。我们的目标是找到一种数据结构,能让这两个操作都达到接近常数时间的效率。

堆的引入

堆就是为了解决上述问题而设计的数据结构。对于堆实现的优先队列:

  • 入队 时间复杂度为 O(log n)
  • 出队 时间复杂度为 O(log n)

虽然是对数时间复杂度,但它的增长极其缓慢。例如,处理10亿个数据仅需约30次操作,处理1万亿个数据也仅需约40次操作,性能接近常数时间,远优于线性时间。

堆的定义与性质

堆是一种特殊的完全二叉树,并满足堆序性质。

树结构相关术语

首先,我们明确几个树结构的基本术语:

  • :一个无环的连通图。
  • 根节点:没有父节点的节点。
  • 叶节点:没有子节点的节点。
  • 二叉树:每个节点最多有两个子节点的树。
  • 节点的深度:从根节点到该节点的边数。
  • 树的高度:从根节点到最深叶节点的边数。

完全二叉树

堆在结构上必须是一棵完全二叉树
完全二叉树是指除了最后一层外,其他所有层都被完全填满,并且最后一层的节点都尽可能靠左排列。这意味着树形结构是紧凑的,不会在左侧出现空缺。

堆序性质

堆在数据上必须满足堆序性质

  • 最小堆中,每个节点的值都小于或等于其所有子节点的值。因此,根节点是整个树中的最小值。
  • 最大堆中,每个节点的值都大于或等于其所有子节点的值。因此,根节点是整个树中的最大值。

堆序性质是一种“局部有序”的性质,它只规定了父节点与子节点之间的大小关系,并不要求左子节点和右子节点之间有大小顺序。

堆的示例

根据堆的定义(完全二叉树 + 堆序性质),我们可以判断以下哪些结构是堆:

  • ❌ 结构A:不是完全二叉树(最后一层节点未从左至右连续填充)。
  • ❌ 结构B:不是二叉树(节点有三个子节点)。
  • ✅ 结构C:是完全二叉树,且满足最小堆性质。
  • ✅ 结构D:是完全二叉树,且满足最小堆性质。
  • ✅ 结构E:包含了C和D,两者都是有效的堆。

本节课中我们一起学习了优先队列的概念,分析了其简单实现方式的局限性,并引出了高效实现数据结构——堆。我们详细定义了堆作为一种完全二叉树所需满足的结构性(完全二叉树)和有序性(堆序性质)条件,为下一节课学习堆的具体操作打下了基础。

019:堆与优先级队列

在本节课中,我们将学习堆(Heap)这种数据结构,它是实现优先级队列(Priority Queue)的一种高效方式。我们将重点理解堆的定义、如何用数组表示一个堆,以及如何在堆上执行插入和删除操作。


堆的定义与性质

上一节我们介绍了优先级队列的基本概念。本节中,我们来看看实现它的核心数据结构——堆。

堆是一种特殊的完全二叉树。它满足以下性质:

  • 堆序性:对于最小堆(Min-Heap),每个节点的值都小于或等于其子节点的值。对于最大堆(Max-Heap),每个节点的值都大于或等于其子节点的值。
  • 结构性:堆总是一棵完全二叉树

这意味着,在最小堆中,根节点始终是树中的最小值。在最大堆中,根节点始终是最大值。

核心概念公式

  • 最小堆:对于任意节点 i,有 value(i) <= value(leftChild(i))value(i) <= value(rightChild(i))
  • 最大堆:对于任意节点 i,有 value(i) >= value(leftChild(i))value(i) >= value(rightChild(i))

堆的数组表示法

由于堆是完全二叉树,我们可以使用一个数组来高效地表示它,而无需使用节点和指针。

以下是数组表示堆的规则:

  1. 数组的第一个元素(索引1) 存储堆的根节点。
  2. 对于数组中索引为 i 的节点:
    • 左子节点的索引为 2 * i
    • 右子节点的索引为 2 * i + 1
    • 父节点的索引为 i / 2(整数除法)

核心概念公式(假设数组索引从1开始):

  • leftChild(i) = 2 * i
  • rightChild(i) = 2 * i + 1
  • parent(i) = i / 2 (整数除法)

这种映射关系是堆操作的基础。你需要能够在脑海中将数组布局与树形结构相互转换。


堆的基本操作:插入(Enqueue)

现在,我们来看看如何向堆中插入一个新元素,即优先级队列的入队操作。

插入操作的核心思想是:

  1. 将新元素添加到数组的末尾(即完全二叉树的下一个可用位置),以维持完全二叉树的结构。
  2. 由于新元素可能破坏堆序性,需要将其向上调整(Percolate Up / Bubble Up),即与其父节点比较,如果违反堆序则交换,并重复此过程,直到堆序恢复。

以下是插入操作的步骤:

  1. 将新元素 x 放入数组末尾。
  2. 设置当前索引 current = size(新元素的位置)。
  3. current > 1x 的值小于其父节点 parent(current) 的值时(对于最小堆):
    • 交换 array[current]array[parent(current)]
    • current 更新为 parent(current)
  4. 循环结束,插入完成。

堆的基本操作:删除最小值(Dequeue)

接下来,我们学习如何从最小堆中删除并返回最小值,即优先级队列的出队操作。

直接删除根节点(数组第一个元素)会破坏树的结构。正确的做法是:

  1. 移除并保存根节点的值(即 array[1],这是最小值)。
  2. 将数组最后一个元素移动到根节点位置(array[1] = array[size])。
  3. 数组大小减一。
  4. 由于新的根元素可能破坏堆序性,需要将其向下调整(Percolate Down / Trickle Down / Heapify),即与其子节点比较,如果违反堆序则与较小的子节点交换,并在此子树中重复此过程,直到堆序恢复。

以下是删除最小值操作的步骤:

  1. 保存 minValue = array[1]
  2. array[size] 的值赋给 array[1]
  3. size = size - 1
  4. 设置当前索引 current = 1
  5. current 节点不是叶子节点时:
    • 找到 current 节点的最小子节点 minChild
    • 如果 array[current] 的值大于 array[minChild] 的值(对于最小堆):
      • 交换 array[current]array[minChild]
      • current 更新为 minChild
    • 否则,跳出循环。
  6. 返回 minValue

为什么用最后一个元素替换根节点?
这样做可以避免在数组中移动大量元素,只需一次赋值和一次“向下调整”即可,保证了操作的高效性。


总结

本节课中我们一起学习了堆数据结构。

  • 我们明确了堆的定义:一个具有堆序性的完全二叉树。
  • 我们掌握了使用数组表示堆的方法,以及通过索引计算父子节点位置的关键公式
  • 我们深入理解了堆的两个核心操作:插入(向上调整)删除最小值(向下调整) 的步骤与原理。

堆是实现优先级队列的基石,其插入和删除操作的时间复杂度均为 O(log n),非常高效。理解这些操作背后的“向上调整”和“向下调整”思想,是掌握堆的关键。

020:堆与树基础

概述

在本节课中,我们将要学习堆(Heap)数据结构的插入、删除和构建操作,并初步了解树(Tree)数据结构的基本概念和节点定义。我们将重点理解堆操作的算法逻辑、时间复杂度,以及树结构在编程中的表示方法。


堆的回顾与删除操作

上一节我们介绍了堆的基本概念。堆是一个完全二叉树,其根节点存储着最值数据(最小堆为最小值,最大堆为最大值)。由于完全二叉树可以用数组高效表示,我们通常操作数组而非显式的树节点结构。

当我们从堆中删除元素(通常指删除根节点)时,过程如下:

  1. 移除根节点(即数组的第一个元素)。
  2. 将堆中最后一个元素(即数组最后一个有效元素)移动到根节点的位置。
  3. 将这个新根节点向下“渗透”(trickle down),通过与子节点比较并交换,直到它到达正确的位置,重新满足堆序性质。

这个“向下渗透”算法的运行时间,在最坏情况下,与树的高度成正比。

那么,对于一个存储了 N 个数据的堆,其完全二叉树的高度是多少?

答案是 O(log N)。这是因为完全二叉树的每一层节点数大致翻倍(1, 2, 4, 8...),节点总数 N 与层数(高度)h 的关系满足 N ≥ 2^h - 1,因此 h ≤ log₂(N+1),即高度为对数级别。

所以,堆删除操作(DQ)的最坏情况时间复杂度是 O(log N)


堆的插入操作

理解了删除操作后,本节我们来看看如何向堆中插入新元素。

插入操作需要维护堆的两个性质:结构性质(完全二叉树)和堆序性质。我们优先保持结构性质不变,通过调整来满足堆序性质。

以下是插入新元素“16”、“8”、“3”到给定最小堆的步骤:

  1. 插入 16:将 16 作为新叶子节点(在数组末尾添加)。比较其与父节点(索引 size/2),16 > 父节点值,无冲突,插入完成。
  2. 插入 8:将 8 添加到数组末尾。比较其与父节点 14,8 < 14,违反最小堆性质。
    • 交换 8 和 14。
    • 现在 8 位于新的位置,继续与其新的父节点比较,此时已满足堆序,停止。
  3. 插入 3:将 3 添加到数组末尾。与其父节点比较,发生冲突。
    • 不断交换新插入的节点与其父节点,直到不再违反堆序或到达根节点。这个过程称为“向上冒泡”(bubble up)。

插入操作的“向上冒泡”过程,最坏情况也需要从叶子节点走到根节点,因此其时间复杂度也是 O(log N)

“向上冒泡”的递归基(停止条件)有两个:

  1. 当前节点已到达根节点。
  2. 当前节点与其父节点之间没有违反堆序性质。

堆的构建:两种方法

现在我们已经知道如何插入单个元素。如果给定一个无序数组,如何将其构建成一个堆呢?主要有两种方法。

方法一:连续插入法

创建一个空堆,然后遍历数组,对每个元素调用插入(insert)操作。

以下是此方法的时间复杂度分析:

  • 每次插入操作是 O(log k),其中 k 是当前堆的大小。
  • 总时间复杂度为各次插入复杂度的和,这是一个 O(N log N) 的级别。

虽然这种方法正确,但效率并非最优。

方法二:堆化法

更高效的方法是“堆化”(heapify)。此方法直接在原数组上操作,假设数组已经是一个完全二叉树的层序遍历结果,然后从最后一个非叶子节点开始,向前遍历每个节点,确保以该节点为根的子树满足堆序性质。

以下是堆化过程的步骤:

  1. 找到最后一个非叶子节点(索引约为 size/2)。
  2. 从该节点开始,向前扫描到根节点。
  3. 对于每个节点,执行“向下渗透”操作,确保其子树成为堆。

堆化过程的时间复杂度是 O(N),优于连续插入法。这是因为大部分“向下渗透”操作只作用于高度较低的子树。详细的数学证明涉及对各级节点操作次数的求和,其总和与 N 成正比。

在编程作业中,务必使用堆化方法来构建堆。虽然两种方法都能产生合法的堆,但具体的堆结构可能不同,自动评分器会期待使用堆化法得到的结果。


树数据结构简介

完成了堆的学习后,我们将进入一个极其重要的数据结构领域:树。树结构将贯穿你的整个计算生涯,无论你主修计算机科学、数据科学还是生物信息学,都会在程序与算法中频繁见到它。

首先,我们来回顾一些基本定义:

  • :一个无环的连通图。
  • 二叉树:每个节点最多有两个子节点的树。
  • 完全二叉树:除了最后一层,所有层都被完全填满,且最后一层节点尽可能靠左排列(即堆的结构)。
  • 满二叉树:每个节点要么有 0 个子节点,要么有 2 个子节点。

树具有根节点、内部节点和叶节点。树的高度是指从根节点到最深叶节点的路径边数。

对树的基本操作包括:构建树、遍历并打印所有节点、查找最大/最小值、查找特定范围内的值等。


树的节点表示

与完全二叉树可以用数组方便表示不同,一般的二叉树通常使用链式结构(类似链表)在代码中表示。

一个典型的树节点类(例如 TNode)可能包含:

  • data:节点存储的数据。
  • left:指向左子节点的引用。
  • right:指向右子节点的引用。

其代码结构类似于:

class TNode {
    int data;
    TNode left;
    TNode right;
    // 构造函数等...
}

在这个基础设计中,我们可以轻松访问或修改节点的数据、左孩子和右孩子。但是,无法直接访问父节点或根节点。如果需要从某个节点向上回溯,则需要在节点类中增加一个 TNode parent 的引用。

另一个重要概念是节点的深度,即从根节点到该节点的路径长度。根节点深度为 0。

处理树结构时,递归是最核心、最常用的编程技巧。在接下来的课程中,我们将深入探讨如何利用递归思想来编写操作树结构的代码。


总结

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

  1. 堆的删除与插入:理解了“向下渗透”和“向上冒泡”算法,两者时间复杂度均为 O(log N)。
  2. 堆的构建:比较了连续插入法(O(N log N))和堆化法(O(N)),并强调了堆化法的高效性与必要性。
  3. 树结构基础:回顾了树的类型与基本术语。
  4. 树的表示:学习了如何使用包含左右子节点引用的类来表示二叉树节点,并指出了基础设计的局限性(如缺少父指针)。

掌握这些内容是熟练运用堆和树数据结构的基础。下一节课,我们将重点学习编写操作树结构的递归代码。

021:树结构基础与遍历算法 🌳

在本节课中,我们将学习树结构的基本概念,并重点探讨如何在树结构上使用递归和迭代两种方法实现搜索操作。我们将通过具体的代码示例,理解深度优先搜索(DFS)和广度优先搜索(BFS)在树中的应用。

树结构概述与递归思想

上一节我们介绍了树的基本结构。本节中,我们来看看处理树结构时一个至关重要的思想:递归

处理任何树结构时,必须首先考虑递归。不使用递归,很难高效地解决树相关问题。虽然可以通过队列、栈等数据结构辅助,但递归会让解决方案简洁得多。

编写关于树的代码时,脑海中应浮现一个通用结构:你总是在为树中的某个特定节点编写代码。由于递归,同一段代码将应用于树中的每个节点。你为当前节点编写代码,这个节点可能有父节点,也可能有多个子节点。

设计当前节点的解决方案时,通常可以访问其子节点(例如,二叉树有左、右引用)。当前节点可能依赖其父节点或子节点的信息进行计算,然后汇总这些信息,进行一些运算,再将结果返回给父节点或递归传递给子节点。

递归实现树搜索

以下是递归实现树搜索的步骤:

  1. 检查当前节点是否为空,若为空则返回 false
  2. 检查当前节点的值是否等于目标值,若相等则返回 true
  3. 若不等,则递归地在左子树和右子树中搜索,使用逻辑或(||)操作符合并结果。

对应的递归辅助方法代码如下:

private boolean containsHelper(int toFind, TNode current) {
    if (current == null) {
        return false;
    }
    if (current.getData().equals(toFind)) {
        return true;
    }
    return containsHelper(toFind, current.left) || containsHelper(toFind, current.right);
}

公共方法则调用此辅助方法,从根节点开始搜索:

public boolean contains(int toFind) {
    return containsHelper(toFind, root);
}

以一棵树 (3 (20 (7 (null 20)) 5) (5 (2))) 为例,搜索目标值 8 时,递归访问节点的顺序为:3, 20, 7, null, 20, null, null, 5, 2, null, null。搜索目标值 7 时,由于短路求值,访问顺序为:3, 20, 7,找到后立即返回。

迭代实现树搜索(栈 - DFS)

现在,我们来看看如何使用迭代方法实现相同的搜索功能。我们将使用栈来模拟递归过程,实现深度优先搜索。

以下是使用栈进行迭代搜索的步骤:

  1. 创建一个栈,并将根节点压入栈中。
  2. 当栈不为空时,循环执行:
    • 弹出栈顶节点作为当前节点。
    • 检查当前节点的值是否等于目标值,若相等则返回 true
    • 若不等,则将其非空的左子节点和右子节点依次压入栈中。
  3. 若循环结束仍未找到,则返回 false

对应的迭代方法代码如下:

public boolean containsIterativeDFS(int toFind) {
    if (root == null) return false;
    Stack<TNode> stack = new Stack<>();
    stack.push(root);
    while (!stack.isEmpty()) {
        TNode current = stack.pop();
        if (current.getData().equals(toFind)) {
            return true;
        }
        if (current.right != null) stack.push(current.right);
        if (current.left != null) stack.push(current.left);
    }
    return false;
}

在之前的树中搜索 8,使用栈(DFS)的访问顺序可能是:3, 5, 2, 20, 7, 20。访问顺序取决于子节点入栈的顺序。

迭代实现树搜索(队列 - BFS)

除了栈,我们还可以使用队列来实现迭代搜索,这将导致广度优先搜索(BFS)的遍历顺序。

以下是使用队列进行迭代搜索的步骤:

  1. 创建一个队列,并将根节点加入队列。
  2. 当队列不为空时,循环执行:
    • 从队列中取出队首节点作为当前节点。
    • 检查当前节点的值是否等于目标值,若相等则返回 true
    • 若不等,则将其非空的左子节点和右子节点依次加入队列。
  3. 若循环结束仍未找到,则返回 false

对应的迭代方法代码如下:

public boolean containsIterativeBFS(int toFind) {
    if (root == null) return false;
    Queue<TNode> queue = new LinkedList<>();
    queue.add(root);
    while (!queue.isEmpty()) {
        TNode current = queue.poll();
        if (current.getData().equals(toFind)) {
            return true;
        }
        if (current.left != null) queue.add(current.left);
        if (current.right != null) queue.add(current.right);
    }
    return false;
}

在之前的树中搜索 8,使用队列(BFS)的访问顺序是:3, 20, 5, 7, 2, 20。这是按层级进行遍历的。

算法对比与总结

本节课中我们一起学习了树结构的递归和迭代搜索方法。

  • 递归方法:代码简洁,直观体现了树的结构,本质上是深度优先搜索(DFS)。
  • 迭代栈方法:显式使用栈,避免了递归的函数调用开销,也是深度优先搜索(DFS)。
  • 迭代队列方法:使用队列,实现了广度优先搜索(BFS),按层级遍历节点。

这三种方法在最坏情况下(目标不存在)都需要访问树中的所有 N 个节点,因此时间复杂度均为 O(N)

最后,一个关键区别是:在图或迷宫的 DFS/BFS 中需要记录节点的访问状态(visited),以防止重复访问。但在树结构中,任意两节点间只有唯一路径,不存在环路,因此不需要 visited 标记。

理解这些基础的遍历方法是学习更复杂树结构(如二叉搜索树)操作的重要基石。

022:二叉树基础操作 🧮

在本节课中,我们将学习二叉树的基本概念,并通过实践练习来掌握如何构建二叉树、按层遍历以及计算树的高度。这些操作是理解更复杂数据结构(如二叉搜索树)的基础。


构建完全二叉树 🌲

上一节我们介绍了二叉树节点的基本结构。本节中,我们来看看如何根据一个给定的数组来构建一个完全二叉树

给定一个非空的整型数组,我们需要构建一个完全二叉树来存储这些值。例如,对于数组 [3, 2, 20, 30, 1, 0],我们希望构建的树结构如下:

        3
       / \
      2   20
     / \  /
    30 1 0

以下是构建完全二叉树的步骤:

  1. 创建一个根节点,其值为数组的第一个元素(假设数组为1-索引)。
  2. 使用一个队列来辅助进行广度优先构建。
  3. 将根节点加入队列。
  4. 当队列不为空时,执行以下循环:
    • 从队列中取出一个节点 current
    • 计算该节点在数组中可能存在的左孩子索引 2*i 和右孩子索引 2*i+1
    • 如果左孩子索引在数组范围内,则为 current 创建左孩子节点,并将其加入队列。
    • 如果右孩子索引在数组范围内,则为 current 创建右孩子节点,并将其加入队列。
  5. 循环结束后,树即构建完成。

核心逻辑的伪代码如下:

Queue<TreeNode> queue = new LinkedList<>();
queue.offer(root);
int i = 1; // 假设数组为1-索引
while (!queue.isEmpty()) {
    TreeNode current = queue.poll();
    if (2*i < values.length) {
        current.left = new TreeNode(values[2*i]);
        queue.offer(current.left);
    }
    if (2*i + 1 < values.length) {
        current.right = new TreeNode(values[2*i + 1]);
        queue.offer(current.right);
    }
    i++;
}

二叉树的层序遍历 📊

构建好树之后,我们常常需要遍历它。本节我们学习如何按层(广度优先)打印二叉树的所有节点值。

按层遍历与构建树的思路非常相似,同样需要使用队列。以下是具体步骤:

  1. 如果根节点为空,则直接返回。
  2. 创建一个队列,并将根节点加入队列。
  3. 当队列不为空时,执行以下循环:
    • 从队列中取出一个节点 current
    • 打印 current 节点的值。
    • 如果 current 有左孩子,则将左孩子加入队列。
    • 如果 current 有右孩子,则将右孩子加入队列。

核心逻辑的伪代码如下:

if (root == null) return;
Queue<TreeNode> queue = new LinkedList<>();
queue.offer(root);
while (!queue.isEmpty()) {
    TreeNode current = queue.poll();
    System.out.print(current.value + " ");
    if (current.left != null) queue.offer(current.left);
    if (current.right != null) queue.offer(current.right);
}

如果将上述代码中的队列(Queue)替换为栈(Stack),则遍历顺序将变为深度优先。


计算二叉树的高度 📏

了解了遍历,我们来看一个经典的递归问题:计算二叉树的高度(或深度)。树的高度定义为从根节点到最远叶子节点的路径上的边数(或节点数,定义需统一)。

递归是解决此问题最简洁的方法。思路如下:

  1. 基准情况:如果当前节点 current 为空,则返回 -1(表示空树高度为-1)或 0(取决于定义)。
  2. 递归计算左子树的高度 leftHeight
  3. 递归计算右子树的高度 rightHeight
  4. 当前节点的高度为 max(leftHeight, rightHeight) + 1

核心逻辑的伪代码如下:

public int getHeight(TreeNode current) {
    if (current == null) {
        return -1; // 或 return 0;
    }
    int leftHeight = getHeight(current.left);
    int rightHeight = getHeight(current.right);
    return Math.max(leftHeight, rightHeight) + 1;
}

这个递归过程体现了“分而治之”的思想:每个节点只负责询问其左右子树的高度,然后结合自身(+1)将结果向上汇报。


递归思想的强化:计算子树规模 👥

最后,我们通过一个类似的问题来强化递归思维:计算以某个节点为根的子树中包含的节点总数(即该节点“管理”的人数)。

思路与计算高度类似:

  1. 基准情况:如果当前节点为空,返回 0
  2. 递归计算左子树的节点数 leftSize
  3. 递归计算右子树的节点数 rightSize
  4. 当前子树的节点总数为 leftSize + rightSize + 1+1 代表当前节点自身)。

核心逻辑的伪代码如下:

public int getSize(TreeNode current) {
    if (current == null) {
        return 0;
    }
    int leftSize = getSize(current.left);
    int rightSize = getSize(current.right);
    return leftSize + rightSize + 1;
}

无论是计算高度、规模还是其他属性,递归在树结构中的应用模式都是:向下递归到叶子节点,然后自底向上地组合信息并返回


本节课中我们一起学习了二叉树的基本操作:如何从数组构建完全二叉树、如何进行层序遍历、如何递归地计算树的高度和子树规模。掌握这些基础是后续学习二叉搜索树等更高级结构的关键。下节课我们将进入二叉搜索树的学习。

023:二叉树与二叉搜索树

在本节课中,我们将完成对二叉树的讨论,并开始学习一种特殊的二叉树——二叉搜索树。我们将学习如何遍历二叉树、计算其属性,并探索二叉搜索树的基本操作,如查找和插入。


二叉树回顾与属性计算

上一节我们介绍了二叉树的基本结构和遍历。本节中,我们来看看如何计算二叉树的一些属性,例如每个节点的“下属”数量或深度。

计算节点的下属数量

以下是计算一个节点及其所有后代节点总数的方法。我们假设每个节点有一个 leftrightvalue 和一个 count 变量来存储这个数量。

int subordinates(TreeNode current) {
    if (current == null) {
        return -1; // 空节点代表-1个下属
    }
    int leftCount = subordinates(current.left);
    int rightCount = subordinates(current.right);
    current.count = leftCount + rightCount + 2; // 加上两个直接子节点
    return current.count;
}

核心思想:每个节点的下属总数等于其左子树下属数 + 右子树下属数 + 2(其直接子节点)。叶节点的下属数为0。

计算节点的深度

节点的深度是指该节点到根节点的距离。我们可以通过递归,利用父节点的深度信息来计算。

void updateDepth(TreeNode current) {
    if (current == null) {
        return; // 到达叶节点的子节点,返回
    }
    if (current.parent == null) { // 当前节点是根节点
        current.depth = 0;
    } else {
        current.depth = current.parent.depth + 1;
    }
    // 递归更新左右子节点
    updateDepth(current.left);
    updateDepth(current.right);
}

核心思想:当前节点的深度等于其父节点的深度加1。根节点的深度为0。


二叉搜索树简介

现在,我们聚焦于一种特殊的二叉树——二叉搜索树。并非所有二叉树都是二叉搜索树。二叉搜索树具有一个关键属性:对于树中的任意节点,其左子树中的所有键值都小于或等于该节点的键值,其右子树中的所有键值都大于或等于该节点的键值。

公式:对于节点 x,满足 left_subtree_keys ≤ x.key ≤ right_subtree_keys

这与堆不同。堆(如最小堆)要求父节点小于子节点,但没有左右子树之间的排序要求。二叉搜索树是Java中 TreeSetTreeMap 等集合类的基础数据结构。

识别二叉搜索树

以下哪个是二叉搜索树?

  • A:一个向右倾斜的链表(如 1 -> 2 -> 3)。是BST,因为它满足左小右大的性质。
  • B:一个平衡的树,根为42,左子为10,右子为55。是BST
  • C:一个违反性质的树(例如,左子树包含大于根节点的值)。不是BST
  • D:一个看似平衡但右子树的左节点(如50)小于根节点(42)的树。不是BST,因为必须检查整个子树。

一个常见的面试问题是:给定一棵二叉树,验证它是否是二叉搜索树。


二叉搜索树的操作

查找操作

在普通二叉树中,查找需要遍历左右子树。在二叉搜索树中,我们可以利用其有序性进行优化。

boolean containsHelper(TreeNode current, Key key) {
    if (current == null) {
        return false; // 未找到
    }
    int cmp = key.compareTo(current.data);
    if (cmp == 0) {
        return true; // 找到
    } else if (cmp < 0) {
        // 目标值小于当前节点值,只在左子树中查找
        return containsHelper(current.left, key);
    } else {
        // 目标值大于当前节点值,只在右子树中查找
        return containsHelper(current.right, key);
    }
}

时间复杂度分析:在平衡的二叉搜索树中,查找的时间复杂度为 O(log n)。然而,在最坏情况下(例如树退化成链表),时间复杂度会变为 O(n)

插入操作

插入操作类似于查找。我们沿着树向下寻找合适的插入位置,并记住父节点以便链接新节点。

插入步骤:

  1. 从根节点开始。
  2. 将待插入值与当前节点值比较。
  3. 如果值更小,转向左子树;如果值更大,转向右子树。
  4. 重复步骤2-3,直到到达一个空位置(null)。
  5. 在该空位置创建新节点,并将其链接到父节点。
boolean addHelper(TreeNode current, Value toAdd) {
    int cmp = toAdd.compareTo(current.data);
    if (cmp == 0) {
        return false; // 重复值,不插入
    } else if (cmp < 0) {
        // 应插入左子树
        if (current.left == null) {
            current.left = new TreeNode(toAdd); // 找到插入点
            return true;
        } else {
            return addHelper(current.left, toAdd); // 继续向左递归
        }
    } else {
        // 应插入右子树
        if (current.right == null) {
            current.right = new TreeNode(toAdd); // 找到插入点
            return true;
        } else {
            return addHelper(current.right, toAdd); // 继续向右递归
        }
    }
}

关键点:插入时,必须检查子节点是否为 null 以确定插入位置,而不能直接递归到 null 节点,因为那样会丢失父节点的信息。


总结

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

  1. 如何递归地计算二叉树的属性,如节点下属数量和深度。
  2. 二叉搜索树的定义:左子树所有节点值 ≤ 根节点值 ≤ 右子树所有节点值。
  3. 二叉搜索树的高效查找操作,其最坏时间复杂度为 O(n),发生在树不平衡时。
  4. 二叉搜索树的插入操作,其过程类似于查找,但需要小心处理父节点链接。

下节课我们将探讨二叉搜索树中更复杂的操作——删除节点,并介绍树的遍历方式。

024:二叉搜索树(下)与排序入门 🧠

在本节课中,我们将完成二叉搜索树(BST)的讲解,学习删除节点、寻找后继节点以及树的遍历。随后,我们将开启排序算法的学习,首先了解树排序的基本概念。


二叉搜索树删除操作

上一节我们介绍了BST的插入和查找。本节中,我们来看看如何从BST中删除一个节点。删除操作比插入和查找更复杂,因为需要考虑被删除节点的子节点情况。

删除节点时,主要分为三种情况:

以下是三种需要处理的情况:

  1. 删除叶子节点:如果节点没有子节点,可以直接将其删除。例如,删除节点35,只需将其父节点对应的子节点引用设为null
  2. 删除只有一个子节点的节点:如果节点只有一个子节点,可以将该子节点直接连接到其父节点上,然后删除该节点。例如,删除节点12,可以让节点6直接成为节点32的左子节点。
  3. 删除有两个子节点的节点:这是最复杂的情况。策略是找到该节点的后继节点(即中序遍历中的下一个节点),用后继节点的值替换要删除的节点的值,然后删除原来的后继节点。例如,要删除节点32,其后继节点是35。我们将节点32的值替换为35,然后删除原来值为35的节点。

寻找后继节点

在删除有两个子节点的节点时,我们需要找到它的后继节点。后继节点的定义是:比当前节点大的所有节点中最小的那个。

以下是寻找后继节点的两种主要情况:

  • 情况一:节点有右子树。后继节点是其右子树中的最左节点。代码逻辑可以描述为:从右子节点出发,一直向左走,直到没有左子节点为止。
    Node successor = node.right;
    while (successor.left != null) {
        successor = successor.left;
    }
    // 此时 successor 就是后继节点
    
  • 情况二:节点没有右子树。需要向上回溯,直到找到一个祖先节点,使得当前节点位于该祖先节点的左子树中。这个祖先节点就是后继节点。如果回溯到根节点仍未找到这样的祖先,则该节点没有后继(它是树中的最大值)。

树的遍历

遍历二叉树有三种标准方式:中序遍历、前序遍历和后序遍历。它们定义了访问节点及其子节点的顺序。

以下是三种遍历方式的定义:

  • 中序遍历:顺序为 左子树 -> 根节点 -> 右子树。对BST进行中序遍历,会得到一个升序排列的序列。
  • 前序遍历:顺序为 根节点 -> 左子树 -> 右子树。常用于复制树的结构。
  • 后序遍历:顺序为 左子树 -> 右子树 -> 根节点。常用于删除树或计算表达式。

以图中的树为例:

  • 中序遍历结果:6, 12, 32, 35, 42, 48, 56, 60
  • 前序遍历结果:42, 32, 12, 6, 35, 56, 48, 60
  • 后序遍历结果:6, 12, 35, 32, 48, 60, 56, 42

排序算法入门:树排序

现在,我们开始学习排序算法。第一个算法是树排序,它直接利用了BST的性质。

树排序的步骤非常简单:

  1. 根据待排序数组,构建一棵BST(插入所有元素)。
  2. 对这棵BST进行中序遍历,得到的序列就是排序结果。

时间复杂度分析

  • 构建BST:平均情况为 O(n log n),最坏情况(输入已排序)会退化成链表,时间复杂度为 O(n²)
  • 中序遍历:无论树形如何,都需要访问每个节点一次,时间复杂度为 O(n)
  • 因此,树排序的整体最坏时间复杂度是 O(n²),平均时间复杂度是 O(n log n)

本节课中我们一起学习了BST删除节点的三种情况、如何寻找后继节点、树的三种遍历方式,并引入了第一个排序算法——树排序及其时间复杂度分析。下节课我们将继续探讨更多高效的排序算法。

025:排序算法(下)📊

在本节课中,我们将继续学习排序算法。我们将重点介绍堆排序、冒泡排序、选择排序、插入排序和归并排序的核心思想、实现步骤以及时间复杂度分析。理解这些算法对于掌握数据处理和算法设计至关重要。


堆排序 (Heap Sort) ⛰️

上一节我们介绍了树排序,本节中我们来看看堆排序。堆排序基于堆这种数据结构。其基本思想是:首先将数组构建成一个堆,然后反复移除堆顶元素(即当前最大或最小元素),从而得到一个有序序列。

以下是堆排序的两个核心步骤:

  1. 构建堆 (Heapify): 将给定的无序数组调整为一个堆。对于一个大小为 n 的数组,可以从最后一个非叶子节点开始,向上进行“下滤”操作,确保每个子树都满足堆的性质。这个过程的时间复杂度是 O(n)
    • 公式:从索引 floor(n/2) - 1 开始,递减到 0,对每个索引 i 执行 heapify(arr, n, i)

  1. 排序: 将堆顶元素(根节点,通常是最大或最小值)与堆的最后一个元素交换,然后减小堆的大小,并对新的堆顶元素执行“下滤”操作以恢复堆的性质。重复此过程 n-1 次。
    • 代码描述:
      for (int i = n - 1; i > 0; i--) {
          // 将当前根节点(最大值)与末尾元素交换
          swap(arr[0], arr[i]);
          // 在缩小的堆上恢复堆性质
          heapify(arr, i, 0);
      }
      

堆排序的整体时间复杂度在最好、最坏和平均情况下都是 O(n log n)。它是一种原地排序算法,但通常不稳定。


简单排序算法对比 🔄

接下来,我们看看几种基于简单比较和交换思想的排序算法:冒泡排序、选择排序和插入排序。这些算法虽然效率不高,但思想直观,易于理解和实现。

冒泡排序 (Bubble Sort) 🫧

冒泡排序重复地遍历要排序的列表,比较相邻的元素,如果它们的顺序错误就把它们交换过来。遍历列表的工作会重复进行,直到列表已经排序完成。

以下是冒泡排序的核心逻辑:

  • 算法描述:进行 n 轮遍历。在每一轮中,从数组开头开始,比较每对相邻元素 (arr[j], arr[j+1]),如果 arr[j] > arr[j+1] 则交换它们。经过一轮遍历,最大的元素会“冒泡”到数组末尾。
  • 代码描述:
    for (int i = 0; i < n-1; i++) {
        for (int j = 0; j < n-i-1; j++) {
            if (arr[j] > arr[j+1]) {
                swap(arr[j], arr[j+1]);
            }
        }
    }
    
  • 时间复杂度:
    • 最坏情况(数组完全逆序):O(n²)
    • 最好情况(数组已排序):O(n)(但需要一次完整的遍历来确认)
    • 平均情况:O(n²)

选择排序 (Selection Sort) 🎯

选择排序将数组分为“已排序”和“未排序”两部分。它不断地从未排序部分中选择最小(或最大)的元素,将其放到已排序部分的末尾。

以下是选择排序的核心步骤:

  1. 在未排序序列中找到最小元素。
  2. 将其与未排序序列的第一个元素交换。
  3. 将未排序序列的边界向后移动一位,重复步骤1和2。
  • 代码描述:
    for (int i = 0; i < n-1; i++) {
        int minIdx = i;
        for (int j = i+1; j < n; j++) {
            if (arr[j] < arr[minIdx]) {
                minIdx = j;
            }
        }
        swap(arr[i], arr[minIdx]);
    }
    
  • 时间复杂度:无论数据如何分布,比较次数固定,均为 O(n²)

插入排序 (Insertion Sort) 📥

插入排序的工作方式类似于整理手中的扑克牌。它将数组视为已排序和未排序两部分,每次从未排序部分取出一个元素,将其插入到已排序部分的正确位置。

以下是插入排序的核心逻辑:

  • 算法描述:从第二个元素开始(第一个元素视为已排序),将该元素与前面已排序的元素从后向前依次比较,如果该元素更小,则将比较的元素向后移动一位,直到找到合适的位置插入。
  • 代码描述:
    for (int i = 1; i < n; i++) {
        int key = arr[i];
        int j = i - 1;
        while (j >= 0 && arr[j] > key) {
            arr[j + 1] = arr[j];
            j--;
        }
        arr[j + 1] = key;
    }
    
  • 时间复杂度:
    • 最坏情况(数组完全逆序):O(n²)
    • 最好情况(数组已排序):O(n)
    • 平均情况:O(n²)

总结对比:冒泡、选择、插入排序在最坏和平均情况下的时间复杂度都是 O(n²),远差于堆排序的 O(n log n)。它们通常不用于大规模数据排序,但冒泡和插入排序因其简单性,在小规模数据或近乎有序的数据中可能有用。


归并排序 (Merge Sort) 🤝

归并排序采用分治策略。它将数组递归地分成两半,分别对两半进行排序,然后将两个已排序的子数组合并成一个完整的已排序数组。

以下是归并排序的递归框架:

  1. 分解:如果数组长度大于1,找到中点 mid,将数组分成左半部分 arr[0...mid-1] 和右半部分 arr[mid...n-1]
  2. 解决:递归地对左半部分和右半部分调用归并排序。
  3. 合并:将两个已排序的子数组合并成一个有序数组。这是算法的关键步骤。

合并两个已排序数组 ABC 的算法如下:

int i = 0, j = 0, k = 0; // i指向A, j指向B, k指向C
while (i < A.length && j < B.length) {
    if (A[i] <= B[j]) {
        C[k++] = A[i++];
    } else {
        C[k++] = B[j++];
    }
}
// 拷贝剩余元素
while (i < A.length) { C[k++] = A[i++]; }
while (j < B.length) { C[k++] = B[j++]; }

  • 时间复杂度分析
    • 分解过程形成一棵深度为 log₂ n 的递归树。
    • 在每一层递归上,合并所有子数组的总工作量是 O(n)(因为需要遍历和比较所有元素)。
    • 因此,归并排序的总时间复杂度为 O(n log n)
  • 空间复杂度:归并排序不是原地排序算法,它需要额外的空间来存储临时数组,空间复杂度为 O(n)

归并排序在最好、最坏和平均情况下的时间复杂度都是 O(n log n),是一种稳定且高效的排序算法。


本节课中我们一起学习了堆排序、冒泡排序、选择排序、插入排序和归并排序。我们了解了它们的基本思想、实现方式以及时间复杂度特性。其中,堆排序和归并排序具有 O(n log n) 的优异性能,而简单的交换和选择类算法性能较差。理解这些差异是选择合适排序算法的基础。快速排序我们将在下节课详细讨论。

026:Lecture 27

概述

在本节课中,我们将学习关于课程安排和考试政策的基本信息。内容主要涉及考试安排、特殊情况处理以及相关的学术规定。

课程安排与考试政策

上一节我们介绍了课程的基本框架,本节中我们来看看具体的考试安排与政策。

考试安排

通常情况下,课程会按照既定的时间表进行考试。

公式考试时间 = 预设时间表

这意味着考试日期和时间通常是固定的,不会随意更改。

特殊情况处理

对于有特殊需求的学生,学校会提供相应的便利措施。

以下是关于便利措施的具体说明:

  • 学校会为符合条件的学生提供考试便利。
  • 这些便利旨在确保所有学生都有公平的评估机会。

学术规定

学生需要遵守相关的学术规定,以确保学习环境的公平性。

总结

本节课中我们一起学习了课程考试的基本安排、特殊情况的处理方式以及需要遵守的学术规定。理解这些政策有助于你更好地规划学习并顺利完成课程要求。

027:课程总结与期末考试复习 📚

在本节课中,我们将完成关于树结构的练习,并对整个CSE 12课程进行全面的期末考试复习。我们将回顾本学期学习的所有核心数据结构和算法,并分析它们在不同操作下的性能。


树结构练习回顾 🌳

上一节我们介绍了树、堆和二叉搜索树的基本概念。本节中,我们来看看一些相关的判断题,以巩固理解。

以下是关于树结构的一些判断题及其解析:

  1. 一个二叉树总是有 n-1 条边。

    • 答案:正确。 任何具有 n 个节点的树(无论是否为二叉树)都有 n-1 条边。二叉搜索树是二叉树的一种特例,此规则同样适用。
  2. 二叉搜索树中最左边的叶子节点是整个树中的最小值。

    • 答案:错误。 二叉搜索树中的最小值节点是最左边的节点,但该节点不一定是叶子节点。例如,一个具有左子树的节点可能是最小值,但它本身不是叶子。
  3. 如果将 successor(后继者)函数放入二叉搜索树节点类中,则该类必须包含指向父节点的引用。

    • 答案:正确。 在寻找一个节点的后继者时,如果该节点没有右子树,则需要向上遍历查找父节点。因此,访问父节点的能力是必要的。
  4. 二叉搜索树的根节点一定是树中所有节点的中位数。

    • 答案:错误。 只有在平衡的二叉搜索树中,根节点才可能是中位数。对于倾斜的树,无法保证这一点。
  5. 对于一组给定的数据,只存在一种合法的二叉搜索树结构。

    • 答案:错误。 二叉搜索树的最终结构取决于数据插入的顺序。不同的插入顺序可能产生结构不同但包含相同数据的二叉搜索树。
  6. 只能对二叉搜索树进行中序遍历,不能对普通二叉树进行中序遍历。

    • 答案:错误。 可以对任何二叉树进行前序、中序、后序遍历。对于二叉搜索树,中序遍历会产生有序序列;对于普通二叉树,中序遍历只是以特定顺序访问所有节点。
  7. 对二叉搜索树进行中序遍历的时间复杂度是 O(n)。

    • 答案:正确。 中序遍历会访问每个节点常数次(最多几次),并且遍历的边数也与 n 成线性关系,因此是线性时间复杂度。


面试题实践:验证二叉搜索树 ✅

接下来,我们看一个常见的面试问题:如何验证一个给定的二叉树是否是二叉搜索树。

核心思路:不能仅检查每个节点是否大于其左子节点且小于其右子节点。必须确保节点的左子树中的所有值都小于该节点,右子树中的所有值都大于该节点。

一种有效的方法是使用递归,并为每个节点维护一个允许取值的上下界区间。

以下是该算法的代码实现:

boolean isBST(TreeNode root) {
    return isBSTHelper(root, Integer.MIN_VALUE, Integer.MAX_VALUE);
}

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucsd-cse12-dast-oo/img/d033e180b10198e7505380d47100f2a0_9.png)

boolean isBSTHelper(TreeNode node, int low, int high) {
    // 空树是有效的BST
    if (node == null) {
        return true;
    }
    // 当前节点的值必须在 (low, high) 区间内
    if (node.val <= low || node.val >= high) {
        return false;
    }
    // 递归检查左子树和右子树,并更新边界
    // 左子树的所有值必须小于当前节点值 (high = node.val)
    // 右子树的所有值必须大于当前节点值 (low = node.val)
    return isBSTHelper(node.left, low, node.val) && isBSTHelper(node.right, node.val, high);
}

另一种方法:对树进行中序遍历,检查遍历得到的序列是否是严格递增的。这也是一种可行方案。


期末考试总复习 📖

现在,我们开始进行期末考试的总复习。本学期我们涵盖了以下主要主题:

  1. Java 基础:接口继承、泛型。
  2. 数据结构:数组、链表、哈希表、栈、队列、堆、二叉树、二叉搜索树。
  3. 算法:广度优先搜索(BFS)、深度优先搜索(DFS)、各种排序算法(如归并排序、快速排序)。

对于每种数据结构,最重要的两点是:

  • 何时使用:了解其优势和劣势。
  • 性能分析:能够分析基于该数据结构的各种操作的时间复杂度。

数据结构操作性能分析表 📊

为了帮助理解,我们创建一个表格来分析不同数据结构在 插入删除查找 操作下的最佳情况与最坏情况时间复杂度。假设数据规模为 n,哈希表大小为 m

数据结构 插入(最佳/最坏) 删除(最佳/最坏) 查找(最佳/最坏) 支持查找?
数组 (Array) O(1) / O(n) O(1) / O(n) O(1) / O(n)
链表 (Linked List) O(1) / O(n) O(1) / O(n) O(1) / O(n)
哈希表 (Hash Table) O(1) / O(n) O(1) / O(n) O(1) / O(n)
栈 (Stack) O(1) O(1) 不支持
队列 (Queue) O(1) O(1) 不支持
堆 (Heap) O(1) / O(log n) O(log n) 不支持
二叉树 (Binary Tree) O(1) / O(n) O(1) / O(n) O(1) / O(n)
二叉搜索树 (BST) O(1) / O(n) O(1) / O(n) O(1) / O(n)

关键点说明

  • 最佳情况:通常发生在操作位置已知或极其幸运时(例如,在链表头部插入、在哈希表中无冲突插入)。
  • 最坏情况:通常与数据分布或结构状态有关(例如,数组中间插入导致移位、链表遍历到末尾、BST退化成链表)。
  • 平均情况:对于哈希表,我们更关注平均情况性能,约为 O(1 + α),其中 α 是负载因子。
  • 不支持的操作:栈、队列、堆这些数据结构的设计目的不是用于随机查找元素。

请勿死记硬背此表。重要的是理解每种数据结构的工作原理,从而能够在脑海中推导出这些复杂度。


总结 🎯

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

  1. 通过判断题深化了对树、堆、二叉搜索树性质的理解。
  2. 实践了一个经典的面试编码问题——验证二叉搜索树,并掌握了递归和边界检查的解决方法。
  3. 系统回顾了本学期学习的所有核心数据结构和算法主题。
  4. 通过性能分析表,对比了不同数据结构在关键操作上的时间复杂度,强化了根据应用场景选择合适数据结构的能力。

期末考试的重点在于理解概念而非死记硬背。请利用过去的试卷和课堂练习进行复习,并准备好周五的答疑环节。祝大家复习顺利!

028:期末考试复习与答疑 📚

在本节课中,我们将一起回顾课程的核心概念,并为期末考试做准备。课程内容涵盖数据结构、算法、递归、迭代器、泛型以及堆等关键主题。我们将通过解答常见问题来巩固理解。


期末考试安排与结构 📅

期末考试将于明天下午3点至6点举行,地点在Center 1,15。请准时参加,并准备好铅笔和纸张。

考试分为两个部分:

  • 第一部分:涵盖期中考试之前的内容,共26分。如果得分高于20分(例如24分),则这部分可获得满分,并可用于替换期中考试成绩。
  • 第二部分:涵盖期中考试之后的内容,共60分。

整场考试共有四道编程题。编程题要求编写完整代码,而非填空。


核心概念回顾与答疑 🤔

上一节我们介绍了考试的整体安排,本节中我们来看看一些核心概念的具体问题。

关于树的基本概念

以下是关于满树和完全树的区别:

  • 满树:每个节点要么有0个子节点,要么有2个子节点。
  • 完全树:除了最底层,其他层都是满的,并且最底层的节点都尽可能靠左排列。一个完全树不一定是满树。

树的遍历(前序、中序、后序)

树的遍历通常采用递归方法。以下是三种遍历方式的递归框架:

// 伪代码框架
void traverse(Node current) {
    if (current == null) return;
    // 前序:操作当前节点
    // operate(current);
    traverse(current.left);
    // 中序:操作当前节点
    // operate(current);
    traverse(current.right);
    // 后序:操作当前节点
    // operate(current);
}

三种遍历方式的区别仅在于“操作当前节点”这一步骤在递归调用中的位置。

归并排序的时间复杂度

归并排序的时间复杂度是 O(n log n)

  • 分割阶段:每次将数组分成两半,需要 log n 层。每层复制数组的操作是 O(n),所以分割总成本是 O(n log n)。
  • 合并阶段:合并两个已排序数组是线性操作 O(n),同样需要进行 log n 层,所以合并总成本也是 O(n log n)。

迭代器的作用与使用

迭代器是一种工具,它允许用户遍历数据结构中的元素,而无需了解数据结构的内部实现细节。
以下是关于迭代器的几个要点:

  • 可以为链表、数组、树等提供迭代器。
  • 遍历链表时,使用迭代器比使用索引的 get 方法更高效。
  • 使用迭代器删除元素时,通常要求前一个操作是 next()previous()
  • 迭代器通常实现为外部类的私有内部类,以便访问外部类的私有数据。

泛型与对象创建

在Java中,不能直接创建泛型类型 E 的对象,例如 new E()。这是因为在编译时,泛型类型 E 会被擦除为 Object,但编译器无法确定具体要实例化哪种类型的对象。

堆化数组

堆化(Heapify)是一个将普通数组转换为堆的过程。对于构建最小堆,算法从最后一个非叶子节点开始,向上遍历每个节点,确保每个节点都满足堆的性质(父节点小于子节点)。这个过程的时间复杂度是 O(n)

以下是堆化过程中可能涉及的节点交换操作(以最小堆为例):

if (array[parent] > array[child]) {
    swap(array, parent, child);
    // 可能需要继续向下调整
}

深度优先搜索与广度优先搜索

DFS和BFS是遍历图(或树、迷宫)的基本算法。

  • 核心思想:从起始节点开始,访问其未探索的邻居,并将其加入一个数据结构中,然后重复此过程。
  • 区别
    • DFS 使用作为数据结构,遵循后进先出的原则。
    • BFS 使用队列作为数据结构,遵循先进先出的原则。
  • 在遍历过程中,一个节点可能会被多次加入数据结构(如果它被多个邻居发现时还未被处理)。


总结 📝

本节课中我们一起学习了期末考试的重要信息和核心概念的复习要点。关键内容包括:

  1. 考试分为两部分,第一部分可替换期中成绩。
  2. 编程题需要编写完整代码。
  3. 理解了满树与完全树的区别。
  4. 掌握了树的前序、中序、后序遍历的递归方法。
  5. 明确了归并排序 O(n log n) 的时间复杂度。
  6. 了解了迭代器的用途和正确使用方法。
  7. 知道了Java中不能直接实例化泛型对象。
  8. 复习了堆化数组的线性时间算法。
  9. 区分了DFS(使用栈)和BFS(使用队列)的遍历机制。

请利用Canvas上的历年试卷进行练习。祝大家在期末考试中取得好成绩!

posted @ 2026-03-29 09:31  布客飞龙II  阅读(16)  评论(0)    收藏  举报