Skip to content

Cinema - 题解

标签与难度

标签: 动态规划, 状态压缩DP, Profile DP, 位运算, 图论, 最大独立集 难度: 2300

题目大意喵~

各位主人晚上好,喵~!今天我们来解决一个电影院座位安排的问题,听起来很有趣对吧?

问题是这样的:有一个 nnmm 列的电影院。为了保持社交距离,任何两个人都不能坐在相邻的座位上。两个座位 (x1,y1)(x_1, y_1)(x2,y2)(x_2, y_2) 如果满足 x1x2+y1y2=1|x_1 - x_2| + |y_1 - y_2| = 1,它们就是相邻的(也就是上下左右)。

客人们会一个接一个地来买票,并且他们可以自己选择任何一个“有效”的座位。一个座位是“有效”的,当且仅当它没有被占据,并且它的所有邻居座位也都没有被占据。当整个电影院里再也找不到任何一个“有效”座位时,售票就会停止。

作为电影院老板,我们想知道一个最坏的情况:假设客人们总是做出“最糟糕”的选择,使得最终卖出的票数最少。我们需要求出这个最少的票数是多少,并且还要打印出一种能达到这个最少票数的座位安排方案,喵~

简单来说,就是要找一种座位安排方式,满足:

  1. 没有人坐在相邻座位。
  2. 不能再往里加人了(所有空位都至少有一个邻居被占了)。
  3. 在这种安排下,总人数最少。

解题思路分析

这道题的核心,其实是在一个网格图上寻找一个最小的极大独立集 (Minimum Maximal Independent Set),喵~

  • 独立集:在图论里,一个点的集合,如果集合中任意两点之间都没有边相连,那它就是一个独立集。对应到这道题,就是任何两个坐了人的座位都不相邻。
  • 极大独立集:一个独立集,如果再也无法加入任何一个点(不破坏独立集性质),那它就是极大的。对应到这-道题,就是售票停止时的状态——所有空位都至少有一个被占用的邻居,不能再加人了。
  • 最小的极大独立集:在所有可能的极大独立集中,找到那个点数(人数)最少的。

寻找最小极大独立集在一般图上是NP-hard问题,非常困难的说!但是,这道题的 mm(列数)非常小(m15m \le 15),这给了我们一个重要的提示:可以使用**状态压缩动态规划(Profile DP)**来解决,喵!

我们可以一行一行地来安排座位,并计算到当前行为止的最小总人数。

1. DP状态定义

我们需要定义一个DP状态,它能包含足够的信息来帮助我们做下一行的决策。 一个很自然的想法是:dp[i][mask]dp[i][\text{mask}] 表示处理完前 ii 行,且第 ii 行的座位安排由 mask 描述时,所能达成的最少总人数。

这里的 mask 是一个长度为 mm 的二进制数(位掩码),mask 的第 j 位为 1 表示第 ii 行第 jj 列有座位被占据,为 0 则表示空着。

2. DP转移方程

当我们计算 dp[i][curr_mask]dp[i][\text{curr\_mask}] 时,我们需要从第 i1i-1 行的某个状态 dp[i1][prev_mask]dp[i-1][\text{prev\_mask}] 转移过来。转移方程的基本形式是:

dp[i][curr_mask]=popcount(curr_mask)+minprev_mask{dp[i1][prev_mask]}dp[i][\text{curr\_mask}] = \text{popcount}(\text{curr\_mask}) + \min_{\text{prev\_mask}} \{ dp[i-1][\text{prev\_mask}] \}

这里的 popcount 是计算二进制中 1 的个数,也就是当前行坐的人数。关键在于,什么样的 prev_mask 才能合法地转移到 curr_mask 呢?

3. 合法转移的条件

一个从 prev_mask (第 i1i-1 行) 到 curr_mask (第 ii 行) 的转移是合法的,必须满足以下三个条件,一个都不能少哦,喵~

条件一:当前行内部合法性curr_mask 本身必须是合法的,即不能有两个人在同一行内相邻。 用位运算表示就是:curr_mask 和把它左移一位的 curr_mask << 1 不能有重合的 1。 也就是 (curr_mask & (curr_mask << 1)) == 0

条件二:与上一行的兼容性ii 行的安排不能和第 i1i-1 行的安排冲突。即,如果第 i1i-1 行的第 jj 列有人,那么第 ii 行的第 jj 列就不能有人。 用位运算表示就是:(curr_mask & prev_mask) == 0

条件三:上一行的“极大性” 这是最关键也最容易被忽略的一点!我们的DP是在构建一个极大独立集。当我们完成了第 ii 行的安排后,第 i1i-1 行的状态就应该被“最终确定”了。这意味着,第 i1i-1 行所有空着的位置,都必须被它在第 i1i-1 行或第 ii 行的邻居给“封死”。

