-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
c236c34
commit fd87c6b
Showing
8 changed files
with
443 additions
and
6 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,133 @@ | ||
# nSum | ||
|
||
<https://mp.weixin.qq.com/s/fSyJVvggxHq28a0SdmZm6Q> | ||
|
||
## twoSum 问题 | ||
|
||
先排序 再左右指针 | ||
**根据 sum 和 target 的比较,移动左右指针** | ||
|
||
如果要求返回所有和为 target 的元素对儿,且不能出现重复 | ||
|
||
则 **根据 sum 和 target 的比较,移动左右指针** 这一步中 `sum == target` 条件分支 要跳过所有重复的元素 | ||
|
||
<https://leetcode.cn/problems/kLl5u1/> | ||
|
||
通用函数 非答案 | ||
|
||
```ts | ||
function twoSum(numbers: number[], target?: number, start?: number): number[][] { | ||
target = target || 0 | ||
start = start || 0 | ||
numbers.sort((a, b) => a - b) | ||
let p1 = start | ||
let p2 = numbers.length - 1 | ||
const res: number[][] = [] | ||
while (p1 < p2) { | ||
const sum = numbers[p1] + numbers[p2] | ||
const left = numbers[p1] | ||
const right = numbers[p2] | ||
if (sum < target) { | ||
while (p1 < p2 && numbers[p1] === left) p1++ | ||
} else if (sum > target) { | ||
while (p1 < p2 && numbers[p2] === right) p2-- | ||
} else { | ||
res.push([left, right]) | ||
while (p1 < p2 && numbers[p1] === left) p1++ | ||
while (p1 < p2 && numbers[p2] === right) p2-- | ||
} | ||
} | ||
|
||
return res | ||
} | ||
``` | ||
|
||
## 3Sum | ||
|
||
力扣第 15 题「三数之和」 | ||
|
||
<https://leetcode.cn/problems/3sum/> | ||
|
||
确定了第一个数字之后,剩下的两个数字可以是什么呢?其实就是和为 `target - nums[i]` 的两个数字 | ||
|
||
而且不能让第一个数重复,至于后面的两个数,我们复用的 twoSum 函数会保证它们不重复。所以代码中必须用一个 while 循环来保证 3Sum 中第一个元素不重复。 | ||
|
||
```ts | ||
function threeSum(nums: number[], target?: number): number[][] { | ||
target = target || 0 | ||
nums.sort((a, b) => a - b) | ||
const n = nums.length | ||
const res: number[][] = [] | ||
// 穷举 threeSum 的第一个数 | ||
for (let i = 0; i < n; i++) { | ||
// 对 target - nums[i] 计算 twoSum | ||
const tuples = twoSum(nums, target - nums[i], i + 1) | ||
tuples.forEach(item => { | ||
res.push([nums[i], ...item]) | ||
}) | ||
// 跳过第一个数字重复的情况,否则会出现重复结果 | ||
while (i < n - 1 && nums[i] == nums[i + 1]) i++ | ||
} | ||
return res | ||
} | ||
``` | ||
|
||
排序的复杂度为 `O(NlogN)`,`twoSumTarget` 函数中的双指针操作为 `O(N)`,`threeSumTarget` 函数在 for 循环中调用 `twoSumTarget` 所以总的时间复杂度就是 `O(NlogN + N^2) = O(N^2)` | ||
|
||
## 4Sum 问题 | ||
|
||
力扣第 18 题「四数之和」 | ||
|
||
<https://leetcode.cn/problems/4sum/> | ||
|
||
统一出一个 `nSum` 函数 | ||
|
||
```ts | ||
function nSum(numbers: number[], target?: number, start?: number, n?: number): number[][] { | ||
/* 注意:调用这个函数之前一定要先给 nums 排序 */ | ||
|
||
target = target || 0 | ||
start = start || 0 | ||
n = n || 0 | ||
|
||
// 至少是 2Sum,且数组大小不应该小于 n | ||
const sz = numbers.length | ||
const res: number[][] = [] | ||
|
||
if (n < 2 || sz < n) return res | ||
|
||
// 2Sum 是 base case | ||
if (n === 2) { | ||
// 双指针那一套操作 | ||
let p1 = start | ||
let p2 = numbers.length - 1 | ||
while (p1 < p2) { | ||
const sum = numbers[p1] + numbers[p2] | ||
const left = numbers[p1] | ||
const right = numbers[p2] | ||
if (sum < target) { | ||
while (p1 < p2 && numbers[p1] === left) p1++ | ||
} else if (sum > target) { | ||
while (p1 < p2 && numbers[p2] === right) p2-- | ||
} else { | ||
res.push([left, right]) | ||
while (p1 < p2 && numbers[p1] === left) p1++ | ||
while (p1 < p2 && numbers[p2] === right) p2-- | ||
} | ||
} | ||
} else { | ||
// n > 2 时,递归计算 (n-1)Sum 的结果 | ||
for (let i = start; i < sz; i++) { | ||
const sub: number[][] = nSum(numbers, target - numbers[i], i + 1, n - 1) | ||
sub.forEach(item => { | ||
res.push([numbers[i], ...item]) | ||
}) | ||
while (i < sz - 1 && numbers[i] === numbers[i + 1]) i++ | ||
} | ||
} | ||
|
||
return res | ||
} | ||
``` | ||
|
||
`n == 2` 时是 `twoSum` 的双指针解法,`n > 2` 时就是穷举第一个数字,然后递归调用计算 `(n-1)Sum`,组装答案。 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,144 @@ | ||
# 动态规划解题套路框架 | ||
|
||
<https://labuladong.gitee.io/algo/1/7/> | ||
|
||
动态规划问题的一般形式就是求最值,求解动态规划的核心问题是穷举 | ||
|
||
首先,虽然动态规划的核心思想就是穷举求最值,但是问题可以千变万化,穷举所有可行解其实并不是一件容易的事,需要你熟练掌握递归思维,只有列出`正确的「状态转移方程」`,才能正确地穷举。而且,你需要判断算法问题是否具备`「最优子结构」`,是否能够通过子问题的最值得到原问题的最值。另外,动态规划问题存在`「重叠子问题」`,如果暴力穷举的话效率会很低,所以需要你使用「备忘录」或者「DP table」来优化穷举过程,避免不必要的计算。 | ||
|
||
以上提到的重叠子问题、最优子结构、状态转移方程就是动态规划三要素。 | ||
|
||
思维框架,辅助你思考状态转移方程: | ||
|
||
**明确 base case -> 明确「状态」-> 明确「选择」 -> 定义 dp 数组/函数的含义。** | ||
|
||
## 斐波那契数列 | ||
|
||
力扣第 509 题「 斐波那契数」 | ||
|
||
<https://leetcode.cn/problems/fibonacci-number/> | ||
|
||
### 暴力递归 | ||
|
||
> PS:但凡遇到需要递归的问题,最好都画出递归树,这对你分析算法的复杂度,寻找算法低效的原因都有巨大帮助。 | ||
 | ||
