\documentclass[UTF-8]{ctexart}
\usepackage{fancyhdr}
\usepackage{enumerate}
\usepackage{amsmath}
\usepackage{indentfirst}
\setlength{\parindent}{2em}
\usepackage{fancyhdr}
\pagestyle{fancy}
\begin{document}
\title{防抱灵总结}
\author{好渴鹅}
\date{\today}
\maketitle
\newpage
\tableofcontents
\newpage
\part{环境}
CSP 使用 NOI Linux 进行评测,提高组使用 Linux 系统进行代码编辑,但是普及组默认不会。Linux 和 Windows 有很大不同,导致好渴鹅抱过灵。为了不让好渴鹅重蹈覆辙,他在写题目长草期间写了这个文章。
\section{Linux 简单使用}
\subsection{与 Windows 不同的地方}
\subsubsection{区分大小写}
Linux 区分大小写,这代表着你不能在文件、目录中混淆大小写,而非经过优化的应用中。
\subsubsection{目录不同}
Windows 会有卷标,然而 Linux 没有卷标。Linux 的目录分隔符为 \verb|/|,但是 Windows 正反斜杠都可以。
\subsubsection{应用格式不同}
因为是不同的操作系统,应用的格式当然不同,例如 Windows 的应用程序后缀为 \verb|.exe|,而 Linux 为 \verb|.deb| 等。
\subsection{简单的命令}
\begin{itemize}
\item \verb|cd| 切换当前工作目录,注意分隔符为正斜杠。
\item \verb|g++| GNU++ 编译器,可以编译 C++ 源文件。命令:\verb|-o| 输出的可执行文件,\verb|-O2| 采用 O2 优化,\verb|-std=c++14| C++ 标准改为 $14$,\verb|-exec-charset=GBK| 将输出的可执行文件编码改为 GBK,……
\item \verb|suto| 以管理员身份运行。
\item \verb|apt instal| 安装包。
\item \verb|mkdir| 创建目录,今年提高组考了。
\item \verb|mklink| 创建软链接。
\end{itemize}
\subsection{编译事项}
由于操作系统不一样,因此 Windows 的 C++ 代码可能无法在 Linux 系统上通过编译。如:
\begin{itemize}
\item Linux 上 \verb|iostream| 并没有包含 \verb|string|,因此你得 \verb|#include <string>|;
\item 可能会有平常我们没有注意到的变量名变成了关键字;
\item 需要格外注意大小写。
\end{itemize}
\section{赛事注意事项}
\subsection{文件输入输出}
为了输入输出的速度,在比赛中,所有的题目都\textbf{必须开启文件输入输出}。方法如下:
\subsubsection{freopen}
如下:\verb|freopen("xxx.in/out", "r/w", stdin/stdout);|。之后,所有的 \verb|cin/cout| 或 \verb|scanf/printf| 都会被重定向到文件进行输出。要想关闭文件,需要 \verb|fclose(stdin/stdout)|。
\subsubsection{fopen}
首先定义 \verb|FILE *file = fopen("xxx.in", "r+/w+")|,然后在调用 \\
\verb|fscanf(file, |\verb|"", ...)| 或者 \verb|fprintf()|。注意,这里不能够使用 \verb|cin| 进行文件输入了。
\subsubsection{fstream}
需要包含文件 \verb|#include <fstream>|,然后定义 \verb|ifstream fin("xxx.in")| 和 \verb|ofstream cout("xxx.out")|,然后你就可以使用 \verb|fin, fout| 像 \verb|cin, cout| 一样使用了。需要注意的是,此时的 \verb|cin/cout| 仍然可以在控制台输入。
\subsection{卡常}
当你的算法时间复杂度没有问题时,但是常数太大超时了,那么你可能就需要卡常,来优化你的算法的常数。
\subsubsection{输入输出}
很多题目输入输出数据过多,导致可能会超时,因此你可以进行以下优化:
\paragraph{关闭缓冲区同步}
C++ 的输入输出的缓冲区默认是与 C 进行同步的,因此输入输出会较慢,因此我们可以通过 \verb|ios::sync_with_stdio(0)| 关闭同步。
\paragraph{关闭缓冲区自动刷新}
程序会在运行过程中多次刷新缓冲区,可以使用 \verb|cin.tie(0), cout.tie(0)| 进行关闭。
\paragraph{手写快读 / 快写(超级不推荐)}
通过 \verb|getchar(), putchar()| 进行整数的输入 / 输出。
\subsubsection{底层优化}
程序在编译时会与底层打交道,通过优化编译指令集可以进行底层加速。因电脑而异。
\paragraph{循环展开}
恰当的循环展开可以让 CPU 多线程进行并发运算,可以提升速度。如:
\begin{verbatim}
for (int i = 1; i <= n; i++) {
ans += i;
}
\end{verbatim}
\begin{verbatim}
for (i = 1; i + 7 <= n; i += 8) {
ans += i;
ans += i + 1;
ans += i + 2;
...
ans += i + 7;
}
\end{verbatim}
\paragraph{指令集优化(火车头)(NOI禁止)}
在编译时进行优化。
\begin{verbatim}
#pragma GCC optimize(3)
#pragma GCC target("avx")
#pragma GCC optimize("Ofast")
#pragma GCC optimize("inline")
#pragma GCC optimize("-fgcse")
... ...
\end{verbatim}
\part{语法}
C++ 是一门极其复杂的语言,因此本节将主要讲解 C++ 的语法,学会了你就可以在你的朋友面前装 13。
\section{语句优化}
\subsection{分支语句}
\subsubsection{三目表达式}
三目表达式其实就是一个小小的 if-else 语句,但是极其简短,因此我们可以用它进行一些简单的判断、运算。
如,一条语句:
\begin{verbatim}
if (x == 114514) {
s = "恶臭!"
} else {
s = "好耶!"
}
\end{verbatim}
可以改为这样:
\begin{verbatim}
x == 114514 ? s = "恶臭!" : s = "好耶!";
\end{verbatim}
\noindent
还可以把 $s$ 写在外面:
\begin{verbatim}
s = (s == 114514 ? "恶臭!" : "好耶!");
\end{verbatim}
尽量在外面打上括号,因为三目表达式的优先级很低,仅次于赋值运算符。而且如果三目表达式过于庞大,那么代码可读性将会很低。
\subsubsection{逻辑运算短路}
在 C++ 中进行逻辑与的时候,如果第一个表达式已经为 $0$ 了,那么将不会执行后面的其他表达式,因此我们可以使用它来进行赋值操作:
\begin{verbatim}
s == 114514 && (s = "恶臭!");
\end{verbatim}
注意第二个表达式如果是赋值的话得用括号括起来,因为赋值运算符优先级特别低。最最重要的是,第二个表达式必须有返回值!别看这里赋值运算符貌似没有返回值,其实它返回的是 s 的引用。不过你可以使用万能的逗号表达式!
\begin{verbatim}
// 如果 x 为 1,加入队列
x == 1 && (q.push(x), 114514);
// 逗号表达式的返回值是最后一个值!
\end{verbatim}
同理,逻辑与运算符当表达式为 $1$ 的时候就会停止运算,因此我们也可以这样:
\begin{verbatim}
// 当 x 为 0,输出 x
x || (cout << x, 114514); // 还是逗号
\end{verbatim}
\subsection{循环语句}
\subsubsection{range-for (C++ 11)}
对于一个有迭代器的对象,我们可以使用 range-for 进行顺向快速遍历:
\begin{verbatim}
// 遍历 vector 的内容进行输出
for (int i : v) { // range-for
cout << i << ' ';
}
\end{verbatim}
\noindent
这条语句等效于:
\begin{verbatim}
for (vector<int>::iterator it = v.begin();
it != v.end();
it++) {
int i = *it;
cout << i << ' ';
}
\end{verbatim}
\noindent
也等效于:
\begin{verbatim}
for (int i = 0; i < v.size(); i++) {
cout << v[i] << ' ';
}
\end{verbatim}
\subsubsection{假分支}
对于遇到错误的情况,分支语句需要预判路径,对于程序有着很大的影响。我们可以使用 \verb|while| 循环做一个假分支语句:
\begin{verbatim}
bool f = 0;
while (!f && 表达式) {
语句;
f = 1;
}
\end{verbatim}
\noindent
还可以使用下水道语句——\verb|do-while| 进行更简单的假分支语句:
\begin{verbatim}
do (表达式) {
语句;
} while (0)
\end{verbatim}
\section{类与对象}
在使用结构体进行排序的时候,我们使用了 \verb|struct| 并学会了如果定义一个类、创建一个对象。现在,跟随好渴鹅的脚步,使用 \verb|class| 定义对象,打开新世界的大门!
\subsection{导入}
什么是类?万物皆是类。人类就是一个大类,而我们就是一个个对象。比如一个机器人,我们需要定义一个类,里面定义它的成员函数,是这个机器人,这个“机器人类”的对象通过成员函数动起来。这种思维拿来编程,就是面向对象思维。
C++ 支持面向对象编程,只是数量的人不多。但是 C++ 能接近底层,因此学会 C++ 的面向对象是很有必要的。
\subsection{创建一个类}
\subsubsection{简单构建}
\noindent
我们可以试着创建一个学生类,先构思一下:
\paragraph{成员变量}
我们需要获取到每一个学生的姓名、学号、分数,这些都是私有变量,以防外界改变它们,这样子我们只能通过函数来管理它们,保证了安全性。
\paragraph{成员函数}
我们需要定义输入、输出、获取信息、更改信息等成员函数。
\begin{verbatim}
/// @brief 学生类
/// @author 好渴鹅
class Student {
private: // 私有成员
string name; // 名字
int id, sc; // 学号、分数
public:
Student() = default;
// 默认构造函数,即在声明是啥也不干
Student(string, int, int);
// 在声明是直接更改信息
void update(string, int, int);
// 更改信息
string getName();
// 获取名字
int getId();
// 获取学号
int getScore();
// 获取分数
void input();
// 输入
void print();
// 输出
};
\end{verbatim}
\subsubsection{定义成员}
声明完了,我们该定义成员了!我们在类里面声明,在类的外面定义时,记得加上 \verb|Student::|,表示访问声明的函数:
\begin{verbatim}
Student::Student(string name, int id, int sc) {
this->update(name, id, sc);
// this 指针表示当前对象的地址
// 使用指针成员运算符->来访问当前对象的更改信息函数
}
void Student::update(string name, int id, int sc) {
// 这里由于重名了
// 因此当前对象的成员需要使用this->来进行访问
this->name = name;
this->id = id;
this->sc = sc;
}
string Student::getName() {
return name; // 单纯访问成员可以不用this->
}
int getId() {
return id;
}
int getScore() {
return sc;
}
void input() {
cin >> name >> id >> sc;
// 这里输入的是当前对象的成员
}
void print() {
cout << name << ' ' << id << ' ' << sc << '\n';
}
\end{verbatim}
\subsubsection{重载运算符}
当我们想要对两个对象进行比较的时候,我们可以在类的内部重载一个 comp 函数。但是 \verb|a.comp(b)| 这样的方式不够好看,因此我们不用成员函数,直接 \verb|comp(a, b)|。但是这样不够简介,能否可以进行 \verb|a < b| 这样的运算呢?当然可以:
\begin{verbatim}
// 在类里面
public:
bool operator<(Student that) {
return this->sc > that.sc;
}
\end{verbatim}
注意这里实在类里面,因此第一个参数、也就是小于号前面的对象就是 \verb|this|。那我们可不可以当前面的对象变为 \verb|a| 呢?两种方法:
\begin{verbatim}
// 在类里面
// 使用 friend
friend bool operator<(Student a, Student b) {
return a.sc > b.sc;
}
// 在类外面
// 直接重载
bool operator<(Student a, Student b) {
return a.sc > b.sc; // 错误,无法访问sc
}
\end{verbatim}
但是在类的外面就不可以访问 \verb|sc| 了,因为它是一个私有成员,私有成员只能在类的里面进行访问。
\section{C++ 前沿}
\subsection{更新的函数}
\subsubsection{format (C++ 20)}
可以使用 \verb|format| 函数对字符串进行格式化,但是与 C 不同,占位符改成了 \verb|{}| 。
\begin{verbatim}
#include <format>
using namespace std;
cout << format("{}+{}={}", 1, 1, 1 + 1);
\end{verbatim}
\subsubsection{print (C++ 23)}
使用 print 函数进行格式化输出,其实就是 \verb|cout << format()|。还加入了 \verb|println| 函数,以便输出后换行。
\begin{verbatim}
print("{}+{}={}", 1, 1, 1 + 1);
\end{verbatim}
\part{算法}
\section{时空复杂度}
人们将程序运行次数的量级以及空间的量级成为时空复杂度,用大 $O$ 表示,一般会忽略常数。评测机大约每秒能够运行 $10^8$ 次,但是使用了数组就只能够运行 $2\times 10^7$ 次了。需要格外注意你的时空复杂度是否都在题目限制以内。
\subsection{能够接受的时间复杂度}
\begin{center}
\begin{tabular}{|c|c|c|c|c|c|c|c|c|}
\hline
{\tiny \textbf{$n$ 的大小}} & {\tiny $O(\log_2 n)$} & {\tiny $O(\sqrt{n})$} & {\tiny $O(n)$} & {\tiny $O(n\log_2 n)$} & {\tiny $O(n\sqrt{n})$} & {\tiny $O(n^2)$} & {\tiny $O(2^n)$} & {\tiny $O(n!)$} \\
\hline
{\tiny $10$} & {\tiny 是} & {\tiny 是} & {\tiny 是} & {\tiny 是} & {\tiny 是} & {\tiny 是} & {\tiny 是} & {\tiny 是} \\
\hline
{\tiny $20$} & {\tiny 是} & {\tiny 是} & {\tiny 是} & {\tiny 是} & {\tiny 是} & {\tiny 是} & {\tiny 是} & {\tiny 否} \\
\hline
{\tiny $1000$} & {\tiny 是} & {\tiny 是} & {\tiny 是} & {\tiny 是} & {\tiny 是} & {\tiny 是} & {\tiny 否} & {\tiny 否} \\
\hline
{\tiny $10000$} & {\tiny 是} & {\tiny 是} & {\tiny 是} & {\tiny 是} & {\tiny 是} & {\tiny 否} & {\tiny 否} & {\tiny 否} \\
\hline
{\tiny $10^6$} & {\tiny 是} & {\tiny 是} & {\tiny 是} & {\tiny 是} & {\tiny 否} & {\tiny 否} & {\tiny 否} & {\tiny 否} \\
\hline
{\tiny $2\times 10^7$} & {\tiny 是} & {\tiny 是} & {\tiny 是} & {\tiny 否} & {\tiny 否} & {\tiny 否} & {\tiny 否} & {\tiny 否} \\
\hline
{\tiny $10^{12}$} & {\tiny 是} & {\tiny 是} & {\tiny 否} & {\tiny 否} & {\tiny 否} & {\tiny 否} & {\tiny 否} & {\tiny 否} \\
\hline
{\tiny $1e+602$} & {\tiny 是} & {\tiny 否} & {\tiny 否} & {\tiny 否} & {\tiny 否} & {\tiny 否} & {\tiny 否} & {\tiny 否} \\
\hline
\end{tabular}
\end{center}
\section{排序}
\subsection{时间复杂度表}
\begin{center}
\begin{tabular}{|c|c|c|c|}
\hline
\textbf{排序算法} & \textbf{时间复杂度} & \textbf{稳定性} & \textbf{特殊说明} \\
\hline
选择排序 & $O(n^2)$ & 否 & 可以通过额外的 $O(n)$ 空间达到稳定 \\
\hline
插入排序 & $O(n^2)$ & 是 & 较小的 $n$ 速度特别快 \\
\hline
冒泡排序 & $O(n^2)$ & 是 & 如果没有交换可以直接 \verb|break| \\
\hline
快速排序 & $O(n\log_2 n)-O(n^2)$ & 否 & 最快的排序,常使用 $\log_2 n$ 的递归栈 \\
\hline
归并排序 & $O(n\log_2 n)$ & 是 & 良心排序,不易被卡 \\
\hline
堆排序 & $O(n\log_2 n)$ & 否 & 常熟较大 \\
\hline
桶排序 & $O(\max\limits_{i=1}^{n} a_i)$ & 否 & 非比较排序 \\
\hline
\end{tabular}
\end{center}
\subsection{结构体自定义排序比较方式}
\paragraph{函数传参}
新建函数 \verb|bool xxx(xxx a, xxx b)| 在里面写好比较方式后,调用 \verb|sort| 时添加到第三个参数里面 \verb|sort(a + 1, a + n + 1, xxx)|。
\paragraph{重载小于号}
在类里面重载两个类进行比较的小于号就行。
\paragraph{Lambda 表达式}
在第三个参数填入 lambda 表达式即可,与第一种函数传参相似。
\subsection{模板题目}
\subsubsection{逆序对 / 归并排序}
给定一个长度为 $n$ 的数组 $a$,对于 $1\le i<j\le n$,若 $a_i>a_j$,那么这就算一组你需要。你需要求出逆序对的个数。
本题一般使用归并排序,但是树状数组也可以。归并排序的原理就是先将序列划成两半,让两半自身有序,并进行合并。在合并的过程中,我们就可以求出逆序对的个数。
\begin{verbatim}
void merge_sort(int l, int r) {
if (l == r) {
return;
}
int m = (l + r) >> 1, i = l, j = m + 1, k = l;
sort(l, m);
sort(m + 1, r);
while (i <= m && j <= r) {
if (a[i] < a[j]) {
f[k++] = a[i++];
} else {
f[k++] = a[i++], ans += j - i + 1;
}
}
for (; i <= m; f[k++] = a[i++]) {
}
for (; j <= r; f[k++] = a[j++]) {
}
for (int k = l; k <= r; k++) {
a[k] = f[k];
}
}
\end{verbatim}
\section{搜索}
\subsection{深度优先搜索}
\subsubsection{前言}
深度优先搜索是用来在搜索图内搜索的一种算法。在算法中,如果遇到新状态,那么立刻处理新状态,并处理新状态转移出来的状态。若没有新状态了,那么才回去处理旧状态。DFS 一般使用递归实现。
搜索的题目灵活多变,需要有一定的经验。
\subsubsection{记录路径}
按照字典序输出自然数 $1$ 到 $n$ 所有不重复的排列,即 $n$ 的全排列,要求所产生的任一数字序列中不允许出现重复的数字。
我们定义一个函数 \verb|dfs(k)| 表示当前搜索第 $k$ 个数字,然后对所有的可能的状态进行遍历。我们使用 $ans_i$ 表示第 $i$ 个数字的值。当已经选完了 $k$ 个数字之后,我们就打印答案。
\begin{verbatim}
void dfs(int k) {
if (k > n) {
for (int i = 1; i <= n; i++) {
cout << a[i] << ' ';
}
cout << '\n';
return void();
}
for (int i = 1; i <= n; i++) {
if (!f[i]) {
f[i] = 1, a[i] = i;
dfs(k + 1);
f[i] = 0;
}
}
}
\end{verbatim}
\subsubsection{可行性剪枝}
将整数 $n$ 分成 $k$ 份,且每份不能为空,任意两个方案不相同(不考虑顺序)。
例如:$n=7$,$k=3$,下面三种分法被认为是相同的。
$1,1,5$;
$1,5,1$;
$5,1,1$.
问有多少种不同的分法。
根据题目,我们可以写出 \verb|dfs(int x, int s, int l)|。表示当前是第 $x$ 个数,和为 $s$,上一个数是 $l$。因为不考虑顺序的话,每个数必须大于等于前面的数,因此需要一个变量 $l$ 来记录。
\begin{verbatim}
void dfs(int x, int s, int l) {
if (x == k) { // 如果已经选完了
ans += s == n; // 判断是否正好等于 n
return;
}
for (int i = l; i <= n; i++) { // 从 l 开始枚举
dfs(i + 1, s + j, j);
}
}
\end{verbatim}
但是我们 TLE 了……因为有可能我们累积的和已经大于 $n$ 了,但是仍然还在进行搜索。很简单,我们在 dfs 里面加一个判断就可以了!
\begin{verbatim}
void dfs(int x, int s, int l) {
if (s > n) {
return;
}
if (x == k) { // 如果已经选完了
ans += s == n; // 判断是否正好等于 n
return;
}
for (int i = l; i <= n; i++) { // 从 l 开始枚举
dfs(i + 1, s + j, j);
}
}
\end{verbatim}
但是还是 TLE 了一个点……我们知道,递归的速度是很慢的,不如我们直接减少递归,在递归前就剪枝。很简单,我们只枚举到 $n-s$ 就行了,这样子递归后一定满足要求。
\begin{verbatim}
#include <iostream>
using namespace std;
int n, k, ans;
void dfs(int i, int s, int l) {
if (i == k) {
ans += s == n;
return;
}
for (int j = l; j <= n - s; ++j) {
dfs(i + 1, s + j, j);
}
}
int main() {
cin >> n >> k;
dfs(0, 0, 1);
cout << ans << '\n';
return 0;
}
\end{verbatim}
顺利 AC!这就是可行性剪枝。当当前状态已经无法产生答案的时候,我们就不再进行搜索了。
\subsubsection{状态判重}
年轻的拉尔夫开玩笑地从一个小镇上偷走了一辆车,但他没想到的是那辆车属于警察局,并且车上装有用于发射车子移动路线的装置。那个装置太旧了,以至于只能发射关于那辆车的移动路线的方向信息。
编写程序,通过使用一张小镇的地图帮助警察局找到那辆车。程序必须能表示出该车最终所有可能的位置。
小镇的地图是矩形的,上面的符号用来标明哪儿可以行车哪儿不行。$\verb!.!$ 表示小镇上那块地方是可以行车的,而符号 $\verb!X!$ 表示此处不能行车。拉尔夫所开小车的初始位置用字符的 $\verb!*!$ 表示,且汽车能从初始位置通过。汽车能向四个方向移动:向北(向上),向南(向下),向西(向左),向东(向右)。
拉尔夫所开小车的行动路线是通过一组给定的方向来描述的。在每个给定的方向,拉尔夫驾驶小车通过小镇上一个或更多的可行车地点。
\paragraph{输入格式}
输入文件的第一行包含两个用空格隔开的自然数 $R$ 和 $C$,$1\le R\le 50$,$1\le C\le 50$,分别表示小镇地图中的行数和列数。以下的 $R$ 行中每行都包含一组 $C$ 个符号($\verb!.!$ 或 $\verb!X!$ 或 $\verb!*!$)用来描述地图上相应的部位。接下来的第 $R+2$ 行包含一个自然数 $N$,$1\le N\le 1000$,表示一组方向的长度。接下来的 $N$ 行幅行包含下述单词中的任一个:\verb|NORTH|(北)、\verb|SOUTH|(南)、\verb|WEST|(西)和 \verb|EAST|(东),表示汽车移动的方向,任何两个连续的方向都不相同。
\paragraph{输出格式}
用 $R$ 行表示的小镇的地图(像输入文件中一样),字符 $\verb|*|$ 应该仅用来表示汽车最终可能出现的位置。
\paragraph{解法}
这题就是一个暴力的 dfs。定义函数 \verb|dfs(x, y, i, b)| 表示当前坐标为 $(x,y)$,当前的操作是 $i$,$b$ 表示是否可以转向。然后进行 dfs 就行。但是当我们到了同一个坐标,操作编号也完全相同时,此时再次搜索一遍没有任何意义,因此我们直接 \verb|return|。这就叫做状态判重。
\begin{verbatim}
#include <cstring>
#include <iostream>
using namespace std;
const int kMaxR = 51, kMaxN = 1001;
int o[kMaxN], dx[] = {-1, 1, 0, 0}, dy[] = {0, 0, -1, 1}, r, c, n, x, y;
char a[kMaxR][kMaxR]; // 地图
bool v[kMaxR][kMaxR][kMaxN]; // 判重数组
string s;
void dfs(int x, int y, int i, bool b) {
if (i > n || x < 1 || x > r || y < 1 || y > c ||
a[x][y] == 'X' || v[x][y][i]) {
return;
}
// 如果已经走完了操作、超出了地图边界、已经走过了、当前地点为障碍那么就 return
if (i == n) { // 如果正好走完操作
a[x][y] = '*'; // 标记为可能的重点
}
v[x][y][i] = 1; // 标记已经走过了
dfs(x + dx[o[i]], y + dy[o[i]], i, 1); // 不转向
if (b) { // 如果可以转向
dfs(x + dx[o[i + 1]], y + dy[o[i + 1]], i + 1, 1); // 转向
}
}
int main() {
cin >> r >> c;
for (int i = 1; i <= r; ++i) {
for (int j = 1; j <= c; ++j) {
cin >> a[i][j];
if (a[i][j] == '*') {
x = i, y = j, a[i][j] = '.';
}
}
}
cin >> n;
for (int i = 1; i <= n; ++i) {
cin >> s;
if (s == "NORTH") {
o[i] = 0;
} else if (s == "SOUTH") {
o[i] = 1;
} else if (s == "WEST") {
o[i] = 2;
} else {
o[i] = 3;
}
}
dfs(x, y, 1, 0);
for (int i = 1; i <= r; ++i) {
for (int j = 1; j <= c; ++j) {
cout << a[i][j];
}
cout << '\n';
}
return 0;
}
\end{verbatim}
\subsubsection{最优性剪枝}
本题使用 Special Judge。
Farmer John 把农场划分为了一个 $r$ 行 $c$ 列的矩阵,并发现奶牛们无法通过其中一些区域。此刻,Bessie 位于坐标为 $(1,1)$ 的区域,并想到坐标为 $(r,c)$ 的牛棚享用晚餐。她知道,以她所在的区域为起点,每次移动至相邻的四个区域之一,总有一些路径可以到达牛棚。
这样的路径可能有无数种,请你输出任意一种,并保证所需移动次数不超过 $100000$。
\paragraph{输入格式}
第一行两个整数 $r,c$。接下来 $r$ 行,每行 $c$ 个字符,表示 Bessie 能否通过相应位置的区域。字符只可能是 \verb|.| 或 \verb|*|。
\begin{itemize}
\item \verb|.| 表示 Bessie 可以通过该区域。
\item \verb|*| 表示 Bessie 无法通过该区域。
\end{itemize}
\paragraph{输出格式}
若干行,每行包含两个用空格隔开的整数,表示 Bessie 依次通过的区域的坐标。显然,输出的第一行是 \verb|1 1|,最后一行是 \verb|r c|。相邻的两个坐标所表示的区域必须相邻。
\paragraph{解法}
这题其实十分的暴力。使用 \verb|vector| 记录路径,并写下状态判重的数组,当遇到重复状态的时候就剪枝。但是这样子是非常错误的,因为有可能后面到了那个点会比前面的步数更少,因此我们就定义一个数组 $f_{(i,j)}$ 表示 $(1,1)$ 到 $(r,c)$ 的最小步数。当当前的状态已经劣于以前的状态时,我们就剪枝。这就叫做最优性剪枝。
\begin{verbatim}
#include <iostream>
#include <utility>
#include <vector>
using namespace std;
const int kMaxN = 250, kD[4][2] = {{1, 0}, {0, 1}, {-1, 0}, {0, -1}};
int f[kMaxN][kMaxN], r, c, ans; // 最优化数组
char a[kMaxN][kMaxN];
vector<pair<int, int>> v, vec; // 记录路径
void dfs(int x, int y, int s) {
if (x < 1 || x > r || y < 1 || y > c ||
a[x][y] == '*' || s >= f[x][y] || v.size() > 1e5) {
return;
}
// 如果超出了地图范围、当前点位不可走、答案过大就剪枝
if (x == r && y == c) { // 如果走到了地方
if (s < ans) { // 如果比答案更优
vec = v, ans = s; // 赋值
}
return;
}
f[x][y] = s; // 记录
for (auto i : kD) { // 枚举走向
int nx = x + i[0], ny = y + i[1];
v.push_back({nx, ny}), dfs(nx, ny, s + 1), v.pop_back();
}
}
int main() {
fill(f[0], f[0] + kMaxN * kMaxN, 1e9);
cin >> r >> c, ans = 1e9;
for (int i = 1; i <= r; ++i) {
for (int j = 1; j <= c; ++j) {
cin >> a[i][j];
}
}
v.push_back({1, 1}), dfs(1, 1, 0);
for (auto i : vec) {
cout << i.first << ' ' << i.second << '\n';
}
return 0;
}
\end{verbatim}
\subsection{广度优先搜索}
\subsubsection{前言}
广度优先搜索,即在搜索树内搜索,一起处理相同层级的状态,再去处理下一级的状态。由于不是一个劲地往前搜,因此处理可行性问题是搜到结果就可以直接退出,但是深度优先搜索最坏得遍历完整个搜索树才能退出。广度优先搜索也不是没有坏处,它会破坏先驱状态,因此在路径问题中你需要整个路径都加到状态内。
广度优先搜索一般使用队列实现,根据不同的情况可以使用双端队列、优先队列,具体请看 STL 章节。
\subsubsection{普通 BFS}
这类 bfs 在每次转移时只会加上一个相同的值,因此最先遇到的答案必然是最优的,搜的时间越久,状态就越劣。
机器人移动学会(RMI)现在正尝试用机器人搬运物品。机器人的形状是一个直径 $1.6$ 米的球。在试验阶段,机器人被用于在一个储藏室中搬运货物。储藏室是一个 $N\times M$ 的网格,有些格子为不可移动的障碍。机器人的中心总是在格点上,当然,机器人必须在最短的时间内把物品搬运到指定的地方。机器人接受的指令有:
\begin{itemize}
\item 向前移动 $1$ 步(\verb|Creep|)
\item 向前移动 $2$ 步(\verb|Walk|)
\item 向前移动 $3$ 步(\verb|Run|)
\item 向左转(\verb|Left|)
\item 向右转(\verb|Right|)
\end{itemize}
每个指令所需要的时间为 $1$ 秒。请你计算一下机器人完成任务所需的最少时间。
\paragraph{输入格式}
第一行为两个正整数 $N,M\ (1\le N,M\le50)$,下面 $N$ 行是储藏室的构造,$0$ 表示无障碍,$1$ 表示有障碍,数字之间用一个空格隔开。接着一行有 $4$ 个整数和 $1$ 个大写字母,分别为起始点和目标点左上角网格的行与列,起始时的面对方向(东 $\tt E$,南 $\tt S$,西 $\tt W$,北 $\tt N$),数与数,数与字母之间均用一个空格隔开。终点的面向方向是任意的。
\paragraph{输出格式}
一个整数,表示机器人完成任务所需的最少时间。如果无法到达,输出 $-1$。
\paragraph{解法}
我们可以使用一个队列,定义结构体存储状态,然后每次从队列里面取出状态转移,并把步数加一。如果找到了答案,那就立马记录并退出。
\begin{verbatim}
#include <iostream>
#include <queue>
#include <vector>
using namespace std;
const int kMaxN = 55;
const int kD[5][2] = {{114514, 114514}, {0, 1}, {1, 0}, {0, -1}, {-1, 0}};
struct Node { // 结构体
int x, y, s, to; // 坐标、步数和方向
};
int f[kMaxN][kMaxN][5], a[kMaxN][kMaxN];
iny n, m, stx, sty, edx, edy, to, ans = 1e9;
char c;
queue<Node> q; // 队列
// 记录状态函数
void Record(int x, int y, int s, int to) {
if (x < 1 || x >= n || y < 1 || y >= m || a[x][y] || s >= f[x][y][to]) {
return;
}
// 如果超出了地图的界限或者已经有更优的状态了,那就剪枝
f[x][y][to] = s, q.push({x, y, s, to});
// 记录下当前最优值并进入队列
}
void bfs() { // 搜索
for (Record(stx, sty, 0, to); q.size(); q.pop()) {
Node t = q.front();
if (t.x == edx && t.y == edy) { // 如果满足结束条件
ans = t.s; // 记录答案
return; // 退出
}
Record(t.x + kD[t.to][0], t.y + kD[t.to][1], t.s + 1, t.to); // 走一步
if (!a[t.x + kD[t.to][0]][t.y + kD[t.to][1]]) {
Record(t.x + 2 * kD[t.to][0], t.y + 2 * kD[t.to][1], t.s + 1, t.to);
} // 走两步
if (!a[t.x + kD[t.to][0]][t.y + kD[t.to][1]] &&
!a[t.x + 2 * kD[t.to][0]][t.y + 2 * kD[t.to][1]]) {
Record(t.x + 3 * kD[t.to][0], t.y + 3 * kD[t.to][1], t.s + 1, t.to);
} // 走三步,注意这里三个格子 都要判断
Record(t.x, t.y, t.s + 1, (t.to == 1 ? 4 : t.to - 1));
// 左转
Record(t.x, t.y, t.s + 1, (t.to == 4 ? 1 : t.to + 1));
// 右转
}
}
int main() {
fill(f[0][0], f[0][0] + kMaxN * kMaxN * 5, 1e9); // 记得初始化
cin >> n >> m;
for (int i = 1; i <= n; ++i) {
for (int j = 1, x; j <= m; ++j) {
cin >> x;
x && (a[i - 1][j - 1] = a[i - 1][j] = a[i][j - 1] = a[i][j] = 1);
}
}
cin >> stx >> sty >> edx >> edy >> c;
to = (c == 'E' ? 1 : (c == 'S' ? 2 : (c == 'W' ? 3 : 4)));
// 起始方向
bfs();
cout << (ans == 1e9 ? -1 : ans) << '\n';
return 0;
}
\end{verbatim}
\subsubsection{优先队列 BFS}
给定一张 $N$ 个点(编号为 $1 \sim N$),$M$ 条边的无向图,保证无重边无自环。现在有 $K$ 个被标记的点,其中第 $i$ 个被标记的点的编号为 $p_i$,任何从 $p_i$ 出发经过不超过 $h_i$ 条边能到达的点都会被染色(包括 $p_i$ 自身)。你需要求出这张图最终有哪些点被染色。
\paragraph{输入格式}
第一行三个正整数 $N,M,K$,含义见题目描述。接下来 $M$ 行,每行两个正整数 $a_i,b_i$,表示编号为 $a_i,b_i$ 的点连有一条无向边。接下来 $K$ 行,每行两个正整数 $p_i,h_i$,含义见题目描述。
\paragraph{输出格式}
第一行一个数字 $G$,表示被染色的点的个数。第二行 $G$ 个数字,表示被染色的点,按照从小到大的顺序输出。
\paragraph{数据范围}
$1 \le N \le 2 \times 10^5$,$0 \le M \le 2 \times 10^5$,$1 \le K,a_i,b_i,p_i,h_i \le N$,$p_i$ 互不相同。保证给定的图无重边,无自环。
\paragraph{解法}
首先这道题一看就是图上的 bfs,具体的图论知识可以看数据结构板块。但是由于状态转移合并并不是单纯相加,因此我们需要一个优先队列,每次取得都是最优值,因此结果也一定是最优值。
\begin{verbatim}
// 本题为著名教师 胖头鱼 的例题:s**t污染
#include <iostream>
#include <queue>
#include <vector>
using namespace std;
const int kMaxN = 2e5 + 1;
struct Node { // 结点
int h, p; // 当前点位,距离
// 按照距离排序
friend bool operator<(const Node& a, const Node& b) {
return a.p < b.p;
// 按照剩余距离最大的排序
// 但是由于优先队列的比较是反着的,因此得写小于号
}
};
int f[kMaxN], v[kMaxN], n, m, k, x, y, ans;
vector<int> e[kMaxN];
priority_queue<Node> q; // 优先队列
void Record(Node t) {
if (f[t.h] - 1 < 0 || v[t.h]) {
// 如果已经访问过了节点或者无法到达那里
return; // 剪枝
}
v[t.h] = 1; // 打上标记
for (int i : e[t.h]) { // 枚举邻居
if (f[i] < f[t.h] - 1) { // 如果剩余距离更大,即更优
f[i] = f[t.h] - 1; // 更改
q.push({ i, f[i] }); // 加入队列
}
}
}
void bfs() {
for (; q.size(); q.pop()) { // 不停搜索
Record(q.top()); // 记录
}
}
int main() {
cin >> n >> m >> k;
fill(f + 1, f + n + 1, -1e9); // 初始化距离数组
for (int i = 1; i <= m; ++i) {
cin >> x >> y;
e[x].push_back(y), e[y].push_back(x);
// 邻接表加边
}
for (int i = 1; i <= k; ++i) {
cin >> x >> y; // 输入进去的答辩污染
q.push({ x, y });
f[x] = y;
}
bfs(); // 搜索
for (int i = 1; i <= n; ++i) {
ans += f[i] != -1e9; // 加上值
}
cout << ans << '\n';
for (int i = 1; i <= n; ++i) {
f[i] != -1e9 && (cout << i << ' ');
}
// 输出
return 0;
}
\end{verbatim}
\section{动态规划}
众所周知,动态规划是一种十分困难的算法,简单的有普及-,难的有 NOI,那我们如何掌握做题方法呢?由 胖头鱼教练 名师列出的“三部曲”:
\subsection{动态规划三部曲}
动态规划大多数是使用递推的风格进行的,而递推需要状态表,状态表是需要拓扑序的,因此第一步你得先找题目中的拓扑序,采取分治型或递推型进行计算。
\paragraph{设计状态}
设计一个可以表示出当前状态的状态,并对状态进行分组,每组取最优值或者是方案数,最后进行简化。可以使用:压数组、滚动数组等思路。
\paragraph{状态转移方程}
一个转移状态的方程,这个是动态规划的核心,所有的状态靠它进行转移,从起始状态转移到最终状态。
\paragraph{其他}
记得初始化 dp 数组并设好边界。
\subsection{线性动态规划}
\subsubsection{最长上升子序列}
给定一个长度为 $n$ 的数组 $a$,你需要求出一条在 $a$ 当中顺序求出一条不一定连续的序列 $l$,使得 $l$ 最长,并且对于 $2\le i\le |l|$,$a_{i-1}<a_i$。
首先,题目已经说明了这个子序列在原序列当中是顺序的,那么拓扑序不就来了?我们设 $dp_i$ 表示子序列的尾部为 $i$ 的最长子序列,那么对于任意一个 $1\le j<i$,如果满足 $a_j<a_i$,那么 $dp_i=\max(dp_i,dp_j+1)$。
\begin{verbatim}
fill(dp + 1, dp + n + 1, 1);
for (int i = 1; i <= n; i++) {
for (int j = 1; j < i; j++) {
if (a[j] < a[i]) {
dp[j] = max(dp[j], dp[i] + 1);
}
}
}
\end{verbatim}
\subsection{背包型动态规划}
\subsubsection{开心的坤坤 / 01背包}
坤坤今天很开心,因为他要去领新房的钥匙了!坤坤有 $n$ 的想要买的篮球,第 $i$ 个篮球需要消耗 $w_i$ 的坤币,买完之后可以获得 $v_i$ 的篮球技术。每个篮球限量 $1$ 个。现在坤坤只有 $m$ 的坤币,请你告诉他最多能够获得多少的篮球技术。
这是一道 01 背包题。设 $dp_{(i,j)}$ 表示前面 $i$ 个篮球坤坤已经使用了 $j$ 个坤币,他获得的最大篮球技术。状态转移方程:$dp_{(i,j)}=\max(dp_{(i,j)},dp_{(i-1,j-w_i)}+v_i)$
\begin{verbatim}
for (int i = 1; i <= n; i++) {
for (int j = w[i]; j <= m; j++) {
dp[i][j] = max(dp[i][j], dp[i - 1][j - w[i]] + v[i]);
}
}
return dp[n][m];
\end{verbatim}
我们还可以滚动数组。由于每一层的状态只与上一层有关,因此我们可以使用两个一位数组进滚动。
\begin{verbatim}
for (int i = 1; i <= n; i++) {
for (int j = w[i]; j <= m; j++) {
dp[j] = max(dp[j], f[j - w[i]] + v[i]);
}
for (int j = 1; j <= m; j++) {
f[j] = dp[j];
}
}
\end{verbatim}
最后我们其实可以使用一个一维数组,因为不能使用一个数组的关键原因就是因为 $j$ 的状态转移到了 $j+w_i$,但是 $j+w_i$ 又转移到了 $j+2\times w_i$,导致了一个篮球坤坤买了多次。解决方案也很简单,把内层循环改为倒序就刑。
\begin{verbatim}
for (int i = 1; i <= n; i++) {
for (int j = m; j >= w[i]; j--) {
dp[j] = max(dp[j], dp[j - w[i]] + v[i]);`
}
}
\end{verbatim}
\subsubsection{疯狂的采药 / 完全背包}
LiYuxiang 是个天资聪颖的孩子,他的梦想是成为世界上最伟大的医师。为此,他想拜附近最有威望的医师为师。医师为了判断他的资质,给他出了一个难题。医师把他带到一个到处都是草药的山洞里对他说:“孩子,这个山洞里有一些不同种类的草药,采每一种都需要一些时间,每一种也有它自身的价值。我会给你一段时间,在这段时间里,你可以采到一些草药。如果你是一个聪明的孩子,你应该可以让采到的草药的总价值最大。”如果你是 LiYuxiang,你能完成这个任务吗?\\
\noindent
此题和原题的不同点:
\begin{itemize}
\item 每种草药可以无限制地疯狂采摘。
\item 药的种类眼花缭乱,采药时间好长好长啊!师傅等得菊花都谢了!
\end{itemize}
输入第一行有两个整数,分别代表总共能够用来采药的时间 $t$ 和代表山洞里的草药的数目 $m$。第 $2$ 到第 $(m + 1)$ 行,每行两个整数,第 $(i + 1)$ 行的整数 $a_i, b_i$ 分别表示采摘第 $i$ 种草药的时间和该草药的价值。\\
输出一行,这一行只包含一个整数,表示在规定的时间内,可以采到的草药的最大总价值。
\subsubsection*{解法}
这题每种草药可以无限制地采,看起来很复杂,其实把 01 背包的内层循环改为正向枚举就行了,这样子本来是我们之前的 bug,现在成了完全背包的特性。
\begin{verbatim}
#include <iostream>
using namespace std;
using ll = long long;
const int kMaxN = 1e4 + 5, kMaxM = 1e7 + 5;
ll a[kMaxN], b[kMaxN], dp[kMaxM], m, n;
int main() {
cin >> m >> n;
for (ll i = 1; i <= n; ++i) {
cin >> a[i] >> b[i];
}
for (ll i = 1; i <= n; ++i) {
for (ll j = a[i]; j <= m; ++j) {
dp[j] = max(dp[j], dp[j - a[i]] + b[i]);
}
}
cout << dp[m] << '\n';
return 0;
}
\end{verbatim}
\subsubsection{挑选原石 / 多重背包}
这几天,稻妻城来了一位神奇的「商人」。刚登岛,就有一群人跑来购买他的产品。履行者也来凑了热闹。听了旁人的话,旅行者似乎了解了这位商人的「秘密」:原来这位商人本来属于天空岛,但是他非常无聊,便「偷」了一些原石,拿去稻妻卖。
他有 $n$ 个「套餐」,第 $i$ 套餐需要 $w_i$ 的摩拉来交换 $v_i$ 的原石,这个套餐一共有 $c_i$ 份。旅行者使用了他的元素力,因此他抢在了最前面。由于出门时,旅行者根本没有带多少摩拉,因此他只剩下了 $m$ 元的摩拉。
旅行者还有强迫症,他必须把身上仅有的 $m$ 元摩拉全部花完,并且得到的数量最多。请问他最多能够得到多少原石?
\subsubsection*{普通解法}
我们可以将它看成 01 背包,$c_i$ 的数量就是一样的物品,进行暴力三重循环转移即可。注意这题旅行者必须把 $m$ 的摩拉全部花完,因此我们将 $dp$ 数组全部赋为极小值,并保留初始状态 $dp_0$。当转以后 $dp_j$ 小于 $0$,那么就说明当前状态不存在。时间复杂度:$\mathcal{O}(nm\sum\limits_{i=1}^{n}c_i)$。
\begin{verbatim}
#include <iostream>
using namespace std;
const int kMaxN = 1e6 + 5;
int w[kMaxN], v[kMaxN], c[kMaxN], dp[kMaxN], n, m;
int main() {
fill(dp + 1, dp + kMaxN, -1e9);
cin >> n >> m;
for (int i = 1; i <= n; i++) {
cin >> w[i] >> v[i] >> c[i];
}
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= c[i]; j++) {
for (int k = m; k >= w[i]; k--) {
dp[k] = max(dp[k], dp[k - w[i]] + v[i]);
dp[k] < 0 && (dp[k] = -1e9);
}
}
}
cout << (dp[m] == -1e9 ? -1 : dp[m]) << '\n';
return 0;
}
\end{verbatim}
完美地 TLE!
\subsubsection*{二进制优化}
我们知道,对于一个数 $n$,我们可以划分为 $\log_2 n$ 个二次幂的数,使得对于一个数 $i\in [1, n]$,可以通过这些数拼凑出 $i$。时间复杂度:$\mathcal{O}(nm\sum\limits_{i=1}^{n}\log_2 c_i)$ 开上 O2 可以勉强卡过。
\begin{verbatim}
#include <iostream>
#include <vector>
using namespace std;
const int kMaxN = 1e6 + 5;
int v[kMaxN], w[kMaxN], m[kMaxN], dp[kMaxN], n, W, j, _n;
vector<int> _v, _w;
int main() {
fill(dp + 1, dp + kMaxN, -1e9);
cin >> n >> W;
for (int i = 1; i <= n; ++i) {
cin >> w[i] >> v[i] >> m[i];
// 进行二进制拆分
for (j = 1; m[i] > 0 && j <= m[i]; j <<= 1) {
if (m[i] >= j) {
_v.push_back(v[i] * j);
_w.push_back(w[i] * j);
++_n;
m[i] -= j;
}
}
if (m[i] > 0) { // 余数
_v.push_back(v[i] * m[i]);
_w.push_back(w[i] * m[i]);
++_n;
}
}
// 接下来就用普通的 01 背包就行了
for (int i = 0; i < _n; ++i) {
for (int j = W; j >= _w[i]; --j) {
dp[j] = max(dp[j], dp[j - _w[i]] + _v[i]);
dp[j] < 0 && (dp[j] = -1e9);
}
}
cout << (dp[W] == -1e9 ? -1 : dp[W]) << '\n';
return 0;
}
\end{verbatim}
\subsection{序列型动态规划}
\subsubsection{弹簧跳}
为了帮助训练 Bessie 更好地控制弹跳,Farmer John 在牧场的一条直线道路上给她设置了一个练习课程。他将 $N$ 个目标点放置在了道路的不同位置处, Bessie 需要跳到这些点( $1\le N\le 1000$)。目标点 $i$ 在位置 , Bessie 跳到这个点可以获得 $p_i$ 分。 Bessie 可以选择任何一个目标点作为她的初始位置,并且只能朝一个方向的目标点弹跳。每次弹跳都必须落在目标点上,且距离不能小于上次弹跳的距离。
Bessie每跳到一个目标点就能获得相应分数(初始位置的目标点也包含在内),请求出她能够获得的最大分数。
这是一道序列型 dp,虽然 $N$ 有 $1000$,但是我们仍然可以使用常数优秀的 $O(n^3)$ dp 过掉。由于每一个点的下一步状态决策只与上一个点有关,因此我们设 $dp_{(i,j)}$ 表示当前的点是 $i$,上一个点是 $j$,所能获得的最大分数,然后进行转移即可。但是最重要的是,Bessie 可以反着跳过去,因此我们需要进行两遍 $dp$。
\begin{verbatim}
#include <iostream>
#include <algorithm>
#include <cstring>
using namespace std;
using ll = long long;
const ll kMaxN = 1005;
struct Node {
ll x, p;
friend bool operator<(const Node &a, const Node &b) {
return a.x < b.x;
}
} a[kMaxN];
ll dp[kMaxN][kMaxN], n, ans;
int main() {
cin >> n;
for (ll i = 1; i <= n; i++) {
cin >> a[i].x >> a[i].p;
}
sort(a + 1, a + n + 1);
for (ll i = 1; i <= n; i++) {
dp[i][i] = a[i].p;
}
for (ll i = 1; i <= n; i++) { // 当前点位
for (ll j = 1; j <= i; j++) { // 上一个点位
for (ll k = i + 1; k <= n; k++) { // 下一个点位
if (a[k].x - a[i].x >= a[i].x - a[j].x) { // 满足条件
dp[k][i] = max(dp[k][i], dp[i][j] + a[k].p);
}
}
}
}
for (ll i = 1; i <= n; i++) {
for (ll j = 1; j <= n; j++) {
ans = max(ans, dp[i][j]);
}
}
// 反着的请读者自行补充
cout << ans << endl;
return 0;
}
\end{verbatim}
\subsection{状态压缩动态规划}
\subsubsection{吃奶酪}
房间里放着 $n$ 块奶酪。一只小老鼠要把它们都吃掉,问至少要跑多少距离?老鼠一开始在 $(0,0)$ 点处。$1\le n\le 15$
我们设 $dp_({i,j})$ 表示当前小老鼠走过的点是 $i$,当前的点是 $j$。其中,由于 $n$ 很小,$i$ 表示一个二进制的数,若第 $k$ 位为 $1$,那么表示小老鼠已经走过了第 $k$ 个点,这就是状态压缩得到精髓。
状态转移方程:
$$
dp_{(j, i)} =
\min(
dp_{(j, i)},
dp_{(k,
i - (2 ^ {j-1})
}) + dis_{(j, k)}
)
$$
{\footnotesize
\begin{verbatim}
#include <iostream>
#include <cmath>
#include <iomanip>
using namespace std;
const int kMaxN = 16;
int n;
double x[kMaxN], y[kMaxN], d[kMaxN][kMaxN], dp[kMaxN][(1 << kMaxN) + 5], ans = 1e9;
double dis(int i, int j) {
return sqrt((x[i] - x[j]) * (x[i] - x[j]) + (y[i] - y[j]) * (y[i] - y[j]));
}
int main() {
fill(dp[0], dp[0] + kMaxN * ((1 << kMaxN) + 5), 1e9);
cin >> n;
for (int i = 1; i <= n; i++) {
cin >> x[i] >> y[i];
}
for (int i = 0; i <= n; i++) {
for (int j = 0; j <= n; j++) {
d[i][j] = dis(i, j);
}
}
for (int i = 1; i <= n; i++) {
dp[i][1 << (i - 1)] = d[0][i];
}
for (int i = 1; i < 1 << n; i++) { // 当前状态
for (int j = 1; j <= n; j++) { // 当前点位
if (i & (1 << (j - 1))) {
for (int k = 1; k <= n; k++) { // 上一个点位
if (j != k && (i & (1 << (k - 1)))) {
dp[j][i] = min(dp[j][i], dp[k][i - (1 << (j - 1))] + d[j][k]);
}
}
}
}
}
for (int i = 1; i <= n; i++) {
ans = min(ans, dp[i][(1 << n) - 1]);
}
cout << fixed << setprecision(2) << ans << '\n';
return 0;
}
\end{verbatim}
}
\subsection{数位型动态规划}
\subsubsection{windy 数}
不含前导零且相邻两个数字之差至少为 $2$ 的正整数被称为 windy 数。windy 想知道,在 $a$ 和 $b$ 之间,包括 $a$ 和 $b$,总共有多少个 windy 数?
设 $dp_{(x,last)}$ 表示当前是第 $x$ 位,上一位的值是 $last$,所能获得的 windy 数的数量。
\begin{verbatim}
#include <iostream>
using namespace std;
using ll = long long;
const ll kMaxN = 10;
// t 表示每一位的最大值
ll t[kMaxN], dp[kMaxN][kMaxN], a, b;
// 记忆化搜索,lead 是否是前导数,limit 是否被数位限制
ll dfs(ll x, ll last, bool lead, bool limit) {
if (!x) { // 如果已经结束了,返回 $1$
return 1;
}
if (!lead && !limit && dp[x][last] != -1) {
return dp[x][last]; // 调用以前的记忆
}
ll up = limit ? t[x] : 9, ans = 0; // up 最大值
for (ll i = 0; i <= up; ++i) {
if (abs(i - last) < 2) { // 判断条件
continue;
}
if (lead && i == 0) {
ans += dfs(x - 1, -2, 1, limit && (i == t[x]));
} else {
ans += dfs(x - 1, i, 0, limit && (i == t[x]));
}
}
if (!lead && !limit) { // 记录
dp[x][last] = ans;
}
return ans;
}
ll S(ll x) {
ll l = 0;
for (; x; x /= 10) { // 分离数位
t[++l] = x % 10;
}
return dfs(l, -2, 1, 1); // 调用
}
int main() {
fill(dp[0], dp[0] + kMaxN * kMaxN, -1);
cin >> a >> b;
cout << S(b) - S(a - 1) << '\n';
return 0;
}
\end{verbatim}
\section{简单数学}
\subsection{快速幂}
我们知道 $x$ 个 $a$ 相乘乘上 $y$ 个 $a$ 相乘,等于 $x+y$ 个 $a$ 相乘,因此我们可以得出公式 $a^x\times a^y=a^{x+y}$。因此,设 $f(a,b)$ 表示 $a$ 的 $b$ 次方,我们可以这样算:
\[
f(a,b)=\left\{
\begin{array}{ll}
1~(b=0) \\
a~(b=1) \\
f(a,b-1)\times a~(b\bmod 2=1) \\
f(a,\dfrac{b}{2})^2~(b\bmod 2=0)
\end{array}
\right.
\]
\noindent
代码模板:
\begin{verbatim}
template <typename Ty>
Ty pow(Ty a, Ty b) {
if (!b) {
return 1;
}
if (b == 1) {
return a;
}
int t = pow(a, b / 2);
return t * t * (b & 1 ? a : 1);
}
\end{verbatim}
但是,我们还可以使用二进制的方式进行运算。如:$a^{(1101_{(2)})}\times a^{(1101_{(2)})}=a^{(11010_{(2)})}$,其实跟前面差不多,但是省去了递归步骤。
\begin{verbatim}
template <typename Ty>
Ty pow(Ty a, Ty b) {
int ans = 1;
for (; b; b >>= 1) {
if (b & 1) {
ans *= a;
}
ans *= ans;
}
return ans;
}
\end{verbatim}
\subsection{质数筛}
\subsubsection{埃氏筛}
若一个数为质数,则把它的倍数筛掉。
\begin{verbatim}
void prime(int n) {
for (int i = 2; i <= n; ++i) {
if (!f[i]) {
for (int j = 2; i * j <= n; ++j) {
f[i * j] = 1;
}
}
}
}
\end{verbatim}
\subsubsection{欧拉筛}
可以避免筛到重复的质数,具体原理不知道,反正我就只背代码。
\begin{verbatim}
void prime(int n) {
for (int i = 2; i <= n; ++i) {
if (!f[i]) {
v[++s] = i;
}
for (int j = 1; j <= s; ++j) {
if (i * v[j] > n) {
break;
}
f[i * v[j]] = 1;
if (!(i % v[j])) {
break;
}
}
}
}
\end{verbatim}
\subsection{其他模板}
\paragraph{gcd 和 lcm} 通过辗转相除法求出最小公因数,而 $\operatorname{lcm}(n,m)=\dfrac{nm}{\gcd(n,m)}$。
\begin{verbatim}
int gcd1(int n, int m) {
return !m ? n : gcd(m, n % m);
}
int gcd2(int n, int m) {
for (; m; n %= m, m ^= n, n ^= m, m ^= n) {
}
return n;
}
int lcm(int n, int m) {
return n * m / gcd(n, m);
}
\end{verbatim}
\part{STL}
*标准模板库 STL(Standard Template Library),是 C++ 标准库的一部分,不需要单独安装,只需要 \verb|#include| 头文件。C++ 对模板(Template)支持得很好,STL 就是借助模板把常用的数据结构及其算法都实现了一遍,并且做到了数据结构和算法的分离。{\footnotesize 借鉴自:https://zhuanlan.zhihu.com/p/344558356}
\section{算法库}
\subsection{序列遍历}
\begin{itemize}
\item \verb|for_each| 将区间内的每一个元素全部传入函数进行操作。
\item \verb|find| 返回迭代器等于第三个参数的迭代器,如果没有返回 last 迭代器。
\item \verb|find_if| 返回第一个满足函数返回值为 true 的迭代器,如果没有返回 last。
\item \verb|equal| 比较两个序列是否完全相等。
\item \verb|count| 返回区间内等于给定的值的数量。
\item \verb|lower_bound| 返回区间内第一个大于等于给定值的数,二分查找。
\item \verb|upper_bound| 返回区间内第一个大于给定值的数,二分查找。
\end{itemize}
\subsection{序列修改}
\begin{itemize}
\item \verb|swap| 交换两个变量的值。
\item \verb|replace| 将序列中的元素进行替换。
\item \verb|replace_if| 满足条件的时候进行替换操作。
\item \verb|sort| 在序列中进行排序,详解请看第 $8$ 页。
\item \verb|next_permutation| 下一个排列。
\end{itemize}
\section{容器库}
\subsection{顺序型容器}
\begin{itemize}
\item \verb|vector| 向量,可以看作一个动态数,可以自己申请内存,支持尾插尾删以及下标访问。
\item \verb|list| 双向链表,支持两边的插入删除,无法随机访问,但是有迭代器。
\item \verb|array| 固定大小的数组,是 \verb|vector| 的底层。
\item \verb|deque| 双端队列,常数特别大。
\end{itemize}
\subsection{关联式容器}
\begin{itemize}
\item \verb|map| 红黑树,可以映射、有序。
\item \verb|set| 红黑树集合,自动去重,有序。
\item \verb|multi-set/map| 多重映射、集合。
\end{itemize}
\subsection{散列表}
好渴鹅
\begin{itemize}
\item \verb|unordered-map/set| 无序的映射、集合,但是插入的时间复杂度是 $O(1)$。
\end{itemize}
\subsection{容器适配器}
\begin{itemize}
\item \verb|stack| 堆栈。
\item \verb|queue| 队列。
\item \verb|priority_queue| 优先队列、二叉堆。
\end{itemize}
\subsection{经典例题}
\subsubsection{合并果子}
$n$ 堆果子,你需要通过 $n-1$ 次合并让它们变成一堆果子。假如合并的两堆果子数量分别是 $a_i$ 和 $a_j$,那么它们合并需要花费的力气就是 $a_i+a_j$。请你求出最少需要多少的力气。
我们可以定义一个小根对,每次都取出最小值,这样子合并之后就是最小值了。对于小根对,我们可以直接使用 STL 的 \verb|priority_queue|。
\begin{verbatim}
int main() {
priority_queue<int, vector<int>, greater<int>> q; // 小根堆
cin >> n;
for (int i = 1; i <= n; i++) {
cin >> x, q.push(x);
}
while (q.size() > 1) {
x = q.top(), q.pop();
y = q.top(), q.pop();
q.push(x + y), ans += x + y;
}
cout << ans << '\n';
return 0;
}
\end{verbatim}
\part{数据结构}
\section{树}
\subsection{树的定义}
树是一个由 $n$ 个结点组成的有限集,它们之间有 $n-1$ 条边。每个结点都有 $0$ 个或以上个儿子,但是只有最多一个父亲。有根树有一个结点没有父亲,它被成为根。
\subsubsection{结点的度}
结点所拥有的儿子的数量就是这个节点的度,度分为出度和入度。在有根树中,入度为 $0$ 的结点就是根节点。
\subsubsection{树的深度}
树中结点的最大层次数称为树的深度或高度。
\subsubsection{树的直径}
树中两个结点的最远距离为树的直径。
\subsection{二叉树}
\subsubsection{二叉树的定义及性质}
如果一棵树每个结点都只有左儿子或右儿子,那么这棵树就叫做二叉树。
二叉树的第 $i$ 层最多有 $2^{i-1}$ 个结点。二叉树的深度如果是 $k$,那么结点数最多是 $2^k-1$。在完全二叉树当中,有 $n$ 个结点的树的深度为 $\lfloor log_2 n\rfloor$。
\subsubsection{满二叉树}
如果除了叶子结点以外的所有结点,其他的结点都有左儿子和右儿子,那么这颗二叉树就叫做满二叉树。
\subsubsection{完全二叉树}
对于每一个 $1\le i\le n$,如果结点 $i$ 与在同样深度的满二叉树中的同样编号的结点位置相同,那么这棵树就叫做满二叉树。满二叉树一定是完全二叉树,但是完全二叉树不一定是满二叉树。
\subsubsection{二叉树的遍历}
\begin{itemize}
\item \textbf{前序遍历}:先访问当前结点,再访问左右儿子。
\item \textbf{中序遍历}:先访问左儿子,再访问当前结点,最后访问右儿子。
\item \textbf{前序遍历}:先访问左右儿子,再访问当前结点。
\item \textbf{层序遍历}:一层一层遍历所有的结点。
\end{itemize}
\noindent
代码:
\begin{verbatim}
void dfs(int x, int o) { // o 表示当前的遍历信息
if (o == 1) { // 前序遍历
cout << x << ' ';
}
dfs(ls(s), o);
if (o == 2) { // 中序遍历
cout << x << ' ';
}
dfs(rs(s), o);
if (o == 3) { // 后序遍历
cout << x << ' ';
}
}
void bfs(int x) { // 层序遍历
queue<int> q;
for (q.push(x); q.size(); q.pop()) {
cout << q.front() << ' ';
q.push(ls(q.front()));
q.push(rs(q.front()));
}
}
\end{verbatim}
\subsection{简单树上问题}
\subsubsection{根结点到任意节点距离}
给定一个结点数为 $n$ 的树,每条边距离为 $1$,你需要求出根节点到所有结点的距离。
\begin{verbatim}
void dfs(int x, int f, int s) {
d[x] = s;
for (int i : e[x]) {
if (i != f) {
dfs(i, x, s + 1);
}
}
}
\end{verbatim}
\subsubsection{树的直径}
给定一个结点数为 $n$ 的树,你需要求出这棵树的直径。我们可以先从根节点找到离根节点最远的结点,再从这个结点出发,找到离这个结点最远的结点。
\begin{verbatim}
void dfs(int x, int f, int s) {
d[x] = s;
for (int i : e[x]) {
if (i != f) {
dfs(i, x, s + 1);
}
}
}
int main() {
read(), dfs(1, 0, 0);
int id = max_val_point(d + 1, d + n + 1);
dfs(id);
cout << *std::max_element(d + 1, d + n + 1) << '\n';
return 0;
}
\end{verbatim}
\subsubsection{没有上司的舞会 / 树形dp}
某大学有 $n$ 个职员,编号为 $1\ldots n$。他们之间有从属关系,也就是说他们的关系就像一棵以校长为根的树,父结点就是子结点的直接上司。
现在有个周年庆宴会,宴会每邀请来一个职员都会增加一定的快乐指数 $r_i$,但是呢,如果某个职员的直接上司来参加舞会了,那么这个职员就无论如何也不肯来参加舞会了。
所以,请你编程计算,邀请哪些职员可以使快乐指数最大,求最大的快乐指数。
\subsubsection*{解法}
我们设 $dp_{(i,j)}$ 表示当前选不选择第 $i$ 个人所能获得的最大快乐值,其中 $j\in [0,1]$。随后进行递归转移即可。
\begin{verbatim}
#include <iostream>
#include <vector>
using namespace std;
const int kMaxN = 6e3 + 5;
int a[kMaxN], d[kMaxN], dp[kMaxN][2], n, r;
vector<int> e[kMaxN];
void dfs(int x) {
for (int i : e[x]) {
dfs(i); // 递归提前处理完子树
dp[x][0] += max(dp[i][0], dp[i][1]); // 不选当前上司
dp[x][1] += dp[i][0]; // 选择当前上司
}
dp[x][1] += a[x]; // 当前上司一起跳舞
}
int main() {
cin >> n;
for (int i = 1; i <= n; i++) {
cin >> a[i];
}
for (int i = 1, l, k; i < n; i++) {
cin >> l >> k;
e[k].push_back(l); //存边
d[l]++; // 入度加一
}
for (int i = 1; i <= n; i++) {
if (!d[i]) { // 找根结点
r = i;
break;
}
}
dfs(r);
cout << max(dp[r][0], dp[r][1]) << '\n';
return 0;
}
\end{verbatim}
\section{图}
\subsection{图的定义}
图是用边连接起来的顶点的集合,它比树更加复杂,因为每个结点可能不只有一个父亲。图分为有向图与无向图,有向图的每条边是有明确的方向的,而无向图没有。边是可以带权值的。
一个有 $n$ 个节点的完全图是一个无向图,共有 $n(n-1)\div 2$ 条边。
\subsection{图的存储方式}
\subsubsection{邻接矩阵}
定义一个二维数组 $a_{(n,n)}$,访问 $a_{(i,j)}$ 即可得到 $i$ 到 $j$ 的边权,若为 $0$ 则表示没有这条边。一般用于获取任意两点的边权,但是需要 $n^2$ 的巨大空间。
\subsubsection{邻接表}
可以看作 $n$ 个长度不等的数组,而数组里面的每一个数都表示这个结点相邻的结点,一般使用 \verb|vector| 进行实现。
\subsubsection{链式前向星}
跟邻接表很像,但是换成了链表,并且速度嘎嘎快,还不用一直申请空间。就是难以理解,可以说是大佬的装 13 神器。其实可以使用 \verb|list| 的……
\subsection{图的遍历}
\noindent
采用邻接表进行存储。
\subsubsection{深度优先遍历}
\noindent
其实就是 dfs:
\begin{verbatim}
void dfs(int x) {
if (f[x]) {
return;
}
f[x] = 1;
cout << x << ' ';
for (int i : e[x]) {
dfs(i);
}
}
\end{verbatim}
\subsubsection{宽度优先遍历}
\noindent
其实就是 bfs:
\begin{verbatim}
void bfs(int x) {
queue<int> q;
for (q.push(x); q.size(); q.pop()) {
cout << q.front() << ' ';
for (int i : e[x]) {
Record(i);
}
}
}
\end{verbatim}
\subsection{最短路径算法}
\subsubsection{Floyed-Warshall}
采用 dp 的思想。设 $dp_{(i,j)}$ 表示 $i$ 到 $j$ 的最短路径,那么对于任意的 $k$,$dp_{(i,j)}=\min(dp_{(i,j)}, dp_{(i,k)} + dp_{(k,j)})$。
\begin{verbatim}
void floyed() {
for (int k = 1; k <= n; k++) {
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++) {
dp[i][j] = min(dp[i][j], dp[i][k] + dp[k][j]);
}
}
}
}
\end{verbatim}
\subsubsection{Dijkstra}
采用贪心的思想,求一个点到其他点的最短路。每一步都从最短的路经走到其他地方,一般使用 \verb|priority_queue| 进行取最小值。此算法不能处理负边权的情况。
\begin{verbatim}
void dijkstra(int s) {
priority_queue<Edge> q;
fill(d + 1, d + n + 1, 1e9);
fill(f + 1, f + n + 1, 0);
for (q.push({s, 0}), d[0] = 0; q.size(); ) {
Edge t = q.top();
q.pop();
if (f[t.to]) {
continue;
}
f[t.to] = 1;
for (auto i : e[t.to]) {
if (d[t.to] + i.v < d[i.to]) {
d[i.to] = d[t.to] + i.v;
q.push({i.to, d[i.to]});
}
}
}
}
\end{verbatim}
\subsubsection{Sortest Path Faster Algorithm}
关于 SPFA,她死了。
\subsection{无向图的最小生成树}
\subsubsection{kruskal}
每次确定一条边。首先我们排序一遍,若当前边的顶点已经可以联通,那么换下一条边,否则加入生成树。需要用到并查集。
\begin{verbatim}
int F(int x) {
return f[x] == x ? x : f[x] = F(f[x]);
}
void U(int u, int v) {
u = F(u), v = F(v);
if (u != v) {
f[u] = v;
}
}
void kruskal() {
sort(e + 1, e + m + 1);
for (int i = 1; i <= m; i++) {
if (F(e[i].x) == F(e[i].y)) {
continue;
}
U(e[i].x, e[i].y);
ans += e[i].v;
}
}
\end{verbatim}
\subsection{拓扑排序}
递推型动态规划一般不能处理图的问题,因为图是没有拓扑序的。一个定点可能有多个入度或者多个出度,而缺少了动态规划必须的拓扑序。但是其实我们可以对图进行拓扑排序,找到这个图的拓扑序,然后再进行动态规划。
拓扑序仅在有向无环图里面出现,一个同样的图可能会有多个拓扑序。
\paragraph{基本思想}
首先我们选取所有入度为 $0$ 的顶点加入队列,然后进行 bfs。每次遍历邻点,并把这些点的入度减一。当入度为零时,加入拓扑序列。
\begin{verbatim}
void topo_sort() {
queue<int> q;
for (int i = 1; i <= n; i++) {
if (!d[i]) {
q.push(i), cout << i << ' ';
}
}
for (; q.size(); q.pop()) {
for (int i : e[q.front()]) {
if (!--deg[i]) {
q.push(i), cout << i << ' ';
}
}
}
}
\end{verbatim}
\section{高级数据结构}
\subsection{哈希表}
有的时候,我们需要使用数组记录下标,但是下标可能会非常大,但是存储的数据却不多,这时我们就需要一个名叫“哈希表”的东西进行使用了。
\subsubsection{哈希表的创建}
哈希表的原理就是用一个哈希函数,使得一些数的的哈希值都在哈希表的可承受范围之内。哈希表本质上就是一个数组,但是大小并没有这么大,而是通过哈希函数映射成一个较小范围的数。
哈希表的基本结构:
\begin{verbatim}
template <typename Ty> // 类型名
class hash_tale {
using value_type = Ty;
using reference = Ty &;
private: // 私有成员
static constexpr auto kMaxN = 114514 + 17;
// 越大越不容易冲突
value_type f[kMaxN] = {}; // 表
value_type hash(value_type x) { // 哈希函数
return x % kMaxN;
}
public:
// 重载下标运算符,注意返回的是引用
reference operator[](int x) {
return f[this->hash(x)];
}
};
\end{verbatim}
这里,我们选择的是一个较大的数 $114514+17$,这个模数越大哈希表冲突的可能性就越小,但是哈希表的内存占用就会越大。如果你需要开二维的哈希表,那么 \verb|kMaxN| 估计就只能开到 $1007$ 了。
注意我们重载了 \verb|operator[]| 下标运算符,但是返回的得是引用变量,不然你无法在外部更改值。
\subsubsection{冲突的处理}
这个哈希表有着很大的缺陷,因为有可能有相同的两个数有着同样的模数,而它们被映射到了同一个下标位置。为了解决冲突,我们可以使用最简单的“线性探查法”。即记录下当前位置所有键值,这样就可以解决冲突的处理。
\begin{verbatim}
template <class Key, class Ty>
struct Node {
Key key;
Ty data;
};
template <class Key, class Ty>
class hash_table {
using key_type = Key; // 键值类型
using value_type = Ty; // 数据类型
using size_type = size_t; // 长度类型
using reference = Ty&; // 引用
using const_reference = const Ty&; // 常引用
private:
static constexpr auto kMaxN = 114514 + 17;
vector<Node<Key, Ty> > a[kMaxN];
size_type hash(key_type x); // 哈希函数
public:
reference operator[](key_type x) {
size_type hash_code = this->hash(x);
for (auto i : a[hash_code]) { // 线性探查
if (i.key == x) { // 如果匹配
return i.data; // 返回引用
}
}
value_type temp; // 初始化一个空值
a[hash_code].push_back({hash_code, temp}); // 推进哈希表
return a[hash_code].back().data; // 返回最后一个值
}
};
\end{verbatim}
\subsubsection{字符串哈希}
如果键值是一个字符串,那我们该怎么办呢?其实我们可以把它看作是一个1$128$ 进制的数字,然后使用 \verb|unsigned| 的自然溢出,最后在摸上模数,这样子字符串哈希就完成了。
\begin{verbatim}
private: size_type hash(string &s) {
unsigned x = 0;
for (char c : s) {
x *= 128, x += c;
}
return x % kMaxN;
}
\end{verbatim}
\subsection{并查集}
并查集是一种树形的结构,重点不在于遍历所有的点而是记录父亲。并查集使用起来就是集合,拥有查找、合并等操作。
\subsubsection{并查集初始化}
最开始,有 $n$ 棵树,第 $i$ 棵树只有自己一个结点。当想要合并时,就把一棵树的根的父亲改为另一棵树,这样子它们就合并成了一个集合。
\begin{verbatim}
void init() {
for (int i = 1; i <= n; i++) {
f[i] = i;
}
}
\end{verbatim}
这样子,每个结点的父亲就变成了自己,每个结点都是独立的树。
\subsubsection{并查集合并}
接下来,我们需要将一棵树的根节点的父亲改为另一棵树:
\begin{verbatim}
void U(int u, int v) { // u, v 是根节点
f[u] = v; // 谁是谁的爸爸并不重要,反过来也行
}
\end{verbatim}
但是这里 $u$ 和 $v$ 都必须是根节点,而两棵树合并之后可能还会和下一棵树进行合并,因此我们需要实现一个寻找祖宗的函数。
\subsubsection{并查集查找}
我们可以使用递归一直往当前的爸爸上找,直到 $f_x=x$,也就是已经是根了我们就停止:
\begin{verbatim}
int F(int x) {
return f[x] == x ? x : F(f[x]);
}
\end{verbatim}
但是这样子当 $n$ 棵树完全合并到了一起时,查找就可能会变成 $O(n)$,因此我们可以直接存储当前结点的祖宗,这样子就只用遍历一遍,平均速度就达到了约 $O(\log n)$。
\begin{verbatim}
int F(int x) {
return f[x] == x ? x : f[x] = F(f[x]);
}
\end{verbatim}
接着,我们的合并函数也得加上查找祖宗。
\begin{verbatim}
void U(int u, int v) {
u = F(u), v = F(v); // 寻找祖宗
if (u != v) { // 如果不在一棵树里面
f[u] = v;
}
}
\end{verbatim}
\subsubsection{模板练习题}
给定 $n$ 个独立的集合和 $m$ 个询问。对于每一个询问,输入 $o,x,y$,如果 $o=1$ 表示合并 $x$ 和 $y$,否则判断 $x$ 和 $y$ 是否在一个集合内。
\noindent
伪代码:
\begin{verbatim}
void input();
void init();
int F(x); // 这些都是模板
void U(u, v);
int main() {
input(), init();
for (; m; m--) {
cin >> o >> x >> y;
cout << (o == 1 ? (U(x, y), "") : (F(x) == F(y) ? "Yes" : "No")) << '\n';
}
return 0;
}
\end{verbatim}
\subsection{线段树}
线段树是一种经常用来维护区间信息的数据结构,可以在 $O(\log_2 n)$ 的时间复杂度之内完成单点修改、区间修改、区间查询等操作,底层是一个二叉树。
\subsubsection{结构与建树}
我们可以画出一颗树的形态,每往下一层就会将上一层管辖的范围分成两半,然后递归进行操作。我们可以递归建树,当前建树的区域为 $[l,r]$,所以我们进行递归建好左右子树,并累加上左右子树的区间值。代码如下:
\begin{verbatim}
void build(int l, int r, int p) {
if (l == r) { // 如果当前子树只有一个结点
t[p] = a[l]; // 直接赋值
return;
}
int m = l + r >> 1; // 找出分界点
build(l, m, 2 * p); // 建左子树
build(m + 1, r, 2 * p + 1); // 建右子树
t[p] = t[2 * p] + t[2 * p + 1]; // 累加上左右子树的和
}
\end{verbatim}
\subsubsection{区间查询}
比如求 $[l,r]$ 当中的和 / 最大值可以使用线段树,达到 $O(\log_2 n)$ 的效果。
当我们需要求出 $[l,r]$ 的和的时候,很显然并不一定有一个子树 $t_i$ 可以直接满足要求,但是我们可以将它拆分成两个区间 $[l,r]$ 和 $[m,r]$,然后继续递归调用。
\begin{verbatim}
// 当前查询区间 [l, r],当前结点区间为 [s, t]。
int query(int l, int r, int s, int t, int p) {
if (l <= s && t <= r) { // 已经包含在了 [s, t] 里面
return t[p];
}
int m = s + t >> 1, s = 0;
if (l <= m) { // 如果 [s, t] 与 [l, r] 有交集
s += query(l, r, s, m, 2 * p); // 递归做儿子
}
if (r > m) {
s += query(l, r, m + 1, t, 2 * p + 1);
}
return s;
}
\end{verbatim}
\subsubsection{区间修改、惰懒标记}
对于区间修改,我们可以直接递归子树相加,但是这是 $O(n)$ 的,不能承受。所以我们需要一个叫做躲懒标记的东西。这个东西被标记了之后只会在下一次访问当前结点的时候再去进行修改,减少了不必要的麻烦。
\noindent
区间修改:
\begin{verbatim}
// 修改 [l, r],当前子树是 [s, t],当前编号是 p,每个结点加上 c
void update(int l, int r, int c, int s, int t, int p) {
if (l <= s && t <= r) {
t[p] += (t - s + 1) * c, f[p] += c; // 直接相加,并加上标记
return;
}
int m = s + t >> 1;
if (s != t && f[p]) { // 如果有标记
t[2 * p] += t[p] * (m - s + 1); // 左子树相加
t[2 * p + 1] += t[p] * (t - m); // 右子树相加
f[p] = 0; // 标记清空
}
if (l <= r) { // 递归左子树
update(l, r, c, s, m, 2 * p);
}
if (r > m) { // 递归右子树
update(l, r, c, m + 1, t, 2 * p + 1);
}
t[p] = t[2 * p] + t[2 * p + 1];
}
\end{verbatim}
\noindent
区间查询:
\begin{verbatim}
void query(int l, int r, int s, int t, int p) {
if (l <= s && t <= r) {
return t[p];
}
int m = s + t >> 1, s = 0;
if (f[p]) { // 如果有标记
t[2 * p] += t[p] * (m - s + 1); // 左子树相加
t[2 * p + 1] += t[p] * (t - m); // 右子树相加
f[p] = 0; // 标记清空
}
if (l <= m) {
s += query(l, r, s, m, 2 * p);
}
if (r > m) {
s += query(l, r, m + 1, t, 2 * p + 1);
}
return s;
}
\end{verbatim}
\noindent
至此,简单的线段树就完成了!
\subsection{树状数组}
树状数组的代码极少,支持单点修改、区间查询,但是特别不好理解。好渴鹅这里只给代码,不给树状数组的原理。如果需要可以自己百度。
\subsubsection{单点修改}
首先,我们需要一个函数 $lowbit(x)$ 求出 $x$ 的二进制当中最低为的 $1$ 与后面的 $0$ 所组成的数。通过位运算我们可以很快的算出结果。
\begin{verbatim}
int lowbit(int x) {
return x & -x;
}
\end{verbatim}
\noindent
然后我们从 $x$ 开始,一直跳跳跳直到大于 $n$。
\begin{verbatim}
void update(int x, int y) {
for (; x <= n; x += lowbit(x)) {
t[x] += y;
}
}
\end{verbatim}
\subsubsection{区间查询}
\noindent
通过以下的 \verb|query| 函数,我们可以获取到 $1-x$ 的和:
\begin{verbatim}
int query(int x) {
int ans = 0;
for (; x; x -= lowbit(x)) {
ans += t[x];
}
return ans;
}
\end{verbatim}
\noindent
那么问题来了,怎么计算 $[l,r]$ 的和呢?很简单,做个差分就行了:
\begin{verbatim}
@override
int query(int l, int r) {
return query(r) - query(l - 1);
}
\end{verbatim}
\subsubsection{例题 逆序对 / 树状数组}
同样的题目,归并排序版本可以看 4.3.1。
我们首先将 $n$ 个数定义成结构体,进行按照值的大小进行稳定排序,然后我们定义一个 \verb|rank| 数组,表示之前的第 $i$ 个数排序了之后变成了第 $rank_i$ 个数。然后我们遍历 $rank_i$ 数组,每次往树状数组的第 $rank_i$ 个点上添加 $1$ 的值,然后 $ans$ 加上 $i-\sum\limits_{j=1}^{r_i}t_j$ 表示答案加上当前数字的排名减去更高的排名,就是当前数字的逆序对。上代码:
\begin{verbatim}
#include <iostream>
#include <algorithm>
using namespace std;
using ll = long long;
const ll kMaxN = 5e5 + 5;
struct Node {
ll v, id;
friend bool operator<(const Node &a, const Node &b) {
if (a.v == b.v) {
return a.id < b.id;
}
return a.v < b.v;
}
} a[kMaxN];
ll t[kMaxN], r[kMaxN], n, ans;
inline ll lowbit(ll x) {
return x & -x;
}
void update(ll x, ll y) {
for (; x <= n; x += lowbit(x)) {
t[x] += y;
}
}
ll query(ll x) {
ll ans = 0;
for (; x > 0; x -= lowbit(x)) {
ans += t[x];
}
return ans;
}
int main() {
cin >> n;
for (ll i = 1; i <= n; i++) {
cin >> a[i].v, a[i].id = i;
}
sort(a + 1, a + n + 1);
for (ll i = 1; i <= n; i++) {
r[a[i].id] = i; // r[i] 表示 i 的排名
}
for (ll i = 1; i <= n; i++) {
update(r[i], 1); // 先将 i 的排名加进去
ans += i - query(r[i]);
// i 减去比 i 更低的排名的数量
// 因为 i 以前的数在 i 前面,如果前面一个数大于右边的一个数那么就产生了逆序对。query 求出比 i 小的数,被 i 减去就是 i 的逆序对
}
cout << ans << '\n';
return 0;
}
\end{verbatim}
\end{document}