Cinema - 题解
标签与难度
标签: 动态规划, 状态压缩DP, Profile DP, 位运算, 图论, 最大独立集 难度: 2300
题目大意喵~
各位主人晚上好,喵~!今天我们来解决一个电影院座位安排的问题,听起来很有趣对吧?
问题是这样的:有一个 行 列的电影院。为了保持社交距离,任何两个人都不能坐在相邻的座位上。两个座位 和 如果满足 ,它们就是相邻的(也就是上下左右)。
客人们会一个接一个地来买票,并且他们可以自己选择任何一个“有效”的座位。一个座位是“有效”的,当且仅当它没有被占据,并且它的所有邻居座位也都没有被占据。当整个电影院里再也找不到任何一个“有效”座位时,售票就会停止。
作为电影院老板,我们想知道一个最坏的情况:假设客人们总是做出“最糟糕”的选择,使得最终卖出的票数最少。我们需要求出这个最少的票数是多少,并且还要打印出一种能达到这个最少票数的座位安排方案,喵~
简单来说,就是要找一种座位安排方式,满足:
- 没有人坐在相邻座位。
- 不能再往里加人了(所有空位都至少有一个邻居被占了)。
- 在这种安排下,总人数最少。
解题思路分析
这道题的核心,其实是在一个网格图上寻找一个最小的极大独立集 (Minimum Maximal Independent Set),喵~
- 独立集:在图论里,一个点的集合,如果集合中任意两点之间都没有边相连,那它就是一个独立集。对应到这道题,就是任何两个坐了人的座位都不相邻。
- 极大独立集:一个独立集,如果再也无法加入任何一个点(不破坏独立集性质),那它就是极大的。对应到这-道题,就是售票停止时的状态——所有空位都至少有一个被占用的邻居,不能再加人了。
- 最小的极大独立集:在所有可能的极大独立集中,找到那个点数(人数)最少的。
寻找最小极大独立集在一般图上是NP-hard问题,非常困难的说!但是,这道题的 (列数)非常小(),这给了我们一个重要的提示:可以使用**状态压缩动态规划(Profile DP)**来解决,喵!
我们可以一行一行地来安排座位,并计算到当前行为止的最小总人数。
1. DP状态定义
我们需要定义一个DP状态,它能包含足够的信息来帮助我们做下一行的决策。 一个很自然的想法是: 表示处理完前 行,且第 行的座位安排由 mask 描述时,所能达成的最少总人数。
这里的 mask 是一个长度为 的二进制数(位掩码),mask 的第 j 位为 1 表示第 行第 列有座位被占据,为 0 则表示空着。
2. DP转移方程
当我们计算 时,我们需要从第 行的某个状态 转移过来。转移方程的基本形式是:
这里的 popcount 是计算二进制中 1 的个数,也就是当前行坐的人数。关键在于,什么样的 prev_mask 才能合法地转移到 curr_mask 呢?
3. 合法转移的条件
一个从 prev_mask (第 行) 到 curr_mask (第 行) 的转移是合法的,必须满足以下三个条件,一个都不能少哦,喵~
条件一:当前行内部合法性curr_mask 本身必须是合法的,即不能有两个人在同一行内相邻。 用位运算表示就是:curr_mask 和把它左移一位的 curr_mask << 1 不能有重合的 1。 也就是 (curr_mask & (curr_mask << 1)) == 0。
条件二:与上一行的兼容性 第 行的安排不能和第 行的安排冲突。即,如果第 行的第 列有人,那么第 行的第 列就不能有人。 用位运算表示就是:(curr_mask & prev_mask) == 0。
条件三:上一行的“极大性” 这是最关键也最容易被忽略的一点!我们的DP是在构建一个极大独立集。当我们完成了第 行的安排后,第 行的状态就应该被“最终确定”了。这意味着,第 行所有空着的位置,都必须被它在第 行或第 行的邻居给“封死”。
我们来把这个条件用位运算表达出来:
- 令
U = (1 << m) - 1,代表全为1的掩码。 - 第 行所有空位是
empty_prev = U ^ prev_mask。 - 能封锁第 行座位的,有三类邻居:
- 来自第 行左边的邻居:
prev_mask << 1 - 来自第 行右边的邻居:
prev_mask >> 1 - 来自第 行正下方的邻居:
curr_mask
- 来自第 行左边的邻居:
- 所以,所有能封锁第 行空位的座位集合是
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_mask到 curr_mask 的转移才是合法的!
4. DP的实现细节
- 预处理状态:我们可以先生成所有满足条件一的
mask,存起来。这样可以减少DP时的无效计算。 - 预处理转移:我们可以用一个邻接表
transitions[curr_mask_idx]存下所有可以合法转移到curr_mask的prev_mask的索引。 - DP初始化 (Base Case):对于第
1行,它没有上一行。所以它的极大性必须由它自己和下一行(也就是第2行)来保证。但我们的DP是向前走的,所以我们反过来想:对于第1行,它的极大性由它自身来满足,即所有空位都被本行的邻居封锁。 只有在mask满足(U ^ mask) & ~((mask << 1) | (mask >> 1)) == 0时才被初始化为popcount(mask),否则为无穷大。 - 最终答案:当DP到第
n行时,我们得到了所有dp[n][mask]。因为第n行是最后一行,它没有下一行来封锁它的空位,所以它的极大性必须由它自己和第n-1行来保证。这个条件已经在n-1到n的转移中保证了。但是,第n行本身也必须是“终结”的,即它自己的空位必须被它自己的邻居封锁。 所以,最终答案是 ,其中mask必须满足自身是终结状态,即(U ^ mask) & ~((mask << 1) | (mask >> 1)) == 0。 - 路径恢复:为了打印方案,我们需要一个
path[i][mask_idx]数组来记录dp[i][mask]是从哪个prev_mask转移过来的。DP结束后,从得到最优解的最终mask开始,一路回溯到第一行,就能得到完整的座位安排方案啦,喵~
好啦,思路就是这样,是不是感觉清晰多啦?接下来就让我们一起用代码把它实现出来吧!
代码实现
#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;
}复杂度分析
时间复杂度: ,其中 是列数为 时行内合法的状态数。 的数量级约等于斐波那契数 。对于 , 大约为1597。 是所有查询中最大的行数。 是测试用例数。由于我们将所有相同 的查询捆绑在一起进行一次DP,所以整体效率很高。在最坏情况下,转移检查的数量远小于 ,所以实际运行速度会比理论上界快很多,喵~
空间复杂度: 。主要开销是DP表
dp[n][S]和路径表path[n][S],以及存储转移关系的transitions邻接表。对于 ,这个空间是相当大的,但仍在题目允许的范围内。
知识点总结
这真是一道非常经典的 Profile DP 题目呢,喵~ 通过解决它,我们可以学到和巩固以下知识点:
- 问题建模: 将实际问题(座位安排)抽象成图论模型(最小极大独立集),是解决组合优化问题的第一步。
- 状态压缩DP: 当问题的一个维度很小时,可以将其状态压缩成一个整数(通常是位掩码),从而进行动态规划。
- Profile DP: 这是状态压缩DP在网格图问题上的一种特定应用。状态通常描述了相邻两行之间的“轮廓”或“剖面”信息。
- 位运算技巧: 整个解法高度依赖位运算来高效地表示和操作集合(座位安排),例如检查相邻、计算封锁位置等,熟练掌握位运算是必需的!
- DP的完备性: 在设计DP状态和转移时,要确保包含了所有必要的信息。这道题的难点就在于要正确处理“极大性”这一约束,不能遗漏。
- 路径回溯: 对于需要输出方案的DP问题,用一个额外的数组记录转移路径是一种常规且有效的技巧。
希望这篇题解能帮助你更好地理解这道题,如果还有疑问,随时可以再来问我哦,喵~