动态规划经典应用:C语言求解合唱队形问题详解

在算法竞赛和编程面试中,合唱队形问题是一个经典的动态规划应用场景。它巧妙地将最长上升子序列(LIS)和最长下降子序列(LDS)问题结合在一起,考察了我们对动态规划核心思想的掌握程度。理解并解决这个问题,不仅能提升你的C语言算法实现能力,其背后的“分而治之”思想在C++、Python、Go乃至JavaScript/TypeScript等现代语言开发中同样至关重要。本文将带你从问题本质出发,深入剖析动态规划的解决思路,并提供清晰的代码实现。

一、问题重述与核心挑战

想象一下,n位同学站成一排,老师需要请一部分人出列,使得剩下的同学能排成一个“合唱队形”。这个队形的定义是:存在一位“核心”同学,其左侧所有同学的身高严格递增,其右侧所有同学的身高严格递减。这就形成了一个“山峰”形状。我们的目标是:找出最少需要出列多少位同学,才能让剩下的同学满足这个条件。

问题的输入是同学总数n和他们的身高数组,输出是一个整数。这本质上是一个最优化问题。暴力枚举所有可能的出列方案(即所有可能的子序列)并检查其是否满足“山峰”形状,时间复杂度是指数级的,对于n=100显然不可行。因此,我们必须寻找更高效的算法,动态规划正是为此而生。

[AFFILIATE_SLOT_1]

二、解题思路剖析:化整为零

要求出列人数最少,等价于让留在队形中的人数最多。对于任意一位同学i,如果我们能知道:

  • 以他结尾的最长上升子序列长度(Left[i]):这代表他能作为“山峰”左侧部分时,左侧最多能有多少人(包括他自己)。
  • 以他开头的最长下降子序列长度(Right[i]):这代表他能作为“山峰”右侧部分时,右侧最多能有多少人(包括他自己)。

那么,当同学i作为整个合唱队形的“顶峰”时,整个队形的最多人数就是 Left[i] + Right[i] - 1(因为i被计算了两次)。我们只需要遍历所有同学,找到这个最大值,然后用总人数n减去它,就得到了最少出列人数。

关键洞察:将一个复杂的全局最优问题(整个合唱队形),分解为两个独立的、更简单的子问题(从左到右的LIS和从右到左的LIS),然后通过组合子问题的解来得到原问题的解。这是动态规划乃至许多算法设计(如分治、贪心)的核心思想,在构建复杂系统时,这种模块化思维同样重要。

三、动态规划五部曲详解

下面我们以计算从左到右的上升序列(Left数组)为例,严格遵循动态规划的思考框架。

  1. 确定dp数组及下标的含义

    定义 Left[i]:表示以第 i 位同学(从1开始计数)作为结尾的最长上升子序列的长度。这里的“上升子序列”是指从队列开头到i位置的某个子序列,且身高严格递增。

  2. 确定递推公式

    如何求 Left[i]?我们考虑所有在i左边的同学 j (1 <= j < i)。如果同学j的身高小于同学i的身高(height[j] < height[i]),那么同学i就可以接在同学j所在的那个上升子序列的后面,形成一个更长的、以i结尾的上升子序列,其长度为 Left[j] + 1

    我们不知道哪个j是最好的,所以需要遍历所有可能的j,并取最大值。同时,最差情况是i前面没有比他矮的人,那么序列就只有他自己,长度为1。因此递推公式为:

    Left[i] = max(1, max{ Left[j] + 1 | for all j < i and height[j] < height[i] })

    用代码逻辑表示就是:

    输入
    8
    186 186 150 200 160 130 197 220
    输出
    4
    说明/提示
    对于 50% 的数据,保证有 n≤20。
    对于全部的数据,保证有 n≤100。

    这个公式是动态规划的灵魂。Left[j] 是已经计算好的子问题最优解,我们用它来构造当前问题 Left[i] 的解。这正是 “利用历史,避免重复计算” 的体现。在JavaScript或Python中实现时,其核心逻辑完全一致。

  3. dp数组如何初始化

    每一个同学都可以作为一个长度为1的上升子序列(只包含自己)。因此,我们将所有的 Left[i] 初始值都设为1。

  4. 确定遍历顺序

    计算 Left[i] 时,需要用到所有 j < iLeft[j] 的值。因此,我们必须从前往后(i从1到n)遍历。这是一个自底向上的填表过程。

  5. 举例推导dp数组

    我们用一个简单例子来验证思路。假设身高数组为:

    186 186 150 200 160 130 197 220

    计算过程如下:

    • i=1: 前面无人,Left[1]=1
    • i=2: height[1]=186 不小于 186,Left[2]=1
    • i=3: 前面186,186都大于150,Left[3]=1
    • i=4: 检查j=1,2,3。身高186,186,150都小于200。取max(Left[1]+1=2, Left[2]+1=2, Left[3]+1=2, 1)=2。故Left[4]=2(如序列{186,200}或{150,200})。
    • i=5: 只有j=3(150<160)满足,Left[5] = max(Left[3]+1=2, 1)=2
    • i=6: 前面所有人都比他高,Left[6]=1

    同理,我们需要计算从右到左的“上升”序列(即原序列从左看的下降序列)Right[i],方法完全对称,只需从n到1遍历,寻找右边身高比当前矮的j。

