目录
题目解析
1. 核心难点与转化
题目核心:
在一个
思维陷阱:
初学者容易想到使用搜索(DFS)或者状态压缩 DP。但是
正确思路:二分图最大匹配
我们需要转换视角。不要把“坐标点
关键点:硬石头 # 的切割作用
- 如果没有
#,一行只能放一个炸弹。 - 有了
#,一行可能被切成好几段,每一段都可以(且最多只能)放一个炸弹。
例如:* * # * *
- 左边的
* *是一个独立的“行块”。 - 右边的
* *是另一个独立的“行块”。 - 这两个块互不干扰,可以各放一个炸弹。
2. 建模步骤
2.1 提取“行块”与“列块”
我们可以对地图进行两次扫描:
- 行扫描:找出所有横向的连续块。遇到
#则当前块结束,新起一块。遇到*或x则属于同一块。 - 列扫描:同理,找出所有纵向的连续块。
编号映射:
我们需要给每一个“行块”和每一个“列块”分配一个唯一的 ID。
假设我们扫描完后,得到了
2.2 构建二分图
- 左部点(U集合):所有的“行块”,编号
。 - 右部点(V集合):所有的“列块”,编号
。 - 连边规则:
- 遍历地图上的每一个格子
。 - 如果该格子是 空地
*:- 它属于某个行块
。 - 它同时也属于某个列块
。 - 这意味如果我们在这里放炸弹,就同时占用了
和 。 - 连边:从
连向 ,容量为 1。
- 它属于某个行块
- 如果格子是
x或#,则不连边(因为不能放炸弹)。
- 遍历地图上的每一个格子
2.3 网络流 / 匈牙利算法
- 源点
连向所有 行块,容量 1。 - 所有 列块 连向 汇点
,容量 1。 - 行块 连向 列块(由
*产生的边),容量 1。 - 求最大流。最大流量即为最多能放置的炸弹数。
3. 图解示例
地图:
# * *
* # *
1. 处理行块 (Row Blocks):
- 第 1 行:
#(跳过),* *(记为行块 R1) - 第 2 行:
*(记为行块 R2),#(分隔),*(记为行块 R3) - 总行块:R1, R2, R3
2. 处理列块 (Col Blocks):
- 第 1 列:
#(跳过),*(记为列块 C1) - 第 2 列:
*(记为列块 C2),#(分隔) -> 下面没空地了 - 第 3 列:
*(记为列块 C3),*(记为列块 C3延续) -> 也就是**是一整个列块 C3 - 总列块:C1, C2, C3
3. 连边 (只有 * 处可以连边):
- 坐标 (0, 1) 是
*: 属于 R1, 属于 C2连边 - 坐标 (0, 2) 是
*: 属于 R1, 属于 C3连边 - 坐标 (1, 0) 是
*: 属于 R2, 属于 C1连边 - 坐标 (1, 2) 是
*: 属于 R3, 属于 C3连边 - 求解:
这是一个二分图匹配问题。求最大匹配数。
graph LR
S -->|1| R1
S -->|1| R2
S -->|1| R3
R1 -->|1| C2
R1 -->|1| C3
R2 -->|1| C1
R3 -->|1| C3
C1 -->|1| T
C2 -->|1| T
C3 -->|1| T
subgraph Row Blocks
R1
R2
R3
end
subgraph Column Blocks
C1
C2
C3
end
S
T
digraph network_flow {
rankdir=LR;
// 源点和汇点
S [label="Source", shape=box, style=filled, fillcolor=lightblue];
T [label="Sink", shape=box, style=filled, fillcolor=lightblue];
// 行块节点(左部)
subgraph cluster_left {
label="Row Blocks";
style=dashed;
R1 [label="R1", shape=circle, style=filled, fillcolor=pink];
R2 [label="R2", shape=circle, style=filled, fillcolor=pink];
R3 [label="R3", shape=circle, style=filled, fillcolor=pink];
}
// 列块节点(右部)
subgraph cluster_right {
label="Column Blocks";
style=dashed;
C1 [label="C1", shape=circle, style=filled, fillcolor=lightgreen];
C2 [label="C2", shape=circle, style=filled, fillcolor=lightgreen];
C3 [label="C3", shape=circle, style=filled, fillcolor=lightgreen];
}
// 源点到行块(容量1)
S -> R1 [label="cap=1"];
S -> R2 [label="cap=1"];
S -> R3 [label="cap=1"];
// 行块到列块(基于地图中的*位置)
R1 -> C2 [label="(0,1)", color=blue];
R1 -> C3 [label="(0,2)", color=blue];
R2 -> C1 [label="(1,0)", color=blue];
R3 -> C3 [label="(1,2)", color=blue];
// 列块到汇点(容量1)
C1 -> T [label="cap=1"];
C2 -> T [label="cap=1"];
C3 -> T [label="cap=1"];
// 添加图例
subgraph cluster_legend {
label="Legend";
style=dashed;
legend1 [label="Edge: bomb placement option\n(connects row and column blocks)", shape=plaintext];
legend2 [label="Capacity 1 edges ensure\nonly one bomb per block", shape=plaintext];
}
}
4. 为什么不是 MCMF (最小费用最大流)?
你在上一个问题中使用的是 MCMF,因为那个问题有“权值”(路程长度、配合优势)。
而本题只问“最多能放多少个”,没有权值/费用的概念,所以只需要普通的 最大流 (Dinic) 或者 二分图最大匹配 (匈牙利算法) 即可。
5. 代码实现提示 (基于 Dinic 模板)
-
预处理 ID 数组:
row_id[55][55] 和 col_id[55][55]。
-
生成行 ID:
双层循环遍历
。如果遇到 #,当前的 ID 计数器加 1。如果遇到 * 或 x,row_id[i][j] = current_id。 -
生成列 ID:
同理,先遍历列
,再遍历行 。 -
建图:
行块 (cap 1) 列块
(cap 1) 遍历所有
,如果是 *:addEdge(row_id[i][j], col_id[i][j], 1)。 -
Run Dinic。
疑问
连边规则:
- 遍历地图上的每一个格子 (i, j)。
- 如果该格子是 空地 *:
- 它属于某个行块 ID_{row}。
- 它同时也属于某个列块 ID_{col}。
- 这意味如果我们在这里放炸弹,就同时占用了 ID_{row} 和 ID_{col}。
- 连边:从 ID_{row} 连向 ID_{col},容量为 1。
- 如果格子是 x 或 #,则不连边(因为不能放炸弹)。
我想知道,这个 连 边 代表什么含义 ? 是冲突吗?
这是一个非常深刻且直指核心的问题!
简单的回答是:这条连边代表“放置炸弹的一个候选位置(可行方案)”,而不是冲突。
但在二分图匹配的模型中,它的深层含义是将“位置”转化为“资源占用”的关系。为了让你彻底理解,我们把它拆解开来讲。
1. 边的物理含义:一次“合法的放置机会”
在这个二分图中:
- 左边的点(行块):代表“横向的空位资源”。(比如:第一行左边的那个空隙)
- 右边的点(列块):代表“纵向的空位资源”。(比如:第一列上边的那个空隙)
当你发现坐标 * 时,你连接 ID_row ID_col,这条边的含义是:
“坐标
是一个合法的坑位。如果你选择在这里放炸弹,你就必须同时消耗掉它所属的【行块资源】和【列块资源】。”
2. 为什么不是“冲突”?
在图论中,通常有两种建模思路,不要混淆:
- 冲突图(Conflict Graph):
- 节点是“位置”,边代表“互相排斥”。
- 例如:位置 A 和位置 B 不能同时放,就连一条边。
- 目标是求最大独立集(很难,通常是 NP-hard)。
- 匹配图(Matching Graph / Bipartite Graph) —— 本题是这种!
- 节点是“资源限制”(行块、列块)。
- 边是“可以做的事情”(放炸弹)。
- 连边代表“利用”:利用 Row 资源和 Col 资源可以生成一个炸弹。
3. 如何通过“连边”解决“冲突”?
你可能会问:“如果不代表冲突,那怎么保证炸弹不互相炸到呢?”
答案在于流量限制(容量),而不是边的存在。
请看这个逻辑链条:
- 源点限制:源点
行块 ,容量为 1。 - 这意味着:行块 A 最多只能被用一次(最多只能放一个炸弹)。
- 汇点限制:列块
汇点 ,容量为 1。 - 这意味着:列块 B 最多只能被用一次。
- 中间连边:行块
列块 (代表位置 )。
场景模拟:
假设同一行块
- 位置
对应边:行块 列块 。 - 位置
对应边:行块 列块 。
因为 源点
- 要么流向列块
(在 放炸弹)。 - 要么流向列块
(在 放炸弹)。 - 绝不可能同时流向两者。
结论:
- 连边代表:“我可以在这里放炸弹”。
- 容量(流)代表:“互斥关系”(同一个行块只能供养一个炸弹)。
4. 举个生动的例子
想象这是一个 相亲大会:
- 左边的点(行块) = 男嘉宾(比如 1号男嘉宾)。
- 右边的点(列块) = 女嘉宾(比如 2号女嘉宾)。
- 地图上的
*(空地) = 两人看对眼了(有缘分,可以配对)。
连边的含义:
如果 1号男 和 2号女 在地图交叉点是
*,说明他们可以牵手。
冲突的解决:
因为这是一夫一妻制(最大匹配),1号男嘉宾一旦选择了 2号女嘉宾(流量流过这条边),他就不能再选别人了,2号女嘉宾也不能再选别人了。
digraph Dating {
node [shape=box, style=filled];
// 男嘉宾(行块)
subgraph cluster_men {
label="男嘉宾";
style=filled;
color=lightblue;
Man1 [label="男嘉宾1", fillcolor=lightblue];
Man2 [label="男嘉宾2", fillcolor=lightblue];
Man3 [label="男嘉宾3", fillcolor=lightblue];
}
// 女嘉宾(列块)
subgraph cluster_women {
label="女嘉宾";
style=filled;
color=lightpink;
Woman1 [label="女嘉宾1", fillcolor=lightpink];
Woman2 [label="女嘉宾2", fillcolor=lightpink];
Woman3 [label="女嘉宾3", fillcolor=lightpink];
}
// 连边(看对眼了)
Man1 -> Woman2 [label="看对眼了"];
Man1 -> Woman3 [label="看对眼了"];
Man2 -> Woman1 [label="看对眼了"];
Man3 -> Woman3 [label="看对眼了"];
}
总结
*(空地):代表机遇。在二分图中变成一条边,连接了它占用的“行资源”和“列资源”。x或#:代表不可行。没有边,流就流不过去,自然永远不会在那里放炸弹。
这是一种将 “几何位置排斥问题” 转化为 “资源分配问题” 的经典建模手段。
思考与理解
- R1 的可行区域只能放一个Bomb(显然)
- R1 可以选择 C1 或 C2,这两个位置放
- 如果 R1 选择了 C1
- 那么 R3 R4 就不能选择 C1
- 这显然是一种选择冲突
- 问题: 不能冲突的最大匹配
代码
/**
* Author by Rainboy blog: https://rainboylv.com github : https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* date: 2026-01-12 19:34:42
* Problem: P2825 [HEOI2016/TJOI2016] 游戏
* Algorithm: Max Flow (Dinic) / Bipartite Matching
*/
#include <bits/stdc++.h>
using namespace std;
const int maxn = 1e6+5; // 点
const int maxe = 2e6+5; // 边
const long long INF = 1e18;
int n, m;
int s, t; // 源点 汇点
char grid[60][60]; // 存储地图
int row_id[60][60]; // 记录每个格子属于哪个“行块”
int col_id[60][60]; // 记录每个格子属于哪个“列块”
// --- 存图模板 (你的模板保持不变) ---
//oisnip_begin code/graph/linklist.cpp 内容开始
struct linkList {
typedef struct {int u,v,w,next;} edge;
edge e[maxe];
int h[maxn],edge_cnt=0;
linkList(){
reset();
}
void reset() {
edge_cnt=0;
memset(h,-1,sizeof(h));
}
//遍历点u 周围点
template<typename U>
void for_each(int u,U func){
for(int i = h[u] ; i !=-1;i = e[i].next)
func(e[i].u,e[i].v,e[i].w); //u v w
}
void add(int u,int v,int w=0){
e[edge_cnt] = {u,v,w,h[u]};
h[u] = edge_cnt++;
}
void add2(int u,int v,int w=0){
add(u,v,w);
add(v,u,w);
}
//下标访问
edge& operator[](int i){ return e[i]; }
} e;
//oisnip_end code/graph/linklist.cpp 内容结束
// --- Dinic算法模板 (你的模板保持不变) ---
struct Dinic {
vector<int> level, cur; // level: BFS分层, cur: 当前弧优化
int n; // 节点数
void init(int n) {
// 重置linkList
e.edge_cnt = 0;
memset(e.h, -1, sizeof(e.h));
level.resize(n+5);
cur.resize(n+5);
this->n = n;
}
// 添加边:从u到v,容量为cap
void addEdge(int u, int v, int cap) {
e.add(u, v, cap); // 正向边
e.add(v, u, 0); // 反向边
}
// BFS分层,构建层次图
bool bfs(int s, int t) {
fill(level.begin(), level.end(), -1);
queue<int> q;
level[s] = 0;
q.push(s);
while (!q.empty()) {
int u = q.front();
q.pop();
for(int i = e.h[u] ; ~i ;i = e[i].next) {
int v = e[i].v, cap = e[i].w;
if (cap > 0 && level[v] < 0) {
level[v] = level[u] + 1;
q.push(v);
}
}
}
return level[t] >= 0; // 返回是否能到达汇点
}
// DFS寻找增广路
long long dfs(int u, int t, long long preFlow) {
if (u == t || preFlow == 0) return preFlow;
long long flow = 0;
for (int& cid = cur[u]; cid != -1; cid = e[cid].next) {
auto& edge = e[cid]; // 当前弧优化
int to = edge.v;
long long cap = edge.w;
if (level[u] + 1 != level[to] || cap <= 0) continue;
long long tr = dfs(to, t, min(preFlow, cap));
e[cid].w -= tr ; // 正向边容量减少
e[cid ^ 1].w += tr; // 反向边容量增加
flow += tr;
preFlow -= tr;
if (preFlow == 0) break;
}
if (flow == 0) level[u] = -1;
return flow;
}
// 求从s到t的最大流
long long maxFlow(int s, int t) {
long long flow = 0;
while (bfs(s, t)) { // 能够分层
for (int i = 0; i <= n; i++) {
cur[i] = e.h[i];
}
flow += dfs(s, t, LLONG_MAX);
}
return flow;
}
} dinic;
// --- 针对 P2825 的初始化逻辑 ---
void init(){
cin >> n >> m;
for(int i = 0; i < n; i++) {
for(int j = 0; j < m; j++) {
cin >> grid[i][j];
}
}
// 1. 处理“行块” ID
// 遇到硬石头 #,或者换行时,ID增加(产生新的块)
int tot_row = 0;
for(int i = 0; i < n; i++) {
for(int j = 0; j < m; j++) {
if (grid[i][j] == '#') continue; // 硬石头不属于任何块
// 如果是该行的第一个,或者前一个是硬石头,说明新起了一个块
if (j == 0 || grid[i][j-1] == '#') {
tot_row++;
}
row_id[i][j] = tot_row;
}
}
// 2. 处理“列块” ID
// 逻辑同上,只是外层循环是列,内层是行
int tot_col = 0;
for(int j = 0; j < m; j++) {
for(int i = 0; i < n; i++) {
if (grid[i][j] == '#') continue;
if (i == 0 || grid[i-1][j] == '#') {
tot_col++;
}
col_id[i][j] = tot_col;
}
}
// 3. 建图
// S = 0
// 行块节点 = 1 ~ tot_row
// 列块节点 = tot_row + 1 ~ tot_row + tot_col
// T = tot_row + tot_col + 1
s = 0;
t = tot_row + tot_col + 1;
dinic.init(t + 5);
// 源点 -> 所有行块 (容量1)
for(int i = 1; i <= tot_row; i++) {
dinic.addEdge(s, i, 1);
}
// 所有列块 -> 汇点 (容量1)
for(int i = 1; i <= tot_col; i++) {
dinic.addEdge(tot_row + i, t, 1);
}
// 行块 -> 列块
// 遍历每一个格子,如果是空地 *,则可以在这里放炸弹
// 这意味着占用了它所属的行块和列块
for(int i = 0; i < n; i++) {
for(int j = 0; j < m; j++) {
if (grid[i][j] == '*') {
int u = row_id[i][j]; // 当前格子所属行块
int v = tot_row + col_id[i][j]; // 当前格子所属列块 (加上偏移量)
dinic.addEdge(u, v, 1);
}
}
}
}
int main() {
ios::sync_with_stdio(0); cin.tie(0);
init();
cout << dinic.maxFlow(s, t) << "\n";
return 0;
}