|
||
这个递归树怎么理解?就是说想要计算原问题 f(20),我就得先计算出子问题 f(19) 和 f(18),然后要计算 f(19),我就要先算出子问题 f(18) 和 f(17),以此类推。最后遇到 f(1) 或者 f(2) 的时候,结果已知,就能直接返回结果,递归树不再向下生长了。 | ||
|
||
递归算法的时间复杂度怎么计算?就是用子问题个数乘以解决一个子问题需要的时间。 | ||
|
||
首先计算子问题个数,即递归树中节点的总数。显然二叉树节点总数为指数级别,所以子问题个数为 O(2^n)。 | ||
|
||
后计算解决一个子问题的时间,在本算法中,没有循环,只有 f(n - 1) + f(n - 2) 一个加法操作,时间为 O(1)。 | ||
|
||
所以,这个算法的时间复杂度为二者相乘,即 O(2^n),指数级别,爆炸。 | ||
|
||
观察递归树,很明显发现了算法低效的原因:存在大量重复计算,比如 f(18) 被计算了两次,而且你可以看到,以 f(18) 为根的这个递归树体量巨大,多算一遍,会耗费巨大的时间。更何况,还不止 f(18) 这一个节点被重复计算,所以这个算法及其低效。 | ||
|
||
这就是动态规划问题的第一个性质:`重叠子问题`。 | ||
|
||
### 带备忘录的递归解法 | ||
|
||
即然耗时的原因是重复计算,那么我们可以造一个「备忘录」,每次算出某个子问题的答案后别急着返回,先记到「备忘录」里再返回;每次遇到一个子问题先去「备忘录」里查一查,如果发现之前已经解决过这个问题了,直接把答案拿出来用,不要再耗时去计算了。 | ||
|
||
一般使用一个数组充当这个「备忘录」,当然你也可以使用哈希表(字典),思想都是一样的。 | ||
|
||
现在,画出递归树,你就知道「备忘录」到底做了什么。 | ||
|
||
 | ||
|
||
实际上,带「备忘录」的递归算法,把一棵存在巨量冗余的递归树通过「剪枝」,改造成了一幅不存在冗余的递归图,极大减少了子问题(即递归图中节点)的个数。 | ||
|
||
 | ||
|
||
递归算法的时间复杂度怎么计算?就是用子问题个数乘以解决一个子问题需要的时间。 | ||
|
||
子问题个数,即图中节点的总数,由于本算法不存在冗余计算,子问题就是 f(1), f(2), f(3) … f(20),数量和输入规模 n = 20 成正比,所以子问题个数为 O(n)。 | ||
|
||
解决一个子问题的时间,同上,没有什么循环,时间为 O(1)。 | ||
|
||
所以,本算法的时间复杂度是 O(n),比起暴力算法,是降维打击。 | ||
|
||
至此,带备忘录的递归解法的效率已经和迭代的动态规划解法一样了。实际上,这种解法和常见的动态规划解法已经差不多了,只不过这种解法是「自顶向下」进行「递归」求解,我们更常见的动态规划代码是「自底向上」进行「递推」求解。 | ||
|
||
```ts | ||
function fib(n: number): number { | ||
if (n === 0) return 0 | ||
const arr = [0, 1] | ||
for (let i = 2; i <= n; i++) { | ||
arr[i] = arr[i-1] + arr[i-2] | ||
} | ||
return arr[n] | ||
}; | ||
``` | ||
|
||
### dp 数组的迭代(递推)解法 | ||
|
||
有了上一步「备忘录」的启发,我们可以把这个「备忘录」独立出来成为一张表,通常叫做 DP table,在这张表上完成「自底向上」的推算岂不美哉! | ||
|
||
 | ||
