先在外层 DFS 枚举单顺、双顺、三顺的拆法,再对剩余牌型做记忆化搜索,精确求出最少出牌次数。
OJ: luogu
题目 ID: P2668
难度:提高+/省选-
标签:搜索记忆化搜索DFS状态压缩思维
日期: 2026-06-20 21:10
题意
给出若干组手牌,每组有 n 张。
每次可以按题目给定的合法牌型打出一手牌,目标是让总出牌次数最少。
本题只关心最少要打多少手,不考虑和对手比大小。
思路
先看一个最容易理解的小数据暴力:
cpp
#include <bits/stdc++.h>
using namespace std;
const int RANK_CNT = 16;
int T, n;
int cnt[RANK_CNT];
int answer;
int rank_id(int a) {
if (a == 0) {
return 14;
}
if (a == 1) {
return 12;
}
if (a == 2) {
return 13;
}
return a - 2;
}
bool empty_hand() {
for (int i = 1; i <= 15; i++) {
if (cnt[i] != 0) {
return false;
}
}
return true;
}
void dfs(int step) {
if (step >= answer) {
return;
}
if (empty_hand()) {
answer = step;
return;
}
for (int i = 1; i <= 15; i++) {
if (cnt[i] == 0) {
continue;
}
cnt[i]--;
dfs(step + 1);
cnt[i]++;
if (cnt[i] >= 2) {
cnt[i] -= 2;
dfs(step + 1);
cnt[i] += 2;
}
if (cnt[i] >= 3) {
cnt[i] -= 3;
dfs(step + 1);
for (int j = 1; j <= 15; j++) {
if (cnt[j] >= 1) {
cnt[j]--;
dfs(step + 1);
cnt[j]++;
}
}
for (int j = 1; j <= 15; j++) {
if (cnt[j] >= 2) {
cnt[j] -= 2;
dfs(step + 1);
cnt[j] += 2;
}
}
cnt[i] += 3;
}
if (cnt[i] >= 4) {
cnt[i] -= 4;
dfs(step + 1);
for (int a = 1; a <= 15; a++) {
if (cnt[a] == 0) {
continue;
}
cnt[a]--;
for (int b = a; b <= 15; b++) {
if (cnt[b] == 0) {
continue;
}
cnt[b]--;
dfs(step + 1);
cnt[b]++;
}
cnt[a]++;
}
for (int a = 1; a <= 15; a++) {
if (cnt[a] < 2) {
continue;
}
cnt[a] -= 2;
for (int b = a; b <= 15; b++) {
if (cnt[b] < 2) {
continue;
}
cnt[b] -= 2;
dfs(step + 1);
cnt[b] += 2;
}
cnt[a] += 2;
}
cnt[i] += 4;
}
}
if (cnt[14] >= 1 && cnt[15] >= 1) {
cnt[14]--;
cnt[15]--;
dfs(step + 1);
cnt[14]++;
cnt[15]++;
}
for (int l = 1; l <= 12; l++) {
if (cnt[l] == 0) {
continue;
}
for (int r = l; r <= 12 && cnt[r] >= 1; r++) {
if (r - l + 1 < 5) {
continue;
}
for (int i = l; i <= r; i++) {
cnt[i]--;
}
dfs(step + 1);
for (int i = l; i <= r; i++) {
cnt[i]++;
}
}
}
for (int l = 1; l <= 12; l++) {
if (cnt[l] < 2) {
continue;
}
for (int r = l; r <= 12 && cnt[r] >= 2; r++) {
if (r - l + 1 < 3) {
continue;
}
for (int i = l; i <= r; i++) {
cnt[i] -= 2;
}
dfs(step + 1);
for (int i = l; i <= r; i++) {
cnt[i] += 2;
}
}
}
for (int l = 1; l <= 12; l++) {
if (cnt[l] < 3) {
continue;
}
for (int r = l; r <= 12 && cnt[r] >= 3; r++) {
if (r - l + 1 < 2) {
continue;
}
for (int i = l; i <= r; i++) {
cnt[i] -= 3;
}
dfs(step + 1);
for (int i = l; i <= r; i++) {
cnt[i] += 3;
}
}
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> T >> n;
while (T--) {
memset(cnt, 0, sizeof(cnt));
for (int i = 1; i <= n; i++) {
int a, b;
cin >> a >> b;
cnt[rank_id(a)]++;
}
answer = n;
dfs(0);
cout << answer << '\n';
}
return 0;
}brute.cpp 直接从当前手牌枚举所有合法出牌方式,然后递归搜索最少步数。
这个做法很直观,但会遇到两个问题:
- 同一个剩余状态会被重复搜索很多次;
- 顺子类牌型是连续区间,和普通牌型混在一起枚举会很乱。
所以正式解把问题拆成两层。
第一层,先枚举顺子类牌型:
- 单顺
- 双顺
- 三顺
顺子类的共同特点是都作用在一段连续区间上,因此很适合作为外层 DFS 的分支。
第二层,当我们决定“当前不再继续拆顺子”后, 剩余问题只需要处理这些非顺子牌型:
- 单张
- 对子
- 三张
- 三带一
- 三带二
- 炸弹
- 火箭
- 四带二
这时状态只和“每个点数还剩几张牌”有关,和花色已经完全无关了。
因此可以把整组手牌压成计数数组 cnt[i],
再用记忆化搜索求这个状态的最优值。
正式解的结构就是:
dfs_straight():枚举所有可能的顺子类拆法;solve_rest():对当前剩余状态做记忆化搜索,精确求非顺子部分的最优解;- 两者结合,得到全局最优答案。
这里还有一个很关键的细节:
- 顺子不能包含
2和大小王
所以代码里顺子只会枚举到 A 为止。
代码
cpp
#include <bits/stdc++.h>
using namespace std;
const int RANK_CNT = 16;
int T, n;
int cnt[RANK_CNT];
int answer;
map<long long, int> memo;
// 把 3..A,2,小王,大王 映射到 1..15,方便顺子判断。
int rank_id(int a) {
if (a == 0) {
return 14;
}
if (a == 1) {
return 12;
}
if (a == 2) {
return 13;
}
return a - 2;
}
long long encode_state() {
long long code = 0;
for (int i = 1; i <= 15; i++) {
code = code * 5 + cnt[i];
}
return code;
}
// 不再考虑顺子时,剩余牌型的最优解。
int solve_rest() {
long long state = encode_state();
map<long long, int>::iterator it = memo.find(state);
if (it != memo.end()) {
return it->second;
}
int single_cnt = 0;
int pair_cnt = 0;
int best = 0;
for (int i = 1; i <= 15; i++) {
if (cnt[i] == 1) {
single_cnt++;
} else if (cnt[i] == 2) {
pair_cnt++;
} else if (cnt[i] == 3) {
best++;
} else if (cnt[i] == 4) {
best++;
}
}
// 三张/炸弹尽量带牌,可以把剩下的单牌或对子吸收进去。
for (int i = 1; i <= 15; i++) {
if (cnt[i] == 3) {
if (pair_cnt > 0) {
pair_cnt--;
} else if (single_cnt > 0) {
single_cnt--;
}
} else if (cnt[i] == 4) {
if (pair_cnt >= 2) {
pair_cnt -= 2;
} else if (pair_cnt >= 1 && single_cnt >= 1) {
pair_cnt--;
single_cnt--;
} else if (single_cnt >= 2) {
single_cnt -= 2;
}
}
}
best += pair_cnt + single_cnt;
// 再尝试更精细地枚举三张/炸弹的出法,修正上面的贪心估值。
for (int i = 1; i <= 15; i++) {
if (cnt[i] >= 3) {
cnt[i] -= 3;
best = min(best, solve_rest() + 1);
for (int j = 1; j <= 15; j++) {
if (cnt[j] >= 1) {
cnt[j]--;
best = min(best, solve_rest() + 1);
cnt[j]++;
}
}
for (int j = 1; j <= 15; j++) {
if (cnt[j] >= 2) {
cnt[j] -= 2;
best = min(best, solve_rest() + 1);
cnt[j] += 2;
}
}
cnt[i] += 3;
}
if (cnt[i] == 4) {
cnt[i] -= 4;
best = min(best, solve_rest() + 1);
for (int a = 1; a <= 15; a++) {
if (cnt[a] == 0) {
continue;
}
cnt[a]--;
for (int b = a; b <= 15; b++) {
if (cnt[b] == 0) {
continue;
}
cnt[b]--;
best = min(best, solve_rest() + 1);
cnt[b]++;
}
cnt[a]++;
}
for (int a = 1; a <= 15; a++) {
if (cnt[a] < 2) {
continue;
}
cnt[a] -= 2;
for (int b = a; b <= 15; b++) {
if (cnt[b] < 2) {
continue;
}
cnt[b] -= 2;
best = min(best, solve_rest() + 1);
cnt[b] += 2;
}
cnt[a] += 2;
}
cnt[i] += 4;
}
}
if (cnt[14] >= 1 && cnt[15] >= 1) {
cnt[14]--;
cnt[15]--;
best = min(best, solve_rest() + 1);
cnt[14]++;
cnt[15]++;
}
memo[state] = best;
return best;
}
// depth 表示已经打出了多少手顺子类牌型。
void dfs_straight(int depth) {
if (depth >= answer) {
return;
}
answer = min(answer, depth + solve_rest());
// 单顺子:长度至少 5,只能用到 A,不能含 2 和大小王。
for (int l = 1; l <= 12; l++) {
if (cnt[l] == 0) {
continue;
}
for (int r = l; r <= 12 && cnt[r] >= 1; r++) {
if (r - l + 1 < 5) {
continue;
}
for (int i = l; i <= r; i++) {
cnt[i]--;
}
dfs_straight(depth + 1);
for (int i = l; i <= r; i++) {
cnt[i]++;
}
}
}
// 双顺子:长度至少 3。
for (int l = 1; l <= 12; l++) {
if (cnt[l] < 2) {
continue;
}
for (int r = l; r <= 12 && cnt[r] >= 2; r++) {
if (r - l + 1 < 3) {
continue;
}
for (int i = l; i <= r; i++) {
cnt[i] -= 2;
}
dfs_straight(depth + 1);
for (int i = l; i <= r; i++) {
cnt[i] += 2;
}
}
}
// 三顺子:长度至少 2。
for (int l = 1; l <= 12; l++) {
if (cnt[l] < 3) {
continue;
}
for (int r = l; r <= 12 && cnt[r] >= 3; r++) {
if (r - l + 1 < 2) {
continue;
}
for (int i = l; i <= r; i++) {
cnt[i] -= 3;
}
dfs_straight(depth + 1);
for (int i = l; i <= r; i++) {
cnt[i] += 3;
}
}
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> T >> n;
while (T--) {
memset(cnt, 0, sizeof(cnt));
for (int i = 1; i <= n; i++) {
int a, b;
cin >> a >> b;
cnt[rank_id(a)]++;
}
memo.clear();
answer = n;
dfs_straight(0);
cout << answer << '\n';
}
return 0;
}复杂度
这题的复杂度很难写成一个紧致公式,因为搜索规模和手牌结构强相关。
从实现上看:
- 外层 DFS 枚举顺子类拆法
- 内层记忆化避免同一状态重复计算
在 n <= 23 且数据随机的条件下可以通过。
总结
这题的重点不是把所有牌型硬塞进一个 DFS 里暴搜, 而是先把最难处理的“连续顺子类”拆出来, 再把剩余状态压成“每个点数剩几张”的记忆化搜索问题。
这样结构会清楚很多,也更容易写对。
一图流解析
这张图把本题的建模、关键转移、实现检查和训练方法压缩到一页,适合读完正文后复盘。

