返回 LeetCode 刷题

Markdown File

DP状态机和DP数组解决打家劫舍

DP状态机和DP数组解决打家劫舍.md

1打家劫舍问题:
2问:为什么只需要考虑从左往右(从右往左)偷?我就不能从中间开始偷吗?
3
4答:先偷 A 再偷 B,先偷 B 再偷 A,都是一样的,因为我们只关心最终能偷多少钱。推广,任意一种偷房子的顺序,都可以重新排列成从左到右(从右往左)偷。
5先看灵神的思路:
6一、递归搜索 + 保存计算结果 = 记忆化搜索
7这个cache装饰器就是我之前那个需要一个memo的字典在py里面,把索引key对应的value保存,否则就return对应的值,或者用C的方式就是拿一个容量大的memo数组,里面每个索引对应的值都为-1,然后每当记忆的时候就保存改变-1成对应的值,之后return,还是不如DP数组方便
8
9```python
10class Solution:
11 def rob(self, nums: List[int]) -> int:
12 # dfs(i) 表示从 nums[0] 到 nums[i] 最多能偷多少
13 @cache # 缓存装饰器,避免重复计算 dfs 的结果
14 def dfs(i: int) -> int:
15 if i < 0: # 递归边界(没有房子)
16 return 0
17 return max(dfs(i - 1), dfs(i - 2) + nums[i])
18
19 return dfs(len(nums) - 1) # 从最后一个房子开始思考
20```
21
22复杂度分析
23时间复杂度:O(n),其中 n 是 nums 的长度。
24空间复杂度:O(n)。
25二、1:1 翻译成递推
26直接翻译的话,dfs(i) 翻译成 f[i]。
27
28但记忆化搜索会访问 dfs(−2) 和 dfs(−1),f[−2] 和 f[−1] 下标越界了。
29
30解决办法:在 f 数组的前面插入两个 0,把 f 数组整体往右偏移 2 位。偏移后,dfs(i) 翻译成 f[i+2]。
31这样避免了下标是负数的问题
32注意只有 f 发生了偏移,nums 并没有偏移。
33
34
35```python
36class Solution:
37 def rob(self, nums: List[int]) -> int:
38 f = [0] * (len(nums) + 2)
39 for i, x in enumerate(nums):
40 f[i + 2] = max(f[i + 1], f[i] + x)
41 return f[-1]
42```
43
44复杂度分析
45时间复杂度:O(n)。其中 n 是 nums 的长度。
46空间复杂度:O(n)。
47三、空间优化
48
49```python
50class Solution:
51 def rob(self, nums: List[int]) -> int:
52 f0 = f1 = 0
53 for x in nums:
54 f0, f1 = f1, max(f1, f0 + x)
55 return f1
56```
57
58复杂度分析
59时间复杂度:O(n)。其中 n 是 nums 的长度。
60空间复杂度:O(1)。
61下面按C语言 我们做一下习题总结一下
62
63一、198 打家劫舍:最大值 DP
64二、494 目标和:方案数 DP / 01背包计数
65
66一、198 打家劫舍
671. 前提条件
68
69题目给一个数组:
70
71int* nums;
72int numsSize;
73
74nums[i] 表示第 i 个房子的钱。
75
76规则:不能偷相邻两个房子。
77
78目标:求最多能偷多少钱。
79
80LeetCode 198 的题意就是在不触发相邻房屋警报的情况下,求最大偷窃金额。
81
822. 先想递归
83
84定义:dfs(i)
85
86表示:从第 i 个房子开始偷,最多能偷多少钱。
87
88对于第 i 个房子,有两个选择。
89
90选择一:偷第 i 个
91
92偷了第 i 个,就不能偷 i + 1。
93
94所以收益是:nums[i] + dfs(i + 2)
95选择二:不偷第 i 个
96
97直接看下一个:dfs(i + 1)
98
99所以:
100
101dfs(i) = max(nums[i] + dfs(i + 2), dfs(i + 1))
102
103
1043.加 memo:记忆化搜索
105
106```c
107#include <stdlib.h>
108
109int max(int a, int b) {
110 return a > b ? a : b;
111}
112
113int dfs(int* nums, int numsSize, int i, int* memo) {
114 if (i >= numsSize) {
115 return 0;
116 }
117
118 if (memo[i] != -1) {
119 return memo[i];
120 }
121
122 int rob = nums[i] + dfs(nums, numsSize, i + 2, memo);
123 int notRob = dfs(nums, numsSize, i + 1, memo);
124//这里是把打劫的就是从最左边开始计算,打劫就跳到i+2个,然后memo保存,不打劫就i+1,因为只是说不能相邻,所以就是memo每次保存从第i个房子获得的最大收益
125 memo[i] = max(rob, notRob);
126
127 return memo[i];
128}
129
130int rob(int* nums, int numsSize) {
131 int* memo = malloc(numsSize * sizeof(int));
132
133 for (int i = 0; i < numsSize; i++) {
134 memo[i] = -1;
135 }
136//这里是分配空间,最后把return出来的memo[i]作为ans
137 int ans = dfs(nums, numsSize, 0, memo);
138
139 free(memo);
140
141 return ans;
142}
143
144```
145
146这就是:递归 + 缓存 = 记忆化搜索 = 自顶向下 DP
1475. 改成 DP 数组
148
149递归里:dfs(i) = max(nums[i] + dfs(i + 2), dfs(i + 1))
150
151换成 DP:dp[i] = max(nums[i] + dp[i + 2], dp[i + 1])
152
153这里:dp[i] 表示从第 i 个房子开始,最多能偷多少钱。
154
155因为 dp[i] 依赖后面的 dp[i + 1] 和 dp[i + 2],所以要从后往前算。
156
1576. DP 数组 C 代码
158
159```c
160#include <stdlib.h>
161
162int max(int a, int b) {
163 return a > b ? a : b;
164}
165
166int rob(int* nums, int numsSize) {
167 int* dp = malloc((numsSize + 2) * sizeof(int));
168
169 for (int i = 0; i < numsSize + 2; i++) {
170 dp[i] = 0;
171 }
172
173 for (int i = numsSize - 1; i >= 0; i--) {
174 int rob = nums[i] + dp[i + 2];
175 int notRob = dp[i + 1];
176//把倒着每次算出来的结果最大收益保存在dp数组里面
177 dp[i] = max(rob, notRob);
178 }
179
180 int ans = dp[0];
181
182 free(dp);
183
184 return ans;
185}
186```
187
1887. 另一种常见定义
189
190也可以定义:dp[i] 表示偷到第 i 个房子为止,最多能偷多少钱。
191
192转移:
193
194
195dp[i] = max(dp[i - 1], dp[i - 2] + nums[i])
196
197
198这个也是最常见的写法;很多题解按这个方向写。
199也就是从前往后算
200C 代码:
201
202
203```c
204#include <stdlib.h>
205
206int max(int a, int b) {
207 return a > b ? a : b;
208}
209
210int rob(int* nums, int numsSize) {
211 if (numsSize == 0) {
212 return 0;
213 }
214
215 if (numsSize == 1) {
216 return nums[0];
217 }
218
219 int* dp = malloc(numsSize * sizeof(int));
220
221 dp[0] = nums[0];
222 dp[1] = max(nums[0], nums[1]);
223
224 for (int i = 2; i < numsSize; i++) {
225 dp[i] = max(dp[i - 1], dp[i - 2] + nums[i]);
226 }
227
228 int ans = dp[numsSize - 1];
229
230 free(dp);
231
232 return ans;
233}
234```
235
2368. 空间优化
237
238因为:dp[i] 只依赖 dp[i - 1] 和 dp[i - 2]
239
240所以不用整个数组。
241
242
243```c
244int max(int a, int b) {
245 return a > b ? a : b;
246}
247
248int rob(int* nums, int numsSize) {
249 int prev2 = 0;
250 int prev1 = 0;
251
252 for (int i = 0; i < numsSize; i++) {
253 int cur = max(prev1, prev2 + nums[i]);
254
255 prev2 = prev1;
256 prev1 = cur;
257 }
258
259 return prev1;
260}
261
262```
263
264含义:
265
266prev2 = dp[i - 2]
267prev1 = dp[i - 1]
268cur = dp[i]
2699. 易错点
270易错点 1:偷了当前房子,不能偷下一个
271
272所以不是:nums[i] + dfs(i + 1)
273
274而是:nums[i] + dfs(i + 2)
275
276易错点 2:最大值问题用 max
277
278打家劫舍问的是:最多多少钱
279
280所以转移里是:
281
282max(...)
283
284不是加起来。
285
286易错点 3:DP 定义不同,遍历方向不同
287
288如果定义:dp[i] = 从第 i 个房子开始偷
289
290就从后往前算。
291
292如果定义:dp[i] = 偷到第 i 个房子为止
293
294就从前往后算。
295
29610. 题型总结
297198 打家劫舍:
298
299题型:
300 线性 DP / 最大值 DP
301
302核心决策:
303 偷当前房子
304 不偷当前房子
305
306递归:
307
308dfs(i) = max(nums[i] + dfs(i + 2), dfs(i + 1))
309
310
311DP:
312
313dp[i] = max(dp[i - 1], dp[i - 2] + nums[i])
314
315
316空间优化:
317 只保留前两个状态。
318
319一句话:
320
321打家劫舍的本质是:每个位置都在“偷”和“不偷”之间做最大收益选择。
322二、494 目标和
3231. 前提条件
324
325题目给:
326
327int* nums;
328int numsSize;
329int target;
330
331每个数前面可以加:
332
333+
334-
335
336要求:
337
338最终表达式等于 target 的方案数。
339
340LeetCode 494 的题意是:给数组中每个整数前添加 + 或 -,构造表达式,使结果等于 target,返回方案数。
341
3422. 先想递归
343
344定义:
345
346dfs(i, sum)
347
348表示:
349
350处理到第 i 个数时,当前和是 sum,最后能凑成 target 的方案数。
351
352对于 nums[i] 有两个选择。
353
354选择一:加 +
355dfs(i + 1, sum + nums[i])
356选择二:加 -
357dfs(i + 1, sum - nums[i])
358
359所以:
360
361dfs(i, sum)
362= dfs(i + 1, sum + nums[i])
363+ dfs(i + 1, sum - nums[i])
364
365为什么是加?
366
367因为题目问:
368
369有多少种方案
370
371所以左右两种选择的方案数要加起来。
372
3733. 暴力递归 C 代码
374
375```c
376int dfs(int* nums, int numsSize, int target, int i, int sum) {
377 if (i == numsSize) {
378 if (sum == target) {
379 return 1;
380 } else {
381 return 0;
382 }
383 }
384
385 int add = dfs(nums, numsSize, target, i + 1, sum + nums[i]);
386 int sub = dfs(nums, numsSize, target, i + 1, sum - nums[i]);
387
388 return add + sub;
389}
390
391int findTargetSumWays(int* nums, int numsSize, int target) {
392 return dfs(nums, numsSize, target, 0, 0);
393}
394
395```
396
397这个思路直观,但会超时。
398
399因为很多:
400
401dfs(i, sum)
402
403会重复出现。
404
4054. 记忆化搜索:处理负数 sum
406
407问题是:
408
409sum 可能是负数,不能直接当数组下标。
410
411所以要加一个偏移量。
412
413设:
414
415
416total = nums 所有元素之和
417
418
419那么 sum 的范围是:
420
421
422[-total, total]
423
424
425可以用:
426
427sum + total
428
429把负数下标平移成非负数。
430
431例如:
432
433
434sum = -3
435
436
437total = 10
438
439
440index = sum + total = 7
441
4425. 记忆化搜索 C 代码
443
444这里用二维数组:
445
446memo[i][sum + total]
447
448表示:
449
450处理到 i,当前和为 sum 时的方案数。
451
452```c
453#include <stdlib.h>
454
455int dfs(int* nums, int numsSize, int target, int i, int sum,
456 int total, int** memo) {
457 if (i == numsSize) {
458 if (sum == target) {
459 return 1;
460 } else {
461 return 0;
462 }
463 }
464
465 int index = sum + total;
466
467 if (memo[i][index] != -1) {
468 return memo[i][index];
469 }
470
471 int add = dfs(nums, numsSize, target, i + 1, sum + nums[i], total, memo);
472 int sub = dfs(nums, numsSize, target, i + 1, sum - nums[i], total, memo);
473
474 memo[i][index] = add + sub;
475
476 return memo[i][index];
477}
478
479int findTargetSumWays(int* nums, int numsSize, int target) {
480 int total = 0;
481
482 for (int i = 0; i < numsSize; i++) {
483 total += nums[i];
484 }
485
486 if (target > total || target < -total) {
487 return 0;
488 }
489
490 int cols = 2 * total + 1;
491
492 int** memo = malloc(numsSize * sizeof(int*));
493
494 for (int i = 0; i < numsSize; i++) {
495 memo[i] = malloc(cols * sizeof(int));
496
497 for (int j = 0; j < cols; j++) {
498 memo[i][j] = -1;
499 }
500 }
501
502 int ans = dfs(nums, numsSize, target, 0, 0, total, memo);
503
504 for (int i = 0; i < numsSize; i++) {
505 free(memo[i]);
506 }
507
508 free(memo);
509
510 return ans;
511}
512```
513
5146. 转成 01 背包
515
516目标和这题还有一个更常用、更重要的 DP 转换。
517
518把加 + 的数看成一组,和为 positive。
519
520把加 - 的数看成一组,和为 negative。
521
522有:
523
524
525positive - negative = target
526
527
528positive + negative = total
529
530
531两式相加:
532
533
5342 * positive = target + total
535
536
537所以:
538
539
540positive = (target + total) / 2
541
542
543于是问题变成:
544
545从 nums 中选一些数,使它们的和为 positive,有多少种选法。
546
547这就是 01 背包的“方案数”问题;很多题解也会这样把目标和转换成背包计数问题。
548
5497. 什么情况下无解?
550
551positive = (target + total) / 2
552
553
554必须是整数。
555
556所以如果:
557
558target + total 是奇数
559
560无解。
561
562另外如果:
563
564target > total 或 target < -total
565
566也无解。
567
5688. 01 背包 DP 状态定义
569
570定义:
571
572dp[j]
573
574表示:
575
576凑出和为 j 的方案数。
577
578初始化:
579
580
581dp[0] = 1
582
583
584意思是:
585
586什么都不选,凑出 0,有 1 种方案。
5879. 状态转移
588
589对于每个数 x = nums[i]:
590
591选 x:
592
593dp[j] += dp[j - x]
594
595
596也就是:
597
598
599dp[j] = dp[j] + dp[j - x];
600
601
602注意 j 要倒序遍历。
603
604因为每个数只能用一次。
605
60610. 01 背包 C 代码
607
608```c
609#include <stdlib.h>
610
611int findTargetSumWays(int* nums, int numsSize, int target) {
612 int total = 0;
613
614 for (int i = 0; i < numsSize; i++) {
615 total += nums[i];
616 }
617
618 if (target > total || target < -total) {
619 return 0;
620 }
621
622 if ((target + total) % 2 != 0) {
623 return 0;
624 }
625
626 int positive = (target + total) / 2;
627
628 int* dp = malloc((positive + 1) * sizeof(int));
629
630 for (int j = 0; j <= positive; j++) {
631 dp[j] = 0;
632 }
633
634 dp[0] = 1;
635
636 for (int i = 0; i < numsSize; i++) {
637 int x = nums[i];
638
639 for (int j = positive; j >= x; j--) {
640 dp[j] += dp[j - x];
641 }
642 }
643
644 int ans = dp[positive];
645
646 free(dp);
647
648 return ans;
649}
650```
651
65211. 为什么 j 要倒序?
653
654因为这是:
655
65601 背包
657
658每个数只能选一次。
659
660如果正序:
661
662for (int j = x; j <= positive; j++)
663
664会导致当前 x 被重复使用。
665
666倒序:
667
668for (int j = positive; j >= x; j--)
669
670可以保证:
671
672dp[j - x] 还是上一轮的结果
673
674也就是当前数字只用一次。
675
67612. 目标和易错点
677易错点 1:方案数问题用加法
678
679目标和问:
680
681有多少种方案
682
683所以转移是:
684
685add + sub
686
687或者:
688
689
690dp[j] += dp[j - x]
691
692
693不是 max。
694
695易错点 2:target 可能是负数
696
697如果用记忆化搜索,sum 可能为负数,要加偏移量。
698
699sum + total
700易错点 3:背包转换时要判断奇偶
701
702如果:
703
704target + total
705
706是奇数,那么:
707
708
709positive = (target + total) / 2
710
711
712不是整数,直接返回 0。
713
714易错点 4:背包容量是 positive
715
716不是 target。
717
718是:
719
720
721positive = (target + total) / 2
722
72313. 题型总结
724494 目标和:
725
726题型:
727 计数 DP / 01 背包方案数
728
729递归定义:
730 dfs(i, sum)
731 表示处理到第 i 个数,当前和为 sum,最后凑成 target 的方案数。
732
733递归转移:
734 dfs(i, sum)
735 = dfs(i + 1, sum + nums[i])
736 + dfs(i + 1, sum - nums[i])
737
738背包转换:
739
740positive - negative = target
741
742
743positive + negative = total
744
745
746positive = (target + total) / 2
747
748
749转换后:
750 从 nums 中选一些数,使和为 positive,求方案数。
751
752背包状态:
753 dp[j] 表示凑出 j 的方案数。
754
755转移:
756
757dp[j] += dp[j - nums[i]]
758
759
760遍历:
761 j 倒序。
762
763一句话:
764
765目标和的本质是:每个数前面选 + 或 -,计数所有能到 target 的路径;也可以转成 01 背包的选数方案数。
766三、两个题放一起对比
767题目 问什么 决策 转移核心
768198 打家劫舍 最大收益 偷 / 不偷 max
769494 目标和 方案数量 加正号 / 加负号 +
770
771最重要区别:
772
773问最大值:用 max。
774问最小值:用 min。
775问方案数:用加法。
776
777DP 不是背公式,先看题目问的是哪种答案。
778
Rendered Preview

