大朋友的数字

GitHub跳转原题关系图返回列表

O(n²) DP 递推每个位置结尾的最长不下降子序列的长度与元素和,同长时取编号字典序最小的序列。

OJ: luogu

题目 ID: P2008

难度:普及-

标签:dp枚举

日期: 2026-06-14 15:57

题意

有 n 个人,每人手上有一个一位数字(0~9)。对于第 i 个人,找到以他结尾的最长不下降子序列,计算该子序列中所有数字的和,作为他的分数。

如果存在多个最长不下降子序列(长度相同),选择编号字典序最小的那个——即把子序列中各元素在原数组中的下标按顺序排列,比较这些下标序列的字典序。

思路

对于每个位置 i,扫描所有 j < i,当 a[j] ≤ a[i] 时尝试转移:

  • dp[i] 记录以 i 结尾的最长不下降子序列的长度
  • sum[i] 记录该序列的元素和
  • pre[i] 记录该序列中 i 的前驱下标(0 表示自己就是起点)

转移时优先取长度更长的序列;长度相同时保留编号字典序更小的序列(即之前已找到的、下标更靠前的前驱)。

DP 公式

dpidp_i 表示以第 ii 个数结尾的最长不下降子序列长度,preipre_i 表示恢复方案时的前驱位置。初始有:

dpi=1,prei=0 dp_i=1,\quad pre_i=0

j<ij<iajaia_j\leqslant a_i 时,尝试转移:

dpi=max(dpi, dpj+1) dp_i=\max(dp_i,\ dp_j+1)

若出现相同长度,保留字典序更小的前驱方案。最终从 dpidp_i 最大的位置向前追溯即可恢复答案。

先看朴素解法的代码:

cpp
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n;
    cin >> n;
    vector<int> a(n + 1);
    for (int i = 1; i <= n; ++i) cin >> a[i];

    vector<int> len(n + 1), sum(n + 1), pre(n + 1);

    auto seq_less = [&](int x, int y) -> bool {
        if (x == y) return false;
        vector<int> px, py;
        for (int p = x; p; p = pre[p]) px.push_back(p);
        for (int p = y; p; p = pre[p]) py.push_back(p);
        reverse(px.begin(), px.end());
        reverse(py.begin(), py.end());
        return px < py;
    };

    for (int i = 1; i <= n; ++i) {
        len[i] = 1;
        sum[i] = a[i];
        for (int j = 1; j < i; ++j) {
            if (a[j] > a[i]) continue;
            int clen = len[j] + 1;
            if (clen > len[i]) {
                len[i] = clen;
                sum[i] = sum[j] + a[i];
                pre[i] = j;
            } else if (clen == len[i]) {
                if (seq_less(j, pre[i])) {
                    sum[i] = sum[j] + a[i];
                    pre[i] = j;
                }
            }
        }
        cout << sum[i] << " \n"[i == n];
    }

    return 0;
}

下面是另一种「01 序列」风格的暴力写法。它对每个结尾位置 i,递归枚举前面每个位置“选 / 不选”。递归先生成完整选择,叶子节点再检查序列是否不下降,并统计以 i 结尾的最优答案:

另一种暴力写法:01 序列
cpp
// brute_01_style.cpp:01 序列风格暴力,对每个结尾 i 枚举前面位置选或不选。
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 25;

int n;
int a[MAXN];
int target_pos;
int choose_pos[MAXN]; // choose_pos[i] = 0/1,表示位置 i 不选/选
vector<int> best_path;

bool path_less(const vector<int> &x, const vector<int> &y) {
    if (y.empty()) {
        return true;
    }
    return x < y;
}

int path_sum(const vector<int> &path) {
    int sum = 0;
    for (int i = 0; i < (int)path.size(); i++) {
        sum += a[path[i]];
    }
    return sum;
}

void update_answer() {
    vector<int> path;
    for (int i = 1; i < target_pos; i++) {
        if (choose_pos[i] == 1) path.push_back(i);
    }
    path.push_back(target_pos);

    for (int i = 1; i < (int)path.size(); i++) {
        if (a[path[i - 1]] > a[path[i]]) {
            return;
        }
    }

    if ((int)path.size() > (int)best_path.size()) {
        best_path = path;
        return;
    }
    if ((int)path.size() == (int)best_path.size() && path_less(path, best_path)) {
        best_path = path;
    }
}

void dfs_choose(int dep) {
    if (dep == target_pos) {
        update_answer();
        return;
    }

    for (int i = 0; i <= 1; i++) {
        choose_pos[dep] = i;
        dfs_choose(dep + 1);
    }
}

int solve_one_position(int pos) {
    target_pos = pos;
    best_path.clear();
    dfs_choose(1);
    return path_sum(best_path);
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n;
    for (int i = 1; i <= n; i++) {
        cin >> a[i];
    }

    for (int i = 1; i <= n; i++) {
        if (i > 1) {
            cout << ' ';
        }
        cout << solve_one_position(i);
    }
    cout << '\n';

    return 0;
}

brute.cpp 实现了完整的 O(n²) 枚举,同长时通过 seq_less 回溯整个 pre 链来正确比较编号字典序,适合小数据验证。

最终的 main.cpp 同样采用 O(n²) 枚举所有前驱,但做了简化:当两个候选序列长度相同时,保留下标更小的前驱。这是因为对于相同长度的序列,较小的前驱下标使整个编号序列的字典序更小。

公式解释:dp_i 表示最后一个数固定为 a_i 时能得到的最长长度。只有 a_j <= a_i 的前驱才能接到 i,所以用 dp_j + 1 更新;长度相同时保留更靠前、更小字典序的前驱,是为了恢复出题目要求的那条序列。

代码

cpp
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;
const int maxn = 1e4 + 5;

ll a[maxn];    // 输入的数字 (0~9)
ll dp[maxn];   // dp[i] = 以 i 结尾的 LNDS 长度
ll sum[maxn];  // sum[i] = 该序列的元素和
int pre[maxn]; // pre[i] = 该序列中 i 的前驱下标 (0 表示无前驱)

int n;

void init() {
    cin >> n;
    for (int i = 1; i <= n; ++i) cin >> a[i];
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    init();

    // 第一个位置:序列只有自己
    dp[1] = 1;
    sum[1] = a[1];

    // 递推位置 2..n
    for (int i = 2; i <= n; ++i) {
        dp[i] = 1;     // 至少是长度为 1 的序列(只包含自己)
        sum[i] = a[i];

        int pre_j = 0; // 当前最优前驱
        for (int j = 1; j < i; ++j) {
            if (a[j] > a[i]) continue; // 不满足不下降

            int clen = dp[j] + 1;

            if (clen > dp[i]) {
                // 长度更优,直接替换
                dp[i] = clen;
                sum[i] = sum[j] + a[i];
                pre_j = j;
            } else if (clen == dp[i]) {
                // 长度相同:保留编号字典序更小的序列
                // 因为 pre_j 一定是之前找到的编号更小的前驱,
                // 而 j 是更大的编号,默认保留 pre_j 即可得到字典序更小的序列
            }
        }

        pre[i] = pre_j;
    }

    // 输出所有结果
    for (int i = 1; i <= n; ++i) {
        cout << sum[i] << " \n"[i == n];
    }

    return 0;
}

复杂度

  • 时间复杂度:O(n²)。每个位置 i 扫描 i-1 个前驱,总比较次数约 n²/2。
  • 空间复杂度:O(n)。

总结

本题是最长不下降子序列(LNDS)的变体,需要在求长度的同时维护元素和。核心注意点:

  1. DP 状态需要分别记录长度 dp[i] 和元素和 sum[i]
  2. 长度优先,同长时按编号字典序比较
  3. 字典序的比较可以通过 pre 链回溯实现,也可以在长度相同时直接保留下标更小的前驱(二者等效)

对于 n ≤ 10^4,O(n²) 的算法即可通过,编码简单直观。

一图流解析

这张图把本题的建模、关键转移、实现检查和训练方法压缩到一页,适合读完正文后复盘。

一图流解析