这篇文章的结构是这样的:一、问题介绍和文章撰写缘由介绍。二、从暴力算法到kadane算法的思路过程,并着重介绍kadane算法。三、问题的实际应用场景。其中重点是第二部分。
一、问题介绍和文章撰写缘由介绍。
前两周面试滴滴,一面面试官手撕了几道算法题,其中有一道题是这样的:求数组的最大连续子数组之和。例如,数组为[-2,1,-3,4,-1,2,1,-5,4],最大连续子数组之和为6。(即最大连续子数组是[4,-1,2,1],其和为6。)
我当场给出的解法是kadane算法,不过面试官似乎不太了解,拿测试用例手动计算了一遍才判定了算法的正确性。所以想写一篇文章,不仅介绍这个简洁方便的算法,也对这个问题的其他解法进行对比分析。
二、从暴力算法到kadane算法的思路过程,并着重介绍kadane算法。
这个问题最暴力的算法是比较数组所有子数组的大小,通过滑动窗口来实现,该算法的时间复杂度是O(n^3)
(子数组数量为n^2
,求和的算法复杂度为O(n)
),空间复杂度为O(1)
。
def maxSubArrayBF(nums):
length = len(nums)
MAX = nums[0]
for i in range(0,length):
for j in range(i+1,length+1):
sub = nums[i:j]
sub_sum = sum(sub)
if sub_sum>MAX:
MAX = sub_sum
return MAX
将暴力算法的求和过程优化下可以达到O(n^2)
。此时,用sum_sub
变量保存num[i]
开始至nums[j]
的子数组之和。
def maxSubArrayBF2(nums):
length = len(nums)
MAX = nums[0]
for i in range(0,length):
sum_sub = 0
for j in range(i, length):
sum_sub += nums[j]
if sum_sub>MAX:
MAX = sum_sub
return MAX
平方阶复杂度在实际应用中还是过于复杂了。故需要思考效率更高的算法。
要想得出效率更高的算法,首先得明白效率低的算法为何会效率低。
在前两种暴力算法中,我们在遍历子数组获取当前最大子数组之和的时候,是以某个节点为开头的所有子序列: 如[a]
,[a, b]
,[ a, b, c]
... 再从以b
为开头的子序列开始遍历[b] [b, c]
。
这样导致每次获取当前最大子数组之和的时候需要对迄今为止所有子数组之和进行计算和比较。
若更改对子数组的遍历方式,以子序列的结束节点为基准,先遍历出以某个节点为结束的所有子序列,因为每个节点都可能会是子序列的结束节点,因此要遍历下整个序列,如: 以 b
为结束点的所有子序列: [a , b] [b]
以 c
为结束点的所有子序列: [a, b, c] [b, c] [ c ]
。
这样当我们想获取以c
为结束点的子序列相关信息时,可以利用先前以b
为结束点的子序列信息,比如若想知道子序列[a,b,c]
之和,只要利用先前计算的[a,b]
子序列之和再加上c
即可,用递推式表示就是sum[i]=sum[i-1]+array[i]
。
总而言之,新的遍历方式可以产生递推关系,当前问题的解可以在先前问题的解的基础上获得,我们若保存先前问题的解,获得当前问题的解只需要常数时间。这样的算法效率自然得到了提高。采用这种遍历方式来解决问题,就是动态规划的思路。
动态规划的关键有三点,一是定义子问题。二是递推基,即问题规模在最简单的情况下解是怎样的。三是递推关系,即如何通过先前保存的子问题的解获得当前解。
在最大子数组之和问题中,首先定义子问题。我们定义子问题是为了能在最终保存的所有子问题解中获得目标问题解。常见的定义子问题的方式有两种,一是定义目标问题为子问题,二是定义非目标问题为子问题,目标问题的解可以通过所保存的所有子问题的解来获得。
我们首先通过尝试使用第一种方式来定义子问题。我们将目标问题抽象为array[0,n-1]
的最大子数组之和maxSubSum(n-1)
,其中n表示数组长度。子问题即求array[0,i]
的最大子数组之和maxSubSum(i)
,我们用数组dp来记录子问题的解。递推基为dp[0]=array[0]
。递推关系需要思考maxSubSum(i-1)
和maxSubSum(i)
的关系。而这样的递推关系很难获得。例如,数组[-2,1,-3,4,-1,2,1,-5,4]
递推基为dp[0]=-2
,而dp[1]=1
, dp[2]=1
, dp[3]=4
, dp[3]
和dp[2]
之间的关系并不明确。
故尝试使用第二种方式来定义子问题。我们将子问题定义为求以i
为终止下标的子数组之和的最大值,这样最终可以通过比较以0
为下标的子数组最大值、以1
为下标的子数组最大值......以n-1
为下标的子数组最大值获得最终解。我们用数组dp
来记录子问题的解。递推基为dp[0]=array[0]
。递推关系相比下来就清晰明了多了,当我们对dp[i-1]
和array[i]
分正负讨论。若dp[i-1]<0
, array[i]>0
,dp[i]=array[i]
,若dp[i-1]<0
, array[i]<0
, dp[i]=array[i]
,若dp[i-1]>0
, array[i]<0
, dp[i]=dp[i-1]+array[i]
, 若dp[i-1]>0
, array[i]>0
, dp[i]=dp[i-1]+array[i]
。最后, 原始问题的解即max(dp)
。
整个动态规划解法就浮现了。当然递推关系可以简化,dp[i]=max(dp[i-1]+array[i], array[i])
。算法时间复杂度为O(n)
,空间复杂度为O(n)
。
def maxSubArrayDP(nums):
length = len(nums)
dp = [0 for i in range(length)]
dp[0] = nums[0]
for i in range(1, length):
dp[i] = max(dp[i-1]+nums[i], nums[i])
return max(dp)
kadane算法是在动态规划解的基础上进一步优化。它使用一根指针保存以i为结尾的子数组最大值之和,使用另一根指针保存迄今为止的子数组最大值之和。算法时间复杂度为O(n)
,空间复杂度为O(1)
。代码非常漂亮优雅。
def maxSubArrayKadane(nums):
length = len(nums)
max_ending_here = max_sub_sum = nums[0]
for i in range(1,length):
max_ending_here = max(max_ending_here+nums[i],nums[i])
max_sub_sum = max(max_ending_here, max_sub_sum)
return max_sub_sum
三、问题的实际应用场景。
在计算机视觉中,通过kadane算法来检测代表图像中最亮区域的最高分数子序列。
有些其他算法题可以通过转化为最大子数组之和问题,用kadane算法求解。如leetcode的第121题:
给定一个数组,它的第 i 个元素是一支给定股票第 i 天的价格。
如果你最多只允许完成一笔交易(即买入和卖出一支股票),设计一个算法来计算你所能获取的最大利润。
注意你不能在买入股票前卖出股票。
示例:
输入: [7,1,5,3,6,4]
输出: 5
解释: 在第 2 天(股票价格 = 1)的时候买入,在第 5 天(股票价格 = 6)的时候卖出,最大利润 = 6-1 = 5 。
注意利润不能是 7-1 = 6, 因为卖出价格需要大于买入价格。
求两点差的问题可以看作求区间和的问题。
class Solution(object):
def maxSubArrayKadane(self, nums):
length = len(nums)
max_ending_here = max_sub_sum = nums[0]
for i in range(1,length):
max_ending_here = max(max_ending_here+nums[i],nums[i])
max_sub_sum = max(max_ending_here, max_sub_sum)
return max_sub_sum
def maxProfit(self, prices):
"""
:type prices: List[int]
:rtype: int
"""
if not prices:
return 0
if len(prices)<=1:
return 0
gap = [0 for i in range(len(prices)-1)]
for i in range(0,len(prices)-1):
gap[i] = prices[i+1]-prices[i]
# print(gap)
max_subarrary = self.maxSubarray(gap)
if max_subarrary<0:
return 0
else:
return max_subarrary