打家劫舍问题:
问:为什么只需要考虑从左往右(从右往左)偷?我就不能从中间开始偷吗?

答:先偷 A 再偷 B,先偷 B 再偷 A,都是一样的,因为我们只关心最终能偷多少钱。推广,任意一种偷房子的顺序,都可以重新排列成从左到右(从右往左)偷。
先看灵神的思路:
一、递归搜索 + 保存计算结果 = 记忆化搜索
这个cache装饰器就是我之前那个需要一个memo的字典在py里面,把索引key对应的value保存,否则就return对应的值,或者用C的方式就是拿一个容量大的memo数组,里面每个索引对应的值都为-1,然后每当记忆的时候就保存改变-1成对应的值,之后return,还是不如DP数组方便

class Solution:
    def rob(self, nums: List[int]) -> int:
        # dfs(i) 表示从 nums[0] 到 nums[i] 最多能偷多少
        @cache  # 缓存装饰器,避免重复计算 dfs 的结果
        def dfs(i: int) -> int:
            if i < 0:  # 递归边界(没有房子)
                return 0
            return max(dfs(i - 1), dfs(i - 2) + nums[i])

        return dfs(len(nums) - 1)  # 从最后一个房子开始思考

复杂度分析
时间复杂度:O(n),其中 n 是 nums 的长度。
空间复杂度:O(n)。
二、1:1 翻译成递推
直接翻译的话,dfs(i) 翻译成 f[i]。

