倒序 DP 求出 g[i]=以 a[i] 开头的最长不下降子序列长度,再逐位贪心:每步在「值不小于已选末尾且 g 够长」的候选中取值最小者,得到字典序最小的最优方案。
OJ: roj
题目 ID: 1678
难度:普及+/提高-
标签:动态规划最长上升子序列线性DP贪心构造python
创建: 2026-10-01 01:50
更新: 2026-10-07 13:50
形式化题目
给定长度为
记
注意比较的是值序列而不是下标序列。样例 1 的序列是 10 11 12 13 1 2 3 4,两条长度 4 的链分别为 10 11 12 13 与 1 2 3 4,答案是后者。
正解
思路
朴素做法不行。 最直接的想法是枚举全部
先拆目标。 题目其实问了两个问题:最长有多长(唯一答案 10 11 12 13 与 1 2 3 4 长度都是 4,后者字典序更小,所以答案是后者;而这里能直接比较的前提是已经知道
把 LIS 的方向翻过来。 常见的 LIS 状态是"以
右边取不到元素时
用潜力值刻画"能不能补齐"。 假设已经选好了链的末尾(值为
- 不下降要求
且 ; - 更关键的是
:选 之后最多还能接 个元素,而后面还需要 个( 本身算一个),所以 必须够长。
反过来,只要
这就是第一步必须先用 DP 求出
逐位贪心。 既然每一步的可行集都算得出来,字典序最小就顺理成章:维护(起点 start = 上一个选中下标 lower = 已选末尾值、还差 need 个),每一步在
中选 start = j + 1、lower = a_j、need -= 1,重复
为什么这样得到的是字典序最小?对已选位置数
归纳时取这样一个 min 在键并列时保留最先出现的元素),于是
归纳到头,对任意最优解
注意比较键必须是值而不是下标:样例 3 中第一步下标 1(值 2)与下标 4(值 1)都满足 1 4 6。
用样例 3 走一遍。 序列
先倒序(从右往左)算 DP。表中"可接下标"是满足
| 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | |
|---|---|---|---|---|---|---|---|---|
| 2 | 6 | 8 | 1 | 5 | 7 | 4 | 6 | |
| 可接下标 |
2,3,5,6,7,8 | 3,6,8 | — | 5,6,7,8 | 6,8 | — | 8 | — |
| 3 | 2 | 1 | 3 | 2 | 1 | 2 | 1 |
从右往左读这张表。
再逐位贪心,初值 start = 1、lower = 无、need = 3:
还差 need |
起点 start |
下界 lower |
可行下标 | 对应值 | 选中 |
|---|---|---|---|---|---|
| 3 | 1 | 无 | 1, 4 | 2, 1 | |
| 2 | 5 | 1 | 5, 7 | 5, 4 | |
| 1 | 8 | 4 | 8 | 6 |
第一步两个候选的 need 变成 2,起点挪到 5、下界变成 1。第二步候选是下标 5(值 5,2 4 6,字典序更大。第三步只剩下标 8 可选,得 1 4 6,与样例输出一致。
代码
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 01:50
# update_at: 2026-10-01 02:01
import sys
def suffix_lengths(seq: list[int]) -> list[int]:
"""g[i] = 以 seq[i] 开头的最长不下降子序列长度 = 1 + max{g[j] : j > i 且 seq[j] >= seq[i]}。"""
g = [1] * len(seq)
for i in range(len(seq) - 1, -1, -1): # 倒着算,用到的 g[j] 都已就绪
g[i] = 1 + max((g[j] for j in range(i + 1, len(seq)) if seq[j] >= seq[i]), default=0)
return g
def lex_smallest(seq: list[int], g: list[int]) -> list[int]:
"""在全部最长不下降子序列中挑出字典序最小的那一条。
每一步在候选下标里取值最小的元素:候选 = 下标在当前之后、值不小于已选末尾、
且以它开头的 g 值还够长(g[j] >= 剩余个数)。够长保证后面一定能补齐,
取最小保证本位最小;并列时 min 保留最靠前的下标,留给后面的选择最多。
"""
picked: list[int] = []
start = 0 # 下一个元素只能从 start 及之后取
lower: int | None = None # 已选末尾的值;None 表示还没选过,不受下界限制
need = max(g) # 还差几个元素才够最长
while need:
j = min(
(j for j in range(start, len(seq))
if g[j] >= need and (lower is None or seq[j] >= lower)),
key=seq.__getitem__,
)
picked.append(seq[j])
start, lower, need = j + 1, seq[j], need - 1
return picked
def solve() -> None:
data = iter(map(int, sys.stdin.buffer.read().split()))
n = next(data) # 题面的序列长度
seq = [next(data) for _ in range(n)]
g = suffix_lengths(seq)
ans = lex_smallest(seq, g)
print(len(ans))
print(*ans)
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-10-06 01:45
* update_at: 2026-10-06 01:45
*/
#include <cstdio>
typedef long long ll;
const int MAXN = 305;
ll a[MAXN]; // 输入序列,下标从 1 开始
ll g[MAXN]; // g[i] = 以 a[i] 开头的最长不下降子序列长度(潜力值:从 i 出发还能延伸多远)
ll ans[MAXN]; // 答案序列(按顺序保存选出的值)
ll n; // 序列长度
// 阶段 1:倒序 DP 求 g[i] = 1 + max{ g[j] : j > i 且 a[j] >= a[i] }
// 倒着枚举 i,用到的 g[j] 都已算好
void solve_dp() {
for (ll i = n; i >= 1; --i) {
g[i] = 1; // 右边接不上任何元素时,a[i] 自己成链
for (ll j = i + 1; j <= n; ++j)
if (a[j] >= a[i] && g[j] + 1 > g[i])
g[i] = g[j] + 1;
}
}
// 阶段 2:逐位贪心构造字典序最小的最长不下降子序列
// 每一步在 { j > 上一个选中下标 : g[j] >= 还差个数 且 a[j] >= 已选末尾值 }
// 中取 a[j] 最小者;g[j] >= need 用潜力值保证后面一定补得满,
// 值并列时取最靠前的下标,留给后面的选择最多。
void solve_greedy() {
ll need = 0; // 还差几个元素才够最长
for (ll i = 1; i <= n; ++i)
if (g[i] > need)
need = g[i]; // need 初值 = 最长长度 L = max g[i]
ll start = 1; // 下一个元素只能从 start 及之后取
ll lower = 0; // 已选末尾的值;还没选时不设下界(见 lower_used)
bool lower_used = false;
ll cnt = 0; // 已选个数
while (need > 0) {
ll best = 0; // 选中的下标,0 表示这一步还没找到候选
for (ll j = start; j <= n; ++j) {
if (g[j] < need) continue; // 潜力不够,后面凑不满
if (lower_used && a[j] < lower) continue; // 不满足不下降
// 比较“值”而不是下标;值并列取最靠前的 j(先遇到的不被覆盖)
if (best == 0 || a[j] < a[best])
best = j;
}
cnt = cnt + 1;
ans[cnt] = a[best];
start = best + 1; // 下标严格递增
lower = a[best];
lower_used = true;
need = need - 1;
}
printf("%lld\n", cnt);
for (ll i = 1; i <= cnt; ++i)
printf("%lld%c", ans[i], i == cnt ? '\n' : ' ');
}
int main() {
scanf("%lld", &n);
for (ll i = 1; i <= n; ++i)
scanf("%lld", &a[i]);
solve_dp();
solve_greedy();
return 0;
}suffix_lengths 是阶段 ①,default=0 处理"右边接不上"的情形;lex_smallest 是阶段 ②,候选条件与上面的两个不等式逐字对应,key=seq.__getitem__ 指明按值比较(键并列时 min 返回最先遇到的下标,即最靠前的那个)。solve 只负责读入、调用、输出。
复杂度
- 时间复杂度:
。倒序 DP 对每个 扫描右侧,共约 次比较;构造阶段最多 步、每步 。 时总计约 次比较,实测单组数据 0.017 s。 - 空间复杂度:
。序列、 表与答案列表各占 。
总结
本题的两个目标必须分开处理:长度最大是一次数值 DP,字典序最小是一次逐位构造。
关键一步是把 LIS 的状态从"以
顺着这个思路可以推广:任何"先定最优值、再求字典序最小方案"的题目,都可以先 DP 出每个位置的潜力,再逐位在可行集里取最小。当
图示解析
这张图串起本题从建模到得到答案的主线:
输入:n 个整数 a[1..n]
`- 目标:在所有最长不下降子序列里取字典序最小
|- 阶段 1:倒序 DP 求潜力值
| `- g[i] = 以 a[i] 开头的最长不下降子序列长度 (O(n^2))
`- 阶段 2:逐位贪心构造
|- 还差 c 个元素、已选末尾值 v、起点 p
|- 候选 = { j >= p : g[j] >= c 且 a[j] >= v }
| `- g[j] >= c 用"潜力"保证后面一定填得满
`- 在候选里取 a[j] 最小者,重复 c = L 次
`- 答案:长度为 L 的值序列,字典序最小先看"目标"这一层:长度最大与字典序最小是两个目标,必须按顺序拆开,这正是两个阶段的来源。再看阶段 2 的候选条件: