算法基础课-数据结构

第二章 数据结构

  • 基本上都选择用数组进行模拟,可能与主流的做法有些出入。(“结构体加指针的做法太慢,通常不考虑”)
  • 另外还强调了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·队列

先进后出

  1. 朴素数组模拟:数据数量过大时会出现问题,tt = -1
// hh 表示队头,tt表示队尾
int q[N], hh = 0, tt = -1;

// 向队尾插入一个数
q[ ++ tt] = x;

// 从队头弹出一个数
hh ++ ;

// 队头的值
q[hh];

// 判断队列是否为空
if (hh <= tt)
  1. 循环数组模拟:遇到数组尾就回到数组开头,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((nk)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[jk...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=13113331 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[l1]×prl+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[i1]×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位取反

请添加图片描述

请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值