标签:结点 idx int 基础 ++ 数组 字符串 数据结构
数据结构基础一
1.单链表(数组模拟)
常用的是邻接表(其实就是单链表,邻接表就是用多个单链表存储了多条线?!),一般用来存储图和树,使用结构体和指针来表示数组的时候,是使用的动态开辟空间,速度太慢,所以一般使用数组来静态模拟单链表。
数组模拟单链表的思路:首先开辟两个足够大的数组,一个是存储链表中的数,一个表示链表中结点的下一个结点的指针,还需要有两个数,一个数来存储头指针,一个是存储当前已经用到了哪个数组位置。
注意到单链表有个特质,就是只能向后看,你只能找到一个结点的下一个结点,但是不能找到上一个结点。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
|
#include<iostream> using namespace std;
const int N = 100010; //head 表示头结点的指针是多少 //e[i] 表示节点i的值是多少 //ne[i] 表示节点i的next指针是多少 //idx 储存当前已经用到了哪个点 int head,e[N],ne[N],idx;
//初始化 void init() { head = -1;//数组模拟使用-1来表示空 idx = 0; }
//将x插入到头结点的位置`在这里插入代码片` void add_to_head(int x) { e[idx] = x;//存下值 ne[idx] = head;//存下next指针 head = idx;//更新头结点 idx ++; }
//将x插入到下标为k的结点后面 void add(int k,int x) { e[idx] = x; ne[idx] = ne[k]; ne[k] = idx; idx ++; }
//删除下标是k的后面的点 void remove(int k) { ne[k] = ne[ne[k]];//指向下一个的下一个 }
int main() { return 0; }
CPP
|
2.双链表(数组模拟)
双链表也就是同时储存上一个点的指针和下一个点的指针
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
|
#include<iostream> using namespace std;
const int N = 100010; int n; int e[N],l[N],r[N],idx; //这里就不用定义头结点和尾结点了,我们可以使用第0个点和第1个点偷懒,e[N]代表结点中存储的值,r[N]代表每一个结点的下一个点的指针(也就是数组存储中的下标),l[N]代表每一个结点上一个点的指针,idx记录现在已经使用到了哪个数组下标
//初始化 void init(){ //0表示左端点,1表示右端点 r[0] = 1; l[1] = 0; idx = 2; }
//第k点的右边插入 在k的左边插入一个新的点直接调用add(l[k],x),也就是在k的左边插入等于在k的左边第一个点的右边插入一个点 void add(int k,int x){ e[idx] = x; r[idx] = r[k]; l[idx] = k; l[r[k]] = idx; r[k] = idx; idx ++; }
//删除结点 void remove(int k){ r[l[k]] = r[k]; //k点左边的第一个点的右指针指向k点的右结点,直接跳过k点 l[r[k]] = l[k]; //k点右边的第一个点的左指针指向k点的左结点,直接跳过k点 }
int main(){ return 0; }
CPP
|
3.栈和队列(数组模拟)
栈:先进后出 (想象成单口的水井,每次进行一次操作,不是拿出就是放入,先被放入的会更晚拿出来)
队列:先进先出(想象成双口的罐子,每次进行一次操作,从上面的口放入,从下面的口拿出,所以先放入队列的元素会先拿出来)
栈的数组模拟
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
|
#include<iostream> using namespace std;
const int N = 100010; int stk[N],tt;//stk数组所代表的就是栈,tt代表的是栈顶元素的下标
//插入 stk[++ tt] = x;//就是将新的元素放在了数组的最右端,并且将其下标记录
//删除栈顶元素 tt --;//栈顶元素的下标记录减一,就相当于删除了栈顶元素
//判断是否为空 if(tt > 0) not empty; else empty;
//取出栈顶元素 stk[tt];
int main() { return 0; }
CPP
|
队列的数组模拟
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
|
#include<iostream> using namespace std;
const int N = 100010; int q[N],hh,tt;//hh表示队头元素,tt表示队尾元素
//插入 h[++tt] = x;
//弹出 hh++;
//判断是否为空 if(hh <= tt) not empty; else empty;
//取出队头元素 q[hh]; int main() { return 0; }
CPP
|
单调栈和单调队列
单调栈一般应用:给定一个序列,找到这个序列中每一个数左边(右边)离他最近并且比它小或者大的数的位置,存在就返回这个数,不存在就返回这个-1。
单调栈
给定一个长度为 N 的整数数列,输出每个数左边第一个比它小的数,如果不存在则输出 −1 。
输入格式
第一行包含整数 N ,表示数列长度。
第二行包含 N 个整数,表示整数数列。
输出格式
共一行,包含 N 个整数,其中第 ii 个数表示第 ii 个数的左边第一个比它小的数,如果不存在则输出 −1 。
数据范围
$1≤N≤10^5$
$1≤数列中元素≤10^9$
输入样例:
输出样例:
暴力做法类似于双指针算法,就是遍历一个数左边所有的数,来查找到离这个数最近且比这个数小的数。
使用单调栈优化,就是使用栈来存放所有的数据,在输入数据的时候就看一下栈顶元素是不是比当前的数大,如果栈顶元素大于你当前读入的元素,说明后面读入的所有数都不可能以栈顶元素为答案,因为你新读入的数大于栈顶元素并且距离左边更近,所以当读入的数据小于栈顶元素的时候就弹出栈顶元素。
代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
|
#include <iostream> using namespace std; const int N = 100010;
int n,stk[N],tt;//stk数组模拟栈,tt记录栈顶元素
int main() { cin.tie(0); ios::sync_with_stdio(false); //输入优化,使得cin的输入更快 cin >> n; for(int i = 0; i < n; i ++){ int x; cin >> x; while(tt && stk[tt] >= x)tt--; //判断栈顶元素是否大于等于当前插入元素 if(tt) cout << stk[tt] <<" "; //如果栈不为空,并且上一个判断完成,说明此时的栈顶元素就满足条件 else cout << "-1 "; //如果栈为空,并且上一个判断完成,说明这些数据中这个数的左边没有满足条件的数 stk[++tt] = x; //向栈中插入新的元素 } return 0; }
CPP
|
时间复杂度分许:每个元素最多进栈出栈一次,所以是O(n)的时间复杂度。
单调队列一般应用:求一个滑动窗口里的最大值或者最小值
滑动窗口(单调队列)
给定一个大小为$n≤10^5$的数组
有一个大小为 k 的滑动窗口,它从数组的最左边移动到最右边。
你只能在窗口中看到 k 个数字。
每次滑动窗口向右移动一个位置。
以下是一个例子:
该数组为 [1 3 -1 -3 5 3 6 7]
, k 为 33。
窗口位置 | 最小值 | 最大值 |
[1 3 -1] -3 5 3 6 7 |
-1 |
3 |
1 [3 -1 -3] 5 3 6 7 |
-3 |
3 |
1 3 [-1 -3 5] 3 6 7 |
-3 |
5 |
1 3 -1 [-3 5 3] 6 7 |
-3 |
5 |
1 3 -1 -3 [5 3 6] 7 |
3 |
6 |
1 3 -1 -3 5 [3 6 7] |
3 |
7 |
你的任务是确定滑动窗口位于每个位置时,窗口中的最大值和最小值。
输入格式
输入包含两行。
第一行包含两个整数 n 和 k,分别代表数组长度和滑动窗口的长度。
第二行有 n 个整数,代表数组的具体数值。
同行数据之间用空格隔开。
输出格式
输出包含两个。
第一行输出,从左至右,每个位置滑动窗口中的最小值。
第二行输出,从左至右,每个位置滑动窗口中的最大值。
输入样例:
1 2
|
8 3 1 3 -1 -3 5 3 6 7
BASIC
|
输出样例:
1 2
|
-1 -3 -3 -3 3 3 3 3 5 5 6 7
SUBUNIT
|
暴力做法,双重遍历,时间复杂度O(n^2^)。
使用单调队列来优化,就拿寻找最小值举例,如果向右移动后的数据是最小的数,那么窗口中的前两个数在寻找最小值的过程中是不是就没有用了,因为最新进入窗口的数更小。寻找最大值优化也是类似的。
代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
|
#include <iostream> using namespace std; const int N = 1000010; //数据范围较大,注意使用scanf和printf输入输出,或者使用ios优化和tie优化
int n,k; int a[N],q[N];//a数组是用来存储数组,q数组是用来存储队列
int main() { int hh = 0,tt = -1;//hh表示队头,tt表示队尾 scanf("%d%d",&n,&k); for(int i = 0; i < n; i ++) scanf("%d",&a[i]); 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]]);//输出,队列是一个一个进的,所以前k - 1个数字依次进入窗口中的时候,窗口中的数字还不是k个,这个时候还没有输出,所以需要判断一下 } puts(""); //最大值做法基本一致,只需要更改输入时的判断条件即可 hh = 0,tt = -1; scanf("%d%d",&n,&k); for(int i = 0; i < n; i ++) scanf("%d",&a[i]); 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; }
CPP
|
从代码的实现过程中不难看出,单调栈和单调队列都是利用在插入的时候进行判断弹出了栈顶元素或者是队尾元素,通过这样的方式来维护栈和队列,使得其始终是单调递增或者单调递减的,这样就能满足某些寻找最大值或者最小值的条件。
4.KMP
KMP算法就是匹配字符串的优化算法,给定模板字符串和目标字符串,需要寻找目标字符串是不是模板字符串的子串,如果是的话就返回字串的初始下标
暴力做法:暴力做法就是遍历每一个模板字符串中的每一个字符,将模板字符串中的每一个字符当作目标字符串的初始字符,来匹配后面的字符串是不是和目标字符串相匹配。
1 2 3 4 5 6 7 8 9
|
for(int i = 0; i < n; i ++){ bool flag = true; for(int j = 0; j < n; j ++){ if(s[j] != p[j]){ flag = false; break; } } }
CPP
|
KMP算法思路:每次匹配完一个字符后,如果相同就匹配两个字符串的下一个字符,如果不相同,按照暴力思路的话,就需要从下一个模板字符串的字符开始重新依次循环匹配,而KMP算法就是通过优化,减少不必要的移动后再次匹配。例如一个模板字符串abababababcabab(p[]),一个目标字符串ababcabab(s[]),按照暴力思路的话,第一次匹配的时候第五个字符不同,就会向后移动一位,但是很显然,移动一位后模板字符串的首字符变成了b,很明显这就是无效的移动。那我们再想一想,目标字符串ababcabab以s[3]为终点的子串,以及以s[0]为起点的子串,最大长度是2(就是ab),注意观察,我们能发现,暴力模拟每次匹配失败的字符前的都是匹配成功的,那么以s[0]为起点的子串,它与以s[3]为终点的子串相等,那么也就和模板字符串匹配失败的字符的前一个字符相同长度的子串是相同的,所以可以直接将目标字符串向后移动多位,这个位数就是匹配失败的字符的前一个字符的下标和这个字符的最大子串长度的差。
KMP演示代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
|
#include <iostream> using namespace std; const int N = 100010,M = 1000010; int n,m; char p[N],s[M];//s数组就是模板字符串,p数组就是目标字符串 int ne[N];
int main() { cin >> n >> p + 1 >> m >> s + 1; //求next数组,其实与KMP核心思想类似,差别就是拿着两个相等的字符串再进行比较,去寻找模板字符串中的每个i所能对应的最大的j。然后将这个j更新到next数组中 for(int i = 2, j = 0; i <= n; i ++){ //首先,i从2开始 while(j && p[i] != p[j + 1]) j = ne[j]; //如果j不为0,并且字符不匹配,那么就相当于将目标字符串向前移动 if(p[i] == p[j + 1]) j ++;//如果匹配成功,那么就能将j向后移动 ne[i] = j; //更新next } //KMP核心 for(int i = 1, j = 0; i <= m; i ++) { while(j && s[i] != p[j + 1]) j = ne[j]; if(s[i] == p[j + 1]) j ++; if(j == n){ printf("%d ",i - n); j = ne[j]; } } return 0; }
CPP
|
5. Trie
作用:高效的储存和查找字符串集合的数据结构
原理:其实就是使用一种类似于链表的方法,储存字符串集合的时候,从根结点出发,判断根节点是否有链接到该字符串的第一个字符,没有的话就创建一个结点,让它和根结点链接,如果存在的话,就直接移动到该结点,然后查找该结点是否有和第二个字符有链接,依次类推,当每一个字符串的最后一个字符存储进去后,在这个结点使用一个标记,代表有一个以这个结点结束的字符,避免子串被母串所覆盖,或者有相同的字符串相覆盖的情况存在,查找字符串的时候类似,从根结点开始查找第一个字符,查找到了后就移动到第二个字符的结点然后查找这个结点是否和第三个结点相连接,一次类推。
代码实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
|
#include <iostream> using namespace std; const int N = 100010;、 int son[N][26],cnt[N],idx;//son[N][26]表示所有的结点,每一个结点有26个字母(不同的题目不一样),当需要创建结点的时候就直接从其中使用就是了,cnt[N]记录所有结点以当前结点为终点的字符串的数量是多少,idx记录当前使用到的下标 char str[N];
//向Trie树中存储字符串 void insert(char str[]){ int p = 0;//p表示当前从存储到了哪个结点,最开始是从根节点开始,所以p=0 for(int i = 0; str[i]; i ++){ int u = str[i] - 'a'; if(!son[p][u]) son[p][u] = ++ idx;//如果该结点的这个地方没有链接字符,就使用新的结点,让其链接起来,并且将idx++ p = son[p][u];//移动到下一个结点,注意,如果这是最后一重循环的话,下一个结点才是你当前存储的字符串的最后一个结点。 } cnt[p] ++;//当前字符串存储完毕,该字符串存储的最后一个结点代表的字符串数量加一 }
//在Trie数中查找字符串 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];//如果按照链接的查找能够遍历完目标字符串的所有字符,那么就返回尾结点的字符串数量,如果为0的话说明该字符串集合中存在的其实是目标字符串的一个母串 }
int main() { int n; cin >> n; char op[2]; while(n --){ cin >> op >> str; if (*op == 'I') insert(str);//插入 else printf("%d\n", query(str));//搜索 } return 0; }
CPP
|
6.并查集
作用:将两个集合合并,询问两个元素是否在一个集合当中
原理:每个集合用一棵树来表示,树根的编号就是整个集合的编号,每个结点存储它的父节点,p[x]表示x的父节点,根结点的父节点就让他等于自己的编号,所以查找x的集合编号的时候就依次向上遍历父节点是否等于自身的编号就是了,需要将两个集合合并的时候,只需要将其中任意一个的根节点的父节点指向另一个集合的父节点就行了。
路径压缩优化:朴素版本的并查集的时间复杂度依旧较高,主要原因是查找的必须向上遍历多次,并且每次都需要遍历,所以路径优化的原理就是,对于查找过根结点的结点,直接将它们的父节点指向根结点,这样在需要重复查找的时候就能更加快速,使用路径压缩优化后的时间复杂度接近O(1)
代码实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
|
#include <iostream> using namespace std; const int N = 100010;
int n,m; int p[N];//每个点的父结点
int find(int x){//实现祖宗结点的的查找和路径压缩优化 if(p[x] != x) p[x] = find(p[x]); //如果这个点不是父节点那么就让它的父节点等于它的祖宗结点 return p[x]; }
int main() { cin >> n >> m; for(int i = 1; i <= n; i ++) p[i] = i;//初始化所有点的父节点 while(m --){ char op[2];//如果使用字符变量而不是使用字符串的话,使用scanf读入的时候会读入些莫名其妙的空格或者回车 int a,b; scanf("%s%d%d",&op,&a,&b); if(op[0] == 'M') p[find(a)] = find(b); else{ if(find(a) == find(b)) puts("Yes"); else puts("No"); } } return 0; }
CPP
|
其余变形的并查集无非就是多维护一些别的数据,接下来将给出两个例题。
连通块中点的数量
给定一个包含 n 个点(编号为 1∼n)的无向图,初始时图中没有边。
现在要进行 m 个操作,操作共有三种:
C a b
,在点 a 和点 b 之间连一条边,a 和 b 可能相等;
Q1 a b
,询问点 a 和点 b 是否在同一个连通块中,a 和 b 可能相等;
Q2 a
,询问点 a 所在连通块中点的数量;
输入格式
第一行输入整数 n 和 m。
接下来 m 行,每行包含一个操作指令,指令为 C a b
,Q1 a b
或 Q2 a
中的一种。
输出格式
对于每个询问指令 Q1 a b
,如果 a 和 b 在同一个连通块中,则输出 Yes
,否则输出 No
。
对于每个询问指令 Q2 a
,输出一个整数表示点 a 所在连通块中点的数量
每个结果占一行。
数据范围
$1≤n,m≤10^5$
输入样例:
1 2 3 4 5 6
|
5 5 C 1 2 Q1 1 2 Q2 1 C 2 5 Q2 5
APACHE
|
输出样例:
思路:此题是给定了n个点的无向图,这些点之间连接线后,可以形成连通块,同一个连通块可以只用一个集合来维护,所以就很容易的想到使用并查集,但是,操作当中需要寻找到连通块中点的数量,所以就需要有一个数组来维护连通块中点的数量是多少,注意,只需要维护每个根节点的这个变量就行了,只需要每个根节点的这个数字是正确的就能完成对应的操作。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
|
#include <iostream> #include <string> using namespace std; const int N = 100010;
int n,m; int p[N],cnt[N];
int find(int x){ if(p[x] != x) p[x] = find(p[x]); return p[x]; }
int main() { cin >> n >> m; for(int i = 1; i <= n; i ++) p[i] = i, cnt[i] = 1; while(m --){ char op[2]; int a,b; cin >> op; if(op[0] == 'C'){ cin >> a >> b; if(find(a) == find(b)) continue; p[find(a)] = find(b); cnt[b] += cnt[a]; //连通块中点的数量的维护 } else if(op[1] == '1'){ cin >> a >> b; if(find(a) == find(b)) puts("Yes"); else puts("No"); }else{ cin >> a; cout << cnt[p[a]] << endl; } } return 0; }
CPP
|
7.堆
手写堆最基本的操作:
- 插入一个数
- 求集合当中的最小值
- 删除最小值
- 删除任意一个元素
- 修改任意一个元素
前三个要求STL是可以实现的,最后两个是STL不能直接实现的。
堆是一棵完全二叉树,除了最后一层结点之外上层的所有结点都是满的,最后一层的结点是从左到右排列的,同时,(小根堆)堆满足所有的结点都小于左右子结点,所以很明显根节点就是最小值。
堆的存储是一种全新的存储方式,使用的是一维数组,x 结点的左儿子是 2x,右儿子是 2x+1,所以数组的下标从1开始比较方便。
主要函数是down()函数和up()函数,对传入的数据进行向上的维护或者向下的维护。
实现方式:heap[]数组模拟堆,size记录最后一个元素
- 插入一个数 heap[++size] = x; up(size);
- 求集合当中的最小值 heap[1];
- 删除最小值 heap[1] = heap[size];size–;down(1);
- 删除任意一个元素 heap[k] = heap[size];size–;down(k);up(k);
- 修改任意一个元素 heap[k] = x;down(k);up(k);
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
|
//堆的初始化,首先可以使用一个一个插入的方法,但是这个初始化的方法的时间复杂度是O(logn),另一种方法是将所有的数据先读入数组中,然后对n/2前的所有数进行一次down的操作,就能实现时间复杂度为O(n)的堆初始化 for(int i = n / 2; i; i --) down(i); //down()函数 注意:size的初始化一定要放在调用down函数之前 void down(int x){ int t = x; if(x * 2 <= size && h[x * 2] > h[t]) t = x * 2; if(x * 2 + 1 <= size && h[x * 2 + 1] > h[t]) t = x * 2 + 1; if(t != x) { swap(h[t],h[x]); down(t); } } //up函数 void up(int x){ while(x / 2 && h[x/2] > h[x]){ swip(h[x/2],h[x]); x /= 2; } }
|
标签:结点,
idx,
int,
基础,
++,
数组,
字符串,
数据结构
From: https://www.cnblogs.com/CJCZRX/p/17416497.html