一、迭代加深 (IDDFS)
有时候我们用DFS搜索可行解时,可能会遇到这样一种情况:有些非合法方案的分支,需要搜索特别深才能判定失败,但是合法方案在其之后一个比较浅的位置,如下图所示,这样直接运用DFS会进入“无底洞”,浪费大量时间。迭代加深则可以有效解决这种问题。
这里引入迭代加深的概念:一层一层来搜,每一次定义一个层数上限
m
a
x
_
d
e
p
t
h
max\_depth
max_depth,使用DFS搜索,当DFS搜索层数大于
m
a
x
_
d
e
p
t
h
max\_depth
max_depth 时,直接 return
(即把层数大于
m
a
x
_
d
e
p
t
h
max\_depth
max_depth 的部分全部剪掉)。从
0
0
0 开始限制层数上限,若搜索结束后没有找到合法方案,则将
m
a
x
_
d
e
p
t
h
+
1
max\_depth+1
max_depth+1,进行下一次搜索,直到找到一个合法方案,结束。由于限制层数上限由小到大,第一次找到的方案一定是最优解 (层数最小)。
可以将 m a x _ d e p t h max\_depth max_depth 对搜索深度的限制看成在整个搜索空间中划分了一个小的搜索区域,在这个区域内搜索可行解,若区域内无解,则将区域向外扩大一圈 (即 m a x _ d e p t h + 1 max\_depth+1 max_depth+1),然后继续搜索。在这个过程中,搜索区域逐步扩大,可以很大减少搜索到的状态数量。
但是,每次层数上限加一时,DFS都会从头重新开始搜索,这样多次重复搜索会不会浪费时间?假设合法方案在第 10 10 10 层,每个结点均有两个分支,在 m a x _ d e p t h max\_depth max_depth 分别为 0 , 1 , 2 , . . . , 10 0,1,2,...,10 0,1,2,...,10 时,搜索到的结点数依次为 2 1 − 1 , 2 2 − 1 , 2 3 − 1 , . . . , 2 11 − 1 2^1-1,2^2-1,2^3-1,...,2^{11}-1 21−1,22−1,23−1,...,211−1,而 ∑ i = 1 10 ( 2 i − 1 ) = 2 11 − 12 \sum\limits^{10}_{i=1}(2^i-1)=2^{11}-12 i=1∑10(2i−1)=211−12,前 10 10 10 次搜索到的结点数总和还不如第 11 11 11 次搜索到的结点数多,而且在一般情况下,分支结点远多于 2 2 2 个,因此前面重复搜索的部分在整个搜索空间里面只占很少部分,无需在意。
二、加成序列
由于 1 ≤ n ≤ 100 1\le n\le 100 1≤n≤100,序列中元素递增,搜索树的最大深度可达 100 100 100 ( x [ i ] = i , n = 100 x[i]=i,n=100 x[i]=i,n=100 时)。但是最优解一定在比较浅的层数中:考虑序列 x [ i ] = 2 i − 1 x[i]=2^{i-1} x[i]=2i−1,当 i = 8 i=8 i=8 时, x [ i ] = 128 > 100 ≥ n x[i]=128>100\ge n x[i]=128>100≥n。因此,本题非常适合使用迭代加深来搜索。
考虑一个可行的搜索方案:从前往后搜索序列中每个元素,对于每个位置,枚举该位置能填的所有数,选择一个填入,然后继续搜索。
考虑剪枝,对于本题:
- 优化搜索顺序:在枚举某一位置能填的所有数时,从大到小枚举;
- 排除等效冗余:用一个
bool
数组记录每个数是否被枚举过; - 可行性剪枝:枚举的数一定大于在其前一个位置的数,且不超过 n n n;
- 最优性剪枝:迭代加深搜到的第一个可行方案就是最优解,无需剪枝。
代码实现:
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 110;
int n;
int path[N];
bool dfs(int u, int depth){
if (u > depth) return 0;
if (path[u - 1] == n) return 1;
bool st[N] = {0};
for (int i = u - 1; i >= 0; i --)
for (int j = i; j >= 0; j --){
int s = path[i] + path[j];
if (s > n || s <= path[u - 1] || st[s]) continue;
st[s] = 1;
path[u] = s;
if (dfs(u + 1, depth)) return 1;
}
return 0;
}
int main(){
path[0] = 1;
while (cin >> n, n){
int depth = 1;
while (!dfs(1, depth)) depth ++;
for (int i = 0; i < depth; i ++)
cout << path[i] << " ";
cout << endl;
}
return 0;
}
二、送礼物
题目背景与01背包问题相同,但数据范围 1 ≤ W , G [ i ] ≤ 2 31 − 1 1\le W,G[i]\le 2^{31}−1 1≤W,G[i]≤231−1 使得该题不能用DP求解 (01背包的DP方法时间复杂度为 O ( N V ) O(NV) O(NV), V V V 为背包体积)。注意到 1 ≤ N ≤ 46 1\le N\le 46 1≤N≤46,尝试暴搜求解。
但是若直接用朴素DFS,依次枚举每个物品选或不选,总共有 2 N = 2 46 2^N=2^{46} 2N=246 种方案,肯定会超时。这里介绍一种新的方法:双向DFS,其思想与双向BFS相同。
思想:将所有物品按从大到小排序,划分成两个部分搜索。先暴搜处理出前
N
/
2
N/2
N/2 个物品所能凑成的所有重量并排序判重,再枚举后
N
/
2
N/2
N/2 个物品的选择情况,对于每一种选择方案,设其重量为
S
S
S,最多举起重量为
W
W
W,在前
N
/
2
N/2
N/2 个物品锁凑成的所有重量中二分找到最大的且不超过
W
−
S
W-S
W−S 的重量
X
X
X,用
S
+
X
S+X
S+X 更新最大值。这样时间复杂度为
O
(
2
N
/
2
∗
(
1
+
N
/
2
)
)
O(2^{N/2}*(1+N/2))
O(2N/2∗(1+N/2))。
同时,这里运用了空间换时间的重要思想:将前
N
/
2
N/2
N/2 个物品的搜索结果记录下来,在搜索后
N
/
2
N/2
N/2 个物品时,可直接查表得前面的搜索结果。
注意,在视频中y总还提到了进一步的时间优化 (
N
=
46
,
K
=
25
N=46,K=25
N=46,K=25),但是在实际测试中发现,最后两个测试点会超时。
其实,y总的分析有误:前半部分搜索的复杂度并非只是深搜的
2
K
2^K
2K,而应该再加上排序的时间,实际复杂度
2
K
∗
(
1
+
K
)
2^K*(1+K)
2K∗(1+K),后半部分搜索复杂度为
K
∗
2
N
−
K
K*2^{N-K}
K∗2N−K,当
N
=
46
N=46
N=46 时,计算得
K
=
23
(
=
N
/
2
)
K=23\ (=N/2)
K=23 (=N/2) 时总耗时最短。
代码实现:
#include <iostream>
#include <algorithm>
#include <cstring>
using namespace std;
const int N = 46;
typedef long long LL;
int n, m, k;
int w[N], weights[1 << 25];
int cnt = 1, ans;
void dfs1(int u, int s){
if (u == k){
weights[cnt ++] = s;
return;
}
dfs1(u + 1, s);
if (s <= m - w[u]) dfs1(u + 1, s + w[u]);
}
void dfs2(int u, int s){
if (u >= n){
int l = 0, r = cnt - 1;
while (l < r){
int mid = l + r + 1 >> 1;
if (weights[mid] <= m - s) l = mid;
else r = mid - 1;
}
ans = max(ans, s + weights[l]);
return;
}
dfs2(u + 1, s);
if (s <= m - w[u]) dfs2(u + 1, s + w[u]);
}
int main(){
cin >> m >> n;
for (int i = 0; i < n; i ++) cin >> w[i];
sort(w, w + n);
reverse(w, w + n);
k = n / 2;
dfs1(0, 0);
sort(weights, weights + cnt);
cnt = unique(weights, weights + cnt) - weights;
dfs2(k, 0);
cout << ans;
return 0;
}
三、IDA*
IDA*,顾名思义,即IDDFS+A*,在迭代加深的基础上增加了A*中的启发函数,用于估计某个状态到终点至少需要的步数 (估价不超过真实值)。当当前层数加上估价距离大于限制的搜索层数上限
m
a
x
_
d
e
p
t
h
max\_depth
max_depth 时,直接 return
。不同于A*基于优先队列BFS,IDA*基于IDDFS,不需要手写队列或者优先队列,代码量相对较短,实现较为容易。
四、排书
在搜索时,每个状态有多少个分支?可以先枚举一段书的长度,再枚举这段书插入的位置。长度为
i
i
i 的段有
n
−
i
+
1
n-i+1
n−i+1 种,对于每种选择,都可以将其插入
n
−
i
n-i
n−i 个不同位置 (拿去
i
i
i 本书后剩下
n
−
i
n-i
n−i 本书,有
n
−
i
+
1
n-i+1
n−i+1 个空挡,其中有一个空挡是原来取出书的位置,故可以插入另外
n
−
i
n-i
n−i 个位置)。
此外,这些方案有重复:取出
[
x
,
y
]
[x,y]
[x,y] 段插入
z
z
z 后与取出
[
y
+
1
,
z
]
[y+1,z]
[y+1,z] 段插入
x
x
x 前得到的结果是相同的,即每种实际排书操作被计算了两次。
因此,每个状态的分支数量有
1
2
∑
i
=
1
14
(
15
−
i
+
1
)
(
15
−
i
)
=
560
\frac12\sum\limits^{14}_{i=1}(15-i+1)(15-i)=560
21i=1∑14(15−i+1)(15−i)=560 种。可以使用双向BFS,这里用IDA*求解。
IDA*的核心与A*相同,需要设计出一个估价函数。这里引入后继:即某个元素之后的那个元素,如在从小到大的序列中,
1
1
1 的后继是
2
2
2,
2
2
2 的后继是
3
3
3。
在一次排书的操作 (取出
[
x
,
y
]
[x,y]
[x,y] 段插入
z
z
z 后) 中,序列中有且仅有
x
−
1
,
y
,
z
x-1,y,z
x−1,y,z 三者的后继被改变。统计出当前序列中后继“不正确” (后继不是它加一得到的数) 的数的个数
t
o
t
tot
tot,在最好情况下,每次排书操作使得
t
o
t
−
3
tot-3
tot−3,至少需要
⌈
t
o
t
/
3
⌉
(
=
⌊
(
t
o
t
+
2
)
/
3
⌋
)
\lceil tot/3\rceil\ (=\lfloor (tot+2)/3\rfloor)
⌈tot/3⌉ (=⌊(tot+2)/3⌋) 次操作才能到达目标状态。
代码实现:
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 15;
int n;
int q[N];
int w[5][N];
int f(){
int tot = 0;
for (int i = 0; i + 1 < n; i ++)
if (q[i + 1] != q[i] + 1)
tot ++;
return (tot + 2) / 3;
}
bool dfs(int depth, int max_depth){
if (depth + f() > max_depth) return false;
if (f() == 0) return true;
for (int len = 1; len <= n; len ++)
for (int l = 0; l + len - 1 < n; l ++){
int r = l + len - 1;
for (int k = r + 1; k < n; k ++){
memcpy(w[depth], q, sizeof q);
int y = l;
for (int x = r + 1; x <= k; x ++, y ++) q[y] = w[depth][x];
for (int 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(){
int T;
cin >> T;
while (T --){
cin >> n;
for (int i = 0; i < n; i ++) cin >> q[i];
int depth = 0;
while (depth < 5 && !dfs(0, depth)) depth ++;
if (depth >= 5) puts("5 or more");
else cout << depth << endl;
}
return 0;
}
五、回转游戏
(从直觉上来讲最优解所需步数不会太多)
用IDA*求解。这样设计估价函数:由于每一次操作至多会改变中间八个格子中的一个数,统计一下当前中间八个格子中哪个数最多,用
8
8
8 减去这最多的数的个数,即得到一个估价。
本题最难之初在于如何模拟这
8
8
8 种移动操作。可以将该 #
形棋盘中的每个格子编上序号,如下图所示,将其作为一个长度为
24
24
24 的序列存储。
再对于每个移动操作,按移动方向顺序存储下来移动的长度为
7
7
7 的序列中各个元素在原序列中的下标,在进行移动时只需将后
6
6
6 个元素前移一位,首元素移动到末尾即可。
代码实现:
#include <iostream>
#include <algorithm>
#include <cstring>
using namespace std;
const int N = 24;
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 opposite[8] = {5, 4, 7, 6, 1, 0, 3, 2};
int center[8] = {6, 7, 8, 11, 12, 15, 16, 17};
int q[N];
int path[100];
int f(){
int sum[4];
memset(sum, 0, sizeof sum);
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){ //last存储上一步操作
if (depth + f() > max_depth) return 0;
if (f() == 0) return 1;
for (int i = 0; i < 8; i ++)
if (last != opposite[i]){ //剪枝:如果上一步操作与当前操作相反,直接跳过
operate(i);
path[depth] = i;
if (dfs(depth + 1, max_depth, i)) return 1;
operate(opposite[i]);
}
return 0;
}
int main(){
while (cin >> q[0], q[0]){
for (int i = 1; i < N; i ++) cin >> q[i];
int depth = 0;
while (!dfs(0, depth, -1)) depth ++;
if (!depth) puts("No moves needed");
else{
for (int i = 0; i < depth; i ++)
cout << char('A' + path[i]);
cout << endl;
}
cout << q[6] << endl;
}
return 0;
}