Skip to content

Commit

Permalink
update:更新核心算法套路
Browse files Browse the repository at this point in the history
  • Loading branch information
labuladong committed Jul 8, 2021
1 parent c967627 commit e94b84c
Show file tree
Hide file tree
Showing 5 changed files with 646 additions and 0 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ GitHub Pages 地址:https://labuladong.github.io/algo/
* [动态规划解题框架](动态规划系列/动态规划详解进阶.md)
* [动态规划答疑篇](动态规划系列/最优子结构.md)
* [回溯算法解题框架](算法思维系列/回溯算法详解修订版.md)
* [提高刷题幸福感的小技巧](技术/刷题技巧.md)
* [为了学会二分查找,我写了首诗](算法思维系列/二分查找详解.md)
* [滑动窗口解题框架](算法思维系列/滑动窗口技巧.md)
* [双指针技巧解题框架](算法思维系列/双指针技巧.md)
Expand All @@ -80,6 +81,7 @@ GitHub Pages 地址:https://labuladong.github.io/algo/
* [动态规划答疑篇](动态规划系列/最优子结构.md)
* [动态规划设计:最长递增子序列](动态规划系列/动态规划设计:最长递增子序列.md)
* [编辑距离](动态规划系列/编辑距离.md)
* [经典动态规划:0-1 背包问题](动态规划系列/背包问题.md)
* [经典动态规划问题:高楼扔鸡蛋](动态规划系列/高楼扔鸡蛋问题.md)
* [经典动态规划问题:高楼扔鸡蛋(进阶)](动态规划系列/高楼扔鸡蛋进阶.md)
* [动态规划之子序列问题解题模板](动态规划系列/子序列问题模板.md)
Expand Down
Binary file modified pictures/souyisou.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
160 changes: 160 additions & 0 deletions 动态规划系列/背包问题.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
# 动态规划之背包问题


<p align='center'>
<a href="https://github.com/labuladong/fucking-algorithm" target="view_window"><img alt="GitHub" src="https://img.shields.io/github/stars/labuladong/fucking-algorithm?label=Stars&style=flat-square&logo=GitHub"></a>
<a href="https://www.zhihu.com/people/labuladong"><img src="https://img.shields.io/badge/%E7%9F%A5%E4%B9%[email protected]?style=flat-square&logo=Zhihu"></a>
<a href="https://i.loli.net/2020/10/10/MhRTyUKfXZOlQYN.jpg"><img src="https://img.shields.io/badge/公众号[email protected]?style=flat-square&logo=WeChat"></a>
<a href="https://space.bilibili.com/14089380"><img src="https://img.shields.io/badge/B站[email protected]?style=flat-square&logo=Bilibili"></a>
</p>

![](../pictures/souyisou.png)

