CSP-S 复赛指南(2025年版)

此文章为 daiyulong 独家原创,耗时比较长。部分借助 AI。文章有点长(10 万字左右),请保持耐心。

第一章 数据结构

第一节 线性结构

1.1 【5】双端栈

1.1.1 什么是双端栈?

在理解双端栈之前,我们先回顾一下普通的栈。一个普通的栈,所有元素的插入(入栈,push)和删除(出栈,pop)都只能在同一端------也就是"栈顶"进行。

那么,一个很自然的想法是:如果我们允许栈的两端都可以进行入栈和出栈操作,会怎么样呢?这种两端都具备栈顶功能的数据结构,就是双端栈(Double-Ended Stack)

想象一个很长的薯片筒,我们不仅可以从上面的开口放入或取出薯片,也可以打开下面的盖子,从底部放入或取出薯片。这个薯片筒就是一个典型的双端栈模型。

然而,在标准的计算机科学数据结构中,"双端栈"这个术语并不常用。其功能被一个更通用、更强大的数据结构------双端队列(Deque)------所完全覆盖。因此,通常我们将双端栈理解为双端队列的一个概念性前身。在接下来的内容中,我们将直接学习功能更全面的双端队列。

1.2 【5】双端队列

1.2.1 什么是双端队列?

双端队列(Double-Ended Queue,简称 Deque) 是一种允许在队列的头部和尾部都能进行插入和删除操作的线性数据结构。它就像一个"全能选手",集成了栈和队列的特点。

  • 如果只使用它的一端进行插入和删除,它就表现得像一个
  • 如果在一端进行插入,在另一端进行删除,它就表现得像一个队列

我们可以将双端队列想象成一个两头都开放的"队伍"。新来的人可以选择排在队伍的最前面,也可以选择排在队伍的最后面;同时,队伍最前面的人可以离开,队伍最后面的人也可以"反悔"直接离开。

1.2.2 双端队列的核心操作

一个标准的双端队列通常支持以下几种核心操作:

  • push_back: 在队列的尾部插入一个元素。
  • pop_back: 删除队列尾部的元素。
  • push_front: 在队列的头部插入一个元素。
  • pop_front: 删除队列头部的元素。
  • front: 获取队列头部的元素。
  • back: 获取队列尾部的元素。
  • empty: 检查队列是否为空。
  • size: 获取队列中元素的数量。

1.2.3 双端队列的实现

在竞赛中,我们几乎总是使用 C++ 标准模板库(STL)中提供的 std::deque 容器,因为它性能高效且使用方便。但为了深入理解其工作原理,了解如何用数组模拟双端队列是非常有帮助的。

1.2.3.1 数组模拟实现(循环数组)

我们可以使用一个数组来模拟双端队列,并设置两个指针:head 指向队头,tail 指向队尾的下一个位置。

为了避免数组存满后无法再插入元素,我们通常使用循环数组 。也就是说,当指针移动到数组的末尾时,它会自动"绕回"到数组的开头。这可以通过取模运算 % 来实现。

  • 初始化 : headtail 都指向数组中间的某个位置,以保证两边都有扩展的空间。
  • push_back : 元素存入 tail 指向的位置,然后 tail 向后移动一位(tail = (tail + 1) % N,其中 \(N\) 是数组大小)。
  • push_front : head 向前移动一位(head = (head - 1 + N) % N),然后元素存入 head 指向的位置。
  • pop_back : tail 向前移动一位。
  • pop_front : head 向后移动一位。

判断队列为空的条件是 head == tail。判断队列为满的条件是 (tail + 1) % N == head

这种手写方式虽然能加深理解,但在实际比赛中,除非有特殊要求,否则不推荐,因为容易出错且效率不如 STL

1.2.4 C++ STL deque 代码模板

使用 STL 中的 deque 非常简单。首先需要包含头文件 <deque>

cpp 复制代码
#include <iostream>
#include <deque> // 包含 deque 的头文件

using namespace std;

int main() {
    // 1. 创建一个 deque
    deque<int> dq;

    // 2. 在队尾插入元素
    dq.push_back(10); // dq: [10]
    dq.push_back(20); // dq: [10, 20]

    // 3. 在队头插入元素
    dq.push_front(5);  // dq: [5, 10, 20]
    dq.push_front(1);  // dq: [1, 5, 10, 20]

    // 4. 查看队头和队尾元素
    cout << "队头元素是: " << dq.front() << endl; // 输出 1
    cout << "队尾元素是: " << dq.back() << endl;  // 输出 20

    // 5. 获取队列大小
    cout << "队列大小是: " << dq.size() << endl; // 输出 4

    // 6. 从队头删除元素
    dq.pop_front(); // dq: [5, 10, 20]
    cout << "pop_front 后队头元素是: " << dq.front() << endl; // 输出 5

    // 7. 从队尾删除元素
    dq.pop_back();  // dq: [5, 10]
    cout << "pop_back 后队尾元素是: " << dq.back() << endl;  // 输出 10

    // 8. 判断队列是否为空
    while (!dq.empty()) {
        cout << "当前队头: " << dq.front() << ", 准备出队..." << endl;
        dq.pop_front();
    }

    if (dq.empty()) {
        cout << "队列现在为空。" << endl;
    }

    return 0;
}

双端队列本身的应用场景非常广泛,例如在广度优先搜索(BFS)的某些变体中,以及作为实现其他高级数据结构(如我们即将学习的单调队列)的基础。

1.3 【5】单调队列

1.3.1 什么是单调队列?

单调队列 并不是一个 STL 中直接存在的数据结构,而是一种基于双端队列(deque)实现的算法思想和数据结构。

顾名思义,单调队列内部的元素是单调 的,即单调递增或单调递减。这个特性使得它非常适合解决一类经典问题:滑动窗口内的最值问题

例如,给定一个数组和一个固定大小的窗口,当窗口在数组上从左到右滑动时,如何快速求出每个窗口内的最大值或最小值?

1.3.2 单调队列的核心思想

我们以"滑动窗口最大值"为例来剖析单调队列的精髓。

假设有一个窗口,我们需要找到其中的最大值。如果使用暴力法,每次窗口滑动时都遍历一遍窗口内的所有元素,时间复杂度会是 \(O(n \cdot k)\)(其中 \(n\) 是数组长度,\(k\) 是窗口大小),在数据量大时会超时。

单调队列能将这个过程优化到 \(O(n)\) 的时间复杂度。它是如何做到的呢?关键在于它维护了一个"有潜力"成为最大值的候选者列表。

这个列表(用双端队列实现)有两个核心规则:

  1. 规则一(维护单调性) :当一个新元素准备从队尾入队时,它会从队尾开始,将所有比它小的元素都"踢"出队列。然后,它自己再入队。这保证了队列中的元素从队头到队尾是单调递减的。
  2. 规则二(维护窗口范围) :队头元素永远是当前窗口内的最大值。当队头元素的位置已经滑出当前窗口的左边界时,就需要将它从队头弹出(pop_front)。

为什么这个方法是正确的?

思考一下规则一:假设队列中已经有元素 abab 前面),它们都比新来的元素 c 小。由于 c 进入窗口的时间比 ab 都晚,而 c 的值又比它们大。这意味着,在未来的任何包含 c 的窗口中,ab 都不可能成为最大值了(因为有 c "压着"它们)。所以,ab 就成了"没有潜力"的元素,可以被安全地从队列中移除。

这个过程保证了队列里的元素不仅值是单调递减的,它们在原数组中的下标也是单调递增的。队头的元素,就是这个单调队列中最大且最"老"的元素。

1.3.3 算法伪代码(滑动窗口最大值)

假设有一个数组 A 和窗口大小 k。我们用 dq 来存储元素的下标

pseudocode 复制代码
// 伪代码:滑动窗口最大值
// 数组 A 的下标从 1 到 n
// 窗口大小为 k
// dq 是一个双端队列,存储的是数组 A 的下标

function slidingWindowMax(A, n, k):
    创建一个空的双端队列 dq
    创建一个空的结果数组 ans

    // 遍历数组 A 的每一个元素
    for i from 1 to n:
        // 规则一:维护单调性
        // 当队列不为空,且队尾下标对应的元素 <= 当前元素 A[i]
        while dq is not empty and A[dq.back()] <= A[i]:
            dq.pop_back() // 弹出队尾,因为它已经没有机会成为最大值了

        // 将当前元素的下标加入队尾
        dq.push_back(i)

        // 规则二:维护窗口范围
        // 如果队头元素的下标已经超出了窗口的左边界
        // 当前窗口的范围是 [i - k + 1, i]
        if dq.front() <= i - k:
            dq.pop_front() // 弹出队头

        // 当窗口形成后(即遍历过的元素个数 >= k),开始记录答案
        if i >= k:
            // 此时队头元素就是当前窗口的最大值
            ans.push_back(A[dq.front()])

    return ans

注意 :单调队列中存储的是元素的下标而不是元素的值。这是因为我们需要根据下标来判断元素是否已经滑出窗口。

1.3.4 算法流程演示

我们用一个具体的例子来走一遍流程。

数组 A = [1, 3, -1, -3, 5, 3, 6, 7],窗口大小 k = 3

  1. i = 1, A[1] = 1。队列为空,1 的下标入队。dq = [1]
  2. i = 2, A[2] = 3A[2] > A[1]1 出队,2 入队。dq = [2]
  3. i = 3, A[3] = -1A[3] < A[2]3 直接入队。dq = [2, 3]
    • 此时窗口 [1, 3] 形成,窗口范围是 [1, 3]。队头下标是 \(2\),在窗口内。最大值为 A[2] = 3输出 3
  4. i = 4, A[4] = -3A[4] < A[3]4 直接入队。dq = [2, 3, 4]
    • 窗口 [2, 4] 形成。队头下标是 \(2\),在窗口 [2, 4] 内。最大值为 A[2] = 3输出 3
  5. i = 5, A[5] = 5A[5]A[4], A[3], A[2] 都大,所以 4, 3, 2 依次出队。5 入队。dq = [5]
    • 窗口 [3, 5] 形成。队头下标是 \(5\),在窗口 [3, 5] 内。最大值为 A[5] = 5输出 5
  6. i = 6, A[6] = 3A[6] < A[5]6 直接入队。dq = [5, 6]
    • 窗口 [4, 6] 形成。队头下标是 \(5\),在窗口 [4, 6] 内。最大值为 A[5] = 5输出 5
  7. i = 7, A[7] = 6A[7] > A[6]6 出队。A[7] > A[5]5 出队。7 入队。dq = [7]
    • 窗口 [5, 7] 形成。队头下标是 \(7\),在窗口 [5, 7] 内。最大值为 A[7] = 6输出 6
  8. i = 8, A[8] = 7A[8] > A[7]7 出队。8 入队。dq = [8]
    • 窗口 [6, 8] 形成。队头下标是 \(8\),在窗口 [6, 8] 内。最大值为 A[8] = 7输出 7

最终输出的最大值序列为:3, 3, 5, 5, 6, 7

1.3.5 洛谷例题与题解

题目链接 : P1886 滑动窗口 /【模板】单调队列

题目描述

给定一个长度为 \(n\) 的数组和一个大小为 \(k\) 的窗口。窗口从数组的最左端滑到最右端。你需要求出窗口在每一次滑动时,窗口中的最大值和最小值。

输入格式

第一行包含两个整数 \(n, k\),表示数组长度和窗口大小。

第二行包含 \(n\) 个整数,表示数组的元素。

输出格式

两行。

第一行输出,每个窗口的最小值。

第二行输出,每个窗口的最大值。

题解思路

这道题是单调队列的模板题。我们需要分别求出滑动窗口的最小值和最大值。

  • 最小值 :需要维护一个队头到队尾单调递增 的队列。当新元素入队时,把队尾所有比它的元素都踢出去。
  • 最大值 :需要维护一个队头到队尾单调递减 的队列。当新元素入队时,把队尾所有比它的元素都踢出去。(这正是我们上面详细分析的过程)

我们可以写两个函数,或者在一个循环里用两个单调队列分别处理。下面给出的代码是在一个主循环中用两个队列分别完成任务。

1.3.6 C++ 代码实现

cpp 复制代码
#include <iostream>
#include <vector>
#include <deque>

using namespace std;

const int MAXN = 1000005;

int n, k;
int a[MAXN];
deque<int> min_dq, max_dq; // min_dq 存最小值的候选项,max_dq 存最大值的候选项

int main() {
    // 读入数据
    cin >> n >> k;
    for (int i = 1; i <= n; ++i) {
        cin >> a[i];
    }

    // --- 求滑动窗口最小值 ---
    for (int i = 1; i <= n; ++i) {
        // 维护单调递增性:新来的元素 a[i] 从队尾把比它大的都赶走
        while (!min_dq.empty() && a[min_dq.back()] >= a[i]) {
            min_dq.pop_back();
        }
        min_dq.push_back(i); // 将当前元素下标入队

        // 维护窗口范围:检查队头是否已经滑出窗口
        if (min_dq.front() <= i - k) {
            min_dq.pop_front();
        }

        // 当窗口形成后(i >= k),输出结果
        if (i >= k) {
            cout << a[min_dq.front()] << " ";
        }
    }
    cout << endl;

    // --- 求滑动窗口最大值 ---
    for (int i = 1; i <= n; ++i) {
        // 维护单调递减性:新来的元素 a[i] 从队尾把比它小的都赶走
        while (!max_dq.empty() && a[max_dq.back()] <= a[i]) {
            max_dq.pop_back();
        }
        max_dq.push_back(i); // 将当前元素下标入队

        // 维护窗口范围:检查队头是否已经滑出窗口
        if (max_dq.front() <= i - k) {
            max_dq.pop_front();
        }
        
        // 当窗口形成后(i >= k),输出结果
        if (i >= k) {
            cout << a[max_dq.front()] << " ";
        }
    }
    cout << endl;

    return 0;
}

代码解释

  1. 代码中,我们开了两个 dequemin_dqmax_dq,都用来存储数组下标。
  2. 为了逻辑清晰,我们用了两个独立的循环来分别计算最小值和最大值序列。在实际比赛中,也可以将两个过程合并到一个循环中以提高一点点效率,但逻辑会稍微复杂一些。
  3. 求最小值部分while (!min_dq.empty() && a[min_dq.back()] >= a[i]) 这句是核心。它保证了 min_dq 中的元素对应的值是单调递增的。因为如果一个又老(下标小)又大的元素存在,它一定不会是未来任何窗口的最小值,所以可以被淘汰。
  4. 求最大值部分while (!max_dq.empty() && a[max_dq.back()] <= a[i]) 这句是核心。它保证了 max_dq 中的元素对应的值是单调递减的。原理同上。
  5. if (dq.front() <= i - k) 这句是用来判断队头下标是否过期的。i 是当前窗口的右边界,那么左边界就是 i - k + 1。如果队头的下标小于等于 i - k,说明它已经在窗口之外了,必须出队。
  6. if (i >= k) 这个判断确保我们只在窗口完全形成之后才开始输出结果。

通过单调队列,我们成功地将每个元素入队一次、出队一次,使得解决滑动窗口最值问题的总时间复杂度从 \(O(n \cdot k)\) 优化到了 \(O(n)\),这是一个巨大的提升。单调队列是动态规划优化的一个重要工具,掌握它对于解决更难的算法问题至关重要。

1.4 【6】优先队列

1.4.1 什么是优先队列?

在学习优先队列之前,我们先回顾一下队列(Queue)这种数据结构。队列是一种"先进先出"(First In First Out, FIFO)的线性表。这就像在食堂排队打饭,先到窗口的人先打到饭,后到的人后打饭。所有元素在队列中的地位都是平等的,唯一的区别就是它们进入队列的顺序。

然而,在现实世界的很多场景中,元素的"地位"并非平等。例如,在医院的急诊室,医生会优先处理病情更危重的病人,而不是严格按照病人挂号的先后顺序。一位心脏病突发的病人,即使比一位只是有点轻微擦伤的病人晚到,也应该被优先救治。

为了在计算机程序中模拟这种"按优先级处理"的场景,一种新的数据结构应运而生,它就是 优先队列(Priority Queue)

顾名思义,优先队列中的每一个元素都有一个"优先级"。在进行出队操作时,它不再遵循"先进先出"的原则,而是将当前队列中优先级最高(或最低)的元素率先弹出。

我们可以将优先队列与栈(Stack)和普通队列(Queue)进行一个简单的对比:

数据结构 出队原则 生活中的例子
栈 (Stack) 后进先出 (LIFO) 叠放的盘子,最后放上去的盘子最先被取用。
队列 (Queue) 先进先出 (FIFO) 排队买票,排在最前面的人最先买到票。
优先队列 (Priority Queue) 优先级最高者先出 医院急诊室,病情最危重的病人最先被救治。

因此,优先队列是一种特殊的队列,它允许我们在任何时候插入一个元素,但只能访问并移除当前优先级最高的元素。

1.1.5 优先队列的实现:二叉堆

优先队列是一个抽象的数据结构概念,它可以通过多种方式实现,例如使用有序数组、链表等。但这些实现在插入或删除操作上效率不高。在信息学竞赛中,实现优先队列最常用、最高效的数据结构是 堆(Heap) ,特别是 二叉堆(Binary Heap)

1. 什么是二叉堆?

二叉堆本质上是一棵完全二叉树 ,并且它需要满足堆属性

  • 完全二叉树 :一棵深度为 \(k\) 的二叉树,如果它的第 \(1\) 层到第 \(k-1\) 层都是满的,并且第 \(k\) 层的节点都连续地集中在左边,那么它就是一棵完全二叉树。如下图所示,它非常"丰满"且"紧凑"。

    复制代码
        ●
       / \
      ●   ●
     / \ /
    ●  ● ●
  • 堆属性:堆属性规定了树中父节点和子节点之间的关系。它分为两种:

    • 大根堆(Max-Heap) :任意一个节点的值都 大于或等于 它的所有子节点的值。因此,根节点一定是整个堆中最大的元素。
    • 小根堆(Min-Heap) :任意一个节点的值都 小于或等于 它的所有子节点的值。因此,根节点一定是整个堆中最小的元素。

    下面的左图是一个大根堆,右图是一个小根堆。

    复制代码
        大根堆             小根堆
          100                 10
         /   \               /   \
        70    60            20    15
       / \   /             / \   /
      20 30 50            40 50 30

    在信息学竞赛中,当我们提到"优先队列",通常默认指的是用"大根堆"实现的、取出最大元素的队列。如果需要取出最小元素,则使用"小根堆"。

2. 二叉堆的存储

二叉堆最巧妙的地方在于,尽管它是一棵树,但我们不需要使用复杂的指针来存储它。由于它是完全二叉树,我们可以非常方便地用一个一维数组来表示。

我们将树的节点从上到下、从左到右依次编号,从 \(1\) 开始。然后将编号为 \(i\) 的节点存储在数组的第 \(i\) 个位置。

复制代码
        节点1 (arr[1])
       /      \
  节点2(arr[2])  节点3(arr[3])
   /     \       /      \
arr[4] arr[5] arr[6] arr[7]

在这种存储方式下,节点之间存在着优美的数学关系:

  • 对于数组中下标为 \(i\) 的节点,它的父节点的下标是 \(\lfloor i/2 \rfloor\)。
  • 对于数组中下标为 \(i\) 的节点,它的左子节点的下标是 \(2 \times i\)。
  • 对于数组中下标为 \(i\) 的节点,它的右子节点的下标是 \(2 \times i + 1\)。

通过这些简单的计算,我们就可以在数组中模拟出树的父子关系,极大地节省了空间和编码复杂度。

1.4.3 堆的核心操作

以大根堆为例,堆的核心操作主要有两个:向上调整(Up-Heapify)和向下调整(Down-Heapify)。这两个操作是维持堆属性的关键。

1. 插入元素 (Push)

当向堆中插入一个新元素时,为了保持完全二叉树的结构,我们首先将这个新元素放在数组的末尾,也就是树的最底层、最右边的位置。

但是,这样做很可能会破坏大根堆的属性(新元素可能比它的父节点大)。因此,我们需要进行向上调整操作。

  • 向上调整 (Up-Heapify):将新插入的节点(当前节点)和它的父节点进行比较。如果当前节点的值大于父节点,就交换它们的位置。然后继续将当前节点与新的父节点比较,如此循环,直到当前节点不再大于其父节点,或者它已经到达了堆顶(根节点)的位置。这个过程就像一个"气泡"不断上浮。

伪代码:Up(index)

复制代码
while (index > 1 and heap[index] > heap[index / 2])
    swap(heap[index], heap[index / 2])
    index = index / 2

2. 删除堆顶元素 (Pop)

优先队列的 pop 操作总是删除优先级最高的元素,也就是堆顶元素。但是,如果我们直接删除根节点 heap[1],整棵树的结构就遭到了破坏。

正确的做法是:

  1. 记录下堆顶元素 heap[1] 的值,这是我们要返回的结果。
  2. 将数组的最后一个元素(树的最底层最右边的叶子节点)移动到堆顶的位置。
  3. 此时,新的堆顶元素很可能小于它的子节点,破坏了大根堆的属性。因此,我们需要进行向下调整
  • 向下调整 (Down-Heapify) :将新的堆顶(当前节点)与它的左右子节点中值较大的那个进行比较。如果当前节点的值小于那个较大的子节点,就交换它们的位置。然后继续将当前节点与新的子节点比较,如此循环,直到当前节点的两个子节点都比它小,或者它已经成为了叶子节点。这个过程就像一块"石头"不断下沉。

伪代码:Down(index, size)

复制代码
while (2 * index <= size) // 只要存在子节点
    child_index = 2 * index // 左子节点
    // 如果右子节点存在,且比左子节点大,则目标是右子节点
    if (child_index + 1 <= size and heap[child_index + 1] > heap[child_index])
        child_index = child_index + 1
    
    // 如果当前节点比它的两个孩子都大,则调整结束
    if (heap[index] >= heap[child_index])
        break
    
    swap(heap[index], heap[child_index])
    index = child_index // 继续向下调整

所有这些操作的时间复杂度都与树的高度成正比。一个包含 \(N\) 个节点的完全二叉树,其高度大约是 \(\log_2 N\)。因此,插入和删除操作的时间复杂度都是非常高效的 \(O(\log N)\)。获取堆顶元素只需访问数组第一个元素,时间复杂度为 \(O(1)\)。

1.4.4 C++ STL 中的 priority_queue

在实际的编程竞赛中,我们通常不需要手动编写一个堆。C++ 的标准模板库(STL)中已经为我们提供了一个非常方便的容器:std::priority_queue

要使用它,需要包含头文件 <queue>

1. 基本用法

cpp 复制代码
#include <iostream>
#include <queue>
#include <vector>

using namespace std;

int main() {
    // 默认是大根堆
    priority_queue<int> max_heap;

    max_heap.push(30);
    max_heap.push(100);
    max_heap.push(20);

    cout << "大根堆的堆顶元素是: " << max_heap.top() << endl; // 输出 100

    max_heap.pop(); // 弹出 100

    cout << "弹出后,堆顶元素是: " << max_heap.top() << endl; // 输出 30

    return 0;
}

2. 如何定义小根堆

priority_queue 默认是大根堆。如果我们想使用小根堆(用于取出最小值),需要提供额外的模板参数。其完整定义是:
priority_queue<Type, Container, Compare>

  • Type: 存储的元素类型。
  • Container: 实现底层堆的容器,通常是 vector<Type>
  • Compare: 比较函数对象,用于决定优先级。less<Type> 表示大根堆(默认),greater<Type> 表示小根堆。

定义一个小根堆的语法如下:

cpp 复制代码
// 定义一个存储 int 类型的小根堆
priority_queue<int, vector<int>, greater<int>> min_heap;

min_heap.push(30);
min_heap.push(100);
min_heap.push(20);

cout << "小根堆的堆顶元素是: " << min_heap.top() << endl; // 输出 20

3. 常用成员函数

函数 功能描述 时间复杂度
push(x) 将元素 x 插入队列 \(O(\log N)\)
pop() 弹出队首元素(优先级最高的元素) \(O(\log N)\)
top() 返回队首元素的引用 \(O(1)\)
empty() 判断队列是否为空 \(O(1)\)
size() 返回队列中元素的个数 \(O(1)\)

1.4.5 洛谷例题:P3378 【模板】堆

这道题目是优先队列(堆)的模板题,完美地契合了我们刚刚学习的内容。

题目描述

给定一个序列,你需要支持以下三种操作:

  1. 插入一个数 \(x\)。
  2. 输出当前序列中最小的数。
  3. 删除当前序列中最小的数。

分析

题目要求我们维护一个数据集合,并能快速地插入、查询最小值、删除最小值。这正是小根堆 的典型应用场景。我们直接使用 C++ STL 中的 priority_queue 就可以轻松解决。

C++ 代码模板

cpp 复制代码
#include <iostream>
#include <queue>
#include <vector>

// 使用 using namespace std 是为了让初学者更容易理解代码
// 在大型项目中,推荐使用 std:: 前缀
using namespace std;

int main() {
    // 关闭同步流,加速输入输出
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    cin >> n;

    // 定义一个小根堆,因为题目要求我们处理最小值
    priority_queue<int, vector<int>, greater<int>> min_heap;

    for (int i = 1; i <= n; ++i) {
        int op;
        cin >> op;

        if (op == 1) {
            int x;
            cin >> x;
            min_heap.push(x);
        } else if (op == 2) {
            // top() 操作返回的是最小的元素
            cout << min_heap.top() << "\n";
        } else { // op == 3
            // pop() 操作会删除最小的元素
            min_heap.pop();
        }
    }

    return 0;
}

解题思路

  1. 读取操作总数 \(N\)。
  2. 创建一个 int 类型的小根堆 min_heap,因为我们需要对最小值进行操作。
  3. 循环 \(N\) 次,每次读取一个操作指令 op
  4. 如果 op 是 \(1\),就再读取一个整数 \(x\),并调用 min_heap.push(x) 将其插入堆中。
  5. 如果 op 是 \(2\),就调用 min_heap.top() 获取堆顶(即最小值)并输出。
  6. 如果 op 是 \(3\),就调用 min_heap.pop() 删除堆顶元素。

通过这个模板题,我们可以看到 priority_queue 的强大和便捷。它将复杂的堆操作封装起来,让我们能更专注于解决问题本身的逻辑。

1.5 【6】ST表 (Sparse Table)

1.5.1 什么是 ST 表?

ST 表(Sparse Table,稀疏表)是一种用于高效解决 静态区间查询 问题的数据结构。最经典的应用是 区间最值查询(Range Maximum/Minimum Query, RMQ)

设想这样一个问题:给定一个长度为 \(N\) 的固定数组 \(A\),接下来会有 \(M\) 次询问,每次询问会给出两个下标 \(l\) 和 \(r\),要求你回答数组 \(A\) 在区间 \([l, r]\) 内的最大值(或最小值)是多少。

  • 静态 :这个词非常重要,它意味着数组 \(A\) 的元素在所有查询过程中都不会被修改。如果数组元素会发生变化,ST 表就不再适用了,需要使用线段树等更复杂的数据结构。
  • 区间查询:询问的对象是数组的一个连续子区间。

如果用最朴素的暴力方法,每次询问都遍历一次区间 \([l, r]\),那么单次查询的时间复杂度是 \(O(N)\),总时间复杂度是 \(O(N \times M)\)。当 \(N\) 和 \(M\) 都很大时(例如 \(10^5\)),这种方法会严重超时。

ST 表通过 \(O(N \log N)\) 的预处理,可以实现 \(O(1)\) 的单次查询。这种用预处理时间换取查询时间的思想,在算法竞赛中非常常见。

1.5.2 ST 表的核心思想:倍增

ST 表的核心思想是 倍增(Binary Lifting) 。倍增是一种"以 \(2\) 的幂次划分问题"的思想。比如,我们要从点 \(A\) 跳到点 \(B\),距离为 \(13\)。我们可以先跳 \(8\) (\(2^3\)),再跳 \(4\) (\(2^2\)),再跳 \(1\) (\(2^0\)),总共 \(3\) 次就跳到了。任何一个整数都可以被拆分成若干个 \(2\) 的幂次之和,这就是倍增思想的根基。

ST 表正是利用了这一点。它通过预处理,计算出数组中所有"长度为 \(2\) 的幂次"的区间的最大值。

我们定义一个二维数组 st[i][j],它表示的含义是:从数组下标 \(i\) 开始,长度为 \(2^j\) 的连续区间的最大值

也就是说:
st[i][j] = max(A[i], A[i+1], ..., A[i + 2^j - 1])

有了这个定义,我们来看看如何计算 st 数组。

  • 基础情况 (Base Case) :当 \(j=0\) 时,区间的长度是 \(2^0 = 1\)。所以 st[i][0] 就表示从下标 \(i\) 开始,长度为 \(1\) 的区间的最大值,这显然就是 A[i] 本身。
    st[i][0] = A[i]

  • 递推关系 (Recurrence Relation) :如何计算 st[i][j] 呢?一个长度为 \(2^j\) 的大区间,可以被精确地拆分成两个长度为 \(2^{j-1}\) 的、相邻的小区间。

    • 第一个小区间:从 \(i\) 开始,长度为 \(2^{j-1}\)。它的最大值是 st[i][j-1]
    • 第二个小区间:从 \(i + 2^{j-1}\) 开始,长度为 \(2^{j-1}\)。它的最大值是 st[i + 2^(j-1)][j-1]

    整个大区间的最大值,就是这两个小区间最大值的较大者。

    于是,我们得到了 ST 表的核心递推公式:
    st[i][j] = max(st[i][j-1], st[i + 2^(j-1)][j-1])

通过这个公式,我们就可以构建出整个 ST 表。

1.5.3 ST 表的构建(预处理)

构建过程就是填充 st 数组。根据递推公式,我们需要先知道所有长度为 \(2^{j-1}\) 的区间的最值,才能计算长度为 \(2^j\) 的区间。所以,我们应该把 \(j\) 作为外层循环。

伪代码:Build(A, N)

复制代码
// 预计算 log2 的值,可以加速查询
log_table[1] = 0
for i from 2 to N:
    log_table[i] = log_table[i / 2] + 1

// 初始化 j=0 的情况
for i from 1 to N:
    st[i][0] = A[i]

// j 从 1 开始,表示区间长度从 2^1=2 开始
for j from 1 up to log_table[N]:
    // i 的范围要保证区间 [i, i + 2^j - 1] 不越界
    for i from 1 up to N - (1 << j) + 1:
        st[i][j] = max(st[i][j-1], st[i + (1 << (j-1))][j-1])

预处理的时间复杂度是 \(O(N \log N)\),因为外层循环 \(\log N\) 次,内层循环 \(N\) 次。空间复杂度也是 \(O(N \log N)\)。

C++ 代码模板 (构建)

cpp 复制代码
// 假设 MAXN 是数组最大长度,LOGN 是 log2(MAXN) 的最大值
const int MAXN = 200005;
const int LOGN = 18; // log2(200005) 约等于 17.6

int a[MAXN];
int st[MAXN][LOGN];
int log_table[MAXN];

void build(int n) {
    // 预处理 log_table
    log_table[1] = 0;
    for (int i = 2; i <= n; i++) {
        log_table[i] = log_table[i / 2] + 1;
    }

    // 初始化 j=0
    for (int i = 1; i <= n; i++) {
        st[i][0] = a[i];
    }

    // 递推计算
    for (int j = 1; j <= log_table[n]; j++) {
        for (int i = 1; i + (1 << j) - 1 <= n; i++) {
            // st[i][j] = max(st[i][j-1], st[i + 2^(j-1)][j-1])
            st[i][j] = max(st[i][j - 1], st[i + (1 << (j - 1))][j - 1]);
        }
    }
}

1.5.4 ST 表的查询

预处理完成后,如何进行 \(O(1)\) 的查询呢?

假设我们要查询区间 \([l, r]\) 的最大值。区间长度为 len = r - l + 1

我们可以找到一个最大的整数 \(k\),使得 \(2^k \le \text{len}\)。这个 \(k\) 可以通过 log_table[len] 快速得到。

现在,我们考虑两个长度为 \(2^k\) 的区间:

  1. 第一个区间:从 \(l\) 开始,长度为 \(2^k\)。即 \([l, l+2^k-1]\)。它的最大值是 st[l][k]
  2. 第二个区间:从 \(r-2^k+1\) 开始 ,长度为 \(2^k\)。即 \([r-2^k+1, r]\)。它的最大值是 st[r - (1 << k) + 1][k]

由于我们选择的 \(k\) 满足 \(2^k \le \text{len}\),所以 \(l+2^k-1 < r+1\) 和 \(r-2^k+1 > l-1\)。这意味着这两个区间必然会覆盖 整个 \([l, r]\) 区间。它们可能会在中间有重叠部分。

对于求最大值(或最小值)的操作,一个元素被重复计算多次是不会影响最终结果的(例如 max(a, b, b, c) = max(a, b, c))。这种性质被称为 幂等性(Idempotence)

因此,区间 \([l, r]\) 的最大值就等于这两个覆盖区间的最大值的较大者。

查询公式:
query(l, r) = max(st[l][k], st[r - 2^k + 1][k])

其中 k = log_table[r - l + 1]

由于 k 的计算和数组的访问都是 \(O(1)\) 的,所以整个查询操作的时间复杂度是 \(O(1)\)。

C++ 代码模板 (查询)

cpp 复制代码
int query(int l, int r) {
    int len = r - l + 1;
    int k = log_table[len];
    return max(st[l][k], st[r - (1 << k) + 1][k]);
}

1.5.5 洛谷例题:P3865 【模板】ST表

这道题是 RMQ 问题的标准模板,用于检验对 ST 表的掌握程度。

题目描述

给定一个长度为 \(N\) 的数列,和 \(M\) 次询问,求出每一次询问的区间 \([l, r]\) 中的最大值。

分析

这道题是静态区间最值查询,完全符合 ST 表的应用场景。我们只需要按照上面学习的步骤,先构建 ST 表,然后对于每次询问,调用查询函数即可。

C++ 完整代码

cpp 复制代码
#include <iostream>
#include <vector>
#include <cmath>
#include <algorithm>

using namespace std;

const int MAXN = 100005;
const int LOGN = 17; // log2(100005) 约等于 16.6

// st[i][j] 表示从 i 开始,长度为 2^j 的区间的最大值
int st[MAXN][LOGN + 1];
int log_table[MAXN];

// 预处理 log 表
void precompute_log(int n) {
    log_table[1] = 0;
    for (int i = 2; i <= n; i++) {
        log_table[i] = log_table[i / 2] + 1;
    }
}

// 构建 ST 表
void build(int n) {
    // j=0 的情况已经在主函数读入时处理
    for (int j = 1; j <= LOGN; j++) {
        for (int i = 1; i + (1 << j) - 1 <= n; i++) {
            st[i][j] = max(st[i][j - 1], st[i + (1 << (j - 1))][j - 1]);
        }
    }
}

// 查询 [l, r] 区间的最大值
int query(int l, int r) {
    int k = log_table[r - l + 1];
    return max(st[l][k], st[r - (1 << k) + 1][k]);
}

int main() {
    // 加速输入输出
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, m;
    cin >> n >> m;

    precompute_log(n);

    // 读入原始数据,并初始化 st 表 j=0 的情况
    for (int i = 1; i <= n; i++) {
        cin >> st[i][0];
    }

    // O(N log N) 预处理
    build(n);

    // M 次 O(1) 查询
    for (int i = 1; i <= m; i++) {
        int l, r;
        cin >> l >> r;
        cout << query(l, r) << "\n";
    }

    return 0;
}

解题思路

  1. 定义好 st 数组和 log_table 数组。
  2. main 函数中,首先读入 \(N\) 和 \(M\)。
  3. 调用 precompute_log(n) 预先计算好所有需要的 \(\log_2\) 值,避免在查询时重复计算。
  4. 读入 \(N\) 个数,直接存入 st[i][0],完成 ST 表的基础情况初始化。
  5. 调用 build(n) 函数,完成整个 ST 表的预处理。
  6. 循环 \(M\) 次,每次读入查询区间 \([l, r]\),调用 query(l, r) 函数计算并输出结果。

ST 表是一个优雅且高效的数据结构,它将倍增思想和动态规划的预处理结合起来,是信息学竞赛中解决静态 RMQ 问题的有力武器。

第二节 集合与特殊树

2.1 【6】并查集

2.1.1 什么是并查集?

并查集(Disjoint Set Union, DSU)是一种非常精妙的树形数据结构,主要用于处理一些不相交集合(Disjoint Sets)的合并(Union)及查询(Find)问题。

想象一下,在江湖中有许多侠客,一开始每个人都自成一派。然后,一些侠客之间会结成联盟,形成一个个小门派。随着时间的推移,一些小门派之间又会合并,形成更大的门派。在这个过程中,我们经常需要关心两个问题:
\(1\). 查询(Find) :某位侠客现在属于哪个门派?或者说,两位侠客是否属于同一个门派?
\(2\). 合并(Union):将两个不同的门派合并成一个。

并查集就是解决这类问题的利器。它能以接近 \(O(1)\) 的惊人效率来处理这两个操作。

2.1.2 并查集的实现原理

并查集的核心思想是用一个数组 fa (father) 来维护一个"代表元"构成的森林。每个集合被表示为一棵树,树的根节点就是这个集合的"代表元"(或者叫"掌门人")。

  • 数据结构 :我们使用一个整型数组 fa[]fa[i] 存储的是元素 \(i\) 的父节点。
  • 规定 :如果一个元素的父节点是它自己,即 fa[i] == i,那么这个元素就是它所在集合的代表元(根节点)。

1. 初始化

最开始,每个元素都各自属于一个独立的集合。就像江湖之初,每个侠客都自成一派,自己就是自己的掌门人。所以,对于 \(1\) 到 \(n\) 的所有元素,我们初始化 fa[i] = i

伪代码:

复制代码
procedure Initialize(n)
    for i from 1 to n
        fa[i] ← i
    end for
end procedure

2. 查找(Find)操作

查找操作的目标是找到一个元素所在集合的代表元。这就像去问一个侠客:"你的掌门人是谁?"如果他不是掌门人,他会指向他的"上级",我们再去问他的"上级",如此反复,直到找到那个"上级"就是自己的那个人,他就是最终的掌门人。

这个过程在数据结构中,就是从一个节点出发,不断地沿着父节点指针 fa 向上寻找,直到找到根节点(即 fa[x] == x 的那个节点 \(x\))为止。

伪代码 (朴素版):

复制代码
function Find(x)
    while fa[x] ≠ x
        x ← fa[x]
    end while
    return x
end function

3. 合并(Union)操作

合并操作是将两个元素所在的集合合并成一个。比如,我们要合并侠客 \(x\) 和侠客 \(y\) 所在的门派。

步骤很简单:
\(1\). 先分别找到 \(x\) 和 \(y\) 的掌门人,记为 root_xroot_y
\(2\). 如果他们已经是同一个掌门人 (root_x == root_y),说明他们本就在同一个门派,无需任何操作。
\(3\). 如果他们不是同一个掌门人,那么就将一个门派并入另一个。例如,我们可以让 root_x 的掌门人变成 root_y,即 fa[root_x] = root_y。这样,原来所有属于 root_x 门派的成员,通过他们的查找路径,最终也都能找到 root_y 这位新的总掌门人。

伪代码 (朴素版):

复制代码
procedure Union(x, y)
    root_x ← Find(x)
    root_y ← Find(y)
    if root_x ≠ root_y then
        fa[root_x] ← root_y
    end if
end procedure

2.1.3 并查集的优化

上述朴素的实现方法在某些极端情况下效率会很低。例如,如果我们将 \(1, 2, 3, \dots, n\) 依次合并,可能会形成一条长长的链:fa[1]=2, fa[2]=3, ..., fa[n-1]=n。此时,查找元素 \(1\) 的代表元需要一步步走到 \(n\),时间复杂度为 \(O(n)\),这显然不是我们想要的。因此,必须进行优化。

1. 路径压缩(Path Compression)

这是并查集最重要的优化。核心思想是:在查找一个元素的根节点的过程中,将这条查找路径上的所有节点都直接指向根节点。

这样做的好处是,下次再查找这些节点时,只需要一步就能找到根节点。这极大地压缩了树的高度,使其变得非常扁平。

伪代码 (路径压缩版 Find):

这个优化可以用递归非常优雅地实现。

复制代码
function Find(x)
    if fa[x] == x then
        return x
    else
        fa[x] ← Find(fa[x])  // 在回溯时将沿途节点的父节点设为根节点
        return fa[x]
    end if
end function

2. 按秩合并(Union by Rank/Size)

另一个优化是在合并时进行的。当我们合并 root_xroot_y 两个集合时,是把 \(x\) 的树接到 \(y\) 上,还是反过来?一个启发式的想法是:总是将较小的树合并到较大的树上。这样可以有效地避免树的高度增长过快。

"大小"可以用两种方式来衡量:

  • 按大小(Size)合并 :用一个数组 siz[] 记录以每个节点为根的树的大小(节点数)。合并时,将节点数少的树的根节点,连接到节点数多的树的根节点上。
  • 按秩(Rank)合并 :用一个数组 rnk[] 记录以每个节点为根的树的高度(秩)。合并时,将高度低的树的根,连接到高度高的树的根上。

实践中,按大小合并 更为常用且实现简单。

伪代码 (按大小合并版 Union):

复制代码
procedure Initialize(n)
    for i from 1 to n
        fa[i] ← i
        siz[i] ← 1  // 初始化每个集合大小为 1
    end for
end procedure

procedure Union(x, y)
    root_x ← Find(x)
    root_y ← Find(y)
    if root_x ≠ root_y then
        // 将小树合并到大树
        if siz[root_x] < siz[root_y] then
            fa[root_x] ← root_y
            siz[root_y] ← siz[root_y] + siz[root_x]
        else
            fa[root_y] ← root_x
            siz[root_x] ← siz[root_x] + siz[root_y]
        end if
    end if
end procedure

复杂度分析

同时使用路径压缩和按秩(或按大小)合并的并查集,其单次操作的平均时间复杂度可以证明是 \(O(\alpha(n))\),其中 \(\alpha(n)\) 是反阿克曼函数。这个函数的增长速度极其缓慢,对于在宇宙中所有可观测到的原子数量级别的 \(n\),\(\alpha(n)\) 的值都不会超过 \(5\)。因此,我们可以认为其时间复杂度接近常数级别 \(O(1)\),效率极高。

2.1.4 C++ 代码模板

下面是一个包含了路径压缩和按大小合并的并查集模板。

cpp 复制代码
#include <iostream>
#include <vector>

using namespace std;

const int MAXN = 100005; // 假设元素数量不超过 100005

int fa[MAXN]; // 存储父节点
int siz[MAXN]; // 存储以该节点为根的集合的大小

// 初始化 1 到 n 的并查集
void init(int n) {
    for (int i = 1; i <= n; ++i) {
        fa[i] = i;
        siz[i] = 1;
    }
}

// 查找x的根节点(带路径压缩)
int find(int x) {
    if (fa[x] == x) {
        return x;
    }
    // 递归查找根节点,同时将路径上的节点直接指向根节点
    return fa[x] = find(fa[x]);
}

// 合并x和y所在的集合(按大小合并)
void unite(int x, int y) {
    int root_x = find(x);
    int root_y = find(y);

    if (root_x != root_y) {
        // 将小集合合并到大集合
        if (siz[root_x] < siz[root_y]) {
            fa[root_x] = root_y;
            siz[root_y] += siz[root_x];
        } else {
            fa[root_y] = root_x;
            siz[root_x] += siz[root_y];
        }
    }
}

// 判断x和y是否在同一个集合
bool is_same_set(int x, int y) {
    return find(x) == find(y);
}

int main() {
    // 这是一个使用示例,并非完整题目代码
    int n = 10; // 假设有 10 个元素
    init(n);

    unite(1, 2);
    unite(3, 4);
    unite(1, 3);

    if (is_same_set(2, 4)) {
        cout << "元素2和元素4在同一个集合中。" << endl;
    } else {
        cout << "元素2和元素4不在同一个集合中。" << endl;
    }

    if (is_same_set(1, 5)) {
        cout << "元素1和元素5在同一个集合中。" << endl;
    } else {
        cout << "元素1和元素5不在同一个集合中。" << endl;
    }

    return 0;
}

2.1.5 洛谷例题精讲

题目:P3367 【模板】并查集

题目描述

如题,现在有 \(N\) 个元素,编号从 \(1\) 到 \(N\)。你需要进行 \(M\) 次操作,操作有两种:
\(1\). 1 x y:将元素 \(x\) 和元素 \(y\) 所在的集合合并。
\(2\). 2 x y:询问元素 \(x\) 和元素 \(y\) 是否在同一个集合中。

输入格式

第一行包含两个整数 \(N, M\),表示有 \(N\) 个元素和 \(M\) 次操作。

接下来 \(M\) 行,每行包含三个整数 \(Z, X, Y\)。

当 \(Z=1\) 时,将 \(X\) 和 \(Y\) 所在的集合合并。

当 \(Z=2\) 时,询问 \(X\) 和 \(Y\) 是否在同一个集合中。

输出格式

对于每个 \(Z=2\) 的操作,如果 \(X\) 和 \(Y\) 在同一个集合中,输出一行 Y;否则输出一行 N

分析

这道题是并查集最直接、最经典的应用。

  • \(N\) 个元素对应并查集中的 \(N\) 个节点。
  • 操作 1 x y 就是调用 unite(x, y) 函数。
  • 操作 2 x y 就是判断 find(x) 是否等于 find(y),即调用 is_same_set(x, y) 函数。

我们直接套用上面给出的带有路径压缩和按大小合并的模板即可解决。

题解代码

cpp 复制代码
#include <iostream>

using namespace std;

const int MAXN = 10001; // 题目数据范围 N <= 10000

int fa[MAXN];
int siz[MAXN]; // 按大小合并的辅助数组,本题也可不用,但好习惯

void init(int n) {
    for (int i = 1; i <= n; ++i) {
        fa[i] = i;
        siz[i] = 1; // 即使不用按大小合并,初始化fa数组是必须的
    }
}

int find(int x) {
    if (fa[x] == x) {
        return x;
    }
    return fa[x] = find(fa[x]); // 路径压缩
}

void unite(int x, int y) {
    int root_x = find(x);
    int root_y = find(y);
    if (root_x != root_y) {
        // 直接合并,不使用按大小合并也足以通过本题
        fa[root_x] = root_y;
    }
}

int main() {
    // 为了提高读写效率
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, m;
    cin >> n >> m;

    init(n);

    for (int i = 0; i < m; ++i) {
        int z, x, y;
        cin >> z >> x >> y;
        if (z == 1) {
            unite(x, y);
        } else { // z == 2
            if (find(x) == find(y)) {
                cout << "Y" << '\n';
            } else {
                cout << "N" << '\n';
            }
        }
    }

    return 0;
}

2.2 【6】二叉堆

2.2.1 什么是二叉堆?

二叉堆(Binary Heap)是一种特殊的树形数据结构。尽管它的名字里有"二叉"二字,但它通常不是用指针链接的二叉树来实现的,而是用数组 来实现。二叉堆是一个满足以下两个性质的完全二叉树

\(1\). 结构性 :它必须是一棵完全二叉树 。完全二叉树指的是,除了最后一层外,其他各层节点数都达到最大,并且最后一层的节点都连续集中在左侧。这个性质使得它能被高效地存储在数组中。
\(2\). 堆序性(Heap Property) :树中任意一个节点的值都必须不大于(或不小于)其所有子节点的值。

* 如果任意节点的值都不大于 其子节点的值,我们称之为小根堆 (Min-Heap)。堆顶元素是整个堆中的最小值。

* 如果任意节点的值都不小于 其子节点的值,我们称之为大根堆(Max-Heap)。堆顶元素是整个堆中的最大值。

二叉堆最常见的应用是实现优先队列(Priority Queue),它允许我们快速地插入一个元素,并快速地查询和删除当前集合中的最值(最大值或最小值)。

2.2.2 二叉堆的实现原理

1. 数组存储

由于二叉堆是完全二叉树,我们可以用一个数组来存储它,而不需要指针。节点之间的父子关系可以通过数组下标的计算来确定。通常我们选择从下标 \(1\) 开始存储,这样关系会非常简洁:

  • 节点 \(i\) 的父节点是 \(\lfloor i/2 \rfloor\)。
  • 节点 \(i\) 的左子节点是 \(2 \times i\)。
  • 节点 \(i\) 的右子节点是 \(2 \times i + 1\)。

2. 核心操作

我们以小根堆为例来讲解其核心操作。大根堆的原理完全对称。

  • 插入元素(push)

    1. 将新元素放到数组的末尾(即完全二叉树最后一个位置的后面)。
    2. 新元素可能会破坏堆序性。因此,需要将它与它的父节点比较。如果它比父节点小,就交换它们的位置。
    3. 重复这个过程,不断地将该元素向上"调整",直到它不再小于其父节点,或者它已经到达了堆顶(下标为 \(1\) 的位置)。这个过程称为上浮(sift-up)
  • 删除堆顶元素(pop)

    1. 堆顶元素(数组下标为 \(1\) 的元素)是最小值,我们想删除它。
    2. 为了保持完全二叉树的结构,我们不能直接删除堆顶。正确的做法是:将数组的最后一个元素 移动到堆顶的位置,并让堆的大小减 \(1\)。
    3. 此时,新的堆顶元素很可能不满足堆序性(它可能比它的子节点大)。
    4. 我们需要将这个新的堆顶元素向下"调整"。将它与它的左右子节点中较小的一个进行比较。如果它比这个较小的子节点大,就交换它们的位置。
    5. 重复这个过程,不断地将该元素向下"调整",直到它的所有子节点都比它大,或者它已经成为叶子节点。这个过程称为下沉(sift-down)

复杂度分析

二叉堆是一棵近似平衡的二叉树,其高度为 \(O(\log n)\)。无论是上浮还是下沉操作,元素移动的距离最多就是树的高度。因此,插入和删除操作的时间复杂度都是 \(O(\log n)\)。获取堆顶元素(最值)的操作,只需要访问数组的第一个元素,时间复杂度为 \(O(1)\)。

2.2.3 C++ 代码模板

下面是一个手写的小根堆模板。

cpp 复制代码
#include <iostream>
#include <vector>
#include <algorithm>

using namespace std;

const int MAXN = 1000005;

int h[MAXN]; // 数组模拟堆,从下标1开始
int heap_size = 0; // 堆中元素的数量

// 上浮操作,调整位置idx的元素
void sift_up(int idx) {
    // 当idx不是根节点(idx > 1) 且 当前节点比父节点小
    while (idx > 1 && h[idx] < h[idx / 2]) {
        swap(h[idx], h[idx / 2]);
        idx /= 2; // 继续向上看
    }
}

// 下沉操作,调整位置idx的元素
void sift_down(int idx) {
    while (2 * idx <= heap_size) { // 保证idx有左孩子
        int son = 2 * idx; // son先指向左孩子
        // 如果右孩子存在,且比左孩子更小,则son指向右孩子
        if (son + 1 <= heap_size && h[son + 1] < h[son]) {
            son++;
        }
        
        // 如果当前节点比它的两个孩子中较小的那个还要小,则满足堆序性,停止下沉
        if (h[idx] <= h[son]) {
            break;
        }
        
        // 否则,交换并继续下沉
        swap(h[idx], h[son]);
        idx = son;
    }
}

// 插入一个元素
void push(int val) {
    heap_size++;
    h[heap_size] = val;
    sift_up(heap_size);
}

// 删除堆顶元素
void pop() {
    if (heap_size == 0) return; // 堆为空
    h[1] = h[heap_size]; // 将最后一个元素移到堆顶
    heap_size--;
    sift_down(1); // 对新的堆顶执行下沉操作
}

// 获取堆顶元素
int top() {
    if (heap_size > 0) {
        return h[1];
    }
    return -1; // 表示堆为空
}

// 检查堆是否为空
bool empty() {
    return heap_size == 0;
}

int main() {
    // 使用示例
    push(30);
    push(10);
    push(20);
    push(5);

    while (!empty()) {
        cout << top() << " ";
        pop();
    }
    // 输出应为: 5 10 20 30
    cout << endl;

    return 0;
}

提示 :在 C++ 的 STL(标准模板库)中,已经提供了 priority_queue 容器,它底层就是用二叉堆实现的。在竞赛中,为了快速开发,可以直接使用 priority_queue。但理解并能手写二叉堆是信息学学习中非常重要的一环。

cpp 复制代码
#include <queue> // 使用STL中的priority_queue
// priority_queue<int> max_heap; // 默认是大根堆
// priority_queue<int, vector<int>, greater<int>> min_heap; // 小根堆

2.2.4 洛谷例题精讲

题目:P3378 【模板】堆

题目描述

你需要实现一个数据结构,支持以下 \(3\) 种操作:
\(1\). 1 x:插入一个数 \(x\)。
\(2\). 2:输出当前集合中最小的数。
\(3\). 3:删除当前集合中最小的数。

输入格式

第一行一个整数 \(N\),表示操作的总数。

接下来 \(N\) 行,每行包含 \(1\) 或 \(2\) 个整数,表示一个操作。

输出格式

对于每个操作 \(2\),输出一个对应的答案。

分析

这道题完美地对应了小根堆的三个基本操作:

  • 操作 1 x:对应堆的 push(x) 操作。
  • 操作 2:对应堆的 top() 操作。
  • 操作 3:对应堆的 pop() 操作。

因此,我们可以直接使用手写的二叉堆模板或者 STL 中的 priority_queue 来解决。

题解代码 (使用手写模板)

cpp 复制代码
#include <iostream>
#include <algorithm>

using namespace std;

const int MAXN = 1000005;

int h[MAXN];
int heap_size = 0;

void sift_up(int idx) {
    while (idx > 1 && h[idx] < h[idx / 2]) {
        swap(h[idx], h[idx / 2]);
        idx /= 2;
    }
}

void sift_down(int idx) {
    while (2 * idx <= heap_size) {
        int son = 2 * idx;
        if (son + 1 <= heap_size && h[son + 1] < h[son]) {
            son++;
        }
        if (h[idx] <= h[son]) {
            break;
        }
        swap(h[idx], h[son]);
        idx = son;
    }
}

void push(int val) {
    heap_size++;
    h[heap_size] = val;
    sift_up(heap_size);
}

void pop() {
    if (heap_size > 0) {
        h[1] = h[heap_size];
        heap_size--;
        sift_down(1);
    }
}

int top() {
    if (heap_size > 0) {
        return h[1];
    }
    return -1; // 题目保证操作合法,不会在空堆时查询
}

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    cin >> n;

    for (int i = 0; i < n; ++i) {
        int op;
        cin >> op;
        if (op == 1) {
            int x;
            cin >> x;
            push(x);
        } else if (op == 2) {
            cout << top() << '\n';
        } else { // op == 3
            pop();
        }
    }

    return 0;
}

2.3 【6】树状数组

2.3.1 什么是树状数组?

树状数组(Binary Indexed Tree, BIT),又称芬威克树(Fenwick Tree),是一种能够高效地完成以下两种操作的数据结构:
\(1\). 单点修改 :给序列中的某一个元素加上一个值(更新)。
\(2\). 区间查询 :查询序列中某一个前缀的和(例如,从第 \(1\) 个元素到第 \(k\) 个元素的和)。

如果用普通数组来处理,单点修改是 \(O(1)\),但查询前缀和是 \(O(n)\);如果用前缀和数组,查询是 \(O(1)\),但每次单点修改都需要更新后续所有前缀和,是 \(O(n)\)。树状数组则可以将这两种操作的时间复杂度都优化到 \(O(\log n)\)。

树状数组的思想非常巧妙,它通过一种特殊的二进制划分,让每个节点管理一个特定长度的区间,从而实现了对数级的效率。

2.3.2 树状数组的实现原理

1. lowbit 操作

树状数组的精髓在于 lowbit 操作。lowbit(x) 函数返回 \(x\) 的二进制表示中,最低位的 \(1\) 以及它后面的所有 \(0\) 组成的数。例如:

  • \(x = 6\),二进制是 110。最低位的 \(1\) 在第二位,所以 lowbit(6)10(二进制),即 \(2\)。
  • \(x = 12\),二进制是 1100。最低位的 \(1\) 在第三位,所以 lowbit(12)100(二进制),即 \(4\)。

在计算机中,利用位运算可以非常快速地计算 lowbit
lowbit(x) = x & (-x)

这是一个基于补码表示的位运算技巧。

2. 树状数组的结构

我们用一个数组 C[] 来表示树状数组。C[x] 存储的是原数组 A 中一个特定区间的和。这个区间是 (x - lowbit(x), x],即从 x - lowbit(x) + 1x,长度为 lowbit(x)

例如:

  • C[1] 管辖 A[1]
  • C[2] 管辖 A[2]A[1]。区间是 (2-lowbit(2), 2](0, 2]
  • C[3] 管辖 A[3]
  • C[4] 管辖 A[1]A[4]。区间是 (4-lowbit(4), 4](0, 4]
  • C[6] 管辖 A[5]A[6]。区间是 (6-lowbit(6), 6](4, 6]

3. 单点修改(add)

当我们要给原数组的 A[x] 加上一个值 k 时,我们需要更新所有管辖了 A[x]C 数组的节点。

哪些节点管辖了 A[x] 呢?可以发现,需要更新的节点是 C[x], C[x + lowbit(x)], C[x + lowbit(x) + lowbit(x + lowbit(x))], ...

所以,修改操作就是从 \(x\) 开始,不断地 x = x + lowbit(x),并对相应的 C[x] 加上 k,直到 \(x\) 超出数组范围。

伪代码:

复制代码
procedure Add(x, k)
    while x ≤ n
        C[x] ← C[x] + k
        x ← x + lowbit(x)
    end while
end procedure

4. 前缀和查询(query)

当我们要查询前缀和 Sum(x),即 A[1] + A[2] + ... + A[x] 时,我们可以通过组合 C 数组中的若干个节点来得到。
Sum(x) = C[x] + C[x - lowbit(x)] + C[x - lowbit(x) - lowbit(x - lowbit(x))] + ...

查询操作就是从 \(x\) 开始,不断地 x = x - lowbit(x),累加 C[x] 的值,直到 \(x\) 变成 \(0\)。

伪代码:

复制代码
function Query(x)
    sum ← 0
    while x > 0
        sum ← sum + C[x]
        x ← x - lowbit(x)
    end while
    return sum
end function

复杂度分析

由于每次 addquery 操作,x 的二进制表示中至少会消去一个最低位的 \(1\),而 \(n\) 的二进制表示最多有 \(\log n\) 位,所以这两个操作的时间复杂度都是 \(O(\log n)\)。

区间和查询

如果要查询区间 [L, R] 的和,可以利用前缀和的思想,结果为 Query(R) - Query(L - 1)

2.3.3 C++ 代码模板

cpp 复制代码
#include <iostream>

using namespace std;

const int MAXN = 500005;

int n; // 数组大小
long long c[MAXN]; // 树状数组,注意数据范围可能需要long long

// lowbit函数
int lowbit(int x) {
    return x & (-x);
}

// 单点增加:给A[x]增加k
void add(int x, int k) {
    while (x <= n) {
        c[x] += k;
        x += lowbit(x);
    }
}

// 前缀和查询:查询A[1]到A[x]的和
long long query(int x) {
    long long sum = 0;
    while (x > 0) {
        sum += c[x];
        x -= lowbit(x);
    }
    return sum;
}

int main() {
    // 使用示例
    n = 10;
    // 假设原数组 A = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
    // 初始化树状数组
    for (int i = 1; i <= n; ++i) {
        int val;
        // 假设这里能读到原数组的值
        // cin >> val;
        val = i;
        add(i, val);
    }

    // 查询前5个数的和 (1+2+3+4+5 = 15)
    cout << "Sum of first 5 elements: " << query(5) << endl;

    // 给第3个数加上5 (A[3]从3变为8)
    add(3, 5);
    
    // 再次查询前5个数的和 (1+2+8+4+5 = 20)
    cout << "Sum of first 5 elements after update: " << query(5) << endl;
    
    // 查询区间[3, 7]的和
    // A数组现在是 {1, 2, 8, 4, 5, 6, 7, 8, 9, 10}
    // Sum[3,7] = 8+4+5+6+7 = 30
    // query(7) - query(2)
    cout << "Sum of range [3, 7]: " << query(7) - query(2) << endl;
    
    return 0;
}

2.3.4 洛谷例题精讲

题目:P3374 【模板】树状数组 1

题目描述

已知一个数列,你需要进行下面两种操作:
\(1\). 1 x k:将第 \(x\) 个数加上 \(k\)。
\(2\). 2 x y:输出区间 [x, y] 内每个数的和。

输入格式

第一行包含两个整数 \(N, M\),分别表示该数列数字的个数和操作的总个数。

第二行包含 \(N\) 个用空格分隔的整数,其中第 \(i\) 个数字表示数列第 \(i\) 项的初始值。

接下来 \(M\) 行每行包含 \(3\) 个整数,表示一个操作。

分析

这道题是树状数组的直接应用。

  • 初始化 :题目给出了初始数列。我们可以遍历一遍初始数列,对每个数 A[i],调用一次 add(i, A[i]) 来构建树状数组。
  • 操作 1 x k:这对应了树状数组的单点修改,直接调用 add(x, k) 即可。
  • 操作 2 x y:这对应了树状数组的区间查询,结果为 query(y) - query(x - 1)

题解代码

cpp 复制代码
#include <iostream>

using namespace std;

const int MAXN = 500005;

int n, m;
long long c[MAXN];

int lowbit(int x) {
    return x & (-x);
}

void add(int x, int k) {
    while (x <= n) {
        c[x] += k;
        x += lowbit(x);
    }
}

long long query(int x) {
    long long sum = 0;
    while (x > 0) {
        sum += c[x];
        x -= lowbit(x);
    }
    return sum;
}

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    cin >> n >> m;

    // 初始化树状数组
    for (int i = 1; i <= n; ++i) {
        int val;
        cin >> val;
        add(i, val);
    }

    for (int i = 0; i < m; ++i) {
        int op, x, y;
        cin >> op >> x >> y;
        if (op == 1) {
            add(x, y); // y在这里是增量k
        } else { // op == 2
            cout << query(y) - query(x - 1) << '\n';
        }
    }

    return 0;
}

2.4 【6】线段树

2.4.1 什么是线段树?

在程序设计竞赛中,我们经常会遇到一类关于 区间(Interval) 的问题。例如,给定一个序列,要求你反复地对序列中的某一个区间进行操作(比如求和、求最大值),或者修改序列中某个元素的值。

如果序列的长度是 \(N\) ,每次查询的区间长度是 \(M\) 。一个朴素的想法是,每次查询都用一个循环遍历区间 [L, R],时间复杂度为 \(O(M)\) 。如果需要修改,就直接修改数组中的值,时间复杂度为 \(O(1)\)。但如果查询次数非常多,比如达到 \(Q\) 次,总时间复杂度可能高达 \(O(Q \times N)\),这在 \(N\) 和 \(Q\) 都很大(例如 \(10^5\))时是无法接受的。

线段树(Segment Tree)是一种专门用于解决这类问题的二叉树结构。它能够在 \(O(\log N)\) 的时间内完成区间查询和单点修改,极大地提高了效率。

顾名思义,线段树是用来存放"线段"或"区间"信息的树。它的每个节点都代表了原始序列中的一个区间。

2.4.2 线段树的结构

线段树是一棵完美的二叉树(Complete Binary Tree)

  1. 根节点 代表整个序列的区间,例如 [1, N]
  2. 树中的每一个非叶子节点 [L, R],它的左子节点代表的区间是 [L, M],右子节点代表的区间是 [M+1, R],其中 \(M = \lfloor \frac{L+R}{2} \rfloor\) (即 \(L\) 和 \(R\) 的中点,向下取整)。这种"一分为二"的思想,是分治法(Divide and Conquer)的体现。
  3. 叶子节点 代表长度为 1 的区间,即 [i, i],它存储了原始序列中第 \(i\) 个元素的值。

如何存储线段树?

由于线段树是一棵完美的二叉树,所以它非常适合用一个数组来存储。类似堆的存储方式:

  • 根节点的编号为 1。
  • 对于编号为 \(p\) 的节点,它的左子节点编号为 \(2p\)(或写作 p << 1),右子节点编号为 \(2p+1\)(或写作 p << 1 | 1)。

空间大小 :一个长度为 \(N\) 的序列,构建出的线段树大约需要 \(4N\) 的存储空间。这是一个经验性的结论,可以确保在 \(N\) 的任何取值下,树的节点都不会超出数组范围。

2.4.3 线段树的核心操作

线段树主要有三个核心操作:建树(Build)、查询(Query)、修改(Update)。下面我们以最常见的"区间求和"为例进行讲解。

1. 建树 (Build)

建树是一个递归的过程。从根节点(代表区间 [1, N])开始:

  • 如果当前区间 [L, R] 的长度为 1(即 \(L=R\)),说明到达了叶子节点。该节点的值就等于原始序列中 A[L] 的值。
  • 如果 \(L \neq R\),则递归地为左子区间 [L, M] 和右子区间 [M+1, R] 建树。当左右子树都建好后,当前节点的值就等于它两个子节点的值之和。

【伪代码】

复制代码
BUILD(p, L, R)
  // p 是当前节点编号,[L, R] 是它代表的区间
  IF L == R THEN
    tree[p] = A[L]
    RETURN
  END IF
  
  M = (L + R) / 2
  BUILD(2*p, L, M)        // 递归建左子树
  BUILD(2*p+1, M+1, R)    // 递归建右子树
  tree[p] = tree[2*p] + tree[2*p+1] // 用子节点信息更新当前节点

2. 单点修改 (Update)

当需要修改序列中第 idx 个元素的值为 val 时,也需要一个递归过程来更新树中所有包含这个点信息的节点。

  • 从根节点开始查找。
  • 如果当前节点 [L, R] 代表的区间包含了 idx,则继续向下。判断 idx 是在左子区间 [L, M] 还是右子区间 [M+1, R],然后递归地进入相应的子树进行修改。
  • 当递归到达叶子节点 [idx, idx] 时,直接修改它的值。
  • 在递归返回的过程中,沿途更新所有祖先节点的值(等于其左右孩子节点值之和)。

【伪代码】

复制代码
UPDATE(p, L, R, idx, val)
  // p: 当前节点编号, [L,R]: 当前区间
  // idx: 要修改的元素下标, val: 新的值
  IF L == R THEN
    tree[p] = val
    RETURN
  END IF
  
  M = (L + R) / 2
  IF idx <= M THEN
    UPDATE(2*p, L, M, idx, val) // idx 在左子区间
  ELSE
    UPDATE(2*p+1, M+1, R, idx, val) // idx 在右子区间
  END IF
  
  tree[p] = tree[2*p] + tree[2*p+1] // 回溯时更新父节点

3. 区间查询 (Query)

这是线段树最精髓的操作。当要查询区间 [queryL, queryR] 的和时:

  • 从根节点开始。
  • 若当前节点 p 代表的区间 [L, R] 完全被 查询区间 [queryL, queryR] 包含 ,即 queryL <= LR <= queryR,那么这个节点的值就是我们需要的一部分,直接返回 tree[p]
  • 若当前区间与查询区间没有交集,则返回一个不影响结果的初始值(对于求和,是0)。
  • 若当前区间与查询区间有部分交集,则问题无法在当前层解决。需要递归地到它的左右子节点去查询,并将左右子树返回的结果合并(相加)。

【伪代码】

复制代码
QUERY(p, L, R, queryL, queryR)
  // p: 当前节点, [L,R]: 当前区间
  // [queryL, queryR]: 目标查询区间
  
  // 1. 完全包含
  IF queryL <= L AND R <= queryR THEN
    RETURN tree[p]
  END IF
  
  // 2. (隐式) 无交集的情况会在下面递归中处理,最终返回 0
  
  M = (L + R) / 2
  sum = 0
  
  // 3. 部分交集,需要递归
  IF queryL <= M THEN // 左子树与查询区间有交集
    sum = sum + QUERY(2*p, L, M, queryL, queryR)
  END IF
  
  IF queryR > M THEN // 右子树与查询区间有交集
    sum = sum + QUERY(2*p+1, M+1, R, queryL, queryR)
  END IF
  
  RETURN sum

这三个操作的时间复杂度都是 \(O(\log N)\),因为树的高度是 \(O(\log N)\),每次操作都类似在树上从根走到叶子的过程。

2.4.4 进阶:懒惰标记(Lazy Propagation)

上面的修改操作是"单点修改"。如果我们需要对一个区间 进行修改,比如将 [L, R] 内的每个数都加上 val,怎么办?

如果对区间内的每个点都进行一次单点修改,时间复杂度会退化到 \(O(N \log N)\),优势尽失。

这时就需要懒惰标记(Lazy Propagation)
核心思想 :如果要修改的区间 [updateL, updateR] 恰好完全覆盖了当前节点 p 代表的区间 [L, R],我们不去递归修改它的子孙节点,而是在当前节点 p 上打一个"标记"(tag)。这个标记代表"该节点的所有子孙节点都需要进行某个修改,但我先不下去,等需要的时候再说"。

具体操作

  1. 修改 (Range Update):

    • 当修改区间完全覆盖当前节点区间时,更新当前节点的值(例如,区间和 tree[p] += (R - L + 1) * val),并给它打上懒惰标记(例如,tag[p] += val)。然后直接返回。
    • 否则,在递归到子节点之前,先要**下放(Push Down)**当前节点的懒惰标记。
  2. 下放标记 (Push Down):

    • 检查当前节点 p 是否有懒惰标记。
    • 如果有,将这个标记应用到它的左右两个子节点上。
      • 更新左子节点的值和标记:tree[2p] += ..., tag[2p] += ...
      • 更新右子节点的值和标记:tree[2p+1] += ..., tag[2p+1] += ...
    • 清除当前节点 p 的懒惰标记。
  3. 查询 (Range Query):

    • 在递归查询的过程中,每次进入一个节点的子节点前,都必须先执行下放标记的操作,以确保子节点的信息是正确的。

通过懒惰标记,区间修改的时间复杂度也优化到了 \(O(\log N)\)。

2.4.5 C++ 代码模板

下面是一个支持区间加法和区间求和的线段树模板。

cpp 复制代码
#include <iostream>
#include <vector>

using namespace std;

const int MAXN = 100005;

long long a[MAXN];      // 原始数组,下标从1开始
long long tree[MAXN * 4]; // 线段树数组
long long tag[MAXN * 4];  // 懒惰标记数组
int n; // 元素个数

// 用子节点信息更新父节点
void push_up(int p) {
    tree[p] = tree[p * 2] + tree[p * 2 + 1];
}

// 下放懒惰标记
void push_down(int p, int l, int r) {
    if (tag[p] != 0) {
        int mid = (l + r) / 2;
        // 更新左子节点的值和标记
        tree[p * 2] += tag[p] * (mid - l + 1);
        tag[p * 2] += tag[p];
        // 更新右子节点的值和标记
        tree[p * 2 + 1] += tag[p] * (r - (mid + 1) + 1);
        tag[p * 2 + 1] += tag[p];
        // 清除当前节点的标记
        tag[p] = 0;
    }
}

// 建树
void build(int p, int l, int r) {
    if (l == r) {
        tree[p] = a[l];
        return;
    }
    int mid = (l + r) / 2;
    build(p * 2, l, mid);
    build(p * 2 + 1, mid + 1, r);
    push_up(p);
}

// 区间修改:将区间 [update_l, update_r] 内的数都加上 val
void update(int p, int l, int r, int update_l, int update_r, int val) {
    if (update_l <= l && r <= update_r) { // 完全覆盖
        tree[p] += (long long)(r - l + 1) * val;
        tag[p] += val;
        return;
    }
    
    push_down(p, l, r); // 准备访问子节点,先下放标记
    
    int mid = (l + r) / 2;
    if (update_l <= mid) {
        update(p * 2, l, mid, update_l, update_r, val);
    }
    if (update_r > mid) {
        update(p * 2 + 1, mid + 1, r, update_l, update_r, val);
    }
    
    push_up(p); // 用更新后的子节点信息更新自己
}

// 区间查询:查询区间 [query_l, query_r] 的和
long long query(int p, int l, int r, int query_l, int query_r) {
    if (query_l <= l && r <= query_r) { // 完全覆盖
        return tree[p];
    }
    
    push_down(p, l, r); // 准备访问子节点,先下放标记
    
    int mid = (l + r) / 2;
    long long sum = 0;
    if (query_l <= mid) {
        sum += query(p * 2, l, mid, query_l, query_r);
    }
    if (query_r > mid) {
        sum += query(p * 2 + 1, mid + 1, r, query_l, query_r);
    }
    return sum;
}

int main() {
    // 使用示例
    int m; // 操作次数
    cin >> n >> m;
    for (int i = 1; i <= n; ++i) {
        cin >> a[i];
    }

    build(1, 1, n);

    for (int i = 0; i < m; ++i) {
        int type;
        cin >> type;
        if (type == 1) { // 区间修改
            int l, r, val;
            cin >> l >> r >> val;
            update(1, 1, n, l, r, val);
        } else { // 区间查询
            int l, r;
            cin >> l >> r;
            cout << query(1, 1, n, l, r) << endl;
        }
    }

    return 0;
}

2.4.6 例题与解析

【例题】洛谷 P3372 【模板】线段树 1

题目描述:已知一个数列,你需要进行下面两种操作:

  1. 将某区间 [x, y] 每个数加上 k
  2. 求出某区间 [x, y] 的和。

输入格式 :第一行包含两个整数 \(N, M\),分别表示数列的长度和操作的个数。第二行包含 \(N\) 个整数,表示初始数列。接下来 \(M\) 行,每行包含三或四个整数,表示一个操作。

解题思路

这道题是线段树带有懒惰标记的模板题。

  • 我们需要维护一个区间和。
  • 操作1是区间修改(区间加法)。
  • 操作2是区间查询(区间求和)。
    可以直接使用上一节提供的C++代码模板来解决。将输入数据读入后,先调用 build 函数建树,然后根据操作类型调用 updatequery 函数即可。

2.5 【6】字典树(Trie)

2.5.1 什么是字典树?

字典树,又称 Trie 树、前缀树(Prefix Tree),是一种专门用于高效地存储和检索字符串集合的数据结构。它的核心思想是利用字符串的公共前缀来节省存储空间和查询时间

想象一下查英文字典的过程:要找单词 apple,你会先翻到 'a' 开头的部分,再找 'ap',然后 'app',以此类推。字典树的组织方式与此非常相似。

Trie 树可以解决以下问题:

  • 快速判断一个单词是否出现在一个单词集合中。
  • 快速查找一个集合中有多少个单词以某个字符串为前缀。
  • ......以及很多其它与字符串前缀相关的问题。

2.5.2 字典树的结构

Trie 树是一棵多叉树,它的结构特点如下:

  1. 根节点不代表任何字符。
  2. 从根节点到任意一个节点的路径上,经过的字符连接起来,就是该节点所代表的字符串(前缀)。
  3. 每个节点的所有子节点所代表的字符都不同。
  4. 为了区分一个前缀是否也是一个完整的单词,节点上通常会有一个标记(例如 is_end)。

例如,我们有单词集合 {"cat", "car", "catch", "dog", "do"},构建出的 Trie 树如下:

复制代码
      (root)
      /    \
     c      d
     |      |
     a      o
    / \     |
   t*  r*   g*
   |
   c
   |
   h*

(带 * 号的节点表示一个单词的结尾)

从图中可以看出:

  • 路径 root -> c -> a -> t 对应字符串 "cat"。节点 t 被标记为单词结尾。
  • "ca""cat", "car", "catch" 的公共前缀,所以它们共享从 roota 的路径。

如何存储 Trie 树?

最常见的方式是用一个二维数组 trie[N][C],其中 N 是可能的最大节点数,C 是字符集的大小(例如,小写英文字母是 26)。

  • trie[p][ch] 存储的是节点 p 通过字符 ch 连接到的子节点的编号。如果值为 0,表示不存在这个子节点。
  • 需要一个计数器 idx 来分配新的节点编号,idx 从 1 开始。
  • 另外还需要一个数组,如 end_count[N],来记录以节点 p 结尾的单词有多少个。

2.5.3 字典树的核心操作

1. 插入 (Insert)

将一个字符串插入到 Trie 树中,过程如下:

  • 从根节点开始,当前节点指针 p = root(通常 root=01)。
  • 遍历字符串的每一个字符 ch
  • 检查当前节点 p 是否有指向 ch 的子节点,即 trie[p][ch] 是否存在。
    • 如果不存在,就创建一个新的节点 newNode,并令 trie[p][ch] = newNode
    • 然后,将指针移动到这个子节点:p = trie[p][ch]
  • 字符串遍历结束后,在最后的节点 p 上打上单词结尾标记,例如 end_count[p]++

【伪代码】

复制代码
INSERT(str)
  p = ROOT
  FOR ch IN str DO
    IF trie[p][ch] IS NULL THEN
      newNode = GET_NEW_NODE()
      trie[p][ch] = newNode
    END IF
    p = trie[p][ch]
  END FOR
  end_count[p] = end_count[p] + 1

2. 查询 (Search)

查询一个字符串是否在集合中,过程与插入类似:

  • 从根节点开始,p = root
  • 遍历查询字符串的每一个字符 ch
  • 检查 trie[p][ch] 是否存在。
    • 如果不存在,说明这个单词肯定不在集合中,直接返回 false
    • 如果存在,移动到子节点 p = trie[p][ch]
  • 字符串遍历结束后,检查当前节点 p 的单词结尾标记 end_count[p] 是否大于 0。如果是,则单词存在,返回 true;否则,它只是一个前缀,返回 false

【伪代码】

复制代码
SEARCH(str)
  p = ROOT
  FOR ch IN str DO
    IF trie[p][ch] IS NULL THEN
      RETURN false
    END IF
    p = trie[p][ch]
  END FOR
  RETURN (end_count[p] > 0)

Trie 树的插入和查询操作,时间复杂度都只与字符串的长度 \(L\) 有关,为 \(O(L)\),与集合中单词的总数无关,效率非常高。

2.5.4 C++ 代码模板

下面是一个用于存储和查询小写英文字母字符串的 Trie 树模板。

cpp 复制代码
#include <iostream>
#include <string>
#include <vector>

using namespace std;

const int MAX_NODES = 100005; // 估算最大节点数(字符串总长)
const int CHARSET_SIZE = 26; // 字符集大小

int trie[MAX_NODES][CHARSET_SIZE];
int end_count[MAX_NODES];
int node_count; // 已使用的节点数量

// 初始化Trie树
void init() {
    // C++中全局数组默认初始化为0,这里可以省略memset
    // memset(trie, 0, sizeof(trie));
    // memset(end_count, 0, sizeof(end_count));
    node_count = 1; // 0号节点作为根节点
}

// 插入字符串
void insert(const string& s) {
    int p = 0; // 从根节点开始
    for (char ch : s) {
        int c = ch - 'a'; // 将字符映射到 0-25
        if (trie[p][c] == 0) {
            trie[p][c] = node_count++;
        }
        p = trie[p][c];
    }
    end_count[p]++;
}

// 查询字符串
bool search(const string& s) {
    int p = 0;
    for (char ch : s) {
        int c = ch - 'a';
        if (trie[p][c] == 0) {
            return false;
        }
        p = trie[p][c];
    }
    return end_count[p] > 0;
}

int main() {
    init();

    int n;
    cin >> n;
    for (int i = 0; i < n; ++i) {
        string s;
        cin >> s;
        insert(s);
    }

    int m;
    cin >> m;
    for (int i = 0; i < m; ++i) {
        string s;
        cin >> s;
        if (search(s)) {
            cout << "YES" << endl;
        } else {
            cout << "NO" << endl;
        }
    }

    return 0;
}

2.5.5 例题与解析

【例题】洛谷 P2580 于是他错误的点名开始了

题目描述:给定一个名字列表,然后进行若干次点名。对于每次点名:

  1. 如果名字在列表中,且是第一次被点到,输出 "OK"。
  2. 如果名字在列表中,但之前已经被点到过,输出 "REPEAT"。
  3. 如果名字不在列表中,输出 "WRONG"。

解题思路

这道题是 Trie 树的典型应用。我们可以用 end_count 数组来记录状态,而不仅仅是单词的结尾。

  • end_count[p] = 0:表示从根到 p 的路径不是一个完整的名字。
  • end_count[p] = 1:表示这是一个合法的名字,且从未被点到过
  • end_count[p] = 2:表示这是一个合法的名字,且已经被点到过

具体步骤

  1. 建树 :读入所有名字,插入到 Trie 树中。对于插入的每个名字,将其最终节点的 end_count 值设为 1。
  2. 查询 :对于每次点名,在 Trie 树中查询这个名字。
    • 在查询路径中,如果中途发现没有路可走(trie[p][c] == 0),说明名字不存在,输出 "WRONG"。
    • 如果路径走完了,到达节点 p
      • 检查 end_count[p] 的值。如果为 0,说明这个名字只是某个更长名字的前缀,而不是一个合法的名字,输出 "WRONG"。
      • 如果为 1,说明是第一次点到,输出 "OK",并把 end_count[p] 修改为 2。
      • 如果为 2,说明是重复点名,输出 "REPEAT"。

这个方法完美地利用了 Trie 树的节点信息来存储和更新状态,高效地解决了问题。

2.6 【7】笛卡尔树

2.6.1 什么是笛卡尔树?

笛卡尔树(Cartesian Tree)是一种特殊的二叉树,它同时满足两种性质:堆的性质二叉搜索树的性质 。但这里的"二叉搜索树"性质是针对数组下标而言的。

给定一个序列(通常要求元素值互不相同),其笛卡尔树定义如下:

  1. 树中的每个节点包含一个 (key, value) 对,通常 key 是元素在原序列中的下标value 是元素的值。
  2. 堆性质 (Heap Property) :如果以 value 为标准,它是一个小根堆 (Min-Heap)。也就是说,任意一个节点的 value 都小于或等于它子节点的 value
  3. 二叉搜索树性质 (BST Property) :如果以 key (即下标) 为标准,它是一棵二叉搜索树 。也就是说,对于任意节点,其左子树中所有节点的 key 都小于它自身的 key,其右子树中所有节点的 key 都大于它自身的 key

综合起来

  • 树的根节点是原序列中 value 最小的那个元素。
  • 根节点的左子树由原序列中在该最小元素左边的所有元素构成。
  • 根节点的右子树由原序列中在该最小元素右边的所有元素构成。
  • 左右子树本身也都是笛卡尔树,这个定义是递归的。

例如,对于序列 A = {3, 1, 4, 5, 9, 2, 6} (下标从1到7),其笛卡尔树如下:

  • 序列中最小值是 1 (下标2)。所以 (2, 1) 是根。

  • 在 1 左边的元素是 {3} (下标1),构成左子树。

  • 在 1 右边的元素是 {4, 5, 9, 2, 6} (下标3-7),构成右子树。

  • 对于右子树 {4, 5, 9, 2, 6},最小值是 2 (下标6)。所以 (6, 2) 是 (2, 1) 的右孩子。

  • ... 以此类推,最终构建的树如下:

    复制代码
            (2, 1)
           /      \
        (1, 3)    (6, 2)
                 /      \
              (4, 4)    (7, 6)
               /  \
             (3, 5) (5, 9)

重要性质

  • 一个序列的笛卡尔树,在元素值互不相同的情况下,是唯一的。
  • 树的中序遍历结果,恰好是原序列的下标序列。例如上图的中序遍历结果是 {1, 2, 3, 4, 5, 6, 7}。

2.6.2 笛卡尔树的构造

  • 朴素构造法 ( \(O(N^2)\) )

    直接根据定义递归构造。每次在当前区间 [L, R] 内找到最小值的下标 min_idx,创建节点,然后递归构造 [L, min_idx-1]作为左子树,[min_idx+1, R]作为右子树。每次查找最小值需要 \(O(N)\),效率很低。

  • 高效构造法 ( \(O(N)\) )

    我们可以使用一个单调栈 来在线性时间内完成建树。

    思路是:按顺序(从左到右)遍历原序列的每个元素,并动态地维护树的"右链"(从根节点一直往右子节点走形成的路径)。这个右链上的节点,它们的 value 是单调递增的。

    算法流程

    1. 维护一个单调栈,栈中存放已构建好的树的节点,且从栈底到栈顶,节点的 value 严格递增。
    2. 依次遍历序列中的每个元素 A[i],为它创建一个新节点 curr
    3. 核心步骤 :比较 curr 和栈顶元素 stk.top()value
      • 如果栈顶元素的 value 大于 currvalue,说明栈顶元素不再能维持右链的单调性。它应该成为 curr 的左孩子(因为它下标更小,值更大)。于是,不断地将栈顶元素弹出,并让最后一个弹出的元素成为 curr 的左孩子。
      • 当循环结束时(栈为空或栈顶元素 value 小于 currvalue),此时的 curr 节点找到了它在树中的位置。
    4. 如果此时栈不为空,那么栈顶元素 stk.top() 就是 curr 的父节点。curr 应该成为 stk.top() 的右孩子(因为 curr 的下标更大)。
    5. curr 压入栈中。

    用一个例子来模拟 A = {3, 1, 4, 5, 9, 2, 6}

    • i=1, val=3 :栈空,节点1入栈。stk: {1}
    • i=2, val=1val(1)=3 > val(2)=1。弹出1。节点2的左孩子是1。栈空。节点2入栈。 stk: {2}
    • i=3, val=4val(2)=1 < val(4)=4。节点4成为节点2的右孩子。节点4入栈。 stk: {2, 4}
    • i=4, val=5val(4)=4 < val(5)=5。节点5成为节点4的右孩子。节点5入栈。 stk: {2, 4, 5}
    • i=5, val=9val(5)=5 < val(9)=9。节点9成为节点5的右孩子。节点9入栈。 stk: {2, 4, 5, 9}
    • i=6, val=2val(9)=9 > val(2)=2,弹9。val(5)=5 > val(2)=2,弹5。val(4)=4 > val(2)=2,弹4。最后弹出的4成为节点2的左孩子。此时栈顶是2,val(2)=1 < val(2)=2。节点2成为节点6的右孩子。节点6入栈。 stk: {2, 6}
    • i=7, val=6val(6)=2 < val(6)=6。节点6成为节点7的右孩子。节点7入栈。stk: {2, 6, 7}

    最终,栈中从底到顶为 2, 6, 7,这就是树的右链。整个树的根是栈中所有元素最终的父节点,也就是 2。

2.6.3 C++ 代码模板

cpp 复制代码
#include <iostream>
#include <vector>
#include <stack>

using namespace std;

const int MAXN = 1000005;

struct Node {
    int val;
    int l_child, r_child;
};

Node tree[MAXN];
int val[MAXN];
int l_child[MAXN], r_child[MAXN];
int n;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    cin >> n;
    for (int i = 1; i <= n; ++i) {
        cin >> val[i];
    }

    stack<int> s;
    for (int i = 1; i <= n; ++i) {
        int last_pop = 0;
        // 当栈不空且栈顶元素值大于当前元素值
        while (!s.empty() && val[s.top()] > val[i]) {
            last_pop = s.top();
            s.pop();
        }

        if (!s.empty()) {
            // 栈顶是父节点,当前节点是其右孩子
            r_child[s.top()] = i;
        }

        // 最后一个弹出的节点是当前节点的左孩子
        l_child[i] = last_pop;
        
        s.push(i);
    }
    
    // 找到根节点(没有父节点的节点)
    // 在这个建树方法中,根是整个序列中最小值的下标
    // 或者可以通过一个 parent 数组来找,谁的 parent 是 0 谁就是根
    // P5854 模板题的输出要求比较特殊,需要计算哈希值
    long long ans1 = 0, ans2 = 0;
    for(int i = 1; i <= n; ++i) {
        ans1 ^= (long long)i * (l_child[i] + 1);
        ans2 ^= (long long)i * (r_child[i] + 1);
    }

    cout << ans1 << " " << ans2 << endl;

    return 0;
}

2.6.4 例题与解析

【例题】洛谷 P5854 【模板】笛卡尔树

题目描述 :给定一个 \(1 \dots N\) 的排列,构建其笛卡尔树。你需要分别输出每个节点的左儿子和右儿子。

解题思路

这道题就是笛卡尔树的模板题。题目给定的是一个排列,保证了元素值(也就是权值)互不相同。我们只需要按照 \(O(N)\) 的单调栈方法构建笛卡尔树即可。

代码实现细节:

  • 用两个数组 l_child[]r_child[] 来存储每个节点的左右儿子编号。
  • 遍历 \(1 \dots N\),对于每个节点 i
    • 维护一个单调栈。
    • while 循环找到它正确的左孩子(last_pop)。
    • 栈中剩下的栈顶元素是它的父节点,所以它自己是父节点的右孩子。
    • 将自己入栈。
  • 遍历结束后,l_childr_child 数组就存储了整棵树的结构。
  • 题目的输出要求是对左右孩子编号进行异或加密,只需要按照公式计算即可。上面的代码模板就是针对这道题的解法。

笛卡尔树的应用

笛卡尔树最强大的应用之一是解决区间最值查询 (RMQ, Range Minimum/Maximum Query) 问题。

序列中任意一个区间 [L, R] 的最小值,就是原序列中下标在 [L, R] 内的这些节点,在笛卡尔树中的最近公共祖先 (LCA, Lowest Common Ancestor)

因此,RMQ 问题可以转化为树上的 LCA 问题,而 LCA 问题有非常成熟的 \(O(N \log N)\) 预处理、\(O(1)\) 查询的算法(例如基于ST表的算法)。这使得笛卡尔树成为解决某些复杂问题的基础。

2.7 【8】平衡树:AVL、Treap、Splay

在之前的学习中,我们已经掌握了二叉搜索树(Binary Search Tree, BST)。我们知道,二叉搜索树在理想情况下,也就是"形态匀称"的情况下,其查找、插入和删除操作的时间复杂度都是 \(O(\log n)\),效率非常高。

但是,二叉搜索树有一个致命的弱点:它的形态完全依赖于插入元素的顺序。如果插入的元素是随机的,那么它有很大概率会是一棵比较"平衡"的树。但如果插入的元素是提前排好序的(例如,依次插入 1, 2, 3, 4, 5),那么这棵二叉搜索树就会退化成一条链。

在这种情况下,树的高度为 \(n\),所有的操作时间复杂度都会退化到 \(O(n)\),这与暴力枚举的效率无异,失去了数据结构的优势。

为了解决这个问题,科学家们发明了一系列能够在进行插入和删除操作时,通过一些巧妙的变换来自动维持树的形态平衡 的数据结构,它们被统称为平衡二叉搜索树 (Self-Balancing Binary Search Tree),简称平衡树

平衡树的核心思想是:在对树进行修改,可能导致其不平衡时,通过一种叫做旋转(Rotation)的操作来局部调整树的结构,使其重新恢复平衡,同时保持二叉搜索树的性质不变。这个过程就像一个杂技演员,在不断有人跳上他叠的椅子时,他会不断调整自己的姿势来保持整体的平衡。

本章,我们将学习三种最经典、最常用的平衡树:AVL 树、Treap 和 Splay 树。它们用不同的策略来维护平衡,各有优劣。

2.7.1 AVL 树

AVL 树是最早被发明的自平衡二叉搜索树。它的名字来源于其两位发明者 G. M. Adelson-Velsky 和 E. M. Landis 的姓氏首字母。

2.7.1.1 AVL 树的平衡策略

AVL 树的平衡策略非常严格甚至有些"暴力":它要求树中任何一个节点的左子树和右子树的高度差的绝对值不能超过 1

定义:平衡因子(Balance Factor)

某个节点的平衡因子 被定义为:它的右子树的高度 减去它的左子树的高度

\(BalanceFactor(node) = Height(node \to right) - Height(node \to left)\)

那么,AVL 树的性质可以描述为:对于树中的任意节点 node,其平衡因子的取值只能是 -1、0 或 1

  • 当平衡因子为 -1 时,说明左子树比右子树高 1。
  • 当平衡因子为 0 时,说明左右子树等高。
  • 当平衡因子为 1 时,说明右子树比左子树高 1。

如果一个节点的平衡因子的绝对值变成了 2,我们就称这个节点失衡(Unbalanced),并且需要通过旋转操作来修复它。

2.7.1.2 核心操作:旋转

旋转是所有平衡树调整形态的原子操作。它可以在不破坏二叉搜索树性质的前提下,改变节点的父子关系,从而降低树的高度。旋转分为两种:左旋(Left Rotation)右旋(Right Rotation)

1. 右旋 (Right Rotation)

假设节点 p 是失衡节点,它的左孩子是 c。对 p 进行右旋,意味着将 c "提"上来成为新的根节点,而 p 则"降"下去成为 c 的右孩子。

  • 操作过程 (以 p 为轴心右旋):

    1. p 的左孩子 c 成为新的根。
    2. p 成为 c 的右孩子。
    3. c 原来的右子树,现在成为 p 的左子树。
  • 性质观察

    • 旋转前,中序遍历的结果是 T1 -> c -> T2 -> p -> T3
    • 旋转后,中序遍历的结果是 T1 -> c -> T2 -> p -> T3
    • 中序遍历序列不变,所以它仍然是一棵二叉搜索树!但是树的结构改变了,高度也可能随之改变。

2. 左旋 (Left Rotation)

左旋是右旋的镜像操作。假设节点 p 是失衡节点,它的右孩子是 c。对 p 进行左旋,意味着将 c "提"上来,p "降"下去成为 c 的左孩子。

  • 操作过程 (以 p 为轴心左旋):
    1. p 的右孩子 c 成为新的根。
    2. p 成为 c 的左孩子。
    3. c 原来的左子树(上图中的 T2)现在成为 p 的右子树。
2.7.1.3 AVL 树的四种失衡情况与调整

当我们在 AVL 树中插入一个新节点后,可能会导致从插入点到根节点的路径上某些祖先节点的平衡因子绝对值变为 2。我们只需要找到离插入点最近的那个失衡节点,对它进行调整即可。失衡的情况可以分为四种:

1. LL 型(左-左)

  • 成因 :在新节点插入到失衡节点 p 的左子树 c 的左子树 中,导致 p 失衡。
  • 调整 :对失衡节点 p 进行一次右旋

2. RR 型(右-右)

  • 成因 :在新节点插入到失衡节点 p 的右子树 c 的右子树 中,导致 p 失衡。
  • 调整 :对失衡节点 p 进行一次左旋

3. LR 型(左-右)

  • 成因 :在新节点插入到失衡节点 p 的左子树 c 的右子树 中,导致 p 失衡。
  • 调整 :这种稍微复杂,需要两次旋转。
    1. 先对 p 的左孩子 c 进行一次左旋。(把它变成 LL 型)
    2. 再对 p 本身进行一次右旋

4. RL 型(右-左)

  • 成因 :在新节点插入到失衡节点 p 的右子树 c 的左子树 中,导致 p 失衡。
  • 调整 :与 LR 型对称。
    1. 先对 p 的右孩子 c 进行一次右旋。(把它变成 RR 型)
    2. 再对 p 本身进行一次左旋
2.7.1.4 AVL 树的伪代码与实现

节点结构

cpp 复制代码
struct Node {
    int val;    // 节点的值
    int height; // 以当前节点为根的子树的高度
    Node *left, *right; // 左右孩子指针
};

插入操作伪代码

复制代码
function insert(node, value):
    if node is null:
        return create_new_node(value)

    if value < node.val:
        node.left = insert(node.left, value)
    else if value > node.val:
        node.right = insert(node.right, value)
    else: // 值已存在,不插入
        return node
    
    // 1. 更新高度
    update_height(node)

    // 2. 获取平衡因子,检查是否失衡
    balance = get_balance_factor(node)

    // 3. 如果失衡,进行旋转调整 (四种情况)
    // LL Case
    if balance < -1 and value < node.left.val:
        return right_rotate(node)
    
    // RR Case
    if balance > 1 and value > node.right.val:
        return left_rotate(node)

    // LR Case
    if balance < -1 and value > node.left.val:
        node.left = left_rotate(node.left)
        return right_rotate(node)

    // RL Case
    if balance > 1 and value < node.right.val:
        node.right = right_rotate(node.right)
        return left_rotate(node)

    // 4. 如果未失衡,直接返回当前节点
    return node
2.7.1.5 优缺点分析
  • 优点 :由于其严格的平衡限制,AVL 树的高度被严格控制在 \(O(\log n)\),因此其查找性能非常稳定且高效。
  • 缺点:为了维持这种严格的平衡,插入和删除操作可能需要进行多次旋转(尽管最多两次),导致这些操作的常数因子较大,实现也相对复杂。

在实际应用和竞赛中,由于其实现的复杂性,AVL 树并不如接下来要讲的 Treap 和 Splay 常用。但理解它的平衡思想是学习其他平衡树的基础。


2.7.2 Treap

Treap 是一种非常有趣的平衡树,它的名字是 Tree (树) 和 Heap (堆) 的合成词。它巧妙地利用了"随机化"来大概率地维持树的平衡,代码实现比 AVL 树简单得多。

2.7.2.1 Treap 的双重性质

Treap 的每个节点除了存储一个键值 val 之外,还额外存储一个随机生成的优先级 priority。Treap 这棵树需要同时满足两个性质:

  1. 关于键值 val,它是一棵二叉搜索树(BST)
    • 对于任意节点,其左子树中所有节点的 val 都小于该节点的 val
    • 其右子树中所有节点的 val 都大于该节点的 val
  2. 关于优先级 priority,它是一个大根堆(Max-Heap)
    • 对于任意节点,其 priority 都大于等于它左右孩子的 priority
    • 即父节点的优先级总是最高的。

一个重要的结论 :当一棵树上所有节点的键值 val 和优先级 priority 都确定后,如果这棵树同时满足 BST 和堆的性质,那么它的形态是唯一确定的。

正是因为优先级是随机赋予的,我们就可以认为这棵树的结构是"随机"的,从而在概率上期望树的高度为 \(O(\log n)\),避免了退化成链的情况。

2.7.2.2 Treap 的核心操作

Treap 维持平衡的方式完全依赖于旋转操作,但其触发旋转的条件比 AVL 树简单得多:当且仅当一个节点的优先级小于其子节点的优先级时(即违反了堆性质),才需要进行旋转

1. 插入 (Insert)

  1. 第一步 :忽略优先级,像普通二叉搜索树一样,根据键值 val 找到合适的位置,插入一个新节点。为这个新节点随机赋予一个优先级 priority
  2. 第二步 :检查新插入的节点是否满足堆性质。如果它的优先级比其父节点大,就违反了堆性质。此时,通过旋转 来修复它。
    • 如果该节点是其父节点的左孩子,就对父节点进行右旋
    • 如果该节点是其父节点的右孩子,就对父节点进行左旋
    • 这个过程就像在大根堆中插入元素后的"上浮"操作,不断旋转,直到该节点的父节点优先级比它大,或者它自己成为根节点为止。

2. 删除 (Delete)

删除一个节点比插入稍微麻烦一点,因为我们不能直接删除一个内部节点,那样会破坏树的结构。

  1. 第一步:在树中找到要删除的节点。
  2. 第二步 :通过旋转,将这个要删除的节点**"下沉"** 到叶子节点的位置。
    • 比较其左右孩子的优先级。选择优先级较大的那个孩子进行旋转。
    • 如果左孩子优先级大,就对当前节点进行右旋(把它作为右孩子换下去)。
    • 如果右孩子优先级大,就对当前节点进行左旋(把它作为左孩子换下去)。
    • 重复这个过程,直到要删除的节点成为一个叶子节点。
  3. 第三步:当要删除的节点成为叶子节点后,直接删除它。
2.7.2.3 Treap 的 C++ 实现模板

Treap 的实现通常采用指针方式,并且经常会把左右孩子 lson, rson 写成一个数组 ch[2],这样在旋转时代码可以写得更简洁。

cpp 复制代码
#include <iostream>
#include <cstdlib> // For rand()
#include <ctime>   // For srand()

using namespace std;

// 建议在程序开始时调用 srand(time(0)) 来初始化随机数种子

struct Node {
    int val;      // BST 的键值
    int priority; // 堆的优先级
    int size;     // 子树大小(用于查询排名等)
    Node *ch[2];  // ch[0] 是左孩子, ch[1] 是右孩子

    Node(int v) {
        val = v;
        priority = rand();
        size = 1;
        ch[0] = ch[1] = nullptr;
    }
};

// 更新节点 size 信息
void pushup(Node* p) {
    p->size = 1;
    if (p->ch[0]) p->size += p->ch[0]->size;
    if (p->ch[1]) p->size += p->ch[1]->size;
}

// 旋转操作 (d=0 右旋, d=1 左旋)
// d=0: 将 p 的左孩子(ch[0]) 旋上来
// d=1: 将 p 的右孩子(ch[1]) 旋上来
void rotate(Node* &p, int d) {
    Node* k = p->ch[d^1]; // d=0, k=p->ch[1]; d=1, k=p->ch[0]
    p->ch[d^1] = k->ch[d];
    k->ch[d] = p;
    pushup(p);
    pushup(k);
    p = k;
}

// 插入操作
void insert(Node* &p, int val) {
    if (!p) {
        p = new Node(val);
        return;
    }
    if (val == p->val) return; // 假设不插入重复值
    
    int d = (val > p->val); // d=0 插入左子树, d=1 插入右子树
    insert(p->ch[d], val);

    // 维护堆性质
    if (p->ch[d]->priority > p->priority) {
        rotate(p, d^1); // 如果右孩子(d=1)优先级大,需要左旋(d^1=0)
                        // 如果左孩子(d=0)优先级大,需要右旋(d^1=1)
                        // 这里有个小技巧,可以思考一下
    }
    pushup(p);
}

// 删除操作
void remove(Node* &p, int val) {
    if (!p) return;
    if (val < p->val) {
        remove(p->ch[0], val);
    } else if (val > p->val) {
        remove(p->ch[1], val);
    } else { // 找到了要删除的节点 p
        if (!p->ch[0] && !p->ch[1]) { // 叶子节点
            delete p;
            p = nullptr;
        } else if (!p->ch[0]) { // 只有右孩子
            Node* k = p;
            p = p->ch[1];
            delete k;
        } else if (!p->ch[1]) { // 只有左孩子
            Node* k = p;
            p = p->ch[0];
            delete k;
        } else { // 有两个孩子
            int d = (p->ch[0]->priority < p->ch[1]->priority); // 哪个孩子优先级大,就把它旋上来
            rotate(p, d);
            remove(p->ch[d], val); // 继续在旋转下去的子树中删除
        }
    }
    if (p) pushup(p);
}

注意rotate(p, d)d 的含义是"哪个方向的孩子不动 "。例如 d=0 时,p 的左孩子 p->ch[0] 和右子树 p->ch[1]->ch[1] 保持父子关系不变,所以是右旋。思考一下 dd^1 的对应关系,这是 Treap 代码简洁的关键。

2.7.2.4 例题讲解:【洛谷 P3369】【模板】普通平衡树

这道题要求我们实现一个数据结构,支持插入、删除、查询排名、查询数值、找前驱、找后继等操作。这是平衡树的经典模板题,用 Treap 来实现非常合适。

除了上面讲的插入和删除,我们还需要实现其他几个查询功能。这些功能都利用了二叉搜索树的性质以及我们维护的 size 信息。

  • 查询 x 的排名 :从根节点开始,如果 x 小于当前节点值,就往左走;如果 x 大于当前节点值,答案加上左子树的 size 和 1(当前节点),然后往右走;如果相等,答案就加上左子树的 size 再加 1。
  • 查询排名为 k 的数 :从根节点开始,比较 k 和左子树的 size。如果 k 小于等于左子树 size,就往左走;如果 k 大于左子树 size + 1,就从 k 中减去左子树 size + 1,然后往右走;否则,当前节点就是答案。
  • 找前驱:比 x 小的数中最大的那个。在树中查找 x,沿途经过的节点值如果小于 x,就可能是答案,记录下来。最后一次记录的值就是前驱。
  • 找后继:比 x 大的数中最小的那个。与找前驱类似。

完整的题解代码较长,但核心就是上面 Treap 模板的扩展。Treap 代码短、思路清晰,是竞赛中性价比非常高的选择。


2.7.3 Splay 树

Splay 树,又称伸展树,是另一种高效的平衡二叉搜索树。它与 AVL 和 Treap 的思想完全不同。它不通过特定的"平衡因子"或"优先级"来维持平衡,而是遵循一个非常朴素的原则:每次访问一个节点后,都通过一系列旋转将这个节点移动到树的根部

这个核心操作被称为伸展(Splay)

2.7.3.1 Splay 的神奇之处:局部性原理

Splay 树的设计基于一个经验性的假设,叫做局部性原理(Locality of Reference)。这个原理指的是,在很多应用场景中,刚刚被访问过的数据,在接下来有很大概率会再次被访问。

Splay 树将最近访问的节点移动到根,就是为了让下一次对它的访问变得极快(\(O(1)\))。虽然单次操作的最坏情况复杂度可能是 \(O(n)\)(例如,访问一个深度最深的节点,然后把它旋转到根),但 Splay 树有一个非常强大的性质:均摊复杂度(Amortized Complexity)

均摊复杂度 :简单来说,虽然某一次操作可能很慢,但它会为后续的操作"铺路",使得后续操作变快。将一系列操作的总时间除以操作次数,得到的平均时间复杂度是稳定的。Splay 树的所有操作的均摊时间复杂度都是 \(O(\log n)\)。对于初学者,我们不需要深入证明,只需记住这个结论。

2.7.3.2 核心操作:伸展 (Splay)

伸展操作就是将指定节点 x 旋转到根。这个过程不是简单地一路单旋上去,否则仍然可能形成链。Splay 的精髓在于它的双层旋转 。假设要伸展的节点是 x,它的父亲是 p,祖父是 g。根据 x, p, g 的相对位置,分为三种情况:

1. Zig (单旋)

  • 情况p 就是根节点。
  • 操作 :如果 xp 的左孩子,就右旋 p;如果 xp 的右孩子,就左旋 p

2. Zig-Zig (一字型)

  • 情况xp 都是左孩子(或都是右孩子)。
  • 操作先旋转父亲 p,再旋转自己 x 。例如,g-p-x 是一条左斜链,那么先右旋 g,再右旋 p

3. Zig-Zag (之字型)

  • 情况x 是右孩子而 p 是左孩子(或反之)。
  • 操作连续旋转自己 x 两次 。例如,xp 的右孩子,pg 的左孩子。那么先对 p 左旋,再对 g 右旋。

Splay 操作流程 :只要 x 还不是根节点,就反复执行上述三种情况之一,直到 x 成为根。

2.7.3.3 Splay 树的基本操作

Splay 树的所有操作都围绕 splay 函数展开。

  • 插入 (Insert)

    1. 像普通 BST 一样插入新节点。
    2. 对新插入的节点执行 splay 操作,将其移动到根。
  • 查找 (Find)

    1. 像普通 BST 一样查找目标节点。
    2. 如果找到了,对该节点执行 splay 操作。如果没找到,对最后访问到的那个节点执行 splay 操作。
  • 删除 (Delete)

    1. 首先查找要删除的值,并将其 splay 到根。假设现在的根是 x
    2. 此时 x 的左子树中的所有值都比 x 小,右子树所有值都比 x 大。
    3. x 的左子树和右子树断开。
    4. x 的左子树中,找到值最大的那个节点(也就是左子树的根一直往右走到底),我们称之为 max_left
    5. max_left 执行 splay 操作,使其成为左子树的新根。
    6. 此时,max_left 一定没有右孩子。将 x 原来的右子树,连接为 max_left 的右孩子。
    7. 最后,删除节点 x,新的树根就是 max_left
2.7.3.4 Splay 树的 C++ 实现模板

Splay 树的实现通常不用递归,而是用 while 循环自底向上进行旋转,这样更高效。

cpp 复制代码
#include <iostream>
#include <algorithm>

using namespace std;

struct Node {
    int val;
    int size;
    Node *ch[2]; // ch[0] left, ch[1] right
    Node *fa;    // parent pointer

    Node(int v, Node* f) {
        val = v;
        size = 1;
        ch[0] = ch[1] = nullptr;
        fa = f;
    }
};

Node* root;

void pushup(Node* p) {
    p->size = 1;
    if (p->ch[0]) p->size += p->ch[0]->size;
    if (p->ch[1]) p->size += p->ch[1]->size;
}

// d=0: 左孩子, d=1: 右孩子
// get(x) 判断 x 是其父节点的哪个孩子
int get(Node* p) {
    return p == p->fa->ch[1];
}

void rotate(Node* p) {
    Node *f = p->fa, *gf = f->fa;
    int d = get(p);
    
    // 连接 gf 和 p
    if (gf) gf->ch[get(f)] = p;
    p->fa = gf;

    // 连接 f 和 p 的子树
    f->ch[d] = p->ch[d^1];
    if (p->ch[d^1]) p->ch[d^1]->fa = f;
    
    // 连接 p 和 f
    p->ch[d^1] = f;
    f->fa = p;

    pushup(f);
    pushup(p);
}

void splay(Node* p, Node* goal = nullptr) {
    while (p->fa != goal) {
        Node *f = p->fa, *gf = f->fa;
        if (gf != goal) { // 如果有祖父节点,判断 zig-zig or zig-zag
            if (get(p) == get(f)) { // zig-zig
                rotate(f);
            } else { // zig-zag
                rotate(p);
            }
        }
        rotate(p);
    }
    if (goal == nullptr) { // 如果目标是根,更新 root 指针
        root = p;
    }
}

注意 :这里的 Splay 代码是一个更常见的竞赛模板写法。splay(p, goal) 的意思是将 p 旋转到 goal 的下方。当 goalnullptr 时,就是将 p 旋转到根。这种写法在处理区间操作时非常有用。

2.7.3.5 Splay 的应用

Splay 树非常灵活,除了能完成普通平衡树的所有操作外,它的 splay 操作能方便地将任意节点置于根,这使得它在处理需要区间合并、分裂等操作的场景时特别强大。例如,文艺平衡树(洛谷 P3391)就是 Splay 的一个经典应用,通过 Splay 维护一个序列,可以实现区间翻转等复杂操作。

2.7.3.6 总结与对比
特性 AVL 树 Treap Splay 树
平衡策略 严格高度限制(平衡因子) 随机优先级(堆性质) 访问后伸展到根
时间复杂度 所有操作严格 \(O(\log n)\) 所有操作期望 \(O(\log n)\) 所有操作均摊 \(O(\log n)\)
实现难度 复杂,情况讨论多 简单,代码短小 中等,旋转逻辑需要清晰
空间开销 每个节点需存 height 每个节点需存 priority 每个节点需存 parent 指针
常数 较大(旋转频繁) 较小 较小(但splay操作长)
适用场景 查找密集,修改较少 绝大多数平衡树场景 有区间操作,或数据访问有局部性

对于初学者和信息学竞赛选手来说,Treap 是最需要优先掌握的平衡树,因为它足够应对大多数模板题,且代码简单不易出错。Splay 树功能更强大,是进阶选手必须掌握的利器。而 AVL 树,则更多地作为理解平衡思想的经典案例出现在数据结构的教材中。

第三节 哈希表

在信息学竞赛中,经常会遇到需要在庞大的数据集中快速查找、统计或判断某个元素是否存在的问题。如果数据范围非常大,例如,需要判断一个范围在 1 到 10 亿之间的数字是否出现过,直接开一个大小为 10 亿的数组来记录是不现实的,因为它会占用巨量的内存空间。

为了解决这类问题,信息学家们设计了一种巧妙的工具------哈希算法(Hash Algorithm)

哈希算法的核心思想是映射压缩 。它可以将任意长度的输入(可能是一个巨大的数字、一个长字符串,甚至更复杂的数据结构),通过一个特定的哈希函数(Hash Function) ,转换成一个固定长度的、通常是较小的数值,这个数值被称为哈希值(Hash Value)

可以把哈希函数想象成一个神奇的"搅拌机"。无论你放进去的是什么水果(苹果、香蕉、西瓜),它都能输出一杯固定大小的果汁。即使是两种非常相似的水果,榨出来的果汁也可能看起来天差地别。哈希算法的目标就是让不同的输入(不同的水果)能够生成不同的哈希值(不同口味的果汁)。通过比较哈希值,我们就能快速地判断原始输入是否可能相同。

本章将详细介绍数值和字符串的哈希函数构造方法,以及一个不可避免的问题------哈希冲突及其处理方法。

3.1 【5】数值哈希函数构造

3.1.1 什么是数值哈希函数?

数值哈希函数处理的是数字。它的任务是,将一个取值范围可能非常大的整数,映射到一个相对较小的、可以用作数组下标的范围内。

例如,班级里有 50 名学生,他们的学号可能是从 2024000120249999 之间任意的 50 个数字。如果我们想快速查询某个学号的学生是否存在,可以开一个大小为 10000 的数组,但这太浪费空间了。我们希望将这些巨大的学号,映射到 049(或者 150)这样的小范围里,方便我们用一个大小为 50 的数组来存储信息。这个将大学号映射到小下标的"规则",就是数值哈希函数。

一个好的数值哈希函数应该具备两个特点:

  1. 计算简单:函数本身的计算过程不能太复杂,否则就失去了"快速"的意义。
  2. 分布均匀 :应尽可能地将不同的数字映射到不同的位置,减少"多个不同学号被映射到同一个位置"的情况。这种情况被称为哈希冲突(Hash Collision)

3.1.2 常见的数值哈希构造方法

在各种构造方法中,除留余数法(Division Method) 是最常用也是最简单的一种。

它的思想非常直接:选择一个合适的正整数 \(M\) ,对于任意的数值关键字 \(key\) ,其哈希值 \(H(key)\) 计算公式为:

\(H(key) = key \pmod M\)

这里的 \(\pmod M\) 就是取 \(key\) 除以 \(M\) 的余数。例如,如果 \(M=100\) ,那么学号 20240321 的哈希值就是 \(20240321 \pmod {100} = 21\)。这样,一个巨大的学号就被映射到了 099 的范围内。

如何选择 \(M\) ?

\(M\) 的选择至关重要,它直接影响到哈希冲突的概率。一个经验法则是:\(M\) 应该选择一个不小于哈希表大小(即你需要的数组大小)的质数

为什么是质数?举个例子,假设我们要存储的学号末尾数字都是偶数,如果我们选择 \(M=10\) ,那么 \(key \pmod {10}\) 的结果也都是偶数,哈希值 1, 3, 5, 7, 9 这些位置就永远不会被用到,而 0, 2, 4, 6, 8 这些位置的冲突概率就大大增加了。但如果我们选择一个质数,比如 \(M=97\) ,那么即使是规律性很强的输入数据,计算出的哈希值也会在 096 之间分布得更加均匀,从而有效减少冲突。

3.1.3 伪代码与C++代码模板

算法伪代码:

复制代码
function Numeric_Hash(key, M):
  // key: 输入的数值
  // M:   模数,通常是一个质数
  return key mod M

C++ 代码模板:

cpp 复制代码
#include <iostream>

using namespace std;

// M 通常选择一个质数,例如 100003
const int M = 100003; 

/**
 * @brief 计算数值的哈希值
 * @param key 输入的整数
 * @return key 对 M 取模后的哈希值
 */
int get_hash(int key) {
    // 为了防止 key 是负数,C++ 的负数取模结果可能也是负数
    // 通过 (key % M + M) % M 的方式确保结果为非负数
    return (key % M + M) % M;
}

int main() {
    int student_id = 20240321;
    int hash_value = get_hash(student_id);
    
    // 学号 20240321 对应的哈希值为:20240321 % 100003 = 20182
    cout << "学号 " << student_id << " 的哈希值是: " << hash_value << endl;
    
    return 0;
}

3.2 【6】字符串哈希函数构造

3.2.1 字符串哈希的挑战

数值哈希处理的是数字,但我们更常遇到的是字符串。如何将一个字符串,比如 "hello" 或者 "luogu",也转换成一个数字,从而可以对它进行存储和比较呢?

计算机本身就是用数字(ASCII码或Unicode码)来存储字符的。例如,在ASCII码中,'a' 是 97,'b' 是 98,等等。一个很自然的想法是,我们可以把字符串看作一个特殊的"数字"。

3.2.2 核心方法:P进制哈希

我们可以借鉴P进制 的思想来构造字符串的哈希函数。一个十进制数 \(123\) 可以表示为 \(1 \times 10^2 + 2 \times 10^1 + 3 \times 10^0\)。类似地,我们可以将一个字符串看作一个 P进制 数,其中字符串的每个字符就是这个P进制数的每一位上的"数值",而 \(P\) 是我们选择的基数。

对于一个字符串 \(S = s_1s_2s_3...s_n\) ,其哈希函数可以定义为:

\(H(S) = (s_1 \times P^{n-1} + s_2 \times P^{n-2} + \dots + s_n \times P^0) \pmod M\)

其中,\(s_i\) 是字符串第 \(i\) 个字符的整数表示(例如ASCII码),\(P\) 是一个选定的基数,\(M\) 是一个选定的模数。

如何选择 \(P\) 和 \(M\)?

为了让哈希函数的效果更好,即冲突概率更低,\(P\) 和 \(M\) 的选择需要满足一些经验规则:

  1. 基数 \(P\) :应该选择一个质数,且这个质数要大于所有可能出现的字符的种类数。例如,如果字符串只包含小写字母,那么字符种类是 26。我们可以选择一个比 26 大的质数,比如 131 或者 13331。这些数字在实践中被证明效果很好。
  2. 模数 \(M\) :应该选择一个足够大的质数,以减小哈希冲突的概率。这个数的大小通常要能存放在一个 long long 类型的变量中。常用的模数有 \(10^9+7\)、\(10^9+9\)、\(998244353\) 等。

一个特别的技巧:自然溢出

在信息学竞赛中,为了追求极致的效率和代码的简洁性,选手们常常使用一种被称为"自然溢出"的技巧。具体做法是,选择一个 unsigned long long 类型来存储哈希值,它的取值范围是 \([0, 2^{64}-1]\)。在计算哈希值的过程中,不进行任何取模操作。当计算结果超过 unsigned long long 的最大值时,它会自动"溢出",效果等价于对 \(2^{64}\) 取模。这种方法省去了取模运算的时间,且由于 \(2^{64}\) 是一个巨大的合数,其哈希效果在实践中也相当不错。

3.2.3 快速计算子串哈希:前缀哈希法

如果我们需要频繁计算一个长字符串中任意子串 的哈希值,每次都从头遍历子串来计算会非常慢。这时,我们可以使用前缀哈希法来优化。

我们预处理出两个数组:

  1. h 数组:h[i] 存储字符串 \(S\) 前 \(i\) 个字符组成的前缀 \(S[1..i]\) 的哈希值。
  2. p 数组:p[i] 存储 \(P^i \pmod M\) 的值。

h 数组的递推公式为:\(h[i] = (h[i-1] \times P + s_i) \pmod M\)。

现在,假设我们想求子串 \(S[l..r]\) (从第 \(l\) 个字符到第 \(r\) 个字符)的哈希值。

  • 前缀 \(S[1..r]\) 的哈希值是 \(h[r]\)。
  • 前缀 \(S[1..l-1]\) 的哈希值是 \(h[l-1]\)。

观察哈希值的计算公式, \(h[r]\) 相当于 \(S[1..l-1]\) 这部分对应的数值"左移"了 \(r-(l-1)\) 位,也就是乘以了 \(P^{r-l+1}\) ,然后加上了 \(S[l..r]\) 对应的数值。

所以,我们可以得到:
\(h[r] \equiv h[l-1] \times P^{r-l+1} + H(S[l..r]) \pmod M\)

通过移项,我们得到计算子串 \(S[l..r]\) 哈希值的公式:

\(H(S[l..r]) = (h[r] - h[l-1] \times P^{r-l+1}) \pmod M\)

注意,在计算减法取模时,结果可能会是负数。为了保证结果非负,我们应该使用 (a - b % M + M) % M 的形式。如果使用 unsigned long long 自然溢出,则可以直接相减。

3.2.4 伪代码与C++代码模板

算法伪代码:

复制代码
// --- 预处理 ---
function Precompute_Hash(S, n, P, M):
  h[0] = 0
  p[0] = 1
  for i from 1 to n:
    p[i] = (p[i-1] * P) mod M
    h[i] = (h[i-1] * P + S[i]) mod M

// --- 查询子串哈希 ---
function Get_Substring_Hash(l, r):
  // 计算 S[l..r] 的哈希值
  len = r - l + 1
  hash_val = (h[r] - h[l-1] * p[len] % M + M) % M
  return hash_val

C++ 代码模板 (采用 unsigned long long 自然溢出):

cpp 复制代码
#include <iostream>
#include <string>
#include <vector>

using namespace std;

typedef unsigned long long ULL;

const int N = 1000010; // 字符串最大长度
const int P = 131;     // 基数 P

ULL h[N]; // h[i] 存储字符串前 i 个字符的哈希值
ULL p[N]; // p[i] 存储 P 的 i 次方

/**
 * @brief 计算字符串 s 从下标 l 到 r 的子串的哈希值
 * @param l 子串左端点 (1-based)
 * @param r 子串右端点 (1-based)
 * @return 子串的哈希值
 */
ULL get_hash(int l, int r) {
    return h[r] - h[l - 1] * p[r - l + 1];
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    string s;
    cin >> s;
    int n = s.length();

    // 预处理 p 数组和 h 数组
    p[0] = 1;
    for (int i = 1; i <= n; ++i) {
        p[i] = p[i - 1] * P;
        // 注意字符串下标从 0 开始,而我们习惯从 1 开始处理
        h[i] = h[i - 1] * P + s[i - 1]; 
    }

    // 示例:查询 "abacaba" 中 "aca" 的哈希值
    // "aca" 是原串的第 3 到 5 个字符
    // 假设 s = "abacaba"
    int l = 3, r = 5;
    cout << "子串 " << s.substr(l - 1, r - l + 1) << " 的哈希值是: " << get_hash(l, r) << endl;

    // 示例:比较两个子串是否相同
    // 比较 S[1..3] ("aba") 和 S[5..7] ("aba")
    if (get_hash(1, 3) == get_hash(5, 7)) {
        cout << "子串 S[1..3] 和 S[5..7] 相同" << endl;
    }

    return 0;
}

3.2.5 洛谷例题与题解

题目:P3370 【模板】字符串哈希

题目大意:

给定 \(N\) 个字符串,询问其中有多少个不同的字符串。

解题思路:

这是字符串哈希最经典的应用。我们可以依次读入每个字符串,计算出它的哈希值。然后,将这些哈希值存入一个数据结构中,最后统计这个数据结构里有多少个不重复的元素即可。

为了方便地统计不重复元素的个数,我们可以:

  1. 将所有计算出的哈希值存入一个数组。
  2. 对这个数组进行排序。
  3. 使用 std::unique 函数(或手动遍历)来统计排序后数组中不重复的元素个数。

C++ 题解代码:

cpp 复制代码
#include <iostream>
#include <string>
#include <vector>
#include <algorithm>

using namespace std;

typedef unsigned long long ULL;

const int P = 131; // 基数

// 计算单个字符串的哈希值
ULL calculate_string_hash(const string& s) {
    ULL hash_value = 0;
    for (char c : s) {
        hash_value = hash_value * P + c;
    }
    return hash_value;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    int n;
    cin >> n;

    vector<ULL> hash_values;
    for (int i = 0; i < n; ++i) {
        string str;
        cin >> str;
        hash_values.push_back(calculate_string_hash(str));
    }

    // 排序
    sort(hash_values.begin(), hash_values.end());

    // 使用 unique 函数去重,它会返回去重后最后一个元素的下一个位置
    // 两个指针的差值就是不重复元素的个数
    int unique_count = unique(hash_values.begin(), hash_values.end()) - hash_values.begin();

    cout << unique_count << endl;

    return 0;
}

3.3 【6】哈希冲突的常用处理方法

3.3.1 什么是哈希冲突?

哈希函数的目标是为每个不同的输入生成一个唯一的哈希值。然而,由于哈希值的范围(由模数 \(M\) 决定)通常远小于输入的可能范围,根据抽屉原理 ,不可避免地会出现两个或多个不同的输入(\(key_1 \neq key_2\))却得到了相同的哈希值(\(H(key_1) = H(key_2)\))的情况。这种情况就叫做哈希冲突

可以把哈希表想象成一个旅馆,每个房间有一个编号(哈希值)。哈希冲突就像来了两个不相识的客人,却被分配了同一个房间。旅馆管理员(我们的程序)必须有办法处理这种情况,让两个客人都能住下。

处理哈希冲突的方法有很多,这里介绍两种最经典的方法:拉链法开放定址法 。同时,介绍一种有效降低 冲突概率的方法:双哈希

3.3.2 方法一:拉链法 (Chaining)

拉链法,又称链地址法,是处理哈希冲突最常用的方法。

核心思想:

将哈希表(通常是一个数组)的每个位置都看作一个"桶"或"槽位"。这个桶里不是直接存储一个元素,而是存储一个链表 (或者动态数组 std::vector)的头节点。所有哈希值相同的元素,都被依次放入对应位置的链表中。

工作流程:

  1. 插入元素 \(key\)
    • 计算 \(key\) 的哈希值 \(h = H(key)\)。
    • 在哈希表数组的第 \(h\) 个位置,找到对应的链表。
    • 将元素 \(key\) 添加到这个链表的末尾。
  2. 查找元素 \(key\)
    • 计算 \(key\) 的哈希值 \(h = H(key)\)。
    • 在哈希表数组的第 \(h\) 个位置,找到对应的链表。
    • 遍历这个链表,逐一检查链表中的元素是否与要查找的 \(key\) 相等。如果找到,则查找成功;如果遍历完整个链表都未找到,则查找失败。

伪代码:

复制代码
// HashTable 是一个数组,每个元素是一个链表
HashTable table[M];

function Insert(key):
  h = H(key)
  // 在 table[h] 对应的链表中查找 key 是否已存在,避免重复插入
  // ...
  // 如果不存在,将 key 插入到 table[h] 链表的头部或尾部

function Find(key):
  h = H(key)
  // 遍历 table[h] 对应的链表
  for each element in table[h]:
    if element == key:
      return true // 找到了
  return false // 没找到

C++ 代码模板 (使用 std::vector)

cpp 复制代码
#include <iostream>
#include <vector>
#include <string>

using namespace std;

const int M = 100003; // 选择一个质数作为哈希表的大小
vector<string> hashTable[M];

// 简单的字符串哈希函数
int get_hash(const string& s) {
    long long hash_value = 0;
    const int P = 131;
    for (char c : s) {
        hash_value = (hash_value * P + c) % M;
    }
    return (int)hash_value;
}

// 插入
void insert(const string& s) {
    int h = get_hash(s);
    // 简单起见,这里允许重复插入
    // 严谨的实现会先查找是否存在
    hashTable[h].push_back(s);
}

// 查找
bool find(const string& s) {
    int h = get_hash(s);
    for (const string& str_in_list : hashTable[h]) {
        if (str_in_list == s) {
            return true;
        }
    }
    return false;
}

int main() {
    insert("apple");
    insert("banana");
    insert("apply"); // 假设 "apply" 和 "apple" 哈希冲突

    if (find("apple")) {
        cout << "找到了 apple" << endl;
    }
    if (find("orange")) {
        cout << "找到了 orange" << endl;
    } else {
        cout << "没有找到 orange" << endl;
    }

    return 0;
}

3.3.3 方法二:开放定址法 (Open Addressing)

开放定址法的核心思想是:如果计算出的哈希位置 \(h\) 已经被占用了,那就按照某种规则去寻找下一个可用的空位置。

工作流程(以最简单的线性探测为例):

  1. 插入元素 \(key\)
    • 计算初始哈希位置 \(h_0 = H(key)\)。
    • 如果 table[h0] 是空的,则将 \(key\) 放入该位置。
    • 如果 table[h0] 已被占用,则尝试下一个位置 \(h_1 = (h_0 + 1) \pmod M\)。
    • 如果 table[h1] 仍被占用,则继续尝试 \(h_2 = (h_0 + 2) \pmod M\), \(h_3 = (h_0 + 3) \pmod M\) ...... 直到找到一个空位。
  2. 查找元素 \(key\)
    • 计算初始哈希位置 \(h_0 = H(key)\)。
    • 检查 table[h0] 的元素。
      • 如果是 \(key\),则查找成功。
      • 如果为空,则说明 \(key\) 不存在,查找失败。
      • 如果是一个不等于 \(key\) 的其他元素,说明发生了冲突,继续探测下一个位置 \(h_1 = (h_0 + 1) \pmod M\),重复此过程。

开放定址法相比于拉链法,没有链表的额外开销,但它容易产生"聚集"现象,即连续的位置都被占满,导致后续元素的插入和查找需要探测很长的距离,影响效率。

3.3.4 方法三:双哈希(降低冲突概率)

双哈希并不是一种处理冲突的结构性方法,而是一种从根源上极大地降低冲突概率的技巧,在信息学竞赛中尤为常用,特别是在字符串哈希中。

核心思想:

为同一个字符串计算两个不同 的哈希值。我们可以选择两组不同的基数和模数(例如,\(\{P_1, M_1\}\) 和 \(\{P_2, M_2\}\)),分别计算出 \(hash_1\) 和 \(hash_2\)。

只有当两个字符串的 \(hash_1\) 和 \(hash_2\) 相等时,我们才认为这两个字符串是相同的。

为什么有效?

假设使用单哈希时,两个不同字符串发生冲突的概率是 \(\frac{1}{M}\)。这是一个很小的概率。那么,如果使用两组独立 的哈希函数,它们同时 发生冲突的概率就是 \((\frac{1}{M})^2 = \frac{1}{M^2}\),这个概率变得微乎其微。

例如,如果 \(M \approx 10^9\),那么冲突概率大约是 \(10^{-9}\)。而双哈希的冲突概率大约是 \(10^{-18}\),在绝大多数信息学竞赛的数据范围内,可以认为这几乎不可能发生冲突。

C++ 代码模板 (字符串双哈希)

cpp 复制代码
#include <iostream>
#include <string>
#include <utility> // for std::pair

using namespace std;

typedef unsigned long long ULL;
typedef long long LL;
typedef pair<LL, LL> PII;

// 哈希函数1
const int P1 = 131, M1 = 1e9 + 7;
// 哈希函数2
const int P2 = 13331, M2 = 998244353;

// 计算字符串 s 的双哈希值
PII get_double_hash(const string& s) {
    LL h1 = 0, h2 = 0;
    for (char c : s) {
        h1 = (h1 * P1 + c) % M1;
        h2 = (h2 * P2 + c) % M2;
    }
    return {h1, h2};
}

int main() {
    string s1 = "luogu";
    string s2 = "luogu";
    string s3 = "google";

    PII hash1 = get_double_hash(s1);
    PII hash2 = get_double_hash(s2);
    PII hash3 = get_double_hash(s3);

    if (hash1 == hash2) {
        cout << "'" << s1 << "' 和 '" << s2 << "' 的双哈希值相同。" << endl;
    }

    if (hash1 != hash3) {
        cout << "'" << s1 << "' 和 '" << s3 << "' 的双哈希值不同。" << endl;
        cout << "Hash1: (" << hash1.first << ", " << hash1.second << ")" << endl;
        cout << "Hash3: (" << hash3.first << ", " << hash3.second << ")" << endl;
    }

    return 0;
}

在解决类似洛谷 P3370 的问题时,如果担心单哈希会被特殊数据卡掉(即出题人故意构造冲突数据),使用双哈希或三哈希是一种非常稳妥的策略。

第二章 算 法

第一节 搜索算法

在信息学竞赛中,搜索算法是解决问题的一把利剑。无论是深度优先搜索(DFS)还是广度优先搜索(BFS),它们都为我们提供了一套系统性的方法来"暴力"地探索所有可能性。然而,随着问题规模的增大,单纯的暴力搜索会因为需要探索的状态空间过于庞大而导致"超时"(Time Limit Exceeded, TLE)。

这就好比在一座巨大的、拥有无数分岔路口的迷宫中寻找出口。如果我们漫无目的地把每一条路都走到黑,很可能会耗尽所有时间。因此,我们需要更聪明的策略,来优化我们的搜索过程。本章将深入探讨五种至关重要的搜索优化技术:剪枝优化记忆化搜索启发式搜索双向广度优先搜索迭代加深搜索

1.1 【6】搜索的剪枝优化

1.1.1 什么是搜索?什么是搜索树?

在讨论剪枝之前,必须先理解我们到底在"搜索"什么。在很多问题中,我们可以把解决问题的过程看作是一系列决策的集合。例如,在N皇后问题中,第一个决策是在第一行放皇后,第二个决策是在第二行放皇后,以此类推。

所有这些决策点和它们之间的联系,可以构成一棵"树"的结构,我们称之为搜索树。树的根节点代表初始状态,每一个非叶子节点代表一个中间状态,每一条边代表一个决策,而每一个叶子节点则代表一个最终的解决方案或者一个死胡同。

深度优先搜索(DFS)的过程,本质上就是从根节点出发,遍历这棵搜索树,试图找到我们想要的答案。

1.1.2 剪枝:砍掉无用的树枝

一个朴素的DFS会尝试走遍搜索树的每一条路径,访问每一个节点。但很多时候,搜索树的某些"树枝"是完全没有意义的。

举个例子,假设我们在寻找从家里到学校的最短路径。我们正在探索一条路线,走了10分钟后发现,这条路的总长度已经超过了我们之前找到的一条只需要8分钟的路线。那么,我们还有必要继续沿着这条10分钟的路线走下去吗?显然没有,因为无论接下来怎么走,最终花费的时间都必定超过8分钟。

这个"放弃继续探索这条更长路线"的决策,就是剪枝(Pruning)。

剪枝的核心思想是:通过特定的判断,提前终止对搜索树中那些不可能产生有效解或最优解的分支的探索,从而避免不必要的计算,大幅度提高搜索算法的效率。

1.1.3 常见的剪枝策略

剪枝没有固定的公式,它更像是一种思维方式。根据问题的性质,我们可以设计出不同的剪枝策略。以下是几种最常见的剪枝类型:

  1. 可行性剪枝 (Feasibility Pruning)

    这是最基础也最重要的一种剪枝。当我们在搜索过程中发现,当前的状态无论如何调整,都无法满足问题的约束条件,最终也无法得到一个合法的解时,就应该立即停止对这个分支的深入探索。

    • 例子:N皇后问题
      问题要求在 \(N \times N\) 的棋盘上放置 \(N\) 个皇后,使得它们互相不能攻击。当我们在第 row 行尝试放置一个皇后在 col 列时,如果这个位置已经被其他皇后攻击(即同一列、同一对角线已有皇后),那么从这个位置出发的所有后续方案都是不合法的。此时,我们就不应该继续递归下去,而是直接尝试下一个列,这就是可行性剪枝。
  2. 最优性剪枝 (Optimality Pruning)

    这种剪枝通常用于解决"最优化问题",例如求最小值、最大值、最短路径等。如果我们维护一个全局变量来记录当前找到的最优解(例如 min_costmax_value),在搜索过程中,一旦发现当前状态的"成本"已经不优于当前最优解了,那么这个分支就没有继续探索的必要了。

    • 例子:旅行商问题 (TSP) 的简化版
      假设要找一条经过若干城市的最短路径。我们用一个变量 best_len 记录已找到的最短路径长度。在DFS搜索时,我们记录当前已经走过的路径长度 current_len。如果 current_len 已经大于等于 best_len,那么无论接下来怎么走,总路径长度都只会更长,不可能比 best_len 更优。因此,可以直接返回,剪掉这个分支。
  3. 搜索顺序优化 (Search Order Optimization)

    这本身不是一种直接的"剪枝",但它能极大地增强剪枝的效果。搜索的顺序会影响到搜索树的形态。一个好的搜索顺序可以让我们更快地到达最优解,或者更快地发现某个分支是不可行的。

    • 核心思想:优先搜索那些"可能性更少"或者"约束更强"的分支。这样可以使搜索树变得更"瘦",从而减少搜索的总节点数。同时,如果能先搜到优质解,最优性剪枝的效率会大大提高。
    • 例子:在一个填数问题中,如果有些位置能填的数字选择很少,我们应该优先去填这些位置。因为选择少,更容易碰壁,从而触发可行性剪枝,避免了在其他选择多的位置上浪费时间。
  4. 等效冗余/重复性剪枝 (Symmetry/Redundancy Pruning)

    在某些问题中,不同的搜索顺序可能会得到本质上相同的解。例如,求组合数"从5个球中选3个",先选1号再选2号再选3号,与先选3号再选2号再选1号,得到的组合是完全一样的。为了避免这种重复计算,我们可以施加一个人为的约束。

    • 例子:数的划分
      将整数 \(n\) 划分为 \(k\) 个正整数之和。例如 \(n=7, k=3\),1+1+51+5+15+1+1 是同一种划分。为了避免重复,我们可以强制要求划分出的数是单调不减的。即 dfs(n, k, last_num),规定下一次划分出的数不能小于 last_num。这样,1+1+5 会被搜到,而 1+5+1 这种不满足单调不减的顺序就不会被搜索,从而避免了冗余。

1.1.4 剪枝实战:洛谷 P1025 数的划分

题目描述 :将整数 \(n\) 分成 \(k\) 份,且每份不能为空,任意两份不能相同(即升序),问有多少种不同的分法。

分析

这是一个典型的DFS问题。我们可以定义一个函数 dfs(sum, count, prev),表示当前已经划分出的数的总和是 sum,已经划分了 count 个数,上一个划分的数是 prev

  • 基本框架 (未剪枝)

    prev + 1 开始枚举当前要划分的数 i,然后递归调用 dfs(sum + i, count + 1, i)

  • 剪枝策略

    1. 等效冗余剪枝 :题目要求"任意两份不能相同(即升序)",这天然地为我们提供了一个剪枝方向。我们在搜索时,强制要求下一个选择的数必须比前一个大。这正是上面提到的第4种剪枝。我们的 dfs 参数设计中的 prev 就是为了实现这一点。

    2. 可行性/最优性剪枝

      • 和的剪枝 :如果当前已划分的和 sum 加上接下来要尝试的数 i 已经超过了总数 n,那么 i 和任何比 i 大的数都是不合法的。可以直接结束当前循环。
      • 剩余数量剪枝 :假设我们还需要划分 k - count 个数。为了使和最小,我们接下来只能选择 prev+1, prev+2, ..., prev + (k-count) 这些数。如果当前和 sum 加上这些最小的数的和都已经超过了 n,那么当前分支肯定无解。这个剪枝非常强力。
      • 更简单的剩余数量剪枝 :还需要划分 k - count 个数,每个数至少是 prev+1。那么至少还需要 (k - count) * (prev + 1) 的和。如果 sum + (k - count) * (prev + 1) > n,则无解。

1.1.5 C++ 代码实现

cpp 复制代码
#include <iostream>

using namespace std;

int n, k;
int ans = 0;

// dfs(last, sum, cnt)
// last: 上一个选择的数
// sum: 当前已经划分的数的总和
// cnt: 当前已经划分了几个数
void dfs(int last, int sum, int cnt) {
    // 1. 递归终止条件
    if (cnt == k) {
        if (sum == n) {
            ans++;
        }
        return;
    }

    // 2. 剪枝优化
    // 如果当前和已经超过n,或者剩余的数即使全取最小值也超过n,则剪枝
    // 还需要 k-cnt 个数,每个数最小是 last+1
    if (sum + (k - cnt) * (last + 1) > n) {
        return;
    }

    // 3. 循环遍历所有可能的选择
    // i 是当前要选择的数
    for (int i = last + 1; sum + i <= n; ++i) {
        dfs(i, sum + i, cnt + 1);
    }
}

int main() {
    cin >> n >> k;
    
    // 初始状态:上一个数是0,和是0,划分了0个数
    dfs(0, 0, 0);

    cout << ans << endl;

    return 0;
}

1.2 【6】记忆化搜索

1.2.1 重复的劳动:低效的根源

我们再来看一个经典的例子:斐波那契数列。其递推公式为 \(F(n) = F(n-1) + F(n-2)\)。如果用纯粹的递归函数来求解:

cpp 复制代码
int fib(int n) {
    if (n <= 1) return n;
    return fib(n - 1) + fib(n - 2);
}

计算 fib(5) 的过程会是这样的:
fib(5) -> fib(4) + fib(3)
fib(4) -> fib(3) + fib(2)
fib(3) -> fib(2) + fib(1)

可以发现,fib(3) 被计算了两次,fib(2) 被计算了三次... 随着 n 的增大,这种重复计算会呈指数级增长,导致极低的效率。

在很多搜索问题中,我们也会遇到同样的情况:不同的搜索路径可能会到达同一个中间状态。如果我们每次到达这个状态都要重新计算一遍它的后续结果,无疑是巨大的浪费。

1.2.2 记忆化:好记性不如烂笔头

记忆化搜索 (Memoization Search) 就是为了解决这个问题而生的。它的核心思想非常朴素:将计算过的结果保存下来,下次再遇到相同的状态时,直接使用保存的结果,而不是重新计算。

这就像我们做数学题,一道复杂的题目需要一个中间步骤 A 的结果。我们第一次算出 A 的值后,把它记在草稿纸上。后面再次需要 A 的值时,直接看草稿纸就行了,无需再推导一遍。

实现记忆化搜索,通常需要一个数组或者哈希表(在C++中常用 map)来充当这个"草稿纸"(我们称之为 备忘录缓存)。

1.2.3 记忆化搜索的"三步曲"

一个标准的记忆化搜索通常在递归函数的开头加上固定的逻辑:

  1. 检查备忘录 :在函数开始时,检查当前状态 (state_params) 对应的结果是否已经存在于备忘录中。
  2. 直接返回结果:如果备忘录中已有记录,说明这个子问题已经被解决了,直接返回储存的结果,不再进行后续的计算。
  3. 计算并存入备忘录 :如果备忘录中没有记录,那么就正常进行计算。在计算出结果后,在函数返回前,将该结果存入备忘录,以便将来使用。

为了区分"未计算"和"计算结果为0"这两种情况,我们通常将备忘录数组初始化为一个特殊值,比如 -1

1.2.4 记忆化搜索 vs. 动态规划

细心的同学可能会发现,记忆化搜索解决问题的思路(记录子问题解,避免重复计算)和动态规划(DP)非常相似。

  • 关系:记忆化搜索是动态规划的一种实现方式。可以说,记忆化搜索就是"自顶向下"的DP。
  • 区别
    • 常规DP(递推):通常是"自底向上"的。从最小的子问题开始,一步步算出更大问题的解。通常使用循环实现。
    • 记忆化搜索(递归):是"自顶向下"的。从目标问题开始,通过递归分解成子问题。只计算那些在求解目标问题过程中真正需要用到的子问题。
  • 优点
    • 直观:代码结构和朴素的递归搜索很像,容易理解和编写。
    • 高效:只计算必要的子问题,对于某些状态空间稀疏的问题,可能比自底向上的DP更快。

1.2.5 记忆化搜索实战:洛谷 P1464 Function

题目描述

对于一个递归函数 \(w(a, b, c)\):

如果 \(a \le 0\) 或 \(b \le 0\) 或 \(c \le 0\),则 \(w(a, b, c) = 1\)。

如果 \(a > 20\) 或 \(b > 20\) 或 \(c > 20\),则 \(w(a, b, c) = w(20, 20, 20)\)。

如果 \(a < b\) 并且 \(b < c\),则 \(w(a, b, c) = w(a, b, c-1) + w(a, b-1, c-1) - w(a, b-1, c)\)。

否则 \(w(a, b, c) = w(a-1, b, c) + w(a-1, b-1, c) + w(a-1, b, c-1) - w(a-1, b-1, c-1)\)。

求 \(w(a, b, c)\) 的值。

分析

这是一个赤裸裸的递归函数定义。如果我们直接照着定义写递归代码,会因为大量的重复计算而超时。这正是记忆化搜索的用武之地。

  • 状态 :函数的解完全由参数 (a, b, c) 决定。
  • 备忘录 :由于 \(a, b, c\) 的有效范围是 020,我们可以创建一个三维数组 long long memo[21][21][21]; 来存储计算结果。
  • 实现:按照"三步曲"来改造递归函数即可。

1.2.6 伪代码模板

复制代码
function solve(state):
    // 1. 检查备忘录
    if memo[state] has been computed:
        // 2. 直接返回结果
        return memo[state]

    // 正常计算
    result = ... // 根据递推关系计算
    
    // 3. 存入备忘录
    memo[state] = result
    return result

1.2.7 C++ 代码实现

cpp 复制代码
#include <iostream>
#include <vector>

using namespace std;

// 备忘录,初始化为一个特殊值,这里可以用0,因为题目保证结果都是正数
// 但为了通用性,用-1或者一个bool数组来标记更清晰
long long memo[21][21][21];
bool visited[21][21][21];

long long w(int a, int b, int c) {
    // 基础边界条件
    if (a <= 0 || b <= 0 || c <= 0) {
        return 1;
    }
    if (a > 20 || b > 20 || c > 20) {
        return w(20, 20, 20);
    }

    // 1. 检查备忘录
    if (visited[a][b][c]) {
        // 2. 直接返回结果
        return memo[a][b][c];
    }

    // 3. 正常计算
    long long result;
    if (a < b && b < c) {
        result = w(a, b, c - 1) + w(a, b - 1, c - 1) - w(a, b - 1, c);
    } else {
        result = w(a - 1, b, c) + w(a - 1, b - 1, c) + w(a - 1, b, c - 1) - w(a - 1, b - 1, c - 1);
    }

    // 4. 存入备忘录并返回
    visited[a][b][c] = true;
    memo[a][b][c] = result;
    return result;
}

int main() {
    int a, b, c;
    while (cin >> a >> b >> c && (a != -1 || b != -1 || c != -1)) {
        cout << "w(" << a << ", " << b << ", " << c << ") = " << w(a, b, c) << endl;
    }
    return 0;
}

1.3 【7】启发式搜索

1.3.1 当剪枝和记忆化也不够用时

剪枝优化了搜索,避免了无效路径;记忆化避免了重复计算。但对于一些状态空间极其巨大的最优化问题(如最短路、最小代价),即使有了这些优化,我们可能仍然无法在规定时间内找到最优解。

问题在于,DFS和BFS在选择下一个要扩展的节点时,带有一定的"盲目性"。DFS是一条路走到黑,BFS是稳扎稳打、齐头并进。它们都没有考虑哪个分支"看起来更有希望"接近目标。

启发式搜索 (Heuristic Search) 引入了一个"评估函数"或"估价函数" (Heuristic Function),来评估当前状态距离目标状态的"远近"或"优劣",并优先选择那些"看起来最有希望"的状态进行扩展。

1.3.2 核心:估价函数 \(h(n)\)

估价函数是启发式搜索的灵魂,通常记为 \(h(n)\)。它作用于一个状态 \(n\),得出一个数值,这个数值用于估计 从状态 \(n\) 到达最终目标状态所需要付出的最小代价

  • 重要特性 :\(h(n)\) 只是一个估计,它不一定等于真实代价。
  • 类比:你在一个陌生的城市要去火车站。你不知道具体路线,但你知道火车站大致在你的东北方向。于是,你每到一个路口,都会优先选择东北方向的路走。这个"朝东北方向走"的策略,就是一种启发式策略。它不能保证你走的是最短的路,但它大大提高了你找到火车站的效率,避免了你朝西南方向走冤枉路。

1.3.3 A* 算法:最经典的启发式搜索

A* (A-star) 算法是启发式搜索中最著名、最常用的算法。它完美地结合了已知信息和未来估计。A* 算法为每个状态 \(n\) 计算一个评价值 \(f(n)\),并总是优先扩展 \(f(n)\) 最小的状态。

\(f(n)\) 的计算公式为:
\(f(n) = g(n) + h(n)\)

  • \(g(n)\):从初始状态 到当前状态 \(n\) 的实际代价。这个值是精确已知的。
  • \(h(n)\):从当前状态 \(n\) 到目标状态估计代价(由估价函数给出)。
  • \(f(n)\):从初始状态经过 状态 \(n\) 到达目标状态的估计总代价

A* 算法的执行流程类似于Dijkstra算法或BFS,但它使用的不是普通的队列,而是一个优先队列 (Priority Queue) ,队列中的元素按照 \(f(n)\) 的值从小到大排序。

1.3.4 A* 算法的伪代码

复制代码
function A_Star_Search(start, goal):
    // 1. 初始化优先队列和记录
    open_list = a priority queue containing start
    g_score[start] = 0
    f_score[start] = h(start) // h是估价函数

    // 2. 循环直到找到解或队列为空
    while open_list is not empty:
        // 取出f值最小的节点
        current = node in open_list with the lowest f_score
        
        if current is goal:
            return construct_path(current) // 成功找到路径

        remove current from open_list
        
        // 3. 遍历邻居节点
        for each neighbor of current:
            // tentative_g_score 是从起点到这个邻居的g值
            tentative_g_score = g_score[current] + cost(current, neighbor)
            
            // 如果找到了到邻居的更短路径
            if tentative_g_score < g_score[neighbor]:
                g_score[neighbor] = tentative_g_score
                f_score[neighbor] = g_score[neighbor] + h(neighbor)
                if neighbor is not in open_list:
                    add neighbor to open_list

1.3.5 估价函数的设计:A* 的关键

估价函数 \(h(n)\) 的好坏直接决定了A*算法的效率甚至正确性。一个好的估价函数必须满足可采纳性 (Admissibility)

  • 可采纳性 :对于任何状态 \(n\),估价函数的值 \(h(n)\) 必须小于或等于 从状态 \(n\) 到目标状态的真实最小代价 。换句话说,\(h(n)\) 必须是乐观的,它永远不能高估代价。
  • 为什么重要? 如果估价函数高估了代价,可能会导致A算法错过最优解。例如,一条实际上是最优的路径,因为它的 \(h(n)\) 被高估了,导致它的 \(f(n)\) 值变得很大,使得A算法错误地放弃了对它的探索。

如果 \(h(n)\) 始终为0,A* 算法就退化成了Dijkstra算法,只考虑了 \(g(n)\),保证能找到最优解,但效率较低。

如果 \(h(n)\) 设计得越接近真实代价(但又不超过它),A* 算法的搜索就越有方向性,效率也就越高。

1.3.6 A* 实战:洛谷 P1379 八数码难题

题目描述 :在一个 \(3 \times 3\) 的棋盘上,有8个数字(1-8)和一个空格。每次可以将空格与它上下左右相邻的数字交换。给定一个初始状态和一个目标状态,求从初始状态到目标状态所需的最少移动步数。

分析

这是一个典型的最短路问题,状态空间巨大,适合用A*算法解决。

  • 状态 :\(3 \times 3\) 棋盘的布局。可以用一个二维数组或一个字符串来表示。
  • \(g(n)\) :从初始状态到当前状态 \(n\) 所用的步数。
  • \(h(n)\) :估价函数。一个简单且可采纳的估价函数是:所有数字当前位置与它在目标状态中位置的曼哈顿距离之和
    • 曼哈顿距离 :两个点 \((x_1, y_1)\) 和 \((x_2, y_2)\) 的曼哈顿距离是 \(|x_1 - x_2| + |y_1 - y_2|\)。
    • 为什么可采纳? 因为每个数字至少需要移动它与目标位置的曼哈顿距离那么多的步数才能归位,而且每次移动空格最多只能让一个数字向它的目标位置靠近一步。所以这个估计值永远不会超过真实的步数。
  • 数据结构
    • 一个优先队列,存放待扩展的状态,按 \(f(n)\) 排序。
    • 一个 map<string, int> 或哈希表,用来记录已经访问过的状态以及到达该状态的最小步数(\(g(n)\)),防止走回头路和重复搜索。

1.3.7 C++ 代码实现 (核心思路)

cpp 复制代码
#include <iostream>
#include <string>
#include <queue>
#include <map>
#include <cmath>

using namespace std;

// 状态结构体
struct State {
    string s; // 棋盘状态的字符串表示
    int g;    // 实际步数
    int h;    // 估价函数值

    // 优先队列默认是最大堆,重载小于号实现最小堆
    bool operator < (const State& other) const {
        return g + h > other.g + other.h; // f = g + h
    }
};

string goal = "123804765"; // 目标状态
map<char, pair<int, int>> goal_pos; // 预处理目标位置

// 计算h(n)
int calculate_h(const string& s) {
    int dist = 0;
    for (int i = 0; i < 9; ++i) {
        if (s[i] != '0') {
            int r1 = i / 3, c1 = i % 3;
            int r2 = goal_pos[s[i]].first;
            int c2 = goal_pos[s[i]].second;
            dist += abs(r1 - r2) + abs(c1 - c2);
        }
    }
    return dist;
}

void a_star(string start) {
    priority_queue<State> pq;
    map<string, int> g_score;

    // 预处理目标位置
    for (int i = 0; i < 9; ++i) {
        goal_pos[goal[i]] = {i / 3, i % 3};
    }

    State initial_state = {start, 0, calculate_h(start)};
    pq.push(initial_state);
    g_score[start] = 0;

    int dr[] = {-1, 1, 0, 0}; // 上下左右
    int dc[] = {0, 0, -1, 1};

    while (!pq.empty()) {
        State current = pq.top();
        pq.pop();

        if (current.s == goal) {
            cout << current.g << endl;
            return;
        }

        // 找到空格位置
        int zero_pos = current.s.find('0');
        int r = zero_pos / 3;
        int c = zero_pos % 3;

        for (int i = 0; i < 4; ++i) {
            int nr = r + dr[i];
            int nc = c + dc[i];

            if (nr >= 0 && nr < 3 && nc >= 0 && nc < 3) {
                string next_s = current.s;
                swap(next_s[zero_pos], next_s[nr * 3 + nc]);

                int new_g = current.g + 1;

                // 如果这个状态还没访问过,或者找到了更短的路
                if (g_score.find(next_s) == g_score.end() || new_g < g_score[next_s]) {
                    g_score[next_s] = new_g;
                    State next_state = {next_s, new_g, calculate_h(next_s)};
                    pq.push(next_state);
                }
            }
        }
    }
}

int main() {
    string start_s;
    char c;
    for(int i = 0; i < 9; ++i) {
        cin >> c;
        start_s += c;
    }
    a_star(start_s);

    return 0;
}

1.4 【7】双向广度优先搜索

在学习双向广度优先搜索(Bidirectional BFS)之前,我们首先需要回顾一下普通的广度优先搜索(BFS)。BFS 是一种用于图和树的遍历算法,它从一个起始节点开始,逐层地向外探索,直到找到目标节点。由于其逐层搜索的特性,BFS 能够保证找到从起点到终点的最短路径(在所有边的权重都相同的情况下)。

然而,当搜索的范围非常大时,普通的 BFS 会遇到性能瓶颈。BFS 搜索的节点数量随着搜索深度的增加呈指数级增长。如果一个问题的状态空间非常广阔,普通的 BFS 可能会因为需要访问的节点太多而导致超时或内存溢出。

为了解决这个问题,双向广度优先搜索应运而生。

1.4.1 什么是双向广度优先搜索?

双向广度优先搜索是一种对普通 BFS 的优化策略。它的核心思想是,同时从起始状态和目标状态开始进行广度优先搜索,当两个搜索方向相遇时,就意味着找到了一条从起点到终点的路径。

可以想象一个生活中的场景:两个人 A 和 B 在一个巨大的迷宫里,A 在入口,B 在出口。如果只有 A 一个人寻找 B,他可能需要走遍大半个迷宫才能找到出口。但如果 A 和 B 约定好,同时从各自的位置出发向对方靠近,他们相遇的地点很可能在迷宫的中间区域。这样,他们每个人需要探索的区域都大大减小了,找到对方的速度自然也就快了很多。

这个"迷宫"就是我们算法中的"状态空间",A 和 B 的搜索就是两个方向的 BFS。

(此处原为图片,已根据要求移除。文字描述如下:一个搜索空间中,起始点 S 和目标点 T 分别位于两侧。普通 BFS 从 S 出发,像一个逐渐扩大的圆形波纹,需要覆盖到 T 点。双向 BFS 从 S 和 T 同时出发,形成两个逐渐扩大的波纹,当两个波纹在中途相遇时,搜索即完成,两个波纹覆盖的总面积远小于单个波紋覆盖的面积。)

从数学上理解它的优势:假设每个状态可以扩展出 \(b\) 个新状态(称为分支因子),起点到终点的最短距离为 \(d\)。

  • 普通 BFS 需要访问的节点数量大约是 \(O(b^d)\)。
  • 双向 BFS 从两端同时搜索,理想情况下,它们会在深度约为 \(d/2\) 的地方相遇。此时,两个方向总共访问的节点数量大约是 \(O(b^{d/2} + b^{d/2}) = O(2 \cdot b^{d/2})\)。

当 \(b\) 和 \(d\) 比较大时,\(b^d\) 的值要远远大于 \(2 \cdot b^{d/2}\)。例如,当 \(b=10, d=10\) 时,\(b^d = 10^{10}\),而 \(2 \cdot b^{d/2} = 2 \cdot 10^5\)。效率的提升是巨大的。

1.4.2 双向广度优先搜索的核心原理

实现双向 BFS,需要以下几个关键组件:

  1. 两个队列 :一个用于正向搜索(从起点开始),我们称之为 q_start;另一个用于反向搜索(从终点开始),我们称之为 q_end
  2. 两个记录访问状态的数据结构 :通常使用数组或哈希表(如 C++ STL 中的 std::mapstd::unordered_map)。一个记录从起点出发到达各个状态的距离 dist_start,另一个记录从终点出发到达各个状态的距离 dist_end。这些数据结构同时也起到了标记已访问节点的作用,防止重复搜索。
  3. 交替搜索:为了保证两个搜索方向能够均衡地向中间扩展,通常采用交替进行的方式。即先扩展一层正向搜索的节点,再扩展一层反向搜索的节点,如此往复。一个更优化的策略是,每次选择当前节点数较少的那个队列进行扩展,这样可以使得两个搜索"波纹"的大小尽可能保持一致。
  4. 相遇判断 :在某一方向的搜索扩展出一个新节点 u 时,需要检查该节点是否已经被另一方向的搜索访问过。
    • 当正向搜索访问到节点 u 时,检查 dist_end 中是否已经记录了 u。如果记录过,说明反向搜索已经到达过这里,两个搜索在此"相遇"。
    • 同理,当反向搜索访问到节点 v 时,检查 dist_start 中是否已经记录了 v
  5. 计算最终结果 :一旦在节点 meet_node 处相遇,从起点到终点的最短路径长度就是 dist_start[meet_node] + dist_end[meet_node]

1.4.3 伪代码实现

下面是双向广度优先搜索的伪代码描述。

pseudocode 复制代码
function BidirectionalBFS(start, end):
  // 如果起点和终点相同,直接返回0
  if start == end:
    return 0

  // 1. 初始化
  q_start = new Queue()
  q_end = new Queue()
  dist_start = new Map() // 或者数组
  dist_end = new Map()   // 或者数组

  // 2. 将起点和终点加入各自的队列和距离表
  q_start.push(start)
  dist_start[start] = 0
  q_end.push(end)
  dist_end[end] = 0

  // 3. 开始交替搜索
  while not q_start.empty() and not q_end.empty():
    
    // 扩展正向搜索(可以加上优化:总是扩展较小的队列)
    // 为了简化,这里只写扩展一层的逻辑
    level_size = q_start.size()
    for i from 1 to level_size:
      current = q_start.pop()
      
      for each neighbor of current:
        if neighbor not in dist_start:
          dist_start[neighbor] = dist_start[current] + 1
          q_start.push(neighbor)
          
          // 4. 相遇判断
          if neighbor in dist_end:
            return dist_start[neighbor] + dist_end[neighbor]

    // 扩展反向搜索
    level_size = q_end.size()
    for i from 1 to level_size:
      current = q_end.pop()

      for each neighbor of current:
        if neighbor not in dist_end:
          dist_end[neighbor] = dist_end[current] + 1
          q_end.push(neighbor)

          // 4. 相遇判断
          if neighbor in dist_start:
            return dist_start[neighbor] + dist_end[neighbor]

  // 5. 如果队列为空仍未相遇,则说明无解
  return -1 // 表示无解

1.4.4 C++ 代码模板

对于状态复杂、无法直接用整数下标表示的情况(例如八数码问题中的棋盘布局),使用 std::mapstd::unordered_map 来记录距离和访问状态非常方便。

cpp 复制代码
#include <iostream>
#include <queue>
#include <string>
#include <map>
#include <algorithm>

using namespace std;

// 假设状态可以用一个整数或字符串表示
// 这里以 int 为例,如果是复杂状态,替换成 string 即可
// get_neighbors 函数需要根据具体问题来实现,它返回一个状态的所有相邻状态
vector<int> get_neighbors(int state);

int bidirectional_bfs(int start_state, int end_state) {
    if (start_state == end_state) {
        return 0;
    }

    queue<int> q_start, q_end;
    map<int, int> dist_start, dist_end;

    q_start.push(start_state);
    dist_start[start_state] = 0;

    q_end.push(end_state);
    dist_end[end_state] = 0;

    while (!q_start.empty() && !q_end.empty()) {
        // 优化:总是从较小的队列开始扩展
        if (q_start.size() > q_end.size()) {
            swap(q_start, q_end);
            swap(dist_start, dist_end);
        }

        int u = q_start.front();
        q_start.pop();

        int d = dist_start[u];

        // 假设 get_neighbors 返回一个包含所有邻居状态的 vector
        for (int v : get_neighbors(u)) {
            // 如果这个邻居还没有被正向搜索访问过
            if (dist_start.find(v) == dist_start.end()) {
                dist_start[v] = d + 1;
                q_start.push(v);
                
                // 检查是否与反向搜索相遇
                if (dist_end.find(v) != dist_end.end()) {
                    return dist_start[v] + dist_end[v];
                }
            }
        }
    }

    // 搜索结束仍未相遇,表示无解
    return -1;
}

1.4.5 经典例题:P1379 八数码难题

1. 题目描述

在一个 3x3 的棋盘上,有 1 到 8 八个数字和一个空格(通常用 0 表示)。每次操作可以将空格与它上下左右相邻的数字交换位置。给定一个初始的棋盘布局和一个目标布局,求最少需要多少次交换才能从初始布局到达目标布局。

2. 题目分析

这是一个典型的状态空间搜索问题,并且要求最少步数,自然会想到使用 BFS。

  • 状态表示 :一个 3x3 的棋盘布局可以看作一个状态。为了方便存储和查询,我们可以将这个二维的布局 "压扁" 成一个一维的字符串或一个九位数。例如,123456780 就代表了目标状态。
  • 状态转移:通过移动空格,一个状态可以转移到其相邻的 2 到 4 个新状态。
  • 问题 :八数码问题的状态总数是 \(9! = 362880\) 种。虽然这个数字看起来不大,但如果从起点到终点的步数较多(比如 30 步),普通 BFS 的搜索队列可能会变得非常庞大,导致超时或超内存。
  • 解决方案 :这正是双向 BFS 的用武之地。我们已知起点状态和终点状态(123456780),可以从这两个状态同时开始搜索,直到在中途相遇。

3. 题解代码

cpp 复制代码
#include <iostream>
#include <string>
#include <queue>
#include <map>
#include <algorithm>

using namespace std;

// 初始状态和目标状态
string start_s, target_s = "123456780";

// 记录两个方向的距离
map<string, int> dist_start, dist_end;

// 记录两个方向的队列
queue<string> q_start, q_end;

// dx, dy 用于找到空格的邻居位置
int dx[] = {0, 0, 1, -1};
int dy[] = {1, -1, 0, 0};

void bfs() {
    q_start.push(start_s);
    dist_start[start_s] = 0;

    q_end.push(target_s);
    dist_end[target_s] = 0;

    while (!q_start.empty() && !q_end.empty()) {
        // 正向扩展
        string u = q_start.front();
        q_start.pop();

        int d = dist_start[u];
        
        // 找到'0'的位置
        int zero_pos = u.find('0');
        int x = zero_pos / 3;
        int y = zero_pos % 3;

        for (int i = 0; i < 4; ++i) {
            int nx = x + dx[i];
            int ny = y + dy[i];

            if (nx >= 0 && nx < 3 && ny >= 0 && ny < 3) {
                string v = u;
                swap(v[zero_pos], v[nx * 3 + ny]);

                // 如果 v 状态还没被正向搜索访问过
                if (dist_start.find(v) == dist_start.end()) {
                    dist_start[v] = d + 1;
                    q_start.push(v);
                    
                    // 检查是否与反向搜索相遇
                    if (dist_end.find(v) != dist_end.end()) {
                        cout << dist_start[v] + dist_end[v] << endl;
                        return;
                    }
                }
            }
        }
        
        // 反向扩展
        u = q_end.front();
        q_end.pop();

        d = dist_end[u];
        
        zero_pos = u.find('0');
        x = zero_pos / 3;
        y = zero_pos % 3;

        for (int i = 0; i < 4; ++i) {
            int nx = x + dx[i];
            int ny = y + dy[i];

            if (nx >= 0 && nx < 3 && ny >= 0 && ny < 3) {
                string v = u;
                swap(v[zero_pos], v[nx * 3 + ny]);

                if (dist_end.find(v) == dist_end.end()) {
                    dist_end[v] = d + 1;
                    q_end.push(v);
                    
                    if (dist_start.find(v) != dist_start.end()) {
                        cout << dist_start[v] + dist_end[v] << endl;
                        return;
                    }
                }
            }
        }
    }
}

int main() {
    cin >> start_s;
    
    if (start_s == target_s) {
        cout << 0 << endl;
        return 0;
    }
    
    bfs();

    return 0;
}

注意:为了简化逻辑,上述代码没有采用"总是扩展小队列"的优化,而是简单地交替扩展。在实际比赛中,加入该优化可以获得更好的性能。

1.5 【7】迭代加深搜索

在信息学竞赛中,我们经常需要在庞大的状态空间中寻找解决方案。深度优先搜索(DFS)和广度优先搜索(BFS)是两种最基本的搜索策略,但它们各有优缺点:

  • DFS (Depth-First Search):

    • 优点: 内存开销小,因为它只需要存储从起点到当前节点的路径。实现起来也相对简单(通常用递归)。
    • 缺点: 如果搜索树有很深的分支或者无限分支,DFS 可能会陷入其中,找不到解。即使找到解,也不能保证是"层数"最浅的解(即最优解)。
  • BFS (Breadth-First Search):

    • 优点: 能保证找到层数最浅的解,即最短路径解。
    • 缺点: 内存开销巨大,因为它需要存储所有待扩展的节点。当搜索树的宽度很大时,队列会迅速膨胀,导致内存耗尽。

那么,是否存在一种算法,能够兼具 DFS 的小空间开销和 BFS 的寻找最优解的能力呢?答案是肯定的,它就是迭代加深搜索。

1.5.1 什么是迭代加深搜索?

迭代加深搜索(Iterative Deepening Search, IDS),全称为"迭代加深深度优先搜索"(IDDFS),是一种巧妙地结合了 DFS 和 BFS 优点的搜索算法。

它的核心思想是:对搜索深度进行限制,并逐步放大这个限制,反复地进行深度优先搜索。

让我们用一个比喻来理解它。假设你在一个大图书馆里找一本书,但你不知道它在哪一层。

  • DFS 就像你一头扎进一个书架,沿着这个书架一直走到头,再换下一个书架......你可能会在图书馆的顶层绕了半天,而书其实就在一楼。
  • BFS 就像你喊来一大群朋友,让大家把一楼的所有书架同时搜一遍,没找到,再让所有人去二楼同时搜......这样能最快找到书在哪一层,但你需要非常多的"朋友"(内存)。
  • IDS 则像你一个人行动:
    1. 你先规定:"今天只找第 1 层。" 然后你用 DFS 的方式把第 1 层的所有书架都搜一遍。
    2. 如果没找到,你回家休息一下,第二天规定:"今天搜索范围扩大到第 2 层。" 然后你从头开始,用 DFS 的方式把第 1 层和第 2 层的所有书架都搜一遍。
    3. 如果还没找到,第三天规定:"搜索范围扩大到第 3 层。" 然后你再次从头开始,把第 1、2、3 层都搜一遍。
    4. ......如此循环,直到你在某一次搜索中找到了这本书。

因为你是逐层扩大搜索范围的,所以你第一次找到书时,它所在的层数一定是最浅的,这保证了解的最优性(同 BFS)。而在每一次的搜索中,你都采用 DFS 的方式,只需要记录当前的一条路径,所以空间开销很小(同 DFS)。

1.5.2 迭代加深搜索的核心原理

迭代加深搜索的算法框架非常清晰:

  1. 设置一个循环,用来控制当前允许搜索的最大深度 max_depth。这个 max_depth 从 0 或 1 开始,每次循环递增 1。
  2. 在循环内部,调用一个带深度限制的深度优先搜索函数 depth_limited_dfs(current_node, current_depth, max_depth)
  3. 这个 depth_limited_dfs 函数和普通的 DFS 几乎一样,但增加了一个剪枝判断:如果 current_depth > max_depth,则立刻返回,不再继续向下搜索。
  4. 如果 depth_limited_dfs 在当前 max_depth 的限制下找到了解,则整个算法结束,返回该解。如果没有找到,外层循环会增加 max_depth,开始新一轮的搜索。

1.5.3 效率分析:为什么它不慢?

一个很自然的疑问是:迭代加深搜索反复地搜索浅层节点,这难道不是巨大的浪费吗?比如在搜索深度为 5 时,深度为 1, 2, 3, 4 的节点都被重复搜索了很多次。

这个担心在大多数情况下是多余的。其原因在于,在一个典型的搜索树中,绝大多数节点都集中在最底层

让我们再次考虑一个分支因子为 \(b\) 的搜索树。

  • 在深度为 \(d\) 的那一次迭代中,它会访问所有深度小于等于 \(d\) 的节点。
  • 在之前的 \(d-1\) 次迭代中,它访问了所有深度小于等于 \(d-1\) 的节点。

总访问节点数 T(d) 约等于:
\(T(d) = (d)b^0 + (d-1)b^1 + (d-2)b^2 + ... + (1)b^d\)

而一次深度为 \(d\) 的 BFS 访问的节点数约为:
\(BFS(d) = b^0 + b^1 + b^2 + ... + b^d\)

当分支因子 \(b\) 比较大时(例如 \(b \ge 2\)),最深一层 \(b^d\) 的节点数远超过上面所有层的节点数之和。因此,重复搜索上层节点的开销,相比于搜索最深一层节点的开销来说,是可以忽略不计的。IDS 的总时间复杂度和 BFS 处于同一个数量级,即 \(O(b^d)\),只是常数因子稍大一些。

所以,迭代加深搜索用一个可以接受的、较小的常数时间代价,换来了巨大的空间优化。

1.5.4 伪代码实现

pseudocode 复制代码
function IterativeDeepeningSearch(start_node, goal_node):
  // 1. 外层循环,迭代加深
  for max_depth from 0 to infinity:
    // 创建一个集合或布尔数组来记录当前路径上的节点,防止在单次DFS中走回头路
    visited_in_path = new Set() 
    
    // 2. 调用带深度限制的DFS
    result = DepthLimitedSearch(start_node, goal_node, 0, max_depth, visited_in_path)
    
    // 3. 如果找到解,则返回
    if result is a solution:
      return result
      
// 带深度限制的DFS函数
function DepthLimitedSearch(current_node, goal_node, depth, max_depth, visited_in_path):
  // 4. 到达目标,返回成功
  if current_node == goal_node:
    return solution
    
  // 5. 深度超限,剪枝
  if depth >= max_depth:
    return failure
    
  // 将当前节点加入路径
  visited_in_path.add(current_node)
  
  // 扩展邻居
  for each neighbor of current_node:
    // 如果邻居不在当前路径上(防止环)
    if neighbor not in visited_in_path:
      result = DepthLimitedSearch(neighbor, goal_node, depth + 1, max_depth, visited_in_path)
      // 如果找到了解,立刻层层返回
      if result is a solution:
        return result
  
  // 回溯:将当前节点移出路径
  visited_in_path.remove(current_node)
  
  return failure

1.5.5 C++ 代码模板

cpp 复制代码
#include <iostream>
#include <vector>
#include <cstring> // for memset

using namespace std;

const int MAXN = 100; // 假设节点数上限

vector<int> adj[MAXN]; // 邻接表存图
int start_node, goal_node;
bool found = false;

// 带深度限制的DFS
// u: 当前节点
// depth: 当前深度
// max_depth: 最大深度限制
void dfs(int u, int depth, int max_depth) {
    if (found) return; // 如果已经找到答案,直接返回
    if (depth > max_depth) return; // 深度超限

    if (u == goal_node) {
        found = true;
        // 在这里可以记录路径或直接输出结果
        return;
    }

    for (int v : adj[u]) {
        // 如果需要防止在DFS的路径中走回头路,可以在这里加判断
        // (例如,传入一个 bool visited[] 数组)
        dfs(v, depth + 1, max_depth);
        if (found) return;
    }
}

void iterative_deepening_search() {
    // 从深度 0 开始迭代,直到找到答案或达到一个合理的上限
    for (int max_depth = 0; max_depth < MAXN; ++max_depth) {
        // 每次开始新的深搜前,重置状态
        found = false;
        // 如果需要 visited 数组,也在这里重置
        
        dfs(start_node, 0, max_depth);
        
        if (found) {
            cout << "Found solution at depth: " << max_depth << endl;
            return;
        }
    }
    cout << "No solution found." << endl;
}

1.5.6 经典例题:UVA529 Addition Chains

1. 题目描述

对于一个整数 \(n\),一个"加法链"是一个整数序列 \(a_0, a_1, \ldots, a_m\),满足以下条件:

  1. \(a_0 = 1\)
  2. \(a_m = n\)
  3. 对于所有的 \(k\) (\(1 \le k \le m\)),都存在 \(i, j\) (\(0 \le j \le i < k\)),使得 \(a_k = a_i + a_j\)。

要求找到对于给定的 \(n\),最短的加法链的长度 \(m\)。

2. 题目分析

这是一个典型的最优解搜索问题。我们要找最短的链,很自然想到 BFS。

  • 状态:一个已经生成的加法链序列。
  • 状态转移 :从当前链的末尾元素 \(a_{k-1}\),我们可以通过 \(a_k = a_i + a_j\) (\(j \le i < k\)) 来生成下一个元素,从而扩展链,形成新状态。

但是,从一个链可以扩展出的新状态非常多,分支因子巨大。如果用 BFS,队列会迅速爆炸,内存不允許。

如果用普通的 DFS,我们不知道该搜多深。如果运气不好,可能会陷入一条非常长的无效链的搜索中,无法自拔。

这正是迭代加深搜索的用武之地:

  1. 搜索目标明确:寻找最短的链。
  2. 深度未知但不会无限大:链的长度是有限的。
  3. 分支因子大:不适合 BFS。

我们可以用迭代加深搜索,枚举链的长度 max_depth,从 1 开始。对于每一个 max_depth,我们用 DFS 去尝试能否构造出一个长度为 max_depth 的加法链。第一次成功时的 max_depth 就是答案。

3. 剪枝优化与题解代码

在 DFS 过程中,可以加入一些强大的剪枝来提高效率:

  • 优化剪枝 :要生成的下一个数 \(a_k\) 必须大于当前链中的最大数 \(a_{k-1}\)。
  • 可行性剪枝 :如果当前链中的最大数是 \(a_{k-1}\),那么通过剩下 max_depth - (k-1) 步,最多能达到的数是 \(a_{k-1} \times 2^{\text{max\_depth} - (k-1)}\)。如果这个数都小于目标 \(n\),那么这条路肯定走不通,可以直接剪枝。
cpp 复制代码
#include <iostream>
#include <vector>
#include <numeric>

using namespace std;

int n;
vector<int> path; // 用来存储当前的加法链
bool found;
int max_d; // 当前迭代的最大深度

// u: 当前处理链的第 u 个位置 (从1开始)
void dfs(int u) {
    if (found) return;

    // 剪枝2: 可行性剪枝
    // path[u-1] 是当前链的最大值
    // 剩下的步数是 max_d - (u-1)
    // 每一-步最多是把当前最大值翻倍
    if ((path[u - 1] << (max_d - u + 1)) < n) {
        return;
    }

    if (u > max_d) {
        if (path[u - 1] == n) {
            found = true;
        }
        return;
    }

    // 从大到小枚举 i 和 j, 这样可以更快地接近 n
    for (int i = u - 1; i >= 0; --i) {
        for (int j = i; j >= 0; --j) {
            int next_val = path[i] + path[j];
            // 剪枝1: 新生成的数必须更大,且不能超过 n
            if (next_val > path[u - 1] && next_val <= n) {
                path[u] = next_val;
                dfs(u + 1);
                if (found) return;
            }
        }
    }
}

int main() {
    while (cin >> n && n != 0) {
        if (n == 1) {
            cout << "1" << endl;
            continue;
        }
        
        // 迭代加深
        for (max_d = 1; ; ++max_d) {
            path.assign(max_d + 1, 0);
            path[0] = 1;
            found = false;
            dfs(1);
            if (found) {
                for (int i = 0; i <= max_d; ++i) {
                    cout << path[i] << (i == max_d ? "" : " ");
                }
                cout << endl;
                break;
            }
        }
    }
    return 0;
}

这段代码展示了迭代加深搜索的威力。它通过限制深度,将一个看似无法解决的 BFS 问题,转化为了一个可以高效求解的 DFS 问题,并且保证了解的最优性。

第二节 图论算法

2.1 【6】最小生成树

在学习具体的算法之前,需要先理解几个基本概念。

图(Graph) :图由若干个顶点(Vertex) 和连接顶点的**边(Edge)**组成。可以把它想象成一张城市地图,城市就是顶点,连接城市的道路就是边。

权值(Weight):在地图上,每条道路都有自己的长度。在图论中,我们给每条边赋予一个数值,称为权值。它可以代表长度、费用、时间等。

连通图(Connected Graph):如果一个图中任意两个顶点之间都至少存在一条路径,那么这个图就是连通图。简单来说,就是从任何一个城市出发,都能到达其他任何一个城市。

树(Tree) :树是一种特殊的图。它是一个无环的连通图。想象一下你家里的族谱,它就是一个树形结构,不会出现一个人既是自己的祖先又是自己的后代(这就是环)。在一个有 \(N\) 个顶点的树中,它有且仅有 \(N-1\) 条边。

生成树(Spanning Tree):对于一个连通图,它的生成树是图的一个子图(即包含原图中的一部分顶点和边),这个子图需要满足两个条件:

  1. 包含原图中所有的 \(N\) 个顶点。
  2. 它本身是一棵树(连通且无环),所以它恰好有 \(N-1\) 条边。
    一个图可以有很多个不同的生成树。

最小生成树(Minimum Spanning Tree, MST):在一个带权的连通图中,所有生成树中,边的权值之和最小的那一棵,就被称为最小生成树。

应用场景 :想象一下,现在有 \(N\) 个村庄,需要在这些村庄之间修建公路,使得任意两个村庄之间都可以互通。每条备选公路的造价不同。那么,如何选择修建哪些公路,才能在保证所有村庄都连通的前提下,花费的总造价最少?这个问题就是典型的最小生成树问题。

解决最小生成树问题主要有两种经典的贪心算法:Kruskal 算法和 Prim 算法。

2.1.1 【6】最小生成树:Kruskal 算法

Kruskal 算法是一种非常直观且容易理解的最小生成树算法。

2.1.1.1 核心思想:贪心的选择

Kruskal 算法的核心思想是**"边的贪心"**。它遵循一个简单的原则:为了让总权值最小,我们每次都选择当前可选的、权值最小的边,加入到我们的生成树中。

当然,这个选择有一个限制:新加入的边不能与已经选择的边构成一个环路。因为树是不能有环的。

所以,算法的思路就是:

  1. 将图中所有的边按照权值从小到大进行排序。
  2. 从权值最小的边开始,依次遍历每一条边。
  3. 对于当前遍历到的边,判断如果将它加入到已选择的边的集合中,是否会形成环路。
    • 如果不会形成环路,就选择这条边。
    • 如果会形成环路,就放弃这条边。
  4. 重复步骤3,直到我们选出了 \(N-1\) 条边为止。这 \(N-1\) 条边和图中的 \(N\) 个顶点就构成了最小生成树。
2.1.1.2 如何判断环路:并查集

Kruskal 算法的关键在于如何快速判断加入一条边后是否会形成环路。这里需要一个非常高效的数据结构------并查集(Disjoint Set Union, DSU)

并查集可以把 \(N\) 个顶点看作 \(N\) 个独立的集合,它支持两种操作:

  1. Find(查找):确定一个顶点属于哪个集合。通常用一个代表元素(根节点)来标识一个集合。
  2. Union(合并):将两个不同顶点所在的集合合并成一个大集合。

我们可以用并查集来维护图中的连通分量。一开始,每个顶点都自成一个连通分量(一个集合)。

当我们考虑一条连接顶点 \(u\) 和顶点 \(v\) 的边时:

  • 我们使用 Find 操作查找 \(u\) 和 \(v\) 的代表元素(它们分别属于哪个集合)。
  • 如果 \(u\) 和 \(v\) 的代表元素相同,说明它们早已处于同一个连通分量中。此时如果再连接它们,必然会形成一个环路。所以我们不能选择这条边。
  • 如果 \(u\) 和 \(v\) 的代表元素不同,说明它们分属于两个不同的连通分量。连接它们不会形成环路,只会将这两个连通分量合并成一个。于是,我们选择这条边,并使用 Union 操作合并这两个集合。
2.1.1.3 算法步骤
  1. 初始化

    • 创建一个并查集,其中每个顶点都是一个独立的集合。
    • 将图中所有的 \(M\) 条边存储起来,并按权值从小到大排序。
    • 初始化最小生成树的总权值为 \(0\),已选择的边数量为 \(0\)。
  2. 遍历边

    • 依次遍历排好序的边。设当前边连接顶点 \(u\) 和 \(v\),权值为 \(w\)。
    • 使用并查集的 Find 操作检查 \(u\) 和 \(v\) 是否在同一个集合中。
    • 如果不在同一个集合:
      • 将这条边加入最小生成树。
      • 累加总权值:\(ans += w\)。
      • 已选择的边数量加一:\(edge\_count++\)。
      • 使用并查集的 Union 操作合并 \(u\) 和 \(v\) 所在的集合。
    • 如果已选择的边数量达到了 \(N-1\),则最小生成树已经构建完成,算法结束。
2.1.1.4 伪代码
复制代码
function Kruskal(N, edges):
    // N 是顶点数, edges 是边的集合 (u, v, w)
    
    // 1. 初始化并查集
    parent = array of size N+1
    for i from 1 to N:
        parent[i] = i
    
    // 2. 将所有边按权值排序
    sort(edges)
    
    total_weight = 0
    edge_count = 0
    
    // 3. 遍历所有边
    for each edge (u, v, w) in edges:
        // 查找 u 和 v 的根节点
        root_u = find(u)
        root_v = find(v)
        
        // 如果它们不在同一个集合中
        if root_u != root_v:
            // 合并它们
            union(root_u, root_v)
            
            // 累加权值和边数
            total_weight += w
            edge_count += 1
            
            // 如果已经选了 N-1 条边,提前结束
            if edge_count == N - 1:
                break
    
    // 4. 判断是否连通
    if edge_count < N - 1:
        return "图不连通,无法构成生成树"
    else:
        return total_weight
2.1.1.5 C++ 代码模板
cpp 复制代码
#include <iostream>
#include <vector>
#include <algorithm>

using namespace std;

// 边的结构体
struct Edge {
    int u, v, w;
};

// 用于 sort 函数的比较函数
bool cmp(const Edge& a, const Edge& b) {
    return a.w < b.w;
}

const int MAXN = 5005; // 最大顶点数
int parent[MAXN];     // 并查集的父节点数组

// 并查集 - 查找操作(带路径压缩)
int find(int x) {
    if (parent[x] == x) {
        return x;
    }
    return parent[x] = find(parent[x]); // 路径压缩
}

// Kruskal 算法
long long kruskal(int n, vector<Edge>& edges) {
    // 1. 初始化并查集
    for (int i = 1; i <= n; ++i) {
        parent[i] = i;
    }

    // 2. 排序边
    sort(edges.begin(), edges.end(), cmp);

    long long total_weight = 0;
    int edge_count = 0;

    // 3. 遍历边
    for (const auto& edge : edges) {
        int root_u = find(edge.u);
        int root_v = find(edge.v);

        if (root_u != root_v) {
            parent[root_u] = root_v; // 合并
            total_weight += edge.w;
            edge_count++;
            
            // 剪枝:如果已经选够了 N-1 条边,就可以退出了
            if (edge_count == n - 1) {
                break;
            }
        }
    }

    if (edge_count < n - 1) {
        return -1; // 表示图不连通
    }
    return total_weight;
}

int main() {
    int n, m; // n: 顶点数, m: 边数
    cin >> n >> m;

    vector<Edge> edges(m);
    for (int i = 0; i < m; ++i) {
        cin >> edges[i].u >> edges[i].v >> edges[i].w;
    }

    long long result = kruskal(n, edges);

    if (result == -1) {
        cout << "orz" << endl; // 按照一些题目的要求输出
    } else {
        cout << result << endl;
    }

    return 0;
}
2.1.1.6 复杂度分析
  • 时间复杂度 :算法的主要时间开销在于对 \(M\) 条边进行排序,其时间复杂度为 \(O(M \log M)\)。并查集的操作(查找和合并)如果使用了路径压缩和按秩合并优化,其单次操作的平均时间复杂度接近 \(O(1)\),总共 \(M\) 次操作的复杂度远小于排序。因此,Kruskal 算法的总时间复杂度为 \(O(M \log M)\)。
  • 空间复杂度 :需要 \(O(M)\) 的空间存储边,以及 \(O(N)\) 的空间用于并查集。

Kruskal 算法适用于稀疏图 (边的数量 \(M\) 远小于 \(N^2\)),因为它的复杂度主要和边数有关。

2.1.1.7 洛谷例题:P3366 【模板】最小生成树

题目描述

如题,给出一个 \(N\) 个点,\(M\) 条边的无向图,求该图的最小生成树。如果图不连通,则输出 "orz"。

输入格式

第一行包含两个整数 \(N, M\),表示该图共有 \(N\) 个结点和 \(M\) 条无向边。

接下来 \(M\) 行每行包含三个整数 \(u, v, w\),表示点 \(u\) 和点 \(v\) 之间存在一条权值为 \(w\) 的边。

输出格式

共一行,若存在最小生成树,则输出一个整数,表示最小生成树的权值之和,否则输出 "orz"。

题解

这道题是最小生成树的模板题。可以直接使用上面提供的 Kruskal 算法代码模板来解决。将输入的 \(N\) 和 \(M\) 以及所有的边信息读取后,调用 kruskal 函数即可。函数返回值如果是 -1(在代码中判断 edge_count < n - 1),则说明图不连通,输出 "orz";否则输出计算得到的最小权值和。

2.1.2 【6】最小生成树:Prim 算法

Prim 算法是另一种经典的最小生成树算法。如果说 Kruskal 是"选边"的贪心,那么 Prim 就是"选点"的贪心。

2.1.2.1 核心思想:从点出发的贪心

Prim 算法的思路是从一个任意的顶点开始,逐步扩大一棵"已经建好的树"。

  1. 初始化

    • 将图中的顶点分为两个集合:\(S\) 集合(已加入最小生成树的顶点)和 \(T\) 集合(未加入的顶点)。
    • 一开始,任选一个顶点(比如 1 号顶点)放入集合 \(S\) 中,其他所有顶点都在集合 \(T\) 中。
  2. 循环扩展

    • 重复以下操作 \(N-1\) 次,直到所有顶点都加入集合 \(S\)。
    • 在每一步中,寻找一条权值最小的边 \((u, v)\),其中 \(u \in S\) 且 \(v \in T\)。也就是说,找到一条连接"树内"和"树外"的、最短的"桥梁"。
    • 将这条最短的边加入最小生成树,并将顶点 \(v\) 从集合 \(T\) 移动到集合 \(S\)。

这个过程就像在一个孤岛上(初始顶点),每次都选择修建一座通往最近的新岛屿的最短的桥,然后把新岛屿也纳入自己的版图,不断重复这个过程,直到所有岛屿都连接起来。

2.1.2.2 算法步骤
  1. 初始化

    • dist[i]:表示顶点 i 到集合 \(S\) 的最短距离。初始化 dist[1] = 0,其他 dist[i] 为无穷大。
    • visited[i]:布尔数组,标记顶点 i 是否已加入集合 \(S\)。初始化所有顶点都为 false
    • 总权值 total_weight = 0
  2. 循环 N 次

    • 在所有未被访问的顶点中,找到一个 dist 值最小的顶点 u
    • u 标记为已访问 visited[u] = true
    • dist[u] 加入总权值 total_weight
    • 遍历所有与 u 相连的顶点 v
      • 如果 v 未被访问,并且从 uv 的边权值 w(u, v) 小于 dist[v],则更新 dist[v] = w(u, v)。这被称为**"松弛"** 操作,意味着我们找到了一个更短的方式从集合 \(S\) 连接到顶点 v
  3. 结束 :循环结束后,total_weight 就是最小生成树的权值和。

2.1.2.3 如何实现:朴素版本与堆优化

朴素版本

在上面步骤的第二步,"找到一个 dist 值最小的顶点 u",我们可以通过一个循环遍历所有未访问的顶点来实现。

  • 外层循环 \(N\) 次。
  • 内层循环 \(O(N)\) 寻找 dist 最小的顶点。
  • 总时间复杂度为 \(O(N^2)\)。这适用于稠密图 (边的数量 \(M\) 接近 \(N^2\))。

堆优化版本

注意到"找到 dist 值最小的顶点"这个操作非常适合用**优先队列(最小堆)**来优化。

  1. 初始化

    • dist 数组和 visited 数组同上。
    • 创建一个优先队列 pq,存储二元组 (距离, 顶点编号),按距离从小到大排序。
    • (0, 1) 放入优先队列。
  2. 循环

    • 当优先队列不为空时,取出队首元素 (d, u)
    • 如果 u 已经被访问过,跳过。
    • u 标记为已访问,累加总权值。
    • 遍历 u 的邻居 v,如果 v 未被访问且 w(u, v) < dist[v],则更新 dist[v] = w(u, v),并将 (dist[v], v) 加入优先队列。

这个过程和后面要讲的 Dijkstra 算法非常相似。

2.1.2.4 伪代码(堆优化版)
复制代码
function Prim(N, graph):
    // graph 是邻接表表示的图
    dist = array of size N+1, initialized to infinity
    visited = array of size N+1, initialized to false
    pq = new PriorityQueue() // 最小堆
    
    dist[1] = 0
    pq.push((0, 1))
    
    total_weight = 0
    edge_count = 0
    
    while pq is not empty:
        d, u = pq.pop()
        
        // 如果已经处理过,则跳过
        if visited[u]:
            continue
        
        visited[u] = true
        total_weight += d
        edge_count += 1
        
        // 遍历 u 的邻居 v
        for each neighbor v of u with edge weight w:
            if not visited[v] and w < dist[v]:
                dist[v] = w
                pq.push((w, v))
                
    if edge_count < N:
        return "图不连通"
    else:
        return total_weight
2.1.2.5 C++ 代码模板(堆优化版)
cpp 复制代码
#include <iostream>
#include <vector>
#include <queue>

using namespace std;

const int MAXN = 5005;
const int INF = 0x3f3f3f3f; // 代表无穷大

// 邻接表存储图
struct Edge {
    int to, weight;
};
vector<Edge> graph[MAXN];

// 优先队列中存储的节点状态
struct Node {
    int u, dist;
    // 重载小于号,用于优先队列排序
    bool operator<(const Node& other) const {
        return dist > other.dist;
    }
};

bool visited[MAXN];
int dist[MAXN];

long long prim(int n) {
    // 1. 初始化
    for (int i = 1; i <= n; ++i) {
        dist[i] = INF;
        visited[i] = false;
    }

    priority_queue<Node> pq;
    
    dist[1] = 0;
    pq.push({1, 0});

    long long total_weight = 0;
    int node_count = 0;

    // 2. 主循环
    while (!pq.empty() && node_count < n) {
        Node current = pq.top();
        pq.pop();

        int u = current.u;
        int d = current.dist;
        
        if (visited[u]) {
            continue;
        }

        visited[u] = true;
        total_weight += d;
        node_count++;

        // 3. 松弛操作
        for (const auto& edge : graph[u]) {
            int v = edge.to;
            int w = edge.weight;
            if (!visited[v] && w < dist[v]) {
                dist[v] = w;
                pq.push({v, dist[v]});
            }
        }
    }
    
    if (node_count < n) {
        return -1; // 图不连通
    }
    return total_weight;
}

int main() {
    int n, m;
    cin >> n >> m;

    for (int i = 0; i < m; ++i) {
        int u, v, w;
        cin >> u >> v >> w;
        graph[u].push_back({v, w});
        graph[v].push_back({u, w}); // 无向图
    }

    long long result = prim(n);

    if (result == -1) {
        cout << "orz" << endl;
    } else {
        cout << result << endl;
    }

    return 0;
}
2.1.2.6 复杂度分析
  • 朴素版本 :时间复杂度为 \(O(N^2)\),空间复杂度为 \(O(N^2)\)(如果用邻接矩阵存图)或 \(O(M)\)(如果用邻接表)。
  • 堆优化版本 :每个顶点入队一次,出队一次。每次出队后,会遍历其所有出边,可能导致其他顶点入队。每条边最多被考虑两次(在无向图中)。因此,时间复杂度为 \(O(M \log N)\)(因为优先队列的操作是 \(\log\) 级别的)。空间复杂度为 \(O(M)\)(邻接表)+ \(O(N)\)(优先队列)。
2.1.2.7 Kruskal 与 Prim 的比较

| 特性 | Kruskal 算法 | Prim 算法 |

| :- | :- | : |

| 核心思想 | 边的贪心 | 点的贪心 |

| 数据结构 | 并查集 | 优先队列(堆) |

| 时间复杂度 | \(O(M \log M)\) | \(O(M \log N)\) |

| 适用图 | 稀疏图(\(M\) 较小) | 稠密图(\(M\) 较大,朴素版 \(O(N^2)\) 更优) |

| 实现 | 相对简单,只需排序和并查集 | 堆优化版与 Dijkstra 算法类似 |

对于大部分题目,由于 \(M \log M\) 和 \(M \log N\) 在数值上差别不大,两种算法都可以通过。选择哪一种通常取决于个人习惯和图的存储方式。

2.2 【6】单源最短路

单源最短路(Single-Source Shortest Path, SSSP)问题是图论中最基本也是最重要的问题之一。

问题描述 :给定一个带权有向图(或无向图)和一个源顶点(起点) \(s\),求从 \(s\) 出发到图中所有其他顶点的最短路径长度。

这里的"最短"指的是路径上所有边的权值之和最小。

在学习算法前,需要理解一个所有最短路算法共有的核心操作------松弛(Relaxation)

dist[v] 表示从源点 \(s\) 到顶点 \(v\) 的当前已知 的最短路径长度。

松弛操作是这样的:对于一条从顶点 \(u\) 到顶点 \(v\) 的边,权值为 \(w(u,v)\),我们检查是否可以通过 \(u\) 来缩短到达 \(v\) 的路径。

即,判断 dist[u] + w(u,v) 是否小于 dist[v]

如果小于,就说明从 \(s\) 先到 \(u\),再从 \(u\) 到 \(v\) 这条路更近。于是我们更新 dist[v] = dist[u] + w(u,v)

复制代码
if dist[u] + w(u,v) < dist[v]:
    dist[v] = dist[u] + w(u,v)

所有单源最短路算法,本质上都是在用不同的策略,反复对图中的边进行松弛操作,直到无法再松弛为止。

2.2.1 【6】单源最短路:Dijkstra 算法

Dijkstra(迪杰斯特拉)算法是解决单源最短路问题最经典的算法之一。它适用于所有边权均为非负数的图。

2.2.1.1 核心思想:贪心的扩展

Dijkstra 算法的思想与 Prim 算法非常相似。它也是将顶点分为两个集合:\(S\)(已确定最短路径的顶点)和 \(T\)(未确定最短路径的顶点)。

算法的贪心策略是:每次都从集合 \(T\) 中,选取一个距离源点 \(s\) 最近的顶点 \(u\),将其加入集合 \(S\)。

为什么这个贪心是正确的?因为边权都是非负的。当我们选择了当前最近的顶点 \(u\) 时,dist[u] 的值就已经被确定为最终的最短路径了。任何其他从 \(s\) 绕道某个 \(T\) 中的点 \(v\) 再到 \(u\) 的路径,其长度必然是 dist[v] + w(v, u)。由于 dist[v] >= dist[u](因为 \(u\) 是当前最近的),且 \(w(v, u) \ge 0\),所以 dist[v] + w(v, u) \ge dist[u]。这保证了不可能再有比 dist[u] 更短的路径了。

2.2.1.2 无法处理负权边的原因

正是上面这个贪心策略的正确性证明,揭示了它为什么不能处理负权边。如果存在负权边 \(w(v, u) < 0\),那么 dist[v] + w(v, u) 就可能小于 dist[u],导致我们过早地确定了 \(u\) 的最短路径,而实际上存在一条经过负权边的更短的路径。

例如:从 \(s\) 到 \(u\) 的直接距离是 5。从 \(s\) 到 \(v\) 的距离是 10,但 \(v\) 到 \(u\) 有一条权值为 -6 的边。

Dijkstra 算法会先确定 \(u\) 的最短路为 5。但实际上 \(s \to v \to u\) 的路径长度是 \(10 + (-6) = 4\),更短。算法会出错。

2.2.1.3 算法步骤
  1. 初始化

    • dist 数组:dist[s] = 0,其他 dist[i] 为无穷大。
    • visited 数组:标记顶点是否已加入集合 \(S\)(即已确定最短路),全部初始化为 false
  2. 循环 N 次

    • 在所有未被访问的顶点中,找到一个 dist 值最小的顶点 u
    • u 标记为已访问 visited[u] = true
    • 遍历所有从 u 出发的边 \((u, v)\),对顶点 v 进行松弛操作:if (dist[u] + w(u,v) < dist[v]) dist[v] = dist[u] + w(u,v)
2.2.1.4 实现:朴素版本与堆优化

这和 Prim 算法的实现几乎一模一样。

朴素版本 :时间复杂度 \(O(N^2)\),适用于稠密图。
堆优化版本 :使用优先队列来快速找到 dist 值最小的未访问顶点。时间复杂度 \(O(M \log N)\),适用于稀疏图。这是竞赛中最常用的版本。

2.2.1.5 伪代码(堆优化版)
复制代码
function Dijkstra(graph, source):
    dist = array of size N+1, initialized to infinity
    pq = new PriorityQueue() // 最小堆
    
    dist[source] = 0
    pq.push((0, source)) // 存 (距离, 顶点)
    
    while pq is not empty:
        d, u = pq.pop()
        
        // 这是一个重要的优化:如果取出的距离比已知的还大,说明是旧信息,跳过
        if d > dist[u]:
            continue
        
        // 对 u 的所有邻居 v 进行松弛
        for each neighbor v of u with edge weight w:
            if dist[u] + w < dist[v]:
                dist[v] = dist[u] + w
                pq.push((dist[v], v))
                
    return dist
2.2.1.6 C++ 代码模板(堆优化版)
cpp 复制代码
#include <iostream>
#include <vector>
#include <queue>

using namespace std;

const int MAXN = 100005;
const long long INF = 1e18; // 距离可能很大,用 long long

// 邻接表
struct Edge {
    int to;
    int weight;
};
vector<Edge> graph[MAXN];

// 优先队列中的节点
struct Node {
    int u;
    long long dist;
    bool operator>(const Node& other) const {
        return dist > other.dist;
    }
};

long long dist[MAXN];
bool visited[MAXN]; // 在堆优化版中,visited 可省略,用 d > dist[u] 判断

void dijkstra(int s, int n) {
    // 1. 初始化
    for (int i = 1; i <= n; ++i) {
        dist[i] = INF;
    }
    dist[s] = 0;

    priority_queue<Node, vector<Node>, greater<Node>> pq;
    pq.push({s, 0});

    while (!pq.empty()) {
        Node current = pq.top();
        pq.pop();

        int u = current.u;
        long long d = current.dist;

        if (d > dist[u]) {
            continue;
        }

        // 2. 松弛
        for (const auto& edge : graph[u]) {
            int v = edge.to;
            int w = edge.weight;
            if (dist[u] + w < dist[v]) {
                dist[v] = dist[u] + w;
                pq.push({v, dist[v]});
            }
        }
    }
}

int main() {
    int n, m, s;
    cin >> n >> m >> s;

    for (int i = 0; i < m; ++i) {
        int u, v, w;
        cin >> u >> v >> w;
        graph[u].push_back({v, w});
        // 如果是无向图,需要加反向边
        // graph[v].push_back({u, w}); 
    }

    dijkstra(s, n);

    for (int i = 1; i <= n; ++i) {
        if (dist[i] == INF) {
            cout << 2147483647 << " "; // 按题目要求
        } else {
            cout << dist[i] << " ";
        }
    }
    cout << endl;

    return 0;
}
2.2.1.7 洛谷例题:P4779 【模板】单源最短路径(标准版)

题目描述

给出一个 \(N\) 个点,\(M\) 条边的有向图,求从源点 \(S\) 到所有点的最短路。

输入格式

第一行包含三个整数 \(N, M, S\),分别表示点的个数、有向边的个数、源点。

接下来 \(M\) 行每行包含三个整数 \(u, v, w\),表示一条从 \(u\) 到 \(v\) 的,长度为 \(w\) 的有向边。

输出格式

输出一行 \(N\) 个整数,第 \(i\) 个表示 \(S\) 到第 \(i\) 个点的最短路,若不能到达则输出 \(2^{31}-1\)。

题解

题目保证了边权为正,是标准版的 Dijkstra 算法应用。直接使用上面的堆优化 Dijkstra 代码模板即可通过。注意数据范围,dist 数组要使用 long long 来防止溢出。

2.2.2 【6】单源最短路:Bellman-Ford 算法

与 Dijkstra 不同,Bellman-Ford 算法可以处理带有负权边 的图,并且还能检测负环。当然,它的代价是时间复杂度更高。

负环(Negative Cycle):一个权值之和为负数的环路。如果图中存在负环,并且从源点可以到达这个环,那么最短路就不存在了。因为每绕这个环一圈,路径长度就会变得更小,可以无限地小下去。

2.2.2.1 核心思想:迭代松弛

Bellman-Ford 的思想非常暴力而有效。它基于这样一个事实:在一个不包含负环的图中,从源点 \(s\) 到任意顶点 \(v\) 的最短路径,最多包含 \(N-1\) 条边。

于是,算法进行了 \(N-1\) 轮迭代。在每一轮迭代中,它都会对图中的所有 \(M\) 条边进行一次松弛操作。

  • 第 1 轮迭代后,dist[v] 记录的是从 \(s\) 出发,最多经过 1 条边,到达 \(v\) 的最短路。
  • 第 2 轮迭代后,dist[v] 记录的是从 \(s\) 出发,最多经过 2 条边,到达 \(v\) 的最短路。
  • ...
  • 第 \(N-1\) 轮迭代后,dist[v] 记录的是从 \(s\) 出发,最多经过 \(N-1\) 条边,到达 \(v\) 的最短路。

由于最短路最多就 \(N-1\) 条边,所以 \(N-1\) 轮迭代后,我们就得到了最终的最短路结果。

2.2.2.2 处理负权边

Dijkstra 的贪心策略在负权边面前会失效,但 Bellman-Ford 的"蛮力"迭代法则不会。它不依赖任何贪心选择,而是系统性地考虑了所有可能性(路径长度从 1 到 \(N-1\)),因此可以正确处理负权边。

2.2.2.3 负环判断

这是 Bellman-Ford 算法一个非常重要的功能。如果在完成了 \(N-1\) 轮迭代后,我们再进行第 \(N\) 轮迭代,发现仍然有边可以被松弛,这意味着什么?

dist[u] + w(u,v) < dist[v] 仍然成立,说明从 \(s\) 到 \(v\) 的路径长度可以被一条经过 \(u\) 的路径更新。这条新的路径必然包含了至少 \(N\) 条边(因为 \(N-1\) 轮已经穷尽了所有边数少于 \(N\) 的路径)。一条经过 \(N\) 个顶点的路径如果包含 \(N\) 条边,那它必然形成了一个环。而这个环之所以能让路径变短,只可能是因为这个环的权值和是负数。

所以,如果在第 \(N\) 轮迭代中仍有松弛操作成功,图中就存在负环。

2.2.2.4 算法步骤
  1. 初始化

    • dist 数组:dist[s] = 0,其他 dist[i] 为无穷大。
    • 存储所有边(例如用一个结构体数组)。
  2. 迭代

    • 循环 \(N-1\) 次(for i from 1 to N-1):
      • 遍历图中的每一条边 \((u, v)\),权值为 \(w\):
        • 进行松弛操作:if (dist[u] + w < dist[v]) dist[v] = dist[u] + w
  3. 负环检测

    • 再进行一轮遍历(第 \(N\) 轮):
      • 遍历图中的每一条边 \((u, v)\),权值为 \(w\):
        • 如果 dist[u] + w < dist[v] 仍然成立,则说明图中存在负环。
2.2.2.5 伪代码
复制代码
function BellmanFord(edges, N, source):
    dist = array of size N+1, initialized to infinity
    dist[source] = 0
    
    // N-1 轮松弛
    repeat N-1 times:
        for each edge (u, v, w) in edges:
            if dist[u] != infinity and dist[u] + w < dist[v]:
                dist[v] = dist[u] + w
    
    // 第 N 轮检测负环
    for each edge (u, v, w) in edges:
        if dist[u] != infinity and dist[u] + w < dist[v]:
            return "Graph contains a negative cycle"
            
    return dist
2.2.2.6 C++ 代码模板
cpp 复制代码
#include <iostream>
#include <vector>

using namespace std;

const int MAXN = 505;
const int MAXM = 10005;
const int INF = 0x3f3f3f3f;

struct Edge {
    int u, v, w;
};

Edge edges[MAXM];
int dist[MAXN];
int n, m;

bool bellman_ford(int s) {
    // 1. 初始化
    for (int i = 1; i <= n; ++i) {
        dist[i] = INF;
    }
    dist[s] = 0;

    // 2. N-1 轮松弛
    for (int i = 1; i < n; ++i) {
        bool relaxed = false;
        for (int j = 0; j < m; ++j) {
            int u = edges[j].u;
            int v = edges[j].v;
            int w = edges[j].w;
            if (dist[u] != INF && dist[u] + w < dist[v]) {
                dist[v] = dist[u] + w;
                relaxed = true;
            }
        }
        // 一个优化:如果某一轮没有发生任何松弛,说明已经收敛,可以提前退出
        if (!relaxed) break;
    }

    // 3. 第 N 轮检测负环
    for (int j = 0; j < m; ++j) {
        int u = edges[j].u;
        int v = edges[j].v;
        int w = edges[j].w;
        if (dist[u] != INF && dist[u] + w < dist[v]) {
            return true; // 存在负环
        }
    }

    return false; // 不存在负环
}

int main() {
    int s; // 假设源点为1
    cin >> n >> m >> s;
    for (int i = 0; i < m; ++i) {
        cin >> edges[i].u >> edges[i].v >> edges[i].w;
    }

    if (bellman_ford(s)) {
        cout << "存在负环" << endl;
    } else {
        for (int i = 1; i <= n; ++i) {
            if (dist[i] == INF) {
                cout << "INF ";
            } else {
                cout << dist[i] << " ";
            }
        }
        cout << endl;
    }

    return 0;
}
2.2.2.7 复杂度分析
  • 时间复杂度 :算法有两层循环,外层循环 \(N-1\) 次,内层循环遍历 \(M\) 条边。因此,时间复杂度为 \(O(N \cdot M)\)。
  • 空间复杂度 :需要 \(O(M)\) 存储边, \(O(N)\) 存储 dist 数组。

Bellman-Ford 算法比 Dijkstra 慢,但在有负权边时是必需的。

2.2.2.8 洛谷例题:P3385 【模板】负环

题目描述

给出一个 \(N\) 个点,\(M\) 条边的有向图,判断图中是否存在从 1 号点可达的负环。

输入格式

第一行一个正整数 \(T\),表示有 \(T\) 组数据。

每组数据第一行两个整数 \(N, M\)。

接下来 \(M\) 行,每行三个整数 \(u, v, w\),表示 \(u \to v\) 有一条权值为 \(w\) 的边。

题解

这道题正是 Bellman-Ford 算法的经典应用。对每组数据,运行一次 Bellman-Ford 算法。如果算法的返回值表示存在负环,则输出 "YES"(或题目要求的肯定回答),否则输出 "NO"。源点可以设为 1。

2.2.3 【6】单源最短路:SPFA 算法

SPFA(Shortest Path Faster Algorithm)算法,从名字就能看出它的特点------"更快"。实际上,它是 Bellman-Ford 算法的一种队列优化版本。

2.2.3.1 核心思想:Bellman-Ford 的队列优化

回顾 Bellman-Ford,它在每一轮都盲目地对所有 \(M\) 条边进行松弛。但实际上,只有那些 dist 值发生变化的顶点的出边,才有可能去松弛其他顶点。

SPFA 敏锐地抓住了这一点。它使用一个队列来维护那些 dist 值被成功松弛的顶点。

  1. 初始化 :将源点 \(s\) 入队。
  2. 循环 :当队列不为空时,取出队首顶点 \(u\)。
  3. 松弛 :遍历 \(u\) 的所有出边 \((u,v)\),进行松弛操作。
  4. 入队 :如果顶点 \(v\) 的 dist 值被成功更新了,并且 \(v\) 当前不在队列中 ,就将 \(v\) 入队。

这样,只有"有潜力"去更新别人的顶点才会被放入队列,并作为松弛的起点,大大减少了冗余的计算。

2.2.3.2 算法步骤
  1. 初始化

    • dist 数组:dist[s] = 0,其他为无穷大。
    • in_queue 数组:标记顶点是否在队列中,全为 false
    • cnt 数组:记录每个顶点入队的次数,用于判负环。
    • 创建一个队列 q,将源点 \(s\) 入队,in_queue[s] = true, cnt[s] = 1
  2. 主循环

    • 当队列 q 不为空时:
      • 取出队首顶点 uq.pop(), in_queue[u] = false
      • 遍历 u 的所有出边 \((u, v)\),权值为 \(w\):
        • 进行松弛:if (dist[u] + w < dist[v])
        • 如果松弛成功:
          • 更新 dist[v] = dist[u] + w
          • 如果 v 不在队列中:
            • q.push(v), in_queue[v] = true
            • cnt[v]++
            • 如果 cnt[v] > n,则说明发现了负环,算法结束。
2.2.3.3 负环判断

SPFA 判断负环的原理是:如果一个顶点入队超过 \(N\) 次,那么一定存在负环。因为在一个没有负环的图中,一个顶点的 dist 值最多被更新 \(N-1\) 次(对应最多 \(N-1\) 条边的最短路)。如果它被更新了第 \(N\) 次,说明最短路经过了 \(N\) 条边,必然形成了环,且是负环。

2.2.3.4 伪代码
复制代码
function SPFA(graph, N, source):
    dist = array of size N+1, initialized to infinity
    in_queue = array of size N+1, initialized to false
    count = array of size N+1, initialized to 0
    q = new Queue()
    
    dist[source] = 0
    q.push(source)
    in_queue[source] = true
    count[source] = 1
    
    while q is not empty:
        u = q.front()
        q.pop()
        in_queue[u] = false
        
        for each neighbor v of u with edge weight w:
            if dist[u] + w < dist[v]:
                dist[v] = dist[u] + w
                if not in_queue[v]:
                    q.push(v)
                    in_queue[v] = true
                    count[v] += 1
                    if count[v] > N:
                        return "Graph contains a negative cycle"
                        
    return dist
2.2.3.5 C++ 代码模板
cpp 复制代码
#include <iostream>
#include <vector>
#include <queue>

using namespace std;

const int MAXN = 100005;
const long long INF = 1e18;

struct Edge {
    int to;
    int weight;
};
vector<Edge> graph[MAXN];

long long dist[MAXN];
bool in_queue[MAXN];
int cnt[MAXN]; // 记录入队次数
int n, m;

bool spfa(int s) {
    // 1. 初始化
    for (int i = 1; i <= n; ++i) {
        dist[i] = INF;
        in_queue[i] = false;
        cnt[i] = 0;
    }

    queue<int> q;
    
    dist[s] = 0;
    q.push(s);
    in_queue[s] = true;
    cnt[s]++;

    // 2. 主循环
    while (!q.empty()) {
        int u = q.front();
        q.pop();
        in_queue[u] = false;

        for (const auto& edge : graph[u]) {
            int v = edge.to;
            int w = edge.weight;

            if (dist[u] != INF && dist[u] + w < dist[v]) {
                dist[v] = dist[u] + w;
                if (!in_queue[v]) {
                    q.push(v);
                    in_queue[v] = true;
                    cnt[v]++;
                    if (cnt[v] > n) {
                        return true; // 存在负环
                    }
                }
            }
        }
    }

    return false; // 不存在负环
}

int main() {
    int s;
    cin >> n >> m >> s;
    for (int i = 0; i < m; ++i) {
        int u, v, w;
        cin >> u >> v >> w;
        graph[u].push_back({v, w});
    }

    if (spfa(s)) {
        cout << "存在负环" << endl;
    } else {
        // ... 输出结果 ...
    }

    return 0;
}
2.2.3.6 复杂度分析
  • 时间复杂度 :SPFA 的时间复杂度在随机数据下表现非常好,期望是 \(O(k \cdot M)\),其中 \(k\) 是一个很小的常数,通常认为是 \(O(M)\) 级别的。但在一些特殊构造的网格图、链式图上,SPFA 会被"卡"到其最坏时间复杂度 \(O(N \cdot M)\),与 Bellman-Ford 相同。因此在没有负权边的图中,Dijkstra 依然是首选。
  • 空间复杂度 :\(O(M)\)(邻接表)+ \(O(N)\)(队列及辅助数组)。
2.2.3.7 SPFA, Dijkstra, Bellman-Ford 的选择
  1. 图中没有负权边

    • 首选堆优化的 Dijkstra 算法 。它的时间复杂度 \(O(M \log N)\) 非常稳定高效。
  2. 图中有负权边

    • 需要判断负环:使用 Bellman-Ford 或 SPFA。SPFA 在绝大多数情况下更快,但如果担心被特殊数据卡,Bellman-Ford 是更稳妥的选择。
    • 保证没有负环:同样使用 Bellman-Ford 或 SPFA。

在信息学竞赛中,如果题目包含负权边,通常 SPFA 是可以通过的。只有在出题人特意构造数据卡 SPFA 时,才需要换成 Bellman-Ford 或其他更复杂的算法。

2.3 【7】单源次短路

在掌握了最短路之后,一个自然的延伸问题就是:如果最短路走不了,第二短的路是哪条?这就是单源次短路问题。

问题描述 :给定一个带权图、一个源点 \(s\) 和一个终点 \(t\),求从 \(s\) 到 \(t\) 的路径中,长度严格大于 最短路长度,并且是所有这种路径中长度最小的一条。这条路径被称为次短路

注意,次短路和最短路的路线可以有部分甚至完全重合,只要它们的总权值不同即可。

2.3.1 核心思想:记录两种最短路

解决这个问题的关键在于,我们不能只关心每个点的最短路了。对于图中的任意一个顶点 \(u\),到达它的次短路,其来源只有两种可能:

  1. 从某个邻居 \(v\) 的最短路走过来。
  2. 从某个邻居 \(v\) 的次短路走过来。

这就启发我们,在求解过程中,需要为每个顶点维护两个信息:

  • dist[u]:从源点 \(s\) 到 \(u\) 的最短路径长度。
  • dist2[u]:从源点 \(s\) 到 \(u\) 的次短路径长度。

2.3.2 类似于 Dijkstra 的算法

我们可以对 Dijkstra 算法进行魔改,来同时计算 distdist2

  1. 状态定义

    • dist[i]dist2[i] 分别表示 \(s\) 到 \(i\) 的最短和次短路,初始化为无穷大。
    • dist[s] = 0
  2. 优先队列

    • 优先队列中存储的不再是 (距离, 顶点),而是 (d, u),表示一条长度为 \(d\) 的路径到达了顶点 \(u\)。我们不再需要 visited 数组,因为一个点可能会因为找到了更短的次短路而再次入队。
  3. 松弛操作的扩展

    • 当从优先队列中取出 (d, u) 时,我们遍历 \(u\) 的邻居 \(v\),设边权为 \(w\)。有一条新路径到达 \(v\),长度为 new_dist = d + w
    • 现在,用 new_dist 去更新 dist[v]dist2[v]
      • Case 1: new_dist < dist[v]
        • 这说明我们找到了一个更短的"最短路"。
        • 那么,原来的最短路 dist[v] 就变成了当前的"次短路"候选。所以 dist2[v] = dist[v]
        • 新的最短路是 dist[v] = new_dist
        • 将新的最短路 (dist[v], v) 和次短路 (dist2[v], v) 都放入优先队列。
      • Case 2: dist[v] < new_dist < dist2[v]
        • 这条新路径比最短路长,但比已知的次短路短。
        • 这正是我们想要的,更新次短路:dist2[v] = new_dist
        • 将新的次短路 (dist2[v], v) 放入优先队列。
  4. 最终结果

    • 算法结束后,dist2[t] 就是从 \(s\) 到 \(t\) 的次短路长度。

这个算法本质上是在图上跑 Dijkstra,但把状态扩充了。每个点都有"最短"和"次短"两个状态,只要能更新这两个状态之一,就继续扩展。

2.3.3 伪代码

复制代码
function SecondShortestPath(graph, N, s, t):
    dist = array of size N+1, initialized to infinity
    dist2 = array of size N+1, initialized to infinity
    pq = new PriorityQueue()
    
    dist[s] = 0
    pq.push((0, s))
    
    while pq is not empty:
        d, u = pq.pop()
        
        // 如果当前取出的路径比已知的次短路还长,没必要扩展了
        if d > dist2[u]:
            continue
            
        for each neighbor v of u with edge weight w:
            new_dist = d + w
            
            // Case 1: 发现更短的最短路
            if new_dist < dist[v]:
                dist2[v] = dist[v]
                dist[v] = new_dist
                pq.push((dist[v], v))
                pq.push((dist2[v], v))
            // Case 2: 发现更短的次短路
            else if new_dist > dist[v] and new_dist < dist2[v]:
                dist2[v] = new_dist
                pq.push((dist2[v], v))
                
    return dist2[t]

2.3.4 C++ 代码模板

cpp 复制代码
#include <iostream>
#include <vector>
#include <queue>

using namespace std;

const int MAXN = 5005;
const int INF = 0x3f3f3f3f;

struct Edge {
    int to, weight;
};
vector<Edge> graph[MAXN];

struct Node {
    int u, dist;
    bool operator>(const Node& other) const {
        return dist > other.dist;
    }
};

int dist[MAXN], dist2[MAXN];
int n, m;

void find_second_shortest(int s) {
    // 1. 初始化
    for (int i = 1; i <= n; ++i) {
        dist[i] = INF;
        dist2[i] = INF;
    }

    priority_queue<Node, vector<Node>, greater<Node>> pq;

    dist[s] = 0;
    pq.push({s, 0});

    while (!pq.empty()) {
        Node current = pq.top();
        pq.pop();

        int u = current.u;
        int d = current.dist;

        // 剪枝:如果当前路径比已知的次短路还长,则无需继续
        if (d > dist2[u]) {
            continue;
        }

        for (const auto& edge : graph[u]) {
            int v = edge.to;
            int w = edge.weight;
            int new_dist = d + w;

            if (new_dist < dist[v]) {
                dist2[v] = dist[v];
                dist[v] = new_dist;
                pq.push({v, dist[v]});
                pq.push({v, dist2[v]});
            } else if (new_dist > dist[v] && new_dist < dist2[v]) {
                dist2[v] = new_dist;
                pq.push({v, dist2[v]});
            }
        }
    }
}


int main() {
    cin >> n >> m;
    for (int i = 0; i < m; ++i) {
        int u, v, w;
        cin >> u >> v >> w;
        graph[u].push_back({v, w});
        graph[v].push_back({u, w}); // 假设是无向图
    }

    find_second_shortest(1); // 从 1 号点开始

    cout << dist2[n] << endl; // 输出到 n 号点的次短路

    return 0;
}

2.3.5 复杂度分析

  • 时间复杂度 :虽然看起来我们向优先队列中推送了更多的状态,但可以分析得出,每条边最多会使distdist2被更新常数次。因此,算法的整体时间复杂度与堆优化的 Dijkstra 类似,约为 \(O(M \log N)\)。
  • 空间复杂度 :\(O(M)\)(邻接表)+ \(O(N)\)(辅助数组和优先队列)。

2.3.6 洛谷例题:P2865 [USACO06NOV]Roadblocks G

题目描述

给出一张 \(N\) 个点,\(M\) 条边的无向图。求从 1 号点到 \(N\) 号点的次短路长度。

输入格式

第一行两个整数 \(N, M\)。

接下来 \(M\) 行,每行三个整数 \(u, v, w\),表示 \(u, v\) 之间有一条长度为 \(w\) 的路。

输出格式

输出 1 到 \(N\) 的次短路长度。

题解

这是一道标准的次短路模板题。题目保证了边权非负,可以直接套用上面讲解的"Dijkstra魔改版"次短路算法。将起点设为 1,终点为 \(N\),运行算法后,输出 dist2[N] 即可。上面的代码模板就是针对这道题的解法。

2.4 【6】Floyd-Warshall算法

2.4.1 算法简介

Floyd-Warshall 算法(通常简称为 Floyd 算法)是一种用于求解所有节点对之间的最短路径(All-Pairs Shortest Path, APSP)的经典动态规划算法。

想象一张地图,上面有若干个城市和连接它们的道路,每条道路都有一个长度。Dijkstra 算法或 SPFA 算法可以帮助我们计算出从某一个特定的城市(源点)出发,到所有其他城市的最短距离。但如果我们需要知道地图上任意两个城市之间的最短距离,怎么办呢?

一个显而易见的想法是,对每个城市都运行一次 Dijkstra 算法。如果图中有 \(N\) 个节点,运行一次 Dijkstra 算法(使用堆优化)的时间复杂度是 \(O(M \log N)\)(其中 \(M\) 是边的数量),那么总的时间复杂度就是 \(O(N \cdot M \log N)\)。这种方法在稀疏图(边的数量远小于节点数量的平方)中表现不错。

然而,在稠密图(边的数量接近 \(N^2\))中,或者当图中存在负权边 但没有负权环 时,Dijkstra 算法可能会失效(SPFA 可以处理负权边,但其最坏复杂度较高)。这时,Floyd 算法就展现了它的优势。它非常简洁,易于实现,并且能够处理带负权边的图。其时间复杂度为 \(O(N^3)\),与边的数量无关,因此在稠密图的场景下尤其高效。

2.4.2 核心思想

Floyd 算法的核心思想是动态规划 。它基于一个非常朴素却强大的思想:"中转"

dist[i][j] 表示从节点 \(i\)到节点 \(j\) 的最短路径长度。我们思考,从节点 \(i\) 到节点 \(j\) 的路径,可以分为两种情况:

  1. 直接从 \(i\) 走到 \(j\),不经过任何其他节点。
  2. 从 \(i\) 先走到某个中转点 \(k\),再从 \(k\) 走到 \(j\)。

Floyd 算法将这个思想进行了扩展。它定义了一个状态 dp[k][i][j],表示"只允许经过编号从 \(1\) 到 \(k\) 的节点作为中转点时,从节点 \(i\) 到节点 \(j\) 的最短路径长度"。

当我们考虑 dp[k][i][j] 时,路径同样可以分为两种:

  1. 不经过 节点 \(k\) 作为中转点。这种情况下,最短路径等同于"只允许经过编号从 \(1\) 到 \(k-1\) 的节点作为中转点"时的最短路径,即 dp[k-1][i][j]
  2. 经过 节点 \(k\) 作为中转点。这种情况下,路径一定是从 \(i\) 先走到 \(k\),再从 \(k\) 走到 \(j\)。由于中途不能再经过比 \(k\) 编号更大的节点,所以从 \(i\) 到 \(k\) 的路径和从 \(k\) 到 \(j\) 的路径都只能使用编号从 \(1\) 到 \(k-1\) 的节点作为中转。因此,这条路径的长度就是 dp[k-1][i][k] + dp[k-1][k][j]

综合以上两种情况,我们就得到了状态转移方程:
\(dp[k][i][j] = \min(dp[k-1][i][j], \ dp[k-1][i][k] + dp[k-1][k][j])\)

观察这个方程,可以发现计算第 \(k\) 层的状态 dp[k] 时,只依赖于第 \(k-1\) 层的状态 dp[k-1]。这意味着我们可以进行空间优化,省略掉第一维的 \(k\)。优化后的状态转移方程变为:
\(dist[i][j] = \min(dist[i][j], \ dist[i][k] + dist[k][j])\)

这个方程的含义是:我们依次尝试让每个节点 \(k\) (从 \(1\) 到 \(N\)) 作为中转点,来更新任意两个节点 \(i\) 和 \(j\) 之间的最短路径。当所有节点都作为中转点被尝试过后,dist[i][j] 中存储的就是从 \(i\)到 \(j\) 的全局最短路径。

2.4.3 算法流程与模拟

初始化:

  1. 创建一个二维数组 dist[N+1][N+1]
  2. 对于所有的 \(i\) 和 \(j\)(\(i \neq j\)),将 dist[i][j] 初始化为一个非常大的值(代表无穷大,表示两点不直接相连)。
  3. 对于所有的 \(i\),将 dist[i][i] 初始化为 \(0\)(自己到自己的距离为0)。
  4. 对于图中存在的每一条边 \((u, v)\),其权重为 \(w\),更新 dist[u][v] = w。如果是无向图,同时更新 dist[v][u] = w

核心循环:

算法的主体是三层嵌套循环,最外层必须是中转点 k

复制代码
for k from 1 to N
  for i from 1 to N
    for j from 1 to N
      dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j])

这个顺序至关重要。最外层循环 k 保证了在尝试用 \(k\) 作为中转点时,dist[i][k]dist[k][j] 的值已经是"只允许经过 \(1\) 到 \(k-1\) 作为中转点"的最优结果。

模拟示例:

假设有一个4个节点的有向图,其邻接矩阵如下(INF代表无穷大):

| from\to | 1 | 2 | 3 | 4 |

| :: | :-: | :-: | :-: | :-: |

| 1 | 0 | 3 | INF | 5 |

| 2 | 2 | 0 | INF | INF |

| 3 | INF | 7 | 0 | 1 |

| 4 | 6 | INF | INF | 0 |

k = 1 (以1为中转点):

  • dist[2][4] = min(dist[2][4], dist[2][1] + dist[1][4]) = min(INF, 2 + 5) = 7
  • ... 其他更新 ...

更新后的矩阵:

| from\to | 1 | 2 | 3 | 4 |

| :: | :-: | :-: | :-: | :-: |

| 1 | 0 | 3 | INF | 5 |

| 2 | 2 | 0 | INF | 7 |

| 3 | INF | 7 | 0 | 1 |

| 4 | 6 | 9 | INF | 0 |

k = 2 (以2为中转点):

  • dist[1][4] = min(dist[1][4], dist[1][2] + dist[2][4]) = min(5, 3 + 7) = 5 (没有更新)
  • dist[4][1] = min(dist[4][1], dist[4][2] + dist[2][1]) = min(6, 9 + 2) = 6 (没有更新)
  • ...

k = 3 (以3为中转点):

  • dist[1][4] = min(dist[1][4], dist[1][3] + dist[3][4])dist[1][3]是INF,不更新)
  • dist[2][4] = min(dist[2][4], dist[2][3] + dist[3][4])dist[2][3]是INF,不更新)
  • dist[1][2] = min(dist[1][2], dist[1][3] + dist[3][2]) (INF)
  • dist[4][2] = min(dist[4][2], dist[4][3] + dist[3][2]) (INF)

k = 4 (以4为中转点):

  • dist[2][1] = min(dist[2][1], dist[2][4] + dist[4][1]) = min(2, 7 + 6) = 2 (没有更新)
  • dist[3][1] = min(dist[3][1], dist[3][4] + dist[4][1]) = min(INF, 1 + 6) = 7
  • dist[3][2] = min(dist[3][2], dist[3][4] + dist[4][2]) = min(7, 1 + 9) = 7 (没有更新)

经过四轮迭代后,最终的 dist 矩阵就包含了所有点对之间的最短路径。

2.4.4 伪代码

复制代码
function FloydWarshall(graph):
  n = a number of vertices in graph
  dist = an n x n matrix initialized with infinity, diagonal with 0
  
  // Initialize dist with direct edge weights
  for each edge (u, v) with weight w in graph:
    dist[u][v] = w

  // Main algorithm
  for k from 1 to n:
    for i from 1 to n:
      for j from 1 to n:
        if dist[i][k] != infinity and dist[k][j] != infinity:
          dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j])
          
  return dist

2.4.5 C++ 代码模板

cpp 复制代码
#include <iostream>
#include <vector>
#include <algorithm>

using namespace std;

const int MAXN = 505; // 节点数量上限
const long long INF = 1e18; // 使用 long long 防止溢出,无穷大也设大一些

int n, m, q; // n: 节点数, m: 边数, q: 查询数
long long dist[MAXN][MAXN];

void floyd_warshall() {
    // 核心三层循环
    for (int k = 1; k <= n; ++k) {
        for (int i = 1; i <= n; ++i) {
            for (int j = 1; j <= n; ++j) {
                // 防止因无穷大相加导致溢出
                if (dist[i][k] != INF && dist[k][j] != INF) {
                    dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j]);
                }
            }
        }
    }
}

int main() {
    // 提高cin/cout效率
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    cin >> n >> m >> q;

    // 1. 初始化距离矩阵
    for (int i = 1; i <= n; ++i) {
        for (int j = 1; j <= n; ++j) {
            if (i == j) {
                dist[i][j] = 0;
            } else {
                dist[i][j] = INF;
            }
        }
    }

    // 2. 读入边信息
    for (int i = 0; i < m; ++i) {
        int u, v;
        long long w;
        cin >> u >> v >> w;
        // 对于有重边的情况,只保留权值最小的边
        dist[u][v] = min(dist[u][v], w);
        // 如果是无向图,加上下面这句
        // dist[v][u] = min(dist[v][u], w);
    }

    // 3. 执行 Floyd-Warshall 算法
    floyd_warshall();

    // 4. 回答查询
    for (int i = 0; i < q; ++i) {
        int u, v;
        cin >> u >> v;
        if (dist[u][v] >= INF / 2) { // 用 INF/2 判断是为了避免一些微小的误差
            cout << "impossible" << endl;
        } else {
            cout << dist[u][v] << endl;
        }
    }

    return 0;
}

2.4.6 时间与空间复杂度分析

  • 时间复杂度: 算法的核心是三层嵌套循环,每一层都从 \(1\) 循环到 \(N\)。因此,时间复杂度非常稳定,就是 \(O(N^3)\)。
  • 空间复杂度: 算法需要一个二维数组来存储所有点对之间的距离,所以空间复杂度是 \(O(N^2)\)。

2.4.7 应用与例题

Floyd 算法除了直接求解所有点对最短路径外,还有一些巧妙的应用,例如:

  • 判断图的连通性: 算法结束后,若 dist[i][j] 仍为无穷大,则 \(i\) 和 \(j\) 不连通。
  • 求最小环: 在一个图中,包含节点 \(k\) 的最小环的长度,就是 dist_before_k[i][j] + w[j][k] + w[k][i],其中 dist_before_k 是外层循环到 \(k-1\) 时的距离矩阵。
  • 传递闭包: 修改状态转移方程为 dist[i][j] = dist[i][j] or (dist[i][k] and dist[k][j]),可以求出图中任意两点是否可达。

例题:洛谷 P1119 灾后重建

题目大意:

有 \(N\) 个村庄,一开始所有村庄之间都没有路。接下来有 \(M\) 条路将要被修建。但是村庄是按时间顺序修复的,第 \(i\) 个村庄在 \(t_i\) 时刻修复完成。只有当一条路连接的两个村庄都修复完成后,这条路才能使用。有 \(Q\) 次询问,每次询问在 \(T\) 时刻,从村庄 \(u\) 到村庄 \(v\) 的最短路径是多少。

题解思路:

这道题有一个关键的限制:时间。一个村庄和连接它的道路只有在特定时间点之后才能使用。这恰好与 Floyd 算法中"中转点"的概念完美契合。

Floyd 算法的 for (int k = 1; k <= n; ++k) 循环,本质上是"解锁"了节点 \(k\) 作为中转点的能力。在本题中,村庄是按修复时间 \(t_i\) 顺序"解锁"的。

我们可以将村庄按照修复时间从小到大排序。然后,我们不再是简单地从 \(1\) 到 \(N\) 循环中转点 k,而是根据询问的时间 \(T\) 来决定哪些村庄已经被修复,可以作为中转点了。

具体做法是:

  1. 将村庄按修复时间 t[i] 从小到大排序。
  2. 初始化 dist 矩阵。
  3. 对于每次询问 \((u, v, T)\),我们只将修复时间 t[k] <= T 的村庄 \(k\) 作为中转点来更新 dist 矩阵。

为了避免每次询问都重复计算,我们可以离线处理所有询问。将询问也按时间 \(T\) 从小到大排序。然后,我们维护一个指针 k,表示当前已经修复好的村庄。随着询问时间的增加,我们不断地将新修复好的村庄 k 加入中转点集合,并用它来更新全局的 dist 矩阵。

这样,对于时间为 \(T_i\) 的询问,我们只需要保证所有修复时间小于等于 \(T_i\) 的村庄都已经被用来更新过 dist 矩阵了。这巧妙地将时间维度融入了 Floyd 算法的执行过程中。

核心逻辑修改如下:

cpp 复制代码
// 假设村庄已经按修复时间排好序
int k_ptr = 0;
for (each query (u, v, t) sorted by t) {
    // 不断解锁新的中转点,直到当前中转点的修复时间超过询问时间
    while (village[k_ptr].repaire_time <= t && k_ptr < n) {
        int k = village[k_ptr].id;
        for (int i = 0; i < n; ++i) {
            for (int j = 0; j < n; ++j) {
                dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j]);
            }
        }
        k_ptr++;
    }
    // 此时的 dist 矩阵就是 t 时刻下的最短路情况
    // 回答询问
}

2.5 【6】有向无环图的拓扑排序

2.5.1 什么是拓扑排序?

拓扑排序 (Topological Sort) 是对有向无环图 (Directed Acyclic Graph, DAG) 的顶点进行排序,使得对于图中任意一条有向边 \((u, v)\),节点 \(u\) 在排序后的序列中都出现在节点 \(v\) 的前面。

通俗地讲,如果我们将图中的节点看作是一系列需要完成的任务,边 \((u, v)\) 表示任务 \(u\) 必须在任务 \(v\) 之前完成(\(u\) 是 \(v\) 的"前置任务"),那么拓扑排序就是给出了一个合法的任务完成顺序。

例如,大学里修课程,想修《算法设计》,必须先修完《数据结构》和《C++程序设计》。这就可以表示为一个有向图,从《数据结构》到《算法设计》有一条边,从《C++程序设计》到《算法设计》也有一条边。拓扑排序给出的就是一个合法的修课顺序。

关键点:

  • 拓扑排序的结果不是唯一的。一个 DAG 可能有多个合法的拓扑序列。
  • 只有有向无环图(DAG)才能进行拓扑排序。如果图中存在环(例如,任务A依赖B,B依赖C,C又依赖A),那么就不可能存在一个合法的任务顺序,也就无法进行拓扑排序。这个特性也可以用来判断一个有向图是否存在环

2.5.2 核心思想与算法流程 (Kahn算法)

最常用和最容易理解的拓扑排序算法是 Kahn 算法,它基于贪心的思想。

核心思想:

在一个有向图中,入度为 \(0\) 的节点没有任何前置依赖,因此它可以作为当前序列的第一个(或下一个)节点。

算法流程如下:

  1. 统计入度: 计算图中所有节点的入度(in-degree),即指向该节点的边的数量。
  2. 初始化队列: 将所有入度为 \(0\) 的节点放入一个队列中。这些是起始任务。
  3. 循环处理: 当队列不为空时,执行以下操作:
    a. 从队列中取出一个节点 \(u\)(队首元素),并将其加入到拓扑排序的结果序列中。
    b. "消除影响" : 遍历所有从 \(u\) 出发的边 \((u, v)\)。对于每一个邻居节点 \(v\),将其入度减 \(1\)。这模拟了"完成了任务 \(u\) 后,以它为前置条件的那些任务的依赖就少了一个"。
    c. 检查新节点: 在将 \(v\) 的入度减 \(1\) 后,如果 \(v\) 的入度变为 \(0\),说明 \(v\) 的所有前置任务都已完成,此时将 \(v\) 加入队列。
  4. 结束与判断: 循环结束后,检查结果序列中的节点个数是否等于图中总节点数。
    • 如果相等,则该序列就是一个合法的拓扑排序。
    • 如果不相等,说明图中存在环,导致有些节点的入度永远无法变为 \(0\),无法加入队列。

2.5.3 伪代码

复制代码
function TopologicalSort(graph):
  n = a number of vertices in graph
  in_degree = array of size n, initialized to 0
  adj = adjacency list representation of graph
  
  // 1. Calculate in-degrees
  for each vertex u in graph:
    for each neighbor v of u:
      in_degree[v]++
      
  // 2. Initialize queue with zero-in-degree vertices
  queue = a new queue
  for i from 1 to n:
    if in_degree[i] == 0:
      queue.enqueue(i)
      
  result = an empty list
  
  // 3. Main loop
  while queue is not empty:
    u = queue.dequeue()
    result.append(u)
    
    // "Remove" u's outgoing edges
    for each neighbor v of u in adj[u]:
      in_degree[v]--
      if in_degree[v] == 0:
        queue.enqueue(v)
        
  // 4. Check for cycle
  if length of result == n:
    return result // Success
  else:
    return "Graph has a cycle" // Failure

2.5.4 C++ 代码模板

cpp 复制代码
#include <iostream>
#include <vector>
#include <queue>
#include <algorithm>

using namespace std;

const int MAXN = 100005;

int n, m;
vector<int> adj[MAXN]; // 邻接表存图
int in_degree[MAXN];   // 存放入度
vector<int> result;    // 存放拓扑排序结果

bool topological_sort() {
    queue<int> q;

    // 1. 将所有入度为0的节点入队
    for (int i = 1; i <= n; ++i) {
        if (in_degree[i] == 0) {
            q.push(i);
        }
    }

    while (!q.empty()) {
        int u = q.front();
        q.pop();
        result.push_back(u);

        // 2. 遍历u的所有出边,更新邻居的入度
        for (int v : adj[u]) {
            in_degree[v]--;
            if (in_degree[v] == 0) {
                q.push(v);
            }
        }
    }

    // 3. 判断是否存在环
    return result.size() == n;
}

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    cin >> n >> m;

    for (int i = 0; i < m; ++i) {
        int u, v;
        cin >> u >> v;
        adj[u].push_back(v); // u -> v
        in_degree[v]++;
    }

    if (topological_sort()) {
        cout << "A valid topological sort is: ";
        for (int i = 0; i < result.size(); ++i) {
            cout << result[i] << (i == result.size() - 1 ? "" : " ");
        }
        cout << endl;
    } else {
        cout << "The graph has a cycle, no topological sort exists." << endl;
    }

    return 0;
}

2.5.5 时间与空间复杂度分析

  • 时间复杂度:
    • 计算所有节点的入度,需要遍历所有边,复杂度为 \(O(M)\)。
    • 将入度为 \(0\) 的节点入队,最多 \(N\) 个节点,复杂度为 \(O(N)\)。
    • 主循环中,每个节点最多入队一次、出队一次,复杂度为 \(O(N)\)。在处理每个出队节点 \(u\) 时,会遍历其所有出边,所有节点的出边加起来就是总边数 \(M\)。因此这部分复杂度为 \(O(M)\)。
    • 综上,总时间复杂度为 \(O(N+M)\)。
  • 空间复杂度:
    • 邻接表需要 \(O(N+M)\) 的空间。
    • in_degree 数组需要 \(O(N)\) 的空间。
    • 队列在最坏情况下可能存储所有节点,需要 \(O(N)\) 的空间。
    • 综上,总空间复杂度为 \(O(N+M)\)。

2.5.6 应用与例题

例题:洛谷 P1347 车站分级

题目大意:

有 \(N\) 个火车站,编号 \(1 \sim N\)。有 \(M\) 条消息,每条消息形如 A < B,表示 A 车站的等级低于 B 车站。你需要根据这些消息判断:

  1. 是否存在一个唯一的、确定的车站等级排列。如果是,输出这个排列并指出是在第几条消息后确定的。
  2. 是否存在矛盾(即出现环)。如果是,指出是在第几条消息后发现的矛盾。
  3. 如果到最后所有消息都处理完,仍然无法确定唯一的排列,则输出无法确定。

题解思路:

这道题是拓扑排序的经典应用。车站等级关系 A < B 可以看作一条有向边 A -> B。一个确定的车站等级排列就是一个唯一的拓扑序列

我们可以在读入每条消息后,都尝试进行一次拓扑排序。

  1. 建图: 读入一条消息 u < v,就在图中加一条边 (u, v),并更新节点 \(v\) 的入度。
  2. 拓扑排序: 执行 Kahn 算法。
  3. 判断结果:
    • 发现矛盾(环): 如果在拓扑排序后,结果序列的长度小于当前已出现过的车站总数,说明形成了环。此时就找到了矛盾,输出信息并结束程序。
    • 确定唯一序列: 如何判断序列唯一?在 Kahn 算法的任何时刻,如果队列中的元素个数大于1 ,说明此时有多个入度为 \(0\) 的节点可供选择,那么最终的拓扑序列就不是唯一的。所以,我们在算法过程中可以加一个判断。如果在整个拓扑排序过程中,队列大小始终不大于 \(1\),并且最终能得到一个完整的拓ゆ序列,那么这个序列就是唯一的。
    • 无法确定: 如果处理完所有 \(M\) 条消息,既没有发现矛盾,也没有找到唯一序列,就输出无法确定。

我们需要维护一个变量来记录当前处理到第几条消息,以及一个变量记录图中出现了多少个不同的车站。在每次拓扑排序时,都要重置 in_degreeresult 等数据结构,或者使用一个副本进行操作。

这个题目综合考察了拓扑排序本身、利用拓扑排序判环,以及对拓扑排序过程的深入理解(唯一序列的判断)。

2.6 【7】割点、割边

2.6.1 基本概念

无向连通图中,割点和割边是两个非常重要的概念,它们反映了图的"脆弱性"。

  • 割点 (Cut Vertex / Articulation Point):

    在一个无向图中,如果移除某个节点 \(u\) 以及所有与它相连的边后,图的连通分量数量增加,那么节点 \(u\) 就被称为一个割点。通俗地说,割点就是图中的一个"关键节点",去掉它会使得图"断开"。

  • 割边 (Cut Edge / Bridge):

    在一个无向图中,如果移除某条边 \(e\) 后,图的连通分量数量增加,那么边 \(e\) 就被称为一条割边。割边是不属于任何环的边。

理解这两个概念对于分析网络稳定性、寻找关键路径等问题至关重要。

2.6.2 核心思想与 Tarjan 算法

寻找割点和割边的标准算法是 Tarjan 算法,它基于深度优先搜索(DFS)和"时间戳"的概念。

为了理解 Tarjan 算法,我们需要先定义两个关键的数组:

  1. dfn[u] (Discovery Time): 在 DFS 过程中,节点 \(u\) 第一次被访问到的次序(时间戳)。
  2. low[u] (Lowest Reachable Ancestor): 从节点 \(u\) 出发,通过其在 DFS 树中的后代节点 ,以及从这些后代节点出发最多走一条非树边(返祖边) ,能够到达的时间戳最小 的节点的 dfn 值。

听起来很绕口,我们来分解 low[u] 的含义:

  • low[u] 的初始值是 dfn[u]
  • low[u] 可以通过两种方式更新:
    1. 通过 DFS 树中的子节点 \(v\) 更新:low[u] = min(low[u], low[v])。这意味着 \(u\) 的子孙 \(v\) 能到达一个更早的祖先,那么 \(u\) 自然也能通过 \(v\) 到达那个祖先。
    2. 通过一条直接连接到已访问过的祖先 \(v\) 的返祖边 \((u, v)\) 更新:low[u] = min(low[u], dfn[v])。这表示 \(u\) 有一条"捷径"可以回到它的祖先 \(v\)。

dfn 数组是在 DFS 向下递归时确定的,而 low 数组是在回溯时,利用子节点的信息来更新的。

2.6.3 割边的判定法则

一条边 \((u, v)\)(假设在 DFS 树中 \(u\) 是 \(v\) 的父节点)是割边的充要条件 是:
\(low[v] > dfn[u]\)

直观理解:

这个不等式的含义是,从节点 \(v\) 出发,它和它的所有后代节点,不管怎么走(包括走返祖边),能到达的最早的祖先节点,也比 \(u\) 的发现时间要晚。这意味着 \(v\) 和它的子树无法通过任何"捷径"回到 \(u\) 或者 \(u\) 的祖先节点。因此,连接 \(u\) 和 \(v\) 的这条边 \((u, v)\) 是它们唯一的联系通道。一旦切断这条边, \(v\) 所在的子树就会与图的其余部分分离。

2.6.4 割点的判定法则

割点的判定比割边稍微复杂一些,需要分两种情况:

  1. 根节点:

    如果节点 \(u\) 是 DFS 树的根节点,那么 \(u\) 是割点的充要条件 是:\(u\) 在 DFS 树中拥有两个或两个以上 的子树。
    直观理解: 如果根节点只有一个孩子,那么即使去掉根节点,剩下的部分仍然是一个连通的子树。但如果有多个孩子,去掉根节点后,这些子树之间就失去了联系,图会分裂。

  2. 非根节点:

    如果节点 \(u\) 不是根节点,那么 \(u\) 是割点的充要条件 是:存在一个它的子节点 \(v\) ,满足:
    \(low[v] \geq dfn[u]\)

    直观理解:

    这个不等式的含义是,子节点 \(v\) 和它的子树,能到达的最早的祖先节点就是 \(u\) 自己 (或者更晚,但不可能比 \(u\) 更早)。这意味着 \(v\) 这棵子树想要连接到图的其他部分,必须通过父节点 \(u\)。如果把 \(u\) 去掉, \(v\) 这棵子树就无法连接到 \(u\) 的祖先,从而与图分离。

    注意这里是 low[v] >= dfn[u]= 的情况意味着 \(v\) 最多只能回到 \(u\),但回不到 \(u\) 的祖先,所以去掉 \(u\) 仍然会使 \(v\) 子树分离。

2.6.5 C++ 代码模板 (Tarjan算法)

cpp 复制代码
#include <iostream>
#include <vector>
#include <algorithm>

using namespace std;

const int MAXN = 200005;
const int MAXM = 1000005;

int n, m;
vector<pair<int, int>> adj[MAXN]; // 存储边和边的原始编号
int dfn[MAXN], low[MAXN], timestamp;
bool is_cut[MAXN]; // 标记是否为割点
vector<int> bridges; // 存储割边的原始编号

void tarjan(int u, int parent_edge_id) {
    dfn[u] = low[u] = ++timestamp;
    int child_count = 0; // 用于根节点判断

    for (auto& edge : adj[u]) {
        int v = edge.first;
        int edge_id = edge.second;

        // 避免通过同一条边返回父节点
        if (edge_id == parent_edge_id) {
            continue;
        }

        if (!dfn[v]) { // v 未被访问,是 u 的子节点
            child_count++;
            tarjan(v, edge_id);
            low[u] = min(low[u], low[v]);

            // 割点判定 (非根节点)
            if (parent_edge_id != -1 && low[v] >= dfn[u]) {
                is_cut[u] = true;
            }

            // 割边判定
            if (low[v] > dfn[u]) {
                bridges.push_back(edge_id);
            }
        } else { // v 已被访问,是 u 的祖先
            low[u] = min(low[u], dfn[v]);
        }
    }

    // 割点判定 (根节点)
    if (parent_edge_id == -1 && child_count >= 2) {
        is_cut[u] = true;
    }
}

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    cin >> n >> m;
    for (int i = 1; i <= m; ++i) {
        int u, v;
        cin >> u >> v;
        adj[u].push_back({v, i});
        adj[v].push_back({u, i});
    }

    // Tarjan算法可能从多个连通分量开始
    for (int i = 1; i <= n; ++i) {
        if (!dfn[i]) {
            // parent_edge_id 设为 -1 表示根节点
            tarjan(i, -1);
        }
    }

    // 输出割点
    vector<int> cut_vertices;
    for (int i = 1; i <= n; ++i) {
        if (is_cut[i]) {
            cut_vertices.push_back(i);
        }
    }
    cout << cut_vertices.size() << endl;
    for (int i = 0; i < cut_vertices.size(); ++i) {
        cout << cut_vertices[i] << (i == cut_vertices.size() - 1 ? "" : " ");
    }
    cout << endl;
    
    // 输出割边(可选)
    // sort(bridges.begin(), bridges.end());
    // cout << "Bridges: " << endl;
    // for(int id : bridges) {
    //     cout << id << " ";
    // }
    // cout << endl;

    return 0;
}

注意:

  • tarjan 函数中,传递 parent_edge_id 而不是 parent_node 是为了正确处理重边的情况。
  • 对于非连通图,需要对每个未访问过的节点都调用一次 tarjan 函数。

2.6.6 时间与空间复杂度分析

  • 时间复杂度: Tarjan 算法的本质是一次深度优先搜索。每个节点和每条边都只会被访问常数次。因此,时间复杂度为 \(O(N+M)\)。
  • 空间复杂度: 算法需要邻接表存储图,以及 dfnlow 等辅助数组,所以空间复杂度为 \(O(N+M)\)。

2.6.7 应用与例题

例题:洛谷 P3388 【模板】割点(割顶)

题目大意:

给一个 \(N\) 个点,\(M\) 条边的无向图,求图中的所有割点。

题解思路:

这道题就是割点定义的裸题。直接套用上面给出的 Tarjan 算法模板即可。

  1. 使用邻接表存储无向图。
  2. 初始化 dfn, low 等数组。
  3. 从节点 1 开始(或者遍历所有节点以处理非连通图),调用 tarjan 函数。
  4. tarjan 函数中,根据根节点和非根节点的判定法则,标记出所有割点。
  5. 最后,遍历 is_cut 数组,输出所有被标记为 true 的节点。

代码与上面的模板基本一致,只需要按照题目的输入输出格式进行调整即可。这个模板是解决割点、割边问题的基础,必须熟练掌握。

2.7 【6】树的重心

2.7.1 什么是树的重心?

在一棵树中,每一个节点都有其独特的位置。我们可以想象一下,如果把一棵树的结构看作一个用小球(节点)和细杆(边)连接成的手机挂饰,那么是否有一个点,我们提着它,整个挂饰能达到最"平衡"的状态?这个点,在信息学中就被形象地称为树的重心

更严谨的定义是:对于树中的一个节点 \(u\) ,如果将其从树中删除,树会分裂成若干个不相连的子树(以及原来的父节点方向也形成一个部分)。树的重心就是那个使得这些分裂出的子树中,节点数最多的子树的节点数最小的节点。

换句话说,重心是让树"最平衡"的点,它避免了某一个分支"过重"的情况。

一棵树可能有一个重心,也可能有两个重心。当有两个重心时,这两个重心一定是相邻的。

2.7.2 如何求解树的重心?

求解树的重心通常需要两次深度优先搜索(DFS)。

  1. 第一次 DFS

    这次 DFS 的目的是计算出以每个节点为根的子树的大小(即包含的节点总数)。我们用 \(size[u]\) 表示以节点 \(u\) 为根的子树的大小。计算公式是:\(size[u] = 1 + \sum_{v \in \text{children}(u)} size[v]\) 。这里的 \(1\) 代表节点 \(u\) 本身。

  2. 第二次 DFS

    这次 DFS 的目的是遍历每个节点,并计算删除它之后,所产生的最大子树的大小。对于一个节点 \(u\) ,删除它之后,产生的子树分为两类:

    • 向下的子树 :即以 \(u\) 的各个孩子节点 \(v\) 为根的子树。它们的大小就是第一次 DFS 中已经计算出的 \(size[v]\) 。
    • 向上的部分 :即除去以 \(u\) 为根的整个子树后,树剩下的部分。这部分的大小为 \(n - size[u]\) ,其中 \(n\) 是整棵树的总节点数。

    对于节点 \(u\) ,我们把它所有向下子树的大小和向上部分的大小进行比较,取其中的最大值,记为 \(max\_part[u]\) 。

    我们的目标是找到一个节点 \(u\) ,使得 \(max\_part[u]\) 最小。这个节点就是树的重心。

    我们可以在第二次 DFS 的过程中,维护一个全局变量来记录当前找到的最小的 \(max\_part\) 值以及对应的重心节点编号。

2.7.3 树的重心的性质

树的重心有一些非常重要的性质,在解决问题时非常有用:

  1. 以重心为根,所有子树的大小都不会超过整棵树大小的一半,即 \(size[v] \le n/2\) 。这是重心的核心性质,也是其"平衡"的体现。
  2. 树中所有节点到某个点的距离之和,当且仅当这个点是重心时最小。
  3. 一棵树最多有两个重心,且这两个重心相邻。

2.7.4 算法伪代码

复制代码
// 全局变量
n: 树的总节点数
adj: 邻接表,存储树的结构
size: 数组,size[u] 存储以 u 为根的子树大小
max_part: 数组,max_part[u] 存储删除 u 后最大子树的大小
ans_node: 最终找到的重心节点
min_max_part: 记录最小的 max_part 值,初始为无穷大

// 第一次 DFS,计算子树大小
function DFS1(u, parent):
    size[u] = 1
    for each neighbor v of u:
        if v is not parent:
            DFS1(v, u)
            size[u] = size[u] + size[v]

// 第二次 DFS,寻找重心
function DFS2(u, parent):
    // 计算删除 u 后的最大子树大小
    current_max = n - size[u] // 向上部分的大小
    for each neighbor v of u:
        if v is not parent:
            current_max = max(current_max, size[v]) // 向下子树的大小
    
    // 更新答案
    if current_max < min_max_part:
        min_max_part = current_max
        ans_node = u
    else if current_max == min_max_part and u < ans_node: // 如果有多个重心,取编号最小的
        ans_node = u

    // 递归处理子节点
    for each neighbor v of u:
        if v is not parent:
            DFS2(v, u)

// 主过程
main():
    读入 n 和树的边
    DFS1(1, 0) // 从任意节点开始,比如 1 号节点,0 表示没有父节点
    DFS2(1, 0)
    输出 ans_node

注:实际上,两次DFS可以合并为一次。在计算完一个节点所有子树的size后,就可以立即计算该节点的max_part并更新答案。

2.7.5 C++ 代码模板

cpp 复制代码
#include <iostream>
#include <vector>
#include <algorithm>

using namespace std;

const int MAXN = 100005;
vector<int> adj[MAXN];
int n;
int sz[MAXN];         // sz[u] 存储以u为根的子树大小
int min_max_part = MAXN; // 记录最小的max_part值
int ans_node;         // 存储重心编号

// 一次DFS完成所有操作
void find_centroid(int u, int parent) {
    sz[u] = 1;
    int current_max_part = 0; // 删除u后,产生的最大子树的大小

    for (int v : adj[u]) {
        if (v == parent) {
            continue;
        }
        find_centroid(v, u);
        sz[u] += sz[v];
        current_max_part = max(current_max_part, sz[v]);
    }
    
    // 向上部分的大小
    current_max_part = max(current_max_part, n - sz[u]);

    // 更新重心
    if (current_max_part < min_max_part) {
        min_max_part = current_max_part;
        ans_node = u;
    } else if (current_max_part == min_max_part) {
        ans_node = min(ans_node, u); // 若有多个重心,取编号最小的
    }
}

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    cin >> n;
    for (int i = 1; i < n; ++i) {
        int u, v;
        cin >> u >> v;
        adj[u].push_back(v);
        adj[v].push_back(u);
    }

    find_centroid(1, 0);

    cout << ans_node << endl;

    return 0;
}

2.7.6 洛谷例题与题解

例题:P1364 医院设置

题意简述:

在一个由 \(n\) 个点构成的树形结构中,每个点都有一定数量的病人。要求在某个点上建立一个医院,使得所有病人到医院的距离之和最小。求这个最小的距离之和。

题解思路:

这道题直接应用了树的重心的性质2:所有节点到某个点的加权 距离之和,当且仅当这个点是加权重心 时最小。

本题中,每个点的"重量"就是病人的数量。

我们可以把求解普通重心的过程稍作修改。在计算 \(size\) 时,不再是统计节点个数,而是统计子树内病人的总数。

设 \(w[i]\) 为点 \(i\) 的病人数, \(sum\_w[u]\) 为以 \(u\) 为根的子树的病人数之和。
\(sum\w[u] = w[u] + \sum{v \in \text{children}(u)} sum\_w[v]\) 。

然后,我们寻找一个点 \(u\) ,使得删除它后,产生的各个连通块的病人数之和的最大值最小。这个点就是加权重心。医院就应该建在这里。

但是,题目要求的是最小距离和,而不是重心的位置。我们可以先用一次 DFS 计算出把医院建在 1 号节点的总距离和。然后,在第二次 DFS 的过程中,当我们从父节点 \(p\) 移动到子节点 \(u\) 时,可以 \(O(1)\) 地推导出把医院从 \(p\) 移动到 \(u\) 后,总距离和的变化量。

设 \(dist[i]\) 是节点 \(i\) 的总距离和,\(W\) 是总病人数。

当医院从 \(p\) 移到 \(u\) 时:

  • 对于 \(u\) 子树内的所有病人,他们到医院的距离都减少了 1。总距离减少量为 \(sum\_w[u]\) 。
  • 对于不在 \(u\) 子树内的所有病人,他们到医院的距离都增加了 1。总距离增加量为 \(W - sum\_w[u]\) 。
    所以,\(dist[u] = dist[p] - sum\_w[u] + (W - sum\_w[u])\) 。
    通过一次 DFS 就可以计算出所有点的 \(dist\) 值,然后取最小值即可。

2.8 【6】树的直径

2.8.1 什么是树的直径?

树的直径,顾名思义,就是一棵树中最长的一条简单路径的长度。这条路径可能不经过根节点。路径的长度通常定义为路径上边的数量(对于无权树)或边的权值之和(对于带权树)。

2.8.2 如何求解树的直径?

求解树的直径主要有两种经典方法:两次 DFS/BFS 和树形动态规划(DP)。

方法一:两次 DFS/BFS

这个方法非常直观且容易实现,适用于边权为正的树。

  1. 从树中任意 一个节点 \(S\) 出发,进行一次 DFS 或 BFS,找到距离它最远的节点,记为 \(U\)。
  2. 再从节点 \(U\) 出发,进行第二次 DFS 或 BFS,找到距离 \(U\) 最远的节点,记为 \(V\)。
  3. 节点 \(U\) 和 \(V\) 之间的路径就是树的一条直径,其长度就是所求的答案。

为什么这个方法是正确的?

可以这样证明:假设树的真正直径的两个端点是 \(A\) 和 \(B\)。

在第一次搜索中,我们从任意点 \(S\) 开始,找到了最远点 \(U\)。

可以证明,\(U\) 必然是 \(A\) 或 \(B\) 中的一个。如果 \(U\) 不是 \(A\) 也不是 \(B\) ,那么 \(S\) 到 \(U\) 的路径与 \(A\) 到 \(B\) 的路径必然有交点或不相交。通过反证法和三角不等式可以推导出矛盾,证明 \(U\) 必须是直径的一个端点。既然 \(U\) 是直径的一个端点,那么从 \(U\) 出发能到达的最远点,必然是直径的另一个端点。

方法二:树形 DP

这种方法更为通用,可以处理边权为负的情况(虽然树中一般不讨论负权)。

我们对每个节点 \(u\) 维护一些信息。具体来说,是记录从节点 \(u\) 向其子树中延伸 的最长路径和次长路径的长度。

设 \(d1[u]\) 为从 \(u\) 向下走的最长路径长度, \(d2[u]\) 为从 \(u\) 向下走的次长路径长度(注意,这两条路径不能经过同一个子节点)。

在对树进行深度优先搜索(后序遍历)时,对于当前节点 \(u\) ,我们先递归处理完它的所有子节点 \(v\)。

对于每个子节点 \(v\) ,我们已经知道了从 \(v\) 向下走的最长路径 \(d1[v]\) 。那么,从 \(u\) 经过 \(v\) 再向下走的最长路径长度就是 \(d1[v] + \text{weight}(u, v)\) 。

我们用这个值来更新 \(u\) 的 \(d1[u]\) 和 \(d2[u]\) 。

  • 如果 \(d1[v] + \text{weight}(u, v) > d1[u]\) ,则原来的 \(d1[u]\) 变成了次长的,所以 \(d2[u] = d1[u]\) ,然后更新 \(d1[u] = d1[v] + \text{weight}(u, v)\) 。
  • 否则,如果 \(d1[v] + \text{weight}(u, v) > d2[u]\) ,则更新 \(d2[u]\) 。

在更新完 \(d1[u]\) 和 \(d2[u]\) 之后,一条经过节点 \(u\) 的最长路径长度就是 \(d1[u] + d2[u]\) 。我们用这个值去更新全局的直径长度答案。

2.8.3 算法伪代码(两次DFS法)

复制代码
// 全局变量
n: 节点数
adj: 邻接表,存储边和权重
dist: 数组,记录从起点到各点的距离
farthest_node: 记录最远节点的编号
max_dist: 记录最远距离

// DFS函数
function DFS(u, parent, current_dist):
    dist[u] = current_dist
    if current_dist > max_dist:
        max_dist = current_dist
        farthest_node = u
    
    for each neighbor v with edge weight w of u:
        if v is not parent:
            DFS(v, u, current_dist + w)

// 主过程
main():
    读入 n 和树的边
    
    // 第一次DFS
    max_dist = -1
    DFS(1, 0, 0) // 从节点1开始
    
    // U 就是 farthest_node
    let U = farthest_node
    
    // 第二次DFS
    max_dist = -1
    DFS(U, 0, 0) // 从 U 开始
    
    // V 就是 farthest_node, 最终答案是 max_dist
    输出 max_dist

2.8.4 C++ 代码模板(两次DFS法)

cpp 复制代码
#include <iostream>
#include <vector>
#include <algorithm>

using namespace std;

const int MAXN = 100005;

struct Edge {
    int to;
    int weight;
};

vector<Edge> adj[MAXN];
int n;
int dist[MAXN];
int farthest_node;
int max_dist;

void dfs(int u, int parent, int current_dist) {
    dist[u] = current_dist;
    if (current_dist > max_dist) {
        max_dist = current_dist;
        farthest_node = u;
    }

    for (const auto& edge : adj[u]) {
        int v = edge.to;
        int w = edge.weight;
        if (v == parent) {
            continue;
        }
        dfs(v, u, current_dist + w);
    }
}

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    cin >> n;
    for (int i = 1; i < n; ++i) {
        int u, v, w;
        cin >> u >> v >> w;
        adj[u].push_back({v, w});
        adj[v].push_back({u, w});
    }

    // 第一次DFS
    max_dist = -1;
    dfs(1, 0, 0);

    int U = farthest_node;

    // 第二次DFS
    max_dist = -1;
    dfs(U, 0, 0);

    cout << max_dist << endl;

    return 0;
}

2.8.5 洛谷例题与题解

例题:P1099 树网的核

题意简述:

在一棵树上找到一条长度不超过 \(s\) 的路径(称为"主路"),使得树上所有节点到这条主路的距离的最大值最小。这个最小的最大值被称为"偏心距"。要求输出这个最小的偏心距。

题解思路:

这个问题与树的直径密切相关。

首先可以确定,我们选择的主路,一定在树的某一条直径上。如果不在直径上,我们可以通过平移或延长主路,使其落到直径上,这样偏心距不会变大,甚至可能变小。

所以,问题转化为:在树的直径上,选择一段长度不超过 \(s\) 的路径,使得偏心距最小。

偏心距是所有点到这条路径距离的最大值。一个点到一条路径的距离,是指这个点到路径上所有点的距离的最小值。

这个问题具有单调性:如果偏心距 \(x\) 可以达到,那么偏心距 \(x+1\) 也一定可以达到。因此我们可以二分答案,即二分最终的偏心距 \(d\)。

对于一个给定的偏心距 \(d\) ,我们需要检查是否存在一条长度不超过 \(s\) 的路径,使得所有点到它的距离都不超过 \(d\) 。

我们先求出树的直径,并把直径上的点记录下来。设直径端点为 \(U, V\) 。

对于直径上的每一个点 \(p\) ,它都需要被主路"覆盖"。被覆盖的意思是, \(p\) 到主路的距离要小于等于 \(d\) 。对于直径上的点,这个距离就是它们在直径上的距离。

对于不在直径上的点 \(q\) ,它需要被主路覆盖,即 \(dist(q, \text{主路}) \le d\) 。这等价于 \(q\) 的祖先中,在直径上的那个点(记为 \(p\) ),它到主路的距离要小于等于 \(d - dist(q, p)\) 。

综合起来,对于直径上的每个点 \(p\) ,我们都要求它到主路的距离不能超过一个值,这个值是 \(d\) 减去所有以 \(p\) 为根的、不包含直径路径的子树中,点到 \(p\) 的最大距离。

这样,我们在直径上就得到了一系列约束,即每个点 \(p_i\) 都必须被主路中 \([p_j, p_k]\) 的某一点 \(p_m\) 覆盖,使得 \(dist(p_i, p_m) \le \text{limit}_i\) 。这变成了一个在序列上的问题,可以用双指针(two-pointers)或类似的线性扫描方法在 \(O(n)\) 时间内解决。

总复杂度是 \(O(n \log W)\) ,其中 \(W\) 是直径的长度。

2.9 【6】DFS序与欧拉序

2.9.1 什么是DFS序和欧拉序?

DFS序和欧拉序是将树形结构"线性化"的强大工具。它们通过深度优先搜索(DFS)将树上的节点按照某种顺序排列到一个序列中,从而把对树的复杂操作(如子树操作)转化为对序列的区间操作,这样就可以利用线段树、树状数组等高效数据结构来解决。

  • DFS序 (DFS Order)

    DFS序记录的是在DFS过程中,首次 访问到每个节点的顺序。它也常被称为"时间戳"。我们会给每个节点 \(u\) 记录两个值:

    • \(dfn[u]\) 或 \(in[u]\):节点 \(u\) 在DFS序中的位置(时间戳)。
    • \(size[u]\):以节点 \(u\) 为根的子树的大小。
  • 欧拉序 (Euler Tour)

    欧拉序则记录了DFS遍历过程中经过的每一个节点。当DFS访问到一个节点 \(u\) 时,就将 \(u\) 加入序列;当遍历完 \(u\) 的一个子树并返回到 \(u\) 时,再次将 \(u\) 加入序列;当完全离开 \(e\) 时,也可能加入。具体记录方式有多种,一种常见且功能强大的方式是:进入节点 \(u\) 时记录一次,从 \(u\) 返回到其父节点时再记录一次。

2.9.2 如何生成它们?

通过一次DFS即可同时生成DFS序和欧拉序。

cpp 复制代码
int dfn[MAXN], sz[MAXN], timestamp = 0;
int euler_tour[MAXN * 2], pos_in[MAXN], pos_out[MAXN], euler_idx = 0;

void dfs(int u, int parent) {
    // 处理DFS序
    timestamp++;
    dfn[u] = timestamp;
    sz[u] = 1;

    // 处理欧拉序 (进入时记录)
    euler_idx++;
    euler_tour[euler_idx] = u;
    pos_in[u] = euler_idx;

    for (int v : adj[u]) {
        if (v == parent) continue;
        dfs(v, u);
        sz[u] += sz[v];

        // 处理欧拉序 (从子节点返回时记录)
        euler_idx++;
        euler_tour[euler_idx] = u;
    }
    
    // 另一种欧拉序记录方式是在离开时记录
    pos_out[u] = euler_idx; 
}

pos_out如果像上面那样写,记录的是最后一次访问u的子节点返回后的位置。有时也会在dfs函数的最后,即完全离开u时再记录一次u

2.9.3 核心性质与应用

DFS序的核心性质:

一个节点 \(u\) 和它的所有后代(即以 \(u\) 为根的子树)在DFS序中会形成一个连续的区间 。这个区间的范围是 \([dfn[u], dfn[u] + sz[u] - 1]\) 。
应用:

这个性质是革命性的。它意味着:

  • 对 \(u\) 的子树进行求和/求最值 :等价于查询DFS序序列中区间 \([dfn[u], dfn[u] + sz[u] - 1]\) 的和/最值。
  • 对 \(u\) 的子树所有节点增加一个值 :等价于对DFS序序列中区间 \([dfn[u], dfn[u] + sz[u] - 1]\) 进行区间修改。

这些区间问题都可以用线段树或树状数组在 \(O(\log n)\) 的时间内高效解决。

欧拉序的应用:

欧拉序最经典的应用是使用RMQ(区间最值查询)算法在 \(O(1)\) 时间内求解最近公共祖先(LCA)

我们记录欧拉序时,不仅仅记录节点编号,还记录该节点的深度。对于两个节点 \(u\) 和 \(v\) ,它们在欧拉序中第一次出现的位置分别为 \(pos\_in[u]\) 和 \(pos\_in[v]\) (假设 \(pos\_in[u] < pos\_in[v]\)) 。那么,在欧拉序的区间 \([pos\_in[u], pos\_in[v]]\) 中,深度最小的那个节点就是 \(u\) 和 \(v\) 的LCA。这个问题就转化成了一个标准的RMQ问题。

2.9.4 算法伪代码(生成DFS序)

复制代码
// 全局变量
timestamp = 0
dfn: 数组, 记录节点的DFS序编号
sz: 数组, 记录子树大小

function DFS_Order(u, parent):
    timestamp = timestamp + 1
    dfn[u] = timestamp
    sz[u] = 1
    for each neighbor v of u:
        if v is not parent:
            DFS_Order(v, u)
            sz[u] = sz[u] + sz[v]

main():
    读入树
    DFS_Order(root, 0)

2.9.5 C++ 代码模板(DFS序结合树状数组)

这个模板解决一个经典问题:对树进行两种操作,1. 将节点 \(u\) 的子树中所有节点的值增加 \(k\)。 2. 查询节点 \(u\) 的值。

cpp 复制代码
#include <iostream>
#include <vector>

using namespace std;

const int MAXN = 100005;
vector<int> adj[MAXN];
int n, m;
int initial_val[MAXN];  // 节点的初始值

// DFS序相关
int dfn[MAXN], sz[MAXN], mapped_val[MAXN], timestamp;
void dfs_order(int u, int p) {
    timestamp++;
    dfn[u] = timestamp;
    mapped_val[timestamp] = initial_val[u]; // 将初始值映射到DFS序上
    sz[u] = 1;
    for (int v : adj[u]) {
        if (v == p) continue;
        dfs_order(v, u);
        sz[u] += sz[v];
    }
}

// 树状数组 (Fenwick Tree)
long long bit[MAXN];
void add(int idx, int val) {
    for (; idx <= n; idx += idx & -idx) {
        bit[idx] += val;
    }
}
long long query(int idx) {
    long long sum = 0;
    for (; idx > 0; idx -= idx & -idx) {
        sum += bit[idx];
    }
    return sum;
}
void range_add(int l, int r, int val) {
    add(l, val);
    add(r + 1, -val);
}

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    cin >> n >> m; // n节点数, m操作数
    for (int i = 1; i <= n; ++i) cin >> initial_val[i];
    for (int i = 1; i < n; ++i) {
        int u, v;
        cin >> u >> v;
        adj[u].push_back(v);
        adj[v].push_back(u);
    }
    
    dfs_order(1, 0); // 假设1为根

    // 初始化树状数组
    for (int i = 1; i <= n; ++i) {
        add(i, mapped_val[i] - mapped_val[i - 1]); // 用差分数组初始化
    }

    while (m--) {
        int type;
        cin >> type;
        if (type == 1) { // 子树修改
            int u, k;
            cin >> u >> k;
            int l = dfn[u];
            int r = dfn[u] + sz[u] - 1;
            range_add(l, r, k);
        } else { // 单点查询
            int u;
            cin >> u;
            cout << query(dfn[u]) << "\n";
        }
    }
    return 0;
}

2.9.6 洛谷例题与题解

例题:P3178 [HAOI2015]树上操作

题意简述:

给定一棵 \(n\) 个节点的树,每个点有点权。有 \(m\) 个操作,操作有三种:

  1. 将节点 \(x\) 的点权增加 \(a\)。
  2. 将以节点 \(x\) 为根的子树中所有节点的点权都增加 \(a\)。
  3. 查询以节点 \(x\) 为根的子树中所有节点的点权和。

题解思路:

这是DFS序的模板应用。

首先,通过一次DFS预处理出每个节点的 \(dfn\) (进入时间戳)、\(sz\) (子树大小)。

将每个节点的初始点权,按照DFS序映射到一个新的线性数组 val_on_dfn 上,即 val_on_dfn[dfn[i]] = val[i]

现在,三种操作被转化为了:

  1. 单点修改 :将节点 \(x\) 的点权增加 \(a\)。这对应于对 val_on_dfn 数组的第 \(dfn[x]\) 个位置增加 \(a\)。
  2. 子树修改 :将以 \(x\) 为根的子树中所有节点点权增加 \(a\)。这对应于对 val_on_dfn 数组的区间 \([dfn[x], dfn[x] + sz[x] - 1]\) 进行区间增加 \(a\) 的操作。
  3. 子树查询 :查询以 \(x\) 为根的子树中所有节点的点权和。这对应于查询 val_on_dfn 数组的区间 \([dfn[x], dfn[x] + sz[x] - 1]\) 的和。

这是一个典型的"支持区间修改、区间查询"的数据结构问题,可以使用线段树来解决。用线段树维护 val_on_dfn 这个数组,每个节点维护区间和,并使用懒惰标记(lazy tag)来处理区间修改。所有操作的时间复杂度均为 \(O(\log n)\)。

2.10 【6】树上差分

2.10.1 什么是树上差分?

在学习树上差分之前,需要先理解一维数组的差分。对于数组 \(A\) ,其差分数组 \(D\) 定义为 \(D[i] = A[i] - A[i-1]\) (规定 \(A[0]=0\))。这样,对原数组 \(A\) 的一个区间 \([l, r]\) 进行加值操作,就等价于对差分数组 \(D\) 进行两次单点修改:\(D[l]\) 增加 \(k\),\(D[r+1]\) 减少 \(k\)。最后通过求前缀和就能还原出原数组。

树上差分就是将这个思想推广到树形结构上。它主要用来高效处理对树上的一条路径或一个子树进行批量修改的操作。树上差分分为点差分边差分 。这里我们主要讨论更常用的点差分,用来处理对路径上所有的修改。

2.10.2 路径上的点差分

假设我们要对树上从节点 \(u\) 到节点 \(v\) 的路径上所有点都增加一个值 \(k\)。

利用差分的思想,我们希望通过在几个关键点上打标记,然后通过一次信息汇总(通常是DFS求子树和)来得到每个点的最终值。

这几个关键点是:\(u\), \(v\), 它们的最近公共祖先 \(LCA(u,v)\) ,以及 \(LCA(u,v)\) 的父节点 \(fa[LCA(u,v)]\) 。

操作如下:

  1. 在 \(u\) 点的差分数组上 \(+k\)。
  2. 在 \(v\) 点的差分数组上 \(+k\)。
  3. 在 \(LCA(u,v)\) 点的差分数组上 \(-k\)。
  4. 在 \(fa[LCA(u,v)]\) 点的差分数组上 \(-k\)。(如果LCA是根节点,则此步省略)

为什么这样做是正确的?

我们定义一个点的最终值,是其子树中所有点的差分值之和。

  1. diff[u] += k
  2. diff[v] += k
  3. diff[LCA(u,v)] -= k
  4. diff[fa[LCA(u,v)]] -= k
    理由:\(u\) 和 \(v\) 的贡献使得路径 (u,v)(lca,root) 都加了 k。在 \(lca\) 减 \(k\) 会使得 (lca,root) 的值减 k,恢复正常。但 lca 本身也减了 k,它本应只加 k,现在变成 0 了。所以 \(lca\) 的值是错误的。
    哦,是我对差分求和的方式理解错了。最终点 \(x\) 的值 \(val[x]\) 是它子树 内所有差分值的和。
    \(val[x] = \sum_{y \in \text{subtree}(x)} diff[y]\) 。
  • diff[u] += k: 这只会影响 \(u\) 和 \(u\) 的所有祖先。
  • diff[v] += k: 这只会影响 \(v\) 和 \(v\) 的所有祖先。
  • diff[lca] -= k: 会影响 \(lca\) 和它的所有祖先。
  • diff[fa[lca]] -= k: 会影响 \(fa[lca]\) 和它的所有祖先。
    效果是:
  • 对于路径 \((u, lca)\) 上的一个点 \(x\) (不含 \(lca\)),它的子树包含 \(u\),但不包含 \(v\) 和 \(lca\)。所以 \(val[x]\) 只受到 diff[u] 的影响,增加了 \(k\)。正确。
  • 对于路径 \((v, lca)\) 上的一个点 \(y\) (不含 \(lca\)),它的子树包含 \(v\),但不包含 \(u\) 和 \(lca\)。所以 \(val[y]\) 只受到 diff[v] 的影响,增加了 \(k\)。正确。
  • 对于 \(lca\) 本身,它的子树包含 \(u, v, lca\)。所以它的值会受到 diff[u], diff[v], diff[lca] 的影响,总共增加了 \(k+k-k = k\)。正确。
  • 对于 \(lca\) 的一个祖先 \(z\) (不含 \(fa[lca]\)) ,它的子树包含 \(u,v,lca,fa[lca]\)。它的值增加了 \(k+k-k-k=0\)。正确。
    所以这四个操作是正确的。

所有修改操作完成后,从叶子节点向上进行一次DFS,计算每个点子树的差分和,即可得到每个点最终被增加的值。
val[u] = diff[u] + sum(val[v]) for all children v of u

2.10.3 算法伪代码

复制代码
// 全局变量
diff: 差分数组,初始为0
val: 每个点最终的值
LCA: 一个可以查询最近公共祖先的函数

// 对 u-v 路径增加 k
function path_add(u, v, k):
    lca_node = LCA(u, v)
    parent_of_lca = parent[lca_node]
    
    diff[u] += k
    diff[v] += k
    diff[lca_node] -= k
    if parent_of_lca is not null:
        diff[parent_of_lca] -= k

// DFS 从下到上汇总差分值
function calculate_final_values(u, parent):
    // 先递归处理子节点
    for each child v of u:
        if v is not parent:
            calculate_final_values(v, u)
            // 将子节点的差分值累加到父节点
            diff[u] += diff[v]
    // 此时 diff[u] 就是 u 点的最终值

main():
    读入树结构
    预处理LCA相关信息 (例如倍增)
    读入 m 次修改操作
    for i = 1 to m:
        读入 u, v, k
        path_add(u, v, k)
    
    // 从根节点开始DFS,计算最终值
    calculate_final_values(root, 0)
    
    输出每个点的最终值

2.10.4 C++ 代码模板

cpp 复制代码
#include <iostream>
#include <vector>
#include <algorithm>

using namespace std;

// ... 此处需要一个完整的LCA模板,篇幅原因省略,详见2.12节 ...
// 假设已有的函数:
// void lca_init(int root, int n);
// int lca_query(int u, int v);
// int fa[MAXN]; // fa[i]是i的父节点

const int MAXN = 100005;
vector<int> adj[MAXN];
long long diff[MAXN];

// 假设LCA和fa数组已经预处理好
void path_add(int u, int v, int k) {
    int lca = lca_query(u, v);
    diff[u] += k;
    diff[v] += k;
    diff[lca] -= k;
    if (fa[lca] != 0) { // 如果LCA不是根
        diff[fa[lca]] -= k;
    }
}

void dfs_calc(int u, int p) {
    for (int v : adj[u]) {
        if (v == p) continue;
        dfs_calc(v, u);
        diff[u] += diff[v];
    }
}

int main() {
    // 读入树,并进行LCA的预处理 (dfs求深度、父节点等)
    // lca_init(1, n);

    // 读入m次操作
    // while(m--) {
    //     int u, v, k;
    //     cin >> u >> v >> k;
    //     path_add(u, v, k);
    // }

    // 计算最终值
    // dfs_calc(1, 0);

    // 输出结果
    // for(int i=1; i<=n; ++i) { cout << diff[i] << " "; }
    return 0;
}

2.10.5 洛谷例题与题解

例题:P3128 [USACO15DEC]Max Flow P

题意简述:

给定一棵树和 \(K\) 条路径,每条路径都有一个流量。可以认为这 \(K\) 条路径的流量会同时在这棵树上传输。问树上所有边中,被经过的次数最多的那条边的经过次数是多少?

题解思路:

这道题是"边差分"的模板题。对一条边进行修改,等价于对这条边较深的那个端点的整个子树进行修改。

对一条路径 \((u,v)\) 增加流量,相当于路径上所有边的流量+1。

这可以转化为点的操作:对于一条边 \((p, u)\) (其中 \(p\) 是 \(u\) 的父节点),它的流量等于以 \(u\) 为根的子树被路径覆盖的次数。

所以,我们对每条路径 \((u,v)\) 进行如下点差分操作:

  • \(diff[u]++\)
  • \(diff[v]++\)
  • \(diff[LCA(u,v)] -= 2\)
    经过 \(K\) 次操作后,进行一次DFS,求出每个点 \(u\) 的子树差分和,这个和就是边 \((fa[u], u)\) 被经过的次数。
    最后遍历所有边,求一个最大值即可。
    这个点差分操作的逻辑是:\(u\) 的标记会给 \((u, \text{root})\) 路径上所有边都+1,\(v\) 的标记也一样。而 \((LCA(u,v), \text{root})\) 路径上的边被加了两次,所以需要在 \(LCA(u,v)\) 处减2来抵消。\(LCA(u,v)\) 本身没有父边在路径上,所以这个操作是正确的。

2.11 【6】倍增

2.11.1 什么是倍增?

倍增(Binary Lifting)是一种重要的算法思想,它的核心是"二进制拆分"。一个整数可以被拆分成若干个2的次幂之和,例如 \(13 = 8 + 4 + 1 = 2^3 + 2^2 + 2^0\) 。

利用这个思想,我们可以将一个"走 \(k\) 步"的问题,分解成"走 \(2^i\) 步"、"走 \(2^j\) 步"... 的问题。

如果我们能通过预处理,快速知道从任何一个点出发"走 \(2^k\) 步"能到哪里,我们就能快速回答任意步数的问题。

这种"一次跳一大步"的思想,能够将很多暴力 \(O(k)\) 的查询优化到 \(O(\log k)\) 。

2.11.2 倍增在树上的应用

倍增在树上最经典的应用就是求解最近公共祖先 (LCA) 和查询k级祖先

我们定义一个二维数组 \(fa[u][k]\),表示节点 \(u\) 的第 \(2^k\) 个祖先是谁。

  • \(fa[u][0]\) 就是 \(u\) 的父节点。
  • \(fa[u][1]\) 是 \(u\) 的第 \(2^1=2\) 个祖先,也就是它父亲的父亲,即 \(fa[fa[u][0]][0]\) 。
  • \(fa[u][2]\) 是 \(u\) 的第 \(2^2=4\) 个祖先,也就是它第2个祖先的第2个祖先,即 \(fa[fa[u][1]][1]\) 。

由此我们得到一个递推式:
\(fa[u][k] = fa[ fa[u][k-1] ][k-1]\)

这个递推式是倍增的核心。它告诉我们,要想到达 \(2^k\) 步远的地方,可以先走 \(2^{k-1}\) 步,再从到达的地方继续走 \(2^{k-1}\) 步。

2.11.3 预处理过程

预处理通常通过一次DFS完成。

  1. DFS遍历树 :在DFS过程中,确定每个节点的深度 dep[u] 和它的直接父节点 fa[u][0]
  2. 动态规划填充fa表 :在DFS之后,我们用递推式 \(fa[u][k] = fa[ fa[u][k-1] ][k-1]\) 来填充整个 \(fa\) 表。
    循环的顺序是,外层循环 \(k\) 从 1 到 \(\log n\),内层循环 \(u\) 从 1 到 \(n\)。这样能保证在计算 \(fa[u][k]\) 时,\(fa[\cdot][k-1]\) 的值都已经被计算出来了。

2.11.4 查询k级祖先

如何找到节点 \(u\) 的第 \(k\) 个祖先?

我们将 \(k\) 进行二进制拆分。例如,要找第13个祖先,就是先从 \(u\) 跳 \(2^3=8\) 步,到达 \(u' = fa[u][3]\),还剩下 \(13-8=5\) 步要跳。再从 \(u'\) 跳 \(2^2=4\) 步,到达 \(u'' = fa[u'][2]\),还剩下 \(5-4=1\) 步。最后从 \(u''\) 跳 \(2^0=1\) 步,到达 \(u''' = fa[u''][0]\)。

这个过程可以从大到小遍历 \(k\) 的二进制位,如果第 \(i\) 位是1,就从当前节点向上跳 \(2^i\) 步。

2.11.5 算法伪代码(预处理)

复制代码
// 全局变量
MAX_LOGN = 20
fa[MAXN][MAX_LOGN]
dep[MAXN] // 深度
adj: 邻接表

// DFS 预处理深度和直接父节点
function dfs_pre(u, p, d):
    dep[u] = d
    fa[u][0] = p
    for each neighbor v of u:
        if v is not p:
            dfs_pre(v, u, d + 1)

// 倍增预处理
function build_lca(root, n):
    dfs_pre(root, 0, 1) // 假设根深度为1, 父节点为0
    for k = 1 to MAX_LOGN-1:
        for u = 1 to n:
            if fa[u][k-1] != 0:
                fa[u][k] = fa[ fa[u][k-1] ][k-1]

2.11.6 C++ 代码模板(k级祖先查询)

cpp 复制代码
#include <iostream>
#include <vector>
#include <cmath>

using namespace std;

const int MAXN = 100005;
const int LOGN = 17; // log2(100005)约等于16.6

vector<int> adj[MAXN];
int fa[MAXN][LOGN + 1];
int dep[MAXN];
int n;

void dfs_pre(int u, int p, int d) {
    dep[u] = d;
    fa[u][0] = p;
    for (int v : adj[u]) {
        if (v == p) continue;
        dfs_pre(v, u, d + 1);
    }
}

void build() {
    dfs_pre(1, 0, 1); // 假设1为根
    for (int k = 1; k <= LOGN; ++k) {
        for (int u = 1; u <= n; ++u) {
            if (fa[u][k - 1] != 0) {
                fa[u][k] = fa[fa[u][k - 1]][k - 1];
            }
        }
    }
}

int query_kth_ancestor(int u, int k) {
    if (dep[u] <= k) return 0; // 不存在k级祖先

    for (int i = LOGN; i >= 0; --i) {
        if ((k >> i) & 1) { // 如果k的第i个二进制位是1
            u = fa[u][i];
        }
    }
    return u;
}

int main() {
    cin >> n;
    // ... 读入树的边 ...
    build();
    // ... 查询 ...
    return 0;
}

2.11.7 洛谷例题与题解

例题:P1967 货车运输

题意简述:

给定一个图,求任意两点之间所有路径中,路径上边权的最小值的最大值。如果两点不连通,输出-1。

题解思路:

这个问题等价于求图的最大生成树 上,两点之间路径的最小边权。因为要让最小边权最大化,我们肯定优先选择权值大的边来构建连通性,这就是最大生成树的定义。

所以第一步是使用Kruskal或Prim算法构建最大生成树。如果图不连通,建成的是一个森林。

第二步,问题转化为在树上查询两点 \(u,v\) 路径上的最小边权。

这可以用倍增来解决。我们修改一下倍增的预处理,除了记录 \(fa[u][k]\) ( \(u\) 的 \(2^k\) 级祖先)之外,再记录一个 \(min\_w[u][k]\),表示从 \(u\) 到 \(fa[u][k]\) 这条路径上的最小边权。

递推关系是:
\(min\_w[u][k] = \min(min\_w[u][k-1], min\_w[fa[u][k-1]][k-1])\)

在查询 \(u, v\) 路径的最小边权时,我们先找到它们的LCA。从 \(u\) 跳到LCA的路径,和从 \(v\) 跳到LCA的路径,这两段的最小边权中的较小值,就是最终答案。

求 \(u\) 到LCA路径的最小边权,也可以用类似跳k级祖先的方法,把路径拆成 \(2^i\) 步长的段,然后对每段的 \(min\_w\) 取最小值。

2.12 【6】最近公共祖先

2.12.1 什么是最近公共祖先?

最近公共祖先(Lowest Common Ancestor, LCA)是树形结构中一个非常基础且重要的概念。对于树中的两个节点 \(u\) 和 \(v\) ,它们的公共祖先是指既是 \(u\) 的祖先,也是 \(v\) 的祖先的节点。在所有这些公共祖先中,深度最深的那个,就被称为 \(u\) 和 \(v\) 的最近公共祖先,记为 \(LCA(u,v)\)。

2.12.2 如何求解LCA(倍增法)?

求解LCA有多种方法,如暴力向上爬、Tarjan离线算法、转换为RMQ问题等。其中,倍增法 是在线算法中综合效率和实现难度最优秀的方法之一,非常常用。

该方法分为预处理和查询两个阶段。
预处理阶段:

就是上一节讲的倍增预处理。通过一次DFS和一次DP,计算出 dep[u] (深度) 和 fa[u][k] ( \(u\) 的 \(2^k\) 级祖先)。时间复杂度为 \(O(n \log n)\)。

查询阶段 \(LCA(u, v)\):

查询过程分为两步:

  1. 将两点提到同一深度
    首先比较 \(u\) 和 \(v\) 的深度,假设 dep[u] < dep[v]。我们需要将较深的节点 \(v\) 向上跳 dep[v] - dep[u] 步,使其与 \(u\) 处于同一深度。这个跳跃过程可以用倍增高效完成,复杂度 \(O(\log n)\)。
  2. 两点同时向上跳,直到相遇
    现在 \(u\) 和 \(v\) 在同一深度。
    • 如果此时 \(u\) 和 \(v\) 已经是同一个节点,那么这个节点就是它们的LCA。
    • 如果不是,说明它们的LCA还在更上方。我们需要让 \(u\) 和 \(v\) 同步 向上跳。关键是,我们要跳到LCA的子节点。
      为了做到这一点,我们从大到小遍历 \(k\) (从 \(\log n\) 到 0)。如果 \(fa[u][k]\) 不等于 \(fa[v][k]\) ,说明它们跳了 \(2^k\) 步之后仍然没有相遇,它们的LCA还在更上方。并且,这次跳跃是安全的(不会跳过LCA)。于是,我们就让它们都跳:u = fa[u][k], v = fa[v][k]
      当这个循环结束后,\(u\) 和 \(v\) 就一定是LCA的两个不同的子节点。那么,它们的父节点 fa[u][0] 就是所求的LCA。

查询阶段的时间复杂度为 \(O(\log n)\)。

2.12.3 算法伪代码(查询部分)

复制代码
function LCA_Query(u, v):
    // 1. 将两点提到同一深度
    if dep[u] < dep[v]:
        swap(u, v)
    
    // 将 u 提升到和 v 同样深度
    diff = dep[u] - dep[v]
    for k = MAX_LOGN-1 down to 0:
        if (diff >> k) & 1: // 如果 diff 的第 k 位是 1
            u = fa[u][k]

    // 2. 如果 v 是 u 的祖先,v 就是LCA
    if u == v:
        return u

    // 3. 同步向上跳
    for k = MAX_LOGN-1 down to 0:
        if fa[u][k] != 0 and fa[u][k] != fa[v][k]:
            u = fa[u][k]
            v = fa[v][k]

    // 此时 fa[u][0] 就是LCA
    return fa[u][0]

2.12.4 C++ 代码模板

cpp 复制代码
#include <iostream>
#include <vector>
#include <cmath>
#include <algorithm>

using namespace std;

const int MAXN = 500005;
const int LOGN = 19; // log2(500005)

vector<int> adj[MAXN];
int fa[MAXN][LOGN + 1];
int dep[MAXN];
int n, m, s; // n节点, m查询, s根

void dfs_pre(int u, int p, int d) {
    dep[u] = d;
    fa[u][0] = p;
    for (int v : adj[u]) {
        if (v == p) continue;
        dfs_pre(v, u, d + 1);
    }
}

void build_lca() {
    dfs_pre(s, 0, 1);
    for (int k = 1; k <= LOGN; ++k) {
        for (int u = 1; u <= n; ++u) {
            if (fa[u][k - 1] != 0) {
                fa[u][k] = fa[fa[u][k - 1]][k - 1];
            }
        }
    }
}

int lca_query(int u, int v) {
    if (dep[u] < dep[v]) {
        swap(u, v);
    }

    int diff = dep[u] - dep[v];
    for (int k = LOGN; k >= 0; --k) {
        if ((diff >> k) & 1) {
            u = fa[u][k];
        }
    }

    if (u == v) {
        return u;
    }

    for (int k = LOGN; k >= 0; --k) {
        if (fa[u][k] != 0 && fa[u][k] != fa[v][k]) {
            u = fa[u][k];
            v = fa[v][k];
        }
    }

    return fa[u][0];
}

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    cin >> n >> m >> s;
    for (int i = 1; i < n; ++i) {
        int u, v;
        cin >> u >> v;
        adj[u].push_back(v);
        adj[v].push_back(u);
    }

    build_lca();

    for (int i = 0; i < m; ++i) {
        int u, v;
        cin >> u >> v;
        cout << lca_query(u, v) << "\n";
    }

    return 0;
}

2.12.5 洛谷例题与题解

例题:P3379 【模板】最近公共祖先(LCA)

题意简述:

给定一棵有根树和多次询问,每次询问两个节点的最近公共祖先。

题解思路:

这道题是LCA的模板题,可以直接使用上面介绍的倍增法来解决。

  1. 输入和建图 :读入节点数 \(n\)、查询数 \(m\) 和根节点 \(s\)。然后读入 \(n-1\) 条边,建立邻接表来表示树的结构。
  2. 预处理 :调用 build_lca() 函数。该函数首先通过一次DFS从根节点 \(s\) 开始遍历整棵树,计算出每个节点的深度 dep 和直接父节点 fa[u][0]。然后,使用动态规划填充 fa 数组,计算出所有节点的 \(2^k\) 级祖先。
  3. 处理查询 :循环 \(m\) 次,每次读入两个节点 \(u, v\),调用 lca_query(u, v) 函数,并输出其返回值。

整个算法的预处理时间复杂度为 \(O(n \log n)\),每次查询的时间复杂度为 \(O(\log n)\),足以通过本题的数据范围。这份代码就是针对该模板题的完整解决方案。

LCA是许多更复杂树上问题的基础构件,例如前面提到的树上差分、求树上两点距离 (\(dist(u,v) = dep[u] + dep[v] - 2 \cdot dep[LCA(u,v)]\)) 等,必须熟练掌握。

第三节 动态规划

3.1 【4】动态规划的基本思路

在正式学习具体的动态规划模型之前,首先需要理解其核心思想。动态规划的本质是将一个复杂的问题分解为若干个更小的、相互关联的子问题,通过求解这些子问题,并记录它们的结果,最终组合成原问题的解。

3.1.1 什么是动态规划?

让我们从一个简单的例子开始:爬楼梯问题

假设有一座 \(n\) 级的楼梯,每次可以选择爬1级或者2级,问从地面(第0级)爬到第 \(n\) 级共有多少种不同的方法?

对于这个问题,可以进行简单的分析。要想到达第 \(n\) 级,只有两种可能:

  1. 从第 \(n-1\) 级爬1级上来。
  2. 从第 \(n-2\) 级爬2级上来。

因此,到达第 \(n\) 级的方法数,就等于"到达第 \(n-1\) 级的方法数"与"到达第 \(n-2\) 级的方法数"之和。

如果用 \(f(i)\) 表示到达第 \(i\) 级的方法数,那么可以得到一个递推关系式:
\(f(n) = f(n-1) + f(n-2)\)

这个关系式是不是很眼熟?它就是著名的斐波那契数列。

这个过程就蕴含了动态规划最核心的思想:将原问题(如何到达第 \(n\) 级)转化为规模更小的子问题(如何到达第 \(n-1\) 级和第 \(n-2\) 级)

动态规划解决的问题通常具备两个重要性质:

  1. 最优子结构 (Optimal Substructure) :问题的最优解包含了其子问题的最优解。换句话说,一个大问题的最优解可以由小问题的最优解推导出来。在爬楼梯问题中,到达第 \(n\) 级的总方法数,就是由到达前面台阶的方法数(子问题的解)组合而成的。
  2. 无后效性 (No Aftereffect) :一旦某个子问题的解确定下来,它将不再受后续决策的影响。当我们计算 \(f(i)\) 时,我们只关心 \(f(i-1)\) 和 \(f(i-2)\) 的值是多少,而不关心它们是如何计算出来的。未来的决策也不会反过来改变已经计算好的子问题的解。

3.1.2 动态规划的核心要素:状态与转移

在动态规划中,有两个核心概念:

  1. 状态 (State) :状态是对问题在某个阶段的描述。通常用一个或多个变量来表示。在动态规划中,我们常常用一个数组来存储不同状态下的解,这个数组被称为"DP数组"。例如,在爬楼梯问题中,dp[i] 就是一个状态,它表示"到达第 \(i\) 级的方法数"。
  2. 状态转移方程 (State Transition Equation) :状态转移方程描述了不同状态之间的递推关系。它是动态规划的灵魂,指明了如何从一个或多个已知的子问题状态,计算出当前状态的解。爬楼梯问题中的状态转移方程就是:dp[i] = dp[i-1] + dp[i-2]

3.1.3 动态规划的解题步骤

通常,解决一个动态规划问题可以遵循以下四个步骤:

  1. 定义状态 :明确 dp 数组的含义。例如,dp[i] 代表什么?dp[i][j] 又代表什么?这是最关键的一步,一个好的状态定义会使后续步骤变得清晰。
  2. 寻找状态转移方程:找出当前状态和它依赖的前序状态之间的关系。这是动态规划最核心的一步。
  3. 确定初始状态(边界条件) :任何递推都需要一个起点。例如,在爬楼梯问题中,dp[0] = 1(在地面上也算一种方法),dp[1] = 1
  4. 确定计算顺序 :根据状态转移方程,通常需要从小规模的子问题开始计算,逐步推导到大规模的子问题。例如,要计算 dp[i],必须先计算出 dp[i-1]dp[i-2],所以计算顺序应该是从小到大遍历 i

通过这四个步骤,就可以构建出解决大多数动态规划问题的框架。

3.2 【4】简单一维动态规划

一维动态规划是指问题的状态可以用一个一维数组来表示。这是最基础、最简单的动态规划模型。

3.2.1 概念与模型

一维DP的状态通常定义为 dp[i],表示问题在规模为 i 时的解。其状态转移方程通常依赖于 dp[i-1], dp[i-2] ... 等前面的状态。

经典例题:最长上升子序列 (LIS)

给定一个长度为 \(N\) 的序列 \(A = (a_1, a_2, ..., a_N)\),找出一个子序列 \(B = (b_1, b_2, ..., b_k)\),使得 \(b_1 < b_2 < ... < b_k\),并且 \(k\) 尽可能大。这个最长的子序列被称为最长上升子序列。

例如,序列 (3, 1, 4, 1, 5, 9, 2, 6) 的最长上升子序列是 (1, 4, 5, 9) 或者 (1, 4, 5, 6),长度为4。

下面我们用动态规划的四步法来解决这个问题。

  1. 定义状态
    dp[i] 表示以第 \(i\) 个数 \(a_i\) 结尾 的最长上升子序列的长度。这个定义非常关键,必须是以 \(a_i\) 结尾。

  2. 寻找状态转移方程

    对于第 \(i\) 个数 \(a_i\),我们需要考虑如何计算 dp[i]。为了构成一个以 \(a_i\) 结尾的上升子序列,我们可以将 \(a_i\) 接在前面某个上升子序列的末尾。

    这个"前面"的子序列必须满足两个条件:

    • 它是一个上升子序列。
    • 它的末尾元素必须小于 \(a_i\)。

    因此,我们可以遍历 \(j\) 从 \(1\) 到 \(i-1\),如果 \(a_j < a_i\),说明 \(a_i\) 可以接在以 \(a_j\) 结尾的上升子序列后面,形成一个新的、更长的上升子序列。这个新序列的长度就是 dp[j] + 1

    由于我们要找最长的,所以需要遍历所有满足条件的 \(j\),取其中的最大值。

    所以,状态转移方程为:
    \(dp[i] = \max \{ dp[j] + 1 \}\) ,其中 \(1 \le j < i\) 且 \(a_j < a_i\)。

  3. 确定初始状态

    对于任何一个数 \(a_i\),它自身就可以构成一个长度为1的上升子序列。所以,dp[i] 的初始值至少为1。
    dp[i] = 1 (对于所有的 \(i=1, 2, ..., N\))。

  4. 确定计算顺序
    dp[i] 的计算依赖于 dp[1]dp[i-1],所以我们应该按照 \(i\) 从 \(1\) 到 \(N\) 的顺序来计算。

    最终的答案不是 dp[N],因为最长上升子序列不一定以 \(a_N\) 结尾。答案应该是所有 dp[i] 中的最大值,即 \(\max \{ dp[1], dp[2], ..., dp[N] \}\)。

3.2.2 算法伪代码与C++模板

伪代码:

复制代码
function LIS(A, N):
  // A是输入序列,N是序列长度
  dp = array of size N+1, initialized to 1
  
  for i from 2 to N:
    for j from 1 to i-1:
      if A[j] < A[i]:
        dp[i] = max(dp[i], dp[j] + 1)
        
  max_len = 0
  for i from 1 to N:
    max_len = max(max_len, dp[i])
    
  return max_len

C++代码模板:

cpp 复制代码
#include <iostream>
#include <algorithm>
#include <vector>

using namespace std;

const int MAXN = 1005;

int a[MAXN];
int dp[MAXN];

int main() {
    int n;
    cin >> n;

    for (int i = 1; i <= n; ++i) {
        cin >> a[i];
    }

    // 初始化
    for (int i = 1; i <= n; ++i) {
        dp[i] = 1;
    }

    // 状态转移
    for (int i = 2; i <= n; ++i) {
        for (int j = 1; j < i; ++j) {
            if (a[j] < a[i]) {
                dp[i] = max(dp[i], dp[j] + 1);
            }
        }
    }

    // 寻找最终答案
    int ans = 0;
    for (int i = 1; i <= n; ++i) {
        ans = max(ans, dp[i]);
    }

    cout << ans << endl;

    return 0;
}

这个算法的时间复杂度是 \(O(n^2)\),对于 \(N\) 较小的情况足够使用。

3.2.3 洛谷例题与题解

例题:P1020 导弹拦截

题目大意

第一问:求一个序列的最长不上升子序列的长度。

第二问:求最少需要多少个不上升子序列来覆盖整个序列。

题解

  • 第一问

    "不上升"即"小于或等于"。这和我们刚才讲的最长上升子序列(LIS)非常相似,只需要把状态转移方程中的 < 条件改为 >= 即可。

    状态定义:dp[i] 表示以第 \(i\) 个导弹结尾的最长不上升子序列的长度。

    状态转移方程:\(dp[i] = \max \{ dp[j] + 1 \}\),其中 \(1 \le j < i\) 且 \(a_j \ge a_i\)。

    初始条件:dp[i] = 1

    最终答案:\(\max \{ dp[1], ..., dp[n] \}\)。

    用 \(O(n^2)\) 的DP方法即可解决。

  • 第二问

    第二问的结论是一个非常著名的定理:Dilworth定理 。对于一个偏序集,其最长反链的长度等于最小链划分的大小。在序列问题中,这个定理可以简化为:
    一个序列的最长上升子序列的长度,等于将其划分为若干个不上升子序列时所需的最少子序列个数。

    所以,第二问的答案就是原序列的最长上升子序列 的长度。

    因此,这个问题只需要分别求一次最长不上升子序列和最长上升子序列的长度即可。

    注:对于这道题,数据范围较大,\(O(n^2)\) 的算法只能通过部分测试点。更优的解法是使用贪心+二分查找,可以将时间复杂度优化到 \(O(n \log n)\),这在后续进阶课程中会学习到。但作为一维DP的练习,理解 \(O(n^2)\) 的解法是基础。

3.3 【5】简单背包类型动态规划

背包问题是动态规划中一个非常经典的分类,它有多种变体,但核心思想是相似的。这里我们从最基础的0/1背包开始。

3.3.1 0/1 背包问题

问题描述

有 \(N\) 件物品和一个容量为 \(V\) 的背包。第 \(i\) 件物品的体积是 \(w_i\),价值是 \(v_i\)。求解将哪些物品装入背包,可使这些物品的总体积不超过背包容量,且总价值最大。

特点:每件物品只有一件,要么装入背包(1),要么不装入(0),因此称为0/1背包。

DP四步法分析

  1. 定义状态

    解决背包问题,我们需要同时考虑两个维度:物品和容量。因此,状态也需要用二维来表示。
    dp[i][j]:表示从前 \(i\) 件物品中任意选择,放入一个容量为 \(j\) 的背包中所能获得的最大价值。

  2. 寻找状态转移方程

    当我们决策第 \(i\) 件物品时,有两种选择:

    • 不放入第 \(i\) 件物品 :如果我们不把第 \(i\) 件物品放入背包,那么问题就转化为:用前 \(i-1\) 件物品填满容量为 \(j\) 的背包,能获得的最大价值。这个值就是 dp[i-1][j]
    • 放入第 \(i\) 件物品 :如果决定放入第 \(i\) 件物品,前提是背包的容量必须足够大,即 \(j \ge w_i\)。放入后,背包的剩余容量为 \(j - w_i\)。此时,我们能获得第 \(i\) 件物品的价值 \(v_i\),再加上用前 \(i-1\) 件物品填满剩余容量 \(j - w_i\) 的背包所能获得的最大价值,即 dp[i-1][j - w_i]。总价值为 \(v_i + dp[i-1][j-w_i]\)。

    我们需要在这两种选择中取一个最优的(价值最大的)。因此,状态转移方程为:
    \(dp[i][j] = \max(dp[i-1][j], \quad dp[i-1][j - w_i] + v_i)\) (当 \(j \ge w_i\) 时)

    如果 \(j < w_i\),第 \(i\) 件物品肯定放不下,只能选择不放:
    \(dp[i][j] = dp[i-1][j]\) (当 \(j < w_i\) 时)

  3. 确定初始状态
    dp[0][j] = 0 (当没有物品可选时,任何容量的背包价值都为0)。

    或者,可以认为 dp 数组在声明时默认为0,循环从 \(i=1\) 开始即可。

  4. 确定计算顺序
    dp[i][j] 依赖于 dp[i-1] 行的状态,所以外层循环应该是物品 \(i\) 从 \(1\) 到 \(N\),内层循环是容量 \(j\) 从 \(1\) 到 \(V\)。

3.3.2 空间优化:滚动数组

观察状态转移方程,可以发现计算第 \(i\) 行的状态 dp[i] 时,只用到了第 \(i-1\) 行的数据。这意味着我们可以用一个一维数组来代替二维数组,以节省空间。这个技巧叫做"滚动数组"。

我们定义一维状态 dp[j]:表示容量为 \(j\) 的背包所能获得的最大价值。

在遍历第 \(i\) 件物品时,dp[j] 更新的方程变为:
dp[j] = max(dp[j], dp[j - w[i]] + v[i])

这里有一个非常关键的细节:内层循环 j 必须从 V 向下遍历到 w[i]

为什么?

思考一下,当我们计算 dp[j] 时,方程右边的 dp[j] 是代表不放第 \(i\) 件物品的情况,它应该等于 dp[i-1][j]。而方程右边的 dp[j - w[i]],应该等于 dp[i-1][j - w[i]]

如果 j 从小到大遍历,当我们计算 dp[j] 时,dp[j - w[i]] 可能已经被本次循环(关于物品 \(i\) 的循环)更新过了,它代表的是 dp[i][j - w[i]] 的值,而不是我们需要的 dp[i-1][j - w[i]]。这就导致一件物品可能被重复放入,变成了完全背包问题。

而当 j 从大到小遍历时,我们计算 dp[j] 需要用到的 dp[j - w[i]] 还没有在本次循环中被更新,它保留的还是上一轮(关于物品 \(i-1\))的值,这正是我们需要的。

3.3.3 算法伪代码与C++模板

伪代码 (空间优化版):

复制代码
function ZeroOneKnapsack(W, V, N, Cap):
  // W: 重量数组, V: 价值数组, N: 物品数量, Cap: 背包容量
  dp = array of size Cap+1, initialized to 0
  
  for i from 1 to N:
    for j from Cap down to W[i]:
      dp[j] = max(dp[j], dp[j - W[i]] + V[i])
      
  return dp[Cap]

C++代码模板:

cpp 复制代码
#include <iostream>
#include <algorithm>
#include <vector>

using namespace std;

const int MAXN = 105;   // 物品数量
const int MAXV = 1005;  // 背包容量

int w[MAXN]; // 体积
int v[MAXN]; // 价值
int dp[MAXV];

int main() {
    int n, V; // n: 物品数量, V: 背包容量
    cin >> n >> V;

    for (int i = 1; i <= n; ++i) {
        cin >> w[i] >> v[i];
    }
    
    // dp数组默认初始化为0

    for (int i = 1; i <= n; ++i) {
        for (int j = V; j >= w[i]; --j) {
            dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
        }
    }

    cout << dp[V] << endl;

    return 0;
}

3.3.4 洛谷例题与题解

例题:P1048 采药

题目大意

一个采药人有 \(T\) 的时间,山里有 \(M\) 株草药。每株草药采摘需要花费 time[i] 的时间,价值为 value[i]。问在规定时间内,能采到草药的最大总价值。

题解

这是一个非常标准的0/1背包问题。

  • 背包容量 \(V\) 就相当于总时间 \(T\)。
  • 物品数量 \(N\) 就相当于草药数量 \(M\)。
  • 每件物品的体积 \(w_i\) 就相当于采摘第 \(i\) 株药花费的时间 time[i]
  • 每件物品的价值 \(v_i\) 就相当于第 \(i\) 株药的价值 value[i]

问题转化为:有 \(M\) 件物品和一个容量为 \(T\) 的背包,每件物品的体积是 time[i],价值是 value[i],求最大总价值。

直接套用0/1背包的空间优化模板即可解决。

3.4 【5】简单区间类型动态规划

区间DP是一类在区间上进行动态规划的算法。它的主要思想是,通过合并小区间的信息来求得大区间的信息。

3.4.1 什么是区间DP?

区间DP的模型特征非常明显:

  1. 问题形式:通常是求解一个序列上,一段连续区间的最优值。例如,将一排石子合并成一堆的最小代价。
  2. 状态定义 :状态通常用 dp[i][j] 表示,代表区间 [i, j] 上的最优解。
  3. 状态转移 :大区间的解由其内部的子区间合并而来。状态转移方程通常形式为:
    dp[i][j] = min/max (dp[i][k] + dp[k+1][j] + cost)
    这里的 k 是区间 [i, j] 的一个"分割点",cost 是合并两个子区间的代价。我们需要遍历所有可能的分割点 k 来找到最优解。
  4. 计算顺序 :因为大区间的解依赖于小区间的解,所以我们必须先计算出所有长度为2的区间的解,然后是长度为3的,以此类推,直到计算出整个区间 [1, n] 的解。
    通常的实现方式是:第一层循环枚举区间长度 len ,第二层循环枚举区间的起始点 i ,然后根据 leni 计算出区间的结束点 j

3.4.2 经典例题:石子合并

问题描述

有 \(N\) 堆石子排成一排,每堆石子的质量为 \(w_i\)。现在要将这些石子合并成一堆,每次只能合并相邻的两堆石子,合并的代价为这两堆石子的质量之和。求将所有石子合并成一堆的最小总代价。

DP四步法分析

  1. 定义状态
    dp[i][j] 表示将第 \(i\) 堆到第 \(j\) 堆石子合并成一堆的最小代价。我们的最终目标是求 dp[1][N]

  2. 寻找状态转移方程

    考虑区间 [i, j],它的最后一次合并,一定是将某个子区间 [i, k] 和它右边相邻的子区间 [k+1, j] 合并而成的(其中 \(i \le k < j\))。

    [i, k] 合并的最小代价是 dp[i][k],将 [k+1, j] 合并的最小代价是 dp[k+1][j]

    当这两部分合并时,产生的代价是区间 [i, j] 内所有石子的总质量。

    我们可以预处理一个前缀和数组 sumsum[i] 表示前 \(i\) 堆石子的总质量。那么区间 [i, j] 的石子总质量就是 sum[j] - sum[i-1]

    因此,通过分割点 k 进行合并的总代价为:
    dp[i][k] + dp[k+1][j] + (sum[j] - sum[i-1])

    我们需要遍历所有可能的分割点 \(k\) (从 \(i\) 到 \(j-1\)),找到使这个总代价最小的那个 \(k\)。

    状态转移方程为:
    \(dp[i][j] = \min_{i \le k < j} \{ dp[i][k] + dp[k+1][j] + \sum_{t=i}^{j} w_t \}\)

  3. 确定初始状态

    当区间长度为1时,即 i == j,只有一堆石子,不需要合并,所以代价为0。
    dp[i][i] = 0 (对于所有的 \(i=1, 2, ..., N\))。

  4. 确定计算顺序

    • 外层循环枚举区间长度 len,从 2 到 \(N\)。
    • 中层循环枚举区间的起始点 i,从 1 到 \(N - len + 1\)。
    • 根据 leni 计算出结束点 j = i + len - 1
    • 内层循环枚举分割点 k,从 ij - 1,进行状态转移。

3.4.3 算法伪代码与C++模板

伪代码:

复制代码
function StoneMerge(W, N):
  // W: 石子质量数组, N: 石子堆数
  sum = prefix sum array of W
  dp = 2D array of size (N+1)x(N+1), initialized to infinity
  
  for i from 1 to N:
    dp[i][i] = 0
    
  for len from 2 to N:
    for i from 1 to N - len + 1:
      j = i + len - 1
      for k from i to j - 1:
        cost = sum[j] - sum[i-1]
        dp[i][j] = min(dp[i][j], dp[i][k] + dp[k+1][j] + cost)
        
  return dp[1][N]

C++代码模板:

cpp 复制代码
#include <iostream>
#include <algorithm>
#include <cstring> // For memset

using namespace std;

const int MAXN = 305;
const int INF = 0x3f3f3f3f;

int w[MAXN];
int sum[MAXN];
int dp[MAXN][MAXN];

int main() {
    int n;
    cin >> n;

    for (int i = 1; i <= n; ++i) {
        cin >> w[i];
        sum[i] = sum[i - 1] + w[i];
    }

    // 初始化dp数组
    memset(dp, 0x3f, sizeof(dp));
    for (int i = 1; i <= n; ++i) {
        dp[i][i] = 0;
    }

    // 枚举区间长度
    for (int len = 2; len <= n; ++len) {
        // 枚举起始点
        for (int i = 1; i <= n - len + 1; ++i) {
            int j = i + len - 1; // 计算结束点
            // 枚举分割点
            for (int k = i; k < j; ++k) {
                int cost = sum[j] - sum[i - 1];
                dp[i][j] = min(dp[i][j], dp[i][k] + dp[k + 1][j] + cost);
            }
        }
    }

    cout << dp[1][n] << endl;

    return 0;
}

3.4.4 洛谷例题与题解

例题:P1880 [NOI1995] 石子合并

题目大意

在一个圆形 的操场上摆放着 \(N\) 堆石子。要将石子合并成一堆,每次只能选择相邻的两堆合并,成本为新堆的石子数。求合并成一堆的最小成本和最大成本。

题解

这个问题是经典石子合并问题的变体,区别在于石子是环形排列 的。

处理环形问题有一个经典技巧:破环成链

我们可以将原来长度为 \(N\) 的序列复制一遍,接在原序列的后面,形成一个长度为 \(2N\) 的新序列。例如,原序列是 (1, 2, 3, 4),新序列就是 (1, 2, 3, 4, 1, 2, 3, 4)

这样,原来环上的任意一个长度为 \(N\) 的连续段,都可以在这个新链上找到对应的长度为 \(N\) 的连续子段。例如,原环上的 (4, 1, 2, 3) 就对应新链上的 [4, 7] 这个区间。

于是,问题就转化为了:在这个长度为 \(2N-1\) 的链上,求所有长度为 \(N\) 的区间的合并最优值。

即,我们要求出 dp[1][N], dp[2][N+1], ..., dp[N][2N-1],然后取其中的最小值和最大值。

具体实现时:

  1. 将输入的 \(N\) 个数存入数组 w[1...N],然后复制一份到 w[N+1...2N],即 w[i+N] = w[i]
  2. 计算这个新数组的前缀和 sum
  3. 用区间DP的方法,计算出所有 dp[i][j] 的值,其中 \(1 \le i \le j < 2N\)。
  4. 最后,遍历所有可能的长度为 \(N\) 的区间,找出最优解。
    min_ans = min(dp[1][N], dp[2][N+1], ..., dp[N][2*N-1])
    max_ans = max(dp[1][N], dp[2][N+1], ..., dp[N][2*N-1])
    (求最大值时,只需将 min 操作改为 max,初始值设为0即可)。

这样,就通过"破环成链"的技巧,将一个环形DP问题转化为了我们熟悉的链上区间DP问题。

3.5 【6】多维动态规划

在前面的学习中,我们接触到的动态规划问题,其状态通常可以用一个一维数组来表示,例如 \(f[i]\) 代表考虑到第 \(i\) 个物品时的最优解。这种动态规划被称为线性动态规划或一维动态规划。然而,许多问题的状态并不能仅仅用一个维度来完整描述。当一个状态需要由两个或更多的变量共同确定时,就需要使用多维动态规划。

3.5.1 什么是多维动态规划?

多维动态规划,顾名思义,就是其状态表示是多维的。最常见的是二维动态规划,其状态通常用一个二维数组 \(f[i][j]\) 来表示。这里的两个维度 \(i\) 和 \(j\) 通常代表了问题中的两个关键限制因素或变量。

可以做一个简单的类比:

  • 一维DP :就像在一条数轴上前进,当前的位置只和之前的位置有关。例如,\(f[i]\) 的值由 \(f[i-1]\) 推导而来。
  • 二维DP :就像在一个棋盘或地图上移动,当前位置 \((i, j)\) 的状态,可能与它的上方 \((i-1, j)\) 和左方 \((i, j-1)\) 的状态都有关系。

多维动态规划的核心思想没有改变,依然是寻找最优子结构设计状态转移方程。只是状态的定义变得更复杂,需要考虑的维度增加了。

3.5.2 多维动态规划的核心要素

解决一个多维DP问题,通常需要明确以下几个步骤:

  1. 状态定义 :这是最关键的一步。需要明确 \(f[i][j]\) (或者更多维度 \(f[i][j][k]...\)) 究竟代表什么。一个好的状态定义应该满足无后效性,即当前状态的决策不会影响到之前的状态,同时它应该能包含推导出后续状态所需的所有信息。
  2. 状态转移方程 :这是算法的灵魂。它描述了状态之间是如何演进的。你需要思考 \(f[i][j]\) 是如何由一个或多个"更小"的子问题状态(例如 \(f[i-1][j]\), \(f[i][j-1]\), \(f[i-1][j-1]\) 等)计算出来的。
  3. 初始化:确定DP的边界条件或起始状态。就像多米诺骨牌,你需要推倒第一张牌。对于二维DP,通常需要初始化第0行和第0列,或者某个特定的起始点。
  4. 计算顺序 :确保在计算一个状态 \(f[i][j]\) 时,所有它所依赖的状态都已经被计算出来。对于二维DP,通常是从小到大枚举 \(i\),再从小到大枚举 \(j\),即逐行逐列地填满整个DP表格。
  5. 最终答案 :在所有状态都计算完毕后,确定问题的最终解在DP表格的哪个位置。有时是 \(f[n][m]\),有时是某一行或某一列的最大值。

3.5.3 经典例题:数字三角形 (P1216 [USACO1.5] Number Triangles)

这是一个非常经典的多维DP入门题,能很好地帮助理解其思想。

题目描述

观察下面的数字金字塔。写一个程序来查找从最高点到底部任意处结束的路径,使路径上所有数字的和最大。每一步可以从当前点走到左下方的点或右下方的点。

复制代码
        7
      3   8
    8   1   0
  2   7   4   4
4   5   2   6   5

思路分析

  1. 状态定义 :路径是从上到下走的,我们可以定义一个状态来表示"走到某一个点的最大路径和"。因此,定义 \(f[i][j]\) 为从金字塔顶端 (第1行第1列) 走到第 \(i\) 行第 \(j\) 列这个位置时,所能获得的最大数字和。

  2. 状态转移方程 :考虑如何到达第 \(i\) 行第 \(j\) 列的数字。根据题目规则,只能从它的左上方或右上方的点走过来。

    • 左上方的点是第 \(i-1\) 行的第 \(j-1\) 列。
    • 右上方的点是第 \(i-1\) 行的第 \(j\) 列。
      为了使得到达 \((i, j)\) 的路径和最大,我们应该从这两个可能的来源点中选择一个路径和更大的。所以,状态转移方程为:
      \(f[i][j] = \max(f[i-1][j-1], f[i-1][j]) + a[i][j]\)
      其中 \(a[i][j]\) 是数字三角形中第 \(i\) 行第 \(j\) 列的数字本身。
  3. 初始化 :整个过程的起点是金字塔的顶端,即第1行第1列。所以,初始状态为 \(f[1][1] = a[1][1]\)。为了方便处理边界,可以让 \(f[i-1][j-1]\) 或 \(f[i-1][j]\) 在 \(j=1\) 或 \(j=i\) 时取到一个不会影响结果的值(例如0)。

  4. 计算顺序 :我们发现,计算第 \(i\) 行的状态只需要第 \(i-1\) 行的信息。所以,我们可以从上到下,逐行计算。对于每一行,从左到右计算即可。

  5. 最终答案 :题目要求的是从最高点到底部任意处结束的最大路径和。这意味着终点可以是最后一行的任何一个位置。所以,最终答案就是最后一行所有 \(f[n][j]\) (其中 \(1 \le j \le n\)) 中的最大值。

伪代码

复制代码
输入数字三角形 a[n][n]

定义二维数组 f[n+1][n+1] 并初始化为0

f[1][1] = a[1][1]

对于 i 从 2 到 n:
  对于 j 从 1 到 i:
    f[i][j] = max(f[i-1][j-1], f[i-1][j]) + a[i][j]

max_sum = 0
对于 j 从 1 到 n:
  max_sum = max(max_sum, f[n][j])

输出 max_sum

C++ 代码模板

cpp 复制代码
#include <iostream>
#include <algorithm>

using namespace std;

const int N = 1005;

int a[N][N];
int f[N][N];

int main() {
    int n;
    cin >> n;

    for (int i = 1; i <= n; ++i) {
        for (int j = 1; j <= i; ++j) {
            cin >> a[i][j];
        }
    }

    // 初始化
    f[1][1] = a[1][1];

    // 状态转移
    for (int i = 2; i <= n; ++i) {
        for (int j = 1; j <= i; ++j) {
            // f[i-1][0] 和 f[i-1][i] 默认是0,不会影响max结果
            // 所以不需要特殊处理边界
            f[i][j] = max(f[i-1][j - 1], f[i-1][j]) + a[i][j];
        }
    }

    // 寻找最终答案
    int ans = 0;
    for (int j = 1; j <= n; ++j) {
        ans = max(ans, f[n][j]);
    }

    cout << ans << endl;

    return 0;
}

3.6 【6】树型动态规划

树型动态规划,简称树形DP,是一种在树形结构上进行的动态规划。与线性DP不同,树形DP的状态转移不再是简单的从前一个到后一个,而是依赖于树的父子关系。

3.6.1 什么是树型动态规划?

当一个问题可以被抽象成一个树形结构,并且问题的解可以通过解决其子树的解来合并得到时,我们通常可以考虑使用树形DP。

树形DP的典型特征是:

  • 问题的输入是一个树结构。
  • 状态定义通常与树的节点有关,例如 \(f[u][...]\) 表示在以节点 \(u\) 为根的子树中,满足某种条件的最优解。
  • 状态的转移依赖于其子节点的DP结果。

为了实现这种依赖关系,树形DP通常和树的遍历算法(深度优先搜索DFS)结合在一起。计算一个节点的状态,需要先递归地计算完它所有子节点的状态,这个过程天然地符合DFS的回溯过程。

3.6.2 树型动态规划的核心要素

  1. 找到树的根:对于有根树,根是明确的。对于无根树,可以任选一个节点作为根,或者根据题意找到一个合适的根。
  2. 状态定义 :定义 \(f[u][\text{state}]\),其中 \(u\) 是当前节点编号,\(\text{state}\) 是一个附加状态。这个附加状态非常重要,通常表示节点 \(u\) 本身的某种选择或情况。例如,\(\text{state}\) 可以是0或1,代表节点 \(u\) 是否被选中。
  3. 状态转移方程 :在DFS回溯的过程中,当一个节点 \(u\) 的所有子节点 \(v_1, v_2, \dots\) 都已经访问完毕并计算出了它们的DP值后,就可以利用这些子节点的DP值来计算节点 \(u\) 的DP值。
    \(f[u][\text{state}u] = \text{combine}(f[v_1][\text{state}{v1}], f[v_2][\text{state}_{v2}], \dots)\)
    这个 combine 操作是根据具体题目逻辑来设计的。
  4. 遍历顺序:通常使用一次DFS来完成整个DP过程。在DFS函数中,先递归进入所有子节点,在从子节点回溯之后,进行状态转移计算。这保证了在计算父节点时,其所有子节点的状态都已是最终结果。

3.6.3 经典例题:没有上司的舞会 (P1352)

题目描述

某大学有 \(N\) 个职员,编号为 \(1 \sim N\)。他们之间有唯一的直接上级关系,形成一个树状结构(校长是根)。每个职员有一个快乐指数。现在要举办一场舞会,但是规定,如果某个职员的直接上司参加舞会,那么这个职员就不能参加。求参加舞会的所有职员的最大快乐指数之和。

思路分析

  1. 模型抽象:这是一个典型的树形结构。每个职员是一个节点,上级关系是树的边。问题是在这个树上选出一些点(参加舞会),要求任意被选中的点都没有父子关系,并使得这些点的权值(快乐指数)之和最大。这被称为"树的最大权独立集"问题。

  2. 状态定义 :对于每个职员(节点 \(u\)),他有两种状态:参加舞会或不参加舞会。这两种选择会影响其子节点的决策。因此,我们需要为每个节点定义两种状态:

    • \(f[u][1]\): 表示在以 \(u\) 为根的子树中,邀请 \(u\) 参加舞会时,能获得的最大快乐指数和。
    • \(f[u][0]\): 表示在以 \(u\) 为根的的子树中,不邀请 \(u\) 参加舞会时,能获得的最大快乐指数和。
  3. 状态转移方程 :我们通过一次DFS,从叶子节点往根节点计算。对于当前节点 \(u\) 和它的每一个直接下属(子节点)\(v\):

    • 计算 \(f[u][1]\) :如果邀请了 \(u\),那么根据规则,它的所有直接下属 \(v\) 都不能参加。所以, \(u\) 参加时能获得的最大快乐指数,等于 \(u\) 自己的快乐指数,加上它所有子树在"子节点不参加"情况下的最大快乐指数。
      \(f[u][1] = \text{happy}[u] + \sum_{v \in \text{children}(u)} f[v][0]\)
    • 计算 \(f[u][0]\) :如果不邀请 \(u\),那么对于它的每个直接下属 \(v\),我们可以自由选择邀请或不邀请 \(v\)。为了整体快乐指数最大,我们应该为每个子树选择最优的方案,即 \(\max(f[v][0], f[v][1])\)。
      \(f[u][0] = \sum_{v \in \text{children}(u)} \max(f[v][0], f[v][1])\)
  4. 初始化/边界 :对于叶子节点(没有下属的职员) \(u\):

    • \(f[u][1] = \text{happy}[u]\)
    • \(f[u][0] = 0\)
      这个初始化过程可以在DFS中自然完成。
  5. 最终答案 :整个公司的舞会快乐指数最大值,取决于校长(树的根节点)是否参加。所以最终答案是 \(\max(f[\text{root}][0], f[\text{root}][1])\)。我们需要先找到谁是根节点(没有上司的那个职员)。

伪代码

复制代码
// 邻接表存储树
// has_parent[i] 记录i是否有父节点,用于找根

// DFS函数,计算节点u的DP值
function dfs(u):
  // 初始化当前节点u的值
  f[u][1] = happy[u]
  f[u][0] = 0
  
  // 遍历u的每一个子节点v
  对于 u 的每个子节点 v:
    dfs(v) // 递归计算子节点的DP值
    
    // 状态转移
    f[u][0] = f[u][0] + max(f[v][0], f[v][1])
    f[u][1] = f[u][1] + f[v][0]
    
// 主函数
读入数据,建树,记录父子关系
找到根节点 root (has_parent[root] == false)

dfs(root)

输出 max(f[root][0], f[root][1])

C++ 代码模板

cpp 复制代码
#include <iostream>
#include <vector>
#include <algorithm>

using namespace std;

const int N = 6005;

int happy[N];
vector<int> son[N]; // 存储每个节点的直接下属(子节点)
bool has_father[N];
long long f[N][2]; // f[i][1]表示i参加, f[i][0]表示i不参加

void dfs(int u) {
    // 初始化
    f[u][1] = happy[u];
    f[u][0] = 0;

    // 遍历所有子节点
    for (int i = 0; i < son[u].size(); ++i) {
        int v = son[u][i];
        
        // 递归计算子节点的DP值
        dfs(v);

        // 根据子节点的DP值更新父节点的DP值
        f[u][1] += f[v][0];
        f[u][0] += max(f[v][0], f[v][1]);
    }
}

int main() {
    int n;
    cin >> n;

    for (int i = 1; i <= n; ++i) {
        cin >> happy[i];
    }

    for (int i = 0; i < n - 1; ++i) {
        int l, k;
        cin >> l >> k; // l是k的上司
        son[k].push_back(l);
        has_father[l] = true;
    }

    // 找到根节点
    int root = 1;
    while (has_father[root]) {
        root++;
    }

    // 从根节点开始进行树形DP
    dfs(root);

    // 输出最终答案
    cout << max(f[root][0], f[root][1]) << endl;

    return 0;
}

3.7 【7】状态压缩动态规划

状态压缩动态规划(简称状压DP)是一种特殊的动态规划,它通过将一个"状态集合"压缩成一个整数来表示,从而解决了某些看似状态数量庞大、无法用常规DP处理的问题。

3.7.1 为什么需要状态压缩?

在一些DP问题中,状态的一个维度可能不是一个简单的数字,而是一个集合。例如,我们需要知道"当前已经访问了哪些城市"或者"棋盘的这一行中,哪些位置已经放了棋子"。

如果城市数量是 \(N\),用一个数组来记录每个城市是否被访问,这个状态本身就很大。但如果我们注意到 \(N\) 的值通常很小(例如 \(N \le 20\)),就可以用一个整数的二进制位来表示这个集合。

一个 \(N\) 位的二进制数,可以表示一个大小为 \(N\) 的集合中所有元素的选取情况。

  • 第 \(i\) 位是 1,代表集合中包含第 \(i\) 个元素。
  • 第 \(i\) 位是 0,代表集合中不包含第 \(i\) 个元素。

这样,一个复杂的状态集合就被"压缩"成了一个整数。

3.7.2 核心工具:位运算

状压DP离不开位运算。以下是几个关键的位运算操作,假设我们用整数 mask 来表示集合:

  • 判断元素 i 是否在集合中 : if (mask & (1 << i))
    • 1 << i 会生成一个只有第 i 位是1的二进制数。
    • & (按位与) 运算后,如果结果不为0,说明 mask 的第 i 位也是1。
  • 将元素 i 加入集合 : mask = mask | (1 << i)
    • | (按位或) 运算可以将 mask 的第 i 位置为1,而不改变其他位。
  • 从集合中移除元素 i : mask = mask & (~(1 << i)) 或者 mask = mask ^ (1 << i) (当确定i在集合中时)
    • ^ (按位异或) 可以翻转指定位。
  • 表示空集 : 0
  • 表示包含所有 \(N\) 个元素的全集 : (1 << N) - 1,这是一个所有前 \(N\) 位都是1的二进制数。

注意:在代码实现中,我们通常用 1 << i 表示第 i 个元素(从0开始计数)。如果问题是从1开始编号,那么第 i 个元素对应 1 << (i-1)

3.7.3 状态压缩DP的核心要素

  1. 识别状压特征 :题目的数据范围是关键信号。当看到某个关键维度 \(N\) 的范围特别小,比如 \(N \le 20\),就要考虑是否可以使用状压DP。
  2. 状态定义 :状态通常包含一个压缩后的整数。例如,\(f[\text{mask}][i]\) 表示当前访问过的城市集合是 mask,并且最后停留在城市 i 时的最优解。
  3. 状态转移方程 :思考如何从一个"小"的集合状态转移到一个"大"的集合状态。通常是枚举当前集合 mask,再枚举集合中的最后一个元素 i,然后考虑它是从哪个元素 j 转移过来的。
    \(f[\text{mask}][i] = \text{最优操作}(f[\text{mask} \setminus \{i\}][j] + \text{cost}(j, i))\)
    这里的 \(\text{mask} \setminus \{i\}\) 表示从集合 mask 中去掉元素 i,在位运算中对应 mask ^ (1 << i)
  4. 计算顺序 :通常按照集合的大小(即二进制表示中1的个数)从小到大进行转移。或者直接从 1(1 << N) - 1 遍历 mask,因为任何 mask 所依赖的 mask' 必然比 mask 小,所以这样遍历是有效的。

3.7.4 经典例题:最短Hamilton路径 (P1171)

这是一个状压DP的模板题,本质是旅行商问题(TSP)的简化版。

题目描述

给定一张 \(N\) 个点的带权无向图,点从 \(0 \sim N-1\) 编号。求从起点 \(0\) 出发,经过每个点恰好一次 ,最终到达终点 \(N-1\) 的最短路径长度。(\(1 \le N \le 20\))

思路分析

  1. 识别状压特征 :\(N \le 20\),这个数据范围是状压DP的强烈暗示。我们需要记录"哪些点被访问过",这个集合可以用一个整数来压缩。

  2. 状态定义:我们需要知道两个信息:当前访问过的点的集合,以及当前路径的终点是哪个点。

    • 定义 \(f[\text{mask}][i]\):表示访问过的点集为 mask,且当前停留在点 i 时的最短路径长度。
    • mask 是一个整数,它的二进制表示中,第 j 位为1表示点 j 已经被访问过。
    • i 必须是 mask 集合中的一个点。
  3. 状态转移方程 :考虑如何计算 \(f[\text{mask}][i]\)。要想到达状态 (mask, i),上一步必然是在另一个状态 (mask', j)

    • 这个 mask'mask 去掉点 i 后的集合。位运算表示为 mask ^ (1 << i)
    • jmask' 集合中的任意一个点。
    • j 走到 i 的成本是 dist[j][i]
    • 为了让路径最短,我们应该从所有可能的上一个点 j 中,选择一个能使总路径最短的。
      所以,状态转移方程为:
      \(f[\text{mask}][i] = \min_{j \in \text{mask'}, j \neq i} \{ f[\text{mask'}][j] + \text{dist}[j][i] \}\)
      其中 mask'mask ^ (1 << i)
  4. 初始化

    • 首先将所有 \(f\) 值初始化为一个极大值(表示不可达)。
    • 起点是 \(0\)。初始状态是只访问了点 \(0\),并且停留在点 \(0\)。所以 \(f[1][0] = 0\)。(mask=1 的二进制是 ...001,表示只访问了第0个点)。
  5. 计算顺序

    • 外层循环枚举状态 mask,从 1(1 << N) - 1
    • 中层循环枚举当前终点 i,从 0N-1
    • 内层循环枚举上一个终点 j,从 0N-1
    • 在转移时,需要判断 ij 是否在对应的集合中。
  6. 最终答案 :题目要求经过所有点,最后到达点 \(N-1\)。

    • "经过所有点"对应的集合是全集,即 mask = (1 << N) - 1
    • "最后到达点 \(N-1\)"意味着我们寻找的答案是 \(f[(1 \ll N) - 1][N-1]\)。

伪代码

复制代码
输入邻接矩阵 dist[N][N]

定义 f[(1<<N)][N],并全部初始化为无穷大

// 初始化起点
f[1][0] = 0

// 遍历所有状态集合
对于 mask 从 1 到 (1 << N) - 1:
  // 遍历当前集合的终点 i
  对于 i 从 0 到 N-1:
    // 如果 i 在集合 mask 中
    if (mask & (1 << i)):
      // 遍历上一个终点 j
      对于 j 从 0 到 N-1:
        // 如果 j 在集合 mask 中, 且 j 不等于 i
        if ((mask & (1 << j)) and (i != j)):
          // 考虑从 j 转移到 i
          // 上一个状态的集合是 mask 去掉 i
          prev_mask = mask ^ (1 << i)
          // 确保 j 在上一个状态的集合中
          if (prev_mask & (1 << j)):
            f[mask][i] = min(f[mask][i], f[prev_mask][j] + dist[j][i])

输出 f[(1 << N) - 1][N - 1]

C++ 代码模板

cpp 复制代码
#include <iostream>
#include <cstring>
#include <algorithm>

using namespace std;

const int N = 20;
const int INF = 0x3f3f3f3f;

int dist[N][N];
int f[1 << N][N]; // 1 << N 表示 2^N

int main() {
    int n;
    cin >> n;

    for (int i = 0; i < n; ++i) {
        for (int j = 0; j < n; ++j) {
            cin >> dist[i][j];
        }
    }

    // 初始化DP数组
    memset(f, 0x3f, sizeof(f));
    
    // 起点状态
    f[1][0] = 0; // mask=1表示只经过了0号点,当前在0号点

    // 遍历所有状态集合
    for (int mask = 1; mask < (1 << n); ++mask) {
        // 遍历当前终点 i
        for (int i = 0; i < n; ++i) {
            // 确保 i 在集合 mask 中
            if (mask & (1 << i)) {
                // 遍历上一个终点 j
                for (int j = 0; j < n; ++j) {
                    // 确保 j 也在集合 mask 中,且 j != i
                    // 并且 j 是从去掉i之前的集合转移来的
                    int prev_mask = mask ^ (1 << i);
                    if (prev_mask & (1 << j)) {
                         f[mask][i] = min(f[mask][i], f[prev_mask][j] + dist[j][i]);
                    }
                }
            }
        }
    }
    
    cout << f[(1 << n) - 1][n - 1] << endl;

    return 0;
}

第四节 字符串算法

字符串是信息学竞赛中一种非常基础而又重要的数据类型。在前面的学习中,我们已经接触了字符串的基本操作。本章将深入探讨两种高效的字符串处理算法:KMP 算法和 Manacher 算法。它们分别是解决字符串匹配和最长回文子串问题的利器,能够将原本朴素算法的平方级复杂度优化到线性复杂度,是每一位追求更高水平的 Oier 必须掌握的核心知识。

4.1 【6】字符串匹配:KMP算法

4.1.1 朴素的字符串匹配:一个引子

在讨论 KMP 算法之前,我们首先需要理解它要解决的问题是什么。这个问题就是字符串匹配 :给定一个主字符串(通常称为文本串) \(T\) 和一个待查找的字符串(通常称为模式串) \(P\),我们需要找出模式串 \(P\) 在文本串 \(T\) 中所有出现的位置。

例如,文本串 \(T = \text{"ababcabcacbab"}\),模式串 \(P = \text{"abcac"}\)。我们的任务就是找到 \(P\) 在 \(T\) 中的起始位置。

一个最容易想到的方法是"暴力匹配",或者叫朴素算法。它的思路非常直接:

  1. 将 \(P\) 的开头对准 \(T\) 的第 1 个字符。
  2. 逐一比较 \(P\) 和 \(T\) 对应位置的字符。
  3. 如果所有字符都匹配成功,就记录下这个位置。
  4. 如果中途有一个字符不匹配,就将 \(P\) 整体向右移动一位,回到第 2 步,从 \(T\) 的第 2 个字符开始重新比较。
  5. 重复这个过程,直到 \(P\) 的开头对准了 \(T\) 中所有可能的位置。

让我们用上面的例子模拟一下:

  • \(T\) 的 ababc... 与 \(P\) 的 abcac 比较,在第 3 个字符 (a vs c) 处失败。
  • \(P\) 右移一位。\(T\) 的 babc... 与 \(P\) 的 abcac 比较,在第 1 个字符 (b vs a) 处失败。
  • \(P\) 右移一位。\(T\) 的 abc... 与 \(P\) 的 abcac 比较,匹配成功!记录起始位置 3。
  • ... 以此类推

这个算法虽然直观,但效率不高。在最坏情况下,例如在文本串 \(T = \text{"aaaaaaaaaab"}\) 中查找模式串 \(P = \text{"aaab"}\),每次失配都只发生在模式串的最后一个字符,导致模式串每次只能向后移动一位,进行了大量重复的比较。如果文本串 \(T\) 的长度为 \(n\),模式串 \(P\) 的长度为 \(m\),朴素算法的时间复杂度是 \(O(n \times m)\)。当 \(n\) 和 \(m\) 都很大时,这个效率是无法接受的。

4.1.2 KMP算法的核心思想:聪明的"跳跃"

朴素算法的低效根源在于,它在失配后没有利用任何已知的信息,只是盲目地将模式串右移一位。而 KMP 算法的精髓就在于,它能充分利用已经匹配过的信息,在失配后进行一次聪明的"跳跃",从而跳过大量不必要的比较

这个"已知的信息"是什么呢?信息就藏在模式串 \(P\) 自身之中。

设想一下,当我们在文本串 \(T\) 的第 \(i\) 位和模式串 \(P\) 的第 \(j+1\) 位发生失配时,意味着 \(T\) 中从 \(i-j\) 到 \(i-1\) 的这部分子串,与 \(P\) 中从 \(1\) 到 \(j\) 的这部分是完全匹配的。即 \(T[i-j \dots i-1] == P[1 \dots j]\)。

朴素算法会把 \(P\) 右移一位,再去比较 \(T[i-j+1 \dots]\) 和 \(P[1 \dots]\)。但 KMP 算法会思考:我们能不能把 \(P\) 移动更多位呢?

KMP 算法的答案是:可以。我们可以在已经匹配的模式串部分 \(P[1 \dots j]\) 中寻找一个"最长的、既是它的前缀又是它的后缀的子串"。这个概念听起来有点绕,我们把它拆解一下。

前缀和后缀

  • 前缀:指字符串中从第一个字符开始的任意长度的连续子串(不包括字符串本身)。例如,字符串 "ababa" 的前缀有 "a", "ab", "aba", "abab"。
  • 后缀:指字符串中到最后一个字符结束的任意长度的连续子串(不包括字符串本身)。例如,字符串 "ababa" 的后缀有 "a", "ba", "aba", "baba"。

最长公共前后缀

就是在一个字符串的所有"前缀"和"后缀"中,找到一个最长的、内容相同的字符串。

例如,对于 "ababa":

  • 前缀集合:
  • 后缀集合:
  • 公共前后缀有 "a" 和 "aba"。其中最长的是 "aba",长度为 3。

现在回到失配的场景。当 \(T\) 的 \(i\) 位和 \(P\) 的 \(j+1\) 位失配时,我们已经知道 \(T[i-j \dots i-1] == P[1 \dots j]\)。如果我们找到了 \(P[1 \dots j]\) 的一个长度为 \(k\) 的公共前后缀,这意味着 \(P[1 \dots k] == P[j-k+1 \dots j]\)。

结合上面两个等式,我们可以推导出:\(T[i-k \dots i-1] == P[j-k+1 \dots j] == P[1 \dots k]\)。

这个推导非常关键!它告诉我们,文本串中 \(i\) 位置之前的那 \(k\) 个字符,恰好和模式串的开头 \(k\) 个字符是匹配的。所以,我们根本不需要把模式串只移动一位,而是可以直接把模式串的第 \(1\) 位对准文本串的第 \(i-k\) 位,然后从模式串的第 \(k+1\) 位和文本串的第 \(i\) 位开始继续比较。这相当于将模式串的指针 \(j\) 直接"跳"到 \(k\)。

为了实现这个聪明的"跳跃",我们需要对模式串 \(P\) 进行预处理,计算出对于 \(P\) 的每一个前缀 \(P[1 \dots j]\),它的最长公共前后缀的长度。这个信息通常存储在一个叫做 next 数组(或 border 数组、fail 数组)中。next[j] 的值,就定义为 \(P[1 \dots j]\) 的最长公共前后缀的长度。

例如,对于模式串 \(P = \text{"ababc"}\):

  • next[1] ("a"): 0
  • next[2] ("ab"): 0
  • next[3] ("aba"): "a"是公共前后缀,长度为 1。next[3] = 1
  • next[4] ("abab"): "ab"是公共前后缀,长度为 2。next[4] = 2
  • next[5] ("ababc"): 0

4.1.3 如何计算next数组?

计算 next 数组的过程,是一个"自己匹配自己"的过程。我们可以用递推的方式来计算。假设我们已经求出了 next[1], next[2], ..., next[i-1],现在要求 next[i]

我们使用两个指针,ij。指针 i 从 2 遍历到 \(m\)(模式串长度),表示我们正在计算 next[i]。指针 j 代表 \(P[1 \dots i-1]\) 的最长公共前后缀的长度,即 j = next[i-1]

  1. 我们比较 \(P[i]\) 和 \(P[j+1]\)。
  2. 如果 \(P[i] == P[j+1]\),说明 \(P[1 \dots i-1]\) 的最长公共前后缀后面接上一个 \(P[i]\),恰好等于 \(P[1 \dots j]\) 的最长公共前后缀后面接上一个 \(P[j+1]\)。这样,我们就找到了一个更长的公共前后缀。所以 next[i] = j + 1
  3. 如果 \(P[i] \ne P[j+1]\),说明直接扩展失败了。我们需要在 \(P[1 \dots j]\) 中寻找一个更短的公共前后缀,然后再次尝试匹配。\(P[1 \dots j]\) 的最长公共前后缀是 \(P[1 \dots \text{next}[j]]\),所以我们就令 j = next[j],然后回到步骤 1,继续比较 \(P[i]\) 和新的 \(P[j+1]\)。这个过程不断重复,直到 \(j\) 变为 0(表示找不到任何公共前后缀)或者匹配成功。

伪代码:计算next数组

复制代码
GET_NEXT(P, m):
  next[1] = 0
  j = 0
  FOR i FROM 2 TO m:
    WHILE j > 0 AND P[i] != P[j+1]:
      j = next[j]
    IF P[i] == P[j+1]:
      j = j + 1
    next[i] = j
  RETURN next

C++ 代码模板:计算next数组

cpp 复制代码
// P是模式串,长度为m
// next数组存储结果
void get_next(char P[], int m, int next[]) {
    next[1] = 0;
    for (int i = 2, j = 0; i <= m; i++) {
        while (j > 0 && P[i] != P[j + 1]) {
            j = next[j];
        }
        if (P[i] == P[j + 1]) {
            j++;
        }
        next[i] = j;
    }
}

4.1.4 KMP算法的完整流程

有了 next 数组,KMP 匹配过程就变得非常清晰了。我们同样使用两个指针,i 遍历文本串 \(T\),j 遍历模式串 \(P\)。

  1. i=1, j=0 开始。
  2. 比较 \(T[i]\) 和 \(P[j+1]\)。
  3. 如果 \(T[i] == P[j+1]\),则两个指针都向后移动,即 i++, j++
  4. 如果 \(j\) 移动到了 \(m\) (模式串末尾),说明我们找到了一个完整的匹配。记录下匹配的起始位置 i-m+1。然后,为了继续寻找下一个可能的匹配,我们不能让 j 停在这里,而是应该让它"跳跃"到 next[m] 的位置,即 j = next[j],继续匹配过程。
  5. 如果 \(T[i] \ne P[j+1]\),失配发生。此时,文本串的指针 i 不需要回溯,模式串的指针 j 则利用 next 数组进行跳跃,令 j = next[j]。然后回到步骤 2,比较新的 \(P[j+1]\) 和 \(T[i]\)。如果 j 已经为 0 仍失配,说明当前 \(T[i]\) 无法与 \(P\) 的任何一个前缀的后继字符匹配,则直接 i++

伪代码:KMP匹配

复制代码
KMP_MATCH(T, n, P, m, next):
  j = 0
  FOR i FROM 1 TO n:
    WHILE j > 0 AND T[i] != P[j+1]:
      j = next[j]
    IF T[i] == P[j+1]:
      j = j + 1
    IF j == m:
      PRINT "找到一个匹配,起始位置为", i - m + 1
      j = next[j] // 继续寻找下一个匹配

C++ 代码模板:KMP算法

cpp 复制代码
#include <iostream>
#include <vector>
#include <string>

using namespace std;

// T是文本串,长度为n
// P是模式串,长度为m
// next数组由get_next函数计算得出
// 假设字符串下标从1开始
void kmp_search(char T[], int n, char P[], int m, int next[]) {
    for (int i = 1, j = 0; i <= n; i++) {
        while (j > 0 && T[i] != P[j + 1]) {
            j = next[j];
        }
        if (T[i] == P[j + 1]) {
            j++;
        }
        if (j == m) {
            // 找到一个匹配,起始位置是 i - m + 1
            cout << i - m + 1 << endl;
            // 继续寻找下一个可能的匹配
            // 这等价于模式串P[1...next[m]]与文本串T的对应部分已经匹配
            j = next[j];
        }
    }
}

KMP算法通过 next 数组避免了主串指针 i 的回溯,并且模式串指针 j 的移动(包括增加和跳跃)总次数是线性的。因此,计算 next 数组的时间复杂度是 \(O(m)\),匹配过程的时间复杂度是 \(O(n)\),总时间复杂度为 \(O(n+m)\),这是一个巨大的提升。

4.1.5 洛谷例题:P3375 【模板】KMP字符串匹配

题目描述

给出两个字符串 \(s_1\) 和 \(s_2\),其中 \(s_2\) 为 \(s_1\) 的前缀,求出 \(s_2\) 在 \(s_1\) 中所有出现的位置。

为了减少输出量,您只需要输出 \(s_2\) 在 \(s_1\) 中所有出现的位置的起始下标(下标从 1 开始)。

另外,您还需要输出 \(s_2\) 的 next 数组。

题解思路

这道题是 KMP 算法的模板题。题目要求我们做两件事:

  1. 找出模式串(\(s_2\))在文本串(\(s_1\))中所有出现的起始位置。
  2. 输出模式串(\(s_2\))的 next 数组。

这完全符合我们上面学习的内容。我们只需要先调用 get_next 函数计算出 \(s_2\) 的 next 数组,然后将它输出。接着,调用 kmp_search 函数,在函数内找到匹配时输出起始位置即可。

参考代码

cpp 复制代码
#include <iostream>
#include <string>
#include <vector>

using namespace std;

const int MAXN = 1000005;

char s1[MAXN], s2[MAXN]; // 文本串和模式串
int next_arr[MAXN];
int n, m;

// 计算模式串s2的next数组
void get_next() {
    next_arr[1] = 0;
    for (int i = 2, j = 0; i <= m; i++) {
        while (j > 0 && s2[i] != s2[j + 1]) {
            j = next_arr[j];
        }
        if (s2[i] == s2[j + 1]) {
            j++;
        }
        next_arr[i] = j;
    }
}

// 在文本串s1中查找模式串s2
void kmp_search() {
    for (int i = 1, j = 0; i <= n; i++) {
        while (j > 0 && s1[i] != s2[j + 1]) {
            j = next_arr[j];
        }
        if (s1[i] == s2[j + 1]) {
            j++;
        }
        if (j == m) {
            cout << i - m + 1 << endl;
            j = next_arr[j];
        }
    }
}

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    // cin读取字符串时,会自动在末尾添加'\0'
    // C-style字符串的长度可以通过strlen获取,但我们直接用cin读取到s1+1, s2+1
    // 这样可以直接使用1-based索引
    cin >> (s1 + 1);
    cin >> (s2 + 1);

    // 计算字符串长度
    n = 0;
    while(s1[n + 1] != '\0') n++;
    m = 0;
    while(s2[m + 1] != '\0') m++;

    get_next();
    kmp_search();

    for (int i = 1; i <= m; i++) {
        cout << next_arr[i] << (i == m ? "" : " ");
    }
    cout << endl;

    return 0;
}

4.2 【7】Manacher算法

4.2.1 最长回文子串:另一个挑战

回文串是指正读和反读都一样的字符串,例如 "level", "noon"。最长回文子串问题,就是给定一个字符串,找出其中最长的一个回文子串。

例如,对于字符串 "abacaba",最长回文子串是它本身。对于 "google",最长回文子串是 "goog"。

解决这个问题,同样可以从朴素算法入手。最直接的想法是:

  1. 枚举回文串的中心
  2. 从这个中心开始,向两边同时扩展,判断两边的字符是否相等。
  3. 直到两边字符不相等,或者到达了字符串的边界,就找到了以该点为中心的最长回文串。
  4. 遍历所有的中心,记录下最长的回文串长度。

这里有一个细节:回文串的长度可能是奇数(如 "aba",中心是字符 'b'),也可能是偶数(如 "abba",中心是两个 'b' 之间的空隙)。所以,我们需要枚举 \(n\) 个字符作为奇数回文中心,以及 \(n-1\) 个字符间的空隙作为偶数回文中心。

这种"中心扩展法"的思路是正确的,但效率如何?我们有大约 \(2n-1\) 个中心,每个中心最多向外扩展约 \(n/2\) 次。所以,总的时间复杂度是 \(O(n^2)\)。对于 \(10^6\) 级别的数据,平方算法显然会超时。我们需要一个更高效的算法。

Manacher 算法("马拉车"算法)正是解决这个问题的线性时间复杂度的算法。

4.2.2 Manacher算法的巧妙构思:化零为整

Manacher 算法的第一个巧妙之处,就是通过一个简单的预处理,将奇数长度和偶数长度的回文串统一起来处理

方法是:在原字符串的每个字符之间,以及字符串的开头和结尾,都插入一个不会在原串中出现的特殊字符(例如 '#')。

  • 原串 s = "aba" -> 新串 t = "#a#b#a#"
  • 原串 s = "abba" -> 新串 t = "#a#b#b#a#"

观察新串 t

  • 原先长度为奇数的回文串 "aba",在新串中变成了 "#a#b#a#",长度为 7,中心是 'b'。
  • 原先长度为偶数的回文串 "abba",在新串中变成了 "#a#b#b#a#",长度为 9,中心是 'b' 和 'b' 中间的那个 '#'。

神奇的事情发生了:在新串 t 中,任何一个回文子串的长度都是奇数,并且它们的中心都落在了一个具体的字符上(要么是原字符,要么是 '#')!

这样,我们就不用再区分奇偶了,只需要用同一种方式------以每个字符为中心向外扩展------来寻找回文串。

为了方便记录,我们定义一个辅助数组 p,其中 p[i] 表示以新串 t 中第 i 个字符为中心的最长回文子串的半径 。例如,对于 t = "#a#b#a#",以 'b' (下标为 4) 为中心的最长回文子串是 "#a#b#a#",它的半径是 4(包含中心'b'自己,以及向一侧扩展的'#', 'a', '#')。

新串中的回文半径 p[i] 和原串中的回文长度有什么关系呢?

  • 如果中心 t[i] 是原字符,如 "aba" -> "#a#b#a#" 中以 'b' 为中心的回文串,p[4]=4,对应原串长度为 p[4]-1 = 3
  • 如果中心 t[i] 是 '#',如 "abba" -> "#a#b#b#a#" 中以中间的 '#' 为中心的回文串,p[5]=5,对应原串长度为 p[5]-1 = 4

可以发现,规律是统一的:新串中以 i 为中心的回文串,在原串中对应的回文子串长度就是 p[i] - 1。因此,我们只需要求出所有 p[i] 的最大值,就能得到最长回文子串的长度。

4.2.3 加速扩展:利用已知回文信息

解决了奇偶问题后,我们依然面临着 \(O(n^2)\) 的中心扩展。Manacher 算法的第二个、也是最核心的巧妙之处,在于它利用已经计算过的回文信息来加速后续的计算,避免了大量重复的扩展

算法维护两个变量:

  • max_right: 到目前为止,所有找到的回文串中,其右边界能到达的最远位置。
  • mid: 对应 max_right 的那个回文串的中心。

当我们从左到右计算 p[i] 时,假设我们正在计算位置 i 的回文半径:

  1. 如果 imax_right 的右边 (即 i > max_right):

    这说明我们对 i 位置一无所知,没有任何历史信息可以利用。我们只能老老实实地进行中心扩展,从半径为 1 开始,一点点尝试扩大,直到失败。然后用这个新找到的回文串更新 max_rightmid

  2. 如果 imax_right 的内部 (即 i <= max_right):

    这是 Manacher 算法的精华所在!因为 i 在以 mid 为中心、右边界为 max_right 的大回文串内部,所以我们可以利用回文的对称性。

    找到 i 关于 mid 的对称点 j,其坐标为 j = 2 * mid - i

    由于 ji 的左边,p[j] 的值我们是已经计算出来的。

    现在我们有了来自 p[j] 的宝贵信息。根据大回文串的对称性,以 i 为中心的回文串,在一定范围内,和以 j 为中心的回文串是镜像对称的。

    • 情况A:以 j 为中心的回文串完全被包含在以 mid 为中心的大回文串的内部。

      这意味着 j 的回文半径 p[j] 没有超出大回文串的左边界。根据对称性,i 的回文半径 p[i] 至少也等于 p[j]。所以我们可以直接令 p[i] = p[j],并且此时无需继续扩展,因为 p[j] 的回文串两端已经是不同字符了,对称过来 p[i] 的两端也必然不同。

    • 情况B:以 j 为中心的回文串超出了以 mid 为中心的大回文串的左边界。

      这意味着 j 的回文串有一部分在 mid 的大回文串之外。根据对称性,我们只能保证 i 的回文串在 mid 大回文串内部的部分是对称的。这部分的半径就是从 imax_right 的距离,即 max_right - i + 1。所以我们可以确定 p[i] 的值至少max_right - i + 1。至于它能否更长,我们不知道,需要从这个基础上继续向外尝试扩展。

    聪明的你可能发现,情况 A 和 B 可以合并。我们可以给 p[i] 一个初始值 min(p[j], max_right - i + 1),然后在这个基础上继续尝试中心扩展。在情况 A 下,p[j] 更小,扩展一次就会失败;在情况 B 下,max_right - i + 1 更小或相等,我们从这个保底值继续扩展。

在每次计算完 p[i] 后,如果 i + p[i] - 1 的值超过了当前的 max_right,我们就更新 max_rightmid

4.2.4 Manacher算法的复杂度

为什么这样就能把复杂度降到 \(O(n)\) 呢?

关键在于 max_right 这个变量。在整个算法流程中,max_right 只会不断地向右移动,永远不会向左回退。

暴力扩展(while 循环)只会在我们尝试将 max_right 推向更右边的时候才会执行。max_right 最多从 1 移动到 \(2n+1\)。所以,暴力扩展的总次数不会超过 \(O(n)\)。而主循环 for 本身也是 \(O(n)\) 的。因此,总的时间复杂度是线性的 \(O(n)\)。

4.2.5 算法实现与模板

伪代码:Manacher算法

复制代码
MANACHER(s):
  t = 预处理字符串s,加'#'
  n = length of t
  p = new array of size n
  mid = 0, max_right = 0
  
  FOR i FROM 1 TO n-1:
    IF i <= max_right:
      p[i] = min(p[2*mid - i], max_right - i + 1)
    ELSE:
      p[i] = 1
      
    WHILE t[i - p[i]] == t[i + p[i]]: // 边界检查
      p[i]++
      
    IF i + p[i] - 1 > max_right:
      max_right = i + p[i] - 1
      mid = i
      
  max_len = 0
  FOR i FROM 1 TO n-1:
    max_len = max(max_len, p[i] - 1)
  
  RETURN max_len

C++ 代码模板:Manacher算法

为了防止中心扩展时数组越界,我们可以在预处理后的字符串开头再加一个永远不会匹配的字符,比如 '$'。

cpp 复制代码
#include <iostream>
#include <string>
#include <vector>
#include <algorithm>

using namespace std;

const int MAXN = 22000005; // 2 * n + 5

char s[MAXN]; // 原字符串
char t[MAXN]; // 预处理后的字符串
int p[MAXN]; // 回文半径数组

// 预处理字符串
int init(char* str) {
    int n = 0;
    while(str[n] != '\0') n++;
    
    t[0] = '$';
    t[1] = '#';
    int j = 2;
    for (int i = 0; i < n; i++) {
        t[j++] = str[i];
        t[j++] = '#';
    }
    t[j] = '\0'; // 字符串结束符
    return j; // 返回新字符串的长度
}

int manacher(char* str) {
    int len = init(str);
    int max_right = 0, mid = 0;
    int max_len = 0;

    for (int i = 1; i < len; i++) {
        // 计算p[i]的初始值
        if (i <= max_right) {
            p[i] = min(p[2 * mid - i], max_right - i + 1);
        } else {
            p[i] = 1;
        }

        // 中心扩展
        // 因为t[0]是'$',所以t[i-p[i]]不会越界到负数
        while (t[i - p[i]] == t[i + p[i]]) {
            p[i]++;
        }

        // 更新max_right和mid
        if (i + p[i] - 1 > max_right) {
            max_right = i + p[i] - 1;
            mid = i;
        }

        // 更新答案
        max_len = max(max_len, p[i] - 1);
    }
    return max_len;
}

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);
    
    cin >> s;
    cout << manacher(s) << endl;
    
    return 0;
}

4.2.6 洛谷例题:P3829 【模板】Manacher算法

题目描述

给定一个字符串 \(s\),求其最长回文子串的长度。

题解思路

这道题目正是 Manacher 算法解决的经典问题。我们只需要将上述的 Manacher 算法模板应用到这道题中即可。

  1. 读入字符串 s
  2. 调用 init 函数对 s 进行预处理,得到新串 t
  3. 执行 Manacher 算法的核心循环,计算出 p 数组。
  4. 在循环过程中,不断用 p[i] - 1 更新最终答案 max_len
  5. 循环结束后,输出 max_len

上述模板代码就是针对这个问题的完整解决方案。通过这道题,可以加深对 Manacher 算法的理解和代码实现能力的锻炼。