文章目录
- 数据结构合集
- 一、数组与链表
- 二、哈希表
-
- [1. 核心原理](#1. 核心原理)
- [2. 哈希冲突](#2. 哈希冲突)
- [3. 用链表加强哈希表(LinkedHashMap)](#3. 用链表加强哈希表(LinkedHashMap))
- [4. 用数组加强哈希表(ArrayHashMap)](#4. 用数组加强哈希表(ArrayHashMap))
- 三、二叉树
-
- [1. 常见二叉树](#1. 常见二叉树)
-
- [1.1 满二叉树](#1.1 满二叉树)
- [1.2 完全二叉树](#1.2 完全二叉树)
- [1.3 平衡二叉树](#1.3 平衡二叉树)
- [1.4 二叉搜索树](#1.4 二叉搜索树)
- [1.5 二叉树的实现](#1.5 二叉树的实现)
- [2. 二叉树的递归/层序遍历](#2. 二叉树的递归/层序遍历)
-
- [2.1 递归遍历(DFS)](#2.1 递归遍历(DFS))
- [2.2 理解前/中/后序遍历](#2.2 理解前/中/后序遍历)
- [2.3 层序遍历(BFS)](#2.3 层序遍历(BFS))
- [3. 多叉树的递归/层序遍历](#3. 多叉树的递归/层序遍历)
-
- [3.1 递归遍历(DFS)](#3.1 递归遍历(DFS))
- [3.2 层序遍历(BFS)](#3.2 层序遍历(BFS))
- [4. DFS 和 BFS 的适用场景](#4. DFS 和 BFS 的适用场景)
-
- [4.1 为什么 BFS 常用来寻找最短路径](#4.1 为什么 BFS 常用来寻找最短路径)
- [4.2 为什么 DFS 常用来寻找所有路径](#4.2 为什么 DFS 常用来寻找所有路径)
- [5. 二叉搜索树的应用](#5. 二叉搜索树的应用)
- 四、二叉堆
-
- [1. 性质](#1. 性质)
- [2. 优先级队列](#2. 优先级队列)
- [3. 堆排序](#3. 堆排序)
- [4. 二叉堆/优先级队列代码实现](#4. 二叉堆/优先级队列代码实现)
-
- [4.1 简化版优先级队列](#4.1 简化版优先级队列)
- [4.2 增:push/swim 方法插入元素](#4.2 增:push/swim 方法插入元素)
- [4.3 pop/sink 方法删除元素](#4.3 pop/sink 方法删除元素)
- [4.4 查:peek 方法查看堆顶元素](#4.4 查:peek 方法查看堆顶元素)
- [4.5 在数组上模拟二叉树](#4.5 在数组上模拟二叉树)
- [4.6 简化版优先级队列实现](#4.6 简化版优先级队列实现)
- [4.7 完善版优先级队列](#4.7 完善版优先级队列)
- 五、线段树
-
- [1. 使用场景](#1. 使用场景)
- [2. 核心 API](#2. 核心 API)
- [3. 万能线段树模版](#3. 万能线段树模版)
- 六、图
-
- [1. 逻辑结构](#1. 逻辑结构)
- [2. 邻接表和邻接矩阵](#2. 邻接表和邻接矩阵)
- [3. 不同种类的图结构](#3. 不同种类的图结构)
- [4. 图结构的通用代码](#4. 图结构的通用代码)
-
- [4.1 有向加权图(邻接表实现)](#4.1 有向加权图(邻接表实现))
- [4.2 有向加权图(邻接矩阵实现)](#4.2 有向加权图(邻接矩阵实现))
- [4.3 有向无权图(邻接表/邻接矩阵实现)](#4.3 有向无权图(邻接表/邻接矩阵实现))
- [4.4 无向加权图(邻接表/邻接矩阵实现)](#4.4 无向加权图(邻接表/邻接矩阵实现))
- [4.5 无向无权图(邻接表/邻接矩阵实现)](#4.5 无向无权图(邻接表/邻接矩阵实现))
- [5. 图结构的 DFS/BFS 遍历](#5. 图结构的 DFS/BFS 遍历)
-
- [5.1 深度优先搜索(DFS)](#5.1 深度优先搜索(DFS))
-
- [a 遍历所有节点(visited 数组)](#a 遍历所有节点(visited 数组))
- [b 遍历所有路径(onPath 数组)](#b 遍历所有路径(onPath 数组))
- [c 同时使用 visited 和 onPath 数组](#c 同时使用 visited 和 onPath 数组)
- [d 完全不用 visited 和 onPath 数组](#d 完全不用 visited 和 onPath 数组)
- [5.2 广度优先搜索(BFS)](#5.2 广度优先搜索(BFS))
-
- [a 写法一](#a 写法一)
- [b 写法二](#b 写法二)
- [c 写法三](#c 写法三)
- [七、Trie 树(字典树/前缀树)](#七、Trie 树(字典树/前缀树))
- [八、AVL 树与红黑树](#八、AVL 树与红黑树)
-
- [AVL 树](#AVL 树)
- 红黑树
- [九、B 树与 B+树](#九、B 树与 B+树)
-
- [B 树](#B 树)
- B+树
数据结构合集
一、数组与链表
1. 数组(顺序存储)基本原理
静态数组的增删查改操作的时间复杂度如下:
- 增 :
- 在末尾追加元素:O(1)
- 在中间(非末尾)插入元素:O(N)
- 删 :
- 删除末尾元素:O(1)
- 删除中间(非末尾)元素:O(N)
- 查:给定指定索引,查询索引对应元素的值,时间复杂度 O(1)
- 改:给定指定索引,修改索引对应元素的值,时间复杂度 O(1)
动态数组底层仍然是静态数组,只是自动帮助我们进行数组空间的扩缩容,并将增删查改操作进行了封装,使我们使用起来更加便捷。
2. 链表(链式存储)基本原理
2.1 两种链表节点定义
java
class ListNode{
int val;
ListNode next;
ListNode(int x){
val = x;
}
}
class Node<E>{
E val;
Node<E> next;
Node<E> prev;
Node(Node<E> prev, E element, Node<E> next){
this.prev = prev;
this.val = element;
this.next = next;
}
}
2.2 单链表的基本操作
创建单链表:
java
class ListNode {
int val;
ListNode next;
ListNode(int x) { val = x; }
}
// 输入一个数组,转换为一条单链表
ListNode createLinkedList(int[] arr) {
if (arr == null || arr.length == 0) {
return null;
}
ListNode head = new ListNode(arr[0]);
ListNode cur = head;
for (int i = 1; i < arr.length; i++) {
cur.next = new ListNode(arr[i]);
cur = cur.next;
}
return head;
}
遍历 / 查 / 改
访问单链表的每一个节点,并打印其值:
java
// 创建一条单链表
ListNode head = createLinkedList(new int[]{1, 2, 3, 4, 5});
// 遍历单链表
for (ListNode p = head; p != null; p = p.next) {
System.out.println(p.val);
}
增
在单链表头部插入新元素:
java
// 创建一条单链表
ListNode head = createLinkedList(new int[]{1, 2, 3, 4, 5});
// 在单链表头部插入一个新节点 0
ListNode newHead = new ListNode(0);
newHead.next = head;
head = newHead;
// 现在链表变成了 0 -> 1 -> 2 -> 3 -> 4 -> 5
在单链表尾部插入新元素:
需要先从头节点开始遍历到链表的最后一个节点,然后才能在最后一个节点后面插入新节点:
java
// 创建一条单链表
ListNode head = createLinkedList(new int[]{1, 2, 3, 4, 5});
// 在单链表尾部插入一个新节点 6
ListNode p = head;
// 先走到链表的最后一个节点
while (p.next != null) {
p = p.next;
}
// 现在 p 就是链表的最后一个节点
// 在 p 后面插入新节点
p.next = new ListNode(6);
// 现在链表变成了 1 -> 2 -> 3 -> 4 -> 5 -> 6
在单链表中间插入新元素:
需要先找到要插入位置的前驱节点,然后操作前驱节点把新节点插入进去:
java
// 创建一条单链表
ListNode head = createLinkedList(new int[]{1, 2, 3, 4, 5});
// 在第 3 个节点后面插入一个新节点 66
// 先要找到前驱节点,即第 3 个节点
ListNode p = head;
for (int i = 0; i < 2; i++) {
p = p.next;
}
// 此时 p 指向第 3 个节点
// 组装新节点的后驱指针
ListNode newNode = new ListNode(66);
newNode.next = p.next;
// 插入新节点
p.next = newNode;
// 现在链表变成了 1 -> 2 -> 3 -> 66 -> 4 -> 5
删
在单链表中删除一个节点:
删除一个节点,首先要找到被删除节点的前驱节点,然后将该前驱节点的 next 指针指向被删除节点的下一个节点。这样就能把被删除节点从链表中摘除。
java
// 创建一条单链表
ListNode head = createLinkedList(new int[]{1, 2, 3, 4, 5});
// 删除第 4 个节点,要操作前驱节点
ListNode p = head;
for (int i = 0; i < 2; i++) {
p = p.next;
}
// 此时 p 指向第 3 个节点,即要删除节点的前驱节点
// 把第 4 个节点从链表中摘除
p.next = p.next.next;
// 现在链表变成了 1 -> 2 -> 3 -> 5
在单链表尾部删除元素:
这个操作比较简单,找到倒数第二个节点,然后把它的 next 指针置为 null 即可:
java
// 创建一条单链表
ListNode head = createLinkedList(new int[]{1, 2, 3, 4, 5});
// 删除尾节点
ListNode p = head;
// 找到倒数第二个节点
while (p.next.next != null) {
p = p.next;
}
// 此时 p 指向倒数第二个节点
// 把尾节点从链表中摘除
p.next = null;
// 现在链表变成了 1 -> 2 -> 3 -> 4
在单链表头部删除元素:
这个操作比较简单,直接把 head 移动到下一个节点即可:
java
// 创建一条单链表
ListNode head = createLinkedList(new int[]{1, 2, 3, 4, 5});
// 删除头结点
head = head.next;
// 现在链表变成了 2 -> 3 -> 4 -> 5
2.3 双链表的基本操作
创建双链表:
java
class DoublyListNode {
int val;
DoublyListNode next, prev;
DoublyListNode(int x) { val = x; }
}
DoublyListNode createDoublyLinkedList(int[] arr) {
if (arr == null || arr.length == 0) {
return null;
}
DoublyListNode head = new DoublyListNode(arr[0]);
DoublyListNode cur = head;
// for 循环迭代创建双链表
for (int i = 1; i < arr.length; i++) {
DoublyListNode newNode = new DoublyListNode(arr[i]);
cur.next = newNode;
newNode.prev = cur;
cur = cur.next;
}
return head;
}
遍历 / 查 / 改
对于双链表的遍历和查找,我们可以从头节点或尾节点开始,根据需要向前或向后遍历:
java
// 创建一条双链表
DoublyListNode head = createDoublyLinkedList(new int[]{1, 2, 3, 4, 5});
DoublyListNode tail = null;
// 从头节点向后遍历双链表
for (DoublyListNode p = head; p != null; p = p.next) {
System.out.println(p.val);
tail = p;
}
// 从尾节点向前遍历双链表
for (DoublyListNode p = tail; p != null; p = p.prev) {
System.out.println(p.val);
}
增
在双链表头部插入新元素:
在双链表头部插入元素,需要调整新节点和原头节点的指针:
java
// 创建一条双链表
DoublyListNode head = createDoublyLinkedList(new int[]{1, 2, 3, 4, 5});
// 在双链表头部插入新节点 0
DoublyListNode newHead = new DoublyListNode(0);
newHead.next = head;
head.prev = newHead;
head = newHead;
// 现在链表变成了 0 -> 1 -> 2 -> 3 -> 4 -> 5
在双链表尾部插入新元素:
在双链表尾部插入元素时,如果我们持有尾节点的引用,这个操作会更加简洁:
java
// 创建一条双链表
DoublyListNode head = createDoublyLinkedList(new int[]{1, 2, 3, 4, 5});
DoublyListNode tail = head;
// 先走到链表的最后一个节点
while (tail.next != null) {
tail = tail.next;
}
// 在双链表尾部插入新节点 6
DoublyListNode newNode = new DoublyListNode(6);
tail.next = newNode;
newNode.prev = tail;
// 更新尾节点引用
tail = newNode;
// 现在链表变成了 1 -> 2 -> 3 -> 4 -> 5 -> 6
在双链表中间插入新元素:
在双链表的指定位置插入新元素,需要调整前驱节点和后继节点的指针:
java
// 创建一条双链表
DoublyListNode head = createDoublyLinkedList(new int[]{1, 2, 3, 4, 5});
// 想要插入到索引 3(第 4 个节点)
// 需要操作索引 2(第 3 个节点)的指针
DoublyListNode p = head;
for (int i = 0; i < 2; i++) {
p = p.next;
}
// 组装新节点
DoublyListNode newNode = new DoublyListNode(66);
newNode.next = p.next;
newNode.prev = p;
// 插入新节点
p.next.prev = newNode;
p.next = newNode;
// 现在链表变成了 1 -> 2 -> 3 -> 66 -> 4 -> 5
删
在双链表中删除一个节点:
java
// 创建一条双链表
DoublyListNode head = createDoublyLinkedList(new int[]{1, 2, 3, 4, 5});
// 删除第 4 个节点
// 先找到第 3 个节点
DoublyListNode p = head;
for (int i = 0; i < 2; i++) {
p = p.next;
}
// 现在 p 指向第 3 个节点,将其后面那个节点摘除
DoublyListNode toDelete = p.next;
// 把 toDelete 从链表中摘除
p.next = toDelete.next;
toDelete.next.prev = p;
// 把 toDelete 的前后指针都置为 null 是个好习惯(可选)
toDelete.next = null;
toDelete.prev = null;
// 现在链表变成了 1 -> 2 -> 3 -> 5
在双链表头部删除元素:
在双链表头部删除元素需要调整头节点的指针:
java
// 创建一条双链表
DoublyListNode head = createDoublyLinkedList(new int[]{1, 2, 3, 4, 5});
// 删除头结点
DoublyListNode toDelete = head;
head = head.next;
head.prev = null;
// 清理已删除节点的指针
toDelete.next = null;
// 现在链表变成了 2 -> 3 -> 4 -> 5
在双链表尾部删除元素:
java
// 创建一条双链表
DoublyListNode head = createDoublyLinkedList(new int[]{1, 2, 3, 4, 5});
// 删除尾节点
DoublyListNode p = head;
// 找到尾结点
while (p.next != null) {
p = p.next;
}
// 现在 p 指向尾节点
// 把尾节点从链表中摘除
p.prev.next = null;
// 把被删结点的指针都断开是个好习惯(可选)
p.prev = null;
// 现在链表变成了 1 -> 2 -> 3 -> 4
3. 环形数组技巧
环形数组技巧 利用求模(余数)运算,将普通数组变成逻辑上的环形数组 ,可以让我们 用 O(1) 的时间在数组头部增删元素。
环形数组的关键在于,它维护了两个指针
start和end,start指向第一个有效元素的索引,end指向最后一个有效元素的下一个位置索引。这样,当我们在数组头部添加或删除元素时,只需要移动
start索引,而在数组尾部添加或删除元素时,只需要移动end索引。当
start, end移动超出数组边界(< 0或>= arr.length)时,我们可以通过求模运算%让它们转一圈到数组头部或尾部继续工作,这样就实现了环形数组的效果。
代码实现:
java
// 定义一个泛型类 CycleArray
public class CycleArray<T> {
private T[] arr;
private int start;
private int end;
private int count;
private int size;
public CycleArray() {
// 调用 CycleArray 类的另一个构造方法
this(1);
}
@SuppressWarnings("unchecked")
public CycleArray(int size) {
this.size = size;
// 因为 Java 不支持直接创建泛型数组,所以这里使用了类型转换
this.arr = (T[]) new Object[size];
// start 指向第一个有效元素的索引,闭区间
this.start = 0;
// 切记 end 是一个开区间,
// 即 end 指向最后一个有效元素的下一个位置索引
this.end = 0;
this.count = 0;
}
// 自动扩缩容辅助函数
@SuppressWarnings("unchecked")
private void resize(int newSize) {
// 创建新的数组
T[] newArr = (T[]) new Object[newSize];
// 将旧数组的元素复制到新数组中
for (int i = 0; i < count; i++) {
newArr[i] = arr[(start + i) % size];
}
arr = newArr;
// 重置 start 和 end 指针
start = 0;
end = count;
size = newSize;
}
// 在数组头部添加元素,时间复杂度 O(1)
public void addFirst(T val) {
// 当数组满时,扩容为原来的两倍
if (isFull()) {
resize(size * 2);
}
// 因为 start 是闭区间,所以先左移,再赋值
start = (start - 1 + size) % size;
arr[start] = val;
count++;
}
// 删除数组头部元素,时间复杂度 O(1)
public void removeFirst() {
if (isEmpty()) {
throw new IllegalStateException("Array is empty");
}
// 因为 start 是闭区间,所以先赋值,再右移
arr[start] = null;
start = (start + 1) % size;
count--;
// 如果数组元素数量减少到原大小的四分之一,则减小数组大小为一半
if (count > 0 && count == size / 4) {
resize(size / 2);
}
}
// 在数组尾部添加元素,时间复杂度 O(1)
public void addLast(T val) {
if (isFull()) {
resize(size * 2);
}
// 因为 end 是开区间,所以是先赋值,再右移
arr[end] = val;
end = (end + 1) % size;
count++;
}
// 删除数组尾部元素,时间复杂度 O(1)
public void removeLast() {
if (isEmpty()) {
throw new IllegalStateException("Array is empty");
}
// 因为 end 是开区间,所以先左移,再赋值
end = (end - 1 + size) % size;
arr[end] = null;
count--;
// 缩容
if (count > 0 && count == size / 4) {
resize(size / 2);
}
}
// 获取数组头部元素,时间复杂度 O(1)
public T getFirst() {
if (isEmpty()) {
throw new IllegalStateException("Array is empty");
}
return arr[start];
}
// 获取数组尾部元素,时间复杂度 O(1)
public T getLast() {
if (isEmpty()) {
throw new IllegalStateException("Array is empty");
}
// end 是开区间,指向的是下一个元素的位置,所以要减 1
return arr[(end - 1 + size) % size];
}
public boolean isFull() {
return count == size;
}
public int size() {
return count;
}
public boolean isEmpty() {
return count == 0;
}
}

- 数组头部添加元素,由于 start 是左闭的性质,所以需要先左移
(start - 1 + size) % size,后赋值。 - 数组尾部添加元素,由于 end 是右开的性质,所以需要先赋值,后右移
(end + 1) % size。 - 数组头部删除元素,由于 start 是左闭的性质,所以需要先赋值,后右移
(start + 1) % size。 - 数组尾部删除元素,由于 end 是右开的性质,所以需要先左移,后赋值
(end - 1 + size) % size。
二、哈希表
哈希表(Hash Table)又名散列表,是根据键(Key)直接访问在内存存储位置值(Value)的数据结构。它是由数组演化而来的,利用了数组支持按照下标进行随机访问数据的特性。


1. 核心原理
哈希表的底层实现就是一个数组(我们不妨称之为 table)。它先把 key 通过一个哈希函数(我们不妨称之为 hash)转化成数组里面的索引,然后增删查改操作和数组基本相同:
java
// 哈希表伪码逻辑
class MyHashMap {
private Object[] table;
// 增/改,复杂度 O(1)
public void put(K key, V value) {
int index = hash(key);
table[index] = value;
}
// 查,复杂度 O(1)
public V get(K key) {
int index = hash(key);
return table[index];
}
// 删,复杂度 O(1)
public void remove(K key) {
int index = hash(key);
table[index] = null;
}
// 哈希函数,把 key 转化成 table 中的合法索引
// 时间复杂度必须是 O(1),才能保证上述方法的复杂度都是 O(1)
private int hash(K key) {
// ...
}
}
如何把
key转化成整数?任意 Java 对象都有一个
int hashCode()方法。在实现自定义的类时,如果不重写该方法,其默认返回值可以认为是该对象的内存地址。一个对象的内存地址显然是全局唯一的一个整数,所以只要调用key的hashCode()方法就相当于把key转化成了一个整数,且这个整数是全局唯一的。如何保证索引合法?
javaint hash(K key){ int h = key.hashCode(); // 位运算,把最高位的符号位去掉 // 另外,位运算的运行速度也会比一般的算术运算快 // 所以标准库的源码中,能用位运算的地方都会优先使用位运算 h = h & 0x7fffffff; // 这个 0x7fffffff 的二进制表示是 0111 1111 ... 1111 // 即除了最高位(符号位)是 0,其他位都是 1 // 把 0x7fffffff 和其他 int 进行 & 运算之后,最高位(符号位)就会被清零,即保证了 h 是非负数 // 映射到 table 数组的合法索引 return h % table.length; }
2. 哈希冲突
如果两个不同的 key 通过哈希函数得到了相同的索引,这种情况就叫做「哈希冲突」。
哈希冲突的解决方法(哈希冲突不可避免 ):一种是 拉链法 ,另一种是 线性探查法 (也经常被叫做 开放寻址法)。说白了就是纵向延伸和横向延伸两种思路。
拉链法相当于哈希表的底层数组并不直接存储 value 类型,而是存储一个链表。当有多个不同的 key 映射到了同一个索引上,这些 key -> value 对就存储在这个链表中,这样就能解决哈希冲突的问题。
而线性探查法的思路是,一个 key 发现算出来的 index 值已经被别的 key 占了,那么它就去 index + 1 的位置看看,如果还是被占了,就继续往后找,直到找到一个空的位置为止。
比方说上图,key 的插入顺序是 k2, k4, k5, k3, k1,那么哈希表底层就会变成这样:

3. 用链表加强哈希表(LinkedHashMap)
我们现在希望在不改变哈希表增删查改复杂度的前提下,能够按照插入顺序来访问所有键,且不受扩缩容影响。那么一个最直接的思路就是,把这些键值对都用类似链表节点的结构串联起来,持有一个头尾节点 head, tail 的引用,每次把新的键插入 table 数组时,同时把这个键插入链表的尾部。这样一来,只要从头节点 head 开始遍历链表,就能按照插入顺序访问所有键了:

抽象来看,哈希表本质上就是一个键值映射,链表本质上就是一个顺序存储元素的容器。现在就是想让键值映射中的键按照插入顺序排列,把哈希表和链表结合起来:

代码实现:
java
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
public class MyLinkedHashMap<K, V> {
private static class Node<K, V> {
K key;
V val;
Node<K, V> next, prev;
Node(K key, V val) {
this.key = key;
this.val = val;
}
}
private final Node<K, V> head, tail;
private final HashMap<K, Node<K, V>> map = new HashMap<>();
public MyLinkedHashMap() {
head = new Node<>(null, null);
tail = new Node<>(null, null);
head.next = tail;
tail.prev = head;
}
public V get(K key) {
Node<K, V> node = map.get(key);
if (node != null) {
return node.val;
}
return null;
}
public void put(K key, V val) {
// 若为新插入的节点,则同时插入链表和 map
if (!map.containsKey(key)) {
// 插入新的 Node
Node<K, V> node = new Node<>(key, val);
addLastNode(node);
map.put(key, node);
return;
}
// 若存在,则替换之前的 val
map.get(key).val = val;
}
public void remove(K key) {
// 若 key 本不存在,直接返回
if (!map.containsKey(key)) {
return;
}
// 若 key 存在,则需要同时在哈希表和链表中删除
Node<K, V> node = map.get(key);
map.remove(key);
removeNode(node);
}
public boolean containsKey(K key) {
return map.containsKey(key);
}
public List<K> keys() {
List<K> keyList = new ArrayList<>();
for (Node<K, V> p = head.next; p != tail; p = p.next) {
keyList.add(p.key);
}
return keyList;
}
public int size() {
return map.size();
}
private void addLastNode(Node<K, V> x) {
Node<K, V> temp = tail.prev;
// temp <-> tail
x.next = tail;
x.prev = temp;
// temp <- x -> tail
temp.next = x;
tail.prev = x;
// temp <-> x <-> tail
}
private void removeNode(Node<K, V> x) {
Node<K, V> prev = x.prev;
Node<K, V> next = x.next;
// prev <-> x <-> next
prev.next = next;
next.prev = prev;
x.next = x.prev = null;
}
public static void main(String[] args) {
MyLinkedHashMap<String, Integer> map = new MyLinkedHashMap<>();
map.put("a", 1);
map.put("b", 2);
map.put("c", 3);
map.put("d", 4);
map.put("e", 5);
System.out.println(map.keys()); // [a, b, c, d, e]
map.remove("c");
System.out.println(map.keys()); // [a, b, d, e]
}
}
4. 用数组加强哈希表(ArrayHashMap)
如何用数组加强哈希表,轻松实现 randomKey() API。
思路:在哈希表中建立 数组元素到数组索引的映射关系,这样就能在 O(1) 的时间复杂度内找到数组元素对应的索引了。
javaprivate final HashMap<K, Integer> map = new HashMap<>(); private final ArrayList<Node<K, V>> arr = new ArrayList<>();map 建立数组元素到数组索引的映射关系,通过映射到的索引,去 ArrayList 中找到对应的
<K, V>值。
java
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Random;
public class MyArrayHashMap<K, V> {
private static class Node<K, V> {
K key;
V val;
Node(K key, V val) {
this.key = key;
this.val = val;
}
}
// 存储 key 和 key 在 arr 中的索引
private final HashMap<K, Integer> map = new HashMap<>();
// 真正存储 key-value 的数组
private final ArrayList<Node<K, V>> arr = new ArrayList<>();
private final Random r = new Random();
public V get(K key) {
if (!map.containsKey(key)) {
return null;
}
// 获取 key 在 map 中的索引
int index = map.get(key);
return arr.get(index).val;
}
public void put(K key, V val) {
if (containsKey(key)) {
// 修改
int i = map.get(key);
Node<K, V> node = arr.get(i);
node.val = val;
return;
}
// 新增
arr.add(new Node<>(key, val));
map.put(key, arr.size() - 1);
}
public void remove(K key) {
if (!map.containsKey(key)) {
return;
}
int index = map.get(key);
Node<K, V> node = arr.get(index);
// 1. 最后一个元素 e 和第 index 个元素 node 换位置
Node<K, V> e = arr.get(arr.size() - 1);
arr.set(index, e);
arr.set(arr.size() - 1, node);
// 2. 修改 map 中 e.key 对应的索引
map.put(e.key, index);
// 3. 在数组中删除最后一个元素
arr.remove(arr.size() - 1);
// 4. 在 map 中删除 node.key
map.remove(node.key);
}
// 随机弹出一个键
public K randomKey() {
int n = arr.size();
int randomIndex = r.nextInt(n);
return arr.get(randomIndex).key;
}
public boolean containsKey(K key) {
return map.containsKey(key);
}
public int size() {
return map.size();
}
public static void main(String[] args) {
MyArrayHashMap<Integer, Integer> map = new MyArrayHashMap<>();
map.put(1, 1);
map.put(2, 2);
map.put(3, 3);
map.put(4, 4);
map.put(5, 5);
System.out.println(map.get(1)); // 1
System.out.println(map.randomKey());
map.remove(4);
System.out.println(map.randomKey());
System.out.println(map.randomKey());
}
}
三、二叉树
1. 常见二叉树
从根节点到最下方叶子节点经过的节点个数为二叉树的最大深度(高度)。
1.1 满二叉树
满二叉树就是每一层节点都是满的,整棵树像一个正三角形:

满二叉树有个优势,就是它的节点个数很好算 。假设深度为 h,那么总节点数就是 2^h - 1。
1.2 完全二叉树
完全二叉树是指,二叉树的每一层的节点都紧凑靠左排列,且除了最后一层,其他每层都必须是满的:

完全二叉树的特点:由于它的节点紧凑排列,如果从左到右从上到下对它的每个节点编号,那么父子节点的索引存在明显的规律。
完全二叉树的左右子树也是完全二叉树 (完全二叉树的左右子树中,至少有一棵是满二叉树):

1.3 平衡二叉树
平衡二叉树(Balanced Binary Tree)是一种特殊的二叉树,它的每个节点的左右子树的高度差不超过 1。
java
1
/ \
2 3
/ / \
4 5 6
\
7
1.4 二叉搜索树
对于树中的每个节点,其 左子树的每个节点 的值都要小于这个节点的值,右子树的每个节点 的值都要大于这个节点的值。可以简单记为「左小右大」。二叉搜索树(BST)的中序遍历结果是有序的,这是 BST 的一个重要性质。
1.5 二叉树的实现
java
class TreeNode {
int val;
TreeNode left;
TreeNode right;
TreeNode(int x) { this.val = x; }
}
// 可以这样构建一棵二叉树:
TreeNode root = new TreeNode(1);
root.left = new TreeNode(2);
root.right = new TreeNode(3);
root.left.left = new TreeNode(4);
root.right.left = new TreeNode(5);
root.right.right = new TreeNode(6);
// 构建出来的二叉树是这样的:
// 1
// / \
// 2 3
// / / \
// 4 5 6
其他实现方式:在二叉堆和并查集的实现中,我们会根据具体的需求场景选择用数组来存储二叉树。
在一般的算法题中,我们可能会把实际问题 抽象 成二叉树结构,但并不需要真的用 TreeNode 创建一棵二叉树出来,而是可以直接用哈希表的结构来表示二叉树或多叉树。可以用一个哈希表,其中的键是父节点 id,值是子节点 id 的列表(每个节点的 id 是唯一的),那么一个键值对就是一个多叉树节点了:
java
// 1 -> [2, 3]
// 2 -> [4]
// 3 -> [5, 6]
HashMap<Integer, List<Integer>> tree = new HashMap<>();
tree.put(1, Arrays.asList(2, 3));
tree.put(2, Collections.singletonList(4));
tree.put(3, Arrays.asList(5, 6));
2. 二叉树的递归/层序遍历
二叉树只有 递归遍历 和 层序遍历 这两种,再无其他。递归遍历可以衍生出 DFS 算法,层序遍历可以衍生出 BFS 算法。
递归遍历二叉树节点的顺序是固定的,但是有三个关键位置(前序遍历、中序遍历、后序遍历),在不同位置插入代码,会产生不同的效果。层序遍历二叉树节点的顺序也是固定的,但是有三种不同的写法,对应不同的场景。
2.1 递归遍历(DFS)
代码模版:
java
// 基本的二叉树节点
class TreeNode {
int val;
TreeNode left, right;
}
/// 二叉树的遍历框架
void traverse(TreeNode root) {
if (root == null) {
return;
}
// 前序位置
traverse(root.left);
// 中序位置
traverse(root.right);
// 后序位置
}

遍历顺序:enter 1 -> enter 2 -> enter 7 -> leave 7 -> enter 4 -> leave 4 -> leave 2 -> enter 3 -> enter 5 -> leave 5 -> enter 6 -> leave 6 -> leave 3 -> leave 1(1,2,7,4,3,5,6)
traverse 函数的遍历顺序就是一直往左子节点走,直到遇到空指针不能再走了,才尝试往右子节点走一步;然后再一直尝试往左子节点走,如此循环;如果左右子树都走完了,则返回上一层父节点。
递归遍历节点的顺序 仅取决于左右子节点的递归调用顺序,与其他代码无关。
2.2 理解前/中/后序遍历
递归遍历的顺序,即 traverse 函数访问节点的顺序确实是固定的(root 指针在树上移动的顺序是固定的)。但是,在 traverse 函数中不同位置写代码,效果是可以不一样的。前中后序遍历的结果不同,原因是因为把代码写在了不同位置,所以产生了不同的效果。
前序遍历:1,2,7,4,3,5,6
中序遍历:7,2,4,1,5,3,6
后序遍历:7,4,2,5,6,3,1
三种位置的关键区别在于执行时机不同。前序位置的代码会在进入节点时立即执行;中序位置的代码会在左子树遍历完成后、遍历右子树之前执行;后序位置的代码会在左右子树遍历完成后执行。
2.3 层序遍历(BFS)
- 写法一
java
void levelOrderTraverse(TreeNode root) {
if (root == null) {
return;
}
Queue<TreeNode> q = new LinkedList<>();
q.offer(root);
while (!q.isEmpty()) {
TreeNode cur = q.poll();
// 访问 cur 节点
System.out.println(cur.val);
// 把 cur 的左右子节点加入队列
if (cur.left != null) {
q.offer(cur.left);
}
if (cur.right != null) {
q.offer(cur.right);
}
}
}
这种写法的最大优势就是简单。每次把队头元素拿出来,然后把它的左右子节点加入队列即可。
但是这种写法的缺点是,无法知道当前节点在第几层。知道节点的层数是个常见的需求,比方说让你收集每一层的节点,或者计算二叉树的最小深度等。所以这种写法虽然 简单,但用得不多。
- 写法二(可以记录下来每个节点所在的层数 ,可以解决诸如二叉树最小深度这样的问题,是 最常用的层序遍历写法)
java
void levelOrderTraverse(TreeNode root) {
if (root == null) {
return;
}
Queue<TreeNode> q = new LinkedList<>();
q.offer(root);
// 记录当前遍历到的层数(根节点视为第 1 层)
int depth = 1;
while (!q.isEmpty()) {
int sz = q.size();
for (int i = 0; i < sz; i++) {
TreeNode cur = q.poll();
// 访问 cur 节点,同时知道它所在的层数
System.out.println("depth = " + depth + ", val = " + cur.val);
// 把 cur 的左右子节点加入队列
if (cur.left != null) {
q.offer(cur.left);
}
if (cur.right != null) {
q.offer(cur.right);
}
}
depth++;
}
}
注意队列的长度 sz 一定要在循环开始前保存下来 ,因为在循环过程中队列的长度是会变化的,不能直接用 q.size() 作为循环条件。
- 写法三
写法三在写法一的基础上添加一个 State 类,让每个节点自己负责维护自己的路径权重和:
java
class State {
TreeNode node;
int depth;
State(TreeNode node, int depth) {
this.node = node;
this.depth = depth;
}
}
void levelOrderTraverse(TreeNode root) {
if (root == null) {
return;
}
Queue<State> q = new LinkedList<>();
// 根节点的路径权重和是 1
q.offer(new State(root, 1));
while (!q.isEmpty()) {
State cur = q.poll();
// 访问 cur 节点,同时知道它的路径权重和
System.out.println("depth = " + cur.depth + ", val = " + cur.node.val);
// 把 cur 的左右子节点加入队列
if (cur.node.left != null) {
q.offer(new State(cur.node.left, cur.depth + 1));
}
if (cur.node.right != null) {
q.offer(new State(cur.node.right, cur.depth + 1));
}
}
}
这样每个节点都有了自己的 depth 变量,是最灵活的,可以满足所有 BFS 算法的需求。
3. 多叉树的递归/层序遍历
多叉树结构就是二叉树结构的延伸,二叉树是特殊的多叉树。森林是指多个多叉树的集合。
java
// 多叉树节点定义
class Node{
int val;
List<Node> children;
}
3.1 递归遍历(DFS)
java
// 二叉树的遍历框架
void traverse(TreeNode root) {
if (root == null) {
return;
}
// 前序位置
traverse(root.left);
// 中序位置
traverse(root.right);
// 后序位置
}
// N 叉树的遍历框架
void traverse(Node root) {
if (root == null) {
return;
}
// 前序位置
for (Node child : root.children) {
traverse(child);
}
// 后序位置
}
3.2 层序遍历(BFS)
- 写法一:无法记录节点深度。
java
void levelOrderTraverse(Node root) {
if (root == null) {
return;
}
Queue<Node> q = new LinkedList<>();
q.offer(root);
while (!q.isEmpty()) {
Node cur = q.poll();
// 访问 cur 节点
System.out.println(cur.val);
// 把 cur 的所有子节点加入队列
for (Node child : cur.children) {
q.offer(child);
}
}
}
- 写法二:能够记录节点深度。
java
void levelOrderTraverse(Node root) {
if (root == null) {
return;
}
Queue<Node> q = new LinkedList<>();
q.offer(root);
// 记录当前遍历到的层数(根节点视为第 1 层)
int depth = 1;
while (!q.isEmpty()) {
int sz = q.size();
for (int i = 0; i < sz; i++) {
Node cur = q.poll();
// 访问 cur 节点,同时知道它所在的层数
System.out.println("depth = " + depth + ", val = " + cur.val);
for (Node child : cur.children) {
q.offer(child);
}
}
depth++;
}
}
- 写法三:能够适配不同权重边。
java
class State {
Node node;
int depth;
public State(Node node, int depth) {
this.node = node;
this.depth = depth;
}
}
void levelOrderTraverse(Node root) {
if (root == null) {
return;
}
Queue<State> q = new LinkedList<>();
// 记录当前遍历到的层数(根节点视为第 1 层)
q.offer(new State(root, 1));
while (!q.isEmpty()) {
State state = q.poll();
Node cur = state.node;
int depth = state.depth;
// 访问 cur 节点,同时知道它所在的层数
System.out.println("depth = " + depth + ", val = " + cur.val);
for (Node child : cur.children) {
q.offer(new State(child, depth + 1));
}
}
}
4. DFS 和 BFS 的适用场景
在实际的算法问题中,DFS 算法常用来穷举所有路径,BFS 算法常用来寻找最短路径。二叉树的递归遍历和层序遍历就是最简单的 DFS 算法和 BFS 算法。
4.1 为什么 BFS 常用来寻找最短路径
由于 BFS 逐层遍历的逻辑,第一次遇到目标节点时,所经过的路径就是最短路径,算法可能并不需要遍历完所有节点就能提前结束。DFS 遍历当然也可以用来寻找最短路径,但必须遍历完所有节点才能得到最短路径。
- 二叉树的最小深度(递归遍历)
java
class Solution {
// 记录最小深度(根节点到最近的叶子节点的距离)
private int minDepth = Integer.MAX_VALUE;
// 记录当前遍历到的节点深度
private int currentDepth = 0;
public int minDepth(TreeNode root) {
if (root == null) {
return 0;
}
// 从根节点开始 DFS 遍历
traverse(root);
return minDepth;
}
private void traverse(TreeNode root) {
if (root == null) {
return;
}
// 前序位置进入节点时增加当前深度
currentDepth++;
// 如果当前节点是叶子节点,更新最小深度
if (root.left == null && root.right == null) {
minDepth = Math.min(minDepth, currentDepth);
}
traverse(root.left);
traverse(root.right);
// 后序位置离开节点时减少当前深度
currentDepth--;
}
}
- 二叉树的最小深度(层序遍历)
java
class Solution {
public int minDepth(TreeNode root) {
if (root == null) return 0;
Queue<TreeNode> q = new LinkedList<>();
q.offer(root);
// root 本身就是一层,depth 初始化为 1
int depth = 1;
while (!q.isEmpty()) {
int sz = q.size();
// 遍历当前层的节点
for (int i = 0; i < sz; i++) {
TreeNode cur = q.poll();
// 判断是否到达叶子结点
if (cur.left == null && cur.right == null)
return depth;
// 将下一层节点加入队列
if (cur.left != null)
q.offer(cur.left);
if (cur.right != null)
q.offer(cur.right);
}
// 这里增加步数
depth++;
}
return depth;
}
}
4.2 为什么 DFS 常用来寻找所有路径
代码如 4.1 所示。
5. 二叉搜索树的应用
二叉搜索树(BST)是特殊的二叉树结构,其主要的实际应用是 TreeMap 和 TreeSet。左小右大的特性,可以让我们在 BST 中快速找到某个节点,或者找到某个范围内的所有节点,这是 BST 的优势所在。
HashMap 底层把键值对存储在一个 table 数组里面,而 TreeMap 底层把键值对存储在一棵二叉搜索树的节点里面。
java
// 大写 K 为键的类型,大写 V 为值的类型
class TreeNode<K, V> {
K key;
V value;
TreeNode<K, V> left;
TreeNode<K, V> right;
TreeNode(K key, V value) {
this.key = key;
this.value = value;
}
}
二叉搜索树的性能取决于树的高度,树的高度取决于树的平衡性。
四、二叉堆
二叉堆是一种能够动态排序的数据结构,是二叉树结构的延伸。
二叉堆的关键应用是优先级队列
PriorityQueue。1、优先级队列是一种能够自动排序的数据结构,增删元素的复杂度是 O(log N),底层使用二叉堆实现。
2、二叉堆是一种拥有特殊性质的完全二叉树。
3、优先级队列(二叉堆)的核心方法是
swim, sink,用来维护二叉堆的性质。
1. 性质

二叉堆就是一种能够动态排序的数据结构。所谓动态排序,就是说我们可以不断往数据结构里面添加或删除元素,数据结构会自动调整元素的位置,使得我们可以有序地从数据结构中读取元素。
能动态排序的常用数据结构其实只有两个,一个是优先级队列(底层用二叉堆实现),另一个是二叉搜索树。
- 二叉堆是一种特殊的二叉树,这棵二叉树上的任意节点的值,都必须大于等于(或小于等于)其左右 子树所有节点 的值。如果是大于等于,称之为「大顶堆」;如果是小于等于,称之为「小顶堆」。
- 对于小顶堆,每个节点下方的所有节点的值都比它大,那么不难想象根节点就是整棵树上的最小值。同理,大顶堆的根节点就是整棵树上的最大值。所以二叉堆可以辅助我们快速找到最大值或最小值。
- 一个二叉堆的左右子堆(子树)也是一个二叉堆。
2. 优先级队列
java
class MyPriorityQueue {
// 在二叉堆堆顶插入一个元素,时间复杂度 O(logN)
// N 为当前二叉堆中的元素个数
void push(int x);
// 返回堆顶元素,时间复杂度 O(1)
// 该堆顶元素就是二叉堆中的最大值或最小值,取决于是最大堆还是最小堆
int peek();
// 删除堆顶元素,时间复杂度 O(logN)
int pop();
// 返回堆中元素的个数,时间复杂度 O(1)
int size();
}
3. 堆排序
原理:把一个乱序的数组都 push 到一个二叉堆(优先级队列)里面,然后再一个个 pop 出来,就得到了一个有序的数组。
java
// 堆排序伪码,对 arr 原地排序
// 时间复杂度 O(NlogN),空间复杂度 O(N)
int[] heapSort(int[] arr) {
int[] res = new int[arr.length];
MyPriorityQueue pq = new MyPriorityQueue();
for (int x : arr)
pq.push(x);
// 元素出堆的顺序是有序的
for (int i = 0; i < arr.length; i++)
res[i] = pq.pop();
return res;
}
4. 二叉堆/优先级队列代码实现
4.1 简化版优先级队列
实现的这个简化版优先级队列有如下限制:
1、不支持泛型,仅支持存储整数类型的元素。
2、不考虑扩容的问题,队列的容量在创建时固定,假设插入的元素数量不会超过这个容量。
3、底层仅实现一个小顶堆(即根节点是整个堆中的最小值),不支持自定义比较器。
java
class SimpleMinPQ {
// 创建一个容量为 capacity 的优先级队列
public SimpleMinPQ(int capacity);
// 返回队列中的元素个数
public int size();
// 向队列中插入一个元素
public void push(int x);
// 返回队列中的最小元素(堆顶元素)
public int peek();
// 删除并返回队列中的最小元素(堆顶元素)
public int pop();
}
// 使用方法
SimpleMinPQ pq = new SimpleMinPQ(10);
pq.push(3);
pq.push(4);
pq.push(1);
pq.push(2);
System.out.println(pq.pop()); // 1
System.out.println(pq.pop()); // 2
System.out.println(pq.pop()); // 3
System.out.println(pq.pop()); // 4
4.2 增:push/swim 方法插入元素
以小顶堆为例,向小顶堆中插入新元素遵循两个步骤:
1、先把新元素追加到二叉树底层的最右侧,保持完全二叉树的结构。此时该元素的父节点可能比它大,不满足小顶堆的性质。
2、为了恢复小顶堆的性质,需要将这个新元素不断上浮(
swim),直到它的父节点比它小为止,或者到达根节点。此时整个二叉树就满足小顶堆的性质了。
4.3 pop/sink 方法删除元素
以小顶堆为例,删除小顶堆的堆顶元素遵循两个步骤:
1、先把堆顶元素删除,把二叉树底层的最右侧元素摘除并移动到堆顶,保持完全二叉树的结构。此时堆顶元素可能比它的子节点大,不满足小顶堆的性质。
2、为了恢复小顶堆的性质,需要将这个新的堆顶元素不断下沉(
sink),直到它比它的子节点小为止,或者到达叶子节点。此时整个二叉树就满足小顶堆的性质了。
4.4 查:peek 方法查看堆顶元素
直接返回根节点的值即可。
4.5 在数组上模拟二叉树
之前都把二叉堆作为一种二叉树,但是实际上在代码实现时,不会用类似 HeapNode 的节点类来实现,而是用数组来模拟二叉树的结构。原因如下:
- 第一个原因是链表节点需要一个额外的指针存储相邻节点的地址,所以相对数组,链表的内存消耗会大一些。
- 第二个原因,也是最主要的原因,是时间复杂度的问题 。仔细想一下前面展示的
push和pop方法的操作过程,它们的第一步是什么?是不是要找到二叉树最底层的最右侧元素?正常情况下你需要层序遍历或递归遍历二叉树,时间复杂度是 O(N),进而导致push和pop方法的时间复杂度退化到 O(N),这显然是不可接受的。
如果用数组来模拟二叉树,就可以完美解决这个问题,在 O(1) 时间内找到二叉树的底层最右侧节点。
想要用数组模拟二叉树,前提是这个二叉树必须是 完全二叉树。
直接在数组的末尾追加元素,就相当于在完全二叉树的最后一层从左到右依次填充元素;数组中最后一个元素,就是完全二叉树的底层最右侧的元素,完美契合实现二叉堆的场景。

在这个数组中,索引 0 空着不用,就可以根据任意节点的索引计算出父节点或左右子节点的索引:
java
// 父节点的索引
int parent(int node) {
return node / 2;
}
// 左子节点的索引
int left(int node) {
return node * 2;
}
// 右子节点的索引
int right(int node) {
return node * 2 + 1;
}
从 0 开始也是可以的,稍微改一改计算公式:

java
// 父节点的索引
int parent(int node) {
return (node - 1) / 2;
}
// 左子节点的索引
int left(int node) {
return node * 2 + 1;
}
// 右子节点的索引
int right(int node) {
return node * 2 + 2;
}
4.6 简化版优先级队列实现
下面是一个简化版的小顶堆优先级队列核心逻辑的实现,没有特别处理边界情况,供参考:
java
public class SimpleMinPQ {
// 底层使用数组实现二叉堆
private final int[] heap;
// 堆中元素的数量
private int size;
public SimpleMinPQ(int capacity) {
heap = new int[capacity];
size = 0;
}
public int size() {
return size;
}
// 父节点的索引
private int parent(int node) {
return (node - 1) / 2;
}
// 左子节点的索引
private int left(int node) {
return node * 2 + 1;
}
// 右子节点的索引
private int right(int node) {
return node * 2 + 2;
}
// 交换数组的两个元素
private void swap(int i, int j) {
int temp = heap[i];
heap[i] = heap[j];
heap[j] = temp;
}
// 查,返回堆顶元素,时间复杂度 O(1)
public int peek() {
return heap[0];
}
// 增,向堆中插入一个元素,时间复杂度 O(logN)
public void push(int x) {
// 把新元素追加到最后
heap[size] = x;
// 然后上浮到正确位置
swim(size);
size++;
}
// 删,删除堆顶元素,时间复杂度 O(logN)
public int pop() {
int res = heap[0];
// 把堆底元素放到堆顶
heap[0] = heap[size - 1];
size--;
// 然后下沉到正确位置
sink(0);
return res;
}
// 上浮操作,时间复杂度是树高 O(logN)
private void swim(int node) {
while (node > 0 && heap[parent(node)] > heap[node]) {
swap(parent(node), node);
node = parent(node);
}
}
// 下沉操作,时间复杂度是树高 O(logN)
private void sink(int node) {
while (left(node) < size || right(node) < size) {
// 比较自己和左右子节点,看看谁最小
int min = node;
if (left(node) < size && heap[left(node)] < heap[min]) {
min = left(node);
}
if (right(node) < size && heap[right(node)] < heap[min]) {
min = right(node);
}
if (min == node) {
break;
}
// 如果左右子节点中有比自己小的,就交换
swap(node, min);
node = min;
}
}
public static void main(String[] args) {
SimpleMinPQ pq = new SimpleMinPQ(5);
pq.push(3);
pq.push(2);
pq.push(1);
pq.push(5);
pq.push(4);
System.out.println(pq.pop()); // 1
System.out.println(pq.pop()); // 2
System.out.println(pq.pop()); // 3
System.out.println(pq.pop()); // 4
System.out.println(pq.pop()); // 5
}
}
明白了这个 SimpleMinPQ 类的实现,如果想实现一个大顶堆 SimpleMaxPQ,只需要把 swim 和 sink 方法中元素大小比较的逻辑反过来即可。
4.7 完善版优先级队列
基于上面的简化版优先级队列,只要加上泛型、自定义比较器、扩容等功能,就可以实现一个比较完善的优先级队列了:
java
import java.util.Comparator;
import java.util.NoSuchElementException;
public class MyPriorityQueue<T> {
private T[] heap;
private int size;
private final Comparator<? super T> comparator;
@SuppressWarnings("unchecked")
public MyPriorityQueue(int capacity, Comparator<? super T> comparator) {
heap = (T[]) new Object[capacity];
size = 0;
this.comparator = comparator;
}
public int size() {
return size;
}
public boolean isEmpty() {
return size == 0;
}
// 父节点的索引
private int parent(int node) {
return (node - 1) / 2;
}
// 左子节点的索引
private int left(int node) {
return node * 2 + 1;
}
// 右子节点的索引
private int right(int node) {
return node * 2 + 2;
}
// 交换数组的两个元素
private void swap(int i, int j) {
T temp = heap[i];
heap[i] = heap[j];
heap[j] = temp;
}
// 查,返回堆顶元素,时间复杂度 O(1)
public T peek() {
if (isEmpty()) {
throw new NoSuchElementException("Priority queue underflow");
}
return heap[0];
}
// 增,向堆中插入一个元素,时间复杂度 O(logN)
public void push(T x) {
// 扩容
if (size == heap.length) {
resize(2 * heap.length);
}
// 把新元素追加到最后
heap[size] = x;
// 然后上浮到正确位置
swim(size);
size++;
}
// 删,删除堆顶元素,时间复杂度 O(logN)
public T pop() {
if (isEmpty()) {
throw new NoSuchElementException("Priority queue underflow");
}
T res = heap[0];
// 把堆底元素放到堆顶
swap(0, size - 1);
// 避免对象游离
heap[size - 1] = null;
size--;
// 然后下沉到正确位置
sink(0);
// 缩容
if ((size > 0) && (size == heap.length / 4)) {
resize(heap.length / 2);
}
return res;
}
// 上浮操作,时间复杂度是树高 O(logN)
private void swim(int node) {
while (node > 0 && comparator.compare(heap[parent(node)], heap[node]) > 0) {
swap(parent(node), node);
node = parent(node);
}
}
// 下沉操作,时间复杂度是树高 O(logN)
private void sink(int node) {
while (left(node) < size || right(node) < size) {
// 比较自己和左右子节点,看看谁最小
int min = node;
if (left(node) < size && comparator.compare(heap[left(node)], heap[min]) < 0) {
min = left(node);
}
if (right(node) < size && comparator.compare(heap[right(node)], heap[min]) < 0) {
min = right(node);
}
if (min == node) {
break;
}
// 如果左右子节点中有比自己小的,就交换
swap(node, min);
node = min;
}
}
// 调整堆的大小
@SuppressWarnings("unchecked")
private void resize(int capacity) {
assert capacity > size;
T[] temp = (T[]) new Object[capacity];
for (int i = 0; i < size; i++) {
temp[i] = heap[i];
}
heap = temp;
}
public static void main(String[] args) {
MyPriorityQueue<Integer> pq = new MyPriorityQueue<>(3, Comparator.naturalOrder());
pq.push(3);
pq.push(1);
pq.push(4);
pq.push(1);
pq.push(5);
pq.push(9);
// 1 1 3 4 5 9
while (!pq.isEmpty()) {
System.out.println(pq.pop());
}
}
}
五、线段树
线段树是二叉树结构的衍生,用于高效解决数组的 区间查询和区间动态修改 问题。
线段树可以在 O(log N) 的时间复杂度查询 任意长度 的区间元素聚合值,在 O(log N) 的时间复杂度对 任意长度 的区间元素进行动态修改,其中 N 为数组中的元素个数。
基本的线段树包含区间查询 query 和 单点修改 update 方法。

可以看到这棵二叉树的叶子节点是数组中的元素,非叶子节点就是索引区间(线段)的汇总信息,也就是「线段树」这个名字的由来。
但上面这个线段树有个问题,就是必须输入 nums 数组进行构建,如果想在一个非常长的区间上进行区间操作,比如 [0, 10^9],那么就需要 10 9 10^9 109 的空间复杂度构建线段树,这是非常浪费的。
动态线段树的实现运用「动态开点」技巧优化线段树处理稀疏数据的内存开支。

上面的实现都只支持「单点更新」,但更通用的需求是 区间更新 ,比如把索引区间 [i, j] 的元素都更新为 val。懒更新线段树的实现运用「懒更新」技巧,给线段树新增 rangeAdd/rangeUpdate 方法,可以在 O(log N) 时间复杂度内完成 任意长度 的区间更新。
1. 使用场景
计算 nums 数组中从索引 i 开始到末尾的最小值(预计算的思路):
java
int[] nums = new int[]{3, 1, 4, 2};
// suffixMin [i] 表示 nums [i..] 中的最小值
int[] suffixMin = new int[nums.length];
// 从后往前计算 suffixMin
suffixMin[nums.length - 1] = nums[nums.length - 1];
for (int i = nums.length - 2; i >= 0; i--) {
suffixMin[i] = Math.min(nums[i], suffixMin[i + 1]);
}
// [1, 1, 2, 2]
System.out.println(suffixMin);
// 有了 suffixMin 数组,可以在 O(1) 时间内查询任意 nums [0..i] 后缀的最小值
// 查询 nums [1..] 的最小值
System.out.println(suffixMin[1]); // 1
但这个技巧有它的局限性,即 nums 数组本身不能变化。一旦 nums[i] 变化了,那么 suffixMin[0..i] 的值都会失效,需要 O(N) 的时间复杂度重新计算 suffixMin 数组。
对于这种希望对整个区间进行查询,同时支持动态修改元素的场景,就是线段树结构的应用场景。
2. 核心 API
java
class SegmentTree {
// 构造函数,给定一个数组,初始化线段树,时间复杂度 O(N)
// merge 是一个函数,用于定义 query 方法的行为
// 通过修改这个函数,可以让 query 函数返回区间的元素和、最大值、最小值等
public SegmentTree(int[] nums, Function<Integer, Integer> merge) {}
// 查询闭区间 [i, j] 的元素和(也可能是最大最小值,取决于 merge 函数),时间复杂度 O(logN)
public int query(int i, int j) {}
// 更新 nums [i] = val,时间复杂度 O(logN)
public void update(int i, int val) {}
}
3. 万能线段树模版
java
class AllInOneSegmentTree {
static class SegmentNode {
int l, r;
int mergeValue;
SegmentNode left, right;
// 累加懒标记, 为 0 表示无懒更新
int lazyAdd;
// 赋值懒标记
int lazyAssign;
boolean hasLazyAssign;
public SegmentNode(int l, int r, int mergeValue) {
this.l = l;
this.r = r;
this.mergeValue = mergeValue;
this.lazyAdd = 0;
this.lazyAssign = 0;
this.hasLazyAssign = false;
}
}
private final SegmentNode root;
private final int defaultValue;
// "sum", "max" or "min"
private final String type;
public AllInOneSegmentTree(int start, int end, int defaultValue, String type) {
if (type.equals("sum") || type.equals("max") || type.equals("min")) {
this.type = type;
} else {
throw new IllegalArgumentException("Invalid type, must be sum, max, or min");
}
this.defaultValue = defaultValue;
int rootMergeValue = computeRangeValue(start, end, defaultValue);
this.root = new SegmentNode(start, end, rootMergeValue);
}
// 计算区间 [l, r] 赋值为 val 后的 mergeValue
private int computeRangeValue(int l, int r, int val) {
// 如果类型为求和,则返回区间长度乘以 val
if (type.equals("sum")) {
return (r - l + 1) * val;
} else {
// 如果类型为求最大值或最小值,则返回 val
return val;
}
}
// 根据区间长度更新 mergeValue,加上 delta
private int applyAddToValue(SegmentNode node, int delta) {
if (type.equals("sum")) {
return node.mergeValue + (node.r - node.l + 1) * delta;
} else {
// 如果类型为求最大值或最小值,则返回当前值加上 delta
return node.mergeValue + delta;
}
}
// 根据类型合并左右子节点的值
private int merge(int leftVal, int rightVal) {
if (type.equals("sum")) {
return leftVal + rightVal;
} else if (type.equals("max")) {
return Math.max(leftVal, rightVal);
} else if (type.equals("min")) {
return Math.min(leftVal, rightVal);
}
throw new IllegalArgumentException("Invalid type");
}
// 动态创建线段树节点
private void initChildrenIfNeeded(SegmentNode node) {
if (node.l == node.r) {
return;
}
int mid = node.l + (node.r - node.l) / 2;
if (node.left == null) {
int leftMergeValue = computeRangeValue(node.l, mid, defaultValue);
node.left = new SegmentNode(node.l, mid, leftMergeValue);
}
if (node.right == null) {
int rightMergeValue = computeRangeValue(mid + 1, node.r, defaultValue);
node.right = new SegmentNode(mid + 1, node.r, rightMergeValue);
}
}
// 下传懒标记,保证子节点的数据正确
private void pushDown(SegmentNode node) {
// 如果存在赋值懒标记,优先下传赋值
if (node.hasLazyAssign) {
applyAssign(node.left, node.lazyAssign);
applyAssign(node.right, node.lazyAssign);
node.hasLazyAssign = false;
node.lazyAssign = 0;
}
// 下传累加懒标记
if (node.lazyAdd != 0) {
applyAdd(node.left, node.lazyAdd);
applyAdd(node.right, node.lazyAdd);
node.lazyAdd = 0;
}
}
// 将赋值懒标记下传到子节点
private void applyAssign(SegmentNode child, int val) {
child.hasLazyAssign = true;
child.lazyAssign = val;
// 清除累加懒标记
child.lazyAdd = 0;
child.mergeValue = computeRangeValue(child.l, child.r, val);
}
// 将累加懒标记下传到子节点
private void applyAdd(SegmentNode child, int delta) {
if (child.hasLazyAssign) {
// 如果子节点已有赋值懒标记,则直接更新该赋值
child.lazyAssign += delta;
child.mergeValue = computeRangeValue(child.l, child.r, child.lazyAssign);
} else {
// 如果子节点没有赋值懒标记,则更新累加懒标记
child.lazyAdd += delta;
child.mergeValue = applyAddToValue(child, delta);
}
}
// 单点赋值:将索引 index 赋值为 val
public void update(int index, int val) {
// 直接复用区间赋值方法
rangeUpdate(index, index, val);
}
// 区间赋值:将闭区间 [qL, qR] 赋值为 val
public void rangeUpdate(int qL, int qR, int val) {
_rangeUpdate(root, qL, qR, val);
}
private void _rangeUpdate(SegmentNode node, int qL, int qR, int val) {
if (node.r < qL || node.l > qR) {
throw new IllegalArgumentException("Invalid update range");
}
// 当前节点完全包含于更新区间内
if (qL <= node.l && node.r <= qR) {
node.hasLazyAssign = true;
node.lazyAssign = val;
node.lazyAdd = 0;
node.mergeValue = computeRangeValue(node.l, node.r, val);
return;
}
initChildrenIfNeeded(node);
pushDown(node);
int mid = node.l + (node.r - node.l) / 2;
if (qR <= mid) {
_rangeUpdate(node.left, qL, qR, val);
} else if (qL > mid) {
_rangeUpdate(node.right, qL, qR, val);
} else {
_rangeUpdate(node.left, qL, mid, val);
_rangeUpdate(node.right, mid + 1, qR, val);
}
node.mergeValue = merge(node.left.mergeValue, node.right.mergeValue);
}
// 单点累加:将索引 index 增加 delta(可为负数)
public void add(int index, int delta) {
// 直接复用区间累加方法
rangeAdd(index, index, delta);
}
// 区间累加:将闭区间 [qL, qR] 增加 delta(可为负数)
public void rangeAdd(int qL, int qR, int delta) {
_rangeAdd(root, qL, qR, delta);
}
private void _rangeAdd(SegmentNode node, int qL, int qR, int delta) {
if (node.r < qL || node.l > qR) {
throw new IllegalArgumentException("Invalid update range");
}
if (qL <= node.l && node.r <= qR) {
if (node.hasLazyAssign) {
// 若已有赋值懒标记,则更新赋值值
node.lazyAssign += delta;
node.mergeValue = computeRangeValue(node.l, node.r, node.lazyAssign);
} else {
node.lazyAdd += delta;
node.mergeValue = applyAddToValue(node, delta);
}
return;
}
initChildrenIfNeeded(node);
pushDown(node);
int mid = node.l + (node.r - node.l) / 2;
if (qL <= mid) {
_rangeAdd(node.left, qL, qR, delta);
}
if (qR > mid) {
_rangeAdd(node.right, qL, qR, delta);
}
node.mergeValue = merge(node.left.mergeValue, node.right.mergeValue);
}
// 查询闭区间 [qL, qR] 的聚合值
public int query(int qL, int qR) {
return _query(root, qL, qR);
}
private int _query(SegmentNode node, int qL, int qR) {
if (node.r < qL || node.l > qR) {
throw new IllegalArgumentException("Invalid query range");
}
if (qL <= node.l && node.r <= qR) {
return node.mergeValue;
}
initChildrenIfNeeded(node);
pushDown(node);
int mid = node.l + (node.r - node.l) / 2;
if (qR <= mid) {
return _query(node.left, qL, qR);
} else if (qL > mid) {
return _query(node.right, qL, qR);
} else {
int leftResult = _query(node.left, qL, mid);
int rightResult = _query(node.right, mid + 1, qR);
return merge(leftResult, rightResult);
}
}
}
六、图
图结构就是多叉树结构的延伸。图结构逻辑上由若干节点(
Vertex)和边(Edge)构成,我们一般用邻接表、邻接矩阵等方式来存储图。在树结构中,只允许父节点指向子节点,不存在子节点指向父节点的情况,子节点之间也不会互相链接;而图中没有那么多限制,节点之间可以相互指向,形成复杂的网络结构。
1. 逻辑结构
一幅图是由 节点(Vertex) 和 边(Edge) 构成的,逻辑结构如下:

每个节点的实现如下:
java
class Vertex{
int id;
Vertex[] neighbors;
}
和多叉树节点几乎完全一样:
java
class TreeNode{
int val;
TreeNode[] children;
}
在无向图中,度就是每个节点相连的边的条数。由于有向图的边有方向,所以有向图中每个节点的度被细分为入度(indegree)和出度(outdegree)。
其中节点
3的入度为 3(有三条边指向它),出度为 1(它有 1 条边指向别的节点)。
具体实现上,我们很少用这个 Vertex 类,而是用 邻接表、邻接矩阵 来实现图结构。
2. 邻接表和邻接矩阵
用邻接表和邻接矩阵的存储方式分别如下:

用代码的形式来表现,邻接表和邻接矩阵:
java
// 邻接表
// graph [x] 存储 x 的所有邻居节点
List<Integer>[] graph;
// 邻接矩阵
// matrix [x][y] 记录 x 是否有一条指向 y 的边
boolean[][] matrix;
节点类型不是 int 怎么办?
很简单,再额外使用一个哈希表,把实际节点和整数 id 映射起来,然后就可以用邻接表和邻接矩阵存储整数 id 了。
邻接表(更常用) 和邻接矩阵的使用场景:注意分析两种存储方式的空间复杂度,对于一幅有V个节点、E条边的图,邻接表的空间复杂度是 O(V+E),而邻接矩阵的空间复杂度是 O(V^2)。所以如果一幅图的
E远小于V^2(稀疏图),那么邻接表会比邻接矩阵节省空间;反之,如果E接近V^2(稠密图),二者就差不多了。
3. 不同种类的图结构
有向加权图怎么实现?
- 如果是邻接表,不仅仅存储某个节点
x的所有邻居节点,还存储x到每个邻居的权重。 - 如果是邻接矩阵,
matrix[x][y]不再是布尔值,而是一个 int 值,0 表示没有连接,其他值表示权重。
java
// 邻接表
// graph [x] 存储 x 的所有邻居节点以及对应的权重
class Edge {
int to;
int weight;
}
List<Edge>[] graph;
// 邻接矩阵
// matrix [x][y] 记录 x 指向 y 的边的权重,0 表示不相邻
int[][] matrix;
无向图怎么实现?所谓的「无向」,等同于「双向」。
- 如果是邻接表,在
x的邻居列表里添加y,同时在y的邻居列表里添加x。 - 如果是邻接矩阵,连接无向图中的节点
x和y,把matrix[x][y]和matrix[y][x]都变成true。
4. 图结构的通用代码
我们可以抽象出一个 Graph 接口,来实现图的基本增删查改:
java
interface Graph {
// 添加一条边(带权重)
void addEdge(int from, int to, int weight);
// 删除一条边
void removeEdge(int from, int to);
// 判断两个节点是否相邻
boolean hasEdge(int from, int to);
// 返回一条边的权重
int weight(int from, int to);
// 返回某个节点的所有邻居节点和对应权重
List<Edge> neighbors(int v);
// 返回节点总数
int size();
}
这其实是有向加权图的接口,但基于这个接口可以实现所有不同种类的无向/有向/无权/加权图。
4.1 有向加权图(邻接表实现)
java
// 加权有向图的通用实现(邻接表)
class WeightedDigraph {
// 存储相邻节点及边的权重
public static class Edge {
int to;
int weight;
public Edge(int to, int weight) {
this.to = to;
this.weight = weight;
}
}
// 邻接表,graph [v] 存储节点 v 的所有邻居节点及对应权重
private List<Edge>[] graph;
public WeightedDigraph(int n) {
graph = new List[n];
for (int i = 0; i < n; i++) {
graph[i] = new ArrayList<>();
}
}
// 增,添加一条带权重的有向边,复杂度 O(1)
public void addEdge(int from, int to, int weight) {
graph[from].add(new Edge(to, weight));
}
// 删,删除一条有向边,复杂度 O(V)
public void removeEdge(int from, int to) {
for (int i = 0; i < graph[from].size(); i++) {
if (graph[from].get(i).to == to) {
graph[from].remove(i);
break;
}
}
}
// 查,判断两个节点是否相邻,复杂度 O(V)
public boolean hasEdge(int from, int to) {
for (Edge e : graph[from]) {
if (e.to == to) {
return true;
}
}
return false;
}
// 查,返回一条边的权重,复杂度 O(V)
public int weight(int from, int to) {
for (Edge e : graph[from]) {
if (e.to == to) {
return e.weight;
}
}
throw new IllegalArgumentException("No such edge");
}
// 查,返回某个节点的所有邻居节点,复杂度 O(1)
public List<Edge> neighbors(int v) {
return graph[v];
}
public static void main(String[] args) {
WeightedDigraph graph = new WeightedDigraph(3);
graph.addEdge(0, 1, 1);
graph.addEdge(1, 2, 2);
graph.addEdge(2, 0, 3);
graph.addEdge(2, 1, 4);
System.out.println(graph.hasEdge(0, 1)); // true
System.out.println(graph.hasEdge(1, 0)); // false
graph.neighbors(2).forEach(edge -> {
System.out.println(2 + " -> " + edge.to + ", weight: " + edge.weight);
});
// 2 -> 0, weight: 3
// 2 -> 1, weight: 4
graph.removeEdge(0, 1);
System.out.println(graph.hasEdge(0, 1)); // false
}
}
4.2 有向加权图(邻接矩阵实现)
java
import java.util.ArrayList;
import java.util.List;
// 加权有向图的通用实现(邻接矩阵)
public class WeightedDigraph {
// 存储相邻节点及边的权重
public static class Edge {
int to;
int weight;
public Edge(int to, int weight) {
this.to = to;
this.weight = weight;
}
}
// 邻接矩阵,matrix [from][to] 存储从节点 from 到节点 to 的边的权重
// 0 表示没有连接
private int[][] matrix;
public WeightedDigraph(int n) {
matrix = new int[n][n];
}
// 增,添加一条带权重的有向边,复杂度 O(1)
public void addEdge(int from, int to, int weight) {
matrix[from][to] = weight;
}
// 删,删除一条有向边,复杂度 O(1)
public void removeEdge(int from, int to) {
matrix[from][to] = 0;
}
// 查,判断两个节点是否相邻,复杂度 O(1)
public boolean hasEdge(int from, int to) {
return matrix[from][to] != 0;
}
// 查,返回一条边的权重,复杂度 O(1)
public int weight(int from, int to) {
return matrix[from][to];
}
// 查,返回某个节点的所有邻居节点,复杂度 O(V)
public List<Edge> neighbors(int v) {
List<Edge> res = new ArrayList<>();
for (int i = 0; i < matrix[v].length; i++) {
if (matrix[v][i] > 0) {
res.add(new Edge(i, matrix[v][i]));
}
}
return res;
}
public static void main(String[] args) {
WeightedDigraph graph = new WeightedDigraph(3);
graph.addEdge(0, 1, 1);
graph.addEdge(1, 2, 2);
graph.addEdge(2, 0, 3);
graph.addEdge(2, 1, 4);
System.out.println(graph.hasEdge(0, 1)); // true
System.out.println(graph.hasEdge(1, 0)); // false
graph.neighbors(2).forEach(edge -> {
System.out.println(2 + " -> " + edge.to + ", weight: " + edge.weight);
});
// 2 -> 0, weight: 3
// 2 -> 1, weight: 4
graph.removeEdge(0, 1);
System.out.println(graph.hasEdge(0, 1)); // false
}
}
4.3 有向无权图(邻接表/邻接矩阵实现)
复用上面的 WeightedDigraph 类即可,把 addEdge 方法的权重参数默认设置为 1。
4.4 无向加权图(邻接表/邻接矩阵实现)
无向加权图就等同于双向的有向加权图,所以直接复用上面用邻接表/邻接矩阵实现的 WeightedDigraph 类即可,只是在增加边的时候,要同时添加两条边:
java
// 无向加权图的通用实现
class WeightedUndigraph {
private WeightedDigraph graph;
public WeightedUndigraph(int n) {
graph = new WeightedDigraph(n);
}
// 增,添加一条带权重的无向边
public void addEdge(int from, int to, int weight) {
graph.addEdge(from, to, weight);
graph.addEdge(to, from, weight);
}
// 删,删除一条无向边
public void removeEdge(int from, int to) {
graph.removeEdge(from, to);
graph.removeEdge(to, from);
}
// 查,判断两个节点是否相邻
public boolean hasEdge(int from, int to) {
return graph.hasEdge(from, to);
}
// 查,返回一条边的权重
public int weight(int from, int to) {
return graph.weight(from, to);
}
// 查,返回某个节点的所有邻居节点
public List<WeightedDigraph.Edge> neighbors(int v) {
return graph.neighbors(v);
}
public static void main(String[] args) {
WeightedUndigraph graph = new WeightedUndigraph(3);
graph.addEdge(0, 1, 1);
graph.addEdge(1, 2, 2);
graph.addEdge(2, 0, 3);
graph.addEdge(2, 1, 4);
System.out.println(graph.hasEdge(0, 1)); // true
System.out.println(graph.hasEdge(1, 0)); // true
graph.neighbors(2).forEach(edge -> {
System.out.println(2 + " <-> " + edge.to + ", weight: " + edge.weight);
});
// 2 <-> 0, weight: 3
// 2 <-> 1, weight: 4
graph.removeEdge(0, 1);
System.out.println(graph.hasEdge(0, 1)); // false
System.out.println(graph.hasEdge(1, 0)); // false
}
}
4.5 无向无权图(邻接表/邻接矩阵实现)
复用上面的 WeightedUndigraph 类即可,把 addEdge 方法的权重参数默认设置为 1。
5. 图结构的 DFS/BFS 遍历
图的遍历就是多叉树遍历的延伸,主要遍历方式还是深度优先搜索(DFS)和广度优先搜索(BFS)。
唯一的区别是,树结构中不存在环,而图结构中可能存在环,所以我们需要标记遍历过的节点,避免遍历函数在环中死循环。
遍历图的「节点」和「路径」略有不同,遍历「节点」时,需要
visited数组(用来记录被遍历过的节点)在前序位置标记节点;遍历图的所有「路径」时,需要onPath数组在前序位置标记节点,在后序位置撤销标记。
5.1 深度优先搜索(DFS)
虽然邻接表/邻接矩阵的底层存储方式不同,但提供了统一的 API,所以直接使用 Graph 接口的方法即可。visited 和 onPath 主要的作用就是处理成环的情况,避免死循环。
a 遍历所有节点(visited 数组)
java
// 遍历图的所有节点
void traverse(Graph graph, int s, boolean[] visited) {
// base case
if (s < 0 || s >= graph.size()) {
return;
}
if (visited[s]) {
// 防止死循环
return;
}
// 前序位置
visited[s] = true;
System.out.println("visit " + s);
for (Edge e : graph.neighbors(s)) {
traverse(graph, e.to, visited);
}
// 后序位置
}
由于 visited 数组的剪枝作用,这个遍历函数会遍历一次图中的所有节点,并尝试遍历一次所有边,所以算法的时间复杂度是 O(E+V),其中 E 是边的总数,V 是节点的总数。
b 遍历所有路径(onPath 数组)
对于图结构来说,由起点 src 到目标节点 dest 的路径可能不止一条。我们需要一个 onPath 数组,在进入节点时(前序位置)标记为正在访问,退出节点时(后序位置)撤销标记,这样才能遍历图中的所有路径,从而找到 src 到 dest 的所有路径。
java
// 下面的算法代码可以遍历图的所有路径,寻找从 src 到 dest 的所有路径
// onPath 和 path 记录当前递归路径上的节点
boolean[] onPath = new boolean[graph.size()];
List<Integer> path = new LinkedList<>();
void traverse(Graph graph, int src, int dest) {
// base case
if (src < 0 || src >= graph.size()) {
return;
}
if (onPath[src]) {
// 防止死循环(成环)
return;
}
// 前序位置
onPath[src] = true;
path.add(src);
if (src == dest) {
System.out.println("find path: " + path);
}
for (Edge e : graph.neighbors(src)) {
traverse(graph, e.to, dest);
}
// 后序位置
path.remove(path.size() - 1);
onPath[src] = false;
}
由于这里使用的 onPath 数组会在后序位置撤销标记,所以这个函数可能重复遍历图中的节点和边,复杂度一般较高(阶乘或指数级),具体的时间复杂度是所有路径的长度之和,取决于图的结构特点。
c 同时使用 visited 和 onPath 数组
遍历所有路径的算法复杂度较高,大部分情况下我们可能并不需要穷举完所有路径,而是仅需要找到某一条符合条件的路径。这种场景下,可能会借助 visited 数组进行剪枝,提前排除一些不符合条件的路径,从而降低复杂度。
比方说判定成环的场景,在遍历所有路径的过程中,如果发现一个节点 s 被标记为 visited,那么说明从 s 这个起点出发的所有路径在之前都已经遍历过了。如果之前遍历的时候都没有找到环,现在再去遍历一次,肯定也不会找到环,所以这里可以直接剪枝,不再继续遍历节点 s。
d 完全不用 visited 和 onPath 数组
visited 和 onPath 主要的作用就是处理成环情况,避免死循环。如果题目告诉你输入的图结构不包含环,那么就不需要考虑成环的情况了。
java
class Solution {
// 记录所有路径
List<List<Integer>> res = new LinkedList<>();
LinkedList<Integer> path = new LinkedList<>();
public List<List<Integer>> allPathsSourceTarget(int[][] graph) {
traverse(graph, 0);
return res;
}
// 图的遍历框架
void traverse(int[][] graph, int s) {
// 添加节点 s 到路径
path.addLast(s);
int n = graph.length;
if (s == n - 1) {
// 到达终点
res.add(new LinkedList<>(path));
path.removeLast();
return;
}
// 递归每个相邻节点
for (int v : graph[s]) {
traverse(graph, v);
}
// 从路径移出节点 s
path.removeLast();
}
}
5.2 广度优先搜索(BFS)
图结构的广度优先搜索其实就是多叉树的层序遍历,无非就是加了一个 visited 数组来避免重复遍历节点。
BFS 算法一般只用来寻找那条 最短路径 ,不会用来求 所有路径。如果只求最短路径的话,只需要遍历「节点」就可以了,因为按照 BFS 算法一层一层向四周扩散的逻辑,第一次遇到目标节点,必然就是最短路径。
a 写法一
不记录遍历步数。
java
// 图结构的 BFS 遍历,从节点 s 开始进行 BFS
void bfs(Graph graph, int s) {
boolean[] visited = new boolean[graph.size()];
Queue<Integer> q = new LinkedList<>();
q.offer(s);
visited[s] = true;
while (!q.isEmpty()) {
int cur = q.poll();
System.out.println("visit " + cur);
for (Edge e : graph.neighbors(cur)) {
if (!visited[e.to]) {
q.offer(e.to);
visited[e.to] = true;
}
}
}
}
b 写法二
记录遍历步数。
java
// 从 s 开始 BFS 遍历图的所有节点,且记录遍历的步数
void bfs(Graph graph, int s) {
boolean[] visited = new boolean[graph.size()];
Queue<Integer> q = new LinkedList<>();
q.offer(s);
visited[s] = true;
// 记录从 s 开始走到当前节点的步数
int step = 0;
while (!q.isEmpty()) {
int sz = q.size();
for (int i = 0; i < sz; i++) {
int cur = q.poll();
System.out.println("visit " + cur + " at step " + step);
for (Edge e : graph.neighbors(cur)) {
if (!visited[e.to]) {
q.offer(e.to);
visited[e.to] = true;
}
}
}
step++;
}
}
c 写法三
能够适配不同权重边的写法。
java
// 图结构的 BFS 遍历,从节点 s 开始进行 BFS,且记录路径的权重和
// 每个节点自行维护 State 类,记录从 s 走来的权重和
class State {
// 当前节点 ID
int node;
// 从起点 s 到当前节点的权重和
int weight;
public State(int node, int weight) {
this.node = node;
this.weight = weight;
}
}
void bfs(Graph graph, int s) {
boolean[] visited = new boolean[graph.size()];
Queue<State> q = new LinkedList<>();
q.offer(new State(s, 0));
visited[s] = true;
while (!q.isEmpty()) {
State state = q.poll();
int cur = state.node;
int weight = state.weight;
System.out.println("visit " + cur + " with path weight " + weight);
for (Edge e : graph.neighbors(cur)) {
if (!visited[e.to]) {
q.offer(new State(e.to, weight + e.weight));
visited[e.to] = true;
}
}
}
}
七、Trie 树(字典树/前缀树)
Trie 树(也叫做字典树、前缀树)就是多叉树结构的延伸,是一种针对字符串进行特殊优化的数据结构。
Trie 树在处理字符串相关操作时有诸多优势,比如 节省公共字符串前缀的内存空间、方便处理前缀操作、支持通配符匹配、支持字典序遍历键等。
实现的高级数据结构:TrieMap 和 TrieSet。
1. Trie 树操作
前缀操作
java
// Trie 树的键类型固定为 String 类型,值类型可以是泛型
TrieMap<Integer> map = new TrieMap<>();
map.put("that", 1);
map.put("the", 2);
map.put("them", 3);
map.put("apple", 4);
// "the" 是 "themxyz" 的最短前缀
System.out.println(map.shortestPrefixOf("themxyz")); // "the"
// "them" 是 "themxyz" 的最长前缀
System.out.println(map.longestPrefixOf("themxyz")); // "them"
// "tha" 是 "that" 的前缀
System.out.println(map.hasKeyWithPrefix("tha")); // true
// 没有以 "thz" 为前缀的键
System.out.println(map.hasKeyWithPrefix("thz")); // false
// "that", "the", "them" 都是 "th" 的前缀
System.out.println(map.keysWithPrefix("th")); // ["that", "the", "them"]
除了 keysWithPrefix 方法的复杂度取决于返回结果的长度,其他前缀操作的复杂度都是 O(L),其中 L 是前缀字符串长度。
通配符使用
java
// Trie 树的键类型固定为 String 类型,值类型可以是泛型
// 支持通配符匹配,"." 可以匹配任意一个字符
TrieMap<Integer> map = new TrieMap<>();
map.put("that", 1);
map.put("the", 2);
map.put("team", 3);
map.put("zip", 4);
// 匹配 "t.a." 的键有 "team", "that"
System.out.println(map.keysWithPattern("t.a.")); // ["team", "that"]
// 匹配 ".ip" 的键有 "zip"
System.out.println(map.hasKeyWithPattern(".ip")); // true
// 没有匹配 "z.o" 的键
System.out.println(map.hasKeyWithPattern("z.o")); // false
字典序遍历键
java
// Trie 树的键类型固定为 String 类型,值类型可以是泛型
TrieMap<Integer> map = new TrieMap<>();
map.put("that", 1);
map.put("the", 2);
map.put("them", 3);
map.put("zip", 4);
map.put("apple", 5);
// 按照字典序遍历键
System.out.println(map.keys()); // ["apple", "that", "the", "them", "zip"]
2. Trie 树的基本结构

java
// Trie 树节点实现
class TrieNode<V> {
V val = null;
TrieNode<V>[] children = new TrieNode[256];
}
这个 val 字段存储键对应的值,children 数组存储指向子节点的指针。但是和之前的普通多叉树节点不同,TrieNode 中 children 数组的索引是有意义的,代表键中的一个字符 。比如说 children[97] 如果非空,说明这里存储了一个字符 'a',因为 'a' 的 ASCII 码为 97。
这里只考虑处理 ASCII 字符,所以 children 数组的大小设置为 256。不过这个可以根据具体问题修改。比如在实际做题时,题目说了只包含字符 a-z,那么可以把大小改成 26;或者不想用字符索引来映射,直接用哈希表 HashMap<Character, TrieNode> 也可以,都是一样的效果。
这里要特别注意,TrieNode 节点本身只存储 val 字段,并没有一个字段来存储字符,字符是通过子节点在父节点的 children 数组中的索引确定的。
形象理解就是,Trie 树用「树枝」存储字符串(键),用「节点」存储字符串(键)对应的数据(值)。所以在图中把字符标在树枝,键对应的值 val 标在节点上:

3. TrieMap API

java
// 底层用 Trie 树实现的键值映射
// 键为 String 类型,值为类型 V
class TrieMap<V> {
// **** 增/改 ****
// 在 Map 中添加 key
public void put(String key, V val);
// **** 删 ****
// 删除键 key 以及对应的值
public void remove(String key);
// **** 查 ****
// 搜索 key 对应的值,不存在则返回 null
public V get(String key);
// 判断 key 是否存在于 Map 中
public boolean containsKey(String key);
// 在 Map 的所有键中搜索 query 的最短前缀
public String shortestPrefixOf(String query);
// 在 Map 的所有键中搜索 query 的最长前缀
public String longestPrefixOf(String query);
// 搜索所有前缀为 prefix 的键
public List<String> keysWithPrefix(String prefix);
// 判断是否存在前缀为 prefix 的键
public boolean hasKeyWithPrefix(String prefix);
// 通配符 . 匹配任意字符,搜索所有匹配的键
public List<String> keysWithPattern(String pattern);
// 通配符 . 匹配任意字符,判断是否存在匹配的键
public boolean hasKeyWithPattern(String pattern);
// 返回 Map 中键值对的数量
public int size();
}
4. TrieSet API
java
class TrieSet {
// 底层用一个 TrieMap,键就是 TrieSet,值仅仅起到占位的作用
private final TrieMap<Object> map = new TrieMap<>();
// **** 增 ****
public void add(String key) {
map.put(key, new Object());
}
// **** 删 ****
public void remove(String key) {
map.remove(key);
}
// **** 查 ****
public boolean contains(String key) {
return map.containsKey(key);
}
public String shortestPrefixOf(String query) {
return map.shortestPrefixOf(query);
}
public String longestPrefixOf(String query) {
return map.longestPrefixOf(query);
}
public List<String> keysWithPrefix(String prefix) {
return map.keysWithPrefix(prefix);
}
public boolean hasKeyWithPrefix(String prefix) {
return map.hasKeyWithPrefix(prefix);
}
public List<String> keysWithPattern(String pattern) {
return map.keysWithPattern(pattern);
}
public boolean hasKeyWithPattern(String pattern) {
return map.hasKeyWithPattern(pattern);
}
public int size() {
return map.size();
}
}
5. TrieMap 实现
java
class TrieMap<V> {
// ASCII 码个数
private static final int R = 256;
// 当前存在 Map 中的键值对个数
private int size = 0;
// Trie 树的根节点
private TrieNode<V> root = null;
private static class TrieNode<V> {
V val = null;
TrieNode<V>[] children = new TrieNode[R];
}
// **** 增/改 ****
// 在 map 中添加或修改键值对
public void put(String key, V val) {
if (!containsKey(key)) {
// 新增键值对
size++;
}
// 需要一个额外的辅助函数,并接收其返回值
root = put(root, key, val, 0);
}
// 定义:向以 node 为根的 Trie 树中插入 key [i..],返回插入完成后的根节点
private TrieNode<V> put(TrieNode<V> node, String key, V val, int i) {
if (node == null) {
// 如果树枝不存在,新建
node = new TrieNode<>();
}
if (i == key.length()) {
// key 的路径已插入完成,将值 val 存入节点
node.val = val;
return node;
}
char c = key.charAt(i);
// 递归插入子节点,并接收返回值
node.children[c] = put(node.children[c], key, val, i + 1);
return node;
}
// **** 删 ****
// 在 Map 中删除 key
public void remove(String key) {
if (!containsKey(key)) {
return;
}
// 递归修改数据结构要接收函数的返回值
root = remove(root, key, 0);
size--;
}
// 定义:在以 node 为根的 Trie 树中删除 key [i..],返回删除后的根节点
private TrieNode<V> remove(TrieNode<V> node, String key, int i) {
if (node == null) {
return null;
}
if (i == key.length()) {
// 找到了 key 对应的 TrieNode,删除 val
node.val = null;
} else {
char c = key.charAt(i);
// 递归去子树进行删除
node.children[c] = remove(node.children[c], key, i + 1);
}
// 后序位置,递归路径上的节点可能需要被清理
if (node.val != null) {
// 如果该 TrieNode 存储着 val,不需要被清理
return node;
}
// 检查该 TrieNode 是否还有后缀
for (int c = 0; c < R; c++) {
if (node.children[c] != null) {
// 只要存在一个子节点(后缀树枝),就不需要被清理
return node;
}
}
// 既没有存储 val,也没有后缀树枝,则该节点需要被清理
return null;
}
// **** 查 ****
// 搜索 key 对应的值,不存在则返回 null
public V get(String key) {
// 从 root 开始搜索 key
TrieNode<V> x = getNode(root, key);
if (x == null || x.val == null) {
return null;
}
return x.val;
}
// 判断 key 是否存在于 Map 中
public boolean containsKey(String key) {
return get(key) != null;
}
// 判断是否存在前缀为 prefix 的键
public boolean hasKeyWithPrefix(String prefix) {
return getNode(root, prefix) != null;
}
// 在所有键中寻找 query 的最短前缀
public String shortestPrefixOf(String query) {
TrieNode<V> p = root;
for (int i = 0; i < query.length(); i++) {
if (p == null) {
return "";
}
if (p.val != null) {
return query.substring(0, i);
}
char c = query.charAt(i);
p = p.children[c];
}
if (p != null && p.val != null) {
return query;
}
return "";
}
// 在所有键中寻找 query 的最长前缀
public String longestPrefixOf(String query) {
TrieNode<V> p = root;
int max_len = 0;
for (int i = 0; i < query.length(); i++) {
if (p == null) {
break;
}
if (p.val != null) {
max_len = i;
}
char c = query.charAt(i);
p = p.children[c];
}
if (p != null && p.val != null) {
return query;
}
return query.substring(0, max_len);
}
// 搜索前缀为 prefix 的所有键
public List<String> keysWithPrefix(String prefix) {
List<String> res = new LinkedList<>();
TrieNode<V> x = getNode(root, prefix);
if (x == null) {
return res;
}
traverse(x, new StringBuilder(prefix), res);
return res;
}
// 遍历以 node 节点为根的 Trie 树,找到所有键
private void traverse(TrieNode<V> node, StringBuilder path, List<String> res) {
if (node == null) {
return;
}
if (node.val != null) {
res.add(path.toString());
}
for (char c = 0; c < R; c++) {
path.append(c);
traverse(node.children[c], path, res);
path.deleteCharAt(path.length() - 1);
}
}
// 通配符 . 匹配任意字符
public List<String> keysWithPattern(String pattern) {
List<String> res = new LinkedList<>();
traverse(root, new StringBuilder(), pattern, 0, res);
return res;
}
// 遍历函数,尝试在「以 node 为根的 Trie 树中」匹配 pattern [i..]
private void traverse(TrieNode<V> node, StringBuilder path, String pattern, int i, List<String> res) {
if (node == null) {
return;
}
if (i == pattern.length()) {
if (node.val != null) {
res.add(path.toString());
}
return;
}
char c = pattern.charAt(i);
if (c == '.') {
for (char j = 0; j < R; j++) {
path.append(j);
traverse(node.children[j], path, pattern, i + 1, res);
path.deleteCharAt(path.length() - 1);
}
} else {
path.append(c);
traverse(node.children[c], path, pattern, i + 1, res);
path.deleteCharAt(path.length() - 1);
}
}
// 判断是否存在前缀为 prefix 的键
public boolean hasKeyWithPattern(String pattern) {
return hasKeyWithPattern(root, pattern, 0);
}
private boolean hasKeyWithPattern(TrieNode<V> node, String pattern, int i) {
if (node == null) {
return false;
}
if (i == pattern.length()) {
return node.val != null;
}
char c = pattern.charAt(i);
if (c != '.') {
return hasKeyWithPattern(node.children[c], pattern, i + 1);
}
for (int j = 0; j < R; j++) {
if (hasKeyWithPattern(node.children[j], pattern, i + 1)) {
return true;
}
}
return false;
}
// 从节点 node 开始搜索 key,如果存在返回对应节点,否则返回 null
private TrieNode<V> getNode(TrieNode<V> node, String key) {
TrieNode<V> p = node;
for (int i = 0; i < key.length(); i++) {
if (p == null) {
return null;
}
char c = key.charAt(i);
p = p.children[c];
}
return p;
}
public int size() {
return size;
}
}
6. TrieSet 的实现
只要对 TrieMap 做简单的封装,即可实现 TrieSet:
java
class TrieSet {
private final TrieMap<Object> map = new TrieMap<>();
public void add(String key) {
map.put(key, new Object());
}
public void remove(String key) {
map.remove(key);
}
public boolean contains(String key) {
return map.containsKey(key);
}
public String shortestPrefixOf(String query) {
return map.shortestPrefixOf(query);
}
public String longestPrefixOf(String query) {
return map.longestPrefixOf(query);
}
public List<String> keysWithPrefix(String prefix) {
return map.keysWithPrefix(prefix);
}
public boolean hasKeyWithPrefix(String prefix) {
return map.hasKeyWithPrefix(prefix);
}
public List<String> keysWithPattern(String pattern) {
return map.keysWithPattern(pattern);
}
public boolean hasKeyWithPattern(String pattern) {
return map.hasKeyWithPattern(pattern);
}
public int size() {
return map.size();
}
}
八、AVL 树与红黑树
AVL 树与红黑树本质是解决 普通二叉搜索树退化为链表 的问题,通过自平衡机制维持 O(log n) 的高效操作,但两者针对不同场景的需求侧重做了优化。

AVL 树
AVL 树是 自平衡二叉搜索树,核心特征是左右子树高度差(平衡因子)的绝对值不超过 1。
- 平衡因子 = 左子树高度 - 右子树高度,取值只能是 -1、0、1。
- 继承二叉搜索树的特性:左子树所有节点值 < 根节点值,右子树所有节点值 > 根节点值。

关键特性:
- 自平衡机制:插入或删除节点后若失衡,会通过旋转操作恢复平衡。
- 操作效率:查找、插入、删除的时间复杂度均为 O(log n),避免普通二叉搜索树退化为链表(O(n))。
- 旋转类型:包括 LL(左左)、RR(右右)、LR(左右)、RL(右左)四种基础旋转。

红黑树
红黑树也是 自平衡二叉搜索树,通过节点颜色(红/黑)和五条规则维持平衡,相比 AVL 树旋转操作更少,插入删除效率更优。五条规则如下:
- 每个节点非红即黑。
- 根节点必须是黑色。
- 所有叶子节点(NIL 节点,空节点)都是黑色。
- 若一个节点是红色,其两个子节点必须是黑色(无连续红节点)。
- 从任意节点到其所有叶子节点的路径,包含的黑色节点数量相同(黑高一致)。

关键特性:
- 平衡标准:不追求 "高度差最小",而是通过颜色规则保证 "最长路径不超过最短路径 2 倍",间接维持平衡。
- 调整方式:插入/删除后失衡时,优先通过 "变色" 修正,无法修正时再进行旋转(左旋、右旋)。
- 效率表现:查找、插入、删除的时间复杂度均为 O(log n),旋转次数比 AVL 树少(最多 2 次)。
- 适用场景:更适合插入、删除频繁的场景(如 Java 的 TreeMap、HashMap 的红黑树实现)。
九、B 树与 B+树
B 树
B 树是一种 多路平衡查找树,专为磁盘等块存储设备设计,核心目标是减少 IO 次数、提升查找效率。
核心特性:
- 每个节点可存储多个关键字和对应子树指针(多路特性),大幅降低树的高度。
- 所有叶子节点处于同一层级,保证查找、插入、删除的时间复杂度稳定为 O(log n)。
- 节点内关键字按升序排列,支持快速二分查找定位目标。

B+树
B+树是 B 树的优化版多路平衡查找树,是数据库索引、文件系统的主流核心数据结构,核心优势是更适配范围查询和磁盘 IO 特性。
核心特性:
- 数据仅存储在叶子节点,非叶子节点只保存关键字和子树指针(精简节点体积)。
- 所有叶子节点通过双向链表连接,形成有序数据链。
- 叶子节点包含全部关键字及对应数据地址,且所有叶子节点处于同一层级。
核心优势(对比 B 树):
- 查找效率更稳定:无论目标数据位置,都需遍历到叶子节点,时间复杂度始终为 O(log n)。
- 范围查询更高效:借助叶子节点的链表结构,无需回溯,直接遍历链表即可。
- IO 效率更高:非叶子节点不存数据,单个节点可容纳更多关键字,树高更矮,减少磁盘 IO 次数。