|
||
画个图就很好理解了,而且你发现这个 DP table 特别像之前那个「剪枝」后的结果,只是反过来算而已。实际上,带备忘录的递归解法中的「备忘录」,最终完成后就是这个 DP table,所以说这两种解法其实是差不多的,大部分情况下,效率也基本相同。 | ||
|
||
这里,引出「状态转移方程」这个名词,实际上就是描述问题结构的数学形式: | ||
|
||
 | ||
|
||
为啥叫「状态转移方程」?其实就是为了听起来高端。 | ||
|
||
f(n) 的函数参数会不断变化,所以你把参数 n 想做一个状态,这个状态 n 是由状态 n - 1 和状态 n - 2 转移(相加)而来,这就叫状态转移,仅此而已。 | ||
|
||
你会发现,上面的几种解法中的所有操作,例如 `return f(n - 1) + f(n - 2)`,`dp[i] = dp[i - 1] + dp[i - 2]`,以及对备忘录或 DP table 的初始化操作,都是围绕这个方程式的不同表现形式。 | ||
|
||
## 凑零钱问题 | ||
|
||
力扣第 322 题「 零钱兑换」 | ||
|
||
<https://leetcode.cn/problems/coin-change/> | ||
|
||
给你 k 种面值的硬币,面值分别为 c1, c2 ... ck,每种硬币的数量无限,再给一个总金额 amount,问你最少需要几枚硬币凑出这个金额,如果不可能凑出,算法返回 -1 。 | ||
|
||
那么,既然知道了这是个动态规划问题,就要思考如何列出正确的状态转移方程? | ||
|
||
1、**确定 base case**,这个很简单,显然目标金额 amount 为 0 时算法返回 0,因为不需要任何硬币就已经凑出目标金额了。 | ||
|
||
2、**确定「状态」**,也就是原问题和子问题中会变化的变量。由于硬币数量无限,硬币的面额也是题目给定的,只有目标金额会不断地向 base case 靠近,所以唯一的「状态」就是目标金额 amount。 | ||
|
||
3、**确定「选择」**,也就是导致「状态」产生变化的行为。目标金额为什么变化呢,因为你在选择硬币,你每选择一枚硬币,就相当于减少了目标金额。所以说所有硬币的面值,就是你的「选择」。 | ||
|
||
4、**明确 dp 函数/数组的定义**。我们这里讲的是自顶向下的解法,所以会有一个递归的 dp 函数,一般来说函数的参数就是状态转移中会变化的量,也就是上面说到的「状态」;函数的返回值就是题目要求我们计算的量。就本题来说,状态只有一个,即「目标金额」,题目要求我们计算凑出目标金额所需的最少硬币数量。 | ||
|
||
**所以我们可以这样定义 dp 函数:dp(n) 表示,输入一个目标金额 n,返回凑出目标金额 n 所需的最少硬币数量。** | ||
|
||
目标金额为 0 时,所需硬币数量为 0;当目标金额小于 0 时,无解,返回 -1 | ||
|
||
状态转移方程: | ||
|
||
 | ||
|
||
消除一下重叠子问题 | ||
|
||
 | ||
|
||
### dp 数组的迭代解法 | ||
|
||
dp 数组的定义:当目标金额为 i 时,至少需要 `dp[i]` 枚硬币凑出。dp 数组中的值都初始化为 amount + 1,因为凑成 amount 金额的硬币数最多只可能等于 amount(全用 1 元面值的硬币),所以初始化为 amount + 1 就相当于初始化为正无穷,便于后续取最小值。为啥不直接初始化为 int 型的最大值 Integer.MAX_VALUE 呢?因为后面有 `dp[i - coin] + 1`,这就会导致整型溢出。 | ||
|
||
```ts | ||
function coinChange(coins: number[], amount: number): number { | ||
const arr = new Array(amount + 1).fill(amount + 1) | ||
|
||
// base case | ||
arr[0] = 0 | ||
for (let i = 0; i < arr.length; i++) { | ||
for (const coin of coins) { | ||
if (i - coin < 0) { | ||
continue | ||
} | ||
arr[i] = Math.min(arr[i], 1 + arr[i - coin]) | ||
} | ||
} | ||
return (arr[amount] === amount + 1) ? -1 : arr[amount] | ||
}; | ||
``` |
Empty file.
Oops, something went wrong.