数据结构总结(CSP-J)

· · 算法·理论

upd:修正了一处关于时间复杂度的错误言论,实际上使用提高组算法可以做到更优。

upd:修正:堆不在 J 组考纲里,已转移至 S 组文章。

前言

保证全部的内容在 CSP-J 的考纲内。题目难度为橙题到青题。

介绍的数据结构有:栈、队列、链表、树、图、堆(优先队列)。

好的,废话不多说,直接开始~

1. 栈

1.1 基础栈

栈是什么东西?

是一个 FILO 的数据结构

很明显刚才那句话不是给人看的。

其实栈是这么一个东西。它是一个数组加上了一个栈顶,并且只可以在栈顶进行操作。

比如这里有一只可爱的栈 s。它初始为空。假如我们要往这个栈里面依次加入 1, 2, 3, 4, 5,那么这个栈就只会星程 s=1, 2, 3, 4, 5 这个序列。这个时候,栈顶指向 5

如果我们一个一个把这个栈里面的数弹出,那么得到的出栈序列就是 5, 4, 3, 2, 1。为什么?因为我们的栈顶从 5 移动到了 1

这个时候我们就可以解释 FILO 是什么了。FILO 其实是 First In Last Out 的缩写,也就是在前面进去的数在后面出来。就像 1,是第一个入栈的,但是却最后一个出栈。

一个手写栈大概有以下的基础操作:

栈可以用一个数组实现,核心代码如下:

int a[], t;//栈数组,栈顶指针
bool empty() {return t == 0;}//判断栈是否为空
int top() {return a[t];}//询问栈顶元素
int size() {return t;}//询问栈的大小
void push(int x) {a[++t] = x;}//向栈中加入一个数x
void pop() {if(t > 0) t--;}//弹出栈顶元素,如果没有元素则忽略
void clear() {t = 0;}//清空栈

可以发现栈还是非常好写的。

这是 模板题 的代码: ::::success[代码]

#include <bits/stdc++.h>
#define endl '\n'
#define ull unsigned long long
using namespace std;
const int N = 1e6 + 10;
struct myStack {
    ull a[N];
    int t;
    void clear() {t = 0;}
    void push(ull x) {a[++t] = x;}
    int size() {return t;}
    void pop() {if(t > 0) t--;}
    ull top() {return a[t];}
} st;
int T, n;
int main() {
    ios :: sync_with_stdio(0);
    cin.tie(0); cout.tie(0);
    cin >> T;
    while(T--) {
        cin >> n;
        st.clear();
        for(int i = 1; i <= n; i++) {
            string s;
            ull x;
            cin >> s;
            if(s == "push") {
                cin >> x;
                st.push(x);
            } else if(s == "pop") {
                if(st.t == 0) cout << "Empty\n";
                st.pop();
            } else if(s == "query") {
                ull t = st.top();
                if(st.t == 0) cout << "Anguei!\n";
                else cout << t << endl;
            } else {
                cout << st.t << endl;
            }
        }
    }
    return 0;
} 

:::: 当然,就算栈很好写,时时刻刻的手写还是很烦的,所以 STL 给我们提供了一个封装好了的栈:stack<T>

其中提供了以下的核心操作:

stack<int> st;//声明一个栈,int也可以换成其他类型
st.push(x);//相当于手写栈的push
st.pop();//相当于手写栈的pop
st.top();//相当于手写栈的top
st.size();//相当于手写栈的size
st.empty();//相当于手写栈的empty

当然,手写还是有一定好处的,毕竟 STL 栈不支持 \mathcal{O}(1) 的清空栈,只能一个一个的手动弹出。

STL 栈实现模板题的代码如下: ::::success[代码]

#include <bits/stdc++.h>
#define endl '\n'
#define ull unsigned long long
using namespace std;
const int N = 1e6 + 10;
stack<ull> st;
int T, n;
int main() {
    ios :: sync_with_stdio(0);
    cin.tie(0); cout.tie(0);
    cin >> T;
    while(T--) {
        cin >> n;
        while(!st.empty()) st.pop();
        for(int i = 1; i <= n; i++) {
            string s;
            ull x;
            cin >> s;
            if(s == "push") {
                cin >> x;
                st.push(x);
            } else if(s == "pop") {
                if(st.empty()) cout << "Empty\n";
                else st.pop();
            } else if(s == "query") {
                if(st.empty()) cout << "Anguei!\n";
                else cout << st.top() << endl;
            } else {
                cout << st.size() << endl;
            }
        }
    }
    return 0;
} 

:::: 可以做一下下面这些题:

  1. 验证栈序列
  2. 日志分析

以及团队内部的一道神题(我绝对不会承认小 W 是我): ::::info[题面]

题目描述

小 W 终于鼓起勇气向小 M 表白,然而只是有勇气写情书。为了防止情书内容被同学窃取,小 W 给情书加密。小 M 的解密方式很简单,假设情书是字符串 S1,小 W 给她的解密串是 S2,小 M 会重复 地完成“在 S1 中找到子串 S2 并删除”这一操作直到在 S1 中找不到 S2。假如你是小 M,请你确定情书的最终内容。(从前往后遍历的时候找到一个可以删除的立即删除,删除之后,继续遍历新的数组,继续删除)

输入格式

第一行,一个字符串 S_1

第二行,一个字符串 S_2

输出格式

输出只有一行,一个字符串 S,表示最终内容。

输入输出样例 #1

输入 #1

iloooooooooooooooveu
oo

输出 #1

iloveu

说明 / 提示

L(s) 表示字符串 s 的长度。

