五、普通数组问题

53、最大子数组和(中等)

题目描述

给你一个整数数组 nums ,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。

子数组 是数组中的一个连续部分。

示例 1:

输入:nums = [-2,1,-3,4,-1,2,1,-5,4]
输出:6
解释:连续子数组 [4,-1,2,1] 的和最大,为 6 。

示例 2:

输入:nums = [1]
输出:1

示例 3:

输入:nums = [5,4,-1,7,8]
输出:23

提示:

  • 1 <= nums.length <= 105
  • -104 <= nums[i] <= 104

题目思路

题目要我们找出和最大的连续子数组的值是多少,「连续」是关键字,连续很重要,不是子序列。

题目只要求返回结果,不要求得到最大的连续子数组是哪一个。这样的问题通常可以使用「动态规划」解决。

关键: 如何定义子问题(如何定义状态)
设计状态思路: 把不确定的因素确定下来,进而把子问题定义清楚,把子问题定义得简单。动态规划的思想通过解决了一个一个简单的问题,进而把简单的问题的解组成了复杂的问题的解。

由于我们 不知道和最大的连续子数组一定会选哪一个数,那么我们可以求出 所有 经过输入数组的某一个数的连续子数组的最大和。

例如,示例 1 输入数组是 [-2,1,-3,4,-1,2,1,-5,4] ,我们可以求出以下子问题:

  • 子问题 1:经过 −2的连续子数组的最大和是多少;
  • 子问题 2:经过 1 的连续子数组的最大和是多少;
  • 子问题 3:经过 −3 的连续子数组的最大和是多少;
  • 子问题 4:经过 4 的连续子数组的最大和是多少;
  • 子问题 5:经过 −1 的连续子数组的最大和是多少;
  • 子问题 6:经过 2 的连续子数组的最大和是多少;
  • 子问题 7:经过 1 的连续子数组的最大和是多少;
  • 子问题 8:经过 −5 的连续子数组的最大和是多少;
  • 子问题 9:经过 4 的连续子数组的最大和是多少。

一共 9 个子问题,这些子问题之间的联系并没有那么好看出来,这是因为 子问题的描述还有不确定的地方

例如「子问题 3」:经过 −3 的连续子数组的最大和是多少。

「经过 −3 的连续子数组」我们任意举出几个:

  • [-2,1,-3,4] ,−3 是这个连续子数组的第 3 个元素;
  • [1,-3,4,-1] ,−3 是这个连续子数组的第 2 个元素;
  • ……

我们不确定的是:−3 是连续子数组的第几个元素。那么我们就把 −3 定义成连续子数组的最后一个元素。在新的定义下,我们列出子问题如下:

  • 子问题 1:以 −2 结尾的连续子数组的最大和是多少;
  • 子问题 2:以 1 结尾的连续子数组的最大和是多少;
  • 子问题 3:以 −3 结尾的连续子数组的最大和是多少;
  • 子问题 4:以 4 结尾的连续子数组的最大和是多少;
  • 子问题 5:以 −1 结尾的连续子数组的最大和是多少;
  • 子问题 6:以 2 结尾的连续子数组的最大和是多少;
  • 子问题 7:以 1 结尾的连续子数组的最大和是多少;
  • 子问题 8:以 −5 结尾的连续子数组的最大和是多少;
  • 子问题 9:以 4 结尾的连续子数组的最大和是多少。

我们加上了「结尾的」,这些子问题之间就有了联系。我们单独看子问题 1 和子问题 2:

  • 子问题 1:以 −2 结尾的连续子数组的最大和是多少?

以 −2 结尾的连续子数组是 [-2],因此最大和就是 −2。

  • 子问题 2:以 1 结尾的连续子数组的最大和是多少?

以 1 结尾的连续子数组有 [-2,1] 和 [1] ,其中 [-2,1] 就是在「子问题 1」的后面加上 1 得到。
-2+1 = -1 < 1,因此「子问题 2」 的答案是1。

发现了吗?如果编号为 i 的子问题的结果是负数或者 0 ,那么编号为 i + 1 的子问题就可以把编号为 i 的子问题的结果舍弃掉(这里 i 为整数,最小值为 1 ,最大值为 8),这是因为:

  • 一个数 a 加上负数的结果比 a 更小;
  • 一个数 a 加上 0 的结果不会比 a 更大;
  • 而子问题的定义必须以一个数结尾,因此如果子问题 i 的结果是负数或者 0,那么子问题 i + 1 的答案就是以 nums[i] 结尾的那个数。

定义状态(定义子问题)
dp[i]:表示以 nums[i] 结尾连续 子数组的最大和。

状态转移方程(描述子问题之间的联系)
根据状态的定义,由于 nums[i] 一定会被选取,并且以 nums[i] 结尾的连续子数组与以 nums[i - 1] 结尾的连续子数组只相差一个元素 nums[i]

