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
打家劫舍问题:
问:为什么只需要考虑从左往右(从右往左)偷?我就不能从中间开始偷吗?
答:先偷 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:
@cache
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 打家劫舍
- 前提条件
题目给一个数组:
int* nums;
int numsSize;
nums[i] 表示第 i 个房子的钱。
规则:不能偷相邻两个房子。
目标:求最多能偷多少钱。
LeetCode 198 的题意就是在不触发相邻房屋警报的情况下,求最大偷窃金额。
- 先想递归
定义: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))
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);
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;
}
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],所以要从后往前算。
- 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[i] = max(rob, notRob);
}
int ans = dp[0];
free(dp);
return ans;
}
- 另一种常见定义
也可以定义:dp[i] 表示偷到第 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;
}
- 空间优化
因为: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 个房子为止
就从前往后算。
- 题型总结
198 打家劫舍:
题型:
线性 DP / 最大值 DP
核心决策:
偷当前房子
不偷当前房子
递归:
dfs(i)=max(nums[i]+dfs(i+2),dfs(i+1))
DP:
dp[i]=max(dp[i−1],dp[i−2]+nums[i])
空间优化:
只保留前两个状态。
一句话:
打家劫舍的本质是:每个位置都在“偷”和“不偷”之间做最大收益选择。
二、494 目标和
- 前提条件
题目给:
int* nums;
int numsSize;
int target;
每个数前面可以加:
要求:
最终表达式等于 target 的方案数。
LeetCode 494 的题意是:给数组中每个整数前添加 + 或 -,构造表达式,使结果等于 target,返回方案数。
- 先想递归
定义:
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])
为什么是加?
因为题目问:
有多少种方案
所以左右两种选择的方案数要加起来。
- 暴力递归 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)
会重复出现。
- 记忆化搜索:处理负数 sum
问题是:
sum 可能是负数,不能直接当数组下标。
所以要加一个偏移量。
设:
total=nums所有元素之和
那么 sum 的范围是:
[−total,total]
可以用:
sum + total
把负数下标平移成非负数。
例如:
sum=−3
total=10
index=sum+total=7
- 记忆化搜索 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;
}
- 转成 01 背包
目标和这题还有一个更常用、更重要的 DP 转换。
把加 + 的数看成一组,和为 positive。
把加 - 的数看成一组,和为 negative。
有:
positive−negative=target
positive+negative=total
两式相加:
2∗positive=target+total
所以:
positive=(target+total)/2
于是问题变成:
从 nums 中选一些数,使它们的和为 positive,有多少种选法。
这就是 01 背包的“方案数”问题;很多题解也会这样把目标和转换成背包计数问题。
- 什么情况下无解?
positive=(target+total)/2
必须是整数。
所以如果:
target + total 是奇数
无解。
另外如果:
target > total 或 target < -total
也无解。
- 01 背包 DP 状态定义
定义:
dp[j]
表示:
凑出和为 j 的方案数。
初始化:
dp[0]=1
意思是:
什么都不选,凑出 0,有 1 种方案。
9. 状态转移
对于每个数 x = nums[i]:
选 x:
dp[j]+=dp[j−x]
也就是:
dp[j]=dp[j]+dp[j−x];
注意 j 要倒序遍历。
因为每个数只能用一次。
- 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;
}
- 为什么 j 要倒序?
因为这是:
01 背包
每个数只能选一次。
如果正序:
for (int j = x; j <= positive; j++)
会导致当前 x 被重复使用。
倒序:
for (int j = positive; j >= x; j--)
可以保证:
dp[j - x] 还是上一轮的结果
也就是当前数字只用一次。
- 目标和易错点
易错点 1:方案数问题用加法
目标和问:
有多少种方案
所以转移是:
add + sub
或者:
dp[j]+=dp[j−x]
不是 max。
易错点 2:target 可能是负数
如果用记忆化搜索,sum 可能为负数,要加偏移量。
sum + total
易错点 3:背包转换时要判断奇偶
如果:
target + total
是奇数,那么:
positive=(target+total)/2
不是整数,直接返回 0。
易错点 4:背包容量是 positive
不是 target。
是:
positive=(target+total)/2
- 题型总结
494 目标和:
题型:
计数 DP / 01 背包方案数
递归定义:
dfs(i, sum)
表示处理到第 i 个数,当前和为 sum,最后凑成 target 的方案数。
递归转移:
dfs(i, sum)
= dfs(i + 1, sum + nums[i])
+ dfs(i + 1, sum - nums[i])
背包转换:
positive−negative=target
positive+negative=total
positive=(target+total)/2
转换后:
从 nums 中选一些数,使和为 positive,求方案数。
背包状态:
dp[j] 表示凑出 j 的方案数。
转移:
dp[j]+=dp[j−nums[i]]
遍历:
j 倒序。
一句话:
目标和的本质是:每个数前面选 + 或 -,计数所有能到 target 的路径;也可以转成 01 背包的选数方案数。
三、两个题放一起对比
题目 问什么 决策 转移核心
198 打家劫舍 最大收益 偷 / 不偷 max
494 目标和 方案数量 加正号 / 加负号 +
最重要区别:
问最大值:用 max。
问最小值:用 min。
问方案数:用加法。
DP 不是背公式,先看题目问的是哪种答案。