NeFut Logo NeFut
EN 管理员登录

深入解析最长上升子序列算法及其优化策略

发布于:2026-05-27 09:17 最后更新:2026-06-06 13:04
#algorithm #optimization #Dynamic Programming

核心逻辑与数学原理

最长上升子序列(Longest Increasing Subsequence, LIS)是单序列线性动态规划的重要基础。给定一个长度为 $N$ 的序列 $A$,需要从中选出一个子序列 $A_{p_1}, A_{p_2}, ext{...}, A_{p_k}$,使得下标满足 $p_1 < p_2 < ext{...} < p_k$ 且数值严格单调递增,即 $A_{p_1} < A_{p_2} < ext{...} < A_{p_k}$,求 $k$ 的最大值。

  1. $O(N^2)$ 的朴素状态空间: 每个位置 $i$ 的最优解仅依赖于在其之前且数值比它小的位置 $j$。通过枚举前驱进行状态转移,由于每个状态需要扫描 $O(N)$ 个前驱,总复杂度为 $O(N^2)$。

  2. $O(N \log N)$ 的二分优化(贪心降维): 朴素算法的瓶颈在于:为了找到最大值,遍历了大量冗余的前驱状态。引入贪心策略:若两个上升子序列长度相同,末尾元素越小的那个,后续承接新元素的能力越强(潜力更大)。因此,定义一个辅助数组 $D$,其中 $D[len]$ 表示当前所有长度为 $len$ 的上升子序列中,末尾元素的最小值

  3. 数学单调性证明: 辅助数组 $D$ 具有严格的单调递增性,即 $D[1] < D[2] < ext{...} < D[len]$。 证明简述:若存在 $D[i] \ge D[i+1]$,根据定义,长度为 $i+1$ 的子序列倒数第二项必严格小于最后一项 $D[i+1]$,则可截取其前 $i$ 项构成一个末尾元素更小的长度为 $i$ 的子序列,其末尾元素必定小于 $D[i+1] \le D[i]$,这与 $D[i]$ 是长度为 $i$ 的子序列末尾最小值的定义矛盾。利用 $D$ 数组的严格单调性,对于每个新加入的元素 $A[i]$,无需遍历前驱,可直接通过二分查找(Binary Search)在 $O(\log N)$ 时间内定位并更新 $D$ 数组,将全局复杂度降至 $O(N \log N)$。


状态设计与算法推导

1. $O(N^2)$ 朴素算法

$$f[i] = \max_{1 \le j < i, A[j] < A[i]} \{ f[j] \} + 1$$

边界条件为 $f[i] = 1$(自身独立成链)。

2. $O(N \log N)$ 贪心变维


C++ 标准源码(NOIP风格)

以下源码在一个程序中展示了 $O(N^2)$ 与 $O(N \log N)$ 两种算法的实现,重点演示二分优化的工程规范,通过 Linux g++ -O2 编译验证。

#include <iostream>
#include <vector>
#include <algorithm>

using namespace std;

const int MAXN = 100005;
int a[MAXN];
int f[MAXN]; // 用于 O(N^2) 的状态数组
int d[MAXN]; // 用于 O(N log N) 的贪心增量数组

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    if (!(cin >> n)) return 0;

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

    // ==================== 1. O(N^2) 朴素算法 ====================
    int ans_n2 = 0;
    for (int i = 1; i <= n; ++i) {
        f[i] = 1;
        for (int j = 1; j < i; ++j) {
            if (a[j] < a[i]) { // 严格上升条件
                f[i] = max(f[i], f[j] + 1);
            }
        }
        ans_n2 = max(ans_n2, f[i]);
    }

    // ==================== 2. O(N log N) 二分优化 ====================
    int g_len = 0;
    // 致命踩坑点:d 数组存储的是数值,不是下标,且必须保持严格单调性
    for (int i = 1; i <= n; ++i) {
        if (g_len == 0 || a[i] > d[g_len]) {
            // 情况 A:比当前所有能构成的最长上升子序列的末尾还要大,直接接在后面
            g_len++;
            d[g_len] = a[i];
        } else {
            // 情况 B:二分查找到第一个大于等于 a[i] 的元素位置并替换它
            // d + 1 到 d + g_len + 1 是有效的一维检索区间
            int p = lower_bound(d + 1, d + g_len + 1, a[i]) - d;
            d[p] = a[i]; // 贪心修正:用更小的末尾元素替换旧的较大元素
        }
    }

    cout << "O(N^2) Ans: " << ans_n2 << "\n";
    cout << "O(N log N) Ans: " << g_len << "\n";

    return 0;
}

NOIP 实战避坑指南

1. 严格上升与非严格上升的二分函数混淆

2. 误以为 $D$ 数组存储的是最终的最长上升子序列


经典 NOIP/洛谷 真题

1. 洛谷 P1020 [NOIP1999 普及组] 导弹拦截

2. 洛谷 P1439 【模板】最长公共子序列

原文链接: local://14.1

[h] 返回首页