【BFS】熊熊采蜜
晴问链接:熊熊采蜜
题目描述
熊熊正在采蜜,他需要编写一个AI来帮助他高效地采集蜂蜜。在采蜜的过程中,熊熊会遇到三个蜂巢,每个蜂巢都有一定数量的蜂蜜。当熊熊发动采集时,他可以选择最多三个不同的蜂巢进行采集,这里选择的顺序不限制:
第一个被采集的蜂巢将失去 a 点蜂蜜。
第二个被采集的蜂巢(如果有)将失去 b 点蜂蜜。
第三个被采集的蜂巢(如果有)将失去 c 点蜂蜜。
如果一个蜂巢的蜂蜜数量降到 0 或以下,它就会被完全采集。注意,熊熊在一次采集中不能对同一个蜂巢采集两次。现在,给定三个蜂巢的蜂蜜数量,请帮助熊熊计算,最少需要多少次采集才能完全采集所有蜂巢。
样例
输入:
5 6 7
10 5 2
输出:
2
思路分析
为什么想到BFS?什么时候用BFS?它和DFS相比的优劣是?
这道题需要一步一步向前推进,并且每一步都有不同选择,那么显然它可以通过DFS或BFS解决。而分析过程中这道题有一个最重要的特点:某些状态是重复的。
具体来说,由于每次采集的蜂蜜a b c是不变的,如果当前蜂巢的蜂蜜数量分布重复,那么它后续的所有搜索过程都会重复。此时BFS能够对这种情况剪枝,极大优化效率
换句话说,我的理解里,搜索中的BFS相比DFS最大的优势就是能够对重复路径剪枝。
举个例子,初始蜂巢是4 4 4
,采蜜量为1 2 2
。那么以1 2 2
顺序采蜜和以2 2 1
顺序采蜜,最终都会得到2 2 3
的蜂巢分布,于是它们的结果实际上是同样的子问题,只需要解决一个即可。
实现
首先我们要明确本题BFS中每一层的意义是什么?进入下一层的不同方向是什么?如何剪枝重复结点?
获取搜索方向
容易知道,本题对任何一个蜂巢分布每次有六种不同的采蜜方式,也就是a b c
的全排列。所以开始搜索之前我们可以将这六个方向先算出来:
vector<int> v1 = {a,b,c};
sort(v1.begin(),v1.end());
vector<vector<int>> perms;
do {
vector<int> tv = v1;
perms.pb(tv);
} while(next_permutation(v1.begin(),v1.end()));
其中next_permutation
用于求序列的下一个字典序更高的排列。因此我们首先排序使v1本身字典序最小。当v1已经是最大字典序时,next_permutation
将返回false
,否则返回true
。
BFS搜索本体
既然是BFS,就一定需要队列辅助维护遍历顺序。暂时不考虑剪枝,大体逻辑是:
vector<int> v2 = {H1,H2,H3};
//队列每一个元素代表一个状态结点<操作步数(搜索层数),蜂巢分布>
deque<pair<int,vector<int>>> q;
//将初始状态结点入队
q.push_back({0,v2});
while(!q.empty()) {
int cnt = q.size();
for(int i = 0; i < cnt; i++) {
//获取当前状态结点
auto p = q.front(); q.pop_front();
//当前操作步数和当前蜂巢分布
int step = p.first; vector<int> pv = p.second;
//isValid用于判断当前蜂巢是否已经满足题意
//如果已经满足,当前就是最优结果,结束即可
if(isValid(pv)) {
cout << step;
return 0;
}
//对六个方向的下一个状态结点挨个入队
for(vector<int> t : perms) {
vector<int> tv = pv;
//计算按该方向前进后的蜂巢分布
for(int k = 0; k < 3; k++) {
if(tv[k] > 0) {
tv[k] -= t[k];
}
}
}
}
}
对重复结点的剪枝核心思路
这里一个关键思路是:一个状态结点由<操作步数,蜂巢分布>
确定,也就是说这两项都相同时,它们就是相同的状态结点,那后续搜索过程将会完全一致
于是我们考虑从这个角度去重,可以定义一个set<pair<int,vector<int>>> st
,后续每次想要入队一个新状态结点,就在st中查询是否存在,存在就不入队,从而实现剪枝。
但是仔细想想,由于我们是按层级遍历的,如果出现蜂巢分布相同而层数不同的状态结点,一定是丢弃后来的那个,因为后来的状态结点步数一定大于等于先来的状态结点。于是set中其实不需要存储操作步数,只需要对蜂巢分布去重即可。
因此set的结构可以简化为set<vector<int>> st
,由于set底层由红黑树实现,这个改动能优化近十倍效率。红黑树的基础结构一旦复杂,性能上的损耗是巨大的。
在哪个位置进行剪枝?
其实在代码的哪个步骤剪枝都可以,最重要的是思路清晰。一般有两个选择:
从队列中取出结点时检查set中是否重复
生成下一层结点时检查set中是否重复
无论哪一种方式,都需要在检查之后立刻将刚刚检查的状态结点插入到set中
最终代码
#include<bits/stdc++.h>
using namespace std;
#define int long long
#define MAX 0x7fffffff
#define pb push_back
//检查v是否全为0,即是否符合题目要求
int isValid(vector<int>& v) {
for(int i = 0; i < 3; i++) {
if(v[i] > 0) {
return 0;
}
}
return 1;
}
signed main() {
int a,b,c;
cin >> a >> b >> c;
int H1,H2,H3;
cin >> H1 >> H2 >> H3;
vector<int> v1 = {a,b,c};
vector<int> v2 = {H1,H2,H3};
//队列每一个元素代表一个状态结点<操作步数(搜索层数),蜂巢分布>
deque<pair<int,vector<int>>> q;
//剪枝set
set<vector<int>> st;
//将初始状态结点入队
q.push_back({0,v2});
//先排序,令v1初始字典序最小
sort(v1.begin(),v1.end());
//获取六个搜索方向
vector<vector<int>> perms;
do {
vector<int> tv = v1;
perms.pb(tv);
} while(next_permutation(v1.begin(),v1.end()));
while(!q.empty()) {
int cnt = q.size();
//按层遍历
for(int i = 0; i < cnt; i++) {
auto p = q.front();
q.pop_front();
int step = p.first;
vector<int> pv = p.second;
if(isValid(pv)) {
ans = step;
q.clear();
break;
}
for(vector<int> t : perms) {
vector<int> tv = pv;
for(int k = 0; k < 3; k++) {
if(tv[k] > 0) {
tv[k] -= t[k];
}
}
//如果重复则跳过
if(st.find(tv) == st.end()) {
st.insert(tv);
q.push_back({step+1,tv});
}
}
}
}
cout << ans;
return 0;
}