WilliamFiset-数据结构笔记-全-
WilliamFiset 数据结构笔记(全)
001:课程介绍与概览
在本节课中,我们将要学习这门数据结构系列课程的整体安排、学习目标以及讲师背景。课程将从最基础的数据结构开始,逐步深入到更复杂的主题,并通过动画、代码和练习来帮助你掌握核心概念。

讲师介绍
我是William,是这门课程的讲师。我的热情在于算法和数据结构,这使我参与了大量的编程竞赛,并在2017年获得了ACM-ICPC世界总决赛的参赛资格。目前,我在谷歌担任软件工程师,工作地点位于加利福尼亚州山景城的总部。
课程学习方法
本课程将采用多种方法来帮助你高效学习数据结构。


以下是课程的核心学习方法:
- 动画演示:所有视频都将包含动画,以直观的方式展示数据结构的工作原理。动画是学习体验中必不可少的部分。
- 代码实现:我们将一起为每个数据结构编写代码,并提供简单易懂的逐步指导。我们会分析可运行的源代码来巩固理解。
- 动手练习:我将发布各种编程练习和选择题,确保你能获得实际操作数据结构的经验。
课程内容安排

我们将从最简单、最基础的数据结构开始,然后逐步增加难度。

以下是初级数据结构的学习顺序:
- 动态数组
- 链表
- 栈
- 队列
上一节我们介绍了初级数据结构,本节中我们来看看中级数据结构。


以下是中级数据结构的学习顺序:


- 基于堆的优先队列
- 并查集
- 二叉树与二叉搜索树
总结


本节课中我们一起学习了本系列课程的总体框架。我们了解了讲师的背景,明确了课程将通过动画演示、代码实现和动手练习三种核心方法来教学。课程内容将遵循从易到难的顺序,从动态数组、链表等基础结构开始,逐步过渡到优先队列、并查集等中级主题。准备好开始你的数据结构学习之旅吧。
数据结构入门:01:核心概念与抽象数据类型 🧱
在本节课中,我们将学习数据结构的基础核心概念,包括数据结构的定义、重要性,以及抽象数据类型(ADT)的含义与作用。

什么是数据结构?
数据结构是一种组织数据的方式,目的是使数据能够被高效地使用。本质上,它是以某种形式组织数据,以便后续能够快速、便捷地访问、查询或更新。
为什么数据结构很重要?
数据结构之所以重要,主要有以下几个原因。
以下是几个关键点:
- 算法基石:它们是构建快速、强大算法的核心要素。
- 数据管理:它们以一种非常自然的方式帮助我们管理和组织数据。
- 代码质量:它们能使代码更清晰、更易于理解。
一个值得注意的现象是,区分普通程序员与优秀程序员的关键因素之一,就在于是否从根本上理解如何以及何时为特定任务选择合适的数据结构。数据结构的好坏,可能直接决定一个产品是“尚可”还是“卓越”。

抽象数据类型(ADT)
上一节我们介绍了数据结构的基本概念,本节中我们来看看一个与之紧密相关的核心思想:抽象数据类型。
抽象数据类型是一种数学模型,它定义了一组数据以及基于这些数据的一系列操作。关键在于,ADT只规定了“做什么”,而不规定“如何做”。它是对数据结构的逻辑描述,隐藏了具体的实现细节。
例如,我们可以定义一个“列表”ADT,它支持添加元素、删除元素、获取元素等操作。至于这个列表是用数组还是链表来实现,ADT本身并不关心。
数据结构 vs. 抽象数据类型
初学者有时会混淆这两个概念。简单来说:
- 抽象数据类型(ADT) 是逻辑层面的描述,是接口或规范。
- 数据结构 是物理层面的实现,是具体的代码。
我们可以用以下伪代码来理解这种关系:
// 这是一个“栈”(Stack)ADT的接口定义(逻辑)
interface StackADT {
void push(Item item); // 入栈操作
Item pop(); // 出栈操作
boolean isEmpty(); // 检查是否为空
}
// 这是一个使用数组实现“栈”ADT的具体数据结构(物理)
class ArrayStack implements StackADT {
private Item[] items;
private int top;
public void push(Item item) { /* 具体实现代码 */ }
public Item pop() { /* 具体实现代码 */ }
public boolean isEmpty() { /* 具体实现代码 */ }
}
同一个ADT(如栈),可以用不同的数据结构(如数组、链表)来实现。

本节课中我们一起学习了数据结构的基础。我们明确了数据结构的定义与重要性,并深入理解了抽象数据类型(ADT)作为逻辑蓝图的概念,以及它与具体数据结构实现之间的区别。掌握这些核心思想,是后续学习各种具体数据结构(如链表、树、图)的坚实基础。
003:大O表示法入门 🚀
在本节课中,我们将学习一个至关重要的概念——大O表示法。它是衡量算法性能的标准工具,帮助我们理解算法在时间和空间上的效率。理解大O表示法是学习数据结构和算法的基石。
从抽象数据类型到算法性能

上一节我们介绍了抽象数据类型,现在我们需要快速了解一下计算复杂度的广阔世界,以理解我们设计的数据结构所提供的性能。
作为程序员,我们经常反复问自己两个相同的问题:这个算法需要多少时间来完成?以及这个算法需要多少空间来进行计算?
如果你的程序需要宇宙生命周期那么长的时间才能完成,那它显然是不行的。同样,如果你的程序在常数时间内运行,但需要的空间等于互联网上所有文件字节的总和,你的算法也是无用的。
大O表示法的作用
为了标准化地讨论算法运行所需的时间和空间,理论计算机科学家发明了大O表示法。此外还有大Θ、大Ω等符号,但我们主要关注大O,因为它告诉我们的是最坏情况。

大O表示法只关心最坏情况。例如,如果你的算法是排序数字,就想象输入是你的特定排序算法可能遇到的最糟糕的数字排列。
或者举一个具体例子:假设你有一个无序的唯一数字列表,你正在搜索数字7或其位置。
核心概念与表示
大O表示法用数学方式描述了算法复杂度随输入规模增长的趋势。它忽略了常数因子和低阶项,专注于主导项。
以下是几种常见的复杂度类别:
- O(1) - 常数时间复杂度:执行时间不随输入数据规模变化。
- 示例:访问数组中的某个元素。
- 代码示例:
array[index]
- O(log n) - 对数时间复杂度:执行时间随输入规模呈对数增长。
- 示例:二分查找。
- O(n) - 线性时间复杂度:执行时间与输入规模成正比。
- 示例:遍历一个列表。
- 代码示例:
for item in list: print(item)
- O(n log n) - 线性对数时间复杂度:常见于高效的排序算法。
- 示例:归并排序、快速排序的平均情况。
- O(n²) - 平方时间复杂度:执行时间与输入规模的平方成正比。
- 示例:简单的双重循环(如冒泡排序)。
- 代码示例:
for i in range(n): for j in range(n): # 执行操作
- O(2^n) - 指数时间复杂度:通常出现在递归求解所有组合的问题中,性能随规模增长急剧下降。
总结

本节课中,我们一起学习了算法分析的核心工具——大O表示法。我们了解到它用于描述算法在最坏情况下的时间或空间复杂度随输入规模增长的趋势,并且它关注的是增长的量级而非具体时间。掌握大O表示法,将帮助你在未来设计和选择数据结构与算法时,做出更明智、更高效的决策。
004:静态与动态数组
在本节课中,我们将学习数组这一最基本的数据结构。数组是所有其他数据结构的基石,仅通过数组和指针,我们几乎可以构建出任何复杂的数据结构。本节课是数组专题的第一部分。
数组概述与基本问题
首先,我们将讨论数组并回答一些基本问题:数组是什么?在哪里以及如何使用数组?
接下来,我们将解释数组的基本结构以及可以在数组上执行的常见操作。
最后,我们将进行一些复杂度分析,并查看如何仅使用静态数组来构建动态数组的源代码示例。
什么是静态数组?
上一节我们介绍了数组的基本概念,本节中我们来看看静态数组的具体定义。

静态数组是一个固定长度的容器,包含 n 个可索引的元素。索引范围通常是从 0(包含)到 n-1(包含)。
那么,一个随之而来的问题是:什么是“可索引”?
这意味着数组中的每个槽位或索引都可以用一个数字来引用。
此外,我想补充说明静态数组的特点。
静态数组的使用
以下是静态数组的一些典型使用场景:
- 存储和访问顺序数据。
- 用作其他数据结构的底层实现(如堆栈、队列)。
- 用于迭代访问元素的临时存储。
数组的基本操作
了解定义后,我们来看看可以对数组执行哪些基本操作。以下是常见的数组操作:

- 访问:通过索引获取或修改元素。时间复杂度为 O(1)。
- 遍历:按顺序访问每个元素。时间复杂度为 O(n)。
- 查找:在数组中搜索特定元素。线性查找的时间复杂度为 O(n)。
复杂度分析与动态数组
我们讨论了静态数组的操作,现在来分析其复杂度,并探索如何构建动态数组。
静态数组的主要限制在于其大小固定。为了克服这一点,我们可以用静态数组实现动态数组。其核心思想是,当数组容量不足时,创建一个更大的新数组,并将所有元素复制过去。
以下是一个简化的动态数组扩容过程的伪代码描述:
function insertEnd(array, element):
if array is full:
new_capacity = array.capacity * 2
new_array = allocate new array with size new_capacity
for i from 0 to array.size - 1:
new_array[i] = array[i]
array = new_array
array[array.size] = element
array.size = array.size + 1
虽然单次扩容操作的成本较高(O(n)),但通过均摊分析,在动态数组末尾插入元素的均摊时间复杂度仍然是 O(1)。

总结
本节课中我们一起学习了数组的基础知识。我们定义了静态数组——一个固定大小、可索引的容器,并探讨了其基本操作。最后,我们了解了动态数组的原理,即通过静态数组扩容来实现可调整大小的数组,并分析了其操作的复杂度。理解数组是掌握更高级数据结构的关键第一步。
005:动态数组代码实现 📝

在本节课中,我们将学习如何用代码实现一个动态数组。我们将深入探讨其内部结构、核心方法以及如何管理容量。通过本教程,你将理解动态数组的工作原理,并能够自己实现一个。
上一节我们介绍了动态数组的基本概念。本节中,我们来看看具体的代码实现。
类结构与成员变量
首先,我们定义一个支持泛型 T 的 Array 类。这意味着我们的数组可以存储任意类型的数据。


以下是类的核心成员变量:
arr:这是内部的静态数组,用于实际存储数据。len:这是用户感知的数组长度。capacity:这是内部数组arr的实际容量。有时capacity会大于len,这意味着数组中有未使用的空闲槽位,但这属于内部实现细节,不对外暴露。

public class Array<T> {
private T[] arr;
private int len = 0; // 用户认为的长度
private int capacity = 0; // 数组的实际容量
}
构造函数
我们的数组类提供了两个构造函数。

以下是构造函数的实现:


- 无参构造函数:将内部数组的初始容量设置为默认值 16。
- 带参构造函数:允许用户指定初始容量。容量必须大于或等于 0。

public Array() {
this(16); // 调用另一个构造函数,设置默认容量为16
}
public Array(int capacity) {
if (capacity < 0) throw new IllegalArgumentException("Illegal Capacity: " + capacity);
this.capacity = capacity;
// 初始化泛型数组需要进行类型转换
arr = (T[]) new Object[capacity];
}

注意:在初始化泛型数组 arr 时,我们需要创建 Object 数组并将其转换为 T[] 类型。这通常会产生一个编译器警告,但在此上下文中是安全的。


本节课中我们一起学习了动态数组类的基本框架,包括其成员变量和构造函数的实现。我们了解了 len 与 capacity 的区别,以及如何初始化一个泛型数组。在接下来的课程中,我们将继续实现数组的添加、删除、扩容等核心方法。
006:链表入门 🧩
在本节课中,我们将要学习链表,这是一种极其有用的数据结构。我们将探讨单链表和双链表的基本概念、术语、优缺点以及基本操作。这是关于链表的两部分教程的第一部分,在第二部分中,我们将通过源代码学习如何实现一个双链表。
什么是链表? 🤔
上一节我们介绍了课程概述,本节中我们来看看链表的定义。
链表是一个由节点组成的顺序列表,每个节点包含数据,并指向其他也包含数据的节点。
下图是一个包含任意数据的单链表示例。

请注意,每个节点都有一个指向下一个节点的指针。同时,最后一个节点指向 null,这意味着在此之后没有更多节点。最后一个节点总是指向 null。为了简洁,在后续的幻灯片中我将省略这一点。

链表的应用场景 🏗️
了解了链表的基本定义后,我们来看看它在哪些地方被广泛使用。
以下是链表的一些常见应用场景:
- 列表、队列和栈的实现:链表是构建这些抽象数据类型的理想底层数据结构。
- 循环列表:例如用于操作系统中的循环调度。
- 哈希表的冲突处理:在哈希表中,链表可用于解决键冲突。
- 图的邻接表表示:链表可用于高效地表示图结构。
链表术语 📖
在深入讨论操作之前,我们需要统一一些关键术语,以便后续理解。
以下是链表相关的核心术语:
- 头节点:链表中的第一个节点。
- 尾节点:链表中的最后一个节点。
- 指针/引用:指向另一个节点的变量。
- 节点:包含数据和指针的对象。
单链表与双链表的优缺点 ⚖️
现在我们已经熟悉了基本术语,本节我们来对比分析单链表和双链表的优缺点。
单链表的优缺点
以下是单链表的主要特点:
- 优点:使用的内存较少(每个节点少一个指针)。插入和删除操作相对简单。
- 缺点:无法轻易访问前驱节点。若需反向遍历链表,则实现较为困难。
双链表的优缺点
以下是双链表的主要特点:
- 优点:可以双向遍历链表。在给定节点前插入或删除该节点的操作更简单。
- 缺点:每个节点需要额外的内存来存储指向前驱节点的指针。插入和删除操作需要处理更多指针。

插入与删除操作 🔧
理解了优缺点后,我们来看看链表的核心操作:插入和删除元素。
单链表的插入与删除
在单链表中插入或删除节点,主要涉及调整 next 指针的指向。
在节点 X 之后插入节点 Y:
- 将
Y的next指针指向X原来的下一个节点。 - 将
X的next指针指向Y。
用伪代码表示:
Y.next = X.next
X.next = Y
删除节点 X 之后的节点:
- 找到
X之后的节点(记为Y)。 - 将
X的next指针指向Y的下一个节点(即Y.next)。
用伪代码表示:
X.next = X.next.next
// 注意:在实际编程中,可能需要处理内存释放(如Java的GC或C++的delete)。
双链表的插入与删除
双链表的操作需要同时维护 prev(前驱)和 next(后继)指针。
在节点 X 之后插入节点 Y:
- 将
Y的prev指针指向X。 - 将
Y的next指针指向X原来的下一个节点(记为Z)。 - 如果
Z不为空(即X不是尾节点),则将Z的prev指针指向Y。 - 将
X的next指针指向Y。
用伪代码表示:
Y.prev = X
Y.next = X.next
if X.next != null:
X.next.prev = Y
X.next = Y
删除节点 X:
- 如果
X有前驱节点(X.prev不为空),则将前驱节点的next指针指向X的后继节点(X.next)。 - 如果
X有后继节点(X.next不为空),则将后继节点的prev指针指向X的前驱节点(X.prev)。

用伪代码表示:
if X.prev != null:
X.prev.next = X.next
if X.next != null:
X.next.prev = X.prev
// 同样,注意实际的内存管理。

总结 📚
本节课中我们一起学习了链表的基础知识。我们首先定义了链表是由节点组成的顺序集合。然后探讨了链表的常见应用场景,并学习了头节点、尾节点等关键术语。接着,我们对比了单链表和双链表的优缺点。最后,我们详细讲解了如何在单链表和双链表中执行插入与删除操作,这是操作链表的基石。
在下一部分,我们将通过实际的源代码来深入实现一个双链表。
007:双向链表代码实现 🧩
在本节课中,我们将学习如何用Java代码实现一个双向链表。我们将从定义节点类开始,逐步实现链表的清空、大小获取、检查是否为空、添加和移除元素等核心操作。课程内容基于实际源代码,力求简单明了,适合初学者理解。
上一节我们介绍了双向链表的基本概念。本节中,我们来看看具体的代码实现。
首先,我们定义链表类并声明几个实例变量。我们需要跟踪链表的大小,以及当前的头节点和尾节点。初始时,链表为空,因此头节点和尾节点都设为null。
private int size = 0;
private Node<T> head = null;
private Node<T> tail = null;
此外,我们将频繁使用一个内部节点类,因为它封装了每个节点的数据以及指向前后节点的指针,这是双向链表的关键。
private static class Node<T> {
T data;
Node<T> prev, next;
public Node(T data, Node<T> prev, Node<T> next) {
this.data = data;
this.prev = prev;
this.next = next;
}
}
接下来,我们实现第一个方法:清空链表。这个方法以线性时间复杂度遍历所有节点,并通过将节点引用设为null来帮助垃圾回收器释放内存。
public void clear() {
Node<T> trav = head;
while (trav != null) {
Node<T> next = trav.next;
trav.prev = trav.next = null;
trav.data = null;
trav = next;
}
head = tail = trav = null;
size = 0;
}


在学习了如何清空链表后,我们来看几个简单的工具方法。这些方法用于获取链表当前的状态。
以下是获取链表大小和检查链表是否为空的实现:
public int size() {
return size;
}
public boolean isEmpty() {
return size() == 0;
}
掌握了基础状态查询后,我们开始学习如何向链表中添加元素。首先实现的是在链表末尾添加一个节点。
public void add(T elem) {
addLast(elem);
}
addLast方法处理两种情况:如果链表为空,新节点即成为头节点和尾节点;如果链表不为空,则将新节点链接到当前尾节点之后,并更新尾节点。
public void addLast(T elem) {
if (isEmpty()) {
head = tail = new Node<T>(elem, null, null);
} else {
tail.next = new Node<T>(elem, tail, null);
tail = tail.next;
}
size++;
}

学会了在末尾添加,我们再来看看如何在链表开头添加一个节点。其逻辑与addLast对称。

public void addFirst(T elem) {
if (isEmpty()) {
head = tail = new Node<T>(elem, null, null);
} else {
head.prev = new Node<T>(elem, null, head);
head = head.prev;
}
size++;
}
有时我们需要在特定位置插入节点。addAt方法实现了这一功能,它首先检查索引是否有效,然后根据索引位置决定是在开头插入、末尾插入还是在中间插入。
public void addAt(int index, T data) throws Exception {
if (index < 0 || index > size) {
throw new Exception("Illegal Index");
}
if (index == 0) {
addFirst(data);
return;
}
if (index == size) {
addLast(data);
return;
}
Node<T> temp = head;
for (int i = 0; i < index - 1; i++) {
temp = temp.next;
}
Node<T> newNode = new Node<>(data, temp, temp.next);
temp.next.prev = newNode;
temp.next = newNode;
size++;
}

添加元素是构建链表的基础,与之对应,移除元素也是核心操作。我们首先实现查看(但不移除)头节点和尾节点数据的方法。
public T peekFirst() {
if (isEmpty()) throw new RuntimeException("Empty list");
return head.data;
}
public T peekLast() {
if (isEmpty()) throw new RuntimeException("Empty list");
return tail.data;
}
现在,我们实现移除头节点的方法removeFirst。它需要处理链表为空、只有一个节点和多个节点的情况。

public T removeFirst() {
if (isEmpty()) throw new RuntimeException("Empty list");
T data = head.data;
head = head.next;
--size;
if (isEmpty()) {
tail = null;
} else {
head.prev = null;
}
return data;
}
类似地,移除尾节点的方法removeLast逻辑与之对称。
public T removeLast() {
if (isEmpty()) throw new RuntimeException("Empty list");
T data = tail.data;
tail = tail.prev;
--size;
if (isEmpty()) {
head = null;
} else {
tail.next = null;
}
return data;
}
最后,我们实现一个通用的移除方法remove,它遍历链表找到与给定对象匹配的第一个节点并将其移除。
public boolean remove(Object obj) {
Node<T> trav = head;
if (obj == null) {
for (trav = head; trav != null; trav = trav.next) {
if (trav.data == null) {
remove(trav);
return true;
}
}
} else {
for (trav = head; trav != null; trav = trav.next) {
if (obj.equals(trav.data)) {
remove(trav);
return true;
}
}
}
return false;
}

内部的remove方法负责处理节点断开连接的具体逻辑。
private T remove(Node<T> node) {
if (node.prev == null) return removeFirst();
if (node.next == null) return removeLast();
node.next.prev = node.prev;
node.prev.next = node.next;
T data = node.data;
node.data = null;
node = node.prev = node.next = null;
--size;
return data;
}
为了查找元素,我们还需要一个indexOf方法,它返回给定对象在链表中首次出现的索引。
public int indexOf(Object obj) {
int index = 0;
Node<T> trav = head;
if (obj == null) {
for (; trav != null; trav = trav.next, index++) {
if (trav.data == null) {
return index;
}
}
} else {
for (; trav != null; trav = trav.next, index++) {
if (obj.equals(trav.data)) {
return index;
}
}
}
return -1;
}
最后,为了检查链表中是否包含某个元素,我们可以简单地利用indexOf方法。

public boolean contains(Object obj) {
return indexOf(obj) != -1;
}

本节课中我们一起学习了双向链表的完整Java代码实现。我们从定义节点结构开始,逐步实现了链表的初始化、清空、状态检查、添加元素(在开头、末尾和指定位置)以及移除元素(移除头节点、尾节点和特定对象)等所有核心功能。理解这些基础操作是掌握更复杂数据结构的基石。
008:栈(Stack)入门 🥞
在本节课中,我们将要学习一种非常出色的数据结构——栈。我们将了解栈是什么、它在哪里被使用,并初步探讨其核心操作。这是关于栈的三个视频中的第一部分。
栈是一种单端线性数据结构,它通过两个主要操作——压入(push) 和 弹出(pop) ——来模拟现实世界中的堆叠行为。在栈中,元素的添加和移除总是发生在顶部,这种行为通常被称为 后进先出(LIFO)。
什么是栈?🤔
栈是一种单端线性数据结构,它通过两个主要操作——压入(push) 和 弹出(pop) ——来模拟现实世界中的堆叠行为。
下图展示了一个栈的示例。有一个数据成员正从栈顶被弹出,同时另一个数据成员正被添加到栈中。请注意,有一个 顶部指针(top pointer) 始终指向栈顶的元素。


这是因为栈中的元素总是在堆的顶部被移除和添加。这种行为通常被称为 后进先出(LIFO,Last In First Out)。
栈的核心操作 ⚙️
栈的核心操作非常简单,主要围绕栈顶进行。
以下是栈的两种基本操作:
- 压入(Push):将一个元素添加到栈的顶部。
- 公式/代码表示:
stack.push(item)
- 公式/代码表示:
- 弹出(Pop):移除并返回栈顶的元素。
- 公式/代码表示:
top_item = stack.pop()
- 公式/代码表示:
除了这两个主要操作,通常还有一个 查看栈顶(Peek) 操作,它只返回栈顶元素的值而不移除它。

栈的应用场景 🛠️
栈在计算机科学中有着广泛的应用。理解这些应用场景能帮助我们更好地掌握栈的概念。

以下是栈的一些常见用途:
- 函数调用栈:在程序执行时,用来管理函数调用和返回地址。
- 撤销(Undo)功能:许多编辑器将操作历史保存在栈中,以便撤销。
- 括号匹配:编译器使用栈来检查代码中的括号是否成对且正确嵌套。
- 深度优先搜索(DFS):在图和树的遍历算法中,栈用于跟踪待访问的节点。
栈的实现与复杂度 📊
上一节我们介绍了栈的概念和应用,本节中我们来看看栈是如何实现的以及其操作的效率。
栈通常可以使用数组或链表来实现。无论采用哪种底层结构,其核心操作的时间复杂度都是常数时间。
以下是栈主要操作的时间复杂度:
- 压入(Push):O(1)
- 弹出(Pop):O(1)
- 查看栈顶(Peek):O(1)
- 搜索(Search):O(n) (因为可能需要遍历整个栈)

这意味着添加和移除元素的操作非常高效。
总结 📝
本节课中我们一起学习了栈数据结构的基础知识。我们了解到栈是一种遵循LIFO原则的线性数据结构,其核心操作是压入和弹出。我们还探讨了栈在编程中的多种实际应用,并了解了其操作具有很高的效率。

在接下来的视频中,我们将深入探讨栈的具体实现方式,并查看使用链表实现栈的源代码。
009:栈的实现 🧱
在本节课中,我们将学习如何使用链表来实现一个栈数据结构。栈是一种遵循“后进先出”原则的数据结构,理解其底层实现对于掌握算法至关重要。
概述

栈通常可以使用数组或链表来实现。本节将重点介绍如何使用单向链表来实现栈的核心操作——入栈。我们首先会理解其背后的逻辑,然后会简要提及,在后续的完整源代码中,实际使用的是双向链表。
栈的链表实现原理
上一节我们介绍了栈的基本概念,本节中我们来看看如何用链表来构建它。
栈的链表实现关键在于:所有新元素都插入在链表的头部,而不是尾部。这样做是为了确保当我们执行“弹出”操作时,指针的指向是正确的。
初始化一个空栈
首先,我们需要初始化一个空栈。这通过将链表的头指针 head 指向 null 来实现。
head = null;
这表示栈的初始状态为空。
执行入栈操作
入栈操作的步骤如下:
以下是向栈中依次添加元素 2、5、13 的过程:
-
添加元素 2:
- 创建一个值为
2的新节点。 - 将新节点的
next指针指向当前head指向的节点(此时为null)。 - 将
head指针更新为指向这个新节点。
![]()
- 创建一个值为
-
添加元素 5:
- 创建一个值为
5的新节点。 - 将新节点的
next指针指向当前head指向的节点(即值为2的节点)。 - 将
head指针更新为指向这个新节点。
- 创建一个值为
-
添加元素 13:
- 遵循相同的步骤,创建新节点,将其
next指向当前head(值为5的节点),然后移动head指针。
- 遵循相同的步骤,创建新节点,将其
通过这种方式,最后添加的元素 13 始终位于链表的头部,也就是栈顶。
执行出栈操作
理解了入栈,出栈操作就相对简单了。
出栈操作的逻辑如下:
- 将
head指针移动到当前头节点的next节点。 - 原先的头节点(栈顶元素)便被移除了(在支持垃圾回收的语言中会被自动回收)。
![]()
这个过程非常高效,时间复杂度为 O(1)。
总结

本节课中我们一起学习了栈的链表实现方法。我们了解到,通过在链表头部进行插入和删除,可以高效地模拟栈的“后进先出”特性。虽然本节以单向链表为例进行讲解,但请记住,在实际的完整代码实现中,我们可能会使用双向链表来获得更多的操作灵活性。掌握这一实现原理是理解更复杂数据结构的基础。
010:栈的代码实现 📚
在本节课中,我们将学习如何使用Java语言实现一个简单的栈数据结构。我们将基于Java内置的LinkedList来构建栈,并实现其核心方法,如入栈、出栈、查看栈顶元素以及获取栈的大小。
栈的实现概述

上一节我们介绍了栈的理论概念和基于链表的实现原理。本节中,我们来看看具体的代码实现。我们将创建一个名为Stack的类,它内部使用Java的LinkedList来存储数据。
以下是栈类的基本结构:
import java.util.LinkedList;
public class Stack<T> {
private LinkedList<T> list = new LinkedList<T>();
// 构造方法和其他方法将在这里实现
}
构造方法
栈类提供了两种构造方式。一种是创建一个空栈,另一种是创建一个包含初始元素的栈。
以下是两种构造方法的实现:
// 创建空栈
public Stack() {
// 初始化一个空的链表
}
// 创建包含一个初始元素的栈
public Stack(T firstElem) {
push(firstElem);
}

核心方法实现
现在,我们来实现栈的核心操作。这些方法包括获取栈的大小、检查栈是否为空、入栈、出栈和查看栈顶元素。
获取栈的大小
要获取栈中元素的数量,我们只需返回内部链表的长度。
public int size() {
return list.size();
}
检查栈是否为空
如果栈中没有元素,则栈为空。我们可以通过检查内部链表是否为空来判断。

public boolean isEmpty() {
return size() == 0;
}
入栈操作
向栈中添加一个元素。根据栈的后进先出特性,新元素应被添加到链表的头部。
public void push(T elem) {
list.addFirst(elem);
}
出栈操作
从栈中移除并返回顶部的元素。这对应于移除链表的第一个元素。
public T pop() {
if (isEmpty()) {
throw new java.util.EmptyStackException();
}
return list.removeFirst();
}
查看栈顶元素

返回栈顶的元素但不移除它。这对应于获取链表的第一个元素。

public T peek() {
if (isEmpty()) {
throw new java.util.EmptyStackException();
}
return list.getFirst();
}

使用示例

为了帮助理解,这里有一个简单的示例,展示如何使用我们实现的栈类。
public class Main {
public static void main(String[] args) {
Stack<Integer> stack = new Stack<>();
// 入栈操作
stack.push(10);
stack.push(20);
stack.push(30);
// 查看栈顶元素
System.out.println("栈顶元素: " + stack.peek()); // 输出 30
// 出栈操作
System.out.println("出栈: " + stack.pop()); // 输出 30
// 获取栈的大小
System.out.println("栈的大小: " + stack.size()); // 输出 2
}
}
总结

本节课中我们一起学习了如何使用Java实现一个栈数据结构。我们基于LinkedList构建了栈类,并实现了其核心方法:size、isEmpty、push、pop和peek。通过具体的代码示例,我们进一步理解了栈的后进先出特性及其基本操作。掌握栈的实现是理解更复杂数据结构和算法的重要基础。
011:队列入门 🚶♂️➡️🚶♀️
在本节课中,我们将要学习一种在计算机科学中极为有用的数据结构——队列。这是队列系列三部分中的第一部分。
概述 📋

首先,我们将探讨队列是什么。接着,我们会分析队列操作的时间复杂度。然后,我们将详细讨论如何实现队列的入队和出队操作。在本系列的最后一部分,我们将提供相关的源代码示例。
什么是队列? 🤔
队列是一种线性数据结构,它模拟了现实世界中的排队现象。队列主要支持两种基本操作:入队和出队。
下图展示了一个队列的示例:


队列的结构与操作 🔄
每个队列都有一个前端和一个后端。元素从后端插入,从前端移除。

- 将元素添加到队列后端称为 入队。
- 从队列前端移除元素称为 出队。
下图清晰地展示了这一过程:


关于队列的术语 📝
围绕队列的术语存在一些不一致的情况,许多人会使用不同的词语来描述相同的操作。
以下是常见的同义词:
- 入队 也可称为 添加 或 提供。


- 类似地,出队 也有其他叫法。

具体来说,当我们从队列前端移除元素时,这个过程就是出队。


总结 🎯

本节课我们一起学习了队列的基本概念。我们了解到队列是一种“先进先出”的线性数据结构,包含前端和后端,核心操作是入队和出队。同时,我们也注意到描述这些操作的术语存在多种表达方式。
在接下来的课程中,我们将深入分析这些操作的时间复杂度,并开始探讨如何具体实现一个队列。
012:队列实现 🧠
在本节课中,我们将学习队列在广度优先搜索中的应用,并深入探讨队列的入队和出队操作的实现细节。
广度优先搜索示例 🔍

上一节我们介绍了队列的基本概念,本节中我们来看看队列在广度优先搜索中的具体应用。广度优先搜索是一种用于图遍历的操作。这里的“图”指的是网络结构,而非条形图或折线图。
在广度优先搜索中,目标是从一个起始节点开始,遍历整个图。首先访问起始节点的所有邻居,然后访问第一个访问节点的所有邻居,接着是第二个访问节点的所有邻居,依此类推。随着搜索的进行,边界不断向外扩展。
我们可以将广度优先搜索的每一次迭代视为从当前节点向外扩展边界的过程。
让我们从节点0开始广度优先搜索。将节点0标记为黄色,并将其放入边界或访问组中。
现在,我们将访问节点0的所有邻居,即节点1和节点9,并将它们添加到边界中。
接着,我们将访问节点1的邻居,即节点8。同样地,对于节点8,我们将访问其邻居节点7。

队列操作实现细节 ⚙️
在了解了广度优先搜索的基本流程后,我们来看看队列的入队和出队操作是如何实现的。以下是队列的核心操作:
- 入队操作:将元素添加到队列的末尾。
- 出队操作:从队列的头部移除元素。
队列的实现通常使用数组或链表来存储元素。以下是使用数组实现队列的示例代码:
class Queue:
def __init__(self):
self.items = []
def enqueue(self, item):
self.items.append(item)
def dequeue(self):
if not self.is_empty():
return self.items.pop(0)
else:
raise IndexError("队列为空")
def is_empty(self):
return len(self.items) == 0
def size(self):
return len(self.items)
在上述代码中,我们使用列表来存储队列的元素。enqueue方法将元素添加到列表的末尾,而dequeue方法则从列表的头部移除元素。需要注意的是,如果队列为空,dequeue方法会抛出异常。
总结 📚

本节课中我们一起学习了队列在广度优先搜索中的应用,并深入探讨了队列的入队和出队操作的实现细节。通过具体的代码示例,我们了解了如何使用数组实现队列的基本操作。希望这些内容能帮助你更好地理解队列的工作原理及其在实际问题中的应用。
013:队列代码实现 🧱
在本节课中,我们将学习如何用代码实现一个队列数据结构。我们将基于Java语言,使用双向链表作为底层存储结构来构建一个队列。通过分析核心方法的实现,你将理解队列“先进先出”原则在代码层面的具体体现。

上一节我们介绍了队列的理论概念,本节中我们来看看如何用代码实现它。
首先,我们定义队列类并声明其核心数据结构。这里使用Java内置的LinkedList作为底层存储,它是一个双向链表。
public class Queue<T> {
private LinkedList<T> list = new LinkedList<T>();
}

接下来,我们为队列类创建构造函数。以下是两种初始化队列的方式。
- 一个默认构造函数,用于创建一个空队列。
- 一个带参数的构造函数,允许在创建队列时传入第一个元素。
// 构造函数1:创建空队列
public Queue() { }
// 构造函数2:创建包含第一个元素的队列
public Queue(T firstElem) {
offer(firstElem);
}

上一部分我们完成了队列的初始化,现在我们来添加一些基础方法。
获取队列大小和检查队列是否为空是两个基础操作。它们的实现非常直接,直接委托给底层的链表。
// 返回队列中的元素数量
public int size() {
return list.size();
}
// 检查队列是否为空
public boolean isEmpty() {
return size() == 0;
}


了解了基础状态查询后,我们进入队列的核心操作部分。
peek方法用于查看队列前端的元素(即下一个要出队的元素),但不会将其从队列中移除。
// 查看队首元素(不移除)
public T peek() {
if (isEmpty()) {
throw new RuntimeException("Queue Empty");
}
return list.peekFirst();
}

查看元素之后,我们自然需要实现元素的入队和出队操作。

以下是队列的两个核心修改操作:offer(入队)和poll(出队)。
offer方法将一个新元素添加到队列的末尾。poll方法移除并返回队列前端的元素。
// 入队:将元素添加到队尾
public void offer(T elem) {
list.addLast(elem);
}
// 出队:移除并返回队首元素
public T poll() {
if (isEmpty()) {
throw new RuntimeException("Queue Empty");
}
return list.removeFirst();
}

本节课中我们一起学习了如何使用双向链表在Java中实现一个完整的队列数据结构。我们涵盖了从类定义、构造函数,到核心方法peek、offer和poll的实现。这个实现清晰地展示了队列“先进先出”的访问顺序,即所有元素从尾部加入,从头部离开。理解这个基础实现是进一步学习更复杂队列变体(如循环队列、优先队列)的重要基石。
014:优先队列入门教程 🎯
在本节课中,我们将要学习优先队列的一切知识,包括其用途、实现方式,并在最后查看一些源代码。除了优先队列的内容,我们还将讨论堆,因为这两个主题密切相关,尽管并不相同。
什么是优先队列?🤔
上一节我们介绍了课程概述,本节中我们来看看优先队列的基本概念。
优先队列是一种抽象数据类型,其操作类似于常规队列或栈,但每个元素都关联有一个“优先级”。在优先队列中,高优先级的元素在低优先级的元素之前被服务。如果两个元素具有相同的优先级,则通常根据它们在队列中的顺序来服务。
为什么优先队列有用?💡
优先队列在计算机科学中有广泛的应用。以下是其常见用途:
- Dijkstra算法:用于在图中寻找最短路径。
- 数据压缩:例如在哈夫曼编码中,用于构建最优前缀码。
- 最佳优先搜索算法:如A*搜索算法。
- 操作系统:用于负载平衡和中断处理。
- 人工智能:用于决策制定。
优先队列的常见操作 ⚙️
了解了优先队列的用途后,本节我们来看看对优先队列可以执行哪些基本操作。

以下是优先队列支持的核心操作:
- 插入(Insert/Add/Offer):向队列中添加一个带有优先级的元素。
- 轮询(Poll/Remove):移除并返回优先级最高(或最低,取决于类型)的元素。
- 查看(Peek):返回优先级最高(或最低)的元素但不移除它。
- 是否为空(IsEmpty):检查队列是否为空。
- 获取大小(Size):返回队列中元素的数量。
最小堆与最大堆的转换 🔄
优先队列通常分为两种:最小优先队列(元素值越小优先级越高)和最大优先队列(元素值越大优先级越高)。一个常见的技巧是,我们可以通过简单的转换,用一种实现来模拟另一种。
要将最小优先队列转换为最大优先队列,可以在插入元素时对其值取反。这样,最小的负数(即绝对值最大的正数)就会处于堆顶。取出元素时,再对其值取反即可恢复原始值。
代码示例(概念性):
# 使用最小堆实现最大优先队列
max_pq = MinPriorityQueue()
# 插入元素 5
max_pq.insert(-5)
# 插入元素 10
max_pq.insert(-10)
# 轮询:取出 -10,取反后得到 10(最大值)
value = -max_pq.poll()
复杂度分析 📊
现在我们已经了解了优先队列的操作,本节我们来分析这些操作的时间复杂度。复杂度取决于其底层实现。对于最常见的实现——二叉堆,其复杂度如下:
- 插入(Insert):O(log n)
- 轮询(Poll):O(log n)
- 查看(Peek):O(1)
- 是否为空(IsEmpty):O(1)
- 获取大小(Size):O(1)
其中,n代表队列中元素的数量。
优先队列的实现方式 🏗️
很多人认为堆是实现优先队列的唯一方式,或者认为优先队列就是堆。本节我们将澄清这个误解。
优先队列是一个抽象接口,可以通过多种数据结构来实现。以下是几种常见的实现方式及其时间复杂度比较:
| 数据结构 | 插入复杂度 | 轮询复杂度 | 备注 |
|---|---|---|---|
| 有序动态数组/链表 | O(n) | O(1) | 插入慢,因为要维护顺序。 |
| 无序动态数组/链表 | O(1) | O(n) | 轮询慢,因为要搜索最值。 |
| 二叉堆(Binary Heap) | O(log n) | O(log n) | 最常用的平衡实现。 |
| 二项堆(Binomial Heap) | O(1) | O(log n) | 合并操作高效。 |
| 斐波那契堆(Fibonacci Heap) | O(1) | O(log n) | 理论性能最佳,但实现复杂。 |
可以看到,二叉堆在插入和删除操作上取得了较好的平衡,因此成为实践中实现优先队列的标准选择。
使用二叉堆实现优先队列(核心:上浮与下沉)🌊
既然二叉堆如此重要,本节我们将深入探讨如何用它来实现优先队列,核心在于理解“上浮”和“下沉”操作。
二叉堆是一种特殊的完全二叉树,它满足堆属性:在最小堆中,每个节点的值都小于或等于其子节点的值;在最大堆中则相反。
为了在插入或删除元素后维护堆属性,我们需要两个关键操作:
-
上浮(Swim):当一个节点的值变得比其父节点更优(在最小堆中更小)时,需要将其向上移动,直到堆属性恢复。
公式/过程:比较节点与其父节点,如果顺序错误则交换,并重复此过程直到根节点。 -
下沉(Sink):当一个节点(通常是根节点)的值变得比其子节点更差(在最小堆中更大)时,需要将其向下移动,直到堆属性恢复。
公式/过程:比较节点与其子节点,与最优(最小堆中最小)的子节点交换,并重复此过程直到叶节点。

插入元素(Add)
插入元素时,我们将其添加到堆的末尾(保持完全二叉树形态),然后对这个新节点执行上浮操作,使其到达正确位置。
移除元素(Poll)

移除元素(通常是根节点)时,我们将根节点与堆的最后一个节点交换,移除原来的根节点(现在是最后一个节点),然后对新的根节点执行下沉操作,以恢复堆属性。
总结 📝
本节课中我们一起学习了优先队列的核心知识。我们首先了解了优先队列是什么以及它的应用场景。然后,我们探讨了其基本操作,并学习了如何在最小堆和最大堆之间进行转换。接着,我们分析了不同实现方式的复杂度,并澄清了优先队列与堆的关系——堆只是实现优先队列的一种高效数据结构。最后,我们深入讲解了如何使用二叉堆实现优先队列,重点掌握了通过“上浮”和“下沉”操作来维护堆属性的机制。掌握这些概念是理解和使用更高级算法的基础。
015:最小堆与最大堆的转换 🔄
在本节课中,我们将学习如何将一个最小优先队列转换为一个最大优先队列。这是一个非常实用的技巧,因为许多编程语言的标准库通常只提供一种类型的优先队列(通常是基于最小堆的最小优先队列)。理解这种转换机制,能让你在需要相反排序逻辑时,灵活运用现有的数据结构。

为什么需要转换?🤔
你可能会问,为什么需要知道如何转换优先队列的类型?问题在于,大多数编程语言的标准库通常只提供最小优先队列或最大优先队列中的一种,最常见的是最小优先队列。最小优先队列会优先处理最小的元素。
然而,根据编程任务的不同,我们有时需要的是最大优先队列,即优先处理最大的元素。
转换的核心思想 💡

那么,我们如何进行这种转换呢?如何将一种类型的优先队列转变为另一种类型?我们可以利用一个技巧:滥用优先队列中所有元素都必须实现某种可比较接口这一事实。


通过简单地取反或反转比较逻辑,我们就可以得到另一种类型的堆。让我们来看一些例子。
通过取反比较逻辑实现转换 🔧
假设我们有一个最小优先队列,其中的元素如屏幕右侧所示。如果 x 和 y 是优先队列中的数字,并且 x <= y,那么在最小优先队列中,x 会先于 y 被取出。
这个逻辑的取反是 x >= y。在这种情况下,y 会先于 x 被取出,因为所有元素仍然在优先队列中,但排序逻辑被反转了。

以下是实现这种转换的几种方法:
-
数值取反法:如果优先队列存储的是数值,可以在插入元素时将其取反(乘以-1)。这样,最小堆在处理取反后的值时,实际上会表现得像最大堆。
# 示例:将数值取反后插入最小堆,以实现最大堆行为 min_heap.push(-value) # 插入时取反 max_value = -min_heap.pop() # 取出后再取反回来 -
自定义比较器/比较函数:许多优先队列实现允许传入自定义的比较函数。要获得最大堆,只需提供一个反转默认顺序的比较逻辑。
// Java示例:使用自定义比较器创建最大优先队列 PriorityQueue<Integer> maxHeap = new PriorityQueue<>(Collections.reverseOrder()); -
包装对象法:创建一个包装类,在类的
compareTo方法(或等效方法)中实现相反的比较逻辑。class ReverseComparable: def __init__(self, value): self.value = value def __lt__(self, other): # 反转小于号的定义,使最小堆表现为最大堆 return self.value > other.value

