用一个例子讲述平时练习题目的思路
对于平时题目的练习,我们的思路应该是首先考虑暴力解法,因为暴力解法或者叫直接的解法是最容易想到的,他的时间复杂度一般是指数级别的(暴力解法最常见的就是递归(包含回溯和剪枝)、DFS【正向以及逆向的方法】),如pow(2, n);下面的优化就是以及降低其时间复杂度,一般而言要降到高阶的幂函数的级别上,如O(n^2)一般居多,一般采用的方法就是dp。最后就是考虑降到O(n)的时间复杂度,如果涉及到排序就采用二分查找降到O(logn)的时间复杂度。这就是整体的思想。
给定一个无序的整数数组,找到其中最长上升子序列的长度。
示例:
输入: [10,9,2,5,3,7,101,18]
输出: 4
解释: 最长的上升子序列是 [2,3,7,101],它的长度是 4。
里面涉及到关于 DFS的处理,全排列的实现以及剪枝;涉及到dp的状态转移的设计,涉及到的关于dp的优化详细的还会再dp那部分阐述,但是由于这里是要用到的,所以这里我们用这个例子去实现相关的内容以及考虑。
1:那么上来最直接的解法就是考虑暴力法,暴力法主要就是DFS的递归版本,包含回溯以及剪枝。DFS的两种思路:正向和逆向。所谓的正向就是按照事实去推理,依据前一步的结果去推理下一步的结果,这就是正向,所谓的逆向,其实本质上就是利用DFS找到全排列,然后去判断是否在这个序列集合里面包含相关的内容。一般而言这个方法能处理,但是时间复杂度太高。它处理的题目一般都是让列出全排列,这种的话才一般使用DFS,其余的判断类的题目话一般都有优化的方法,或者就是单纯的数学方法,切记。这段话我将在DFS那一块进行再次阐述。
那么这个例子使用暴力法的话主要就是列出全部的子序列,然后判断是不是严格的递增,但是我说过了,由于题目不是让列出所有的子序列的题目,因此这种方法不常用,或者不用。
2:就是动态规划,dp的思想以及方法我还会再dp那里阐述,这里主要的就是再次说明dp的手段以及方法。
一般而言dp的定义就是问题为导向进行定义,一般情况下就是问题是什么,dp定义的就是什么,这样的题目最多;定义了dp之后就需要初始化一些值;初始化之后就是写出状态转移方程,一般而言这一块是难点;写出之后就是最后设定输出;最后就是考虑参数压缩,这就是处理dp的一般方法。
特殊点是:
1:定义的不确定性:上边说的是一般而言问题是什么,定义就是什么,但是还有一些情况下面,问题是什么,并非一定定义dp就是什么,有些时候直接按照问题的话不好写状态转移方程,所以有时候会变一变,那么变的情况往往是以当前元素结尾且包含当前元素的问题值。和直接问题定义的本质区别就是是否包含当前元素。特殊一点的还会定义其它的,比如末尾的最小值(本题就是这种的)等等,所以最后这种就比较少见了,但是比较巧妙,需要好好考虑和斟酌。
2:定义维度的不确定性:以一维数组进行处理,也有以二维数组进行处理,一般而言如果一维数组难以处理,二维数组都能得到正确答案,其中比较繁琐的是二维数组的初始化有好几种方式,以及推理也有好几种方式,初始化有初始化第一行第一列的推右下角的,有初始化主对角线推又上角的,一般而言二维数组就这两种,一般一维数组尽可能的写出状态转移方程,写不出来的转化为二维数组去写。
3:设定输出:输出的设定主要依赖于初始化定义为了什么。如果初始化定义的就是问题,那么输出就是dp一般是最后那个位置上的结果,如果定义的不是问题而是问题的其它导出,那么输出也会变化,可能是max(dp)等等,所以输出不是一尘不变的就是dp里面的某一个值,而是随着问题的定义而设定的相关输出。
4:最后就是dp的压缩,一般而言dp的压缩主要可能会涉及到二维的dp进行压缩,压缩到一维,或者二维的数据直接利用原始的数据输入作为空间,二维的压缩主要就是这两个方面,一维的数组一般能压缩的比较少,可能会压缩成0维的常数,主要看状态转移的推到是否仅仅依赖于dp[i-1]。
上述就是dp的整体的处理办法以及思路,我会在dp那里再进行详细的阐述。
dp的思想一般而言会降低到O(n^2)或者O(nlogn)。那么主要是看dp的定义是什么。
3:就是使用O(n)的办法,主要就是hash、双指针、排序等等,加上力扣右侧总共的方法。
题目解答:
首先,需要对「子序列」和「子串」这两个概念进行区分;子序列(subsequence):子序列并不要求连续,例如:序列 [4, 6, 5] 是 [1, 2, 4, 3, 7, 6, 5] 的一个子序列。子串(substring、subarray):子串一定是连续的,例如:「力扣」第 3 题:“无重复字符的最长子串”,「力扣」第 53 题:“最大子序和”。
方法一:暴力解法
使用「回溯搜索算法」或者「位运算」的技巧,可以得到输入数组的所有子序列,时间复杂度为 O(2^N)O(2N);再对这些子串再依次判定是否为「严格上升」,时间复杂度 为O(N)O(N),所以总的时间复杂度为:O(N2^N)O(N2N)。
方法二:动态规划(完全按照上述的思路求解,这里写下面这些话的意义不是别的,是为了对照着捋清楚上面的思路)。
首先考虑题目问什么,就把什么定义成状态;题目问最长上升子序列的长度,其实可以把「子序列的长度」定义成状态,但是发现「状态转移」不好做;
把「子序列的长度」定义成状态,事实上也可以,只是目前这样定义状态,没有定义得很清晰,具体做法在下文「方法三」;
为了从一个较短的上升子序列得到一个较长的上升子序列,我们主要关心这个较短的上升子序列结尾的元素。由于要保证子序列的相对顺序,在程序读到一个新的数的时候,如果比已经得到的子序列的最后一个数还大,那么就可以放在这个子序列的最后,形成一个更长的子序列;
所以我们可以这样定义状态:
第 1 步:定义状态:
由于一个子序列一定会以一个数结尾,于是将状态定义成:dp[i] 表示以 nums[i] 结尾的「上升子序列」的长度。注意:这个定义中 nums[i] 必须被选取,且必须是这个子序列的最后一个元素。第 2 步:考虑状态转移方程:
遍历到 nums[i] 时,需要把下标 i 之前的所有的数都看一遍;只要 nums[i] 严格大于在它位置之前的某个数,那么 nums[i] 就可以接在这个数后面形成一个更长的上升子序列;因此,dp[i] 就等于下标 i 之前严格小于 nums[i] 的状态值的最大者 +1+1。
语言描述:在下标 i 之前严格小于 nums[i] 的所有状态值中的最大者+1。写出公式:具体的看代码就明白了。
第 3 步:考虑初始化
dp[0] = 1。
第 4 步:考虑输出
这里要注意,不能返回最后一个状态值,因为我们的定义不是问题,根据定义,最后一个状态值只是以 nums[len - 1] 结尾的「上升子序列」的长度,所以状态数组 dp 的最大值才是最后要输出的值,这就是上面输出所说的看输入决定。第 5 步:考虑状态压缩。
遍历到一个新数的时候,之前所有的状态值都得保留,因此无法压缩。代码如下:
class Solution: def lengthOfLIS(self, nums: List[int]) -> int: # 利用动态规划实现最长递增、非连续、最长子序列 # 时间复杂度是O(n^2),且空间复杂度是O(n) # 那么首先实现的就是关于动态规划的同时关于状态转移方程的自我实现 # 其实这里就是定义的关于 以当前的元素结尾的且必须包含当前元素的最长子序列。 dp = [] for i in range(len(nums)): much = 1 for j in range(i): if nums[i] > nums[j]: much = max(much, dp[j]+1) """ # 下面这段就是为了说明以当前元素结尾且未必包含当前元素的最长子序列的定义的状态转移方程不好写,下面就是原因; 我们这个代码里头是没有else的,如果当前的数据没有比前面大的,那么当前的数据的dp就是1 这一点千万要注意,因为如果不这样处理,会出错,具体的出错原因看个例子: 1234,2344,428,1456, 按照这个例子如果428不设置为1,后面的1456的dp就变成了3,但是其实他是2 所以为了避免这种,干脆这里428设置为1,这样最后只要取dp里面最大的就行。 这就是不用以当前元素结尾作为dp的定义的原因。因为对这个例子出错,换句话说就是状态转移不太容易写。 """ dp.append(much) return max(dp) if dp != [] else 0
第二种动态规划思路:
我们依据上述的dp的特殊点的第一点的最后一条,找一些特殊的定义作为dp的值。
我们找一个数组temp,temp[i]定义为所有满足题目要求的长度为i+1的序列的末尾的最小值,所以temp一定是单调递增,那么为什么定义最小值呢,就是采用的贪心策略,要想递增的子序列的长度最长,那么就是长度为i+1的所有序列的末尾元素尽可能的小,这样长度就会更长。那么就是遍历这个nums数组,如果当前的元素大于temp[-1],那说明,那当前这个元素就要直接放到temp最后面,这样最后一个元素就更新了,如果比temp[-1]小,那么就一直往前找(这里的搜索可以使用二分查找),直到替换第一个比这个元素小的后面的那个元素,记其index为j,这样的话长度为j+1的所有子序列的末尾就变小了,如此往复更新,最后temp长度就是最小的。
其实说白了,就是遍历当前的nums,如果当前的元素比temp的最后一个元素大,那么temp直接降当前的元素append进去,如果当前元素比temp的最后一个元素小,说明前面子序列一定有位置需要更新,这样的话采用二分查找去个更新,找到第一个比当前元素小的元素的后一个位置,将二者替换,这就是贪心的策略。所以遍历nums是O(n),里面的二分查找是O(logn),整体而言就是O(nlogn)。
这个策略的本质还是属于dp的定义问题,只不过里面涉及到了贪心和二分搜索。
不懂得看这个链接:查询
代码:这个代码本人写完一次通过(没有任何修改以及调试)。
class Solution: def lengthOfLIS(self, nums: List[int]) -> int: # 这个题目已在csdn上公开 lenth = len(nums) if lenth <= 1: return lenth dp = [nums[0]] def erFengFind(value): left = 0 right = len(dp) while right >= left: mid = (left + right) // 2 if dp[mid] == value: return elif dp[mid] < value: left = mid + 1 else: right = mid - 1 dp[left] = value for i in range(1, len(nums)): if nums[i] > dp[-1]: dp.append(nums[i]) else: erFengFind(nums[i]) return len(dp)