最大连续子段和的暴力法、分治法和动态规划法求解(Python)

前言

最近正在学习dp,其中一个很经典的例子是最大连续子段和。为了打开思路,我就想着用三种方法来求解这个问题的最优解。如果有任何问题,欢迎看文的小伙伴们指正呀~

正文

1. 暴力法

本人实在是太菜了,如果有哪里说的不对的,看文的小伙伴请直接指出,谢谢~*×1

算法思想:顾名思义,暴力法就是把所有的子段的和都求解出来并记录下来,通过多次比较再最终得出最大的子段和并返回。于是我们枚举每个子段**可能的起点**和**长度**,然后**计算出从某起点开始、某长度的子段和**。

这样想的话我们很容易可以想到是三层循环:第一层控制起点,第二层控制长度,第三层计算该子段的和,时间复杂度为O(n³)。

可这也太慢了,我们自然而然就想要找到更好的算法。

对于时间复杂度为O(n³)的暴力法,我们可以很容易就想到其中的一点优化:对于第三层循环其实并没有必要,既然子段的起点是一样的,只是长度不同,那就没有必要在每次长度增加的时候,再用一层循环从同一个起点开始求和,**只要用一个变量作为该起点的所有长度的累加和就好了呀**。这样就避免了一层循环,**将时间复杂度提升到了O(n²)**!

# 输入
lst = [int(i) for i in input().split()]
n = len(lst)

# 先将最大值设为负无穷,这样的话后续求到的和可以很快替代掉这个值
maximum = float('-inf')
# i代表最大连续子段和的起点,j代表终点
i = 0
j = 0
# 用循环变量start枚举起点,用循环变量move枚举长度的变化,用cur_sum来记录当前计算的子段和
for start in range(n):
# 先将把起点的值赋给当前子段和
    cur_sum = lst[start]
# 与maxisum比较,若cur_sum更大则更新maxisum,并立刻更新下标
    if cur_sum > maximum:
        maximum = cur_sum
        i = j = start
    for move in range(start + 1, n):
# 随着长度的变化,cur_sum的值也开始不断累加
        cur_sum += lst[move]
# 如果cur_sum更大就更新,下标同理
        if cur_sum > maximum:
            maximum = cur_sum
            i = start
            j = move
print(maximum)
print(i, j)

2. 1 分治法(不返回最优解版本)

本人实在是太菜了,如果有哪里说的不对的,看文的小伙伴请直接指出,谢谢~*×2

算法思想:分治法的笼统思想就是**把大问题划分为问题相同的更小的子问题去求解,简称“分而治之”**,可以分为三步:**Divide, Conquer and Combine**。运用分治法求解最大子段和问题可以想到,我们的最优解会出现在三种情况:

  • 最优解出现在序列的左边
  • 最优解出现在序列的右边
  • 最优解出现在序列的中点,并向两端蔓延

Divide:我们把序列一直二分,直到长度短到无法再分并可以直接解决,即len == 1;

Conquer:“治”其实看具体的题目,不同的题目体现不同。比如归并排序或者是双调排序,治就体现在将数据按照升序或降序或者是序列的特性来排序。对于最大子段和问题,我个人的理解是,其中的“治”就体现在MaxSubSum的递归出口和MaxSubSum_Cross中,优雅地、分别地处理了子问题的左边的最大和、右边的最大和及从中点起向两边扩散的最大和;

Combine:这是分治法中很容易被忽略的一步。我个人的理解是,其中的“合并”就体现在递归回溯上。从不可再分的最小的子问题的答案一直回溯到最初的问题的答案,妙不可言~

整理好这三部曲后,我们便可以着手实现:将序列分分分后,直到不可再分的子段,此时便直接返回该子段的唯一的那个值,这也就是为什么分治法要分到可以直接解决的子问题,这也就是递归出口啦!接下来我们再分别调用递归,分别求出左部分的和,右部分的和及中间部分的和,最后返回这三部分中的最大值便是问题的最优值啦!

分治法的思想大多是用递归实现的,所以要掌握好分治的思想要有递归的基础,但并不是有了递归的基础就可以很容易地写出分治(比如我这个菜鸡…),所以还是要多加练习,多分析!

比起暴力法的O(n²),分治法的时间复杂度提升到了O(nlogn)

lst = [int(i) for i in input().split()]
n = len(lst)

“”“
函数名:MaxSubSum
传入参数:序列a[], 起点left, 终点right
函数功能:将序列一分为二,求出左边最大和、右边最大和及中段最大和,最后从中返回最大连续子段和
”“”
def MaxSubSum(a, left, right):
# 递归出口:如果序列不可再分,则返回该子段的唯一值,若为负数则返回0
    if left == right:
        return max(0, a[left])