假设数组 nums 的值全都严格大于 0,那么一定有 dp[i] = dp[i - 1] + nums[i]

可是 dp[i - 1] 有可能是负数,于是分类讨论并得到状态转移方程:

  • 如果 dp[i - 1] > 0,那么可以把 nums[i] 直接接在 dp[i - 1] 表示的那个数组的后面,得到和更大的连续子数组;
  • 如果 dp[i - 1] <= 0,那么 nums[i] 加上前面的数 dp[i - 1] 以后值不会变大。于是 dp[i] 「另起炉灶」,此时单独的一个 nums[i] 的值,就是 dp[i]

思考初始值
dp[0] 根据定义,只有 1 个数,一定以 nums[0] 结尾,因此 dp[0] = nums[0]

需要注意的是:
这里状态的定义不是题目中的问题的定义,不能直接将最后一个状态返回回去!
这个问题的输出是把所有的 dp[0]、dp[1]、……、dp[n - 1] 都看一遍,取最大值。

关于空间的优化:
虽然这道题可以使用单变量来替代dp,但这样做会丢失代码可读性,不容易理解。
因此除非是空间消耗极为离谱,否则我们就不必费心去优化空间了。

关于 「无后效性」
为了保证计算子问题能够按照顺序、不重复地进行,动态规划要求已经求解的子问题不受后续阶段的影响。这个条件也被叫做「无后效性」。
换言之,动态规划对状态空间的遍历构成一张有向无环图,遍历就是该有向无环图的一个拓扑序。
有向无环图中的节点对应问题中的「状态」,图中的边则对应状态之间的「转移」,转移的选取就是动态规划中的「决策」。

  • 「有向无环图」「拓扑序」表示了每一个子问题只求解一次,以后求解问题的过程不会修改以前求解的子问题的结果;
  • 也即:如果之前的阶段求解的子问题的结果包含了一些不确定的信息,导致了后面的阶段求解的子问题无法得到,或者很难得到,这叫「有后效性」,我们在当前这个问题第 1 次拆分的子问题就是「有后效性」的;
  • 解决「有后效性」的办法是固定住需要分类讨论的地方,记录下更多的结果。在代码层面上表现为:
    • 状态数组增加维度;
    • 把状态定义得更细致、准确;

算法代码

class Solution:
    def maxSubArray(self, nums: List[int]) -> int:
        n = len(nums)
        if n == 0:
            return 0

        dp = [0] * n
        dp[0] = nums[0]

        for i in range(1, n):
            if dp[i-1] >= 0:
                dp[i] = dp[i-1] + nums[i]
            else:
                dp[i] = nums[i]
        
        return max(dp)

56、合并区间(中等)

题目描述

以数组 intervals 表示若干个区间的集合,其中单个区间为 intervals[i] = [start_i, end_i] 。请你合并所有重叠的区间,并返回 一个不重叠的区间数组,该数组需恰好覆盖输入中的所有区间 。

示例 1:

输入:intervals = [[1,3],[2,6],[8,10],[15,18]]
输出:[[1,6],[8,10],[15,18]]
解释:区间 [1,3] 和 [2,6] 重叠, 将它们合并为 [1,6].

示例 2:

输入:intervals = [[1,4],[4,5]]
输出:[[1,5]]
解释:区间 [1,4] 和 [4,5] 可被视为重叠区间。

提示:

  • 1 <= intervals.length <= 104
  • intervals[i].length == 2
  • 0 <= starti <= endi <= 104

题目思路

对于这道题,题目要求对区间进行合并。
所谓的区间合并,就是指如果给定的两个区间有交集,那么我们就将其合二为一、分别取其左右,编程一个更大区间。

这里,如果给定的区间数据组是有序的,我们只需从前到后依次遍历判断即可。
但由于其无序性,因此我们首先需要对其进行排序(这里,我们直接按照数组左端点值进行排序):

  • intervals.sort(key=lambda x: x[0])

值得注意的是,这里我们仅对,而没有做二级排序(即在第一次排序结果的基础上根据右端点值再做排序)。
只需确保整个序列左端点值均是从小到大排列即可。

最后,我们直接遍历intervals,并将最终结果保存至merged中:

  • 如果当前区间左端点在当前数组merged中最后一个区间的右端点之后,那么它们一定不可能重合、且其后所有的区间也不可能与之重合;此时我们直接将该区间加入到merged的末尾;
  • 否则,二者是重合的,由于本身我们时按照左端点进行从小到大的排序,因此此时左端点值不变、只更新右端点值即可:取二者较大值。

算法代码

