数据结构
第二章 数据结构
- 基本上都选择用数组进行模拟,可能与主流的做法有些出入。(“结构体加指针的做法太慢,通常不考虑”)
- 另外还强调了C++STL的应用
1·链表
单链表
大体步骤:
- 数组的下标作为数据逻辑上的关联,数组的下标与“逻辑结构”里,各节点的编号,对应
- 数组e[N]:e[i] 储存 编号为i的节点,储存的value
- 数组ne[N]:ne[i] 储存 编号为i的节点的“”下家“ 的编号;空的话用-1表示
实现和接口:
// head存储链表头,e[]存储节点的值,ne[]存储节点的next指针,idx表示当前用到了哪个节点
int head, e[N], ne[N], idx;
// 初始化
void init(){
head = -1;
idx = 0;
}
// 在链表头插入一个数a
void insert_head(int a){
e[idx] = a,//把值放入值的容器里
ne[idx] = head, //让当前节点,指向原本head指向的节点(NTR)
head = idx ++ ;//让head指向当前节点,idx向右移一位(喜新厌旧)
}
//插入到序号为k的点后面
void insert_k(int k, int a){
e[idx] = a;
ne[idx] = ne[k];//NTR
ne[k] = idx++;//喜新厌旧
}
// 将头结点删除,需要保证头结点存在
void remove(){
head = ne[head];
}
//将下标是k的节点后面的点删除
void remove_k(int k){
ne[k] = ne[ne[k]];//next两次
}
双链表
大体步骤:
- 数组的下标作为数据逻辑上的关联,数组的下标与“逻辑结构”里,各节点的编号,对应
- 数组e[N]:e[i] 储存 编号为i的节点,储存的value
- 数组r[N]:r[i] 储存 编号为i的节点的“”下家“ 的编号;空的话用-1表示
- 数组l[N]: l[i] 编号为i的节点的“”上家“ 的编号;空的话用-1表示
- 约定,节点0是头,节点1是尾
实现和接口:
// e[]表示节点的值,l[]表示节点的左指针,r[]表示节点的右指针,idx表示当前用到了哪个节点
int e[N], l[N], r[N], idx;
// 初始化
void init()
{
//0是左端点,1是右端点
r[0] = 1, l[1] = 0;
idx = 2;
}
// 在节点序号为k的右边插入一个数a
void insert(int k, int a)
{//需要改变四条边
e[idx] = a;
l[idx] = k, r[idx] = r[k];//把当前的节点先塞进去
l[r[k]] = idx, r[k] = idx ++ ;//修整
}
// 删除序号k的节点
void remove(int k)
{
l[r[k]] = l[k];
r[l[k]] = r[k];
}
2·栈
先进后出,朴素数组模拟
实现和接口:
// tt表示栈顶
int stk[N], tt = 0;
// 向栈顶插入一个数
stk[ ++ tt] = x;
// 从栈顶弹出一个数
tt -- ;
// 栈顶的值
stk[tt];
// 判断栈是否为空
if (tt > 0)
3·队列
先进后出
- 朴素数组模拟:数据数量过大时会出现问题,tt = -1
// hh 表示队头,tt表示队尾
int q[N], hh = 0, tt = -1;
// 向队尾插入一个数
q[ ++ tt] = x;
// 从队头弹出一个数
hh ++ ;
// 队头的值
q[hh];
// 判断队列是否为空
if (hh <= tt)
- 循环数组模拟:遇到数组尾就回到数组开头,tt = 0
// hh 表示队头,tt表示队尾的后一个位置
int q[N], hh = 0, tt = 0;
// 向队尾插入一个数
q[tt ++ ] = x;
if (tt == N) tt = 0;
// 从队头弹出一个数
hh ++ ;
if (hh == N) hh = 0;
// 队头的值
q[hh];
// 判断队列是否为空
if (hh != tt)
4·单调栈
情景引入:
- 问题:在数列中,找到每一个数其左侧,最近的,且比他小的数是什么
- 分析:
- 暴力做法:两层遍历,一对一对判断
- 优化:有些 对子 一看就是不可能的
- 如果把前面的数大于后面的数,称之为”正序“
- 那些”逆序对“的·后面那个数,对于本题是无意义的。
- 引出 ”单调栈“ 的思想:一直维护一个递增的栈,如果新插入的元素不满足,就不断地从栈顶弹出,直到满足为止
代码:
#include <iostream>
using namespace std;
const int N = 100010;
int stk[N], tt;
int main()
{
int n;
cin >> n;
while (n -- )
{
int x;
scanf("%d", &x);
while (tt && stk[tt] >= x) tt -- ;//如果栈顶元素大于当前待入栈元素,则出栈
if (tt) printf("%d ", stk[tt]);//栈顶元素就是左侧第一个比它小的元素。
else printf("-1 ");//如果栈空,则没有比该元素小的值。
stk[ ++ tt] = x;//满足单调性了,入栈
}
return 0;
}
5·单调队列
情景引入
-
问题:滑动窗口中的最值
-
分析:
- 暴力做法:首先滑动窗口可以用一个队列进行模拟,当滑动窗口向右滑动时,对应就是先出队再入队。这样不断向右移动,每次遍历队列内所有元素找到最值,复杂度是 O ( ( n − k ) k ) O((n-k)k) O((n−k)k)
- 优化:
- 以最小值为例,仍然是对于那些“逆序对”,即左侧数大于右侧数的情况,那么只要右侧数还在队列内,那左侧树就不可能作为答案
- 所以“”逆序对”中的左侧数没有意义,可以删去
代码:
#include <cstdio>
using namespace std;
const int N = 1000010;
int n,k;
int a[N],q[N];
int main(){
scanf("%d %d",&n,&k);
for(int i = 0;i < n;i++) scanf("%d",&a[i]);
//最小值,维护一个从队头向队尾递增的队列
int hh = 0,tt = -1;//初始化队列,队列里面储存元素下标
for(int i = 0;i < n;i++){
if(hh <= tt && i-k+1 > q[hh]) hh++;//如果队头已经滑出窗口,就右移队头
while(hh <= tt && a[q[tt]] >= a[i]) tt--;//队尾元素比即将入队的大,弹出
q[++tt] = i;//入队
if(i >= k-1) printf("%d ",a[q[hh]]);//由于是递增队列,所以队头一定是最小元素
}
puts("");
//最大值,维护一个从队头向队尾递减的队列
hh = 0,tt = -1;
for(int i = 0;i < n;i++){
if(hh <= tt && i-k+1 > q[hh]) hh++;
while(hh <= tt && a[q[tt]] <= a[i]) tt--;
q[++tt] = i;//入队
if(i >= k-1) printf("%d",a[q[hh]]);
}
puts("");
return 0;
}
小结:
- 以上两个数据结构,能优化的问题都在于发现问题中隐含的单调性
- 有了单调性,从而排除一些不必要的运算
6·KMP(改进字符串匹配)
哎呀,这块讲的很烂,看的别的地方的
情景是在字符串S(主串)中,匹配字符串P(模式串)
先说暴力做法
S[N],p[M];
for(int i = 1;i <= n;i++){
bool flag = true;
for(int j =1;j <= m;j++){
flag = false;
break;
}
}
这个做法烂,烂就烂在忽略了许多信息。
- 当我们发现 ( i , j ) (i,j) (i,j)这里不匹配时,怎样做最合理?怎么移动模式串?
- 这时我们的信息是,至少 i 到 i + j i \ 到 \ i+j i 到 i+j都是匹配的
- 反正不是 i + + , j = 0 i++,j = 0 i++,j=0这样更新
kmp 优化的关键点: n e x t [ i ] next[i] next[i]:
- 概念:
- 前缀:字符串从头部开始前n个连续字符形成的子串
- 后缀:以字符串尾部结束的后连续n个字符形成的子串
- 倘若我们有 n e x t [ j ] = k next[j]=k next[j]=k
- 当且仅当: p [ 0... k ] = p [ j − k . . . j ] p[0...k] = p[j-k...j] p[0...k]=p[j−k...j] 以当前位置j为终点的子串,和以模板串为起点的子串,最长有k位是一样的
- 即找到以 j j j为结尾的字符串,其全等的前缀和后缀中的,前缀的结束下标
更具体的原因可以看: 什么是KMP算法(详解)
还有题解:AcWing 831. KMP字符串
假设已经有了这样的next数组,该如何进行匹配
-
初始化·:i是目标串s的指针,初始化为0,j是模板串的指针,初始化为-1
-
下面i开始遍历:
- 如果i和j+1匹配,自然继续,i++, j++
- 如果不匹配,看图:在进行更新时,两段绿色部分是相同的,自然不用进行检查,而j需要更新的位置正好是最长全等前缀的尾部,于是只需 j = n e x t [ j ] j = next[j] j=next[j]
- 代码
for(int i = 0,j = -1;i<n;i++){ while(j >-1 && s[i]!=p[j+1]) j = ne[j]; if(s[i] == p[j+1]) j++; if(j == m-1){ //匹配成功,进行一些操作 j = ne[j];//继续匹配下一个子串 } }
理解如何进行匹配后,再回头看next数组的构造:其实就是p和自己的匹配过程,只是要记录到ne数组里
- n e x t [ 0 ] next[0] next[0]初始化为-1, -1表示当前位置没有全等的前后缀
- j 从-1开始遍历
ne[0] = -1;
for(int i = 1, j = -1; i < m; i++)
{
while(j>-1 && p[i] != p[j+1]) j = ne[j];
if(p[i] == p[j+1]) j++;
ne[i] = j;
}
模板题:
代码:
#include <iostream>
using namespace std;
const int N = 1e5+10,M = 1e6+10;
char p[N],s[M];
int ne[N];
int main(){
int n,m;
cin>>n>>p>>m>>s;
ne[0] = -1;
for(int i = 0,j = -1;i<n;i++){
while(j > -1 && p[i]!=p[j+1]) j = ne[j];
if(p[i] == p[j+1]) j++;
ne[i] = j;
}
for(int i = 0,j = -1;i<m;i++){
while(j > -1 && s[i]!=p[j+1]) j = ne[j];
if(s[i] == p[j+1]) j++;
if(j == n-1){
printf("%d ",i-n+1);
j = ne[j];
}
}
return 0;
}
时间复杂度: O ( N ) O(N) O(N) 具体的分析可以参考算法导论
7·Trie树
Trie树是一种用于储存字符串集合的数据结构,实现了较省空间的插入和查询操作
题目
一个朴素的想法就是使用STL的map<string,int>
Trie树 插入 大体思路:
-
预置一个根节点
-
对于需要插入的字符串,从头开始一个一个读取字符,同时从根节点开始向下层走
- 如果当前节点的子节点中没有当前读取的字符,就创建新子节点
- 如果当前节点的字节点中存在当前读取的字符,则继续向下层走
-
直到走到当前字符串结尾,这时为当前节点打上一个记号,表示存在以当前节点结尾的字符串
Trie树 查询 大体思路
- 仍然是从查询字符串开头开始,从根节点向下进行搜索,若遍历不下去了为失败;若字符串已到结尾,当前节点没有结束记号,失败
代码:这个son数组尤其巧妙,细品
#include <iostream>
using namespace std;
const int N = 100010;
int son[N][26];//用于存储每个节点的子节点的下标,每个节点的子节点的个数最多是26
int cnt[N];//用于打标记,以当前节点为尾部的的单词有多少个
int idx = 0;//分配下标,约定下标是0的点既是根节点也是空节点
char str[1000];
void insert(char *str){
int p = 0;
for(int i = 0;str[i];i++){
int u = str[i] - 'a';
if(!son[p][u]) son[p][u] = ++idx;//如果当前节点的子节点中不存在该字符的节点,添加
p = son[p][u];
}
cnt[p]++;//打标记
}
int query(char *str){
int p = 0;
for(int i = 0;str[i];i++){
int u = str[i] - 'a';
if(!son[p][u]) return 0;
p = son[p][u];
}
return cnt[p];
}
int main(){
int n;
scanf("%d",&n);
while(n--){
char op[2];
scanf("%s %s",op,str);
if(op[0] == 'I') insert(str);
else printf("%d\n",query(str));
}
return 0;
}
8·并查集
并查集支持的操作:
- 将两个集合合并
- 询问两个元素是否在一个集合中
基本原理
-
用树的结构维护所有集合:
- 根节点储存当前集合的编号
- 每一个节点储存自己的父节点是谁
-
如何求某元素的集合编号:不断向父节点回溯
-
如何合并两个集合:直接把某个集合的根节点插到另一个集合树的根节点之下
以上,在回溯父节点那一步,时间复杂度还是高,于是有优化:路径压缩
- 简单来说,当我为某个节点查询到其根节点时,就把路径上所有节点都放到根节点下面
模板代码:
//初始化
int p[N];
for(int i = 1;i <= n;i++) p[i] = i;//既是表示每一个数字都独占一个集合,也是表示只有x=p[x]
//返回祖宗节点,并进行路径压缩
int find(int x){
if(p[x] != x) p[x] = find(x);//如果没到祖宗节点,就直接通过递归归到祖宗节点上
return p[x];//递归出口:p[x] = x,即到达祖宗节点
}
//合并a,b所在的集合
p[find(a)] = find(b);
维护额外变量的并查集
-
维护每个集合的大小:
//初始化 int p[N],size[N];//但是只有根节点的size有意义 for(int i = 1;i <= n;i++){ p[i] = i;//既是表示每一个数字都独占一个集合,也是表示只有x=p[x] size[i] = 1; } //返回祖宗节点,并进行路径压缩 int find(int x){ if(p[x] != x) p[x] = find(x);//如果没到祖宗节点,就直接通过递归归到祖宗节点上 return p[x];//递归出口:p[x] = x,即到达祖宗节点 } //合并a,b所在的集合 size[find(b)] += find(a);//合并,size相加 p[find(a)] = find(b);
9·堆(讨论的是 小根堆)
堆也是维护一个集合
- 用一棵完全二叉树实现
- 小根堆:父节点小于等于左右孩子
堆的存储:用一个一维数组
- 节点 x x x的左孩子: 2 x 2x 2x
- 节点 x x x的右孩子$2x+1vb $
两个“元操作”:
- d o w n down down :把某个节点向下调整,直到形成堆;方法是每一步都在当前节点,左儿子,右儿子
- u p up up:把某个节点向上调整,直到形成堆
手写堆实现的操作:
-
STL能实现的
- 插入一个数:把数字插入到最后,然后 u p up up
- 求集合的最小值:取出第一个元素
- 删除最小值:用最后一个元素覆盖第一个元素,然后删除最后一个元素,然后 d o w n down down第一个元素
-
手写堆能实现的
- 删除任意元素:用最后一个元素覆盖当前元素,删除最后一个元素,然后既 d o w n down down又 u p up up(涵盖了被覆盖后变小了还是变大了两种情况)
- 修改任意元素 :直接进行修改,然后既 d o w n down down又 u p up up
代码:
-
元操作:
//将当前节点向上调整 void down(int u){//输入调整节点的下标 int t = u; if(u*2 <= size && h[u*2] < h[t]) t = u*2;//如果有左儿子而且左儿子小 if(u*2+1 <= size && h[u*2+1] < h[t]) t = u*2+1;//如果右儿子存在而且右儿子小 if(u != t){ swap(h[u],h[t]);//这里可以替换其他交换函数,以用于更广泛的用途 down(t);//递归 } return ; } //将当前元素向上调整 void up(int u){//输入调整节点的下标,由于父节点只有一个,所以用循环就行,不用使用递归 while (u / 2 && h[u] < h[u / 2]) {//不断向父节点追溯 swap(h[u], h[u/2]);//可以换用其他的swap u >>= 1;//向父节点 } } //O(N)时间快速把一个数组改造成堆 a[n]; for(int i = n/2;i;i--) down((i);
-
操作因为题目中出现了“第k个插入的数”,为了快速追踪该数,需要引入两个数组,一个记录第k个插入的数在我们的数组中的下标是几,一个记录数组中下标为i的数是第几个插入的数,其关系类似一对函数和反函数,就是为了快速查找数据的位置
// h[N]存储堆中的值, h[1]是堆顶,x的左儿子是2x, 右儿子是2x + 1
// ph[k]存储第k个插入的点在堆中的位置
// hp[k]存储堆中下标是k的点是第几个插入的
int h[N], ph[N], hp[N], size = 0;
// 交换两个点,及其映射关系
void heap_swap(int a, int b)
{
swap(ph[hp[a]],ph[hp[b]]);
swap(hp[a], hp[b]);
swap(h[a], h[b]);
}
void down(int u);
void up(int u);//用heap_swap替换原函数中的Swap
int m = 0;//用于记录是第几个插入的
- 插入一个数 x
size++,m++;
ph[m] = size,hp[size] = m;
h[size] = x;
up(size);
- 输出当前集合中的最小值
printf("%d\n",h[1]);
- 删除当前集合中的最小值
heap_swap(1,size);//头尾交换
size--;//去除尾部
down(i);//调整堆
- 删除第 k 个插入的数
int k = ph[k];//取下标
heap_swap(1,size);//交换到尾部
size--;//去除尾部
down(k),up(k);//调整堆
- 修改第 k 个插入的数,将其变为 x;
int k = ph[k];
h[k] = x;
down(k),up(k);
10·哈希表
思想是将庞大的范围,映射到一个比较小的区间内。要讨论两个东西,哈希函数 h ( x ) h(x) h(x)和处理冲突
大部分的时候选择 h ( x ) = x % p h(x) = x\%p h(x)=x%p其中 p p p是一个足够大的质数,且不能与 2 n 2^n 2n太过接近
储存结构
-
拉链法
- 用一个数组进行储存
- 对于每一个下标,直接开一个单链表,来保存所有被分配到当前下标的元素
- 添加:类似单链表添加
- 查找,遍历当前下标下的单链表
const int N = 100003;//足够大的质数
int hash(int k){
return (x%N+N)%N;
}
int h[N];//拉链的槽,其实类似于邻接表,需要初始化成全-1
int e[N],ne[N],idx = 0;//单链表部分
void insert(int x){//插入
int k = hash(x);
e[idx] = x,ne[idx] = h[k],h[k] = idx++;
}
bool find(int x){//查询
int k = hash(x);
for(int i = h[k];i!=-1;i = ne[i]){
if(e[i] == x){
return true;
}
}
return false;
}
-
开放寻址法
- 只开一个数组储存,但数组大小要开题目数据总量的2-3倍
- 添加:如果当前位置已被占用,则移向下一个位置
- 查找:从哈希给出位置开始,不断向后遍历,遍历到找到x或者空为止
const int N = 200003;//足够大的质数,且是2-3倍
const int null = 0x3f3f3f3f;//用于标记空位置,需要一个不在数据范围内的数
int hash(int k){
return (x%N+N)%N;
}
memset(h,0x3f,sizeof(h));
int find(int x){//如果x在哈希表中已经存在,则返回存储的位置;若不存在,则返回应该存储的位置。其实就是不断向后遍历寻找,直到找到数据x或者找到空位
int k = hash(k);
while(h[k] != null && h[k]!=x){
k++;
if (k == N) k = 0;//循环
}
return k;
}
字符串哈希
字符串前缀哈希法
- 对于一个字符串,开一个哈希数组,来存储前i位前缀串的哈希值 h [ i ] h[i] h[i]
- 方法是:将字符串视作一个p进制的数,把一个字符串转化成一个数字
- 将字符串化成的数字取模q,得到一个较小的哈希值
- 不考虑冲突
- p = 131 或 13331 且 Q = 2 64 p=131或13331\ 且Q=2^{64} p=131或13331 且Q=264,经验上不会出现冲突
- 快速保持取模的状态:用64位整数
unsigned long long
来储存h,让溢出来帮我们取模
如果得到了前缀哈希,那么对于
[
l
,
r
]
[l,r]
[l,r]这个子串,则有,
h
a
s
h
(
l
.
.
.
r
)
=
h
[
r
]
−
h
[
l
−
1
]
×
p
r
−
l
+
1
hash(l...r) \ = \ h[r] - h[l-1] \times p^{r-l+1}
hash(l...r) = h[r]−h[l−1]×pr−l+1
对上面公式变形,带入
r
=
l
=
i
r=l=i
r=l=i然后移项,得到递推前缀哈希公式
h
[
i
]
=
h
[
i
−
1
]
×
p
+
s
t
r
[
i
]
h[i] \ = \ h[i-1]\times p+str[i]
h[i] = h[i−1]×p+str[i]
typedef unsigned long long ull;
const int N = 100010,P=131;
char str[N];//字符串下标从1开始
ull h[N];//存储前缀哈希值,下标从0开始
ull p[N];//存储p的i次方
//初始化
p[0] = 1;
for(int i = 1;i <= n;i++){
p[i] = p[i-1]*P;
h[i] = h[i-1]*P + str[i];
}
//计算区间的哈希值
ull hash(int l,int r){
return (h[r] - h[l-1]*p[r-l+1]);
}
可以用于快速判断两个字符串是否相等,查询的时间是 O ( 1 ) O(1) O(1)
11·C++STL
- vector:变长数组,倍增的思想
- string:字符串
- queue:队列
- priority_queue:优先队列(堆)
- stack:栈
- deque:双端队列
- set,map,multi_set,multi_map:基于红黑树实现,动态维护有序序列
- unordered_map,unordered_set:基于hash表
- bitset:压位,把字节拆成bit用,狠狠地省空间
bitset<1000> s
尖括号里面写的是长度- 可以把一个bitset视作整数,支持所有位运算
.count()
返回1的数量.any()
返回是否至少有一个1.none()
返回是否全是0.set()
将所有位置0.set(k, v)
将第k位变成v.reset()
把所有位变成0.flip()
等价于~
.flip(k)
把第k位取反