# 然后就是一直二分的过程...
    mid = (left + right) // 2
    leftsum = MaxSubSum(a, left, mid)
    rightsum = MaxSubSum(a, mid + 1, right)
# 求中间部分的最大和我用了一个函数MaxSubSum_Cross来conquer,然后再把值返回回来
    midsum = MaxSubSum_Cross(a, left, right)
    return max(leftsum, rightsum, midsum)
    
“”“
函数名:MaxSubSum_Cross
传入参数:序列a[], 起点left, 终点right
函数功能:从中点开始向两端的扩散,找到中段的最大和并返回到MaxSubSum中
”“”
def MaxSubSum_Cross(a, left, right):
    leftmax = -float("inf")
    rightmax = -float("inf")
    
# 从中间向左遍历,求到中间部分的左部分的最大和
    cur_sum = 0
    mid = (left + right) // 2
    for i in range(mid, left - 1, -1):
        cur_sum += a[i]
        if cur_sum > leftmax:
            leftmax = cur_sum
# 从中间向右遍历,求到中间部分的右部分的最大和
# 这里我在写代码的时候翻了个错误,把向右遍历的起点设置成了mid。
# 但其实这是不对的,因为我设置向左遍历的起点也是mid,如果向右的起点也是mid的话那岂不是mid多加了一次吗?
# 所以出了一些细节上的错误还是要靠多调试来发现的~
    cur_sum = 0
    for i in range(mid + 1, right + 1):
        cur_sum += a[i]
        if cur_sum > rightmax:
            rightmax = cur_sum
    return leftmax + rightmax

ans = MaxSubSum(lst, 0, n - 1)
print(ans)

2. 2 分治法(返回最优解版本)

最优解就是指下标啦!Python真的很强大,稍加修改便可以将问题的最优解和最优值一起返回。

lst = [int(i) for i in input().split()]
n = len(lst)

“”“
函数名:MaxSubSum
传入参数:序列a[], 起点left, 终点right
函数功能:
将序列一分为二,求出左边最大和、右边最大和及中段最大和,
最后从中返回最大连续子段和及其该子段的下标(以元组的形式)
”“”
def MaxSubSum(a, left, right):
    if left == right:
        if a[left] < 0:
            return (0, left, 0)
        return (a[left], left, 1)
    mid = (left + right) // 2
    leftSum = MaxSubSum(a, left, mid)
    rightSum = MaxSubSum(a, mid + 1, right)
    midSum = MaxSubSum_Cross(a, left, right)
# 根据元组的索引为0的数据为依据,取出最大连续子段和的答案
    return max(leftSum, rightSum, midSum, key=lambda x:x[0])
    
“”“
函数名:MaxSubSum_Cross
传入参数:序列a[], 起点left, 终点right
函数功能:
从中点开始向两端的扩散,找到中段的最大和及起点和终点一同返回到MaxSubSum中
”“”
def MaxSubSum_Cross(a, left, right):
    leftmax = -float("inf")
    rightmax = -float("inf")

    cur_sum = 0
    mid = (left + right) // 2
    l = mid
    r = mid
    for i in range(mid, left - 1, -1):
        cur_sum += a[i]
        if cur_sum > leftmax:
            leftmax = cur_sum
            l = i

    cur_sum = 0
    for i in range(mid + 1, right + 1):
        cur_sum += a[i]
        if cur_sum > rightmax:
            rightmax = cur_sum
            r = i
    return leftmax + rightmax, l, r-l+1

ans = MaxSubSum(lst, 0, n - 1)
print(ans)

3. 动态规划法

本人实在是太菜了,如果有哪里说的不对的,看文的小伙伴请直接指出,谢谢~*×3

先简述一下动态规划的基本思想:

将待求解的问题分解成若干个较为简单的子问题,先求解子问题,再由子问题的解得到原问题的解,而原问题的解就蕴含在子问题的解中.

通常,可以用动态规划求解的问题也可以用枚举来解决问题。但在枚举的过程中会存在“重叠子问题”。即在暴力求解时,一些中间值会进行多次的重复计算,严重影响效率。一个很经典的例子就是斐波那契数列,纯暴力求解其时间复杂度达到了O(2^n),但经过记忆化递归优化后,其时空复杂度都跃升到了O(n)级别!若再用动态规划的思想来进行优化,空间复杂度则可以进一步优化到常数级!由此,动态规划的威力可见一斑。

