把均方差化简为最小化平方和后,用「剩余矩形 + 还要切几块」做记忆化 DP,每次枚举一刀割下的那块并让剩下的矩形继续递归。
OJ: roj
题目 ID: 3172
难度:提高+/省选-
标签:动态规划记忆化搜索二维前缀和python
创建: 2026-10-01 22:45
更新: 2026-10-10 13:00
形式化题目
给定一个
设第
的值,四舍五入到小数点后三位。
注意“割下一块、剩下的继续切”与“每次把矩形分成两块、两块都继续切”不是同一个问题:第一种读法每刀只有一侧存活,可行切法更少,最优值只会更大。两种模型在同一组棋盘上给出的答案差别很大,后面会用一个反例具体说明。
以样例(
| 行/列 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|---|
| 0 | B | B | B | B | C | C | C | C |
| 1 | B | B | B | B | C | C | C | C |
| 2 | B | B | B | B | C | C | C | C |
| 3 | B | B | B | B | C | C | C | C |
| 4 | B | B | B | B | C | C | C | C |
| 5 | A | A | A | A | A | A | A | A |
| 6 | A | A | A | A | A | A | A | A |
| 7 | A | A | A | A | A | A | A | A |
三块分值
正解
思路
朴素做法与瓶颈
最直接的做法是搜索所有切割顺序:每一步在当前矩形上选一条切线和方向,割下的块记入答案,剩下的矩形继续切,直到凑满
真正的瓶颈不在枚举量,而在没有合并重复子问题。观察搜索过程:每一步只改变两件事——当前的剩余矩形、还要切出几块;被割下的块此后只贡献一个固定的
题面里“剩下的部分也是矩形”这句话正是这个状态成立的原因:剩余部分总能被四个整数
第一步:化简目标函数
棋盘总分
第二步:状态与转移
设
其中 itertools.chain 接起来,于是转移体里只剩一种写法。
之所以只对
可行切法会变多、答案会偏小:真实数据 chess2 用这个形式会得到
用样例看状态表
下表的每一行是一个子问题,列出矩形和
| 状态 |
矩形 | 首刀决策 | ||
|---|---|---|---|---|
| 整盘 | 66 | 4356 | 不切 | |
| 整盘 | 66 | 2180 | 割下第 0~3 行,保留第 4~7 行 | |
| 整盘 | 66 | 1460 | 割下第 5~7 行,保留第 0~4 行 | |
| 下 3 行 | 24 | 576 | 不切 | |
| 上 5 行 | 42 | 884 | 割下左 4 列,保留右 4 列 | |
| 左上 |
20 | 400 | 不切 | |
| 右上 |
22 | 484 | 不切 |
校验最后一步:
为什么不用递推而用记忆化
按 functools.cache 记忆化,让递归自己决定访问顺序,代码短得多,代价只是常数级的时间开销。
代码
/**
* 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-10 13:00
* update_at: 2026-10-10 13:00
*/
// 棋盘分割:记忆化搜索最小化各块分值平方和,最后换算成均方差
#include <cstdio>
#include <cmath>
using namespace std;
typedef long long ll;
const int SIZE = 8; // 棋盘边长固定为 8
const int MAXK = 20; // 切块数上界
const ll INF = 1000000000000000000LL; // 一块也切不动时的不可行代价
int cells[SIZE][SIZE];
ll pre[SIZE + 1][SIZE + 1]; // 二维前缀和
ll memo[SIZE][SIZE][SIZE][SIZE][MAXK + 1]; // best(r1,c1,r2,c2,k) 的缓存,-1 表示未算
// 闭区间矩形 [r1,r2] x [c1,c2] 的分值之和
ll box(int r1, int c1, int r2, int c2) {
return pre[r2 + 1][c2 + 1] - pre[r1][c2 + 1] - pre[r2 + 1][c1] + pre[r1][c1];
}
// 把矩形切成 k 块时各块分值平方和的最小值
ll best(int r1, int c1, int r2, int c2, int k) {
if (k == 1) {
ll s = box(r1, c1, r2, c2);
return s * s;
}
ll& res = memo[r1][c1][r2][c2][k];
if (res >= 0) {
return res;
}
ll ans = INF;
// 横着切:上面一块定稿,下面一块还要再切 k-1 刀
for (int i = r1; i < r2; i++) {
ll s1 = box(r1, c1, i, c2);
ll v = s1 * s1 + best(i + 1, c1, r2, c2, k - 1);
if (v < ans) {
ans = v;
}
ll s2 = box(i + 1, c1, r2, c2);
v = s2 * s2 + best(r1, c1, i, c2, k - 1);
if (v < ans) {
ans = v;
}
}
// 竖着切:左一块定稿,右一块还要再切 k-1 刀
for (int j = c1; j < c2; j++) {
ll s1 = box(r1, c1, r2, j);
ll v = s1 * s1 + best(r1, j + 1, r2, c2, k - 1);
if (v < ans) {
ans = v;
}
ll s2 = box(r1, j + 1, r2, c2);
v = s2 * s2 + best(r1, c1, r2, j, k - 1);
if (v < ans) {
ans = v;
}
}
res = ans;
return ans;
}
int main() {
for (int r = 0; r < SIZE; r++) {
for (int c = 0; c < SIZE; c++) {
for (int a = 0; a < SIZE; a++) {
for (int b = 0; b < SIZE; b++) {
for (int k = 0; k <= MAXK; k++) {
memo[r][c][a][b][k] = -1;
}
}
}
}
}
int n;
if (scanf("%d", &n) != 1) {
return 0;
}
for (int r = 0; r < SIZE; r++) {
for (int c = 0; c < SIZE; c++) {
scanf("%d", &cells[r][c]);
pre[r + 1][c + 1] = pre[r][c + 1] + pre[r + 1][c] - pre[r][c] + cells[r][c];
}
}
ll total = pre[SIZE][SIZE];
ll s2 = best(0, 0, SIZE - 1, SIZE - 1, n); // Σx² 的最小值
double ans = sqrt((double)s2 / n - ((double)total / n) * ((double)total / n));
printf("%.3f\n", ans);
return 0;
}#!/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-10-02 10:20
# update_at: 2026-10-02 10:20
import sys
from functools import cache
from itertools import chain
from math import sqrt
SIZE = 8 # 棋盘边长固定为 8
INF = 10**18 # 矩形小到一刀也下不去时的不可行代价
PREFIX: list[list[int]] = [] # (SIZE+1) x (SIZE+1) 的二维前缀和,由 solve 填入
def prefix_of(cells: list[int]) -> list[list[int]]:
"""把行优先读入的 64 个格子分值变成二维前缀和,使任意子矩形和 O(1) 可求。"""
P = [[0] * (SIZE + 1) for _ in range(SIZE + 1)]
for r in range(SIZE):
for c in range(SIZE):
P[r + 1][c + 1] = P[r][c + 1] + P[r + 1][c] - P[r][c] + cells[SIZE * r + c]
return P
def box(r1: int, c1: int, r2: int, c2: int) -> int:
"""闭区间矩形 [r1,r2] x [c1,c2] 的分值之和。"""
return PREFIX[r2 + 1][c2 + 1] - PREFIX[r1][c2 + 1] - PREFIX[r2 + 1][c1] + PREFIX[r1][c1]
def cuts(r1: int, c1: int, r2: int, c2: int):
"""枚举“割下一块矩形、剩下部分仍是矩形”的所有一刀,产出 (割下的块, 剩下的块)。"""
return chain(
(((r1, c1, i, c2), (i + 1, c1, r2, c2)) for i in range(r1, r2)), # 割走上半
(((i + 1, c1, r2, c2), (r1, c1, i, c2)) for i in range(r1, r2)), # 割走下半
(((r1, c1, r2, j), (r1, j + 1, r2, c2)) for j in range(c1, c2)), # 割走左半
(((r1, j + 1, r2, c2), (r1, c1, r2, j)) for j in range(c1, c2)), # 割走右半
)
@cache
def best(r1: int, c1: int, r2: int, c2: int, k: int) -> int:
"""把矩形切成 k 块时各块分值平方和的最小值(均方差只与它相差一个常数)。"""
if k == 1:
return box(r1, c1, r2, c2) ** 2
return min(
# 割下的那块就此定稿,剩下那块还要再切 k-1 刀
(box(*peeled) ** 2 + best(*kept, k - 1) for peeled, kept in cuts(r1, c1, r2, c2)),
default=INF, # 单格矩形切不出两块
)
def solve() -> None:
global PREFIX
data = list(map(int, sys.stdin.buffer.read().split()))
n, *cells = data
PREFIX = prefix_of(cells)
total = PREFIX[SIZE][SIZE]
s2 = best(0, 0, SIZE - 1, SIZE - 1, n) # Σx² 的最小值
# 均方差 = sqrt(Σx²/n - (Σx/n)²):n 与总分 Σx 固定,所以只需最小化 Σx²
print(f"{sqrt(s2 / n - (total / n) ** 2):.3f}")
if __name__ == "__main__":
solve()复杂度
- 时间复杂度:状态数为子矩形个数
乘以剩余块数 ,约 ;每个状态枚举一刀,水平线 条、竖直线 条、各带两个方向,候选数不超过 。转移总量的上界约 ,实测最慢一组( )只访问了 9296 个状态、0.044 s。 - 空间复杂度:记忆化缓存
个整数,加上 的前缀和;实测峰值内存 16.6 MB。
总结
本题有两步是绕不开的。第一步是把均方差化简掉:总分和块数固定,均方差只通过
最容易出错的是把题意读成“每次分成两块、两块都继续切”,那会让答案系统性偏小;真实数据里 chess2 与 chess5 都能立刻暴露这个错误。
图示解析
下面用样例的决策链展示
f(整盘 8x8, 3) S=66 f=1460
├─ 割下第 5~7 行 S=24 → 24² = 576
└─ f(第 0~4 行 5x8, 2) S=42 f=884
├─ 割下左 4 列 S=20 → 20² = 400
└─ f(右 4 列, 1) S=22 f=484读图时关注三点:每层的
再下面这张图概括转移的一般形态:一个状态只在两种情况下终止或分叉。
flowchart TD
S["状态:矩形 [r1,r2] x [c1,c2],还要切 k 块"] --> C{"k == 1 ?"}
C -- 是 --> B["整块定稿<br/>f = S(矩形)²"]
C -- 否 --> D["枚举一刀,分成 A 与 B<br/>A 是割下的那块"]
D --> E["A 定稿:贡献 S(A)²"]
D --> F["B 继续切 k-1 块<br/>递归 f(B, k-1)"]
E --> G["两者相加,对所有一刀取最小"]
F --> G
图里两条分叉对应题面里“割下一块”和“剩下部分继续分割”这两件事;注意从

,其中平均值
,