ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

动态规划实战:解决不相邻元素最大和问题

动态规划实战:解决不相邻元素最大和问题

1. 题目背景与问题理解

"聪明的美食家"是东华OJ平台上的一道经典动态规划练习题。题目描述了一位美食家在一条美食街上挑选美食的场景:街上有N家店铺,每家店铺提供不同美味值的美食,但相邻的店铺不能连续光顾(类似打家劫舍问题)。美食家的目标是选择一组不相邻的店铺,使得总美味值最大化。

这道题本质上是动态规划中的"不相邻元素最大和"问题,也是算法竞赛中的常见题型。它考察的是对动态规划状态定义和转移方程的理解能力,非常适合用来练习动态规划的基本思想。

提示:虽然题目描述是美食场景,但这类问题在计算机科学中有广泛应用,如资源调度、投资组合优化等场景都会遇到类似模型。

2. 动态规划基础分析

2.1 问题抽象化

首先我们需要将美食街的问题抽象为数学模型:

  • 给定一个数组nums,表示每家店铺的美味值
  • 需要选择一个子集,满足:
    • 子集中任意两个元素不能相邻
    • 子集元素和最大

例如对于输入[1,2,3,1],最优解是选择第2和第4家店铺,总美味值为2+1=3。

2.2 动态规划三要素

解决动态规划问题通常需要考虑三个核心要素:

  1. 状态定义:dp[i]表示前i家店铺能获得的最大美味值
  2. 初始条件:
    • dp[0] = 0(没有店铺时美味值为0)
    • dp[1] = nums[0](只有一家店铺时只能选择它)
  3. 状态转移方程:
    • 对于第i家店铺,有两种选择:
      • 不选择它:则最大值为dp[i-1]
      • 选择它:则不能选择i-1,最大值为dp[i-2]+nums[i-1]
    • 因此转移方程为:dp[i] = max(dp[i-1], dp[i-2]+nums[i-1])

3. 算法实现与优化

3.1 基础实现

根据上述分析,我们可以写出基础版的动态规划实现:

def maxDelicious(nums): if not nums: return 0 n = len(nums) dp = [0] * (n + 1) dp[1] = nums[0] for i in range(2, n+1): dp[i] = max(dp[i-1], dp[i-2] + nums[i-1]) return dp[n]

这个实现的时间复杂度是O(n),空间复杂度也是O(n)。对于OJ题目来说通常已经足够,但在实际工程中我们还可以进一步优化空间。

3.2 空间优化

观察状态转移方程可以发现,dp[i]只依赖于dp[i-1]和dp[i-2],因此我们不需要存储整个dp数组,只需要保存前两个状态即可:

def maxDelicious(nums): if not nums: return 0 prev2 = 0 # dp[i-2] prev1 = nums[0] # dp[i-1] for i in range(1, len(nums)): current = max(prev1, prev2 + nums[i]) prev2, prev1 = prev1, current return prev1

这样空间复杂度优化到了O(1),这在处理大规模数据时会有明显优势。

4. 边界条件与测试用例

4.1 常见边界情况

在实现动态规划算法时,特别需要注意各种边界条件:

  • 空数组输入:应该返回0
  • 单元素数组:直接返回该元素值
  • 双元素数组:返回较大的那个值
  • 全相同值数组:测试是否能正确间隔选择
  • 全负数数组:测试是否能正确处理负值情况

4.2 测试用例设计

针对这道题,建议设计以下测试用例:

test_cases = [ ([], 0), # 空数组 ([5], 5), # 单元素 ([1,2,3,1], 4), # 常规情况 ([2,7,9,3,1], 12), # 需要间隔选择 ([-1,-2,-3], -1), # 全负数 ([5,1,1,5], 10), # 选择首尾 ]

注意:在OJ系统中,除了常规测试用例,还需要考虑极端情况如大数组测试,确保算法在时间和空间限制内完成。

5. 算法扩展与变种

5.1 环形美食街问题

如果美食街是环形的(即首尾店铺也视为相邻),问题会变得更加复杂。这种情况下,我们可以将问题拆分为两种情况:

  1. 不选择第一家店铺:转化为求解nums[1:]的线性问题
  2. 不选择最后一家店铺:转化为求解nums[:-1]的线性问题 然后取两种情况的最大值。

实现代码:

def maxDeliciousCircular(nums): if len(nums) == 1: return nums[0] return max(maxDelicious(nums[1:]), maxDelicious(nums[:-1]))

5.2 输出具体选择方案

如果题目要求不仅输出最大美味值,还要输出选择的店铺索引,我们需要额外记录选择路径。可以通过维护一个选择数组来实现:

def maxDeliciousWithPath(nums): if not nums: return 0, [] n = len(nums) dp = [0] * (n + 1) dp[1] = nums[0] path = [[] for _ in range(n+1)] path[1] = [0] for i in range(2, n+1): if dp[i-1] > dp[i-2] + nums[i-1]: dp[i] = dp[i-1] path[i] = path[i-1].copy() else: dp[i] = dp[i-2] + nums[i-1] path[i] = path[i-2].copy() path[i].append(i-1) return dp[n], path[n]

6. 动态规划思维训练

6.1 为什么动态规划适合这类问题

这类问题具有两个关键特性,使其适合用动态规划解决:

  1. 最优子结构:问题的最优解包含子问题的最优解
  2. 重叠子问题:在递归求解过程中会重复计算相同的子问题

通过记忆化存储子问题的解,动态规划避免了重复计算,大大提高了效率。

6.2 如何识别动态规划问题

在实际编程中,可以通过以下特征识别动态规划问题:

  • 问题可以分解为相似的子问题
  • 需要求最大值/最小值/计数等
  • 有明确的限制条件(如本题的不相邻限制)
  • 暴力解法存在大量重复计算

6.3 动态规划解题框架

根据这道题的解题过程,可以总结出动态规划解题的一般框架:

  1. 定义子问题(状态)
  2. 写出状态转移方程
  3. 确定初始条件和边界情况
  4. 确定计算顺序(通常是自底向上)
  5. 考虑空间优化可能性
  6. 编写代码并测试

7. 实际应用与进阶学习

7.1 实际应用场景

虽然题目设定是美食街,但这类算法在实际中有广泛应用:

  • 投资组合选择(不能同时投资有冲突的项目)
  • 任务调度(不能安排冲突时间段的任务)
  • 资源分配(不能同时占用冲突的资源)

7.2 相关进阶题目

为了进一步掌握动态规划,推荐尝试以下类似题目:

  • LeetCode 198. 打家劫舍(与本题几乎相同)
  • LeetCode 213. 打家劫舍 II(环形变种)
  • LeetCode 337. 打家劫舍 III(树形结构)
  • LeetCode 740. 删除并获得点数(带计数变种)

7.3 动态规划优化技巧

在解决更复杂的动态规划问题时,还可以应用以下优化技巧:

  • 滚动数组优化空间
  • 状态压缩(用位运算表示状态)
  • 记忆化搜索(自顶向下+缓存)
  • 四边形不等式优化
  • 斜率优化

8. 解题心得与常见错误

在解决这道题的过程中,我总结了一些经验教训:

  1. 状态定义要清晰:最初我尝试用dp[i]表示"选择第i家店铺时的最大值",这样定义会导致状态转移复杂。后来改为"前i家店铺的最大值"后问题简化很多。

  2. 边界条件容易忽略:特别是空数组和单元素数组的情况,在竞赛中经常因此丢分。

  3. 空间优化要谨慎:虽然优化空间很重要,但在初次解题时应先写出清晰的基础版本,确保正确后再优化。

  4. 测试用例要全面:不能只测试常规情况,必须考虑各种边界和极端输入。

  5. 变量命名要明确:在动态规划代码中,清晰的变量名能大大减少思维负担。比如用prev2、prev1比用a、b更易理解。

这道题虽然看似简单,但很好地体现了动态规划的核心思想。通过反复练习这类基础题目,可以培养出解决更复杂动态规划问题的直觉和能力。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进