四、代码实现与解析

将上述思路转化为C语言代码。核心部分是两次动态规划循环,分别计算 LeftRight 数组。

#include 
#include   // 为了 memset
int a[105];//用于存储输入的同学身高数
int b[105];//表示以i结尾的最长上升子序列长度是b[i]
int c[105];//表示以i结尾的最长下降子序列长度是c[i]
int main() {
    int n;
    scanf("%d", &n);
    // 初始化数组为0
    memset(b, 0, sizeof(b));
    memset(c, 0, sizeof(c));
    for (int i = 1; i <= n; i++) {
        scanf("%d", &a[i]);
    }
    // 计算最长上升子序列
    for (int i = 1; i <= n; i++) {
        b[i] = 1;  // 初始化为1(自己)
        for (int j = 1; j < i; j++) {
            if (a[i] > a[j] && b[j] + 1 > b[i]) {//如果定义的最高的人比前面的那个人更高,也就是满足判断条件,而且之前的人数加上这个人之后总人数比之前的大,那人数就进行替换。
            //这个替换刚好使不满足题意的人去掉,因为总人数取的还是之前的。
                b[i] = b[j] + 1;
            }
        }
    }
    // 计算最长下降子序列(从右向左的上升子序列)
    for (int i = n; i >= 1; i--) {
        c[i] = 1;  // 初始化为1
        for (int j = i + 1; j <= n; j++) {
            if (a[i] > a[j] && c[j] + 1 > c[i]) {
                c[i] = c[j] + 1;
            }
        }
    }
    // 找最大合唱队形长度
    int ans = 0;
    for (int i = 1; i <= n; i++) {
        if (b[i] + c[i] > ans) {
            ans = b[i] + c[i];
        }
    }
    // 输出最少出列人数
    printf("%d\n", n - (ans - 1));
    return 0;
}

代码要点解析

  • 两次DP循环:第一个循环(计算b/Left)是标准的LIS DP。第二个循环(计算c/Right)是反向的LIS,逻辑镜像对称。
  • 状态转移核心:语句 if (a[i] > a[j] && b[j] + 1 > b[i]) 是算法的精髓。它检查了两件事:1) 能否连接(身高关系);2) 连接后是否更优(长度比较)。这里的 b[j] 就是子问题 b[j] 的最优解,用于构建 b[i]
  • 结果计算:遍历所有同学作为“顶峰”的可能性,b[i] + c[i] - 1 的最大值即为合唱队形最多人数。

⚠️ 注意事项:初始化很重要,必须将每个同学的初始序列长度设为1。同时,题目要求严格递增递减,因此判断条件为 > 而非 >=

[AFFILIATE_SLOT_2]

五、算法扩展与思维迁移

解决合唱队形问题,掌握的是“正反两次LIS”这一模式。这种思想可以迁移到许多场景:

  • Python/Go实现:在其他语言中,逻辑完全一致。Python可以利用列表生成式使代码更简洁,Go语言则需注意数组和切片的初始化。关键在于理解DP状态的定义和转移,而非语法细节。
  • 变体问题:如果队形允许身高相等(非严格递增递减),只需将判断条件中的 > 改为 >= 即可。这是面试中常见的追问点。
  • 性能分析:该算法时间复杂度为O(n²),因为有两层嵌套循环。对于n≤100绰绰有余。如果n很大(如10^5),则需要使用O(n log n)的LIS优化算法(贪心+二分查找)来计算Left和Right数组,这是算法进阶的必经之路。
  • 与其他算法的联系:此问题是LIS问题的直接应用。LIS本身是动态规划的入门必修课,其思想也用于解决股票买卖、最长数对链等问题。

b[i] = max( b[j] + 1 )  对于所有 j < i 且 a[j] < a[i]

总结:合唱队形问题是一个绝佳的动态规划教学案例。它清晰地展示了如何将复杂问题分解为重叠子问题(LIS),定义状态(Left[i], Right[i]),找到状态转移方程(基于身高比较的最大值更新),并最终组合出全局最优解。无论你使用C语言、C++、Java还是Python、TypeScript,其核心算法思想是相通的。掌握它,你不仅解决了一道经典题目,更获得了一把打开许多最优化问题大门的钥匙。希望本文的详细拆解能帮助你彻底理解动态规划在这一场景下的美妙应用。

posted on 2026-04-07 16:44  ljbguanli  阅读(11)  评论(0)    收藏  举报