字符都是小写字母且无空格。 :::: ::::success[题解] ### 验证栈序列 注意到栈 FILO 的性质,也就是出栈的数字必须要在栈顶。 所以我们从先往后模拟入栈出栈,当遇到出栈序列的第 $i$ 位时,首先往栈中压元素,直到栈顶元素为 $a_i$。然后弹栈。 如果没有可以入栈的数了,那么这个栈序列就不可能是出栈序列。 ### 日志分析 在栈的每一个元素上同时维护一个值 $m$,表示从栈底到这个元素的最大值。只需要在入栈的时候更新一下最大值。每次询问栈中最大值的时候只需要输出栈顶的 $m$ 值就可以了。 由于只会在栈顶一头操作,所以这个做法是正确的。 ### 小 W 的爱情密码 注意到这里 $\mathcal{O}(L(S_1)\times L(S_2))$ 复杂度的做法可以通过。可以做到线性,但是需要提高组算法。 所以我们可以维护一个字符栈,每次把栈顶的所有数拎出来匹配,如果匹配成功就把栈顶全部弹出。一个一个字符跟着入栈就可以了。 而且这个做法天然支持把中间删掉之后前后两段匹配的情况,由于中间已经出栈了,所以前面和后面也可以匹配。 这个做法需要访问栈中间的元素,所以只能手写栈。 :::: ## 1.2 单调栈 单调栈,顾名思义,就是一个栈,其中的元素满足单调性。 我们在实现插入的时候,可以采用以下做法(假如维护的是单调递减):不断删除栈顶的数直到栈顶的数大于待插入的数,然后把这个元素入栈。 比如说我们的入栈序列是 $3,2,1,7,4,6$。 那么我们的栈就会像下面这样运行: - 将 $3, 2, 1$ 分别入栈,栈中元素为 $3, 2, 1$。 - 接着我们要入栈 $7$,这个 $7$ 就把之前的 $3, 2, 1$ 全部删除了(由于 $7$ 大于栈中的所有数),栈中只有 $7$。 - 然后直接入栈 $4$,栈中元素为 $7, 4$。 - 最后入栈 $6$,把栈顶的 $4$ 删除,到了 $7$ 发现 $7\geq 6$ 了,于是把 $6$ 入栈,栈中元素为 $7, 6$。 可以发现栈中的元素是单调递减的。 这个东西有什么用呢? [模板题](https://www.luogu.com.cn/problem/P5788) 就是一个很好的应用。 这个题目要求我们找到每一个数后面第一个比它大的数的位置,而且这里 $n$ 很大,达到了 $3\times 10^6$,所以只能用 $\mathcal{O}(n)$ 的算法。 我们的单调栈就开始展示神力了。 首先手摸一遍样例。(这里我就不摸了,请大家自己摸) 可以发现,每一个数都是被第一个比自己大的数弹出的? 确实。有一个稍微带点感性的理解如下: 由于在入栈的时候这个数会删掉所有比它大的数,所以在栈里的数一定是没有遇到在它后面还比它大的数的,否则就被踢出了。 我们往栈中加入一个数的时候,会删掉一些数。被删掉的数必定满足以下性质: - 这个数之前没有在后面比它大的数。 - 现在加入栈中的数在这个数的后面,并且比这个数大。 所以加入栈中的这个数就是在被删掉的数后面第一个比它大的数。 虽然插入一个数的时候可能会删掉多个数,时间复杂度不是 $\mathcal{O}(1)$ 的,但是总体来看,时间复杂度还是 $\mathcal{O}(n)$ 的,因为一个数字最多入栈出栈一次。 那么模板题就可以做了,代码如下: ::::success[代码] ```cpp #include<bits/stdc++.h> #define endl '\n' using namespace std; const int N = 3e6 + 10; struct node {int val, pos;}; stack<node> st; int n, a[N], f[N]; int main() { ios :: sync_with_stdio(0); cin.tie(0); cout.tie(0); cin >> n; for(int i = 1; i <= n; i++) { cin >> a[i]; while(!st.empty() && st.top().val < a[i]) { f[st.top().pos] = i; st.pop(); } st.push({a[i], i}); } for(int i = 1; i <= n; i++) cout << f[i] << " "; return 0; } ``` :::: 然后是我们并不喜闻乐见的练习题: 1. [Bad Hair Day S](https://www.luogu.com.cn/problem/P2866) 2. [求数列所有后缀最大值的位置](https://www.luogu.com.cn/problem/B3666) 其实练习题还有很多,但这里是 CSP-J 的数据结构总结,所以不会给出太难的题。 ::::success[题解] ### Bad Hair Day S 基本上和单调栈模板题一样。能看到的奶牛的个数就是后面第一个比奶牛大的数和这个奶牛的距离。 ### 求数列所有后缀最大值的位置 这个题目把题意描述的非常明白啊。并且这道题也揭示了单调栈的本质——维护后缀的 $\max/\min$(严格或者非严格都可以)。 也基本上是单调栈的模板题。首先观察到异或的自反性,也就是 $a\oplus a = 0$,所以我们对于每一个数,入栈出栈的时候,如果这个数是“后缀最大值”,那么就异或上这个数的下标。如果不是,直接入栈即可。 :::: # 2. 队列 ## 2.1 普通队列 队列是什么呢? ~~就是一个 FIFO 的数据结构。~~ 很明显这句话也不是给人看的。 那我们来解释一下 FIFO。FIFO 是 First In First Out 的缩写,也就是最先进入队列的元素被最先弹出。 比如入队序列是 $1, 2, 3, 4, 5$,那么出队序列也是 $1, 2, 3, 4, 5$。最先进去的 $1$,被最先弹出了。 然后我们就可以实现手写队列了,支持以下操作: - 访问队列队头的元素(就是马上要被弹出的元素)。 - 访问队列队尾的元素(就是最新被插入的元素)。 - 查询队列的大小。 - 查询队列是否为空。 - 向队尾插入一个数。 - 删除队头的数。 - 清空队列。 这可以用一个数组和队头,队尾两个指针来实现,关键代码如下(为了实现方便,队头指针并不是指向队头的位置,而是指向队头前一个位置): ```cpp int a[], head, tail;//数组,队头指针,队尾指针 int front() {return a[head + 1];}//查询队头 int back() {return a[tail];}//查询队尾 int size() {return tail - head;}//查询队列大小 bool empty() {return tail == head;}//查询队列是否为空 void push(int x) {a[++tail] = x;}//向队尾插入一个数 void pop() {if(head < tail) head++;}//删除队头的数 void clear() {head = tail = 0;}//清空队列 ``` 然后就可以做 [模板题](https://www.luogu.com.cn/problem/B3616) 了。 完整代码实现如下,按照题意模拟即可: ::::success[代码] ```cpp #include<bits/stdc++.h> #define endl '\n' using namespace std; const int N = 1e4 + 10; struct myQueue { int a[N], head, tail; void push(int x) {a[++tail] = x;} void pop() {if(head < tail) head++;} int front() {return a[head + 1];} int size() {return tail - head;} bool empty() {return tail == head;} } q; int n; int main() { ios :: sync_with_stdio(0); cin.tie(0); cout.tie(0); cin >> n; for(int i = 1; i <= n; i++) { int op, x; cin >> op; if(op == 1) { cin >> x; q.push(x); } else if(op == 2) { if(!q.empty()) q.pop(); else cout << "ERR_CANNOT_POP\n"; } else if(op == 3) { if(!q.empty()) cout << q.front() << endl; else cout << "ERR_CANNOT_QUERY\n"; } else { cout << q.size() << endl; } } return 0; } ``` :::: 当然,STL 也给我们提供了包装好的队列,可以用 `queue<T> q;` 定义一个队列,所以我们也不用手写这些代码,这个队列提供了以下的操作: ```cpp queue<int> q;//这里的int换成其它类型也可以 q.front();//查询队头元素 q.back();//查询队尾元素 q.size();//查询队列大小 q.empty();//查询队列是否为空 q.push(x);//向队列中加入一个数 q.pop();//删除队头的数 ``` 队列同样不支持 $\mathcal{O}(1)$ 的清空操作,必须循环弹出,代码和 STL 栈里的类似。 用 STL 队列实现模板题的代码如下: ::::success[代码] ```cpp #include<bits/stdc++.h> #define endl '\n' using namespace std; const int N = 1e4 + 10; queue<int> q; int n; int main() { ios :: sync_with_stdio(0); cin.tie(0); cout.tie(0); cin >> n; for(int i = 1; i <= n; i++) { int op, x; cin >> op; if(op == 1) { cin >> x; q.push(x); } else if(op == 2) { if(!q.empty()) q.pop(); else cout << "ERR_CANNOT_POP\n"; } else if(op == 3) { if(!q.empty()) cout << q.front() << endl; else cout << "ERR_CANNOT_QUERY\n"; } else { cout << q.size() << endl; } } return 0; } ``` :::: 然后是一些练习题: 1. [机器翻译](https://www.luogu.com.cn/problem/P1540) 2. [约瑟夫问题](https://www.luogu.com.cn/problem/P1996) ::::success[题解] ### 机器翻译 比较简单的队列模拟题。按照题意模拟即可。 由于这里的数据范围很小,$n\leq 1000$,所以我们每次遇到一个单词,就可以在队列里暴力匹配,然后如果有就不操作;如果没有就判断队列大小,如果队列大小等于 $m$ 的话弹队头然后插入,否则直接插入。插入的时候记录一下即可。 ### 约瑟夫问题 这道题的数据范围较小,所以用暴力数组模拟也可以过,但是有一种用队列实现的复杂度 $\mathcal{O}(nm)$ 的做法。 我们使用一个队列,一开始把所有的数字全部入队,然后报数就是把队头的元素放到队尾,这样就可以实现一个高效的循环报数。如果报到了 $m$ 就删掉队头。 :::: ## 2.2 双端队列 队列是一个很规范的东西,只能在队头删除数字,在队尾插入数字。 那有没有不那么规范的队列呢? 有的,兄弟,有的,那就是双端队列。 双端队列支持以下的操作(手写): - 在队头插入一个数。 - 删除队头的数。 - 在队尾插入一个数。 - 删除队尾的数。 - 其他操作和队列一样。 手写的双端队列是这样的: ```cpp const int N = 2e6 + 10;//记得开2倍空间 int a[N], head = N / 2, tail = N / 2;//队头和队尾要初始化为中间,由于push_front操作可能会导致队头越界 void push_front(int x) {a[head--] = x;}//在队头插入一个数 void push_back(int x) {a[++tail] = x;}//在队尾加入一个数 void pop_front() {if(head < tail) head++;}//删除队头的数 void pop_back() {if(head < tail) tail--;}//删除队尾的数 //其他操作和队列一样 ``` [模板题](https://www.luogu.com.cn/problem/B3656) 其实是链表(那道题卡空间),所以在这里不给出实现,会在链表一节提到。 STL 也给我们提供了封装好的双端队列,叫做 `deque<T>`,虽然这东西常数大,内存大,但是还是介绍一下,毕竟避免了你手动实现,而且大多数时候(模板题除外)都不会被卡掉。 STL 双端队列有以下的操作: ```cpp deque<int> dq; dq.push_front(x);//在队头插入一个数 dq.push_back(x);//在队尾插入一个数 dq.pop_front();//删除队头的数 dq.pop_back();//删除队尾的数 //包含STL 队列的所有其他操作 ``` 双端队列的应用主要是单调队列,所以这一届没有例题。 ## 2.3 单调队列 > 假如一个人又比你小,又比你强,你就永远打不过他了。 >——单调队列 所以“被单调队列了”就表示某个人(通常是同一机房里的)比你小又比你强。比如 [这位初一打省选的 dalao](https://www.luogu.com.cn/user/1045301) 就单调队列了初二还没有资格打省选的我。 顾名思义,单调队列也是一个满足单调性的队列。 如何实现?当我们向队尾插入一个数的时候,往前查找,把前面所有比它小 / 大的数全部删掉,然后插入这个数。由于这里需要删除队尾的数,所以单调队列是基于双端队列的。 与单调栈不同的是,单调队列还支持删除队头,所以它可以实现一些其他的操作。 比如我们维护一个单调递减的队列,依次往里面插入 $3, 2, 4, 1, 3$,那么会是这样的: - $3, 2$ 入队,现在队列还是单调递减,没有元素出队。 - 假如我们现在做一次出队呢?那么我们就直接把队头删除,可以发现这个队列还是单调递减的。队列中现在只剩下了 $2$。 - $4$ 入队,把 $2$ 删除了,于是队列中只剩下 $4$。 - $1$ 入队,直接插入,队列中有 $4, 1$。 - $3$ 入队,把 $1$ 删除了之后发现 $4 > 3$,于是 $3$ 在队尾插入,队列中元素为 $4, 3$。 所以这个支持在开头删除的单调栈有什么用呢? 参考 [模板题](https://www.luogu.com.cn/problem/P1886)。 其实我们扫一个窗口只需要做这些事: - 如果单调队列最前面的元素在窗口滑动的时候不在窗口里了,把队头删除。 - 窗口扫到的新的数入队。 - 队头的值就是答案。 为什么? 依旧是一个有一点感性的理解: - 在单调队列中,由于呈现单调性,所以队头所对应的值一定是最大的。 - 然后插入的时候,我们插入的数字都是在当前窗口里面的,所以在单调队列里面的数都是合法的,在当前窗口的。 - 如果要把一个数删除,那么必定有一个数比它大才能把它删除,所以正确的最大值不会被删除,除非它被滑出了窗口外面,即位置不合法。 综上,队头的数必定是最大值。 由于每一个数最多出入单调队列 $1$ 次,所以复杂度是 $\mathcal{O}(n)$ 的。 于是就可以写代码了(我采用的是 `deque` 实现,但是上文已经提到,`deque` 是一个巨慢的东西,所以大家也可以尝试用手写双端队列实现) ::::success[代码] ```cpp #include <bits/stdc++.h> #define int long long #define endl '\n' using namespace std; const int N = 1e6 + 10; int n, k, a[N]; deque<int> dq; signed main() { ios :: sync_with_stdio(0); cin.tie(0); cout.tie(0); cin >> n >> k; for(int i = 1; i <= n; i++) cin >> a[i]; for(int i = 1; i <= n; i++) {//最小值维护单调递增 if(!dq.empty() && dq.front() < i - k + 1) dq.pop_front();//记得一定要判队列为空 while(!dq.empty() && a[dq.back()] > a[i]) dq.pop_back(); dq.push_back(i); if(i >= k) cout << a[dq.front()] << " "; } cout << endl; dq.clear();//deque是支持O(1)清空的!!! for(int i = 1; i <= n; i++) {//最大值维护单调递减 if(!dq.empty() && dq.front() < i - k + 1) dq.pop_front(); while(!dq.empty() && a[dq.back()] < a[i]) dq.pop_back(); dq.push_back(i); if(i >= k) cout << a[dq.front()] << " "; } return 0; } ``` :::: 剩下的是一些单调队列的练习题: 1. [扫描](https://www.luogu.com.cn/problem/P2032) 2. [切蛋糕](https://www.luogu.com.cn/problem/P1714) ::::success[题解] ### 扫描 这个就不用我说了吧,单调队列模板还少了一半。 ### 切蛋糕 这一道题涉及前缀和,我们用数组的前缀和 $s$ 来辅助计算。并且有一点单调队列优化 dp 的味道了。 前缀和可以 $\mathcal{O}(1)$ 计算数组的区间和,也就是 $\sum^{r}_{i=l}a_i = s_{r} - s_{l - 1}$。这一点不用我多说了吧,我相信大家都是会前缀和的。 然后,以一个数为结尾的最大子段和是多少呢?领这个位置是 $p$,可以得到以下的式子: $$\max^{m}_{i=1}s_p-s_{p-i}=s_p - \min^{m}_{i=1}s_{p-i}$$ 其中后面的 $\min^{m}_{i=1}s_{p-i}$ 就是一个定长的区间最值问题,可以使用单调队列来计算。 对于每一个点求出来的值,取最大即可。 :::: # 3. 链表 由于链表不常考,所以这里只有 $1$ 节做一个简单的介绍。 链表是什么? ~~是一个有一点难写的东西。~~ 链表支持下面的操作,普通的数组需要把后面的元素全部移动,所以是 $\mathcal{O}(n)$ 的,而链表可以做到 $\mathcal{O}(1)$ 的实现。 - 在一个位置插入一个数。 - 删除一个位置的数。 然而链表有一个劣势,就是它不支持随机访问,这与它的底层实现有关。 Q:那有没有同时支持单点插入,单点删除,随机访问的数据结构? A:有的,但是 [这道题](https://www.luogu.com.cn/problem/P13981) 的难度会直接把你吓哭。这其实是一个块状链表或者文艺平衡树的实现,已经属于 NOIP 知识点了。 链表的具体实现是这样的: 对于每一个节点,维护一个 $nex$ 表示这个节点的下一个数的位置。这个时候,第 $n$ 个数的位置就已经不确定了,只能够从开头来寻找。 为了做删除操作,还需要找到前面的一个数,对于每一个数再维护一个 $pre$ 就可以了。 那我们具体怎么插入删除呢? 插入: 设插入的数字的位置为 $a$(已经找到)。 接着我们在后面直接新建一个节点。把这个新建的节点的 $pre$ 指向 $a$,$nex$ 指向 $a$ 的 $nex$,然后把 $a$ 的 $nex$ 所对应的 $pre$ 指向新建的节点,最后把 $a$ 的 $nex$ 指向这个新建的节点。 看了这段话你可能不太明白,那么就直接看代码吧: ```cpp struct node { int pre, nxt, val; } c[]; int cnt; void insert(int a, int val) { //注意,顺序一定不能写错,由于前面要用到c[a].nxt,所以c[a].nxt的值一定不能被提前修改 c[++cnt].val = val; c[cnt].pre = a; c[cnt].nxt = c[a].nxt; c[c[a].nxt].pre = cnt; c[a].nxt = cnt; } ``` 删除: 设删除的是 $a$。 那么 $a$ 的 $pre$ 所对应的 $nex$ 就应该指向 $a$ 的 $nex$,$a$ 的 $nex$ 的 $pre$ 应该指向 $a$ 的 $pre$。这样,$a$ 就被自然地隔离开了。 然后给出代码: ```cpp void del(int a) { int p = c[a].pre, n = c[a].nxt; c[p].nxt = n; c[n].pre = p; } ``` 然后我们来看看 [模板题](https://www.luogu.com.cn/problem/B4324)。 哎,这个模板题怎么找不到单点插,单点删的影子呢? 别急,这道题的本质其实还是一个链表,只是使用了这个数对应的值来明智的避免了链表的 $\mathcal{O}(n)$ 随机访问。把 $x$ 插入到 $y$ 的左边,其实就是在 $x$ 位置删除 $x$,然后在 $y$ 前面插入 $x$。接到右边其实就是在 $y$ 的后面插入。然后删除就是链表的普通删除。 那代码就很好(好像也并不简单)实现了: ::::success[代码] ```cpp #include <bits/stdc++.h> #define endl '\n' using namespace std; const int N = 5e5 + 10; struct node { int pre, nxt, val; } c[N]; int head; void insert_back(int a, int val) {//在a的后面插入 c[val].pre = a; c[val].nxt = c[a].nxt; c[c[a].nxt].pre = val; c[a].nxt = val; } void insert_front(int a, int val) {//在a的前面插入 if(a == head) head = val; //如果在表头前面插入,记得更新表头!!! c[val].nxt = a; c[val].pre = c[a].pre; c[c[a].pre].nxt = val; c[a].pre = val; } void del(int a) { if(a == head) head = c[a].nxt;//如果删除表头,记得更新表头!!! int p = c[a].pre, n = c[a].nxt; c[p].nxt = n; c[n].pre = p; } int n, m, cnt; bool d[N]; int main() {//剩下都是按题意模拟,我就不多说了 ios :: sync_with_stdio(0); cin.tie(0); cout.tie(0); cin >> n >> m; cnt = n; head = 1; for(int i = 1; i <= n; i++) { c[i].val = i; c[i].pre = i - 1; c[i].nxt = i + 1; } c[1].pre = 0; c[n].nxt = 0; for(int i = 1; i <= m; i++) { int op, x, y; cin >> op; if(op == 1) { cin >> x >> y; if(x == y) continue; del(x); insert_front(y, x); } else if(op == 2) { cin >> x >> y; if(x == y) continue; del(x); insert_back(y, x); } else { cin >> x; if(d[x]) continue; del(x); d[x] = true; } } if(head == 0) cout << "Empty!";//表头为0说明链表为空 for(int i = head; i != 0; i = c[i].nxt) cout << i << " "; return 0; } ``` :::: 给出一些链表的练习题目: 1. [【模板】双端队列 1](https://www.luogu.com.cn/problem/B3656) 2. [约瑟夫问题](https://www.luogu.com.cn/problem/P1996) ::::success[题解] ###【模板】双端队列 1 开 $10^6$ 个链表。由于这些链表的总结点数只有 $10^6$,所以用来存元素的内存池也只需要 $10^6$ 大小。 同时开 $10^6$ 个 $head$ 表示这些链表的开始,$10^6$ 个 $tail$ 表示这些链表的结束。然后就可以用链表愉快的两端操作了。同样,记得更新表头表尾。可以发现,链表的一个应用就是把很多的数压到一个内存池里。 ### 约瑟夫问题 这道题在之前的队列提到过,用链表同样可以做到 $\mathcal{O}(nm)$ 的复杂度。 容易发现报数的过程其实是一个相邻位置的遍历,出队其实是删除一个数,所以可以用链表来模拟。个人感觉用链表比用队列直观。 :::: # 4. 树 ## 4.1 树的定义和存储 树是什么? ~~一个 $n$ 个节点 $n-1$ 条边的连通图。~~ 其实树可以这么理解。 之前讲的链表,队列,栈都是线性的数据结构,也就是“一对一”,一个元素只和 $\mathcal{O}(1)$ 个元素相邻,而树是一个“一对多”的数据结构,也就是一个元素会和很多的元素相邻。同时,树有一个“从上到下”的关系,也就是每一个节点都可以看做只和一个“父亲”相连。 画成图大概就是这样的: ![](https://cdn.luogu.com.cn/upload/image_hosting/aot7lzpu.png) 接下来是树的一些定义: - 从上到下,这棵树有 $4$ 层,每一层的节点分别是 $a$,$be$,$cdfgi$,$h$。所以这一棵树的深度是 $4$。 - 如果一个节点被画在最“上面”,那么这个节点是这棵树的树根。比如上面的图中 $a$ 是树根。 - 假如在树上 $u$ 和 $v$ 之间有一条边,并且 $u$ 在 $v$ 的上面,那么我们叫 $u$ 是 $v$ 的父亲,$v$ 是 $u$ 的孩子。例如 $a$ 是 $b$ 的父亲,$b$ 是 $a$ 的孩子。一个节点可能有多个或没有孩子,但假如不是树根,都必定有一个父亲。 - 可以发现,拥有孩子最多的节点是 $e$,它有 $3$ 个孩子。一个节点的度就是它拥有的孩子的数量,例如 $e$ 的度为 $3$,$b$ 的度为 $2$。一棵树的度就是所有节点中度最大的节点的度,比如上面这一棵树的度为 $3$。 - 度为 $0$ 的节点叫做叶子结点。 - 如果两个节点有同一个父亲,比如 $b$ 和 $e$ 的父亲都是 $a$,那么称这个两个节点为“兄弟节点”。 然后,树这个东西该怎么写? 我们可以采用一种“邻接”的思想去存储这一棵树。对于每一个节点,存储与这个节点相邻的所有节点。那么,就有了这些不同的存储一个节点相邻的所有节点的方法: **一、邻接矩阵** 这种是最暴力,“硬开”的方法。就是对于每一个节点,开一个长度为 $n$ 的数组来存储与这个节点相邻的节点。可以发现,这种方法的空间复杂度是 $\mathcal{O}(n^2)$,适合存储 $n\le 1000$ 的树。 而且这种方法还有一个很明显的劣势:遍历一个节点相邻的所有节点的复杂度是 $\mathcal{O}(n)$ 的,因为它会挨着检查所有相邻的节点。 但是这种方法也不是没有优势,但是在树上体现不出来,在之后的图的存储有的时候会用到。 还是给出代码吧: ```cpp int t[][];//定义 t[x][y] = 1;//在(x, y)中加一条边 for(int i = 1; i <= n; i++) if(t[x][i]) {//遍历与x相邻的所有节点 } ``` **二、vector(邻接表)** 这个是我最常用的存树方法。由于 C++ 提供了动态扩容的容器 vector,所以这个方法写起来跟邻接矩阵一样简单。对于每一个节点,开一个 vector 来存储与这个节点相邻的所有节点。然后遍历的时候直接遍历这个 vector 即可。 先介绍一下 vector 吧。这应该是常数最小的 STL 容器了,是一个好东西。 ```cpp vector<int> vt;//这里的int换成其他类型也可以 vt.push_back(x);//向vt末端加入一个数x vt[i];//访问vt的第i个数 vt.at(i);//和vt[i]等价,只不过不能修改值 ``` 除此之外,vector 还有很多操作,但是这里的主角是树,所以就不介绍其他操作了。 邻接表的原理非常简单,我就不多说了,直接给出代码实现: ```cpp vector<int> t[N]; t[x].push_back(y);//在x和y中添加一条边 for(int i = 0; i < t[x].size(); i++) {//遍历于x相邻的所有节点,注意这里是用v来存储节点,i是数组下标 int v = t[x][i]; } ``` **三、链式向前星** 我们在之前对于链表的讲解里就提到过,链表可以把多个数组压到一个内存池里。 所以对于邻接表这种,需要存储多个数组,但是只有 $n-1$ 条边,总个数只有 $n-1$ 的东西,可不可以用链表压到一个内存池里呢? 肯定是可以的。我们使用一个这样的思路:如果一个节点要加入一条边,那么就把这条边插入到这个点的链头前面,更新链头。既然这个方法又用到了链表,又有“向前”的性质,所以肯定叫链式向前星啦~ 下面是代码实现。 链式向前星比 vector 空间小,但是常数大,适合卡空间的场景。 ```cpp struct node {//边内存池 int to, nxt; } nd[]; int head[], cnt;//每一条链表的链头和节点计数 void adde(int u, int v) { cnt++;//计数+1,开一个新节点 nd[cnt].to = v;//插入到链头 nd[cnt].nxt = head[u];//用链表记录下一条边 head[u] = cnt;//更新链头 } for(int i = head[x]; i; i = nd[i].nxt) {//遍历x的所有相邻节点 } ``` ## 4.2 树的遍历 知道了怎么存储这一棵树,是时候对这棵树进行一些操作了。 这一节介绍的就是最简单的操作——树的遍历。 用一道 [例题](https://www.luogu.com.cn/problem/P5908) 引入。 就像搜索一样,树的遍历也分为深度优先和广度优先。我们来演示一下这一棵树的遍历: ![](https://cdn.luogu.com.cn/upload/image_hosting/hlbkuzwl.png) **一、深度优先** 深度优先遍历是以下的思想:“一条路走到黑”,也就是不断往儿子上跳,直到没有儿子了,再回溯到父亲继续遍历其它儿子。 上面那棵树的深度优先遍历如下:(只是一种可能,孩子的访问顺序可以调换): - 访问根节点 $a$,接着访问它的所有孩子。 - 访问节点 $b$,访问它的所有孩子。 - 访问节点 $d$,访问它的所有孩子。 - $d$ 没有孩子,$d$ 节点的子树访问结束。 - 访问节点 $e$,访问它的所有孩子。 - $e$ 没有孩子,$e$ 节点的子树访问结束。 - 访问节点 $f$,访问它的所有孩子。 - $f$ 没有孩子,$f$ 节点的子树访问结束。 - $b$ 没有更多的孩子了,$b$ 子树的访问结束。 - 访问节点 $c$,访问它的所有孩子。 - $c$ 没有孩子,$c$ 子树的访问结束。 - $a$ 没有更多的孩子了,$a$ 子树的访问结束。 于是我们得到了这一棵树的一个深度优先遍历的顺序,得到的序列是 $abdefc$。同样的,例如 $acbfed$ 也是一个合法的遍历顺序。 树的深度优先遍历本质上就是一个 dfs,所以它也是用 dfs 实现的。特别需要注意的是在遍历的时候要记得记录父节点,防止重复访问父节点。 下面是一棵树的深度优先遍历实现(vector 存树,之后的代码都使用 vector 存树): ```cpp vector<int> t[]; void dfs(int x, int fa) { //这里可以记录一些信息 for(int i = 0; i < t[x].size(); i++) {//遍历这个节点的所有孩子 int v = t[x][i]; if(v != fa) dfs(v, x);//记得一定要判断是不是父节点 } } ``` 那么例题就很好做了。利用记录的 $fa$ 节点,可以很方便的更新节点的距离,这个节点的距离就是父节点的距离加 $1$。 例题的完整实现如下: ::::success[代码] ```cpp #include <bits/stdc++.h> #define endl '\n' using namespace std; const int N = 1e5 + 10; vector<int> t[N]; int n, d, dis[N], ans = 0; void dfs(int x, int fa) { if(x != 1) dis[x] = dis[fa] + 1; for(int i = 0; i < t[x].size(); i++) { int v = t[x][i]; if(v != fa) dfs(v, x); } } int main() { ios :: sync_with_stdio(0); cin.tie(0); cout.tie(0); cin >> n >> d; for(int i = 1; i < n; i++) {//记得树只有n-1条边,千万不要写成<=n! int u, v; cin >> u >> v; t[u].push_back(v); t[v].push_back(u); } dfs(1, 0); for(int i = 2; i <= n; i++) { if(dis[i] <= d) ans++; } cout << ans << endl; return 0; } ``` :::: **二、广度优先** 还是看到上面那棵用来演示遍历的树。 广度优先的思想如下:“同一层的一起遍历”,就是从上到下一层一层遍历。 比如上面那棵树的广度优先遍历就是这样的: - 从根节点 $a$ 开始,把 $a$ 加入第一层。 - 访问第一层的所有节点。 - 访问到了 $a$,把它所有的孩子加入第二层,第二层现在有 $b, c$。 - 第一层没有节点了,开始访问第二层。 - 访问第二层的所有节点。 - 访问到了 $b$,把它所有的孩子加入第三层,第三层现在又 $d, e, f$。 - 访问到了 $c$,由于没有孩子,所以不加入第三层节点。 - 访问第三层的所有节点。 - 依次访问到 $d, e, f$,由于都没有孩子所以不继续访问第四层。 所以这棵树的一个广度优先遍历序列是 $abcdef$。这个序列同样不唯一,比如 $acbfed$ 也是这棵树的一个广度优先遍历序列。 广度优先遍历的本质是 bfs,因此我们用一个队列来模拟 bfs。如果你没有学过 bfs,那么下面的代码可以不看,因为树上的广度优先遍历很少见,基本都可以被深度优先遍历替代。 给出广度优先遍历的参考代码: ```cpp int fa[]; queue<int> q; void bfs(int x) {//以x为根开始遍历 q.push(x); while(!q.empty()) { int f = q.front();//取队首 q.pop();//节点出队 //这里可以记录一些信息 for(int i = 0; i < t[f].size(); i++) {//遍历这个节点的所有孩子 int v = t[f][i]; if(fa[f] != v) {//还是要记得判断父亲 fa[v] = f;//记录父亲 q.push(v);//节点入队 } } } } ``` 那么也可以很简单的完成例题了: ::::success[代码] ```cpp #include <bits/stdc++.h> #define endl '\n' using namespace std; const int N = 1e5 + 10; vector<int> t[N]; int n, d, dis[N], fa[N], ans = 0; queue<int> q; void bfs(int x) { q.push(x); while(!q.empty()) { int f = q.front(); q.pop(); if(f != 1) dis[f] = dis[fa[f]] + 1; for(int i = 0; i < t[f].size(); i++) { int v = t[f][i]; if(fa[f] != v) { fa[v] = f; q.push(v); } } } } int main() { ios :: sync_with_stdio(0); cin.tie(0); cout.tie(0); cin >> n >> d; for(int i = 1; i < n; i++) { int u, v; cin >> u >> v; t[u].push_back(v); t[v].push_back(u); } bfs(1); for(int i = 2; i <= n; i++) { if(dis[i] <= d) ans++; } cout << ans << endl; return 0; } ``` :::: 下面给出一些树的遍历的练习题目: 1. [道路修建](https://www.luogu.com.cn/problem/P2052) 2. [医院设置](https://www.luogu.com.cn/problem/P1364) 3. [二叉树问题](https://www.luogu.com.cn/problem/P3884) ::::success[题解] ### 道路修建 看到这个题目,我们有一个很简单的想法,就是对于每一条边暴力找出左右两边的子树的节点个数,然后直接统计即可。但是一看到 $n = 10^6$,我们就知道这样肯定 TLE,$\mathcal{O}(n^2)$ 的时间复杂度是没办法接受的。 那我们想想该怎么高效计算一条边两边的节点数。首先,一条边的两边肯定是存在父子关系的,这一点从树的定义中可以很容易的看出。 于是我们可以“钦定”一个节点为根,算出所有的点的父亲和子树大小。然后一条边,我们取到这个儿子节点,有一边的大小肯定是这个儿子节点的子树的大小。 那另一边呢?因为整棵树只有 $n$ 个节点,而一个节点必定在某一边,所以另一边的节点就是 $n$ 减去这个子树的大小。 于是就很好做了。这道题好像有一点换根 dp 的思想了。 ### 医院设置 首先,你可以暴力求出两边的节点的深度,然后用每一个节点的点权乘上这个节点的深度就可以暴力求出来医院设置在每一个点上的走路距离之和。 然后取一个最大值即可。时间复杂度 $\mathcal{O}(n^2)$,对于这道题的 $n\le 100$ 的数据范围,可以通过。 这道题也有 $\mathcal{O}(n)$ 的换根 dp 做法,但是有一点复杂了,我在这里就不展开了。 ### 二叉树问题 这道题是一个比较综合的树的遍历问题。 首先,树的深度和树的宽度非常的好求,树的深度统计所有节点的深度取最大即可,树的宽度统计每一层的节点个数取最大即可,可以用一个桶数组来做。 然后问题就来到了怎么计算 $u, v$ 之间的距离。 这里有一点涉及 LCA(也就是树上最近公共祖先,两点之间最短路径上面的那个拐点),但是可以暴力跳所以应该不超纲。 暴力跳 LCA 的求法如下: - 预处理出每一个节点的深度。 - 求解 LCA 的时候,首先把深度大的那个节点往上跳,直到两个节点的深度相同。 - 然后把两个节点同时往上跳,直到两个节点相同,这个节点就是两个节点的 LCA。 然后两个节点的最短路径该怎么算?明显是 LCA 到 $u$ 的距离乘 $2$ 加上 LCA 到 $v$ 的距离。代码里的公式是这个式子拆开之后的结果。 由于这道题的代码比较难写,而且涉及 LCA 这个偏 CSP-S 的知识点,所以我给出了代码(远古代码,写的有一点丑,见谅)。 :::success[代码] ```cpp #include <bits/stdc++.h> using namespace std; const int N = 1e2 + 10; int n, a, b, dep[N], fa[N], buc[N]; vector<int> v[N]; bool vis[N]; void dfs(int x, int depth) { vis[x] = true; dep[x] = depth; for(int i = 0; i < v[x].size(); i++) { if(!vis[v[x][i]]) { fa[v[x][i]] = x; vis[v[x][i]] = true; dfs(v[x][i], depth + 1); } } } int lca(int a, int b) { while(dep[a] > dep[b]) a = fa[a]; while(dep[b] > dep[a]) b = fa[b]; while(a != b) { a = fa[a]; b = fa[b]; } return a; } int main() { ios :: sync_with_stdio(0); cin.tie(0); cout.tie(0); cin >> n; for(int i = 1; i <= n - 1; i++) { cin >> a >> b; v[a].push_back(b); v[b].push_back(a); } dfs(1, 1); int d = 0, w = 0; for(int i = 1; i <= n; i++) { d = max(d, dep[i]); buc[dep[i]]++; } for(int i = 1; i <= n; i++) w = max(w, buc[i]); cout << d << endl << w << endl; cin >> a >> b; int l = lca(a, b); cout << dep[a] * 2 + dep[b] - 3 * dep[l] << endl; return 0; } ``` ::: :::: ## 4.3 树的直径和重心 **一、定义&性质** 首先给出一个定义: - 树的直径:树上最长的那条路径。 - 树的重心:删除树的重心之后,使得树剩下的所有块中最大的块最小的节点。 听了这两句话,你可能不太明白这是什么,所以我还是来用图讲一下吧。 依旧是上面用来演示遍历的图: ![](https://cdn.luogu.com.cn/upload/image_hosting/hlbkuzwl.png) 对于这棵树,重心是 $b$ 点,直径有很多条,一条可能的直径是 $d\rightarrow b\rightarrow a\rightarrow c$。 那么这些东西有什么用呢? 树的重心和树的直径有很多很重要的性质!比如下面是一部分: - 对于树的直径: - 树上任何节点到最远端点的一条路径,必定经过直径的一个端点。 - 树的直径必然有重合。比如上面这棵树,所有的直径都重合了 $b\rightarrow a\rightarrow c$ 这一段。 - 对于树的重心: - 一棵树最多有 $2$ 个重心,也可以有 $1$ 个重心。 - 删除树的重心之后,最大的一个块的大小不超过树原来节点个数的 $\dfrac{1}{2}$。这一点可以反证,如果这个点是重心,删除这个点之后有一个子树的大小大于原本的 $\dfrac{1}{2}$,那么选择那个大的子树的根作为重心肯定比原来更优,故“原来的节点是重心”不成立。 - 树的重心到所有节点的距离之和最小。 **二、直径的求法** 用 [这道题](https://www.luogu.com.cn/problem/B4016) 作为例题。 直径有两种求法:做两次 dfs 或者树形 dp。由于这里是 CSP-J,所以我只讲两次 dfs 的求法。 具体是这样的。在之前讲的直径的性质里面,有这么一条性质: > 树上任何节点到最远端点的一条路径,必定经过直径的一个端点。 换言之,就是离树上某个点最远的点肯定是直径的端点。 因此我们可以做两次 dfs。第一次随便找一个点(比如 $1$)开始 dfs,算出每个点到这个点的距离。然后距离最长的肯定是一个端点。 接着,从这个端点开始再做一次 dfs,找到所有点到这个点的距离。由于这个点肯定是一个端点,找到的这个距离的最大值对应的点也是一个端点,所以这肯定是一条直径。长度就是这个距离。 下面给出核心代码: ```cpp vector<int> t[]; int dis[]; void dfs(int x, int fa) {//dfs,求每个节点的距离 if(fa == 0) dis[x] = 0; else dis[x] = dis[fa] + 1; for(int i = 0; i < t[x].size(); i++) { int v = t[x][i]; if(v != fa) dfs(v, x); } } int get_d() { dfs(1, 0);//第一次 dfs,从 1 开始,找出所有距离 int mx = 0; for(int i = 1; i <= n; i++) { if(dis[i] > dis[mx]) mx = i;//取距离最大的节点作为端点 } dfs(mx, 0);//第二次 dfs,从端点开始,找出一条直径 mx = 0;//然后对所有距离取一个最大值,就是直径长度了 for(int i = 1; i <= n; i++) mx = max(mx, dis[i]); return mx; } ``` 于是模板题就很好做了,下面给出代码: ::::success[代码] ```cpp #include <bits/stdc++.h> using namespace std; const int N = 1e5 + 10; int n; vector<int> t[N]; int dis[N]; void dfs(int x, int fa) { if(fa == 0) dis[x] = 0; else dis[x] = dis[fa] + 1; for(int i = 0; i < t[x].size(); i++) { int v = t[x][i]; if(v != fa) dfs(v, x); } } int get_d() { dfs(1, 0); int mx = 0; for(int i = 1; i <= n; i++) { if(dis[i] > dis[mx]) mx = i; } dfs(mx, 0); mx = 0; for(int i = 1; i <= n; i++) mx = max(mx, dis[i]); return mx; } int main() { ios :: sync_with_stdio(0); cin.tie(0); cout.tie(0); cin >> n; for(int i = 1; i < n; i++) { int u, v; cin >> u >> v; t[u].push_back(v); t[v].push_back(u); } cout << get_d() << endl; return 0; } ``` :::: **三、重心的求法** [这道题](https://www.luogu.com.cn/problem/P1395) 是模板题。 一看,它没叫你求重心啊? 等等,树的重心有一个性质: > 树的重心到所有节点的距离之和最小。 因此我们会议地点就是在树的重心…… 所以,怎么求重心呢?根据重心的定义:删除树的重心之后,使得树剩下的所有块中最大的块最小的节点,我们就可以求了。 还是跟道路修建那道题有一点类似的思想。在一棵树上,首先做一遍 dfs 求出子树大小,以及和这个节点的孩子的子树的最大大小。然后我们就可以枚举节点,把这个节点删除之后的最大的块就很好算了。 对于不在子树内的一个块,就是整棵树的大小减去这个子树的大小;在一个子树内的块的最大大小就是这个节点的孩子的最大大小。 所以我们可以一遍 dfs 算出所有子树的大小和以这个节点的孩子为根的所有子树的最大大小,然后枚举一遍节点就可以了。 下面给出核心代码: ```cpp int siz[], msiz[];//子树大小,孩子的最大子树大小 vector<int> t[]; void dfs(int x, int fa) {//dfs求siz和msiz数组 siz[x] = 1; for(int i = 0; i < t[x].size(); i++) { int v = t[x][i]; if(v != fa) { dfs(v, x); msiz[x] = max(msiz[x], siz[v]);//更新最大子树大小 siz[x] += siz[v];//记得累计孩子的子树大小! } } } int get_wc() { dfs(1, 0); int wc = 0, wsiz = inf; for(int i = 1; i <= n; i++) {//枚举节点计算最大块大小 int s = max(n - siz[i], msiz[i]);//这个公式前文已经提到过了 if(s < wsiz) {//更新重心 wsiz = s; wc = i; } } return wc; } ``` 这是模板题的完整代码: ::::success[代码] ```cpp #include <bits/stdc++.h> #define endl '\n' using namespace std; const int N = 5e4 + 10; const int inf = 0x3f3f3f3f; int n, dis[N], siz[N], msiz[N]; vector<int> t[N]; void dfs1(int x, int fa) { siz[x] = 1; for(int i = 0; i < t[x].size(); i++) { int v = t[x][i]; if(v != fa) { dfs1(v, x); msiz[x] = max(msiz[x], siz[v]); siz[x] += siz[v]; } } } int get_wc() { dfs1(1, 0); int wc = 0, wsiz = inf; for(int i = 1; i <= n; i++) { int s = max(n - siz[i], msiz[i]); if(s < wsiz) { wsiz = s; wc = i; } } return wc; } void dfs2(int x, int fa) { if(fa != 0) dis[x] = dis[fa] + 1; for(int i = 0; i < t[x].size(); i++) { int v = t[x][i]; if(v != fa) dfs2(v, x); } } int main() { ios :: sync_with_stdio(0); cin.tie(0); cout.tie(0); cin >> n; for(int i = 1; i < n; i++) { int u, v; cin >> u >> v; t[u].push_back(v); t[v].push_back(u); } int w = get_wc(); dfs2(w, 0); int ans = 0; for(int i = 1; i <= n; i++) ans += dis[i]; cout << w << " " << ans << endl; return 0; } ``` :::: 最后给出树的直径和重心的例题: 1. [Tree Cutting S](https://www.luogu.com.cn/problem/P1670) 2. [最远点对](https://www.luogu.com.cn/problem/P10725) ::::success[题解] ### Tree Cutting S 这道题也是树的重心模板题。我们采用和求重心一样的方法,把每一个节点删去之后的最大子网的大小算出来,然后就可以直接遍历求解了。 不知道为什么是绿题。 ### 最远点对 树的直径的拓展题(加强版?)。需要在树上做 $3$ 次 dfs。 第一次 dfs,随便找一个节点为根,求出黑色节点到这个节点的最大距离,以及白色节点到这个节点的最大距离。 第二次 dfs,从白色的最远点开始,算出所有节点到这个点的距离,然后取一个最大值,注意另一个端点必须是黑色。第三次 dfs 和第二次比较类似,从黑色的最远点开始统计所有白色节点到它的最大距离就可以了。 然后答案就是这两个距离取一个最大值。这个的证明和树的直径的求法的正确性证明比较类似。 :::: ## 4.4 二叉树和霍夫曼树 由于二叉树的一些题目在“树的遍历”章节已经提到过,所以本章节不会给出一些除了介绍概念以外的东西。 **一、二叉树** 二叉树是什么? 就是每一个节点最多有 $2$ 个孩子的树。 二叉树具有以下的性质: - 一棵二叉树的叶子节点个数是度为 $2$ 的节点个数加 $1$。 - 二叉树第 $i$ 层最多有 $2^{i - 1}$ 个节点。 - 深度为 $d$ 的二叉树最多有 $2^d - 1$ 个节点。 看来是一个优美的树形结构。这种东西现在在 CSP-J 还没有太大用处,到了 CSP-S 会大发神威。 二叉树该如何存储?其实对于每一个节点开一个结构体存储左右孩子即可,比一般的树的存储简单不少。 作为一棵特殊的树,二叉树肯定也有其特殊的遍历方式。二叉树的遍历分为前序遍历,中序遍历和后序遍历。 为什么呢?二叉树每一个节点都有左孩子(左),自己(中)和右孩子(右)。那么就会衍生出以下的 $6$ 中遍历:左中右,左右中,中左右,中右左,右左中,右中左。 如果我们规定右孩子的遍历必须在左孩子遍历之后,那么还剩下三种遍历:中左右(前序遍历)、左中右(中序遍历)、左右中(后序遍历)。 那么具体应该怎么实现这几种遍历呢?这需要对递归有一定的理解。 首先先给出结论。 - 前序遍历:先输出自身,然后遍历左子树,最后遍历右子树。 - 中序遍历:先遍历左子树,然后输出自身,最后遍历右子树。 - 后序遍历:先遍历左子树,然后遍历右子树,最后输出自身。 为什么呢?递归的性质决定了,在遍历一个子树之后,这个子树以内的所有节点都被输出了。我们拿中序遍历举一个例子:遍历了左子树,就保证了左子树的所有节点都在自身之前输出,最后遍历右子树就保证了右子树的所有节点在自身之后输出,也就满足了终须遍历。对于其他遍历类似。 用 [这道例题](https://www.luogu.com.cn/problem/B3642) 给出代码。 ::::success[代码] ```cpp #include<bits/stdc++.h> #define endl '\n' using namespace std; const int N = 1e6 + 10; struct node { int lc, rc; } t[N]; int n; void dfs1(int u) { cout << u << " "; if(t[u].lc) dfs1(t[u].lc); if(t[u].rc) dfs1(t[u].rc); } void dfs2(int u) { if(t[u].lc) dfs2(t[u].lc); cout << u << " "; if(t[u].rc) dfs2(t[u].rc); } void dfs3(int u) { if(t[u].lc) dfs3(t[u].lc); if(t[u].rc) dfs3(t[u].rc); cout << u << " "; } int main() { ios :: sync_with_stdio(0); cin.tie(0); cout.tie(0); cin >> n; for(int i = 1; i <= n; i++) cin >> t[i].lc >> t[i].rc; dfs1(1); cout << endl; dfs2(1); cout << endl; dfs3(1); return 0; } ``` :::: 二叉树有一些特殊的形态,如下: - 满二叉树:每一层都有 $2^{i - 1}$ 个节点的二叉树。 - 完全二叉树:除了最后一层外,每一层都有 $2^{i - 1}$ 个节点的二叉树。 对于这些很“满”的二叉树,我们也有一种特殊的存储方式:数组表示法。对于一个静态数组,如果一个二叉树的一个节点的编号为 $x$,那么它的左孩子的编号为 $2x$,右孩子的编号为 $2x + 1$。 对于满二叉树和完全二叉树,容易发现,如果里面有 $n$ 个节点,那么这棵树的高度为 $\log_2 n$,这是一个增长很缓慢的数,也是很多高级数据结构之所以高效的原因。之后我们手写堆的时候就需要用到这个东西,但是现在还没有什么用。 **二、霍夫曼树** 假如你有一个字符串 $S = aaaaaaaaaabbbbbcccdd$,那么你会如何存储这个字符串?由于是计算机,所以你只能用 $01$ 来表示每一个字符。 你的表示方法必须要没有歧义。比如说,假如你取 $a=0, b=1, c=01, d=10$,那么这种表示是错误的。比如,给你一个 $01$,你怎么判断这应该是 $c$ 还是 $ab$? 如果你使用一种朴素的编码方式,那么空间肯定不是最优的。例如在这里,有 $4$ 个字符,我们取 $a=00, b=01, c=10, d=11$,那么每个字符都将占用 $2$ 个字节,字符串长度为 $20$,所以你需要 $40$ 字节的存储空间。~~好像记事本就是这么存的。~~ 但是我们的电脑上有压缩软件,它可以把一个比较大的文本文档压缩掉很多空间,这是什么原理呢?就是霍夫曼树与霍夫曼编码。当然压缩软件是用了更多的压缩方式的,但是这里只讨论霍夫曼树。 霍夫曼树是一棵二叉树。它是针对于一个“频率”构建的,具体构建如下: 反复执行直到所有的节点被合并为一个节点。 - 取频率最低的两个节点。 - 新建一个节点,作为频率最低的两个节点的父亲,这个节点的频率为两个节点的频率之和。 - 然后把这个新建的节点加入,之前的两个节点弹出。 比如说,上面的字符串有 $a = 10, b = 5, c = 3, d = 2$,那么它建出来霍夫曼树的过程如下: ![](https://cdn.luogu.com.cn/upload/image_hosting/ixdt5j84.png) ![](https://cdn.luogu.com.cn/upload/image_hosting/vosta545.png) ![](https://cdn.luogu.com.cn/upload/image_hosting/co9b68gi.png) ![](https://cdn.luogu.com.cn/upload/image_hosting/1c48zy1r.png) 于是,这一棵霍夫曼树就建完了。 那每一个点所对应的二进制编码是多少呢? 我们可以将一条边抽象成一个数字,如果是到左孩子的抽象为 $0$,到右孩子的抽象为 $1$。所以,上面这一棵霍夫曼树所对应的编码是 $a = 0, b = 10, c = 110, d = 111$。哎,好像 $c$ 和 $d$ 的编码变长了?你自己仔细算一算压缩后的长度:$10\times 1 + 5\times 2 + 3\times 3 + 3\times 2 = 35$,比之前的 $40$ 要少 $5$ 字节! 霍夫曼树生成的编码不会冲突,因为所有的编码之间没有前缀包含关系。然后这一棵树所生成的编码必定是最优的。 这玩意只有初赛才考,复赛我在 CSP-S 的考纲里都没找到,所以没有例题。 # 5. 图 ## 5.1 图的定义、类型和存储 图是什么? ~~是由点集和边集所组成的集合,即 $G = \{V,E\} $。~~ 图与之前我们学过的树和线性数据结构不同,它是一个“多对多”的关系。 即给出 $n$ 个节点和 $m$ 条边,每一条边连接两个节点,组成的一个数据结构。 比如下面就是一个图: ![](https://cdn.luogu.com.cn/upload/image_hosting/ulsnadib.png) 当然,图的世界多种多样,图大概可以分为以下几种类型,每一种类型我都画了图。 ![](https://cdn.luogu.com.cn/upload/image_hosting/fsodx023.png) 对比图一和图二,我们发现了图一的每一条边上都多了一个数字,这一个数字叫做边权,而图二没有边权,所以图可以分为两种类型: 1. 带权图。 2. 无权图。 对比图二和图三,四,我们可以发现图三,四的边上都带有一个箭头,这个箭头表示的是边的方向。而图二的边没有方向,就是从两个方向都可以走。于是图又可以分为以下两种类型: 1. 有向图。 2. 无向图。 图四所对应的无向图没有“环”,也就是可以顺着有向边一直打转的地方,这种无向图被称为 DAG(Directed Acylic Graph,有向无环图)。DAG 有一些美妙的算法,这个会在后面的拓扑排序章节讲到。 然后,对于图,也有一些特殊的定义: - 前驱节点。假如在有向图中 $u\rightarrow v$ 有一条边,那么我们称 $u$ 为 $v$ 的前驱。 - 后继节点。假如在有向图中 $u\rightarrow v$ 有一条边,那么我们称 $v$ 为 $u$ 的后继。 - 连通块。对于一个无向图,如果某一些节点可以两两互相到达,那么我们称这些节点为联通分量。一个图的极大连通分量,也就是再任意加入一个点都会不满足连通分量的性质的连通分量,被称作连通块。 - 入度。在有向图中,有多少条边指向这个节点,这个节点的入度就为多少。 - 出度。在有向图中,这个节点可以通过一条边到达多少个节点,这个节点的出度就为多少。 另外还有很多的定义,但是那是 CSP-S 的内容了,在这里不展开。 图的存储其实和树的存储非常类似,也是用一个“邻接”思想来存图,存储每一个节点可以通过一条边到达的所有节点。代码由于和树非常类似就不放了。 ## 5.2 图的遍历 图的遍历比树的遍历要复杂一些。 由于图中并不是唯一的父子关系,所以为了避免回溯到之前已经访问过了的节点,我们需要一个 $vis$ 数组来记录这一个节点是否访问过。 图的遍历同样分为深度优先遍历和广度优先遍历。 还是先给出一道 [例题](https://www.luogu.com.cn/problem/P5318)。 这道题简直是把图的遍历写在脸上了,要求我们输出这个图最小的 dfs 序和 bfs 序。由于要求输出的顺序最小,所以我们需要对每一个节点的邻接节点先按小到大排序。 我们来演示一下这个图的遍历(从 $a$ 开始,假设邻接表已排序)。 ![](https://cdn.luogu.com.cn/upload/image_hosting/3atmwk55.png) 对于深度优先遍历: - 访问节点 $a$,遍历它的所有邻接节点。 - 遍历到 $b$,访问节点 $b$,遍历它的所有邻接节点。 - 遍历到 $c$,访问节点 $c$,遍历它的所有邻接节点。 - 遍历到 $a$,已经被访问过了。 - $c$ 节点的所有邻接节点遍历结束。 - $b$ 节点的所有邻接节点遍历结束。 - 遍历到 $d$,遍历它的所有邻接节点。 - 遍历到 $c$,已经被访问过了。 - $d$ 节点的所有邻接节点遍历结束。 - 遍历到 $e$,访问它的所有邻接节点。 - 遍历到 $d$,已经被访问过了。 - $e$ 节点的所有邻接节点遍历结束。 - $a$ 节点的所有邻接节点遍历结束。 所以这个图一种可能的 dfs 序就是 $abcde$。这个 dfs 序也不是唯一的。这还是一个非常经典的 dfs,可以使用 dfs 来实现。现在给出示例代码: ```cpp vector<int> g[];//邻接表存图 bool vis[];//标记数组,表示是否访问过 void dfs(int u) { vis[u] = true;//标记节点为已访问 for(int i = 0; i < g[u].size(); i++) {//遍历邻接节点 int v = g[u][i]; if(!vis[v]) dfs(v);//如果未访问则遍历该节点 } } ``` 对于广度优先遍历: - 将 $a$ 加入第一层节点。 - 遍历第一层的所有节点。 - 遍历到 $a$,访问它的所有邻接节点 $b, d, e$,将其加入第二层。 - 第一层遍历结束。 - 遍历第二层的所有节点。 - 遍历到 $b$,访问它的所有邻接节点 $c$,将其加入第三层。 - 遍历到 $d$,访问它的所有邻接节点 $c$,发现已经被访问了,不加入第三层。 - 遍历到 $e$,访问它的所有邻接节点 $d$,发现已经被访问了,不加入第三层。 - 第二层遍历结束。 - 遍历第三层的所有节点。 - 遍历到 $c$,访问它的所有邻接节点 $a$,发现已经被访问了,不加入第四层。 - 第三层遍历结束。 所以这个图一个可能的 bfs 序就是 $abdec$。这个顺序同样不是唯一的。可以发现这也是一个经典的 bfs,所以可以用 bfs 来实现。示例代码如下: ```cpp queue<int> q; void bfs(int u) { q.push(u); vis[u] = true;//这里不要忘记给u打访问标记 while(!q.empty()) { int f = q.front();//取队首 q.pop(); for(int i = 0; i < g[f].size(); i++) {//遍历邻接节点 int v = g[f][i]; if(!vis[v]) { q.push(v);//如果没有被遍历,则加入队列 vis[v] = true;//打访问标记 } } } } ``` 于是之前的那道题就很好实现了。 下面给出示例代码: ::::success[代码] ```cpp #include<bits/stdc++.h> #define endl '\n' using namespace std; const int N = 1e5 + 10; vector<int> g[N]; int n, m; bool vis[N]; void dfs(int u) { vis[u] = true; cout << u << " "; for(int i = 0; i < g[u].size(); i++) { int v = g[u][i]; if(!vis[v]) dfs(v); } } queue<int> q; void bfs(int u) { q.push(u); vis[u] = true; while(!q.empty()) { int f = q.front(); q.pop(); cout << f << " "; for(int i = 0; i < g[f].size(); i++) { int v = g[f][i]; if(!vis[v]) { q.push(v); vis[v] = true; } } } } int main() { ios :: sync_with_stdio(0); cin.tie(0); cout.tie(0); cin >> n >> m; for(int i = 1; i <= m; i++) { int u, v; cin >> u >> v; g[u].push_back(v); } for(int i = 1; i <= n; i++) sort(g[i].begin(), g[i].end()); dfs(1); cout << endl; memset(vis, false, sizeof vis); bfs(1); return 0; } ``` :::: 下面给出一些图的遍历的练习题: 1. [图的遍历](https://www.luogu.com.cn/problem/P3916) 2. [Milk Factory B](https://www.luogu.com.cn/problem/P1700) ::::success[题解] ### 图的遍历 正难则反。与其统计每一个顶点可以到达的编号最大的点,不如建反图跑出最大的点可以到达哪些点。 于是从大到小枚举出发点。需要注意的是,如果枚举到了一个点,这个点被已经遍历了,那么就证明有一个更大的点可以走到这个位置,进一步可以推出有一个更大的点可以走到这个点可以走到的所有位置,所以不用拿这个点再去访问一遍。 由于每一个节点至多被访问 $1$ 次,所以时间复杂度 $\mathcal{O}(n)$。 ### Milk Factory B 这一道题比上一道题简单。 注意到 $n\leq 100$,这个数据范围很小,所以可以枚举每一个点,从这个点开始遍历,访问到的点都把计数加 $1$。 最后计数是 $n$ 的节点就可以成为加工站。 :::: ## 5.3 拓扑排序 拓扑排序是一种针对 DAG 的算法。为什么只能针对 DAG?你看了它的思想之后就知道了。 拓扑排序解决的是这么一个问题:给定一个 DAG,求一个序列,使得一个节点的所有前驱都在这个节点的前面。这个序列被称为这个图的拓扑序。 从这一点我们也可以推出来这一个性质:在拓扑序中,每一个节点的所有后继都在这个节点后面。 拓扑排序的应用很广,从黄题到黑题都可以见到它的身影。 让我们从问题出发,逐步推出拓扑排序的核心逻辑吧。 首先,对于一个 DAG,必然会有没有入度的节点,否则图必定会存在环。所以我们可以考虑先把所有没有入度的节点全部跑出来,作为拓扑排序的开始。(这一点就是为什么拓扑排序不能在有环图上跑) 接着,对于那些有入度的节点该怎么办呢?拓扑排序采用的是“删节点”的思想。在已有的拓扑序中,我们一个一个节点的遍历,然后“删除”这个节点(其实就是把这个节点的后继节点的入度全部减 $1$),然后看看有没有被删到入度为 $0$ 的节点,如果有则把这个节点加入拓扑序。 为什么这么做是正确的?因为假如一个节点的入度为 $0$ 了,那么它的所有前驱都已经被删掉了,所以它的所有前驱必定在拓扑序中在它的前面。因此得到的序列满足拓扑序的性质。 指到了拓扑排序的思想,我们就可以做 [模板题](https://www.luogu.com.cn/problem/B3644) 了。 这道题也是把拓扑排序写在了脸上,要求输出的不就是这个图的拓扑序吗?于是就很好写代码了。 先给出拓扑排序的核心代码(注意,由于图是 DAG,所以不需要用 $vis$ 数组来避免回溯): ```cpp int in[], top[], t; queue<int> q; void top_sort() { for(int i = 1; i <= n; i++) {//将所有入度为0的点入队 if(in[i] == 0) q.push(i); } while(!q.empty()) { int f = q.front();//取队首 q.pop(); top[++ t] = f;//记录拓扑序 for(int i = 0; i < g[f].size(); i++) { int v = g[f][i]; in[v] --;//删除这个节点之后的入度 if(in[v] == 0) q.push(v);//如果入度为0就入队 } } } ``` 于是这道题的完整代码也很好写了: ::::success[代码] ```cpp #include<bits/stdc++.h> #define endl '\n' using namespace std; const int N = 1e2 + 10; int n; vector<int> g[N]; int in[N], top[N], t; queue<int> q; void top_sort() { for(int i = 1; i <= n; i++) { if(in[i] == 0) q.push(i); } while(!q.empty()) { int f = q.front(); q.pop(); top[++ t] = f; for(int i = 0; i < g[f].size(); i++) { int v = g[f][i]; in[v] --; if(in[v] == 0) q.push(v); } } } int main() { ios :: sync_with_stdio(0); cin.tie(0); cout.tie(0); cin >> n; for(int i = 1; i <= n; i++) { int k; cin >> k; while(k != 0) { g[i].push_back(k); in[k] ++; cin >> k; } } top_sort(); for(int i = 1; i <= n; i++) cout << top[i] << " "; return 0; } ``` :::: 最后给出一些联系题目。拓扑排序经常和 dp 结合,因为其节点前驱都在这个节点前面的性质,让一个节点从所有前驱转移成为可能。 1. [最大食物链计数](https://www.luogu.com.cn/problem/P4017) 2. [食物链](https://www.luogu.com.cn/problem/P3183) 3. [车站分级](https://www.luogu.com.cn/problem/P1983) ::::success[题解] ### 最大食物链计数 拓扑排序和 dp 结合,可以解决很多问题,这道题基本上是一个模板题。 首先,我们考虑,如果一个节点的前驱为终点的食物链条数都已经确定,那么我们该如何处理?这一个节点为终点的食物链条数显然是这个节点的所有前驱为终点的食物链条数之和,因为可以通过之前的任何一条食物链转移到这个节点为终点上。形式化地,我们可以得出这样的一个方程,其中 $pre(i)$ 表示 $i$ 的所有前驱: $$dp_i = \sum_{j \in pre(i)} dp_j$$ 按照拓扑序转移即可。然后统计没有后继节点的节点的 dp 值之和就做完了。 好像我在生物考试中数食物链就是这么数的,数的又快又准,给我的同桌看的一脸懵逼。 ### 食物链 没什么,单纯想放一个双倍经验来让大家多 AC 一道题。其实和最大食物链计数一模一样。 ### 车站分级 真正的 CSP-J T4!(好像是 NOIP 普及组) 这一道题的主要难点在图的建模上。 主要是观察到这么一句话:如果这趟车次停靠了火车站 $x$,则始发站、终点站之间所有级别大于等于火车站 $x$ 的都必须停靠。 换言之就是在始发站、终点站之间所有没有被停靠的车站的优先级都小于停靠的优先级。 于是就可以抽象成一个图论问题了。在一个图中,如果有一条 $u\rightarrow v$ 的边,就表示 $u$ 的优先级必定比 $v$ 的优先级高。由于顶点之间需要两两建边,意思是一次可能会建出 $\mathcal{O}(n ^ 2)$ 条边,那么总边数来到了 $\mathcal{O}(n ^ 3)$,非常大,用邻接表会爆空间,所以采用邻接矩阵。~~这好像也是少数用邻接矩阵存图的题了。~~ 为了满足这个结论,我们必须找到这个图中的最长路。其实最长路和最大食物链计数的转移方程十分相似,就只改了以下后面的部分,核心的思路还是依赖拓扑序的前驱节点。一个节点为终点的最长路的长度肯定是它所有前驱节点为终点的最长路的长度加 $1$,写成方程就是这样的: $$dp_i = \max_{j \in pre(i)} dp_j + 1$$ 答案是图上的最长路,也就是对于所有的 dp 值取一个最大值。 为什么?由于必须满足所有的约束,所以最长路上的所有约束也必须被满足,那么最优情况就是最长路上的所有点都比其前驱的优先级大 $1$,即最长路长度。 于是时间复杂度 $\mathcal{O}(n ^ 3)$。哎,不对啊,$\mathcal{O}(n ^ 3)$ 对于 $n = 1000$ 过得了吗?可以的,你要相信洛谷神机的实力。 算了,我还是来讲一个优化吧,也就是一个小 Trick:虚点优化建图。这一种优化是专门用来处理一些节点向另外一些节点连一条边的。 也就是给边赋权,新建一个节点,从一些节点向这个节点连一条边权为 $0$ 的边,再从这个新建的节点向另一些节点都连一条边,边权为 $1$。容易发现,这样节点两两之间都有一条边权为 $1$ 的路径了,所以不影响整个图的最长路。这样,每一次都变成连 $\mathcal{O}(n)$ 条边了,所以总共只有 $\mathcal{O}(n ^ 2)$ 条边,时间复杂度 $\mathcal{O}(n ^ 2)$。 然后跑拓扑排序,转移最长路就可以了。记得转移方程后面的 $1$ 要改成 $j$ 到 $i$ 的边权。 怎么样,T4 还不是太难吧? :::: **完结撒花!**