数据结构合集

文章目录

  • 数据结构合集
  • 一、数组与链表
    • [1. 数组(顺序存储)基本原理](#1. 数组(顺序存储)基本原理)
    • [2. 链表(链式存储)基本原理](#2. 链表(链式存储)基本原理)
      • [2.1 两种链表节点定义](#2.1 两种链表节点定义)
      • [2.2 单链表的基本操作](#2.2 单链表的基本操作)
        • [遍历 / 查 / 改](#遍历 / 查 / 改)
      • [2.3 双链表的基本操作](#2.3 双链表的基本操作)
        • [遍历 / 查 / 改](#遍历 / 查 / 改)
    • [3. 环形数组技巧](#3. 环形数组技巧)
  • 二、哈希表
    • [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 树(字典树/前缀树))
    • [1. Trie 树操作](#1. Trie 树操作)
    • [2. Trie 树的基本结构](#2. Trie 树的基本结构)
    • [3. TrieMap API](#3. TrieMap API)
    • [4. TrieSet API](#4. TrieSet API)
    • [5. TrieMap 实现](#5. TrieMap 实现)
    • [6. TrieSet 的实现](#6. TrieSet 的实现)
  • [八、AVL 树与红黑树](#八、AVL 树与红黑树)
  • [九、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) 的时间在数组头部增删元素

环形数组的关键在于,它维护了两个指针 startendstart 指向第一个有效元素的索引,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() 方法。在实现自定义的类时,如果不重写该方法,其默认返回值可以认为是该对象的内存地址。一个对象的内存地址显然是全局唯一的一个整数,所以只要调用 keyhashCode() 方法就相当于把 key 转化成了一个整数,且这个整数是全局唯一的。

如何保证索引合法?

java 复制代码
int 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) 的时间复杂度内找到数组元素对应的索引了。

java 复制代码
private 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)是特殊的二叉树结构,其主要的实际应用是 TreeMapTreeSet左小右大的特性,可以让我们在 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 的节点类来实现,而是用数组来模拟二叉树的结构。原因如下:

  • 第一个原因是链表节点需要一个额外的指针存储相邻节点的地址,所以相对数组,链表的内存消耗会大一些。
  • 第二个原因,也是最主要的原因,是时间复杂度的问题 。仔细想一下前面展示的 pushpop 方法的操作过程,它们的第一步是什么?是不是要找到二叉树最底层的最右侧元素?正常情况下你需要层序遍历或递归遍历二叉树,时间复杂度是 O(N),进而导致 pushpop 方法的时间复杂度退化到 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,只需要把 swimsink 方法中元素大小比较的逻辑反过来即可。

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
  • 如果是邻接矩阵,连接无向图中的节点 xy,把 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 接口的方法即可。visitedonPath 主要的作用就是处理成环的情况,避免死循环。

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 数组,在进入节点时(前序位置)标记为正在访问,退出节点时(后序位置)撤销标记,这样才能遍历图中的所有路径,从而找到 srcdest 的所有路径。

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 数组

visitedonPath 主要的作用就是处理成环情况,避免死循环。如果题目告诉你输入的图结构不包含环,那么就不需要考虑成环的情况了。

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 数组存储指向子节点的指针。但是和之前的普通多叉树节点不同,TrieNodechildren 数组的索引是有意义的,代表键中的一个字符 。比如说 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。
  • 继承二叉搜索树的特性:左子树所有节点值 < 根节点值,右子树所有节点值 > 根节点值。

关键特性

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

红黑树

红黑树也是 自平衡二叉搜索树,通过节点颜色(红/黑)和五条规则维持平衡,相比 AVL 树旋转操作更少,插入删除效率更优。五条规则如下:

  • 每个节点非红即黑。
  • 根节点必须是黑色。
  • 所有叶子节点(NIL 节点,空节点)都是黑色。
  • 若一个节点是红色,其两个子节点必须是黑色(无连续红节点)。
  • 从任意节点到其所有叶子节点的路径,包含的黑色节点数量相同(黑高一致)。

关键特性

  1. 平衡标准:不追求 "高度差最小",而是通过颜色规则保证 "最长路径不超过最短路径 2 倍",间接维持平衡。
  2. 调整方式:插入/删除后失衡时,优先通过 "变色" 修正,无法修正时再进行旋转(左旋、右旋)。
  3. 效率表现:查找、插入、删除的时间复杂度均为 O(log n),旋转次数比 AVL 树少(最多 2 次)。
  4. 适用场景:更适合插入、删除频繁的场景(如 Java 的 TreeMap、HashMap 的红黑树实现)。

九、B 树与 B+树

B 树

B 树是一种 多路平衡查找树,专为磁盘等块存储设备设计,核心目标是减少 IO 次数、提升查找效率。

核心特性

  1. 每个节点可存储多个关键字和对应子树指针(多路特性),大幅降低树的高度。
  2. 所有叶子节点处于同一层级,保证查找、插入、删除的时间复杂度稳定为 O(log n)。
  3. 节点内关键字按升序排列,支持快速二分查找定位目标。

B+树

B+树是 B 树的优化版多路平衡查找树,是数据库索引、文件系统的主流核心数据结构,核心优势是更适配范围查询和磁盘 IO 特性。

核心特性

  1. 数据仅存储在叶子节点,非叶子节点只保存关键字和子树指针(精简节点体积)。
  2. 所有叶子节点通过双向链表连接,形成有序数据链。
  3. 叶子节点包含全部关键字及对应数据地址,且所有叶子节点处于同一层级。

核心优势(对比 B 树)

  • 查找效率更稳定:无论目标数据位置,都需遍历到叶子节点,时间复杂度始终为 O(log n)。
  • 范围查询更高效:借助叶子节点的链表结构,无需回溯,直接遍历链表即可。
  • IO 效率更高:非叶子节点不存数据,单个节点可容纳更多关键字,树高更矮,减少磁盘 IO 次数。
相关推荐
兰令水1 小时前
hot100【acm版】【2026.7.11/12打卡-java版本】
java·开发语言·数据结构·算法·职场和发展
研來如此2 小时前
图像文件大小
人工智能·算法·计算机视觉
千纸鹤安安2 小时前
开源 Agent 框架实战:用自然语言替代 Crontab 做运维
算法
从零开始的代码生活_2 小时前
C++ string 详解:常用接口、字符串算法与深拷贝实现
开发语言·c++·后端·学习·算法
Keven_112 小时前
算法札记:差分约束系统为啥叫差分约束系统Σ(っ °Д °;)っ
算法·差分约束系统
XWalnut2 小时前
LeetCode刷题 day29
java·算法·leetcode
embrace_the_sunhaha2 小时前
卡尔曼滤波理解
算法
叩码以求索3 小时前
使用next数组加速匹配过程
java·数据结构·算法
名字还没想好☜3 小时前
Go 内存逃逸分析:什么时候变量跑到堆上
开发语言·算法·性能优化·golang·go·内存逃逸