但记忆化搜索会访问 dfs(−2) 和 dfs(−1),f[−2] 和 f[−1] 下标越界了。

解决办法:在 f 数组的前面插入两个 0,把 f 数组整体往右偏移 2 位。偏移后,dfs(i) 翻译成 f[i+2]。
这样避免了下标是负数的问题
注意只有 f 发生了偏移,nums 并没有偏移。

class Solution:
    def rob(self, nums: List[int]) -> int:
        f = [0] * (len(nums) + 2)
        for i, x in enumerate(nums):
            f[i + 2] = max(f[i + 1], f[i] + x)
        return f[-1]

复杂度分析
时间复杂度:O(n)。其中 n 是 nums 的长度。
空间复杂度:O(n)。
三、空间优化

class Solution:
    def rob(self, nums: List[int]) -> int:
        f0 = f1 = 0
        for x in nums:
            f0, f1 = f1, max(f1, f0 + x)
        return f1

复杂度分析
时间复杂度:O(n)。其中 n 是 nums 的长度。
空间复杂度:O(1)。
下面按C语言 我们做一下习题总结一下

一、198 打家劫舍:最大值 DP
二、494 目标和:方案数 DP / 01背包计数

一、198 打家劫舍

  1. 前提条件

题目给一个数组:

int* nums;
int numsSize;

nums[i] 表示第 i 个房子的钱。

规则:不能偷相邻两个房子。

目标:求最多能偷多少钱。

LeetCode 198 的题意就是在不触发相邻房屋警报的情况下,求最大偷窃金额。

  1. 先想递归

定义:dfs(i)

表示:从第 i 个房子开始偷,最多能偷多少钱。

对于第 i 个房子,有两个选择。

选择一:偷第 i 个

偷了第 i 个,就不能偷 i + 1。

所以收益是:nums[i] + dfs(i + 2)
选择二:不偷第 i 个

直接看下一个:dfs(i + 1)

所以:

dfs(i)=max(nums[i]+dfs(i+2),dfs(i+1))dfs(i) = max(nums[i] + dfs(i + 2), dfs(i + 1))

3.加 memo:记忆化搜索

#include <stdlib.h>

int max(int a, int b) {
    return a > b ? a : b;
}

int dfs(int* nums, int numsSize, int i, int* memo) {
    if (i >= numsSize) {
        return 0;
    }

    if (memo[i] != -1) {
        return memo[i];
    }

    int rob = nums[i] + dfs(nums, numsSize, i + 2, memo);
    int notRob = dfs(nums, numsSize, i + 1, memo);
//这里是把打劫的就是从最左边开始计算,打劫就跳到i+2个,然后memo保存,不打劫就i+1,因为只是说不能相邻,所以就是memo每次保存从第i个房子获得的最大收益
    memo[i] = max(rob, notRob);

    return memo[i];
}

