跳到主要内容
极客日志极客日志
首页博客AI提示词GitHub精选代理工具
搜索
|注册
博客列表
C++算法

线性 DP 五大经典模型:LIS、LCS、合唱队形、编辑距离详解与模板

综述由AI生成线性动态规划涵盖最长上升子序列(LIS)、最长公共子序列(LCS)等经典模型。文章详解了基础 LIS 的 O(n^2) 解法及贪心 + 二分优化的 O(n log n) 解法,并扩展至合唱队形、字符串编辑距离等问题。通过状态定义、转移方程推导及代码实现,提供了完整的 C++ 模板与解题思路,适用于算法竞赛及面试准备。

并发大师发布于 2026/3/21更新于 2026/5/76 浏览
线性 DP 五大经典模型:LIS、LCS、合唱队形、编辑距离详解与模板

最长上升子序列

题目描述

在这里插入图片描述

题目解析

本题介绍最长上升子序列的一般解法,当数据量不大时用这种解法。 在此之前,先区分一下子数组和子序列,子数组需要是连续的,而子序列可以是间断的。

  1. 状态表示 dp[i] 表示以 i 结尾的所有子序列中,最长的上升子序列长度。

  2. 状态转移方程 分两种情况推导: 一、当序列长度为 1 时,因为 dp[i] 自己本身就是一个子序列,所以 dp[i] 的最小取值可能是 1(比如总序列是一个递减序列的话,那么每个 dp[i] 的取值都会是 1)。 二、当序列长度大于 1 时,如果第 i 个格子前面的所有格子中有比第 i 个格子小的格子,记为 j,那么第 i 个格子就可以挂在 j 格子后面组成一个序列,序列长度就是 dp[j] + 1。因为我们要求最长上升子序列,所以需要遍历 dp 数组中从 1 到 i-1 的所有值,找出所有 a[j] 小于 a[i] 的格子,从中选出 dp[j] + 1 的最大值,该值为 dp[i] 的取值。

在这里插入图片描述

所以状态转移方程如下图所示:

在这里插入图片描述

  1. 初始化 因为我们没遍历到一个格子都会先把 dp[i] 置为 1,所以 dp 数组不用初始化,用默认值 0 即可。

  2. 填表顺序 从左往右。

  3. 输出结果 结果为 dp 数组从 1 到 n 的所有取值的最大值。

代码

#include <iostream>
#include <algorithm>
using namespace std;

const int N = 5010;
int n;
int a[N], dp[N];

int main() {
    // 处理输入
    cin >> n;
    for (int i = 1; i <= n; i++) {
        cin >> a[i];
    }

    // 按序填表
    int ret = 0;
    for (int i = 1; i <= n; i++) {
        dp[i] = 1;
        for (int j = 1; j < i; j++) {
            // 选出 dp 数组中 1 到 i-1 所有格子的最大值,把最大值加一赋给 dp[i]
            if (a[j] < a[i]) {
                dp[i] = max(dp[i], dp[j] + 1);
            }
        }
        ret = max(ret, dp[i]);
    }
    cout << ret << endl;
    return 0;
}

【模板】最长上升子序列

题目描述

在这里插入图片描述

题目解析

本题介绍最长上升子序列的推广解法,当数据量较大时用这种解法。 这里会利用贪心加二分优化时间复杂度,但是需要注意,这种方式是有局限性的,它只能求出最长上升子序列的长度,而不能还原出整个最长上升子序列。

在研究最长上升子序列时,其实只用关心序列的长度和序列最后一个元素是谁,我们并不关心序列具体长什么样子,本题是求解思路就是基于此实现的。

  1. 状态表示 对于原序列来说,我们先找出所有长度刚好是 i 的上升子序列(比如 i=3 时,就是所有长度为 3 的上升子序列),然后把这些子序列的最后一个数都列出来,在这些数里找最小的那个,这个最小的数就是 dp[i]。 这里用最小值充当 dp[i] 是用到了贪心的思想:要让序列尽可能长,就要让序列末尾元素尽可能小,让序列后面能挂更多元素。

  2. 状态转移方程 除了 dp 数组,还需要用一个全局变量 len 来存储最长上升子序列的长度。 填 dp 数组时我们要遍历整个 a 数组,首先处理边界情况,当 len 为 0(序列为空)或者 a[i] 比 dp[len] 大(a[i] 比最长序列的末尾元素还大)时,直接把 len++,然后把 a[i] 赋值给 dp[len]。否则就是一般情况,也就是序列本身已经有元素了,并且 a[i] 的值小于最长序列的末尾元素,这时我们要更新 dp 数组,但是 len 保持不变,更新策略是在 dp 数组中二分查找所有大于等于 a[i] 的元素中,其中的最小值所在的位置,然后把 a[i] 的值赋给该位置。(本质就是维护 dp 数组的特性:dp[i] 为长度为 i 的所有子序列末尾元素的最小值)

  3. 初始化 无。

  4. 填表顺序 从左往右。

  5. 输出结果 len。