总结 📝
本节课我们一起学习了将最小优先队列转换为最大优先队列的核心技巧。关键在于理解并操作元素的比较逻辑:通过对数值取反、使用自定义比较器或包装对象来反转排序顺序,我们可以让一个最小堆表现出最大堆的行为,反之亦然。掌握这一方法能极大地提高你使用标准库数据结构的灵活性。
数据结构与算法:P16:优先队列插入元素
在本节课中,我们将学习如何向二叉堆中添加元素。这是优先队列系列五部分中的第三部分。我们将首先介绍一些重要的术语和概念,以便有效地向优先队列中添加元素。

上一节我们介绍了优先队列的基本概念,本节中我们来看看其核心实现结构——堆。
一种非常流行的实现优先队列的方法是使用某种堆。这是因为堆这种数据结构,能为我们执行优先队列所需操作提供最佳可能的时间复杂度。

然而,需要明确的是:优先队列不是堆。优先队列是一种抽象数据类型,它定义了优先队列应有的行为。而堆只是让我们实际实现该行为的一种方式。例如,我们也可以使用无序列表来实现优先队列所需的行为,但这无法提供最佳的时间复杂度。
了解了优先队列与堆的关系后,我们来具体看看堆的类型。
关于堆,存在许多不同的类型,包括二叉堆、斐波那契堆、二项堆、配对堆等等。为了简化,本教程将仅使用二叉堆。

上一节我们提到了二叉堆,现在我们来明确其定义。
一个二叉堆是一个支持堆性质的二叉树。在二叉树中,每个节点恰好有两个子节点。下图展示了一个二叉树的例子。



本节课中,我们一起学习了优先队列与堆的关系,明确了优先队列是抽象数据类型而堆是其一种高效实现。我们还介绍了二叉堆作为本系列重点使用的具体堆类型,并给出了其基本定义。下一节我们将深入探讨堆的性质及其维护方法。
017:优先队列移除元素
在本节课中,我们将学习如何从二叉堆中移除元素。这是优先队列系列的第45部分。请确保您已观看上一视频,以理解二叉堆的基本结构。
概述
在二叉堆中,移除操作通常针对根节点进行,因为根节点存储了最高优先级(最小或最大)的元素。移除根节点的过程称为“轮询”。由于在数组实现中,根节点始终位于索引0处,因此我们无需搜索其位置。
移除根节点的步骤

上一节我们介绍了二叉堆的结构,本节中我们来看看移除根节点的具体过程。以下是移除根节点的标准步骤:
- 交换根节点与末尾节点:将堆数组中的最后一个元素与根节点(索引0处的元素)进行交换。
- 移除末尾节点:移除并返回(或丢弃)现在位于数组末尾的原根节点元素。
- 下沉新根节点:由于新放置在根节点的元素可能破坏了堆的性质(堆序性),需要将其“下沉”到正确位置。
下沉(Bubbling Down)过程
交换并移除元素后,堆的性质可能被破坏。此时,我们需要通过“下沉”操作来恢复堆序性。这与插入元素时的“上浮”操作相对应。
下沉操作的核心是比较新根节点与其子节点,并根据堆的类型(最小堆或最大堆)决定交换方向。
以下是下沉操作的具体步骤:
- 从当前节点(初始为根节点)开始。
- 比较当前节点与其左右子节点的值。
- 对于最小堆,找出值最小的子节点。
- 对于最大堆,找出值最大的子节点。
- 如果当前节点的值违反了堆序性(例如在最小堆中,当前节点值大于最小子节点值),则将其与该子节点交换。
- 将当前节点指针移动到交换后的子节点位置。
- 重复步骤2-4,直到当前节点不再违反堆序性,或者到达了叶子节点(没有子节点)。
让我们通过一个最小堆的例子来可视化这个过程。初始时,我们移除了根节点(值为2),并将末尾节点(值为10)交换到根位置。

现在,根节点10破坏了最小堆的性质(父节点应小于子节点)。我们开始下沉操作:
- 比较根节点10的两个子节点5和1。
- 选择值更小的子节点1。
- 将根节点10与子节点1交换。

交换后,节点10位于新的位置。我们继续检查其子节点3和4。由于10大于3,需要再次交换。这个过程持续进行,直到节点10到达一个满足堆性质的位置,或者成为叶子节点。
代码实现要点
在代码中实现“轮询”操作时,关键步骤可以概括如下:
def poll(self):
if self.size == 0:
return None
# 1. 保存根节点的值用于返回
root_value = self.heap[0]
# 2. 将末尾元素移动到根节点
self.heap[0] = self.heap[self.size - 1]
self.size -= 1
# 3. 执行下沉操作
self.sink(0)
return root_value
def sink(self, index):
while self.has_left_child(index):
# 找出更小的子节点(对于最小堆)
smaller_child_index = self.get_left_child_index(index)
if (self.has_right_child(index) and
self.heap[self.get_right_child_index(index)] < self.heap[smaller_child_index]):
smaller_child_index = self.get_right_child_index(index)
# 如果当前节点已经小于等于最小子节点,停止下沉
if self.heap[index] <= self.heap[smaller_child_index]:
break
# 否则交换并继续
self.swap(index, smaller_child_index)
index = smaller_child_index
总结

本节课中我们一起学习了从二叉堆(优先队列)中移除最高优先级元素的方法。核心操作是“轮询”,它通过交换根节点与末尾节点 -> 移除原根节点 -> 下沉新根节点这三步来完成。下沉操作通过不断将节点与其最优先的子节点交换,直到堆序性恢复,从而保证了堆结构的正确性。理解这个过程对于掌握优先队列的内部机制至关重要。
018:优先队列代码实现 🧱

在本节课中,我们将学习如何用代码实现一个优先队列。我们将基于之前讨论的堆数据结构,构建一个支持插入、删除和查看最高优先级元素的操作。课程将涵盖类的结构、核心方法以及内部使用的映射表。

上一节我们介绍了优先队列的理论基础,本节中我们来看看具体的代码实现。
首先,我们定义优先队列类。它使用泛型 T,并要求 T 必须实现 Comparable 接口,以确保元素可以相互比较。这允许我们存储诸如字符串、整数等可比较对象。

public class PriorityQueue <T extends Comparable<T>> {
// 类的主体
}

以下是类的实例变量:

heapSize:表示堆中当前实际存储的元素数量。heapCapacity:表示底层动态列表(heap)的总容量,它可能大于heapSize。heap:一个用于存储堆元素的动态列表,在Java中使用List<T>实现。map:一个映射表(例如TreeMap),用于高效跟踪每个元素在堆中的位置,这对于实现对数时间复杂度的删除操作至关重要。



本节课中我们一起学习了优先队列类的基本框架。我们明确了类需要使用泛型和 Comparable 接口来保证元素可比性,并介绍了四个核心实例变量:heapSize、heapCapacity、存储元素的 heap 列表以及用于优化操作的 map 映射表。在接下来的章节中,我们将深入探讨这些变量如何协同工作,以及具体方法的实现逻辑。
019:并查集入门 🧲
在本节课中,我们将要学习一种名为“并查集”的数据结构。并查集有时也被称为“不相交集合”。这是一种用于跟踪被分割成一个或多个互不相交集合的元素的数据结构。它有两个核心操作:find(查找)和 union(合并)。我们将通过生动的例子来理解它的工作原理和应用场景。

课程大纲 📋
以下是本节课我们将要涵盖的内容概览:
首先,我们将通过一个关于磁铁的生动例子,来说明并查集的实际用途。
接着,我们会探讨一个经典的使用并查集的算法——克鲁斯卡尔最小生成树算法。这个算法非常优雅,你将看到为什么它需要并查集来实现其高效的复杂度。
然后,我们将深入探讨并查集的两个核心操作:find 和 union。
最后,我们将了解“路径压缩”技术,正是这项技术赋予了并查集极佳的摊还常数时间复杂度。
什么是并查集? 🤔
并查集是一种数据结构,它跟踪被分割成一个或多个互不相交集合的元素。它有两个主要操作:

find(查找):给定一个元素,并查集会告诉你这个元素属于哪个组。union(合并):将两个组合并在一起。
一个生动的例子:磁铁 🧲
为了更直观地理解,让我们来看一个关于磁铁的例子。

假设我们有一些磁铁,它们可以相互吸引或排斥。我们可以用并查集来管理哪些磁铁是连接在一起的(即属于同一个组)。
通过这个例子,我们可以清晰地看到 find 操作如何确定某块磁铁属于哪个连接组,而 union 操作如何将两个独立的连接组合并成一个更大的组。

在本节课中,我们一起学习了并查集数据结构的基本概念。我们了解了它的两个核心操作 find 和 union,并通过磁铁的例子看到了它的直观应用。在接下来的章节中,我们将深入探讨这些操作的具体实现以及它们如何应用于像克鲁斯卡尔算法这样的经典问题中。
020:克鲁斯卡尔算法与并查集应用

在本节课中,我们将学习并查集的一个非常实用的应用场景:克鲁斯卡尔最小生成树算法。我们将了解什么是最小生成树,并详细拆解克鲁斯卡尔算法的工作原理和实现步骤。
什么是最小生成树?🌲
你可能会问,什么是最小生成树?假设我们有一个包含若干顶点和边的图,最小生成树是图中边的一个子集。这个子集需要连接图中所有的顶点,并且所有边的总权重(或成本)达到最小。


例如,如果这是我们的图,那么一个可能的最小生成树如下所示,其总边权重为14。需要注意的是,最小生成树不一定是唯一的。如果存在另一个最小生成树,它的总权重也同样是14。


克鲁斯卡尔算法如何工作?⚙️
了解了最小生成树的定义后,我们来看看克鲁斯卡尔算法是如何构建它的。该算法可以分解为三个核心步骤。
以下是算法的具体步骤:
- 排序边:首先,取出图中所有的边,并按照边的权重进行升序排序。
- 遍历与检查:接着,遍历排序后的边列表。对于每一条边,检查该边连接的两个顶点。
- 合并或忽略:使用并查集来判断这两个顶点当前是否属于同一个连通分量(即同一组)。
- 如果它们已经属于同一组,则忽略这条边,因为加入它会在生成树中形成环,而最小生成树要求是无环的。
- 如果它们属于不同的组,则使用并查集的
union操作将这两个组合并,并将这条边加入到最小生成树的边集合中。
- 终止条件:重复步骤2和3,直到我们遍历完所有边,或者所有顶点都已经被合并到同一个连通分量中(即最小生成树已包含
V-1条边,V为顶点数)。

总结📚

本节课中,我们一起学习了并查集的一个重要应用——克鲁斯卡尔算法。我们首先定义了最小生成树是连接图中所有顶点且总权重最小的边子集。然后,我们详细剖析了克鲁斯卡尔算法的三个步骤:对边按权重排序、遍历边并检查顶点连通性、以及使用并查集合并不同分量的顶点。该算法高效地避免了环路的产生,从而构建出最小生成树。理解并查集在此过程中的核心作用,是掌握该算法的关键。
021:合并与查找操作 🧩
在本节课中,我们将深入探讨并查集(或称不相交集)数据结构中的核心操作:合并与查找。我们将揭示这些操作在内部是如何工作的,并通过简单的示例和图解帮助你理解其原理。
上一节我们介绍了并查集的基本概念,本节中我们来看看其核心操作的具体实现。
创建映射关系

首先,我们需要在对象与整数之间建立一个双射(即一一映射)。假设我们有 n 个元素,这个映射将每个对象对应到从 0(包含)到 n(不包含)范围内的一个整数。
这个步骤通常不是必需的,但它允许我们创建一个基于数组的、高效且易于操作的并查集。

如果我们有一些随机对象,并且想为它们分配映射,我们可以任意进行,只要每个元素恰好映射到一个数字。

上图展示了一个随机的双射。我们需要存储这些映射关系,例如使用哈希表,以便进行查找并确定每个对象对应的数字。

构建数组

接下来,我们将构建一个数组。数组的每个索引都将通过我们建立的映射关系关联到一个对象。

例如,根据上一张幻灯片的映射,对象 a 被映射到数字 5。因此,在数组中,索引 5 的位置就对应着对象 a。


核心操作:查找
查找操作的目的是确定某个元素属于哪个集合(通常用该集合的“代表元”或“根”来标识)。以下是其基本步骤:
- 从目标元素对应的索引开始。
- 沿着数组中的指针(或父节点索引)向上查找,直到找到一个指向自身的元素(即根节点)。
- 返回该根节点的索引作为集合标识。

这个过程可以用一个简单的循环或递归来实现,其核心思想是不断寻找父节点,直到找到根。
核心操作:合并
合并操作的目的是将两个元素所在的集合合并为一个集合。以下是其基本步骤:
- 使用查找操作找到两个元素各自的根节点。
- 如果根节点相同,说明它们已在同一集合中,无需操作。
- 如果根节点不同,则将其中一个根节点的父指针指向另一个根节点,从而将两棵树(集合)连接起来。

为了提高效率,我们通常会采用“按秩合并”或“路径压缩”等优化策略,但这属于更高级的内容。

总结
本节课中,我们一起学习了并查集数据结构中合并与查找操作的基本原理。我们了解到,通过建立对象到整数的映射,可以构建一个基于数组的高效实现。查找操作通过追溯父节点找到集合的根,而合并操作则通过连接两个不同集合的根来合并它们。理解这些基础操作是掌握并查集及其应用的关键。
022:并查集路径压缩 🚀
在本节课中,我们将要学习并查集数据结构中一个极其重要的优化技术——路径压缩。这项操作是并查集能够实现高效性能的关键所在。
上一节我们介绍了并查集的基本查找与合并操作。本节中我们来看看如何通过路径压缩来大幅提升查找操作的效率。
路径压缩的核心思想

路径压缩的目标是在执行查找操作时,扁平化树的结构,使得后续的查找操作更加快速。其核心思想是:在查找某个节点的根节点过程中,将沿途访问的所有节点直接指向最终的根节点。
以下是路径压缩在查找操作中的具体实现方式:
- 递归实现:在递归回溯时,将每个节点的父指针指向根节点。
def find(x): if parent[x] != x: parent[x] = find(parent[x]) # 递归查找并压缩路径 return parent[x] - 迭代实现(两步法):先找到根节点,然后再遍历一次路径,将所有节点的父指针指向根节点。
路径压缩操作示例

让我们通过一个具体的例子来理解路径压缩是如何工作的。假设我们有以下一个深度较大的并查集结构(尽管在实际应用中,经过路径压缩后很难出现这样的结构,但它是一个很好的教学示例)。

现在,假设我们要合并节点 E 和节点 L(或者说合并橙色和蓝色两个组)。我们会对 E 和 L 调用合并操作。
合并操作的第一步是找到它们各自的根节点。查找 E 的根节点过程如下:
- E 的父节点是 D。
- D 的父节点是 C。

在没有路径压缩的普通查找中,我们只是简单地向上遍历直到根节点。但在路径压缩中,我们会在找到根节点后,将沿途经过的所有节点(如 E 和 D)的父指针直接指向根节点 C。
这样,当下次再查找 E 或 D 时,就可以在常数时间内直接找到根节点 C,而不需要再遍历中间节点。这个“压缩”过程极大地减少了树的深度,为后续操作带来了近乎常数时间的摊还复杂度。
重要提示:为了充分理解路径压缩的原理和效果,确保你已经观看了上一个讲解并查集基本查找与合并操作的视频。否则,你可能难以理解路径压缩是如何运作并带来卓越效率的。
为何路径压缩如此强大
路径压缩之所以强大,是因为它主动地、持续地优化数据结构本身。每次查找操作不仅完成了任务,还顺便修复了树的形态,使得整个集合的表示越来越扁平。这种“自我优化”的特性,结合按秩合并,使得并查集操作的摊还时间复杂度接近常数级 O(α(n)),其中 α(n) 是增长极其缓慢的反阿克曼函数。

本节课中我们一起学习了并查集的路径压缩优化。我们了解了它的核心思想是在查找根节点的过程中,将路径上的所有节点直接链接到根节点,从而大幅降低树高。我们还通过示例观察了其工作过程,并理解了它是实现并查集超高效率的关键技术之一。掌握路径压缩,你就能真正领略并查集这一数据结构的精妙与强大。
023:并查集代码实现 🔧
在本节课中,我们将学习并查集(Union Find)数据结构的具体代码实现。我们将详细解析其核心类结构、实例变量以及关键方法,确保你能理解每一行代码的含义和作用。
概述

并查集是一种用于处理不相交集合合并与查询问题的数据结构。本节我们将深入其源代码,了解如何用数组高效地表示树形结构,并实现查找与合并操作。
类结构与实例变量
首先,我们来看并查集类的整体结构及其包含的实例变量。
public class UnionFind {
private int size;
private int[] id;
private int[] sz;
private int numComponents;
}
以下是各个变量的详细说明:
size: 这个变量表示并查集中元素的总数量。id[]与sz[]: 这是两个核心数组。id数组尤为重要,id[i]的值表示索引i所对应元素的父节点索引。如果id[i] == i成立,则表明元素i是一个根节点。我们正是通过这个数组,在内部以树形结构来组织所有元素,这种方法非常高效实用。同时,由于我们在元素和数字索引之间建立了一一映射关系,才能通过这个id数组来访问它们。numComponents: 这个变量是为了方便而记录的,它表示当前并查集中连通分量的数量。

构造函数
上一节我们介绍了并查集的核心变量,本节我们来看看如何初始化这些变量。

public UnionFind(int size) {
if (size <= 0) throw new IllegalArgumentException("Size must be positive.");
this.size = numComponents = size;
id = new int[size];
sz = new int[size];
for (int i = 0; i < size; i++) {
id[i] = i; // 每个元素初始时都是自己的根
sz[i] = 1; // 每个集合的初始大小是1
}
}
构造函数接收一个 size 参数来设定并查集的大小。它主要完成以下工作:
- 参数校验,确保大小为正数。
- 初始化
size和numComponents。 - 为
id和sz数组分配空间。 - 通过循环,将每个元素的父节点设为自己(形成独立的集合),并将每个集合的大小初始化为1。

核心方法:查找

在初始化之后,我们需要能够查找一个元素属于哪个集合。这就是 find 方法的作用。
public int find(int p) {
int root = p;
// 找到根节点
while (root != id[root]) {
root = id[root];
}
// 路径压缩:将查找路径上的所有节点直接指向根
while (p != root) {
int next = id[p];
id[p] = root;
p = next;
}
return root;
}

find 方法接收一个元素索引 p,返回其所在集合的根节点索引。它包含两个关键步骤:
- 向上追溯: 通过
while循环,沿着父指针不断向上,直到找到根节点(满足id[root] == root)。 - 路径压缩: 在找到根节点后,再次遍历从
p到根节点的路径,将路径上所有节点的父指针直接指向根节点。这能显著降低后续查询的耗时。
核心方法:合并与连通性检查

查找操作让我们能确定元素所属的集合,而合并操作则能将两个集合连接起来。
public void union(int p, int q) {
int root1 = find(p);
int root2 = find(q);
if (root1 == root2) return; // 已经在同一集合中
// 将较小的树合并到较大的树下(按大小加权)
if (sz[root1] < sz[root2]) {
sz[root2] += sz[root1];
id[root1] = root2;
} else {
sz[root1] += sz[root2];
id[root2] = root1;
}
numComponents--; // 合并后,连通分量总数减1
}
public boolean connected(int p, int q) {
return find(p) == find(q);
}
以下是这两个方法的说明:
union(int p, int q): 该方法用于合并元素p和q所在的集合。它首先找到各自的根节点root1和root2。如果根节点相同,则无需操作。否则,它会比较两个根节点对应集合的大小(sz数组),总是将较小的树连接到较大的树的根节点上。这种“按大小加权”的策略有助于保持树的平衡。最后,连通分量计数numComponents减1。connected(int p, int q): 该方法通过比较p和q的根节点是否相同,来判断两个元素是否连通(属于同一集合)。
辅助方法
除了核心操作,类中还提供了一些有用的辅助方法,用于获取当前状态。
public int componentSize(int p) {
return sz[find(p)];
}
public int size() {
return size;
}
public int components() {
return numComponents;
}

这些方法功能如下:
componentSize(int p): 返回元素p所在连通分量(集合)的大小。size(): 返回并查集中的元素总数。components(): 返回当前的连通分量总数。

总结
本节课中,我们一起学习了并查集数据结构的完整代码实现。我们从类变量开始,理解了如何用 id 和 sz 数组表示森林与集合大小。接着,我们逐步分析了构造函数、核心的 find(包含路径压缩)、union(按大小加权合并)以及 connected 方法。最后,我们了解了一些获取状态的辅助方法。掌握这份代码,你就具备了实现高效并查集的基础。
数据结构:P24:二叉树与二叉搜索树入门 🌳
在本节课中,我们将学习一种非常重要的数据结构——树。具体来说,我们将重点介绍二叉树和二叉搜索树,了解它们的定义、用途以及基本概念。通过本教程,你将建立起对树结构的基础理解,为后续学习插入、删除节点以及遍历等操作做好准备。
什么是树?
在深入讨论二叉树之前,我们首先需要理解“树”在数据结构中的一般定义。树是一种无向图,它必须满足以下任一常见定义:
以下是树的三个等价定义:
- 树是一个连通且无环的无向图。无环意味着图中不存在循环路径。
- 一棵具有 n 个节点的树,恰好有 n - 1 条边。
- 对于树中的任意两个顶点,连接它们的路径有且仅有一条。

