# 动态规划设计:最长递增子序列 很多读者反应,就算看了前文[动态规划详解](动态规划详解进阶.md),了解了动态规划的套路,也不会写状态转移方程,没有思路,怎么办?本文就借助「最长递增子序列」来讲一种设计动态规划的通用技巧:数学归纳思想。 最长递增子序列(Longest Increasing Subsequence,简写 LIS)是比较经典的一个问题,比较容易想到的是动态规划解法,时间复杂度 O(N^2),我们借这个问题来由浅入深讲解如何写动态规划。比较难想到的是利用二分查找,时间复杂度是 O(NlogN),我们通过一种简单的纸牌游戏来辅助理解这种巧妙的解法。 先看一下题目,很容易理解: ![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) 注意「子序列」和「子串」这两个名词的区别,子串一定是连续的,而子序列不一定是连续的。下面先来一步一步设计动态规划算法解决这个问题。 ### 一、动态规划解法 动态规划的核心设计思想是数学归纳法。 相信大家对数学归纳法都不陌生,高中就学过,而且思路很简单。比如我们想证明一个数学结论,那么我们先假设这个结论在 $k nums[j]) dp[i] = Math.max(dp[i], dp[j] + 1); } ``` 这段代码的逻辑就可以算出 dp[5]。到这里,这道算法题我们就基本做完了。读者也许会问,我们刚才只是算了 dp[5] 呀,dp[4], dp[3] 这些怎么算呢? 类似数学归纳法,你已经可以算出 dp[5] 了,其他的就都可以算出来: ```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); } } ``` 还有一个细节问题,dp 数组应该全部初始化为 1,因为子序列最少也要包含自己,所以长度最小为 1。下面我们看一下完整代码: ```java public int lengthOfLIS(int[] nums) { int[] dp = new int[nums.length]; // dp 数组全都初始化为 1 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; } ``` 至此,这道题就解决了,时间复杂度 O(N^2)。总结一下动态规划的设计流程: 首先明确 dp 数组所存数据的含义。这步很重要,如果不得当或者不够清晰,会阻碍之后的步骤。 然后根据 dp 数组的定义,运用数学归纳法的思想,假设 $dp[0...i-1]$ 都已知,想办法求出 $dp[i]$,一旦这一步完成,整个题目基本就解决了。 但如果无法完成这一步,很可能就是 dp 数组的定义不够恰当,需要重新定义 dp 数组的含义;或者可能是 dp 数组存储的信息还不够,不足以推出下一步的答案,需要把 dp 数组扩大成二维数组甚至三维数组。 最后想一想问题的 base case 是什么,以此来初始化 dp 数组,以保证算法正确运行。 ### 二、二分查找解法 这个解法的时间复杂度会将为 O(NlogN),但是说实话,正常人基本想不到这种解法(也许玩过某些纸牌游戏的人可以想出来)。所以如果大家了解一下就好,正常情况下能够给出动态规划解法就已经很不错了。 根据题目的意思,我都很难想象这个问题竟然能和二分查找扯上关系。其实最长递增子序列和一种叫做 patience game 的纸牌游戏有关,甚至有一种排序方法就叫做 patience sorting(耐心排序)。 为了简单起见,后文跳过所有数学证明,通过一个简化的例子来理解一下思路。 首先,给你一排扑克牌,我们像遍历数组那样从左到右一张一张处理这些扑克牌,最终要把这些牌分成若干堆。 ![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) 处理这些扑克牌要遵循以下规则: 只能把点数小的牌压到点数比它大的牌上。如果当前牌点数较大没有可以放置的堆,则新建一个堆,把这张牌放进去。如果当前牌有多个堆可供选择,则选择最左边的堆放置。 比如说上述的扑克牌最终会被分成这样 5 堆(我们认为 A 的值是最大的,而不是 1)。 ![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) 我们只要把处理扑克牌的过程编程写出来即可。每次处理一张扑克牌不是要找一个合适的牌堆顶来放吗,牌堆顶的牌不是有序吗,这就能用到二分查找了:用二分查找来搜索当前牌应放置的位置。 PS:旧文[二分查找算法详解](../算法思维系列/二分查找详解.md)详细介绍了二分查找的细节及变体,这里就完美应用上了。如果没读过强烈建议阅读。 ```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; } ``` 至此,二分查找的解法也讲解完毕。 这个解法确实很难想到。首先涉及数学证明,谁能想到按照这些规则执行,就能得到最长递增子序列呢?其次还有二分查找的运用,要是对二分查找的细节不清楚,给了思路也很难写对。 所以,这个方法作为思维拓展好了。但动态规划的设计方法应该完全理解:假设之前的答案已知,利用数学归纳的思想正确进行状态的推演转移,最终得到答案。 坚持原创高质量文章,致力于把算法问题讲清楚,欢迎关注我的公众号 labuladong 获取最新文章: ![labuladong](../pictures/labuladong.jpg) [上一篇:动态规划答疑篇](../动态规划系列/最优子结构.md) [下一篇:编辑距离](../动态规划系列/编辑距离.md) [目录](../README.md#目录)