按「结尾位置 + 上一项位置」记等差子序列的个数, 每轮把中间项左侧的状态按高度分桶, 让右侧查询 O(1), 从而在不依赖值域的前提下做到 O(n^2)。
OJ: luogu
题目 ID: P4933
难度:普及+/提高-
标签:动态规划计数DP桶子序列
创建: 2026-10-26 14:41
更新: 2026-10-26 16:36
目录
形式化题目
给定长度为
从下标
要求统计美观子集的个数, 对
注意
解法路线
最直白的做法是枚举全部
关键观察是: 固定公差
下面按题面的真实档位组织主线, 每层都能对上题面里的一个约束. 第四档「高度本身是等差数列」是结构受限的特例分支, 列在正解之后.
| 层次 | 适用约束 | 做法 | 复杂度 | 相对前层的推进 |
|---|---|---|---|---|
| 暴力枚举 | 01 序列递归枚举所有下标子集, 叶子处检查相邻高度差是否全等 | 时间 |
基线 | |
| 按公差建桶的 DP | 状态 dp[i][d] = 以 d + v 平移到下标 |
时间 |
用「结尾 + 公差」取代逐方案枚举, 把 |
|
| 按上一项位置建桶的 DP(正解) | 状态 f[i][j] = 最后两项是 2h[j] - h[i] |
时间 |
时间同阶. 用 |
「另外 20%: 所有高度构成等差数列」与上面三层互不包含: 它的
正式主解是第三层, 代码为 main.cpp(C++17)与 main.py(同算法 Python 实现).
暴力解法
适用范围
思路
一个方案就是一个下标子集, 所以直接枚举全部子集: 每座塔"留"或"不留"两个选择, 用 01 序列递归把
choose[i]记录第座塔的选择, 1 为留下, 0 为不留. dfs(dep)只决定第dep座塔的取值, 然后递归到下一层.dep == n + 1时方案完整, 调用check_and_count():
- 按顺序取出所有
choose[i] == 1的位置, 记项数; 不美观, 直接返回; 一定美观, 计数加一; 时取前两项高度差作为 , 逐对检查相邻高度差是否都等于 , 全部相等才计数.
把合法性检查放在叶子、而不在递归参数里提前剪枝, 是为了让"每层只做一个选择"这件事保持清楚.
代码
/**
* 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-26 13:45
* update_at: 2026-10-26 16:36
*/
// brute.cpp:小数据暴力解,用来帮助理解题意并辅助对拍。
// 用 01 序列递归枚举所有下标子集,到叶子再检查选出的高度是否构成等差数列。
// 复杂度 O(2^n * n),只适合 n <= 20 的数据。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 25; // 只能枚举到 n = 20 左右
int n;
int h[MAXN]; // h[i]: 第 i 座塔的高度, 下标从 1 开始
int choose[MAXN]; // choose[i]: 第 i 座塔留在地上为 1, 缩到地下为 0
ll ans; // 美观方案数
// 检查当前 choose[1..n] 选出的高度是不是等差数列, 是则把答案加一。
void check_and_count() {
int first = 0; // 选出的第一个下标
int second = 0;
int cnt = 0; // 选出了几座塔
for (int i = 1; i <= n; i++) {
if (choose[i] == 0) {
continue;
}
cnt++;
if (cnt == 1) {
first = i;
} else if (cnt == 2) {
second = i;
}
}
if (cnt == 0) {
return; // 一座都不留不算美观
}
if (cnt <= 2) {
ans++; // 只留一座或两座塔一定是等差数列
return;
}
// 长度 >= 3 时, 相邻高度差是否全部相等就是判定标准
int d = h[second] - h[first];
int last = second;
for (int i = second + 1; i <= n; i++) {
if (choose[i] == 0) {
continue;
}
if (h[i] - h[last] != d) {
return;
}
last = i;
}
ans++;
}
// 这一层决定第 dep 座塔留不留下: 0 不留, 1 留下。
void dfs(int dep) {
if (dep > n) {
check_and_count();
return;
}
for (int i = 0; i <= 1; i++) {
choose[dep] = i;
dfs(dep + 1);
}
}
int main() {
scanf("%d", &n);
for (int i = 1; i <= n; i++) {
scanf("%d", &h[i]);
}
dfs(1);
printf("%lld\n", ans);
return 0;
}复杂度与瓶颈
- 时间
: 个叶子, 每个叶子线性检查. 时约 次基本操作. - 空间
.
瓶颈很清楚: 方案数是
子任务解法: 按公差建桶的 DP
适用范围
思路
先看暴力里浪费在哪里. 枚举子集时, 判断"能不能接下一座塔"只需要知道两件事: 已选序列的最后一项位置, 以及当前公差. 加上这一个观察, 就能把"枚举方案"改成"数方案".
状态: dp[i][d] 表示以第
转移: 枚举倒数第二项的下标
右边的
长度 1: 每座塔单独都算一种方案, 答案初值取
负数公差: d + v 当第二维下标, 值域宽度为
用一个小例子 1 2 3 4(下标
| 枚举 |
算完后 |
||
|---|---|---|---|
| 1 | 1 | 无 | 空 |
| 2 | 2 | ||
| 3 | 3 | ||
| 4 | 4 |
观察重点有两个:
- 同一个公差
在相邻两行都被命中: 由 得到, 又由 得到. 这就是等差链在状态里"接长"的过程, 也是逐方案枚举完全忽略掉的那部分结构. - 第 4 行同时出现了三个不同的公差
, 说明一个结尾位置可以同时延续好几条不同的链; 状态必须带上公差才能把它们分开.
答案
代码
/**
* 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-26 13:45
* update_at: 2026-10-26 16:36
*/
// subtask_nv.cpp:按公差建桶的 DP,直接 dp[i][d] 记录公差 d,适用于 v <= 2e3 的 60% 档。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MOD = 998244353;
const int MAXN = 1005; // n <= 1000
const int MAXV = 2005; // 本题档位的高度上限 v <= 2e3, 负数公差靠偏移量 v 平移
ll n;
ll h[MAXN]; // h[i]: 第 i 座塔的高度, 下标从 1 开始
ll v; // v = 最高的塔
ll dp[MAXN][2 * MAXV]; // dp[i][d + v]: 以第 i 座塔结尾、公差为 d 且长度 >= 2 的等差子序列个数
void read_input() {
cin >> n;
for (ll i = 1; i <= n; i++) {
cin >> h[i];
v = max(v, h[i]);
}
}
void solve() {
ll ans = n; // 长度 1 的子序列一定等差
for (int i = 1; i <= n; i++) {
for (int j = 1; j < i; j++) {
ll d = h[i] - h[j]; // 公差可为负, 下标要加偏移 v
// 前一项是 h[j] 的等差子序列接上 h[i], 再加上只取 j, i 两项这一种
ll ways = dp[j][d + v] + 1;
dp[i][d + v] = (dp[i][d + v] + ways) % MOD;
ans = (ans + ways) % MOD;
}
}
cout << ans % MOD << '\n';
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
read_input();
solve();
return 0;
}复杂度与瓶颈
- 时间
: 枚举 的二元组, 每次 . - 空间
: 状态里带着公差维.
int, 可以接受. 但
问题出在状态设计: 公差
正解
关键观察
上式里"找公差为
固定中间项
于是"待查的东西"从一个公差值
思路
状态: f[i][j](
转移: 枚举倒数第三项的下标
右边的
为什么不会重复计数: 状态里写死了"最后两项是哪两个下标". 一个长度
长度 2 与长度 1 的处理: 长度 2 由初值
每个 只做一次分桶
照上式硬算, 每个
固定
- 建桶: 对所有
, 做 bucket[h_k] += f[j][k]. 代价. - 查询: 对所有
, 需要的是 bucket[2h_j - h_i], 一次取值就是答案, 再+1. 代价. - 清桶: 本轮写过的桶位置清零, 交给下一个
.
总时间
用同一个例子 1 2 3 4 走一遍. 表中每行是固定
| 建好的桶 |
查询 |
||
|---|---|---|---|
| 1 | 1 | 空 | |
| 2 | 2 | ||
| 3 | 3 | ||
| 4 | 4 | 无右侧项 |
观察重点有三个:
- 第
行: 桶里只有高度 (来自 ). 查询 需要的正好是 , 命中, 得 , 对应 与 两条链. - 第
行: 桶是 , 其中高度 下攒的 就是 . 查询 需要 , 命中, 得 , 即 、 、 . 长度 4 的方案只在这里出现. - 第
行: 桶是空的, 所以查到的 全是 . 时不存在倒数第三项, 只能是长度 2, 与结果一致.
need 为负的格子(如
答案
顺序不能换
建桶必须先于查询, 而且桶里只能装
判界
代码
/**
* 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-26 13:45
* update_at: 2026-10-26 16:36
*/
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MOD = 998244353;
const int MAXN = 1005; // n <= 1000
const int MAXH = 20000; // 电塔高度上限 v <= 2e4
ll n;
ll h[MAXN]; // h[i]: 第 i 座塔的高度, 下标从 1 开始
ll f[MAXN][MAXN]; // f[i][j] (i > j): 最后两项是第 j 座和第 i 座塔的等差子序列个数, 含长度 2
ll bucket[MAXH + 1]; // bucket[v]: 固定中间项 j 时, 左侧高度恰为 v 的所有 f[j][k] 之和
void read_input() {
cin >> n;
for (ll i = 1; i <= n; i++) {
cin >> h[i];
}
}
void solve() {
ll ans = n; // 只留一座塔的方案(长度 1)一定等差, 先把它们计入答案
for (int j = 1; j <= n; j++) {
// 把中间项 j 左侧所有 f[j][k] 按高度 k 所在的高度分桶, 后面按需要的高度直接取
for (int k = 1; k < j; k++) {
bucket[h[k]] = (bucket[h[k]] + f[j][k]) % MOD;
}
for (int i = j + 1; i <= n; i++) {
// 公差 d = h[i] - h[j], 所以 j 前面那一项的高度只能是 2*h[j] - h[i]
ll need = 2 * h[j] - h[i];
ll ways = 1; // 只保留第 j 座和第 i 座塔, 长度 2 也算一种方案
if (need >= 0 && need <= MAXH) {
ways += bucket[need];
}
ways %= MOD;
f[i][j] = ways;
ans = (ans + ways) % MOD;
}
for (int k = 1; k < j; k++) { // 清空本轮写过的桶, 交给下一个中间项使用
bucket[h[k]] = 0;
}
}
cout << ans % MOD << '\n';
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
read_input();
solve();
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-26 13:45
# update_at: 2026-10-26 13:45
import sys
MOD = 998244353
def count_arithmetic(heights: list[int]) -> int:
"""统计所有等差子序列(长度 1、2 也计入)。按中间项 j 分桶, 右侧查询 O(1)。"""
n = len(heights)
# f[i][j](i > j): 最后两项是 heights[j], heights[i] 的等差子序列个数, 含长度 2
f = [[0] * n for _ in range(n)]
ans = n # 长度 1 的子序列一定等差
for j in range(n):
# 固定中间项 j, 把左侧所有 f[j][k] 按高度分桶: 高度 -> 这些 f 之和
bucket: dict[int, int] = {}
for k in range(j):
v = heights[k]
bucket[v] = (bucket.get(v, 0) + f[j][k]) % MOD
middle = heights[j]
for i in range(j + 1, n):
# 公差 d = heights[i] - middle, 前一项高度必须是 2*middle - heights[i]
ways = bucket.get(2 * middle - heights[i], 0) + 1 # +1 是只取 j, i 两项
f[i][j] = ways % MOD
ans += ways
ans %= MOD
return ans % MOD
def solve() -> None:
data = iter(map(int, sys.stdin.buffer.read().split()))
n = next(data)
heights = [next(data) for _ in range(n)]
print(count_arithmetic(heights))
if __name__ == "__main__":
solve()复杂度
- 时间
: 每轮 的建桶 加查询 , 求和为 . - 空间
: f是的 int表,bucket是长度的计数数组.
与上一层的差别只在空间: 时间同为 f 是 int(约 4 MB), bucket 是 20001 个 long long(约 160 KB).
实测满规模(dict 代替定长桶, 峰值 27 MB, 在 3 倍时限口径(3000 ms)下最大 391 ms.
在
子任务解法: 所有高度构成等差数列(特例分支)
题面「另外 20%: 所有电塔的高度构成一个等差数列」这一档与上面三层互不包含, 它不靠缩小
适用范围
思路
- 若
, 所有高度相同, 任意非空子集都构成公差为 的等差数列, 答案是 . - 若
, 选出 座塔时, 相邻高度差都是 的整数倍, 而"倍数是否全相等"恰好等价于"下标差是否全相等". 于是合法的下标子集正好是下标 的等差数列. - 长度为
、下标公差为 的下标等差数列, 首项可以取 , 共 个.
这个闭式解与
代码
/**
* 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-26 13:45
* update_at: 2026-10-26 16:36
*/
// subtask_ap.cpp:题面「另外 20%:所有电塔高度本身构成等差数列」这一档的闭式解。
// 这一档不属于正解主线,只是利用「高度限制死了一个固定公差」把计数变成数下标等差数列。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MOD = 998244353;
const int MAXN = 1005; // n <= 1000
ll n;
ll h[MAXN]; // h[i]: 第 i 座塔的高度, 下标从 1 开始
ll pow_mod(ll base, ll exp) {
ll result = 1 % MOD;
base %= MOD;
while (exp > 0) {
if (exp & 1) {
result = result * base % MOD;
}
base = base * base % MOD;
exp >>= 1;
}
return result;
}
void read_input() {
cin >> n;
for (ll i = 1; i <= n; i++) {
cin >> h[i];
}
}
void solve() {
// 公差为 0: 高度全相同, 任意非空子集都等差
bool all_equal = true;
for (int i = 2; i <= n; i++) {
if (h[i] != h[1]) {
all_equal = false;
}
}
if (all_equal) {
cout << (pow_mod(2, n) - 1 + MOD) % MOD << '\n';
return;
}
// 公差不为 0: 选出的高度等差 <=> 选出的下标等差, 于是只需数 1..n 里的下标等差数列
ll ans = n; // 长度 1 的方案
for (ll q = 1; q <= n - 1; q++) { // q: 下标公差的绝对值
for (ll len = 2; (len - 1) * q <= n - 1; len++) { // len: 选出的项数
// 首项可取 1..n-(len-1)q, 每换一个首项就得到一个新的下标等差数列
ans = (ans + n - (len - 1) * q) % MOD;
}
}
cout << ans % MOD << '\n';
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
read_input();
solve();
return 0;
}复杂度与瓶颈
- 时间
(实际内层总次数是 , 按上界记 ). - 空间
.
这一层没有可推进的瓶颈: 它依赖「整体等差」这个额外条件, 条件一旦去掉就要回到主线. 它只说明题面这一档为什么能用完全不同的做法.
这一档已用
总结
- 题目的核心是把"枚举方案"改成"数方案". 固定公差后, 合法性只取决于相邻两项的高度差, 于是"结尾位置 + 公差"成为充分的状态.
- 两种等价的状态刻画都给出
的转移: 用公差做第二维要开 的桶, 空间挂上值域; 用「上一项的位置」做第二维, 再借 把公差换成一个确定的高度查询, 空间就只与 有关. - 每轮
把左侧状态按高度分桶一次, 是本题把 降到 的关键实现手段. 分桶的三个动作(建桶、查询、清桶)顺序固定, 且桶里只能装 的项. - 三个容易出错的细节: 长度 1 与长度 2 单独计; 负数公差在第一种写法里要平移下标;
可能为负或超界, 查桶前必须判界. - 「高度本身是等差数列」这一档是结构受限的特例分支, 有独立闭式解, 不与主线构成递进关系.