题目大意:
最长上升子序列
给定一个无序的整数数组,找到其中最长上升子序列的长度。
示例:
输入:[10,9,2,5,3,7,101,18]
输出: 4 解释: 最长的上升子序列是[2,3,7,101],
它的长度是4
。
说明:
可能会有多种最长上升子序列的组合,你只需要输出对应的长度即可。
你算法的时间复杂度应该为 O(n2) 。
进阶: 你能将算法的时间复杂度降低到 O(n log n) 吗?
如果想查看本题目是哪家公司的面试题,请参考以下免费链接: https://leetcode.jp/problemdetail.php?id=300
解题思路分析:
解法一:DP动态规划(递归加记忆数组)
这道题使用递归方式解题会很简单。我们将数组中每个元素想象成为一个节点,然后以每个节点为起点向后做dfs路径搜索,最长路径的长度即是返回结果。对于每个节点,它能连接到的下一个节点满足以下两个条件:
- 下一个节点的index要大于当前节点。
- 下一个节点的值要大于当前节点的值。
根据以上条件进行dfs并无难度。我们从当前节点下标开始向后循环所有节点,当遇到大于当前节点时,说明找到一个递增节点,并dfs递归至该节点next。递归的返回值为从next节点到路径终点的长度,该长度再加上当前节点长度1为:从当前节点出发一条路径的长度。循环完所有符合条件的节点(大于当前节点)后,最长的路径长度即为当前递归的返回值。
另外我们还需要使用一个记忆数组来记录递归函数的状态(返回值)。因为递归函数中只有一个变量(当前下标index),因此,采用一个一维数组即可。
实现代码:
int[] memo; // 记忆数组 public int lengthOfLIS(int[] nums) { memo=new int[nums.length]; // 初始化记忆数组 int max=0; // 全局最大值(最长路径) for(int i=0;i<nums.length;i++){ // 以数组中每一个数字为起点做dfs // 利用dfs返回值更新全局最大值 max=Math.max(max, help(nums,i)); } return max; } // nums:原始数组 // index:当前下标 // 返回值:当前下标开始到数组结尾区间的最长路径长度 int help(int[] nums, int index){ // 如果当前是数组最后一位,那长度只能为1。 if(index==nums.length-1) return 1; // 如果记忆数组中存在当前解,直接返回 if(memo[index]>0) return memo[index]; // 当前数值 int num=nums[index]; // 最大返回值。默认为当前节点自身,即长度为1 int max=1; // 循环当前节点后的每一个节点 for(int i=index+1;i<nums.length;i++){ if(nums[i]>num){ // 找到每一个大于当前节点的节点 // 递归至该节点,递归返回值为从第i个节点到数组末尾的最长路径的长度 // 加一为当前节点长度 int res=1+help(nums,i); // 用该长度更新当前递归中最大长度。 max=Math.max(res,max); } } // 将最大长度保存至记忆数组 memo[index]=max; return max; // 返回该最大长度 }
本题解法执行时间为20ms。
Runtime: 20 ms, faster than 19.39% of Java online submissions for Longest Increasing Subsequence.
Memory Usage: 37.7 MB, less than 37.81% of Java online submissions for Longest Increasing Subsequence.
解法二:贪心算法加二分查找
这道题的最优解是利用贪心结合二分查找的算法。如果你之前没接触过该贪心算法,那么本思路你可能会稍微难以理解,我会尽量使用浅显易懂的语言来描述出本解法的逻辑。
首先先看下基本思想:
- 我们维护一个升序列表,最终该升序列表的长度即是返回值。
- 使用贪心思想,向列表中添加元素时,每次尽量添加较小元素,这样才能使得后续加入更多较大元素,从而使得列表更长。
- 关键来了:首先向列表中添加数组中首元素,然后从数组第二位开始顺序循环每一个数字,如果当前数字大于列表中最后一个元素的话,可以将该元素直接添加到列表中。反之如果当前元素小于列表中最后一位时,我们采用二分方式找到列表中第一个大于等于当前数字的元素,并使用当前元素替换掉该元素(原理后文再阐述)。(该二分查找为我之前总结的所有二分套路中的第二条。LEETCODE常见算法之二分查找超详细白话讲解)
举个例子,比如:[4,9,2,5,3,7,101,18]
- 首先将首元素4加入列表。 {4}
- 第二位9大于列表尾元素4,直接添加进列表。 {4,9}
- 第三位2小于列表尾元素9,从列表中找打第一个大于等于2的数字替换。 {2,9}(你对这一步也许会有疑问,暂且不论,后文阐述)
- 第四位5小于列表尾元素9,从列表中找打第一个大于等于5的数字替换。{2,5}
- 第五位3小于列表尾元素5,从列表中找打第一个大于等于3的数字替换。 {2,3}
- 第六位7大于列表尾元素3,直接添加进列表。{2,3,7}
- 第七位101大于列表尾元素7,直接添加进列表。{2,3,7,101}
- 第八位18小于列表尾元素101,从列表中找打第一个大于等于18的数字替换。 {2,3,7,18}
至此,列表元素个数定格为4,即本题返回结果。接下来我们复盘一下该解法,首先,如果当前数字大于列表尾元素时,将该元素直接加入列表的做法应该没有疑义。争议较大的操作出现在上述第3步,当时列表为{4,9},当前数字为2,此时,找到列表中第一个大于等于2的数字为4,进而我们使用数字2替代了4,将列表更新为{2,9}。这里你可能会有一个疑问,这样做是否正确?比如当原始数组为[4,9,2]时,我们找到的最长子序列不是{4,9},而是{2,9}。虽然数组本身不正确,但数组的长度没有任何问题,因为本题求解的是子序列长度,所以我们可以忽视序列中元素 的具体内容。接下来我们详细看下为什么该算法求出的长度是正确的?
还以上文为例,当数字2出现时,显然不能和当前列表{4,9}组成一个新递增序列,按照普通的想法,此时我们有2种选择,要么继续向后找比9大的数,要么,以2开头找一条新的路径。我们使用2替换4的目的就是同时兼顾上述两种选择,继续向后循环,当出现比9大的数字时,比如101,此时列表不论是{4,9,101}还是{2,9,101},这都不会影响列表的长度,只要我们知道9前面有一个数字小于它就是了,具体该数字是多少都不会改变9是当前列表中第二大数字的事实。另一方面,如果当前出现了小于9但是大于2的数字时,比如5,此时,我们会使用5替换掉9,相当于以2开头重新找新的一条路路线去了。此时数组会变为{2,5}。
再举个例子,比如当前列表为{2,9,101},当出现数字18时,列表会更新为为{2,18,101},此时这三个数字在原数组中的顺序应该是2,101,18,而列表{2,18,101}包含了2层意思,首先2和18的相对顺序是正确的,因此当大于18的数字出现时,我们会将101更新为大于18的数字,这样三个数字的顺序没有任何问题。另外,虽然101与18的顺序违背的原先数组中的顺序,但是18只是一个占位符,他并不影响101排在列表中第三位以及它是当前列表中最大数的事实。换句话说,如果今后我们找到了比101更大的数字时,直接添加到101后面不会影响结果的正确性。另外如果我们找到了一个大于18但小于101的数字时,我们还能轻松的踢掉101,直接使用当前的这三个数字组成一个新的递增列表。
总结:
- 无论列表中的数字如何添加与改变,列表中的所有元素始终保持了递增的状态。
- 在保持了递增的前提下,我们在不断使用较小值更新列表中的较大元素,从而使得列表长度可以达到最长。
实现代码:
public int lengthOfLIS(int[] nums) { if(nums.length==0) return 0; // 数组长度为0,返回0 List<Integer> list = new ArrayList<>(); // 递增列表 list.add(nums[0]); //添加首元素 for(int i=1;i<nums.length;i++){ //循环每一个元素 if(nums[i]>list.get(list.size()-1)){ //如果当前元素大于列表尾元素 list.add(nums[i]); //直接添加 }else if(nums[i]<list.get(list.size()-1)){ //反之二分查找到第一个大于等于当前数字的元素 int low=0,high=list.size()-1; while(low<=high){ int mid=(low+high)/2; if(list.get(mid)>=nums[i]){ high=mid-1; }else{ low=mid+1; } } //使用当前元素替换掉第一个大于等于它的元素 list.set(low,nums[i]); } } return list.size(); }
本题解法执行时间为3ms。
Runtime: 3 ms, faster than 72.99% of Java online submissions for Longest Increasing Subsequence.
Memory Usage: 39.6 MB, less than 9.39% of Java online submissions for Longest Increasing Subsequence.
本网站文章均为原创内容,并可随意转载,但请标明本文链接如有任何疑问可在文章底部留言。为了防止恶意评论,本博客现已开启留言审核功能。但是博主会在后台第一时间看到您的留言,并会在第一时间对您的留言进行回复!欢迎交流!
本文链接: https://leetcode.jp/leetcode-300-longest-increasing-subsequence-解题思路分析/