A*算法的本质是带有估价函数的优先队列BFS算法,故A*算法有一个显而易见的缺点,就是需要维护一个二叉堆来存储状态及其估价,耗费空间较大,并且对堆进行一次操作也要花费O(logn)的时间。
A*算法的关键在于估价函数,估价函数也能与DFS结合,当然DFS也有一个缺点,一旦估价失误容易向下递归深入到一个不能产生最优解的分支,浪费许多时间。
综上,我们可以把估价函数与迭代加深的DFS相结合,我们设计一个估价函数,估算从每个状态到目标状态的步数,和A*算法相同,固件函数需要遵守“估计值不能大于未来实际步数”的准则。然后以迭代加深DFS的搜索框架为基础,把原来的深度限制加强为:若当前深度+未来估计步数>深度限制,则立即从当前分支回溯。
例题
acwing180.排书
先考虑每一步的决策数量:
当抽取长度为 i 的一段时,有
n
−
i
+
1
n−i+1
n−i+1种抽法,对于每种抽法,有
n
−
i
n−i
n−i 种放法。另外,将某一段向前移动,等价于将跳过的那段向后移动,因此每种移动方式被算了两遍,所以每个状态总共的分支数量是:
∑
i
=
1
n
(
n
−
i
)
∗
(
n
−
i
+
1
)
/
2
≤
(
15
∗
14
+
14
∗
13
+
13
∗
12
+
.
.
.
+
2
∗
1
)
=
560
\sum_{i=1}^{n}(n-i)*(n-i+1)/2 \leq (15*14+14*13+13*12+...+2*1)=560
∑i=1n(n−i)∗(n−i+1)/2≤(15∗14+14∗13+13∗12+...+2∗1)=560。
考虑在四步以内解决,最多有
56
0
4
560^4
5604 个状态,会超时。可以使用双向BFS或者IDA来优化。
我们用IDA来解决此题。
估价函数:
估价函数需要满足:不大于实际步数在最终状态下,每本书后面的书的编号应该比当前书多1。
每次移动最多会断开三个相连的位置,再重新加入三个相连的位置,因此最多会将3个错误的连接修正,所以如果当前有
t
o
t
tot
tot 个连接,那么最少需要
⌈
t
o
t
/
3
⌉
⌈tot/3⌉
⌈tot/3⌉次操作。因此当前状态的估价函数可以设计成
f
(
s
)
=
⌈
t
o
t
/
3
⌉
f(s)=⌈tot/3⌉
f(s)=⌈tot/3⌉如果当前层数加上
f
(
s
)
f(s)
f(s) 大于迭代加深的层数上限,则直接从当前分支回溯。
#include<iostream>
#include<cstring>
using namespace std;
#define MAX_N 15
int depth=0;
int t,n;
int q[MAX_N+5],w[5][MAX_N+5];
int f()
{
int tot=0;
for(int i=0;i<n-1;i++)
if(q[i]!=q[i+1]-1)tot++;
return (tot+2)/3;
}
bool dfs(int depth,int max_depth)
{
if(f()+depth>max_depth)return false;
if(!f())return true;
for(int l=0;l<n;l++)
{
for(int r=l;r<n;r++)
{
for(int k=r+1;k<n;k++)
{
memcpy(w[depth],q,sizeof q);
int x,y;
for(x=r+1,y=l;x<=k;x++,y++)q[y]=w[depth][x];
for(x=l;x<=r;x++,y++)q[y]=w[depth][x];
if(dfs(depth+1,max_depth))return true;
memcpy(q,w[depth],sizeof q);
}
}
}
return false;
}
int main()
{
cin>>t;
while(t--)
{
depth=0;
cin>>n;
for(int i=0;i<n;i++)cin>>q[i];
while(depth<5&&!dfs(0,depth))depth++;
if(depth==5)cout<<"5 or more"<<endl;
else cout<<depth<<endl;
}
return 0;
}
acwing181.回转游戏
本题采用 IDA* 算法,即迭代加深的 A* 算法。
估价函数:
统计中间8个方格中出现次数最多的数出现了多少次,记为 k 次。
每次操作会从中间8个方格中移出一个数,再移入一个数,所以最多会减少一个不同的数。
因此估价函数可以设为 8−k剪枝:
记录上一次的操作,本次操作避免枚举上一次的逆操作。
如何保证答案的字典序最小?
由于最短操作步数是一定的,因此每一步枚举时先枚举字典序小的操作即可。
时间复杂度
假设答案最少需要 k步,每次需要枚举 7种不同操作(除了上一步的逆操作),因此最坏情况下需要枚举
7
k
7^k
7k 种方案。但加入启发函数后,实际枚举到的状态数很少。
#include<iostream>
using namespace std;
int q[30];
int op[8][7]={
{0,2,6,11,15,20,22},
{1,3,8,12,17,21,23},
{10,9,8,7,6,5,4},
{19,18,17,16,15,14,13},
{23,21,17,12,8,3,1},
{22,20,15,11,6,2,0},
{13,14,15,16,17,18,19},
{4,5,6,7,8,9,10},
};
int center[8]={6,7,8,11,12,15,16,17};
int opposite[8]={5,4,7,6,1,0,3,2};
int path[100];
int f()
{
int sum[4]={0};
for(int i=0;i<8;i++)
sum[q[center[i]]]++;
int s=0;
for(int i=1;i<=3;i++)s=max(s,sum[i]);
return 8-s;
}
void operate(int x)
{
int t = q[op[x][0]];
for (int i = 0; i < 6; i ++ ) q[op[x][i]] = q[op[x][i + 1]];
q[op[x][6]] = t;
}
bool dfs(int depth, int max_depth, int last)
{
if (depth + f() > max_depth) return false;
if (!f()) return true;
for (int i = 0; i < 8; i ++ )
{
if (opposite[i] == last) continue;
operate(i);
path[depth] = i;
if (dfs(depth + 1, max_depth, i)) return true;
operate(opposite[i]);
}
return false;
}
int main()
{
while (scanf("%d", &q[0]), q[0])
{
for (int i = 1; i < 24; i ++ ) scanf("%d", &q[i]);
int depth = 0;
while (!dfs(0, depth, -1))
{
depth ++ ;
}
if (!depth) printf("No moves needed");
for (int i = 0; i < depth; i ++ ) printf("%c", 'A' + path[i]);
printf("\n%d\n", q[6]);
}
return 0;
}
acwing182.破坏正方形
先将每个正方形的所有边的编号预处理出来。
这一部分要耐心观察原图形找规律,可以发现每个正方形上下两组边是公差为1的等差数列,只要求出数列的首项即可;左右两组边是公差为 2n+1的等差数列,同理求出首项即可。
然后问题变成最少选出多少边,使得每个正方形中至少被选出一条边。
这是一个经典的重复覆盖问题,可以用 Dancing Links 求解。
这里我们不适用DLX这个数据结构,直接求解。
估价函数:
枚举所有未被删掉的正方形,将其所有边全部删掉,只记删除一条边。这样估计出的值一定不大于真实值,满足IDA*对估价函数的要求。其实这也是Dancing Links求解重复覆盖问题时的估价函数。
搜索顺序优化:
找出最小的未被删除的正方形,依次枚举删除每条边。
#include <vector>
#include <cstdio>
#include <cstring>
using namespace std;
const int N = 61; // 网格最大是 5 * 5 的,其中最多会有 5 * (5 + 1) * 2 = 60 个正方形,所以要开到 61
int n, idx; // n 为网格规模,idx 为正方形数量
int max_depth; // IDA* 的 max_depth
vector<int> square[N]; // 存每个正方形边上的火柴的编号
bool used[N]; // 存每个火柴是否已经被破坏
// 新加一个左上角坐标为 (r, c),边长为 len 的正方形
void add(int r, int c, int len)
{
int d = n << 1 | 1; // 由于用到的 2n + 1 比较多,这里先用一个变量代替掉 2n + 1
vector<int> &s = square[idx];
s.clear(); // 有多组测试数据,需要上一组数据的内容清空
for (int i = 0; i < len; i ++ )
{
s.push_back(1 + r * d + c + i); // 上边第 i 个
s.push_back(1 + (r + len) * d + c + i); // 下边第 i 个
s.push_back(1 + n + r * d + c + i * d); // 左边第 i 个
s.push_back(1 + n + r * d + c + i * d + len); // 右边第 i 个
}
idx ++ ;
}
// 判断正方形 s 是否完整
bool check(vector<int> &s)
{
for (int i = 0; i < s.size(); i ++ )
if (used[s[i]]) return false; // 如果其中有一条边已经被破坏了,那么说明不完整
return true; // 如果每条边都没被破坏,说明完整
}
// 估价函数
int f()
{
static bool backup[N]; // 由于要改动 used,需要先新建一个备份数组
memcpy(backup, used, sizeof used); // 将 used 复制到备份数组中
int res = 0;
for (int i = 0; i < idx; i ++ ) // 枚举所有正方形
if (check(square[i])) // 如果某个正方形是完整的,
{
res ++ ; // 那么 res ++ ,并将该正方形所有的边都删去
for (int j = 0; j < square[i].size(); j ++ )
used[square[i][j]] = true;
}
memcpy(used, backup, sizeof used); // 复制回来
return res;
}
// IDA*
bool dfs(int depth)
{
if (depth + f() > max_depth) return false;
for (int i = 0; i < idx; i ++ ) // 枚举所有的正方形
if (check(square[i])) // 如果第 i 个正方形还没被破坏
{
// 那么枚举该正方形的所有边编号,去掉该边并继续爆搜
for (int j = 0; j < square[i].size(); j ++ )
{
used[square[i][j]] = true;
if (dfs(depth + 1)) return true;
used[square[i][j]] = false;
}
// 如果每条边都爆搜不成功,那么说明删掉 max_depth 个火柴无法破坏该正方形
return false;
}
return true; // 如果所有的正方形都被破坏了,返回 true
}
int main()
{
int T;
scanf("%d", &T);
while (T -- )
{
scanf("%d", &n), idx = 0; // 初始化 idx
memset(used, false, sizeof used); // 初始化 used
for (int len = 1; len <= n; len ++ ) // 枚举 len, r, c,预处理每个正方形
for (int r = 0; r + len <= n; r ++ )
for (int c = 0; c + len <= n; c ++ )
add(r, c, len);
int k;
scanf("%d", &k);
while (k -- ) // 读入所有已经被破坏的边
{
int x;
scanf("%d", &x);
used[x] = true;
}
max_depth = 0; // IDA*
while (!dfs(0)) max_depth ++ ;
printf("%d\n", max_depth);
}
return 0;
}