数据结构
前言
从我们以往学的数据结构中,链表、树等数据结构会采用结构体来表示,如图:
但是笔试的时候链表是千万级别的,new结构体很费时间。所以我们会 用数组模拟链表。
链表与邻接表:树与图的存储
单链表
变量定义
static int head; //头结点下标
static int e[] = new int[N]; //存储节点的值【即结构体中的value】
static int ne[] = new int[N]; //存储节点的next指针的值
static int idx; //当前用到的点,即一个全局指针
增删改除等相关操作
- 初始化
public static void init(){
head = -1;
idx = 0;
}
-
插入
(1)头插:
//头插法:先连屁股再连头
public static void add_to_head(int x){
e[idx] = x; //赋予结点值
ne[idx] = head; //next指向头结点指向的结点(也就是首结点)——连屁股
head = idx; //头结点指向新加入的这个结点——连头
idx++; //结点的编号+1
}
(2)插到第 k 个点:
//将值为x的新结点插到第 k 个点的后面
public static void add(int k, int x){
e[idx] = x;
ne[idx] = ne[k]; //next指向第 k 个结点的下一个结点——连屁股
ne[k] = idx; //第 k 个结点的下一个结点指向新加入的这个结点——连头
idx++; //结点的编号+1
}
- 删除
//将第 k 个点的后面一个结点删了
public static void remove(int k){
ne[k] = ne[ne[k]]; //让k指向下下个结点
}
826.单链表
实现一个单链表,链表初始为空,支持三种操作:
- 向链表头插入一个数;
- 删除第 k 个插入的数后面的数;
- 在第 k 个插入的数后插入一个数。
(第 k 个插入的数不是指当前链表的第 k 个数,而是操作过程中按照插入的时间顺序第 k 个插入的数。因为如果插入再删除又插入,那第k个数实际可能就在第 k 个插入的数后面)
现在要对该链表进行 M 次操作,进行完所有操作后,从头到尾输出整个链表。
输入格式:第一行为整数 M,表示操作次数。接下来 M 行,每行包含一个操作命令,操作命令可能为以下几种:
H x
,表示向链表头插入一个数 x。D k
,表示删除第 k个插入的数后面的数(当 k 为 0 时,表示删除头结点)。I k x
,表示在第 k 个插入的数后面插入一个数 x(此操作中 k 均大于 0)。输出格式:共一行,将整个链表从头到尾输出。
数据范围:1≤M≤10^5,所有操作保证合法。
输入样例:
10 H 9 I 1 1 D 1 D 0 H 6 I 3 6 I 4 5 I 4 5 I 3 4 D 6
输出样例:
6 4 6 5
思路分析
第一种操作不解释;第二种操作,实际就是:删除 下标为 k-1 的结点 的下一个结点;第三种操作,实际就是:在 下标为 k-1 的结点 后插入一个数。原因:idx从下标为0开始,且每插入一个数就自增,正好符合题意。
代码实现
package acwing.DataStructure.LinkList;
import java.io.BufferedReader;
import java.io.InputStreamReader;
// 826.单链表
@SuppressWarnings("ALL")
public class Single {
static int N = 100010;
static int head; //头结点下标
static int e[] = new int[N]; //存储节点的值【即结构体中的value】
static int ne[] = new int[N]; //存储节点的next指针的值
static int idx; //当前用到的点,即一个全局指针
//链表初始化
public static void init(){
head = -1;
idx = 0;
}
//插入结点:先连屁股再连头
//头插法
public static void add_to_head(int x){
e[idx] = x; //赋予结点值
ne[idx] = head; //next指向头结点指向的结点(也就是首结点)——连屁股
head = idx; //头结点指向新加入的这个结点——连头
idx++; //结点的编号+1
}
//将值为x的新结点插到第 k 个点的后面
public static void add(int k, int x){
e[idx] = x;
ne[idx] = ne[k]; //next指向第 k 个结点的下一个结点——连屁股
ne[k] = idx; //第 k 个结点的下一个结点指向新加入的这个结点——连头
idx++; //结点的编号+1
}
//将第 k 个点的后面一个结点删了
public static void remove(int k){
ne[k] = ne[ne[k]]; //让k指向下下个结点
}
public static void main(String[] args) throws Exception{
BufferedReader bf = new BufferedReader(new InputStreamReader(System.in));
int M = Integer.parseInt(bf.readLine());
init();
for (int i = 0; i < M; i++) {
String[] s = bf.readLine().split(" ");
char op = s[0].charAt(0);
if (op == 'H') {
int x = Integer.parseInt(s[1]);
add_to_head(x);
}
else if (op == 'D') {
int k = Integer.parseInt(s[1]);
if (k==0) //删除头结点,使指向下下个结点
head=ne[head];
else
remove(k-1);
}
else {
int k = Integer.parseInt(s[1]);
int x = Integer.parseInt(s[2]);
add(k-1,x);
}
}
for (int i = head; i != -1 ; i=ne[i])
System.out.print(e[i] + " ");
}
}
双链表
基本操作
- 插入
(先红色再绿色)
public static void add(int k, int x){
e[idx] = x;
r[idx] = r[k];
l[idx] = k;
l[r[k]] = idx;
r[k] = idx;
idx++;
}
- 删除
//将第 k 个点删除
public static void remove(int k){
r[l[k]] = r[k];
l[r[k]] = l[k];
}
827.双链表
栈与队列:单调队列、单调栈
828.模拟栈
830.单调栈
给定一个长度为 N 的整数数列,输出每个数左边第一个比它小的数,如果不存在则输出 −1。
输入格式:第一行包含整数 N,表示数列长度。第二行包含 N 个整数,表示整数数列。
输出格式:共一行,包含N 个整数,其中第 i 个数表示第 i 个数的左边第一个比它小的数,如果不存在则输出 −1。
数据范围:1≤N≤105,1≤数列中元素≤109
输入样例:
5
3 4 2 7 5
输出样例:
-1 3 -1 2 2
暴力解法
单调栈解法
思路
用一个栈依次存储输入的数 x,但每次存储 x时需要保证:栈中的原先 >x 的数全部删除。什么意思呢?看图:
输入第 2 个数 x 时,往回遍历,遇到第 1 个数,发现他比 x 大,那就把 x 删了。对于每个数都这样操作,那么最终得到的将是一个单调栈:
我看到这个思路的时候还有点懵,为什么可以把左边所有大于 x的数就这样全部删除,万一对之后输入的数还有用(即有可能成为之后输入的数 的左边第一个小于它的数)呢?思考过后,答案是否定的。因为对于第 i+1 个数:1.若第 i 个数 < 第 i+1 个数,那答案就是第 i 个数;2.若第 i 个数 > 第 i+1 个数,那答案肯定也不可能是在 对第 i 个数操作时,删除的那些比第 i 个数还大的数。因此就像图中,这种思路处理后栈内是单调的,第一个最小的数就在栈顶。
代码实现
package acwing.DataStructure.Stack_Queue;
import java.io.BufferedReader;
import java.io.InputStreamReader;
//830.单调栈
public class Stack {
static int N = 100010;
static int[] stack = new int[N]; //单调栈
static int tt; //栈顶指针
public static void main(String[] args) throws Exception{
BufferedReader bf = new BufferedReader(new InputStreamReader(System.in));
int n = Integer.parseInt(bf.readLine());
String[] s = bf.readLine().split(" ");
for (int i = 0; i < n; i++) {
int x = Integer.parseInt(s[i]);
while (tt!= 0 && stack[tt]>=x) //让栈顶指针移动到第一个<x的位置(即删除所有>=x的)
tt--;
if (tt!=0)
System.out.print(stack[tt] + " ");
else
System.out.print(-1 + " ");
stack[++tt] = x; //记得把 x 加到栈顶
}
}
}
单调队列:154.滑动窗口
给定一个大小为 n≤10^6 的数组。有一个大小为 k 的滑动窗口,它从数组的最左边移动到最右边。你只能在窗口中看到 k 个数字。每次滑动窗口向右移动一个位置。以下是一个例子:该数组为
[1 3 -1 -3 5 3 6 7]
,k 为 3。
窗口位置 最小值 最大值 [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 个整数,代表数组的具体数值。同行数据之间用空格隔开。
输出格式:输出包含两个。第一行输出,从左至右,每个位置滑动窗口中的最小值。第二行输出,从左至右,每个位置滑动窗口中的最大值。
输入样例:
8 3 1 3 -1 -3 5 3 6 7
输出样例:
-1 -3 -3 -3 3 3 3 3 5 5 6 7
思路分析
首先暴力做法肯定是,对于每次移动完的窗口,遍历窗口内的数找出最小值。因此,可以用单调栈的思想,对于如图所示情况:
,3和-1其实是没用的可以删除的数。因为窗口是从左往右移动,-3在最右边。只要 -3 在,窗口内最小的数就轮不到3和 -1来当。因此我们可以使用一个队列存储所有还没有被移除的下标。
- 在队列中,这些下标按照从小到大的顺序被存储,并且它们在输入数列 a 中对应的值是严格单调递增的。
- 窗口向右移动时,我们还需要不断从队首弹出元素保证队列中的所有元素都是窗口中的,即每一轮移动完窗口时,让队头移动到窗口头。
- 当滑动窗口向右移动时,我们需要把一个新的元素放入队列中。为了保持队列的性质,我们会不断地将新的元素与队尾的元素相比较,如果新元素小于等于队尾元素,那么队尾的元素就可以被永久地移除,我们将其弹出队列。我们需要不断地进行此项操作,直到队列为空或者新的元素大于队尾的元素。
最小值和最大值分开来做,两个for循环完全类似,都做以下四步:
- 解决队首已经出窗口的问题;
- 解决队尾与当前元素a[i]不满足单调性的问题;
- 将当前元素下标加入队尾;
- 如果满足条件则输出结果;
需要注意的细节:
- 上面四个步骤中一定要先3后4,因为有可能输出的正是新加入的那个元素;
- 队列中存的是原数组的下标,取值时要再套一层,a[q[]];
- 算最大值前注意将hh和tt重置;
- hh从0开始,数组下标也要从0开始。
代码实现
package acwing.DataStructure.Stack_Queue;
import java.io.BufferedReader;
import java.io.InputStreamReader;
//154.滑动窗口
public class SlipWindow_154 {
static int N = 1000010;
static int[] q = new int[N]; //单调队列,存储的值为数列下标而非元素值
static int[] a = new int[N]; //输入数列
public static void main(String[] args) throws Exception{
int hh = 0; //队头指针
int tt = -1; //队尾指针
BufferedReader bf = new BufferedReader(new InputStreamReader(System.in));
String[] s = bf.readLine().split(" ");
int n = Integer.parseInt(s[0]);
int k = Integer.parseInt(s[1]);
String[] s1 = bf.readLine().split(" ");
for (int i = 0; i < n; i++) //输入数列
a[i] = Integer.parseInt(s1[i]);
for (int i = 0; i < n; i++) { //移动窗口轮次
//判断队头是否已经滑出窗口
// 即保证队头的下标要在窗口范围内
if (hh <= tt && q[hh] < i - k + 1)
hh++;
//让队尾元素不断弹出直到队尾<新加入的元素
while (hh <= tt && a[q[tt]] >= a[i])
tt--;
q[++tt] = i;
if (i >= k - 1) //窗口内的k个数都移进来了才开始输出
System.out.print(a[q[hh]] + " ");
}
System.out.println();
//窗口最大值序列一样再来一遍
hh = 0; //队头指针
tt = -1; //队尾指针
for (int i = 0; i < n; i++) {
if (hh <= tt && q[hh] < i - k + 1)
hh++;
while (hh <= tt && a[q[tt]] <= a[i])
tt--;
q[++tt] = i;
if (i >= k - 1)
System.out.print(a[q[hh]] + " ");
}
}
}
kmp(831.难)
给定一个字符串 S,以及一个模式串 P,所有字符串中只包含大小写英文字母以及阿拉伯数字。模式串 P 在字符串 S 中多次作为子串出现。求出模式串 P 在字符串 S 中所有出现的位置的起始下标。
输入格式:第一行输入整数 N,表示字符串 P 的长度。第二行输入字符串 P。
第三行输入整数 M,表示字符串 S 的长度。第四行输入字符串 S。
输出格式:共一行,输出所有出现位置的起始下标(下标从 0 开始计数),整数之间用空格隔开。
数据范围:1≤N≤10^5 1≤M≤10^6
输入样例:
3 aba 5 ababa
输出样例:
0 2
(傻逼题目调试失败但是能ac,弄了好久)
暴力解法
思路分析
具体去看kmp,(最好去看视频动态更好理解)这里简单说一下:
由上可知,暴力做法相当于拿 p 去匹配 s,遍历到不匹配的字符时,下一轮,从下一个 s[i] 和 p 的开头进行匹配。其实没必要从头开始,所以提出了kmp,使得匹配到错误字符时,j不要从头开始,s也不要严格从 i++ 这个字符开始。
求next数组的思路(较难,看了好多篇博客才理解)
(借鉴链接:https://www.acwing.com/solution/content/129372/)
next[i] 存储的就是 使子串 s[0…i] 有最长相等前后缀的前缀的最后一位的下标
next[i] = -1 表示没有相等的前后缀;=0表示第一个字符和最后一个字符相等。
求解 next ,暴力做法是可行的,但需要遍历太多次了。下面用 “递推” 的方式来高效求解 next 数组,也就是假设已经求出了 next[0] ~ next[i-1],用它们来推算出 next[i]。
以 s = “abababc” 作为例子。假设已经有了 next[0] = -1、next[1] = -1、next[2] = 0、next[3] = 1,现在来求解 next[4]。如下图所示,当已经得到 next[3] = 1 时,最长相等前后缀为 “ab”,之后计算 next[4] 时,由于 s[4] == s[next[3] + 1] ,因此可以把最长相等前后缀 “ab” 扩展为 “aba”,因此 next[4] = next[3] + 1,并令 j 指向 next[4]。
接着在此基础上求解 next[5]。如下图所示,当已经得到 next[4] = 2 时,最长相等前后缀为 “aba”,之后计算 next[5] 时,由于 s[5] != s[next[4] + 1],因此不能扩展当前相等前后缀,即不能直接通过 next[4] + 1 的方法得到 next[5]。既然相等前后缀没办法达到那么长,那不妨缩短一点!此时希望找到找到一个 j,使得 s[5] == s[j + 1] 成立,同时使得图中的波浪线 ~,也就是 s[0…j] 是 s[0…2] = “aba” 的后缀,而 s[0…j] 是 s[0…2] 的前缀是显然的。同时为了找到相等前后缀尽可能长,找到这个 j 应尽可能大。我们惊奇地发现,实际上s[0…j] 就是 s[0…2] 的最长相等前后缀,即先令 j = next[2]。然后再判断 s[5] == s[j + 1] 是否成立:如果成立,说明 s[0…j + 1] 是 s[0…5] 的最长相等前后缀,令 next[5] = j + 1 即可;如果不成立,就不断让 j = next[j],直到 j 回到了 -1,或是途中 s[5] == s[j + 1] 成立。
如上图所示,j 从 2 回退到 next[2] = 0,发现 s[5] == s[j + 1] 不成立,就继续让 j 从 0 回退到 next[0] = -1;由于 j 已经回退到了 -1,因此不再继续回退。这时发现 s[i] == s[j + 1] 成立,说明 s[0…j + 1] 是 s[0…5] 的最长相等前后缀,于是令 next[5] = j + 1 = -1 + 1 = 0,并令 j 指向 next[5]。
下面总结 next 数组的求解过程,并给出代码:
-
初始化 ne 数组,令 j = ne[0] = -1。
-
让 i 在 1 ~ len - 1范围内遍历,对每个 i ,执行 3、4,以求解 ne[i]。
-
直到 j 回退为 -1,或是 s[i] == s[j + 1] 成立,否则不断令 j = ne[j]。
-
如果 s[i] == s[j + 1],则 ne[i] = j + 1;否则 ne[i] = j。
int j = -1; for (int i = 1; i < n; i++) { //ne[0]已经初始化过了,因此从1开始 while (j!=-1 && p[i]!=p[j+1]) j = ne[j]; if (p[i] == p[j+1]) j++; ne[i] = j; }
kmp过程
比较简单,直接看代码注释
j=-1;
for (int i = 0; i < m; i++) {
//若s和p的字符一直不匹配,且还没回退到p的开头,则不断让p回退到next的位置进行匹配
while (j!=-1 && s[i]!=p[j+1])
j = ne[j];
//如果是因为p到头了才退出循环,那么判断甚至p头会不会和这个s匹配
if (s[i] == p[j+1])
j++; //p的第一个字符和s这一轮的首字符匹配,那j=0,即总算这一轮匹配有一个字符能匹配上
if (j == n-1){ //直到p串的最后一位也匹配成功
int k = i-n+1; //我们要输出的下标
wt.write(k + " ");
//System.out.print(k + " ");
j = ne[j];
}
}
总体代码
这里为了避免超时,不止加入了BufferedReader,还有BufferedWriter
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
//831.kmp
public class kmp {
static int N = 100010;
static int M = 1000010;
static char[] p = new char[N]; //模式串P
static char[] s = new char[M]; //字符串S
static int[] ne = new int[N]; //next数组
public static void main(String[] args) throws Exception {
ne[0] = -1;
BufferedReader bf = new BufferedReader(new InputStreamReader(System.in));
BufferedWriter wt = new BufferedWriter(new OutputStreamWriter(System.out));
int n = Integer.parseInt(bf.readLine());
String s2 = bf.readLine();
for (int i = 0; i < n; i++)
p[i] = s2.charAt(i);
int m = Integer.parseInt(bf.readLine());
String s1 = bf.readLine();
for (int i = 0; i < m; i++)
s[i] = s1.charAt(i);
//求解next数组
int j = -1;
for (int i = 1; i < n; i++) { //ne[0]已经初始化过了,因此从1开始
while (j!=-1 && p[i]!=p[j+1])
j = ne[j];
if (p[i] == p[j+1])
j++;
ne[i] = j;
}
//kmp算法
j=-1;
for (int i = 0; i < m; i++) {
//若s和p的字符一直不匹配,且还没回退到p的开头,则不断让p回退到next的位置进行匹配
while (j!=-1 && s[i]!=p[j+1])
j = ne[j];
//如果是因为p到头了才退出循环,那么判断甚至p头会不会和这个s匹配
if (s[i] == p[j+1])
j++; //p的第一个字符和s这一轮的首字符匹配,那j=0,即总算这一轮匹配有一个字符能匹配上
if (j == n-1){ //直到p串的最后一位也匹配成功
int k = i-n+1; //我们要输出的下标
wt.write(k + " ");
// System.out.print(k + " ");
j = ne[j];
}
}
//所有write下的内容,会先存在writers中,当启用flush以后,会输出存在其中的内容
//如果没有调用flush,则不会将writer中的内容进行输出
wt.flush();
wt.close();
bf.close();
}
}
Trie
835.Trie字符串统计
维护一个字符串集合,支持两种操作:
I x
向集合中插入一个字符串 x;Q x
询问一个字符串在集合中出现了多少次。共有 N个操作,所有输入的字符串总长度不超过 10^5,字符串仅包含小写英文字母。
输入格式:第一行为整数 N,表示操作的个数。接下来N行,每行包含一个操作指令,指令为
I x
或Q x
。输出格式:对于每个询问指令
Q x
,都要输出一个整数作为结果,表示 x 在集合中出现的次数。每个结果占一行。数据范围:1≤N≤2∗10^4
输入样例:
5 I abc Q abc Q ab I ab Q ab
输出样例:
1 0 1
思路分析
我们可以用一棵树存储已插入的字符串如图:
并且在每一个字符串末位打好标记,这样才知道这棵树里有没有某个子串并且有几个。那么执行 I 插入操作时,直接沿着路走,没有路则开辟路,并且做好最终标记;执行 Q 查询操作的时候,沿着分支查询看:1.走的是否是一条已开辟的路;2.停下的终点是否是被标记过的插入终点。
代码实现
本题较为简单,直接看注释。本题的输入也比较特殊,可以注意一下
import java.io.BufferedReader;
import java.io.InputStreamReader;
public class _835 {
static int N = 100010;
static int[] cnt = new int[N]; //统计某个子串的出现次数
static int idx; //当前用的的哪个下标,下标0:既是根节点又是空节点
static int[][] tree = new int[N][26];
//存储字符串的树,最多N层;因为是小写英文字母,所以每个结点最多26个分支
//询问操作 Q
public static int query(char str[]){
int p = 0; //从根节点0开始查询
for (int i = 0; i<str.length ; i++) {
int u = str[i] - 'a'; //当前字母转化为数字
if (tree[p][u]==0) //如果走到一个不存在的子节点(暂时还没有这条路)
return 0; //查找失败
p = tree[p][u]; //否则向前一步:走到下一个结点
}
return cnt[p]; //退出循环的 p 就是走完str.length步的最终的终点
}
//插入操作 I
public static void insert(char str[]){
int p = 0;
for (int i = 0; i<str.length ; i++) {
int u = str[i] - 'a';
if (tree[p][u]==0)
tree[p][u] = ++idx; //没有路就开辟一条路
p = tree[p][u];
}
cnt[p]++; //该串计数++
}
public static void main(String[] args) throws Exception{
BufferedReader bf = new BufferedReader(new InputStreamReader(System.in));
int n = Integer.parseInt(bf.readLine());
for (int i = 0; i < n; i++) {
String s[] = bf.readLine().split(" ");
char op = s[0].charAt(0); //String字符-->char字符
char[] str = s[1].toCharArray(); //String数组-->char数组
if (op == 'I')
insert(str);
else
System.out.println(query(str));
}
}
}
最大异或对
并查集
836.合并集合(简)
一共有 n 个数,编号是 1∼n,最开始每个数各自在一个集合中。现在要进行 m 个操作,操作共有两种:
M a b
,将编号为 a 和 b 的两个数所在的集合合并,如果两个数已经在同一个集合中,则忽略这个操作;Q a b
,询问编号为 a 和 b 的两个数是否在同一个集合中;输入格式:第一行输入整数 n 和 m。接下来 m 行每行包含一个操作指令,为
M a b
或Q a b
中的一种。输出格式:对于每个询问指令
Q a b
,都要输出一个结果,如果 a 和 b 在同一集合内,则输出Yes
,否则输出No
。每个结果占一行。数据范围:1≤n,m≤10^5
输入样例:
4 5 M 1 2 M 3 4 Q 1 2 Q 1 3 Q 3 4
输出样例:
Yes No Yes
思路分析
问题1:只让树根结点的父节点等于他自己,那么如果满足条件就说明这是树根;
问题2:不断向上找爸爸直到找到祖先即根节点;
问题3:让x的祖先(本来是x所在集合的元老)接到b的祖先下面(我的祖先变成了我好朋友的子孙辈)如图:
路径压缩
如图,当向上找父节点的时候,也可以把所有路上的父辈将来都直接变成祖先的儿子。这样下一次找的时候时间复杂度就是O(1)
代码实现
import java.io.BufferedReader;
import java.io.InputStreamReader;
public class _836 {
static int N = 100010;
static int[] p = new int[N]; //存储某个节点的父节点
//查找 x 所在集合的操作(返回该节点的祖先)
public static int find(int x){
if (x != p[x]) //不断向上找爸爸,并进行路径压缩
p[x] = find(p[x]); //给px赋值就是在路径压缩——让路上所有爸爸将来都直接变成祖先的儿子(如果直接递归执行find就没有实现路径压缩)
return p[x];
}
public static void main(String[] args) throws Exception{
BufferedReader bf = new BufferedReader(new InputStreamReader(System.in));
String[] s = bf.readLine().split(" ");
int n = Integer.parseInt(s[0]);
int m = Integer.parseInt(s[1]);
for (int i = 1; i <= n; i++) //初始化p数组
p[i] = i; //所有结点开始都是独立的集合,都是根节点
for (int i = 0; i < m; i++) {
String[] s1 = bf.readLine().split(" ");
char op = s1[0].charAt(0);
int a = Integer.parseInt(s1[1]);
int b = Integer.parseInt(s1[2]);
if (op == 'M') //合并ab所在集合
p[find(a)] = find(b); //a的祖先的父节点为b的祖先
else { //判断ab是不是同一个祖先
if (find(a) == find(b))
System.out.println("Yes");
else
System.out.println("No");
}
}
}
}
837.连通块中点的数量(简)
给定一个包含 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
输入样例:
5 5 C 1 2 Q1 1 2 Q2 1 C 2 5 Q2 5
输出样例:
Yes 2 3
思路分析
这题可以借助并查集来实现。只需在 6.1 的基础上增加一个 size 数组记录每一个连通块点的数量。
- 将 size 初始化为1表示一开始每个连通块都只有一个点;
- 对于 C 操作:实际上我认为这里y总的处理,并不是严格意义上在a、b之间连一条线。是出于本题的特殊性:另外两个操作 Q1 和 Q2,只涉及到连通块。所以我们可以将 C 操作——连接ab两点,在实际代码中转换成:让a、b两点同个祖先即可。那么 C 操作就是在让 a 这个连通块并到 b的连通块中,并且也要改变a的祖先(和6.1一样)
- 对于 Q1 操作:直接判断俩人祖先是否一样,一样则在同一个连通块中;
- 对于 Q2 操作:直接返回a的祖先的size;
- 注意:只有根节点的 size 有意义
代码实现(部分)
for (int i = 1; i <= n; i++) { //初始化p、size数组
p[i] = i; //所有结点开始都是独立的集合,都是根节点
size[i] = 1;
}
for (int i = 0; i < m; i++) {
String[] s1 = bf.readLine().split(" ");
char op = s1[0].charAt(0);
int a = Integer.parseInt(s1[1]);
if (op == 'C') { //在a和b之间连一条边
int b = Integer.parseInt(s1[2]);
if (find(a) == find(b)) //如果ab已经在一个连通块中
continue;
size[find(b)] += size[find(a)];
p[find(a)] = find(b); //a的祖先的父节点为b的祖先
}
else { // Q操作
char op1 = s1[0].charAt(1);
if (op1 == '1') {//询问ab是否在同一个连通块中
int b = Integer.parseInt(s1[2]);
if (find(a) == find(b))
System.out.println("Yes");
else
System.out.println("No");
}
else //询问点a所在连通块中点的数量
System.out.println(size[find(a)]);
}
}
240.食物链
堆
堆分为手写堆和优先队列。手写堆的好处是 可以修改任意一个元素,而优先队列不支持这个操作。但手写堆是含输入的 n 个元素,是全部插入然后调整成堆;而优先队列只含 m 个(m通常 <n^2)元素,是插一个调一下插一个调一下。C++、Python、Java中都有实现优先队列的数据机构。所以优先队列的好处是:1.不用手动实现;2.时间复杂度更低(mlogm)。第三章第二节优化 Dijkstra 算法会用到优先队列。
手写堆
heap数组用来存储堆中的元素;size表示堆中元素个数(也是最后一个结点的编号);down是元素下沉操作;up是元素上调操作。
838.堆排序(简)
输入一个长度为 n 的整数数列,从小到大输出前 m 小的数。
输入格式:第一行包含整数 n 和 m。第二行包含 n 个整数,表示整数数列。
输出格式:共一行,包含 m 个整数,表示整数数列中前 m 小的数。
数据范围:1≤m≤n≤105,1≤数列中元素≤109
输入样例:
5 3 4 5 1 3 2
输出样例:
1 2 3
思路分析
简单回顾一下堆的概念:小顶堆就是任意一个结点的值都比左右孩子小,这样就能保证树根元素是全局最小值,大顶堆反之。堆是一个完全二叉树。
代码思路:(直接看代码也行这题不难而且注释写的很详细了)
- 先直接按顺序将数值输入到 h 数组中;
- 从编号为 n/2 的节点开始循环调用下沉方法。因为n/2之后的节点处于最后一层,不会再下沉。
- 步骤2之后这个堆就是一个合格的小顶堆了,依次输出堆顶元素,输出后把最后一个元素换上来到堆顶,再用 down 方法调整堆顶使之依然符合小顶堆的定义;
- down方法的逻辑:定义变量 t 为:一个父亲 x 和俩儿子——2x、2x+1 三者中值最小的点的编号,通过比较确定这个编号,如果 t != x,就交换,并且继续下沉t。
代码实现
import java.io.BufferedReader;
import java.io.InputStreamReader;
public class _838 {
static int N = 100010;
static int size; //堆中元素个数(也是最后一个结点的编号)
static int[] h = new int[N];
private static void swap(int[] a, int i, int j) {
int tmp = a[i];
a[i] = a[j];
a[j] = tmp;
}
public static void down(int x){
int t = x; //一个父亲 x 和俩儿子 三者中值最小的点的编号
if (2*x <= size && h[2*x] < h[t]) //左儿子存在且比父亲小(那父亲不能忍啊因为父亲要是最小的)
t = 2*x;
if (2*x+1 <= size && h[2*x+1] < h[t]) //右儿子同理
t = 2*x+1;
if (t != x) { //如果值最小的节点的编号不是原来的x
swap(h,t,x);
down(t);
}
}
//元素上调操作(本题没用到)
public static void up(int x){
while ( x/2 != 0 && h[x/2]>h[x]){
swap(h,x,x/2);
x = x/2;
}
}
public static void main(String[] args) throws Exception{
BufferedReader bf = new BufferedReader(new InputStreamReader(System.in));
String[] s = bf.readLine().split(" ");
int n = Integer.parseInt(s[0]);
int m = Integer.parseInt(s[1]);
String[] s1 = bf.readLine().split(" ");
size = n;
for (int i = 1; i <= size; i++)
h[i] = Integer.parseInt(s1[i-1]);
for (int i = n/2; i!=0 ; i--) //使 h 成为一个合格的小顶堆
down(i);
for (int j = 0; j < m; j++) {
System.out.print(h[1]+" "); //依次输出堆顶元素
h[1] = h[size--]; //把最后一个元素换上来到堆顶
down(1); //换上来之后要调整堆顶使之依然符合小顶堆的定义
}
}
}
错误记录
这道题犯了一个还挺经典的错误:在 down 方法中实现交换堆中元素的时候,先写了一个swap方法如下:
public static void swap(int a, int b) {
int temp = a;
a = b;
b = temp;
}
这个方法,在 down 方法中调用后发现,并没有改变h数组的值…然后看到了另一个方法:
private static void swap(int[] a, int i, int j) {
int tmp = a[i];
a[i] = a[j];
a[j] = tmp;
}
执行后能真正实现交换 h 数组的元素。接下来来解释一波:(考察java基础了)
第一种方法的参数是另建了 int 类型的对象,int是基本数据类型。执行 swap 函数的时候,操作是在新的两个实参 int 数上进行的,并不改变原来 h 数组中的值;而第二种方法因为参数是数组,是引用类型,调用的时候传递的是 h 的地址,所以原来数组中的值发生了改变。
839.模拟堆
维护一个集合,初始时集合为空,支持如下几种操作:
I x
,插入一个数 x;PM
,输出当前集合中的最小值;DM
,删除当前集合中的最小值(数据保证此时的最小值唯一);D k
,删除第 k 个插入的数;C k x
,修改第 k 个插入的数,将其变为 x;
现在要进行 N 次操作,对于所有第 2 个操作,输出当前集合的最小值。
输入格式:第一行包含整数 N。接下来 N 行,每行包含一个操作指令,操作指令为 I x
,PM
,DM
,D k
或 C k x
中的一种。
输出格式:对于每个输出指令 PM
,输出一个结果,表示当前集合中的最小值。每个结果占一行。
数据范围:1≤N≤105,−109≤x≤10^9。数据保证合法。
输入样例:
8
I -10
PM
I -10
D 1
C 2 8
I 6
PM
DM
输出样例:
-10
6
思路分析
跟上题比较,因为有一个 第 j(上面写的是k,但为了不混淆取名成 j) 个插入的点 的相关操作,因此需要新增两个数组:hp和ph。主要难点也在他俩的理解(稍微有点绕)。如图,ph[j] 表示 第 j 个插入的点的编号为 k。但是将来输入的是结点编号,我们为了从节点编号映射到是第几个插入的元素,设置数组 hp。hp有点像 ph 的反函数,hp[k]=j,代表编号为 k 的点是第 hp[k] 个插入的。再说的简单一点。ph就是找编号的,hp就是找是第几个插入的。
于是要写一个堆中特殊的交换方法,对上述数组进行特殊处理:
//交换编号为 i、j 的节点,要对一些数组进行特殊处理,是堆中特殊的交换方法
// 本质上是因为节点的编号变了
// 1.交换ph的值——插入号对应的编号要交换一下
// 2.反过来编号变了,对应的插入号也要变
// 3.编号对应值也要变这个很好理解
private static void heap_swap(int[] a, int i, int j) {
swap(ph, hp[i], hp[j]); //因为ph的下标一定是节点的插入次序,所以先让 hp 找到他俩的插入次序,然后交换ph数组值即他俩的编号
swap(hp, i, j); //交换插入号
swap(h, i, j); //最后交换堆中的这两个元素值
}
之后主函数就,根据操作符执行对应的操作。执行时考虑该操作要怎样更改数组。
代码实现(主函数部分)
public static void main(String[] args) throws Exception{
int k,x;
BufferedReader bf = new BufferedReader(new InputStreamReader(System.in));
int n = Integer.parseInt(bf.readLine());
for (int i = 0; i < n; i++) {
String[] s = bf.readLine().split(" ");
String op = String.valueOf(s[0]); //取出操作符
switch (op){
case "I": //插入数x
x = Integer.parseInt(s[1]);
size++;
ph[++idx] = size;
hp[size] = idx;
h[size] = x;
up(size);
break;
case "PM": //输出最小值即堆顶元素值
System.out.println(h[1]);
break;
case "DM": //删除最小值,就把最后一个结点拿到堆顶然后更新堆
heap_swap(1,size);
size--;
down(1);
break;
case "D": //删除第 k 个插入的元素,就把最后一个结点拿到第 k 个插入的元素的位置然后更新堆
k = Integer.parseInt(s[1]);
k = ph[k];
heap_swap(k,size);
size--;
down(k);
up(k);
break;
case "C": //更改第 k 个插入的元素的值,直接改 h 数组值然后更新堆就行
k = Integer.parseInt(s[1]);
x = Integer.parseInt(s[2]);
h[ph[k]] = x;
down(ph[k]);
up(ph[k]);
break;
}
}
}
优先队列
java中用 PriorityQueue 实现。
介绍
类图如下:
特性
-
PriorityQueue 中放置的元素必须要能够比较大小,不能插入无法比较大小的对象,否则会抛出 ClassCastException 异常
-
不能插入 null 对象,否则会抛出 NullPointerException,而Queue是可以插入null的
-
没有容量限制,可以插入任意多个元素,其内部可以自动扩容
-
插入和删除元素的时间复杂度为O(log(N))
-
PriorityQueue 底层使用了堆数据结构
-
PriorityQueue 默认情况下是小堆 — 即每次获取到的元素都是最小的元素
方法
构造方法 | 说明 |
---|---|
PriorityQueue() | 不带参数,默认容量为1 |
PriorityQueue(int initialCapacity) | 参数为初始容量,该初始容量不能小于1 |
PriorityQueue(Collection<? extends E> c) | 参数为一个集合 |
方法 | 说明 |
---|---|
boolean offer(E e) | 插入元素e,返回是否插入成功,e为null,会抛异常 |
E peek() | 获取堆顶元素(不删除),如果队列为空,返回null |
E poll() | 取出堆顶元素 |
int size() | 获取有效元素个数 |
void clear() | 清空队列 |
boolean isEmpty() | 判断队列是否为空 |
第一个构造方法:
// 创建一个空的优先级队列,底层默认容量是1
PriorityQueue<Integer> q1 = new PriorityQueue<>();
第二个构造方法:
// 创建一个空的优先级队列,底层的容量为initialCapacity
PriorityQueue<Integer> q2 = new PriorityQueue<>(100);
第三个构造方法:
ArrayList<Integer> list = new ArrayList<>();
list.add(4);
list.add(3);
list.add(2);
list.add(1);
// 用ArrayList对象来构造一个优先级队列的对象
PriorityQueue<Integer> q3 = new PriorityQueue<>(list);
//此时q3中已经包含了四个元素
System.out.println(q3.size());//4
System.out.println(q3.peek());//1
默认情况下,PriorityQueue队列是小堆,如果要转换成大堆需要用户提供比较器:
class IntCmp implements Comparator<Integer> {
@Override
public int compare(Integer o1, Integer o2) {
return o2-o1;//大根堆
//return o2-o1; 小根堆
}
}
public class PriorityQueueDemo {
public static void main(String[] args) {
IntCmp intcmp = new IntCmp();
PriorityQueue<Integer> priorityQueue = new PriorityQueue<>(intcmp) ;
priorityQueue.offer(4);
priorityQueue.offer(3);
priorityQueue.offer(2);
priorityQueue.offer(1);
System.out.println(priorityQueue.peek());//4
}
}
也可以用匿名内部类的写法:
public static void main(String[] args) {
PriorityQueue<Integer> priorityQueue =
new PriorityQueue<>(new Comparator<Integer>() {
@Override
public int compare(Integer o1, Integer o2) {
return o2 - o1;
}
}) ;
priorityQueue.offer(12);
priorityQueue.offer(2);
priorityQueue.offer(80);
System.out.println(priorityQueue.peek());//80
}
Lambda表达式写法(推荐使用):
PriorityQueue<Integer> queue = new PriorityQueue<>((o1 , o2) -> o1 - o2); // 小根堆
PriorityQueue<Integer> queue = new PriorityQueue<>((o1 , o2) -> o2 - o1); // 大根堆
哈希表
840.模拟散列表(简)
维护一个集合,支持如下几种操作:
I x
,插入一个整数 x;Q x
,询问整数 x 是否在集合中出现过;
现在要进行 N 次操作,对于每个询问操作输出对应的结果。
输入格式:第一行包含整数 N,表示操作数量。接下来 N 行,每行包含一个操作指令,操作指令为 I x
,Q x
中的一种。
输出格式:对于每个询问指令 Q x
,输出一个询问结果,如果 x 在集合中出现过,则输出 Yes
,否则输出 No
每个结果占一行。
数据范围:1≤N≤105,−109≤x≤10^9
输入样例:
5
I 1
I 2
I 3
Q 2
Q 5
输出样例:
Yes
No
代码实现
比较简单,直接上代码了。有的是之前的单链表相关定义和操作,还有拉链法和开放地址法,忘了就自己去看。
拉链法
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.util.Arrays;
public class _840 {
static int N = 100003;
static int[] h = new int[N];
static int[] ne = new int[N];
static int[] e = new int[N];
static int idx;
public static void insert(int x){
int k = (x % N + N) % N; //负数取模为负,所以需要+N再取模。k是拉链法对应的槽位
e[idx] = x;
ne[idx] = h[k];
h[k] =idx++;
}
public static boolean find(int x){
int k = (x % N + N) % N;
for (int i = h[k]; i != -1 ; i=ne[i])
if (x == e[i])
return true;
return false;
}
public static void main(String[] args) throws Exception{
Arrays.fill(h,-1); //将h数组初始化全为-1(很重要,忘了初始化后debug会卡在下面第4行莫名其妙的位置)
BufferedReader bf = new BufferedReader(new InputStreamReader(System.in));
int n = Integer.parseInt(bf.readLine());
for (int i = 0; i < n; i++) {
String[] s = bf.readLine().split(" ");
String op = s[0]; //取出操作符
int x = Integer.parseInt(s[1]);
if (op.equals("I"))
insert(x);
else {
if (find(x))
System.out.println("Yes");
else
System.out.println("No");
}
}
}
}
开放寻址法(更佳)
有一个细节,在定义槽位数组 h 的时候应该定义成 Integer 类的数组,因为 int 是数据类型,无法判空,只有对象才能判空。
public class _840 {
static int N = 100003;
private static Integer[] h = new Integer[N]; //因为要用null标识节点空,所以类型为Integer
public static int find(int x){
int k = (x % N + N) % N;
while (h[k] != null && h[k] != x){ //如果槽位非空且没找到x
k++; //往后找
if (k == N) //如果找到最后一个槽位了
k=0; //从头开始找
}
return k; //返回值为找到的x所在的槽位 或 x本该存在但为空的槽位
}
public static void main(String[] args) throws Exception{
Arrays.fill(h,null); //将h数组初始化为无穷大
BufferedReader bf = new BufferedReader(new InputStreamReader(System.in));
int n = Integer.parseInt(bf.readLine());
for (int i = 0; i < n; i++) {
String[] s = bf.readLine().split(" ");
String op = s[0]; //取出操作符
int x = Integer.parseInt(s[1]);
if (op.equals("I"))
h[find(x)] = x;
else {
if (h[find(x)] != null)
System.out.println("Yes");
else
System.out.println("No");
}
}
}
}
841.字符串哈希(算本章较难的)
给定一个长度为 n 的字符串,再给定 m 个询问,每个询问包含四个整数 l1,r1,l2,r2,请你判断 [l1, r1] 和 [l2,r2] 这两个区间所包含的字符串子串是否完全相同。字符串中只包含大小写英文字母和数字。
输入格式:第一行包含整数 n 和 m,表示字符串长度和询问次数。第二行包含一个长度为 n 的字符串,字符串中只包含大小写英文字母和数字。接下来 m 行,每行包含四个整数 l1,r1,l2,r2,表示一次询问所涉及的两个区间。注意,字符串的位置从 1 开始编号。
输出格式:对于每个询问输出一个结果,如果两个字符串子串完全相同则输出
Yes
,否则输出No
。每个结果占一行。数据范围:1≤n,m≤10^5
输入样例:
8 3 aabbaabb 1 3 5 7 1 3 6 8 1 2 1 2
输出样例:
Yes No Yes
题目解析
给出一个字符串,例如 s = “adcdabcde”
给出[l1, r1],[l2, r2]
问字符串的子串 s[l1, r1] s[l2, r2] 是否相同
思路分析
那个,本来以为python有列表切片直接秒杀,然后数据量太大的测试点过不去,(因为用列表切片来比应该就是一位一位字符比,如果子串长度很大就会超时)。还是老老实实用哈希方法,将每一个子串都映射成一个哈希值,只比较一次。对进制的理解比较高,如果学过计组那魔鬼的第二章可能会比较容易,但问题也不大。
总体思路
将字符串转换成一个P进制的数字:将输入字符串的每一个字符都映射成一个数字,这里就是每一个字符的ascii 码值。然后选定 P, 每一个子串都能转换成一个P 进制的数字,这个数字值看做子串的哈希值 h。相同字符对应的 P 进制数字一定相同,即哈希值相同:
注意点:
- 任意字符不可以映射成0(即 X != 0),否则会出现不同的字符串都映射成0的情况,比如 a, aa, aaa 皆为0
- P取131 或 13331,Q 取 2^64,可保证99%的情况下不会有两个数 mod Q后相等,即保证不会发生地址冲突
因此,只要能快速求出 s[l1, r1] 和 s[l2,r2] 对应的字符串的哈希值 h[s[l1, r1]],h[s[l2, r2]],并判断是否相等。就能得出l1, r1 l2,r2对应的字符串是否相同
具体算法
注意,字符串的位置从 1 开始编号。
h表示字符串前缀和的哈希值数组,即 h[i] 表示 s[1~i] 这个子串对应的哈希值;s为字符串数组。有:
先用一个例子理解俩公式:(为了方便展示,这里的 h 定义没那么严格,粗略表示就是哈希值,懂的都懂)
s = “abcabcd”, 各个位置对应的数字为: s = “97 98 99 97 98 99 100”
h[1] = 97
h[2] = h[1] * P + h[‘b’] = 97 * 131 + 98 = 12,805
h[3] = h[2] * P + h[‘c’] = 12805*131 + 99 = 1,677,554
h[4] = h[3] * P + h[‘a’] = 1,677,554 * 131 + 97 = 219,759,671
h[5] = h[4] * P + h[‘b’] = 219,759,671 * 131 + 98 =28,788,516,999
h[6] = h[5] * P + h[‘c’] = 28,788,516,999 * 131 + 99 = 3,771,295,726,968
h[7] = h[6] * P + h[‘d’] = 3,771,295,726,968 * 131 + 100 = 494,039,740,232,908
那么 h[s[2, 3]] = h[3] - h[1] * P^(3-2+1) = 1,677,554 - 97*131^2 = 12,937 = s[2] (98) * 131 + s[3] (99)
现在来理解为什么这两个公式这样写:
先说前缀和公式:比较好理解。
- 当 i = 0 时,算 h[1] 也就是 s 字符串第一个字符这个子串的哈希值。此时 h[0] 为定义时的初始化值 0,s[0] 就是第一个字符的 ascii 码值,因此 h[1] = s[0] = 97。
- P进制数 a ,乘以进制 P,实际上就是在左移一位。由二进制和十进制类比:对于十进制数 121,x10后为1210;对于二进制数 0110(十进制为6),x2后变为12,写成二进制就是1100。
- 现在我们再去理解前缀和公式:当向右增加一个字符时,想一下,是不是相当于前面的所有字符要往左移动一位,再加上新来的这个字符。也就是 ABCDE = ABCD0 + 0000E。
接下来是区间和公式:
- 假设我们要求ABCDE中的DE的哈希值:ABCDE 与 ABC 的前三个字符值是一样,只差两位,乘上 P^2把 ABC 变为 ABC00,再用 ABCDE - ABC00 得到 DE 的 P进制值即哈希值。
- 乘以的 P 的指数 = r - ( l - 1)。因为在后面补的 0 的个数就是要求的这个子串的长度。子串长度就是 r - ( l - 1) ,如上 r=5,l=4。
代码实现
了解了思路和公式,那代码就比较简单了。但还有一个注意点:需要定义一个 p数组存储累乘的P的 i 次方。因为java中乘方要用 pow 函数,这个函数返回的是 double,如果直接用 double 答案会有点问题,可能是精度(具体不知道)
import java.io.BufferedReader;
import java.io.InputStreamReader;
public class _841 {
static int N = 100003;
static long[] h = new long[N]; //前缀和哈希值
static long[] p = new long[N]; //累乘的P的i次方
static int P = 131;
public static long hash(int l, int r){
return h[r] - h[l-1]*p[r-l+1];
}
public static void main(String[] args) throws Exception{
BufferedReader bf = new BufferedReader(new InputStreamReader(System.in));
String s[] = bf.readLine().split(" ");
int n = Integer.parseInt(s[0]);
int m = Integer.parseInt(s[1]);
char str[] = bf.readLine().toCharArray();
p[0] = 1;
for (int i = 0; i < n; i++){
p[i+1] = P * p[i];
h[i+1] = h[i] * P + str[i];
}
for (int i = 0; i < m; i++) {
String s1[] = bf.readLine().split(" ");
int l1 = Integer.parseInt(s1[0]);
int r1 = Integer.parseInt(s1[1]);
int l2 = Integer.parseInt(s1[2]);
int r2 = Integer.parseInt(s1[3]);
if (hash(l1,r1) == hash(l2,r2))
System.out.println("Yes");
else
System.out.println("No");
}
}
}