int rob(int* nums, int numsSize) {
    int* memo = malloc(numsSize * sizeof(int));

    for (int i = 0; i < numsSize; i++) {
        memo[i] = -1;
    }
//这里是分配空间,最后把return出来的memo[i]作为ans
    int ans = dfs(nums, numsSize, 0, memo);

    free(memo);

    return ans;
}

这就是:递归 + 缓存 = 记忆化搜索 = 自顶向下 DP
5. 改成 DP 数组

递归里:dfs(i) = max(nums[i] + dfs(i + 2), dfs(i + 1))

换成 DP:dp[i] = max(nums[i] + dp[i + 2], dp[i + 1])

这里:dp[i] 表示从第 i 个房子开始,最多能偷多少钱。

因为 dp[i] 依赖后面的 dp[i + 1] 和 dp[i + 2],所以要从后往前算。

  1. DP 数组 C 代码
#include <stdlib.h>

int max(int a, int b) {
    return a > b ? a : b;
}

int rob(int* nums, int numsSize) {
    int* dp = malloc((numsSize + 2) * sizeof(int));

    for (int i = 0; i < numsSize + 2; i++) {
        dp[i] = 0;
    }

    for (int i = numsSize - 1; i >= 0; i--) {
        int rob = nums[i] + dp[i + 2];
        int notRob = dp[i + 1];
//把倒着每次算出来的结果最大收益保存在dp数组里面
        dp[i] = max(rob, notRob);
    }

    int ans = dp[0];

    free(dp);

    return ans;
}
  1. 另一种常见定义

