最近在刷力扣Hot100时遇到了这道经典的动态规划问题——"打家劫舍"。题目描述很简单:假设你是一个专业的小偷,计划偷窃一条街上的房屋,每个房屋都存放着特定金额的现金。但是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚被闯入,系统会自动报警。给定一个代表每个房屋存放金额的非负整数数组,计算在不触动警报的情况下,一夜之内能够偷窃到的最高金额。
这个问题的现实意义其实远超编程竞赛。比如在投资决策中,我们经常面临类似的选择困境:某些机会之间存在互斥性,选择了一个就可能要放弃相邻的其他机会。如何在约束条件下做出最优决策,这正是动态规划最擅长的场景。
我最初尝试用递归来解决这个问题。对于第i个房屋,我们有两个选择:
用代码表示就是:
python复制def rob(nums, i):
if i < 0:
return 0
return max(nums[i] + rob(nums, i-2), rob(nums, i-1))
虽然这个解法逻辑正确,但实际运行时发现存在严重的性能问题。以[1,2,3,1]为例,递归树会指数级膨胀:
这里存在大量重复计算,时间复杂度达到O(2^n),对于n=100的测试用例根本无法在合理时间内完成。
动态规划正是为了解决这种重叠子问题而生的。我们可以用一个数组dp来存储中间结果,其中dp[i]表示前i个房屋能偷窃到的最大金额。
状态转移方程:
code复制dp[i] = max(dp[i-1], dp[i-2] + nums[i])
具体实现:
python复制def rob(nums):
if not nums:
return 0
if len(nums) == 1:
return nums[0]
dp = [0] * len(nums)
dp[0] = nums[0]
dp[1] = max(nums[0], nums[1])
for i in range(2, len(nums)):
dp[i] = max(dp[i-1], dp[i-2] + nums[i])
return dp[-1]
观察发现,dp[i]只依赖于前两个状态dp[i-1]和dp[i-2],因此可以进一步优化空间复杂度到O(1):
python复制def rob(nums):
prev_max = curr_max = 0
for num in nums:
temp = curr_max
curr_max = max(prev_max + num, curr_max)
prev_max = temp
return curr_max
这个优化版本只用两个变量prev_max和curr_max就完成了所有计算,空间复杂度降为O(1),而时间复杂度保持O(n)。
在实际编码中,有几个边界情况需要特别注意:
虽然题目说明是非负整数,但测试用例中可能出现全零的情况。我们的算法应该能正确处理这种情况,返回0。
当房屋金额很大时(比如接近2^31-1),要注意整数溢出问题。不过在Python中这不是问题,如果是其他语言如Java/C++需要考虑使用long类型。
我们可以用数学归纳法证明这个DP解法的正确性:
这个问题具有最优子结构性质:一个问题的最优解包含其子问题的最优解。这正是动态规划适用的前提条件。
力扣上还有一个变种问题"打家劫舍II",房屋排成一个环形。这时第一个和最后一个房屋也视为相邻的。
解决思路:
python复制def rob(nums):
def helper(start, end):
prev_max = curr_max = 0
for i in range(start, end):
temp = curr_max
curr_max = max(prev_max + nums[i], curr_max)
prev_max = temp
return curr_max
if not nums:
return 0
if len(nums) == 1:
return nums[0]
return max(helper(0, len(nums)-1), helper(1, len(nums)))
另一个变种是房屋排列成二叉树结构(打家劫舍III),不能同时偷窃直接相连的两个节点。
这需要用到树形DP的思想,对每个节点返回两个值:
python复制def rob(root):
def helper(node):
if not node:
return (0, 0)
left = helper(node.left)
right = helper(node.right)
rob = node.val + left[1] + right[1]
not_rob = max(left) + max(right)
return (rob, not_rob)
return max(helper(root))
这类问题在实际中有很多应用场景,比如:
解决这类问题的关键在于:
通过这道题,我深刻体会到动态规划"将大问题分解为小问题"的核心思想。在实际工程中,很多复杂问题都可以用类似的思路来分解和解决。