class Solution:
    def merge(self, intervals: List[List[int]]) -> List[List[int]]:
        # 将区间按照左边界从小到大排序
        intervals.sort(key=lambda x: x[0])
        merged = []

        for interval in intervals:
            # 如果已排序列表为空,或者当前两集合没有交集,那么一定是不连续的
            # 否则,就取当前两区间的右侧最大值为新的合并区间边界
            if not merged or merged[-1][1] < interval[0]:
                 merged.append(interval)
            else:
                new_right = max(merged[-1][1], interval[1])
                merged[-1][1] = new_right

        return merged

189、轮转数组(中等)

题目描述

给定一个整数数组 nums,将数组中的元素向右轮转 k 个位置,其中 k 是非负数。

示例 1:

输入: nums = [1,2,3,4,5,6,7], k = 3
输出: [5,6,7,1,2,3,4]
解释:
向右轮转 1 步: [7,1,2,3,4,5,6]
向右轮转 2 步: [6,7,1,2,3,4,5]
向右轮转 3 步: [5,6,7,1,2,3,4]

示例 2:

输入:nums = [-1,-100,3,99], k = 2
输出:[3,99,-1,-100]
解释:
向右轮转 1 步: [99,-1,-100,3]
向右轮转 2 步: [3,99,-1,-100]

提示:

  • 1 <= nums.length <= 105
  • -231 <= nums[i] <= 231- 1
  • 0 <= k <= 105

题目思路

对于这道题而言,要求我们将一个数组中的元素以此向右循环移动k位即可。

直接解法非常简单,定义一个额外的数组、并简单运用一些取模的思想即可。

当然,还可以直接利用Python语言的切片特性对数组翻转,直接按照长度k前后切片、重新调换组装即可。
这种方法的核心思想是(n=7,k=3):

操作结果
原始数组1 2 3 4 5 6 7
翻转所有元素7 6 5 4 3 2 1
翻转[0, k]区间内元素5 6 7 4 3 2 1
翻转[k, n-1]区间内元素5 6 7 1 2 3 4

不过需要注意的是,k有可能大于数组长度,因此需要先对k取模。

算法代码

1、开辟额外空间

class Solution:
    def rotate(self, nums: List[int], k: int) -> None:
        """
        Do not return anything, modify nums in-place instead.
        """
        n = len(nums)
        cahce = nums[:]
        for i in range(n):
            nums[(i+k)%n] = cahce[i]

2、数组翻转

class Solution:
    def rotate(self, nums: List[int], k: int) -> None:
        """
        Do not return anything, modify nums in-place instead.
        """
        k = k % len(nums)
        nums[:] = nums[-k:] + nums[:-k]

238、除自身以外数组的乘积(中等)

题目描述

给你一个整数数组 nums,返回 数组 answer ,其中 answer[i] 等于 nums 中除 nums[i] 之外其余各元素的乘积 。

题目数据 保证 数组 nums 之中任意元素的全部前缀元素和后缀的乘积都在 32 位 整数范围内。

不要使用除法,且在 O(n) 时间复杂度内完成此题。

示例 1:

输入: nums = [1,2,3,4]
输出: [24,12,8,6]

示例 2:

输入: nums = [-1,1,0,-3,3]
输出: [0,0,9,0,0]

提示:

  • 2 <= nums.length <= 105
  • -30 <= nums[i] <= 30
  • 保证 数组 nums之中任意元素的全部前缀元素和后缀的乘积都在 32 位 整数范围内

题目思路

对于这道题,要求我们获取数组中除自身元素之外的累计乘积。
首先,有个最简单的办法:计算出数组内所有元素的乘积,而后依次除以数组每一个元素值,就可以得到对应结果。

然而题目给定条件,不允许使用除法,那么在对题目条件等观察后我们发现,对于数组:

  • [a, b, c, d, e]

其中对于元素c而言,其“除自身外的累计乘积”为:a * b * d * e,也就是c前半部分数组,和后半部分数组的乘积。

在之前的题目中,我们得知一个概念:前缀和,指的是在数组中前i项的和。
因此在这道题中,我们利用前缀和的概念,提出“前缀积”和“后缀积”:即索引左侧所有数字的乘积和右侧所有数字的乘积。

因此,对于给定索引i,我们分别计算其左侧所有数字的乘积,以及右侧所有数字的乘积,最后左右相乘即可得到位置i的结果。

  • 初始化两个空数组 prefix_lprefix_r。对于给定索引 iprefix_l[i] 代表的是 i 左侧所有数字的乘积,prefix_r[i] 代表的是 i 右侧所有数字的乘积;
  • 我们需要用两个循环来填充 prefix_lprefix_r 数组的值:
    • 对于数组 prefix_lprefix_l[0] 应该是 1,因为第一个元素的左边没有元素。对于其他元素:prefix_l[i] = prefix_l[i-1] * nums[i-1]
    • 对于数组 prefix_rprefix_r[n-1] 应为 1n 指的是输入数组的大小。其他元素:prefix_r[i] = prefix_r[i+1] * nums[i+1]
  • prefix_rprefix_l 数组填充完成,我们只需要在输入数组上迭代,且索引 i 处的值为:prefix_l[i] * prefix_r[i]

