目录
二、数据结构
2.1 链表
- 单链表最常用的是邻接表($n$个单链表),主要用于存储图和树
- 双链表用于优化某些问题
2.1.1 单链表
竞赛或笔试时往往不用结构体+指针的方式实现链表,而是用数组模拟,这样时间效率更高。
单链表在数组中的存储:
对于每个结点,$\rm e[i]$存储其值,$\rm ne[i]$存储其$\rm next$指针,表示其指向的下一个结点,空节点下标用$-1$表示
const int N = 1e5 + 10;
int e[N], ne[N], head, idx;//head是头结点的next指针,表示头结点指向的下一个结点(就是首元结点!)的下标。idx表示当前结点的下标(理解为指向待插入位置的指针)。
//初始化
void init()
{
head = -1;//表示链表中没有元素,头结点指向空
idx = 0;//表示当前可利用第0号结点
}
//头插法
void add_to_head(int x)
{
e[idx] = x;//将x的值存储到当前结点
ne[idx] = head;//让当前结点指向原来head的下一个结点(即首元结点)
head = idx;//让头指针指向该结点
idx ++;//idx已经被用过了(已经被编号了),∴待插入的位置变为idx+1.
}
//将x插入到下标为k的结点后面
void add(int x, int k)
{
e[idx] = x;
ne[idx] = ne[k];
ne[k] = idx;
idx ++;
}
//将下标是k的点后面的那个点删掉
void remove(int k) //注意remove是系统函数,需要注意改变一下名称
{
ne[k] = ne[ne[k]];//让下标是k的点直接指向后面的第2个点(这里不用++k是因为k未必连续,ne[k]的下一个数未必是ne[k+1].)
}
for (int i = head; i != -1; i = ne[i]) cout << e[i] << " ";//遍历方式
Leetcode中常用的链表操作:
① 合并两个链表:
void mergeList(ListNode* l1, ListNode* l2) {
ListNode* l1_tmp;
ListNode* l2_tmp;
while (l1 != nullptr && l2 != nullptr) {
l1_tmp = l1->next;
l2_tmp = l2->next;
l1->next = l2;
l1 = l1_tmp;
l2->next = l1;
l2 = l2_tmp;
}
}
② 反转链表:
ListNode* reverseList(ListNode* head) {
ListNode* prev = nullptr;
ListNode* curr = head;
while (curr != nullptr) {
ListNode* nextTemp = curr->next;
curr->next = prev;
prev = curr;
curr = nextTemp;
}
return prev;
}
③ 寻找链表中间结点:(注:快慢指针在链表题中很重要!)
ListNode* middleNode(ListNode* head) {
ListNode* slow = head;
ListNode* fast = head;
while (fast->next != nullptr && fast->next->next != nullptr) {
slow = slow->next;
fast = fast->next->next;
}
return slow;
}
2.1.2 双链表
const int N = 1e5 + 10;
int e[N], l[N], r[N], idx;//e[N],idx含义都与单链表中相同,l[i],r[i]表示当前结点前一个点的下标和后一个点的下标。
//初始化:我们认为0是左端点,1是右端点,有且仅有这两个结点。(当做哨兵,不存储元素的值!)
void init()
{
r[0] = 1, l[1] = 0;
idx = 2;//0和1都被占用,从2开始可用
}
//在下标为k的结点右边插入x
void insert(int k, int x)
{
e[idx] = x;
r[idx] = r[k];
l[idx] = k;
l[r[k]] = idx;
r[k] = idx;//注意这几步的顺序!
idx ++;
}
//删除下标为k的点
void delet(int k)
{
r[l[k]] = r[k];
l[r[k]] = l[k];
}
如要在下标为$k$的结点左边插入$x,$则可调用$\rm insert(l[k], x).$
注意不是$\rm insert(k-1,x),$因为下标未必连续,且中间可能进行了其他操作,$k-1$也未必就在$k$的左边。
2.2 栈
int stk[N], tt = 0;//tt表示栈顶
stk[ ++ tt] = x;// 向栈顶插入一个数
tt -- ;// 从栈顶弹出一个数
stk[tt];// 求栈顶的值
if (tt > 0)// 判断栈是否为空,如果 tt > 0,则表示不为空
栈的经典应用:表达式求值 (模板题,建议背一下)
补充:后缀表达式求值的方法
① 从左到右读入后缀表达式,若读到的是操作
数,将它压入堆栈。② 若读到的是运算符,就从堆栈中连续弹出两
个元素(操作数),进行相应的运算,并将
结果压入栈中。③ 读入结束符时,栈顶元素就是计算结果。
2.3 队列
int q[N], hh = 0, tt = -1; //hh表示队头,tt表示队尾
q[++ tt] = x;//向队尾加入一个元素
hh++;//从队头弹出一个数
q[hh];//取出队头的值
q[tt];//取出队尾的值
if (hh <= tt) //队列不为空
2.4 单调栈
主要用于四种情况(实际情况下还可能更多,如“寻找一个数右边最接近的小于等于它的数”)
$1.$ 寻找一个数左边最接近的小于它的数 例题:AcWing 830. 单调栈
while (n--)
{
int x;
cin >> x;
while (tt && stk[tt] >= x) tt--;//这里是≥,与要求的“小于”相反!
if (tt) cout << stk[tt] << " ";//栈顶元素就是左侧第一个比它小的元素
else cout << "-1 ";//如果栈空,说明没有比其小的值
stk[++tt] = x;
}
$2.$ 寻找一个数左边最接近的小于它的数的下标 (注意下标是从0开始还是从1开始!从而判断数组开头是0还是1)
只需对栈做一点小小的修改就能应对情形二。注意到之前我们寻找的是元素所以让栈去保存元素,现在我们寻找下标,所以让栈去保存元素的下标就可以了。
for (int i = 0; i < n; i++) cin >> a[i];
for (int i = 0; i < n; i++) {
while (!stk.empty() && a[stk.top()] >= a[i]) stk.pop(); // 仅有两处修改
if (!stk.empty()) ans[i] = stk.top();
else ans[i] = -1;
stk.push(i); // 仅有两处修改
}
for (int i = 0; i < n; i++) cout << ans[i] << ' ';
$3.$ 寻找一个数右边最接近的大于它的数
之前我们是在一个数的左边去寻找,所以让栈去保存这个数左边的所有数,类似地,现在需要让栈去保存这个数右边的所有数。
考虑将数组翻转(实际上不可能翻转,而是倒序遍历),因此情形三变成了「寻找一个数左边第一个大于它的数」,于是归结为情形一。
for (int i = 0; i < n; i++) cin >> a[i];
for (int i = n - 1; i; i--) {
while (!stk.empty() && stk.top() <= a[i]) stk.pop();//符号为≤,与要求的“大于”相反!
if (!stk.empty()) ans[i] = stk.top();
else ans[i] = -1;
stk.push(a[i]);
}
for (int i = 0; i < n; i++) cout << ans[i] << ' ';
$4.$ 寻找一个数右边最接近的大于它的数的下标(注意下标是从0开始还是从1开始!从而判断数组开头是0还是1)
for (int i = 0; i < n; i++) cin >> a[i];
for (int i = n - 1; i; i--) {
while (!stk.empty() && a[stk.top()] <= a[i]) stk.pop();
if (!stk.empty()) ans[i] = stk.top();
else ans[i] = -1;
stk.push(i);
}
for (int i = 0; i < n; i++) cout << ans[i] << ' ';
看上去有两重循环,其实每一个元素最多进栈一次出栈一次,所以复杂度仍是线性的。
本质上单调栈维护的是数列所有前缀的后缀最值。(一个下标 $i$ 是当前 $a$ 的后缀最大值下标当且仅当:对于所有的$ i < j \leq |a|,$都有 $a_i > a_j,$其中 $|a|$表示当前$a$的元素个数。)可以通过调整比较条件里的大于号、小于号、大于等于号、小于等于号来用这一数据结构维护所有前缀的后缀最大、最小、非严格最大、非严格最小值。
2.5 单调队列
常见模型:用于求滑动窗口中的最大值/最小值(用双端队列$\rm deque$来实现) 例题:滑动窗口
2.6 KMP算法
2.7 Trie树
高效地存储和查找字符串集合的数据结构
- $\rm son[N][26]$表示当前节点的儿子(一维表示节点总数,二维表示节点与节点之间的关系(谁是谁儿子))(本题全是小写字母,所以数组第二维最多是26);$\rm cnt[]$表示以当前结点结尾的串有多少个;$\rm idx$表示当前用到了哪个下标(相当于下标分配器,给每个节点一个唯一编号)
例:比如$[0][1]=3, [0]$表示根节点,$[1]$表示它有一个儿子$'b’,$这个儿子的下标是$3,$接着如果有一个$[3][2]=8 ; $说明根节点的儿子$'b’$也有一个儿子$’c’,$这个孙子的下标就是$8,$下标由$\rm idx$分配。这样传递下去,就是一个字符串。
- 下标是0的点,既是根节点,又是空节点(一个结点没有子节点则使其指向0)
- $\rm Trie$树本质上是多叉树,$\rm son[N][26]$就是分配的N个节点给$\rm trie$使用,同时每个节点有$26$个状态,每次只有一个状态是成立的
const int N = 1e5 + 10;
int son[N][26], cnt[N], idx;
//插入操作
void insert(char str[])
{
int p = 0;//从根节点开始遍历
for (int i = 0; str[i]; i++) {
int u = str[i] - '0';//把当前字母映射到子节点的编号
if (!son[p][u]) son[p][u] = ++ idx;//没有子节点就创建一个
p = son[p][u];//走到p的子节点
}
cnt[p] ++;//以该点为末尾的单词数量++.
}
//查询操作
void query(char str[])
{
int p = 0;
for (int i = 0; str[i]; i++) {
int u = str[i] - '0';
if (!son[p][u]) return 0;
p = son[p][u];
}
return cnt[p];
}
2.8 并查集
1.将两个集合合并
2.询问两个元素是否在一个集合当中
基本原理:每个集合用一棵树来表示,树根的编号就是整个集合的编号。每个结点存储它的父结点,$p[x]$表示$x$的父结点。
$\rm question1:$
如何判断树根:if (p[x] == x)
(认为祖宗节点的父节点是自己本身)
$\rm question2:$
如何求x的集合编号:while (p[x] != x) x = p[x];
$\rm question3:$
如何合并两个集合:px是x的集合编号,py是y的集合编号,p[px] = py.
优化:路径压缩
对于结点x,当其迭代找到它的根结点(集合编号)时,便将其整条路径上的结点全都直接指向根结点。做完路径压缩优化之后,时间复杂度可以达到几乎$O(1)$.
其实还有另一个优化称为按秩合并,但由于其对于性能的优化并不特别明显且代码量需增加几乎一倍,所以实际上用的很少.
2.9 手写堆
操作:(前三个操作可以用$\sf STL$中的堆来实现)
(这里以小根堆为例)
- 插入一个数:
heap[++size] = x; up(size);
(因为是在最后一个位置插入x,所以是up操作向上调整) - 求集合当中的最小值:
heap[1];
($O(1)$) - 删除最小值:
heap[1] = heap[size];
size--;
down(1);//向下调整
小根堆第一个元素为最小值,但是数组的第一个元素不好删,故先把heap[1]赋成最后一个元素的值,再删除最后那个元素即可。
- 删除任意一个元素:
heap[k] = heap[size];
size--;
down(k);//
up(k); //这两条语句只会执行一条,因为heap[k]只有在变大的时候才会执行down(k),变小时才执行up(k)
- 修改任意一个元素:
heap[k] = x;
down(k);
up(k);
堆是一棵完全二叉树。
以小根堆为例,其每个结点都小于等于其左右儿子的值,因此根结点即是最小值。
堆状数据结构(完全二叉树)的存储: 一维数组
根结点下标为$1$,$x$的左儿子下标为$2x$,右儿子下标为$2x+1$. (如果根结点下标从0开始,则0的左儿子下标为2*0仍为0,不太方便)
核心操作:up(), down()(某个元素变大——向下调整;某个元素变小——向上调整)时间复杂度:$O(log_n)$ (与树的高度成正比)
void down(int u) {
int t = u;
if (u * 2 <= mySize && h[u * 2] < h[t]) t = 2 * u;//左儿子存在且比根结点小
if (u * 2 + 1 <= mySize && h[u * 2 + 1] < h[t]) t = 2 * u + 1;//右儿子存在且比根结点小
if (u != t) {//如果根结点不是最小的,就交换根结点和儿子中的最小值
swap(h[u], h[t]);
down(t);//递归处理
}
}
//这个递归的含义是为一个相对‘大’的数向下找到其在树中合适的位置
void up(int u) {
while (u / 2 && h[u / 2] > h[u]) {//父节点存在且比子节点大,则交换
swap(h[u / 2], h[u]);
u /= 2;
}
}
//down要和两个子节点比较,而up只用和父节点比较,可以迭代到祖宗节点,故用循环进行迭代即可。
应用:堆排序(把一组数放入堆中,每次取堆顶元素)
注:将一组数建堆,可以通过insert的方式,但时间复杂度是$O(nlog_n)$(insert一次是$O(log_n)$,n次就是$O(nlog_n)$。
for (int i = n / 2; i >= 1; i--) down(i);
这样的复杂度是$O(n)$的(可由数学证明得出)
从$\dfrac{n}{2}$开始down的原因:要进行down操作时必须满足左儿子和右儿子已经是个堆。开始创建堆的时候,元素是随机插入的,所以不能从根节点开始down,而是要找到满足下面三个性质的结点:
1.左右儿子满足堆的性质。
2.下标最大(①因为要向上遍历,这样才能逐一满足每个结点的左右儿子都满足堆;②使down操作不会向下过深,节约时间)
3.不是叶结点(叶结点一定满足堆的性质)
换言之,n是最大值,而$\dfrac{n}{2}$是n的父节点,所以$\dfrac{n}{2}$是最大的有子节点的父节点(树的倒数第二层),从$\dfrac{n}{2}$向前遍历就可将heap数组遍历一遍。
2.10 哈希
$$
哈希表:
\begin{cases}
存储方式(解决冲突的方式):\begin{cases}
开放寻址法\
拉链法\
\end{cases}\
字符串哈希
\end{cases}
$$
【补充】离散化是一种特殊的哈希方式
基本操作:插入、查找(极少会删除,即使要删也是在对应元素上用bool值做一下标记)
Hash函数一般将比较复杂(较大)的定义域(如$-109$~$109$)映射为较好维护的值域(如 $0$~$10^5$)。由于值域变简单、范围变小,有可能造成两个不同的原始信息被Hash函数映射为相同的值($k_i ≠k_j$时可能有$h(k_i)=h(k_j)$),我们将这种情况称为哈希冲突。我们将具有不同关键字但具有相同哈希地址的元素称为「同义词」,这种冲突也称为同义词冲突。
当Hash函数设计较好时,原始信息会比较均匀地分配到各个表头之后,从而使每次查找、统计的时间降低到“原始信息总数除以表头数组长度”。若原始信息总数和表头数组长度均是$O(N)$级别且Hash函数分布均匀,几乎不产生冲突,那么每次查找、统计的时间复杂度期望为$O(1)$. 哈希算法是一种期望算法.
2.10.1 整数哈希
当key为整数时,h(key)称为整数哈希。
设哈希表要存储的元素个数为n,一般寻找大于等于n且最接近n的质数(设为m),然后开一个长度为m的数组。
事实上,若质数m能够离2的幂尽可能的远,则冲突概率可以进一步降低,这里我们可以根据 $n$ 的数量级来给出一个简化版的表格:
$n$ | $m$ |
---|---|
$10^3$ | $1543$ |
$10^4$ | $12289$ |
$10^5$ | $196613$ |
$10^6$ | $1572869$ |
注意到k可能是负数,所以我们需要将哈希函数修改成:$h(k)=(k \bmod m+m)\bmod m$来确保$h(k) ≥0$。(若直接$(k+m)\bmod m$则不行,因为若$k$取$-109$,$m$为$105$量级,则$k+m$仍是负数)
2.10.1.1 拉链法
拉链法是把所有的同义词用单链表链接起来的方法。在这种方法中,哈希表的每个单元存储的不再是元素本身,而是相应同义词单链表的头指针(注意是头指针而不是头节点)。
对于单链表,我们可以采用数组的方式进行实现。此外,使用拉链法时,$m$ 的大小通常和 $n$ 差不多。例如,若 $n \leq 10^5$ ,我们可以寻找大于等于 $10^5$的第一个质数,即 $m = 100003$。
2.10.1.2 开放寻址法
开放寻址法就是在插入一个关键字为 $k$ 的元素时,若发生哈希冲突,则通过某种哈希冲突解决函数(也称为再哈希)得到一个新空闲地址再插入该元素的方法。通常使用线性探测法。(一维数组从前向后遍历)
在开放寻址法中,$m$通常取$n$的$2$~$3$倍左右,防止数据冲突(如$n \leq 10^5$则$m$可取$196613$)
如果当成蹲坑:
开放寻址法:如果看到一个坑里有人就往前找没人的坑
拉链法:认准一个坑无论有没有人都在厕所门口排队
2.10.2 字符串哈希(解决字符串类型题目的利器!)
通常用于快速判断两个字符串是否相等!时间复杂度为$O(1)$
采用字符串前缀哈希法,通过记录字符串前缀的哈希值来计算出某一段字符串的哈希值。
将一个字符串看成是一个p进制数,如$\rm ABCD$看成$\rm (1234)_p$,即$\rm 1×p3+2×p2+3×p1+4×p0$。由于字符串可能会有很多位,因此计算后的结果还需$\rm \bmod Q$,可将字符串映射为一个0~Q-1的整数。
注:
①一般情况下,不能将某一个字母映射成$0$:若不然,则“A”, “AA”等的hash值均为$0$,便会造成冲突。
②经验值:取$\rm p = 131$或$13331$,$\rm Q = 2^{64}$,这样冲突的概率较低。
③哈希值$h[]$直接用$\rm unsigned$ $\rm long$ $\rm long$存储,自然溢出就相当于取模$2^{64}$.
子串$ str[l$~$ r]$的计算:先将$str[0$~$ l-1]$与$str[0$~$r]$的高位对齐$(×p^{l-r+1})$,再用$h[r]$减去即可。
(手动模拟一下即可,设字符串“AB”,从左到右为高位到低位1到0,而“ABCDE”从左到右为4到0,现欲求CDE的哈希值,只需将“AB”的1、0转化为“ABCDE”中的“AB”(4、3)。)
一般KMP除了求循环节,其他的都可以用字符串哈希来做hhh
2.11 STL补充 (其他部分见《算法竞赛进阶指南》)
1、$\sf pair<>$支持比较运算,排序时以$\sf first$为第一关键字,$\sf second$为第二关键字(按字典序)
pair<int, string> p;
p = make_pair(10, "yxc");//构造pair
p = {20, "abc"};//在C++ 11中可以这么写
有时候也会有$\sf pair<int, pair<int, int>>$的写法
2、$\sf string$支持$\sf size()/length()$、$\sf empty()$、$\sf clear()$操作
string a = "abc";
a += "de";
a += 'f';//可以加字符串也可以直接加字符
printf("%s", a.c_str());//若要用printf的方式输出字符串,则应输出该字符串对应字符数组的起始地址。
$\sf substr()$的用法:($\sf string$ $\sf s$ $=$ “$0123456789$”)
①$\sf string$ $\sf sub1 = s.substr(5);$ //只有一个数字5表示从下标为5开始一直到结尾:sub1 = “56789”;
②$\sf string$ $\sf sub2 = s.substr(5, 3);$//从下标为5开始截取长度为3位:sub2 = "567";
3、$\sf stack$:只支持$\sf size()$、$\sf empty()$,无$\sf clear()$
$\sf push()$:向栈顶插入一个元素
$\sf pop()$:弹出栈顶元素
$\sf top()$:返回栈顶元素
4、$\sf set$,$\sf map$,$\sf multiset$,$\sf multimap$,基于平衡二叉树(红黑树),动态维护有序序列【均有$\sf lower_bound$和$\sf upper_bound$,但增删改查的时间复杂度是$O(log_n)$。】
$\sf unordered_set$,$\sf unordered_map$,$\sf unordered_multiset$,$\sf unordered_multimap$,基于Hash实现【与上面类似,但增删改查的时间复杂度是$O(1)$且不支持$\sf lower_bound$和$\sf upper_bound$以及迭代器的$ ++/–-$,因为内部无序。】