代码

#include <iostream>
using namespace std;

const int N = 1e5 + 10;
int n;
int a[N], dp[N];
int len; // 记录最长上升子序列长度

int main() {
    // 处理输入
    cin >> n;
    for (int i = 1; i <= n; i++) {
        cin >> a[i];
    }

    // 遍历原数组并填 dp 数组
    for (int i = 1; i <= n; i++) {
        if (len == 0 || a[i] > dp[len]) {
            // 边界情况,len 和 a[i] 大于最长合法序列的末尾元素时
            // 直接把 a[i] 放在 dp 数组 ++len 处
            dp[++len] = a[i];
        } else {
            // 在 dp 数组中二分查找大于等于 a[i] 的最小值
            int l = 1, r = len;
            while (l < r) {
                int mid = (l + r) / 2;
                if (dp[mid] >= a[i]) {
                    // 答案在左半部分,包含 mid
                    r = mid;
                } else {
                    l = mid + 1;
                }
            }
            // 把 a[i] 赋值给查找到的 dp 数组中大于等于 a[i] 的最小值
            dp[l] = a[i];
        }
    }
    cout << len;
    return 0;
}

合唱队形

题目描述

在这里插入图片描述

题目解析

本题是最长上升子序列问题的一个模板题,比较简单。 依据题意,要找一个最长的先递增在递减的子序列,要让该子序列最长,就要分别让递增序列和递减序列都尽可能长,最终答案就是遍历所有同学找到一个同学 i,以他为顶峰的递增序列 + 递减序列长度是最大的,但是需要注意同学 i 既在递增序列中也在递减序列中,相当于递增序列 + 递减序列把同学 i 算了两遍,所以最终答案要减一。

所以我们要对每一个同学求他的最长的递增子序列和最长递减的子序列,求最长递减的子序列也就相当于求从右往左的最长递增子序列,根据求最长的递增子序列的通用方法,我们知道 dp[i] 就表示以 i 结尾的所有子序列中,最长的上升子序列。本题我们要开两个 dp 数组,分别维护从左往右的最长递增子序列和从右往左的最长递增子序列。

代码

#include <iostream>
#include <algorithm>
using namespace std;

const int N = 110;
// dp1 维护从左往右的最长上升子序列
// dp2 维护从右往左的最长上升子序列
int a[N], dp1[N], dp2[N];
int n;

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

    // 初始化
    // 按序填表
    // 1. 填 dp1 数组
    for (int i = 1; i <= n; i++) {
        dp1[i] = 1;
        for (int j = 1; j < i; j++) {
            if (a[j] < a[i]) dp1[i] = max(dp1[i], dp1[j] + 1);
        }
    }

    // 2. 填 dp2 数组
    for (int i = n; i >= 1; i--) {
        dp2[i] = 1;
        for (int j = n; j > i; j--) {
            if (a[j] < a[i]) dp2[i] = max(dp2[i], dp2[j] + 1);
        }
    }

    // 输出结果
    int ret = 0;
    for (int i = 1; i <= n; i++) {
        // 因为中间 i 的同学算了两遍,所以需要减一
        ret = max(ret, dp1[i] + dp2[i] - 1);
    }
    cout << n - ret;
    return 0;
}

牛可乐和最长公共子序列

题目描述

在这里插入图片描述

题目解析

痛点:存储字符串问题!用 string 存储下标从 0 开始! 解决方案:

  1. 修改下标映射:i -> i - 1, j -> j - 1
  2. s = " " + s; t = " " + t;

