济南 CSP-S NOIP 储备营笔记
Day 1 上午 —— 基础算法
模拟 + 枚举
小前言
碰到题目不会做 -> 先写个模拟压压惊()
枚举法
枚举的思想是不断地猜测,从所有可能的集合中一一尝试,然后再判断是否符合题目的条件。
单独提到枚举时我们往往认为这是一个暴力做法,但事实上并非如此,恰当的枚举往往会是解题的关键步骤。
例题 1 : 枚举优化 —— 质数判断
-
简要题面
给定一个数 \(n\),判断 \([1,n]\) 中的每个数是不是质数。
-
朴素枚举算法
每次枚举 \([2,x−1]\) 之间所有的整数 \(i\),逐个判断 \(x\) 是否被 \(i\) 整除,若都不能整除则 \(x\) 是质数。
-
完整代码
#include<bits/stdc++.h>
#define int long long
#define endl '\n'
int n;
bool check(int x){
if(x<=1)return false;
if(x==2)return true;
for(int i=2;i*i<=x;i++)
if(x%i==0)return false;
return true;
}
signed main(){
std::cin>>n;
for(int i=1;i<=n;i++){
if(check(i))std::cout<<"YES\n";
else std::cout<<"NO\n";
}
return 0;
}
-
优化
- 枚举到 \(x/2\);
- 枚举到 \(\sqrt x\);
- 埃氏筛;
- 欧拉筛/线性筛。
之前写的素数判断的题解报告是用 Word 写的,所以没法放链接,这里就不写详细证明了,这里给大家推荐几个比较好的 blog:
P1149 [NOIP2008 提高组] 火柴棒等式
-
题目链接
-
简要思路
for循环 +check判断。首先枚举计算出所有的一位数所需要的火柴数量(设立数组 \(g\),\(g_i\) 代表 \(i\) 这个数所需要的火柴数)。
再次,枚举所有在等式中可能出现的数(即 \([0,2000]\)),设立数组 \(a\) 来表示。
注意到火柴数最多为 \(24\) 根,去掉加号和等号的 \(4\) 根火柴,所以我们最多只能使用 \(20\) 根火柴。因此,我们不难推出:最大的等式大约是 \(1111 + 1111 = 2222\),所以我们只需要枚举每一个加数,其范围为 \([0,1111]\),再利用两个加数推出和,最后
check判断一下火柴数是否符合条件即可。 -
完整代码
#include<bits/stdc++.h>
#define int long long
#define endl '\n'
int n;
int g[]={6,2,5,5,4,5,6,3,7,6};//每个数所需要的火柴的根数
int a[2223]={6};//先把 0 需要的火柴数处理好
bool check(int addend1,int addend2,int sum){
if(a[addend1]+a[addend2]+a[sum]+2+2==n)return 1;//别忘了加上加号和等号的火柴数
else return false;
}
signed main(){
std::cin>>n;
for(int i=1;i<=2222;i++){//提前处理好每一个可能的加数
int t=i;
while(t){//求每个数所用的火柴棒
a[i]+=g[t%10];
t/=10;
}
}
int ans=0;
for(int i=0;i<=1111;i++)
for(int j=0;j<=1111;j++)//枚举两个加数
if(check(i,j,i+j))ans++;
std::cout<<ans<<endl;
return 0;
}
P1115 最大子段和
-
题目链接
-
简要思路
-
第一个数为一个有效序列;
-
如果一个数加上上一个有效序列得到的结果比这个数大,那么该数也属于这个有效序列;
-
如果一个数加上上一个有效序列得到的结果比这个数小,那么这个数单独成为一个新的有效序列;
-
在执行上述处理的过程中实时更新当前有效序列的所有元素之和并取最大值。
-
-
完整代码
#include<bits/stdc++.h>
#define int long long
#define endl '\n'
int n,now,x,maxn=-1e18;
signed main(){
std::cin>>n;
for(int i=1;i<=n;i++){
std::cin>>x;
if(i==1)now=x;//第一个数为一个有效序列
else{
now=std::max(x,now+x);
maxn=std::max(maxn,now);//更新取最大值
}
}
std::cout<<maxn<<endl;
return 0;
}
P1638 逛画展
-
题目链接
-
整体方法
区间伸缩/尺取法
-
简要思路
枚举区间左端点,不断往后选取数字直到出现所有 \(m\) 个画家为止。
左端点右移过程中,合法区间的右端点也一定在右移,所以右端点可以在上一次结果的基础上拓展,而不是重新由新的左端点开始枚举。
-
完整代码
#include<bits/stdc++.h>
#define int long long
#define endl '\n'
int n,m;
int l,r;
int cnt;
int a[1000005];
int tong[1000005];
int ans_l,ans_r;
int minn_length=1e18;
void del(int x){//删除 a[x] 这个点上的画
cnt-=(tong[a[x]]==1);
tong[a[x]]--;
}
void add(int x){//加上 a[x] 这个点上的画
cnt+=(tong[a[x]]==0);
tong[a[x]]++;
}
signed main(){
std::cin>>n>>m;
for(int i=1;i<=n;i++)std::cin>>a[i];
for(int l=1;l<=n;l++){
del(l-1);//删除前一个 a[l]
while(cnt<m&&r<n){//注意 while 循环的条件还要保证右端点 r 合法
add(r+1);
r++;
}
if(r-l+1<minn_length&&cnt>=m){//最短长度且该区间合法
minn_length=r-l+1;
ans_l=l;
ans_r=r;
}
}
std::cout<<ans_l<<' '<<ans_r<<endl;
return 0;
}
作业题 1:P3143 [USACO16OPEN] Diamond Collector S
-
题目链接
-
简要题意
寻找两个不重合的区间,使得这两个区间中最大值和最小值的差不超过 \(k\),问这寻找的两个区间最长的和是多少。
-
简要思路
主要是设立两个变量,分别代表一个点向左或向右可以选择的最多的钻石的数量。然后我们不断向后遍历 \(i\) 即可。
-
完整代码
#include<bits/stdc++.h>
#define int long long
#define endl '\n'
int n,k,ans,a[50005];
int l;//l 表示某个位置的左边最多可以取多少颗钻石,放在第一个架子上
int r=2;//r 表示从某个位置向右最多可以延伸到的位置,放在第二个架子上
int c[50005];//c[i] 用来存从 i-1 向左最多可以取多少颗钻石。
signed main(){
std::cin>>n>>k;
for(int i=1;i<=n;i++)std::cin>>a[i];
std::sort(a+1,a+n+1);//保证 a 数组的单调性
a[n+1]=1e18;
for(int i=1;i<=n;i++){
while(a[r]<=a[i]+k)r++;//可以往右就尽量往右,因为我们只需要最长的。
c[r]=std::max(r-i,c[r]);
l=std::max(l,c[i]);
ans=std::max(ans,l+r-i);//注意区间不要重叠
}
std::cout<<ans<<endl;
return 0;
}
模拟法
模拟就是用计算机来模拟题目中要求的操作。
模拟题目通常具有码量大、操作多、思路繁复的特点。由于它码量大,经常会出现难以查错的情况,如果在考试中写错是相当浪费时间的。
P1328 [NOIP2014 提高组] 生活大爆炸版石头剪刀布
-
题目链接
-
简要思路
按照表格模拟即可。
-
完整代码
#include <bits/stdc++.h>
using namespace std;
int n,lena,lenb;//题面所示
int a[205],b[205];//两个人的出拳顺序
int pointa,pointb;//两个人的得分情况
int Vs[5][5]={{0,0,1,1,0},{1,0,0,1,0},{0,1,0,0,1},{0,0,1,0,1},{1,1,0,0,0}};//打表
signed main(){
cin>>n>>lena>>lenb;
for(int i=0;i<lena;i++)cin>>a[i];
for(int i=0;i<lenb;i++)cin>>b[i];
for(int i=0;i<n;i++){
pointa+=Vs[a[i%lena]][b[i%lenb]];
pointb+=Vs[b[i%lenb]][a[i%lena]];//取模
}
cout<<pointa<<' '<<pointb<<endl;
return 0;
}
P1563 [NOIP2016 提高组] 玩具谜题
-
题目链接
-
简要思路
用一个结构体存下每个人的名字和其身体的朝向。维护当前的人物为
ans,对于每次操作,判断第ans个人身体的朝向,然后按照题目进行模拟,注意维护ans的时候要对n取模。 -
完整代码
#include<bits/stdc++.h>
#define int long long
#define endl '\n'
int n,m;
int ans;
struct node{
bool f;
std::string name;
}a[1000005];
signed main(){
std::cin>>n>>m;
for(int i=0;i<n;i++)
std::cin>>a[i].f>>a[i].name;
while(m--){
int opt,s;
std::cin>>opt>>s;
bool now=a[ans].f;
if(!now&&!opt)ans=(ans+n-s)%n;
else if(!now&&opt)ans=(ans+s)%n;
else if(now&&!opt)ans=(ans+s)%n;
else ans=(ans+n-s)%n;
}
std::cout<<a[ans].name<<endl;
return 0;
}
模拟题小技巧
做模拟题的步骤:
-
先看懂题意,过一下样例;
-
在动手写代码之前,在草纸上尽可能地写好要实现的流程;
-
在代码中,尽量把每个部分模块化,写成函数等;
-
对于一些可能重复用到的概念,可以统一转化,方便处理;
-
调试时分块调试。模块化的好处就是可以方便的单独调某一部分;
-
写代码的时候一定要思路清晰,不要想到什么写什么,要按照落在纸上的步骤写。
作业题 2:P1042 [NOIP2003 普及组] 乒乓球
作业题 3:P2670 [NOIP2015 普及组] 扫雷游戏
作业题 4:P3952 [NOIP2017 提高组] 时间复杂度
二分
定义
在一个单调的有限数列上快速查找某一特定值的方法。
例题 2:二分基础题(1)
-
简要题面
给你一个单调递增的数组,给你一个数 \(x\),问第一个 \(>x\) 和第一个 \(\geq x\) 的数分别是多少。
-
代码找茬
-
保守写法
例题 3:二分基础题(2)
-
简要题面
求一个正整数 \(X\) 开三次根后的结果,保留六位小数。
假设大家不知道
pow,只知道sqrt()。 -
简要思路
二分答案(二分答案限制:假设一个数 \(x\) 满足性质,那么所有 \(\geq x\)/\(\leq x\) 的数都满足条件)。
左边界 \(l=0\),右边界 \(r=x+1\)。
例题 4:二分基础题(3)
-
简要题面
有两个长度为 \(n\) 的数组 \(a\) 和 \(b\),生成一个 \(n \times n\) 的数值表,表中第 \(i\) 行第 \(j\) 列的数为 \(a_i \times b_j\),求表中第 \(k\) 大的数值是多少?
-
简要思路
我们可以这么理解:对于一个数 \(y\),判断表中是否有 \(k\) 个数 \(\geq y\),我们希望 \(y\) 尽量的大,那么这个 \(y\),就是我们要求的第 \(k\) 大的数值。
对于一个 \(y'\),怎么判断是否有 \(k\) 个数 \(\geq y\) 呢?
换句话说,对于一个 \(y'\),怎么判断有多少个数 \(\geq y'\)?我们把 \(a\) 和 \(b\) 数组进行排序,对于一个
P2678 [NOIP2015 提高组] 跳石头
-
题目链接
-
简要思路
最短跳跃距离尽可能长 -> 所有的岩石中间的间隙 \(\geq y\),并且我们希望 \(y\) 尽可能大。
二分 \(y\),如果一个 \(y\) 满足条件,那么所有 \(\leq y\) 的数都满足条件。
判断怎么移动,贪心:
-
如果岩石 \(i\) 和岩石 \(i+1\) 之间的距离 \(<y\),那么我们就移走岩石 \(i+1\),再次判断岩石 \(i\) 和岩石 \(i+2\) 之间的距离是否满足条件,以此类推,每次移走左边的(即较小的)岩石;
-
否则满足条件直接跳过即可。
这样就实现了 \(O(n)\) 的
check了。 -
P6659 [POI 2019] Najmniejsza wspólna wielokrotność
01 分数规划
-
简要题面
有 \(n\) 个物品,每个物品 \(i\) 都有它的价值 \(x_i\) 和它的代价 \(y_i\),选一些物品,使得 \(\sum x_i / \sum y_i\)。
作业题 5:P1182 数列分段 Section II
作业题 6:P2440 木材加工
作业题 7:P1024 [NOIP2001 提高组] 一元三次方程求解
分治
核心思想
分而治之
大致流程
大概的流程可以分为三步:分解 -> 解决 -> 合并。
- 分解原问题为结构相同的子问题。
- 分解到某个容易求解的边界之后,进行递归求解。
- 将子问题的解合并成原问题的解。
解决问题的特征
分治法能解决的问题一般有如下特征:
- 该问题的规模缩小到一定的程度就可以容易地解决。
- 该问题可以分解为若干个规模较小的相同问题,即该问题具有最优子结构性质,利用该问题分解出的子问题的解可以合并为该问题的解。
- 该问题所分解出的各个子问题是相互独立的,即子问题之间不包含公共的子问题。
快速幂
归并排序
分:拆到只剩一个数。
治:双指针,每次对比一下看看两个数组中的指针指向的位置中哪个数最小。
例题 5:利用归并排序求解逆序对
P1908
P1309 [NOIP2011 普及组] 瑞士轮
洛谷数据暴力可以水过()
贪心
P1803 凌乱的yyy / 线段覆盖
P4823 [TJOI2013] 拯救小矮人
CF727F Polycarp's problems
CF865D Buy Low Sell High
作业题 8:P1094 [NOIP2007 普及组] 纪念品分组
作业题 9:P1223 排队接水
作业题 10:P1090 [NOIP2004 提高组] 合并果子 / [USACO06NOV] Fence Repair G
Day 1 下午 —— 全真模拟考试(1)
T1 数
题目链接
简要思路
枚举全排列/搜索。
注意:
- 要开
long long; - 注意 \(a_i\) 可能出现 \(0\) 的情况,不要除以 \(a_i\)。
完整代码
- 枚举全排列
#include<bits/stdc++.h>
#define int long long
#define endl '\n'
int n,m;
int a[15];
bool f[15];
int ans;
signed main(){
std::cin>>n>>m;
for(int i=1;i<=n;i++)std::cin>>a[i];
for(int i=0;i<=n;i++){
for(int j=1;j<=i;j++)f[j]=0;
for(int j=i+1;j<=n;j++)f[j]=1;
do{
int now=1;
for(int j=1;j<=n;j++)
if(f[j])now=now*a[j];
if(now>=m)ans++;
}while(std::next_permutation(f+1,f+n+1));
}
std::cout<<ans<<endl;
return 0;
}
- 搜索
#include<bits/stdc++.h>
#define int long long
#define endl '\n'
int n,m;
int a[50];
int dfs(int now,int cnt){//目前判断到第 now 个数时的乘积为 cnt
if(now==n+1) return cnt>m;
int ans=dfs(now+1,cnt);
ans+=dfs(now+1,cnt*a[now]);
return ans;
}
signed main(){
std::cin>>n>>m;
for(int i=1;i<=n;i++)std::cin>>a[i];
std::cout<<dfs(1,1)<<endl;
return 0;
}
T2 方格染色
题目链接
简要思路
对于每一个黑点,对其可能影响到的 \(9\) 个矩阵进行判断,并进行修改操作,注意提前对所有 \(0\) 个黑点的矩阵进行初始赋值。
完整代码
#include<bits/stdc++.h>
#define int long long
#define endl '\n'
std::map<std::pair<int,int>,bool>m;
int n,h,w;
int ans[15];
int get_cnt(int st_i,int st_j){//判断以 (st_i,st_j) 为左上角的矩阵的黑点的数量
int now=0;
for(int i=st_i;i<=st_i+2;i++)
for(int j=st_j;j<=st_j+2;j++)
if(m[{i,j}])now++;
return now;
}
signed main(){
std::cin>>h>>w>>n;
ans[0]=(h-3+1)*(w-3+1);//提前处理出有 0 个黑点的矩阵数量
while(n--){
int x,y;
std::cin>>x>>y;
if(x-2>=1&&y-2>=1&&x+0<=h&&y+0<=w){int cnt=get_cnt(x-2,y-2);ans[cnt]--;ans[cnt+1]++;}
if(x-2>=1&&y-1>=1&&x+0<=h&&y+1<=w){int cnt=get_cnt(x-2,y-1);ans[cnt]--;ans[cnt+1]++;}
if(x-2>=1&&y-0>=1&&x+0<=h&&y+2<=w){int cnt=get_cnt(x-2,y);ans[cnt]--;ans[cnt+1]++;}
if(x-0>=1&&y-2>=1&&x+2<=h&&y+0<=w){int cnt=get_cnt(x,y-2);ans[cnt]--;ans[cnt+1]++;}
if(x-0>=1&&y-1>=1&&x+2<=h&&y+1<=w){int cnt=get_cnt(x,y-1);ans[cnt]--;ans[cnt+1]++;}
if(x-0>=1&&y-0>=1&&x+2<=h&&y+2<=w){int cnt=get_cnt(x,y);ans[cnt]--;ans[cnt+1]++;}
if(x-1>=1&&y-2>=1&&x+1<=h&&y+0<=w){ int cnt=get_cnt(x-1,y-2);ans[cnt]--;ans[cnt+1]++;}
if(x-1>=1&&y-1>=1&&x+1<=h&&y+1<=w){int cnt=get_cnt(x-1,y-1);ans[cnt]--;ans[cnt+1]++;}
if(x-1>=1&&y-0>=1&&x+1<=h&&y+2<=w){int cnt=get_cnt(x-1,y);ans[cnt]--;ans[cnt+1]++;}
//原谅我浅浅的缩一下行
m[{x,y}]=1;//注意将 (x,y) 标记为黑点
}
for(int i=0;i<=9;i++)std::cout<<ans[i]<<endl;
return 0;
}
T3 最长子序列
题目链接
简要思路
-
80pts
动态规划。
\(dp_{i,j}\) 代表 \(a\) 数组以 \(a_i\) 为结尾,\(b\) 数组以 \(b_j\) 为结尾中最长的满足条件的子序列。
\(4\) 重循环枚举,分别枚举 \(a,b\) 数组中的最后一个数的位置和最后第二个数的位置。
注意如果两个数的差都相等才更新 \(dp_{i,j}\) 的值。
-
100pts
通过移项我们可将子序列的条件改为:\(a_i - b_i = a_{i+1} - b_{i+1}\),所以我们要找的子序列就只用满足每个 \(i\) 都满足 \(a_i - b_i\) 等于一个固定的数 \(x\) 即可。
我们直接 O(\(n^2\)) 遍历 \(a,b\) 数组中的每个位置,将所有的 \(a_i - b_j\) 放入桶中(注意每个答案都加上一个常数,因为 \(a_i - b_j\) 可能出现负数的情况)。
最后我们只需要遍历一下这个桶,找到出现次数最多的 \(a_i - b_j\) 即可。
注意:由于 \(a,b\) 数组都是单调递增的,所以在这个答案中一定不会有某个数被用两次。
完整代码
- 80pts
#include<bits/stdc++.h>
#define int long long
#define endl '\n'
const int MAXN=1e3+5;
int n,m;
int a[MAXN],b[MAXN];
int dp[MAXN][MAXN];
signed main(){
std::cin>>n>>m;
for(int i=1;i<=n;i++)std::cin>>a[i];
for(int j=1;j<=m;j++)std::cin>>b[j];
for(int i=1;i<=n;i++)
for(int j=1;j<=m;j++)
dp[i][j]=1;//注意初始值
for(int a1=1;a1<=n;a1++)//枚举 a 数组倒数第二个位置
for(int a2=a1+1;a2<=n;a2++)//枚举 a 数组最后一个位置
for(int b1=1;b1<=m;b1++)//枚举 b 数组倒数第二个位置
for(int b2=b1+1;b2<=m;b2++)//枚举 b 数组最后一个位置
if(a[a1]-a[a2]==b[b1]-b[b2])//如果符合条件才更新 dp[i][j]
dp[a2][b2]=std::max(dp[a2][b2],dp[a1][b1]+1);
int ans=0;
for(int i=1;i<=n;i++)
for(int j=1;j<=m;j++)
ans=std::max(ans,dp[i][j]);
std::cout<<ans<<endl;
return 0;
}
- 100pts
#include<bits/stdc++.h>
#define int long long
#define endl '\n'
int n,m;
int a[1005],b[1005];
int tong[30000];
signed main(){
std::cin>>n>>m;
for(int i=1;i<=n;i++)std::cin>>a[i];
for(int j=1;j<=m;j++)std::cin>>b[j];
for(int i=1;i<=n;i++)
for(int j=1;j<=m;j++)
tong[a[i]-b[j]+10000]++;//对于每个 a[i]-b[j] 将其放入桶中(注意加上一个常数
int ans=1;
for(int i=0;i<=30000;i++)//遍历桶
ans=std::max(ans,tong[i]);
std::cout<<ans<<endl;
return 0;
}
T4 玻璃珠弹跳大师
题目链接
简要思路
考虑一个特殊情况:\(1,3,5,7,9\),不难发现,答案是 \(n!\),因为每一个珠子都可以在任何时刻达到点 \(0\)。
所以,我们针对所有的情况,都尽可能的让它变成 \(1,3,5,7,9\) 的形式,并让所有与前一个数的间隔 \(> 1\) 的点都往左移,这样才有利于后面的数形成 \(1,3,5,7,9\) 的形式。
举个例子:比如 \(1,5,6,7\),往左靠之后就可以得到 \(1,3,5,7\)。
重复上述过程,直到遇到一个阻碍节。
再举一个例子:对于某些不得不出现的连续位置如 \(1,3,5,6\),则下一个到 \(0\) 的点只能在 \(6\) 之前的那些位置,并且它到之后 \(5,6\) 这个连续位置会自动解开(\(6\) 可以跑到那个原来跑掉的珠子那边,所以无论下一个跑哪个玻璃珠,接下来整个玻璃珠的分布情况是一致的,只是序号不同),所以整理后会形成 \(1,3,5\) 的形式。
这样我们可以发现,对于最终答案序列第 \(i\) 个位置,能填的玻璃珠数目是恒定的。
模拟这个弹跳过程即可得到答案,即:循环遍历数组,让其尽可能的往左靠,如果遇到了阻碍节,那么就把答案 \(ans\) 乘上目前可以成为第一个到达 \(0\) 点的位置的玻璃珠的数量。
最后会剩下形如 \(1,3,5,7,9\) 形式的一串数,最后让答案 \(ans\) 乘上剩下的玻璃珠的数量的阶乘即可。
完整代码
#include<bits/stdc++.h>
#define int long long
#define endl '\n'
const int mod=1e9+7;
const int MAXN=1e6+5;
int n;
int a[MAXN];
signed main(){
std::cin>>n;
for(int i=1;i<=n;i++)std::cin>>a[i];
int now=n;//当前剩余的珠子的数量
a[0]=-1;//防止误判 a[0] 和 a[1] 为阻挡节
int ans=1;
for(int i=1;i<=n;i++){
a[i]=std::min(a[i],a[i-1]+2);//尽量往坐靠,形成 1 3 5 7 9 的形式
if(a[i]-a[i-1]==1){//有阻挡节
int x=i+now-n;//x 为所有能成为第一个消失的珠子的数量(即阻挡节前面的数
ans*=x;
ans%=mod;
now--;//珠子的数量减少一个
a[i]--;//把 a[i] 和 a[i-1] 合并成一个点,再次形成 1 3 5 7 9 的形式
}
}
for(int i=1;i<=now;i++){//剩下的珠子的数量(即剩下的 1 3 5 7 9 形式的珠子的数量
ans*=i;
ans%=mod;
}
std::cout<<ans<<endl;
return 0;
}
Day 2 上午 —— 基础算法
搜索
DFS
深度优先搜索。DFS 顾名思义就是在搜索树中优先搜索向深处延伸。简单来说就是“有路则走,无路回头”,总结为一个字:“莽”!
例题 6:搜索入门题(1)
-
简要题面
给定 \(n\) 个数,问你能否选择其中若干的数,使得它们的和等于另一个给定的值 \(m\)。
-
简要思路
和昨天下午的 T1 差不多,全排列/搜索。
-
完整代码
#include<bits/stdc++.h>
#define int long long
#define endl '\n'
int n,m,ans;
int a[1000005];
void dfs(int x,int sum){
//x 表示的是我们当前要决定第 x 个数选不选
//sum 表示的是我们当前选定的数的和为 sum
if(sum>m)return;//可行性剪枝
if(x>n){
if(sum==m)ans++;
return;
}
dfs(x+1,sum+a[x]);//我们钦定第一个数选
dfs(x+1,sum);//我们钦定第一个数不选
}
signed main(){
std::cin>>n>>m;
for(int i=1;i<=n;i++)std::cin>>a[i];
dfs(1,1);
if(ans)std::cout<<"YES\n";
else std::cout<<"NO\n"<<endl;
return 0;
}
例题 7:搜索入门题(2)———— 数字三角形
-
题目链接
-
简要思路
比较经典且基础的一道题,DP/搜索。
-
完整代码
- 暴搜 55pts
#include<bits/stdc++.h> #define int long long #define endl '\n' int r; int a[1005][1005]; int sum;//每种走法的答案 int ans;//总答案 void dfs(int x,int y){//代表搜索到了 (x,y) 这个点 sum+=a[x][y]; if(x>r){ ans=std::max(ans,sum); return; } dfs(x+1,y); dfs(x+1,y+1); sum-=a[x][y]; } signed main(){ std::cin>>r; for(int i=1;i<=r;i++) for(int j=1;j<=i;j++) std::cin>>a[i][j]; dfs(1,1); std::cout<<ans<<endl; return 0; }- 100pts 记忆化搜索
#include<bits/stdc++.h> #define int long long #define endl '\n' int r; int a[1005][1005]; int sum,ans; int vis[1005][1005];//代表是否算过 dfs(x,y) 的答案 int f[1005][1005];//答案 int dfs(int x,int y){ //dfs(x,y) 代表从 (x,y) 走到最下面能获得的最大的和 //发现对于特定的 (x,y),dfs(x,y) 的结果是一定的 //但是搜索中我们可能多次运用 dfs(x,y) if(vis[x][y])return f[x][y]; vis[x][y]=1;//算过了 if(x>r)return f[x][y]=0; f[x][y]=a[x][y]+std::max(dfs(x+1,y),dfs(x+1,y+1)); return f[x][y]; } signed main(){ std::cin>>r; for(int i=1;i<=r;i++) for(int j=1;j<=i;j++) std::cin>>a[i][j]; ans=dfs(1,1); std::cout<<ans<<endl; return 0; }
例题 8:搜索入门题(3)———— 八皇后问题
-
题目链接
-
简要思路
不断枚举尝试放皇后,然后判断是否可行(此代码只给出了方案数,并未给出具体方案)。
-
完整代码
#include<bits/stdc++.h>
#define int long long
#define endl '\n'
int n,ans,pos[1005];//pos[i]表示第 i 行的皇后放在了第 pos[i] 列
void dfs(int now){//现在要去尝试放 now 行的皇后
if(now>n){
ans++;
return;
}
for(int j=1;j<=n;j++){//第 now 行的皇后放在第 j 列
bool able=true;
for(int i=1;i<now;i++)//枚举第 i 行的皇后
if(pos[i]==j||abs(now-i)==abs(j-pos[i]))//不能在同列和同一斜行
able=false;
if(able){
pos[now]=j;//放在第 j 列
dfs(now+1);
}
}
}
signed main(){
std::cin>>n;
dfs(1);
std::cout<<ans<<endl;
}
剪枝优化
-
What
剪掉搜索树中的一些子树。
-
How
-
可行性剪枝:走到当前状态后,已经能够判断除继续搜索肯定不合法没有解,所以不再搜索。
-
最优性剪枝:走到当前状态后,已经能够判断再继续搜索得到的结果不会比当前的最优解优。
-
P1120 小木棍
-
题目链接
P1074 [NOIP2009 提高组] 靶形数独
POJ1753 (SUPER VERSION)
-
题目链接
-
题目翻译
有一个 \(4 \times 4\)(强化后为 \(6 \times 6\))的方格,每个方格上摆着白色的棋子或者黑色的棋子(如下图)。每一次操作能把一个格子上的棋子以及上下左右四个棋子翻转颜色,白变黑,黑变白。即在角上能一次翻周围两个,边上一次翻周围三个。现在给出初始状态,问将所有棋子编程同一种颜色所需的最少步数。

P2668 [NOIP2015 提高组] 斗地主
P2831 [NOIP2016 提高组] 愤怒的小鸟
BFS
Day 2 下午 —— 数据结构
二叉搜索树
性质
-
一棵二叉树;
-
每个节点的左儿子比自己小;
-
每个节点的右儿子比自己大。

基本实现
const int L=0,R=1;//L 左儿子,R 右儿子
struct node{//一个树上节点
int son[2];//记录两个儿子的下标,分别对应于 son[L],son[R]
int val;//这个节点储存的值
}a[MAXN];
int cnt;//用于分配节点编号
建立二叉搜索树
-
将节点从根节点开始插入;
-
与当前节点进行比较;
-
如果比当前节点储存的值大,就向右递归;
-
如果比当前节点储存的值小,就向左递归。
-
注意:
如果我们从小到大插入二叉搜索树,就会出现一条链的情况。
那么如何避免?
-
尽可能随机地将元素插入二叉搜索树中;
-
当我们发现一个二叉搜索树的高度很接近于元素的数量时,我们就将其进行局部推倒重建;
-
选取值为中间的一个元素为根,再次进行插入操作;
-
平衡树的做法(不属于 NOIP 的考纲)。
-
二叉堆
性质
-
是特殊的二叉树,但不是二叉搜索树;
-
满足任意上面节点的值都比下面节点大的叫做大根堆;
-
默认为大根堆,小根堆则相反。

上图为大根堆。
用途
-
插入元素:O(log n);
-
删除元素:O(log n);
-
查询最大值:O(1)。
基本实现
const int L=0,R=1;//L 左儿子,R 右儿子
struct node{//一个树上节点
int son[2];//记录两个儿子的下标,分别对应于 son[L],son[R]
int val;//这个节点储存的值
int size;//以这个节点为根的子树的大小
//size 能比较平衡的加入节点,防止出现二叉搜索树建立时出现的一条链的情况
}a[MAXN];
int cnt;//用于分配节点编号
手写堆(以大根堆为例)
-
插入
-
从根节点开始,插入一个值,如果当前值比根节点大,就与根节点交换存储的值(即让原来的堆顶元素改变为插入的元素,让原来插入的元素改为堆顶);
-
然后往子树大小更小的儿子递归,与儿子交换其存储的值;
-
直到某个儿子的
size为 \(0\)(即没有儿子了),再建立一个节点来储存这个值。
-
-
删除
-
将删除节点存储的权值赋值为 \(0\),与最大的儿子交换权值并递归;
-
直到节点成为一个叶子节点(无儿子),然后删除该叶子节点。
-
-
查询最大值
- 直接输出堆顶元素即可。
-
完整代码
- 老师的函数实现
#include<bits/stdc++.h> using namespace std; int seed=2322333; bool random_bool(){ seed = seed * 997 % 100007; return seed & 32; // 取出二进制从低到高第 6 位 } struct node{ int val; int lson, rson; // int son[2]; son[L] son[R]; }; node a[100005]; int cnt; // 根节点是1号点 void insert(int k, int val){ // 节点k在的树,插入 val if(val > a[k].val) // 进来的val 更大,直接篡位 swap(val, a[k].val); // 旧王退位 if(a[k].lson == 0){ // 左儿子这边一个节点都没有 a[k].lson = ++cnt; // 安置在这里,新建一个点 a[cnt].val = val; }else if(a[k].rson == 0){ // 如果左边不空,右边空,那就去右边 a[k].rson = ++cnt; a[cnt].val = val; }else if(random_bool()) // 不然的话,随机一边递归下去 insert(a[k].lson, val); else insert(a[k].rson, val); } bool is_leaf(int k){ return a[k].lson == 0 && a[k].rson == 0; } void remove(int k){ // 把这个节点的值删掉 if(a[a[k].lson].val > a[a[k].rson].val){ a[k].val = a[a[k].lson].val; // 把左儿子拿过来替补 if (!is_leaf(a[k].lson)) remove(a[k].lson); else a[k].lson = 0; }else{ a[k].val = a[a[k].rson].val; // 把右儿子拿过来替补 if (!is_leaf(a[k].rson)) remove(a[k].rson); else a[k].rson = 0; } } int root, ele_cnt; void push(int val){ // 堆里插入一个值为val的元素 ele_cnt++; // 给这个新的元素分配一个点 insert(root, val); } void pop(){ ele_cnt--; remove(root); } int top(){ return a[root].val; } bool empty(){ return ele_cnt == 0; }- 我之前的函数实现
#include<bits/stdc++.h> using namespace std; struct heap{//大根堆 int n;//当前堆里面总共有 n 个数 int a[1000005]; int top(){//询问最大值 return a[1] } int size(){//求堆内元素的数量 return n; } int push(int x){//向堆中加入数 x a[++n]=x; int p=n; while(p!=1){ int f=p/2; if(a[f]<a[p]){ swap(a[f],a[p]); p=f; } else break; } } void pop(){//删除最大值 a[1]=a[n--]; int p=1; while(){ int pp=p*2;//左儿子 if(pp+1<=n&&a[pp+1]>a[pp])pp++;//pp指向两个数中最大的那个数 if(a[p]<a[pp]){ swap(a[p],a[pp]); p=pp; } else break; } } };
STL 堆
- 完整代码
#include<bits/stdc++.h>
#define int long long
#define endl '\n'
priority_queue<int> big_pq;//默认大根堆
priority_queue<int,vector<int>,greater<int> > small_pq;
//小根堆的定义方法:
//1. 如上
//2. 定义大根堆,插入和查询时使用相反数
signed main(){
//插入元素
big_pq.push(i);
big_pq.push(j);
big_pq.push(k);
//访问队列首元素,注意这里不是像队列一样使用 front 和 back 来访问首元素和尾元素
std::cout<<big_pq.top()<<endl;
//删除堆顶元素
big_pq.pop();
//判断队列是否为空
if(!big_pq.empty())
std::cout<<"队列不为空"<<endl;
//输出队列中的元素个数
std::cout<<big_pq.size()<<endl;
//输出剩下元素
while(!big_pq.empty()){
std::cout<<big_pq.top()<<" ";
big_pq.pop();
}
return 0;
}
P3378 【模板】堆
-
题目链接
-
简要思路
STL 小根堆按题目实现即可。
-
完整代码
#include<bits/stdc++.h>
#define int long long
#define endl '\n'
int n;
std::priority_queue<int,std::vector<int>,std::greater<int> > pq;
signed main(){
std::cin>>n;
for(int i=1;i<=n;i++){
int opt;
std::cin>>opt;
if(opt==1){
int x;
std::cin>>x;
pq.push(x);//插入
}else if(opt==2){
std::cout<<pq.top()<<endl;//输出最小的数
}else{
pq.pop();//删除最小的数
}
}
return 0;
}
P1090 [NOIP2004 提高组] 合并果子 / [USACO06NOV] Fence Repair G
-
题目链接
-
简要思路
小根堆。开始时将 \(n\) 个果子的重量都放到小根堆中,然后
while循环直至小根堆中只剩下 \(1\) 个元素,每次取出两次堆顶,让答案加上其和并放入小根堆中。 -
完整代码
#include<bits/stdc++.h>
#define int long long
#define endl '\n'
int n,x,ans;
std::priority_queue<int,std::vector<int>,std::greater<int> > pq;
signed main(){
std::cin>>n;
for(int i=1;i<=n;i++){
std::cin>>x;
pq.push(x);
}
while(pq.size()>1){
int a=pq.top();
pq.pop();
int b=pq.top();
pq.pop();
ans+=a+b;
pq.push(a+b);
}
std::cout<<ans<<endl;
return 0;
}
P1631 序列合并
-
题目链接
-
简要思路
注意两个序列都是单调不降的,我们先将 \(a_1\) 和每个 \(b_i\) 相结合,放入小根堆中。然后循环 \(n\) 次,每次取出堆顶元素并将取出位置的下一个位置放入堆中。
运用结构体,存储选的数在 \(a\) 中的位置 \(i\) 和在 \(b\) 中的位置 \(j\),以及 \(a_i + b_j\) 的和 \(sum\)。而因为我们要比较两个不同的 \(sum\),所以我们还需要进行重载运算符操作。
-
完整代码
#include<bits/stdc++.h>
#define int long long
#define endl '\n'
const int MAXN=1e5+5;
int n;
int a[MAXN],b[MAXN];
struct node{
int i;//在 a 数组中的位置
int j;//在 b 数组中的位置
int sum;//a_i + b_j
friend bool operator>(node a,node b){//重载运算符 >
if(a.sum!=b.sum)return a.sum>b.sum;
else return a.i>b.i;
}
node(int _i,int _j,int _sum){//修改操作
i=_i;
j=_j;
sum=_sum;
}
};
std::priority_queue<node,std::vector<node>,std::greater<node> > pq;
signed main(){
std::cin>>n;
for(int i=1;i<=n;i++)std::cin>>a[i];
for(int i=1;i<=n;i++){
std::cin>>b[i];
pq.push(node(1,i,a[1]+b[i]));
}
for(int i=1;i<=n;i++){
node t=pq.top();
std::cout<<t.sum<<" \n"[i==n];
pq.pop();
pq.push(node(t.i+1,t.j,a[t.i+1]+b[t.j]));
}
return 0;
}
P2085 最小函数值
-
题目链接
-
简要思路
暴力水过()。
-
完整代码
#include<bits/stdc++.h>
#define int long long
#define endl '\n'
const int MAXN=3e5+5;
int n,m;
int a[MAXN],b[MAXN],c[MAXN];
int x[MAXN];
signed main(){
std::cin>>n>>m;
for(int i=1;i<=n;i++){
std::cin>>a[i]>>b[i]>>c[i];
x[i]=1;
}
while(m--){
int ans,minn=1e18;
for(int i=1;i<=n;i++){
int t=a[i]*x[i]*x[i]+b[i]*x[i]+c[i];
if(t<minn){
minn=t;
ans=i;
}
}
std::cout<<minn<<" \n"[m==0];
x[ans]++;
}
return 0;
}
线段树
常见解决问题
做各种序列上的操作问题。
举例:给你一个长度为 \(n\) 的序列(可能有初始值),然后有 \(q\) 次操作,每次操作可以修改一个位置的值或查询一个区间的权值和,对于每次查询操作,输出答案。
性质
-
是一种二叉树结构;
-
线段树上的每个节点都对应一个区间 \([l,r]\);
-
一个节点的左儿子对应的区间为 \([l,mid]\),右儿子对应的区间为 \([mid+1,r]\)。
-
没有儿子的节点对应的区间为它本身。
-
线段树的高度为 log 级别的。
建树
-
令根节点的范围为 \([1,n]\);
-
递归建立左右子节点;
-
注意一共需要 \(2 \times n\) 个节点来建立线段树(\(n\) 个区间只有自己的节点的节点和 \(n-1\) 个区间有多个人的节点的节点)。
单点修改
从根开始向下递归,找到包含这个点的所有区间,然后修改对应区间的统计值。
区间查询
-
查询的时候将区间作为参数进行传入;
-
如果查询区间是当前区间,那么返回当前节点的返回值;
-
如果查询区间是当前区间的子区间,根据将查询区间按情况递归进入左儿子、右儿子或者两边。
习题
单点修改 + 区间查询
- 完整代码
#include<bits/stdc++.h>
#define int long long
#define endl '\n'
const int MAXN=1e5+5;
const int L=0,R=1;
int cnt;
int v[MAXN];//每个同学的初始成绩
struct xds{
int son[2];//两个儿子
int sum;//区间和(存储其管理的区间的值
}a[2*MAXN];//注意开两倍
void build(int &k,int l,int r){//建树,k 值可以被改变,方便以后节点的调用
k=++cnt;//每个节点的编号(从 1 开始
if(l==r){//管理的区间只有自己
a[k].sum=v[l];//直接赋值
}else{
int mid=(l+r)/2;
build(a[k].son[L],l,mid);//递归建立左儿子
build(a[k].son[R],mid+1,r);//递归建立右儿子
a[k].sum=a[a[k].son[L]].sum+a[a[k].son[R]].sum;//合并左右区间,区间和等于两个儿子的区间和的和
}
}
void modify(int k,int l,int r,int q,int val){//单点修改操作 ,从根开始向下递归
//k 为编号,[l,r] 为第 k 个点管理的区间,q 为要操作的编号,val 为更改后的值
if(q==l&&r==q){//区间只剩下了一个点
a[k].sum=val;//将编号为 k 的点的值更新为 val
}else{//向下递归,找到含有 k 这个编号的区间
int mid=(l+r)/2;//二分
if(q<=mid)modify(a[k].son[L],l,mid,q,val);//q 在左儿子的序列里
else if(q>mid)modify(a[k].son[R],mid+1,r,q,val);//q 在右儿子的序列里
a[k].sum=a[a[k].son[L]].sum+a[a[k].son[R]].sum;//更新区间和
}
}
int query(int k,int l,int r,int ql,int qr){//区间查询操作
//k 为编号,[l,r] 为第 k 个点管理的区间,要查询 [ql,qr] 的区间和
if(ql==l&&r==qr){//这个区间计算过,是一整个区间
return a[k].sum;
}else{
int mid=(l+r)/2;//同上,二分
int ans=0;
if(qr<=mid) ans=query(a[k].son[L],l,mid,ql,qr);//答案往左找
else if(ql>mid) ans=query(a[k].son[R],mid+1,r,ql,qr);//答案往右找
else ans=query(a[k].son[L],l,mid,ql,mid)+query(a[k].son[R],mid+1,r,mid+1,qr);//答案在两个区间重叠部分,类似与分治
return ans;
}
}
signed main(){
int root=0;
int n,q;
std::cin>>n>>q;
for(int i=1;i<=n;i++)std::cin>>v[i];
build(root,1,n);//建树
while(q--){
int opt;
std::cin>>opt;
if(opt==1){//单点修改操作
int x,val;
std::cin>>x>>val;
modify(1,1,n,x,val);
}
else{//区间查询操作
int ql,qr;
std::cin>>ql>>qr;
std::cout<<query(1,1,n,ql,qr);
}
}
return 0;
}
True
#include<bits/stdc++.h>
#define int long long
#define endl '\n'
const int MAXN=1e5+5;
const int L=0,R=1;
int cnt;
int w[MAXN],t[4*MAXN];
void build(int k,int l,int r){
if(l==r){
t[k]=w[l];
return;
}else{
int mid=(l+r)>>1;
build(k*2,l,mid);
build(k*2+1,mid+1,r);
t[k]=std::max(t[k*2],t[k*2+1]);
return;
}
}
void modify(int k,int l,int r,int q,int val){
if(q==l&&r==q){
t[k]=val;
return;
}else{
int mid=(l+r)>>1;
if(q<=mid)modify(k*2,l,mid,q,val);
if(q>mid)modify(k*2+1,mid+1,r,q,val);
t[k]=std::max(t[k*2],t[k*2+1]);
}
}
int query(int k,int l,int r,int ql,int qr){
if(l>=ql&&r<=qr){
return t[k];
}else{
int mid=(l+r)>>1;
int ans=0;
if(ql<=mid) ans=std::max(ans,query(k*2,l,mid,ql,qr));
if(qr>mid) ans=std::max(ans,query(k*2+1,mid+1,r,ql,qr));
return ans;
}
}
signed main(){
std::ios::sync_with_stdio(false);
std::cin.tie(0);
int n,q;
std::cin>>n>>q;
for(int i=1;i<=n;i++)std::cin>>w[i];
build(1,1,n);//建树
while(q--){
int x,y;
std::cin>>x>>y;
modify(1,1,n,x,y);
w[x]=y;
int ans=0;
for(int i=1;i<=n/2+1;i++)ans=std::max(ans,w[i]+query(1,1,n,i,n-i));
std::cout<<ans<<endl;
}
return 0;
}
区间修改 + 区间查询
- 题目链接
- 完整代码
#include<bits/stdc++.h>
#define int long long
#define endl '\n'
const int MAXN=1e5+5;
const int L=0,R=1;
int cnt;
int v[MAXN];
struct xds{
int son[2];
int sum;
int addtag;//记录这个点管理的序列要加上 addtag
}a[2*MAXN];
void build(int &k,int l,int r){//建树
k=++cnt;
if(l==r){
a[k].sum=v[l];
}else{
int mid=(l+r)/2;
build(a[k].son[L],l,mid);
build(a[k].son[R],mid+1,r);
a[k].sum=a[a[k].son[L]].sum+a[a[k].son[R]].sum;
}
}
void add(int k,int l,int r,int val){
a[k].sum+=(r-l+1)*val;//其管理的区间的和 += 其管理的节点的数量 * 赋值上的标记
a[k].addtag+=val;//维护标记
}
void modify(int k,int l,int r,int ql,int qr,int val){//区间修改操作
if(ql==l&&r==qr){
add(k,l,r,val);
}else{
int mid=(l+r)>>1;
//下传并清空节点k的所有标记
//(注意 k 的儿子没有清空
add(a[k].son[L],l,mid,a[k].addtag);
add(a[k].son[R],mid+1,r,a[k].addtag);
a[k].addtag=0;//标记清空
if(qr<=mid) modify(a[k].son[L],l,mid,ql,qr,val);
else if(ql>mid) modify(a[k].son[R],mid+1,r,ql,qr,val);
else modify(a[k].son[L],l,mid,ql,mid,val),modify(a[k].son[R],mid+1,r,mid+1,qr,val);
a[k].sum=a[a[k].son[L]].sum+a[a[k].son[R]].sum;//下传后更新
}
}
int query(int k,int l,int r,int ql,int qr){//区间查询操作
if(ql==l&&r==qr){
return a[k].sum;
}else{
int mid=(l+r)/2,ans=0;
//同区间修改操作
add(a[k].son[L],l,mid,a[k].addtag);
add(a[k].son[R],mid+1,r,a[k].addtag);
a[k].addtag=0;
if(qr<=mid) ans=query(a[k].son[L],l,mid,ql,qr);
else if(ql>mid) ans=query(a[k].son[R],mid+1,r,ql,qr);
else ans=query(a[k].son[L],l,mid,ql,mid)+query(a[k].son[R],mid+1,r,mid+1,qr);
a[k].sum=a[a[k].son[L]].sum+a[a[k].son[R]].sum;
return ans;
}
}
signed main(){
int root=0;
int n,q;
std::cin>>n>>q;
for(int i=1;i<=n;i++)std::cin>>v[i];
build(root,1,n);//建树
while(q--){
int opt;
std::cin>>opt;
if(opt==1){//区间修改操作
int x,y,val;
std::cin>>x>>y>>val;
modify(1,1,n,x,y,val);
}
else{//区间查询操作
int ql,qr;
std::cin>>ql>>qr;
std::cout<<query(1,1,n,ql,qr)<<endl;
}
}
return 0;
}
- 完整代码
区间修改 + 区间加 + 区间乘 + 区间查询
树状数组
优劣势
-
优势:代码量短,速度较快。
-
劣势:树状数组只能支持单点修改和前缀查询,并不能支持区间修改和区间查询。
管理机制
\(lowbit_x\) 表示 \(x\) 最大的为 \(2\) 的整次幂的因数。
例如:\(lowbit_3=2^0=1,lowbit_12=2^2=4,lowbit_8=2^3=8\)
树状数组的节点 \(i\),管理的区间是 \([i-lowbit_i+1,i]\),也就是长度为 \(lowbit_i\),结尾为 \(i\) 的区间。

Day 3 上午 —— 数据结构
RMQ 问题
又称区间最值问题。
例题 8:RMQ 引入例题
-
简要题面
给出一个长度为 \(n\) 的数列,有 \(m\) 次查询,每次查询给出两个数 \(l,r\),询问区间 \([l,r]\) 中的最大(小)值是多少?
-
简要思路
-
线段树做法
把区间修改、区间查询的线段树代码的
sum不分稍微修改即可。 -
ST 表(详见下文)
-
-
代码(线段树做法)
#include<bits/stdc++.h>
#define int long long
#define endl '\n'
const int MAXN=1e5+5;
const int L=0,R=1;
int cnt;
int v[MAXN];//RMQ 问题会给出每个人的初始分数
struct xds{
int son[2];
int maxn,minn;//其维护区间的最大值和最小值
//没有修改,所以没有了区间和以及任何标记
}a[2*MAXN];
void update(int k){//更新 k 的信息
a[k].maxn=std::max(a[a[k].son[L]].maxn,a[a[k].son[R]].maxn);
a[k].minn=std::min(a[a[k].son[L]].minn,a[a[k].son[R]].minn);
}
void build(int &k,int l,int r){//建树
k=++cnt;
if(l==r){
a[k].maxn=v[l];
a[k].minn=v[l];//最大(小)值为本身
}else{
int mid=(l+r)/2;
build(a[k].son[L],l,mid);
build(a[k].son[R],mid+1,r);
update(k);//把其左右儿子的信息合并
}
}
int query_max(int k,int l,int r,int ql,int qr){//区间查询最大值
if(ql==l&&r==qr){
return a[k].maxn;
}else{
int mid=(l+r)/2,ans=0;
if(qr<=mid) ans=query_max(a[k].son[L],l,mid,ql,qr);
else if(ql>mid) ans=query_max(a[k].son[R],mid+1,r,ql,qr);
else ans=std::max(query_max(a[k].son[L],l,mid,ql,mid),query_max(a[k].son[R],mid+1,r,mid+1,qr));
return ans;
}
}
int query_min(int k,int l,int r,int ql,int qr){//区间查询最小值
if(ql==l&&r==qr){
return a[k].maxn;
}else{
int mid=(l+r)/2,ans=0;
if(qr<=mid) ans=query_min(a[k].son[L],l,mid,ql,qr);
else if(ql>mid) ans=query_min(a[k].son[R],mid+1,r,ql,qr);
else ans=std::min(query_min(a[k].son[L],l,mid,ql,mid),query_min(a[k].son[R],mid+1,r,mid+1,qr));
return ans;
}
}
int n,q,root;
signed main(){
std::cin>>n>>q;
for(int i=1;i<=n;i++)std::cin>>v[i];
build(root,1,n);//建树
while(q--){
int opt;
std::cin>>opt;
if(opt==1){//查询区间最小值
int l,r;
std::cin>>l>>r;
std::cout<<query_min(1,1,n,l,r)<<endl;
}
else{//查询区间最大值
int l,r;
std::cin>>l>>r;
std::cout<<query_max(1,1,n,l,r)<<endl;
}
}
return 0;
}
ST 表
作用/优势
在 O(n log n) 的时间内预处理完后,在 O(1) 的时间查询区间最值。
实现
(其实就是倍增)
-
\(st_{i,l}\) 代表以 \(l\) 为开头,长度为 \(2^i\) 的区间的最大(小)值,即对应于 \([l,l+2^i-1]\);
-
对于所有的 \(st_{0,i}\) 都赋值为 \(a_i\);
-
\(st_{i,l}=max(st_{i-1,l},st_{i-1,l+2^{i-1}})\)。
P3865 【模板】ST 表
- 题目链接
-
简要思路
按照上述步骤实现,注意要加上快读。
-
完整代码
#include<bits/stdc++.h>
#define int long long
#define endl '\n'
const int MAXN=2e5+5;
const int INF=0x3f3f3f3f;
int n,m;//长度 n,询问 m
int a[MAXN];//初始值序列
//ST 表
int st[20][MAXN];//st[i][l] 代表从 l 开始长度为 2^i 的区间的最大值
int lg[MAXN];//lg[i] 表示 2^lg[i] 不超过 i
void pre(){
for(int i=2;i<=n+2;i++)lg[i]=lg[i/2]+1;//预处理 lg 数组
for(int i=1;i<=n;i++)st[0][i]=a[i];//初始化 ST表 第一行 st[0],即长度为 1 的序列
for(int i=1;(1<<i)<=n;i++)//(1<<i) = 2^i
for(int j=1;j+(1<<i)-1<=n;j++)//j 是区间起始位置,j+(1<<i)-1 是区间结尾位置
st[i][j]=std::max(st[i-1][j],st[i-1][j+(1<<(i-1))]);
}
int get_max(int l,int r){//查询 [l,r] 中的最大值
if(l>r)return -INF;
int d=lg[r-l+1];//不超过区间长度的最大的 2 的整数次幂
return std::max(st[d][l],st[d][r-(1<<d)+1]);//维护更新
}
inline int read(){
int x=0,f=1;char ch=getchar();
while (ch<'0'||ch>'9'){if (ch=='-') f=-1;ch=getchar();}
while (ch>='0'&&ch<='9'){x=x*10+ch-48;ch=getchar();}
return x*f;
}
signed main(){
std::cin>>n>>m;
for(int i=1;i<=n;i++)std::cin>>a[i];
pre();
while(m--){
int l=read();
int r=read();
std::cout<<get_max(l,r)<<endl;
}
return 0;
}
LCA 问题

一些基础知识
-
树是什么?
-
\(n\) 个点,\(n-1\) 条边的连通图(或者说没有环的连通图);
-
有根树、无根树;
-
树根,树上节点的父亲、儿子、祖先、后代、兄弟;
-
一个节点的深度定义为该节点到根节点的深度(根节点的深度为 \(1\))。
-
-
如何存储一个树?
- 使用
vector存储与每个节点相邻的节点的编号(稀疏图的存图方法)。
- 使用
-
如何遍历一棵树?
- 从树根开始,DFS 递归遍历,每次遍历记录父亲是谁,避免死循环。
例题 9:LCA(最近公共祖先)问题
-
简要题面
有一棵 \(n\) 个节点的树,给你 \(m\) 次询问,每次询问给出两个点 \(u,v\),求 \(u,v\) 在树上的最近公共祖先(即深度最深的公共祖先)。
节点 \(A\) 是节点 \(B\) 的祖先当且仅当 \(A\) 在 \(B\) 达根的路径上。反之,如果 \(B\) 在 \(A\) 的子树里,则 \(B\) 是 \(A\) 的后代。
做法 1:RMQ
通过欧拉遍历序(ETT)转化为区间最值(RMQ)问题。
-
如何生成一棵树的欧拉遍历序?
- 从根节点开始遍历,每次到达或者返回一个节点,再将节点的编号放入序列末尾(其实就是 DFS 的步骤,只不过是可以重复输出,没有了
vis数组)。
- 从根节点开始遍历,每次到达或者返回一个节点,再将节点的编号放入序列末尾(其实就是 DFS 的步骤,只不过是可以重复输出,没有了

欧拉遍历序:\(1 2 3 2 5 6 5 2 1 4 1\)
-
怎么求最近公共祖先?
- 例如查询 \(x,y\) 的最近公共祖先,那么我们就在欧拉遍历序中找到以 \(x,y\) 为开头/结尾的区间,找到这个区间中深度最小的,那么这个最小的深度对应的节点就是 \(x,y\) 的最近公共祖先。
代码就是线段树改一点点即可,就不放了。
做法 2:倍增
- 基础做法
#include<bits/stdc++.h>
#define int long long
#define endl '\n'
const int MAXN=5e5+5;
int N,M,S;//N 节点数量,M 询问的次数,S 根节点的编号
int depth[MAXN];//depth[i] 代表节点 i 的深度
int f[MAXN];//f[i] 代表节点 i 的父亲
std::vector<int> z[MAXN];
void add_edge(int s,int e){z[s].push_back(e);}//建边
void dfs(int i,int j){//搜索到了节点 i,其父亲为 j
f[i]=j;
depth[i]=depth[j]+1;
for(int k=0;k<z[i].size();k++){//遍历所有与 i 相邻的点
int l=z[i][k];
if(l!=j)dfs(l,i);//保证不是父节点,防止无限递归
}
}
int get_lca(int x,int y){//求出 x,y 的 LCA(最近公共祖先)
while(x!=y){
if(depth[x]<depth[y])std::swap(x,y);//保证 x 深度较深
x=f[x];//往上找祖先
}
return x;
}
inline int read(){//快读
int x=0,f=1;char ch=getchar();
while (ch<'0'||ch>'9'){if (ch=='-') f=-1;ch=getchar();}
while (ch>='0'&&ch<='9'){x=x*10+ch-48;ch=getchar();}
return x*f;
}
signed main(){
N=read(),M=read(),S=read();
for(int i=1;i<N;i++){
int s=read();
int e=read();
add_edge(s,e);
add_edge(e,s);//无向图
}
dfs(S,0);//从根节点 S 开始
while(M--){
int x=read();
int y=read();
std::cout<<get_lca(x,y)<<endl;
}
return 0;
}
并查集
P3367 【模板】并查集
-
题目链接
-
简要题意
共有 \(n\) 个小朋友,每个小朋友各为一组。共有 \(q\) 次操作,每次操作可以将两个小朋友所在的组别合并为一个组,或者是查询某两个小朋友是不是在一个组别中。
-
简要思路
路径压缩 + 按秩合并/随机合并。
-
完整代码
#include<bits/stdc++.h>
#define int long long
#define endl '\n'
const int MAXN=2e5+5;
int n,m;
int f[MAXN];//f[i] 为 i 的父亲
int siz[MAXN];//在按秩合并的做法下,如果 i 是树的根节点,那么 siz[i] 代表以 i 为根节点的树的节点的数量
int find_tree(int x){//找到 x 所在树的根节点
if(x==f[x]){//x 的父亲是自己,即自己是根节点
return x;
} else{
int root=find_tree(f[x]);//递归让父亲找根节点
f[x]=root;//路径压缩
return root;
}
}
int seed=2322333;
bool random_bool(){
seed=seed*996%100007;
return seed&32;//造随机数据
}
signed main(){
std::cin>>n>>m;
for(int i=1;i<=n;i++){
f[i]=i;//每个节点都独立为一个树,都是根节点
siz[i]=1;//树中只有一个节点
}
while(m--){
int opt,x,y;
std::cin>>opt>>x>>y;
int u=find_tree(x);
int v=find_tree(y);
if(opt==1){//合并集合
if(u!=v){
//按秩合并
if(siz[u]<siz[v]){
f[u]=v;
siz[v]+=siz[u];
}else{
f[v]=u;
siz[u]+=siz[v];
}
//随机合并
//if(random_bool()) f[u]=v;
//else f[v]=u;
}
}
else{
if(u==v)std::cout<<"Y\n";
else std::cout<<"N\n";
}
}
return 0;
}
Day 3 下午 —— 动态规划
动态规划入门
解释
-
解决一类问题的方法(当然也可以用来骗分)。
-
组成:有限状态,状态值函数,状态转移方程。
状态
-
能够恰好表达当前需要的信息的一组数据。
-
只记录关键的变化量。
-
不记录对答案没有影响的状态和绝对不变的数据。
-
通过已知信息和状态里可以推导出的数据不用记录。
-
举例:
-
扫雷游戏
-
记录每一个方块是否翻开就可以表示当前状态了。
-
不记录用户扫雷的顺序,因为这个不重要,不影响游戏的继续和已有的得分。
-
不记录棋盘的尺寸和雷的位置,这些数据是绝对变的,这些开始就是已知信息。
-
还有扫雷时每个位置上的数字也不用记录,因为这些可以通过已知信息和状态里的数据推导得出。
-
-
围棋
共有 \(3^{361}\) 种状态,每个位置有 黑子、白子和没有子共 \(3\) 种状态。
-
状态转移方程
既然状态都已经被我们列举出来了,那么我们就可以通过另一些状态计算某个状态的对应值 \(dp_{状态i}\)。
例:求解扫雷最少需要的胜利步数。
\(dp_{状态i}\) 表示到达状态 \(i\) 所需要的最小的步数。
\(dp_{状态i}=min(dp_{状态j}+1)\),其中状态 \(j\) 为可以通过一步操作转移到 \(i\) 的一些状态。
初始状态的是所有位置都没被翻开的局面。
-
满足条件
-
除了初始状态以外,每个状态都可以通过其他状态计算得出;
-
依赖关系不能成环(成环了,该按照什么顺序算?)。
-
通常,我们通过拓扑序遍历所有状态来逐个计算状态值函数。
例题 10:DP 入门题(1)
-
简要题面
有一个用三角形表示的山,从山顶依次向下有 \(1\) 段、\(2\) 段、\(3\) 段等 \(n\) 段山路,每一段上都有一个需要爬的时间,每一次它都可以朝左上、右上两个方向走。输出小猪走到山顶所需要的最短时间。
-
完整代码
#include<bits/stdc++.h>
#define int long long
#define endl '\n'
int n;
int a[1005][1005];
int dp[1005][1005];
signed main(){
std::cin>>n;
for(int i=1;i<=n;i++)
for(int j=1;j<=i;j++)
std::cin>>a[i][j];
for(int i=1;i<=n;i++)
for(int j=1;j<=i;j++){
if(j==1)dp[i][j]=dp[i-1][j]+a[i][j];
else if(j==i)dp[i][j]=dp[i-1][j-1]+a[i][j];
else dp[i][j]=std::min(dp[i-1][j-1],dp[i-1][j])+a[i][j];
}
for(int i=1;i<=n;i++)
for(int j=1;j<=i;j++)
std::cout<<dp[i][j]<<" \n"[j==i];
int minn=1e18;
for(int i=1;i<=n;i++)
minn=std::min(minn,dp[n][i]);
std::cout<<minn<<endl;
return 0;
}
例题 11:DP 入门题(2)
-
简要题面
有一个 \(n\) 行 \(m\) 列的棋盘,每个位置上的奖励不同,走到某个位置都能获得该位置的奖励 \(val_{i,j}\),现在小明从棋盘左上角走到右下角,每次只能向右或向下走,问走到棋盘右下角能获得的最大奖励。
-
完整代码
#include<bits/stdc++.h>
#define int long long
#define endl '\n'
const int MAXN=1e3+5;
int n,m;
int val[MAXN][MAXN];
int dp[MAXN][MAXN];
signed main(){
std::cin>>n>>m;
for(int i=1;i<=n;i++)
for(int j=1;j<=m;j++)
std::cin>>val[i][j];
memset(dp,0,sizeof(dp));
for(int i=1;i<=n;i++)
for(int j=1;j<=m;j++){
if(i==1)dp[i][j]=dp[i][j-1]+val[i][j];
else if(j==1)dp[i][j]=dp[i-1][j]+val[i][j];
else dp[i][j]=std::max(dp[i][j-1],dp[i-1][j])+val[i][j];
}
std::cout<<dp[n][m]<<endl;
return 0;
}
搜索
- 动态规划和搜索的异同点:
-
动态规划和搜索都是求解状态值函数的方法。
-
动态规划是递推,搜索是递归。
-
动态规划一个重复值只算一次,搜索一个重复值可能被算多次。
-
只要能搜索就能动态规划,只要能动态规划也都能搜索。
-
劣势
搜索的搜索树辉指数级增大,而且有大量的重复,我们记录下每个状态得到的答案,这样下一次遇到这个转状态时,直接调用其答案即可,称为“记忆化搜索”。
搜索的时间复杂度是指数界别的,而记忆化搜索比较快(可能快于动态规划)。
例题 12:DP 入门题(3)
-
简要题意
一座独木桥上共有 \(n\) 个以及 \(m\) 个石头,给定这 \(m\) 个石头的位置。青蛙每次最少跳跃 \(s\) 的单位长度,最多跳跃 \(t\) 的单位长度,问青蛙跳过这座独木桥需要踩到的石子数至少是多少。
-
完整代码
#include<bits/stdc++.h>
#define int long long
#define endl '\n'
int n,m,s,t;
int a[10005];
bool stone[10005];
int dp[10005];
signed main(){
std::cin>>n>>m>>s>>t;
for(int i=1;i<=m;i++){
int x;
std::cin>>x;
stone[x]=1;
}
for(int i=1;i<=n;i++){
if(stone[i])a[i]++;
a[i]+=a[i-1];
}
for(int i=1;i<=n;i++)
for(int j=s;j<=t;j++)
dp[i+j]=std::min(dp[i]+a[i+j],dp[i+j]);
std::cout<<dp[n]<<endl;
return 0;
}
P2365 任务安排
-
题目链接
-
简要思路
\(dp_{i,j}\) 表示前 \(i\) 个机器分 \(j\) 批所需要的最小代价(当然也可以记忆化搜索)。
暴力 + 卡常 + 反复寻找优化的地方 + 吸氧(不吸氧好像也可以) + \(O(n^3)\) 水过蓝题()
-
完整代码
//注:全部不合常理的优化都是通过不断试验得到的()
#include<bits/stdc++.h>
#define int long long
#define endl '\n'
const int MAXN=4005;//MAXN 开到 4005 就够了,防止 MLE
int n,s;
int t[MAXN];//t[i] 是物品 i 处理的时间
int f[MAXN];//f[i] 是物品 i 的费用系数
int qzh_t[MAXN],qzh_f[MAXN];//前缀和 t,f 数组
int dp[MAXN][MAXN/2];//第二维度开到 2000 即可,同样防止 MLE
int query_t(int l,int r){return qzh_t[r]-qzh_t[l-1];}//求 \sum_i=l^i<=r t[i]
int query_f(int l,int r){return qzh_f[r]-qzh_f[l-1];}//求 \sum_i=l^i<=r f[i]
signed main(){
std::cin>>n>>s;
memset(dp,0x3f,sizeof(dp));//赋初始值
for(int i=1;i<=n;i++){
std::cin>>t[i]>>f[i];
qzh_t[i]=qzh_t[i-1]+t[i];
qzh_f[i]=qzh_f[i-1]+f[i];//在读入中就处理前缀和数组,防止 TLE
}
dp[0][0]=0;//边界条件
for(int i=1;i<=n;i++)
for(int j=1;j<=std::min(i,2000*1ll);j++){//取个 min,防止 TLE
int ans=1e18;
int now=query_t(1,i)+j*s;
//dp[i][j] 处理完前 i 个任务,解决了 j 次机器的情况
for(int k=std::max(0ll,i-n/j);k<i;k++){//取个 max 防止 TLE
int cost=now*query_f(k+1,i);//代价
ans=std::min(ans,dp[k][j-1]+cost);
}
dp[i][j]=ans;
}
int minn=1e18;
for(int i=1;i<=n;i++)
minn=std::min(minn,dp[n][i]);
std::cout<<minn<<endl;//求最优解
return 0;
}
背包问题
01 背包问题
-
简要题意
有 \(n\) 件物品和一个容量为 \(v\) 的背包,第 \(i\) 件物品的体积是 \(c_i\),价值是 \(w_i\)。求可以获得的最大价值。
-
简要思路
-
特点:每种物品仅有一件,可以选择放或不放。
-
状态:\(dp_{i,j}\) 代表前 \(i\) 件物品体积和为 \(j\) 可以获得的最大价值和。
-
转移方程:\(dp_{i,j}=max(dp_{i-1,j},dp_{i-1,j-c_i}+w_i)\)
-
初始化:当要求“恰好装满背包”时,我们把 \(dp\) 数组的初始值赋为 \(-\infty\);但如果没要求全部装满的话则赋值为 \(0\),因为任何容量的背包都有一个合法的“什么都不装”的解,这个解的价值为 \(0\)。
-
优化:因为 \(dp_{i,j}\) 是由 \(dp_{i-1,j}\) 和 \(dp_{i-1,j-c_i}\) 两个子问题递推来的,而且因为体积都为非负数,所以我们可以保证在推导 \(dp_{i,j}\) 之前(也就是在第 \(i\) 次主循环中推 \(dp_j\) 的时候)能够得到 \(dp_{i-1,j}\) 和 \(dp_{i-1,j-c_i}\) 的值,因此,我们可以将第一个维度的 \(i\) 去掉,以节约空间。
-
-
完整代码
完全背包
-
简要题意
有 \(n\) 种物品和一个容量为 \(v\) 的背包,每种物品数量不限。第 \(i\) 种物品的体积是 \(c_i\),价值是 \(w_i\),求可以获得的最大价值。
-
简要思路
-
状态:\(dp_{i,j}\) 表示前 \(i\) 种物品体积和为 \(v\) 的最大价值和。
-
转移方程:
-
-
完整代码
多重背包
-
简要题意
有 \(n\) 种物品和一个容量为 \(v\) 的背包。第 \(i\) 种物品的体积是 \(c_i\),价值是 \(w_i\),一共有 \(m_i\) 个,求可以获得的最大价值。
-
简要思路
Day 4 上午 —— 动态规划
树形 DP
分类
-
\(O(n)\) 的树上递推(DFS);
-
以子树为单位的转移(即 \(dp_i\) 表示子树 \(i\) 的最优解)。
P1131 [ZJOI2007] 时态同步
-
原题链接
-
简要思路
- 处理出每个节点到自己子树中最远的终止节点的时间,然后从树根开始向树上递推,每次维护补齐即可。
-
完整代码
#include<bits/stdc++.h>
#define int long long
#define endl '\n'
const int MAXN=5e5+5;
int n,root;//节点数和树根
std::vector<std::pair<int,int> > g[MAXN];
void add_edge(int s,int e,int d){
g[s].push_back(std::make_pair(e,d));
g[e].push_back(std::make_pair(s,d));
}
int dp[MAXN];//dp[i] 代表把 i 子树对齐需要的最少操作次数
int dis[MAXN];//dis[i] 代表 i 在对齐后距离自己叶子结点的距离
void dfs(int u,int f){//现在在 u,父亲是 f
int max_dis=-1;//距离儿子中最远的叶子距离有多远
for(auto p:g[u]){
if(p.first==f)continue;
dfs(p.first,u);
dp[u]+=dp[p.first];
max_dis=std::max(max_dis,p.second+dis[p.first]);//更新最远距离
}
for(auto p:g[u]){
if(p.first==f)continue;
dp[u]+=max_dis-(p.second+dis[p.first]);//差多少补多少
}
dis[u]=max_dis;
}
signed main(){
std::cin>>n>>root;
for(int i=1;i<n;i++){
int s,e,d;
std::cin>>s>>e>>d;
add_edge(s,e,d);
}
dfs(root,0);
std::cout<<dp[root]<<endl;
return 0;
}
P2607 [ZJOI2008] 骑士
-
题目链接
-
简要思路
-
把有仇恨的人的关系当做边,建立基环树。
-
环套树 DP,通过断掉一条边使其变成普通的树上 DP。
-
树上:
-
状态:\(dp_{i,0/1}\) 代表以 \(i\) 为根的子树 选/不选 \(i\) 号点的最大独立集权值和。
-
转移方程:当 \(i\) 这个点不选时,其值等于它所有的儿子选或不选的最大值;当 \(i\) 这个点选时,其值等于它所有的儿子不选中的最大值。
-
-
环上:
-
处理完外面树上的所有情况后,在环上随便选一条边。
-
分别枚举该条边的两个没选,然后跑两遍 DP 记录最大值。
-
-
-
完整代码
#include<bits/stdc++.h>
#define int long long
#define endl '\n'
const int MAXN=1e6+5;
inline int read(){//要用快读否则会 TLE
int x=0,f=1;//x 表示我们读到值,f 表示数的符号
char ch=getchar();//getchar 是获得输入内容的下一个字符
while(!isdigit(ch)){//判断不是数字
if (ch=='-') f=-1; // 说明输入一个负数
ch=getchar(); // 不然的话下一个字符
}
while (isdigit(ch)){//判断是数字
x=x*10+(ch-'0');//x=123, x=1234 = 123 * 10 + 4
ch=getchar();//读下一个字符
}
return x*f;//数字乘上符号
}
int ecnt;//点的编号
std::vector<std::pair<int,int> > g[MAXN];
//first 为指向的点的编号,second 为边的编号
void add_edge(int s,int e){
ecnt++;
g[s].push_back(std::make_pair(e,ecnt));
g[e].push_back(std::make_pair(s,ecnt));
//注意加入的是双向边,否则如果两个人互相有仇恨的话就会出锅
}
int val[MAXN];//每个人的战斗力
int t[MAXN];//每个人痛恨的人
bool v[MAXN];//v[i] 表示第 i 个点有没有访问过,以来判断是否为环
int dp[MAXN][2];//dp[i][1/0] 表示以 i 为根的子树 选/不选 i 号点的最大独立集权值和
int ans;
int del_vertex1,del_vertex2,del_edge;
//环上有一条 del_vertex1 到 del_vertex2 的编号为 del_edge 的边,我们要删除这条边
void find_ring(int x,int f){//寻找环
//现在正在搜索点 x
//因为我们是建的双向边,所以我们要记录父亲,以防回去无线递归
v[x]=1;//记录已经被访问过了
for(auto p:g[x]){//遍历所有与 x 相连的节点
int y=p.first;//遍历到了一条 x 到 y 的边
if(y==f)continue;//如果是父亲就跳过
if(v[y]==0){//没访问过,递归进去
find_ring(y,x);//继续搜索 y,其父亲为 x
}else{//遍历到了一个已经访问过的点,即找到了环
del_vertex1=x;
del_vertex2=y;
del_edge=p.second;//边的编号
}
}
}
//n 点 n-1 条的边的图上,在一个连通图中最多只有一个环,而我们删掉了 del_edge,所以剩下的就是一棵树
void dp_evaluation(int x,int f){//树上 dp 求值
dp[x][0]=0;//初始化,不选
dp[x][1]=val[x];//初始化,只选自己
for(auto p:g[x]){//遍历所有与 x 相邻的点
int y=p.first;//遍历到了一条 x 到 y 的边
if(y==f)continue;//如果是父亲就跳过
if(p.second==del_edge)continue;//如果这条边被删除了也跳过
//上面一行也可以写成:if(x==del_vertex1&&y==del_vertex2)continue;
dp_evaluation(y,x);//先递归求得儿子的值之后才能进行 dp 数组的转移
//转移方程
dp[x][0]+=std::max(dp[y][0],dp[y][1]);//不选的话与儿子的选与不选没有关系
dp[x][1]+=dp[y][0];//如果选的话,自己的儿子就一定不能选
}
}
void get_ans(){//算答案
int tmp=-1;
//因为要断开 del_edge 这条边
//所以我们分别 dp del_vertex1 不选和 del_vertex2 不选的情况
dp_evaluation(del_vertex1,0);//第一遍 dp,把 del_vertex1 作为根节点
tmp=std::max(tmp,dp[del_vertex1][0]);//我强制 del_vertex1 不选
//因为 51~52 行每次 dp 求值时已经是重新赋值了,所以不需要重置清空 dp 数组
dp_evaluation(del_vertex2,0);//第二遍 dp,把 del_vertex2 作为根节点
tmp=std::max(tmp,dp[del_vertex2][0]);//强制 del_vertex2 不选
ans+=tmp;//注意是加上 tmp,并不是赋值等于,因为可能出现有多个联通图(即与多个环的情况)
}
signed main(){
int n=read();
for(int i=1;i<=n;i++){
val[i]=read();
t[i]=read();
add_edge(i,t[i]);
//第 i 个人和第 t[i] 个人有仇恨,加入双向边
}
for(int i=1;i<=n;i++){//循环判断,防止出现多个联通图的情况
if(v[i]==0){//没有遍历过,找到了一个新的基环树
find_ring(i,0);//以 i 为树根进行树上 DFS,找环
get_ans();//计算答案
}
}
std::cout<<ans<<endl;
return 0;
}
区间 DP
-
区间 DP 一般解决一些区间上的问题,往往和序列 DP 较难区分,如果一道题序列 DP 无法处理,可以考虑区间 DP。
-
通常 \(dp_{i,j}\) 表示区间 \([i,j]\) 的最优接是多少,然后根据枚举分界点来进行转移。
-
特点:\(n\) 一般都很小,一般只有 \([50,2000]\)。
P4302 [SCOI2003] 字符串折叠
-
题目链接
-
简要思路
-
状态:\(dp_{i,j}\) 代表区间 \([i,j]\) 压缩后的最小长度。
-
根据三种折叠构造方法,我们可以得到三种转移的方法:
-
第一种形式 \(s->s\) 不用变化,直接赋值即可;
-
第二种形式,枚举子区间,看这个子区间是不是这个区间的循环节。该方法的形式为 \(X(s)\),那么答案就是前面的数字 \(X\) 的位数 \(+\) 循环节的长度 + 两个括号的长度 \(2\);
-
第三种形式,枚举分界点。
-
-
方法:记忆化搜索/递推 DP 求解
-
-
完整代码
#include<bits/stdc++.h>
#define int long long
#define endl '\n'
const int MAXN=105;
int dp[MAXN][MAXN];//dp[i][j] 代表 [i,j] 压缩之后的最优长度
int len;//字符串长度
char s[MAXN];
int get_num(int x){//得到数字 x 的位数
int num=0;
while(x){
x/=10;
num++;
}
return num;
}
//检查区间 [l,r] 有没有 [l,k] 这个循环节
bool check(int l,int r,int k){
//[l,r] 的长度要是 [l,k] 的倍数
int len_r=r-l+1,len_k=k-l+1;
if(len_r%len_k!=0)return false;
int tmp=len_r/len_k;//表示成 tmp(xxx)
for(int i=2;i<=tmp;i++){//枚举 i 个循环节
for(int j=1;j<=len_k;j++){//枚举循环节中的第 j 个位置
char a=s[l+(i-1)*len_k+j-1];//第 i 个循环节第 j 个字符的位置
char b=s[l+j-1];//第 1 个循环节第 j 个字符的位置
if(a!=b)return false;//只要有一个不一样就返回 false
}
}
return true;
}
//记忆化搜索版本
int ms_dp(int l,int r){//压缩区间 [l,r] 的最小长度
if(dp[l][r]!=0x3f3f3f3f)
return dp[l][r];//记忆化直接返回
int tmp=r-l+1;//第一种折叠,不变化
for(int k=l;k<r;k++){//第二种折叠 X(s)
if(check(l,r,k)){//[l,k] 是 [l,r] 的循环节
int len_num=get_num((r-l+1)/(k-l+1));//X(s) 前面数字 X 的位数
tmp=std::min(tmp,ms_dp(l,k)+2/*左右括号*/+len_num);
}
}
for(int k=l;k<r;k++)//第三种折叠,枚举分界点
tmp=std::min(tmp,ms_dp(l,k)+ms_dp(k+1,r));
dp[l][r]=tmp;
return tmp;
}
//非记忆化搜索版本(递推版本
void recursion_dp(){
//区间 dp 的递推版要保证算某个区间的时候,所有它的子区间都计算过了
//区间左端点从大到小枚举,右端点随意
for(int l=len;l>=1;l--)
for(int r=l;r<=len;r++){
//现在要计算 dp[l][r]
int tmp=r-l+1;//第一种折叠,不变化
for(int k=l;k<r;k++){//第二种折叠 X(s)
if(check(l,r,k)){//[l,k] 是 [l,r] 的循环节
int len_num=get_num((r-l+1)/(k-l+1));//X(s) 前面数字 X 的位数
tmp=std::min(tmp,dp[l][k]+2/*左右括号*/+len_num);
}
}
for(int k=l;k<r;k++)//第三种折叠,枚举分界点
tmp=std::min(tmp,dp[l][k]+dp[k+1][r]);
dp[l][r]=tmp;
}
}
signed main(){
std::cin>>s+1;
len=strlen(s+1);
memset(dp,0x3f,sizeof(dp));
// std::cout<<ms_dp(1,len)<<endl;//记忆化搜索版本
recursion_dp();
std::cout<<dp[1][len]<<endl;//非记忆化版本(递推版本
return 0;
}
Day 4 下午 —— 动态规划
P4170 [CQOI2007] 涂色
-
简要思路
-
状态:\(dp_{i,j}\) 代表 \([i,j]\) 这个区间涂成目标颜色所需的最小操作次数。
-
因为是区间涂色,所以如果 \(s_l=s_r\),则可以用一次覆盖 \([l,r]\),最后中间的部分再另外考虑。
-
转移方程:
当左右端点的颜色一样时,\(dp_{i,j}=min(dp_{i+1,j},dp_{i,j-1})\)
否则 \(dp_{i,j}=min(dp_{i,k}+dp_{k+1,r})\)
-
-
完整代码
#include<bits/stdc++.h>
#define int long long
#define endl '\n'
char s[105];
int dp[105][105];
signed main(){
std::cin>>s+1;
int n=strlen(s+1);
memset(dp,0x3f,sizeof(dp));
for(int i=1;i<=n;i++)dp[i][i]=1;//初始化
for(int len=2;len<=n;len++)//枚举区间长度
for(int i=1;i+len-1<=n;i++){//枚举左端点
int j=i+len-1;//计算右端点
if(s[i]==s[j]){//如果两个端点一样
dp[i][j]=std::min(dp[i+1][j],dp[i][j-1]);//往中间靠
}else{
for(int k=i;k<j;k++)//找分界点
dp[i][j]=std::min(dp[i][j],dp[k+1][j]+dp[i][k]);
}
}
std::cout<<dp[1][n]<<endl;
return 0;
}
P3205 [HNOI2010] 合唱队
-
题目链接
-
简要思路
-
记忆化搜索/DP。
-
\(dp_{i,j,0/1}\) 表示区间 \([i,j]\) 中最后一个人在最左边/最右边的方案数。分别枚举最后一个人在左边/右边以及倒数第二个人在左边/右边的情况,注意只有满足题目给定的条件才能继续往下进行。
-
-
完整代码
- 记忆化搜索
#include<bits/stdc++.h> #define int long long #define endl '\n' const int mod=19650827; const int MAXN=1005; int n,h[MAXN]; int dp[MAXN][MAXN][5];//记忆化 //把区间 [l,r] 的人排成当前队列,并且最后一个进入队列的入队列是 last 位置的人 //last=0 表示在队伍最左边,last=1 表示在队伍最右边 int dfs(int l,int r,int last){ if(l==r)return 1; if(dp[l][r][last]<1e8)return dp[l][r][last]; int ans=0; //考虑最后加入人后,上一个人在哪里 if(last==0){//最后一个入队的人在左边 int height=h[l];//最后一个加入的人的高度 //考虑倒数第二个人 //(l+1,r,0) 上一个人是剩余的最左边 //那么最后一个人必须比倒数第二个人矮 if(height<h[l+1])ans+=dfs(l+1,r,0); //(l+1,r,1) 上一个人是剩余的最右边 if(height<h[r]&&l+1!=r)ans+=dfs(l+1,r,1);//l+1=r 时防止重复计算 }else{ int height=h[r];//最后一个加入的人的高度 //考虑倒数第二个人 //(l,r-1,0) 上一个人是剩余的最左边 //那么最后一个人必须比倒数第二个人高 if(height>h[l])ans+=dfs(l,r-1,0); //(l+1,r,1) 上一个人是剩余的最右边 if(height>h[r-1]&&l!=r-1)ans+=dfs(l,r-1,1); } dp[l][r][last]=ans%mod; return ans%mod; } signed main(){ std::cin>>n; for(int i=1;i<=n;i++)std::cin>>h[i]; memset(dp,0x3f,sizeof(dp)); if(n==1){ std::cout<<1<<endl; }else{ std::cout<<(dfs(1,n,0)+dfs(1,n,1))%mod<<endl; } return 0; }- DP
#include<bits/stdc++.h> #define int long long #define endl '\n' const int MAXN=1005; const int mod=19650827; int n; int h[MAXN]; int dp[MAXN][MAXN][2]; //表示区间 [i,j] 最后一个人一定是 最左边/右边 可以的方案数 signed main(){ std::cin>>n; for(int i=1;i<=n;i++)std::cin>>h[i]; for(int i=1;i<=n;i++) dp[i][i][0]=dp[i][i][1]=1;//初始化条件 for(int i=n;i>=1;i--){//区间 DP 的遍历方式 for(int j=i+1;j<=n;j++){ //dp[i][j][0] 初始为 0 if(h[i]<h[j]){//最后一个人在左端点 i,倒数第二个人在 j dp[i][j][0]=(dp[i][j][0]+dp[i+1][j][1])%mod; } //保证 (i,j) 不为紧挨着的两个数,避免重复计算 if(h[i]<h[i+1]&&i+1!=j){//最后一个人在左端点 i,倒数第二个人在 i+1 dp[i][j][0]=(dp[i][j][0]+dp[i+1][j][0])%mod; } if(h[j]>h[i]){//最后一个人在右端点 j,倒数第二个人在 i dp[i][j][1]=(dp[i][j][1]+dp[i][j-1][0])%mod; } //同 24 行,避免重复计算 if(h[j]>h[j-1]&&i+1!=j){//最后一个人在右端点 j,倒数第二个人在 j-1 dp[i][j][1]=(dp[i][j][1]+dp[i][j-1][1])%mod; } } } std::cout<<(dp[1][n][0]+dp[1][n][1])%mod<<endl; return 0; }
常见 DP 优化
常见方法
P2513 [HAOI2009] 逆序对数列
-
题目链接
-
弱化版
-
简要思路
滚动数组
Day5 ———— 图论
概念介绍
-
基本要素
点和边(树是有点和有边的一种结构)。
-
基本含义
-
度:在无向图中,顶点 \(v\) 的度是指与顶点 \(v\) 相连的边的数目。
-
入度:在有向图中,顶点 \(v\) 的度数为以顶点 \(v\) 为终点的边的数目和。
-
出度:在有向图中,顶点 \(v\) 的度数为以顶点 \(v\) 为起点的边的数目和。
-
自环:一条边的起点和终点为同一个点。
-
简单路径:不能走重复点和边的路径。
-
环:起点 \(=\) 终点的路径。
-
简单环:简单路径和环的集合体,保证起点 \(=\) 终点且除起点(终点)外不能经过重复的点和边的路径。
-
图的分类
-
根据边的属性来分类:
-
有向图
-
无向图
-
-
根据图的结构来分类:
-
树:无向无环且联通(任意两点间都可以互相到达)的图。\(n\) 个点,\(n-1\) 条边。树 \(+\) 一条边 \(=\) 章鱼图。
-
森林:无向无环但不连通的图(有很多的树组成的图)。
-
有向树:有向无环联通的图。
-
外向树:所有的边都向外处指的有向树。
-
内向树:所有的边都指向一个点的有向树。
-
章鱼图:无向联通且只有一个环的图(把若干棵树的某一个点用环串通在一起)。\(n\) 个点,\(n\) 条边。章鱼图 \(-\) 一条边 \(=\) 树。
-
(边)仙人掌:无向联通且有多个环的图(但要满足任何一条边都只在一个环中)。
-
DAG:有向无环图。
-
二分图:
把无向图的所有的点分为两个部分,第一部分的点连接的一定是第二部分的点,第二部分的点连接的一定是第一部分的点。也就是说一条边一定是连接第一部分和第二部分的点。不要求联通。
树和森林就是二分图。在树中,深度为奇数的为第一部分,深度为偶数的为第二部分。
存在奇环(奇数的环)的不是二分图,没有奇环的就是二分图。
同样,所有的二分图也一定没有奇环。
-
图的存储
邻接矩阵
#include<bits/stdc++.h>
#define int long long
#define endl '\n'
const int MAXN=1005;
int n,m;
int z[MAXN][MAXN];//z[i][j] 表示从点 i 到点 j 的连边的长度
//缺点:消耗空间大,需要 n^2 的内存
//优点:去 i->j 这条边的信息只需要 O(1)
//邻接矩阵加边
void add_edge(int s,int e,int d){//添加一条从 s 到 j 长度为 d 的边
z[s][e]=d;
}
signed main(){
std::cin>>n>>m;
for(int i=1;i<=n;i++){
int s,e,d;
std::cin>>s>>e>>d;
add_edge(s,e,d);
}
return 0;
}
-
缺点:消耗空间大,需要 \(n^2\) 的内存。
-
优点:取 \(i\)->\(j\) 这条边的信息只需要 \(O(1)\)。
边表
#include<bits/stdc++.h>
#define int long long
#define endl '\n'
const int MAXN=1005;
int n,m;
std::vector<std::pair<int,int> > g[MAXN];
//i 为边的起点
//g[i].first 为边的终点
//g[i].second 为边的长度(权值)
//vector 存图(边表)
//优点:只需要 O(n+m) 的内存
//缺点:取 i->j 这条边的信息需要 O(n)。
void add_edge(int s,int e,int d){
g[s].push_back(std::make_pair(e,d));
g[e].push_back(std::make_pair(s,d));//无向图
}
signed main(){
std::cin>>n>>m;
for(int i=1;i<=n;i++){
int s,e,d;
std::cin>>s>>e>>d;
add_edge(s,e,d);
}
return 0;
}
-
优点:只需要 \(O(n+m)\) 的内存。
-
缺点:取 \(i\)->\(j\) 这条边的信息需要 \(O(n)\)。
例题 13:判断二分图
-
简要题面
给定一张 \(n\) 个点和 \(m\) 条边的图,判断是否是二分图。
-
简要思路
染色法:把一个未染色的点染色为 \(1\),然后一层一层向外染色。如果一条边连接的两个点的颜色一样,就说明不是二分图。注意要不断地循环找未染色的点,因为不保证联通。
-
完整代码
#include<bits/stdc++.h>
#define int long long
#define endl '\n'
const int MAXN=1005;
int n,m;
std::vector<int> g[MAXN];
int col[MAXN];
//col[i]==0 i 还没决定放哪边
//col[i]==1 i 点放左边
//col[i]==2 i 点放右边
void add_edge(int s,int e){
g[s].push_back(e);
g[e].push_back(s);
}
signed main(){
std::cin>>n>>m;//n 点 m 边
for(int i=1;i<=m;i++){
int s,e;
std::cin>>s>>e;
add_edge(s,e);
}
bool able=true;
for(int i=1;i<=n;i++){//for 循环不断寻找
if(col[i]==0){//还没被决定
col[i]=1;//放左边
std::queue<int> q;//还需要更新周围点放哪边的那些点
q.push(i);
while(q.size()){
int now=q.front();
q.pop();
for(int i=0;i<g[now].size();i++){//可以与 27 行重复,因为定义域不同
int j=g[now][i];//这是一条从 now 到 j 的边
if(col[j]==0){//没有放
col[j]=3-col[now];
q.push(j);
}else if(col[now]==col[j]){//相邻颜色相同,不是二分图
std::cout<<"No\n";
return 0;
}
}
}
}
}
std::cout<<"Yes\n";
return 0;
}
B3644
最短路问题
多源最短路
Floyd(B3647)
-
简要思路
\(dist_{i,j,k}\) 代表从 \(j\) 走到 \(k\) 且走过的点的编号都 \(\leq i\) 的最短路。最后求出 \(dist_{n,j,k}\) 来作为我们的答案。
如果 \(j\) 到 \(k\) 有边,\(dist_{0,j,k}=d_{j,k}\)。
如果 \(j\) 到 \(k\) 无边,\(dist_{0,j,k}=∞\)。\(dist_{i,j,k}=min(dist_{i-1,j,k},dist_{i-1,j,i}+dist_{i-1,i,k})\)
-
完整代码
#include<bits/stdc++.h>
#define int long long
#define endl '\n'
const int MAXN=205;
int n,m,q;
int dist[MAXN][MAXN][MAXN];
//dist[i][j][k] 代表从 j 走到 k 经过的点(不包含起点终点)的编号 <= i 的最短路
signed main(){
std::cin>>n>>m>>q;
memset(dist,0x3f,sizeof(dist));//最短路赋值为无穷大
for(int i=1;i<=n;i++)
dist[0][i][i]=0;//初始化自己走到自己
for(int i=1;i<=m;i++){
int s,e,d;
std::cin>>s>>e>>d;
dist[0][s][e]=std::min(dist[0][s][e],d);//可能有重边
}
for(int i=1;i<=n;i++)
for(int j=1;j<=n;j++)
for(int k=1;k<=n;k++)
dist[i][j][k]=std::min(dist[i-1][j][k]/*等于 i 的情况*/,dist[i-1][j][i]+dist[i-1][i][k]/*小于 i 的情况*/);
while(q--){
int s,e;
std::cin>>s>>e;
std::cout<<dist[n][s][e]<<endl;
}
return 0;
}
-
压维优化
因为每个 \(i\) 都可以从 \(i-1\) 推来,根据滚动数组的思想,我们就可以把这一维度删掉,以达到空间为 \(n^2\) 级别。
-
完整代码
#include<bits/stdc++.h>
#define int long long
#define endl '\n'
const int MAXN=205;
int n,m,q;
int dist[MAXN][MAXN];
//dist[j][k] 代表从 j 走到 k 的最短路
signed main(){
std::cin>>n>>m>>q;
memset(dist,0x3f,sizeof(dist));//最短路赋值为无穷大
for(int i=1;i<=n;i++)
dist[i][i]=0;//初始化自己走到自己
for(int i=1;i<=m;i++){
int s,e,d;
std::cin>>s>>e>>d;
dist[s][e]=std::min(dist[s][e],d);//可能有重便边
}
for(int i=1;i<=n;i++)
for(int j=1;j<=n;j++)
for(int k=1;k<=n;k++)
dist[j][k]=std::min(dist[j][k]/*等于 i 的情况*/,dist[j][i]+dist[i][k]/*小于 i 的情况*/);
while(q--){
int s,e;
std::cin>>s>>e;
std::cout<<dist[s][e]<<endl;
}
return 0;
}
单源最短路
Dijkstra
限制:边的权值必须都是正数。
- 简要思路
每次选取 dist 值最小的值,也就是选取已经求出最短路的点(因为边的权值都是正数,所以它当前是最小值,那后面也不会有),然后对其进行松弛操作(用自己的最短路去更新其他点的最短路)。
- 完整代码
#include<bits/stdc++.h>
#define int long long
#define endl '\n'
const int MAXN=2e5+5;
int N,M,S;
std::vector<std::pair<int,int> > g[MAXN];
void add_edge(int s,int e,int d){
g[s].push_back(std::make_pair(e,d));
}
int dist[MAXN];//最短路
bool done[MAXN];//是否已经求出最短路
void Dijkstra(int s){//计算 s 到其他所有点的最短路
memset(dist,0x3f,sizeof(dist));
dist[s]=0;
for(int i=1;i<=N;i++){
//找还没有求出最短的 dist 值最小的那个点
int p=0;
for(int j=1;j<=N;j++)
if(!done[j]&&(p==0||dist[j]<dist[p]))p=j;
done[p]=1;
//松弛操作
for(int j=0;j<g[p].size();j++){
int q=g[p][j].first;
int d=g[p][j].second;//这是一条从 p 到 q 长度为 d 的边
dist[q]=std::min(dist[q],dist[p]+d);
}
}
}
signed main(){
std::cin>>N>>M>>S;
for(int i=1;i<=M;i++){
int s,e,d;
std::cin>>s>>e>>d;
add_edge(s,e,d);
}
Dijkstra(S);
for(int i=1;i<=N;i++)std::cout<<(dist[i]!=0x3f3f3f3f3f3f3f3f?dist[i]:2147483647)<<" \n"[i==N];
return 0;
}
- 优化
注意到我们每次取 dist 值最小的点都是一个 \(O(n)\) 的循环,事件复杂度会变大,所以我们维护一个堆来存储 dist 值的信息。
- 完整代码
#include<bits/stdc++.h>
#define int long long
#define endl '\n'
const int MAXN=2e5+5;
int N,M,S;
std::vector<std::pair<int,int> > g[MAXN];
void add_edge(int s,int e,int d){
g[s].push_back(std::make_pair(e,d));
}
int dist[MAXN];//最短路
bool done[MAXN];//是否求过
void Dijkstra(int s){//计算 s 到其他所有点的最短路
memset(dist,0x3f,sizeof(dist));
dist[s]=0;
std::priority_queue<std::pair<int,int> > heap;
//大根堆存相反数
//first 为最短路的相反数,second 为点的编号
for(int i=1;i<=N;i++)
heap.push(std::make_pair(-dist[i],i));
for(int i=1;i<=N;i++){
while(done[heap.top().second])heap.pop();
//找还没有求出最短的 dist 值最小的那个点
int p=heap.top().second;
heap.pop();
done[p]=1;
//松弛操作
for(int j=0;j<g[p].size();j++){
int q=g[p][j].first;
int d=g[p][j].second;//这是一条从 p 到 q 长度为 d 的边
if(dist[q]>dist[p]+d){
dist[q]=dist[p]+d;
heap.push(std::make_pair(-dist[q],q));
}
}
}
}
signed main(){
std::cin>>N>>M>>S;
for(int i=1;i<=M;i++){
int s,e,d;
std::cin>>s>>e>>d;
add_edge(s,e,d);
}
Dijkstra(S);
for(int i=1;i<=N;i++)std::cout<<(dist[i]!=0x3f3f3f3f3f3f3f3f?dist[i]:2147483647)<<" \n"[i==N];
return 0;
}
Bellman_ford
#include<bits/stdc++.h>
#define int long long
#define endl '\n'
const int MAXN=100010;
const int MAXM=200010;
int n,m,s;
int dist[MAXN];
int s[MAXM],e[MAXM],d[MAXM];
signed main(){
std::cin>>n>>m>>s;
for(int i=1;i<=m;i++)
std::cin>>s[i]>>e[i]>>d[i];
memset(dist,0x3f,sizeof(dist));
dist[s]=0;
for(int i=1;i<n;i++)
for(int i=1;i<=m;i++)
dist[e[j]]=std::min(dist[e[j]],dist[s[j]]+d[j]);
for(int i=1;i<=n;i++)
std::cout<<dist[i]<<endl;
return 0;
}
SPFA
本质上是 Bellman_ford 的优化。
-
简要思路
维护一个队列,表示可能改变其他点最短路的点。不断向队列中加入可能改变其他点的最短路,然后再把新的点加入队列中,直至队列为空。
-
完整代码
#include<bits/stdc++.h>
#define int long long
#define endl '\n'
const int MAXN=2e5+5;
int N,M,S;
std::vector<std::pair<int,int> > g[MAXN];
void add_edge(int s,int e,int d){
g[s].push_back(std::make_pair(e,d));
}
int dist[MAXN];//dist[i] 表示到达 i 点的最短路的长度
bool in_queue[MAXN];//i 点是否在队列中
void SPFA(int S){//计算 s 到其他所有点的最短路
memset(dist,0x3f,sizeof(dist));
dist[S]=0;
std::queue<int> q;//用来存储可能改变其他点最短路的点
q.push(S);
in_queue[S]=true;
//最坏 O(nm)
//平均 O(km) k<20
while(q.size()){//队列不为空
int s=q.front();
q.pop();
in_queue[s]=false;
for(int i=0;i<g[s].size();i++){
int e=g[s][i].first;
int d=g[s][i].second;
if(dist[e]>dist[s]+d){
dist[e]=dist[s]+d;
if(in_queue[e]==0){
in_queue[e]=true;
q.push(e);
}
}
}
}
}
signed main(){
std::cin>>N>>M>>S;
for(int i=1;i<=M;i++){
int s,e,d;
std::cin>>s>>e>>d;
add_edge(s,e,d);
}
SPFA(S);
for(int i=1;i<=N;i++)
std::cout<<dist[i]<<" \n"[i==N];
return 0;
}
总结
-
当求多源最短路时,用 Floyd;
-
当求单元最短路且有负数权值时,用 SPFA;
-
当求单源最短路1且无负数边权时,用 Dijkstra 带堆优化的版本。
生成树
例题 13:生成树的引入
- 简要题面
给定 \(n\) 点 \(m\) 边的图,问能否选择 \(n\) 个点和 \(n-1\) 条边,使其变成一棵树,这棵树就是生成树。
-
简要思路
只要是联通的图就一定有生成树。
最小生成树
边上路径和最少的生成树称为最小生成树。
Kruscal
P3366 【模板】最小生成树
- 题目链接
- 简要思路
按照边权的大小从小到大进行排序,然后从小到大枚举,如果不会构成环就可以将这条边加入,否则就不能加入。判断是否会构成环用并查集判断。
- 完整代码
#include<bits/stdc++.h>
#define int long long
#define endl '\n'
const int MAXN=100010;
const int MAXM=200010;
int n,m;//n 点 m 边
struct node{
int s,e,d;
}edge[MAXM];
bool cmp(node a,node b){//把边按照边权从小到大
return a.d<b.d;
}
int f[MAXN];
int get_fa(int p){//查询点 p 属于哪个并查集(根节点是谁
if(p==f[p])return p;
else return f[p]=get_fa(f[p]);
}
signed main(){
std::cin>>n>>m;
for(int i=1;i<=m;i++)
std::cin>>edge[i].s>>edge[i].e>>edge[i].d;
std::sort(edge+1,edge+m+1,cmp);//把边从小到大排序
for(int i=1;i<=n;i++)f[i]=i;;//初始化并查集
int ans=0;
int cnt=0;//当前生成树的边数,判断合不合法
for(int i=1;i<=m;i++){//尝试加入第 i 条边
int fa_s=get_fa(edge[i].s);
int fa_e=get_fa(edge[i].e);
if(fa_s!=fa_e){//不属于同一个并查集,即不联通
f[fa_s]=fa_e;//归并并查集
ans+=edge[i].d;//边权加上
cnt++;
}
}
if(cnt!=n-1)std::cout<<"orz\n";
else std::cout<<ans<<endl;
return 0;
}
例题 14:生成树练习题(HDU4736)
- 简要题面
\(n\) 个点 \(m\) 条边,每条边是黑色或白色,问是否存在某一棵生成树,使得白色边的数量是斐波那契数列中的某一项。
- 小技巧
先求一遍最小生成树/最大生成树。
-
简要思路
-
把白色的边赋值为 \(0\),黑色的边赋值为 \(1\)。
-
求最小生成树,最多有 \(x\) 条白边。
-
求最大生成树,最少有 \(y\) 条白边。
-
只要斐波那契数列中的某一项在 \(x\) 和 \(y\) 之间,那么就有答案(因为可以让黑边替代白边或白边替代黑边)。
-
P1967 [NOIP2013 提高组] 货车运输
- 题目链接
-
简要思路
- 最大生成树求路径,LCA 求树上路径的最小值(限重)。
-
完整代码
#include<bits/stdc++.h>
#define int long long
#define endl '\n'
const int MAXN=100010;
const int MAXM=200010;
int n,m,k;
struct node{
int s,e,d;
}edge[MAXM];
bool cmp(node a,node b){//把边按照边权从小到大
return a.d>b.d;
}
int f_bcj[MAXN];
int get_fa(int p){//查询点 p 属于哪个并查集(根节点是谁
if(p==f_bcj[p])return p;
else return f_bcj[p]=get_fa(f_bcj[p]);
}
std::vector<std::pair<int,int> > g[MAXN];
void add_edge(int s,int e,int d){
g[s].push_back(std::make_pair(e,d));
g[e].push_back(std::make_pair(s,d));
}
int f[MAXN][20];//从 i 向上走 2^j 步会走到哪里
int fe[MAXN][20];//从 i 向上走 2^j 步经过所有边的最小值
int depth[MAXN];//i 点的深度
void dfs(int p,int fa,int fd){//dfs p,父亲为 fa,边权为 fd
depth[p]=depth[fa]+1;
f[p][0]=fa;
fe[p][0]=fd;
for(int i=1;i<=18;i++){
f[p][i]=f[f[p][i-1]][i-1];
fe[p][i]=std::min(fe[p][i-1],fe[f[p][i-1]][i-1]);
}
for(int i=0;i<g[p].size();i++)
if(depth[g[p][i].first]==0)
dfs(g[p][i].first,p,g[p][i].second);
}
int get_min(int p1,int p2){
int ans=1e18;
if(depth[p1]<depth[p2])std::swap(p1,p2);
for(int i=18;i>=0;i--)
if(depth[f[p1][i]]>=depth[p2]){
ans=std::min(ans,fe[p1][i]);
p1=f[p1][i];
}
if(p1==p2)return ans;
for(int i=18;i>=0;i--)
if(f[p1][i]!=f[p2][i]){
ans=std::min(ans,std::min(fe[p1][i],fe[p2][i]));
p1=f[p1][i];
p2=f[p2][i];
}
ans=std::min(ans,std::min(fe[p1][0],fe[p2][0]));
if(f[p1][0]!=f[p2][0])return -1;
return ans;
}
signed main(){
std::cin>>n>>m;
for(int i=1;i<=m;i++)
std::cin>>edge[i].s>>edge[i].e>>edge[i].d;
std::sort(edge+1,edge+m+1,cmp);//把边从小到大排序
for(int i=1;i<=n;i++)f_bcj[i]=i;;//初始化并查集
for(int i=1;i<=m;i++){//尝试加入第 i 条边
int fa_s=get_fa(edge[i].s);
int fa_e=get_fa(edge[i].e);
if(fa_s!=fa_e){//不属于同一个并查集,即不联通
f_bcj[fa_s]=fa_e;//归并并查集
add_edge(edge[i].s,edge[i].e,edge[i].d);
}
}
for(int i=1;i<=n;i++)
if(depth[i]==0)dfs(i,0,0);
std::cin>>k;
while(k--){
int p1,p2;
std::cin>>p1>>p2;
if(get_fa(p1)!=get_fa(p2))std::cout<<-1<<endl;
else std::cout<<get_min(p1,p2)<<endl;
}
return 0;
}
次小生成树
-
简要思路
第二小的生成树。
删掉一条边,再加上一条边,使得差值尽量小,并且要是一个树。
如果一条边在最小生成树上,我们就叫他树边,如果不在最小生成树上就叫他非树边。
删掉一条树边,加上一条非树边。
倍增 LCA 询问环上最大的值(章鱼图)。
强联通分量
B3609 [图论与代数结构 701] 强连通分量
-
题目链接
-
强联通分量定义
在有向图中,找到若干个点和若干条边,使得每个点都可以互相走到,构成的图就叫做强连通分量。
独立的点也可以作为一个强连通分量。
每个点都一定在一个强连通分量里。
-
简要思路
Day6 上午 ———— 数学
快速幂
-
简要题意
计算 \(x^y \ mod p\)。
-
简要思路
指数不断分开
模运算
例题 15:模运算例题(1)
-
简要题面
计算 \(n! \mod p\)。
-
简要思路
要边运算边取模,否则会爆掉
-
完整代码
#include<bits/stdc++.h>
#define int long long
#define endl '\n'
int n,p;
signed main(){
std::cin>>n>>p;
int ans=1;//乘法初始值为 1
for(int i=1;i<=n;i++){
ans*=i;
ans%=p;//及时取模
}
std::cout<<ans<<endl;
return 0;
}
例题 16:模运算例题(2)
-
简要题意
计算 \((n! - m) \mod p\)。
-
简要思路
详见注释。
-
完整代码
#include<bits/stdc++.h>
#define int long long
#define endl '\n'
int n,m,p;
signed main(){
std::cin>>n>>m>>p;
int ans=1;
for(int i=1;i<=n;i++)
ans=ans*i%p;//注意这里防止爆 int
ans=((ans-m)%p+p)%p;//防止出现负数的问题
//减法取模要手动加上模数
return 0;
}
例题 17:模运算例题(3)
-
简要题意
计算 \(a / b \mod p\)。
逆元
费马小定理
当 \(p\) 是质数且 \(gcd(a,p)=1\) 时
欧拉定理
Ex_gcd(扩展欧几里得算法)
判断质数
Day6 下午 ———— STL
C++ 标准库
-
包含
输入输出,文件 IO,基本数据结构,内存管理,多线程支持等。
-
理解
因为有些函数大家基本都要用,所以 C++ 就“自带”了这些函数。
-
网站
STL 介绍
-
全称
标准模板库(Standard Template Library, STL),俗称“标准库”,是 C++ 标准库的一部分,里面包含了一些模板化的通用的数据结构和算法。
-
人话
C++ 标准库提供了一些数据结构(如队列)与算法(如排序),你可以直接用,不用手写了。
-
包含
-
STL 类型:比如字符串
string、二元对pair。 -
STL 容器:比如可变长数组
vector、有序集合set、映射map、队列queue、栈stack、堆priority_queue。 -
STL 算法:比如排序
sort、翻转reverse,随机打乱random_shuffle。
-
STL 类型
-
简要解释
-
int、long long、float、数组等。 -
传统的“字符串”(
char数组)并不是基本类型,用起来很麻烦(比如需要注意长度不能超、不能直接赋值等);某一些很常用的 “复合类型”(比如二元对pair)不是基本类型,大家需要手写。 -
所以,STL 库中包含了许多这种 “不在 C++ 标准中的,却很实用的” 类型,俗称 “STL 类型”。
-
字符串 string
在原生 C++ 语言中,想要存储字符串,需要开一个 char 数组。但是,这样很不方便,比如不能直接拷贝、数组长度不知道应该开多大。
我们希望,字符串能像 int 一样,能直接赋值、随意创建、并支持一些运算(比如字符串拼接就是 \(+\))。
-
想要使用
string的话,请在程序开头加上#include <string>。 -
创建
string:和正常的变量差不多,string a。其中 a 是变量名。
也可以直接 string a = "233" 来创建一个具有初始值的 string
• cin / cout :类似正常的变量的读写: cin >> a; cout << a;
printf :使用 .c_str()
不建议使用 scanf 读取 string 。这里水很深你把握不住。
• 赋值:像正常变量一样直接赋值
• 使用 a.length() 获取字符串的长度(里面有多少个字符)
• 可以使用类似数组的方式( a[i] )来获取或修改第 i 个字符。i 应属于 0 ∼ a.length() − 1。
• 使用 + 号可以拼接两个字符串。
STL 容器
动态数组 Vector
C++ 中原生的数组是定长的(比如 int a[10] ),用起来有时不太方便。
可变长数组(vector):
• 创建: vector<数组内的元素的类型> 容器的名字
创建一个可变长数组,初始为空。
• 容器的名字.push_back(追加的元素)
向指定的可变长数组的末尾追加一个元素(因此数组的长度会 +1)
• 容器的名字.size()
获取指定的可变长数组中的元素个数(也是长度)
• 容器的名字.clear()
清空指定的可变长数组
• 可变长数组也可以像数组一样使用 [] 来访问第 i 个元素
下标从 0 开始,也即,有效的下标范围是 0 ∼ 容器的名字.size() 。
队列 Queue
栈 Stack
优先队列(堆) Priority Queue
哈希表 Hash Table/Map
STL 算法
迭代器
-
什么是迭代器?
-
用来定位 STL 容器中元素的东西;
-
是一种用来遍历 STL 容器的东西;
-
可以看作 STL 中的 “指针”;
-
也是一个
struct或class
-
-
基本内容代码
#include<bits/stdc++.h>
#define int long long
#define endl '\n'
std::vector<int> vec;
signed main(){
int n;
std::cin>>n;
for(int i=1;i<=n;i++){
int x;
std::cin>>x;
vec.push_back(x);
}
std::vector<int>::iterator it1=vec.begin();//获取一个指向 vec 中的开头那个元素的迭代器
std::vector<int>::iterator it2=vec.end();//获取一个指向 vec 中的结尾那个元素的“下一个元素”的位置
std::cout<<*it1<<endl;//可以用 * 来输出它指向的元素是什么
for(std::vector<int>::iterator it=vec.begin()/*it1*/;it!=vec.end()/*it2,不能等于结尾元素的后一个元素*/;it++){
std::cout<<*it<<" ";//遍历 vector,每次指针后移
}
std::cout<<endl;
//如何使用迭代器删除元素
int del_wz;
std::cin>>del_wz;
vec.erase(vec.begin()+del_wz-1);//删除第 del_wz 个位置
//如何使用迭代器插入元素
int ins_wz,ins_val;
std::cin>>ins_wz>>ins_val;
vec.insert(vec.begin()+ins_wz-1,ins_val);//在第 ins_wz 的位置上插入 ins_val 这个数
//如何使用迭代器实现 STL 算法
int start_wz;
int end_wz;//都是从 1 开始
std::cin>>start_wz>>end_wz;
std::sort(vec.begin()+start_wz-1,vec.begin()+end_wz);//对第 start_wz 到 end_wz 进行排序
//左闭右开,所以左边减,右边不用减
return 0;
}

浙公网安备 33010602011771号