从合唱队形问题看动态规划的优雅解法:如何高效求解最长子序列
在算法竞赛和编程面试中,动态规划(Dynamic Programming,简称DP)一直是让许多学习者又爱又恨的话题。它既能优雅地解决复杂问题,又常常让人在状态转移方程的构建上绞尽脑汁。合唱队形问题作为经典的动态规划案例,完美展示了如何将实际问题转化为最长子序列的求解过程。
这个问题源自NOIP提高组竞赛,要求我们找到一种排列方式,使得合唱队形呈现"中间高、两边低"的形态。表面上看是个排列问题,实则暗藏玄机——它巧妙地结合了最长上升子序列(LIS)和最长下降子序列(LDS)两种经典DP模型。理解这个问题的解法,不仅能帮助我们应对类似竞赛题目,更能掌握动态规划中"分而治之"的核心思想。
1. 问题本质与建模思路
合唱队形问题的描述很简单:给定n个学生的身高,要求去掉最少数量的学生,使得剩下的学生能排成T形队列——即存在一个中心学生,其左侧学生身高严格递增,右侧学生身高严格递减。这个看似简单的需求背后,隐藏着两个关键的子问题:
- 如何找到左侧的最长递增序列?
- 如何找到右侧的最长递减序列?
关键突破点在于意识到:对于每一个可能作为中心点的学生i,我们需要计算:
- 以i结尾的最长上升子序列长度(左侧队列)
- 以i开头的最长下降子序列长度(右侧队列)
这样,最优解就是找到使这两个长度之和最大的i,因为这样需要移除的学生数最少(n - (left[i] + right[i] - 1))。
注意:需要减1是因为中心点i被左右两侧的序列重复计算了一次
2. 最长上升子序列的DP实现
最长上升子序列(LIS)是动态规划的经典问题,其标准解法时间复杂度为O(n²),对于竞赛题目通常足够。让我们深入分析其实现细节:
2.1 状态定义与转移方程
定义dp_up[i]表示以第i个元素结尾的最长上升子序列的长度。初始化时,每个元素自身至少构成长度为1的子序列:
dp_up = [1] * n # 初始化所有位置为1
状态转移方程的核心思想是:对于每个i,检查前面所有比它小的元素j,取最大的dp_up[j]+1:
for i in range(n):
for j in range(i):
if heights[j] < heights[i]:
dp_up[i] = max(dp_up[i], dp_up[j] + 1)
2.2 算法优化思路
虽然O(n²)的解法在大多数竞赛中足够,但我们还可以进一步优化到O(nlogn)使用二分查找:
import bisect
def lengthOfLIS(nums):
tails = []
for num in nums:
idx = bisect.bisect_left(tails, num)
if idx == len(tails):
tails.append(num)
else:
tails[idx] = num
return len(tails)
不过在实际比赛中,考虑到编码复杂度和问题规模,O(n²)的实现往往更实用。
3. 最长下降子序列的逆向思维
最长下降子序列(LDS)可以看作是LIS的"镜像问题"。聪明的解法是从右向左遍历,转化为LIS问题:
3.1 反向遍历技巧
定义dp_down[i]表示从i开始的最长下降子序列长度。注意这里是从i"开始"而非"结束":
dp_down = [1] * n # 初始化
for i in range(n-1, -1, -1): # 从后往前遍历
for j in range(i+1, n):
if heights[j] < heights[i]:
dp_down[i] = max(dp_down[i], dp_down[j] + 1)
3.2 与LIS的关系
有趣的是,LDS可以通过反转数组后求LIS来实现:
def lengthOfLDS(nums):
return lengthOfLIS(nums[::-1])
这种对称性体现了动态规划问题的内在美感,也展示了算法设计中的转化思想。
4. 合唱队形的完整解决方案
将LIS和LDS结合起来,我们就能解决合唱队形问题。以下是完整的解决步骤:
4.1 算法流程
- 计算每个位置的LIS长度(从左到右)
- 计算每个位置的LDS长度(从右到左)
- 对于每个位置i,计算可能保留的最大人数:
dp_up[i] + dp_down[i] - 1 - 找出所有位置中的最大值,用总人数减去它得到最少需要移除的人数
4.2 实现示例
def min_removals(heights):
n = len(heights)
if n == 0:
return 0
# 计算LIS
dp_up = [1] * n
for i in range(n):
for j in range(i):
if heights[j] < heights[i]:
dp_up[i] = max(dp_up[i], dp_up[j] + 1)
# 计算LDS
dp_down = [1] * n
for i in range(n-1, -1, -1):
for j in range(i+1, n):
if heights[j] < heights[i]:
dp_down[i] = max(dp_down[i], dp_down[j] + 1)
# 找最大保留人数
max_keep = 0
for i in range(n):
max_keep = max(max_keep, dp_up[i] + dp_down[i] - 1)
return n - max_keep
4.3 复杂度分析
- 时间复杂度:O(n²) — 两个嵌套循环
- 空间复杂度:O(n) — 两个长度为n的数组
对于典型竞赛题目中n≤1000的规模,这个复杂度完全可接受。
5. 边界条件与常见错误
在实现这个算法时,有几个容易出错的细节值得特别注意:
- 初始化问题:所有DP数组必须初始化为1,因为每个元素本身就是一个长度为1的子序列
- 重复计数:合并LIS和LDS结果时要记得减1,避免中心点被重复计算
- 严格递增/递减:题目要求的是严格递增递减,比较时要用
<而非<= - 空输入处理:虽然题目通常保证n≥1,但健壮的代码应该处理边界情况
一个常见的错误实现是:
# 错误示例:没有处理重复计数
max_keep = max(dp_up[i] + dp_down[i] for i in range(n)) # 忘记减1
6. 扩展与变种
掌握了合唱队形问题的解法后,我们可以将其应用于多种变种问题:
6.1 双向最长递增序列
考虑一个问题:找到最长的先递增后递减序列(不需要有明确的中心点)。这实际上是合唱队形问题的简化版,解法完全相同。
6.2 三维合唱队形
更复杂的变种可能考虑三维空间中的排列,此时需要结合更多维度的状态信息。
6.3 其他DP技巧结合
可以将此问题与其他DP技巧结合,例如:
- 使用线段树优化LIS计算
- 结合记忆化搜索实现
- 使用滚动数组优化空间复杂度
7. 实际应用与思维训练
虽然合唱队形问题看起来像纯粹的算法题,但其核心思想在实际开发中也有广泛应用:
- 资源调度:在任务调度中寻找最优的执行序列
- 数据压缩:寻找数据中的有序模式
- 股票分析:识别价格走势中的特定模式
从学习角度看,这个问题完美展示了动态规划的核心思想:
- 最优子结构:全局最优解包含子问题的最优解
- 状态定义:如何选择合适的状态表示
- 转移方程:如何从小问题构建大问题的解
在解决类似问题时,我习惯先画出几个具体例子,手动模拟DP表格的填充过程。这种方法虽然看起来笨拙,但能帮助我直观理解状态转移的逻辑。比如对于输入[1, 2, 3, 2, 1],手动计算dp_up和dp_down数组:
高度: [1, 2, 3, 2, 1]
dp_up:[1, 2, 3, 2, 1]
dp_down:[1, 2, 3, 2, 1]
这样能清晰看到中心点在3时达到最大值5(3+3-1),需要移除0人。

3926

被折叠的 条评论
为什么被折叠?