这些定义从不同角度描述了树的本质特性:它是一个没有回路的连通结构。

二叉树简介 🌲
上一节我们介绍了树的一般概念,本节中我们来看看一种特殊且应用广泛的树——二叉树。
二叉树是每个节点最多拥有两个子节点的树结构。这两个子节点通常被称为左子节点和右子节点。二叉树为许多高效算法(如搜索、排序)提供了基础框架。

二叉搜索树简介 🔍

理解了二叉树后,我们进一步探讨其最重要的变体之一——二叉搜索树。
二叉搜索树是一种特殊的二叉树,它对节点中存储的值有严格的排序约束。这个约束使得在树中查找、插入和删除元素的操作非常高效。
二叉搜索树的核心性质是:对于树中的任意一个节点,
- 其左子树中所有节点的值都小于该节点的值。
- 其右子树中所有节点的值都大于该节点的值。
这个性质可以用一个简单的条件来描述。设当前节点值为 node.value,左子节点值为 left.value,右子节点值为 right.value,则始终满足:
left.value < node.value < right.value
正是这个有序的性质,使得我们能够像在有序数组中进行二分查找一样,在BST中快速定位目标值。
后续内容预告 📚
在本教程中,我们一起学习了树的基本定义、二叉树以及二叉搜索树的核心概念与性质。
在接下来的课程中,我们将深入探讨如何对二叉搜索树进行实际操作,包括:
- 如何向二叉搜索树中插入新的节点。
- 如何从二叉搜索树中删除指定的节点。
- 学习几种流行的树遍历方法(如前序、中序、后序遍历),这些方法不仅适用于二叉搜索树,也适用于其他更一般的树结构。

掌握这些操作是灵活运用二叉搜索树的关键。
025:二叉搜索树插入操作 🧩
在本节课中,我们将学习如何向二叉搜索树中插入新元素。我们将了解插入操作的基本逻辑、需要处理的几种情况,并通过一个动画示例来直观地理解整个过程。

插入操作的基本逻辑
上一节我们介绍了二叉搜索树的基本概念,本节中我们来看看如何向其中添加新元素。
首先,要往二叉搜索树中添加元素,必须确保这些元素是可比较的。这意味着我们能够以某种方式在树中对它们进行排序。这样,在每一步中,我们都能判断出应该将新元素放在当前节点的左子树还是右子树。
在插入一个元素时,我们会将它的值与当前正在考虑的节点值进行比较,并根据比较结果执行以下四种操作之一。
以下是插入过程中可能遇到的四种情况:
- 如果新元素的值小于当前节点的值,则递归进入左子树。
- 如果新元素的值大于当前节点的值,则递归进入右子树。
- 如果新元素的值等于当前节点的值,则需要处理重复值。这取决于树的设计,可以选择添加重复值或忽略它。
- 如果当前节点是空节点(
null),则说明到达了插入位置,此时应创建一个新节点并将其插入树中。
核心的递归插入逻辑可以用以下伪代码描述:
function insert(node, value):
if node is null:
return new Node(value)
if value < node.value:
node.left = insert(node.left, value)
else if value > node.value:
node.right = insert(node.right, value)
// 处理值相等的情况(例如忽略或更新)
return node
动画示例解析
现在,让我们通过一个动画示例来直观地理解插入过程。
动画左侧列出了一系列要插入二叉搜索树的值。最初,这棵二叉搜索树是空的。

随着每个值的插入,算法会从根节点开始,根据比较结果(小于向左,大于向右)递归地找到合适的空位,并创建新节点。这个过程会逐步构建出整个树的结构。


最终,所有元素都按照二叉搜索树的规则被插入到正确的位置。

总结

本节课中我们一起学习了二叉搜索树的插入操作。我们明确了插入的元素必须是可比较的,并详细分析了插入时可能遇到的四种情况:向左子树递归、向右子树递归、处理重复值以及在空节点处创建新节点。通过动画示例,我们直观地看到了元素是如何被逐个插入并构建出完整的二叉搜索树的。理解插入操作是掌握二叉搜索树其他功能(如查找、删除)的重要基础。
026:二叉搜索树节点删除
在本节课中,我们将学习如何从二叉搜索树中删除节点。删除操作比插入操作稍复杂一些,因为它需要维护二叉搜索树的性质。我们将通过一个清晰的两步流程来讲解:首先找到目标节点,然后处理其替换问题,以确保树的结构和性质得以保持。
上一节我们介绍了如何向二叉搜索树中插入元素,本节中我们来看看如何执行删除操作。
删除操作的两阶段流程

删除二叉搜索树中的节点可以看作一个两步过程:
- 查找阶段:在树中定位我们希望删除的节点(如果它存在的话)。
- 替换阶段:用其后继节点(如果存在)替换要删除的节点,以维持二叉搜索树的不变性。
让我重申一下二叉搜索树不变性的定义:对于任意节点,其左子树中的所有节点值都小于该节点值,其右子树中的所有节点值都大于该节点值。
现在,让我们深入第一阶段:查找阶段。
第一阶段:查找目标节点
当我们在二叉搜索树中搜索一个元素时,会发生以下四种情况之一:
以下是可能发生的四种情况:
- 我们遇到了一个空节点(
null)。这意味着我们已经遍历到树的底部,但没有找到目标值,因此该值不存在于树中。 - 比较器返回值为
0。这里的“比较器”是一个函数,如果目标值小于当前节点值则返回-1,等于则返回0,大于则返回1。返回0意味着我们找到了要删除的节点。


- 比较器返回值小于
0。这意味着目标值小于当前节点值,根据二叉搜索树的性质,我们应该继续在左子树中搜索。 - 比较器返回值大于
0。这意味着目标值大于当前节点值,根据二叉搜索树的性质,我们应该继续在右子树中搜索。

通过这个查找过程,我们可以确定目标节点是否存在及其位置。找到节点后,我们就进入了更具挑战性的第二阶段:如何在不破坏树结构的情况下移除它。
第二阶段:替换与维持结构
找到要删除的节点后,我们需要考虑如何移除它。移除节点本身很简单,但关键是要保持二叉搜索树的性质。这通常涉及用另一个合适的节点来“填补”被删除节点留下的空位。
这个填补的节点通常是该节点的“后继者”。后继者是指在中序遍历顺序中,紧接在该节点之后的那个节点。对于二叉搜索树,一个节点的后继者是其右子树中的最小节点。用后继者替换可以确保树的中序遍历序列保持有序,从而维护了二叉搜索树的性质。
处理替换时,我们需要考虑被删除节点的子节点情况(无子节点、有一个子节点、有两个子节点),每种情况的处理逻辑略有不同,但核心思想都是找到并移动合适的后继节点来保持树的正确结构。
总结

本节课中我们一起学习了二叉搜索树的节点删除操作。我们将其分解为两个主要阶段:查找和替换。在查找阶段,我们通过比较器导航树结构以定位目标节点。在替换阶段,核心任务是找到合适的后继节点(通常是右子树中的最小值)来替代被删除的节点,从而确保二叉搜索树的关键性质——左子树的所有值小于节点值,右子树的所有值大于节点值——在删除后依然成立。理解这个两阶段流程是掌握二叉搜索树删除操作的基础。
027:二叉搜索树遍历 🌳
在本节课中,我们将学习二叉搜索树的四种主要遍历方式:前序遍历、中序遍历、后序遍历和层序遍历。理解这些遍历方法是掌握树结构操作的基础。
遍历方法概述
上一节我们介绍了二叉搜索树的基本结构,本节中我们来看看如何系统地访问树中的所有节点。树遍历是指按照特定顺序访问树中每个节点恰好一次的过程。我们将重点介绍前序、中序和后序遍历,因为它们具有相似的递归结构。
递归遍历的核心思想

这三种深度优先遍历方法在实现上非常相似,它们都是自然递归定义的。唯一的区别在于处理当前节点(例如打印节点值)的时机不同。
以下是三种遍历的递归函数框架,请注意 print(node.val) 语句的位置:
def preorder(node):
if node is None:
return
print(node.val) # 前序:先处理当前节点
preorder(node.left)
preorder(node.right)
def inorder(node):
if node is None:
return
inorder(node.left)
print(node.val) # 中序:在左右子树递归之间处理当前节点
inorder(node.right)
def postorder(node):
if node is None:
return
postorder(node.left)
postorder(node.right)
print(node.val) # 后序:最后处理当前节点
- 前序遍历:先访问当前节点,然后递归遍历左子树,最后递归遍历右子树。
- 中序遍历:先递归遍历左子树,然后访问当前节点,最后递归遍历右子树。
- 后序遍历:先递归遍历左子树,然后递归遍历右子树,最后访问当前节点。
前序遍历详解
现在,让我们通过一个具体的例子来详细理解前序遍历的执行过程。我们将跟踪递归调用栈,以明确节点的访问顺序。
考虑以下二叉树:
A
/ \
B C
/ \
D E
前序遍历的规则是:先访问当前节点,然后遍历左子树,最后遍历右子树。
以下是遍历步骤的分解:

- 从根节点 A 开始。访问(打印)A。
- 递归遍历 A 的左子树(以 B 为根)。
- 访问节点 B。
- 递归遍历 B 的左子树(以 D 为根)。
- 访问节点 D。D 是叶子节点,左右子树为空,递归返回到 B。
- 递归遍历 B 的右子树(以 E 为根)。
- 访问节点 E。E 是叶子节点,递归返回到 B,再返回到 A。
- 递归遍历 A 的右子树(以 C 为根)。
- 访问节点 C。C 是叶子节点,遍历结束。
因此,这棵树的前序遍历结果为:A, B, D, E, C。
中序遍历与后序遍历
理解了前序遍历的递归过程后,中序和后序遍历就很容易类推了。它们遵循相同的递归模式,只是处理当前节点的时机不同。
- 中序遍历 对于二叉搜索树特别有用,因为它会以升序访问所有节点。
- 后序遍历 常用于一些需要先处理子节点再处理父节点的场景,例如计算子树的高度或删除整棵树。
层序遍历
除了上述深度优先的遍历方法,还有一种广度优先的遍历方法,称为层序遍历。它从根节点开始,逐层向下访问节点。
以下是层序遍历通常借助队列实现的伪代码:
def levelorder(root):
if root is None:
return
queue = [root]
while queue:
node = queue.pop(0) # 从队列头部取出节点
print(node.val)
if node.left:
queue.append(node.left) # 左子节点入队
if node.right:
queue.append(node.right) # 右子节点入队
对于之前的例子树,层序遍历的结果为:A, B, C, D, E。

本节课中我们一起学习了二叉搜索树的四种核心遍历方法:前序、中序、后序和层序。关键在于理解递归在深度优先遍历中的作用,以及处理节点时的不同顺序。掌握这些遍历方式是进行树结构搜索、插入、删除等更复杂操作的重要基础。
028:二叉搜索树代码实现 🧑💻
在本节课中,我们将学习如何用Java语言实现一个二叉搜索树。我们将从类的定义开始,逐步了解其内部结构、节点定义以及核心成员变量。
概述

我们将要学习一个二叉搜索树的具体代码实现。该实现使用Java编写,核心思想是利用节点的可比较性来维护树的有序结构。

类定义与泛型

首先,我们定义了一个代表二叉搜索树的类。这个类使用了泛型,以确保它可以存储任何可比较类型的数据。
public class BinarySearchTree<T extends Comparable<T>> {
// 类内容
}
泛型约束 <T extends Comparable<T>> 意味着类型 T 必须实现 Comparable 接口。这是必要的,因为我们需要比较节点中的数据,以决定新节点应该插入到左子树还是右子树。


成员变量
接下来,我们来看看这个类包含哪些成员变量。实际上,它只有两个。

// 节点计数器
private int nodeCount = 0;

// 树的根节点
private Node root = null;
nodeCount 变量用于追踪树中节点的总数。root 变量则是指向这棵二叉搜索树根节点的引用。由于二叉搜索树是一种有根树,因此必须有一个明确的根节点。
内部节点类

在二叉搜索树类内部,我们定义了一个私有的 Node 类,用于表示树中的每一个节点。


private class Node {
T data;
Node left, right;
public Node(Node left, Node right, T elem) {
this.data = elem;
this.left = left;
this.right = right;
}
}
这个 Node 类包含以下三个部分:
data: 存储节点本身的数据,其类型为泛型T。left: 一个指向左子节点的引用。right: 一个指向右子节点的引用。

构造函数接收左子节点、右子节点以及节点数据作为参数,并完成初始化工作。这里使用的类型 T 与外部二叉搜索树类的泛型类型一致,保证了数据的可比较性。
总结


本节课我们一起学习了二叉搜索树代码实现的初始部分。我们了解了如何定义一个支持泛型的二叉搜索树类,认识了用于追踪树大小和根节点的成员变量,并剖析了构成树基本单元的私有 Node 类的结构。在接下来的课程中,我们将在此基础上继续探讨如何实现插入、查找和删除等核心操作。
029:哈希表与哈希函数
在本节课中,我们将要学习哈希表,这是一种极其重要的数据结构。我们将从哈希表的基本概念开始,并深入探讨其核心组件——哈希函数。

什么是哈希表?🤔

哈希表是一种数据结构,它允许我们通过一种称为“哈希”的技术,构建从一组键到一组值的映射。
键可以是任何值,只要它们是唯一的,每个键都映射到一个对应的值。例如,键可以是:


- 人名
- 产品ID
- 车牌号
为什么需要哈希函数?🔑
上一节我们介绍了哈希表的基本概念,本节中我们来看看为什么需要哈希函数。哈希函数是哈希表的核心,它负责将任意大小的键转换为一个固定大小的数值(通常是数组索引)。这个转换过程就是“哈希”。
以下是哈希函数的主要作用:
- 确定存储位置:计算出的哈希值决定了键值对在哈希表底层数组中的存储位置。
- 快速访问:理想情况下,通过哈希函数可以直接定位到数据,实现平均时间复杂度为 O(1) 的查找、插入和删除操作。
哈希表系列内容概览 📚



接下来,在这个哈希表系列教程中,我们将涵盖以下核心主题:
- 碰撞解决方法:我们将讨论两种最流行的碰撞解决方法,即分离链接法和开放寻址法。虽然还有其他方法,但这两种最为常见。
- 分离链接法的实现:我们将详细探讨如何使用链表实现分离链接法,因为这是一种非常流行的实现方式。
- 开放寻址法的深入探讨:开放寻址法中有大量内容需要讲解。我们将通过大量示例来讨论线性探测和二次探测的工作原理,因为它们的工作方式并非一目了然。
- 双重哈希:我们将讲解双重哈希这种更高级的探测方法。
- 开放寻址法中的元素删除:最后,我们将学习如何在开放寻址方案中删除元素,因为这一操作也并非显而易见。

总结

本节课中,我们一起学习了哈希表的基本定义及其核心——哈希函数的作用。哈希表通过哈希函数建立键到值的快速映射。我们了解到,键需要具有唯一性。此外,我们还预览了整个系列将要深入探讨的关键主题,包括碰撞解决的两大主流方法(分离链接法和开放寻址法)及其具体实现技术(如线性探测、二次探测和双重哈希)。理解这些基础概念是掌握高效哈希表设计与使用的第一步。
030:哈希表之分离链接法 🧩

在本节课中,我们将要学习哈希表的一种核心冲突解决技术——分离链接法。我们将了解其工作原理、实现方式以及优缺点。
什么是分离链接法?
上一节我们介绍了哈希表的基本概念,本节中我们来看看如何处理哈希冲突。分离链接法是众多哈希冲突解决技术中的一种。当发生哈希冲突时,即两个不同的键通过哈希函数计算得到了相同的哈希值,我们需要一种方法来处理这种情况,以确保哈希表的功能正常。
分离链接法的工作原理是:维护一个辅助数据结构来存储所有映射到同一哈希值的键值对。这样,当我们需要查找某个键时,就可以回到对应的“桶”或数据结构中去寻找目标项。
通常,我们使用链表来实现这个辅助数据结构,但它并不局限于链表。以下是几种可能的实现方式:
- 链表
- 动态数组
- 二叉搜索树
- 自平衡树(如红黑树)
- 混合方法

分离链接法示例
为了更好地理解,让我们通过一个具体的例子来演示分离链接法是如何工作的。
假设我们有一个哈希表,它本质上是一个存储键值对的数组。键是年龄,值是姓名。每个键值对都关联着一个由哈希函数计算出的哈希值。这些哈希值目前的具体数值并不重要,我们主要关注如何使用分离链接法处理冲突。
下图左侧是我们的哈希表,即数组结构。



现在,我将开始向这个哈希表中插入数据。
031:哈希表分离链接法源码解析 🧩
在本节课中,我们将学习哈希表的一种经典实现方式——分离链接法(Separate Chaining)的源代码。我们将通过分析一个具体的Java实现,来理解如何将键值对存储在哈希表中,以及如何处理哈希冲突。
源码概览
首先,我们来看一下代码的整体结构。实现主要包含两个类:Entry 和 SeparateChainingHashTable。
Entry 类代表一个将要插入哈希表的键值对条目。SeparateChainingHashTable 类则是哈希表本身,它使用一个链表数组来处理哈希冲突。
Entry类详解
Entry 类封装了哈希表中的单个数据项。它包含三个核心部分:键(Key)、值(Value)和该键的哈希码(Hash Code)。
以下是 Entry 类的关键代码结构:
public class Entry<K, V> {
int hash; // 缓存的哈希码
K key; // 键
V value; // 值
public Entry(K key, V value) {
this.key = key;
this.value = value;
this.hash = key.hashCode(); // 计算并缓存哈希码
}
}
核心概念解析:
- 键(Key):用于查找和存储数据的唯一标识符。
- 值(Value):与键相关联的数据。
- 哈希码(Hash Code):通过键的
hashCode()方法计算出的一个整数值。这个值决定了该条目在哈希表数组中的初始位置。
缓存哈希码至关重要。对于像字符串这样的对象,计算哈希码可能需要线性时间(O(n))。在构造时计算一次并存储起来,可以避免在后续操作(如查找、比较)中重复计算,从而提升性能。
SeparateChainingHashTable类结构

上一节我们介绍了存储单个数据的 Entry 类,本节中我们来看看管理这些条目的哈希表主体结构。
SeparateChainingHashTable 类使用一个链表(或其它动态集合,如 ArrayList)数组作为底层存储。每个数组位置(通常称为“桶”或“槽”)都对应一个链表,用于存放所有哈希到该位置的条目。
以下是该类的核心成员变量:
public class SeparateChainingHashTable<K, V> {
private static final int DEFAULT_CAPACITY = 3; // 默认容量
private static final double DEFAULT_LOAD_FACTOR = 0.75; // 默认负载因子
private double maxLoadFactor; // 最大负载因子
private int capacity, threshold, size = 0; // 容量、扩容阈值、当前大小
private LinkedList<Entry<K, V>>[] table; // 链表数组
}
核心概念解析:
- 容量(Capacity):哈希表底层数组的长度,即桶的数量。初始值设为
DEFAULT_CAPACITY。 - 负载因子(Load Factor):衡量哈希表满的程度,计算公式为:
size / capacity。当负载因子超过maxLoadFactor时,会触发扩容(Rehashing)。 - 扩容阈值(Threshold):触发扩容的临界值,计算公式为:
threshold = capacity * maxLoadFactor。 - 链表数组(Table):这是实现分离链接法的核心数据结构。数组的每个索引位置都挂载着一个链表。
核心方法流程
理解了基本结构后,我们来看看哈希表的核心操作是如何利用这些结构完成的。
1. 规范化索引
任何键在通过哈希函数计算后,都需要被映射到数组的有效索引范围内(0 到 capacity-1)。这个过程称为“规范化”。
private int normalizeIndex(int keyHash) {
return (keyHash & 0x7FFFFFFF) % capacity;
}
代码解释:首先,keyHash & 0x7FFFFFFF 将哈希码的最高位符号位清零,确保得到一个非负整数。然后,通过取模运算 % capacity 将这个整数压缩到数组索引的范围内。
2. 插入条目(put)
插入操作是哈希表的核心。以下是其逻辑步骤:
以下是插入条目的关键步骤:
- 如果键为
null,则抛出异常(本实现假定键不为空)。 - 根据键计算哈希码,并调用
normalizeIndex方法得到桶的索引。 - 获取该索引对应的链表。
- 遍历链表:
- 如果找到具有相同键的条目,则更新其值并返回旧值。
- 如果未找到,则在链表末尾添加新条目。
- 增加
size。如果size超过了threshold,则执行扩容操作。
3. 查找条目(get)
查找操作与插入的遍历部分类似:
- 计算键的哈希码并规范化得到桶索引。
- 获取该索引对应的链表。
- 遍历链表,比较键是否相等(使用
equals方法)。 - 找到则返回对应的值,否则返回
null。
4. 扩容(Resize)
当元素数量超过阈值时,哈希表的性能会下降,因此需要扩容。
以下是扩容的主要步骤:
- 将容量加倍(或按其他策略增加)。
- 根据新容量计算新的阈值。
- 创建一个新的、更大的链表数组。
- 遍历旧表中的每一个条目,根据新的容量重新计算其索引位置(即重新哈希),并将其插入到新数组对应的链表中。
- 用新数组替换旧数组。

