动态规划设计:最长递增子序列.md 15.2 KB
Newer Older
L
labuladong 已提交
1 2
# 动态规划设计:最长递增子序列

3 4 5 6 7 8
<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%8E-@labuladong-000000.svg?style=flat-square&logo=Zhihu"></a>
<a href="https://i.loli.net/2020/10/10/MhRTyUKfXZOlQYN.jpg"><img src="https://img.shields.io/badge/公众号-@labuladong-000000.svg?style=flat-square&logo=WeChat"></a>
<a href="https://space.bilibili.com/14089380"><img src="https://img.shields.io/badge/B站-@labuladong-000000.svg?style=flat-square&logo=Bilibili"></a>
</p>
L
labuladong 已提交
9

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

L
labuladong 已提交
12
**《labuladong 的算法秘籍》、《labuladong 的刷题笔记》两本 PDF 和刷题插件 2.0 免费开放下载,详情见 [labuladong 的刷题三件套正式发布](https://mp.weixin.qq.com/s/yN4cHQRsFa5SWlacopHXYQ)**~
13 14 15 16 17 18 19

读完本文,你不仅学会了算法套路,还可以顺便去 LeetCode 上拿下如下题目:

[300.最长上升子序列](https://leetcode-cn.com/problems/longest-increasing-subsequence)

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

L
labuladong 已提交
20
也许有读者看了前文 [动态规划详解](https://labuladong.gitee.io/algo/),学会了动态规划的套路:找到了问题的「状态」,明确了 `dp` 数组/函数的含义,定义了 base case;但是不知道如何确定「选择」,也就是不到状态转移的关系,依然写不出动态规划解法,怎么办?
21 22 23 24

不要担心,动态规划的难点本来就在于寻找正确的状态转移方程,本文就借助经典的「最长递增子序列问题」来讲一讲设计动态规划的通用技巧:**数学归纳思想**

最长递增子序列(Longest Increasing Subsequence,简写 LIS)是非常经典的一个算法问题,比较容易想到的是动态规划解法,时间复杂度 O(N^2),我们借这个问题来由浅入深讲解如何找状态转移方程,如何写出动态规划解法。比较难想到的是利用二分查找,时间复杂度是 O(NlogN),我们通过一种简单的纸牌游戏来辅助理解这种巧妙的解法。
L
labuladong 已提交
25 26 27 28 29

先看一下题目,很容易理解:

![title](../pictures/%E6%9C%80%E9%95%BF%E9%80%92%E5%A2%9E%E5%AD%90%E5%BA%8F%E5%88%97/title.png)

30
注意「子序列」和「子串」这两个名词的区别,子串一定是连续的,而子序列不一定是连续的。下面先来设计动态规划算法解决这个问题。
L
labuladong 已提交
31 32 33 34 35

### 一、动态规划解法

动态规划的核心设计思想是数学归纳法。

36 37 38 39 40
相信大家对数学归纳法都不陌生,高中就学过,而且思路很简单。比如我们想证明一个数学结论,那么**我们先假设这个结论在 `k<n` 时成立,然后根据这个假设,想办法推导证明出 `k=n` 的时候此结论也成立**。如果能够证明出来,那么就说明这个结论对于 `k` 等于任何数都成立。

类似的,我们设计动态规划算法,不是需要一个 dp 数组吗?我们可以假设 `dp[0...i-1]` 都已经被算出来了,然后问自己:怎么通过这些结果算出 `dp[i]`

直接拿最长递增子序列这个问题举例你就明白了。不过,首先要定义清楚 dp 数组的含义,即 `dp[i]` 的值到底代表着什么?
L
labuladong 已提交
41

42
**我们的定义是这样的:`dp[i]` 表示以 `nums[i]` 这个数结尾的最长递增子序列的长度。**
L
labuladong 已提交
43

L
labuladong 已提交
44
PS:为什么这样定义呢?这是解决子序列问题的一个套路,后文[动态规划之子序列问题解题模板](https://labuladong.gitee.io/algo/) 总结了几种常见套路。你读完本章所有的动态规划问题,就会发现 `dp` 数组的定义方法也就那几种。
L
labuladong 已提交
45

46
根据这个定义,我们就可以推出 base case:`dp[i]` 初始值为 1,因为以 `nums[i]` 结尾的最长递增子序列起码要包含它自己。
L
labuladong 已提交
47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68

举两个例子:

![1](../pictures/%E6%9C%80%E9%95%BF%E9%80%92%E5%A2%9E%E5%AD%90%E5%BA%8F%E5%88%97/1.jpeg)


![2](../pictures/%E6%9C%80%E9%95%BF%E9%80%92%E5%A2%9E%E5%AD%90%E5%BA%8F%E5%88%97/2.jpeg)

算法演进的过程是这样的,:

![gif1](../pictures/%E6%9C%80%E9%95%BF%E9%80%92%E5%A2%9E%E5%AD%90%E5%BA%8F%E5%88%97/gif1.gif)

根据这个定义,我们的最终结果(子序列的最大长度)应该是 dp 数组中的最大值。

```java
int res = 0;
for (int i = 0; i < dp.size(); i++) {
    res = Math.max(res, dp[i]);
}
return res;
```

69
读者也许会问,刚才的算法演进过程中每个 `dp[i]` 的结果是我们肉眼看出来的,我们应该怎么设计算法逻辑来正确计算每个 `dp[i]` 呢?
L
labuladong 已提交
70

71
这就是动态规划的重头戏了,要思考如何设计算法逻辑进行状态转移,才能正确运行呢?这里就可以使用数学归纳的思想:
L
labuladong 已提交
72

73
**假设我们已经知道了 `dp[0..4]` 的所有结果,我们如何通过这些已知结果推出 `dp[5]` 呢**
L
labuladong 已提交
74 75 76

![3](../pictures/%E6%9C%80%E9%95%BF%E9%80%92%E5%A2%9E%E5%AD%90%E5%BA%8F%E5%88%97/3.jpeg)

77
根据刚才我们对 `dp` 数组的定义,现在想求 `dp[5]` 的值,也就是想求以 `nums[5]` 为结尾的最长递增子序列。
L
labuladong 已提交
78

79
**`nums[5] = 3`,既然是递增子序列,我们只要找到前面那些结尾比 3 小的子序列,然后把 3 接到最后,就可以形成一个新的递增子序列,而且这个新的子序列长度加一**
L
labuladong 已提交
80

81
显然,可能形成很多种新的子序列,但是我们只选择最长的那一个,把最长子序列的长度作为 `dp[5]` 的值即可。
L
labuladong 已提交
82 83 84 85 86 87 88 89 90 91

![gif2](../pictures/%E6%9C%80%E9%95%BF%E9%80%92%E5%A2%9E%E5%AD%90%E5%BA%8F%E5%88%97/gif2.gif)

```java
for (int j = 0; j < i; j++) {
    if (nums[i] > nums[j]) 
        dp[i] = Math.max(dp[i], dp[j] + 1);
}
```

92
`i = 5` 时,这段代码的逻辑就可以算出 `dp[5]`。其实到这里,这道算法题我们就基本做完了。
L
labuladong 已提交
93

94
读者也许会问,我们刚才只是算了 `dp[5]` 呀,`dp[4]`, `dp[3]` 这些怎么算呢?类似数学归纳法,你已经可以算出 `dp[5]` 了,其他的就都可以算出来:
L
labuladong 已提交
95 96 97 98 99 100 101 102 103 104

```java
for (int i = 0; i < nums.length; i++) {
    for (int j = 0; j < i; j++) {
        if (nums[i] > nums[j]) 
            dp[i] = Math.max(dp[i], dp[j] + 1);
    }
}
```

105
结合我们刚才说的 base case,下面我们看一下完整代码:
L
labuladong 已提交
106 107 108 109

```java
public int lengthOfLIS(int[] nums) {
    int[] dp = new int[nums.length];
110
    // base case:dp 数组全都初始化为 1
L
labuladong 已提交
111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126
    Arrays.fill(dp, 1);
    for (int i = 0; i < nums.length; i++) {
        for (int j = 0; j < i; j++) {
            if (nums[i] > nums[j]) 
                dp[i] = Math.max(dp[i], dp[j] + 1);
        }
    }
    
    int res = 0;
    for (int i = 0; i < dp.length; i++) {
        res = Math.max(res, dp[i]);
    }
    return res;
}
```

127
至此,这道题就解决了,时间复杂度 O(N^2)。总结一下如何找到动态规划的状态转移关系:
L
labuladong 已提交
128

129
1、明确 `dp` 数组所存数据的含义。这一步对于任何动态规划问题都很重要,如果不得当或者不够清晰,会阻碍之后的步骤。
L
labuladong 已提交
130

131
2、根据 `dp` 数组的定义,运用数学归纳法的思想,假设 `dp[0...i-1]` 都已知,想办法求出 `dp[i]`,一旦这一步完成,整个题目基本就解决了。
L
labuladong 已提交
132

133
但如果无法完成这一步,很可能就是 `dp` 数组的定义不够恰当,需要重新定义 `dp` 数组的含义;或者可能是 `dp` 数组存储的信息还不够,不足以推出下一步的答案,需要把 `dp` 数组扩大成二维数组甚至三维数组。
L
labuladong 已提交
134 135 136

### 二、二分查找解法

137
这个解法的时间复杂度为 O(NlogN),但是说实话,正常人基本想不到这种解法(也许玩过某些纸牌游戏的人可以想出来)。所以大家了解一下就好,正常情况下能够给出动态规划解法就已经很不错了。
L
labuladong 已提交
138 139 140

根据题目的意思,我都很难想象这个问题竟然能和二分查找扯上关系。其实最长递增子序列和一种叫做 patience game 的纸牌游戏有关,甚至有一种排序方法就叫做 patience sorting(耐心排序)。

141
为了简单起见,后文跳过所有数学证明,通过一个简化的例子来理解一下算法思路。
L
labuladong 已提交
142 143 144 145 146

首先,给你一排扑克牌,我们像遍历数组那样从左到右一张一张处理这些扑克牌,最终要把这些牌分成若干堆。

![poker1](../pictures/%E6%9C%80%E9%95%BF%E9%80%92%E5%A2%9E%E5%AD%90%E5%BA%8F%E5%88%97/poker1.jpeg)

147
**处理这些扑克牌要遵循以下规则**
L
labuladong 已提交
148

149
只能把点数小的牌压到点数比它大的牌上;如果当前牌点数较大没有可以放置的堆,则新建一个堆,把这张牌放进去;如果当前牌有多个堆可供选择,则选择最左边的那一堆放置。
L
labuladong 已提交
150

151
比如说上述的扑克牌最终会被分成这样 5 堆(我们认为纸牌 A 的牌面是最大的,纸牌 2 的牌面是最小的)。
L
labuladong 已提交
152 153 154 155 156 157 158 159 160 161 162

![poker2](../pictures/%E6%9C%80%E9%95%BF%E9%80%92%E5%A2%9E%E5%AD%90%E5%BA%8F%E5%88%97/poker2.jpeg)

为什么遇到多个可选择堆的时候要放到最左边的堆上呢?因为这样可以保证牌堆顶的牌有序(2, 4, 7, 8, Q),证明略。

![poker3](../pictures/%E6%9C%80%E9%95%BF%E9%80%92%E5%A2%9E%E5%AD%90%E5%BA%8F%E5%88%97/poker3.jpeg)

按照上述规则执行,可以算出最长递增子序列,牌的堆数就是最长递增子序列的长度,证明略。

![LIS](../pictures/%E6%9C%80%E9%95%BF%E9%80%92%E5%A2%9E%E5%AD%90%E5%BA%8F%E5%88%97/poker4.jpeg)

163
我们只要把处理扑克牌的过程编程写出来即可。每次处理一张扑克牌不是要找一个合适的牌堆顶来放吗,牌堆顶的牌不是**有序**吗,这就能用到二分查找了:用二分查找来搜索当前牌应放置的位置。
L
labuladong 已提交
164

L
labuladong 已提交
165
PS:旧文[二分查找算法详解](https://labuladong.gitee.io/algo/)详细介绍了二分查找的细节及变体,这里就完美应用上了,如果没读过强烈建议阅读。
L
labuladong 已提交
166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203

```java
public int lengthOfLIS(int[] nums) {
    int[] top = new int[nums.length];
    // 牌堆数初始化为 0
    int piles = 0;
    for (int i = 0; i < nums.length; i++) {
        // 要处理的扑克牌
        int poker = nums[i];

        /***** 搜索左侧边界的二分查找 *****/
        int left = 0, right = piles;
        while (left < right) {
            int mid = (left + right) / 2;
            if (top[mid] > poker) {
                right = mid;
            } else if (top[mid] < poker) {
                left = mid + 1;
            } else {
                right = mid;
            }
        }
        /*********************************/
        
        // 没找到合适的牌堆,新建一堆
        if (left == piles) piles++;
        // 把这张牌放到牌堆顶
        top[left] = poker;
    }
    // 牌堆数就是 LIS 长度
    return piles;
}
```

至此,二分查找的解法也讲解完毕。

这个解法确实很难想到。首先涉及数学证明,谁能想到按照这些规则执行,就能得到最长递增子序列呢?其次还有二分查找的运用,要是对二分查找的细节不清楚,给了思路也很难写对。

L
labuladong 已提交
204 205
所以,这个方法作为思维拓展好了。但动态规划的设计方法应该完全理解:假设之前的答案已知,利用数学归纳的思想正确进行状态的推演转移,最终得到答案。

206
**_____________**
L
labuladong 已提交
207

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

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

212
<p align='center'>
L
labuladong 已提交
213
<img src="../pictures/qrcode.jpg" width=200 >
214
</p>
215 216
======其他语言代码======

B
brucecat 已提交
217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281
### javascript

[scuhzs](https://github.com/brucecat)提供[300.最长上升子序列](https://leetcode-cn.com/problems/longest-increasing-subsequence)

动态规划做法如下:

```javascript
let lengthOfLIS = function (nums) {
  	// 用1填满dp数组
    let dp = [];
    dp.fill(1, 0, nums.length);

    for (let i = 1; i < nums.length; i++)
        for (let j = 0; j < i; j++)
            nums[i] > nums[j] && (dp[i] = Math.max(dp[i], dp[j] + 1))
    return nums.length < 2 ? nums.length : Math.max(...dp)
};
```

二分法做法如下:

```javascript
let lengthOfLIS01 = function (nums) {
    let top = new Array(nums.length);
    for (let i = 0; i < nums.length; i++) {
        top[i] = 0;
    }

    // 牌堆数初始化为 0
    let piles = 0;

    for (let i = 0; i < nums.length; i++) {
        // 要处理的扑克牌
        let poker = nums[i];

        /***** 搜索左侧边界的二分查找 *****/
        let left = 0, right = piles;
        while (left < right) {
            // 记住这里要向下取整
            let mid = Math.floor((left + right) / 2);
            if (top[mid] > poker) {
                right = mid;
            } else if (top[mid] < poker) {
                left = mid + 1;
            } else {
                right = mid;
            }
        }
        /*********************************/

        // 没找到合适的牌堆,新建一堆
        left === piles && piles++;

        // 把这张牌放到牌堆顶
        top[left] = poker;
    }
    // 牌堆数就是 LIS 长度
    return piles;
}
```



### python

282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326
```python 动态规划
class Solution:
    def lengthOfLIS(self, nums: List[int]) -> int:
        n = len(nums)
        f = [1] * (n)

        for i in range(n):
            for j in range(i):
                if nums[j]  < nums[i]:
                    f[i] = max(f[i], f[j] + 1)
        
        res = 0
        for i in range(n):
            res = max(res, f[i])
        return res
```

```python 二分查找
class Solution:
    def lengthOfLIS(self, nums: List[int]) -> int:
        stack = []

        def find_index(num):
            l, r = 0, len(stack)
            while l < r:
                mid = l + r >> 1
                if stack[mid] >= num:
                    r = mid 
                else:
                    l = mid + 1

            return r


        for num in nums:
            if not stack or num > stack[-1]:
                stack.append(num)
            else:
                position = find_index(num)
                stack[position] = num

        return len(stack)
```


B
brucecat 已提交
327 328 329

### c++

330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369
[Kian](https://github.com/KianKw/) 提供 C++ 代码

```c++
class Solution {
public:
    int lengthOfLIS(vector<int>& nums) {
        /* len 为牌的数量 */
        int len = nums.size();
        vector<int> top(len, 0);
        /* 牌堆数初始化为0 */
        int piles = 0;
        for (int i = 0; i < len; i++) {
            /* nums[i] 为要处理的扑克牌 */
            int poker = nums[i];

            /***** 搜索左侧边界的二分查找 *****/
            int left = 0, right = piles;
            while (left < right) {
                int mid = left + (right - left) / 2;
                if (top[mid] > poker) {
                    right = mid;
                } else if (top[mid] < poker) {
                    left = mid + 1;
                } else if (top[mid] == poker) {
                    right = mid;
                }
            }
            /*********************************/

            /* 没找到合适的牌堆,新建一堆 */
            if (left == piles)
                piles++;
            /* 把这张牌放到牌堆顶 */
            top[left] = poker;
        }
        /* 牌堆数就是 LIS 长度 */
        return piles;
    }
};
```
370