目录
第三章 数据结构基础
1、链表
⭐️单链表
单链表是一种基本的数据结构,用于存储一系列数据元素。与数组相比,单链表在内存中不需要连续的存储空间,并且能够动态地增加或减少元素。
单链表由多个节点(Node)组成,每个节点包含两部分:
- 数据域(Data):存储数据元素。
- 指针域(Next):指向下一个节点的引用(或指针)。
其插入与删除时间复杂度为O(1),访问遍历顺序中第i个元素时间复杂度为O(i)。
一般我们用数组模拟链表。
用e数组表示数据域,r数组表示每个结点的下一个结点的位置。
e[1] = A;e[2] = B;e[3] = C;e[4] = D;
r[1] = 2;r[2] = 3;r[3] = 4;r[4] = 0;
我们会用head变量记录链表第一个变量的位置。
遍历链表的代码为:
for(int i=head;i!=0;i=r[i]){
e[i] //
}
删除单链表的第b个结点:
假设第b个结点的前一个节点为p。直接修改r[a]=r[b]
即可。如果是删除第一个节点则直接令head=r[head]
即可。
插入单链表的第i个结点;
如果插入一个节点为头节点,新节点下标为x,则r[x]=head
。若为第b个结点之后,则
r[x]=r[b],r[b]=x
。
⭐️双链表
双链表是一种比单链表更复杂的数据结构。它由多个节点组成,每个节点不仅包含指向下一个节点的指针,还包含指向前一个节点的指针。这使得在双链表中可以更方便地进行插入和删除操作。
双链表中的每个节点包含三部分:
- 数据域(Data):存储数据元素。
- 前指针(Prev):指向前一个节点的引用(或指针)。
- 后指针(Next):指向下一个节点的引用(或指针)。
其插入与删除时间复杂度为O(1),访问第i个元素时间复杂度为O()。
一般我们用数组模拟双链表。
用e数组表示数据域,r数组表示每个结点的下一个结点的位置,1数组表示每个数组的上一个节点的位置。
e[1] = A;e[2] = B;e[3] = C;e[4] = D;
r[1] = 2;r[2] = 3;r[3] = 4;r[4] = 0;
l[1] = 0,[²2] = 1;/[3] = 2;/[4] = 3
一般,我们会用head变量记录链表第一个变量的位置。tail记录最后一个变量的位置。
遍历链表的代码为:
for(int i=head;i!=0;i=r[i]){
e[i] //
}
逆序遍历为:
for(int i=tail;i!=0;i=l[i]){
e[i] //
}
删除操作
假设我们要删除χ号结点。
则代码为r[l[x]]=r[x],l[r[x]]=l[x]
。
注意,如果∞为头结点,我们要提前修改head=r[x]
。如果是尾结点则要修改成tail=l[x]
。
:插入操作:
如果我们要把下标为idx的结点插入到p结点后。
l[idx]=p,r[idx]=r[p],l[r[p]]=idx,r[p]=idx
;
如果是成为头节点需要写l[head]=idx,l[idx]=0,r[idx]=head,head=idx
;
尾结点需要写r[tail]=x,r[idx]=0,l[idx]=tail,tail=idx
。
🍎笔记
2、栈和队列
栈
栈(Stack)是一种后进先出(LIFO,Last In First Out)的数据结构,意味着最后插入的元素最先被移除。
栈的基本操作
- 压入(Push):将一个元素添加到栈的顶部。
- 弹出(Pop):从栈的顶部移除一个元素,并返回该元素。
- 查看顶部元素(Peek):返回栈顶元素但不移除它。
- 检查栈是否为空(isEmpty):判断栈中是否有元素。
- 获取栈的大小(size):返回栈中元素的数量。
模拟栈
我们可以用数组stk模拟栈,用一个top变量来维护栈顶指针。
入栈
stk[++top]=x
出栈
top--
判空
top==0
获取栈的大小
return top
static int stk[] = new int[100010];
static int top=0;
static void push(int x){
stk[++top]=x;
}
static void pop(){
if(isEmpty()) return;
top--;
}
static boolean isEmpty(){
return top==0;
}
static void query(){
if(isEmpty(){
out.println("empty");
}else{
out.println(stk[top]);
}
}
队列
队列(Queue)是一种常见的线性数据结构,遵循 先进先出(FIFO,FirstInFirst Out) 的原则。即最先进入队列的元素会最先被移出队列。
队列基本操作
- 压入(Push):将一个元素插入队尾。
- 弹出(Pop):返回队首元素并删除。
- 查看队首元素(Peek):返回队首元素但不移除它。
- 检查队列是否为空(isEmpty):判断队列中是否有元素。
- 获取队列的大小(size):返回队列中元素的数量。
模拟队列
我们可以用数组模拟队列。用head表示头指针,tail表示尾指针。
初始head=tail=0
;
我们插入元素时,可以写h[tail++]=x
;
删除元素可以写head--
;
判空则判断head==tail
;
📖
📚
import javax.sound.sampled.Line;
import java.math.BigInteger;
import java.util.Arrays;
import java.util.Scanner;
public class Main {
static int N = 100010;
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
int m = sc.nextInt();
int[] a = new int[N];
int head = 0;
int tail = 0;
while(m-->0){
String str = sc.next();
if(str.equals("push")){
int x = sc.nextInt();
a[tail] = x;
tail ++;
} else if (str.equals("pop")) {
if(tail == head){
continue;
}else{
a[head] = 0;
head ++;
}
} else if (str.equals("empty")) {
if(tail == head){
System.out.println("YES");
}else {
System.out.println("NO");
}
} else if (str.equals("query")) {
if(tail == head){
System.out.println("empty");
}else{
System.out.println(a[head]);
}
}
}
}
}
3、递归在程序中运行的原理
递归,即函数自己调用自己的一种程序。
递归(Recursion)是计算机编程中的一种重要概念,指的是一个函数直接或间接调用自身的编程技术。通过将复杂问题分解为更小的子问题来解决。
递归函数通常包含两个关键部分:
1.基准条件(BaseCase):当问题被简化到某种程度时,直接返回结果,不再调用自身,以避免无限循环。
2.递归条件(RecursiveCase):在没有达到基准条件时,函数调用自身,并逐步向基准条件靠近。
int fun(int x){
if(x==O) return 1;
return x*fun(x-1);
}
...
System.out.print(fun(4);
...
递归是借助栈结构进行回溯的。每当递归函数调用自身时,系统会将当前的函数调用状态(包括局部变量、参数、返回地址等)压入栈中。当递归达到基准条件(basecase)时,函数开始从栈中弹出,逐个返回调用状态,完成整个递归过程。开始时我们将fun(4),fun(3),…,fun(0)压入栈中,fun(0)是递归函数的边界,然后开始依次回溯fun(1),fun(2),…,fun(4)。
4、集合与哈希
什么是集合?
给定一个序列,你需要从中判断出一个元素是否存在。
暴力做法时间为O(n)。
排序后二分做法的时间复杂度为O(log)
如果将元素当作下标来存储,也就是cnt[a[司]=1表示a[]存在,时间复杂度为O(1),空间复杂度会很高。
集合便是查找某个元素的复杂度为O(1)的数据结构。其通过一个特殊的哈希函数,将数字映射到某一个位置。查找时通过该哈希函数我们就可以快速找到,一般计算出的哈希函数值不会超过元素个数。
cnt[hash(a[il])] = 1。
⭐️开放寻址法:
定义一个hash函数结果为x&prime,prime是大于元素个数的第一个素数。
计算出哈希函数后,我们直接令mp[hash(∞)]=c,询问一个数是否存在时,我们直接查找
mp[hash(a)]是否是x即可。
但有一种特殊的情况,即两个数字‰prime的结果是一样的,则就会在mp数组的位置产生冲突。
我们有一种方式是如果产生冲突了,就向后继续查找第一个空,直到第一个没有被占的位置。这就是开放寻址法。
⭐️哈希表的实现
find函数的实现
static int find(int x){
int k=getHash(x);
while(h[k]!=-1&&h[k]!=x) k++;
return k;
}
static void solve(){
int n=in.nextInt();
Arrays.fill(h,-1);
for(int i=1;i<=n;i++){
String op=in.next();
if(op.equals("I")){
int x=in.nextInt();
int k=find(x);
h[k]=X;
}else[
int x=in.nextInt();
int k=find(x);
if(h[k]==x){
out.println("Yes");
}else{
out.println("No");
}
}
}
}
⭐️Java提供的Set集合
1️⃣HashSet
HashSet是基于哈希表(HashTable)实现的集合。它使用哈希函数来存储元素,并不保证元素的顺序。
特点:
- 不允许有重复的元素。
- 插入、删除和查找操作的时间复杂度平均为O(1)。
- 元素的存储顺序与插入顺序无关。
常用函数:
- add(E e):向集合中添加元素,如果元素已存在,返回false。
- remove(Object o):从集合中移除元素,成功返回true。
- contains(Object o):判断集合中是否包含指定元素。
- size():返回集合中元素的数量。
- clear():清空集合中的所有元素。
- isEmpty():判断集合是否为空。
import java.util.HashSet;
public class HashSetExample{
public static void main(String[] args) {
HashSet<String> set = new HashSet<>();
//添加元素
set.add("apple" );
set.add("banana");
set.add("cherry");
//检查是否包含元素
System.out.println(set.contains("banana"); // 输出 true
// 移除元素
set.remove("banana");
//集合大小
System.out.println(set.size()); // 输出 2
}
}
2️⃣LinkedHashSet
LinkedHashSet是HashSet的子类,它基于哈希表实现,同时使用链表来维护元素的插入顺序。因此,它既具备HashSet的哈希表特性,又能保证元素按照插入顺序排列。
特点
- 不允许有重复的元素。
- 保证元素的插入顺序。
- 插入、删除和查找操作的时间复杂度平均为(O(1))。
- 适用于既希望利用哈希表的高效性能,又需要保留元素插入顺序的场景。
常用函数
- add(E e):添加元素。
- remove(object o):移除元素。
- contains(object o):检查是否包含某元素。
- size():返回元素个数。
- clear():清空集合。
- isEmpty():判断集合是否为空。
import java.util.LinkedHashSet;
public class LinkedHashSetExample {
public static void main(String[] args){
LinkedHashSet<String> set = new LinkedHashSet<>();
// 添加元素
set.add("apple" );
set.add("banana");
set.add("cherry");
//输出集合,按插入顺序
for (String s : set) {
System.out.println(s); //输出顺序为:apple,banana,cherry
}
// 检查是否包含元素
System.out.println(set.contains("banana")); // 输出 true
}
}
3️⃣TreeSet
TreeSet是基于红黑树(Red-Black Tree)实现的集合。与HashSet和LinkedHashSet不同,TreeSet会自动对元素进行排序。它实现了SortedSet接口,因此可以保证集合中的元素处于自然顺序(或者是用户自定义的排序规则)。
特点
- 不允许有重复的元素。
- 元素按照自然顺序(或自定义顺序)排序。
- 插入、删除和查找操作的时间复杂度为O(logn)。
- 适用于需要保持元素有序的场景。
常用函数:
- add(E e):添加元素。
- remove(Object o):移除元素。
- contains(Object o):检查是否包含某元素。
- size():返回集合中元素的数量。
- isEmpty():判断集合是否为空。
- first():返回集合中的第一个元素(最小的元素)。
- last():返回集合中的最后一个元素(最大的元素)。
import java.util.TreeSet;
public class TreeSetExample{
public static void main(String[] args) {
TreeSet<Integer> set = new TreeSet<>();
//添加元素
set.add(30);
set.add(10);
set.add(20);
set.add(40);
//自动排序
System.out.println(set); // 输出 [10,20, 30,40]
//获取第一个和最后一个元素
System.out.println("最小值:"+ set.first()); // 输出10
System.out.println("最大值:"+ set.last(); // 输出40
//获取子集
System.out.println("小于 30 的元素: " + set.headSet(30)); // 输出 [10,20]
System.out.println("大于等于20的元素:"+ set.tailSet(20));// 输出[20, 30,40]
}
}
⭐️Java提供的Map哈希表
在Java中,Map是一种用于存储键值对<key,value>的数据结构,每个键都唯一对应一个值,允许通过键来快速查找、更新和删除值。Java的Map接口有多种常见实现,如HashMap、LinkedHashMap和TreeMap。
1️⃣HashMap
HashMap是基于哈希表实现的Map,键值对存储无序。
特点
- 基于哈希表实现,键通过哈希函数映射到哈希表中的位置。
- 查找、插入、删除操作的平均时间复杂度为O(1)。
- 无序存储,元素的顺序不保证与插入顺序一致。
常用函数:
- put(K key,Vvalue):向Map中添加键值对,如果键已存在,更新对应的值。
- get(Object key):根据键查找对应的值。
- remove(Object key):移除指定键对应的键值对。
- getOrDefault(Object key,0):根据键查找对应的值,不存在返回0。
- containsKey(Object key):判断是否包含指定的键。
- size():返回Map中的键值对数量。
- clear():清空所有键值对。
- keySet():返回所有键的集合。
import java.util.HashMap;
public class HashMapExample {
public static void main(String[] args) {
//创建一个HashMap
HashMap<String, Integer> map = new HashMap<>();
//添加键值对
map.put("apple",1);
map.put("banana",2);
map.put("cherry", 3);
11获取值
System.out.println("apple 的值: " + map.get("apple"); // 输出 1
//判断是否包含键
System.out.println("是否包含cherry:"+ map.containsKey("cherry");// 输出 true
//移除键值对
map.remove("banana" );
1//获取键的集合
System.out.println("键的集合: " + map.keySet()); // 输出[apple,cherry]
//获取值的集合
System.out.println("值的集合:" + map.values(); // 输出[1,3]
// 获取 Map 大小
System.out.println("Map 大小:" + map.size()); // 输出 2
}
}
遍历哈希表
HashMap<String, Integer> map = new HashMap<>();
for(String key:map.keySet()){
//key和map.get(key);
}
2️⃣LinkedHashMap
LinkedHashMap是HashMap的子类,除了具有HashMap的所有特性外,还维护了一个双向链表,记录元素的
插入顺序或访问顺序。
- 特点:
- 元素按插入顺序(或访问顺序)排列。
- 性能与HashMap类似,但由于维护链表,性能略微降低。
- 适用于既希望保持键值对有序,又需要快速查找的场景。
import java.util.LinkedHashMap;
public class LinkedHashMapForEachExample{
public static void main(String[] args) {
//创建一个LinkedHashMap,默认按插入顺序排列
LinkedHashMap<String, Integer> linkedHashMap = new LinkedHashMap<>();
//添加键值对
linkedHashMap.put("apple",1);
linkedHashMap.put("banana", 2);
linkedHashMap.put("cherry",3);
//使用forEach遍历键值对
System.out.printIn(“使用forEach遍历LinkedHashMap(按插入顺序):");
linkedHashMap.forEach((key, value) -> {
System.out.println(key + ": " + value);
});
}
}
3️⃣TreeMap
TreeMap是基于红黑树实现的有序Map,键值对按照键的自然顺序或自定义比较器排序。
- 特点:
- 元素按键的自然顺序(或自定义顺序)排序。
- 查找、插入和删除的时间复杂度为O(1ogn)。
- 不允许存储null键(会抛出NullPointerException)。
- 适用于需要对键进行排序的场景。
import java.util.TreeMap;
public class TreeMapExample {
public static void main(String[] args) {
//创建一个TreeMap
TreeMap<String, Integer> map = new TreeMap<>();
/添加键值对
map.put("banana", 2);
map.put("apple", 1);1
map.put("cherry",3);
//按照键的自然顺序排序
System.out.println("TreeMap 键值对:"+ map); // 输出 {apple=1,banana=2,cherry=3}
//获取第一个和最后一个键
System.out.println("第一个键:"+ map.firstkey());//输出apple
System.out.println("最后一个键:"+ map.lastKey());//输出cherry
}
}
5、离散化
离散化
给定一个序列A,我们只关心其排名,而不关心其具体的值,将序列的每一个元素转换成其排名,这种过程我们称作离散化。
[1,2,3,4,1000,100000]→[1,2,3,4,5,6]
离散化的步骤:
- 对序列a中每个元素进行去重后排序,得到每个数字的大小关系。
- 将a的值一一映射到b
📖问题描述
给定一个长度为n的序列A,你需要输出序列中每一个元素在原序列A中的大小排名。最小的数输出1,最大的数输出cnt。cnt为序列中不同元素的数量。
输入格式:
第一行输入一个正整数n。(1<n<105)
第二行输入n个整数Ai。(-109<Ai≤109,1 ≤ i ≤ n)。
输出格式:
输出一行,按照题目要求输出n个正整数。
Map<Integer,Integer> mp=new TreeMap<>();
int n = in.nextInt();
for(int i=1;i<=n;i++){
a[i]=in.nextInt();
mp.put(a[i], 0);
}
int cnt=1;
for(int key: mp.keySet()){
mp.put(key,cnt);
cnt++;
}
for(int i=1;i<=n;i++){
a[i]=mp.get(a[i]);
}
for(int i=1;i<=n;i++){
out.print(a[i]+" ");
}
6、优先队列
优先队列
优先队列,也称为堆,是一种特殊的队列数据结构,能够始终保持队列中的元素有序。其底层实现通常为一棵完全二叉树。根据排序规则的不同,优先队列分为两种类型:
- 小根堆:堆顶元素为队列中的最小值,出队时元素按升序排列。
- 大根堆:堆顶元素为队列中的最大值,出队时元素按降序排列。
优先队列定义方式如下:
Queue<Integer> q=new PriorityQueue<>();
Queue<Integer> q=new PriorityQueue<>((x,y)->y-x);
常见操作:
q-poll()
返回并删除队首元素,为优先队列中的最值。复杂度为O(log(n))。q.peek()
返回队首元素,为优先队列中的最值。复杂度为O(1)。q.offer(x)
将x插入队列中。复杂度为O(log(n))。q.isEmpty()
判断队列是否为空。复杂度为O(1)。q.size()
返回队列中元素个数。复杂度为O(1)。q.remove(x)
删除指定元素。复杂度为O(n)。
遍历优先队列
Queue q=new PriorityQueue<>();
while(!q.isEmpty()) {
out.println(q.poll());
}
时间复杂度为O(nlogn)
例题
Multiset实现优先队列
multiset的介绍
multiset我们称作多重集,它和一般的集合不一样,是可以支持重复元素和有序性的一种数据结构。其插入,删除数据的时间复杂度为log级别。正常优先队列删除指定元素复杂度为O(n)。
如果有一道题,需要用可排序的数据结构,加入用java自带优先队列,那么复杂度就是nn了,复杂度就太大了。用multiset复杂度就变成lognn。
如果用multiset实现优先队列,则我们就可以获得一个可以在log的时间复杂度完成删除操作的优先队列。
在Java语言中,是没有系统自带的multiset实现的数据结构的,因此我们需要利用TreeMap来实现multiset。
需要实现的函数:
- add(x):插入一个元素r。
- count(x):返回æ的数量。
- size():返回元素个数。
- peek():返回堆顶元素。
- poll():返回并删除堆顶元素。
- remove(x):删除指定元素。
- removeAll(x):删除全部的指定元素。
- isEmpty:判断队列是否为空。
- clear:清空队列。
multiset的实现逻辑
用TreeMap来辅助实现,可以保证有序性,且TreeMap删除数据的复杂度为log级别。
TreeMap<T,Integer> multiset;
int len=0;
Multiset(){
multiset=new TreeMap<>();
}
Multiset(Comparator cmp){
multiset=new TreeMap<>(cmp);
}
add
很简单,对于每一个<key,value>结构,value存放key出现的次数就好了。
void add(T x){
multiset.put(x,multiset.getOrDefault(x,0)+1);
len++;
}
count 与 size
int count(T x){
return multiset.getOrDefault(x,0);
}
int size(){
return len;
}
removeAll 与 remove
void remove(T x){
int res=multiset.getOrDefault(x,0)-1;
if(res==-1) {
return;
}
if(res==0){
removeAll(x);
return;
}
len--;
multiset.put(x,res);
}
void removeAll(T x){
len=len-multiset.getOrDefault(x,0);
multiset.remove(x);
}
peek,poll
T peek(){
if(len==0) return null;
return multiset.firstKey();
}
T poll(){
if(len==0) return null;
T res = multiset.firstKey();
remove(res);
return res;
}
isEmpty,clear
boolean isEmpty(){
return len==0;
}
void clear(){
len=0;
multiset.clear();
}
📖multiset(模板)
📚
import java.util.*;
public class Main {
static int N = 100010;
public static void main(String[] args) {
Scanner in = new Scanner(System.in);
int q = in.nextInt();
while (q-- > 0) {
int op = in.nextInt();
switch (op) {
case 1: {
int x = in.nextInt();
heap.add(x);
break;
}
case 2: {
int x = in.nextInt();
int count = heap.count(x);
System.out.println(count);
break;
}
case 3: {
int x = in.nextInt();
heap.remove(x);
break;
}
case 4: {
int x = in.nextInt();
heap.removeAll(x);
break;
}
case 5: {
System.out.println(heap.size());
break;
}
case 6: {
if (heap.isEmpty()) System.out.println("null");
else System.out.println(heap.peek());
break;
}
case 7: {
heap.poll();
break;
}
}
}
}
static Multiset<Integer> heap = new Multiset<>(new Comparator<Integer>() { // 传入一个自定义比较器
@Override
public int compare(Integer o1, Integer o2) {
return o2 - o1; // 大根堆是 o2 - o1,小根堆是 o1 - o2
}
});
static class Multiset<T> {
TreeMap<T, Integer> multiset;
int len = 0;
Multiset() {
multiset = new TreeMap<>();
}
Multiset(Comparator<T> cmp) {
multiset = new TreeMap<>(cmp);
}
void add(T x) {
multiset.put(x, multiset.getOrDefault(x, 0) + 1);
len++;
}
int count(T x) {
return multiset.getOrDefault(x, 0);
}
int size() {
return len;
}
void remove(T x) {
int res = multiset.getOrDefault(x, 0) - 1;
if (res == -1) {
return;
}
if (res == 0) {
removeAll(x);
return;
}
len--;
multiset.put(x, res);
}
void removeAll(T x) {
len = len - multiset.getOrDefault(x, 0);
multiset.remove(x);
}
T peek() {
if (len == 0) return null;
return multiset.firstKey();
}
T poll() {
if (len == 0) return null;
T res = multiset.firstKey();
remove(res);
return res;
}
boolean isEmpty() {
return len == 0;
}
void clear() {
len = 0;
multiset.clear();
}
}
}
7、单调栈
数的左右最值问题
给定一个长度为N的序列a。
输出每个数字其左边第一个比其大的数字,不存在则输出-1。
考虑暴力做法,枚举每个数字到其最左边,找到最大的数。时间复杂度为O(n²)。
for(int i=1;i<=n;i++){
boolean flag=true;
int ans=a[i];
for(int j=i-1;j>=1;j--){
if(a[j]>a[i]){
flag=false;
ans=a[j];
break;
}
}
if(flag)out.println(-1);
else out.println(ans);
}
单调栈,便是针对此问题的O(n)的解法。
先把0和11弹出,再把14插入。
⭐️单调栈
不仅有栈的性质,还有单调的性质。
针对本题的操作
答案的条件:在左边、最近的、比他大的数。
用单调栈来维护一个下标序列,使得栈中下标所代表的元素保持单调递减的顺序,即栈顶最小,栈底最大。
每次遍历到一个元素,判断完之后将他的下标压入栈中。
则单调栈天然满足:栈顶元素离他最近。
我们只需要判断是否栈顶元素比他大即可,如果栈顶元素比他大,那么栈顶元素就是他左边第一个比他大的数。
反之,如果栈顶元素比他小,那么弹出栈顶元素,直到栈顶元素比他大为止。或者栈为空。
为什么这不会影响后续元素的判断?
因为栈中的元素是递减的,后续判断中,当前元素会成为新的栈顶,如果当前元素更大,那么栈顶元素就不可能是他左边第一个比他大的数。
复杂度证明
明显可以观察到,每个元素最多进栈一次,出栈一次,所以时间复杂度为O(n)。
只需要维护一个栈和一个结果数组,所以空间复杂度为O(n)。
📖
📚
8、单调队列
滑动窗口最值问题
给定一个长度为N的序列a与一个长度为K的窗口。(1<K<N)
该窗口会从序列的最左端滑动到最右端,你需要输出1行,每行N一K+1个数字。是每个窗口的最小值。
暴力思路是枚举每个窗口,思路为O(n²)。
for(int i=1;i+k-1<=n;i++){
int ans=a[il;
for(intj=i+1;j<=i+k-1;j++){
ans=Math.max(ans,a[j]);
}
}
⭐️单调队列的实现
单调队列(Monotonic Queue)是一种特殊的队列,它维护队列中的元素按某种顺序排列,通常用于在滑动窗口问题中保持窗口的最大值或最小值。
根据不同的需求,单调队列可以分为:
- 单调递减队列:队列中的元素从前到后递减,这样队列的首元素始终是当前窗口的最大值。
- 单调递增队列:队列中的元素从前到后递增,这样队列的首元素始终是当前窗口的最小值。
单调队列解决该问题时间复杂度为O(n)。
- 维护单调性:队列中的元素始终保持单调递增或递减。对于求最小值的情况,队列应保持单调递增(队头最小);对于求最大值的情况,队列应保持单调递减(队头最大)。
- 窗口内元素的更新:
- 队列中的每个元素代表当前窗口中的候选值(对应下标)。
- 如果一个元素在队列尾部比新加入的元素大或等,则说明它不可能成为后续窗口的最小值(或最大值),因此可以将其移除。
- 每次加入新元素时,通过比较并维护队列的单调性。
- 移除过期元素:队列头部存储的是当前滑动窗口内最有价值的元素。当队列中的元素超出滑动窗口时,将其移出。
📚
static void getMin(int a[],int k,int n){
int head=0,tail=0;
queue[e]=0;
for(int i=1;i<=n;i++){
if(i!=1&&i-queue[head]>=k) head++;
while (head!=tail&&a[queue[tail-1j]>=a[i]) tail--;
queue[tail++]=i;
if(i>=k){
ansMin[i]=a[queue[head]];
}
}
}
最大值只需要将while(head!=tail&&a[queue[tail-1]]>=a[i]) tail--
;改成while(head!=tail&&a[queue[tail-1]]<=a[i]) tail--
;即可。
9、并查集
不相交集合的并问题
有n个集合,编号为1~n,第i个集合里有且只有一个数字i。
现在有m次操作,每次操作有以下两种情况:
- Mab:将数字a与数字b所在的集合合并。
- Qab:查询数字a与数字b是否在一个集合中。
该问题便是不相交集合的并问题。
初始思路
很容易得到的思路是,我们用一个数组f来记录每个数字所在的集合。
初始时,我们给f:赋值为i,表示第i个数字所在的集合编号为i。
合并两个集合时,我们将其中一个集合的所有数字的集合编号改为另一个集合的集合编号。
staticvoidmerge(int x,inty){//把x所在集合合并到y所在集合
int fx = f[x], fy = f[y];
for(int i = 1; i <= n; i++) {
if(f[i] == fx) f[i] = fy;
}
}
查询两个数字是否在一个集合中时,我们只需要判断两个数字的集合编号是否相同即可。
复杂度分析
- 初始化的时间复杂度为O(n)。
- 合并两个集合的时间复杂度为O(n),因为需要遍历整个数组。
- 查询两个数字是否在一个集合中的时间复杂度为O(1)。
好像合并时间复杂度有点高?
假如我们有n个数字,我们需要合并n一1次,那么总的时间复杂度为O(n²)。
这个代价我们是无法接受的。
⭐️并查集
并查集是解决不相交集合并问题的一种数据结构,我们一般简称并查集为DSU(Disjoint SetUnion)。
实现上为一个森林,其中每棵树表示一个集合,树中的节点表示对应集合中的元素,树根的编号就是
集合的编号。
初始时,将自己连向自己表示节点i属于集合i。
static int[] f = new int[1005];
static void init(int n) {
for(int i = 1; i <= n; i++) f[i] = i;
}
查询
假如我要查询α与b是否在一个集合中,我们只需要查询a的根节点是否等于b的根节点即可。
static int find(int x) {
return f[x] == x ? x:find(f[x]);
}
static boolean query(int x, int y) {
return find(x) == find(y);
}
合并
合并则是将a的根节点的父节点指向b的根节点,这样下次查询α时候就可以直接查找到b的根节点,即同属于一个集合。
static void merge(int x, int y) {
f[find(x)] = find(y);
}
优化1:路径优化
static int find(int x) {
return f[x]==x?x: f[x]= find(f[x]); //路径压缩
}
优化2:启发式优化
//别忘了在初始化时将size设置为1!
static void merge_size(int x,inty){// 按节点数合并
int fx = find(x), fy = find(y);
if(fx == fy) return;
if(size[fx]<size[fy]){// fx节点较少,合并到fy
f[fx]= fy;
size[fy] += size[fx];
}else{// fy节点较少,合并到fx
f[fy]= fx;
size[fx] += size[fy];
}
时间复杂度
实现路径压缩、启发式合并后,那么并查集的时间复杂度就是O(α(n)),其中α(n)是阿克曼函数的反函数,增长极其缓慢。
仅使用单个优化的情况下,查询时间复杂度为O(logn)。
实践中我们可以仅使用路径压缩,已经能够满足大多数的需求。
📖连通块中点的数量
相比于普通并查集,连通块中点的数量这一题我们多了个询问为每个集合中点的数量。
我们只需要记录一个size数组。令当前节点的最终父结点记录下当前集合中的点的数量。在连接操作
时,我们令size[find(a)]=size[find(b)]
就可以了。
for(int i=1;i<=n;i++){
p[i]=1;
size[i]=1;
}
for(int i=1;i<=m;i++){
String op=in.next();
if(op.equals("C")){
int a=in.nextInt();
int b=in.nextInt();
if(find(a)!=find(b)) {
size[find(b)]+=size[find(a)];
}
p[find(a)]=find(b);
}else if(op.equals("Q1"){
int a=in.nextInt();
int b=in.nextInt(),
if(find(a)==find(b){
out.println("Yes");
}else{
out.println("No");
}
}else{
int a=in.nextInt();
out.println(size[find(a)]);
}
}
10、树状数组
单点修改,区间求和问题
⭐️树状数组的原理
树状数组是可以在O(logn)时间复杂度对序列a某个值进行修改,同样也可以在O(logn)时间复杂度对前缀和进行查询。
区间求和
lowbit函数及其含义
性质推导
上面的性质是为了得出以下 单点修改 的结论。
单点修改
初始化时直接使用 单点修改 就好,从点1一直到点n,不用预处理。
📚例题核心代码实现
static int[] c = new int[N];
static int lowbit(int x) { return x & -x; }
static void add(int x, int v) {
for (int i = x; i <= n; i += lowbit(i)) c[i] += V;
}
static int sum(int x) {
int res = 0;
for (int i = x; i > O; i -= lowbit(i)) res += c[i];
return res;
}
public static void main(String[] args) {
n = in.nextInt();
for(int i= 1; i <= n;i++)add(i,in.nextInt()); // 初始化
int m = in.nextInt();
while (m-- > 0) {
if (in.nextInt() == 1) {
int x = in.nextInt();
int a = in.nextInt();
add(x, a);
} else {
int l = in.nextInt();
int r = in.nextInt();
out.println(sum(r) - sum(l - 1));
}
}
out.flush();
}
11、Java集合API总结
⭐️ArrayList
ArrayList是一个可以动态调整大小的数组实现,它是Java集合框架中最常用的集合之一。
其主要特点是支持随机访问,以及随着元素数量的增加可以动态扩容。
初始化 ArrayList:
ArrayList<Integer> list = new ArrayList<>();
添加元素:
add(E e)
:在列表末尾添加一个元素。
list.add(10);
list.add(20);
add(int index,E e)
:在指定索引处插入一个元素,后续元素依次右移。
list.add(1,15);// 在索引1 处插入15
获取元素:
get(int index)
:获取指定索引处的元素。
intvalue=list.get(0);//获取第一个元素
获取元素个数:
size()
:返回列表中元素的数量。
int size = list.size();
遍历元素:
- 使用for循环或增强型for循环遍历列表。
for(int v:list){
}
⭐️ArrayDeque
初始化一个队列:
Queue<Integer> q = new ArrayDeque<>();
- 添加元素:
q.add(E e
)或q.offer(E e)
:将元素添加到队列尾部。
q.add(10);
q.offer(20);
- 移除元素:
q.remove()
或q.poll()
:移除并返回队列头部的元素。
int first = q.remove();
或
int first = q.poll();
- 获取队列头部元素:
q.element()
或q.peek()
:返回队列头部的元素但不移除它。
int first = q.element();
或
int first = q.peek();
- 检查队列是否为空:
q.isEmpty()
:判断队列是否为空。
boolean isEmpty = q.isEmpty();
本章总结
在2022年比赛中,左移右移是一道可以用链表解决的问题。分值15。
在2023年比赛中,最大区间是一道可以用单调栈解决的问题。分值15。
在2023年比赛中,游戏是一道可以用单调队列解决的问题。分值20。