一、题目描述:
给定一个整数数组 nums ,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
例:
输入:nums = [-2,1,-3,4,-1,2,1,-5,4]
输出:6
解释:连续子数组 [4,-1,2,1] 的和最大,为 6 。
二、问题类型分析
本人将该问题归属于子序列最值问题
子序列最值问题:在一个序列中找到一个满足最佳条件的子序列。
1.问题抽象化
该问题可抽象为:
设序列 a[n] = [a1,a2,a3.,…,an] ,则我们需要寻找这样一个子序列,使这个子序列在规定的计算函数中达到最大的值,公式表示如下:
f
i
n
d
:
1
≤
i
≤
j
≤
n
find:1\leq i\leq j\leq n
find:1≤i≤j≤n
S
a
t
i
s
f
i
e
d
:
f
u
n
(
a
i
,
.
.
.
,
a
j
)
=
m
a
x
(
f
u
n
(
a
k
,
.
.
.
,
a
m
)
)
(
∀
k
,
m
,
1
≤
k
≤
m
≤
n
)
Satisfied:fun(a_i,...,a_j) =max(fun(a_k,...,a_m))(\forall k,m, 1\leq k\leq m\leq n)
Satisfied:fun(ai,...,aj)=max(fun(ak,...,am))(∀k,m,1≤k≤m≤n)
其中fun()为求值函数,因题目描述不同而不同
例:字符串匹配,最大子串等
2.问题特点
(1)不难得出一共有
n
(
n
+
1
)
/
2
n(n+1)/2
n(n+1)/2种子序列
(2)在该类型的问题中,子序列之间具有一定的关系,这种关系可以表示为:结构的变化导致值的变化
例如在本题中,子序列加上下一个单元组成新的序列,这是结构的变化,值变化为当前值加上下一个单元的值,这是值的变化
三、方法
这里已经知道了有 n 2 n^2 n2级别的子序列,我们需要在这些序列中找出最大的子序列(评判标准在本题是数字和)
1.遍历法(暴力算法)(子序列问题的通用解法)
方法描述:根据特性1,找出所有可能,并计算其中的值,然后在所有可能中找出最大的
时间复杂度:
O
(
n
2
)
O(n^2)
O(n2)(找出所有的可能)×
O
(
n
)
O(n)
O(n)(计算其中的值)=
O
(
n
3
)
O(n^3)
O(n3)
空间复杂度:
O
(
1
)
O(1)
O(1)
O
(
n
3
)
O(n^3)
O(n3)在leetcode中必会超时,我们需要降低
必会超时失败的代码不要去写
遍历的优化
优化方向:遍历次数(❌) 每次遍历的计算次数(✅)
优化依据:特性2:子序列之间的关系
根据子序列的关系,
a
i
,
.
.
.
,
a
j
a_i,...,a_j
ai,...,aj值的计算可以由
a
i
,
.
.
.
,
a
j
−
1
a_i,...,a_{j-1}
ai,...,aj−1通过一步就能算出,我们在遍历时可以不断计算其值,这仅通过一步就可以完成
因此,时间复杂度变为
O
(
n
2
)
O(n^2)
O(n2)(找出所有的可能)×
O
(
1
)
O(1)
O(1)(计算其中的值)=
O
(
n
2
)
O(n^2)
O(n2)
C++代码:
static int Force_maxSubArray(vector<int>& nums) {
int max = nums[0];
int temp;
for (int i = 0; i < nums.size(); i++) {
temp = nums[i];
if (temp > max)max = temp;
for (int j = i+1; j < nums.size(); j++) {
temp += nums[j];
if (temp > max)max = temp;
}
}
return max;
}
程序结果:时间6%,空间71%
这种方法显然时间复杂度还是是非常大的( O ( n 2 ) O(n^2) O(n2)一般都很大),我们需要降低
进一步分析
遍历方法虽然很慢,但是它给出了最基本的方法,为问题规模的求解给出了基本的思考方向,是分析其他方法的基础
在遍历方法的基础上,我的优化的思路是(必然的优化思路):只计算少量的子序列的值,即让某一些子序列不需要计算它的值就知道它不是最大值
例如:我们已知序列5>序列4,那么那么我们不需要计算序列4就知道它肯定不是最大值
这种思路的本质是利用了二元关系的传递性,即最大值序列>=序列5>序列4
那么怎样才能实现这一点呢?基于算法理论,产生出两种固定思路
分类法:将子序列分类,在每类中利用子序列关系迅速找出最大值
分治法:将数组规模变小,先求出小规模数组中的最大子序列,然后通过子序列的关系,将多个小规模的数组进行合并,利用数组的关系找出合并后的最大子序列
2.分类法
方法描述:将子序列进行分类,每类中找出最大值,再将这些最大值比较,最后得出结果。
时间复杂度:O(类别数量)*O(每类计算最大值的步骤数量)+O(最大值比较步骤数量)。我们需要保证该时间复杂度小于 O ( n 2 ) O(n^2) O(n2),因为O(最大值比较步骤数量)<O(n)(该方法实则为找数组中的最大值),所以该项可以从公式中剔除
思路已经给出,接下来的关键是如何分类,分类的依据是需要包含全部的子序列。本人能想到的有两种分类:
- 设 f ( i ) f(i) f(i)表示数组中 a 0 a_0 a0到 a i a_i ai中的所有子序列,并同时表示该子序列中的最大子序列,那么最大值为 m a x ( f ( 1 ) , . . . , f ( n ) ) max(f(1),...,f(n)) max(f(1),...,f(n))
- 设 f ( i ) f(i) f(i)表示以 a i a_i ai结尾/开头的所有子序列,并同时表示该子序列的最大子序列,最大值形式和(1)相同
那么该选择哪种呢,本人认为,只要最后计算量小于 O ( n 2 ) O(n^2) O(n2)那就都是合理的解决方案。
首先可以看出这两种都将其分成了n类,这表明时间复杂度中O(类别数量)=O(n)。所以为使结果小于 O ( n 2 ) O(n^2) O(n2),我们的下一步目标变成了使O(每类计算最大值的步骤数量)<O(n)
为了充分的利用子序列之间的关系,我们需要将上一类计算出来的最大序列保存下来用于计算下一类的最大序列,同时还需要下一列所用的其他参数。这种方法可以用公式表示成:
f
(
i
+
1
)
=
g
(
f
(
i
)
,
a
i
+
1
)
f(i+1)=g(f(i),a_{i+1})
f(i+1)=g(f(i),ai+1)
这实际上是一种递归思想,可以通过递归函数来解决,也可以通过动态规划(即保存中间变量)来解决。
我们先来看第一种分类方法:
该分类方法在使用该递归方法时会遇到三种情况,三种情况对应三种计算方式:
- a i + 1 a_{i+1} ai+1是负数: f ( i + 1 ) = f ( i ) f(i+1)=f(i) f(i+1)=f(i),复杂度为O(1)
- a i + 1 a_{i+1} ai+1是正数且f(i)序列末尾是 a i a_i ai: f ( i + 1 ) = f ( i ) + a i + 1 f(i+1)=f(i)+a_{i+1} f(i+1)=f(i)+ai+1,复杂度为O(1)
- a i + 1 a_{i+1} ai+1是正数且f(i)序列末尾不是 a i a_i ai:该步较为复杂,需要从序列{ a i , a i + 1 a_i,a_{i+1} ai,ai+1}其向前遍历找到最大值。此时子序列之间的关系失效,复杂度为O(n)
可以看出第一类分类方法在计算单个类内部的最大值用到的O(n)级别的算法,所以该分类方法无意义,被舍弃。
再看第二种分类方法:
该分类方法中在使用递归方法可直接用一个公式解决:
f
(
i
+
1
)
=
m
a
x
(
f
(
i
)
,
f
(
i
)
+
a
i
+
1
)
f(i+1)=max(f(i),f(i)+a_{i+1})
f(i+1)=max(f(i),f(i)+ai+1),复杂度为O(1),该分类方法可以使用。时间复杂度为O(n)*O(1)=O(n),空间复杂度为O(n)
C++代码:
int maxSubArray2(vector<int>& nums){
vector<int> maxs{nums[0]};
for(int i=1;i<nums.size();i++)maxs.push_back(max(maxs[i-1]+nums[i],nums[i]));
return *max_element(maxs.begin(),maxs.end());
}
ps:C++才用了三行,可以说已经不能再简洁了
程序结果:时间96%,空间53%
分类的优化
由于下一类别的计算只需要上一类的结果,我们在执行动态规划时可以只保存上一类的结果
此外,由于需要比较的值在每次迭代都会出现,我们可以在计算出该类最大值后直接执行全局最大值比较操作,从而不断迭代更新出最大值即可
该方法的优点是降低了空间复杂度为O(1) (时间复杂度由于内置最大值算法的差异,可能会有所不同,但最终都是O(n)级别的)
C++代码:
int maxSubArray(vector<int>& nums){
int max1=nums[0];
int max_true=max1;
for(int i=1;i<nums.size();i++){
max1=max(max1+nums[i],nums[i]);
max_true=max(max_true,max1);
}
return max_true;
}
程序结果:时间96%,空间55%(实际上也没差多少,一个12.9m,一个13.1m)
3.分治法
方法描述:该方法将问题的规模由小到大,分为两步,1.计算小规模的解 2.将多个小规模合并,计算大规模的解。典型的方法是二分法,即将两个小规模合并成一个大规模
在该类型问题中,我们将产生子序列的数组分类,每类找出最大序列,之后合并数组,根据小数组最大序列找到合并后的最大序列。由此不断的合并找出最终值
采用二分法分类:即最开始的类只有一个子序列(一个数字),之后不断的二路合并
算法复杂度:O(logn)(二分合并次数)*O(每次合并的步骤次数)
该方法的关键在于合并的过程,只要合并的步骤次数小于等于O(n),那么该方法就是可取的
合并的过程同递归思想相同,设
f
(
i
,
j
)
f(i,j)
f(i,j)表示子序列
a
i
,
.
.
.
,
a
j
a_i,...,a_j
ai,...,aj中的最大子序列,那么
f
(
i
,
j
)
=
g
(
f
(
i
,
k
)
,
f
(
k
+
1
,
j
)
)
f(i,j)=g(f(i,k),f(k+1,j))
f(i,j)=g(f(i,k),f(k+1,j))
我们接下来的目标就是细化这个g函数
细化函数以结果导向,即结果要满足什么特征,这要包含所有可能的情况:
- 结果在左半边: f ( i , j ) = f ( i , k ) f(i,j)=f(i,k) f(i,j)=f(i,k),复杂度O(1)
- 结果在右半边: f ( i , j ) = f ( k + 1 , j ) f(i,j)=f(k+1,j) f(i,j)=f(k+1,j) ,复杂度O(1)
- 结果横穿中间:这种结果满足 a k , a k + 1 ∈ f ( i , j ) a_k,a_{k+1} \in f(i,j) ak,ak+1∈f(i,j)即中间那两个数必然包含在里面。在需要在两边进行延长遍历,复杂度O(n)
可以得出最终的时间复杂度O(nlogn)
空间复杂度:要使用栈空间,复杂度为O(logn)
代码本人懒得写了
四、总结
针对该问题,首先将问题抽象化,为该问题建立模型(子序列最值问题),并分析其本质特点(子序列个数和子序列之间具有一定的关系),接下来考虑解决方案。
首先考虑暴力解决(遍历),该方法为
O
(
n
3
)
O(n^3)
O(n3),考虑到了子序列之间的关系,将该方法降为
O
(
n
2
)
O(n^2)
O(n2) (规律:有些操作在遍历时就能直接完成)
该方法奠定了优化的方向(只计算个别的子序列和,不计算全部),针对该方向提出了两个方法:分类法和分治法
在分类法中,提出了两种分类方式,为了充分利用子序列的关系,采用递归的方法来找到每类中最大的子序列,通过动态规划思想来实现。之后进一步优化,在不断遍历下一类时直接完成最大值比较操作,降低了空间复杂度
时间复杂度:O(n) 空间复杂度:O(1)
在分治法中,采用二分法思想,同样建立递归函数,将该函数以结果为导向,分成三种情况
时间复杂度:O(nlogn) 空间复杂度:O(logn)