ACwing基础算法学习
NP问题、NP完全问题、NP难问题
*难点
A基础算法 | |||||
---|---|---|---|---|---|
排序 | 785.快速排序 | 786.第k个数 | 787.归并排序 | 788 逆序对数量 | |
二分 | 789 数的范围 | 790 数的三次方根 | |||
模拟运算 | 加法 | 减法 | 乘法 | 除法 | |
前缀和与差分 | 前缀和 | 子矩阵的和 | 差分 | 差分矩阵 | |
双指针算法 | 最长连续不重复子序列 | 数组元素的目标和 | 判断子序列 | ||
位运算 | 二进制中1的个数 | ||||
离散化 | 区间和* | ||||
区间合并 | 区间合并 |
B数据结构 | |||||
---|---|---|---|---|---|
链表 | 单链表 | 双链表 | |||
栈 | 模拟栈 | 表达式求值 | |||
队列 | 模拟队列 | ||||
单调栈 | 单调栈 | ||||
单调队列 | 滑动窗口 | ||||
KMP | KMP | ||||
Tire | Trie字符串统计 | 最大异或对 | |||
并查集 | 合并集合 | 连通块中点的数量 | 食物链(带权并查集,思想很巧妙)* | ||
堆 | 堆排序 | 模拟堆* | |||
hash表 | 模拟散列表 | 字符串hash* |
C搜索与图论 | |||||
---|---|---|---|---|---|
DFS | 排列数字 | n-皇后问题 | |||
BFS | 走迷宫 | 八数码 | |||
树/图的DFS | 树的重心 | ||||
树/图的BFS | 图中点的层次 | ||||
拓扑排序 | 有向图的拓扑序列 | ||||
最短路算法 | Dijkstra求最短路 I | Dijkstra求最短路 II | |||
bellman-ford求最短(有边数限制的最短路) | |||||
spfa求最短路 | spfa判断负环* | ||||
Floyd算法求最短路 | |||||
最小生成树算法 | Prim算法求最小生成树 | Kruskal算法求最小生成树 | |||
二分图的判定与匹配 | 染色法判定二分图 | 二分图的最大匹配 |
D 数学知识 | |||||
---|---|---|---|---|---|
质数 | 试除法判定质数 | 分解质因数(质因数乘积) | 筛质数 | ||
约数 | 试除法求约数 | 约数个数 | 约数之和 | 最大公约数 | |
欧拉函数 | 欧拉函数 | 筛法求欧拉函数* | |||
快速幂 | 快速幂 | 快速幂求逆元 | |||
扩展欧奇里德算法 | 扩展欧几里得算法 | ||||
中国剩余定理* | 表达整数的奇怪方式* | ||||
高斯消元 | |||||
求组合数 | 求组合数 I | ||||
容斥原理 | 能被整除的数 | ||||
博弈论 |
E动态规划 | |||||
---|---|---|---|---|---|
背包问题 | 0-1背包 | 完全背包问题 | 多重背包问题 | 分组背包问题 | |
线性DP | 数字三角形 | 最长上升子序列 | |||
区间DP | 合并石头 | ||||
计数DP | 整数划分 | ||||
数位DP* | 计数问题 | ||||
状压DP* | 蒙德里安的梦想 | 最短Hamilton路径 | |||
树形DP | 没有上司的舞会 | ||||
记忆化搜索 | 滑雪 |
F贪心策略 | |||||
---|---|---|---|---|---|
区间问题(非区间合并问题) | 区间选点 | 最大不相交区间数量 | 区间分组 | 区间覆盖 | |
哈夫曼树 | 合并果子 | ||||
排序不等式 | 排队打水 | ||||
绝对值不等式 | 货仓选址 | ||||
公式推导* | 耍杂技的牛 |
基础代码
import java.util.*;
class Main{
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
}
}
一 基础算法
1 排序
快速排序的基本思想:
基本思想:分治,选择一个数的值作为分割点
step1:确定区间分界点
step2:调整左右区间(难点)
step3:递归处理两段
左取中模板
import java.util.*;
class Main{
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
int[] nums = new int[n];
for(int i = 0;i < n;++i){
nums[i] = sc.nextInt();
}
quickSort(nums,0,n-1);
for(int i = 0;i < n;++i){
System.out.printf("%d ",nums[i]);
}
}
static void swap(int[] nums,int i,int j){
int tmp = nums[i];
nums[i] = nums[j];
nums[j] = tmp;
}
/*双指针思想:实现 <= pivot的值在一边,>pivot的值在另一边
注意:指针相遇的时候,会出现i == j, i > j,j < i 等三种情况
*/
static void quickSort(int[] nums,int left,int right){
if(left >= right)
return;
// int pivot = nums[(right-left)/2+left]; // 与下面等价
int pivot = nums[left+right >> 1]; // 选取中间节点作为基准
int i = left-1,j = right+1;
while(i < j){
do i++; while(nums[i] < pivot);
do j--; while(nums[j] > pivot);
if(i < j) swap(nums,i,j);
}
quickSort(nums,left,j);
quickSort(nums,j+1,right);
}
}
其他模板
- 该模板的特点在于分区的时候能够确定等于pivot元素的下标,之前的模板的双指针的位置元素未必等于pivot,
import java.util.*;
class Main{
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
int[] nums = new int[n];
for(int i = 0;i < n;++i){
nums[i] = sc.nextInt();
}
quickSort(nums,0,n-1);
for(int i = 0;i < n;++i){
System.out.printf("%d ",nums[i]);
}
}
static void swap(int[] nums,int i,int j){
int tmp = nums[i];
nums[i] = nums[j];
nums[j] = tmp;
}
static void quickSort(int[] nums,int left,int right){
if(left >= right)
return;
// numOfThree(nums,left,right);
int mid = partition(nums,left,right);
quickSort(nums,left,mid-1);
quickSort(nums,mid+1,right);
}
/*三数取中确定基准:将最大的数放在right,中间的数放在left(基准),最小的放在mid*/
static void numOfThree(int[] nums,int left,int right){
int mid = (right-left)/2+left;
if(nums[mid] > nums[right])
swap(nums,mid,right);
if(nums[left] > nums[right])
swap(nums,left,right);
if(nums[left] < nums[mid])
swap(nums,left,mid);
}
static int partition(int[] nums,int left,int right){
int target = nums[left];
while(left < right){
while(right > left && nums[right] >= target){ // 注意等号不要遗漏
--right;
}
swap(nums,left,right);
while(left < right && nums[left] <= target){
++left;
}
swap(nums,left,right);
}
return left;
}
}
基于左取中划分模板
import java.util.*;
class Main{
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
int k = sc.nextInt();
int[] nums = new int[n];
for(int i = 0;i < n;++i){
nums[i] = sc.nextInt();
}
// 使用下标对比的时候要减去1(k-1)
System.out.println(quickSort(nums,0,n-1,k-1));
}
static void swap(int[] nums,int i,int j){
int tmp = nums[i];
nums[i] = nums[j];
nums[j] = tmp;
}
/*双指针思想:确保<=pivot的元素在左边,>= pivot的元素在右边*/
static int quickSort(int[] nums,int left,int right,int k){
if(left >= right && right == k)
return nums[k];
int pivot = nums[left+right >> 1]; // 左中间基准(带右)
int i = left-1,j = right+1;
while(i < j){
do i++; while(nums[i] < pivot);
do j--; while(nums[j] > pivot);
if(i < j) swap(nums,i,j);
}
return k <= j ? quickSort(nums,left,j,k) : quickSort(nums,j+1,right,k);
}
}
平均时间复杂度推导(假设每次都能减少一半区间):
n*(n/2+n/4...) <= n*2 (右边根据级数当趋向于无穷大的时候逼近2)
思想:
基本思想:分治
递归排序与快速排序的区别: 快速排序是随机选择一个值作为分节点,递归排序是区间的中间位置进行划分。
步骤:
step1:确定区间分界点
step2:递归处理
step3:归并区间(难点)
注意:在划分区间与递归处理的顺序上快排与递归的顺序相反。
import java.util.*;
class Main{
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
int[] nums = new int[n];
int[] help = new int[n];
for(int i = 0;i < n;++i){
nums[i] = sc.nextInt();
}
Sort(nums,0,n-1,help);
for(int i = 0;i < n;++i){
if(i != 0)
System.out.print(" ");
System.out.print(nums[i]);
}
}
static void Sort(int[] nums,int left,int right,int[] help){
if(left >= right) return;
int mid = (right-left)/2+left; // step1:划分区间
Sort(nums,left,mid,help); Sort(nums,mid+1,right,help); // step2:递归
if(nums[mid] <= nums[mid+1]) return;
merge(nums,left,mid,right,help); // step3:归并
}
static void merge(int[] nums,int left,int mid,int right,int[] help){
int i = left,j = mid+1,pos = left;
while(i <= mid && j <= right){
help[pos++] = nums[i] <= nums[j] ? nums[i++] : nums[j++];
}
while(i <= mid) help[pos++] = nums[i++];
while(j <= right) help[pos++] = nums[j++];
for(i = left;i <= right;++i){
nums[i] = help[i];
}
}
}
import java.util.*;
class Main{
static long sum = 0;
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
int[] nums = new int[n];
int[] help = new int[n];
for(int i = 0;i < n;++i){
nums[i] = sc.nextInt();
}
Sort(nums,0,n-1,help);
System.out.println(sum);
}
static void Sort(int[] nums,int left,int right,int[] help){
if(left >= right) return;
int mid = (right-left)/2+left; // step1:划分区间
Sort(nums,left,mid,help); Sort(nums,mid+1,right,help); // step2:递归
if(nums[mid] <= nums[mid+1]) return;
merge(nums,left,mid,right,help); // step3:归并
}
static void merge(int[] nums,int left,int mid,int right,int[] help){
int i = left,j = mid+1,pos = left;
int ans = 0;
while(i <= mid && j <= right){
if(nums[i] <= nums[j]){
help[pos++] = nums[i++];
}else{
sum += (int)(mid-i+1);
help[pos++] = nums[j++];
}
}
while(i <= mid) help[pos++] = nums[i++];
while(j <= right) help[pos++] = nums[j++];
for(i = left;i <= right;++i){
nums[i] = help[i];
}
}
}
2 二分
import java.util.*;
class Main{
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
int q = sc.nextInt();
int[] nums = new int[n];
for(int i = 0;i < n;++i)
nums[i] = sc.nextInt();
while(q > 0){
int target = sc.nextInt();
int index1 = search1(nums,target);
int index2 = search2(nums,target);
if(index1 == -1 || index2 == -1 || index1 > index2){
System.out.printf("%d %d\n",-1,-1);
}else{
System.out.printf("%d %d\n",index1,index2);
}
--q;
}
}
static int search1(int[] nums,int target){
int left = 0,right = nums.length-1;
while(left < right){
int mid = (right-left)/2+left;
if(nums[mid] >= target){
right = mid;
}else{
left = mid+1;
}
}
return nums[left] >= target ? left : -1;
}
static int search2(int[] nums,int target){
int left = 0,right = nums.length-1;
while(left < right){
int mid = (right-left)/2+left+1;
if(nums[mid] <= target){
left = mid;
}else{
right = mid-1;
}
}
return nums[left] <= target ? left : -1;
}
}
浮点数的二分:r-l的精度要比题目要求的精度高两个数量级左右比较保险,不存在类似于正数二分的精细操作。
import java.util.*;
class Main{
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
double offset = 1e-8;
double n = sc.nextDouble();
double l = -100f,r = 100f;
while(r-l > 1e-8){
double mid = (r-l)/2.0+l;
double v = mid*mid*mid;
if(v >= n){
r = mid;
}else{
l = mid;
}
}
System.out.printf("%6f\n",l);
}
}
import java.util.*;
class Main{
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
double offset = 1e-8;
double n = sc.nextDouble();
double l = -100f,r = 100f;
while(r-l > 1e-8){
double mid = (r-l)/2.0+l;
double v = mid*mid*mid;
if(v >= n){
r = mid;
}else{
l = mid;
}
}
System.out.printf("%6f\n",l);
}
}
3 大整数计算
正数大整数A和B的加减的基本思想
大整数加法:
step1:低位在前存储
step2:从低到高,累加 操作数1,操作数2,进位
step3:查看进位是否为0,不为0再添入1
注意:最后的进位处理
大整数减法:
step1:判断A,B大小,A >= B则A-B,否则-(B-A)
注意:存在负数的情况进行简单的转换即可。
import java.util.*;
class Main{
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
String A = sc.nextLine();
String B = sc.nextLine();
int[] nums1 = strToInt(A);
int[] nums2 = strToInt(B);
// for(int v:nums2)
// System.out.print(v);
// System.out.println();
List<Integer> res = add(nums1,nums2);
for(int i = res.size()-1;i >= 0;--i){
System.out.print(res.get(i));
}
}
/*低位在前存储*/
static int[] strToInt(String s){
int len = s.length();
int[] nums = new int[len];
for(int i = 0;i < len;++i){
nums[len-1-i] = s.charAt(i)-'0';
}
return nums;
}
/*大整数加法模板:确保都是正数,2个数组低位在前*/
static List<Integer> add(int[] nums1,int[] nums2){
List<Integer> res = new ArrayList<>();
int carry = 0,i = 0,j = 0;
while(i < nums1.length || j < nums2.length){
int v = carry;
if(i < nums1.length) v += nums1[i++];
if(j < nums2.length) v += nums2[j++];
res.add(v%10);
carry = v/10;
}
if(carry != 0)
res.add(carry);
return res;
}
}
import java.util.*;
class Main{
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
String A = sc.nextLine();
String B = sc.nextLine();
int[] nums1 = strToInt(A);
int[] nums2 = strToInt(B);
List<Integer> res = null;
boolean isNegative = false;
if(cmp(nums1,nums2)){
res = sub(nums1,nums2);
}else{
isNegative = true;
res = sub(nums2,nums1);
}
if(isNegative)
System.out.print("-");
for(int i = res.size()-1;i >= 0;--i){
System.out.print(res.get(i));
}
}
/*低位在前存储*/
static int[] strToInt(String s){
int len = s.length();
int[] nums = new int[len];
for(int i = 0;i < len;++i){
nums[len-1-i] = s.charAt(i)-'0';
}
return nums;
}
/*nums1 >= nums2 ?*/
static boolean cmp(int[] nums1,int[] nums2){
if(nums2.length != nums1.length){
return nums1.length < nums2.length ? false : true;
}
for(int i = nums1.length-1;i >= 0;--i){
if(nums1[i] != nums2[i]){
return nums1[i] > nums2[i] ? true : false;
}
}
return true;
}
/*大整数减法模板:1)确保都是正数 2)确保nums1大于等于nums2 3)数组低位在前存*/
static List<Integer> sub(int[] nums1,int[] nums2){
List<Integer> res = new ArrayList<>();
int carry = 0,i = 0,j = 0;
while(i < nums1.length){
int v = nums1[i]-carry;
if(j < nums2.length){
v -= nums2[j];
}
res.add((v+10)%10); // 够减/不够减统一处理
carry = v < 0 ? 1 : 0; // 负数:不够减向高位借1
++i;++j;
}
// 去除前导0
while(res.size() > 1 && res.get(res.size()-1) == 0){
res.remove(res.size()-1);
}
return res;
}
}
情况1:大整数 乘或除 非大整数
import java.util.*;
class Main{
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
String A = sc.nextLine();
int b = sc.nextInt();
if(A.equals("0") || b == 0){
System.out.print("0");
return;
}
int[] nums1 = strToInt(A);
List<Integer> res = mul(nums1,b);
for(int i = res.size()-1;i >= 0;--i){
System.out.print(res.get(i));
}
}
/*低位在前存储*/
static int[] strToInt(String s){
int len = s.length();
int[] nums = new int[len];
for(int i = 0;i < len;++i){
nums[len-1-i] = s.charAt(i)-'0';
}
return nums;
}
/*大整数乘法模板:确保都是正数(0需另外处理) */
static List<Integer> mul(int[] nums,int b){
List<Integer> res = new ArrayList<>();
int carry = 0,i = 0;
while(i < nums.length || carry != 0){
int v = carry;
if(i < nums.length){
v += b*nums[i]; // 题目中数据要保证不会溢出
}
res.add(v%10);
carry = v/10;
++i;
}
return res;
}
}
import java.util.*;
class Main{
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
String A = sc.nextLine();
int b = sc.nextInt();
if(A.equals("0")){
System.out.println("0");
System.out.println("0");
return;
}
List<Integer> res = div(A,b);
for(int i = res.size()-2;i >= 0;--i){
System.out.print(res.get(i));
}
System.out.println();
System.out.println(res.get(res.size()-1));
}
/*大整数除法模板:确保都是正数
1)余数存储在List的最后一位
2)返回结果依旧低位在前,为了与乘,加,减保持统一。
除法从高位开始算
*/
static List<Integer> div(String a,int b){
List<Integer> res = new ArrayList<>();
int remain = 0,i = 0;
while(i < a.length()){
int v = remain*10+a.charAt(i)-'0';
res.add(v/b);
remain = v%b;
++i;
}
Collections.reverse(res); // 确保低位在前
while(res.size() > 1 && res.get(res.size()-1) == 0){ // 去除前导0
res.remove(res.size()-1);
}
res.add(remain); // 余数放在尾部
return res;
}
}
情况2:大整数 乘或除 大整数
乘法:
1)朴素模拟
2)FTT加速
除法:待补充
4 前缀和与差分
二者关系: 数组B是A的前缀和,则A是B的差分
差分数组的性质(作用):对差分数组求前缀和可以得到原数组
import java.util.*;
class Main{
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
int m = sc.nextInt();
int[] nums = new int[n];
for(int i = 0;i < n;++i)
nums[i] = sc.nextInt();
int[] prefix = new int[n+1];
prefix[0] = 0;
for(int i = 0;i < n;++i)
prefix[i+1] = prefix[i]+nums[i];
while(m > 0){
int left = sc.nextInt();
int right = sc.nextInt();
System.out.printf("%d\n",getSum(prefix,left,right));
--m;
}
}
static int getSum(int[] prefix,int left,int right){
return prefix[right]-prefix[left-1];
}
}
import java.util.*;
class Main{
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt(),m = sc.nextInt(),q = sc.nextInt();
int[][] mat = new int[n+1][m+1];
for(int i = 1;i <= n;++i){
for(int j = 1;j <= m;++j){
mat[i][j] = sc.nextInt();
}
}
//step1:求前缀和
int[][] prefix = new int[n+1][m+1]; // 下标从1开始
for(int i = 1;i <= n;++i){
for(int j = 1;j <= m;++j){
prefix[i][j] = prefix[i-1][j] + prefix[i][j-1] - prefix[i-1][j-1] + mat[i][j];
}
}
//step2:计算前缀和(坐标从1开始)
while(q-- > 0){
int x1 = sc.nextInt(),y1 = sc.nextInt(),x2 = sc.nextInt(),y2 = sc.nextInt();
int res = prefix[x2][y2]-prefix[x2][y1-1]-prefix[x1-1][y2]+prefix[x1-1][y1-1];
System.out.println(res);
}
}
}
import java.util.*;
class Main{
/*
对于任意一个数组arr,差分数组定义:diff[i] = arr[i] - arr[i-1]
差分数组的应用:快速的区间运算
原数组[L,R]加上c <=> diff[L] + c 和 diff[R+1]-c
最终原数组是差分数组的前缀和:arr[i] = arr[i-1] + diff[i]
*/
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt(),m = sc.nextInt();
int[] diff = new int[n+10];
int[] arr = new int[n+10];
for(int i = 1;i <= n;++i){
arr[i] = sc.nextInt();
diff[i] = arr[i] - arr[i-1]; // 计算差分值
}
while(m-- > 0){
int left = sc.nextInt(),right = sc.nextInt(),val = sc.nextInt();
diff[left] += val; diff[right+1] -= val; // 差分数组区间运算
}
int sum = 0;
for(int i = 1;i <= n;++i){
if(i != 1) System.out.print(" ");
arr[i] = diff[i] + arr[i-1]; // 对差分数组求前缀和
System.out.print(arr[i]);
}
}
}
import java.util.*;
import java.io.*;
class Main{
/*二维差分核心操作*/
static void insert(int[][] diff,int x1,int y1,int x2,int y2,int c){
diff[x1][y1] += c;
diff[x2+1][y1] -= c;
diff[x1][y2+1] -= c;
diff[x2+1][y2+1] += c;
}
public static void main(String[] args) throws NumberFormatException, IOException{
BufferedReader buf=new BufferedReader(new InputStreamReader(System.in));
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(System.out));
String[] tmp = buf.readLine().split(" ");
int n = Integer.parseInt(tmp[0]),m = Integer.parseInt(tmp[1]),q = Integer.parseInt(tmp[2]);
int[][] arr = new int[n+1][m+1];
int[][] diff = new int[n+2][m+2];
for(int r = 1;r <= n;++r){
tmp = buf.readLine().split(" ");
for(int c = 1;c <= m;++c){
arr[r][c] = Integer.parseInt(tmp[c-1]);
insert(diff,r,c,r,c,arr[r][c]);
}
}
while(q-- > 0){
tmp = buf.readLine().split(" ");
int x1 = Integer.parseInt(tmp[0]),y1 = Integer.parseInt(tmp[1]);
int x2 = Integer.parseInt(tmp[2]),y2 = Integer.parseInt(tmp[3]);
int val = Integer.parseInt(tmp[4]);
insert(diff,x1,y1,x2,y2,val);
}
for(int r = 1;r <= n;++r){
for(int c = 1;c <= m;++c){
diff[r][c] += diff[r-1][c] + diff[r][c-1] - diff[r-1][c-1];
writer.write(diff[r][c] +" ");
}
writer.write("\n");
}
writer.flush(); // 将缓冲区写入的内容输出
writer.close();
buf.close();
}
}
5 双指针算法
分类:
1) 两个指针指向两个序列(归并中两个序列的合并)
2) 两个指针指向一个序列(单词空格的分割,最长不含重复元素的序列)
时间复杂度:通常为O(n)的复杂度,将n^2的算法优化为O(n)
使用场景:序列特殊的性质符合双指针策略,比如单调性之类的。
通用写法:
for(int i = 0;i < n;++i){
while(j <= i && check){
...
++j;
}
}
import java.util.*;
class Main{
// 性质:窗口左端只能单调递增
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
int[] arr = new int[n];
for(int i = 0;i < n;++i){
arr[i] = sc.nextInt();
}
int[] cnt = new int[(int)(1e6)]; // 充当hashmap
int res = 0,j = 0;
for(int i = 0;i < n;++i){
cnt[arr[i]]++;
while(j <= i && cnt[arr[i]] > 1){ // 只需要检查arr[i],不需要额外的标记位
cnt[arr[j]]--;
++j;
}
res = Math.max(res,i-j+1);
}
System.out.println(res);
}
}
题意:给定两个升序数组和目标值k,求A[i] + B[j] == k的(i, j)对数
单调性分析:
给定A[i],在数组B中找到满足要求A[i]+B[j] >= k的最小j,由于B是单调的,因此整体具有单调性
import java.util.*;
class Main{
/*
单调性(B是升序数组): a + B[j] >= x 中最小的j,这个不等式具有单调性
因此可以使用双指针优化
*/
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt(),m = sc.nextInt(),x = sc.nextInt();
int[] A = new int[n],B = new int[m];
for(int i = 0;i < n;++i) A[i] = sc.nextInt();
for(int j = 0;j < m;++j) B[j] = sc.nextInt();
int j = m-1;
for(int i = 0;i < n;++i){
while(j >= 0 && A[i]+B[j] > x) --j;
if(j >= 0 && A[i]+B[j] == x){
System.out.printf("%d %d\n",i,j);
break;
}
}
}
}
给定一个长度为 n 的整数序列 a1,a2,…,an 以及一个长度为 m 的整数序列 b1,b2,…,bm。
判断a序列是否为b序列的子序列
解析:b可能存在多个a序列,双指针算法必定能够找到其中一个。
import java.util.*;
class Main{
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt(),m = sc.nextInt();
int[] arr1 = new int[n], arr2 = new int[m];
for(int i = 0;i < n;++i) arr1[i] = sc.nextInt();
for(int j = 0;j < m;++j) arr2[j] = sc.nextInt();
/*采用双指针算法必定能够在arr2中找到与arr1相同的子序列*/
int p1 = 0,p2 = 0;
while(p1 < n && p2 < m){
if(arr1[p1] == arr2[p2]) ++p1;
++p2;
}
if(p1 == n) System.out.println("Yes");
else System.out.println("No");
}
}
6 位运算
常用位运算操作
操作作用 | 实现 | |
---|---|---|
求指定位置的bit值 | (x >> i)&1 | i是右移运算符 |
返回非负整数最低位的1 | x&(-x) | 根据补码概念(源码取反+1),x&(-x) = x&(~x+1), |
import java.util.*;
class Main{
static int lowbit(int x){
return x & (-x);
}
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
while(n-- > 0){
int tmp = sc.nextInt();
int ans = 0;
while(tmp > 0){
if(lowbit(tmp) > 0) ++ans;
tmp -= lowbit(tmp);
}
System.out.print(ans+" ");
}
}
}
7 离散化
离散化定义:把无限空间中有限的个体映射到有限的空间中去,以此提高算法的时空效率。通俗的说,离散化是在不改变数据相对大小的条件下,对数据进行相应的缩小。
---------------------------------------------------------------------------------------------
本题离散化对象:有序整数
特点:数的范围比较大,但是数的个数不是很多
----------------------------------------------------------------------------------------------
具体操作(二分法对有序整数集合映射):
整数集合(值域大,个数少): {1,200,5000,10000}
映射的集合: {0,1,2,3}
----------------------------------------------------------------------------------------------
映射需考虑问题:
1) 集合中的重复元素处理(去重)
2) 如何算出a[i]离散化后的值(二分确定或者hashmap存储)
import java.util.*;
class Main{
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt(),m = sc.nextInt();
int[][] ops = new int[n][2],query = new int[m][2];
int[] pos = new int[2*m+n];
for(int i = 0;i < n;++i){
ops[i][0] = sc.nextInt(); // 坐标值
ops[i][1] = sc.nextInt(); // 操作
pos[i] = ops[i][0];
}
for(int i = 0;i < m;++i){
query[i][0] = sc.nextInt();
query[i][1] = sc.nextInt();
pos[n+2*i] = query[i][0];
pos[n+2*i+1] = query[i][1];
}
/*step1:获取去重并且有序的坐标集合unique*/
Arrays.sort(pos);
int[] unique = new int[n+2*m];
int ans = 0;
for(int i = 0;i < n+2*m;++i){
if(i == 0 || pos[i] != pos[i-1]) unique[ans++] = pos[i];
}
/*step2:二分离散化并构造前缀和数组*/
int[] nums = new int[ans+1];
int[] prefix = new int[ans+1];
for(int[] op:ops){
int idx = find(unique,op[0],ans);
nums[idx] += op[1];
}
for(int i = 1;i <= ans;++i) prefix[i] = nums[i] + prefix[i-1];
/*step3:离散化+前缀和数组进行查询*/
for(int[] q:query){
int ll = find(unique,q[0],ans);
int rr = find(unique,q[1],ans);
System.out.println(prefix[rr]-prefix[ll-1]);
}
}
/*二分查找 将坐标 映射为 索引+1*/
static int find(int[] unique,int t,int len){
int l = 0,r = len-1;
while(l < r){
int m = l + r >> 1;
if(unique[m] >= t) r = m;
else l = m+1;
}
return r+1; // 从1开始方便前缀和计算
}
}
hashmap存储映射后的集合
import java.util.*;
class Main{
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt(),m = sc.nextInt();
int[][] ops = new int[n][2],query = new int[m][2];
int[] pos = new int[2*m+n];
for(int i = 0;i < n;++i){
ops[i][0] = sc.nextInt(); // 坐标值
ops[i][1] = sc.nextInt(); // 操作
pos[i] = ops[i][0];
}
for(int i = 0;i < m;++i){
query[i][0] = sc.nextInt();
query[i][1] = sc.nextInt();
pos[n+2*i] = query[i][0];
pos[n+2*i+1] = query[i][1];
}
/*step1:获取去重并且有序的坐标集合unique*/
Arrays.sort(pos);
int[] unique = new int[n+2*m];
int ans = 0;
for(int i = 0;i < n+2*m;++i){
if(i == 0 || pos[i] != pos[i-1]) unique[ans++] = pos[i];
}
/*step2:离散化并构造前缀和数组,这里map存储的是(原始坐标,离散化坐标)对*/
Map<Integer,Integer> map = new HashMap<>();
for(int i = 1;i <= ans;++i) map.put(unique[i-1],i);
int[] nums = new int[ans+1];
int[] prefix = new int[ans+1];
for(int[] op:ops){
int idx = map.get(op[0]);
nums[idx] += op[1];
}
for(int i = 1;i <= ans;++i) prefix[i] = nums[i] + prefix[i-1];
/*step3:库函数hashmap离散化+前缀和数组进行查询*/
for(int[] q:query){
int ll = map.get(q[0]);
int rr = map.get(q[1]);
System.out.println(prefix[rr]-prefix[ll-1]);
}
}
}
8 区间合并
import java.util.*;
class Main{
/*区间合并的题目注意与贪心问题中的区间系列问题对比学习,虽然相似,但题意不同*/
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
int[][] interval = new int[n][2];
for(int i = 0;i < n;++i){
interval[i][0] = sc.nextInt(); interval[i][1] = sc.nextInt();
}
Arrays.sort(interval,(o1,o2)->{
return Integer.compare(o1[0],o2[0]); // 左端点排序
});
int cnt = 0,end = Integer.MIN_VALUE;
int st = end;
for(int[] t:interval){
if(t[0] > end){ // 可以合并
st = t[0]; end = t[1]; ++cnt;
}else{ // 无法合并,新区间
end = Math.max(end,t[1]);
}
}
// 如果需要打印合并的区间,不要遗忘最后一个区间
System.out.println(cnt);
}
}
二 数据结构
1 链表
数组表示链表
e[i] // element[i]表示地址为i的节点的数据区域
ne[i] // 表示地址为i的节点的下一个节点的地址
import java.util.*;
/*
数组模拟头插法单链表模板
链表结构: -1 <-- element1 <-- element2 <-- element3
||
head
0 1 2
element 1 2 3
ne -1 0 1
支持操作:
1)头插法
2)在第k个插入的数后面插入一个数x
注意:这里的第k个并不是链表中从头开始计算的第k个数
3)删除第k个插入的数
注意:同上
*/
class MyList{
int[] element; // 存储当前索引的节点的数据
int[] ne; // 存储当前节点索引的下个节点的数据索引
int index = 1; // 数组的下标充当指针地址(数据存储从地址1开始,数组地址0充当dummy node,没有数据)
MyList(int N){
element = new int[N];
ne = new int[N];
ne[0] = -1; // 0位置表示哑节点,指向第一个节点
};
/*将节点插入到单链表头部*/
void offerFirst(int val){
element[index] = val;
ne[index] = ne[0];
ne[0] = index;
index++;
}
/*在第k个插入的数后面插入一个数val*/
void insert(int k,int val){
element[index] = val;
ne[index] = ne[k]; // 设置第k个位置的节点为当前节点
ne[k] = index; // 让原本的第k个位置节点指向当前节点
++index;
}
/*在删除第k个插入的数的后面一个数,k=0表示删除头节点*/
void remove(int k){
ne[k] = ne[ne[k]]; // 让第k个数指向第k+2个节点,那么第k+1个节点则被删除
}
void printList(){
int tmp = ne[0];
while(tmp != -1){
System.out.printf("%d ",element[tmp]);
tmp = ne[tmp];
}
System.out.println();
}
}
class Main{
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
MyList mylist = new MyList(100001); // 操作次数必须 < 模拟数组容量
int n = Integer.parseInt(sc.nextLine());
for(int i = 0;i < n;++i){
String tmp = sc.nextLine();
String[] strs = tmp.split(" ");
if(strs[0].equals("H")){
mylist.offerFirst(Integer.parseInt(strs[1]));
}else if(strs[0].equals("D")){
mylist.remove(Integer.parseInt(strs[1]));
}else{
int k = Integer.parseInt(strs[1]);
int val = Integer.parseInt(strs[2]);
// System.out.printf("%d %d\n",k,val);
mylist.insert(k,val);
}
// mylist.printList();
}
mylist.printList();
}
}
import java.util.*;
/*
数组模拟头插法双向链表模板
支持5种操作:
1)头/尾插入元素
2)删除第k个插入的元素
注意:这里的第k个并不是链表中从头开始计算的第k个数
3)在第k个插入元素的左边/右边插入元素
注意:同上
函数使用注意点:由于数据从2开始存储,2) 3)在使用的时候需要将k的实际值+1传入,
实际上也可以将可以地址的最后一个地址作为尾部dummy node,这样就不需要k的实际值+1.
*/
class Dlist{
int[] element; // 存储当前索引的节点的数据
int[] leftp; // 前驱节点地址
int[] rightp; // 后驱节点地址
int index = 2;
// 数组的下标充当指针地址(数据存储从地址1开始,数组地址0充当头 dummy node,没有数据
// 数组地址1充当尾 dummy node,没有数据)
Dlist(int N){
element = new int[N];
leftp = new int[N];
rightp = new int[N];
// 初始化两个dummy node,非常重要的技巧,可以避免无意义的讨论
rightp[0] = 1;
leftp[0] = 1;
rightp[1] = 0;
leftp[1] = 0;
};
/*将节点插入到链表头部,头节点的左地址为-1*/
void offerFirst(int val){
element[index] = val;
int after = rightp[0]; // 插入节点到头部(0,插入,after)
rightp[0] = index;
leftp[after] = index;
leftp[index] = 0;
rightp[index] = after;
index++;
}
/*将节点插入到链表尾部,尾节点的右地址为-1*/
void offerLast(int val){
element[index] = val;
int pre = leftp[1]; //插入节点到尾部 (pre,插入,1)
rightp[pre] = index;
leftp[1] = index;
rightp[index] = 1;
leftp[index] = pre;
index++;
}
/*在第k个位置左边插入节点*/
void insertLeft(int k,int val){
element[index] = val;
int pre = leftp[k]; // 在地址pre与地址k之间插入节点
rightp[pre] = index;
leftp[k] = index;
leftp[index] = pre;
rightp[index] = k;
++index;
}
/*在第k个位置左边插入节点*/
void insertRight(int k,int val){
element[index] = val;
int after = rightp[k]; // 在地址k与地址after之间插入节点
leftp[after] = index;
rightp[k] = index;
leftp[index] = k;
rightp[index] = after;
++index;
}
/*删除第k个位置节点*/
void remove(int k){
int pre = leftp[k];
int after = rightp[k];
rightp[pre] = after; // pre,k,after中删除k
leftp[after] = pre;
}
void printList(){
int tmp = rightp[0]; // 获取头节点
while(tmp != 1){ // 地址为1是尾节点
System.out.printf("%d ",element[tmp]);
tmp = rightp[tmp];
}
System.out.println();
}
}
class Main{
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
Dlist mylist = new Dlist(100010); // 操作次数必须 < 模拟数组容量
int n = Integer.parseInt(sc.nextLine());
for(int i = 0;i < n;++i){
String tmp = sc.nextLine();
String[] strs = tmp.split(" ");
if(strs[0].equals("R")){
mylist.offerLast(Integer.parseInt(strs[1]));
}else if(strs[0].equals("D")){
int p = Integer.parseInt(strs[1])+1; // 插入数据的实际位置与插入次序有偏移
mylist.remove(p);
}else if(strs[0].equals("L")){
mylist.offerFirst(Integer.parseInt(strs[1]));
}else if(strs[0].equals("IL")){
int k = Integer.parseInt(strs[1])+1; // 插入数据的实际位置与插入次序有偏移
int val = Integer.parseInt(strs[2]);
mylist.insertLeft(k,val);
}else{
int k = Integer.parseInt(strs[1])+1; // 插入数据的实际位置与插入次序有偏移
int val = Integer.parseInt(strs[2]);
mylist.insertRight(k,val);
}
// mylist.printList();
}
mylist.printList();
}
}
2 栈
初始化:数组stack+,指针top = 0
push: stack[top++]
pop: --top
query: stack[top-1]
empty: top == 0
注意:
1) top初始化为0可以理解为栈的大小也可以理解为栈的下一个元素存储的索引
2) top也可以初始化为-1,那么相应的操作需要进行修改
import java.util.*;
class Main{
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int M = Integer.parseInt(sc.nextLine());
/*数组模拟栈,top指向下一个元素存储的位置*/
int[] stack = new int[M];
int top = 0;
while(M-- > 0){
String[] strs = sc.nextLine().split(" ");
String op = strs[0];
if(op.equals("push")){
stack[top++] = Integer.parseInt(strs[1]);
}else if(op.equals("pop")){
top--;
}else if(op.equals("query")){
System.out.println(stack[top-1]);
}else if(op.equals("empty")){
if(top == 0) System.out.println("YES");
else System.out.println("NO");
}
}
}
}
表达式树定义:
表达式分类:
1) 中缀表达式(需要考虑优先级,表达式树的中序遍历+优先级的考虑)
2) 后缀表达式(不需要考虑优先级,表达式树的后序遍历)
----------------------------------------------------------------------------
中缀表达式树解析存在的问题:
1)如何判断当前子树已经遍历完?
-往上走:当前子树已经遍历完
-往下走:当前子树没有遍历完
策略:当前的运算符优先级是否大于等于上一个节点的运算符优先级
a*b+c
+
* c
a b
往上走:当前运算符+ <= 上一运算符*优先级(以*为节点的运算符已经遍历完了,必须进行处理)
-----------------------------------------------------------------------------
如何判断往上走还是往下走 ?
中缀表达式树的特点:运算符优先级大的在下面, 运算符优先级小的在上面
-目前运算符的优先级小于等于上一运算符优先级, 说明是往上走
-目前运算符的优先级比上一运算符优先级大是, 说明是往下走
--------------------------------------------------------------------------------
什么时候进行计算?
往上走时, 因为此时子树遍历完, 需要计算子树的结果, 并将结果作为一个新的节点代替原来的子树
import java.util.*;
class Main{
static Stack<Integer> nums = new Stack<>(); // 记录操作数
static Stack<Character> op = new Stack<>(); // 记录运算符
static Map<Character,Integer> map = new HashMap<>();
public static void eval(){
int b = nums.pop(),a = nums.pop();
char c = op.pop();
int v = 0;
if(c == '+') v = a + b;
if(c == '-') v = a - b;
if(c == '*') v = a * b;
if(c == '/') v = a / b;
nums.push(v);
}
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
String s = sc.nextLine();
map.put('+',1);
map.put('-',1);
map.put('*',2);
map.put('/',2);
int len = s.length();
for(int i = 0;i < len;++i){
char c = s.charAt(i);
if(Character.isDigit(c)){
int j = i+1;
int v = c-'0';
while(j < len && Character.isDigit(s.charAt(j))){
v = v*10 + s.charAt(j) - '0';
++j;
}
nums.push(v);
i = j-1;
}else if(c == '('){
op.push(c);
}else if(c == ')'){
while(!op.isEmpty() && op.peek() != '(') eval();
op.pop();
}else{
/*当前运算符优先级 <= 上次运算符,节点往上遍历,需要处理上次运算符*/
while(!op.isEmpty() && op.peek() != '(' && map.get(c) <= map.get(op.peek())){ // ( 特判
eval();
}
op.push(c);
}
}
while(!op.isEmpty()) eval();
System.out.println(nums.peek());
}
}
3 队列
import java.util.*;
class Main{
/*数组模拟队列,注意两个指针的初始化*/
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int M = Integer.parseInt(sc.nextLine());
int[] q = new int[(int)(1e5)];
int hh = 0,tt = -1; // 注意初始化,hh > tt表示为空
while(M-- > 0){
String[] strs = sc.nextLine().split(" ");
String op = strs[0];
if(op.equals("push")){
q[++tt] = Integer.parseInt(strs[1]);
}else if(op.equals("pop")){
++hh;
}else if(op.equals("empty")){
if(hh <= tt) System.out.println("NO");
else System.out.println("YES");
}else if(op.equals("query")){
System.out.println(q[hh]);
}
}
}
}
4 单调栈
问题:给定一个序列,求序列每个元素左边第一个小于该元素的数字。
暴力做法:双指针策略,时间复杂度O(n^2)
for(int i = 0;i < n;++i){
for(int j = i-1;j >= 0;--j){
if(nums[j] < nums[i]){
print(nums[j]);
break;
}
}
}
-------------------------------------------------------------------------
性质分析:求nums[i],左边第一个比其小的值,对于区间[0,i-1]中寻找满足要求的答案,这些元素中存在一些元素肯定不是答案。
假设 0 <= m < n <= i-1,如果a[m] > a[n],那么m肯定不是答案,对于[n+1,]范围内的数来说,也就是说最终的答案会可以提前删除类似m位置的元素,因此可以维护一个随着索引增加的单调递增的序列(通过单调性避免无效的答案的检查)。
-------------------------------------------------------------------------------------
import java.util.*;
class Main{
/*
数组模拟单调栈,栈中存储索引,并且索引的值单调递增
考虑nums[i]和nums[i+1],
当nums[i+1] > nums[i],则nums[i]就是目标值,其下标i已存入栈中
当nums[i+1] <= nums[i],则栈中所有比nums[i]小的元素下标仍然在栈并单调递增中。
*/
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int N = sc.nextInt();
int[] nums = new int[N];
for(int i = 0;i < N;++i){
nums[i] = sc.nextInt();
}
int[] st = new int[N];
int top = 0;
for(int i = 0;i < N;++i){
while(top > 0 && nums[st[top-1]] >= nums[i]) --top;
if(top == 0)
System.out.print(-1+" ");
else
System.out.print(nums[st[top-1]]+" ");
st[top++] = i;
}
}
}
5 单调队列
1 [3 -1 3] 5 3 6 7
由于是求窗口中的最小值,因此在窗口滑动的过程中,如果存在 i < j 并且 nums[i] > nums[j],则nums[i]必定不可能是答案,因此可以通过队列维护随着索引增大单调递增的数组避免无效答案的检查
1) 求滑动窗口的最大值和最小值
import java.util.*;
import java.io.*;
class Main{
/*
数组模拟单调队列
*/
public static void main(String[] args) throws NumberFormatException, IOException{
BufferedReader buf=new BufferedReader(new InputStreamReader(System.in));
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(System.out));
String[] tmp = buf.readLine().split(" ");
int n = Integer.parseInt(tmp[0]),k = Integer.parseInt(tmp[1]);
int[] nums = new int[n];
tmp = buf.readLine().split(" ");
for(int i = 0;i < n;++i){
nums[i] = Integer.parseInt(tmp[i]);
}
int[] q = new int[n];
int head = -1,tail = 0;
for(int i = 0;i < n;++i){
if(tail <= head && q[tail] <= i-k) ++tail; // 维护窗口左端点
while(tail <= head && nums[q[head]] > nums[i]) --head; // 维护窗口内随下标增加的单调性
q[++head] = i; // 维护窗口右端点
if(i >= k-1) writer.write(nums[q[tail]] +" ");
}
writer.write("\n");
head = -1; tail = 0;
for(int i = 0;i < n;++i){
if(tail <= head && q[tail] <= i-k) ++tail;
while(tail <= head && nums[q[head]] < nums[i]) --head;
q[++head] = i;
if(i >= k-1) writer.write(nums[q[tail]] +" ");
}
writer.flush();
writer.close();
buf.close();
}
}
6 KMP
目标:给定字符串s与模式串p,求s中所有与p匹配
暴力算法
import java.util.*;
class Main{
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int len1 = Integer.parseInt(sc.nextLine());
String P = sc.nextLine();
int len2 = Integer.parseInt(sc.nextLine());
String S = sc.nextLine();
// 基本思想:对于原始串的每个字母,模式串是从第一个字母进行匹配
for(int i = 0;i < len2-len1+1;++i){
int j;
for(j = 0;j < len1;++j){
if(S.charAt(i+j) != P.charAt(j))
break;
}
if(j == len1)
System.out.printf("%d ",i);
}
}
}
KMP的思想:
模式串P每次匹配S的上一个子串失败,开始匹配下一个子串:
暴力法:S指向匹配失败子串的第二个字符,P指向串的首个字符,进行新的匹配
KMP:利用匹配失败的子串成功匹配的部分, 求出 成功匹配失败的子串 与 模式串的最大前后缀长度,该长度在匹配前存起来,匹配的时候使用,假
注意:
- 模式子串的后缀长度 < 模式子串的长度,等于的话,并非有效信息,因为该有效信息的前提就是当前模式子串无法匹配。
Java实现
import java.util.*;
import java.io.*;
class Main{
public static void main(String[] args) throws NumberFormatException, IOException {
// 本题输入输出必须使用BufferedReader和BufferedWrite
BufferedReader buf=new BufferedReader(new InputStreamReader(System.in));
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(System.out));
int len1 = Integer.parseInt(buf.readLine());
String P = buf.readLine();
int len2 = Integer.parseInt(buf.readLine());
String S = buf.readLine();
P = " "+P;
S = " "+S;
char[] pp = P.toCharArray();
char[] ss = S.toCharArray();
// step1:构造模式串对应的next数组
/*
next[i]表示模式串中第i(从1开始)个字符结尾的子串的后缀与模式串的前缀的公共部分长度
next 1 2 3 4 5 6 7 8 (模式串P)
a b a b a b a b
0 0 1 2 3 4 5 6
*/
int[] next = new int[len1+1];
next[1] = 0;
for(int i = 2,size = 0;i <= len1;++i){
while(size > 0 && pp[size+1] != pp[i]) size = next[size]; // 匹配失败
if(pp[size+1] == pp[i]) ++size; // 匹配成功
next[i] = size;
}
// step2:匹配
for(int i = 1,size = 0;i <= len2;++i){
while(size > 0 && pp[size+1] != ss[i]) size = next[size]; // 匹配失败
if(pp[size+1] == ss[i]) ++size; // 匹配成功
if(size == len1){
writer.write(i - size +" ");
size = next[size];
}
}
writer.flush(); // 将缓冲区写入的内容输出
writer.close();
buf.close();
}
}
7 Tire
Tire(Try树):高效存储和查找字符串集合的数据结构
数组模拟实现
import java.util.*;
/*使用二维数目实现字典树*/
class Tire{
int[][] son; // Tire的树的节点空间分配,[一维,二维] = [树总的节点个数,字符串集合总的],存储节点的id
int[] cnt; // 节点的标记空间分配,表明以当前节点结尾的单词个数
int idx; // 节点索引,id=0的节点为根节点
Tire(int n,int m){
idx = 0;
son = new int[n][m];
cnt = new int[n];
}
/*将字符串插入集合*/
void insert(String s){
int cur = 0;
for(int i = 0;i < s.length();++i){
int v = s.charAt(i) - 'a';
if(son[cur][v] == 0) son[cur][v] = ++idx;
cur = son[cur][v];
}
cnt[cur]++;
}
/*查询字符串s在集合中出现次数*/
int query(String s){
int cur = 0;
for(int i = 0;i < s.length();++i){
int v = s.charAt(i) - 'a';
if(son[cur][v] == 0) return 0;
cur = son[cur][v];
}
return cnt[cur];
}
}
class Main{
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int N = Integer.parseInt(sc.nextLine());
Tire tire = new Tire((int)(1e5+10),26);
while(N-- > 0){
String[] str = sc.nextLine().split(" ");
if(str[0].equals("I")) tire.insert(str[1]);
if(str[0].equals("Q")) System.out.println(tire.query(str[1]));
}
}
}
import java.util.*;
/*
基本思路:使用Tire树提高配对速度
时间复杂度: 31(二进制字符串匹配)*单词数量
*/
class Main{
static int M = (int)(4e6); // 确保节点的数目 >= 31*字符串个数
static int[][] son = new int[M][2];
static int idx = 0;
static void insert(int x){
int p = 0;
for(int i = 30;i >= 0;--i){ // 根据题意可知数的范围,最高位是符号位都是0,因此长度为30即只考虑30个二进制位
int v = (x >> i) & 1;
if(son[p][v] == 0) son[p][v] = ++idx;
p = son[p][v];
}
}
static int query(int x){
int p = 0,res = 0;
for(int i = 30;i >= 0;--i){
int v = (x >> i) & 1;
int t = v ^ 1;
if(son[p][t] != 0){
p = son[p][t];
res += (1 << i);
}else
p = son[p][v];
}
return res;
}
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int N = sc.nextInt();
int[] nums = new int[N];
for(int i = 0;i < N;++i){ // 建立字典树
nums[i] = sc.nextInt();
insert(nums[i]);
}
int res = Integer.MIN_VALUE;
for(int i = 0;i < N;++i){ // 利用字典树求每个数字能够匹配的最大值
res = Math.max(res,query(nums[i]));
}
System.out.println(res);
}
}
8 并查集
应用场景:
1)将两个集合合并 2)询问两个元素是否在一个集合中
基本原理:
数据结构:每个集合用一颗树表示,树根的编号就是”集合的编号“,每个节点存储他的父节点,p[x]表示x的父节点。
1)树根的判定: if(p[x] == x)
2) 求x的集合编号: while(p[x] != x) x = p[x]
3) 合并a,b所在集合: p[a] = b
优化:1)路径压缩(常用) 2)按秩合并
其他属性的维护:
1)维护每个集合的大小
2)维护每个节点到根节点的距离(带权并查集)
import java.util.*;
/*维护集合大小+路径压缩的并查集模板*/
class Dsu{
int[] p; // 节点根节点
int[] size; // 节点所在集合大小,只有根节点这个数值有意义
Dsu(int n){
p = new int[n]; size = new int[n];
for(int i = 0;i < n;++i) p[i] = i;
Arrays.fill(size,1);
}
int find(int a){
int tmp = a;
while(tmp != p[tmp]){
tmp = p[tmp];
}
int root = tmp;
/*路径压缩*/
while(a != p[a]){
int v = p[a]; p[a] = root; a = v;
}
return root;
}
void union(int a,int b){
int fa = find(a);
int fb = find(b);
if(fa != fb){
p[fb] = fa;
size[fa] = size[fa]+size[fb];
}
}
}
class Main{
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
String[] input = sc.nextLine().split(" ");
int n = Integer.parseInt(input[0]);
int m = Integer.parseInt(input[1]);
Dsu dsu = new Dsu(n+1); // 编号从1开始
for(int i = 0;i < m;++i){
input = sc.nextLine().split(" ");
char op = input[0].charAt(0);
int a = Integer.parseInt(input[1]);
int b = Integer.parseInt(input[2]);
if(op == 'M') dsu.union(a,b);
if(op == 'Q'){
if(dsu.find(a) == dsu.find(b)){
System.out.println("Yes");
}else{
System.out.println("No");
}
}
}
}
}
import java.util.*;
/*维护集合大小+路径压缩的并查集模板*/
class Dsu{
int[] p; // 节点根节点
int[] size; // 节点所在集合大小,只有根节点这个数值有意义
Dsu(int n){
p = new int[n]; size = new int[n];
for(int i = 0;i < n;++i) p[i] = i;
Arrays.fill(size,1);
}
int find(int a){
int tmp = a;
while(tmp != p[tmp]){
tmp = p[tmp];
}
int root = tmp;
/*路径压缩*/
while(a != p[a]){
int v = p[a]; p[a] = root; a = v;
}
return root;
}
void union(int a,int b){
int fa = find(a);
int fb = find(b);
if(fa != fb){
p[fb] = fa;
size[fa] = size[fa]+size[fb];
}
}
// 返回节点a所在集合大小,注意:size只在根节点处有意义,因此必须使用根节点的size值
int setSize(int a){
int fa = find(a);
return size[fa];
}
}
class Main{
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
String[] input = sc.nextLine().split(" ");
int n = Integer.parseInt(input[0]);
int m = Integer.parseInt(input[1]);
Dsu dsu = new Dsu(n+1); // 编号从1开始
for(int i = 0;i < m;++i){
input = sc.nextLine().split(" ");
String op = input[0];
int a = Integer.parseInt(input[1]);
if(op.equals("C")) {
int b = Integer.parseInt(input[2]);
dsu.union(a,b);
}
else if(op.equals("Q1")){
int b = Integer.parseInt(input[2]);
if(dsu.find(a) == dsu.find(b)){
System.out.println("Yes");
}else{
System.out.println("No");
}
}else{
System.out.println(dsu.setSize(a));
}
}
}
}
存在三类生物满足互吃的关系,即 A->B->C->A
从并查集的角度维护上述关系:
o
/
o
/
o
/
o
到根节点距离%3为1的点 吃 根节点
到根节点距离%3为2的点 吃 到根节点距离为1的点
到根节点距离%3为0的点 与 根节点是同类
d[x]:表示节点x到根节点距离
通过上述的定义可以发现两个节点关系满足下面公式:
x和y的关系 = ((d[x]-d[y])%3 + 3) % 3
x吃y = 1
y吃x = 2
x,y同类 = 0
1,2,0可以看作上述三种关系的编码表示r
------------------------------------------------------
解题思路:将动物作为节点添加到“带权并查集”中并按照上述关系定义维护集合,从而完美的表示食物链关系
d[x]:表示x到根节点距离(本题必须进行路径压缩)
对于新的关系r = (x,y)
s1:先检查是否已经存在关系(p[x] == p[y] ?),如果有,判断是否等于r
s2:没有关系( p[x] != p[y] )则添加关系r,即
p[p[x]] = p[y] (将x的所在集合的根节点添加到y集合中)
(d[x]+d[p[x]]-d[y]-r)% 3 == 0 (修改x根节点到y根节点的距离确保x,y满足关系r)
总结:这里通过距离根节点的距离来定义节点之间的关系,非常巧妙
import java.util.*;
class Main{
public static int[] fath = new int[(int)(1e5)];
public static int[] dis = new int[(int)(1e5)];
/*维护距离的路径压缩*/
public static int find(int a){
int t = fath[a];
if(t != a){
fath[a] = find(t);
dis[a] = dis[a]+dis[t];
}
return fath[a];
}
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int N = sc.nextInt(),K = sc.nextInt();
for(int i = 1;i <= N;++i) fath[i] = i;
int ans = 0;
while(K-- > 0){
int type = sc.nextInt(),x = sc.nextInt(),y = sc.nextInt();
if(x < 1 || x > N || y < 1 || y > N){ // 数据范围判定
++ans; continue;
}
int fx = find(x),fy = find(y);
if(type == 1){
if(fx == fy){
/* ((dx - dy) % 3 + 3) % 3 == 0*/
if((dis[x] - dis[y]) % 3 != 0) ++ans; // 到根节点的距离差%3 != 0,则不是同类
}else{
fath[fy] = fx; // 合并集合
dis[fy] = dis[x] - dis[y]; // 确保到根节点的距离差%3 == 0
/* (dis[y] + dis[fy] - dis[x]) % 3 == 0*/
}
}else if(type == 2){
if(fx == fy){
if((dis[x]-dis[y]-1) % 3 != 0) ++ans; // 到根节点的距离差%3 != 1,则不是x吃y的关系
}else{
fath[fx] = fy;
dis[fx] = dis[y] - dis[x] + 1; // 确保到根节点的距离差%3 == 1
/*(dis[x] + dis[fx] - dis[y] - 1) % 3 == 0 */
}
}
}
System.out.println(ans);
}
}
9 堆
基本思想
堆的结构与完全二叉树比较类似,
定义:递归定义比如小根堆每个顶点都是当前子堆的最小值。
存储方式:一维数组存储,1号节点是根节点,2x是左节点,2x+1是右节点(0号节点是根节点,2x+1是左节点,2x+2是右节点)
小根堆的相关操作:
1)插入数字 heap[++size]= val, up(size)
2)求集合中最小值 heap[1]
3)删除最小值 heap[1] = heap[size],size--,down(1)
4)删除任意元素 heap[k] = heap[size],size--,down(k),up(k)
5)修改任意元素 heap[k] = x,down(k),up(k)
注意:自己实现堆的时候,不要忘记堆的初始调整
自底向下建堆的时间复杂度为:O(n)
import java.util.*;
class Heap{
int[] data;
int size;
int capacity;
Heap(int capacity){
data = new int[capacity+1];
this.capacity = capacity;
this.size = 0;
}
/*插不调整*/
void add(int x){
if(size < capacity){
data[++size] = x;
}
}
/*建立堆,由于堆在结构上与完全二叉树一致,因此从第一个非叶节点即size/2调整*/
void buildHeap(){
for(int i = size/2;i >= 1;--i){
down(i);
}
}
/*向下调整*/
void down(int src){
int target = src;
if(src*2 <= size && data[target] > data[src*2]) target = src*2;
if(src*2+1 <= size && data[target] > data[src*2+1]) target = src*2+1;
if(src != target){
swap(data,src,target);
down(target);
}
}
void swap(int[] data,int i,int j){
int tmp = data[i];
data[i] = data[j];
data[j] = tmp;
}
/*返回堆顶元素*/
int poll(){
int v = data[1];
data[1] = data[size];
--size;
down(1);
return v;
}
}
class Main{
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
int m = sc.nextInt();
int[] nums = new int[n];
Heap heap = new Heap(n);
for(int i = 0;i < n;++i){
heap.add(sc.nextInt());
}
heap.buildHeap();
for(int i = 0;i < m;++i){
System.out.printf("%d ",heap.poll());
}
}
}
import java.util.*;
/*
上下调整时机:
1)堆的下标为1,只需要向下调整,因为该节点没有父节点
2)堆的下标大于size/2只需要向上调整,因为此时节点没有子节点
3)其余情况,上下都需要调整
保险起见,为了避免出错,只要调整,上下都写。
*/
class Main{
static int N = 100001;
static int[] insertToHeap= new int[N]; // 插入顺序编号->插入元素在堆数组的索引
static int[] heapToinsert = new int[N]; // 插入元素在对数组的索引->插入顺序编号
static int[] data = new int[N]; // 存放堆的数据
static int size = 0; // 堆的元素数量
static int insertNum = 0; // 插入次数
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = Integer.parseInt(sc.nextLine());
for(int i = 0;i < n;++i){
String tmp = sc.nextLine();
String[] strs = tmp.split(" ");
if(strs[0].equals("I")){
++size; ++insertNum;
data[size] = Integer.parseInt(strs[1]);
insertToHeap[insertNum] = size;
heapToinsert[size] = insertNum;
//调整堆
up(size);
}else if(strs[0].equals("PM")){
System.out.println(data[1]);
}else if(strs[0].equals("DM")){
heapSwap(1,size);
--size;
down(1);
}else if(strs[0].equals("D")){
int insertId = Integer.parseInt(strs[1]);
int heapIndex = insertToHeap[insertId];
heapSwap(heapIndex,size);
--size;
up(heapIndex);
down(heapIndex);
}else if(strs[0].equals("C")){
int insertId = Integer.parseInt(strs[1]);
int value = Integer.parseInt(strs[2]);
int heapIndex = insertToHeap[insertId];
data[heapIndex] = value;
up(heapIndex);
down(heapIndex);
}
}
}
/*向下调整*/
static void down(int src){
int target = src;
if(src*2 <= size && data[target] > data[src*2]) target = src*2;
if(src*2+1 <= size && data[target] > data[src*2+1]) target = src*2+1;
if(src != target){
heapSwap(src,target);
down(target);
}
}
/*向上调整*/
static void up(int src){
while(src/2 > 0 && data[src] < data[src/2]){
heapSwap(src,src/2);
src = src/2;
}
}
static void heapSwap(int i,int j){
swap(insertToHeap,heapToinsert[i],heapToinsert[j]);
swap(data,i,j);
swap(heapToinsert,i,j);
}
static void swap(int[] arr,int i,int j){
int tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}
}
10 hash表
hash碰撞常用的解决策略:
1)开发定址法
2)拉链法
模数的选择:采用离2的幂尽可能远的质数,减少hash碰撞
import java.util.*;
/*
拉链法实现hash表,其中链表采用数组实现,插入采用头插法。
*/
class Main{
static int N = (int)(1e5+3); // hash表的大小
static int[] h,e,ne;
static int idx = 0;
static void put(int x){
int p = (x % N + N) % N; // 计算槽位置
e[idx] = x; ne[idx] = h[p]; h[p] = idx; ++idx; // 头插法
}
static boolean find(int x){
int p = (x % N + N) % N; // 确保结果为正
for(int idx = h[p];idx != -1;idx = ne[idx]){
if(e[idx] == x) return true;
}
return false;
}
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
h = new int[N]; ne = new int[N]; e = new int[N]; // 初始化hash数组以及链表头
Arrays.fill(h,-1);
while(n-- > 0){
String op = sc.next();
int x = sc.nextInt();
if(op.equals("I")){
put(x);
}else{
if(find(x)) System.out.println("Yes");
else System.out.println("No");
}
}
}
}
开放地址法
import java.util.*;
/*
开放地址法(线性探测法的策略)实现hash表
*/
class Main{
static int N = (int)(1e5+3); // hash表的大小
static int NULL = (int)(1e9+10); // 表示当前槽位为空
static int[] h;
/*确定当前key在表中的索引位置*/
static int index(int x){
int p = (x % N + N) % N;
while(h[p] != NULL && h[p] != x) p = (p+1)%N;
return p;
}
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
h = new int[N];
Arrays.fill(h,NULL);
while(n-- > 0){
String op = sc.next();
int x = sc.nextInt();
if(op.equals("I")){
int p = index(x); h[p] = x;
}else{
int p = index(x);
if(h[p] == x) System.out.println("Yes");
else System.out.println("No");
}
}
}
}
基本思想:
str="ABCABCDEYXC"
前缀hash值定义:将每个数字看成p进制数(高位在左边,低位在右边)
h[0] = 0;
h[1] = h[0]xP+'A' (前缀'A'的hash值)
h[2] = h[1]xP+'B' (前缀'AB'的hash值)
h[3] = h[2]xP+'C' (前缀'ABC'的hash值)
h[2,3] = h[3]-h[1]xP^(2) = 完整值-高位部分
-----------------------------------------------------------------------
前缀和:首位是最高位,
区间和:h[l,r] = h[r]-h[l-1]xP^(r-l+1) 等价于 完整值 - 高位部分
------------------------------------------------------------------------
h[i]:字符串前i个字符的前缀hash值(最右边是最高位)
p[i]存储p进制的i次方即p^i
公式1:hash[i] = hash[i-1]*P + (int)C
公式2:hash[i,j] = hash[j]-hash[i-1]*p[j-i+1]
(字符串的第i个字符到第j个字符的hash)
------------------------------------------------------------------------
注意点:
1)不要把具体的字符映射为0,如果映射将A映射为0,那么'A','AA','AAA'都是相同的字符串。
import java.util.*;
class Main{
static long getHash(long[] h,long[] p,int ll,int rr){
return h[rr]-h[ll-1]*p[rr-ll+1];
}
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt(),m = sc.nextInt();
String s = sc.next();
/*
h[i]表示字符串前i个字符的前缀hash值(最右边是最高位):hash[i] = hash[i-1]*P + (int)C
字符串的第i个字符到第j个字符的hash值表示为:hash[i,j] = hash[j]-hash[i-1]*p[j-i+1] 即 长前缀-短前缀(高位信息*P^区间长度)
p[i]存储p进制的i次方即p^i
*/
long[] h = new long[n+1],p = new long[n+1]; //
h[0] = 0; p[0] = 1;
int P = 131;
for(int i = 1;i <= n;++i){
p[i] = p[i-1]*P;
h[i] = h[i-1]*P+(long)s.charAt(i-1);
}
while(m-- > 0){
int l1 = sc.nextInt(),r1 = sc.nextInt(),l2 = sc.nextInt(),r2 = sc.nextInt();
long v1 = getHash(h,p,l1,r1);
long v2 = getHash(h,p,l2,r2);
if(v1 == v2) System.out.println("Yes");
else System.out.println("No");
}
}
}
三 搜索与图论
1 DFS
import java.util.*;
class Main{
static int N = 10;
static boolean[] flag = new boolean[N];
static int[] res = new int[N];
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
dfs(0,n);
}
static void dfs(int n,int num){
if(n == num){
for(int i = 0;i < num;++i) System.out.print(res[i]+" ");
System.out.println();
}
for(int i = 1;i <= num;++i){
if(flag[i]) continue;
res[n] = i; flag[i] = true;
dfs(n+1,num);
flag[i] = false;
}
}
}
import java.util.*;
class Main{
public static boolean[][] grid;
public static int n;
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
n = sc.nextInt();
grid = new boolean[n][n];
dfs(0);
}
public static void dfs(int r){
if(r == n){
printInfo();
return;
}
for(int c = 0;c < n;++c){
if(check(r,c)){
grid[r][c] = true;
dfs(r+1);
grid[r][c] = false;
}
}
}
public static int[][] dir = {{-1,-1},{-1,1}};
public static boolean check(int r,int c){
for(int i = 0;i < r;++i){
if(grid[i][c]) return false;
}
for(int[] d:dir){
int i = r+d[0],j = c+d[1];
while(i >= 0 && j >= 0 && i < n && j < n){
if(grid[i][j]) return false;
i += d[0]; j += d[1];
}
}
return true;
}
public static void printInfo(){
for(int i = 0;i < n;++i){
for(int j = 0;j < n;++j){
if(grid[i][j]) System.out.print("Q");
else System.out.print(".");
}
System.out.println();
}
System.out.println();
}
}
2 BFS
import java.util.*;
class Main{
public static boolean[][] grid;
public static int n,m;
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
n = sc.nextInt(); m = sc.nextInt();
int[][] grid = new int[n][m];
for(int i = 0;i < n;++i){
for(int j = 0;j < m;++j){
grid[i][j] = sc.nextInt();
}
}
int[][] dir = {{0,1},{0,-1},{1,0},{-1,0}};
boolean[][] vis = new boolean[n][m];
Queue<int[]> q = new ArrayDeque<>();
q.offer(new int[]{0,0,0});
vis[0][0] = true;
int res = -1;
while(!q.isEmpty()){
int[] cur = q.poll();
if(cur[0] == n-1 && cur[1] == m-1){
res = cur[2];
break;
}
for(int[] d: dir){
int r = cur[0]+d[0],c = cur[1]+d[1],step = cur[2]+1;
if(r < 0 || r >= n || c < 0 || c >= m) continue;
if(vis[r][c] || grid[r][c] == 1) continue;
vis[r][c] = true;
q.offer(new int[]{r,c,step});
}
}
System.out.println(res);
}
}
基本思路:BFS(最小步数问题)
为什么可以用BFS解决该问题?
可以将每个状态抽象为图中的一个节点,两个状态之间可以转移则表示节点之间存在边,求两个状态转移的最小步数等价于图中的最短路问题,对于无权的图可以采用BFS解决这个问题,如果是有权的,则需要最短路算法解决。
import java.util.*;
class Main{
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
StringBuilder start = new StringBuilder();
for(int i = 0;i < 9;++i) start.append(sc.next());
String st = start.toString();
String end = "12345678x";
Map<String,Integer> map = new HashMap<>();
Queue<String> q = new ArrayDeque<>();
int[][] dir = {{0,1},{0,-1},{1,0},{-1,0}};
int res = -1;
map.put(st,0);
q.offer(st);
/*BFS,采用String表示状态*/
while(!q.isEmpty()){
String cur = q.poll();
int step = map.get(cur);
if(cur.equals(end)){
res = step;
break;
}
int idx = cur.indexOf("x");
StringBuilder tmp = new StringBuilder(cur);
int r = idx / 3, c = idx % 3;
for(int[] d:dir){
int rr = r + d[0],cc = c + d[1];
if(rr < 0 || rr >= 3 || cc < 0 || cc >= 3) continue;
int nidx = rr*3+cc;
swap(tmp,idx,nidx);
String t = tmp.toString();
if(!map.containsKey(t)){
q.offer(t);
map.put(t,step+1);
}
swap(tmp,nidx,idx);
}
}
System.out.println(res);
}
public static void swap(StringBuilder s,int idx1,int idx2){
char c = s.charAt(idx1);
s.setCharAt(idx1,s.charAt(idx2));
s.setCharAt(idx2,c);
}
}
3 树与图的遍历
图的存储
方法1:邻接矩阵即二维矩阵g[m][m]
方法2:邻接表,为每个节点创建1个单链表
import java.util.*;
/*图的拉链法(邻接表)存储模板*/
class Graph{
int[] heads; // 单链表的dummy nodes数组,指向每个节点的单链表
int[] element; // 存储节点值(相邻节点的编号)
int[] np; // 存储当前地址的下一个地址(next pointer)
boolean visited[];
int idx = 0; // 未使用的第一个地址index
int res = Integer.MAX_VALUE; // 重心删除后,剩余各个连通块中点数的最大值。
Graph(int capacity){
heads = new int[capacity];
element = new int[capacity];
np = new int[capacity];
visited = new boolean[capacity]; // 默认为false
Arrays.fill(heads,-1); // -1表示空结点,让dummy节点指向-1
}
/* a,b是节点编号,增加一条有向边即a->b
这里采用头插法在编号为a的节点所对应的
单链表中插入节点
*/
void add(int a,int b){
element[idx] = b;
np[idx] = heads[a];
heads[a] = idx;
++idx;
}
/*返回从当前节点出发,遍历节点的数目,num是图的节点数目*/
int dfs(int src,int num){
visited[src] = true;
int subMax = 0; // 图中去除src以及在src前访问的节点,剩余的联通分量节点数的最大值
int leftSum = 1; // 统计当前访问节点+剩余要访问的节点,这里1即当前访问节点
for(int p = heads[src];p != -1;p = np[p]){
int v = element[p];
if(!visited[v]){
int tmp = dfs(v,num);
leftSum += tmp;
subMax = Math.max(subMax,tmp);
}
}
// System.out.printf("%d %d\n",src,leftSum);
int visitedNum = num-leftSum;
res = Math.min(res,Math.max(visitedNum,subMax)); // 更新最大的联通分量
return leftSum;
}
}
class Main{
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
Graph g = new Graph(200005); // 节点数目+边的数据 < 图的存储地址数目
for(int i = 0;i < n-1;++i){
int a = sc.nextInt();
int b = sc.nextInt();
g.add(a,b); g.add(b,a);
}
g.dfs(1,n);
System.out.println(g.res);
}
}
import java.util.*;
class Main{
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt(),m = sc.nextInt();
List<Integer>[] g = new ArrayList[n+1];
for(int i = 1;i <= n;++i) g[i] = new ArrayList<Integer>();
while(m-- > 0){
int a = sc.nextInt(),b = sc.nextInt();
if(a == b) continue;
g[a].add(b); g[b].add(a);
}
Queue<Integer> q = new ArrayDeque<>();
Map<Integer,Integer> map = new HashMap<>();
q.offer(1); map.put(1,0);
int res = -1;
while(!q.isEmpty()){
int u = q.poll(),step = map.get(u);
if(u == n){
res = step; break;
}
for(Integer v:g[u]){
if(map.containsKey(v)) continue;
map.put(v,step+1);
q.offer(v);
}
}
System.out.println(res);
}
}
4 拓扑排序
概念辨析
拓扑序列的定义:有向图中所有点构成的序列A满足对于图中每条有向边(x->y),x都出现在y的前面(起点在终点前面)。
- 有向无环图必定存在拓扑序列(必定存在入度为0的点)
可以使用拓扑排序判断有向图中是否存在环
- 拓扑排序并不唯一
证明:一个有向无环图必定存在一个入度为0的点
抽屉原理:“如果每个抽屉代表一个集合,每一个苹果就可以代表一个元素,假如有n+1个元素放到n个集合中去,其中必定有一个集合里至少有两个元素。” 抽屉原理有时也被称为鸽巢原理。它是组合数学中一个重要的原理
第二抽屉原理:把(mn-1)个物体放入n个抽屉中,其中必有一个抽屉中至多有(m—1)个物体
最差原则:即考虑所有可能情况中,最不利于某件事情发生的情况。
反证法思路: 假设所有节点的入度为不为0,那么对于每个节点都可以找到一个指向他的节点,那么可以找到n+1节点,根据抽屉原理则形成了环
排序流程:
queue <- 所有度为0的节点
while(q非空){
t <-出队
枚举t的所有出边,删除所有的 t ->j
if(d[j] == 0)
j入队
}
拓展:输出字典序最小的拓扑排序
方法:使用优先队列替代队列进行入度为0的节点入队,让编号最小的点在堆顶。
import java.util.*;
class Graph{
int[] e; int[] np; int[] h; int[] d; // 节点的入度
int idx = 0;
Graph(int N){
e = new int[N]; np = new int[N]; h = new int[N]; d = new int[N];
Arrays.fill(h,-1);
}
void add(int a,int b){
e[idx] = b; np[idx] = h[a]; h[a] = idx++; d[b]++;
}
}
public class Main {
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
int m = sc.nextInt();
Graph g = new Graph(100010);
for(int i = 0;i < m;++i){
int a = sc.nextInt();
int b = sc.nextInt();
g.add(a,b);
}
int [] res = new int[n];
int cnt = 0;
//step1:初始化让度为0的节点入队(编号从1开始)
Deque<Integer> q = new ArrayDeque<>();
for(int i = 1;i <= n;++i){
if(g.d[i] == 0){
q.offer(i);
}
}
//step2:拓扑排序
while(!q.isEmpty()){
int t = q.poll();
res[cnt++] = t;
for(int next = g.h[t];next != -1;next = g.np[next]){
int idx = g.e[next];
g.d[idx]--;
if(g.d[idx] == 0){
q.offer(idx);
}
}
}
//step3:打印结果(拓扑排序必须包含所有节点,如果不包含所有节点则说明有向图中有环,输出-1)
if(cnt < n)
System.out.println(-1);
else{
for(int i = 0;i < n;++i){
System.out.print(res[i]+" ");
}
}
}
}
5 最短路算法
概念辨析
最短路问题的分类
问题分类 | 方法1 | 方法2 |
---|---|---|
单源最短路+所有边权为正 | 朴素dijkstra算法(稠密图,邻接矩阵,O(n^2)) | 堆优化dijkstra算法(稀疏图,邻接表,O(mlogn)) |
单源最短路+存在负边 | Bellman-Ford(O(nm)) | SPFA(一般为O(m),最坏为O(nm)) |
多源汇的最短路 | Floyd(O(n^3)) |
各种最短路算法的特点
朴素dijkstra算法(贪心): 时间复杂度是O(n^2),n是节点数,即该时间复杂度与边的数量无关,适合于节点数较少的稠密图。
堆优化dijkstra算法:时间复杂度是O(mlogn),m是边数,n是节点数,适合于节点数较多的稀疏图。
Bellman-Ford算法(离散数学):时间复杂度是O(nm)
SPFA算法:Bellman-Ford算法的改进,一般为O(m),最坏为O(nm),对边数进行限制需要此算法
Floyd算法(动态规划):时间复杂度O(n^3)
==========================================================================
各个算法复杂度对比
边数m与节点数n的关系:n-1 <= m <= n*(n-1)/2
稀疏图:mlogn < n^2 < n*m
稠密图: n^2 < mlogn < n*m
因此Bellman-Ford算法只适合于不超过k的最短路径长度这种场景。
朴素Dijkstra(时间复杂度O(n^2))
贪心思想:每次找距离源点最近的点
step1: 初始化源点距离,dis[源点] = 0,其余为不可达状态,以及已经探索的点的集合S
step2: 遍历所有点
1)找到不在S中距离源点最近的t (操作次数n^2,朴素算法的瓶颈)
2)用节点t更新所有点的距离源点的距离。 (操作次数m)
堆优化的dijkstra
边数m与节点n的关系: n-1 <= m <= n*(n-1)/2
step1: 初始化源点距离,dis[源点] = 0,其余为不可达状态,以及已经探索的点的集合S
step2: 遍历所有边(堆中不为空)
1)找到不在S中距离源点最近的t (小根堆优化,堆的节点(dis[id],id),操作次数优化为n)
2)用节点t更新所有点的距离源点的距离,将新的最短距离添加到堆中。 (操作次数: mlogn 即 边的总数*堆的更新代价)
注意:
关于时间复杂度在实现上的讨论:
-手写堆能够实现常数时间删除对应元素,确保在更新节点的最短距离时堆中元素不会超过n个,时间复杂度mlogn
-采用库函数的优先队列无法在常数时间删除元素,因此如果对某个节点的最短距离进行更新的话,只能将新的最短距离插入到堆中,时间复杂度为mlongm
m < n^2 即 log m < 2logn
Bellman-Ford算法
注意:对于有负边权的图,如果图中有负权值的回路,那么任意两点之间可能就不存在最短路
- 可以绕行负权回路无穷次,从而让总的权重趋向于负无穷
基本流程
外循环更新n次(最短路的边 <= 迭代次数)
遍历所有边(a,b,w)
dis[b] = min(dis[b],back[a]+w) (松弛操作)(i表示迭代的次数,注意每次的更新只能使用上一次更新的结果)
时间复杂度: 节点数 * 边的数目即 O(nm)
上面的算法流程能够保证:
三角不等式:对于所有的边(a,b,w),dis[b] <= dis[a]+w
注意点:更新路劲的时候back[a]+w是上次迭代更新的值,不能用这次迭代的值,不然会发生连锁更新!!!!!!!!!!!!
spfa算法
该算法是对Bellman_Ford算法的改进,其直接思想动机是只有更新过节点采用可能让其他节点的最短路更短,因此通过队列(通过map确保队列不重复)更新过的节点。
Floyd算法
该算法基于动态规划的思想
dp[k][i][j]:表示从节点i直通过节点编号范围为[0,k]的中间点到达节点j的最短距离
dp[k][i][j] = dp[k-1][i][k]+dp[k-1][j][k] // 状态转移方程
可以去掉最高维: dp[i][j] = dp[i][k]+dp[j][k]
Floy算法三种循环: d[i][j] = min(d[i][j], d[i][k] + d[k][j]);
练习题
import java.util.*;
class Main{
/*
朴素dijkstra模板,适用于稠密图(邻接矩阵)的最短路径
节点编号从0开始,求编号src到编号dest节点的最短路
要求:1)有向图是稠密图(点少边多),边的权重为正
2) 传入的图要预处理,避免重边和自环
基本思想:贪心思想,每次寻找未访问且距离src最近的点t,用t更新其他节点的最短路
时间复杂度:n*n(节点的平方,与边无关)
注意边界:图的节点没有边用0表示,两个节点距离不可达用极大值表示!!!
*/
static int dijkstra(int[][] g,int src,int dest){
int n = g.length;
int[] dis = new int[n];
Arrays.fill(dis,1 << 30); // 初始化为不可达(注意这里用极大值可以简化代码)
dis[src] = 0; // 从源点搜索最近的点
boolean[] vis = new boolean[n]; // 访问过的节点,初始化都没有访问
for(int i = 0;i < n;++i){ // 外循环n次(节点数目),每次访问一个节点
// step1:找到距离src最近的点
int t = -1;
for(int j = 0;j < n;++j){
if(!vis[j] && (t == -1 || dis[j] < dis[t]))
t = j;
}
if(t == -1)
break;
vis[t] = true;
// step2:用最近的点,更新所有节点最短路
for(int j = 0;j < n;++j){
if(g[t][j] != 0 && dis[t] != (1 << 30)){
dis[j] = Math.min(dis[j],dis[t]+g[t][j]);
}
}
}
return dis[dest];
}
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
int m = sc.nextInt();
int[][] g = new int[n][n];
for(int i = 0;i < m;++i){
int a = sc.nextInt()-1;
int b = sc.nextInt()-1;
int w = sc.nextInt();
if(a != b && g[a][b] == 0){
g[a][b] = w;
}else if(a != b){ // 有重边
g[a][b] = Math.min(g[a][b],w);
}
}
int dis = dijkstra(g,0,n-1);
if(dis == (1 << 30))
dis = -1;
System.out.println(dis);
}
}
import java.util.*;
class Graph{
/*图的邻接表存储*/
int[] ele;
int[] np;
int[] wgh;
int[] h;
int n,m;
int idx = 0;
/*节点数,边数*/
Graph(int n,int m){
this.n = n;
this.m = m;
int N = m*2+10;
ele = new int[N];
np = new int[N];
wgh = new int[N];
h = new int[n];
Arrays.fill(h,-1);
}
void add(int a,int b,int w){
ele[idx] = b;
wgh[idx] = w;
np[idx] = h[a];
h[a] = idx;
++idx;
}
}
class Main{
/*
堆优化dijkstra模板,适用于稀疏图(邻接表)的最短路径(无负权值)
*/
static int inf = 0x3f3f3f3f;
static int dijkstra(Graph g,int src,int dest){
int n = g.n;
boolean[] vis = new boolean[n];
int[] dis = new int[n];
Arrays.fill(dis,inf);
dis[src] = 0;
Queue<int[]> q = new PriorityQueue<>((o1,o2)->{ // 节点(最短距离,节点编号),小根堆
return Integer.compare(o1[0],o2[0]);
});
q.add(new int[]{0,src}); // 初始化源点距离为0
while(!q.isEmpty()){ // 外循环边的数目
// step1:找到距离src最近的点
int t = q.poll()[1];
if(vis[t]) continue;
vis[t] = true;
// step2:更新与节点t相邻的所有节点最短路
for(int next = g.h[t];next != -1;next = g.np[next]){
int id = g.ele[next],w = g.wgh[next];
if(dis[id] > dis[t]+w){
dis[id] = dis[t]+w;
q.offer(new int[]{dis[id],id});
}
}
}
return dis[dest];
}
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
int m = sc.nextInt();
Graph g = new Graph(n,m);
for(int i = 0;i < m;++i){
int a = sc.nextInt()-1;
int b = sc.nextInt()-1;
int w = sc.nextInt();
if(a != b){
g.add(a,b,w);
}
}
int dis = dijkstra(g,0,n-1);
if(dis == inf)
dis = -1;
System.out.println(dis);
}
}
import java.util.*;
class Main{
/*
bellman_ford模板
返回编号为src的节点到编号des的节点最多经过 k 条边的最短距离(可以有负边)
编号从0开始
*/
static int inf = 0x3f3f3f3f;
static int bellman_ford(int[][] edge,int n,int src,int des,int k){
int[] dis = new int[n]; // 不要写成dp[n][m]的形式因为没法在循环中将上一次迭代的所有结果放到下一层,仍然需要拷贝
int[] back = new int[n]; // 存储上次更新的结果
Arrays.fill(dis,inf);
dis[src] = 0; // 初始化没有迭代的最短距离
for(int i = 0;i < k;++i){ // 迭代k次确保最多k次更新即最多经过k条边
for(int j = 0;j < n;++j){
back[j] = dis[j];
}
for(int[] v:edge){
int a = v[0],b = v[1],w = v[2];
dis[b] = Math.min(dis[b],back[a]+w);
// 不要写成dis[b] = Math.min(bakc[b],back[a]+w); 同一个节点在一次循环中可以更新多次
}
}
int res = dis[des];
/*存在无穷大加负权值更新的状态,因此最后要再次判断是否存在不超过k的最短路 */
return res >= 10000*k ? inf : res;
}
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
int m = sc.nextInt();
int k = sc.nextInt();
int[][] edge = new int[m][3];
for(int i = 0;i < m;++i){
int a = sc.nextInt()-1; // 节点从0开始编号
int b = sc.nextInt()-1; // 节点从0开始编号
int w = sc.nextInt();
if(a != b){ // 去除自环,重边可以在算法中去除影响影响
edge[i][0] = a;
edge[i][1] = b;
edge[i][2] = w;
}
}
int res = bellman_ford(edge,n,0,n-1,k);
if(res == inf)
System.out.println("impossible");
else
System.out.println(res);
}
}
时间复杂度:O(n*m)
内部复制数组虽然也是O(n),但一般边的数目m要大于n所以是n*m,因此在时间复杂度上比朴素dijkstra要差,特别是在稠密图上。
import java.util.*;
class Graph{
/*图的邻接表存储模板,节点编号从0开始*/
int[] ele;
int[] np;
int[] wgh;
int[] h;
int n,m;
int idx = 0;
/*节点数,边数*/
Graph(int n,int m){
this.n = n;
this.m = m;
int N = m*2+10;
ele = new int[N];
np = new int[N];
wgh = new int[N];
h = new int[n];
Arrays.fill(h,-1);
}
void add(int a,int b,int w){
ele[idx] = b;
wgh[idx] = w;
np[idx] = h[a];
h[a] = idx;
++idx;
}
}
class Main{
static int inf = 0x3f3f3f3f;
/*spfa算法通过维护更新过的节点集合来减少搜索的节点数目,基于更新过的系欸但采用可能让其邻居节点的最短路更短*/
static int spfa(Graph g,int src,int des){
int n = g.n;
boolean[] vis = new boolean[n]; // 用于判断节点是否在队列中,避免重复
int[] dis = new int[n];
Arrays.fill(dis,inf);
dis[src] = 0;
Queue<Integer> q = new ArrayDeque<>();
q.add(src); // 队列中存储可能引起最短距离变小的节点
vis[0] = true;
while(!q.isEmpty()){
int a = q.poll(); vis[a] = false;
for(int next = g.h[a];next != -1;next = g.np[next]){
int b = g.ele[next];
int w = g.wgh[next];
if(dis[b] > dis[a]+w){
dis[b] = dis[a]+w;
if(!vis[b]){ // 节点b变小了,可能引发邻居节点变的更小,因此加入队列
q.offer(b); vis[b] = true;
}
}
}
}
return dis[des];
}
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
int m = sc.nextInt();
Graph g = new Graph(n,m);
for(int i = 0;i < m;++i){
int a = sc.nextInt()-1;
int b = sc.nextInt()-1;
int w = sc.nextInt();
if(a != b){
g.add(a,b,w);
}
}
int res = spfa(g,0,n-1);
if(res == inf)
System.out.println("impossible");
else
System.out.println(res);
}
}
基本思想:抽屉原理即在搜索最短路的过程中,维护一个cnt数组,用于统计每个节点更新的次数,如果某个节点更新的次数大于节点的总数n,那么说明存在负环。
1)由于负环可能在任何一个节点的的最短路上,因此可以对每个节点进行spfa搜索,然后有抽屉原理判断
import java.util.*;
class Graph{
/*图的邻接表存储模板,节点编号从0开始*/
int[] ele;
int[] np;
int[] wgh;
int[] h;
int n,m;
int idx = 0;
/*节点数,边数*/
Graph(int n,int m){
this.n = n;
this.m = m;
int N = m*2+10;
ele = new int[N];
np = new int[N];
wgh = new int[N];
h = new int[n];
Arrays.fill(h,-1);
}
void add(int a,int b,int w){
ele[idx] = b;
wgh[idx] = w;
np[idx] = h[a];
h[a] = idx;
++idx;
}
}
class Main{
static int inf = 0x3f3f3f3f;
/*
检测图中是否有负环的模板
1)去除dis初始化,将所有节点加入队列
2)维护cnt,当大于等于n说明有n+1的点即路径中有重复点,说明有负环
*/
static boolean checkNegativeLoop(Graph g){
int n = g.n;
boolean[] vis = new boolean[n]; // 用于判断节点是否在队列中,避免重复
int[] dis = new int[n];
int[] cnt = new int[n];
Queue<Integer> q = new ArrayDeque<>();
for(int i = 0;i < n;++i){ // 将所有节点给入队!!!!!!!(区别于spfa求最短路算法)
q.add(i); vis[i] = true;
}
while(!q.isEmpty()){
int a = q.poll(); vis[a] = false;
for(int next = g.h[a];next != -1;next = g.np[next]){
int b = g.ele[next];
int w = g.wgh[next];
if(dis[b] > dis[a]+w){
dis[b] = dis[a]+w;
cnt[b] = cnt[a]+1;
if(cnt[b] >= n) // 存在负环
return true;
if(!vis[b]){
q.offer(b); vis[b] = true;
}
}
}
}
return false; // 不存在负环
}
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
int m = sc.nextInt();
Graph g = new Graph(n,m);
for(int i = 0;i < m;++i){
int a = sc.nextInt()-1;
int b = sc.nextInt()-1;
int w = sc.nextInt();
if(a != b){
g.add(a,b,w);
}
}
boolean res = checkNegativeLoop(g);
if(res)
System.out.println("Yes");
else
System.out.println("No");
}
}
import java.util.*;
class Main{
/*
Floyld算法模板(可处理负权)
dp[k][i][j]:表示从节点i直通过节点编号范围为[0,k]的中间点到达节点j的最短距离
dp[k][i][j] = dp[k-1][i][k]+dp[k-1][j][k] // 状态转移方程
dp[i][j] = min(dp[i][j],dp[i][k]+dp[k][j])
图的初始化要特别注意
*/
static int inf = 0x3f3f3f3f;
static void floyld(int[][] g){
int n = g.length;
int[] dis = new int[n];
for(int k = 0;k < n;++k){
for(int a = 0;a < n;++a){
for(int b = 0;b < n;++b){
if(g[a][k] != inf && g[k][b] != inf){
g[a][b] = Math.min(g[a][b],g[a][k]+g[k][b]);
}
}
}
}
}
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
int m = sc.nextInt();
int k = sc.nextInt();
int[][] g = new int[n][n];
for(int i = 0;i < n;++i){
Arrays.fill(g[i],inf);
g[i][i] = 0;
}
for(int i = 0;i < m;++i){
int a = sc.nextInt()-1;
int b = sc.nextInt()-1;
int w = sc.nextInt();
g[a][b] = Math.min(g[a][b],w);
}
floyld(g);
for(int i = 0;i < k;++i){
int a = sc.nextInt()-1;
int b = sc.nextInt()-1;
if(a == b){
System.out.println(0);
continue;
}
if(g[a][b] == inf)
System.out.println("impossible");
else
System.out.println(g[a][b]);
}
}
}
6 最小生成树算法
概念辨析
定义:一个有 n 个结点的连通图的生成树是原图的极小连通子图,且包含原图中的所有 n 个结点,并且有保持图连通的最少的边。 [1] 最小生成树可以用kruskal(克鲁斯卡尔)算法或prim(普里姆)算法求出。
1)Prim算法
- 朴素Prim算法(适合稠密图)
时间复杂度:O(n^2)
- 堆优化Prim算法(适合稀疏图)
时间复杂度:O(mlogn),一般用不到,可用kruskal替代
2)克鲁斯卡尔算法(Kruskal)(适合稀疏图)
时间复杂度:O(mlogm),
Prim算法流程
1)初始化所有的距离dis <- 正无穷, 集合S
for(int i = 0;i < n;++i) // 遍历节点的集合(迭代n次,每次加入一条边)
t <- 集合外距离最近的点
用t更新其他所有点到“集合”的距离 !!!!!!!!!!!!!!!(dijkstra是用其他节点更新距离“起点”的距离)
将t加入集合
到“集合”的距离定义:该点距离集合中的任意点所有边的最短边(更新方程不同)
本质区别
1)dijkstra每次选择距离集合最近的节点
2)Prim算法每次选择距离集合最近的边!!!!!!!!!!!
Kruskal算法流程
1)将所有边按照权重从小到大排序(算法瓶颈)
2)从小到大枚举每条边(a,b),如果a,b不连通,那么将该节点加入到集合中(并查集)
动机:确保边的加入不会使得图中出现回路,通过不连通保证
策略:贪心思想,通过并查集避免产生回路
练习题
import java.util.*;
class Main{
/*
Prim算法模板:
返回值: 生成的最小权重之和 或者 0x3f3f3f3f(表示图中不存在最小生成树)
基本思想:每次选取距离“集合”最近的节点,并用该节点更新其他节点
注意:
1)初始化g的没有边的节点之间的距离为0x3f3f3f3f
2) 注意图中要保证没有重边与自环,边值可以是负数
*/
static int prim(int[][] g,int n,int m){
int[] dis = new int[n]; // dis[i]表示节点i距离“集合”的距离(与集合中所有节点的最短距离)
boolean[] vis = new boolean[n]; // vis[i] = true 表示节点i在集合中
Arrays.fill(dis,0x3f3f3f3f);
int res = 0;
for(int i = 0;i < n;++i){ // 选择n个节点,因此需要迭代n次(第一次实际上没有选择边,后面选了n-1条边)
// step1:找到距离集合最近并且不在集合中点t
int t = -1;
for(int j = 0;j < n;++j){
if(!vis[j] && (t == -1 || dis[j] < dis[t])){
t = j;
}
}
// step2:将t加入集合并累加权重之和
if(i > 0 && dis[t] == 0x3f3f3f3f) return 0x3f3f3f3f; // 除了第一个节点外的其他节点如果不存在与集合之间不存在路径,则返回
if(i > 0) res += dis[t]; // 累加距离
vis[t] = true; // 加入集合
// step2:利用t去更新节点距离"集合"的距离
for(int j = 0;j < n;++j){
dis[j] = Math.min(dis[j],g[j][t]);
}
}
return res;
}
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
int m = sc.nextInt();
int[][] g = new int[n][n];
for(int i = 0;i < n;++i){
Arrays.fill(g[i],0x3f3f3f3f);
}
for(int i = 0;i < m;++i){
int a = sc.nextInt();
int b = sc.nextInt();
int w = sc.nextInt();
// 去除重边与自环
if(a != b)
g[a-1][b-1] = g[b-1][a-1] = Math.min(g[a-1][b-1],w);
}
int res = prim(g,n,m);
if(res == 0x3f3f3f3f)
System.out.println("impossible");
else
System.out.println(res);
}
}
import java.util.*;
/*路径压缩+维护集合大小的并查集模板*/
class Dsu{
int[] p; // 节点根节点
int[] size; // 节点所在集合大小,只有根节点这个数值有意义
Dsu(int n){
p = new int[n]; size = new int[n];
for(int i = 0;i < n;++i) p[i] = i;
Arrays.fill(size,1);
}
int find(int a){
int tmp = a;
while(tmp != p[tmp]){
tmp = p[tmp];
}
int root = tmp;
/*路径压缩*/
while(a != p[a]){
int v = p[a]; p[a] = root; a = v;
}
return root;
}
void union(int a,int b){
int fa = find(a);
int fb = find(b);
if(fa != fb){
p[fb] = fa;
size[fa] = size[fa]+size[fb];
}
}
// 返回节点a所在集合大小,注意:size只在根节点处有意义,因此必须使用根节点的size值
int setSize(int a){
int fa = find(a);
return size[fa];
}
}
class Main{
/*
kruskal算法模板:
返回值: 生成的最小权重之和 或者 0x3f3f3f3f(表示图中不存在最小生成树)
基本思想:对边按照权重从小递增排序,顺序枚举,每次选取不连通的边(并查集判定)
*/
static int kruskal(int[][] g,int n,int m){
int res = 0;
int ans = 0;
Dsu dsu = new Dsu(n+1); // 节点编号从1开始
// step1:边升序排列
Arrays.sort(g,(o1,o2)->{
return Integer.compare(o1[2],o2[2]);
});
for(int i = 0;i < m;++i){
int a = g[i][0],b = g[i][1],w = g[i][2];
if(dsu.find(a) != dsu.find(b)){
dsu.union(a,b); ++ans; res += w;
}
}
return ans == n-1 ? res : 0x3f3f3f3f; // 注意:选取的边数不等于n-1,则说明不是连通图,最小生成树不存在
}
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
int m = sc.nextInt();
int[][] g = new int[m][3];
for(int i = 0;i < m;++i){
for(int j = 0;j < 3;++j){
g[i][j] = sc.nextInt();
}
}
int res = kruskal(g,n,m);
if(res == 0x3f3f3f3f)
System.out.println("impossible");
else
System.out.println(res);
}
}
7 二分图算法
概念辨析
1)染色法
时间复杂度:O(n+m)
2)匈牙利算法
时间复杂度:O(mn),时间运行时间一般小于理论时间复杂度。
二分图的定义:二分图又称作二部图,是图论中的一种特殊模型。 设G=(V,E)是一个无向图,如果顶点V可分割为两个互不相交的子集(A,B),并且图中的每条边(i,j)所关联的两个顶点i和j分别属于这两个不同的顶点集(i in A,j in B),则称图G为一个二分图。
- 当且仅当图中不含有奇数环(边的数量为奇数),那么这个图是二分图。
二分图最大匹配定义:所有匹配中包含边数最多的一组匹配被称为二分图的最大匹配,其边数即为最大匹配数。
染色法判断二分图的算法流程
for(int i = 1;i <= n;++i){
if(v未染色){
dfs(i,染色值)
}
}
/**
1,2表示代表两种颜色,当染色的过程中发现边的两端出现相同的颜色则返回false
**/
关键:boolean dfs(Graph g,int idx,int color,int[] color){
}
匈牙利算法计算最大匹配的流程:
目标:将节点分为男生,女生两类,最终目标希望男女两两配对。
核心思想:尽力匹配思想
find算法流程:
对于每个每个匹配的男生i,遍历其想要配对的女生:
1)女生j没有配对,则设置match[j] = i 配对成功
2)女生j已经配对,询问其对象match[j],请求他与其他喜欢的女生配对,如果match[j]能够与其他女生配对成功,则男生i同样能够配对成功(牛头人思维)
注意:2)需要递归调用find函数,形成牛头人配对。
import java.util.*;
class Graph{
int[] e,np,h;
int idx = 0;
Graph(int N,int M){
e = new int[M*2]; np = new int[M*2]; h = new int[N+10];
Arrays.fill(h,-1);
}
void add(int a,int b){
e[idx] = b; np[idx] = h[a]; h[a] = idx++;
}
}
class Main{
/*
染色法判断二分图模板
color数组作用:
0:没有颜色(默认值)
1:颜色1
2:颜色2
确保相邻节点的所染的颜色不同
*/
static boolean dfs(Graph g,int[] color,int idx,int v){
color[idx] = v;
for(int next = g.h[idx];next != -1;next = g.np[next]){
int t = g.e[next];
if(color[t] == 0){
if(!dfs(g,color,t,3-v)) // 未染色,染上另外一种颜色
return false;
}else if(color[t] == v){ //已经染色,但是颜色与邻居相同,不是二分图
return false;
}
}
return true;
}
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
int m = sc.nextInt();
Graph g = new Graph(n,m);
for(int i = 0;i < m;++i){
int a = sc.nextInt(),b = sc.nextInt();
g.add(a,b); g.add(b,a);
}
int[] color = new int[n+1];
boolean res = true;
for(int i = 1;i <= n;++i){
if(color[i] == 0 && !dfs(g,color,i,1)){
res = false;
}
}
if(res){
System.out.println("Yes");
}else{
System.out.println("No");
}
}
}
import java.util.*;
class Graph{
int[] e,np,h;
int idx = 0;
Graph(int N,int M){
e = new int[M*2]; np = new int[M*2]; h = new int[N+10];
Arrays.fill(h,-1);
}
void add(int a,int b){
e[idx] = b; np[idx] = h[a]; h[a] = idx++;
}
}
class Main{
/*
匈牙利(牛头人)配对算法:
对于每个未配对boy,扫描与其存在边的girl:
1)girl未配对,则match[girl] = boy
2)已经配对,则逼迫其配对的boy即match[girl]去配对其他人,成功话,则match[girl] = boy
注意:
1.已经配对的情况是递归调用,形成牛头人联动
2.g必须是有向图,避免遍历时重复计算
schedule的含义需要进一步理解
*/
static boolean find(Graph g,int boy,int[] match,boolean[] schedule){
for(int next = g.h[boy];next != -1;next = g.np[next]){
int girl = g.e[next];
if(!schedule[girl]){ // 还没被其他牛头人提前锁定
schedule[girl] = true;
if(match[girl] == -1 || find(g,match[girl],match,schedule)){ // 未匹配,牛头人匹配
match[girl] = boy;
return true;
}
}
}
return false;
}
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
n = Math.max(n,sc.nextInt());
int m = sc.nextInt();
Graph g = new Graph(n,m);
for(int i = 0;i < m;++i){
int a = sc.nextInt(),b = sc.nextInt();
g.add(a-1,b-1); // 注意:建立有向图即可(编号从0开始)
}
int res = 0;
int[] match = new int[n];
boolean[] schedule = new boolean[n];
Arrays.fill(match,-1);
for(int i = 0;i < n;++i){
Arrays.fill(schedule,false);
if(find(g,i,match,schedule)){
++res;
}
}
System.out.println(res);
}
}
四 数学知识
0 基础知识
0-1 质数
质数
定义:质数是指在大于1的自然数中,除了1和它本身以外不再有其他因数的自然数。
注意:质数和合数是针对所有大于 1 的「自然数」来定义的(所有小于等于1的数都不是质数/合数)。
质数的判定
方法 | 时间复杂度 | 方法 |
---|---|---|
暴力 | O(n) | 从[2,n)枚举%的数,判断是否有其他因数 |
试除法 | O(sqrt(n)) | 利用性质,只需要枚举[2,sqrt(n)] |
质数判定的试除法动机: 一个合数的约数总是成对出现的, n%i = d 和 n%d = i, 只需要枚举较小范围即可
质因数分解
暴力法 | 时间复杂度 | 方法 |
---|---|---|
暴力 | O(n) | 从[2,n)枚举,进行除法 |
试除法 | O(sqrt(n)) | 从[2,sqrt(n)]进行枚举 |
质因数分解的试除法动机:分解的质因数乘积中最多只有一个大于sqrt(n)的质数
质数的个数的判定
求1-n范围内质数的个数?
筛法的基本思想:提前将不是质数的的数字进行标记
埃氏筛法思路:将每个质数的倍数标记为不是质数,时间复杂度: nlog(logn)
方法 | 基本思路 | 时间复杂度 |
---|---|---|
埃氏筛选 | 将质数的倍数给筛除 | O(nlog(logn)) |
线性筛法 | 使用每个数字的最小质数进行筛选 | O(n) |
0-2 约数
求解约数
方法 | 基本思路 | 时间复杂度 |
---|---|---|
试除法 | 从小到大枚举[1,sqrt(n)]的数字判断 | O(sqrt(n)) |
动机:约数都是成对出现的,因此不需要全部枚举
约数个数:根据算数基本定理,任何一个整数都可以分解为质因数的乘积,则约数个数等于
- a是质因子的个数
int范围内约数个数最多的大约是1500多个
约数之和公式:
辗转相除法
(a,b) = (b,a%b)
a % b = a - 下取整[a/b]*b = a - k*b
证明: (a,b) 和 (b,a%b) 有相同的最大公约数
求两个正整数 a 和 b 的 最大公约数 d
则有 gcd(a,b) = gcd(b,a%b)
证明:
设a%b = a - k*b 其中k = a/b(向下取整)
若d是(a,b)的公约数 则知 d|a 且 d|b 则易知 d|a-k*b 故d也是(b,a%b) 的公约数
若d是(b,a%b)的公约数 则知 d|b 且 d|a-k*b 则 d|a-k*b+k*b = d|a 故而d|b 故而 d也是(a,b)的公约数
因此(a,b)的公约数集合和(b,a%b)的公约数集合相同 所以他们的最大公约数也相同
0-3 欧拉函数
定义: 在数论,对正整数n,欧拉函数是小于n的正整数中与n互质的数的数目,也就是 中与 互素的数的个数。(这里不需要考虑 0 和 n ,因为当 n > 1 时,这两个数一定与 n 不互素。)
互质(互素):公约数只有1的两个整数,叫做互质整数
计算方式:
公式的直观理解:
[1,n]当中取出所有质因子的倍数剩下的数目等于
--------------------------------------------------------------------------------------
step1:减去所有质数的倍数的个数
n - n/p1 - n/p2 - ...n/pk 即 总数n - p1的倍数个数 - p2的倍数个数 - ... - pk的倍数个数
step2:加上两个质数乘积的所有倍数的个数
step3:减去3个质数乘积的所有倍数的个数
step4 减去4个质数乘积的所有倍数的个数
...
上述公式展开就是欧拉函数求解公式
0-4 乘法逆元的概念
逆元的动机:将除法的取模运算转换为乘法的取模运算 !!!!!
如果 a/b 和 x * b 对于m同余,那么x称为b对于m的逆元
记作: a/b 和 a * b^(-1)对于m同余
逆元的求解方法可以转化为问题即
找到x使得 b*x 与 1 对于p同余
费马小定理:如果p是质数,那么 b^(p-1) 与 1 对于p 同余
因此: b * b ^ (p-2) 与 1 对于1 同余
因此: 逆元 x = b ^ (p-2)
--------------------------------------------------
实例: 3*5%7 = 1 ,定义可知5是3对于7的逆元
正向操作:乘以3模7 : 20*3%7 = 4
逆元操作:乘以逆元5模7: 4*5%7 = 20
通过例子可以看到通过逆元可以恢复之前操作的数
乘法逆元的动机:将除法的取模运算转换为乘法的取模运算 !!!!!
即 ( a/b ) % c == ( a*b1 ) % c
实例:(60/3)%7 = 6
60*5%7 = 6
上面例子中将(60/3)%7的取模运算通过逆元的方式转化为乘法更加简洁。
0-5 扩展欧几里德
翡蜀定理: 对任意“正整数”a,b,一定存在非零整数x,y,使得 ax + by = gcd(a,b)
目标:求解方程 ax+by=gcd(a,b)的解
(a,b) = (b,a%b)
d = ax+by = bx+(a%b)y = bx + (a-a/b*b)y = ax + b(x-a/b*y)
由公式得在递归的过程中:
1) x变为y
2) y变为x-a/b*y
特别地,当b=0时 ax+by=a,因此x=1,y=0
0-6 中国剩余定理
1 质数
import java.util.*;
class Main{
/*试除法判断质数*/
static boolean is_prime(int x){
if(x < 2) return false; // 质数是 > 1的自然数
for(int i = 2;i <= x/i;++i){ // 试除法枚举[2,sqrt(x)]的数,时间复杂度sqrt(x)
if(x % i == 0) return false;
}
return true;
}
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
while(n-- > 0){
int tmp = sc.nextInt();
if(is_prime(tmp)) System.out.println("Yes");
else System.out.println("No");
}
}
}
import java.util.*;
class Main{
/*
试除法将n分解为质数的乘积
Time: log2(n) ~ sqrt(n) 即 O(sqrt(n))
问题:为什么这里的i一定会是质数?
算法的流程导致一定是质数,在循环的过程中假如 i 是一个合数,那么它一定可以分解成多个质因子相乘的形式,
这多个质因子同时也是n的质因子且比i要小,而比 i小的数在之前的循环过程中一定是被条件除完了的,
所以 i 不可能是合数,只可能是质数。
*/
public static void divide(int n){
for(int i = 2;i <= n/i;++i){
if(n % i == 0){ // 问题:为什么这里的i一定会是质数?
int val = i,cnt = 0; // 质数,质数的个数
while(n % val == 0){
++cnt; n /= val;
}
System.out.printf("%d %d\n",val,cnt);
}
}
if(n > 1) System.out.printf("%d %d\n",n,1); // 没有除尽,最多只有1个大于sqrt(n)的质数
System.out.println();
}
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
while(n-- > 0){
int tmp = sc.nextInt();
divide(tmp);
}
}
}
import java.util.*;
class Main{
/*
埃氏筛选法获取质数表的基本思路: 将质数的倍数被筛除
时间复杂度: O(n*log(logn))非常接近O(n)
*/
public static List<Integer> getPrime(int n){
boolean[] flag = new boolean[n+1];
List<Integer> res = new ArrayList<>(); // 筛选出的质数
for(int i = 2;i <= n;++i){
if(!flag[i]){ // 判断是否是质数
res.add(i);
for(int j = i+i;j <= n;j += i) flag[j] = true; // 将质数的倍数给筛除
}
}
return res;
}
/*
线性筛选法获取质数表的基本思路:使用每个数字的最小质因数进行筛除
时间复杂度: O(n),10^7复杂度下比埃氏快一倍
定理:每个合数都有最小质因数
线性筛法的基本思路就是通过最小质因数不重复的进行标记筛除
*/
public static List<Integer> getPrime1(int n){
boolean[] flag = new boolean[n+1];
List<Integer> res = new ArrayList<>(); // 筛选出的质数
for(int i = 2;i <= n;++i){
if(!flag[i]) res.add(i);
/*基本思想:使用每个数字的最小质因子进行筛选*/
for(int j = 0;res.get(j) <= n/i;++j){
flag[res.get(j) * i] = true; // 将非质数标记筛除
if(i % res.get(j) == 0) break; // 退出循环,避免重复标记
}
}
return res;
}
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
List<Integer> res = getPrime1(n);
System.out.println(res.size());
}
}
2 约数
import java.util.*;
class Main{
/*
试除法求n的所有约数
时间复杂度: sqrt(n)
*/
public static List<Integer> get_divisor(int n){
List<Integer> res = new ArrayList<>();
for(int i = 1;i <= n/i;++i){
if(n%i == 0){
res.add(i);
if(i != n/i) res.add(n/i);
}
}
return res;
}
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
while(n-- > 0){
int v = sc.nextInt();
List<Integer> res = get_divisor(v);
Collections.sort(res);
for(Integer t:res) System.out.print(t+" ");
System.out.println();
}
}
}
import java.util.*;
class Main{
/*
基本思路:对每一个数进行质因数分解,并使用map存储质数以及相应的个数,
然后根据公式(a1+1)*(a2+1)*...(an+1)计算,这里a代表质数的个数
注意:这里没有必要将乘积给计算出来,避免溢出
*/
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
Map<Integer,Integer> map = new HashMap<>();
while(n-- > 0){
int x = sc.nextInt();
for(int i = 2;i <= x/i;++i){
if(x%i == 0){
int ans = 0;
while(x%i == 0){
x /= i; ++ans;
}
ans += map.getOrDefault(i,0);
map.put(i,ans);
}
}
if(x > 1){ // 不是大于0,等于1代表除尽
int num = map.getOrDefault(x,0)+1;
map.put(x,num);
}
}
long res = 1;
long mod = (long)(1e9+7);
for(Map.Entry<Integer,Integer> entry:map.entrySet()){
res *= (entry.getValue()+1);
res = res % mod;
}
System.out.println(res);
}
}
下面第二个公式是约数和公式
import java.util.*;
class Main{
/*
基本思路:对每一个数进行质因数分解,并使用map存储质数以及相应的个数,然后根据约数和公式计算
注意:这里没有必要将乘积给计算出来,避免溢出
*/
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
Map<Integer,Integer> map = new HashMap<>();
while(n-- > 0){
int x = sc.nextInt();
for(int i = 2;i <= x/i;++i){
if(x%i == 0){
int ans = 0;
while(x%i == 0){
x /= i; ++ans;
}
ans += map.getOrDefault(i,0);
map.put(i,ans);
}
}
if(x > 1){
int num = map.getOrDefault(x,0)+1;
map.put(x,num);
}
}
long res = 1;
long mod = (long)(1e9+7);
// 参考约数之和定理
for(Map.Entry<Integer,Integer> entry:map.entrySet()){
int p = entry.getKey(),a = entry.getValue();
long tmp = 1;
while(a-- > 0){
tmp = tmp*p + 1;
tmp = tmp%mod;
}
res *= tmp;
res %= mod;
}
System.out.println(res);
}
}
import java.util.*;
class Main{
// Greatest Common Divisor(GCD)
public static int gcd(int a,int b){
return b != 0 ? gcd(b,a%b) : a;
}
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
while(n-- > 0){
int a = sc.nextInt(),b = sc.nextInt();
System.out.println(gcd(a,b));
}
}
}
3 欧拉函数
import java.util.*;
class Main{
/*
试除法分解质因数并按照定义计算
注意: 代码细节上运算顺序确保正确结果
时间复杂度: n*sqrt(n)
*/
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
while(n-- > 0){
int x = sc.nextInt();
int res = x;
for(int i = 2;i <= x/i;++i){
if(x%i == 0){
res = res/i * (i-1); //注意运算顺序,错误顺序 res = res * (i-1)/i
while(x%i == 0) x /= i;
}
}
if(x > 1) res = res / x * (x-1);
System.out.println(res);
}
}
}
给定一个正整数 n,求 1∼n 中每个数的欧拉函数之和。
即求[1,n]中所有数的欧拉函数
方法1:求每个数的质因数然后根据公式求欧拉函数,最后求和
--时间复杂度:O(n*sqrt(n))
方法2:线性筛法求质数的过程中,求每个数的欧拉函数
分三种情况讨论:
情况2:i mod prime[j] == 0:
情况3: i mod prime[j] != 0:
import java.util.*;
class Main{
/*
线性筛选法获取欧拉函数表的基本思路:
时间复杂度: O(n),10^7复杂度下比埃氏快一倍
*/
public static int[] get_eulers(int n){
boolean[] flag = new boolean[n+1];
int[] phi = new int[n+1];
phi[1] = 1;
List<Integer> res = new ArrayList<>(); // 筛选出的质数
for(int i = 2;i <= n;++i){
if(!flag[i]) {
res.add(i); phi[i] = i-1; // 情况1:质数的欧拉函数
}
for(int j = 0;res.get(j) <= n/i;++j){
int t = res.get(j);
flag[t * i] = true;
if(i % t == 0){ // 情况2
phi[i*t] = phi[i] * t;
break;
}else
phi[i*t] = phi[i] * (t-1); // 情况3
}
}
return phi;
}
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
int[] phi = get_eulers(n);
long ans = 0; // 注意溢出处理
for(int i = 1;i <= n;++i) ans += phi[i];
System.out.println(ans);
}
}
4 快速幂
import java.util.*;
class Main{
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
while(n-- > 0){
long a = sc.nextInt(),b = sc.nextInt(),p = sc.nextInt();
System.out.println(calc(a,b,p));
}
}
public static long calc(long a,long b,long p){
long res = 1;
long v = a;
while(b != 0){
if((b&1) == 1) res = res * v % p;
v = v * v % p;
b >>= 1;
}
return res;
}
}
import java.util.*;
class Main{
/*
快速幂的应用: 求整数a对于质数p的乘法逆元
1) a%p == 0,说明b不存在逆元
2) a%p 不为 0,即a与p互质,根据费马定理,存在逆元为 a^(p-2),这里求幂可以应用快速幂
乘法逆元的作用: 可以将除法表达的取模运算转换为乘法表达式的取模运算
*/
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
while(n-- > 0){
long a = sc.nextInt(),p = sc.nextInt();
if(a%p == 0) System.out.println("impossible"); // 不存在逆元
else System.out.println(calc(a,p-2,p)); // 存在逆元
}
}
public static long calc(long a,long b,long p){
long res = 1;
long v = a;
while(b != 0){
if((b&1) == 1) res = res * v % p;
v = v * v % p;
b >>= 1;
}
return res;
}
}
5 扩展欧奇里德算法
import java.util.*;
class Main{
/* Extension for Greatest Common Divisor(GCD)
自底向上求满足方程的x,y:
当b = 0:
x = 1,y = 0
当b不为0:
ax + by(栈底) = bx' + (a % b)y'(栈顶) = bx' + (a - a/b*b)y' = ay' + b(x' - a/b*y')
得到
x = y'
y = x'- a/b*y'
注意: x'和y'是栈顶的计算结果
*/
public static int exgcd(int a,int b,int[] arr){
if(b == 0){ // 情况1: 当b=0, a * 1 + 0 * y = a 满足方程
arr[0] = 1; arr[1] = 0;
return a;
}
int d = exgcd(b,a%b,arr); // 情况2: 当b != 0, 求 bx + (a%b)y = gcd(b,a%b)的x和y,根据公式进行推导
int x_ = arr[0],y_ = arr[1];
arr[0] = y_; arr[1] = x_ - a/b*y_;
return d;
}
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
int[] arr = new int[2];
while(n-- > 0){
int a = sc.nextInt(),b = sc.nextInt();
int d = exgcd(a,b,arr);
System.out.printf("%d %d\n",arr[0],arr[1]);
}
}
}
同余方式等价于: ax = my + b => ax - my = b
令y' = -y
得到 ax + my' = b
上述方程可以采用扩展gcd算法求解x和y'
同余方程无解的情况: b 无法整除 gcd(a,m)
import java.util.*;
class Main{
/* Extension for Greatest Common Divisor(exGCD)
exGCD的应用:求线性同余方程的等价方程ax + m(-y) = b中的x
求解 ax' + my' = gcd(a,m) = d
如果b能够整除d,则 x = x' * b/d 或者 x' * b/d % m
*/
public static int exgcd(int a,int b,int[] arr){
if(b == 0){ // 情况1: 当b=0, a * 1 + 0 * y = a 满足方程
arr[0] = 1; arr[1] = 0;
return a;
}
int d = exgcd(b,a%b,arr); // 情况2: 当b != 0, 求 bx + (a%b)y = gcd(b,a%b)的x和y,根据公式进行推导
int x_ = arr[0],y_ = arr[1];
arr[0] = y_; arr[1] = x_ - a/b*y_;
return d;
}
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
int[] arr = new int[2];
while(n-- > 0){
int a = sc.nextInt(),b = sc.nextInt(),m = sc.nextInt();
int d = exgcd(a,m,arr);
if(b%d != 0) System.out.println("impossible");
else{
long v = (long)arr[0] * (long)b/d % (long)m; // 考虑溢出用long,题目要求答案必须是int,因此需要%m
System.out.println(v);
}
}
}
}
6 中国剩余定理(孙子定理)
问题分析:显然题目不满足孙子定理的条件
解决思路:exgcd方法
x = k1*a1 + m1
x = k2*a2 + m2
k1*a1 - k2*a2 = m2 - m1 有解 => m2-m1 | gcd(a1,a2) 即 m2-m1能够整除最大公约数
在判定有解后可以使用扩展欧几里德算法求出k1和k2。
k1 = k1 + k * (a2/d)
a2 = k2 + k * (a1/d)
x的所有解可以表示为:
x = k1*a1 + m1 = (k1 + k*(a1/d))*a1 + m1, 其中d = gcd(a1,a2)
x = (a1k1 + m1) + k * 下取整[(a1*a2)/d] = x0 + k*a
因此给定的两个同余方程的求x可以 等价于 x = x0 + k*a 这个方程求x
import java.util.*;
class Main{
// exgcd模板: 求 ax + by = gcd(a,b) 的系数x和y
static long exgcd(long a,long b,long[] arr){
if(b == 0){
arr[0] = 1; arr[1] = 0;
return a;
}
long d = exgcd(b,a%b,arr);
long x_ = arr[0],y_ = arr[1];
arr[0] = y_; arr[1] = x_ - a/b * y_;
return d;
}
/*
基本思想:每次合并两个公式,最终变为一个公式,求出公式对应解
*/
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
long m1 = sc.nextLong(),a1 = sc.nextLong();
if(n == 1){
if(n == 1) System.out.println((a1 % m1 + m1) % m1);
return;
}
long x = 0;
long[] arr = new long[2]; // 存放系数
for(int i = 0;i < n-1;++i){
long m2 = sc.nextLong(),a2 = sc.nextLong();
long d = exgcd(m1,m2,arr);
if((a2-a1)%d != 0){
x = -1; // 无解
break;
}
/*
获取 xa1-ya2 = gcd(a1,a2) = d的系数x = arr[0],y = arr[1]
获取 k1a1-k2a2 = m2-m1的系数
*/
long k1 = arr[0] * (a2-a1)/d;
k1 = (k1%(m2/d) + (m2/d)) % (m2/d); // 求k1 % (m2/d)
x = k1*m1 + a1; // 合并两个方程的解
long m = Math.abs(m1/d*m2);
a1 = k1*m1+a1; m1 = m; // 合并后方程的表示
}
if(x != -1) x = (x % m1 + m1) % m1; // 求x%m1的值确保为正数
System.out.println(x);
}
}
7 高斯消元
8 组合数
从n个数中选择i个数的方案数目集合划分:
1) 包含一个特定的数,从剩下的n-1个数据选择i-1个数。
2) 不包含一个特定的数,从剩下的n-1个数中选择i个数。
显然上面对于集合的划分覆盖了所有可能的方案。
实现
import java.util.*;
class Main{
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
/*组合数预处理计算(0的情况要特殊考虑)*/
long[][] comb = new long[2001][2001];
long mod = (long)(1e9+7);
for(int i = 0;i <= 2000;++i){
for(int j = 0;j <= i;++j){
comb[i][j] = j == 0 ? 1 : comb[i-1][j] + comb[i-1][j-1];
comb[i][j] = comb[i][j]%mod;
}
}
while(n-- > 0){
int a = sc.nextInt(),b = sc.nextInt();
System.out.println(comb[a][b]);
}
}
}
9 容斥定理
定理作用:求n个集合的并集
给定一个整数 n 和 m 个不同的质数 p1,p2,…,pm。
请你求出 1∼n 中能被 p1,p2,…,pm 中的至少一个数整除的整数有多少个。
import java.util.*;
class Main{
/*
容斥定理的应用
m个集合:1-n中可以被相同质数整除的数的合集
单个集合大小:n / p(质数)
多个集合的大小:n / 质数乘积
*/
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
long n = sc.nextInt();
int m = sc.nextInt();
long[] prime = new long[m];
for(int i = 0;i < m;++i) prime[i] = sc.nextLong();
long res = 0;
for(int state = 1;state < (1 << m);++state){ // 用二进制标记哪些数字被选
boolean flag = false;
int num = 0;
long val = 1;
for(int i = 0;i < m;++i){
if((state >> i & 1) == 1){
++num; val *= prime[i];
}
if(val > n){ // 质数乘积超过n,集合大小为0
flag = true; break;
}
}
if(!flag){
if(num % 2 == 1) res += n / val; // 由容斥定理的公式可知集合个数和符号的关系
else res -= n / val;
}
}
System.out.println(res);
}
}
10 博弈论
公平组合游戏
1) 由两名玩家交替行动
2) 在游戏进行的任意时刻,可以执行的合法行动与轮到哪位玩家无关
3) 不能行动的玩家判负
NIM游戏
给定 n 堆石子,两位玩家轮流操作,每次操作可以从任意一堆石子中拿走任意数量的石子(可以拿完,但不能不拿),最后无法进行操作的人视为失败。
问如果两人都采用最优策略,先手是否必胜。
-------------------------------------------------------------------------------------
输入:n堆石子,每堆石子的数量
输出:判断先手是否必胜
--------------------------------------------------------------------------------------
必胜状态:先手进行某一个操作,留给后手是一个必败状态时,对于先手来说是一个必胜状态。即先手可以走到某一个必败状态。
必败状态:先手无论如何操作,留给后手都是一个必胜状态时,对于先手来说是一个必败状态。即先手走不到任何一个必败状态。
NIM游戏分析
假设两堆石子: 2,3
先手策略:
1)第一步,拿走石子,使得出现两个数量一致的石子堆 (2,2)
2)后续步骤,采用镜像策略,模仿对手,从另外一堆中取相同数量的石子。
-----------------------------------------------------------------------
结论:
1)n堆石子数量满足:a1^a2^a3...^an = 0 则是先手必败
2)n堆石子数量满足:a1^a2^a3...^an 不等于 0 则是先手必胜
-------------------------------------------------------------------------
假设n堆石子,石子数目分别是a1,a2,…,an,如果a1⊕a2⊕…⊕an≠0,先手必胜;否则先手必败。
--------------------------------------------------------------------
所有数成对,异或值为0,异或值为0,所有数不一定成对(1,2,3)
import java.util.*;
class Main{
/*
各堆石子数量异或后不为0,则先手必胜,先手可以在第一次操作后,
然后剩余的各堆石子数量异或值为0,然后采用镜像操作确保进行最后操作。
*/
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
int res = 0;
while(n-- > 0){
int v = sc.nextInt();
res ^= v;
}
String t = res != 0 ? "Yes" : "No";
System.out.println(t);
}
}
五 动态规划
背包问题
import java.util.*;
class Main{
public static void main(String[] args){
Scanner sc= new Scanner(System.in);
int N = sc.nextInt(),V = sc.nextInt();
int[] value = new int[N+1],w = new int[N+1];
for(int i = 1;i <= N;++i){
value[i] = sc.nextInt(); w[i] = sc.nextInt();
}
int[] dp = new int[V+1];
Arrays.fill(dp,0);
for(int i = 1;i <= N;++i){
for(int v = V; v >= value[i];--v){
dp[v] = Math.max(dp[v],dp[v-value[i]]+w[i]);
}
}
System.out.println(dp[V]);
}
}
import java.util.*;
class Main{
public static void main(String[] args){
Scanner sc= new Scanner(System.in);
int N = sc.nextInt(),V = sc.nextInt();
int[] value = new int[N+1],w = new int[N+1];
for(int i = 1;i <= N;++i){
value[i] = sc.nextInt(); w[i] = sc.nextInt();
}
int[] dp = new int[V+1];
Arrays.fill(dp,0);
for(int i = 1;i <= N;++i){
for(int v = value[i]; v<= V;++v){
dp[v] = Math.max(dp[v],dp[v-value[i]]+w[i]);
}
}
System.out.println(dp[V]);
}
}
import java.util.*;
class Main{
/*
基本思想: 多重背包转化为0-1背包问题(遍历所有物品)
因此物品总数等于: sum(物品的数量),由数据范围可知: 物品总数 <= 10000
时间复杂度: 物品总量*空间大小
*/
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int N = sc.nextInt(),V = sc.nextInt();
int[] v = new int[N],w = new int[N],s = new int[N];
for(int i = 0;i < N;++i){
v[i] = sc.nextInt(); w[i] = sc.nextInt(); s[i] = sc.nextInt();
}
int[] dp = new int[V+1]; dp[0] = 0;
for(int i = 0;i < N;++i){
for(int j = 0;j < s[i];++j){ // 前两个循环遍历所有物品
for(int k = V;k >= v[i];--k){ // 最后一个循环0-1背包状态转移
dp[k] = Math.max(dp[k],dp[k-v[i]] + w[i]);
}
}
}
System.out.println(dp[V]);
}
}
import java.util.*;
class Main{
/*
基本思想: 多重背包转化为0-1背包问题+物品数量二进制拆分
二进制拆分:使用最少的数字表示物品的数量,
如一种物品的体积,价值,数量为:3 4 5
则5个物品可以二进制拆分为三个不同的物品:
体积 价值
1*3 1*4
2*3 2*4
2*3 2*4
本质上就是将 5 拆分为 1 2 2
*/
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int N = sc.nextInt(),V = sc.nextInt();
int[] v = new int[N],w = new int[N],s = new int[N];
for(int i = 0;i < N;++i){
v[i] = sc.nextInt(); w[i] = sc.nextInt(); s[i] = sc.nextInt();
}
//step1:将各个种类的商品的商品数量进行二进制拆分,形成新的商品
List<int[]> items = new ArrayList<>();
for(int i = 0;i < N;++i){
int k = 1;
while(s[i] >= k){
items.add(new int[]{k*v[i],k*w[i]}); // 拆分出新的物品
s[i] -= k; k = k*2;
}
if(s[i] > 0) items.add(new int[]{s[i]*v[i],s[i]*w[i]}); // 多余的作为单独一个数字
}
// step2: 0-1背包处理
int[] dp = new int[V+1];
int[][] data = items.toArray(new int[items.size()][]); // List<int[]> => int[][]
for(int i = 0;i < data.length;++i){
for(int j = V;j >= data[i][0];--j){
int vv = data[i][0],ww = data[i][1]; // 物品体积,价值
dp[j] = Math.max(dp[j],dp[j-vv] + ww);
}
}
System.out.println(dp[V]);
}
}
import java.util.*;
class Main{
/*
基本思想:
状态定义:dp[i][v]表示从前i组中选择物品,每组最多选择1个,体积不超过v的最大价值
dp[i][v] = max(dp[i-1][v],...,dp[i-1][v-volume[k]]+value[k],....) 其中k是第i组中的编号为k的物品
= max(k组物品数量+1种情况的最大值)
*/
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int N = sc.nextInt(),V = sc.nextInt();
/*volume[i][j]存储第i组第j个物品体积,value[i][j]存储第i组第j个物品的价值*/
int[][] volume = new int[101][101],value = new int[101][101];
int[] nums = new int[101]; // 存储每组物品的数量
for(int i = 0;i < N;++i){
nums[i] = sc.nextInt();
for(int j = 0;j < nums[i];++j){
volume[i][j] = sc.nextInt(); value[i][j] = sc.nextInt();
}
}
int[] dp = new int[V+1];
for(int i = 0;i < N;++i){ // 考虑前i组
for(int v = V;v >= 0;--v){
for(int k = 0;k < nums[i];++k){
if(volume[i][k] > v) continue;
dp[v] = Math.max(dp[v],dp[ v-volume[i][k] ] + value[i][k]);
}
}
}
System.out.println(dp[V]);
}
}
线性DP
import java.util.*;
class Main{
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
int inf = -0x3f3f3f3f;
int[][] dp = new int[n+1][n+1];
for(int i = 0;i <= n;++i) Arrays.fill(dp[i],inf); // 这里多留1个空位并初始化inf是为了避免边界情况的讨论
for(int i = 1;i <= n;++i){
for(int j = 1;j <= i;++j){
dp[i][j] = sc.nextInt();
}
}
for(int i = 2;i <= n;++i){
for(int j = 1;j <= i;++j){
dp[i][j] = Math.max(dp[i-1][j],dp[i-1][j-1]) + dp[i][j];
}
}
int res = inf;
for(int i = 1;i <= n;++i){
res = Math.max(res,dp[n][i]);
}
System.out.println(res);
}
}
import java.util.*;
class Main{
/*
基本思路:
dp[i]表示以nums[i]结尾的最长上升子序列
属性:最大值
状态划分
dp[i] = 1 + max(dp[j]) 其中nums[i] > nums[j], 0 <= j < i
*/
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
int[] nums = new int[n];
for(int i = 0;i < n;++i) nums[i] = sc.nextInt();
int[] dp = new int[n]; Arrays.fill(dp,1);
int res = 0;
for(int i = 0;i < n;++i){
for(int j = 0;j < i;++j){
if(nums[i] > nums[j]){ // 严格递增
dp[i] = Math.max(dp[j]+1,dp[i]);
}
}
res = Math.max(res,dp[i]);
}
System.out.println(res);
}
}
import java.util.*;
class Main{
/*
优化思路分析:
1) 将单调子序列按照长度进行分类: 长度为1的单调子序列,长度为2的单调子序列,长度为N的单调子序列
2) 将每类单调子序列结尾数值的最小值单独拿出来分析,可以证明该值随长度单调递增。
维护q即最小值数组,通过二分确定
*/
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
int[] arr = new int[n];
for(int i = 0;i < n;++i) arr[i] = sc.nextInt();
// 长度为i 的最长上升子序列的末尾元素的最小值
int[] q = new int[n+1]; // q[i]:长度为i上升子序列集合中结尾的最小值
q[1] = arr[0];
int len = 1;
for(int i = 1;i < n;++i){
if(q[len] < arr[i]){
q[++len] = arr[i];
}else{
int l = 1,r = len;
while(l < r){
int mid = (r - l)/2+l;
if(q[mid] >= arr[i]) r = mid;
else l = mid+1;
}
q[l] = arr[i];
}
}
System.out.println(len);
}
}
import java.util.*;
class Main{
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt(),m = sc.nextInt();
String str1 = sc.next(),str2 = sc.next();
char[] s = str1.toCharArray();
char[] p = str2.toCharArray();
int[][] dp = new int[n+1][m+1];
int res = 0;
for(int i = 1;i <= n;++i){
for(int j = 1;j <= m;++j){
dp[i][j] = Math.max(dp[i-1][j],dp[i][j-1]);
if(s[i-1] == p[j-1]) dp[i][j] = Math.max(dp[i-1][j-1]+1,dp[i][j]);
res = Math.max(res,dp[i][j]);
}
}
System.out.println(res);
}
}
import java.util.*;
class Main{
/*
基本思路:序列DP,注意分情况讨论要考虑全面以及状态初始化
*/
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
String str1 = sc.next();
int m = sc.nextInt();
String str2 = sc.next();
char[] s = str1.toCharArray();
char[] p = str2.toCharArray();
// 初始化
int[][] dp = new int[n+1][m+1];
for(int i = 1;i <= n;++i) dp[i][0] = i;
for(int j = 1;j <= m;++j) dp[0][j] = j;
for(int i = 1;i <= n;++i){
for(int j = 1;j <= m;++j){
int v1 = dp[i][j-1] + 1; // insert
int v2 = s[i-1] == p[j-1] ? dp[i-1][j-1] : dp[i-1][j-1]+1; // modify
int v3 = 1 + dp[i-1][j]; // delete
dp[i][j] = Math.min(v1,Math.min(v2,v3));
}
}
System.out.println(dp[n][m]);
}
}
import java.util.*;
class Main{
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt(),m = sc.nextInt();
String[] s1 = new String[n];
for(int i = 0;i < n;++i) s1[i] = sc.next();
while(m-- > 0){
String s2 = sc.next();
int val = sc.nextInt();
int ans = 0;
for(int i = 0;i < n;++i){
if(calc(s1[i],s2) <= val) ++ans;
}
System.out.println(ans);
}
}
public static int[][] dp = new int[12][12];
public static int calc(String str1,String str2){
char[] s1 = str1.toCharArray();
char[] s2 = str2.toCharArray();
int m = s1.length,n = s2.length;
for(int i = 1;i <= m;++i) dp[i][0] = i;
for(int j = 1;j <= n;++j) dp[0][j] = j;
for(int i = 1;i <= m;++i){
for(int j = 1;j <= n;++j){
int v1 = dp[i][j-1] + 1;
int v2 = 1 + dp[i-1][j];
int v3 = s1[i-1] == s2[j-1] ? dp[i-1][j-1] : dp[i-1][j-1] + 1;
dp[i][j] = Math.min(Math.min(v1,v2),v3);
}
}
return dp[m][n];
}
}
区间DP
import java.util.*;
class Main{
/*
基本思路:前缀和+区间dp
dp[l][r]表示合并区间为[l,r]的所有石头最小代价。
属性:最小值
枚举一个区间的右端点对集合进行划分
状态转移:
dp[l][r] = max{dp[l,mid] + dp[mid+1,r]+ans[l,r]} 其中 l<= mid <= r,其中ans是合并的代价
*/
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int N = sc.nextInt();
int[] prefix = new int[N+1];
for(int i = 1;i <= N;++i){
int v = sc.nextInt();
prefix[i] = v + prefix[i-1];
}
int[][] dp = new int[N+1][N+1]; // size=1的区间合并代价为0,默认已经初始化好
for(int size = 2;size <= N;++size){
for(int ll = 1;ll+size-1 <= N;++ll){
int rr = ll+size-1; dp[ll][rr] = Integer.MAX_VALUE;
for(int k = ll;k < rr;++k){
dp[ll][rr] = Math.min(dp[ll][rr],dp[ll][k]+dp[k+1][rr]+prefix[rr]-prefix[ll-1]);
}
}
}
System.out.println(dp[1][N]);
}
}
计数DP
方式1:从完全背包公式推导出数位DP的状态转移公式(完全背包解法,容易推导)
采用完全背包的思路,请整数N的划分方案数
等价于
求背包容量恰好为N的摆放物品的数量,有N种物品可以选择,每个物品的数量没有限制。
状态表示:
dp[i][v]:从1-i个数字进行选择,恰好和为v的方案数目
=================================================================================================
1)dp[i][j] = dp[i-1][j]+(dp[i-1][j-i]+dp[i-1][j-2*i]+....+dp[i-1][j-s*i])
其中s满足 j-s*i >= 0
2)dp[i][j-i] = dp[i-1][j-i]+dp[i-1][j-2*i]+....+dp[i-1][j-s*i] 其中s满足 j-s*i >= 0
联合1), 2)可以得到状态转移方程: dp[i][j] = dp[i-1][j]+dp[i][j-i]
方式2:另外一种思路
dp[i][j]:总和是i,并且恰好表示为j个数的和的方案数目
状态划分:
1) j个数中最小值是1的方案数目 = dp[i-1][j-1] (拿走最小值1)
2) j个数中最小值不是1的方案数目 = dp[i-j][j] (每个数字减去1)
状态转移:
dp[i][j] = dp[i-1][j-1] + dp[i-j][j]
import java.util.*;
class Main{
/*
思路1:完全背包角度推导数位DP状态转移
dp[i][j]表示从1-i个数中进行选择,总和为j的方案数目
dp[i][j] = dp[i-1][j] + (dp[i-1][j-i] + ... + dp[i-1][j-s*i]) 其中j-s*i >= 0
注意:dp[i][j] = dp[i-1][j] + dp[i][j-i]
*/
/*public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
long[] dp = new long[n+1];
long mod = (long)(1e9+7);
dp[0] = 1; // 从0个数中选择等于0的方案数目为1
for(int i = 1;i <= n;++i){ // 当前可选数字
for(int v = i;v <= n;++v){ // 总和
dp[v] = (dp[v]+dp[v-i])%mod;
}
}
System.out.println(dp[n]);
}*/
/*
思路2:
dp[i][j]表示选择i个数据,和为j的方案数目
dp[i][j] = dp[i-1][j-1] + dp[i][j-i] (i个数最小值为1的方案数目 + i个数最小值不是1的方案数目)
初始化:dp[0][0] = 1 其余为0
*/
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
long[][] dp = new long[n+1][n+1];
long mod = (long)(1e9+7);
dp[0][0] = 1; // 选择0个数和等于0的方案数目为1
for(int i = 1;i <= n;++i){ // 当前可选数字
for(int v = i;v <= n;++v){ // 总和
dp[i][v] = (dp[i-1][v-1]+dp[i][v-i])%mod;
}
}
long res = 0;
for(int i = 0; i <= n;++i){ // 总数等于累加
res += dp[i][n]; res = res%mod;
}
System.out.println(res);
}
}
数位DP
状压DP
k | i | |||||||||
---|---|---|---|---|---|---|---|---|---|---|
* | * | |||||||||
* | * | |||||||||
* | * | |||||||||
* | * | |||||||||
标识状态i: 1100000000
标识状态k: 0001100000
1:当前列开始横着摆放的长方形数量
求把 N×M 的棋盘分割成若干个 1×2 的的长方形,有多少种方案(1 <= N,M <= 11)
核心思路分析:
摆放策略:先摆放水平的长方形 |* *| ,然后竖直摆放长方形(合法方案数目 = 合法摆放横着长方形的数目)
如何判断当前横着摆放的方案是否合法?
摆放完所有横着的长方形,按列扫描没有摆放的位置,要求每列的空格必须是偶数,这样所有剩余空的位置都可以摆放
竖直的长方形
状态表示:f[i][j] 表示已经将前i-1列摆好,标识状态是j的所有方案。
标识状态j:二进制数,标记哪些行的小方块是横着放的(1x2),用1标记哪些行会伸出,其位数和棋盘的行数一致。
考虑上一列到当前列的状态转移:
f[i-1][k] -> f[i][j]
合法的状态转移条件满足:
1) j&k == 0:表示相邻的列不能同时存在横着方的长方形,由于都是1x2的长方形,存在的会会导致1x2 1x2 <= 1x3这种非法放置
2) 状态j|k中剩余的连续0的数量必须是偶数,这样才能放置剩下的横着摆放的长方形。
时间复杂度分析: 列数 * 所有状态转移方式 = 11 * 2^11 * 2^11
优化1:根据转移条件,预处理出所有的合法标记状态,避免2^11的枚举。
比赛场景下优化2:打表存储所有结果并提交。
import java.util.*;
class Main{
public static long [][] dp = new long[12][1 << 11 ];
public static boolean[] valid = new boolean[1 << 11];
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
while(true){
int n = sc.nextInt(),m = sc.nextInt();
if(n == 0 && m == 0) break;
// 预处理合法的状态(当前列不存在奇数个数连续0)
int stateNum = 1 << n;
Arrays.fill(valid,true);
for(int state = 0;state < stateNum;++state){
for(int l = 0;l < n;++l){
int r = l;
while(r < n && ((state >> r) & 1) == 0) ++r;
int size = r - l;
if((size & 1) != 0) valid[state] = false;
l = r;
}
}
// 优化1:预处理并存储的每个状态合法的前置状态集合
List<Integer>[] preState = new List[stateNum];
for(int j = 0;j < stateNum;++j) preState[j] = new ArrayList<>();
for(int j = 0;j < stateNum;++j){
for(int k = 0;k < stateNum;++k){
if((k & j) == 0 && valid[k | j]){
preState[j].add(k);
}
}
}
// 多组数据注意初始化
for(int i = 0;i <= m;++i) Arrays.fill(dp[i],0);
dp[0][0] = 1;
// 没有优化的版本
// for(int c = 1;c <= m;++c){
// for(int k = 0;k < stateNum;++k){
// for(int j = 0;j < stateNum;++j){
// if((k & j) == 0 && valid[k | j]){
// dp[c][j] += dp[c-1][k];
// }
// }
// }
// }
// 优化的版本,只遍历合法的前置状态
for(int c = 1;c <= m;++c){
for(int j = 0;j < stateNum;++j){
for(Integer k:preState[j]){ // 比上面快了300ms左右
dp[c][j] += dp[c-1][k];
}
}
}
System.out.println(dp[m][0]);
}
}
}
给定一张 n 个点的带权无向图,点从 0∼n−1 标号,求起点 0 到终点 n−1 的最短 Hamilton 路径。
Hamilton 路径的定义是从 0 到 n−1 不重不漏地经过每个点恰好一次。
import java.util.*;
class Main{
/*
dp[v][j](集合定义):从起点出发到达节点v,经过的节点集合为状态为j的最短路径
j:二进制表示经过的点的集合
dp[v][j] = min(dp[u][k^(1 << v)] + e_uv)
*/
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
int[][] mat = new int[n][n];
for(int i = 0;i < n;++i){
for(int j = 0;j < n;++j){
mat[i][j] = sc.nextInt();
}
}
// 初始状态为不可达距离
int[][] dp = new int[n][1 << n];
for(int i = 0;i < (1 << n);++i){
for(int j = 0;j < n;++j)
dp[j][i] = (int)(2e9);
}
dp[0][1] = 0; // 从起点到起点代价为0
for(int state = 0;state < (1 << n);++state){ // 枚举状态(状态的从枚举顺序确保了更新时所有的状态在之前已经被更新)
for(int v = 1;v < n;++v){ // 枚举转移的情况
for(int u = 0;u < n;++u){
if(((state >> u) & 1) == 1 && ((state >> v) & 1) == 1) // 确保当前状态包含两个节点
dp[v][state] = Math.min(dp[u][state ^ (1 << v)] + mat[u][v],dp[v][state]);
}
}
}
System.out.println(dp[n-1][(1 << n) - 1]);
}
}
树形DP
import java.util.*;
class Graph{
int[] e,np,h;
int[] val;
int idx;
Graph(int n){
e = new int[n*2];
np = new int[n*2];
h = new int[n*2];
val = new int[n+10];
Arrays.fill(h,-1);
idx = 0;
}
void add(int a,int b){
e[idx] = b; np[idx] = h[a]; h[a] = idx; ++idx;
}
}
class Main{
/*
基本思路:树形DP
dp[i][0]表示当前节点不参加,该子树的最大快乐值
dp[i][1]表示当前节点参数舞会,该子树的最大快乐值
dp[u][1] = Math.max(dp[u][1],dp[v][0]+dp[u][1]); // 累加每个子树贡献的权重
dp[u][0] = Math.max(dp[u][0],dp[u][0]+Math.max(dp[v][0],dp[v][1])); // 累计每个子树贡献的权重
*/
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int N = sc.nextInt();
Graph g = new Graph(N+1);
for(int i = 1;i <= N;++i){ // 节点编号从1开始
g.val[i] = sc.nextInt();
}
for(int i = 1;i <= N-1;++i){
int a = sc.nextInt(),b = sc.nextInt();
g.add(a,b); g.add(b,a);
}
int[][] dp = new int[N+1][2];
dfs(-1,1,g,dp);
System.out.println(Math.max(dp[1][0],dp[1][1]));
}
static int res = Integer.MIN_VALUE;
static void dfs(int fa,int u,Graph g,int[][] dp){
dp[u][0] = 0; dp[u][1] = g.val[u]; // 注意初始化
for(int next = g.h[u];next != -1;next = g.np[next]){
int v = g.e[next];
if(v == fa) continue;
dfs(u,v,g,dp);
dp[u][1] = Math.max(dp[u][1],dp[v][0]+dp[u][1]); // 累加每个子树贡献的权重
dp[u][0] = Math.max(dp[u][0],dp[u][0]+Math.max(dp[v][0],dp[v][1])); // 累计每个子树贡献的权重
}
}
记忆化搜索
import java.util.*;
class Main{
/*
基本思路:记忆化搜索
方法:dp[r][c]表示从当前位置出发的最长路径
另外一种思路:采用拓扑排序求最长的递减序列
*/
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int row = sc.nextInt(),col = sc.nextInt();
int[][] mat = new int[row][col];
for(int r = 0;r < row;++r){
for(int c = 0;c < col;++c){
mat[r][c] = sc.nextInt();
}
}
int[][] dp = new int[row][col];
for(int r = 0;r < row;++r) Arrays.fill(dp[r],-1);
int res = 0;
for(int r = 0;r < row;++r){
for(int c = 0;c < col;++c){
res = Math.max(res,dfs(r,c,dp,mat));
}
}
System.out.println(res);
}
static int[][] dir = {{0,1},{0,-1},{1,0},{-1,0}};
static int dfs(int r,int c,int[][] dp,int[][] mat){
int row = mat.length,col = mat[0].length;
if(dp[r][c] != -1) return dp[r][c];
int tmp = 0;
for(int[] d:dir){
int rr = r+d[0],cc = c+d[1];
if(rr >= 0 && rr < row && cc >= 0 && cc < col && mat[rr][cc] < mat[r][c]){
tmp = Math.max(dfs(rr,cc,dp,mat),tmp);
}
}
dp[r][c] = 1+tmp;
return dp[r][c];
}
}
六 贪心策略
1 区间问题
三类典型问题:
1)最大不相交区间的个数(最小覆盖点数)
2)使得组内区间不相交的最小组数
3)最小区间覆盖
注意与区间合并的问题进行区分,二者本质并不相同。
题意:给定N个闭区间,要求选出最少数量的点(最少覆盖点数),使得每个区间至少包含一个点。
贪心策略:按照右端点递增排序区间,选择右端点,遍历所有区间,能够覆盖下个区间的左端点则跳过,不能够覆盖则更新右端点并且端点数量加。
----------------------------------------------------------------------------------------------------------------
题意:给定N个闭区间,求出最大不相交区间的数量
最大不相交区间的个数 == 最少覆盖点数
原因:区间能被同一个点覆盖,说明他们相交了(多个相交区间中只能去一个区间),所以有几个点就有几个不相交区间
实现
import java.util.*;
class Main{
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
int[][] interval = new int[n][2];
for(int i = 0;i < n;++i){
interval[i][0] = sc.nextInt(); interval[i][1] = sc.nextInt();
}
Arrays.sort(interval,(o1,o2)->{
return Integer.compare(o1[1],o2[1]); // 右端点排序
});
int cnt = 0,r = Integer.MIN_VALUE;
for(int[] t:interval){
if(t[0] > r){ // 当前端点无法覆盖下一个区间左端点
++cnt; r = t[1];
}
}
System.out.println(cnt);
}
}
给定 N 个闭区间,将这些区间分成若干组,使得每组内部的区间两两之间(包括端点)没有交集,并使得组数尽可能小,求最小组数
问题具体化:该题目可以变为会议室问题,假设每个闭区间代表每个会议的开始和结束时间,会议之间不能冲突(端点也算),最少需要多少间会议室?
实现
import java.util.*;
class Main{
/*
求区间冲突的最多个数或者简化为会议室问题更容易理解
贪心策略:按照左端点排序后,遍历所有区间并维护所有组的右端点的最小值min.
1)min大于等于当前区间左端点,新增一组
2)min小于当前区间左端点,将当前区间加入到min所属的组。
*/
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int N = sc.nextInt();
int[][] interval = new int[N][2];
for(int i = 0;i < N;++i){
interval[i][0] = sc.nextInt();
interval[i][1] = sc.nextInt();
}
Arrays.sort(interval,(o1,o2)->{
return Integer.compare(o1[0],o2[0]);
});
Queue<Integer> q = new PriorityQueue<>((o1,o2)->{
return o1.compareTo(o2);
}
);
for(int i = 0;i < N;++i){
if(q.isEmpty() || q.peek() >= interval[i][0]) q.offer(interval[i][1]);
else{
q.poll();
q.add(interval[i][1]);
}
}
System.out.println(q.size());
}
}
给定目标区间和一系列备选区间,如何从备选区间中选择最少数量的区间覆盖目标区间
贪心策略:将区间按照左端点排序,双指针遍历,每次选择覆盖当前端点并且右端点最大的区间并更新覆盖端点,直到无法选择区间或者完全覆盖区间。
import java.util.*;
class Main{
/*
求覆盖区间的最小区间个数
贪心策略:按照左端点排序后,每次选择能够覆盖当前端点并且右端点最大的区间,然后更新需要覆盖的端点。
*/
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int st = sc.nextInt(),end = sc.nextInt();
int N = sc.nextInt();
int[][] interval = new int[N][2];
for(int i = 0;i < N;++i){
interval[i][0] = sc.nextInt();
interval[i][1] = sc.nextInt();
}
Arrays.sort(interval,(o1,o2)->{
return Integer.compare(o1[0],o2[0]);
});
int res = 0;
boolean success = false;
for(int i = 0;i < N;++i){
int j = i,maxr = Integer.MIN_VALUE;
while(j < N && interval[j][0] <= st){ // 贪心策略选取区间
maxr = Math.max(interval[j][1],maxr);
++j;
}
if(maxr < st){ // 无法获取覆盖当前点的区间
break;
}
++res;
if(maxr >= end){ // 存在方案
success = true;
break;
}
st = maxr;
i = j-1;
}
if(!success) res = -1;
System.out.println(res);
}
}
2 哈夫曼树
定义:给定N个权值作为N个叶子结点,构造一棵二叉树,若该树的带权路径长度达到最小,称这样的二叉树为最优二叉树,也称为哈夫曼树(Huffman Tree)。
带权路径长度: 树中所有的叶结点的权值乘上其到根结点的路径长度WPL=(W1*L1+W2*L2+W3*L3+...+Wn*Ln),N个权值Wi(i=1,2,...n)构成一棵有N个叶结点的二叉树
构造:在森林中选出两个根结点的权值最小的树合并
应用:二进制编码,在数据传送时,信息表现为0和1的二进制形式。为了提高传输的速度,可以采用变长的编码方式,寻找更优的编码方式。同时,必须要保证编码不存在二义性(任意字符编码都不是其它字符编码的前缀)。哈夫曼编码就是符合上述要求的编码方式,采用自底向上的形式构造哈夫曼树。按照字符的概率分配码长,实现平均码长最短的编码。
朴素理解:
证明:
1)对于哈夫曼树,最小的两个点,其深度一定最深并且可能互为兄弟(反证法:如果最小的点不是最深,那么可以通过交换节点实现WPL更小)
2)n个节点的合并的最优解与合并后n-1的最优解是一致的
题目可以抽象为求最小的WPL的树,因此构建哈夫曼树,求最小的WPL。
思路:贪心策略,每次合并最小的两堆果子
import java.util.*;
class Main{
/*
问题等价于求n个叶节点的最小带权路径。
贪心策略:每次选择权值最小的两个节点。
*/
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
Queue<Integer> q = new PriorityQueue<>((o1,o2)->{
return Integer.compare(o1,o2);
}
);
for(int i = 0;i < n;++i) q.offer(sc.nextInt());
int sum = 0;
while(q.size() > 1){
int v1 = q.poll();
int v2 = q.poll();
sum += (v1+v2);
q.offer(v1+v2);
}
System.out.println(sum);
}
}
3 排序不等式
问题:n个人排队打水,每个人打水的时间都不相同,如何安排排队的顺序,使得平均等待时间最短
数学表达: t1*n+t2*(n-1)+t3*(n-2)+...+t_(n-1)*1
从公式上可以看到,时间短的人优先处理有助于减少总时间,也就是说平均等待时间(需求最强烈的最后处理,莫名有点讽刺,类似于操作系统中的短作业优先的调度策略)
贪心策略:将人按照时间从小到大排序,然后逐个处理(调整法)
证明:反证法
import java.util.*;
class Main{
/*
贪心策略:短作业优先,平均等待时间最短
*/
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
long[] times = new long[n];
for(int i = 0;i < n;++i) times[i] = sc.nextInt();
Arrays.sort(times);
long sum = 0;
for(int i = 1;i <= n;++i){
sum += (n-i)*times[i-1];
}
System.out.println(sum);
}
}
4 绝对值不等式
问题:横坐标上给定N个地点的坐标,选择一个位置使得该位置到达其余所有位置的距离之和最短
上面公式中x是选择的位置,目标是求f(x)的最小值
基本思路:两两分组
|x1 - x| + |xn - x| 当x取[x1, xn]的中间数时,获得最小值xn - x1
|x2 - x| + |xn-1 - x| 当x取[x2,xn-1]的中间数时,获得最小值xn-1 - x2
显然存在这样x的取值即[x_{n/2},x_{n/2+1}],使得所有取得最小值。
策略:从小到大排序n个坐标,选择n/2坐标点即可。
实现
import java.util.*;
class Main{
/*
策略:排序后,选择最中间的两个的任意一点作为货仓,和最小
贪心策略: 距离分组后根据局部最小值求整体最小值
*/
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
long[] point = new long[n];
for(int i = 0;i < n;++i) point[i] = sc.nextInt();
Arrays.sort(point);
long sum = 0;
for(int i = 0;i < n;++i){
sum += Math.abs(point[i/2]-point[i]);
}
System.out.println(sum);
}
}
5 公式推导
题意:给定N个牛,每个牛有重量W和风险值S,每个牛的风险值等去其承担的总重量-S,求使得所有牛风险最大值最小的牛的排列
贪心策略:将牛按照W+S升序排列,W+S最小的牛在最上面
import java.util.*;
class Main{
/*
贪心策略: 从上到下,按照重量+强壮值的顺序从小到大排列
证明:调整法证明,见 https://www.acwing.com/solution/content/845/
*/
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
long[][] data = new long[n][2];
for(int i = 0;i < n;++i){
data[i][0] = sc.nextLong();
data[i][1] = sc.nextLong();
}
Arrays.sort(data,(o1,o2)->{
return Long.compare(o1[0]+o1[1],o2[0]+o2[1]);
});
long sum = 0;
long maxv = Long.MIN_VALUE;
for(int i = 0;i < n;++i){
long tmp = sum - data[i][1];
maxv = Math.max(maxv,tmp);
sum += data[i][0];
}
System.out.println(maxv);
}
}