本节课中我们一起学习了哈希表分离链接法的Java源码实现。我们从最基础的 Entry 类开始,理解了如何存储键值对和缓存哈希码。然后,我们深入分析了 SeparateChainingHashTable 类的结构,包括其底层链表数组以及容量、负载因子等核心参数。最后,我们梳理了插入、查找和扩容这几个关键操作的执行流程。通过将数据分散到多个链表中,分离链接法有效地解决了哈希冲突问题,是构建高效哈希表的一种经典而实用的方法。
032:哈希表开放寻址法
在本节课中,我们将学习哈希表的一种重要冲突解决技术——开放寻址法。我们将了解其核心思想、工作原理、关键参数以及几种常见的探测序列。
概述
开放寻址法是一种处理哈希冲突的策略。与之前视频中介绍的分离链接法不同,开放寻址法将所有键值对直接存储在哈希表数组中,而不使用额外的数据结构。这意味着我们需要密切关注哈希表的容量和负载因子,以确保性能。
哈希表与冲突回顾

首先,我们快速回顾一下哈希表的基本概念,以确保理解一致。
哈希表的目标是构建一个从一组键到一组值的映射。这些键必须是可哈希的。我们定义一个哈希函数,将键转换为数字。然后,我们使用这个数字作为索引来访问数组(即哈希表)。
然而,这种方法并非万无一失,因为有时会发生哈希冲突,即两个不同的键哈希到相同的值。因此,我们需要一种解决冲突的方法,开放寻址法就是其中一种解决方案。
开放寻址法的核心思想
当我们使用开放寻址冲突解决技术时,需要记住一个关键点:实际的键值对将直接存储在哈希表本身中。这与我们上一节视频中看到的分离链接法不同,后者使用链表等辅助数据结构。
这意味着我们非常关心哈希表的大小以及当前表中的元素数量。因为一旦表中的元素过多,我们将很难找到一个空槽位来放置新元素。
关键参数:负载因子
因此,在开放寻址法中,我们引入了一个称为负载因子的关键概念。负载因子(α)定义为表中已存储元素数量(n)与哈希表总容量(m)的比值。
公式:α = n / m
负载因子永远不能超过1,因为元素数量不能超过表容量。为了保持良好的性能,我们通常需要将负载因子维持在一个较低的水平(例如,α < 0.5或α < 0.75)。当负载因子过高时,我们需要对哈希表进行扩容(再哈希)。
探测序列
在开放寻址法中,当发生冲突时,我们需要一个系统的方法来在表中寻找下一个可用的空槽。这个方法就是探测序列。探测序列是一个函数,它根据初始哈希值和尝试次数,生成一系列候选索引位置。
通用公式:index = hash(key, attempt)
其中,hash 是探测函数,attempt 是从0开始的尝试次数。
以下是几种常见的探测方法:
1. 线性探测

这是最简单的方法。如果初始位置被占用,我们就顺序检查下一个位置,直到找到空位。
公式:hash(key, i) = (hash1(key) + i) % m
其中 i 是尝试次数,m 是表大小。
2. 二次探测
为了避免线性探测可能产生的“一次聚集”问题,二次探测使用一个二次函数来增加步长。
公式:hash(key, i) = (hash1(key) + c1i + c2i²) % m
其中 c1 和 c2 是常数(通常 c1 = c2 = 1)。
3. 双重哈希
这是最有效的方法之一,它使用两个不同的哈希函数来计算步长。
公式:hash(key, i) = (hash1(key) + i * hash2(key)) % m
第二个哈希函数 hash2(key) 的结果不能为0,并且应该与表大小 m 互质,以确保能探测到所有槽位。
操作流程
了解了探测序列后,我们来看看如何在开放寻址哈希表中执行基本操作。
以下是插入一个键值对的基本步骤:
- 计算键的初始哈希值,得到索引。
- 如果该索引位置为空,则在此处插入键值对,操作完成。
- 如果该位置已被占用,则使用探测序列函数计算下一个候选索引。
- 重复步骤2和3,直到找到空位或遍历完整个表(表满)。
- 如果表满,通常需要扩容并重新插入所有元素。
查找和删除操作遵循类似的逻辑,但需要注意,删除操作不能简单地将槽位置空,否则会破坏后续的查找链。通常的解决方案是使用一个特殊的“墓碑”标记来标识已删除的位置。

总结
本节课中,我们一起学习了哈希表的开放寻址冲突解决技术。我们了解到,开放寻址法将元素直接存储在表数组中,并通过负载因子来监控表的使用情况。当发生冲突时,我们使用探测序列(如线性探测、二次探测或双重哈希)来系统地寻找下一个可用槽位。理解这些核心概念对于设计和实现高效的哈希表至关重要。
033:哈希表线性探测 🧮
在本节课中,我们将学习哈希表的一种冲突解决方法——线性探测。我们将从回顾开放寻址的基本概念开始,然后深入探讨线性探测的具体实现和原理。

开放寻址回顾
上一节我们介绍了开放寻址的基本框架。无论使用何种探测函数,其核心流程是通用的。
假设我们有一个大小为 n 的哈希表,以下是开放寻址的通用步骤:
- 初始化变量
x = 1。 - 计算键的原始哈希值
keyHash。 - 第一个要检查的索引位置是
keyHash。 - 如果该索引位置已被占用(即
table[index] != null),则根据探测函数计算下一个位置。- 新索引的计算公式为:
index = (keyHash + probingFunction(x)) % n - 每次探测后,递增
x的值。
- 新索引的计算公式为:
- 重复步骤4,直到找到一个空位置。
- 将键值对插入到该空位置。
什么是线性探测? ➡️
在了解了开放寻址的通用流程后,本节我们来看看线性探测这种具体的实现方式。
线性探测是一种探测方法,它按照某个线性公式来确定下一个探测位置。

其探测函数 p(x) 定义为:
p(x) = a * x + b
其中,a 不能等于0,否则函数退化为常数加法,失去了探测的意义。
关于公式中的常数 b,有一个重要的注意事项:常数 b 是多余的。
你明白为什么吗?因为无论 b 是多少,在模运算 (keyHash + p(x)) % n 中,keyHash 本身已经包含了所有可能的常数偏移。添加一个固定的 b 只是相当于改变了 keyHash 的起点,而 keyHash 本身就是一个(伪)随机值,所以 b 并不提供新的、有意义的探测步长变化。因此,通常我们设 b = 0,将线性探测函数简化为 p(x) = a * x。最常用和最简单的情况是 a = 1,即 p(x) = x,这意味着每次探测的步长是固定的1个位置。
总结

本节课中我们一起学习了哈希表的线性探测技术。我们首先回顾了开放寻址法的通用流程,然后重点介绍了线性探测的定义,即通过一个线性函数(通常是 p(x) = x)来确定冲突发生后的下一个探测位置。我们还解释了为什么线性探测函数中的常数项 b 是多余的。线性探测是实现简单、易于理解的一种冲突解决策略。
034:哈希表之二次探测

在本节课中,我们将学习哈希表中一种重要的冲突解决方法——二次探测。我们将了解其工作原理、实现方式以及需要注意的问题。
概述
上一节我们介绍了线性探测,本节中我们来看看另一种开放寻址法:二次探测。二次探测旨在通过二次函数来计算探测偏移量,以减少线性探测可能导致的“聚集”现象。
二次探测的工作原理
首先,回忆一下在开放寻址法中如何插入键值对。
我们初始化一个变量 x = 1,每次无法找到空闲槽位时,x 的值就会递增。
接着,我们计算键的哈希值,这将是我们要检查的第一个索引。
然后,我们进入一个循环,直到找到一个空闲槽位为止。循环条件是:当前索引处的表项不等于 null(即已被占用)。
每当槽位被占用时,我们就使用探测函数来偏移原始的哈希值。
在我们的例子中,探测函数将是一个二次函数。
同时,我们递增 x。
最终,我们将找到一个空闲槽位来插入我们的键值对。

什么是二次探测
二次探测的核心思想是,根据一个二次公式来进行探测。
具体来说,当我们的探测函数 P(x) 形如:
P(x) = A*x² + B*x + C
其中 A、B、C 都是常数,并且我们要求 A 不等于 0,否则就会退化为线性探测。
然而,正如我们在之前的视频中所见,并非所有的二次函数都是可行的,因为它们可能无法探测到哈希表中的所有槽位。
总结

本节课中,我们一起学习了哈希表的二次探测法。我们了解了其通过二次函数计算探测步长以减少聚集的基本原理,并认识到选择适当的二次函数参数对于确保能够探测到所有槽位至关重要。下一节,我们将探讨如何选择一个合适的二次函数。
035:哈希表之双重哈希法
在本节课中,我们将要学习哈希表的一种开放寻址冲突解决方法——双重哈希法。我们将了解其工作原理、公式以及具体的操作步骤。

概述
上一节我们介绍了开放寻址法的基本思想,本节中我们来看看一种更高级的探测方法:双重哈希法。这种方法通过使用第二个哈希函数来计算探测步长,旨在更均匀地分布元素,减少聚集现象。
插入过程回顾
首先,让我们快速回顾一下在开放寻址哈希表中进行插入操作的基本流程。
我们从一个初始化为1的变量 X 开始。我们计算键的哈希值,并将其设为我们将在哈希表中检查的第一个索引,目的是找到一个空槽位。
我们将循环检查,直到找到一个空槽位。每次遇到已被占用的槽位时,我们将使用一个探测函数来偏移我们的键哈希值。在我们的案例中,这个探测函数就是双重哈希探测函数。同时,我们还会递增 X 的值,以便沿着探测路径继续向前寻找。一旦找到空槽位,我们就可以将键值对插入哈希表。
双重哈希法详解
那么,双重哈希法有何特别之处?双重哈希法和其他探测方法一样,是一种探测方法。它的特殊之处在于,我们根据另一个哈希函数的常数倍来进行探测。
具体来说,我们的探测函数如下所示:我们输入键(一个常量)和变量 X,然后计算 X 乘以 h2(k) 的结果,其中 h2 是第二个哈希函数。

其公式可以表示为:
P(k, x) = x * h2(k)
其中,P 是探测函数,k 是键,x 是探测次数,h2(k) 是第二个哈希函数计算出的值。
操作步骤
以下是使用双重哈希法在哈希表中插入一个键值对的具体步骤列表:
- 初始化探测次数
x = 1。 - 计算主哈希值:
index = h1(k) % table_size。 - 检查
table[index]是否为空。 - 如果槽位已被占用,则计算步长:
step = h2(k) % table_size(确保步长不为0)。 - 更新索引进行探测:
index = (h1(k) + x * step) % table_size。 - 递增
x。 - 重复步骤3至6,直到找到空槽位或遍历完整个表。
- 将键值对插入找到的空槽位。
总结

本节课中我们一起学习了哈希表的双重哈希冲突解决方法。我们回顾了开放寻址的插入流程,重点剖析了双重哈希法如何利用第二个哈希函数生成探测步长,并给出了其核心公式和详细的操作步骤。这种方法能有效减少聚集,是构建高效哈希表的重要技术之一。
036:哈希表开放寻址删除操作 🗑️
在本节课中,我们将学习如何在使用开放寻址方案的哈希表中删除元素。我们将首先探讨一种简单的删除方法可能带来的问题,然后介绍一种更健壮的解决方案。
简单删除方法的问题
上一节我们介绍了开放寻址的基本概念,本节中我们来看看删除操作。如果采用一种简单直接的方法来删除元素,可能会遇到严重的问题。

假设我们有一个初始为空的哈希表,并使用线性探测函数。这里,探测函数 P(x) 简单地等于 x。
我们计划执行以下操作:插入三个键 K1、K2 和 K3,然后删除 K2,最后尝试获取 K3 的值。为了说明问题,我们假设 K1、K2 和 K3 都发生了哈希冲突,哈希值均为 1。
以下是操作步骤:
- 插入
K1:它哈希到位置1,因此被插入到索引1。 - 插入
K2:它也哈希到位置1,与K1冲突。根据线性探测规则,我们检查下一个位置(索引2)并将其插入。 - 插入
K3:它同样哈希到位置1。我们开始探测:位置1已被K1占用,位置2已被K2占用,因此继续探测到位置3并将其插入。
此时哈希表状态如下:

现在,我们尝试使用简单的方法删除 K2。简单方法通常意味着直接将 K2 对应的槽位标记为空。

简单删除导致的问题
删除 K2 后,哈希表索引 2 的位置变为空。这时,如果我们尝试查找 K3,会发生什么?
查找 K3 的流程如下:
- 计算
K3的哈希值,得到1。 - 检查索引
1,发现是K1(不是目标)。 - 根据线性探测规则,检查下一个位置索引
2。 - 索引
2现在是空的。在开放寻址中,遇到空槽通常意味着“键不存在”。因此,查找算法会错误地认为K3不在表中,即使它确实存在于索引3的位置。
核心问题在于:删除操作留下的空槽中断了探测序列。对于任何哈希到相同起始位置并需要经过这个空槽才能找到的键,后续的查找操作都会失败。
解决方案:使用“墓碑”标记
为了解决这个问题,我们不能简单地将已删除的槽位置空。取而代之的是,我们引入一个特殊的标记,通常称为“墓碑”(Tombstone)或“已删除”标记。
以下是使用“墓碑”标记的删除流程:
- 当删除一个键值对时,我们并不清空该槽位,而是将其标记为“已删除”(例如,用一个特殊的常量
DELETED表示)。 - 在插入新元素时,遇到“墓碑”标记的槽位可以将其视为空槽并进行覆盖。
- 在查找元素时,遇到“墓碑”标记的槽位不能停止,必须继续探测,直到找到目标键或遇到真正的空槽。
伪代码描述查找逻辑:
def get(key):
index = hash(key) % table_size
while table[index] is not None: # 包括墓碑和实际条目
if table[index] is TOMBSTONE:
index = (index + 1) % table_size # 继续探测
continue
if table[index].key == key:
return table[index].value # 找到目标
index = (index + 1) % table_size # 线性探测
return None # 未找到

伪代码描述插入逻辑:
def insert(key, value):
index = hash(key) % table_size
first_tombstone = -1
while table[index] is not None:
if table[index] is TOMBSTONE:
if first_tombstone == -1:
first_tombstone = index # 记录第一个可用的墓碑位置
elif table[index].key == key:
table[index].value = value # 更新已存在的键
return
index = (index + 1) % table_size
# 循环结束,准备插入
if first_tombstone != -1:
index = first_tombstone # 优先插入到墓碑位置
table[index] = Entry(key, value)
总结

本节课中我们一起学习了在开放寻址哈希表中进行删除操作的关键点。核心结论是:不能简单地删除元素,因为这会破坏探测路径,导致后续查找失败。正确的做法是使用“墓碑”标记来标识已删除的位置。在查找时,需要跳过“墓碑”继续探测;在插入时,则可以优先复用“墓碑”位置。这种方法保证了哈希表在动态插入和删除操作下的正确性。
037:哈希表开放寻址法代码实现 🔧
在本节课中,我们将学习一个使用开放寻址法(具体为二次探测)作为冲突解决策略的哈希表的源代码实现。我们将逐步解析代码结构、核心变量、关键方法以及其背后的逻辑,确保初学者也能理解。
概述
我们将分析一个名为 HashTableQuadraticProbing 的泛型类。它管理键值对,并通过二次探测法处理哈希冲突。代码中定义了负载因子、容量、大小阈值以及用于标记已删除条目的特殊令牌。
类定义与实例变量
首先,我们来看类的定义和它内部使用的主要变量。
public class HashTableQuadraticProbing<K, V> {
private double loadFactor;
private int capacity, threshold, modificationCount;
private int usedBuckets, keyCount;
private K[] keys;
private V[] values;
private final K TOMBSTONE = (K) (new Object());
}
loadFactor: 这是哈希表愿意承受的最大负载因子。当实际负载超过此阈值时,哈希表将进行扩容。capacity: 表示哈希表底层数组的当前容量。threshold: 这是触发扩容的临界大小,计算公式为threshold = (int)(capacity * loadFactor)。modificationCount: 用于跟踪哈希表的结构性修改次数,常用于迭代器中以快速失败。usedBuckets: 记录已被占用的桶数量(包括存放有效键值对的桶和标记为已删除的桶)。keyCount: 记录哈希表中实际存在的有效键值对数量。keys与values: 这是两个并行数组,分别用于存储键和值。TOMBSTONE: 这是一个特殊的常量对象,用于标记数组中已被删除的条目位置,以避免查找链中断。
构造方法与初始化
上一节我们介绍了核心变量,本节中我们来看看对象的初始化过程。哈希表提供了多个构造函数,允许用户指定初始容量和负载因子。
public HashTableQuadraticProbing() {
this(16, 0.75);
}
public HashTableQuadraticProbing(int capacity) {
this(capacity, 0.75);
}
public HashTableQuadraticProbing(int capacity, double loadFactor) {
if (capacity <= 0) throw new IllegalArgumentException("Illegal capacity: " + capacity);
if (loadFactor <= 0 || Double.isNaN(loadFactor) || Double.isInfinite(loadFactor))
throw new IllegalArgumentException("Illegal loadFactor: " + loadFactor);
this.loadFactor = loadFactor;
this.capacity = Math.max(capacity, 16);
adjustCapacity();
threshold = (int)(this.capacity * loadFactor);
keys = (K[]) new Object[this.capacity];
values = (V[]) new Object[this.capacity];
}
关键点在于 adjustCapacity() 方法。由于二次探测法的特性,为了确保探测序列能够遍历所有桶,哈希表的容量必须是一个质数。此方法负责将用户传入的容量调整为一个不小于原值的质数。
核心辅助方法:哈希函数与探测
哈希表的核心操作依赖于两个函数:将键映射到数组索引的哈希函数,以及在发生冲突时寻找下一个可用位置的探测函数。
- 哈希函数:它首先调用键对象的
hashCode()方法,然后将结果与0x7FFFFFFF进行按位与运算以消除符号位,最后对容量取模得到初始索引。private int normalizeIndex(int keyHash) { return (keyHash & 0x7FFFFFFF) % capacity; } - 二次探测:当初始位置被占用时,该方法通过一个二次方程
i = (originalIndex + (j * j)) % capacity来计算下一个探测位置,其中j是探测次数。
关键操作:插入、查找与删除
以下是哈希表最关键的三个操作:插入(或更新)、查找和删除。
插入键值对 (put)
put 方法负责将新的键值对添加到表中,如果键已存在则更新其对应的值。

public V put(K key, V value) {
if (key == null) throw new IllegalArgumentException("Null key");
if (usedBuckets >= threshold) resizeTable();
int index = normalizeIndex(key.hashCode());
int i = index, j = 1;
// 探测循环
do {
// 情况1:找到空桶,直接插入
if (keys[i] == null) {
keys[i] = key;
values[i] = value;
keyCount++;
usedBuckets++;
modificationCount++;
return null;
}
// 情况2:找到相同键,更新值
if (keys[i].equals(key)) {
V oldValue = values[i];
values[i] = value;
modificationCount++;
return oldValue;
}
// 情况3:当前位置是墓碑或已被其他键占用,继续探测
i = normalizeIndex(index + j * j);
j++;
} while (true);
}
插入逻辑的步骤如下:
- 检查负载,必要时扩容。
- 计算初始哈希索引。
- 进入探测循环:
- 如果当前位置为空,直接插入。
- 如果当前位置的键与目标键相同,则更新值。
- 否则,使用二次探测公式计算下一个位置,继续查找。
查找值 (get)
get 方法根据给定的键查找对应的值。
public V get(K key) {
if (key == null) throw new IllegalArgumentException("Null key");
int index = normalizeIndex(key.hashCode());
int i = index, j = 1;
// 探测循环
do {
if (keys[i] == null) return null; // 遇到空桶,说明键不存在
if (keys[i].equals(key)) {
if (keys[i] == TOMBSTONE) return null; // 键已被删除
return values[i]; // 找到键,返回值
}
i = normalizeIndex(index + j * j);
j++;
} while (true);
}
查找过程与插入类似,沿着探测序列查找,直到找到键、遇到空桶或回到起点。遇到 TOMBSTONE 时需继续探测。
删除键值对 (remove)
remove 方法删除指定的键及其关联的值。
public V remove(K key) {
if (key == null) throw new IllegalArgumentException("Null key");
int index = normalizeIndex(key.hashCode());
int i = index, j = 1;
// 探测循环
do {
if (keys[i] == null) return null; // 键不存在
if (keys[i].equals(key)) {
if (keys[i] == TOMBSTONE) return null; // 键已被删除
keyCount--;
modificationCount++;
V oldValue = values[i];
keys[i] = TOMBSTONE; // 标记为已删除
values[i] = null;
return oldValue;
}
i = normalizeIndex(index + j * j);
j++;
} while (true);
}
删除操作不是简单地将桶置空,而是用 TOMBSTONE 标记该位置。这保证了后续的查找和插入操作能正确遍历整个探测链。
动态调整:扩容机制
当哈希表中的元素数量达到阈值(threshold)时,为了维持操作效率,需要进行扩容。
private void resizeTable() {
capacity = nextPowerOfTwo(capacity * 2); // 容量翻倍并调整
adjustCapacity(); // 再次确保容量为质数
threshold = (int)(capacity * loadFactor);
K[] oldKeys = keys;
V[] oldValues = values;
keys = (K[]) new Object[capacity];
values = (V[]) new Object[capacity];
keyCount = usedBuckets = 0;
// 重新哈希所有有效条目
for (int i = 0; i < oldKeys.length; i++) {
if (oldKeys[i] != null && oldKeys[i] != TOMBSTONE) {
put(oldKeys[i], oldValues[i]);
}
}
}
扩容步骤包括:
- 计算新的容量(通常翻倍并确保为质数)。
- 创建新的、更大的键值数组。
- 遍历旧数组,将所有非空且非墓碑的有效键值对重新插入到新表中。这个过程称为“重新哈希”。
其他实用方法
除了核心操作,哈希表还提供了一些查询方法:
hasKey(K key): 检查哈希表中是否包含指定的键。size(): 返回当前有效键值对的数量。isEmpty(): 判断哈希表是否为空。clear(): 清空哈希表,将所有桶重置。