也可以定义:dp[i] 表示偷到第 i 个房子为止,最多能偷多少钱。

转移:

dp[i]=max(dp[i1],dp[i2]+nums[i])dp[i] = max(dp[i - 1], dp[i - 2] + nums[i])

这个也是最常见的写法;很多题解按这个方向写。
也就是从前往后算
C 代码:

#include <stdlib.h>

int max(int a, int b) {
    return a > b ? a : b;
}

int rob(int* nums, int numsSize) {
    if (numsSize == 0) {
        return 0;
    }

    if (numsSize == 1) {
        return nums[0];
    }

    int* dp = malloc(numsSize * sizeof(int));

    dp[0] = nums[0];
    dp[1] = max(nums[0], nums[1]);

    for (int i = 2; i < numsSize; i++) {
        dp[i] = max(dp[i - 1], dp[i - 2] + nums[i]);
    }

    int ans = dp[numsSize - 1];

    free(dp);

    return ans;
}
  1. 空间优化

因为:dp[i] 只依赖 dp[i - 1] 和 dp[i - 2]

所以不用整个数组。

int max(int a, int b) {
    return a > b ? a : b;
}

int rob(int* nums, int numsSize) {
    int prev2 = 0;
    int prev1 = 0;

    for (int i = 0; i < numsSize; i++) {
        int cur = max(prev1, prev2 + nums[i]);

        prev2 = prev1;
        prev1 = cur;
    }

    return prev1;
}

含义:

prev2 = dp[i - 2]
prev1 = dp[i - 1]
cur = dp[i]
9. 易错点
易错点 1:偷了当前房子,不能偷下一个

所以不是:nums[i] + dfs(i + 1)

而是:nums[i] + dfs(i + 2)

易错点 2:最大值问题用 max