本题不仅会讲题解,还会介绍一下如何分析两个数组的 dp 问题。

  1. 初始化 用一个二维数组 dp[i][j],表示 s 字符串 1 到 i 区间内和 t 字符串 1 到 j 区间内,所有子序列中,最长公共子序列的长度。

  2. 状态转移方程 状态转移方程要分两种情况讨论:

  3. 当遍历到两个字符串相同的元素时(s[i] == t[j]),表示这个相同的元素的最长公共子序列的元素之一,要把这个这一对相同的元素选上,也就是把当前公共子序列长度在之前的基础上加 1,那要如何找到之前的公共子序列长度呢?其实之前的公共子序列长度就是 s 字符串 1 到 i - 1 区间内和 t 字符串 1 到 j 区间内的最长公共子序列的长度,也就是 dp[i - 1][j - 1],所以这种情况的状态转移方程: dp[i][j] = dp[i - 1][j - 1] + 1

  4. 当遍历到两个字符串不同的元素时(s[i] != t[j]),一定不会把这一对不同的元素选到,这时为了填 dp[i][j] 就需要退而求其次,求在不看 s[i] 的情况下的最长公共子序列、在不看 t[j] 的情况下的最长公共子序列和求在不看 s[i] 并且不看 t[j] 的情况下的最长公共子序列,也就是分三种情况讨论: 在 s 数组的 1 到 i 区间和 t 数组的 1 到 j - 1 区间找最长公共子序列 在 s 数组的 1 到 i - 1 区间和 t 数组的 1 到 j 区间找最长公共子序列 在 s 数组的 1 到 i - 1 区间和 t 数组的 1 到 j-1 区间找最长公共子序列 dp[i][j] 就是在这三种情况中取最值,但对于这种三种情况中取最值的逻辑,因为情况 3 是包含在情况 1、2 中的,所以情况 3 的方案一定小于等于情况 1、2,所以在前两种情况中取最值即可。 dp[i][j] = max(dp[i][j - 1], dp[i - 1][j])

在这里插入图片描述

  1. 初始化 全部为 0 即可,因为本题的长度必定大于 0,0 不会干扰 max 取最大值的逻辑。 本题虽然是多组测试用例,但是填表是覆盖并且有顺序的,所以每组用例不用重置。

  2. 填表顺序 根据状态转移方程,填 [i][j] 时会用到左、上、和左上三个位置的格子,所以填表顺序是从上往下、从左往右。

在这里插入图片描述

  1. 输出结果 假设 s 字符串长度为 n,t 字符串长度为 m: dp[n][m]

代码

#include <iostream>
#include <string>
#include <algorithm>
using namespace std;

const int N = 5010;
int dp[N][N];
string s, t;

int main() {
    while (cin >> s >> t) {
        int n = s.size();
        int m = t.size();

        // 初始化
        // 按序填表
        for (int i = 1; i <= n; i++) {
            for (int j = 1; j <= m; j++) {
                if (s[i - 1] == t[j - 1]) {
                    dp[i][j] = dp[i - 1][j - 1] + 1;
                } else {
                    dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
                }
            }
        }

        // 输出结果
        cout << dp[n][m] << endl;
    }
    return 0;
}

编辑距离

题目描述

在这里插入图片描述

题目解析

  1. 状态表示 本题思路继续延续上一题的双数组 dp 问题,状态表示也类似:dp[i][j] 表示把 s 字符串的 1 到 i 子字符串变成 t 字符串的 1 到 j 子字符串的最少操作次数。

  2. 状态转移方程 还是和上一题类似分两种情况讨论:

  3. 当遍历到两个字符串相同的元素时(s[i] == t[j]),说明此时不需要修改 s 数组最后一个元素,也就是对于 dp[i][j] 来说不需要将操作数加一,仅需要拿到把 s 字符串的 1 到 i - 1 子字符串变成 t 字符串的 1 到 j - 1 子字符串的最少操作次数即可,拿到的该最少操作次数为 dp[i][j] 的取值。

  4. 当遍历到两个字符串不同的元素时(s[i] != t[j]),说明此时需要修改 s 数组最后一个元素,因为题目给出了三种修改方式,所以这里我们要分三种情况讨论:

    1. 删除 s 字符串最后一个元素,然后把 s 字符串的 1 到 i - 1 子字符串变为 t 字符串,把 s 字符串的 1 到 i - 1 子字符串变为 t 字符串的最少操作次数在 dp[i - 1][j] 中,因为有删除操作,所以还需要加 1。dp[i][j] = dp[i - 1][j] + 1 在这里插入图片描述
    2. 在 s 字符串最后面插入 t 字符串的最后一个元素,然后把 s 字符串的 1 到 i 字符串变为 t 字符串的 1 到 j - 1 子 t 子字符串。dp[i][j] = dp[i][j - 1] + 1 在这里插入图片描述
    3. 把 s 字符串最后一个元素改为 t 字符串最后一个元素,然后把 s 字符串的 1 到 i - 1 子字符串变为 t 字符串的 1 到 j - 1 子字符串。dp[i][j] = dp[i - 1][j - 1] + 1 在这里插入图片描述
  5. 初始化 本题初始化需要结合题目的实际意义,虽然题目数据范围的字符串长度从 1 开始,但是对于本题来说,研究字符串为空时还是有必要的,当把一个空串转换为一个长度为 1 的字符串时最少字符操作次数是 1(插入 1 次),同理把一个空串转换为一个长度为 2 的字符串时最少字符操作次数是 2,所以对于 dp 数组的边界情况 dp[0][1] 要初始化为 1,dp[0][2] 要初始化为 2,依次类推。 当把长度为 1 的字符串转换为一个空串时最少字符操作次数是 1(删除一次),同理当把长度为 2 的字符串转换为一个空串时最少字符操作次数是 2,所以对于 dp 数组的边界情况 dp[1][0] 要初始化为 1,dp[2][0] 要初始化为 2,依次类推。

