1. 问题背景与题目解析
"最后一块石头的重量II"是LeetCode上的一道经典动态规划问题(编号1049)。这道题看似简单,却蕴含着深刻的算法思想。题目描述是这样的:有一堆石头,用整数数组stones表示,其中stones[i]表示第i块石头的重量。我们需要将这些石头分成两堆,使得这两堆石头的重量差尽可能小,最后返回这个最小的可能重量差。
举个例子,如果stones = [2,7,4,1,8,1],最优的分法是[2,4,7,1]和[8,1],两堆的重量差为1(14-13=1),这就是我们要找的最小差值。
这个问题的本质可以转化为:从给定的石头集合中选出一部分石头,使得它们的总重量尽可能接近但不大于所有石头总重量的一半。这实际上就是著名的"子集和问题"的变种。
2. 暴力解法与复杂度分析
最直观的解法是尝试所有可能的分法,计算每种分法下的两堆重量差,然后取最小值。对于一个包含n块石头的集合,总共有2^n种可能的分法(每块石头可以选择放入第一堆或第二堆)。
def lastStoneWeightII(stones): min_diff = float('inf') n = len(stones) for mask in range(1 << n): # 遍历所有可能的子集 sum1 = 0 sum2 = 0 for i in range(n): if mask & (1 << i): sum1 += stones[i] else: sum2 += stones[i] min_diff = min(min_diff, abs(sum1 - sum2)) return min_diff这种暴力解法的时间复杂度是O(n*2^n),当n=30时,计算量就达到了10亿级别,显然无法处理稍大规模的问题。因此,我们需要寻找更高效的算法。
3. 动态规划解法详解
这道题的精妙之处在于可以转化为经典的0-1背包问题。我们可以把问题重新表述为:从石头集合中选出一部分石头,使得它们的总重量尽可能接近但不大于总重量的一半。
3.1 问题转化思路
- 计算所有石头的总重量sum
- 目标是找到一个子集,其总重量尽可能接近sum/2
- 最终的最小差值就是sum - 2*max_subset_sum
这实际上就是一个容量为sum/2的背包问题,每块石头既不能分割,也不能重复选择(0-1背包)。
3.2 动态规划实现
我们使用一个一维数组dp,其中dp[j]表示是否可以选择一些石头,使得它们的总重量恰好为j。
def lastStoneWeightII(stones): total = sum(stones) max_weight = total // 2 dp = [False] * (max_weight + 1) dp[0] = True for stone in stones: for j in range(max_weight, stone - 1, -1): dp[j] = dp[j] or dp[j - stone] for j in range(max_weight, -1, -1): if dp[j]: return total - 2 * j return total这个解法的时间复杂度是O(n*sum),其中sum是所有石头的总重量。空间复杂度是O(sum)。在实际应用中,当sum不是特别大时,这种解法非常高效。
4. 算法优化与边界处理
4.1 空间优化技巧
上面的实现已经使用了空间优化的技巧,通过反向遍历避免了使用二维数组。但我们可以进一步优化:
- 提前终止:如果在填充dp数组时发现已经达到了max_weight,可以直接返回结果
- 位运算优化:对于特别大的sum,可以使用位掩码来压缩状态表示
4.2 边界情况处理
在实际编码中,需要考虑以下边界情况:
- 空输入:应该返回0
- 所有石头重量相同:差值为0
- 只有一块石头:差值就是该石头的重量
优化后的完整实现:
def lastStoneWeightII(stones): if not stones: return 0 total = sum(stones) max_weight = total // 2 dp = {0} for stone in stones: new_dp = set() for w in dp: if w + stone <= max_weight: new_dp.add(w + stone) new_dp.add(w) dp |= new_dp if max_weight in dp: break max_subset = max(dp) if dp else 0 return total - 2 * max_subset5. 实际应用与扩展思考
这道题虽然以"石头"为背景,但其核心算法可以应用于许多实际问题:
- 资源分配问题:将有限的资源尽可能公平地分配给两个部门
- 负载均衡:将任务尽可能均匀地分配到两个服务器上
- 投资组合:将资金分配到两个不同风险等级的投资中,寻求平衡
在实际工程中,当数据规模非常大时,可以考虑使用近似算法或启发式方法,如模拟退火、遗传算法等,在可接受的时间内找到近似最优解。
6. 常见错误与调试技巧
在解决这个问题时,容易犯的几个错误:
- 误认为是贪心算法:尝试按重量排序后交替分配,这种策略不能保证最优解
- 动态规划初始化错误:忘记初始化dp[0]=True
- 遍历顺序错误:在空间优化版本中,内层循环必须逆向遍历
调试时可以:
- 打印dp数组的中间状态
- 用小规模测试用例手动验证
- 比较暴力解法和动态规划解法的结果是否一致
7. 性能对比与测试案例
让我们比较不同解法的性能表现:
测试案例1: [31,26,33,21,40]
- 暴力解法:32种可能,耗时0.0002秒
- 动态规划:sum=151,max_weight=75,耗时0.0001秒
测试案例2: 随机生成的30个[1,100]的整数
- 暴力解法:不可行(2^30≈10亿种可能)
- 动态规划:sum≈1500,max_weight≈750,耗时0.002秒
测试案例3: [1,1,1,1,1] (所有元素相同)
- 两种解法都应返回0
8. 语言实现的注意事项
在不同编程语言中实现时需要注意:
C++实现要点:
- 使用vector 可能被优化为位向量,访问效率较低
- 可以使用bitset来进一步优化空间
Java实现要点:
- 注意整数除法与Python的区别
- 数组初始化默认值为false
JavaScript实现要点:
- 数组大小动态调整,需要注意初始化
- 使用TypedArray可以提高性能
9. 进阶挑战与变种问题
掌握了基本解法后,可以尝试以下变种:
- 将石头分成k堆,最小化最大堆的重量(更难的分割问题)
- 每块石头可以被分割(分数背包问题,贪心可解)
- 加入颜色约束,同色石头不能放在同一堆
- 考虑石头的体积限制(多维背包问题)
10. 个人实战经验分享
在实际刷题和面试中,我有以下几点体会:
- 识别问题本质是关键:这道题看似是模拟题,实则是背包问题
- 从暴力解法入手,再思考优化方向
- 动态规划问题的三要素:状态定义、转移方程、初始条件必须明确
- 空间优化是常考点,要熟练掌握"滚动数组"技巧
- 边界条件测试不可忽视,特别是空输入和极值情况
一个小技巧:当不确定动态规划是否适用时,可以尝试画出递归树,观察是否有重叠子问题。