一个格子的最终颜色由覆盖它的最后一个 t=1 操作决定,没有 t=1 时由第一个操作决定;按行和列分别用并查集求出每个操作负责的格子数,再前缀累加即可。
OJ: luogu
题目 ID: P9715
难度:普及+/提高-
标签:思维离线并查集计数网格
创建: 2026-10-02 15:23
更新: 2026-10-07 12:15
形式化题目
有一个
- 若
,则把第 行的所有格子涂成颜色 ; - 若
,则把第 列的所有格子涂成颜色 ; - 若
,则涂色时跳过已经被染色的格子; - 若
,则涂色时用新颜色覆盖已经染色的格子。
求全部操作结束后,每种颜色各覆盖了多少个格子。
暴力解法
思路
最直接的做法就是把网格真的开出来:用 grid[i][j] 记录第
时枚举第 行的每一列, 时枚举第 列的每一行; - 对每个格子判断:
就直接覆盖成 ; 时只有 grid[i][j] == 0才涂成。
全部操作做完以后再扫一遍网格,用桶统计每种颜色的格子数。因为每个操作都是老老实实逐格处理,题意里
代码
/**
* 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 15:23
* update_at: 2026-10-03 12:16
*/
// brute.cpp:小数据暴力解,用来帮助理解题意并辅助对拍。
// 直接开一张 n*m 的网格,按题意逐格模拟每一次涂色,最后统计每种颜色的格子数。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 105; // 暴力只服务小数据:网格每维不超过 100
const int MAXC = 2005; // 颜色数上限
int n, m, k, q;
int grid[MAXN][MAXN]; // grid[i][j] = 0 表示还没被涂色,否则存颜色编号
ll cnt[MAXC]; // cnt[c]:颜色 c 的格子数
int main() {
scanf("%d %d %d %d", &n, &m, &k, &q);
for (int qi = 1; qi <= q; qi++) {
int op, l, r, c, t;
scanf("%d %d %d %d %d", &op, &l, &r, &c, &t);
if (op == 1) {
// 涂第 l..r 行的所有格子
for (int i = l; i <= r; i++) {
for (int j = 1; j <= m; j++) {
// t=1 直接覆盖;t=0 只涂还没被涂过的格子
if (t == 1 || grid[i][j] == 0) grid[i][j] = c;
}
}
} else {
// 涂第 l..r 列的所有格子
for (int i = 1; i <= n; i++) {
for (int j = l; j <= r; j++) {
if (t == 1 || grid[i][j] == 0) grid[i][j] = c;
}
}
}
}
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
if (grid[i][j] != 0) cnt[grid[i][j]]++;
}
}
for (int c = 1; c <= k; c++) {
printf("%lld", cnt[c]);
putchar(c == k ? '\n' : ' ');
}
return 0;
}复杂度
处理一个操作最多要访问
瓶颈
这个做法有两处硬伤:
- 空间上,
时网格有 个格子,根本开不出来; - 时间上,哪怕
,一次涂一整行 / 一整列也要访问 个格子。
瓶颈来自"把网格逐格存下来"这个建模方式。观察题目可以发现:同一行里的所有格子,经历的涂行操作完全相同;同一列里的所有格子,经历的涂列操作也完全相同。 一个格子的命运只由它所在的行和它所在的列共同决定,因此完全不必逐格处理,只要分别算出每一行、每一列上的关键操作,再组合计数即可。
正解
思路
第一步:一个格子的最终颜色
设所有覆盖格子
- 若存在这样的
:第 个操作把 涂成它的颜色。在它之后的任何覆盖 的操作都只能是 (否则会与" 是最后一个 "矛盾),而 遇到已染色的格子直接跳过,不会改动颜色。所以 的最终颜色就是第 个操作的颜色:格子颜色 = 覆盖它的最后一个 操作的颜色。 - 若不存在这样的
:所有覆盖 的操作都是 。那么第一个覆盖 的操作给它上了色,后面每个操作都因为"已染色"而跳过。所以格子颜色 = 第一个覆盖它的操作的颜色。
于是每个格子只关心两件事:它所在的行 / 列有没有被
第二步:把行和列分成两类
对每一行 lastRow1[x] 为覆盖第 lastCol1[y]。
把所有格子按下面两条规则分类:
- 第一类:
lastRow1[x] > 0或lastCol1[y] > 0。这类格子的颜色由覆盖它的最后一个操作决定,其编号就是 lastRow1[x]与lastCol1[y]中较大的那个。 - 第二类:
lastRow1[x] == 0且lastCol1[y] == 0。这类格子从头到尾只被操作覆盖,颜色由第一个覆盖它的操作决定。
记
第三步:每个操作负责多少格子
同一个操作在不同格子上扮演的角色可能是"最后一次
对每个 lastRow1 恰为 rowLastCnt[i],后者的列数是
这个量随着
对每个 lastRow1 为 lastCol1 为
第四步:两次扫描 + 并查集
上面的 lastRow1 / lastCol1 和"第一个覆盖"都要对每个行 / 列只赋值一次,这正好用"下一个未赋值的下标"并查集来做,并额外加一个哨兵
- 倒序扫描(求"最后一次
"): 从 到 ,遇到 的操作就把它区间内所有还没被赋值的行(或列)赋成 ,然后从并查集里删掉。倒着扫,第一次赋值就是最后一个覆盖。 - 正序扫描(求"第一次
"): 从 到 ,遇到 的操作就把它区间内所有还没被赋值的行(或列)删掉;其中 lastRow1 == 0(对列是lastCol1 == 0)的那些才计入这个操作。正着扫,第一次赋值就是第一个覆盖。
两个扫描里每个行 / 列都只会被删除一次,配合路径压缩总复杂度是 lastRow1 > 0 的行——它们属于第一类,不属于第二类,删掉它们不会影响答案,却保证了每个下标只被处理一次。
第五步:正序累加答案
最后再正序扫一遍所有操作,维护四个前缀量:
| 量 | 含义 |
|---|---|
lastRow1 < i 的行数,初始为 |
|
lastCol1 < i 的列数,初始为 |
|
| 第一次涂色操作编号 |
|
| 第一次涂色操作编号 |
对第
- 涂行、
: ans[c] += rowLastCnt[i] * A,然后加上这个块大小; - 涂行、
: ans[c] += rowFirstCnt[i] * (m_0-B),然后加上这个块大小; - 涂列、
: ans[c] += colLastCnt[i] * C,然后加上这个块大小; - 涂列、
: ans[c] += colFirstCnt[i] * (n_0-D),然后加上这个块大小。
每种颜色最多被计入 long long。
样例 1 的完整推演
样例 1 的四个操作是:
| 编号 | 操作 | 颜色 | |
|---|---|---|---|
| 1 | 涂行 |
1 | 0 |
| 2 | 涂列 |
1 | 1 |
| 3 | 涂列 |
2 | 0 |
| 4 | 涂行 |
2 | 1 |
倒序扫描:只有操作 4 和 2 是 lastRow1[1] = 4(行 1),lastCol1[4] = lastCol1[5] = 2(列 4、5)。于是
正序扫描 lastRow1 都是 lastCol1 非
累加过程:
| 操作 | 块大小 | 使用的量 | 贡献 | 累加后 | |
|---|---|---|---|---|---|
| 1 | 行 |
3 | |||
| 2 | 列 |
2 | |||
| 3 | 列 |
2 | |||
| 4 | 行 |
1 |
颜色 1 得到 17 7 一致。第 2 行里 lastRow1 为
代码
sweep_last() 倒序确定每个 sweep_first() 正序确定每个 calc_answer() 正序累加四种贡献。并查集用迭代路径压缩写成 findR / findC,避免递归。
/**
* 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 15:23
* update_at: 2026-10-03 12:16
*/
// P9715 「QFOI R1」头:正解。
// 关键结论:一个格子最后的颜色,等于覆盖它的最后一个 t=1 操作的颜色;
// 如果没有任何 t=1 操作覆盖它,则等于覆盖它的第一个操作(必为 t=0)的颜色。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 2000005; // 行 / 列下标上限
const int MAXQ = 2000005; // 操作数上限
const int MAXK = 500005; // 颜色数上限
int n, m, k, q; // 行数、列数、颜色数、操作数
int L[MAXQ], R[MAXQ]; // 第 i 个操作的区间 [L[i], R[i]]
int colOf[MAXQ]; // 第 i 个操作使用的颜色
// kind[i]:0 = 涂行且 t=0,1 = 涂行且 t=1,2 = 涂列且 t=0,3 = 涂列且 t=1
unsigned char kind[MAXQ];
int lastRow1[MAXN]; // lastRow1[x]:最后一个整行覆盖第 x 行的 t=1 操作编号,0 表示没有
int lastCol1[MAXN]; // lastCol1[x]:最后一个整列覆盖第 x 列的 t=1 操作编号,0 表示没有
// val[i]:第 i 个操作在这一遍扫描中“新确定”的行数(涂行)或列数(涂列)
int val[MAXQ];
// 并查集:nxtR[x] 指向 >= x 的第一个还没被“确定”的行,nxtC 同理作用在列上
int nxtR[MAXN], nxtC[MAXN];
ll ans[MAXK]; // ans[c]:颜色 c 最终覆盖的格子数
// ---------- 快速读入 ----------
char buf[1 << 16];
int bufPos = 0, bufLen = 0;
inline char getch() {
if (bufPos == bufLen) {
bufLen = (int)fread(buf, 1, 1 << 16, stdin);
bufPos = 0;
if (bufLen <= 0) return 0;
}
return buf[bufPos++];
}
inline int readInt() {
char ch = getch();
while (ch < '0' || ch > '9') {
if (ch == 0) return -1;
ch = getch();
}
int x = 0;
while (ch >= '0' && ch <= '9') {
x = x * 10 + (ch - '0');
ch = getch();
}
return x;
}
// 返回 >= x 的第一个还没被确定的行(路径压缩的迭代写法)
int findR(int x) {
while (nxtR[x] != x) {
nxtR[x] = nxtR[nxtR[x]];
x = nxtR[x];
}
return x;
}
// 返回 >= x 的第一个还没被确定的列
int findC(int x) {
while (nxtC[x] != x) {
nxtC[x] = nxtC[nxtC[x]];
x = nxtC[x];
}
return x;
}
void read_data() {
n = readInt();
m = readInt();
k = readInt();
q = readInt();
for (int i = 1; i <= q; i++) {
int op = readInt();
L[i] = readInt();
R[i] = readInt();
colOf[i] = readInt();
int t = readInt();
kind[i] = (unsigned char)((op - 1) * 2 + t);
}
}
// 倒序扫描:确定每个 t=1 操作是哪些行 / 列的“最后一次覆盖”
void sweep_last() {
for (int i = 1; i <= n + 1; i++) nxtR[i] = i;
for (int i = 1; i <= m + 1; i++) nxtC[i] = i;
for (int i = q; i >= 1; i--) {
if ((kind[i] & 1) == 0) continue; // 只处理 t=1
int l = L[i], r = R[i];
if (kind[i] <= 1) { // 涂行
int cnt = 0;
int x = findR(l);
while (x <= r) {
lastRow1[x] = i;
cnt++;
nxtR[x] = x + 1; // 这一行已经确定,从并查集里删掉
x = findR(x + 1);
}
val[i] = cnt;
} else { // 涂列
int cnt = 0;
int x = findC(l);
while (x <= r) {
lastCol1[x] = i;
cnt++;
nxtC[x] = x + 1;
x = findC(x + 1);
}
val[i] = cnt;
}
}
}
// 正序扫描:确定每个 t=0 操作是哪些行 / 列的“第一次覆盖”
// 只有从未被 t=1 覆盖过的行 / 列,才可能作为“第一次涂色”的来源
void sweep_first() {
for (int i = 1; i <= n + 1; i++) nxtR[i] = i;
for (int i = 1; i <= m + 1; i++) nxtC[i] = i;
for (int i = 1; i <= q; i++) {
if ((kind[i] & 1) != 0) continue; // 只处理 t=0
int l = L[i], r = R[i];
if (kind[i] <= 1) { // 涂行
int cnt = 0;
int x = findR(l);
while (x <= r) {
if (lastRow1[x] == 0) cnt++;
nxtR[x] = x + 1;
x = findR(x + 1);
}
val[i] = cnt;
} else { // 涂列
int cnt = 0;
int x = findC(l);
while (x <= r) {
if (lastCol1[x] == 0) cnt++;
nxtC[x] = x + 1;
x = findC(x + 1);
}
val[i] = cnt;
}
}
}
// 正序累加答案
// C = 当前 lastRow1 < i 的行数;A = 当前 lastCol1 < i 的列数
// D = 当前“第一次覆盖” < i 的纯 t=0 行数;B = 当前“第一次覆盖” < i 的纯 t=0 列数
void calc_answer() {
ll C = 0, A = 0, D = 0, B = 0;
for (int i = 1; i <= n; i++) if (lastRow1[i] == 0) C++; // C 的初值就是 n0
for (int i = 1; i <= m; i++) if (lastCol1[i] == 0) A++; // A 的初值就是 m0
ll n0 = C, m0 = A;
for (int i = 1; i <= q; i++) {
int c = colOf[i];
if (kind[i] <= 1) { // 涂行
if (kind[i] == 1) { // t=1:这些格子由本操作最后覆盖
ans[c] += (ll)val[i] * A;
C += val[i];
} else { // t=0:这些格子是“第一次被涂”
ans[c] += (ll)val[i] * (m0 - B);
D += val[i];
}
} else { // 涂列
if (kind[i] == 3) { // t=1
ans[c] += (ll)val[i] * C;
A += val[i];
} else { // t=0
ans[c] += (ll)val[i] * (n0 - D);
B += val[i];
}
}
}
}
int main() {
read_data();
sweep_last();
sweep_first();
calc_answer();
for (int i = 1; i <= k; i++) {
printf("%lld", ans[i]);
putchar(i == k ? '\n' : ' ');
}
return 0;
}复杂度
时间
总结
这道题的核心是把"逐格涂色"换成"按行、按列记账",整条链路是:
- 定位决定者:格子的最终颜色要么来自覆盖它的最后一个
操作,要么(没有这种操作时)来自覆盖它的第一个 操作。这一步把"多次涂色的先后博弈"压缩成一个格子只看两个操作的结论。 - 按行 / 列分组:同一行的格子共享全部涂行操作,同一列的格子共享全部涂列操作。于是"格子有没有
"退化成"行或列有没有 ",二维问题降成一维。 - 并查集去重:每个行 / 列只需要被"第一次"或"最后一次"赋值一次,用"下一个未赋值下标"的并查集配上哨兵,把两次扫描压到近似线性。
- 前缀累加:扫描时维护
四个前缀量,把"某个操作负责的格子数"写成 块大小 × 前缀量,避免枚举行列配对,也避免开二维数组。
最值得记住的是第 1 步:
