三连判定只看相邻 3 列,把每列压成 3bit 图案加 3bit 已计入标记,做 3 列滑窗的带状状压 DP。
启发记录: 第一次接触延迟推定计算的状态压缩
OJ: roj
题目 ID: 20024
难度:提高
形式化题目
给定一个
- 格子是 X,且它位于连续三个 X 排成的一线里(同行、同列或两条对角线)→ 染红;
- 格子是 O,且它位于连续三个 O 排成的一线里 → 染蓝;
- 否则染黑。
每个格子有权值
思路
一句话本质:三连判定只看相邻 3 列,所以把每列压成 3bit 图案 + 3bit "已计入"标记,做 3 列滑窗的窄带状压 DP,让每个格子的权值在它滑出窗口时恰好结算一次。
问题? 直接枚举棋盘染色有多少种方案?
每种方案
/**
* 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-08-28 23:42
* update_at: 2026-08-28 23:42
*/
// brute.cpp:小数据暴力解,使用 01 序列 / 选择序列递归枚举所有可能。
// 把 3*n 个格子按"行优先"拉平成一条 01 序列:choose[k] = 1 表示画 X,0 表示画 O。
// 递归只负责生成完整序列,到叶子节点再统一按题意判定每个格子是否位于三连里,
// 计算分数(红 + 蓝 -)取最大值。只能跑 n <= 4(2^(3n) 种棋盘)。
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const int MAXN = 5; // 暴力只处理 n <= 4
const ll NEG_INF = -(1LL << 60);
int n;
ll w[3][MAXN]; // w[行][列]:权值
int choose[3 * MAXN]; // choose[k]:第 k 个格子画什么,1 = X,0 = O
ll ans;
// 判断格子 (r,c) 是否位于"连续三个和它相同"的一线里
// 方向 4 种:横(0,1)、纵(1,0)、对角线 ↘(1,1)、对角线 ↗(1,-1)
// 对每个方向,三连中心相对 (r,c) 的偏移 m 只可能是 -1、0、1
bool in_triple(int r, int c) {
int sym = choose[r * n + c];
int dr[4] = {0, 1, 1, 1};
int dc[4] = {1, 0, 1, -1};
for (int d = 0; d < 4; d++) {
for (int m = -1; m <= 1; m++) {
bool ok = true;
for (int t = -1; t <= 1; t++) {
int rr = r + (m + t) * dr[d];
int cc = c + (m + t) * dc[d];
if (rr < 0 || rr >= 3 || cc < 0 || cc >= n) { ok = false; break; }
if (choose[rr * n + cc] != sym) { ok = false; break; }
}
if (ok) return true;
}
}
return false;
}
// 统计完整棋盘的分数:红格权值和 - 蓝格权值和,黑色格子不贡献
ll calc_answer() {
ll sum = 0;
for (int r = 0; r < 3; r++) {
for (int c = 0; c < n; c++) {
if (!in_triple(r, c)) continue; // 黑色
int sgn = choose[r * n + c] ? 1 : -1; // X 红 +,O 蓝 -
sum += w[r][c] * sgn;
}
}
return sum;
}
void dfs(int dep) {
if (dep == 3 * n) { // 一条完整 01 序列生成完毕
ll val = calc_answer();
if (ans < val) ans = val;
return;
}
// 这一层枚举第 dep 个格子的选择:0 = O,1 = X
for (int i = 0; i <= 1; i++) {
choose[dep] = i;
dfs(dep + 1);
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n;
if (n > 4) { // 超出暴力适用范围,防止越界
cerr << "brute.cpp 仅支持 n <= 4\n";
return 0;
}
for (int r = 0; r < 3; r++)
for (int c = 0; c < n; c++)
cin >> w[r][c];
ans = NEG_INF;
dfs(0);
cout << ans << '\n';
return 0;
}问题? 一个格子是红/蓝/黑,到底由哪些格子决定?
"连续三个相同"的线只有 4 类:横(同行相邻 3 列)、纵(同列相邻 3 行)、对角线 ↘ 和 ↗(各跨相邻 3 列)。所以判定格子
问题? 从"每个格子看邻居"出发,从左往右扫描时状态要记多少列?
格子
注意 solve_small 单独处理;
问题? 一个格子可能同时属于多个三连(例如全 X 时一个格子横、纵、两条对角线全是三连),贡献怎么保证只算一次?
每个三连被"发现"时如果都去加权,同一个格子会被重复计权。解决方法是给每列额外维护 3bit colored 标记:格子第一次被判定为红/蓝时打上标记并计入权值,之后再被其它三连命中时直接跳过。这就是关键卡点②。有了标记,权值结算就只发生在"该格子所在的列滑出窗口左端"这一次。
问题? 状态具体记什么?
处理到第
- 纵向:旧列整列图案为 000 或 111;
- 横向:三列同一行图案相同;
- 对角线 ↘:格
相同; - 对角线 ↗:格
相同。
旧列格子命中 → 结算权值(红
问题? 一个
- 3 条横向、2 条对角线的最左格都在 A 列 → 全部收录;
- 3 条纵向里只有 A 列整列落在窗口内 → 收录 1 条;B 列、C 列的纵向三连要各自等它们变成窗口左列 A 时才判定(下一轮、下下轮)。
展开就是下面这 6 条:
1 条纵向: (0,A)(1,A)(2,A)
3 条横向: (0,A)(0,B)(0,C) (1,A)(1,B)(1,C) (2,A)(2,B)(2,C)
2 条对角: (0,A)(1,B)(2,C) (2,A)(1,B)(0,C)所以判定的是 T[6][3]:T[t][k] 是第 offset{row, col} 记录“第几行 + 哪一列(0=A, 1=B, 2=C)”,gain() 用一个循环统一判定,不必为每类三连单独写代码。
问题? 这 12bit 状态是“想”出来的一大坨吗?脑容量不够怎么办?
不是。它只是几个小积木的乘积:2 列 ×(每列 3bit 图案 + 3bit 标记)× 3 行 = 12bit。真正撑爆脑子的不是位数,而是“当场给格子定色”的直觉;把目标换成“让下一列能算对”,状态就是被逼出来的,不用靠记忆堆。
问题? 为什么“当场定色”一定会失败?
一条三连跨 3 列,一个格子会被好几个窗口反复命中。在窗口
问题? 推迟之后,为什么还要多一个“已计入”标记?
因为过去的信息会丢:窗口 mark 位就出现了。
所以状态不用“设计”,用它的唯一职责反推就行:状态 = 为了让下一列能算对、不得不记住的最小信息。下一列要判三连 → 留最近两列的图案;更早窗口的结论会丢 → 留最近两列的标记。12bit 是推导结果,不是要背的清单。
状态转移方程
先把记号固定下来。用
打包了"窗口左列
从
其中
同一轮里,被窗口三连命中的中列
其中
初始状态是"列 0、1 图案任意、无任何标记":
主循环结束后,状态停在最后两列
收尾时右侧没有第三列,
怎么读这个方程:
是对新列图案 取的:三列窗口里 已由状态 唯一确定, 是唯一的自由变量,要挑对答案最有利的一种。 - 加号左边
是过去已固定的分数,右边 是刚滑出窗口的那一列新贡献的分数;列 的权值在此之前从未加过,所以不重不漏。 - 方程里只出现最近两列,更早的列既不在状态里、也不在方程里——这就是"三连只跨 3 列"带来的马尔可夫性:列
一旦结清,对未来再无影响。 - 标记
是方程能成立的关键: 和 的更新必须放在同一次转移里,否则 这条三连下一轮就看不见了,中列 中被它染色的格子会漏账。
DP 转移表格
先看 12bit 状态在代码里的布局(低 6 位是窗口左端“旧列
| 位 | 0…2 | 3…5 | 6…8 | 9…11 |
|---|---|---|---|---|
| 含义 | 旧列图案 | 旧列已计入 | 中列图案 | 中列已计入 |
下表把一次转移拆成 5 个逻辑步骤;代码里这 5 步由 gain() 一次完成——第 1 步是开头的落账循环,第 2~5 步是 6 条三连共用的那个循环。用样例 1(
| 转移步骤 | 判定的三连 | 动作 | 累计 gain |
|---|---|---|---|
| 初始状态 | 旧列 |
0 | |
| 1. 结算旧列已计入格子 | 列0 无标记 | 无 | +0 |
| 2. 纵向三连 | 列0 整列 XXX | 三格计入并结算 | +3,旧列已计入=111 |
| 3. 横向三连 | 三行都是 列0=列1=列2=X | 中列、新列打标记 | 中列已计入=111,新列已计入=111 |
| 4. 对角线 ↘ | (0,列0)(1,列1)(2,列2) 全 X | 打标记 | 标记已存在,跳过 |
| 5. 对角线 ↗ | (2,列0)(1,列1)(0,列2) 全 X | 打标记 | 标记已存在,跳过 |
| 新状态 | 旧列 |
3 |
观察这张表:横向、两条对角线三连都命中了列 1、列 2 的格子,但因为 colored 标记,每个格子只被标记一次;而且列 0 的格子被纵向、横向、两条对角线四个三连同时命中,gain 却只加了 3 而不是 12——这正是"一格只计一次"的体现。
转移完成后,最后两列(列
| 收尾结算 | 图案 | 已计入 | 结算 |
|---|---|---|---|
| 列 1( |
111 | 111 | +3 |
| 列 2( |
111 | 111 | +3 |
| 总计 | 9 |
看这张表:列 1、列 2 的格子权值在此刻结算,加上转移阶段结算的列 0 的 3,总分 9,与样例 1 一致。每个格子的权值在整个 DP 中恰好出现一次。
代码
Python 版:
#!/usr/bin/env python3
import sys
"""ROJ 20024《棋》的教学版状态压缩 DP。
代码重在描述“3 列滑动窗口 + 染色标记”的思想。
使用字典和四元组保存状态,写法直观,但常数较大,可能无法通过最严数据。
兼容 Python 3.15。
"""
# 三列窗口 A、B、C 中需要检查的 6 条三连。
# 每个格子写成 (行号, 列号),列号 0/1/2 分别表示 A/B/C。
LINES = (
((0, 0), (1, 0), (2, 0)), # A 列纵向
((0, 0), (0, 1), (0, 2)), # 三条横向
((1, 0), (1, 1), (1, 2)),
((2, 0), (2, 1), (2, 2)),
((0, 0), (1, 1), (2, 2)), # 对角线 ↘
((2, 0), (1, 1), (0, 2)), # 对角线 ↗
)
def color(pattern: int, row: int) -> int:
"""返回一列第 row 格的棋子:0 表示 O,1 表示 X。"""
return pattern >> row & 1
def score_sign(pattern: int, row: int) -> int:
"""X 染红贡献为正,O 染蓝贡献为负。"""
return 1 if color(pattern, row) == 1 else -1
def move(
col_a: int,
patterns: tuple[int, int, int],
mark_a: int,
mark_b: int,
weight: list[list[int]],
) -> tuple[int, int, int]:
"""检查窗口 (A, B, C),结算 A 列,并返回 B、C 的新标记。
mark 的第 r 位为 1,表示这一格已经确定会染色,但分数还在挂账。
A 列马上离开窗口,所以本轮结算;B、C 只记录标记,留到以后结算。
"""
gain = 0
mark_c = 0
# A 列中以前已经确定染色的格子,现在统一结算。
for row in range(3):
if mark_a >> row & 1:
gain += weight[row][col_a] * score_sign(patterns[0], row)
for line in LINES:
three_colors = [color(patterns[col], row) for row, col in line]
if not (three_colors[0] == three_colors[1] == three_colors[2]):
continue
# 这条线上三个格子都会染色。
for row, col in line:
if col == 0:
# A 列直接计分;用 mark_a 防止一个格子被重复计算。
if not (mark_a >> row & 1):
gain += weight[row][col_a] * score_sign(patterns[0], row)
mark_a |= 1 << row
elif col == 1:
mark_b |= 1 << row
else:
mark_c |= 1 << row
return gain, mark_b, mark_c
def finish_column(
col: int, pattern: int, mark: int, weight: list[list[int]]
) -> int:
"""收尾时结算最后两列:已有标记,再补上本列的纵向三连。"""
if pattern == 0 or pattern == 7: # OOO 或 XXX
mark = 7
gain = 0
for row in range(3):
if mark >> row & 1:
gain += weight[row][col] * score_sign(pattern, row)
return gain
def solve() -> None:
data = iter(map(int, sys.stdin.buffer.read().split()))
n = next(data)
weight = [[next(data) for _ in range(n)] for _ in range(3)]
# 少于 3 列时只有纵向三连,每列独立选择 XXX 或 OOO。
if n <= 2:
answer = 0
for col in range(n):
column_sum = sum(weight[row][col] for row in range(3))
answer += abs(column_sum)
print(answer)
return
# 状态:(A 的图案, A 的标记, B 的图案, B 的标记) -> 最大得分。
# 一列图案是 0..7:它的 3 个二进制位分别表示三行填 O 还是 X。
dp: dict[tuple[int, int, int, int], int] = {}
for pattern_a in range(8):
for pattern_b in range(8):
dp[(pattern_a, 0, pattern_b, 0)] = 0
# 加入新列 C,结算旧列 A,然后窗口由 (A,B) 右移成 (B,C)。
for col_c in range(2, n):
col_a = col_c - 2
next_dp: dict[tuple[int, int, int, int], int] = {}
for (pattern_a, mark_a, pattern_b, mark_b), old_score in dp.items():
for pattern_c in range(8):
patterns = (pattern_a, pattern_b, pattern_c)
gain, new_mark_b, mark_c = move(
col_a, patterns, mark_a, mark_b, weight
)
new_state = (pattern_b, new_mark_b, pattern_c, mark_c)
new_score = old_score + gain
if new_score > next_dp.get(new_state, -10**100):
next_dp[new_state] = new_score
dp = next_dp
# 最后两列不会再滑出窗口,需要单独结算。
answer = -10**100
for (pattern_a, mark_a, pattern_b, mark_b), old_score in dp.items():
total = old_score
total += finish_column(n - 2, pattern_a, mark_a, weight)
total += finish_column(n - 1, pattern_b, mark_b, weight)
answer = max(answer, total)
print(answer)
if __name__ == "__main__":
solve()C++ 版(同一算法):
/**
* 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-09-26 12:00
* update_at: 2026-09-26 12:43
*/
// main.cpp:T4 棋(Chess) 正式解
// 12bit 状压 DP:一列 = 图案 pat(3bit) + 已计入标记 mark(3bit);
// 状态 = 窗口左端"旧列 A" + "中列 B",共 12bit,枚举新列 C 后结算旧列 A。
// 6 条三连写成常量表 T[6][3](每个格子是 {行,
// 列偏移}),转移时用一个循环统一判定。
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll NEG = -(1LL << 60);
const int MAXN = 1005;
const int SZ = 1 << 12; // 12bit 状态,共 4096 种
// dp[s]:状态 s = (旧列A, 中列B) 时,窗口左端之前的列已全部结算完的最大分数;
// 状态里 mark 标出的格子还只是"挂账",权值要等它滑出窗口时才加入。
ll dp[SZ];
// ndp[s]:滚动数组的下一层,含义同 dp,用来承接枚举新列 C 之后的转移结果。
ll ndp[SZ];
int n;
ll w[3][MAXN]; // w[行][列]:每个格子的权值
// 得到数字x 的第 k 位是0 还是1
int bit(int x, int k) { return (x >> k) & 1; }
// 列图案 p 第 r 行的染色符号:X(bit=1) 红取 +1,O(bit=0) 蓝取 -1。
int sgn(int p, int r) { return bit(p, r) ? 1 : -1; }
// 三列窗口里的一个格子:行 row(0~2) + 列偏移 col(0=旧列A, 1=中列B, 2=新列C)。
struct offset {
int row, col;
};
// 6 条三连,每条 3 个格子;只收录"最左格落在 A 列"的直线:
// 1 条 A 列纵向 + 3 条横向 + 2 条对角线。
const offset T[6][3] = {
{{0, 0}, {1, 0}, {2, 0}}, // 纵向:整列 A
{{0, 0}, {0, 1}, {0, 2}}, // 横向 行0
{{1, 0}, {1, 1}, {1, 2}}, // 横向 行1
{{2, 0}, {2, 1}, {2, 2}}, // 横向 行2
{{0, 0}, {1, 1}, {2, 2}}, // 对角线 ↘
{{2, 0}, {1, 1}, {0, 2}} // 对角线 ↗
};
// 状态编码:s = pA | (mA<<3) | (pB<<6) | (mB<<9)
// [mB][pB][mA][pA]
inline int encode(int pA, int mA, int pB, int mB) {
return pA | (mA << 3) | (pB << 6) | (mB << 9);
}
// 解码
inline void decode(int s, int &pA, int &mA, int &pB, int &mB) {
pA = s & 7;
mA = (s >> 3) & 7;
pB = (s >> 6) & 7;
mB = (s >> 9) & 7;
}
// 结算窗口 (A,B,C) 里的旧列 A:返回本轮 gain,并更新三个"已计入"标记。
// 规则:命中三连的 A 格当场结算(已计入的跳过),B、C 格只打标记。
// colA 列的下标: 第j列, p[3] pA,pB,pC 的状态
// mA mB mC 是否已经计算
ll gain(int colA, int p[3], int &mA, int &mB, int &mC) {
ll g = 0; // 结果
// 旧列 A 中"更早窗口已判定染色"的挂账格子,落账
for (int r = 0; r < 3; r++) {
if (bit(mA, r)) g += w[r][colA] * sgn(p[0], r);
}
// 6 条三连统一判定
for (int t = 0; t < 6; t++) {
int v = bit(p[T[t][0].col], T[t][0].row); // 第 t 条三连的第 0 个格子"的颜色
if (bit(p[T[t][1].col], T[t][1].row) != v) continue;
if (bit(p[T[t][2].col], T[t][2].row) != v) continue;
for (int k = 0; k < 3; k++) { // 枚举这一条的三个格子
int r = T[t][k].row, c = T[t][k].col;
if (c == 0) { // 旧列 A:计入
if (!bit(mA, r)) {
g += w[r][colA] * sgn(p[0], r);
mA |= 1 << r;
}
} else if (c == 1) { // 中列 B:只标记
mB |= 1 << r;
} else { // 新列 C:只标记
mC |= 1 << r;
}
}
}
return g;
}
// 收尾结算一列:右侧没有第三列,只可能是"已有标记 + 纵向三连"。
ll settle_last(int col, int p, int m) {
ll g = 0;
for (int r = 0; r < 3; r++) {
if (bit(m, r)) g += w[r][col] * sgn(p, r);
}
if (p == 0 || p == 7) { // 整列同色 -> 纵向三连
for (int r = 0; r < 3; r++) {
if (!bit(m, r)) g += w[r][col] * sgn(p, r);
}
}
return g;
}
// n <= 2:列数不够 3,只有纵向三连,每列独立,直接累加 |列和|。
void solve_small() {
ll ans = 0;
for (int c = 0; c < n; c++) {
ll s = w[0][c] + w[1][c] + w[2][c];
ans += max(s, -s); // = |s|
}
cout << ans << '\n';
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n;
for (int r = 0; r < 3; r++) {
for (int c = 0; c < n; c++) {
cin >> w[r][c];
}
}
if (n <= 2) {
solve_small();
return 0;
}
for (int s = 0; s < SZ; s++) dp[s] = NEG;
// 初始:只确定第 0、1 列图案,还没有任何格子计入
for (int p0 = 0; p0 < 8; p0++) {
for (int p1 = 0; p1 < 8; p1++) {
dp[encode(p0, 0, p1, 0)] = 0;
}
}
// ==================== 主循环:一列一列往右推 ====================
// 进入第 j 轮时,dp 里的状态 s = (旧列 A, 中列 B) 对应 A = j-2、B = j-1,
// 即"棋盘已确定到第 j-1 列,第 j 列还没放"。
// 本轮枚举第 j 列的图案 pC,凑出完整三列窗口 (j-2, j-1, j)。
// 有了这个窗口,列 j-2 的颜色就完全确定了:
// - 它作为"右格/中格"的三连(窗口 j-4、j-3)之前已查过,结果记在 mA 里;
// - 它作为"左格"的三连(窗口 j-2 本身)以及纵向三连,现在才第一次可见。
// 所以本轮把列 j-2 的账一次结清,然后窗口整体右移一列。
for (int j = 2; j < n; j++) {
int colA = j - 2; // 本轮要结算、并即将滑出窗口左端的列
// ndp 是下一层 DP,先全部清成不可达
for (int s = 0; s < SZ; s++) ndp[s] = NEG;
// 枚举当前层的每一个状态 s = (A 的图案/标记, B 的图案/标记)
for (int s = 0; s < SZ; s++) {
if (dp[s] == NEG) continue; // 不可达状态直接跳过
int pA, mA, pB, mB;
decode(s, pA, mA, pB, mB); // 拆出 A、B 两列的图案与"已计入"标记
// 枚举新列 C(第 j 列)的 8 种图案
for (int pC = 0; pC < 8; pC++) {
// 用副本承接本轮的新标记(直接改 mA/mB 会污染 dp[s],因为 s 还要被其它
// pC 复用)
int nA = mA, nB = mB, mC = 0;
// 结算窗口 (A,B,C):算出列 A 本轮得分 g,并把新标记写回 nA/nB/mC
int p[3] = {pA, pB, pC};
ll g = gain(colA, p, nA, nB, mC);
// 窗口右移一列:新状态是 (B, C),其中 B 带新标记 nB、C 带新标记 mC
int ns = encode(pB, nB, pC, mC);
// 松弛:从状态 s 走一步到 ns,保留更优的分数
ndp[ns] = max(ndp[ns], dp[s] + g);
}
}
// 本轮所有转移做完,滚动到下一层
for (int s = 0; s < SZ; s++) dp[s] = ndp[s];
}
// 收尾:最后两列永远不会滑到窗口左端,单独结算
ll ans = NEG;
for (int s = 0; s < SZ; s++) {
if (dp[s] == NEG) continue;
int pA, mA, pB, mB;
decode(s, pA, mA, pB, mB);
ll v = dp[s] + settle_last(n - 2, pA, mA) + settle_last(n - 1, pB, mB);
ans = max(ans, v);
}
cout << ans << '\n';
return 0;
}复杂度
状态数
- 时间复杂度:
, 时约 次操作; - 空间复杂度:
,只需滚动两行 DP 数组。
另一种解法:五列窗口(不记账)
主解是“三列窗口 + 染色标记”:只拿到 3 列就急着结算左列,再用标记把更早窗口的信息补回来。这里换一个更直白、但状态更大的思路——攒够信息再结算。
思路
信息覆盖:一条三连最多跨 3 列,所以某一列的格子是否染色,只取决于它左右各 2 列。把这 5 列凑齐,正中间那一列的颜色就完全确定,可以一次性结算。于是不需要任何“已计入”标记。
滑动一下看(中间列逐列被确定):
| 窗口 | 本步新确定 | 累计已确定 | 仍待定 | 状态里留的 4 列 |
|---|---|---|---|---|
| [1][2][3][4][5] | [3] | [4][5] | [2][3][4][5] | |
| [2][3][4][5][6] | [4] | [5][6] | [3][4][5][6] | |
| [3][4][5][6][7] | [5] | [6][7] | [4][5][6][7] | |
| [4][5][6][7][8] | [6] | [7][8] | [5][6][7][8] |
注意:5 列窗口只确定正中间那一列。[3]、[4] 并不是在窗口 [3…7] 这一步确定的,而是更早的窗口 [1…5]、[2…6] 已经结算过;到 [3…7] 这一步,累计已确定的是
要检查几条三连:结算中间列时,只有"经过中间列"的三连才可能影响它的颜色,其余三连碰不到列 2,判了也白判。3 行 × 5 列里经过列 2 的直线恰好 16 条:横向 9 条(每行 3 条)、纵向 1 条(只有列 2 自己的竖线完整落在窗口内)、右下斜 3 条、右上斜 3 条。代码里用 build_lines() 生成这 16 条,用一张固定的线表代替"对每个格子反推它可能在哪些三连里"的坐标运算。
为什么状态只存 4 列:第 5 列正是“刚要加进来的那一列”。状态里存的是“已经拿到、但还没用于结算”的信息:
4 列 → 加新列 = 5 列 → 结算中间列 → 丢掉最旧列 → 又是 4 列每步恰好丢掉最旧一列,所以状态恒为 4 列,记
和主解的对比:
| 何时结算 | 要不要额外记账 | 状态 | |
|---|---|---|---|
主解(main.cpp) |
只有 3 列就结算左列 | 要(否则更早窗口的信息会丢) | 2 列 + 染色标记 = 12bit |
本解(main2.cpp) |
等到 5 列才结算中列 | 不要(信息齐了才动手) | 4 列图案 = |
一句话:主解是“提前结算 + 记账补信息”,本解是“攒够信息再结算、不记账”。
dp 里到底存了什么
上面说"记 dp:
dp: dict[tuple, int] # 键 = 状态,值 = 分数key 是状态,回答"未来长什么样"。 元组的每个元素是一列的 3bit 图案(0..7),棋盘外的虚拟列写 None;元组里存的是"以刚读入的那一列结尾的最后若干列":
key = ( ..., p[i-3], p[i-2], p[i-1], p[i] )| 处理完 | key 的形状 | 例子 |
|---|---|---|
| 列 0 | 3 元组 | (None, None, 5) |
| 列 1 | 4 元组 | (None, None, 5, 3) |
| 列 2 起 | 4 元组 | (None, 5, 3, 7) |
注意 key 永远不是 5 元组——5 列只活在循环体的临时变量 window 里,结算完就把最左一列丢掉:
window = state + (pattern,) # 凑成 5 列,结算中间列
new_state = window[-4:] # 存回 dp 的仍然只有 4 列value 是分数,回答"过去赚了多少"。 dp[key] = v 读作:最后这几列的图案是 key 时,更左边那些已经结算完的列的得分总和,最大能到 v。
问题? key 已经记了状态,为什么还要 value?直接存 key 不行吗?
因为得分是累加的,而 key 只留了最后 4 列,更左边的列已经被丢掉,它们的分数不可能从 key 反推。用样例 2(
key = (0, 0, 0, None)
历史得分 = [83, 84, 85, 87, 88, 95] ← 6 条不同路径抵达同一状态
dp 只留 = 95同一个 key,有的历史只赚 83 分,有的赚 95 分。key 看不出区别,差的那 12 分只能靠 value 记住。
问题? 那为什么可以只留最大值,把别的都扔掉?
因为转移增量只依赖 key + 新列:
window = state + (pattern,)
gain = 0
if len(window) == 5: # 凑齐 5 列才轮到中间列结算
gain = middle_score(window, next_col - 2, weight)
new_score = old_score + gain # 过去唯一的作用就是这个加法被丢掉的列再也不可能影响任何未来的 gain。所以到达同一 key 的两条历史面对的"未来"完全一样,未来能加的分也一样
next_dp[new_state] = max(next_dp.get(new_state, NEG), new_score)如果 dp 只是"状态的集合",这一行就不存在了——这一行就是 value 这个概念本身。(NEG 只是 max 合并时表示"还没有值"的占位,不会被写进最终结果。)
问题? 那干脆"直接存每列的状态"、存下整条序列
那就是不压缩、不合并,路径数是
所以 dp 的本质是一个映射:
它对应的"状态集"不是预先枚举好的,而是被 max 合并动态长出来的:多条路径撞上同一个 key 才产生一个状态,而"状态存在"这件事,就是它的 value 不是 NEG。main2.cpp 用 C++ 重写时,把这层意思换成了定长数组 dp[9^4]:
for (int s = 0; s < SZ; s++) dp[s] = NEG;
if (dp[s] == NEG) continue; // 等价于"这个 key 不在字典里"数组是稠密预分配的,字典是稀疏懒生长的,两者概念完全一样:key 管未来,value 管过去,缺一不可。
代码
main2.py 是 Python 教学原型,用字典存状态,慢但一眼能看懂。它把"窗口里哪些格子被染色"这件重复劳动也做成了预处理:先用 build_lines() 按四个方向族生成"经过中间列"的三连常量表 LINES(横向 9 + 纵向 1 + 两个斜向各 3,共 16 条),再把 COLORED,主循环里只剩一次查表:
#!/usr/bin/env python3
"""ROJ 20024《棋》:优先讲清思想的五列窗口 DP。
写法兼容 Python 3.15。它省去了染色标记,容易理解,但运行速度较慢。
"""
NONE = 8 # 棋盘外的虚拟列;状态里写成 None,编成整数时才变成 8
NEG = -10**100
def get_color(pattern: int, row: int) -> int:
"""一列用 3 bit 表示;第 row 位是 0 表示 O,是 1 表示 X。"""
return pattern >> row & 1
# 窗口是 3 行 x 5 列。长度为 3 的直线有很多条,但本轮只结算中间列(列 2),
# 所以只保留"经过列 2"的线。每条线写成 3 个 (行, 列) 格子,行取 0..2,列取 0..4:
#
# 列 0 列 1 列 2 列 3 列 4
# 行 0 . . . . .
# 行 1 . . . . .
# 行 2 . . . . .
# ↑ 中间列,本轮的结算对象
def build_lines():
"""生成 3 行 x 5 列窗口里所有经过中间列的三连,共 16 条。"""
lines = []
# 横向 9 条:每行都有 3 条(起点列 0 / 1 / 2),条条都经过列 2,生成:
# ((0,0),(0,1),(0,2)) ((0,1),(0,2),(0,3)) ((0,2),(0,3),(0,4))
# ((1,0),(1,1),(1,2)) ((1,1),(1,2),(1,3)) ((1,2),(1,3),(1,4))
# ((2,0),(2,1),(2,2)) ((2,1),(2,2),(2,3)) ((2,2),(2,3),(2,4))
for r in range(3):
for c in range(3):
lines.append(((r, c), (r, c + 1), (r, c + 2)))
# 纵向 1 条:只有列 2 自己的竖线完整落在窗口里,生成:
# ((0,2),(1,2),(2,2))
lines.append(((0, 2), (1, 2), (2, 2)))
# 右下斜 3 条:从第 0 行走到第 2 行,行 +1、列 +1,生成:
# ((0,0),(1,1),(2,2)) ((0,1),(1,2),(2,3)) ((0,2),(1,3),(2,4))
for c in range(3):
lines.append(((0, c), (1, c + 1), (2, c + 2)))
# 右上斜 3 条:从第 0 行走到第 2 行,行 +1、列 -1,生成:
# ((0,2),(1,1),(2,0)) ((0,3),(1,2),(2,1)) ((0,4),(1,3),(2,2))
for c in range(3):
lines.append(((0, c + 2), (1, c + 1), (2, c)))
return tuple(lines)
LINES = build_lines()
def colored_mask(window) -> int:
"""窗口中间列哪几行位于某个三连中:第 row 位为 1 表示 (row, 2) 被染色。"""
mask = 0
for cells in LINES:
# 三连里只要有一格是虚拟列(棋盘外),这条线就不成立
if any(window[c] is None for _, c in cells):
continue
# 三个格子同色,才构成一条三连
r0, c0 = cells[0]
color = get_color(window[c0], r0)
if not all(get_color(window[c], r) == color for r, c in cells):
continue
# 只登记中间列的行
for r, c in cells:
if c == 2:
mask |= 1 << r
return mask
def window_code(window) -> int:
"""5 列窗口 -> 9 进制整数(window[0] 是最高位,window[4] 是最低位)。"""
code = 0
for pattern in window:
code = code * 9 + (NONE if pattern is None else pattern)
return code
def decode_window(code):
"""9 进制整数 -> 5 列窗口,是 window_code 的逆运算。"""
window = [None] * 5
for i in range(4, -1, -1): # window[4] 是最低位
p = code % 9
window[i] = None if p == NONE else p
code //= 9
return window
# 预处理:窗口一共 9^5 种,每种窗口"中间列哪几行被染色"先整表算好。
# 主循环里同一个窗口会反复出现,查表比每次重判 16 条三连省事得多。
COLORED = bytearray(9 ** 5)
for _code in range(9 ** 5):
COLORED[_code] = colored_mask(decode_window(_code))
def middle_score(window, col: int, weight: list[list[int]]) -> int:
"""五列都已知时,计算正中间这一列的完整得分。"""
mask = COLORED[window_code(window)]
pattern = window[2]
score = 0
for row in range(3):
if mask >> row & 1:
sign = 1 if get_color(pattern, row) == 1 else -1
score += sign * weight[row][col]
return score
def solve() -> None:
n = int(input())
weight = [list(map(int, input().split())) for _ in range(3)]
# 状态是最近至多四列的图案,值是已经结算的最大得分。
# 开头先放两个 None,表示棋盘左边的两列不存在。
dp = {(None, None): 0}
# 枚举 n 列真实棋盘,再补两个 None,让最后两列也能成为窗口中心。
for next_col in range(n + 2):
choices = range(8) if next_col < n else (None,)
next_dp = {}
for state, old_score in dp.items():
for pattern in choices:
window = state + (pattern,)
gain = 0
# 凑齐五列后,中间列左右各有两列,它的颜色已经完全确定。
if len(window) == 5:
middle_col = next_col - 2
gain = middle_score(window, middle_col, weight)
new_state = window[-4:]
new_score = old_score + gain
next_dp[new_state] = max(next_dp.get(new_state, NEG), new_score)
dp = next_dp
print(max(dp.values()))
if __name__ == "__main__":
solve()main3.py 是同一算法的 Pythonic 短写法,和 main2.py 一一对应:四个列表推导直接写出那张 16 条的线表,再用 functools.cache 把"窗口 → 中间列染色掩码"按需缓存——相当于把 COLORED 那张表改成懒加载,于是 window_code() / decode_window() 和建表循环全部消失,代码从 151 行降到不到 90 行。代价是启动时不再预热,运行时要多一次缓存查找:
#!/usr/bin/env python3
# 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-09-28 12:53
# update_at: 2026-09-28 16:58
import sys
from collections.abc import Iterable
from functools import cache
NEG = -10**100
def get_color(pattern: int, row: int) -> int:
"""取一列第 row 位的颜色:0 表示 O,1 表示 X。"""
return pattern >> row & 1
# 窗口里所有"经过中间列(列 2)"的三连,共 16 条;每条写成 3 个 (行, 列) 格子。
# 本轮只结算中间列,所以别的线一概不要。
LINES = (
[((r, c), (r, c + 1), (r, c + 2)) for r in range(3) for c in range(3)] # 横向 9 条
+ [((0, 2), (1, 2), (2, 2))] # 纵向 1 条
+ [((0, c), (1, c + 1), (2, c + 2)) for c in range(3)] # 右下斜 3 条
+ [((0, c + 2), (1, c + 1), (2, c)) for c in range(3)] # 右上斜 3 条
)
@cache
def colored_mask(window: tuple[int | None, ...]) -> int:
"""窗口中间列哪几行位于某个三连中:第 row 位为 1 表示 (row, 2) 被染色。
窗口是元组(元素为 0..7 或 None),天然可哈希,所以能直接当缓存键:
同一个窗口只算一次,效果和 main2.py 里那张 COLORED 表一样,但按需生成。
"""
mask = 0
for cells in LINES:
if any(window[c] is None for _, c in cells):
continue # 有格子落在棋盘外
if len({get_color(window[c], r) for r, c in cells}) == 1: # 三格同色
mask |= sum(1 << r for r, c in cells if c == 2) # 只登记中间列
return mask
def middle_score(window: tuple[int | None, ...], col: int, weight: list[list[int]]) -> int:
"""五列都已知时,计算正中间那一列(棋盘第 col 列)的完整得分。"""
mask = colored_mask(window)
middle = window[2] # 窗口五列里结算的就是第 2 列
return sum(
(1 if get_color(middle, r) else -1) * weight[r][col] # X 红 +,O 蓝 -
for r in range(3)
if mask >> r & 1
)
def advance(dp: dict[tuple[int | None, ...], int], choices: Iterable[int | None],
col: int, weight: list[list[int]]) -> dict[tuple[int | None, ...], int]:
"""滑动一列:每个旧状态接上新列的图案,凑满五列时就结算窗口中心那一列。"""
nxt: dict[tuple[int | None, ...], int] = {}
for state, score in dp.items():
for pattern in choices:
window = state + (pattern,)
gain = middle_score(window, col, weight) if len(window) == 5 else 0
new_state = window[-4:] # 只留最近 4 列当下一轮的状态
nxt[new_state] = max(nxt.get(new_state, NEG), score + gain)
return nxt
def solve() -> None:
data = iter(map(int, sys.stdin.buffer.read().split()))
n = next(data)
weight = [[next(data) for _ in range(n)] for _ in range(3)]
# 状态是最近至多 4 列的图案。左侧两列在棋盘外,先垫两个 None 当窗口左边界,
# 这样第 0 列会在 next_col = 2 时正好落到窗口中心被结算。
dp: dict[tuple[int | None, ...], int] = {(None, None): 0}
for next_col in range(n + 2):
# 棋盘内的列有 8 种图案;末尾追加两个 None,把最后两列也顶到窗口中心。
choices = range(8) if next_col < n else (None,)
dp = advance(dp, choices, next_col - 2, weight)
# 补的 None 已经把每一列都结算过,剩下的状态只比总分。
print(max(dp.values()))
if __name__ == "__main__":
solve()main2.cpp 是同一思路的 C++ 实现,用同一张 LINES 表(build_lines() 生成)判断三连,同样把 colored_mask[],主循环里
/**
* 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-09-27 09:05
* update_at: 2026-09-27 22:56
*/
// main2.cpp:T4 棋(Chess) 教学版——"五列窗口"DP(对应 main2.py)。
//
// 和 main.cpp 的"三列窗口 + 染色标记"不同,这里换个更好懂的切入角度:
// 一条三连最多跨 3 列,所以某一列的格子"是否染色"只取决于它左右各 2 列。
// 等一列左右各 2 列都确定了(凑齐 5 列窗口),就能一次性算出这一整列的得分。
// 好处:状态里不用再存"这一格是否已计入"的标记,思想更直白。
// 代价:状态要记最近 4 列;棋盘左边/右边不存在的列用"虚拟列"补上。
//
// 为了让 n = 1000 也能跑进 1s,这里多做一步预处理:
// 染色判断只跟"5 列窗口"有关,跟具体列号/权值无关;
// 而同一个窗口会被大量 (状态, 新列图案) 组合反复查询。
// 所以先把所有窗口的"中间列染色情况"算好,主循环里 O(1) 查表。
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll NEG = -(1LL << 60);
const int MAXN = 1005;
const int ROWS = 3; // 棋盘固定 3 行
const int PAT_CNT = 8; // 一列的图案:3bit,0..7(bit r = 1 表示第 r 行是 X)
const int NONE = 8; // 虚拟列(棋盘外),用一个额外取值 8 表示
const int BASE = 9; // 状态按 9 进制存,每一位 0..8
const int SZ = BASE * BASE * BASE * BASE; // 状态:最近 4 列,9^4 = 6561
const int WIN_SZ = SZ * BASE; // 窗口:最近 5 列,9^5 = 59049
int n;
ll w[ROWS][MAXN]; // w[行][列]:每个格子的权值
// 取一列图案 pattern 的第 row 位:1 表示 X,0 表示 O。
inline int color(int pattern, int row) {
return (pattern >> row) & 1;
}
/* ---------------- 窗口里"经过中间列"的三连 ----------------
* 窗口是 3 行 x 5 列。长度为 3 的直线有很多条,但本轮只结算中间列(列 2),
* 所以只保留"经过列 2"的线。每条线写成 3 个格子。
*
* 列 0 列 1 列 2 列 3 列 4
* 行 0 . . . . .
* 行 1 . . . . .
* 行 2 . . . . .
* ↑ 中间列,本轮的结算对象
*/
const int LINE_CNT = 16; // 横向 9 + 纵向 1 + 右下斜 3 + 右上斜 3
int line_r[LINE_CNT][3]; // 每条线 3 个格子的行
int line_c[LINE_CNT][3]; // 每条线 3 个格子的列
void build_lines() {
int cnt = 0;
// 横向 9 条:每行都有 3 条(起点列 0 / 1 / 2),条条都经过列 2,生成:
// ((0,0),(0,1),(0,2)) ((0,1),(0,2),(0,3)) ((0,2),(0,3),(0,4))
// ((1,0),(1,1),(1,2)) ((1,1),(1,2),(1,3)) ((1,2),(1,3),(1,4))
// ((2,0),(2,1),(2,2)) ((2,1),(2,2),(2,3)) ((2,2),(2,3),(2,4))
for (int r = 0; r < ROWS; r++) {
for (int c = 0; c < 3; c++) {
for (int k = 0; k < 3; k++) {
line_r[cnt][k] = r;
line_c[cnt][k] = c + k;
}
cnt++;
}
}
// 纵向 1 条:只有列 2 自己的竖线完整落在窗口里,生成:
// ((0,2),(1,2),(2,2))
for (int k = 0; k < 3; k++) {
line_r[cnt][k] = k;
line_c[cnt][k] = 2;
}
cnt++;
// 右下斜 3 条:从第 0 行走到第 2 行,行 +1、列 +1,生成:
// ((0,0),(1,1),(2,2)) ((0,1),(1,2),(2,3)) ((0,2),(1,3),(2,4))
for (int c = 0; c < 3; c++) {
for (int k = 0; k < 3; k++) {
line_r[cnt][k] = k;
line_c[cnt][k] = c + k;
}
cnt++;
}
// 右上斜 3 条:从第 0 行走到第 2 行,行 +1、列 -1,生成:
// ((0,2),(1,1),(2,0)) ((0,3),(1,2),(2,1)) ((0,4),(1,3),(2,2))
for (int c = 0; c < 3; c++) {
for (int k = 0; k < 3; k++) {
line_r[cnt][k] = k;
line_c[cnt][k] = c + 2 - k;
}
cnt++;
}
}
// 窗口中间列哪几行位于某个三连中:第 row 位 = 1 表示 (row, 2) 被染色。
// window[c] 取值 0..7 是真实列,8(NONE) 表示棋盘外的虚拟列。
int colored_mask_of(const int window[5]) {
int mask = 0;
for (int i = 0; i < LINE_CNT; i++) {
// 三连里只要有一格是虚拟列(棋盘外),这条线就不成立
int r0 = line_r[i][0], c0 = line_c[i][0];
if (window[c0] == NONE) continue;
// 三个格子同色,才构成一条三连
int target = color(window[c0], r0);
bool same = true;
for (int k = 1; k < 3; k++) {
int r = line_r[i][k], c = line_c[i][k];
if (window[c] == NONE || color(window[c], r) != target) { same = false; break; }
}
if (!same) continue;
// 只登记中间列的行
for (int k = 0; k < 3; k++) {
if (line_c[i][k] == 2) mask |= 1 << line_r[i][k];
}
}
return mask;
}
/* ---------------- 状态 / 窗口的编码 ----------------
* 把若干列图案按 9 进制拼成一个整数(低位是最旧的一列)。
* 4 位 -> 状态,5 位 -> 窗口。
*/
inline int encode4(int d0, int d1, int d2, int d3) {
return d0 + d1 * BASE + d2 * BASE * BASE + d3 * BASE * BASE * BASE;
}
inline void decode4(int s, int d[4]) {
for (int i = 0; i < 4; i++) {
d[i] = s % BASE;
s /= BASE;
}
}
/* ---------------- 预处理:窗口 -> 中间列的染色情况 ----------------
* colored_mask[code] 的第 r 位 = 1,表示该窗口中间列第 r 行会被染色。
* 同一个窗口在 DP 中会被查很多次,预处理后每次只需一次数组访问。
*/
static unsigned char colored_mask[WIN_SZ];
void precompute_colored() {
for (int code = 0; code < WIN_SZ; code++) {
int window[5];
int t = code;
for (int i = 0; i < 5; i++) { // 拆出 5 个 9 进制位
window[i] = t % BASE;
t /= BASE;
}
colored_mask[code] = (unsigned char)colored_mask_of(window);
}
}
// 5 列都确定时,算出正中间列(对应棋盘第 col 列)的完整得分。
// middle_pattern 就是中间列的图案,用来定符号(X 红 +,O 蓝 -)。
ll middle_score(int code, int middle_pattern, int col) {
int mask = colored_mask[code];
ll score = 0;
for (int row = 0; row < ROWS; row++) {
if (!((mask >> row) & 1)) continue; // 没染色就不计分
int sign = (color(middle_pattern, row) == 1) ? 1 : -1;
score += sign * w[row][col];
}
return score;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n;
for (int r = 0; r < ROWS; r++) {
for (int c = 0; c < n; c++) {
cin >> w[r][c];
}
}
build_lines();
precompute_colored();
static ll dp[SZ], ndp[SZ]; // dp[状态] = 已经结算完的最大得分
for (int s = 0; s < SZ; s++) dp[s] = NEG;
// 初始:最近 4 列都是"虚拟列"(棋盘左边不存在)
dp[encode4(NONE, NONE, NONE, NONE)] = 0;
// 枚举 n 列真实棋盘,再补 2 列虚拟列,
// 让最后两列也能各自成为一次"窗口正中间"被结算。
for (int next_col = 0; next_col < n + 2; next_col++) {
for (int s = 0; s < SZ; s++) ndp[s] = NEG;
// 真实列可选图案 0..7;补出来的虚拟列只能是 NONE
int lo = 0, hi = PAT_CNT;
if (next_col >= n) { lo = NONE; hi = NONE + 1; }
for (int s = 0; s < SZ; s++) {
if (dp[s] == NEG) continue; // 不可达状态
int d[4];
decode4(s, d);
for (int pattern = lo; pattern < hi; pattern++) {
// 旧 4 列 + 新列 = 5 列窗口;顺手算出它的 9 进制编码
int code = encode4(d[0], d[1], d[2], d[3]) + pattern * SZ;
ll gain = 0;
int middle_col = next_col - 2; // 窗口正中对应的棋盘列
if (0 <= middle_col && middle_col < n) {
// 中间列左右各 2 列都齐了,可以结算它的整列得分
gain = middle_score(code, d[2], middle_col);
}
// 窗口右移一列:丢掉最旧的 d0,新状态是 (d1, d2, d3, pattern)
int ns = encode4(d[1], d[2], d[3], pattern);
ndp[ns] = max(ndp[ns], dp[s] + gain);
}
}
for (int s = 0; s < SZ; s++) dp[s] = ndp[s];
}
// 全部列都已结算,取最大得分
ll ans = NEG;
for (int s = 0; s < SZ; s++) ans = max(ans, dp[s]);
cout << ans << '\n';
return 0;
}复杂度
- 状态数
,每个状态枚举 8 种新列图案,共 轮: ; - 窗口/掩码的预处理
种窗口( main3.py用functools.cache按需缓存,只算实际遇到的那部分); - 空间
。
总结
这道题的关键是把"格子颜色"的判定从局部视角换成窗口视角:横向/斜向三连跨相邻 3 列,所以当一列滑出 3 列窗口时,它的颜色已经确定。窄带状压 DP 的通用套路就是–先找"未来只依赖过去哪段紧凑信息"(这里是相邻 2 列),再用 colored 标记解决"一个对象被多个结构重复命中"的计权问题。这种“标记去重”的思想在网格类 DP 里很常用。
本题还演示了两种“有限记忆”策略:主解提前结算 + 用标记补回已经丢失的信息(状态更小),另一解攒够 5 列信息再结算、完全不用标记(状态更大但更直白)——它们都是“只保留够用的过去”这一思想的变体。