相关推荐:
* [经典动态规划:最长公共子序列](https://labuladong.gitee.io/algo/)
* [特殊数据结构:单调栈](https://labuladong.gitee.io/algo/)

**-----------**

本文有视频版:[0-1背包问题详解](https://www.bilibili.com/video/BV15B4y1P7X7/)

后台天天有人问背包问题,这个问题其实不难啊,如果我们号动态规划系列的十几篇文章你都看过,借助框架,遇到背包问题可以说是手到擒来好吧。无非就是状态 + 选择,也没啥特别之处嘛。

今天就来说一下背包问题吧,就讨论最常说的 0-1 背包问题。描述:

给你一个可装载重量为 `W` 的背包和 `N` 个物品,每个物品有重量和价值两个属性。其中第 `i` 个物品的重量为 `wt[i]`,价值为 `val[i]`,现在让你用这个背包装物品,最多能装的价值是多少?

举个简单的例子,输入如下:

```
N = 3, W = 4
wt = [2, 1, 3]
val = [4, 2, 3]
```

算法返回 6,选择前两件物品装进背包,总重量 3 小于 `W`,可以获得最大价值 6。

题目就是这么简单,一个典型的动态规划问题。这个题目中的物品不可以分割,要么装进包里,要么不装,不能说切成两块装一半。这就是 0-1 背包这个名词的来历。

解决这个问题没有什么排序之类巧妙的方法,只能穷举所有可能,根据我们 [动态规划详解](https://labuladong.gitee.io/algo/) 中的套路,直接走流程就行了。

### 动规标准套路

看来我得每篇动态规划文章都得重复一遍套路,历史文章中的动态规划问题都是按照下面的套路来的。

**第一步要明确两点,「状态」和「选择」**

先说状态,如何才能描述一个问题局面?只要给几个物品和一个背包的容量限制,就形成了一个背包问题呀。**所以状态有两个,就是「背包的容量」和「可选择的物品」**

再说选择,也很容易想到啊,对于每件物品,你能选择什么?**选择就是「装进背包」或者「不装进背包」嘛**

明白了状态和选择,动态规划问题基本上就解决了,只要往这个框架套就完事儿了:

```python
for 状态1 in 状态1的所有取值:
for 状态2 in 状态2的所有取值:
for ...
dp[状态1][状态2][...] = 择优(选择1,选择2...)
```

PS:此框架出自历史文章 [团灭 LeetCode 股票问题](https://labuladong.gitee.io/algo/)。

**第二步要明确 `dp` 数组的定义**

首先看看刚才找到的「状态」,有两个,也就是说我们需要一个二维 `dp` 数组。

`dp[i][w]` 的定义如下:对于前 `i` 个物品,当前背包的容量为 `w`,这种情况下可以装的最大价值是 `dp[i][w]`

比如说,如果 `dp[3][5] = 6`,其含义为:对于给定的一系列物品中,若只对前 3 个物品进行选择,当背包容量为 5 时,最多可以装下的价值为 6

PS:为什么要这么定义?便于状态转移,或者说这就是套路,记下来就行了。建议看一下我们的动态规划系列文章,几种套路都被扒得清清楚楚了。

根据这个定义,我们想求的最终答案就是 `dp[N][W]`。base case 就是 `dp[0][..] = dp[..][0] = 0`,因为没有物品或者背包没有空间的时候,能装的最大价值就是 0

细化上面的框架:

```python
int[][] dp[N+1][W+1]
dp[0][..] = 0
dp[..][0] = 0

for i in [1..N]:
for w in [1..W]:
dp[i][w] = max(
把物品 i 装进背包,
不把物品 i 装进背包
)
return dp[N][W]
```

**第三步,根据「选择」,思考状态转移的逻辑**

简单说就是,上面伪码中「把物品 `i` 装进背包」和「不把物品 `i` 装进背包」怎么用代码体现出来呢?

这就要结合对 `dp` 数组的定义,看看这两种选择会对状态产生什么影响:

先重申一下刚才我们的 `dp` 数组的定义:

`dp[i][w]` 表示:对于前 `i` 个物品,当前背包的容量为 `w` 时,这种情况下可以装下的最大价值是 `dp[i][w]`

**如果你没有把这第 `i` 个物品装入背包**,那么很显然,最大价值 `dp[i][w]` 应该等于 `dp[i-1][w]`,继承之前的结果。

**如果你把这第 `i` 个物品装入了背包**,那么 `dp[i][w]` 应该等于 `dp[i-1][w - wt[i-1]] + val[i-1]`

首先,由于 `i` 是从 1 开始的,所以 `val``wt` 的索引是 `i-1` 时表示第 `i` 个物品的价值和重量。

`dp[i-1][w - wt[i-1]]` 也很好理解:你如果装了第 `i` 个物品,就要寻求剩余重量 `w - wt[i-1]` 限制下的最大价值,加上第 `i` 个物品的价值 `val[i-1]`

综上就是两种选择,我们都已经分析完毕,也就是写出来了状态转移方程,可以进一步细化代码:

```python
for i in [1..N]:
for w in [1..W]:
dp[i][w] = max(
dp[i-1][w],
dp[i-1][w - wt[i-1]] + val[i-1]
)
return dp[N][W]
```

**最后一步,把伪码翻译成代码,处理一些边界情况**

我用 C++ 写的代码,把上面的思路完全翻译了一遍,并且处理了 `w - wt[i-1]` 可能小于 0 导致数组索引越界的问题:

```cpp
int knapsack(int W, int N, vector<int>& wt, vector<int>& val) {
// base case 已初始化
vector<vector<int>> dp(N + 1, vector<int>(W + 1, 0));
for (int i = 1; i <= N; i++) {
for (int w = 1; w <= W; w++) {
if (w - wt[i-1] < 0) {
// 这种情况下只能选择不装入背包
dp[i][w] = dp[i - 1][w];
} else {
// 装入或者不装入背包,择优
dp[i][w] = max(dp[i - 1][w - wt[i-1]] + val[i-1],
dp[i - 1][w]);
}
}
}

return dp[N][W];
}
```

至此,背包问题就解决了,相比而言,我觉得这是比较简单的动态规划问题,因为状态转移的推导比较自然,基本上你明确了 `dp` 数组的定义,就可以理所当然地确定状态转移了。

接下来请阅读:

* [背包问题变体之子集分割](https://labuladong.gitee.io/algo/)
* [完全背包问题之零钱兑换](https://labuladong.gitee.io/algo/)

**_____________**

**刷算法,学套路,认准 labuladong,公众号和 [在线电子书](https://labuladong.gitee.io/algo/) 持续更新最新文章**

**本小抄即将出版,微信扫码关注公众号,后台回复「小抄」限时免费获取,回复「进群」可进刷题群一起刷题,带你搞定 LeetCode**

<p align='center'>
<img src="../pictures/qrcode.jpg" width=200 >
</p>
155 changes: 155 additions & 0 deletions 技术/刷题技巧.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# 刷题小技巧


<p align='center'>
<a href="https://github.com/labuladong/fucking-algorithm" target="view_window"><img alt="GitHub" src="https://img.shields.io/github/stars/labuladong/fucking-algorithm?label=Stars&style=flat-square&logo=GitHub"></a>
<a href="https://www.zhihu.com/people/labuladong"><img src="https://img.shields.io/badge/%E7%9F%A5%E4%B9%[email protected]?style=flat-square&logo=Zhihu"></a>
<a href="https://i.loli.net/2020/10/10/MhRTyUKfXZOlQYN.jpg"><img src="https://img.shields.io/badge/公众号[email protected]?style=flat-square&logo=WeChat"></a>
<a href="https://space.bilibili.com/14089380"><img src="https://img.shields.io/badge/B站[email protected]?style=flat-square&logo=Bilibili"></a>
</p>

![](../pictures/souyisou.png)

相关推荐:
* [一文解决三道区间问题](https://labuladong.gitee.io/algo/)
* [Union-Find算法详解](https://labuladong.gitee.io/algo/)

**-----------**

相信每个人都有过被代码的小 bug 搞得心态爆炸的经历,本文分享一个我最常用的简单技巧,可以大幅提升刷题的幸福感。

在这之前,首先回答一个问题,刷力扣题是直接在网页上刷比较好还是在本地 IDE 上刷比较好?

如果是牛客网笔试那种自己处理输入输出的判题形式,一定要在 IDE 上写,这个没啥说的,但**像力扣这种判题形式,我个人偏好直接在网页上刷**,原因有二:

**1、方便**

因为力扣有的数据结构是自定的,比如说 `TreeNode``ListNode` 这种,在本地你还得把这个类 copy 过去。

而且在 IDE 上没办法测试,写完代码之后还得粘贴到网页上跑测试数据,那还不如直接网页上写呢。

算法又不是工程代码,量都比较小,IDE 的自动补全带来的收益基本可以忽略不计。

**2、实用**

到时候面试的时候,面试官给你出的算法题大都是希望你直接在网页上完成的,最好是边写边讲你的思路。

如果平时练习的时候就习惯没有 IDE 的自动补全,习惯手写代码大脑编译,到时候面试的时候写代码就能更快更从容。

之前我面快手的时候,有个面试官让我 [实现 LRU 算法](https://labuladong.gitee.io/algo/),我直接把双链表的实现、哈希链表的实现,在网页上全写出来了,而且一次无 bug 跑通,可以看到面试官惊讶的表情😂

我秋招能当 offer 收割机,很大程度上就是因为手写算法这一关超出面试官的预期,其实都是因为之前在网页上刷题练出来的。

接下来分享我觉得最常实用的干货技巧。

### 如何给算法 debug

代码的错误时无法避免的,有时候可能整个思路都错了,有时候可能是某些细节问题,比如 `i``j` 写反了,这种问题怎么排查?

我想一般的算法问题肯定不难排查,肉眼检查应该都没啥问题,再不济 `print` 打印一些关键变量的值,总能发现问题。

**比较让人头疼的的应该是递归算法的问题排查**

如果没有一定的经验,函数递归的过程很难被正确理解,所以这里就重点讲讲如何高效 debug 递归算法。

有的读者可能会说,把算法 copy 到 IDE 里面,然后打断点一步步跟着走不就行了吗?

这个方法肯定是可以的,但是之前的文章多次说过,递归函数最好从一个全局的角度理解,而不要跳进具体的细节。

如果你对递归还不够熟悉,没有一个全局的视角,这种一步步打断点的方式也容易把人绕进去。

**我的建议是直接在递归函数内部打印关键值,配合缩进,直观地观察递归函数执行情况**

最能提升我们 debug 效率的是缩进,除了解法函数,我们新定义一个函数 `printIndent` 和一个全局变量 `count`

```cpp
// 全局变量,记录递归函数的递归层数
int count = 0;

// 输入 n,打印 n 个 tab 缩进
void printIndent(int n) {
for (int i = 0; i < n; i++) {
printf(" ");
}
}
```
接下来,套路来了:
**在递归函数的开头,调用 `printIndent(count++)` 并打印关键变量;然后在所有 `return` 语句之前调用 `printIndent(--count)` 并打印返回值**。
举个具体的例子,比如说上篇文章 [练琴时悟出的一个动态规划算法](https://labuladong.gitee.io/algo/) 中实现了一个递归的 `dp` 函数,大致的结构如下:
```cpp
int dp(string& ring, int i, string& key, int j) {
/* base case */
if (j == key.size()) {
return 0;
}
/* 状态转移 */
int res = INT_MAX;
for (int k : charToIndex[key[j]]) {
res = min(res, dp(ring, j, key, i + 1));
}
return res;
}
```

这个递归的 `dp` 函数在我进行了 debug 之后,变成了这样:

```cpp
int count = 0;
void printIndent(int n) {
for (int i = 0; i < n; i++) {
printf(" ");
}
}

int dp(string& ring, int i, string& key, int j) {
// printIndent(count++);
// printf("i = %d, j = %d\n", i, j);

if (j == key.size()) {
// printIndent(--count);
// printf("return 0\n");
return 0;
}

int res = INT_MAX;
for (int k : charToIndex[key[j]]) {
res = min(res, dp(ring, j, key, i + 1));
}

// printIndent(--count);
// printf("return %d\n", res);
return res;
}
```
**就是在函数开头和所有 `return` 语句对应的地方加上一些打印代码**。
如果去掉注释,执行一个测试用例,输出如下:
![](../pictures/刷题技巧/1.jpg)
这样,我们通过对比对应的缩进就能知道每次递归时输入的关键参数 `i, j` 的值,以及每次递归调用返回的结果是多少。
**最重要的是,这样可以比较直观地看出递归过程,你有没有发现这就是一棵递归树**?
![](../pictures/刷题技巧/2.jpg)
前文 [动态规划套路详解](https://labuladong.gitee.io/algo/) 说过,理解递归函数最重要的就是画出递归树,这样打印一下,连递归树都不用自己画了,而且还能清晰地看出每次递归的返回值。
**可以说,这是对刷题「幸福感」提升最大的一个小技巧,比 IDE 打断点要高效**。
好了,本文分享就到这里,马上快过年了,估计大家都无心学习了,不过刷题还是要坚持的,这就叫弯道超车,顺便实践一下这个技巧。
如果本文对你有帮助,点个在看,就会被推荐更多相似文章。
**_____________**
**《labuladong 的算法小抄》已经出版,关注公众号「labuladong」查看详情;后台回复关键词「进群」可加入算法群,回复题号获取对应的文章**:
![](../pictures/souyisou2.png)
Loading

0 comments on commit e94b84c

Please sign in to comment.