打家劫舍问的是:最多多少钱

所以转移里是:

max(...)

不是加起来。

易错点 3:DP 定义不同,遍历方向不同

如果定义:dp[i] = 从第 i 个房子开始偷

就从后往前算。

如果定义:dp[i] = 偷到第 i 个房子为止

就从前往后算。

  1. 题型总结
    198 打家劫舍:

题型:
线性 DP / 最大值 DP

核心决策:
偷当前房子
不偷当前房子

递归:

dfs(i)=max(nums[i]+dfs(i+2),dfs(i+1))dfs(i) = max(nums[i] + dfs(i + 2), dfs(i + 1))

DP:

dp[i]=max(dp[i1],dp[i2]+nums[i])dp[i] = max(dp[i - 1], dp[i - 2] + nums[i])

空间优化:
只保留前两个状态。

一句话:

打家劫舍的本质是:每个位置都在“偷”和“不偷”之间做最大收益选择。
二、494 目标和

  1. 前提条件

题目给:

int* nums;
int numsSize;
int target;

每个数前面可以加:

要求:

最终表达式等于 target 的方案数。

LeetCode 494 的题意是:给数组中每个整数前添加 + 或 -,构造表达式,使结果等于 target,返回方案数。

  1. 先想递归

定义:

dfs(i, sum)

表示:

处理到第 i 个数时,当前和是 sum,最后能凑成 target 的方案数。

对于 nums[i] 有两个选择。

选择一:加 +
dfs(i + 1, sum + nums[i])
选择二:加 -
dfs(i + 1, sum - nums[i])

所以:

dfs(i, sum)
= dfs(i + 1, sum + nums[i])

  • dfs(i + 1, sum - nums[i])

为什么是加?

因为题目问:

有多少种方案

所以左右两种选择的方案数要加起来。

  1. 暴力递归 C 代码
int dfs(int* nums, int numsSize, int target, int i, int sum) {
    if (i == numsSize) {
        if (sum == target) {
            return 1;
        } else {
            return 0;
        }
    }

    int add = dfs(nums, numsSize, target, i + 1, sum + nums[i]);
    int sub = dfs(nums, numsSize, target, i + 1, sum - nums[i]);

    return add + sub;
}

int findTargetSumWays(int* nums, int numsSize, int target) {
    return dfs(nums, numsSize, target, 0, 0);
}

这个思路直观,但会超时。

因为很多:

dfs(i, sum)

会重复出现。

  1. 记忆化搜索:处理负数 sum

问题是:

sum 可能是负数,不能直接当数组下标。

所以要加一个偏移量。

设:

total=nums所有元素之和total = nums 所有元素之和

那么 sum 的范围是:

[total,total][-total, total]

可以用:

sum + total

把负数下标平移成非负数。

例如:

sum=3sum = -3 total=10total = 10 index=sum+total=7index = sum + total = 7
  1. 记忆化搜索 C 代码

这里用二维数组:

memo[i][sum + total]

表示:

处理到 i,当前和为 sum 时的方案数。

#include <stdlib.h>

int dfs(int* nums, int numsSize, int target, int i, int sum,
        int total, int** memo) {
    if (i == numsSize) {
        if (sum == target) {
            return 1;
        } else {
            return 0;
        }
    }

    int index = sum + total;

    if (memo[i][index] != -1) {
        return memo[i][index];
    }

    int add = dfs(nums, numsSize, target, i + 1, sum + nums[i], total, memo);
    int sub = dfs(nums, numsSize, target, i + 1, sum - nums[i], total, memo);

    memo[i][index] = add + sub;

    return memo[i][index];
}

int findTargetSumWays(int* nums, int numsSize, int target) {
    int total = 0;

    for (int i = 0; i < numsSize; i++) {
        total += nums[i];
    }

    if (target > total || target < -total) {
        return 0;
    }

    int cols = 2 * total + 1;

    int** memo = malloc(numsSize * sizeof(int*));

    for (int i = 0; i < numsSize; i++) {
        memo[i] = malloc(cols * sizeof(int));

        for (int j = 0; j < cols; j++) {
            memo[i][j] = -1;
        }
    }

    int ans = dfs(nums, numsSize, target, 0, 0, total, memo);

    for (int i = 0; i < numsSize; i++) {
        free(memo[i]);
    }

    free(memo);

    return ans;
}
  1. 转成 01 背包

