贪心算法
1.贪心算法核心特性与操作详解
在 LeetCode Hot 100 中,贪心算法是解决一类 "最优解" 问题的高效策略,其核心是通过局部最优选择 推导全局最优解 。由于无需回溯或存储所有中间状态,贪心算法通常具有时间复杂度低 (多为 O (n log n),因常需排序)、实现简洁的特点,在区间调度、资源分配、排序优化等场景中频繁出现。
一、贪心算法的核心特性
贪心算法的适用需满足两个关键条件,也是其核心特性:
- 贪心选择性质
全局最优解可以通过一系列局部最优选择(即 "贪心选择")来获得。每一步选择只依赖当前状态,不考虑过去或未来的选择,且一旦做出选择就不可回溯。
例如:在 "区间调度问题" 中,每次选择 "结束时间最早的区间",最终能得到 "不重叠区间最多" 的全局最优解,这就是贪心选择性质的体现。 - 最优子结构
问题的全局最优解包含其子问题的最优解。即如果将问题分解为子问题,每个子问题的最优解可以组合成全局最优解。
例如:在 "买卖股票的最佳时机 II" 中,全局最大利润等于所有 "相邻两天涨价" 的局部利润之和,每个局部利润的最优选择(有利润就交易)组合成全局最优。
贪心算法的本质是「短视的局部最优策略」,但并非所有问题都适用,需通过特性判断是否可行:
1. 两大核心前提(必须同时满足)
- 贪心选择性质 :每一步的局部最优选择,能导向全局最优解。
即不需要回溯,当前选择的最优解不会影响后续步骤的最优性。例如「硬币找零问题」(当硬币面额为[1,5,10,25]时,每次选最大面额硬币可得到最少数量,满足贪心选择性质;但面额为[1,3,4]时,贪心失效,需动态规划)。 - 最优子结构性质 :全局最优解包含子问题的最优解。
例如「最短路径问题」(Dijkstra 算法),从起点到终点的最短路径,必然包含起点到路径中某中间节点的最短路径。
2. 三大关键特性
- 无后效性 :当前选择仅依赖于当前状态,不依赖未来状态或过去状态的其他选择。
例:「跳跃游戏 II」中,每一步选择能跳最远的位置,无需考虑后续如何跳,只需基于当前可跳范围做最优决策。 - 高效性 :时间复杂度通常为
O(n log n)(排序为主)或O(n),远优于动态规划(常为O(n²)或O(nk)),空间复杂度多为O(1)或O(n)。 - 局限性:若问题不满足贪心选择性质,贪心算法会得到「局部最优但全局次优」的解,此时需改用动态规划或其他算法。
二、贪心算法的典型操作步骤
贪心算法的实现没有固定模板,但通常遵循以下思路:
- 确定贪心策略
根据问题目标,找到 "局部最优" 的衡量标准(如排序依据、选择优先级)。这是贪心算法的核心,策略错误会导致结果不最优。- 例:"分发饼干" 中,策略是 "用最小的饼干满足最小的孩子"(按大小排序后匹配)。
- 例:"无重叠区间" 中,策略是 "优先保留结束时间早的区间"(按结束时间排序)。
- 排序预处理
多数贪心问题需要先对数据排序,以便按策略逐步选择。排序依据由贪心策略决定(如按值、按区间 start/end、按比例等)。- 例:"两地调度" 中,按 "去 A 地与去 B 地的成本差" 排序,优先选择成本差最小的方案。
- 迭代选择
按照排序后的顺序,依次做出局部最优选择,更新问题状态(如已选数量、剩余资源等)。- 例:"跳跃游戏" 中,每次更新 "当前能到达的最远距离",若能覆盖终点则返回 true。
- 验证全局最优
(非必需,但重要)通过逻辑证明或反例验证:局部最优选择的累积是否一定能得到全局最优。若存在反例,则贪心策略不适用(需改用动态规划等)。
解决贪心问题的核心是「找到正确的贪心策略」,通常遵循以下 4 步:
1. 问题分析:判断是否适用贪心
- 明确问题的「目标函数」(如 "最小化硬币数量""最大化区间覆盖数")。
- 验证是否满足「贪心选择性质」:可通过反证法(假设当前局部最优不是全局最优的一部分,推出矛盾)或举例验证。
2. 设计贪心策略:确定 "局部最优" 的标准
- 这是贪心算法的核心,需根据问题场景定义「如何选择才是局部最优」:
- 区间问题:按「区间起点升序」「区间终点升序」或「区间长度降序」排序(如「无重叠区间」选终点最早的区间,以保留更多空间给后续区间)。
- 资源分配:优先分配给 "性价比最高" 的对象(如「分糖果」中,优先满足评分高的孩子的糖果需求)。
- 最优解问题:优先选择 "贡献最大" 的元素(如「最大子数组和」中,优先保留和为正的子数组,一旦和为负则重置)。
3. 排序 / 预处理:为贪心策略铺路
- 多数贪心问题需先对数据排序(如区间、数组元素),才能按策略逐步选择。例如:
- 「合并区间」需先按区间起点排序,才能依次合并重叠区间;
- 「最大数」需先按自定义规则排序(如
a+b > b+a则a排在b前),才能拼接出最大数。
4. 迭代选择:逐步执行局部最优,验证全局最优
- 按设计的策略迭代选择局部最优解,记录中间结果,最终输出全局最优解。
- 若迭代过程中发现策略无法覆盖所有情况(如出现矛盾),需重新调整贪心策略(或确认问题不适合贪心)。
三、LeetCode Hot 100 中的典型贪心问题与解析
1. 区间调度类:435. 无重叠区间
问题 :移除最少数量的区间,使剩余区间互不重叠。
贪心策略 :优先保留 "结束时间最早" 的区间(结束早的区间留给后续区间的空间更大)。
步骤:
- 按区间结束时间升序排序;
- 迭代选择:若当前区间的 start ≥ 上一个选中区间的 end,则保留,否则移除;
- 总区间数 - 保留的区间数 = 需移除的最少数量。
2. 资源分配类:455. 分发饼干
问题 :用饼干满足孩子的饥饿度,每个孩子最多吃一个饼干,求最多能满足的孩子数。
贪心策略 :"小饼干喂小胃口"(避免大饼干浪费)。
步骤:
- 分别对孩子饥饿度和饼干尺寸排序;
- 双指针匹配:用最小的饼干尝试满足最小的孩子,若能满足则计数 + 1,否则换更大的饼干。
3. 跳跃类:55. 跳跃游戏
问题 :给定非负整数数组,每个元素表示最大跳跃长度,判断能否到达最后一个位置。
贪心策略 :跟踪 "当前能到达的最远距离",若最远距离覆盖终点则可行。
步骤:
- 迭代数组,更新最远距离(
maxReach = max(maxReach, i + nums[i]); - 若迭代中
i > maxReach(当前位置已不可达),返回 false; - 若
maxReach ≥ 数组末尾,返回 true。
4. 排序优化类:122. 买卖股票的最佳时机 II
问题 :可多次买卖股票,求最大利润(买卖无手续费)。
贪心策略 :"见涨就买,见跌就卖"(累积所有正差价)。
步骤:
- 迭代数组,若当天价格 > 前一天价格,利润 += 差价;
- 总利润即为全局最大(因可多次交易,局部正利润之和等于全局最大)。
四、贪心算法的局限性与注意事项
- 适用范围窄:仅当问题满足 "贪心选择性质" 时有效。例如 "零钱兑换" 问题,在人民币体系(1,5,10,20)中可用贪心,但在特殊体系(如 1,3,4 兑换 6)中贪心会失效(贪心选 4+1+1=6,最优为 3+3=6)。
- 策略设计是关键:同一问题可能有多种贪心策略,需通过分析选择正确的一种。例如 "区间合并" 需按 start 排序,而 "区间调度" 需按 end 排序。
- 与动态规划的区别 :
- 贪心:局部最优→全局最优,无回溯,时间效率高;
- 动态规划:存储子问题最优解,处理有后效性的问题(如 "打家劫舍"),时间复杂度较高但适用范围更广。
贪心算法的常见误区与注意事项
-
不要盲目使用贪心 :若问题不满足贪心选择性质,贪心会失效。例如「零钱兑换」(面额
[1,3,4],要凑 6 元:贪心选 4+1+1=3 枚,最优解是 3+3=2 枚),此时需用动态规划。 -
排序是贪心的 "常用前置操作":多数贪心问题(如区间、资源分配)需先排序,才能按策略逐步选择,排序的规则直接决定贪心策略的有效性。
-
验证贪心策略的正确性 :通过反证法或多组测试用例验证。例如「无重叠区间」若按起点排序而非终点排序,会得到错误结果(如
[[1,4],[2,3],[3,4]],按起点排序会保留[1,4],移除 2 个;按终点排序保留[2,3],[3,4],仅移除 1 个)。 -
区分贪心与动态规划
:
- 贪心:无后效性,局部最优→全局最优,时间效率高;
- 动态规划:有后效性,需存储子问题结果,时间效率低但能处理更多问题。
总结
贪心算法在 LeetCode Hot 100 中是解决 "最优解" 问题的高效工具,其核心是通过局部最优选择 和排序预处理推导全局最优。掌握贪心算法的关键在于:
- 识别问题是否满足 "贪心选择性质" 和 "最优子结构";
- 设计正确的贪心策略(如排序依据、选择优先级);
- 通过典型问题(区间调度、资源分配等)积累经验,明确策略设计的逻辑。
在实际解题中,若能找到合理的贪心策略,往往能以极低的时间复杂度得到最优解,是面试中的 "性价比" 之选。
2.121. 买卖股票的最佳时机
给定一个数组 prices ,它的第 i 个元素 prices[i] 表示一支给定股票第 i 天的价格。
你只能选择 某一天 买入这只股票,并选择在 未来的某一个不同的日子 卖出该股票。设计一个算法来计算你所能获取的最大利润。
返回你可以从这笔交易中获取的最大利润。如果你不能获取任何利润,返回 0 。
示例 1:
输入:[7,1,5,3,6,4]
输出:5
解释:在第 2 天(股票价格 = 1)的时候买入,在第 5 天(股票价格 = 6)的时候卖出,最大利润 = 6-1 = 5 。
注意利润不能是 7-1 = 6, 因为卖出价格需要大于买入价格;同时,你不能在买入前卖出股票。
买卖股票的最佳时机问题(仅允许一次买卖)的核心是找到 "最低买入价" 和 "最高卖出价" 的差值最大值 ,且卖出价必须在买入价之后。常见解法包括贪心算法(最优) 、动态规划 和暴力法(基础),其中贪心算法以 O (n) 时间复杂度成为最优选择,动态规划则适合推广到多笔交易的场景。
一、核心解法:贪心算法(一次遍历找最低买点)
(一)思路概述
贪心算法的核心是实时跟踪 "当前最低买入价",并计算当天价格与最低买入价的利润,通过一次遍历更新 "最大利润"。其逻辑基于:
- 对于每一天
i,若当天价格prices[i]低于之前的最低买入价,则更新最低买入价(后续卖出需以更低的价格买入,利润才可能更大); - 若当天价格高于最低买入价,则计算利润
prices[i] - 最低买入价,并与当前最大利润比较,更新最大值; - 遍历结束后,最大利润即为结果(若利润为负则返回 0)。
(二)代码实现
java
class Solution { public int maxProfit(int[] prices) { if (prices.length <= 1) return 0; // 不足2天,无法交易
int minBuyPrice = prices [0]; // 初始最低买入价:第一天价格
int maxProfit = 0; // 初始最大利润:0(无利润时返回)
// 从第二天开始遍历(索引 1)
for (int i = 1; i < prices.length; i++) {
// 1. 更新最低买入价:若当天价格更低,更新为当天价格
if (prices [i] < minBuyPrice) {
minBuyPrice = prices [i];
}
// 2. 计算当天利润:若当天价格高于最低买入价,尝试更新最大利润
else {
int currentProfit = prices [i] - minBuyPrice;
if (currentProfit > maxProfit) {
maxProfit = currentProfit;
}
}
}
return maxProfit;
}
}
(三)执行流程示例
以 prices = [7,1,5,3,6,4] 为例,分步解析:
| 遍历天数(i) | 当天价格(prices [i]) | 最低买入价(minBuyPrice) | 当天利润(prices [i]-minBuyPrice) | 最大利润(maxProfit) |
|---|---|---|---|---|
| 初始(i=0) | 7 | 7 | - | 0 |
| i=1 | 1 | 1(1 < 7,更新) | -(1 不大于 1,不计算) | 0 |
| i=2 | 5 | 1(5 > 1,不更新) | 5-1=4 | max(0,4)=4 |
| i=3 | 3 | 1(3 > 1,不更新) | 3-1=2 | max(4,2)=4 |
| i=4 | 6 | 1(6 > 1,不更新) | 6-1=5 | max(4,5)=5 |
| i=5 | 4 | 1(4 > 1,不更新) | 4-1=3 | max(5,3)=5 |
最终最大利润为 5(第 2 天买入,第 5 天卖出,利润 6-1=5),符合预期。
二、对比解法:动态规划(状态转移)
(一)思路概述
动态规划通过定义 "状态" 记录每天的最优决策,适合推广到 "允许多次交易""含冷冻期" 等复杂场景。对于本题(仅一次交易),定义两个状态:
dp[i][0]:第i天不持有股票的最大利润(可能是当天卖出,或之前已卖出);dp[i][1]:第i天持有股票的最大利润(可能是当天买入,或之前已买入)。
状态转移方程:
- 不持有股票(dp [i][0]) :
两种情况取最大值:- 第
i-1天已不持有,第i天无操作 →dp[i-1][0]; - 第
i-1天持有,第i天卖出 →dp[i-1][1] + prices[i](卖出获得当天价格)。
- 第
- 持有股票(dp [i][1]) :
两种情况取最大值:- 第
i-1天已持有,第i天无操作 →dp[i-1][1]; - 第
i-1天不持有,第i天买入 →-prices[i](买入花费当天价格,因仅一次交易,之前利润为 0)。
- 第
初始状态:
- 第 0 天不持有股票:
dp[0][0] = 0(无交易,利润 0); - 第 0 天持有股票:
dp[0][1] = -prices[0](买入第一天股票,利润为负)。
最终结果:
第 n-1 天不持有股票的利润 dp[n-1][0](持有股票无利润,故不考虑)。
(二)代码实现(空间优化)
由于 dp[i] 仅依赖 dp[i-1],可用两个变量替代二维数组,将空间复杂度从 O (n) 优化到 O (1):
java
class Solution {
public int maxProfit(int[] prices) {
if (prices.length <= 1) return 0;
// 初始状态:第 0 天的状态
int hold = -prices [0]; // 持有股票的利润
int notHold = 0; // 不持有股票的利润
// 从第 1 天开始遍历
for (int i = 1; i < prices.length; i++) {
// 更新当天不持有股票的利润:前一天不持有 或 前一天持有今天卖出
notHold = Math.max (notHold, hold + prices [i]);
// 更新当天持有股票的利润:前一天持有 或 今天买入(仅一次交易,之前利润为 0)
hold = Math.max (hold, -prices [i]);
}
return notHold; // 最终不持有股票的利润即为最大利润
}
}
(三)执行流程示例
仍以 prices = [7,1,5,3,6,4] 为例,分步解析状态变化:
| 遍历天数(i) | 当天价格(prices [i]) | 持有股票利润(hold) | 不持有股票利润(notHold) |
|---|---|---|---|
| 初始(i=0) | 7 | -7 | 0 |
| i=1(价格 1) | 1 | max(-7, -1) = -1 | max(0, -7+1=-6) = 0 |
| i=2(价格 5) | 5 | max(-1, -5) = -1 | max(0, -1+5=4) = 4 |
| i=3(价格 3) | 3 | max(-1, -3) = -1 | max(4, -1+3=2) = 4 |
| i=4(价格 6) | 6 | max(-1, -6) = -1 | max(4, -1+6=5) = 5 |
| i=5(价格 4) | 4 | max(-1, -4) = -1 | max(5, -1+4=3) = 5 |
最终 notHold = 5,与贪心算法结果一致。
三、基础解法:暴力法(枚举所有可能交易)
(一)思路概述
暴力法通过枚举所有 "买入 - 卖出" 组合,计算每个组合的利润,取最大值。具体步骤:
- 遍历每个买入日
i(0 ≤ i < n-1); - 遍历每个卖出日
j(i < j < n); - 计算利润
prices[j] - prices[i],更新最大利润; - 遍历结束后,若最大利润为负则返回 0,否则返回最大利润。
(二)代码实现(仅作参考,时间复杂度不达标)
java
class Solution { public int maxProfit(int[] prices) { int n = prices.length; if (n <= 1) return 0;
int maxProfit = 0;
// 枚举买入日 i
for (int i = 0; i < n - 1; i++) {
// 枚举卖出日 j(j > i)
for (int j = i + 1; j < n; j++) {
int profit = prices [j] - prices [i];
if (profit > maxProfit) {
maxProfit = profit;
}
}
}
return maxProfit;
}
}
(三)局限性
暴力法的时间复杂度为 O (n²),当数组长度较大(如 n=10⁴)时会超时,仅适合理解问题本质,实际解题中不推荐。
四、三种解法对比
| 解法 | 时间复杂度 | 空间复杂度 | 核心优势 | 劣势 |
|---|---|---|---|---|
| 贪心算法 | O(n) | O(1) | 最优效率,一次遍历,实现简单 | 仅适用于 "一次交易" 场景 |
| 动态规划(优化) | O(n) | O(1) | 可推广到复杂场景(多次交易、冷冻期) | 逻辑稍复杂,需理解状态转移 |
| 暴力法 | O(n²) | O(1) | 逻辑直观,易于理解 | 效率极低,大数据量超时 |
总结
买卖股票的最佳时机(一次交易)的最优解是贪心算法,其核心是 "实时跟踪最低买入价,计算当天利润并更新最大值",时间复杂度 O (n)、空间复杂度 O (1),实现简洁且高效。
动态规划法则是更通用的解法,通过状态定义和转移方程,可应对 "多次交易""含手续费" 等扩展问题,适合深入理解问题的本质逻辑。
暴力法虽直观,但效率过低,仅作为基础思路参考,实际解题中需优先选择贪心或动态规划。
3.55. 跳跃游戏
给你一个非负整数数组 nums ,你最初位于数组的 第一个下标 。数组中的每个元素代表你在该位置可以跳跃的最大长度。
判断你是否能够到达最后一个下标,如果可以,返回 true ;否则,返回 false 。
示例 1:
输入:nums = [2,3,1,1,4]
输出:true
解释:可以先跳 1 步,从下标 0 到达下标 1, 然后再从下标 1 跳 3 步到达最后一个下标。
跳跃游戏的核心问题是判断从起点出发,能否通过数组元素规定的最大跳跃长度,最终到达最后一个下标 。关键在于高效跟踪 "可到达的最大范围",避免冗余计算。主流解法包括贪心算法(最优,O (n) 时间) 、动态规划(基础,O (n²) 时间),回溯法因效率过低仅作思路参考。
一、核心解法:贪心算法(跟踪最大可到达距离)
(一)思路概述
贪心算法的核心是不纠结于 "当前跳几步",而是关注 "当前能到达的最远距离"。其逻辑基于:
- 遍历数组时,实时更新 "当前可到达的最大距离"(
maxReach),计算公式为maxReach = max(maxReach, i + nums[i])(i是当前下标,i + nums[i]是从当前位置能跳到的最远下标)。 - 若遍历过程中,
maxReach已覆盖最后一个下标(maxReach >= n-1),直接返回true(无需继续遍历)。 - 若遍历到某个下标
i时,i > maxReach(当前位置已超出可到达范围),说明无法到达后续位置,返回false。
(二)代码实现
java
class Solution { public boolean canJump(int[] nums) { int n = nums.length; if (n == 1) return true; // 只有一个元素,已在终点
int maxReach = 0; // 当前能到达的最远距离
// 遍历每个位置,更新最大可到达距离(无需遍历到最后一个元素,因提前满足条件会返回)
for (int i = 0; i < n; i++) {
// 若当前位置超出可到达范围,直接返回 false
if (i > maxReach) {
return false;
}
// 更新最大可到达距离
maxReach = Math.max (maxReach, i + nums [i]);
// 若最大距离已覆盖终点,返回 true
if (maxReach >= n - 1) {
return true;
}
}
// 遍历结束仍未覆盖终点(理论上不会走到这里,因 i 超过 maxReach 时已返回 false)
return false;
}
}
为什么关注 "当前能到达的最远距离"就行
核心逻辑:最远距离覆盖了所有局部跳法的可能性
跳跃游戏的本质是 "判断是否存在一条从起点到终点的路径",而 "当前能到达的最远距离"maxReach 有一个关键属性:
如果某个位置 i 能被到达(i ≤ maxReach),那么从 i 出发能跳到的所有位置(i+1 到 i+nums[i])也一定能被到达 ------ 因为 i 已在可到达范围内,从 i 跳的所有路径自然也在 "可到达范围" 内。
而 maxReach 正是 "所有已遍历位置能跳到的最远边界"。例如,若当前遍历到 i=2,maxReach=5,这意味着:
- 所有
0~2位置能跳到的最远位置是5; - 无论从
0跳2步到2,还是从1跳3步到4,这些局部跳法的 "最远覆盖范围" 都已被maxReach=5包含。
因此,我们无需关心 "具体从哪个位置跳、跳几步"------ 只要 maxReach 能覆盖到更远的位置,所有局部跳法的可能性都已被纳入考虑,再纠结具体跳法就是冗余的。
简单说,关注 "当前能到达的最远距离",就像你提前摸清了++自己 "脚能迈到的最远地方"------ 只要这个最远范围能罩住终点++,你就一定能到;要是罩不住,再怎么折腾也到不了。
具体掰扯:
- 不用纠结 "现在跳 1 步还是 2 步"------ 比如你站在位置 A,能跳 3 步(到 A+3),站在位置 B(A 能跳到 B),能跳 2 步(到 B+2)。不管你从 A 直接跳 3 步,还是先到 B 再跳 2 步,你能到的++最远地方就是 "A+3" 和 "B+2" 里的最大值++。这个最大值就是 "最远距离",它已经把所有跳法的可能性都包进去了,再想具体跳哪步纯属多余。
- 能不能到终点,看这个 "最远范围" 够不够大 ------ 如果这个范围一直扩大,最后能盖到终点,那肯定能到;如果范围到某个地方就停了(比如前面有个坑,跳不过去),那后面的路再远也白搭,肯定到不了。
比如你要去第 5 个位置,现在最远能到第 4 个位置,那接下来只要从第 4 个位置再跳 1 步就行;但如果最远只能到第 3 个位置,那第 4、5 个位置根本碰不到,再怎么试也没用。
所以不用想太多,盯紧 "最远能到哪" 就行 ------ 它够到终点,就成;够不到,就不成。
(三)执行流程示例
示例 1:可到达(nums = [2,3,1,1,4],终点下标 4)
遍历下标 i |
当前值 nums[i] |
从i能跳的最远距离 i+nums[i] |
最大可到达距离 maxReach |
是否覆盖终点(4) |
|---|---|---|---|---|
| 0 | 2 | 0+2=2 | max(0,2)=2 | 2 < 4 → 否 |
| 1 | 3 | 1+3=4 | max(2,4)=4 | 4 ≥ 4 → 是,返回true |
示例 2:不可到达(nums = [3,2,1,0,4],终点下标 4)
遍历下标 i |
当前值 nums[i] |
从i能跳的最远距离 i+nums[i] |
最大可到达距离 maxReach |
是否超出范围 |
|---|---|---|---|---|
| 0 | 3 | 0+3=3 | max(0,3)=3 | 0 ≤ 3 → 否 |
| 1 | 2 | 1+2=3 | max(3,3)=3 | 1 ≤ 3 → 否 |
| 2 | 1 | 2+1=3 | max(3,3)=3 | 2 ≤ 3 → 否 |
| 3 | 0 | 3+0=3 | max(3,3)=3 | 3 ≤ 3 → 否 |
| 4 | 4 | 4+4=8 | - | 4 > 3 → 是,返回false |
二、对比解法:动态规划(状态转移判断可达性)
(一)思路概述
动态规划通过定义 "状态" 记录每个下标是否可达,逐步推导到终点的可达性。核心逻辑:
- 定义状态 :
dp[i]表示 "能否到达下标i"(true可达,false不可达)。 - 初始状态 :
dp[0] = true(起点下标 0 默认可达)。 - 状态转移:对于每个下标i(从 1 到 n-1),遍历所有前序下标j(从 0 到 i-1):
- 若
dp[j]为true(j可达),且j + nums[j] >= i(从j能跳到i),则dp[i] = true,并跳出对j的遍历(无需继续检查其他j)。
- 若
- 最终结果 :
dp[n-1](判断终点是否可达)。
(二)代码实现
class Solution { public boolean canJump(int[] nums) { int n = nums.length; if (n == 1) return true;
boolean [] dp = new boolean [n];
dp [0] = true; // 起点可达
// 遍历每个目标下标 i(从 1 到终点)
for (int i = 1; i < n; i++) {
// 遍历所有前序下标 j,判断是否能从 j 跳到 i
for (int j = 0; j < i; j++) {
if (dp [j] && j + nums [j] >= i) {
dp [i] = true;
break; // 找到一个可达路径即可,无需继续遍历 j
}
}
}
return dp[n - 1];
}
}
(三)执行流程示例
以 nums = [2,3,1,1,4] 为例,dp 数组变化过程:
- 初始:
dp = [true, false, false, false, false](仅起点 0 可达)。 - 计算i=1(目标下标 1):
- 遍历
j=0:dp[0]=true,且0+2=2 >=1→dp[1] = true。 - 此时
dp = [true, true, false, false, false]。
- 遍历
- 计算i=2(目标下标 2):
- 遍历
j=0:dp[0]=true,0+2=2 >=2→dp[2] = true。 - 此时
dp = [true, true, true, false, false]。
- 遍历
- 计算i=3(目标下标 3):
- 遍历
j=0:0+2=2 <3→ 不满足; - 遍历
j=1:dp[1]=true,1+3=4 >=3→dp[3] = true。 - 此时
dp = [true, true, true, true, false]。
- 遍历
- 计算 i=4(目标下标 4,终点):
- 遍历
j=0:0+2=2 <4→ 不满足; - 遍历
j=1:dp[1]=true,1+3=4 >=4→dp[4] = true。 - 最终
dp = [true, true, true, true, true],返回true。
- 遍历
三、基础思路:回溯法(暴力枚举所有路径,不推荐)
(一)思路概述
回溯法通过递归枚举所有可能的跳跃路径,判断是否存在到达终点的路径:
- 从当前下标
curr出发,尝试跳1到nums[curr]步(即跳到curr+1到curr+nums[curr]的所有下标)。 - 若跳到的下标等于终点,返回
true;若递归到某一步无可用跳跃(或超出范围),回溯到上一步尝试其他跳跃。
但该方法时间复杂度为 O(2ⁿ) (每个位置的跳跃次数可能呈指数级),对于 n>20 的数组会严重超时,仅适合理解 "暴力搜索" 的思路,实际解题中不推荐。
(二)代码实现(仅作思路参考)
class Solution { public boolean canJump(int[] nums) { return backtrack(nums, 0); // 从起点0开始回溯 }
// 递归函数:判断从当前下标 curr 能否到达终点
private boolean backtrack (int [] nums, int curr) {
// 终止条件:当前下标已到达或超出终点
if (curr >= nums.length - 1) {
return true;
}
// 尝试从 curr 跳 1 到 nums [curr] 步
int maxJump = nums [curr];
for (int jump = 1; jump <= maxJump; jump++) {
int next = curr + jump; // 下一个要跳的下标
// 递归判断 next 能否到达终点,若能则返回 true
if (backtrack (nums, next)) {
return true;
}
}
// 所有跳跃尝试都失败,返回 false
return false;
}
}
四、三种解法对比
| 解法 | 时间复杂度 | 空间复杂度 | 核心优势 | 劣势 |
|---|---|---|---|---|
| 贪心算法 | O(n) | O(1) | 最优效率,一次遍历,无额外空间 | 仅适用于 "判断可达性",无法求具体路径 |
| 动态规划 | O(n²) | O(n) | 思路直观,可记录每个下标的可达性 | 时间复杂度高,大数组(n>10³)超时 |
| 回溯法 | O(2ⁿ) | O(n) | 逻辑简单,枚举所有可能路径 | 效率极低,几乎无法通过测试用例 |
总结
跳跃游戏的最优解是贪心算法,其核心是通过 "跟踪最大可到达距离" 避免冗余计算,仅需一次遍历即可判断结果,时间和空间复杂度均为最优。
动态规划虽思路直观,但时间复杂度较高,仅适合理解 "状态转移" 的逻辑;回溯法因指数级时间复杂度,仅作为暴力搜索的思路参考,实际解题中不推荐。
掌握贪心算法的关键是理解 "不纠结于具体跳法,只关注最远范围"------ 这一思想也可推广到 "跳跃游戏 II(求最少跳跃次数)" 等类似问题。
4.45. 跳跃游戏 II
给定一个长度为 n 的 0 索引 整数数组 nums。初始位置为 nums[0]。
每个元素 nums[i] 表示从索引 i 向后跳转的最大长度。换句话说,如果你在索引 i 处,你可以跳转到任意 (i + j) 处:
0 <= j <= nums[i]且i + j < n
返回到达 n - 1 的最小跳跃次数。测试用例保证可以到达 n - 1。
示例 1:
输入: nums = [2,3,1,1,4]
输出: 2
解释: 跳到最后一个位置的最小跳跃数是 2。
从下标为 0 跳到下标为 1 的位置,跳 1 步,然后跳 3 步到达数组的最后一个位置。
跳跃游戏 II 的核心是在 "必然能到达终点" 的前提下,找到到达最后一个下标的最小跳跃次数。关键在于 "每一步都选最优的落脚点"------ 通过贪心算法实现 "局部跳得最远,全局步数最少",这是效率最高的解法;动态规划则是更直观的基础思路,适合理解 "最小步数" 的推导逻辑。
一、核心解法:贪心算法(选下一步能跳最远的位置)
(一)思路通俗理解
就像走路去目的地:你每走一步前,先看当前位置能到的所有地方里,哪个地方能让你下一步走得最远 ------ 选这个地方落脚,这样你需要的总步数肯定最少。
比如你在位置 A,能跳到 B、C、D;其中 B 能再跳 3 步,C 能再跳 5 步,D 能再跳 2 步。那你就选 C 落脚,因为从 C 出发能走得更远,后续可能少跳一次。
(二)具体逻辑
用三个变量跟踪状态,一次遍历数组即可:
end:当前这一步的 "最远落脚点"(比如你当前在第 1 步,最多能跳到索引 3,那end=3)。maxNext:在当前步的可跳范围内(0~end),所有位置能跳到的 "最远位置"(这是下一步的end候选)。count:已经跳的步数。
遍历过程:
-
遍历每个位置
i,更新maxNext(maxNext = max(maxNext, i + nums[i],即从i能跳到的最远位置)。 -
当i走到当前步的end
时,说明 "当前步的可跳范围已看完",必须跳下一步:
- 步数
count += 1; - 把下一步的
end设为maxNext(这一步的最优最远范围); - 若
end已覆盖终点,直接返回count(不用再遍历)。
- 步数
(三)代码实现
java
class Solution {
public int jump(int[] nums) {
int n = nums.length;
if (n == 1) return 0; // 只有1个元素,不用跳
int end = 0; // 当前步的最远落脚点
int maxNext = 0; // 当前步可跳范围内,能到的最远位置(下一步的end)
int count = 0; // 跳跃次数
// 遍历到倒数第2个元素即可(到最后一个元素不用跳)
for (int i = 0; i < n - 1; i++) {
// 更新当前步能到的最远位置
maxNext = Math.max(maxNext, i + nums[i]);
// 走到当前步的最远落脚点,必须跳下一步
if (i == end) {
count++;
end = maxNext; // 下一步的最远落脚点更新为maxNext
// 提前终止:如果下一步能到终点,不用再遍历
if (end >= n - 1) {
break;
}
}
}
return count;
}
}
注意:提前 break 可能导致在 maxnext 覆盖终点后,未能完成当前步的计数。正确的做法是在更新 maxnext 之后再判断是否覆盖终点,而不是在更新之前 ,并且应该在 end 更新后判断是否可以提前返回。
注意这种易错情况:当 maxnext 已经覆盖终点时,你直接跳出循环,但此时可能还需要完成当前步的计数 。例如,当 maxnext 刚达到终点时,当前步的 i 可能还没走到 end,导致这一步的跳跃次数 count 没有被统计。++所以要确保当 maxnext 覆盖终点时,当前 i 已经到达 end,即当前步结束后再返回(count++; // 最后一步)。或者i==end,当前步结束后再判断end覆盖终点没++
如果先if(i==end),那么就要注意是if(end>=n-1)而不是if(maxnext>=n-1)
maxnext=Math.max(maxnext,nums[i]+i);
if(i==end){
count++;
end=maxnext;
if(end>=n-1){
break;
}
}
或者
如果先if (maxnext >= n - 1),那么注意break之前要先count++。
// 先更新最远可达距离,再判断是否覆盖终点
maxnext = Math.max(maxnext, nums[i] + i);
// 当覆盖终点时,当前步结束后即可返回
if (maxnext >= n - 1) {
count++; // 最后一步
break;
}
// 到达当前步的终点,必须跳下一步
if (i == end) {
count++;
end = maxnext;
}
(四)执行流程示例
以 nums = [2,3,1,1,4](终点索引 4)为例,分步看:
遍历索引i |
i+nums[i] |
maxNext |
end |
count |
操作说明 |
|---|---|---|---|---|---|
| 0 | 2 | 2 | 0 | 0 | i==end:跳第 1 步,count=1,end=2 |
| 1 | 4 | 4 | 2 | 1 | maxNext更新为 4(从 1 能跳 4) |
| 2 | 3 | 4 | 2 | 1 | i==end:跳第 2 步,count=2,end=4(覆盖终点) |
| 终止 | - | - | 4 | 2 | 返回 2 |
二、对比解法:动态规划(记录每个位置的最小步数)
(一)思路通俗理解
就像记 "到每个路口最少需要走几步":从起点出发,依次计算到每个位置的最小步数 ------ 比如到位置 3 的最小步数,等于 "所有能跳到 3 的位置的最小步数 + 1",选最小的那个。
(二)具体逻辑
- 定义状态 :
dp[i]表示 "到达索引i的最小跳跃次数"。 - 初始状态 :
dp[0] = 0(起点不用跳),其他位置初始为 "无穷大"(表示暂时到不了)。 - 状态转移 :对每个位置
i(从 1 到n-1),遍历所有能跳到i的位置j(j < i且j + nums[j] >= i),则dp[i] = min(dp[i], dp[j] + 1)(到j的步数 + 1 步跳到i)。 - 最终结果 :
dp[n-1](到达终点的最小步数)。
(三)代码实现
java
class Solution {
public int jump(int[] nums) {
int n = nums.length;
if (n == 1) return 0;
// dp[i]:到达i的最小跳跃次数,初始为无穷大
int[] dp = new int[n];
for (int i = 1; i < n; i++) {
dp[i] = Integer.MAX_VALUE;
}
dp[0] = 0; // 起点步数为0
// 计算每个位置i的最小步数
for (int i = 1; i < n; i++) {
// 遍历所有可能跳到i的位置j
for (int j = 0; j < i; j++) {
// 如果j能跳到i,且j能到达(dp[j]不是无穷大)
if (dp[j] != Integer.MAX_VALUE && j + nums[j] >= i) {
// 更新dp[i]为最小步数
dp[i] = Math.min(dp[i], dp[j] + 1);
}
}
}
return dp[n - 1];
}
}
与上一题相比区别就是遍历所有 j,找到最小的到达步数,而不是只要有一个可达就break.
(四)执行流程示例
仍以 nums = [2,3,1,1,4] 为例,dp 数组变化过程:
- 初始:
dp = [0, ∞, ∞, ∞, ∞](只有起点 0 能到)。 - 计算i=1(到索引 1 的最小步数):
- 遍历
j=0:j+nums[j]=2 ≥1,且dp[0]=0→dp[1] = min(∞, 0+1)=1。 - 此时
dp = [0, 1, ∞, ∞, ∞]。
- 遍历
- 计算i=2(到索引 2 的最小步数):
- 遍历
j=0:j+nums[j]=2 ≥2→dp[2] = min(∞, 0+1)=1; - 遍历
j=1:j+nums[j]=4 ≥2,但dp[1]=1→min(1,1+1)=1(不变)。 - 此时
dp = [0, 1, 1, ∞, ∞]。
- 遍历
- 计算i=3(到索引 3 的最小步数):
- 遍历
j=0:j+nums[j]=2 <3→ 不行; - 遍历
j=1:j+nums[j]=4 ≥3→dp[3] = min(∞,1+1)=2; - 遍历
j=2:j+nums[j]=3 ≥3→min(2,1+1)=2(不变)。 - 此时
dp = [0,1,1,2,∞]。
- 遍历
- 计算i=4(到索引 4 的最小步数):
- 遍历
j=0:j+nums[j]=2 <4→ 不行; - 遍历
j=1:j+nums[j]=4 ≥4→dp[4] = min(∞,1+1)=2; - 遍历
j=2:j+nums[j]=3 <4→ 不行; - 遍历
j=3:j+nums[j]=4 ≥4→min(2,2+1)=2(不变)。 - 最终
dp = [0,1,1,2,2],返回dp[4]=2(正确)。
- 遍历
三、两种解法对比
| 解法 | 时间复杂度 | 空间复杂度 | 核心优势 | 劣势 |
|---|---|---|---|---|
| 贪心算法 | O(n) | O(1) | 最优效率,一次遍历,无额外空间 | 思路需要理解 "局部最优→全局最优" |
| 动态规划 | O(n²) | O(n) | 思路直观,直接记录每个位置的最小步数 | 时间复杂度高,大数组(n>10³)会超时 |
总结
- 贪心算法是最优解 :核心是 "每一步选能让下一步跳最远的位置",通过跟踪
end和maxNext,用 O (n) 时间、O (1) 空间解决问题,是面试和工程中的首选。 - 动态规划是基础:适合理解 "最小步数" 的推导逻辑,但效率低,仅用于小数据量或理解思路。
记住贪心算法的关键:不用纠结当前跳哪一步,只要盯紧 "当前能为下一步铺的最远路",总步数自然最少------ 这就是 "局部最优" 推导 "全局最优" 的魅力。
注意:与跳跃游戏 I对比,差别在哪里
跳跃游戏 I(判断能否到达终点)和跳跃游戏 II(求到达终点的最小步数)虽然都基于 "跳跃" 场景,但核心目标、解题逻辑和关键关注点有显著差异,具体可从 目标、核心逻辑、终止条件、算法选择 四个维度对比:
一、核心目标不同:"能不能到" vs "最少几步到"
这是两者最本质的区别,直接决定了解题的出发点:
- 跳跃游戏 I :仅需判断 "从起点出发,是否存在任何一条路径能到达终点",是 "可行性问题" 。
比如给数组[3,2,1,0,4],只需回答 "不能到终点";给[2,3,1,1,4],只需回答 "能到终点"。 - 跳跃游戏 II :题目已保证 "一定能到终点",需进一步找到 "到达终点的最少跳跃次数",是 "最优解问题" 。
比如给[2,3,1,1,4],不仅要知道 "能到",还要算出 "最少 2 步"(如 0→1→4 或 0→2→4)。
二、核心逻辑不同:"跟踪最远范围" vs "拆分每一步的最远范围"
两者都用到了 "跟踪最远可到达距离",但逻辑深度和操作不同:
1. 跳跃游戏 I:仅需 "全局最远范围"
-
核心逻辑:遍历过程中,只需维护一个 全局最远可到达距离
maxReach,判断这个范围是否能覆盖终点。- 只要
maxReach >= 终点下标,直接返回 "能到"; - 若遍历到某位置
i > maxReach(当前位置已超出可到达范围),返回 "不能到"。 - 无需关心 "每一步跳多远",只需知道 "最终范围够不够大"。
示例(
nums = [2,3,1,1,4]):遍历到
i=1时,maxReach = 1+3=4(已覆盖终点 4),直接返回 "能到",无需继续遍历后续位置。 - 只要
2. 跳跃游戏 II:需 "拆分每一步的最远范围"
-
核心逻辑:在 "全局最远范围" 基础上,额外拆分 "每一步的最远落脚点",通过 两步跟踪 计算最少步数:
end:当前这一步的 "最远落脚点"(比如第 1 步最多能跳到索引 2,end=2);maxNext:在当前步的可跳范围(0~end)内,所有位置能跳到的 "下一步最远范围"。- 当遍历到
i == end时,说明 "当前步的范围已看完",必须跳下一步(步数 + 1),同时将end更新为maxNext(下一步的最远范围)。
示例(
nums = [2,3,1,1,4]):- 第 1 步的
end=2(从 0 出发最多跳 2 步到 2),遍历0~2时,maxNext=1+3=4; - 当
i=2(到达第 1 步的end),步数 + 1(变为 1),end更新为maxNext=4(下一步的范围); - 此时
end=4已覆盖终点,总步数为 2(第 1 步:0→2;第 2 步:2→4)。
这里必须拆分 "每一步的范围"------ 因为要统计 "跳了几次",而不是只看 "最终能不能到"。
三、终止条件不同:"提前判断可行性" vs "提前判断步数足够"
- 跳跃游戏 I 的终止条件是 "可行性判断":
- 正面终止:
maxReach >= 终点下标→ 返回true; - 负面终止:
i > maxReach→ 返回false; - 可能在遍历中途就终止(比如提前判断 "能到" 或 "不能到")。
- 正面终止:
- 跳跃游戏 II 的终止条件是 "步数足够":
- 题目已保证 "能到终点",无需负面终止;
- 正面终止:当
end >= 终点下标时,当前步数就是最少步数,返回即可; - 遍历范围也不同:只需遍历到 "倒数第 2 个元素"(到终点无需再跳),而 I 可能需要遍历到最后。
四、算法选择的侧重不同:"贪心足够" vs "贪心是唯一最优解"
- 跳跃游戏 I :可选算法多,贪心是 "最优解" 但不是 "唯一解":
- 贪心(O (n) 时间,O (1) 空间):最优选择;
- 动态规划(O (n²) 时间):可实现但效率低,仅用于理解;
- 回溯(O (2ⁿ) 时间):几乎不可用(超时)。
- 跳跃游戏 II :贪心是 "唯一最优解":
- 动态规划(O (n²) 时间):虽能实现,但面对大数组(如 n=10⁴)会超时;
- 只有贪心算法能做到 O (n) 时间、O (1) 空间,是工程和面试中的唯一可行解。
总结:一句话区分
| 维度 | 跳跃游戏 I(判断可行性) | 跳跃游戏 II(求最少步数) |
|---|---|---|
| 核心目标 | 能不能到终点? | 最少几步到终点? |
| 核心逻辑 | 跟踪 "全局最远范围" | 跟踪 "每一步的最远范围"+"下一步最远范围" |
| 终止条件 | 范围覆盖终点(能)或超出范围(不能) | 某一步的范围覆盖终点,返回当前步数 |
| 算法核心 | 贪心足够,无需关心步数 | 必须用贪心拆分步数,否则效率过低 |
简单说:
- 跳跃游戏 I 是 "看终点在不在我的射程里";
- 跳跃游戏 II 是 "在射程里的前提下,算我最少要扣几次扳机(跳几步)"。
5.763. 划分字母区间
给你一个字符串 s 。我们要把这个字符串划分为尽可能多的片段,同一字母最多出现在一个片段中。例如,字符串 "ababcc" 能够被分为 ["abab", "cc"],但类似 ["aba", "bcc"] 或 ["ab", "ab", "cc"] 的划分是非法的。
注意,划分结果需要满足:将所有划分结果按顺序连接,得到的字符串仍然是 s 。
返回一个表示每个字符串片段的长度的列表。
示例 1:
输入:s = "ababcbacadefegdehijhklij"
输出:[9,7,8]
解释:
划分结果为 "ababcbaca"、"defegde"、"hijhklij" 。
每个字母最多出现在一个片段中。
像 "ababcbacadefegde", "hijhklij" 这样的划分是错误的,因为划分的片段数较少。
划分字母区间的核心是让每个片段包含所有出现过的字母的全部位置,且尽可能划分出最多的片段。关键在于确定每个片段的 "最远边界"------ 即片段中所有字母的最后一次出现位置的最大值,当遍历到这个边界时,就能确定一个合法的片段。
一、核心解法:贪心算法(跟踪最远边界)
(一)思路概述
贪心算法的核心是 "先记录每个字母的最后出现位置,再遍历字符串时动态更新当前片段的最远边界"。具体逻辑:
- 预处理 :记录每个字母在字符串中最后一次出现的索引(例如,字母
'a'最后出现在索引 8,就记为last['a'] = 8)。 - 遍历划分:遍历字符串时,跟踪当前片段的 "起始位置start" 和 "最远边界end":
- 对于当前字符
s[i],其最后出现位置是last[s[i]],更新end为max(end, last[s[i]])(确保当前片段包含该字符的所有出现)。 - 当遍历到
i == end时,说明当前片段的所有字符都已包含(它们的最后出现位置都不超过end),可以划分出一个片段,长度为end - start + 1,然后更新start为i + 1,开始下一个片段。
- 对于当前字符
(二)代码实现
import java.util.ArrayList;
import java.util.List;
class Solution {
public List<Integer> partitionLabels(String s) {
// 记录每个字母最后一次出现的索引(小写字母共26个,用数组存储)
int[] last = new int[26];
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
last[c - 'a'] = i; // 'a'-'a'=0, 'b'-'a'=1, ..., 对应数组索引
}
List<Integer> result = new ArrayList<>();
int start = 0; // 当前片段的起始索引
int end = 0; // 当前片段的最远边界(所有字符的最后出现位置的最大值)
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
// 更新当前片段的最远边界:必须包含当前字符的最后一次出现
end = Math.max(end, last[c - 'a']);
// 当遍历到最远边界时,说明当前片段可以划分
if (i == end) {
result.add(end - start + 1); // 片段长度 = 结束索引 - 起始索引 + 1
start = i + 1; // 下一个片段的起始索引
}
}
return result;
}
}
(三)执行流程示例
以 s = "ababcbacadefegdehijhklij" 为例(预期输出 [9,7,8]),分步解析:
1. 预处理:记录每个字母的最后出现位置
plaintext
a: 8, b:5, c:7, d:14, e:15, f:9, g:13, h:19, i:22, j:23, k:20, l:21
2. 遍历字符串,动态划分片段
遍历索引 i |
当前字符 | 字符最后出现位置 | 当前片段 end(更新后) |
是否到达 end(i == end) |
操作(划分片段) |
|---|---|---|---|---|---|
| 0 | 'a' | 8 | max(0,8) = 8 | 0 != 8 → 否 | - |
| 1 | 'b' | 5 | max(8,5) = 8 | 1 != 8 → 否 | - |
| 2 | 'a' | 8 | max(8,8) = 8 | 2 != 8 → 否 | - |
| 3 | 'b' | 5 | max(8,5) = 8 | 3 != 8 → 否 | - |
| 4 | 'c' | 7 | max(8,7) = 8 | 4 != 8 → 否 | - |
| 5 | 'b' | 5 | max(8,5) = 8 | 5 != 8 → 否 | - |
| 6 | 'a' | 8 | max(8,8) = 8 | 6 != 8 → 否 | - |
| 7 | 'c' | 7 | max(8,7) = 8 | 7 != 8 → 否 | - |
| 8 | 'a' | 8 | max(8,8) = 8 | 8 == 8 → 是 | 划分片段:长度 8-0+1=9,加入结果;start更新为 9 |
| 9 | 'd' | 14 | max(0,14) = 14 | 9 != 14 → 否 | - |
| 10 | 'e' | 15 | max(14,15) = 15 | 10 != 15 → 否 | - |
| 11 | 'f' | 9 | max(15,9) = 15 | 11 != 15 → 否 | - |
| 12 | 'e' | 15 | max(15,15) = 15 | 12 != 15 → 否 | - |
| 13 | 'g' | 13 | max(15,13) = 15 | 13 != 15 → 否 | - |
| 14 | 'd' | 14 | max(15,14) = 15 | 14 != 15 → 否 | - |
| 15 | 'e' | 15 | max(15,15) = 15 | 15 == 15 → 是 | 划分片段:长度 15-9+1=7,加入结果;start更新为 16 |
| 16 | 'h' | 19 | max(0,19) = 19 | 16 != 19 → 否 | - |
| 17 | 'i' | 22 | max(19,22) = 22 | 17 != 22 → 否 | - |
| 18 | 'j' | 23 | max(22,23) = 23 | 18 != 23 → 否 | - |
| 19 | 'h' | 19 | max(23,19) = 23 | 19 != 23 → 否 | - |
| 20 | 'k' | 20 | max(23,20) = 23 | 20 != 23 → 否 | - |
| 21 | 'l' | 21 | max(23,21) = 23 | 21 != 23 → 否 | - |
| 22 | 'i' | 22 | max(23,22) = 23 | 22 != 23 → 否 | - |
| 23 | 'j' | 23 | max(23,23) = 23 | 23 == 23 → 是 | 划分片段:长度 23-16+1=8,加入结果 |
最终结果为 [9,7,8],符合预期。
二、对比解法:区间合并法(字符区间重叠合并)
(一)思路概述
区间合并法的核心是 "将每个字符的出现范围视为一个区间,合并所有重叠的区间,每个合并后的区间即为一个片段"。具体逻辑:
- 确定字符区间 :对每个字符,找到其第一次出现和最后一次出现的索引,形成区间
[first, last](例如,'a'从索引 0 出现到 8,区间为[0,8])。 - 合并重叠区间 :将所有区间按起始位置排序,然后合并重叠或相邻的区间(若区间 A 的
start <=区间 B 的end,则合并为[min(A.start,B.start), max(A.end,B.end)])。 - 计算片段长度 :每个合并后的区间的长度(
end - start + 1)即为结果。
(二)代码实现
java
import java.util.*;
class Solution {
public List<Integer> partitionLabels(String s) {
// 步骤1:记录每个字符的第一次和最后一次出现位置,形成区间
Map<Character, int[]> intervals = new HashMap<>();
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (!intervals.containsKey(c)) {
intervals.put(c, new int[]{i, i}); // 第一次出现:[i,i]
} else {
intervals.get(c)[1] = i; // 更新最后一次出现位置
}
}
// 步骤2:将区间提取出来,按起始位置排序
List<int[]> intervalList = new ArrayList<>(intervals.values());
intervalList.sort(Comparator.comparingInt(a -> a[0])); // 按start升序
// 步骤3:合并重叠区间
List<int[]> merged = new ArrayList<>();
for (int[] interval : intervalList) {
if (merged.isEmpty()) {
merged.add(interval);
} else {
int[] last = merged.get(merged.size() - 1);
if (interval[0] <= last[1]) { // 重叠:当前区间start <= 上一区间end
// 合并区间:更新end为最大值
last[1] = Math.max(last[1], interval[1]);
} else {
// 不重叠:直接加入
merged.add(interval);
}
}
}
// 步骤4:计算每个合并区间的长度
List<Integer> result = new ArrayList<>();
for (int[] m : merged) {
result.add(m[1] - m[0] + 1);
}
return result;
}
}
(三)执行流程示例
仍以 s = "ababcbacadefegdehijhklij" 为例:
1. 确定字符区间
每个字符的[first, last]区间:
plaintext
a: [0,8], b:[1,5], c:[4,7], d:[9,14], e:[10,15], f:[11,9], g:[12,13], h:[16,19], i:[17,22], j:[18,23], k:[20,20], l:[21,21]
2. 区间排序(按 start 升序)
排序后区间顺序与上述一致(已按 start 从小到大排列)。
3. 合并重叠区间
- 第一个区间
[0,8]加入合并列表; - 下一个区间
[1,5]:1 <= 8(重叠),合并为[0,8]; - 下一个区间
[4,7]:4 <= 8(重叠),合并后仍为[0,8]; - 下一个区间
[9,14]:9 > 8(不重叠),加入合并列表,此时列表为[[0,8], [9,14]]; - 下一个区间
[10,15]:10 <=14(重叠),合并为[9,15]; - 下一个区间
[11,9](即[11,9],实际等价于[9,11]):11 <=15(重叠),合并后仍为[9,15]; - 下一个区间
[12,13]:12 <=15(重叠),合并后仍为[9,15]; - 下一个区间
[16,19]:16 >15(不重叠),加入合并列表,此时列表为[[0,8], [9,15], [16,19]]; - 后续区间
[17,22]、[18,23]、[20,20]、[21,21]均与[16,19]重叠,合并后为[16,23];
最终合并后的区间为 [[0,8], [9,15], [16,23]],长度分别为 9、7、8,与贪心算法结果一致。
三、两种解法对比
| 解法 | 时间复杂度 | 空间复杂度 | 核心优势 | 劣势 |
|---|---|---|---|---|
| 贪心算法 | O(n) | O(1) | 效率更高(一次遍历),空间更优(仅用固定大小数组) | 需理解 "动态更新边界" 的逻辑 |
| 区间合并法 | O(n) | O(26) | 思路更直观(通过区间重叠理解片段划分) | 步骤较多(需记录区间、排序、合并) |
总结
划分字母区间的两种解法本质相通,都是通过 "确保片段包含所有字符的最后出现位置" 来实现合法划分,且尽可能最大化片段数量。
- 贪心算法是最优选择,通过一次遍历动态更新片段边界,时间和空间复杂度均为最优,适合面试和工程实现。
- 区间合并法更易理解,通过 "字符区间→合并重叠区间" 的过程直观呈现片段划分逻辑,适合初学者理解问题本质。
两种方法的核心思想都是 "以字符的最后出现位置为边界",这也是贪心策略在 "区间划分" 问题中的典型应用。