动态规划求解最长严格下降子序列(LDS),并通过倒序扫描结合哈希集合跳过相同前驱数值实现不同方案的去重计数。
OJ: roj
题目 ID: 2069
难度:普及+/提高-
标签:动态规划最长下降子序列计数DP去重
创建: 2026-10-01 06:07
更新: 2026-10-07 13:50
形式化题目
给定一个长度为
求:
- 序列
的最长严格下降子序列(Strictly Decreasing Subsequence)的长度 。 - 长度达到
的严格下降子序列的方案数。其中,两个方案被视为相同当且仅当它们所包含的数值序列完全相同。需要输出去重后的方案数。
正解
思路
1. 问题拆解与长度计算
若不考虑去重,求最长严格下降子序列(LDS)是经典动态规划:
- 设
表示以第 个数 结尾的最长严格下降子序列长度。 - 状态转移为:
2. 去重方案数计数的关键观察
难点在于第二问:如果两个方案构成的数值序列相同,只能计算一次。
例如,对于下标
- 以
结尾的合法子序列的形式为 ,以 结尾的形式为 。因为 ,它们作为前驱拼接到 之后时,倒数第二个数字的数值完全相同。 - 由于
,任何能在 前面接上的合法前驱,其下标必然也位于 之前。因此以 为结尾的不同下降子序列集合,完整包含了以 为结尾的集合。 - 如果同时从
和 转移至 ,就会产生重复的数值子序列。
核心去重策略:
对于相同的前驱数值
具体操作:
- 当确定了
后,前驱必须满足 且 。 - 我们让
从 开始倒序遍历到 。 - 用一个集合(或标记数组)记录已经访问过的前驱数值。
- 当首次遇到数值为
的前驱时,将其方案数累加至当前位置,并记录该数值已访问;之后再遇到相同的数值则直接跳过。
3. 哨兵技巧简化全序列统计
在原序列最后添加一个极小值元素
- 以
结尾的最长下降子序列必然汇集了前面所有的最长序列。 - 其长度恰好为
,去重后的方案数恰好为全序列的最终方案数。 - 这样只需一套统一的双层循环逻辑,免去最后对全序列结尾元素的单独去重统计。
状态推导与图示
以样例
| 下标 |
1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13(哨兵) |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 股价 |
68 | 69 | 54 | 64 | 68 | 64 | 70 | 67 | 78 | 62 | 98 | 87 | 0 |
| 最长长度 |
1 | 1 | 2 | 2 | 2 | 3 | 1 | 3 | 1 | 4 | 1 | 2 | 5 |
| 去重方案数 |
1 | 1 | 2 | 2 | 1 | 1 | 1 | 1 | 1 | 2 | 1 | 1 | 2 |
在上表中:
- 当计算第 6 天(股价 64,
)时,前驱目标长度为 2。可供转移的位置有第 4 天(股价 64)和第 5 天(股价 68),前驱数值不同,均被计入。 - 当计算第 10 天(股价 62,
)时,前驱目标长度为 3,合格前驱有第 6 天(股价 64)和第 8 天(股价 67),累加方案数 。 - 最终哨兵节点
的 ,对应的去重方案数为 。还原实际结果为:最长长度 ,总方案数 。
代码
Python 版:
python
#!/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-01 06:11
# update_at: 2026-10-01 06:11
import sys
def find_lds_with_unique_ways(prices: list[int]) -> tuple[int, int]:
"""计算最长严格下降子序列的长度以及数值去重后的最长方案数。"""
# 追加 0 作为末尾哨兵,汇聚所有最长下降子序列终点
extended = prices + [0]
total_len = len(extended)
lengths = [1] * total_len
ways = [1] * total_len
for i in range(total_len):
cur_price = extended[i]
best_len = 1
for j in range(i):
if extended[j] > cur_price and lengths[j] + 1 > best_len:
best_len = lengths[j] + 1
lengths[i] = best_len
if best_len == 1:
ways[i] = 1
else:
total_ways = 0
visited: set[int] = set()
target_len = best_len - 1
# 倒序遍历取同数值的最靠后位置,避免前驱数值相同的子序列重复累加
for j in range(i - 1, -1, -1):
prev_price = extended[j]
if prev_price > cur_price and lengths[j] == target_len and prev_price not in visited:
visited.add(prev_price)
total_ways += ways[j]
ways[i] = total_ways
return lengths[-1] - 1, ways[-1]
def solve() -> None:
data = iter(map(int, sys.stdin.buffer.read().split()))
n = next(data)
prices = [next(data) for _ in range(n)]
max_len, total_schemes = find_lds_with_unique_ways(prices)
print(f"{max_len} {total_schemes}")
if __name__ == "__main__":
solve()C++ 版(同一算法):
cpp
/**
* 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-06 11:13
* update_at: 2026-10-06 11:13
*/
#include <cstdio>
#include <set>
#include <vector>
using namespace std;
typedef long long ll;
const int MAXN = 5005; // N 最大 5000,再加末尾哨兵
const int BASE = 1000000000; // 大整数压 9 位十进制,方案数可能超过 64 位
// 非负大整数,digits 低位在前,digits[0] 存放最低 9 位
struct BigInt {
vector<int> digits;
};
ll a[MAXN]; // a[i] 为第 i 天股价,a[n+1] 是补上的哨兵 0
ll f[MAXN]; // f[i] 为以第 i 天结尾的最长严格下降子序列长度
BigInt g[MAXN]; // g[i] 为以第 i 天结尾且长度取到 f[i] 的去重方案数
// 大整数加法:x += y
void big_add(BigInt &x, const BigInt &y) {
int carry = 0;
for (int i = 0; i < (int)y.digits.size() || carry > 0; i++) {
if (i == (int)x.digits.size()) {
x.digits.push_back(0);
}
ll cur = x.digits[i] + carry;
if (i < (int)y.digits.size()) {
cur += y.digits[i];
}
x.digits[i] = cur % BASE;
carry = cur / BASE;
}
}
// 输出大整数,最高位不带前导零,其余每组补足 9 位
void big_print(const BigInt &x) {
if (x.digits.empty()) {
printf("0");
return;
}
printf("%d", x.digits.back());
for (int i = (int)x.digits.size() - 2; i >= 0; i--) {
printf("%09d", x.digits[i]);
}
}
int main() {
ll n;
if (scanf("%lld", &n) != 1) {
return 0;
}
for (int i = 1; i <= n; i++) {
scanf("%lld", &a[i]);
}
a[n + 1] = 0; // 哨兵:0 比所有正整数股价都小,汇聚全部最长下降子序列
int total = n + 1;
for (int i = 1; i <= total; i++) {
f[i] = 1;
for (int j = 1; j < i; j++) {
if (a[j] > a[i] && f[j] + 1 > f[i]) {
f[i] = f[j] + 1;
}
}
if (f[i] == 1) {
g[i].digits.clear();
g[i].digits.push_back(1);
continue;
}
// 从后往前找长度恰为 f[i]-1 的前驱;同数值只取最靠后的位置,避免数值序列重复
g[i].digits.clear();
set<ll> seen;
for (int j = i - 1; j >= 1; j--) {
if (a[j] > a[i] && f[j] == f[i] - 1 && seen.find(a[j]) == seen.end()) {
seen.insert(a[j]);
big_add(g[i], g[j]);
}
}
}
printf("%lld ", f[total] - 1);
big_print(g[total]);
printf("\n");
return 0;
}复杂度
- 时间复杂度:
。外层循环遍历 个位置,内层在找最大长度与倒序求和时各遍历最多 次。单次哈希集合插入与查找平均为 。在 下总运算次数约为 ,运行时间约为 0.5s,完全在时限之内。 - 空间复杂度:
。用于存储数组及局部去重哈希集合。
总结
- 数值去重 vs 下标去重:本题的关键在于识别到相同的数值前驱在不同位置产生的信息包含关系,通过“优先选择靠后下标”这一贪心性质,完美转化了复杂的去重子集计算。
- 末尾哨兵统一收敛:通过在序列末尾补
,把寻找全局最大值及其去重统计的问题直接归约到单点的转移过程中,保证了代码实现的简洁与严谨。