算法代码

class Solution:
    def productExceptSelf(self, nums: List[int]) -> List[int]:
        n = len(nums)
        prefix_l, prefix_r, answer = [0] * n, [0] * n, []
        prefix_l[0] = 1
        prefix_r[n-1] = 1
        
        for i in range(1, n):
            prefix_l[i] = prefix_l[i-1] * nums[i-1]
            
        for i in range(n-2, -1, -1):
            prefix_r[i] = prefix_r[i+1] * nums[i+1]
            
        for i in range(n):
            answer.append(prefix_l[i] * prefix_r[i])
            
        return answer

41、缺失的第一个正数(困难)

题目描述

给你一个未排序的整数数组 nums ,请你找出其中没有出现的最小的正整数。

请你实现时间复杂度为 O(n) 并且只使用常数级别额外空间的解决方案。

示例 1:

输入:nums = [1,2,0]
输出:3

示例 2:

输入:nums = [3,4,-1,1]
输出:2

示例 3:

输入:nums = [7,8,9,11,12]
输出:1

提示:

  • 1 <= nums.length <= 5 * 105
  • -231 <= nums[i] <= 231 - 1

题目思路

对于这道题,要求我们找到一个数组中第一个缺失的正数。

首先我们需要理解该问题:

  • 最小的正数是1;
  • 对于数组[1, 2, 3, 4]我们可以看到:
    • 长度为4,且数字为理想情况;
    • 此时缺失的数字为5;
  • 对于数组[0, 1, 2, 3]我们可以看到:
    • 长度为4,但混杂了一个非正数:
    • 此时缺失的数字就是4;
  • 对于数组[1, ,2, 2, 5]我们可以看到:
    • 长度为4,但混杂了一个大于数组长度的数字5:
    • 此时缺失的数字为3;

经过上面的例子以及逻辑分析我们可以得知,假设给定数组的长度是N,那么:

  • 最理想情况,缺失的数字值为N+1;
  • 只要数组中包含非正数(num ≤ 0),或数值大于数组长度的(num > N):
    • 此时缺失的数字值一定在1到N之间;
      • 这是比较好理解的,只需要想象紧凑排列的理想情况,有一些位置被上述情况所占用了;
      • 自然,缺失的值也就一定在1到N之间;

综上,对于长度为N的数组而言,所缺失的第一个正数一定在1到N+1之间。
这样一来,我们将[1, N]范围内的数组放入哈希表,就可以得到最终的答案。此时给定数组的长度恰好为N,为避免开辟额外空间,我们可以直接将该数组设计为哈希表:

  • 我们对数组进行遍历,对于遍历到的数 x,如果它在[1, N] 的范围内,那么就将数组中的第 x−1 个位置(注意:数组下标从 0 开始)打上「标记」。
  • 在遍历结束之后,如果所有的位置都被打上了标记,那么答案是 N+1,否则答案是最小的没有打上标记的位置加 1。

对于 「标记」,由于数组中的数字没有限制——可以为负数、为零、为大于N的一切数字,但从上面我们可以得知,我们实际上只关心[1, N]的数字,因此我们可以先对数组进行遍历,把不在 [1,N] 范围内的数修改成任意一个大于 N 的数(例如 N+1)。
这样一来在遍历时,所有的数字都是正数,我们便可以使用「负号」作为「标记」,同时直接忽略掉所有大于等于N+1的数字,具体流程如下:

  • 我们将数组中所有小于等于0得到数字修改为对结果不影响的N+1,避免原生负数带来的标记影响;
  • 遍历数组中的每个数字x,因为该数字可能在前面某个时刻被打上了标记,因此我们需要对其取绝对值|x|:如果|x|在1到N之间,那么我们就将数组中第|x|-1的位置数字添加一个负号;
    • 需要注意的是,因为该处的数字可能已经被置为负数了,那么在添加负号时要先取绝对值、避免重复添加导致结果错误;
    • 类似于一种“幂等性”;
  • 在完成遍历后,如果每一个数字都是负数,说明数组中N个数字恰好为1到N,那么结果为N+1;否则结果为数组中第一个正数的位置索引所对应的数字——也即idx+1

算法代码

class Solution:
    def firstMissingPositive(self, nums: List[int]) -> int:
        n = len(nums)
        for i in range(n):
            if nums[i] <= 0:
                nums[i] = n + 1

        for i in range(n):
            num = abs(nums[i])
            if num < n + 1:
                nums[num - 1] = -abs(nums[num - 1])

        for i in range(n):
            if nums[i] > 0:
                return i + 1

        return n + 1
  • 26
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值