蓝桥杯补题
目录
组合型枚举 + DPS
一个误区:首先,本题不能使用深搜,因为深搜是一条路走到黑的,但是本题可以出现土字形的形状,这是深搜搜不出来的!
思路:组合型枚举所有可能,dfs搜索判断
题目要求我们找出五个相邻的方块的不同组合,那么我们可以枚举这五个方块的位置,并这只一个长为12的数组,1表示该位置选定,枚举的时候可以使用全排列函数。
虽然我们枚举的数组长度是12,但是因为数组中的元素只有0或者1,所以集合非常少,一共又792种组合,对于每一种组合,我们只需要dfs搜索一下所有的连通块,只有连通块个数为1的时候,这个组合构成的图形才是联通的。
注意在使用next_permutation函数的时候,我们初始化的数组必须是最小的字典序!
因为该函数是按照字典序枚举的,你如果本来字典序就是最大的,那么它还枚举什么?
如果a[12]={1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0 },那么程序就运行一次
#include <iostream>
#include <cstring>
#include <bits/stdc++.h>
#include <algorithm>
using namespace std;
int a[12] = {0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1};
int b[10][10];
int dx[4] = {-1, 0, 1, 0}, dy[4] = {0, 1, 0, -1};
void dfs(int i, int j)
{
b[i][j] = 0;
for(int k = 0; k < 4; k ++ )
{
int x = i + dx[k], y = j + dy[k];
if(!b[x][y] || x < 0 || y < 0 || x > 3 || y > 4) continue;
dfs(x, y);
}
}
bool check()
{
for(int i = 0; i < 12; i ++ )
b[i / 4][i % 4] = a[i];
int cnt = 0;
for(int i = 0; i < 3; i ++ )
for(int j = 0; j < 4; j ++ )
if(b[i][j])
{
dfs(i, j);
cnt ++ ;
}
return cnt == 1;
}
int main()
{
int res = 0;
do{
if(check()) res ++ ;
}while(next_permutation(a, a + 12));
cout << res << endl;
return 0;
}
状态压缩DP
例题1
互质数的原则:
只要两数的公因数只有1时,就说两数是互质数
注意:
在状态压缩DP中,时刻小心位移、与、或运算与加号、减号的优先级问题,前者的优先级小,要用括号括起来!
参考:
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 22, M = 1 << N;
long long f[M][N];
bool g[N][N];
int gcd(int a, int b) // 欧几里得算法
{
return b ? gcd(b, a % b) : a;
}
int main()
{
int n = 21;
for(int i = 1; i <= n; i ++ )
for(int j = i + 1; j <= n; j ++ )
if(gcd(i, j) == 1)
{
g[i - 1][j - 1] = 1;//从1~n映射成0~n-1
g[j - 1][i - 1] = 1;//可以提升效率,方便计算
}
f[1][0] = 1; //初始化,f[s][0]=1,表示自己到自己是一种方案。
//可以理解为,此时,1既是起点,又是它自己的终点和1~20中一个子区间[1,1]的终点!
//时刻理解我们的终点并不是最终的终点,而是任意子区间的终点!
for(int i = 0; i < 1 << n; i ++ )//枚举每一种状态
for(int j = 0; j < n; j ++ )//枚举每个终点
if(i >> j & 1)//如果走过这个点
for(int k = 0; k < n; k ++ )//转折点
if((i >> k & 1) && g[j][k])//走过这个点并且可以转移过去
f[i][j] += f[i - (1 << j)][k];
long long res = 0;
for(int i = 1; i < n; i ++ )
res += f[(1 << n) - 1][i];
cout << res << endl;
return 0;
}
例题2
补充(模板题)求最短哈密顿回路:91. 最短Hamilton路径 - AcWing题库
关于本题为什么考虑压缩状态:
我们考虑这么一种情况,有20个点(1,2,...,20),我们求它的最短哈密顿路径,假设我们当前之走了三个点,对于以下两种情况:
- 1-> 3-> 5:18
- 1-> 5-> 3:20
我们当前已经走过了1,3,5三个点,并且按照1->3->5走的距离更短,那么,从1->5->3继续走下去的所有情况我们都不用再考虑了!因为对于1->5->往后的任何状态,我们都可以从路径更短的1->3->5转移过去,1->5->3就是一个最优子结构!
我们只需要考虑最后停在那个点,以及中间那个点被用过(即这条路径的状态)。对于所有的终点,我们可以通过一个中间点转移过去。这就是状态压缩。
注意:上面的终点并不是说最后停在那个点,例如对于1->2->3->4,那么对于这段路径的一段子路径2->3,我们可以说3是个终点,只不过是一个子区间的终点!同理,2,3,4皆可以作为一个区间的终点!
#include<iostream>
#include<cstring>
#include<algorithm>
using namespace std;
const int N=20,M=1<<N;
int f[M][N],w[N][N];//w表示的是无权图
int main()
{
int n;
cin>>n;
for(int i=0;i<n;i++)
for(int j=0;j<n;j++)
cin>>w[i][j];
memset(f,0x3f,sizeof(f));//因为要求最小值,所以初始化为无穷大
f[1][0]=0;//因为零是起点,所以0已经走过了,所以二进制第0位的状态位1,2^0=1,所以f[1][0]=0;
for(int i=0;i<1<<n;i++)//i表示所有的情况
for(int j=0;j<n;j++)//j表示走到哪一个点
if(i>>j&1)
for(int k=0;k<n;k++)//k表示走到j这个点之前,以k为终点的最短距离
if(i>>k&1)
f[i][j]=min(f[i][j],f[i-(1<<j)][k]+w[k][j]);//更新最短距离
cout<<f[(1<<n)-1][n-1]<<endl;//表示所有点都走过了,且终点是n-1的最短距离
//位运算的优先级低于'+'-'所以有必要的情况下要打括号
return 0;
}
关于下面讨论的一些理解:
- 首先,为什么要在外层循环路径,内层循环终点?我们用反证法,让外层循环终点,假设我们循环到了一个状态111,表示第1,2,3个点都走过了,这时候我们需要状态转移,但如果我们此时外层循环的终点只是1的话,那么这个点的状态还没有被更新,所以不是最优的,往后的所有状态都不是最优的
- 其次,DP状态要按拓扑序计算,即,状态转移需要的状态需要我们提前计算出来。因为我们要保证局部最优子结构!我们不能用一个不是最优的答案去更新另一个答案,这会导致另一个答案肯定不是最优的答案。
并查集
先看一下暴力做法,看看有什么优化的地方
#include <iostream>
#include <cstring>
#include <algorithm>
#include <unordered_set>
#define int long long
using namespace std;
const int N = 100010;
int n, w[N];
unordered_set<int> s;
signed main()
{
cin >> n;
for(int i = 1; i <= n; i ++ ) cin >> w[i];
for(int i = 1; i <= n; i ++ )
{
if(!s.count(w[i]))
{
cout << w[i] << " ";
s.insert(w[i]);
}
else
{
while(s.count(w[i])) w[i] ++ ;
s.insert(w[i]);
cout << w[i] << " ";
}
}
cout << endl;
return 0;
}
通过观察暴力方法,我们可以想到这么一个例子:
1,2,3,......,100000,1,1,1,1,.....1
对于100000往后的所有1,我们都要走100000次才能找到正确答案,而走的这100000次基本上都是重复计算!那么,我们能不能找到一种方法,避免重复计算呢?
我们考虑给每个值input映射一个output,用来表示如果当前输入的值是input,那么我们就输出output,由于output被我们输出了,后面也就不能再用了,所以我们要让output的值加1,这样,当我们下一次选择val时,它会输出一个前面没有用过的output
想到这里,你想到了什么?当然是并查集啊!这个output就是input的根
以下思路:
对于所有的数,起初他们的根节点都指向自己,表示没出现过。当遇到一个数时,找到他的根节点,由于根节点始终是未出现过的,所以输出根节点,接着把根节点再指向一个新的根节点(比旧的根节点大 1),这样就能始终保持根节点是未出现过的了。
在查找根节点的同时使用路径压缩,可以更快的查找到根节点。
6
1 2 3 4 1 3
遇到 1,1 的根节点是 1,输出 1,指向新的根节点 2,也就是 par[1] = 1 + 1;
遇到 2,2 的根节点是 2,输出 2,指向新的根节点 3
遇到 3,3 的根节点是 3,输出 3,指向新的根节点 4
遇到 4,4 的根节点是 4,输出 4,指向新的根节点 5
遇到 1,1 的根节点是 5,输出 5,指向新的根节点 6
遇到 3,3 的根节点是 6,输出 6,指向新的根节点 7
#include <iostream>
using namespace std;
const int MaxNum = 1000005;
int par[MaxNum];
void init() {
for (int i = 0; i < MaxNum; ++i) par[i] = i;
}
int find(int x) {
if (par[x] == x)
return x;
else
return par[x] = find(par[x]);
}
int main() {
int n;
cin >> n;
init();
while (n--) {
int num;
scanf("%d", &num);
int root = find(num);
printf("%d ", root);
par[root] = root + 1;
}
return 0;
}
思路二:本质上就是没有路径压缩的并查集
当我们仔细研究时发现,我们其实可以去掉多余的访问,例如一个数已经被访问了k次,那么就说明他后面的k个数一定都被访问了,所以我们可以将访问数组visited改为记录访问次数的数组。例如,当visited【x】=k时,正好输入的数是x,则我们直接将x跳到x+k的位置再进行判断是否被访问。
#include <iostream>
using namespace std;
const int MaxNum = 1000005;
int vis[MaxNum];
int main() {
int n;
cin >> n;
while (n--) {
int num;
scanf("%d", &num);
while (vis[num]) {
int tmp = num;
num += vis[num];
++vis[tmp];
}
vis[num] = 1;
printf("%d ", num);
}
return 0;
}
参考(两种思路):
递归
我的栈模拟做法,在做这道题的时候,我陷入了一个误区,那就是关于‘ | ’号的处理,我们知道在这道题里面 a|b 的意思是取a和b的最大值,但是当我们遍历到‘ | ’的时候,我们只知道a的值,由于b我们还没有遍历,它的值也就无从得知,所以我在考虑的时候,我是想把这个 ‘ | ’符号先标记一下,然后等我们遍历完b之后,再比较大小,但这样的话是不对的。
其实我们不需要先得到b的值再比较,我们只要遇到‘ | ’就比较一次,再结束的时候再比较一次就可以了,其中第一次比较是没有意义的,不会影响结果。
另外本题用递归实现比模拟简单一万倍!主要是要理解怎么递归。
#include<bits/stdc++.h>
using namespace std;
string s;
int pos = 0; //当前的位置
int dfs(){
int tmp = 0, ans = 0;
int len = s.size();
while(pos < len){
if(s[pos] == '('){ //左括号,继续递归。相当于进栈
pos++;
tmp += dfs();
}else if(s[pos] == ')'){ //右括号,递归返回。相等于出栈
pos++;
break;
}else if(s[pos] == '|'){ //检查或
pos++;
ans = max(ans, tmp);
tmp = 0;
}else{ //检查X,并统计X的个数
pos++;
tmp++;
}
}
ans = max(ans, tmp);
return ans;
}
int main(){
cin >> s;
cout << dfs() << endl;
return 0;
}
附上我的模拟代码,得了40%的分数
//模拟,得40分
#include <iostream>
#include <cstring>
#include <algorithm>
#include <stack>
using namespace std;
int main()
{
string s;
cin >> s;
stack<int> res;
int x = 0, n = s.size();
int t = 0;
bool flag = false, is_new = true;
for(int i = 0; i < n; i ++ )
{
if(s[i] == 'x')
{
if(!res.size() || is_new) res.push(1);
else
{
int maxn = res.top() + 1;
res.pop();
res.push(maxn);
}
is_new = false;
}
else if(s[i] == '(')
{
is_new = true;
}
else if(s[i] == ')')
{
is_new = true;
if(flag)// '|'
{
int x = res.top(); res.pop();
int y = res.top(); res.pop();
res.push(max(x, y));
flag = false;
}
else
{
res.push(t);
t = 0;
}
}
else if(s[i] == '|')
{
is_new = true;
if(flag)
{
int x = res.top(); res.pop();
int y = res.top(); res.pop();
res.push(max(x, y));
}
else flag = true;
}
}
if(flag)
{
int x = res.top(); res.pop();
int y = res.top(); res.pop();
res.push(max(x, y));
}
if(res.size())
{
int sum = 0;
while(res.size())
{
sum += res.top();
// cout << "res.top: " << res.top() << endl;
res.pop();
}
cout << sum << endl;
}
else cout << 0 << endl;
return 0;
}
前缀和 + 公式变形
题目让我们求一个区间[L, R]满足(a[L] + a[L + 1] + .... + a[R]) % k = 0
这种区间问题首先应该立马想到前缀和,那么问题就转化成了求(sum[R]-sum[L-1]) % k == 0
对面上面一个公式,由于我们有两个未知量L,R,所以我们需要两重循环来枚举L,R的位置,但这样肯定会超时
我们可以对公式转变一下,再前缀和当中,这种公式的转化非常常用,应该很敏感才对!
(sum[R] - sum[L - 1]) % k == 0 <==> sum[R] % k == sum[L - 1] % k
(证明略)
此时,我们把求一个区间的和转化成了求求有多少个区间和模k相等!
这是一个非常大的变化!虽然我们仍然有两个变量位置(L,R)但是我们可以用一个数组cnt来保存sum[i]所有值的和,然后再后续如果当前sum[p]%k==1,那么我们只需要调用一下cnt[1]的值就行了,然后再让cnt[1]++,让后面的数调用。
这样,我们就能在O(N)的时间复杂度内完成计算!
另外,对于这种大数据求和运算,时刻记得爆int的问题!
#include <iostream>
#include <cstring>
#include <algorithm>
#define int long long
using namespace std;
const int N = 100010;
int n, k;
int cnt[N], s[N];
signed main()
{
cin >> n >> k;
for(int i = 1; i <= n; i ++ )
{
cin >> s[i];
s[i] += s[i - 1];//注意这里不能预处理求余!
}
cnt[0] = 1;
//什么都不选就是0,0%k=0,实际的含义就是我们L-1最小可以为0
//这种前缀和中对于位置0的预处理也很常见!
int res = 0;
for(int i = 1; i <= n; i ++ )
{
s[i] %= k;
res += cnt[s[i]] ++ ;
/* <=>
res += cnt[s[i]];
cnt[s[i]] ++ ;
*/
}
cout << res << endl;
return 0;
}
数论 + 完全背包
首先献上我的暴力代码,我的思路是把给定数能组合成的数都存下来(暴力DFS),然后挨个遍历,这个有一个特性,就是说如果给定的数不能构成的数是有限个的,那么,肯定存在一个连续的长度大于等于给定数列中最小值的一个序列。
例如,给定x,y,z(升序),那么只要他们组成的数有这么一个序列a1,a2,a3,a3,,,an满足n>=x,那么x,y,z不能组成的数肯定是有限个,因为从a1开始,我们给每个数+x,一直到an,就构成了一个新数列b1,b2,b3,,,bn,由于b1-a1<=n(由n>=x证得)所以a和b之间也是连续的!同理,我们还可以构造c,d,e,,,直到无穷!
这样,我们就实现了判断不能构成的数的个数(遍历),以及构不成的数是不是无穷(是否存在这么一个长度大于最小值的序列)
但是,暴力终究是暴力,由于我们给定的原数列中数的个数可能太多,所以会爆莫名其妙的错误,但这个方法依然拿到了65%的分数!甚至在ACWING只有一个测试点没过!
其次,我当时也不知道最大不能构成的数到底是多大,所以如果我们的size小了,那么结果可能出错,size大了,TLE!
#include <iostream>
#include <cstring>
#include <algorithm>
#include <set>
#define int long long
using namespace std;
const int N = 1010, INF = 0x3f3f3f3f;
bool st[N];
int n, a[N], minx = INF;
int idx;
set<int> s, news;
void dfs()
{
while(s.size() < N)
{
news = s;
for(auto &x : s)
{
for(int i = 0; i < n; i ++ )
news.insert(a[i] + x);
}
s = news;
}
}
signed main()
{
cin >> n;
for(int i = 0; i < n; i ++ )
{
cin >> a[i];
minx = min(minx, a[i]);
s.insert(a[i]);
}
dfs();
bool has_res = false;
int maxn = -1, t = 0, last = -1, en;
for(auto &x : s)
{
st[x] = true;
if(last == -1)
{
last = x;
t = 1;
}
else
{
if(x - last == 1)
{
last = x;
t ++ ;
if(t > minx)
{
has_res = true;
en = x;
break;
}
}
else
{
maxn = max(maxn, t);
if(maxn > minx)
{
has_res = true;
en = x;
break;
}
last = x;
t = 1;
}
}
}
// for(auto &x : s)
// cout << x << " ";
if(has_res)
{
int cnt = 0;
for(int i = 1; i <= en; i ++ )
{
if(!st[i]) cnt ++ ;
}
cout << cnt << endl;
}
else puts("INF");
// int cnt = 0;
// if(int i = 1; i < N; i ++ )
// if(!st[i])
// cnt ++ ;
// cout << cnt << endl;
return 0;
}
正解:数论+完全背包DP
数论的一些结论:
- 任意两个数的组合必定是其公约数的倍数(该性质可以拓展到多个数)
- 对于任意两个互质(公约数为1)的数a、b,最大的、无法凑成的数为ab−a−b
- 第1点解决“凑不出的数目无限个”的问题。当几个数的公约数不为1时,因为它们组合成的数必定为公约数的倍数,所以最起码有无限个质数不能被组成。
- 第2点解决“上界”的问题,原题AiAi最大值为100,一百及以内最大的两个互质的数为100和99,其最大不能凑成的数为100∗99−100−99=9701100∗99−100−99=9701,故取上界到9701以上即可
第三点的证明:
- 反证法,假设公约数不为1时能组成无限个质数。要组成质数,则根据点1该质数必定是公约数的倍数,而质数的因数仅有1和本身,公约数不为1故只能等于该质数本身。因此若质数A可被组成,则公约数为A;若另一个质数B同样能被组成,则公约数应为B,与前者矛盾。所以当公约数不为1时,能组成的质数仅有1个,假设不成立。
上述第二点的证明和例题:
DP:
我们可以把题目给定的数字看作一个物品,这个物品的体积就是该数字。把数组能组成的组合看作背包的体积。由于物品可以选无限个,那么就转化成了一个完全背包。只不过背包的属性不是最大价值,而是一个bool值,表示能不能组成。
//二维背包
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 110, M = 10010;
int f[N][M];
int n, d, a[N];
int gcd(int a, int b)
{
return b ? gcd(b, a % b) : a;
}
int main()
{
ios::sync_with_stdio(false);
cin >> n;
for(int i = 1; i <= n; i ++ )
{
cin >> a[i];
d = gcd(d, a[i]);
}
// cout << d << endl;
if(d != 1) cout << "INF" << endl;
else
{
//完全背包模板
f[0][0] = 1;//0个数一个数都不选是一种合法的方案
for(int i = 1; i <= n; i ++ )
for(int j = 0; j < M; j ++ )
{
f[i][j] |= f[i - 1][j];//不选
if(j >= a[i]) f[i][j] |= f[i][j - a[i]];//选(推导得到)
}
int cnt = 0;
for(int i = 0; i < M; i ++ )
if(!f[n][i])
cnt ++ ;
cout << cnt << endl;
}
return 0;
}
//一维优化
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 110, M = 10010;
int f[M];
int n, d, a[N];
int gcd(int a, int b)
{
return b ? gcd(b, a % b) : a;
}
int main()
{
ios::sync_with_stdio(false);
cin >> n;
for(int i = 1; i <= n; i ++ )
{
cin >> a[i];
d = gcd(d, a[i]);
}
// cout << d << endl;
if(d != 1) cout << "INF" << endl;
else
{
//完全背包模板
f[0] = 1;//0个数一个数都不选是一种合法的方案
for(int i = 1; i <= n; i ++ )
for(int j = a[i]; j < M; j ++ )
f[j] |= f[j - a[i]];
int cnt = 0;
for(int i = 0; i < M; i ++ )
if(!f[i])
cnt ++ ;
cout << cnt << endl;
}
return 0;
}
线性DP + 推结论
乍一看是线性DP中《数字三角形》的模板题,其实就是模板题,只不过题目中加了一个限制条件(向左下走的次数与向右下走的次数相差不能超过1)这句话是什么意思呢?我们模拟一下就知道了。
从根节点开始,我们有两种选择(向左走或者向右走),如果我们做出了某种选择,那么之后我们都必须与做出遇上一次选择相反的选择。即,如果我们在根节点向左走,那么往后我们只能走右->左->.....。也就是说,我们的路线就只有两条,一种是从根节点出发向左走的那条,另外一条就是向右走的那条。
并且,当n为奇数时,无论向左走还是向右走,最后都走到叶子的中点。
当n为偶数时,由于没有真正意义上的中点,所以我们有两种选择:把中点看做是浮点数的中点,上取整和下取整。
j == 1时,即这一行的第一个,dp[i][j]=dp[i-1][j]+M[i][j]//只能从上一层的右边走过来;
j == i时,即这一行的最后一个元素,dp[i][j]=dp[i-1][j-1]+M[i][j]//只能从上一层的左边走过来;
1<j<i时,即一般情况,dp[i][j]=max(dp[i-1][j],dp[i-1][j-1])+M[i][j];//取左边走或右边走的max;这样在dp的时候考虑边界情况就不用再开始的时候在初始化上功夫了,由于本体的节点值可能是负值(容易忽略的一个点),所以初始化不太简单。
#include<bits/stdc++.h>
using namespace std;
int M[102][102];
int dp[102][102];
int n;
int main(){
scanf("%d",&n);
for(int i=1;i<=n;i++){
for(int j=1;j<=i;j++){
scanf("%d",&M[i][j]);
}
}
for(int i=1;i<=n;i++){
for(int j=1;j<=i;j++){
if(j==1){//这一行最左边1元素
dp[i][j]=dp[i-1][j]+M[i][j];
}else if(j==i){//这一行最右边元素
dp[i][j]=dp[i-1][j-1]+M[i][j];
}else{//一般情况
dp[i][j]=max(dp[i-1][j],dp[i-1][j-1])+M[i][j];
}
}
}
if(n%2){//n为奇数只能走到最后一行中间处
printf("%d",dp[n][n/2+1]);
}else{//n为偶数有两个终点可供选择,选最大的
int ans=max(dp[n][n/2],dp[n][n/2+1]);
printf("%d",ans);
}
return 0;
}