引言
嘿,各位算法爱好者、准程序员们,以及那些对"如何不被抓"这件事充满好奇的朋友们!👋 今天,咱们不聊风花雪月,不谈诗和远方,咱们来聊点更刺激的------打家劫舍!💰💰💰
想象一下,你是一位身手敏捷、智商超群的"算法大盗",你的目标是沿街的房屋。每间房里都藏着诱人的现金,但问题来了:这些房子都装了"智能防盗系统",如果你胆敢同时光顾两间相邻的房屋,警报就会"呜呜呜"地响起来,然后你就得和"银手镯"亲密接触了。😱
是不是听起来就很刺激?别担心,咱们不是真的要去当小偷(遵纪守法是第一要务!),咱们是要用最优雅、最聪明的方式------动态规划(Dynamic Programming,简称DP),来解决这个经典的算法难题!😎
打家劫舍系列问题,在LeetCode上可是出了名的"三连击":有线性的、有环形的、还有树形的。它们看似不同,实则同根同源,都藏着动态规划的精髓。今天,我就带大家一步步揭开这些"宝藏"的秘密,保证让你听得懂、学得会,还能笑出声!😂
准备好了吗?系好安全带,咱们的"算法寻宝之旅"这就开始!🚀
第一章:初出茅庐的小贼------打家劫舍 I (LeetCode 198) 🏡
题目分析:小试牛刀,线性作案
首先,咱们来看看最基础的"打家劫舍"问题,也就是LeetCode上的第198题。题目是这样说的:
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下,一夜之内能够偷窃到的最高金额。
简单来说,就是给你一排房子,每个房子里有多少钱都告诉你了。你的任务是,在不能偷相邻房子的前提下,把能偷到的钱最大化。是不是感觉有点像在玩策略游戏?🎮
暴力解法:为什么我们不推荐?🙅♀️
面对这种求"最大值"的问题,很多同学可能第一时间会想到暴力枚举:把所有可能的偷窃方案都列出来,然后找出钱最多的那个。比如,对于 [1, 2, 3, 1]
这四个房子,你可以选择:
- 只偷第一个房子:1
- 只偷第二个房子:2
- 只偷第三个房子:3
- 只偷第四个房子:1
- 偷第一个和第三个房子:1 + 3 = 4
- 偷第二个和第四个房子:2 + 1 = 3
然后比较一下,发现最大值是4。看起来好像可行?🤔
但是,如果房子数量一多,比如有50个房子,那可能的组合数量就会爆炸式增长!因为每个房子都有"偷"和"不偷"两种选择,理论上是 2^n
种情况。这就像你面前有无数条路,你得一条条走过去才能找到最富有的那条,效率低到让人想哭!😭 这种方法会产生大量的重复计算,比如你计算偷到第5个房子的最大金额时,可能需要重复计算偷到第3个房子的最大金额。所以,暴力解法,我们直接pass!🙅♀️
动态规划登场:优雅的解决方案!✨
既然暴力解法不行,那咱们就请出今天的主角------动态规划!DP的核心思想是把一个大问题拆分成小问题,然后从小问题的解推导出大问题的解,并且避免重复计算。对于打家劫舍问题,DP简直是量身定制!
DP状态定义:dp[i]
的含义 💡
这是动态规划最关键的一步!我们需要定义一个 dp
数组,dp[i]
到底代表什么?
在这里,我们定义 dp[i]
为:考虑下标 i
(包括 i
)以内的房屋,在不触动警报的情况下,能够偷窃到的最高金额。
举个例子:
dp[0]
:只考虑第0个房子,能偷到的最高金额就是nums[0]
。dp[1]
:考虑第0和第1个房子,能偷到的最高金额是max(nums[0], nums[1])
。dp[i]
:考虑从第0个房子到第i
个房子,能偷到的最高金额。
DP转移方程:小偷的抉择 ⚖️
当小偷来到第 i
个房子门口时,他面临两种选择:
- 偷第
i
个房子 :如果偷了第i
个房子,那么第i-1
个房子就不能偷了(因为相邻)。所以,此时能偷到的总金额是:偷第i
个房子里的钱nums[i]
+ 偷到第i-2
个房子为止的最大金额dp[i-2]
。 - 不偷第
i
个房子 :如果选择不偷第i
个房子,那么能偷到的最高金额就和偷到第i-1
个房子为止的最高金额一样,也就是dp[i-1]
。
既然我们要求的是最高金额,那当然是选择这两种情况中的最大值啦!所以,我们的DP转移方程就是:
dp[i] = Math.max(dp[i - 1], dp[i - 2] + nums[i])
是不是感觉思路一下子清晰了?这就是DP的魅力!🤩
DP初始化:万事开头难?不,很简单!🎯
DP数组的初始化也非常重要,它为后续的递推提供了基础。根据我们的DP状态定义和转移方程:
dp[0]
:当只有一个房子时,能偷到的最大金额就是nums[0]
。dp[1]
:当有两个房子时,我们只能选择偷其中一个(不能相邻),所以是max(nums[0], nums[1])
。
所以,初始化就是:
dp[0] = nums[0]
dp[1] = Math.max(nums[0], nums[1])
遍历顺序:从前到后,水到渠成 🚶♀️
从转移方程 dp[i] = Math.max(dp[i - 1], dp[i - 2] + nums[i])
可以看出,计算 dp[i]
需要用到 dp[i-1]
和 dp[i-2]
的值。所以,我们必须从前往后,从小到大地计算 dp
数组的值,才能保证每次计算时所需的前置状态都已经计算完毕。也就是从 i = 2
开始遍历到数组的末尾。
代码实现与解析:手把手教你写代码 ✍️
理解了上面的思路,代码实现就非常简单了。我们以JavaScript为例:
js
/**
* @param {number[]} nums
* @return {number}
*/
var rob = function(nums) {
const len = nums.length;
// 处理特殊情况:如果房子数量为0或1
if (len === 0) return 0;
if (len === 1) return nums[0];
// 确定 dp 数组及其下标含义:dp[i] 表示考虑下标i(包括i)以内的房屋,最多可以偷窃的金额
let dp = new Array(len).fill(0);
// 确定初始化
dp[0] = nums[0];
dp[1] = Math.max(nums[0], nums[1]);
// 确定遍历顺序和递推公式
for (let i = 2; i < len; i++) {
dp[i] = Math.max(dp[i - 1], dp[i - 2] + nums[i]);
}
// 最终结果就是考虑所有房子后的最大金额
return dp[len - 1];
};
代码解析:
- 特殊情况处理 :首先,我们处理了房子数量为0或1的特殊情况。如果没房子,那肯定偷不到钱(返回0);如果只有一个房子,那就直接偷它(返回
nums[0]
)。这是非常好的编程习惯,能避免后续逻辑的复杂性。👍 dp
数组初始化 :根据我们前面推导的初始化规则,设置dp[0]
和dp[1]
的值。✅- 循环递推 :从
i = 2
开始,按照转移方程dp[i] = Math.max(dp[i - 1], dp[i - 2] + nums[i])
逐个计算dp
数组的每个元素。这个循环会一直进行到最后一个房子。🔄 - 返回结果 :最终,
dp[len - 1]
就存储了考虑所有房子后,在不触动警报的情况下,能够偷窃到的最高金额。完美!🎉
思考题:空间优化?🤔
细心的你可能发现了,我们计算 dp[i]
的时候,只用到了 dp[i-1]
和 dp[i-2]
。这意味着,我们并不需要一个完整的 dp
数组来存储所有历史状态。那么,有没有办法只用常数级别的空间复杂度来解决这个问题呢?欢迎在评论区留下你的思路!👇 期待你的精彩回答!💡
第二章:环形作案的挑战------打家劫舍 II (LeetCode 213) 🔄
题目分析:房子围成圈,难度加倍?😱
恭喜你,小贼!你已经成功掌握了线性打家劫舍的技巧。但这次,你的目标区域升级了!LeetCode 213题是这样描述的:
你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都 围成一圈,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
"围成一圈"!这四个字是不是让你虎躯一震?😨 是的,这意味着第一个房子和最后一个房子现在是"邻居"了。如果你偷了第一个房子,就不能偷最后一个;如果你偷了最后一个房子,就不能偷第一个。这可比之前单纯的一条街复杂多了!🤯
核心思路:化整为零,分而治之!✌️
面对这种环形结构,我们不能直接套用第一章的线性DP。因为如果直接套用,可能会出现同时偷了第一个和最后一个房子的情况,这显然是违规的!🚫
但是,仔细想想,这个"环"的限制,其实就只有一条:第一个房子和最后一个房子不能同时被偷。那么,我们是不是可以把这个问题拆分成两种情况来考虑呢?
- 情况一:不偷第一个房子 。如果第一个房子不偷,那么问题就变成了从第二个房子到最后一个房子(
nums[1]
到nums[len-1]
)的线性打家劫舍问题。此时,第一个房子和最后一个房子之间就没有了"相邻"关系,我们可以直接使用第一章的线性DP来解决。✅ - 情况二:不偷最后一个房子 。如果最后一个房子不偷,那么问题就变成了从第一个房子到倒数第二个房子(
nums[0]
到nums[len-2]
)的线性打家劫舍问题。同样,此时第一个房子和最后一个房子之间也没有了"相邻"关系,也可以直接使用第一章的线性DP来解决。✅
这两种情况,我们分别计算出它们能偷到的最大金额,然后取两者中的最大值,就是最终的答案!是不是很巧妙?😎 这就是"化整为零,分而治之"的思想!👍
子问题解决:复用线性DP,事半功倍!💪
既然我们已经把环形问题转化成了两个线性问题,那么解决这两个子问题就轻车熟路了。我们只需要对 nums
数组的两个子区间分别调用第一章的线性打家劫舍算法即可。
为了代码的复用性,我们可以把第一章的 rob
函数稍微改造一下,让它能够处理任意子数组的线性打家劫舍问题。当然,为了简化,我们也可以直接在主函数中实现两次线性DP的逻辑。💡
代码实现与解析:双管齐下,攻克环形!🚀
js
/**
* @param {number[]} nums - 表示每个房屋存放的金额
* @return {number} - 表示在不触动警报的情况下,能够抢劫到的最大金额
* @description 这个问题是打家劫舍的环形版本,意味着第一个房屋和最后一个房屋是相邻的,不能同时抢劫
*/
var rob = function(nums) {
const len = nums.length;
// 处理特殊情况:如果只有一个房屋,直接返回该房屋的金额
if (len === 0) return 0;
if (len === 1) return nums[0];
// 辅助函数:解决线性打家劫舍问题
const robLinear = (arr) => {
const l = arr.length;
if (l === 0) return 0;
if (l === 1) return arr[0];
let dp = new Array(l).fill(0);
dp[0] = arr[0];
dp[1] = Math.max(arr[0], arr[1]);
for (let i = 2; i < l; i++) {
dp[i] = Math.max(dp[i - 1], dp[i - 2] + arr[i]);
}
return dp[l - 1];
};
// 情况一:不偷最后一个房屋,偷窃范围 [0, len - 2]
const res1 = robLinear(nums.slice(0, len - 1));
// 情况二:不偷第一个房屋,偷窃范围 [1, len - 1]
const res2 = robLinear(nums.slice(1, len));
// 返回两种情况的最大值
return Math.max(res1, res2);
};
代码解析:
-
特殊情况处理:和第一题一样,对于房子数量为0或1的情况,直接返回结果。👍
-
robLinear
辅助函数 :为了代码的简洁和复用,我们封装了一个robLinear
函数,它就是第一章的线性打家劫舍算法。这个函数接收一个数组arr
,并返回在该数组范围内能偷到的最大金额。✨ -
两种情况的计算:
nums.slice(0, len - 1)
:创建了一个新数组,包含了从第一个房子到倒数第二个房子(不包含最后一个房子)。这对应了"不偷最后一个房子"的情况。🏠➡️🏠nums.slice(1, len)
:创建了一个新数组,包含了从第二个房子到最后一个房子(不包含第一个房子)。这对应了"不偷第一个房子"的情况。🏠⬅️🏠
-
取最大值 :最后,我们比较
res1
和res2
,取其中的最大值作为最终答案。因为这两种情况已经涵盖了所有合法的偷窃方案,并且保证了第一个和最后一个房子不会同时被偷。完美!💯
思考题:为什么不能直接在原数组上进行修改?🤔
在 robLinear
函数中,我们使用了 nums.slice()
来创建新的子数组。那么,如果直接在原数组上通过索引来控制范围,而不创建新数组,可以吗?这样做有什么潜在的问题?欢迎在评论区留下你的高见!👇 期待你的独到见解!🧐
第三章:树上开花的艺术------打家劫舍 III (LeetCode 337) 🌳
题目分析:房子变树了?这可怎么偷!🤯
好了,小贼,你已经掌握了线性DP和环形DP,现在是时候挑战终极形态了!LeetCode 337题,它把房子变成了"树":
小偷又发现了一个新的可行窃的地区。这个地区只有一个入口,我们称之为
root
。除了root
之外,每栋房子有且只有一个"父"房子与之相连。一番侦察之后,聪明的小偷意识到"这个地方的所有房屋的排列类似于一棵二叉树"。如果 两个直接相连的房子在同一天晚上被打劫 ,房屋将自动报警。给定二叉树的root
。返回 在不触动警报的情况下,小偷能够盗取的最高金额。
房子变成了二叉树的节点,相邻的房子就是父子节点。这意味着,如果你偷了父节点,就不能偷它的子节点;如果你偷了子节点,就不能偷它的父节点。这可比之前的"左右不能偷"复杂多了,因为现在是"上下不能偷"!😵💫 真是越来越刺激了!🎢
暴力解法:递归的陷阱 🕸️
对于树形结构,我们很容易想到递归。对于每个节点,我们都有两种选择:偷它,或者不偷它。
- 如果偷当前节点:那么它的左右子节点都不能偷。总金额 = 当前节点的值 + 偷左右孙子节点的最大金额。💰
- 如果不偷当前节点:那么它的左右子节点都可以偷,也可以不偷。总金额 = 偷左右子节点的最大金额(偷或不偷取最大值)。💸
然后取这两种情况的最大值。听起来很合理,对不对?
但是,这种递归方式会产生大量的重复计算!比如,在计算某个节点的"不偷"情况时,会去计算其子节点的"偷"和"不偷"情况;而在计算其子节点的"偷"情况时,又会去计算其孙子节点的"不偷"情况。这样层层递归下去,同一个子树的计算会被重复多次,效率非常低下,很容易超时!⏳ 就像陷入了一个无限循环的迷宫!🌀
树形DP登场:巧妙的后序遍历!🌲
为了避免重复计算,我们依然要请出动态规划。但是,树形DP和线性DP在状态定义和遍历顺序上会有所不同。对于树形DP,我们通常采用后序遍历(自底向上)的方式来计算。这就像从树的叶子开始,一步步向上计算,最终得到根节点的结果。🌿
DP状态定义:dfs(node)
的含义 🎯
对于树形DP,我们不能简单地用 dp[i]
来表示。因为树的结构不是线性的,没有一个简单的索引 i
。我们需要为每个节点定义状态。
这里,我们定义 dfs(node)
函数返回一个长度为2的数组:
dfs(node)[0]
:表示 不偷 当前node
节点时,以node
为根的子树能够偷窃到的最高金额。🚫dfs(node)[1]
:表示 偷 当前node
节点时,以node
为根的子树能够偷窃到的最高金额。💰
这样,对于每个节点,我们都明确了两种情况下的最大金额,方便后续的计算。👍
DP转移方程:父子间的博弈 🤝
当我们遍历到当前 node
节点时,我们需要先递归地计算出它的左右子节点(left
和 right
)的 dfs
结果。然后,根据 left
和 right
的结果来计算当前 node
的 dfs
结果。
-
计算
dfs(node)[1]
(偷当前node
节点) :- 如果偷当前
node
,那么它的左右子节点都不能偷。所以,当前node
能偷到的金额就是node.val
加上左右子节点"不偷"时的最大金额。 dfs(node)[1] = node.val + left[0] + right[0]
🤑
- 如果偷当前
-
计算
dfs(node)[0]
(不偷当前node
节点) :- 如果选择不偷当前
node
,那么它的左右子节点都可以选择偷或者不偷,我们当然要选择能偷到更多钱的方案。所以,左右子节点各自的最大金额就是max(left[0], left[1])
和max(right[0], right[1])
。 dfs(node)[0] = Math.max(left[0], left[1]) + Math.max(right[0], right[1])
💸
- 如果选择不偷当前
遍历顺序:后序遍历,自底向上 ⬆️
为什么是后序遍历?因为在计算当前节点的状态时,我们需要先知道其子节点的状态。后序遍历的特点是先访问左右子节点,再访问根节点,这正好满足了我们自底向上计算的需求。当 dfs
函数返回时,我们就能得到以该节点为根的子树的最大偷窃金额。这就像拼图一样,先拼好小块,再组合成大块。🧩
代码实现与解析:树形DP的优雅舞步 💃
js
/**
* Definition for a binary tree node.
* function TreeNode(val, left, right) {
* this.val = (val===undefined ? 0 : val)
* this.left = (left===undefined ? null : left)
* this.right = (right===undefined ? null : right)
* }
*/
/**
* @param {TreeNode} root
* @return {number}
*/
var rob = function(root) {
// dfs 函数返回一个数组 [不偷当前节点的最大金额, 偷当前节点的最大金额]
const dfs = (node) => {
// 递归出口:如果节点为空,表示没有房子可偷,返回 [0, 0]
if (!node) {
return [0, 0];
}
// 递归遍历左右子树,获取左右子树的偷窃情况
let leftResult = dfs(node.left);
let rightResult = dfs(node.right);
// 计算不偷当前节点时的最大金额
// 如果不偷当前节点,那么左右子节点都可以选择偷或不偷,取各自的最大值相加
let notRobCurrent = Math.max(leftResult[0], leftResult[1]) + Math.max(rightResult[0], rightResult[1]);
// 计算偷当前节点时的最大金额
// 如果偷当前节点,那么左右子节点都不能偷,只能加上左右子节点不偷时的金额
let robCurrent = node.val + leftResult[0] + rightResult[0];
// 返回当前节点两种情况下的最大金额
return [notRobCurrent, robCurrent];
};
// 调用 dfs 函数,从根节点开始计算
let finalResult = dfs(root);
// 最终结果是偷根节点和不偷根节点两种情况中的最大值
return Math.max(finalResult[0], finalResult[1]);
};
代码解析:
dfs
辅助函数 :这是树形DP的核心。它接收一个node
,并返回一个包含两个值的数组:[不偷当前节点的最大金额, 偷当前节点的最大金额]
。这个设计非常精妙!🧠- 递归出口 :当
node
为空时,表示到达叶子节点的下方,没有房子可偷,所以返回[0, 0]
。这是递归的基石。🧱 - 递归调用 :
dfs(node.left)
和dfs(node.right)
会递归地计算左右子树的偷窃情况,并返回它们各自的[不偷, 偷]
数组。层层递进,抽丝剥茧。🔍 - 计算
notRobCurrent
:如果当前节点不偷,那么左右子节点可以偷也可以不偷,我们取它们各自能偷到的最大值(Math.max(leftResult[0], leftResult[1])
)相加。这是不偷的智慧!😎 - 计算
robCurrent
:如果当前节点偷,那么左右子节点就不能偷了,我们只能加上它们"不偷"时的金额(leftResult[0]
和rightResult[0]
)。这是偷的艺术!🎨 - 返回结果 :
dfs
函数返回计算好的[notRobCurrent, robCurrent]
数组。每一步都清晰明了。✅ - 最终结果 :最后,我们调用
dfs(root)
得到整个树的[不偷根节点, 偷根节点]
的结果,然后取两者的最大值,就是我们能偷到的最高金额。是不是感觉树形DP也变得没那么神秘了?😉 简直是豁然开朗!✨
总结:动态规划,小偷的智慧之选!🧐
恭喜你,勇敢的"算法大盗"!你已经成功闯过了打家劫舍系列的三道难关!🎉 从线性的街道,到环形的社区,再到复杂的树形结构,我们一路披荆斩棘,最终都用动态规划的智慧完美解决了问题。
回顾这三道题目,你会发现动态规划的核心思想始终贯穿其中:
- 重叠子问题:无论是线性、环形还是树形,都存在大量重复计算的子问题。动态规划通过存储子问题的解,避免了重复计算,大大提高了效率。🚀
- 最优子结构 :一个大问题的最优解,可以通过其子问题的最优解来推导。比如,偷到第
i
个房子的最大金额,取决于偷到第i-1
和i-2
个房子的最大金额。这就像搭积木,每一块都完美契合。🏗️
动态规划不仅仅是一种算法,更是一种解决问题的思维方式。它教会我们如何将复杂的问题分解为更小的、可管理的部分,并系统地构建解决方案。就像一个聪明的小偷,不会盲目行动,而是会精心策划,步步为营,最终满载而归!💼 成为算法高手,从DP开始!💪
结束语:下次再"偷"!👋
好了,今天的"打家劫舍"之旅就到这里了。感谢各位的陪伴,希望你们有所收获!