总结
本节课中我们一起学习了基于二次探测的开放寻址哈希表的完整代码实现。我们分析了其数据结构设计,包括使用并行数组存储键值、用特殊令牌标记删除位。我们深入探讨了核心操作:插入时的线性探测与更新逻辑、查找时的链式搜索以及删除时使用墓碑标记的策略。最后,我们了解了当负载过高时,哈希表如何通过扩容并重新哈希所有元素来保持性能。理解这个实现是掌握哈希表这一重要数据结构的关键一步。
038:Fenwick Tree 范围查询 📊
在本节课中,我们将要学习一种名为 Fenwick Tree(树状数组)的数据结构。它是一种非常高效且易于实现的数据结构,特别适合处理数组前缀和与范围查询问题。我们将探讨其存在的动机、分析其时间复杂度,并了解其实现细节。
动机与问题背景 🔍
上一节我们介绍了 Fenwick Tree 的基本概念,本节中我们来看看它要解决的核心问题。

假设我们有一个整数数组,我们需要频繁地查询某个范围内的元素之和。一种直接的方法是:
以下是朴素的范围查询方法:
- 从范围的起始位置开始。
- 扫描到范围的结束位置。
- 累加该范围内所有单个元素的值。
这种方法虽然可行,但每次查询都需要线性时间,当查询变得频繁时,效率会非常低下。
前缀和数组方案 📈
为了解决线性查询效率低下的问题,我们可以预先计算数组的前缀和。
前缀和数组 P 的定义如下:
P[i] = A[0] + A[1] + ... + A[i]

通过前缀和数组,计算范围 [l, r] 的和可以优化为:
sum(l, r) = P[r] - P[l-1] (当 l > 0 时)
这个操作的时间复杂度是 O(1),非常快。然而,前缀和方案有一个显著的缺点:如果原数组 A 中的某个值发生了更新(点更新),我们需要更新从该位置开始到数组末尾的所有前缀和,这需要 O(n) 的时间。
Fenwick Tree 的登场 🌲
那么,是否存在一种数据结构,既能支持快速的范围查询,又能支持高效的点更新呢?这就是 Fenwick Tree 要解决的问题。
Fenwick Tree(也称为 Binary Indexed Tree)正是为了在 O(log n) 时间内同时支持范围查询和点更新而设计的。它巧妙地利用了二进制索引的特性来管理部分和,从而达到了高效的平衡。
在本系列视频中,我们将首先学习如何进行范围查询。在后续的视频中,我们会讲解如何进行点更新,以及如何在线性时间内构建 Fenwick Tree。虽然它也能处理范围更新,但本系列课程暂时不会涉及。

总结 📝

本节课中我们一起学习了 Fenwick Tree 的引入动机。我们了解到,在处理数组范围求和问题时,朴素的线性扫描法效率低下,而前缀和数组法虽然查询快,但更新慢。Fenwick Tree 的目标正是为了在这两种操作(查询和更新)之间取得一个高效的平衡,为后续深入其原理和实现打下基础。
039:Fenwick树点更新 📈
在本节课中,我们将学习Fenwick树(又称二叉索引树)的点更新操作。点更新是指在树中某个特定位置增加或减少一个值。理解点更新是掌握Fenwick树的关键一步,它让我们能够动态地修改数组并高效地维护前缀和。

上一节我们介绍了Fenwick树的范围查询操作,了解了如何通过“移除最低有效位”来向下遍历树以计算前缀和。本节中我们来看看如何执行相反的操作——点更新。
回顾:范围查询操作
为了理解点更新,首先需要回顾一下范围查询是如何工作的。在Fenwick树中,计算前缀和的过程是一个“向下级联”的过程。

以下是执行前缀和查询的步骤:
- 从目标索引
i开始。 - 将当前索引
i对应的树节点值累加到结果中。 - 计算
i的最低有效位(LSB),并将其从i中移除:i = i - LSB(i)。 - 重复步骤2和3,直到
i变为0。
这个过程可以用伪代码表示:
def prefix_sum(i):
sum = 0
while i > 0:
sum += tree[i]
i -= i & -i # 移除最低有效位
return sum
点更新操作原理 🔄
点更新操作与范围查询在逻辑上是互补的。如果说查询是“向下移除最低有效位”,那么更新就是“向上添加最低有效位”。
当我们想要在原始数组的某个位置(例如索引 i)增加一个值 v 时,我们需要更新Fenwick树中所有负责包含该索引区间和的节点。这些节点恰好是那些索引可以通过不断“添加最低有效位”到达 i 的节点。


以下是点更新的核心步骤:
- 从目标索引
i开始。 - 将值
v加到当前索引i对应的树节点上。 - 计算
i的最低有效位(LSB),并将其加到i上:i = i + LSB(i)。 - 重复步骤2和3,直到
i超出树的大小n。

这个过程可以用公式描述为:在更新索引 i 的值时,我们需要更新所有满足 j = i + 2^k 且 j <= n 的节点,其中 2^k 是 i 的最低有效位,并在每次迭代后更新 i。
点更新示例演示 📊

让我们通过一个具体的例子来理解这个过程。假设我们想在索引 9 的位置增加一个值。
初始状态,我们位于索引 i = 9。
- 首先,更新
tree[9]。 - 计算
9的二进制表示(1001),其最低有效位是1(即2^0)。 - 将最低有效位加到当前索引:
9 + 1 = 10。更新tree[10]。 - 计算
10的二进制表示(1010),其最低有效位是2(即2^1)。 - 将最低有效位加到当前索引:
10 + 2 = 12。更新tree[12]。 - 计算
12的二进制表示(1100),其最低有效位是4(即2^2)。 - 将最低有效位加到当前索引:
12 + 4 = 16。假设树的大小n小于16,则操作停止。
通过这个“向上添加最低有效位”的路径(9 -> 10 -> 12 -> 16),我们更新了所有需要反映索引9处值变化的节点。

点更新的伪代码如下:
def point_update(i, delta):
while i <= n:
tree[i] += delta
i += i & -i # 添加最低有效位
操作对比与总结 📝
本节课中我们一起学习了Fenwick树的点更新操作。我们来总结一下点更新与范围查询这对核心操作的关系:

- 范围查询(前缀和):通过移除最低有效位(
i -= LSB(i))向下遍历树,累加路径上的值。 - 点更新:通过添加最低有效位(
i += LSB(i))向上遍历树,修改路径上的值。
这两种操作都利用了二进制索引的巧妙性质,确保每次操作的时间复杂度为 O(log n),其中 n 是数组的大小。正是这种高效性使得Fenwick树成为处理频繁更新和查询的序列问题的强大工具。

理解这种“向下查询,向上更新”的对称性,是掌握Fenwick树工作原理的关键。
040:Fenwick树构建 🧱
在本节课中,我们将学习如何从给定的初始数组,高效地构建一个Fenwick树(又称二叉索引树)。我们将探讨一种时间复杂度为O(n)的线性构建方法,这比逐个元素进行点更新的O(n log n)方法更优。
上一节我们介绍了Fenwick树的点更新操作,本节中我们来看看如何利用其原理进行初始化构建。
线性时间构建方法
我们已经在前两个视频中学习了如何进行区间查询和点更新操作,但尚未讨论如何从头开始构建Fenwick树。之所以将这部分内容留到最后,是因为如果不先理解点更新的工作原理,就无法理解Fenwick树的构建过程。

假设我们有一个初始值数组 a,我们希望将其转换为一个功能完整的Fenwick树。一种朴素的方法是:首先将Fenwick树初始化为一个全零数组,然后使用点更新操作逐个添加 a 中的值。这种方法的总时间复杂度为 O(n log n)。
然而,我们可以做得更好。实际上,我们可以在 线性时间 O(n) 内完成构建。既然有更优的方案,何必使用O(n log n)的方法呢?
构建原理
在线性构建方法中,我们被给予一个希望转换为Fenwick树的值数组。我们的目标是得到一个合法的Fenwick树结构,而不仅仅是原始值数组本身。
其核心思想是:我们将在原地(in place)将值传播到整个Fenwick树中。具体做法是,更新每个节点所负责的“直接后继”单元格。
以下是构建过程的关键步骤:
- 初始化:直接将输入数组
a作为Fenwick树的初始底层数组。此时,树中的每个节点i已经包含了原始值a[i],但尚未包含其所有负责的子区间和。 - 传播值:我们从左到右遍历数组(通常从索引1开始,如果使用1-based索引)。对于每个索引
i,我们计算其“父节点”或“负责节点”的索引j。这个j可以通过公式j = i + LSB(i)找到,其中LSB(i)是i的最低有效位(Least Significant Bit)。 - 更新父节点:如果
j没有超出数组范围,我们就将当前节点i的值加到节点j上。这相当于将子区间的和向上传播给负责更大区间的父节点。 - 完成构建:当我们遍历完整个数组后,每个节点的值都已经被其所有子节点更新过,此时数组就变成了一个完全构建好的Fenwick树。

最终,当我们遍历完整棵树后,每个节点都将被正确更新。
算法步骤与代码
以下是线性构建Fenwick树的具体步骤描述和伪代码实现。

步骤描述:
- 将输入数组复制到Fenwick树数组
ft中。 - 从
i = 1开始循环到n(假设为1-based索引)。 - 计算父索引
j = i + (i & -i)。 - 如果
j <= n,则执行ft[j] += ft[i]。
代码示例(1-based索引):
def construct_fenwick(arr):
n = len(arr)
# 创建Fenwick树数组,通常使用1-based索引,所以大小为n+1
ft = [0] * (n + 1)
# 第一步:将原始值放入对应位置(假设输入arr是0-based)
for i in range(1, n + 1):
ft[i] = arr[i-1]
# 第二步:线性构建
for i in range(1, n + 1):
j = i + (i & -i) # 找到i的父节点
if j <= n:
ft[j] += ft[i]
return ft
关键公式:
计算父节点索引的公式为:
parent = i + LSB(i)
其中 LSB(i) = i & -i,用于获取整数 i 的二进制表示中最右边的1所代表的值。

总结
本节课中我们一起学习了Fenwick树的线性时间构建算法。我们了解到,相比于朴素的O(n log n)方法,我们可以通过一次遍历,利用 j = i + LSB(i) 的规则将子节点的值累加到父节点,从而在O(n)时间内完成Fenwick树的初始化。这种方法高效且优雅,是Fenwick树实际应用中的重要组成部分。
041:Fenwick树源码解析 📚
在本节课中,我们将一起学习Fenwick树(又称二叉索引树)的源代码实现。我们将分析其核心构造方法、关键操作,并理解其内部工作原理。
概述
Fenwick树是一种高效的数据结构,用于计算数组前缀和。它支持两种主要操作:在 O(log n) 时间内更新单个元素的值,以及在 O(log n) 时间内查询前缀和。本节我们将深入其Java源码实现。
源码结构
现在,让我们查看Fenwick树的源代码。代码位于一个GitHub仓库中,具体路径在描述中提供。源代码组织在 FenwickTree 文件夹下。
以下是Fenwick树类的核心结构:
public class FenwickTree {
// 树状数组
private long[] tree;
// 构造方法一:创建指定大小的空树
public FenwickTree(int sz) {
tree = new long[sz + 1];
}
// 构造方法二:根据给定数组构造树(线性时间)
public FenwickTree(long[] values) {
if (values == null) throw new IllegalArgumentException("Values array cannot be null!");
tree = values.clone();
for (int i = 1; i < tree.length; i++) {
int j = i + lsb(i);
if (j < tree.length) tree[j] += tree[i];
}
}
}
构造方法详解
上一节我们介绍了类的整体结构,本节中我们来看看两个构造方法的具体实现。
空树构造方法
第一个构造方法创建一个指定大小的空Fenwick树。它初始化一个内部数组,其大小为 sz + 1。这里加1的原因是Fenwick树通常使用1-based索引。
数组构造方法(线性时间)
第二个构造方法接受一个值数组,并在线性时间内构建Fenwick树。这是推荐使用的构造方法。

关键点:传入的 values 数组必须是 1-based 的。这意味着数组的第一个元素(索引0)通常不使用或作为占位符,有效数据从索引1开始。这与上一视频中关于循环边界的讨论相关。
构建过程的核心循环如下:
for (int i = 1; i < tree.length; i++) {
int j = i + lsb(i);
if (j < tree.length) tree[j] += tree[i];
}
其中,lsb(i) 函数返回数字 i 的最低有效位(Least Significant Bit)。公式为:
lsb(i) = i & -i
核心操作
理解了构造方法后,我们来看看Fenwick树支持的核心操作。
前缀和查询
prefixSum 方法计算从索引1到 i 的前缀和。
public long prefixSum(int i) {
long sum = 0L;
while (i != 0) {
sum += tree[i];
i -= lsb(i);
}
return sum;
}
操作通过不断减去最低有效位(i -= lsb(i))来遍历树节点,累加路径上的值。
区间和查询
sum 方法计算闭区间 [i, j] 的和。它利用前缀和实现:
sum(i, j) = prefixSum(j) - prefixSum(i - 1)
public long sum(int left, int right) {
if (right < left) throw new IllegalArgumentException("Make sure right >= left");
return prefixSum(right) - prefixSum(left - 1);
}
单点更新
add 方法将值 k 加到位置 i 的元素上。
public void add(int i, long k) {
while (i < tree.length) {
tree[i] += k;
i += lsb(i);
}
}
操作通过不断加上最低有效位(i += lsb(i))来更新所有受影响的父节点。
单点设置
set 方法将位置 i 的值设置为 k。它通过计算新旧值的差值,然后调用 add 方法实现。
public void set(int i, long k) {
long value = sum(i, i);
add(i, k - value);
}
辅助方法
以下是实现中用到的一个关键辅助方法:
最低有效位(LSB)
lsb 方法是一个私有静态方法,用于计算整数的最低有效位。
private static int lsb(int i) {
return i & -i;
}
这个方法利用了二进制补码的特性,是Fenwick树高效操作的基础。
使用示例
为了让大家更好地理解如何使用这个类,以下是一个简单的示例:
// 假设有一个1-based的输入数组
long[] values = {0, 1, 2, 3, 4, 5}; // 索引0是占位符
FenwickTree ft = new FenwickTree(values);
// 查询前缀和
long sum1to3 = ft.prefixSum(3); // 返回 6 (1+2+3)
// 查询区间和
long sum2to4 = ft.sum(2, 4); // 返回 9 (2+3+4)
// 更新元素
ft.add(3, 10); // 给位置3的元素加10
long newSum1to3 = ft.prefixSum(3); // 返回 16

总结
本节课中我们一起学习了Fenwick树的完整源代码实现。我们分析了它的两个构造方法,理解了为何需要使用1-based索引。我们详细探讨了其四个核心操作:prefixSum(前缀和查询)、sum(区间和查询)、add(单点更新)和 set(单点设置),并了解了最低有效位(LSB)在高效遍历树结构中的关键作用。通过这段简洁的代码,我们获得了一个能在 O(log n) 时间内进行前缀和与更新操作的强大数据结构。
042:后缀数组简介
在本节课中,我们将要学习一个名为“后缀数组”的数据结构。后缀数组是进行字符串处理时一个极其强大的工具,它出现在20世纪90年代初期,主要是为了应对后缀树对内存的高消耗需求。
什么是后缀?
在开始学习后缀数组之前,我们首先需要理解什么是后缀。对于我们的目的而言,后缀是指一个字符串末尾的一个非空子串。

例如,对于字符串 horse,我们可以找出其所有可能的后缀。
以下是字符串 horse 的所有后缀:
- E
- SE
- RSE
- ORSE
- HORSE

什么是后缀数组?

上一节我们介绍了后缀的概念,本节中我们来看看后缀数组的定义。后缀数组是一个包含了给定字符串所有后缀的排序后的数组。

让我们通过一个例子来具体理解。假设我们想为单词 camel 构建后缀数组。
首先,我们列出 camel 的所有后缀及其在原字符串中的起始索引。

以下是 camel 的所有后缀及其起始索引:
- 后缀
camel, 起始索引 0 - 后缀
amel, 起始索引 1 - 后缀
mel, 起始索引 2 - 后缀
el, 起始索引 3 - 后缀
l, 起始索引 4
接着,我们将这些后缀按照字典序(lexicographic order)进行排序。
以下是按字典序排序后的后缀及其起始索引:
- 后缀
amel, 起始索引 1 - 后缀
camel, 起始索引 0 - 后缀
el, 起始索引 3 - 后缀
l, 起始索引 4 - 后缀
mel, 起始索引 2

最终,后缀数组就是这个排序后列表中,后缀起始索引所构成的数组。对于 camel,其后缀数组 SA 为:
SA = [1, 0, 3, 4, 2]

本节课中我们一起学习了后缀数组的基础知识。我们首先定义了后缀,然后通过一个具体的例子,展示了如何从一个字符串构建出其后缀数组。后缀数组的核心是一个按字典序排序的后缀起始索引列表,它是许多高效字符串算法的基础。
043:最长公共前缀数组 🧬
在本节课中,我们将探讨与后缀数组相关的最重要的信息之一:最长公共前缀数组,通常简称为 LCP 数组。
LCP 数组是一个数组,其中每个索引存储了两个已排序后缀之间共有的字符数量。让我们更深入地了解一下。
理解 LCP 数组
上一节我们介绍了 LCP 数组的基本概念。本节中,我们通过一个具体的例子来展示如何构建 LCP 数组。
我们将为字符串 ABABBAA 找出其 LCP 数组。

构建后缀数组
构建 LCP 数组的第一步,是为我们的字符串构造后缀数组,以找出所有已排序的后缀。
以下是字符串 ABABBAA 的后缀数组构建过程:



请注意,我们放在 LCP 数组(中间列)的第一个条目是 0。这是因为这个索引是未定义的,我们暂时忽略它。


计算 LCP 值
现在我们已经有了后缀数组,接下来开始构建 LCP 数组。让我们从查看前两个后缀开始,看看它们有多少个字符是相同的。
我们发现这个值是 2。因此,我们将 2 放入 LCP 数组的第一个索引中。




现在我们继续看接下来的两个后缀。



它们的 LCP 值也是 2。



再接下来的两个后缀没有任何共同字符,所以我们填入 0。


而最后两个后缀只有一个共同字符。

总结
本节课中我们一起学习了最长公共前缀数组的概念和构建方法。我们了解到,LCP 数组存储了后缀数组中相邻后缀对之间的最长公共前缀长度,它是许多高效字符串算法(如模式匹配、查找最长重复子串等)的关键组件。通过为字符串 ABABBAA 逐步构建 LCP 数组,我们掌握了其核心的计算过程。
044:后缀数组应用 - 寻找唯一子串 🔍
在本节课中,我们将学习如何利用后缀数组(Suffix Array)和最长公共前缀数组(LCP Array)来高效地寻找并统计一个字符串中所有唯一的子串。这是一种比朴素算法更快速、更节省空间的优雅方法。
问题背景与朴素方法
在计算机科学,尤其是生物信息学领域,存在许多需要找出字符串所有唯一子串的有趣问题。

朴素算法的时间复杂度非常糟糕,为 O(n²),并且需要大量空间。其思路是生成字符串的所有子串,并将它们放入一个集合中。
更优的解决方案
一种更优的方法是使用存储在LCP数组中的信息。这种方法不仅快速,而且空间效率高。需要说明的是,这并不是寻找所有唯一子串的唯一方法,还存在其他著名的算法,例如结合了布隆过滤器的Rabin-Karp算法。
上一节我们介绍了问题的背景和基本思路,本节中我们来看看具体的操作步骤。
示例:寻找唯一子串
现在,让我们通过一个例子来学习如何寻找字符串的所有唯一子串。我们以字符串 “AZAZA” 为例。
对于任何长度为 n 的字符串,其子串总数恰好为 n(n+1)/2 个。这个公式的证明将留作练习,但它并不难推导。
以下是字符串 “AZAZA” 的所有子串列表,请注意其中存在一些重复项:
- A
- AZ
- AZA
- AZAZ
- AZAZA
- Z
- ZA
- ZAZ
- ZAZA
- A
- AZ
- AZA
- Z
- ZA
- A
我已将重复的子串高亮显示,总共有6个重复子串,因此唯一子串的数量是9个。
利用后缀数组与LCP数组
现在,让我们看看如何利用后缀数组和LCP数组中的信息来高效地计算唯一子串的数量。
核心思想是:后缀数组按字典序排列了所有后缀,而LCP数组告诉我们相邻后缀之间共享的前缀长度。 所有可能的子串都包含在这些后缀的前缀中。通过后缀数组,我们可以系统性地遍历这些前缀,并利用LCP值避免重复计数。
对于一个给定的后缀 SA[i],它所能贡献的、以前缀形式出现的新子串数量,是其后缀长度减去它与前一个后缀的最长公共前缀长度。用公式表示,从后缀 SA[i] 得到的新子串数为:

len(SA[i]) - LCP[i]
其中,len(SA[i]) 是当前后缀的长度,LCP[i] 是当前后缀与前一个后缀的最长公共前缀长度(对于第一个后缀,LCP[0] 通常定义为0)。
因此,要计算整个字符串的唯一子串总数,只需对每个后缀应用这个公式并求和:
总唯一子串数 = Σ ( len(SA[i]) - LCP[i] ),对 i 从 0 到 n-1 求和。
让我们将其应用到 “AZAZA” 的例子中。首先,我们需要构建它的后缀数组和LCP数组(构建过程在本系列前几节课中已介绍)。假设我们已得到如下结果:
- 后缀数组
SA: 索引代表排序后的后缀起始位置,例如[4, 2, 0, 3, 1](表示”A”, “AZA”, “AZAZA”, “ZA”, “ZAZA”)。 - LCP数组
LCP:[0, 1, 3, 0, 2](表示相邻排序后缀之间的LCP长度)。
根据公式计算:
- 后缀0 (
A, 长度1):1 - 0 = 1 - 后缀1 (
AZA, 长度3):3 - 1 = 2 - 后缀2 (
AZAZA, 长度5):5 - 3 = 2 - 后缀3 (
ZA, 长度2):2 - 0 = 2 - 后缀4 (
ZAZA, 长度4):4 - 2 = 2
将结果相加:1 + 2 + 2 + 2 + 2 = 9。这与我们之前手动找出的唯一子串数量一致。
算法步骤总结
以下是利用后缀数组和LCP数组寻找或计数唯一子串的通用步骤:
- 预处理:为输入字符串构建后缀数组
SA和最长公共前缀数组LCP。 - 初始化计数器:设置
unique_substrings = 0。 - 遍历与累加:对于
i从0到n-1(n为字符串长度):- 当前后缀
SA[i]的长度为n - SA[i]。 - 当前LCP值为
LCP[i](定义LCP[0] = 0)。 - 将
(n - SA[i]) - LCP[i]加到unique_substrings上。
- 当前后缀
- 得到结果:
unique_substrings即为唯一子串的总数。如果需要列出所有子串,可以在遍历时根据起始位置和长度生成。

总结
本节课中我们一起学习了如何高效地解决“寻找字符串中所有唯一子串”的问题。我们首先指出了朴素 O(n²) 方法的缺点,然后引入了基于后缀数组和LCP数组的优化方案。其核心公式 新子串数 = 后缀长度 - LCP值 使我们能够在 O(n) 时间内完成计数,这比朴素方法有了显著的效率提升。掌握这种方法不仅能解决此类特定问题,也有助于加深对后缀数组这一强大数据结构应用的理解。
045:最长公共子串问题与后缀数组解法 🧵
在本节课中,我们将学习一个经典问题——最长公共子串问题,并重点探讨其更通用的形式:K公共子串问题。我们将介绍传统的动态规划解法及其局限性,并深入讲解如何利用后缀数组在线性时间内高效解决此问题。
问题定义
首先,让我们明确问题。假设我们有 n 个字符串。我们的目标是找出这些字符串中,至少被其中 K 个字符串共享的最长公共子串。这里 K 的取值范围是从 2 到 n。

例如,考虑三个字符串 S1、S2 和 S3,且 K = 2。这意味着我们需要从这三个字符串中,找到至少被其中两个字符串共享的最长公共子串。

在此例中,最长公共子串是 BA。需要注意的是,最长公共子串可能不唯一,因为可能存在多个长度相同的子串。
传统解法:动态规划
解决此问题的传统方法是使用动态规划技术。其时间复杂度为所有字符串长度的乘积。
公式: O(L1 * L2 * ... * Ln),其中 Li 是第 i 个字符串的长度。
显然,当字符串数量或长度增加时,这种方法会变得非常笨重,应尽量避免使用。
高效解法:后缀数组
一种更优的解决方案是使用后缀数组。这种方法可以在与所有字符串长度总和成线性关系的时间内找到答案。
公式: O(N),其中 N 是所有字符串长度的总和。
接下来,我们将详细介绍如何构建和使用后缀数组来解决K公共子串问题。