目标和这题还有一个更常用、更重要的 DP 转换。

把加 + 的数看成一组,和为 positive。

把加 - 的数看成一组,和为 negative。

有:

positivenegative=targetpositive - negative = target positive+negative=totalpositive + negative = total

两式相加:

2positive=target+total2 * positive = target + total

所以:

positive=(target+total)/2positive = (target + total) / 2

于是问题变成:

从 nums 中选一些数,使它们的和为 positive,有多少种选法。

这就是 01 背包的“方案数”问题;很多题解也会这样把目标和转换成背包计数问题。

  1. 什么情况下无解?
positive=(target+total)/2positive = (target + total) / 2

必须是整数。

所以如果:

target + total 是奇数

无解。

另外如果:

target > total 或 target < -total

也无解。

  1. 01 背包 DP 状态定义

定义:

dp[j]

表示:

凑出和为 j 的方案数。

初始化:

dp[0]=1dp[0] = 1

意思是:

什么都不选,凑出 0,有 1 种方案。
9. 状态转移

对于每个数 x = nums[i]:

选 x:

dp[j]+=dp[jx]dp[j] += dp[j - x]

也就是:

dp[j]=dp[j]+dp[jx];dp[j] = dp[j] + dp[j - x];

注意 j 要倒序遍历。

因为每个数只能用一次。

  1. 01 背包 C 代码
#include <stdlib.h>

int findTargetSumWays(int* nums, int numsSize, int target) {
    int total = 0;

    for (int i = 0; i < numsSize; i++) {
        total += nums[i];
    }

    if (target > total || target < -total) {
        return 0;
    }

    if ((target + total) % 2 != 0) {
        return 0;
    }

    int positive = (target + total) / 2;

    int* dp = malloc((positive + 1) * sizeof(int));

    for (int j = 0; j <= positive; j++) {
        dp[j] = 0;
    }

    dp[0] = 1;

    for (int i = 0; i < numsSize; i++) {
        int x = nums[i];

        for (int j = positive; j >= x; j--) {
            dp[j] += dp[j - x];
        }
    }

    int ans = dp[positive];

    free(dp);

    return ans;
}
  1. 为什么 j 要倒序?

因为这是:

01 背包

每个数只能选一次。

如果正序:

for (int j = x; j <= positive; j++)

会导致当前 x 被重复使用。

倒序:

for (int j = positive; j >= x; j--)

可以保证:

dp[j - x] 还是上一轮的结果

也就是当前数字只用一次。

  1. 目标和易错点
    易错点 1:方案数问题用加法

目标和问:

有多少种方案

所以转移是:

add + sub

或者:

dp[j]+=dp[jx]dp[j] += dp[j - x]

不是 max。

易错点 2:target 可能是负数

如果用记忆化搜索,sum 可能为负数,要加偏移量。

sum + total
易错点 3:背包转换时要判断奇偶

如果:

target + total

是奇数,那么:

positive=(target+total)/2positive = (target + total) / 2

不是整数,直接返回 0。

易错点 4:背包容量是 positive

不是 target。

是:

positive=(target+total)/2positive = (target + total) / 2
  1. 题型总结
    494 目标和:

题型:
计数 DP / 01 背包方案数

递归定义:
dfs(i, sum)
表示处理到第 i 个数,当前和为 sum,最后凑成 target 的方案数。

递归转移:
dfs(i, sum)
= dfs(i + 1, sum + nums[i])
+ dfs(i + 1, sum - nums[i])

背包转换:

positivenegative=targetpositive - negative = target positive+negative=totalpositive + negative = total positive=(target+total)/2positive = (target + total) / 2

转换后:
从 nums 中选一些数,使和为 positive,求方案数。

背包状态:
dp[j] 表示凑出 j 的方案数。

转移:

dp[j]+=dp[jnums[i]]dp[j] += dp[j - nums[i]]

遍历:
j 倒序。

一句话:

目标和的本质是:每个数前面选 + 或 -,计数所有能到 target 的路径;也可以转成 01 背包的选数方案数。
三、两个题放一起对比
题目 问什么 决策 转移核心
198 打家劫舍 最大收益 偷 / 不偷 max
494 目标和 方案数量 加正号 / 加负号 +

最重要区别:

问最大值:用 max。
问最小值:用 min。
问方案数:用加法。

DP 不是背公式,先看题目问的是哪种答案。