我们来把这个条件用位运算表达出来:

  • U = (1 << m) - 1,代表全为 1 的掩码。
  • i1i-1 行所有空位是 empty_prev = U ^ prev_mask
  • 能封锁第 i1i-1 行座位的,有三类邻居:
    1. 来自第 i1i-1 行左边的邻居:prev_mask << 1
    2. 来自第 i1i-1 行右边的邻居:prev_mask >> 1
    3. 来自第 ii 行正下方的邻居:curr_mask
  • 所以,所有能封锁第 i1i-1 行空位的座位集合是 blockers = (prev_mask << 1) | (prev_mask >> 1) | curr_mask
  • 极大性条件就是,empty_prev 中的所有位置都必须被 blockers 覆盖。也就是说,empty_prev 中为 1 的位,在 blockers 中也必须为 1
  • 这等价于 (empty_prev & blockers) == empty_prev,或者写成 (empty_prev & ~blockers) == 0

满足以上所有三个条件,从 prev_maskcurr_mask 的转移才是合法的!

4. DP的实现细节

  1. 预处理状态:我们可以先生成所有满足条件一mask,存起来。这样可以减少DP时的无效计算。
  2. 预处理转移:我们可以用一个邻接表 transitions[curr_mask_idx] 存下所有可以合法转移到 curr_maskprev_mask 的索引。
  3. DP初始化 (Base Case):对于第 1 行,它没有上一行。所以它的极大性必须由它自己和下一行(也就是第2行)来保证。但我们的DP是向前走的,所以我们反过来想:对于第1行,它的极大性由它自身来满足,即所有空位都被本行的邻居封锁。 dp[1][mask]dp[1][\text{mask}] 只有在 mask 满足 (U ^ mask) & ~((mask << 1) | (mask >> 1)) == 0 时才被初始化为 popcount(mask),否则为无穷大。
  4. 最终答案:当DP到第 n 行时,我们得到了所有 dp[n][mask]。因为第 n 行是最后一行,它没有下一行来封锁它的空位,所以它的极大性必须由它自己和第 n-1 行来保证。这个条件已经在 n-1n 的转移中保证了。但是,第 n 行本身也必须是“终结”的,即它自己的空位必须被它自己的邻居封锁。 所以,最终答案是 min(dp[n][mask])\min(dp[n][\text{mask}]),其中 mask 必须满足自身是终结状态,即 (U ^ mask) & ~((mask << 1) | (mask >> 1)) == 0
  5. 路径恢复:为了打印方案,我们需要一个 path[i][mask_idx] 数组来记录 dp[i][mask] 是从哪个 prev_mask 转移过来的。DP结束后,从得到最优解的最终 mask 开始,一路回溯到第一行,就能得到完整的座位安排方案啦,喵~

好啦,思路就是这样,是不是感觉清晰多啦?接下来就让我们一起用代码把它实现出来吧!

代码实现

cpp
#include <iostream>
#include <vector>
#include <numeric>
#include <algorithm>

using namespace std;

// 计算一个整数的二进制表示中1的个数
int count_set_bits(int n) {
    int count = 0;
    while (n > 0) {
        n &= (n - 1);
        count++;
    }
    return count;
}

// 存储不同 m 值下的预计算结果,避免重复计算
struct PrecomputedSolution {
    int m = 0;
    vector<int> valid_masks; // 存储所有行内合法的mask
    vector<vector<int>> transitions; // 存储合法的状态转移关系
    vector<vector<int>> dp; // dp[i][mask_idx]
    vector<vector<int>> path; // 用于回溯路径

    void solve(int n_max, int m_val) {
        if (m_val == 0) return;
        m = m_val;

        // 1. 预处理:生成所有行内合法的状态 (mask)
        for (int mask = 0; mask < (1 << m); ++mask) {
            if ((mask & (mask << 1)) == 0) {
                valid_masks.push_back(mask);
            }
        }

        int num_valid_masks = valid_masks.size();
        transitions.resize(num_valid_masks);
        int full_mask = (1 << m) - 1;

        // 2. 预处理:计算所有合法的状态转移
        for (int i = 0; i < num_valid_masks; ++i) {
            int curr_mask = valid_masks[i];
            for (int j = 0; j < num_valid_masks; ++j) {
                int prev_mask = valid_masks[j];

                // 条件2: 与上一行不冲突
                if ((curr_mask & prev_mask) != 0) continue;

                // 条件3: 保证上一行的极大性
                int empty_prev = full_mask ^ prev_mask;
                int blockers = (prev_mask << 1) | (prev_mask >> 1) | curr_mask;
                if ((empty_prev & ~blockers & full_mask) == 0) {
                    transitions[i].push_back(j);
                }
            }
        }
        
        // 3. DP过程
        dp.assign(n_max + 1, vector<int>(num_valid_masks, 1e9));
        path.assign(n_max + 1, vector<int>(num_valid_masks, -1));

        // 4. 初始化DP base case (i=1)
        for (int i = 0; i < num_valid_masks; ++i) {
            int mask = valid_masks[i];
            // 第1行自己就是极大的(没有上一行),所以它的空位必须被自己封锁
            int empty_mask = full_mask ^ mask;
            int blockers = (mask << 1) | (mask >> 1);
            if ((empty_mask & ~blockers & full_mask) == 0) {
                 dp[1][i] = count_set_bits(mask);
            }
        }

        // 5. DP递推
        for (int r = 2; r <= n_max; ++r) {
            for (int i = 0; i < num_valid_masks; ++i) {
                int curr_mask = valid_masks[i];
                for (int j_idx : transitions[i]) {
                    if (dp[r - 1][j_idx] != 1e9) {
                        int new_cost = dp[r - 1][j_idx] + count_set_bits(curr_mask);
                        if (new_cost < dp[r][i]) {
                            dp[r][i] = new_cost;
                            path[r][i] = j_idx;
                        }
                    }
                }
            }
        }
    }
};