后缀数组解法步骤
以下是利用后缀数组解决该问题的核心步骤。
-
连接字符串:首先,将所有
n个输入字符串连接成一个长字符串。为了在后续步骤中能区分不同字符串的后缀,需要在每个字符串后添加一个唯一的、不在原字母表中的分隔符(例如#、$等)。
代码示例:combined_str = S1 + ‘#1’ + S2 + ‘#2’ + S3 + ‘#3’ -
构建后缀数组:为连接后的长字符串构建后缀数组。后缀数组是一个整数数组,给出了字符串所有后缀按字典序排序后的起始位置索引。
-
构建LCP数组:根据后缀数组计算最长公共前缀数组。
LCP[i]存储的是排序后第i个后缀与第i-1个后缀之间的最长公共前缀的长度。 -
使用滑动窗口:问题转化为在LCP数组上寻找一个满足条件的“窗口”。我们使用一个滑动窗口(通常用双指针维护),确保窗口内覆盖的后缀至少来自
K个不同的原始字符串。 -
寻找最大最小值:在满足条件的窗口中,窗口内
LCP值的最小值,代表了这些后缀共享的一个公共前缀的长度。我们的目标是找到所有满足条件的窗口中,这个最小值的最大值,该值即为最长公共子串的长度。 -
记录结果:在遍历过程中,记录下达到最大长度时的窗口位置或对应的后缀起始点,即可还原出最长公共子串本身。
总结

本节课我们一起学习了最长公共子串问题及其扩展——K公共子串问题。我们分析了传统动态规划解法效率低下的原因,并重点掌握了一种基于后缀数组和LCP数组的高效线性时间算法。该算法的核心在于将多个字符串连接并构建后缀数据结构,通过维护一个在LCP数组上的滑动窗口来寻找满足数量约束的最长公共前缀。理解并实现此方法是解决许多字符串处理难题的关键。
046:最长公共子串问题(后缀数组法)第二部分
在本节课中,我们将通过一个完整的例子,学习如何使用后缀数组和LCP数组来解决多字符串的最长公共子串问题。我们将处理四个字符串,并找出其中至少两个字符串共享的最长子串。
概述与问题设定

上一节我们介绍了使用后缀数组解决最长公共子串问题的基本框架。本节中,我们来看一个具体的计算实例。
我们设定四个字符串:S1, S2, S3, S4。我们将参数K设为2,这意味着我们要求至少有两个字符串共享这个公共子串。屏幕下方提供了拼接后的文本以及最终答案,你可以暂停视频先自行尝试。
首先,我们需要为拼接后的文本构建后缀数组和LCP数组,它们已分别显示在屏幕右侧和左侧。在算法执行过程中,请注意左侧变量的变化。
算法执行与窗口滑动
我们将使用一个滑动窗口算法。窗口的LCP值和窗口的LCS值将追踪当前窗口的最长公共前缀和最长公共子串候选值。而LCS长度和LCS集合则记录迄今为止找到的最佳结果。
以下是算法初始化状态:
- 窗口从顶部开始。
- 我们的规则是:窗口需要包含至少K(此处为2)种不同的“颜色”(即来自不同原始字符串的后缀)。因此,初始窗口不满足条件,我们需要向下扩展窗口。


逐步扩展与收缩窗口
随着我们向下扩展窗口,窗口内逐渐包含了来自不同字符串的后缀。当窗口满足“包含至少两种颜色”的条件时,我们开始检查窗口内的LCP最小值,这代表了该窗口内所有后缀共享的一个公共前缀长度,即一个候选的公共子串长度。
算法会持续向下滑动窗口。每加入一个新的后缀,我们更新窗口的LCP值;每移出一个旧的后缀,我们也相应调整。在这个过程中,我们始终追踪满足颜色数要求(K=2)的窗口中,最大的那个LCP值。


确定最终结果
通过遍历整个后缀数组并动态维护滑动窗口,我们最终可以找到满足条件的最长公共前缀值。这个值对应的前缀,就是至少两个原始字符串共享的最长子串。
屏幕左侧的变量LCS length和LCS set会记录下这个最终找到的长度和具体的子串信息。

总结

本节课中,我们一起通过一个具体实例,完整演练了使用后缀数组和LCP数组,结合滑动窗口算法,求解多字符串最长公共子串的过程。关键步骤在于:构建后缀数组和LCP数组,然后使用一个维护不同字符串来源数量的滑动窗口,在窗口中寻找LCP的最小值,并全局追踪其最大值,从而得到最终答案。



047:最长重复子串
在本节课中,我们将要学习如何利用后缀数组和最长公共前缀数组,高效地解决“最长重复子串”问题。我们将从问题定义开始,逐步讲解其重要性、朴素解法的不足,并最终展示基于后缀数组的优化解法。

问题定义与重要性
最长重复子串问题是计算机科学中一个相当常见的问题。许多其他问题实际上都可以归约为这个问题。因此,掌握一种高效的解决方法非常重要。
最长重复子串 指的是在一个给定的字符串中,至少出现两次的最长子串。例如,在字符串 "Abracadabra" 中,最长重复子串是 "abra"。
朴素解法及其不足
上一节我们介绍了问题的定义。朴素解法通常需要检查所有可能的子串对,这会导致 O(n²) 的时间复杂度和大量的空间开销。对于较长的字符串,这种方法是不可行的。

高效解法:后缀数组与LCP数组
本节中我们来看看如何利用后缀数组和最长公共前缀数组来高效地解决这个问题。这种方法的核心思想是:一个重复出现的子串,必定是至少两个不同后缀的公共前缀。
以下是构建高效解法所需的两个核心数据结构:
- 后缀数组:一个包含字符串所有后缀的排序列表的数组。对于字符串
S,其第i个后缀是S[i..n-1]。 - 最长公共前缀数组:一个数组,其中
LCP[i]表示排序后后缀数组中第i个后缀与第i-1个后缀的最长公共前缀的长度。
核心算法公式可以描述为:
最长重复子串长度 = max(LCP[i]),其中 i 从 1 到 n-1。
实例解析:"Abracadabra"
让我们通过字符串 "Abracadabra" 的例子来具体理解这个过程。我们已经知道其最长重复子串是 "abra"。
首先,我们生成该字符串的后缀数组和对应的LCP数组。后缀数组按字典序排列了所有后缀,而LCP数组记录了相邻后缀之间的公共前缀长度。

以下是关键观察:LCP数组中的最大值,直接对应了最长重复子串的长度。在 "Abracadabra" 的例子中,LCP数组的最大值是 4,这正好是子串 "abra" 的长度。并且,拥有这个最大LCP值的两个相邻后缀,其公共前缀就是我们要找的最长重复子串。
算法步骤总结
本节课中我们一起学习了利用后缀数组和LCP数组寻找最长重复子串的方法。其步骤可以总结如下:
- 为输入字符串构建后缀数组。
- 根据后缀数组计算最长公共前缀数组。
- 遍历LCP数组,找到最大值
maxLCP及其索引index。 - 最长重复子串即为后缀数组中第
index个后缀(或第index-1个后缀)的前maxLCP个字符。

这种方法将时间复杂度从朴素的 O(n²) 优化到了构建后缀数组的复杂度(通常为 O(n log n) 或 O(n)),并且空间效率更高,是解决该问题的标准高效方案。
048:平衡二叉搜索树旋转 🔄
在本节课中,我们将要学习计算机科学中最重要的数据结构之一:平衡二叉搜索树。我们将重点探讨其保持平衡的核心机制——树旋转。
概述
平衡二叉搜索树与传统二叉搜索树有很大不同。它不仅遵循二叉搜索树的不变性,还能自我调整以维持其高度与节点数量成对数比例。这一点至关重要,因为它确保了插入和删除等操作的速度极快。

传统二叉搜索树的操作平均复杂度为对数级,这已经相当不错。然而,最坏情况下的复杂度仍然是线性的,因为对于某些输入序列,树可能会退化成一条链。例如,一个递增的数字序列就会导致这种情况。
为了避免这种线性复杂度,我们发明了平衡二叉搜索树,其所有操作在最坏情况下都能保持对数复杂度,这使得它们极具吸引力。
几乎所有平衡二叉搜索树实现其平衡性的核心秘密,就是树旋转的概念,这也将是本视频的主要主题。稍后,我们将观察一些特定类型的平衡二叉搜索树,看看这些旋转是如何发挥作用的。
树旋转详解
上一节我们介绍了平衡二叉搜索树的基本概念和重要性。本节中,我们来看看保持平衡的核心操作——树旋转。
树旋转是一种局部调整子树结构的操作,它能在不破坏二叉搜索树性质的前提下,改变树的高度和形态。旋转操作主要分为两种基本类型:左旋和右旋。
以下是理解旋转的关键点:
- 不变量保持:旋转操作必须保持二叉搜索树的性质(左子树所有节点值 < 根节点值 < 右子树所有节点值)。
- 高度调整:通过旋转,可以将较“重”一侧的子树节点提升或降低,从而减少整棵树或局部子树的高度差。
- 局部操作:旋转通常只涉及少数几个节点(如父节点、子节点、孙节点),因此效率很高。
右旋操作 🔁
右旋操作针对的是一个节点(我们称其为P)及其左子节点(我们称其为C)。当P的左子树比右子树高时,可以通过右旋来降低左侧高度。
旋转过程可以描述为:
- 节点
C成为新的子树根节点。 - 节点
P成为节点C的右子节点。 - 节点
C原来的右子树(T3)变为节点P的左子树。

用伪代码可以简要表示其核心关系变化:
// 设 P 为当前根,C = P.left
new_root = P.left
P.left = new_root.right
new_root.right = P
// 更新后,new_root 成为该子树的根

左旋操作 🔄
左旋是右旋的对称操作,针对的是一个节点(P)及其右子节点(C)。当P的右子树比左子树高时,使用左旋。
旋转过程如下:
- 节点
C成为新的子树根节点。 - 节点
P成为节点C的左子节点。 - 节点
C原来的左子树(T2)变为节点P的右子树。
其核心关系变化的伪代码如下:
// 设 P 为当前根,C = P.right
new_root = P.right
P.right = new_root.left
new_root.left = P
// 更新后,new_root 成为该子树的根

旋转的应用与意义
理解了左旋和右旋的基本原理后,我们来看看它们的实际应用。在AVL树、红黑树等具体的平衡二叉搜索树实现中,正是通过检测节点左右子树的高度差(平衡因子),并在插入或删除节点后,智能地应用单次或多次旋转(如左右双旋、右左双旋),来恢复树的平衡。
这种动态调整确保了树的高度始终为 O(log n),其中 n 是树中节点的数量。因此,查找、插入和删除等操作的最坏时间复杂度都能稳定在对数级,避免了普通二叉搜索树可能退化为链表的线性复杂度问题。
总结
本节课中,我们一起学习了平衡二叉搜索树的核心平衡技术——树旋转。

- 我们首先了解了平衡二叉搜索树相比普通二叉搜索树的优势,即通过保持对数高度来保证操作效率。
- 然后,我们深入探讨了实现平衡的关键:左旋和右旋操作,并使用伪代码描述了它们的核心逻辑。
- 最后,我们明确了旋转的最终目的是动态维持树的结构,使得所有基本操作的时间复杂度稳定在 O(log n)。
掌握树旋转是理解后续更复杂的平衡树结构(如AVL树、红黑树)的重要基础。
049:AVL树插入操作 🧩
在本节课中,我们将详细学习如何向AVL树中插入节点。我们将充分利用上一节视频中介绍的树旋转技术。如果你没有观看上一节内容,建议先回顾一下。
什么是AVL树? 🤔
AVL树是众多平衡二叉搜索树中的一种,它允许在对数时间内完成插入、删除和查找操作。AVL树的一个特别之处在于,它是第一种被发现的平衡二叉搜索树。此后,涌现出了许多其他类型的平衡二叉搜索树,包括2-3树、AA树、替罪羊树,以及AVL树的主要竞争对手——红黑树。

接下来,你需要了解的是保持AVL树平衡的特性,即平衡因子。简单来说,一个节点的平衡因子是其右子树高度与左子树高度之差。平衡因子也可以定义为左子树高度减去右子树高度,但为了保持一致性,我们采用右子树高度减去左子树高度的定义。
平衡因子的计算公式为:
平衡因子 = 右子树高度 - 左子树高度
AVL树的平衡条件 ⚖️
AVL树要求每个节点的平衡因子必须为 -1、0 或 1。如果任何节点的平衡因子超出这个范围,树就会变得不平衡,需要通过旋转操作来恢复平衡。
插入节点的步骤 📝
向AVL树插入节点与向普通二叉搜索树插入节点类似,但插入后需要检查并修复可能出现的平衡问题。以下是插入节点的基本步骤:
- 执行标准BST插入:首先,像在普通二叉搜索树中一样插入新节点。
- 更新祖先节点的高度:新节点插入后,需要更新从该节点到根节点路径上所有祖先节点的高度。
- 获取节点的平衡因子:计算从新插入节点到根节点路径上每个节点的平衡因子。
- 如果发现不平衡,进行旋转:如果任何节点的平衡因子不是 -1、0 或 1,则树在该节点处不平衡。根据不平衡的类型,需要进行相应的旋转来恢复平衡。
不平衡的四种情况与旋转 🔄
当插入节点导致树不平衡时,会出现四种基本情况。每种情况对应一种特定的旋转操作。
以下是四种不平衡情况及其对应的旋转操作:
- 左左情况(LL):新节点插入在左子树的左子树中,导致节点平衡因子为 -2,且其左子节点的平衡因子为 -1 或 0。这种情况需要通过右旋来修复。
- 右右情况(RR):新节点插入在右子树的右子树中,导致节点平衡因子为 +2,且其右子节点的平衡因子为 +1 或 0。这种情况需要通过左旋来修复。
- 左右情况(LR):新节点插入在左子树的右子树中,导致节点平衡因子为 -2,但其左子节点的平衡因子为 +1。这种情况需要先对不平衡节点的左子节点进行左旋,然后再对不平衡节点本身进行右旋。
- 右左情况(RL):新节点插入在右子树的左子树中,导致节点平衡因子为 +2,但其右子节点的平衡因子为 -1。这种情况需要先对不平衡节点的右子节点进行右旋,然后再对不平衡节点本身进行左旋。
插入操作示例 🌰
让我们通过一个简单的例子来演示插入过程。假设我们向一个空的AVL树中依次插入节点 10, 20, 30。
- 插入10:树是平衡的。
- 插入20:树仍然是平衡的。
- 插入30:在插入30后,节点10的平衡因子变为 +2(右右情况)。此时需要对节点10进行一次左旋,使节点20成为新的根节点,从而恢复树的平衡。

总结 📚

本节课中,我们一起学习了AVL树的插入操作。我们首先回顾了AVL树和平衡因子的定义,然后详细讲解了插入节点的步骤:执行标准BST插入、更新高度、检查平衡因子,并在发现不平衡时进行旋转修复。我们介绍了四种可能导致不平衡的情况(LL, RR, LR, RL)及其对应的旋转解决方案。通过保持每个节点的平衡因子在 {-1, 0, 1} 范围内,AVL树能够维持其平衡性,确保所有核心操作(插入、删除、查找)都能在对数时间复杂度内完成。
050:AVL树删除操作
在本节课中,我们将学习如何从AVL树中删除元素。你将发现,从AVL树中删除元素与从常规二叉搜索树中删除元素几乎完全相同。因此,本视频的大部分内容将首先详细讲解如何从二叉搜索树中删除节点,最后再针对AVL树的特点对该算法进行增强。让我们开始吧。

二叉搜索树删除操作回顾
上一节我们介绍了AVL树删除操作的整体思路。本节中,我们来详细回顾一下如何在二叉搜索树中删除节点。这个过程通常可以分解为两个主要步骤:查找和替换。

在查找阶段,我们需要在树中找到希望删除的元素(如果它存在的话)。在替换阶段,我们将该节点替换为其后继节点。这个后继节点对于维持二叉搜索树的性质是必要的。
查找阶段详解
以下是查找阶段可能发生的四种情况:
- 我们遇到了一个空节点,这意味着我们要查找的值在树中不存在。
- 我们的比较器返回值为0,这意味着我们找到了想要删除的节点。
- 比较器的值小于0,这意味着我们要查找的值(如果存在)将在左子树中找到。
- 比较器的值大于0,这意味着我们要查找的值(如果存在)将在右子树中找到。

让我们通过一个例子来演示如何在二叉搜索树中查找节点。
051:AVL树源码解析 🧮
在本节课中,我们将一起学习AVL树的源代码实现。我们将分析一个用Java编写的递归式AVL树,理解其核心数据结构、平衡因子的维护以及插入和删除操作背后的逻辑。请确保你已经学习了之前关于AVL树旋转、插入和删除的三节课程,以便更好地理解即将展示的代码。
源码概览与演示
上一节我们探讨了AVL树的理论操作,本节中我们来看看其具体的代码实现。

首先,本节所使用的完整源代码可以在Github仓库中找到:
Github链接: github.com/sphysia/data_structures
在深入代码之前,我们先看一个AVL树的动态演示。我编译并运行了Java代码,程序随机生成了一棵AVL树并插入了一些节点。你可以观察到,即使经过随机插入,这棵树依然保持了良好的平衡性。如果这是一棵普通的二叉搜索树,结构可能会松散许多,这正体现了AVL树在维持平衡方面的优势。
现在,我们开始解析源代码。
AVL树类结构定义
以下是AVL树核心类的定义,它包含了节点内部类和树的基本框架。
public class AVLTreeRecursive <T extends Comparable<T>> {
// 节点内部类定义
private class Node {
// 节点属性与构造函数
}
// 树的根节点和节点计数器
private Node root;
private int nodeCount = 0;
// 核心方法:插入、删除、查询等
}
这个类是一个泛型类,要求存储的元素类型 T 必须实现 Comparable 接口,以便进行比较。它主要包含一个私有的 Node 内部类和几个关键属性。
节点内部类详解
接下来,我们详细查看构成AVL树基础的节点内部类。
private class Node {
// 平衡因子,计算公式为: bf = height(right subtree) - height(left subtree)
public int bf;
// 节点存储的实际值
public T value;
// 节点高度,初始为0
public int height;
// 左右子节点的引用
public Node left, right;
public Node(T value) {
this.value = value;
}
}
节点类封装了AVL树的核心信息:
bf: 平衡因子。这是AVL树平衡的关键,其值定义为右子树高度 - 左子树高度。对于平衡节点,bf的值应为 -1, 0 或 1。value: 节点存储的数据。height: 以该节点为根的子树的高度。叶子节点的高度为0。left,right: 指向左、右子节点的指针。

辅助方法:更新与平衡工具
在实现插入和删除操作前,我们需要一些辅助方法来维护树的平衡状态。
1. 更新节点高度与平衡因子
这是一个关键操作,在每次树的结构发生变化后(如旋转、插入子节点),都必须调用它来重新计算节点的高度和平衡因子。
// 更新一个节点的高度和平衡因子
private void update(Node node) {
// 处理空节点情况
if (node == null) return;
// 计算左右子树的高度,空子树高度为-1
int leftNodeHeight = (node.left == null) ? -1 : node.left.height;
int rightNodeHeight = (node.right == null) ? -1 : node.right.height;
// 更新本节点高度: 1 + 左右子树中较高的那个高度
node.height = 1 + Math.max(leftNodeHeight, rightNodeHeight);
// 更新平衡因子: 右子树高度 - 左子树高度
node.bf = rightNodeHeight - leftNodeHeight;
}
2. 平衡操作:旋转
当节点的平衡因子超出 [-1, 1] 的范围时,需要通过旋转来恢复平衡。以下是四种旋转情况的实现。
// 左旋 (适用于左左情况)
private Node leftLeftCase(Node node) {
return rightRotation(node);
}
// 右旋 (适用于右右情况)
private Node rightRightCase(Node node) {
return leftRotation(node);
}
// 左右旋 (适用于左右情况)
private Node leftRightCase(Node node) {
node.left = leftRotation(node.left);
return leftLeftCase(node);
}
// 右左旋 (适用于右左情况)
private Node rightLeftCase(Node node) {
node.right = rightRotation(node.right);
return rightRightCase(node);
}
每种情况都对应着之前课程中讲解的一种不平衡模式,并通过调用基本的右旋或左旋操作来解决。
3. 基础旋转方法
以下是实现平衡的最基本操作:右旋转和左旋转。
// 右旋转
private Node rightRotation(Node node) {
Node newParent = node.left;
node.left = newParent.right;
newParent.right = node;
// 旋转后,必须更新被移动的节点的高度和平衡因子
update(node);
update(newParent);
return newParent;
}
// 左旋转
private Node leftRotation(Node node) {
Node newParent = node.right;
node.right = newParent.left;
newParent.left = node;
// 旋转后,必须更新被移动的节点的高度和平衡因子
update(node);
update(newParent);
return newParent;
}
旋转操作不仅改变了节点的链接关系,最后调用 update 方法确保相关节点的 height 和 bf 信息是正确的。
4. 平衡节点
这个方法根据当前节点的平衡因子 bf,判断属于哪种不平衡情况,并调用相应的旋转方法。
// 平衡以`node`为根的子树
private Node balance(Node node) {
// 左子树更高,可能导致左左或左右情况
if (node.bf == -2) {
// 左左情况: 左子节点的平衡因子 <= 0
if (node.left.bf <= 0) {
return leftLeftCase(node);
// 左右情况: 左子节点的平衡因子 > 0
} else {
return leftRightCase(node);
}
// 右子树更高,可能导致右右或右左情况
} else if (node.bf == +2) {
// 右右情况: 右子节点的平衡因子 >= 0
if (node.right.bf >= 0) {
return rightRightCase(node);
// 右左情况: 右子节点的平衡因子 < 0
} else {
return rightLeftCase(node);
}
}
// 平衡因子在-1,0,1之间,树是平衡的,直接返回原节点
return node;
}
核心操作:插入与删除
掌握了平衡工具后,我们来看AVL树最核心的插入和删除操作,它们都是递归实现的。

插入操作
插入操作递归地找到合适的位置添加新节点,并在回溯路径上更新和平衡每个节点。
public boolean insert(T value) {
// 如果值已存在,则不插入
if (value == null || contains(value)) return false;
root = insert(root, value);
nodeCount++;
return true;
}
private Node insert(Node node, T value) {
// 递归基准情况: 到达空位,创建新节点
if (node == null) return new Node(value);
// 比较大小,决定插入左子树还是右子树
int cmp = value.compareTo(node.value);
if (cmp < 0) {
node.left = insert(node.left, value);
} else {
node.right = insert(node.right, value);
}
// 回溯: 更新当前节点的高度和平衡因子,然后进行平衡操作
update(node);
return balance(node);
}
删除操作
删除操作比插入更复杂,需要处理找到替代节点(左子树的最大值或右子树的最小值)的情况。
public boolean remove(T elem) {
if (elem == null || !contains(elem)) return false;
root = remove(root, elem);
nodeCount--;
return true;
}
private Node remove(Node node, T value) {
if (node == null) return null;
int cmp = value.compareTo(node.value);
// 递归查找要删除的节点
if (cmp < 0) {
node.left = remove(node.left, value);
} else if (cmp > 0) {
node.right = remove(node.right, value);
} else {
// 找到要删除的节点
// 情况1: 只有右子树或没有子树
if (node.left == null) {
return node.right;
// 情况2: 只有左子树
} else if (node.right == null) {
return node.left;
// 情况3: 有左右子树
} else {
// 选择左子树中的最大值作为后继节点
if (node.left.height > node.right.height) {
T successorValue = findMax(node.left);
node.value = successorValue; // 用后继值覆盖当前值
node.left = remove(node.left, successorValue); // 删除原来的后继节点
// 选择右子树中的最小值作为后继节点
} else {
T successorValue = findMin(node.right);
node.value = successorValue; // 用后继值覆盖当前值
node.right = remove(node.right, successorValue); // 删除原来的后继节点
}
}
}
// 回溯: 更新当前节点的高度和平衡因子,然后进行平衡操作
update(node);
return balance(node);
}
// 辅助方法:查找子树中的最小/最大值
private T findMin(Node node) { while (node.left != null) node = node.left; return node.value; }
private T findMax(Node node) { while (node.right != null) node = node.right; return node.value; }

总结
本节课中我们一起学习了AVL树的递归式Java源码实现。我们从类的整体结构开始,深入分析了Node节点的构成,然后逐步讲解了维持AVL树平衡的核心机制:包括更新高度和平衡因子的update方法,以及处理四种不平衡情况的旋转操作balance。最后,我们剖析了插入insert和删除remove这两个关键操作的递归实现逻辑,看到了它们如何在修改树结构后,通过回溯路径调用update和balance来确保树的平衡性。理解这份源码,能帮助你巩固AVL树的理论知识,并掌握如何将其转化为实际的代码。
052:索引优先队列(更新版)

在本节课中,我们将学习一种非常实用的数据结构——索引优先队列。它是传统优先队列的一个变体,除了支持所有常规优先队列操作外,还允许我们快速更新和删除键值对。这对于需要动态改变队列中元素优先级的场景(如医院急诊分诊)非常有用。

回顾:传统优先队列
在深入了解索引优先队列之前,我们先快速回顾一下传统优先队列。优先队列是一种抽象数据类型,它允许我们以任意顺序插入元素,但总是按照某种优先级顺序(例如,数值最小或最大)来移除元素。常见的实现方式有二叉堆。
然而,传统优先队列有一个局限性:它很难高效地查找、更新或删除一个特定的元素(除非它是队首元素)。例如,如果你想改变队列中“James”的优先级,你需要遍历整个堆来找到他,这非常低效。

索引优先队列的核心思想
索引优先队列通过引入一个额外的“索引”概念来解决上述问题。每个元素在插入时都与一个唯一的索引(通常是一个整数ID)相关联。这个索引作为元素的“钥匙”,使我们能够直接定位到它,而无需遍历整个数据结构。
其核心在于维护三个关键数组:
keys[]: 存储每个索引对应的实际值(优先级)。pm[](Position Map): 存储每个索引在堆数组heap中的当前位置。即pm[i]告诉我们索引i的元素在堆的哪个位置。im[](Inverse Map): 存储堆数组中每个位置所对应的索引。即im[j]告诉我们堆位置j上存放的是哪个索引的元素。
它们之间的关系可以用以下公式表示:
pm[im[j]] == j
im[pm[i]] == i
这意味着,给定一个索引 i,我们能立刻知道它在堆中的位置 (pm[i])。反之,给定一个堆位置 j,我们也能立刻知道那里存放的是哪个索引 (im[j])。
索引优先队列支持的操作
以下是索引优先队列支持的主要操作及其简要说明:
- 插入 (
insert(k, value)):将值value与索引k关联起来,并将其插入堆中。 - 更新 (
update(k, value)):将索引k关联的值更新为新的value,并调整堆以维持堆性质。 - 删除 (
delete(k)):删除与索引k关联的键值对。 - 弹出最小/最大值 (
poll()):移除并返回优先级最高(如最小值)的键值对。 - 减小/增大键值 (
decreaseKey(k, value)/increaseKey(k, value)):这是update操作的特化版本,确保新值比旧值更小或更大,常用于像Dijkstra这样的算法中。 - 查看队首 (
peek()):返回优先级最高的键值对,但不移除它。 - 根据索引查找值 (
valueOf(k)):快速返回索引k当前关联的值。


操作示例:医院分诊系统

让我们通过一个医院候诊室的例子来理解这些操作。假设我们有5位病人:
- Mary (索引 0), 优先级 9 (分娩)
- Alex (索引 1), 优先级 1 (纸割伤)
- James (索引 2), 优先级 7 (腿部中箭)
- Naida (索引 3), 优先级 3 (胃痛)
- Richard (索引 4), 优先级 5 (手腕骨折)
初始时,我们根据优先级构建一个最小堆(数字小优先级高,先处理)。堆数组 heap(通过 im 映射)可能看起来像 [1, 3, 2, 0, 4],对应 Alex, Naida, James, Mary, Richard。
场景1:更新优先级
假设 James (索引2) 的箭伤感染,情况恶化,优先级需要提高到 2。我们调用 update(2, 2)。
- 通过
pm[2]找到 James 在堆中的位置(假设是位置2)。 - 将
keys[2]的值从7更新为2。 - 因为新值(2)比旧值(7)小(在最小堆中优先级更高),所以需要向上调整 (
swim) James 在堆中的位置。 - 调整后,堆顺序得到维护。现在队首可能是 James 或 Alex。

场景2:删除病人
假设 Alex (索引1) 的伤口经过简单处理,可以离开。我们调用 delete(1)。
- 通过
pm[1]找到 Alex 在堆中的位置(位置0)。 - 将堆末尾的元素(假设是 Richard,索引4)移动到位置0。
- 更新
pm[4] = 0和im[0] = 4。 - 移除 Alex 的映射关系(
pm[1]设为无效值)。 - 对新移动到位置0的 Richard 执行堆调整 (
sink或swim),以恢复堆性质。
代码示例:关键操作框架
以下是一个简化版的 update 和 delete 操作框架,展示了如何使用 pm 和 im:
public void update(int ki, T value) {
keys[ki] = value; // 更新值
int i = pm[ki]; // 获取堆位置
swim(i); // 可能需要向上调整
sink(i); // 也可能需要向下调整
}
public void delete(int ki) {
int i = pm[ki]; // 获取要删除的索引的堆位置
swap(i, size - 1); // 与堆末尾元素交换
size--; // 减小堆大小
swim(i); // 调整交换上来的元素
sink(i);
// 清理
keys[ki] = null;
pm[ki] = -1;
im[size] = -1;
}
private void swap(int i, int j) {
int ki = im[i], kj = im[j];
// 交换堆中的位置
int temp = im[i];
im[i] = im[j];
im[j] = temp;
// 更新位置映射
pm[ki] = j;
pm[kj] = i;
}
总结

本节课我们一起学习了索引优先队列。我们首先指出了传统优先队列在动态更新元素方面的不足,然后引入了索引优先队列作为解决方案。其核心在于通过位置映射 (pm) 和逆映射 (im) 数组,在索引、键值和堆位置之间建立快速关联。这使得根据索引更新值 (update) 和删除特定元素 (delete) 等操作变得非常高效(时间复杂度通常为 O(log n))。索引优先队列是许多高级算法(如 Dijkstra 最短路径算法、Prim 最小生成树算法)高效实现的关键组件。掌握它将极大提升你解决复杂问题的能力。
数据结构课程:P53:索引优先队列源码解析 🚀

在本节课中,我们将一起学习索引优先队列(Indexed Priority Queue)的源代码实现。我们将重点关注一个最小索引二叉堆(Min Indexed Binary Heap)的实现细节,并理解其核心机制。
上一节我们介绍了索引优先队列的概念和重要性,本节中我们来看看其具体的Java源代码实现。
所有源代码均可在我的GitHub数据仓库中找到:github.com/williamfiset/data-structures。
最小索引二叉堆类概览
首先,我们来看最小索引二叉堆的类定义。它要求传入一个可比较(Comparable)的对象类型,以便在堆中对键值对进行排序。
public class MinIndexedBinaryHeap<T extends Comparable<T>> extends MinIndexedDHeap<T> {
public MinIndexedBinaryHeap(int maxSize) {
super(2, maxSize);
}
}
请注意,此类继承自一个更通用的 MinIndexedDHeap。在构造函数中,我们简单地初始化堆,规定每个节点最多有两个子节点(即二叉堆),而D堆通常支持每个节点有D个子节点。
深入D堆实现

现在,让我们深入查看 MinIndexedDHeap 类,所有核心逻辑都在这里发生。

以下是该类的主要实例变量:
sz:堆中当前元素的数量。N:一个常量,代表堆能容纳的最大元素数量。D:每个节点的度(子节点数量)。对于二叉堆,此值为2。
private int sz;
private final int N, D;
private final int[] child, parent;
private final int[] pm; // 位置映射(Position Map)
private final int[] im; // 逆映射(Inverse Map)
private final Object[] values; // 键值对中的值
为了管理索引和位置,我们使用了几个关键数组:
pm(位置映射):pm[k]给出键k在堆数组(im)中的位置。im(逆映射):im[i]给出堆数组中位置i处存储的键。values:存储与每个键相关联的值。
pm 和 im 之间的关系是核心,它们互为逆映射,满足以下公式:
pm[im[i]] = i
im[pm[k]] = k
核心操作方法
以下是实现堆操作的关键方法:
1. 比较与交换
堆操作依赖于比较和交换元素。less 方法比较两个键对应的值,swap 方法交换堆中两个位置的所有相关数据。

private boolean less(int i, int j) {
return ((T) values[im[i]]).compareTo((T) values[im[j]]) < 0;
}

private void swap(int i, int j) {
pm[im[j]] = i;
pm[im[i]] = j;
int tmp = im[i];
im[i] = im[j];
im[j] = tmp;
}

2. 上浮与下沉
为了维护堆属性,我们需要 swim (上浮) 和 sink (下沉) 操作。
swim:当一个节点的值变小时,它需要向堆顶移动。private void swim(int i) { while (i > 0 && less(i, parent[i])) { swap(i, parent[i]); i = parent[i]; } }sink:当一个节点的值变大时,它需要向堆底移动。这需要找到其子节点中最小的一个。private void sink(int i) { for (int j = minChild(i); j != -1; ) { swap(i, j); i = j; j = minChild(i); } } private int minChild(int i) { int index = -1, from = child[i], to = Math.min(sz, from + D); for (int j = from; j < to; j++) if (less(j, i)) index = i = j; return index; }

3. 主要公开API
基于上述内部方法,我们可以构建出对外的接口:
insert(k, value):插入一个键值对。public void insert(int ki, T value) { pm[ki] = sz; im[sz] = ki; values[ki] = value; swim(sz++); }delete(ki):删除指定键的节点。public T delete(int ki) { int i = pm[ki]; swap(i, --sz); sink(i); swim(i); T value = (T) values[ki]; values[ki] = null; pm[ki] = -1; return value; }update(ki, value):更新指定键的值,并调整堆。decreaseKey/increaseKey:专门用于增加或减少键值的优化方法。peekMinKeyIndex/pollMinKeyIndex:查看或取出最小值的键。peekMinValue/pollMinValue:查看或取出最小值。


本节课中我们一起学习了索引优先队列的核心源代码实现。我们剖析了最小索引D堆的数据结构,包括其用于高效索引管理的 pm 和 im 数组,以及实现堆排序属性的 swim 和 sink 方法。通过继承,二叉堆成为了D堆的一个特例。理解这些代码有助于你掌握如何构建一个能够通过索引快速访问和修改元素的优先队列。
054:稀疏表数据结构



在本节课中,我们将要学习稀疏表数据结构。稀疏表是一种用于在静态数组上高效回答区间查询的专用数据结构,其时间复杂度表现优异。
适用场景
上一节我们介绍了稀疏表的基本概念,本节中我们来看看它的典型应用场景。
稀疏表主要用于对静态数组进行高效的区间查询。典型的应用场景是处理数据不可变的整数数组。
以下是几种常见的区间查询类型:
- 查找某个区间内的最小值。
- 计算某个区间内所有值的总和。
- 计算给定区间内所有值的乘积。
工作原理

了解了稀疏表的适用场景后,本节我们来探讨其背后的核心工作原理。
稀疏表的核心思想基于一个数学事实:任何正整数都可以由其二进制表示所对应的2的幂次之和来表示。
例如,数字19的二进制是 10011,这等价于 2^4 + 2^1 + 2^0。
类似地,我们可以将一个由左右端点定义的区间,分解为若干个长度为2的幂次的子区间。
例如,区间 [5, 7] 可以这样分解。

本节课中我们一起学习了稀疏表数据结构。我们了解了它的适用场景,即对静态数组进行高效的区间查询,例如求最小值、总和或乘积。我们还探讨了其核心工作原理,即利用二进制和2的幂次来分解任意区间,这是实现高效查询的基础。
055:稀疏表数据结构源码解析 📚

在本节课中,我们将学习如何用代码实现一个稀疏表(Sparse Table)。稀疏表是一种数据结构,主要用于高效地回答数组上的静态区间查询,例如区间最小值、最大值或区间和。我们将基于Java源码,一步步解析其构建过程和查询方法。
上一节我们介绍了稀疏表的基本概念和原理。本节中,我们来看看如何用代码具体实现它。

概述与准备

首先,请注意本实现是一个最小值稀疏表,专门用于回答区间最小值查询。如果你需要进行其他类型的区间查询(如最大值、区间和),则需要修改代码。你也可以在GitHub上找到我编写的另一个更通用的稀疏表实现。
以下是如何使用本代码的示例:

public static void main(String[] args) {
// 示例数组
long[] values = {1, 0, -3, 5, 4, -2, 10};
// 构建最小值稀疏表
MinSparseTable minSparseTable = new MinSparseTable(values);
// 查询区间 [1, 5] 的最小值
System.out.println(minSparseTable.queryMin(1, 5)); // 输出:-3
}
稀疏表类结构
现在,让我们深入MinSparseTable类的内部。类的核心是维护一个二维数组dp,用于存储预计算的结果。同时,我们还需要一个log2数组来快速计算查询区间长度对应的幂次。

public class MinSparseTable {
// 存储稀疏表数据的二维数组
private long[][] dp;
// 存储以2为底的对数值,用于快速索引
private int[] log2;
}
构造函数与表的构建
构造函数接收原始数组并初始化稀疏表。构建过程分为两步:首先初始化log2数组,然后填充dp表。
以下是构建过程的步骤说明:
- 初始化与基础设置:计算数组长度
n,并确定稀疏表需要预计算的层数P(即floor(log2(n)))。同时初始化log2数组。 - 填充第一层(基础情况):
dp表的第一层(p=0)就是原始数组本身,因为区间长度为2^0 = 1。 - 动态规划构建高层:对于每一层
p(从1到P-1),我们计算所有长度为2^p的区间的最小值。其状态转移基于上一层的两个重叠区间:
dp[p][i] = min(dp[p-1][i], dp[p-1][i + (1 << (p-1))])
public MinSparseTable(long[] values) {
int n = values.length;
// 计算最大层数 P = floor(log2(n))
int P = (int) (Math.log(n) / Math.log(2)) + 1;
dp = new long[P][n];
log2 = new int[n + 1];
// 构建 log2 数组
for (int i = 2; i <= n; i++) {
log2[i] = log2[i / 2] + 1;
}
// 初始化 dp 表第一层
System.arraycopy(values, 0, dp[0], 0, n);
// 动态规划构建稀疏表
for (int p = 1; p < P; p++) {
for (int i = 0; i + (1 << p) <= n; i++) {
long leftInterval = dp[p - 1][i];
long rightInterval = dp[p - 1][i + (1 << (p - 1))];
dp[p][i] = Math.min(leftInterval, rightInterval);
}
}
}
区间最小值查询
构建好稀疏表后,我们可以用它来回答任何区间 [l, r] 的最小值查询。查询的关键在于将任意区间分解为两个长度为 2^k 的、可能重叠的区间,这两个区间的最小值已经在dp表中计算好了。

查询步骤如下:
- 计算区间长度
len = r - l + 1。 - 找到不大于
len的最大2的幂次k,即k = floor(log2(len))。我们可以直接从预计算的log2数组中获取这个值。 - 查询覆盖区间左端点的长度为
2^k的区间最小值,以及覆盖区间右端点(向左偏移2^k)的另一个长度为2^k的区间最小值。 - 返回这两个值中的较小者。
public long queryMin(int l, int r) {
int length = r - l + 1;
int k = log2[length]; // 获取区间长度对应的幂次 k
// 查询两个可能重叠的区间
return Math.min(dp[k][l], dp[k][r - (1 << k) + 1]);
}
总结
本节课中我们一起学习了稀疏表数据结构的代码实现。我们首先看到了如何使用MinSparseTable类进行区间最小值查询。然后,我们深入分析了类的构造函数,它通过动态规划高效地预计算了所有2的幂次长度区间的最小值。最后,我们解析了queryMin方法,它利用预计算的log2表和dp表,在常数时间内回答任意区间的最小值查询。

记住,这个实现是针对最小值操作的。其核心思想——通过预处理2的幂次长度的区间来加速查询——可以推广到其他可重复贡献的问题(如最大值、区间和、按位与/或等),只需修改状态合并的函数(本例中的Math.min)即可。

浙公网安备 33010602011771号