DFS 按点数分层枚举单顺、双顺、三顺的全部组合,散牌只按张数直方图记忆化精确结算(拆组、带牌、双王火箭),两层相加取最小手数。
OJ: roj
题目 ID: 3630
难度:提高
标签:提高搜索DFS剪枝记忆化搜索python
创建: 2026-10-02 11:31
更新: 2026-10-10 11:30
形式化题目
一手牌是 54 张牌(4 种花色的 3~10、J、Q、K、A、2 加大小王)的一个子集,每张牌至多一张。
点数大小关系为
每次出牌必须恰好是下列 11 种牌型之一:
- 单张 / 对子 / 三张 / 炸弹:同一点数的
张牌; - 三带一 / 三带二:三张同点牌
一张单牌 / 一对; - 单顺子:
张或更多点数连续的单牌;双顺子: 对或更多点数连续的对子; 三顺子: 组或更多点数连续的三张牌——三种顺子都不能含点数 2 和双王,花色任意; - 四带二:四张同点牌
任意两张单牌,或四张同点牌 任意两对; - 火箭:小王
大王(双王)。
给定一手牌,求把全部牌出完的最少出牌次数。
样例 1(8 张牌)的一种最优出法,3 次打光(花色与分组无关,只看点数):
| 手次 | 牌型 | 具体的牌 | 剩余张数 |
|---|---|---|---|
| 1 | 单顺子 | 7、8、9、10、J | 3 |
| 2 | 单张 | 5 | 2 |
| 3 | 对子 | A、A | 0 |
样例 2(17 张牌)答案是 6。一种达到 6 的出法:单顺子 3-4-5-6-7(1 手)、 四带二 QQQQ 带 5 和 A(1 手)、对子 1010、对子 22、单张 J、单张小王(各 1 手)。 搜索可以证明 5 手打不完("QQQQ 带 2 张单"之后剩下的对子与单牌无法再合并)。
正解
思路
一句话本质:把出牌拆成两层——顺子类牌型必须枚举组合,散牌类牌型只取决于剩余牌的直方图、可以精确结算; DFS 按点数分层枚举顺子组合,每到一个中间局面就用结算层算出"剩下的牌最少几手",全程取最小。
第一步:朴素想法与瓶颈。 最朴素的做法是搜索"每一手出什么":每步枚举所有合法牌型与带牌选择,
递归到出完取最小。两个瓶颈立刻出现。其一,顺子不能贪心:手牌 3 4 5 6 7 6 7 8 9 10 里,
先出最长单顺 3..10 会剩下 6 7 两张散牌(共 3 手),而出 3..7 与 6..10 两手顺子直接打光(2 手),
所以顺子的组合必须枚举、不能"每次出最长"。其二,散牌的带牌选择(三带一带哪张、四带二带哪两张……)
再乘上一层分支,两层耦合后状态爆炸。
第二步:牌型二分。 观察 11 种牌型:三种顺子是唯一点数相关的牌型,其余 8 种 (单张、对子、三张、三带一、三带二、炸弹、四带二、火箭)只关心"每个点数还剩几张"。 于是:
- 搜索层只负责顺子类:枚举"出了哪些顺子"的组合;
- 结算层负责散牌类:给定剩余牌,精确求出打光它的最少手数。
两层相加取最小,就覆盖了所有出法。
第三步:搜索层——按点数分层的规范枚举。 把牌压进 15 个槽位:
- 跳过:不让点数
起头任何顺子,进入第 层; - 起头一手:枚举所有以
起头的顺子类——单顺(长度 )、双顺( 对)、 三顺( 组)的全部可行长度,各出一手后仍停在第 层(同一层可以起头多手)。
这样编码不重不漏:任何一组顺子都能按"起头点数从小到大"逐层落地——更早的消耗只来自本组里
已决定的顺子,第
- 中间局面可达:每个节点"已出的顺子
散牌精确结算"都是合法出牌方案,随手收紧全局上界 best; - 深度剪枝:再出一手也追不上上界(
steps + 1 >= best)就回溯; - 支配剪枝:同一个
(层, 剩余牌)已用更少次数到过,就不再展开。
每手顺子至少消耗 5 张牌,
搜索单个节点的决策流程如下(从进入第
flowchart TD
A["进入第 i 层 (i=0..11)"] --> B["结算层收紧上界: best = min(best, steps + min_plain(剩余牌))"]
B --> C{"i==12 或 steps+1 >= best?"}
C -- 是 --> D["回溯"]
C -- 否 --> E{" (i, 剩余牌) 已用更少次数到过? "}
E -- 是 --> D
E -- 否 --> F{"槽位 i 有牌?"}
F -- 否 --> G["跳过: 进入第 i+1 层"]
F -- 是 --> H["枚举以 i 起头的顺子: 单顺/双顺/三顺 x 所有可行长度"]
H --> I["出一手, steps+1, 仍停在第 i 层"]
G --> A
I --> A
第四步:结算层——散牌只按直方图精确结算。 散牌类里点数完全对称(三张带的单牌可以任选点数),
所以答案只取决于直方图
| 点数张数 | 可行拆法(每项 = 单张组数, 对子组数, 三张组数, 炸弹组数) | 说明 |
|---|---|---|
| 1 张 | (1,0,0,0) | 只能单出 |
| 2 张 | (0,1,0,0)、(2,0,0,0) | 对子,或拆成两张单 |
| 3 张 | (0,0,1,0)、(1,1,0,0)、(3,0,0,0) | 三张,或对+单,或三单 |
| 4 张 | (0,0,0,1)、(1,0,1,0)、(0,2,0,0)、(2,1,0,0)、(4,0,0,0) | 炸弹,或三+单,或两对,或对+两单,或四单 |
再让三张、炸弹顺带走散组:三张每手可带 1 个单组(三带一)或 1 个对子组(三带二);
炸弹每手可带 2 个单组(四带二单)或 2 个对子组(四带二对)。设拆完得到
取所有拆法与 333 4444 77 88 里
若三张见对就带(333+77),只剩 4444、88 共 3 手;全局看是 4444+77+88、333 只要 2 手;
其二,拆组本身可能是优解——4444 5555 77 88 99 里若把 99 留作对子,两个炸弹最多带满一个
(4444+77+88、5555、99 共 3 手);按规则"任意两张单牌"把 99 拆成两单,
4444+77+88、5555+9+9 只要 2 手。双王单独处理:合火箭 1 手,或当两张单牌(可被三张/炸弹带走),取小。
结算结果与点数无关,按直方图做全局记忆化(@cache),同一种直方图只算一次。
第五步:正确性。 两头夹逼:一方面,每条搜索路径给出的"顺子手数
第六步:与样例对照。 样例 1 的槽位计数里 7..J 被枚举到,出完后结算层对手里的 5(1 张)与 A A(2 张)算出 2 手,合计
代码
slot_of 把输入映射到 15 个槽位;SPLITS 是上表的全部拆法;
absorb 枚举 min_plain 按直方图记忆化(@cache);
cuts 枚举以某槽位起头的全部顺子类,min_rounds 是分层 DFS 加三重剪枝;solve 负责读入与输出。
/**
* Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* rainboy的学习导航网站: https://idx.roj.ac.cn
* create_at: 2026-10-10 11:30
* update_at: 2026-10-10 11:30
*/
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
struct Split{int s,p,t,b;};
vector<Split> splits[4]; map<array<int,5>, int> memo_plain; int best_ans; map<string,int> seen;
int slot_of(int value,int suit){ if(value==0) return 12+suit; if(value==2) return 12; return value==1?11:value-3; }
int absorb(int single,int pairc,int triple,int bomb){ int bare=single+pairc+triple+bomb,best=bare; for(int ts=0;ts<=min(triple,single);ts++) for(int tp=0;tp<=min(triple-ts,pairc);tp++){int fs=single-ts, fp=pairc-tp; int carried=2*min(bomb, fs/2+fp/2); best=min(best,bare-ts-tp-carried);} return best; }
int min_plain(int c1,int c2,int c3,int c4,int jokers){ array<int,5> key={c1,c2,c3,c4,jokers}; if(memo_plain.count(key)) return memo_plain[key]; vector<array<int,4> > states; states.push_back({0,0,0,0}); int nums[4]={c1,c2,c3,c4}; for(int size=1;size<=4;size++){ for(int rep=0;rep<nums[size-1];rep++){ vector<array<int,4> > nxt; for(int i=0;i<(int)states.size();i++) for(int j=0;j<(int)splits[size-1].size();j++){Split sp=splits[size-1][j]; array<int,4> a={states[i][0]+sp.s,states[i][1]+sp.p,states[i][2]+sp.t,states[i][3]+sp.b}; nxt.push_back(a);} states.swap(nxt); } } int plain=100; for(int i=0;i<(int)states.size();i++) plain=min(plain,absorb(states[i][0]+jokers,states[i][1],states[i][2],states[i][3])); if(jokers==2){int tmp=100; for(int i=0;i<(int)states.size();i++) tmp=min(tmp,absorb(states[i][0],states[i][1],states[i][2],states[i][3])); plain=min(plain,tmp+1);} memo_plain[key]=plain; return plain; }
int plain_of(vector<int>&cnt){int hist[4]={0,0,0,0}; for(int i=0;i<13;i++) if(cnt[i]) hist[cnt[i]-1]++; return min_plain(hist[0],hist[1],hist[2],hist[3],cnt[13]+cnt[14]);}
string enc_state(int i, vector<int>&cnt){string s=to_string(i)+":"; for(int j=0;j<15;j++){s.push_back(char('0'+cnt[j]));} return s;}
vector<vector<int> > cuts(vector<int>&cnt,int start){ vector<vector<int> > res; int needs[3]={1,2,3}, lens[3]={5,3,2}; for(int a=0;a<3;a++){int need=needs[a], minlen=lens[a]; for(int len=minlen; len<13-start; len++){bool ok=true; for(int k=0;k<len;k++) if(cnt[start+k]<need){ok=false; break;} if(!ok) break; vector<int> nxt=cnt; for(int k=0;k<len;k++) nxt[start+k]-=need; res.push_back(nxt);}} return res;}
int sum_cnt(const vector<int>&v){int s=0; for(int i=0;i<(int)v.size();i++) s+=v[i]; return s;}
bool cmp_cut(const vector<int>&a,const vector<int>&b){return sum_cnt(a)<sum_cnt(b);}
void dfs(int i,int steps,vector<int>&cnt){int value=steps+plain_of(cnt); if(value<best_ans) best_ans=value; if(i==12 || steps+1>=best_ans) return; string key=enc_state(i,cnt); if(seen.count(key) && seen[key]<=steps) return; seen[key]=steps; if(cnt[i]==0){dfs(i+1,steps,cnt);return;} vector<vector<int> > all=cuts(cnt,i); sort(all.begin(),all.end(),cmp_cut); for(int z=0;z<(int)all.size();z++) dfs(i,steps+1,all[z]); dfs(i+1,steps,cnt);}
int min_rounds(vector<int> cards){best_ans=plain_of(cards); seen.clear(); dfs(0,0,cards); return best_ans;}
int main(){ios::sync_with_stdio(false); cin.tie(nullptr); splits[0].push_back({1,0,0,0}); splits[1].push_back({0,1,0,0}); splits[1].push_back({2,0,0,0}); splits[2].push_back({0,0,1,0}); splits[2].push_back({1,1,0,0}); splits[2].push_back({3,0,0,0}); splits[3].push_back({0,0,0,1}); splits[3].push_back({1,0,1,0}); splits[3].push_back({0,2,0,0}); splits[3].push_back({2,1,0,0}); splits[3].push_back({4,0,0,0}); int T,n; if(!(cin>>T>>n)) return 0; while(T--){vector<int> cards(15); for(int i=0;i<n;i++){int v,s;cin>>v>>s; cards[slot_of(v,s)]++;} cout<<min_rounds(cards)<<'\n';} return 0;}#!/usr/bin/env python3
# Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
# rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
# rainboy的学习导航网站: https://idx.roj.ac.cn
# create_at: 2026-10-02 11:47
# update_at: 2026-10-02 11:47
import sys
from collections.abc import Iterator
from functools import cache
# counts 共 15 个槽位:0..11 是顺子序(3..10、J、Q、K、A),12 是点数 2,13/14 是小王/大王。
# 2 和双王不能进顺子,所以顺子枚举只走前 12 个槽位。
SPLITS = (
((1, 0, 0, 0),), # 1 张:单张
((0, 1, 0, 0), (2, 0, 0, 0)), # 2 张:对子 / 拆成两单
((0, 0, 1, 0), (1, 1, 0, 0), (3, 0, 0, 0)), # 3 张:三张 / 对+单 / 三单
((0, 0, 0, 1), (1, 0, 1, 0), (0, 2, 0, 0), # 4 张:炸弹 / 三+单 /
(2, 1, 0, 0), (4, 0, 0, 0)), # 两对 / 对+两单 / 四单
) # 一个点数拆成基础组的全部方式,每项是 (单张数, 对子数, 三张数, 炸弹数)
def slot_of(value: int, suit: int) -> int:
"""把输入的一张牌(点数, 花色)映射到 counts 槽位。"""
if value == 0: # 小王 01、大王 02
return 12 + suit
if value == 2: # 点数 2 单独存放,永远进不了顺子
return 12
return 11 if value == 1 else value - 3 # A 的点数是 1,但顺子序里排在 K 之后
def absorb(single: int, pair: int, triple: int, bomb: int) -> int:
"""散牌最少出法:每手牌 = 一个基础组,三张带一单或一对、炸弹带两单或两对能带就带。"""
bare = single + pair + triple + bomb
best = bare
for take_s in range(min(triple, single) + 1): # 带单的三张个数
for take_p in range(min(triple - take_s, pair) + 1): # 带对的三张个数
free_s, free_p = single - take_s, pair - take_p
# 每个炸弹整手带走 2 个散组:两单或两对,两类凑够就能一次带满
carried = 2 * min(bomb, free_s // 2 + free_p // 2)
best = min(best, bare - take_s - take_p - carried)
return best
@cache
def min_plain(c1: int, c2: int, c3: int, c4: int, jokers: int) -> int:
"""不打任何顺子类时打光这些牌的最少次数,答案只取决于各类点数的个数(直方图)。
c_k = 恰好 k 张的点数个数(不含双王),jokers = 王数(0/1/2)。
每个点数先拆成基础组,再让三张/炸弹顺带走散组,取所有拆法里的最小值。
"""
states = {(0, 0, 0, 0)}
for size, num in ((1, c1), (2, c2), (3, c3), (4, c4)):
for _ in range(num):
states = {
(s + ds, p + dp, t + dt, b + db)
for s, p, t, b in states
for ds, dp, dt, db in SPLITS[size - 1]
}
plain = min(absorb(s + jokers, p, t, b) for s, p, t, b in states)
if jokers == 2: # 双王也可以合火箭:一手打完,比两张单各出一次少一手
plain = min(plain, min(absorb(s, p, t, b) for s, p, t, b in states) + 1)
return plain
def plain_of(cnt: tuple[int, ...]) -> int:
"""当前 counts 里非顺子部分(散牌)的最少出牌次数。"""
hist = [0, 0, 0, 0]
for k in cnt[:13]: # 13 个普通点数(含点数 2),双王单独算
if k:
hist[k - 1] += 1
return min_plain(hist[0], hist[1], hist[2], hist[3], cnt[13] + cnt[14])
def cuts(cnt: tuple[int, ...], start: int) -> Iterator[tuple[int, ...]]:
"""枚举从 start 起头的全部顺子类出法(单顺≥5、双顺≥3 对、三顺≥2 三),产出出完后的 counts。"""
for need, min_len in ((1, 5), (2, 3), (3, 2)):
for length in range(min_len, 13 - start):
if any(cnt[start + k] < need for k in range(length)):
break # 更短的都凑不齐,更长的必然也不行
nxt = list(cnt)
for k in range(length):
nxt[start + k] -= need
yield tuple(nxt)
def min_rounds(cards: list[int]) -> int:
"""搜索顺子类牌型的所有组合,非顺子部分用 min_plain 精确结算,返回最少出牌次数。"""
start = tuple(cards)
best = [plain_of(start)] # 一手顺子都不打也是一个可行解
seen: dict[tuple[int, tuple[int, ...]], int] = {}
def dfs(i: int, steps: int, cnt: tuple[int, ...]) -> None:
value = steps + plain_of(cnt) # 每个中间局面都可达,随手收紧上界
if value < best[0]:
best[0] = value
if i == 12 or steps + 1 >= best[0]:
return # 顺子槽位走完;或再出一手也追不上当前最优
key = (i, cnt)
if key in seen and seen[key] <= steps:
return # 同一局面已经用更少的次数到过,继续只会更差
seen[key] = steps
if cnt[i] == 0:
dfs(i + 1, steps, cnt)
return
# 先试出得最多的顺子,让上界尽快收紧;sum 越小说明这一手拿走的牌越多
for nxt in sorted(cuts(cnt, i), key=sum):
dfs(i, steps + 1, nxt)
dfs(i + 1, steps, cnt) # 这个点数不参与任何顺子,留作散牌
dfs(0, 0, start)
return best[0]
def solve() -> None:
data = iter(map(int, sys.stdin.buffer.read().split()))
out: list[str] = []
T = next(data)
n = next(data)
for _ in range(T):
cards = [0] * 15
for _ in range(n):
value, suit = next(data), next(data)
cards[slot_of(value, suit)] += 1
out.append(str(min_rounds(cards)))
print('\n'.join(out))
if __name__ == "__main__":
solve()复杂度
- 搜索层:12 个顺子槽位分层,每层分支数
以该点起头的顺子数 (跳过),每条路径顺子手数 (每手至少 5 张, )。最坏情况随顺子密度指数增长(与经典 C++ 正解同阶的 组合搜索),剪枝后实测极小:20 组真实数据 DFS 节点合计 6235 个(最大单手 2575 个), 单个节点代价 (重建直方图 + 查缓存)。 - 结算层:直方图种数受
限制,实测全部数据只有 194 种;每种直方图的拆组聚合状态最多 40 个, absorb枚举各至多 8 档, 单次结算近似常数; @cache保证每个直方图只算一次。 - 总时间:实测每组数据(含
的文件) s,远低于 1 s 时限(本地校验放宽到 20 s)。 - 空间:搜索栈深
,记忆化字典至多百级条目;实测峰值约 15 MB(含 Python 解释器基线), 满足 32 MB 限制。
总结
- 牌型二分是本题的骨架:顺子依赖点数连续性、不能贪心,必须枚举组合;其余 8 种牌型只依赖 “每个点数剩几张”,与点数顺序无关,可以对直方图精确记忆化结算。两层相加取最小。
- 规范枚举保证不重不漏:第
层只决定"点数 是否起头顺子",出一手后仍留在本层; 任何顺子组合都能按起头点数从小到大落地。 - 剪枝全部合法:中间节点本身就是可行方案(收紧上界)、追不上上界就剪、同局面用更少次数支配。
- 边界别写漏:点数 2 与双王不进顺子;单顺
、双顺 对、三顺 组; 四带二可以带两单或两对;双王既可合火箭、也可拆成两张单被带走;带牌要全局安排,拆组可能是优解。