PrecomputedSolution solutions[16];

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int T;
    cin >> T;
    vector<pair<int, int>> queries(T);
    int max_n = 0;
    vector<bool> m_needed(16, false);

    for (int i = 0; i < T; ++i) {
        cin >> queries[i].first >> queries[i].second;
        max_n = max(max_n, queries[i].first);
        if (queries[i].second > 0) {
            m_needed[queries[i].second] = true;
        }
    }

    for (int m = 1; m <= 15; ++m) {
        if (m_needed[m]) {
            solutions[m].solve(max_n, m);
        }
    }

    for (int t = 0; t < T; ++t) {
        int n = queries[t].first;
        int m = queries[t].second;

        cout << "Case #" << t + 1 << ": ";
        
        if (m == 0) {
            cout << 0 << "\n\n";
            continue;
        }

        auto& sol = solutions[m];
        int min_tickets = 1e9;
        int final_mask_idx = -1;
        int full_mask = (1 << m) - 1;

        // 6. 寻找最优解
        for (int i = 0; i < sol.valid_masks.size(); ++i) {
            if (dp[n][i] == 1e9) continue;
            
            // 最终行也必须是极大的
            int mask = sol.valid_masks[i];
            int empty_mask = full_mask ^ mask;
            int blockers = (mask << 1) | (mask >> 1);
            if ((empty_mask & ~blockers & full_mask) == 0) {
                if (sol.dp[n][i] < min_tickets) {
                    min_tickets = sol.dp[n][i];
                    final_mask_idx = i;
                }
            }
        }

        cout << min_tickets << "\n";
        
        // 7. 回溯路径并打印
        vector<int> result_masks(n + 1);
        int current_mask_idx = final_mask_idx;
        for (int r = n; r >= 1; --r) {
            result_masks[r] = sol.valid_masks[current_mask_idx];
            current_mask_idx = sol.path[r][current_mask_idx];
        }

        for (int r = 1; r <= n; ++r) {
            for (int c = 0; c < m; ++c) {
                if ((result_masks[r] >> c) & 1) {
                    cout << '*';
                } else {
                    cout << '.';
                }
            }
            cout << "\n";
        }
    }

    return 0;
}

复杂度分析

  • 时间复杂度: O(T+mused(Sm2nmax))O(T + \sum_{m_{used}} (S_m^2 \cdot n_{max})),其中 SmS_m 是列数为 mm 时行内合法的状态数。SmS_m 的数量级约等于斐波那契数 Fm+2F_{m+2}。对于 m=15m=15S15S_{15} 大约为1597。nmaxn_{max} 是所有查询中最大的行数。TT 是测试用例数。由于我们将所有相同 mm 的查询捆绑在一起进行一次DP,所以整体效率很高。在最坏情况下,转移检查的数量远小于 Sm2S_m^2,所以实际运行速度会比理论上界快很多,喵~

  • 空间复杂度: O(mused(Smnmax+Sm2))O(\sum_{m_{used}} (S_m \cdot n_{max} + S_m^2))。主要开销是DP表 dp[n][S] 和路径表 path[n][S],以及存储转移关系的 transitions 邻接表。对于 m=15,n=1000m=15, n=1000,这个空间是相当大的,但仍在题目允许的范围内。

知识点总结

这真是一道非常经典的 Profile DP 题目呢,喵~ 通过解决它,我们可以学到和巩固以下知识点:

  1. 问题建模: 将实际问题(座位安排)抽象成图论模型(最小极大独立集),是解决组合优化问题的第一步。
  2. 状态压缩DP: 当问题的一个维度很小时,可以将其状态压缩成一个整数(通常是位掩码),从而进行动态规划。
  3. Profile DP: 这是状态压缩DP在网格图问题上的一种特定应用。状态通常描述了相邻两行之间的“轮廓”或“剖面”信息。
  4. 位运算技巧: 整个解法高度依赖位运算来高效地表示和操作集合(座位安排),例如检查相邻、计算封锁位置等,熟练掌握位运算是必需的!
  5. DP的完备性: 在设计DP状态和转移时,要确保包含了所有必要的信息。这道题的难点就在于要正确处理“极大性”这一约束,不能遗漏。
  6. 路径回溯: 对于需要输出方案的DP问题,用一个额外的数组记录转移路径是一种常规且有效的技巧。

希望这篇题解能帮助你更好地理解这道题,如果还有疑问,随时可以再来问我哦,喵~