数据结构算法学习:LeetCode热题100-普通数组篇(最大子数组和、合并区间、轮转数组、除自身以外数组的乘积、缺失的第一个正常数)
文章目录
- 简介
- 53. 最大子数组和
- 问题描述
- 解题方法
- 动态规划求解
- 分治求解
- 56. 合并区间
- 问题描述
- 解题方法
- 排序求解
- 238. 除自身以外数组的乘积
- 问题描述
- 解题方法
- 41. 缺失的第一个正数
- 问题描述
- 解题方法
- 哈希表求解
- 个人学习总结
简介
本篇博客将深入剖析 LeetCode 中四道极具代表性的数组难题:53. 最大子数组和、56. 合并区间、238. 除自身以外数组的乘积以及 41. 缺失的第一个正数。通过本文,我希望能够系统性地梳理解决复杂数组问题的核心方法论,展示如何根据问题特性选择最优解,并深入分析不同方案在时间与空间复杂度上的权衡。无论您是正在准备面试的算法初学者,还是寻求思维突破的进阶者,相信都能从中获得启发与收获。
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
标签提示: 数组、分治、动态规划
解题方法
动态规划求解
解题思路
动态规划的核心在于将问题分解为子问题,通过局部最优解推导全局最优解。对于最大子数组和问题,我们定义:
- 局部最优解:以当前位置结尾的子数组的最大和
- 全局最优解:整个数组中所有子数组的最大和
在每个位置,我们只需做两个决策:
- 延续:将当前元素加入前一个子数组(当之前和为正时)
- 重新开始:从当前元素开始新子数组(当之前和为负时)
通过遍历数组,在每个位置计算局部最优解,并同步更新全局最优解,最终得到结果。
解题步骤
- 初始化:
- ans = nums[0]:初始最大和设为第一个元素
- pre = 0:表示前一个位置的子数组和(初始为0)
- 遍历数组:
- 对每个元素 x 执行:
- 更新当前子数组和:pre = max(pre + x, x)
- 若 pre + x > x,则延续前一个子数组
- 否则,以当前元素开始新子数组
- 更新全局最大值:ans = max(ans, pre)
- 返回结果:
- 遍历结束后,ans 即为整个数组的最大子数组和
实现代码
class Solution {public int maxSubArray(int[] nums) {int ans = nums[0], pre = 0;for(int x : nums){pre = Math.max(pre + x, x);ans = Math.max(ans, pre);}return ans;}
}
复杂度分析
时间复杂度:O(n)
-
仅需一次遍历数组,n 为数组长度
-
每个元素处理时间为 O(1)
空间复杂度:O(1)
- 仅使用常数个额外变量(ans 和 pre)
- 无需存储 dp 数组,空间优化显著
分治求解
解题思路
采用分治策略求解最大子数组和问题,核心思想是将问题分解为三个子问题:
- 左子数组的最大子数组和
- 右子组的最大子数组和
- 跨越中点的最大子数组和
最终取三者中的最大值作为全局解。递归分解问题直到子问题规模为1(单个元素),然后合并子问题的解。
解题步骤
- 递归终止条件:当子数组只有一个元素时(left == right),直接返回该元素值
- 划分中间节点:计算中间位置 mid = left + (right - left) / 2
- 递归求解子问题:
- 递归求解左子数组最大和:leftsum = divided(nums, left, mid)
- 递归求解右子数组最大和:rightsum = divided(nums, mid + 1, right)
- 求解跨中点子数组和:
- 左扩展:从mid向左遍历,计算包含mid的最大子数组和leftmax
- 右扩展:从mid+1向右遍历,计算包含mid+1的最大子数组和rightmax
- 跨中点和:crosssum = leftmax + rightmax
- 合并结果:返回三者中的最大值 Math.max(Math.max(leftsum, rightsum), crosssum)
实现代码
class Solution {public int divided(int[] nums, int left, int right){if(left == right){return nums[left];}// 划分中间节点int mid = left + (right - left) / 2;// 递归求解左右子数组int leftsum = divided(nums, left, mid);int rightsum = divided(nums, mid + 1, right);// 求解跨中间节点子数组和int crosssum = crossSum(nums, mid, left, right);return Math.max(Math.max(leftsum, rightsum), crosssum);}public int crossSum(int[] nums, int mid, int left, int right){// 左扩展int leftmax = Integer.MIN_VALUE;int sum = 0;for(int i = mid; i >= left; i --){sum += nums[i];leftmax = Math.max(sum, leftmax);}// 右扩展int rightmax = Integer.MIN_VALUE;sum = 0;for(int i = mid + 1; i <= right; i ++){sum += nums[i];rightmax = Math.max(sum, rightmax);}return leftmax + rightmax;}public int maxSubArray(int[] nums) {return divided(nums, 0, nums.length - 1);}
}
复杂度分析
时间复杂度
- 递归关系:T(n) = 2T(n/2) + O(n)
- 递归处理左右两个子数组:2T(n/2)
- 计算跨中点子数组和:O(n)(两个循环各O(n/2))
- 根据主定理(Master Theorem):
- a=2, b=2, f(n)=O(n)
- n^logb(a) = n^1 = n
- 情况2:f(n) = Θ(n^logb(a)) → T(n) = Θ(n log n)
- 最终时间复杂度:O(n log n)
空间复杂度
- 递归栈深度:O(log n)(每次递归将问题规模减半)
- 每层递归使用常数空间(几个局部变量)
- 最终空间复杂度:O(log n)(不考虑输入数组)
56. 合并区间
问题描述
以数组 intervals 表示若干个区间的集合,其中单个区间为 intervals[i] = [starti, endi] 。请你合并所有重叠的区间,并返回 一个不重叠的区间数组,该数组需恰好覆盖输入中的所有区间 。
示例
示例 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] 可被视为重叠区间。示例 3:
输入:intervals = [[4,7],[1,4]]
输出:[[1,7]]
解释:区间 [1,4] 和 [4,7] 可被视为重叠区间。
标签提示: 数组、排序
解题方法
排序求解
解题思路
- 排序预处理:将区间按起始位置升序排序,确保重叠区间相邻
- 单次遍历合并:维护一个合并结果列表,依次检查每个区间:
- 若当前区间与结果列表最后一个区间重叠,则合并
- 否则,将当前区间加入结果列表
- 动态更新:合并时只更新结束位置,保持结果列表有序
解题步骤
- 边界处理:检查输入是否为空或只有一个区间
- 排序区间:使用Arrays.sort按起始位置升序排序
- 初始化结果列表:将第一个区间加入合并列表
- 遍历合并:
- 获取当前区间和结果列表最后一个区间
- 检查重叠:current[0] <= last[1]
- 重叠则合并:last[1] = Math.max(current[1], last[1])
- 不重叠则添加当前区间到结果列表
- 转换结果:将列表转换为二维数组返回
实现代码
class Solution {public int[][] merge(int[][] intervals) {// 边界判断if(intervals == null || intervals.length <= 1){return intervals;}// 按区间排序Arrays.sort(intervals, (a,b) -> a[0] - b[0]);// 使用list存储合并结果List<int[]> merged = new ArrayList<>();// 添加第一个区间merged.add(intervals[0]);// 遍历剩余区间for(int i = 1; i < intervals.length; i++){int[] current = intervals[i];int[] last = merged.get(merged.size() - 1);// 检查重叠if(current[0] <= last[1]){last[1] = Math.max(current[1], last[1]);}else{// 不重叠,便添加merged.add(current);}} // 返回值,得转化为二维数组return merged.toArray(new int[merged.size()][]);}
}
复杂度分析
时间复杂度 O(n log n)
排序占主导:O(n log n) + 遍历合并:O(n) → 总体 O(n log n)
空间复杂度 O(n)
存储合并结果:最坏情况需要存储所有区间(无重叠时)
排序空间 O(log n) Java的Arrays.sort使用双轴快速排序,递归栈空间为O(log n)
补充
Arrays.sort(intervals, (a, b) -> a[0] - b[0]) 原理详解
- 方法基本功能
Arrays.sort() 是 Java 中用于对数组进行排序的核心方法。当传入二维数组 intervals 和一个比较器时,它会对二维数组中的每个一维数组(即每个区间)进行排序。 - Lambda 表达式解析
(a, b) -> a[0] - b[0] 是一个 Lambda 表达式,实现了 Comparator 接口:- a 和 b:代表两个待比较的区间(即两个一维数组)
- a[0]:获取区间 a 的起始位置
- b[0]:获取区间 b 的起始位置
- a[0] - b[0]:计算两个区间起始位置的差值
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]
标签提示: 数组、前缀和
解题方法
解题思想
本题核心思想是分解乘法计算,将每个位置的结果拆分为前缀乘积和后缀乘积的乘积:
- 前缀乘积:当前元素左侧所有元素的乘积
- 后缀乘积:当前元素右侧所有元素的乘积
- 最终结果:result[i] = 前缀乘积[i] × 后缀乘积[i]
关键优化点在于空间复用:
- 利用结果数组存储前缀乘积
- 使用单变量动态计算后缀乘积
- 原地更新结果数组,避免额外空间开销
解题步骤
- 初始化结果数组:创建长度为n的数组result,设置result[0] = 1(首个元素左侧无元素)
- 计算前缀乘积:从索引1开始正向遍历数组
- 执行 result[i] = result[i-1] * nums[i-1]
- 存储当前元素左侧所有元素的乘积
- 初始化后缀变量:创建变量hz并初始化为1(用于累积后缀乘积)
- 计算后缀乘积并更新结果:从末尾开始反向遍历数组(索引n-1到0)
- 执行 result[j] = result[j] * hz(前缀乘积 × 后缀乘积)
- 更新 hz = hz * nums[j](累积当前元素到后缀乘积)
- 返回结果数组:此时result中存储除自身外的乘积结果
实现代码
class Solution {public int[] productExceptSelf(int[] nums) {// 记录前缀乘积与后缀乘积int n = nums.length;int[] result = new int[n];result[0] = 1;for(int i = 1; i < n; i ++){result[i] = result[i - 1] * nums[i - 1];}int hz = 1;for(int j = n - 1; j >= 0; j --){result[j] = result[j] * hz;hz *= nums[j];}return result;}
}
复杂度分析
时间复杂度 O(n)
仅需两次线性遍历(正向计算前缀 + 反向计算后缀)
空间复杂度 O(1)
除结果数组外,仅使用常数个额外变量(hz)
41. 缺失的第一个正数
问题描述
给你一个未排序的整数数组 nums ,请你找出其中没有出现的最小的正整数。
请你实现时间复杂度为 O(n) 并且只使用常数级别额外空间的解决方案。
示例
示例 1:
输入:nums = [1,2,0]
输出:3
解释:范围 [1,2] 中的数字都在数组中。示例 2:
输入:nums = [3,4,-1,1]
输出:2
解释:1 在数组中,但 2 没有。示例 3:
输入:nums = [7,8,9,11,12]
输出:1
解释:最小的正数 1 没有出现。
标签提示: 数组、哈希表
解题方法
哈希表求解
解题思路
-
问题核心:目标是找到一个数组中缺失的最小正整数。这个数一定是从1开始的。
-
关键洞察:对于一个长度为 n 的数组,其缺失的第一个正数一定在 [1, n+1] 这个范围内。
- 原因:如果数组包含了 1 到 n 的所有正整数,那么缺失的就是 n+1。
- 否则,缺失的第一个正数必然是 1 到 n 中的某一个。
-
策略选择:代码采用“空间换时间”的策略。它不直接在原数组上操作,而是引入一个辅助数据结构 HashSet 来快速判断一个数字是否存在。
- 第一步:遍历数组,将所有正整数(且只存一次)存入 HashSet。HashSet 的优势在于其 contains 操作的平均时间复杂度为 O(1),非常适合快速查找。
- 第二步:按照从小到大的顺序(从 1 开始),依次检查 [1, n] 范围内的每个数字是否在 HashSet 中。
- 第三步:第一个在 HashSet 中找不到的数字,就是我们要找的答案。如果 1 到 n 都在 HashSet
中,那么根据关键洞察,答案就是 n+1。
解题步骤
-
初始化:
- 创建一个 HashSet,命名为 set,用于存放数组中出现的正整数。
-
收集正整数:
-
遍历输入数组 nums 中的每一个元素 nums[i]。
-
如果 nums[i] 是一个正整数(nums[i] > 0),就将其添加到 set 中。
-
HashSet 会自动处理重复值,确保集合中每个正整数只出现一次。
-
-
顺序查找缺失数:
-
启动一个循环,变量 i 从 1 开始,一直到 nums.length(包含)。
-
在循环中,检查当前的数字 i 是否存在于 set 中(!set.contains(i))。
-
如果 i 不存在于 set 中,说明这是第一个缺失的正整数,立即 return i 作为结果。
-
-
处理边界情况:
-
如果上面的循环正常结束(没有触发 return),这意味着 set 中包含了从 1 到 nums.length 的所有整数。
-
因此,缺失的第一个正数就是 nums.length + 1。
-
将 nums.length + 1 赋值给 result 并返回。
实现代码
-
class Solution {public int firstMissingPositive(int[] nums) {int result = -1;Set<Integer> set = new HashSet<>();for(int i = 0; i < nums.length; i ++){if(nums[i] > 0 && !set.contains(nums[i])){set.add(nums[i]);}}for(int i = 1; i <= nums.length; i ++){if(!set.contains(i)){return i;}}result = nums.length + 1;return result;}
}
复杂度分析
时间复杂度: O(n)
- 第一个 for 循环遍历了 n 个元素,每次 set.add() 操作的平均时间复杂度为 O(1)。所以这部分是 O(n)。
- 第二个 for 循环最多执行 n 次,每次 set.contains() 操作的平均时间复杂度为 O(1)。所以这部分也是 O(n)。
空间复杂度: O(n)
- 额外的空间主要来自于 HashSet。
- 在最坏的情况下,如果数组中的所有 n 个元素都是不同的正整数,那么 set 将会存储 n 个元素。
个人学习总结
通过对这四道经典数组题的深入钻研,我深刻体会到,算法学习的核心在于从“解决问题”到“理解问题本质”的思维跃迁。这次学习之旅,让我收获了以下几点关键认知:
- 思维模型的重要性:面对一个问题,首先要建立正确的思维模型。例如,最大子数组和让我领悟到动态规划“化整为零,聚零为整”的精髓,通过定义pre状态,将全局最优解巧妙地转化为局部最优解的迭代。而分治法则提供了另一种“分解-解决-合并”的宏观视角,虽然实现更复杂,但拓展了解决问题的思路。
- 预处理与排序的力量:合并区间问题完美诠释了“磨刀不误砍柴工”。当数据杂乱无章时,直接处理往往寸步难行。而通过一次排序,将无序变为有序,就能让重叠的区间“自动靠拢”,将复杂问题简化为一次线性遍历。这让我认识到,排序是解决区间、序列类问题的强大预处理工具。
- 空间优化的精妙之处:除自身以外数组的乘积这道题是空间优化的典范。最初的想法可能是用两个数组分别存储前缀和后缀乘积,但这需要 O(n)的额外空间。而最优解通过复用输出数组,并用一个变量动态追踪后缀乘积,将空间复杂度降至O(1)。这种“变量滚动”和“原地操作”的思想,是写出高质量代码的关键。
- 问题约束的洞察力:缺失的第一个正数教会我如何挖掘题目中的隐藏信息。关键洞察在于“长度为 n 的数组,其缺失的第一个正数必然在 [1,n+1] 的范围内”。这一下就将无限的搜索空间缩小到了有限的 O(n) 范围,使得哈希表这种“空间换时间”的策略变得可行且高效。学会分析问题边界和约束,是找到突破口的重要一步。
总而言之,这次学习让我不再满足于仅仅写出能通过的代码,而是开始主动思考:这个问题最优解是什么?为什么它是最优的?它背后体现了哪种算法思想?还有没有其他解法,它们各自的优劣何在?这种刨根问底的学习方式,虽然耗时,但带来的思维成长是巨大的。未来,我将继续保持这种探索精神,在算法的世界里不断求索。