在这里插入图片描述

  1. 填表顺序 从上往下,从左往后。

  2. 输出结果 dp[n][m]

代码

#include <iostream>
#include <string>
#include <algorithm>
using namespace std;

const int N = 2010;
int dp[N][N];
string s, t;

int main() {
    cin >> s >> t;
    int n = s.size();
    int m = t.size();

    // 初始化
    for (int i = 1; i <= n; i++) {
        dp[i][0] = i;
    }
    for (int i = 1; i <= m; i++) {
        dp[0][i] = i;
    }

    // 按序填表
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= m; j++) {
            if (s[i - 1] == t[j - 1]) {
                dp[i][j] = dp[i - 1][j - 1];
            } else {
                // 删
                int a = dp[i - 1][j] + 1;
                // 插
                int b = dp[i][j - 1] + 1;
                // 改
                int c = dp[i - 1][j - 1] + 1;
                dp[i][j] = min({a, b, c});
            }
        }
    }

    // 输出结果
    cout << dp[n][m] << endl;
    return 0;
}

目录

  1. 最长上升子序列
  2. 题目描述
  3. 题目解析
  4. 代码
  5. 【模板】最长上升子序列
  6. 题目描述
  7. 题目解析
  8. 代码
  9. 合唱队形
  10. 题目描述
  11. 题目解析
  12. 代码
  13. 牛可乐和最长公共子序列
  14. 题目描述
  15. 题目解析
  16. 代码
  17. 编辑距离
  18. 题目描述
  19. 题目解析
  20. 代码
  • 💰 8折买阿里云服务器限时8折了解详情
  • GPT-5.5 超高智商模型1元抵1刀ChatGPT中转购买
  • 代充Chatgpt Plus/pro 帐号了解详情
  • 🤖 一键搭建Deepseek满血版了解详情
  • 一键打造专属AI 智能体了解详情
极客日志微信公众号二维码

微信扫一扫,关注极客日志

微信公众号「极客日志V2」,在微信中扫描左侧二维码关注。展示文案:极客日志V2 zeeklog

更多推荐文章

查看全部
  • 树莓派 libwebkit2gtk-4.1-0 安装与 GUI 启动优化
  • GTC 2026 中国 AI 企业集体亮相:从技术跟随到全球并跑
  • 语义化 AI 驱动器:提示词工程的技术演进与治理
  • AI 辅助 Python 编程实战:5 个提升效率的真实案例
  • GitHub Copilot 接入第三方 OpenAI 兼容模型及去除安全限制方法
  • PID 算法详解:原理、参数整定与 C 语言实现
  • PostgreSQL 动态分区裁剪技术:查询性能优化解析
  • JDK 17 官方下载与安装配置指南
  • 算力虚拟化开源分析:以 Flex:ai 为例谈工程交付边界
  • AI 绘画模型下载优化指南:10 个高效解决方案
  • 7D-AI系列:AI 编程 Spec Coding 完整详细的典型标准化工作流
  • AI 产品经理转行指南与面试核心考点解析
  • C++ 模板与内存管理详解
  • YOLOFuse 与 Whisper 语音视觉协同架构设计
  • XDMA 与 FPGA DMA 控制器协同设计详解
  • Ubuntu 18.04 解决 Linux 5.4 下 Intel I226-V 2.5G 网卡驱动识别问题
  • 基于改进蝙蝠优化算法的无人机 3D 路径规划研究
  • CentOS 7 YUM 包管理操作指南
  • VSCode 插件 GitLens 安装与版本管理指南
  • 图寻路算法详解:基于深度优先搜索 (DFS) 的 Java 实现

相关免费在线工具

  • 加密/解密文本

    使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online

  • Gemini 图片去水印

    基于开源反向 Alpha 混合算法去除 Gemini/Nano Banana 图片水印,支持批量处理与下载。 在线工具,Gemini 图片去水印在线工具,online

  • Base64 字符串编码/解码

    将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online

  • Base64 文件转换器

    将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online

  • Markdown转HTML

    将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online

  • HTML转Markdown

    将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online