[NOIP 2015 提高组] 斗地主

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

先在外层 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 直接从当前手牌枚举所有合法出牌方式,然后递归搜索最少步数。

这个做法很直观,但会遇到两个问题:

  1. 同一个剩余状态会被重复搜索很多次;
  2. 顺子类牌型是连续区间,和普通牌型混在一起枚举会很乱。

所以正式解把问题拆成两层。

第一层,先枚举顺子类牌型:

  • 单顺
  • 双顺
  • 三顺

顺子类的共同特点是都作用在一段连续区间上,因此很适合作为外层 DFS 的分支。

第二层,当我们决定“当前不再继续拆顺子”后, 剩余问题只需要处理这些非顺子牌型:

  • 单张
  • 对子
  • 三张
  • 三带一
  • 三带二
  • 炸弹
  • 火箭
  • 四带二

这时状态只和“每个点数还剩几张牌”有关,和花色已经完全无关了。

因此可以把整组手牌压成计数数组 cnt[i], 再用记忆化搜索求这个状态的最优值。

正式解的结构就是:

  1. dfs_straight():枚举所有可能的顺子类拆法;
  2. solve_rest():对当前剩余状态做记忆化搜索,精确求非顺子部分的最优解;
  3. 两者结合,得到全局最优答案。

这里还有一个很关键的细节:

  • 顺子不能包含 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 里暴搜, 而是先把最难处理的“连续顺子类”拆出来, 再把剩余状态压成“每个点数剩几张”的记忆化搜索问题。

这样结构会清楚很多,也更容易写对。

一图流解析

这张图把本题的建模、关键转移、实现检查和训练方法压缩到一页,适合读完正文后复盘。

一图流解析