[noip2015-提高] 斗地主

GitHub跳转原题关系图返回列表

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 加大小王)的一个子集,每张牌至多一张。 点数大小关系为 3<4<5<⋯<10<J<Q<K<A<2<3<4<5<\cdots<10<J<Q<K<A<2< 小王 << 大王,花色无大小之分。

每次出牌必须恰好是下列 11 种牌型之一:

  1. 单张 / 对子 / 三张 / 炸弹:同一点数的 1/2/3/41/2/3/4 张牌;
  2. 三带一 / 三带二:三张同点牌 ++ 一张单牌 / 一对;
  3. 单顺子:55 张或更多点数连续的单牌;双顺子:33 对或更多点数连续的对子; 三顺子:22 组或更多点数连续的三张牌——三种顺子都不能含点数 2 和双王,花色任意;
  4. 四带二:四张同点牌 ++ 任意两张单牌,或四张同点牌 ++ 任意两对;
  5. 火箭:小王 ++ 大王(双王)。

给定一手牌,求把全部牌出完的最少出牌次数。

样例 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 个槽位:0∼110\sim11 是顺子序 3,4,⋯ ,10,J,Q,K,A3,4,\cdots,10,J,Q,K,A,1212 是点数 2,13/1413/14 是小王/大王。DFS 走到第 ii 层(0⩽i⩽110 \leqslant i \leqslant 11)时二选一:

  • 跳过:不让点数 ii 起头任何顺子,进入第 i+1i+1 层;
  • 起头一手:枚举所有以 ii 起头的顺子类——单顺(长度 ⩾5\geqslant 5)、双顺(⩾3\geqslant 3 对)、 三顺(⩾2\geqslant 2 组)的全部可行长度,各出一手后仍停在第 ii 层(同一层可以起头多手)。

这样编码不重不漏:任何一组顺子都能按"起头点数从小到大"逐层落地——更早的消耗只来自本组里 已决定的顺子,第 ii 层要用的牌一定还在;反之每条路径出的顺子都合法。第 12 层以后顺子槽位走完, 剩余牌全部交给结算层。三个加速让搜索可控:

  1. 中间局面可达:每个节点"已出的顺子 ++ 散牌精确结算"都是合法出牌方案,随手收紧全局上界 best;
  2. 深度剪枝:再出一手也追不上上界(steps + 1 >= best)就回溯;
  3. 支配剪枝:同一个 (层, 剩余牌) 已用更少次数到过,就不再展开。

每手顺子至少消耗 5 张牌,n⩽23n \leqslant 23,所以一条路径最多 4 手顺子,搜索树天然很浅。

搜索单个节点的决策流程如下(从进入第 ii 层到回溯):

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 张的点数个数, 2 张, 3 张, 4 张, 王数)(\text{恰好 1 张的点数个数},\ 2\text{ 张},\ 3\text{ 张},\ 4\text{ 张},\ \text{王数})。 结算分两步。先把每个点数的牌拆成"基础组"(一个基础组就是一手牌的骨架),全部拆法如下:

点数张数 可行拆法(每项 = 单张组数, 对子组数, 三张组数, 炸弹组数) 说明
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 个对子组(四带二对)。设拆完得到 (S,P,T,B)(S,P,T,B) 个单/对/三/炸组,枚举"带单的三张数 xx"与"带对的三张数 yy"(各不超过 7),炸弹用剩余散组带满:

手数  =  S+P+T+B  −  x  −  y  −  2min⁡ ⁣(B, ⌊(S−x)/2⌋+⌊(P−y)/2⌋)\text{手数} \;=\; S+P+T+B \;-\; x \;-\; y \;-\; 2\min\!\bigl(B,\ \lfloor (S-x)/2\rfloor + \lfloor (P-y)/2\rfloor\bigr)

取所有拆法与 (x,y)(x,y) 里的最小值。两处不能贪心:其一,带牌要全局安排——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),同一种直方图只算一次。

第五步:正确性。 两头夹逼:一方面,每条搜索路径给出的"顺子手数 ++ 散牌结算"都是一个真实可行的 出牌方案(结算层的每一种拆法与带法都对应合法牌型),所以全局最小值 ⩽\leqslant 真实最优; 另一方面,设最优方案用了顺子集合 Σ\Sigma,由第三步的规范枚举,搜索树中必有一条路径恰好出 Σ\Sigma 里的顺子 (结算层不打顺子,Σ\Sigma 之外的牌全由它精确结算),该节点取值 ⩽\leqslant 最优值。 两边相等,搜索取到的就是最优解。

第六步:与样例对照。 样例 1 的槽位计数里 7,8,9,10,J7,8,9,10,J 各 1 张,起头于槽位 4 的单顺 7..J 被枚举到,出完后结算层对手里的 5(1 张)与 A A(2 张)算出 2 手,合计 1+2=31+2=3; 「一手顺子都不出」的初始上界是 7 手(6 张单牌各出一次 + 对子 A 一次),被这条路径的 3 手取代,最终输出 3。

代码

slot_of 把输入映射到 15 个槽位;SPLITS 是上表的全部拆法; absorb 枚举 (x,y)(x,y) 求散牌最少手数,min_plain 按直方图记忆化(@cache); cuts 枚举以某槽位起头的全部顺子类,min_rounds 是分层 DFS 加三重剪枝;solve 负责读入与输出。

cpp
/**
 * 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;}
python
#!/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 个顺子槽位分层,每层分支数 == 以该点起头的顺子数 +1+1(跳过),每条路径顺子手数 ⩽4\leqslant 4(每手至少 5 张,n⩽23n\leqslant 23)。最坏情况随顺子密度指数增长(与经典 C++ 正解同阶的 组合搜索),剪枝后实测极小:20 组真实数据 DFS 节点合计 6235 个(最大单手 2575 个), 单个节点代价 O(15)O(15)(重建直方图 + 查缓存)。
  • 结算层:直方图种数受 h1+2h2+3h3+4h4+王数⩽23h_1+2h_2+3h_3+4h_4+\text{王数}\leqslant 23 限制,实测全部数据只有 194 种;每种直方图的拆组聚合状态最多 40 个,absorb 枚举 x,yx,y 各至多 8 档, 单次结算近似常数;@cache 保证每个直方图只算一次。
  • 总时间:实测每组数据(含 T=100T=100 的文件)0.017∼0.030.017\sim0.03 s,远低于 1 s 时限(本地校验放宽到 20 s)。
  • 空间:搜索栈深 ⩽12+4=16\leqslant 12+4=16,记忆化字典至多百级条目;实测峰值约 15 MB(含 Python 解释器基线), 满足 32 MB 限制。

总结

  1. 牌型二分是本题的骨架:顺子依赖点数连续性、不能贪心,必须枚举组合;其余 8 种牌型只依赖 “每个点数剩几张”,与点数顺序无关,可以对直方图精确记忆化结算。两层相加取最小。
  2. 规范枚举保证不重不漏:第 ii 层只决定"点数 ii 是否起头顺子",出一手后仍留在本层; 任何顺子组合都能按起头点数从小到大落地。
  3. 剪枝全部合法:中间节点本身就是可行方案(收紧上界)、追不上上界就剪、同局面用更少次数支配。
  4. 边界别写漏:点数 2 与双王不进顺子;单顺 ⩾5\geqslant 5、双顺 ⩾3\geqslant 3 对、三顺 ⩾2\geqslant 2 组; 四带二可以带两单或两对;双王既可合火箭、也可拆成两张单被带走;带牌要全局安排,拆组可能是优解。