算法思想:最大连续子段和这个问题,如果我们有一定的算法题基础,是很容易想到它的最好算法是dp法。但可能很多人都不知道这个问题为什么可以使用动态规划法呢?这就不得不提到动态规划使用的三个要素了:

  • 符合最优子结构性质
    简单来说,最优子结构性质就是子问题的最优解一定包含在分解前的原问题的最优解中。一个很经典的最优子结构性质的例子就是最短路径,这里就不多叙述其证明了,其实也很简单,用反证法就行。
    对于“最大连续字段和”这个问题,最优子结构性质如何体现呢?
    从下标为0处开始遍历序列,一开始指针指向lst[0]处,其问题的最优值很简单,就是lst[0]本身。然后指针继续后移,移动到lst[1]后,我们也很容易想到此时问题的最优值必定出现在max(lst[0] + lst[1],lst[1]),这样的话,指针在lst[0]处就相当于是指针在lst[1]处的子问题,子问题的最优值也被包含在了规模更大问题的最优值之中,于是我们可以把lst[1]的最优值改写为max(其子问题的最优值 + lst[1], lst[1])…
    依次类推,指针移动到lst[n]后,其问题的最优解就变成了max(问题规模为n - 1的最优值 + lst[n],lst[n])。
    递推结束后,我们只需要输出所有子问题中的最大值,便是原问题的最优值。
    为什么要这样改写呢?细心的小伙伴可能已经发现,这就是我们的递推公式啦~

  • 无后效性
    其具体解释来自百度百科:

某阶段的状态一旦确定,则此后过程的演变不再受此前各状态及决策的影响。也就是说,“未来与过去无关”
当前的状态是此前历史的一个完整总结,此前的历史只能通过当前的状态去影响过程未来的演变。具体地说,如果一个问题被划分各个阶段之后,阶段k中的状态只能通过阶段k+1中的状态通过状态转移方程得来,与其他状态没有关系,特别是与未发生的状态没有关系。

无后效性如果要解释到我们这个“最大连续子段和”这个具体上的问题,那便是下标为n的位置(状态是n)的最大子段和是由下标为n-1的位置(状态是n-1)的最大子段和递推而来的,与之前的状态都没有关系。

  • 子问题的重叠性
    从最优子结构性质那里的分析可以看到,“最大连续子段和”这个问题的子问题是具有重叠性的。动态规划法避免了子问题的多次计算,而是每次计算后都把子问题的答案记录下来,等到要使用的时候直接去取出来就好了。

很多人说dp法的精华就在dp[]的意义中,因为其中蕴含了递推的意义。

所以我想,如果要掌握好dp,心中必须要明确:我们的dp[]是什么意义?这样拿到题目便可以进行分析,不至于稀里糊涂的copy代码或者是背诵代码来过题。

根据最优子性质结构处的分析,我们可以定义dp[]的含义为:状态为i(指针移动到原序列的下标为i处)时的最大连续子段和的最优值。
具体递推公式为:
dp[i] = max(dp[i - 1] + lst[i], lst[i]),其中i >= 1。

此时,,使用了动态规划后,对于最大连续子段和这个问题的求解,我们把时间复杂度提升到了O(n)!!!

# dp法:时间复杂度O(n)
lst = [int(i) for i in input().split()]
n = len(lst)

# 初始化dp数组,这里多给了10个空间也无伤大雅的
dp = [0] * (n + 10)
# 将dp[0]设为lst[0],也就是指在lst[0]处,其问题的最优值很简单,就是lst[0]本身,这也是递推的第一个状态
dp[0] = lst[0]
# 注意是从1开始遍历
for i in range(1, n):
    dp[i] = max(dp[i - 1] + lst[i], lst[i])
# print(dp[0: n])
# dp数组全部计算完毕后,序列的最大连续子段和就是dp数组中的最大值
maximum = max(dp[0: n])
print(maximum)

# 下面是求解最大连续子段和的最优解(最大连续子段的起始、终止下标)
# 求出maximum在dp中的下标,根据dp[]数组的定义可以推出最优解
# end就是指最优解的终点,start指最优解的起点,一开始也设为end
end = dp.index(maximum)
start = end
# 如果最优值就是lst[end]本身,则即刻返回最优解(start和end都指向同一个)
if lst[end] == maximum:
    print("最优解为:", end)
else:
# 设置一个temp变量准备做累加进行判断,值设为lst[end]
    temp = lst[end]
# 如果不是lst[end]本身,那便从end - 1开始向前遍历lst,并把遍历到的值加进temp
    for i in range(end - 1, -1, -1):
        temp += lst[i]
# 如果temp == maximum 表示找到了解。
# 但注意如果遍历没有结束,这个解可能不是最优解!
# 因为在还没有遍历到的那一段中有些值加起来可能为0,加上去不会影响最优值,
# 但是问题是要找“最大连续”,所以这个解不一定是最优解!
# 因而还要继续遍历。所以我把print()放在了循环外面。
        if temp == maximum:
            start = i
    print("最优解为:", start, end)
  • 6
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值