【跳转到上一篇:算法 4(动态规划)】

十、贪心算法#

贪心算法(greedy algorithm)是一种常见的解决优化问题的算法,其基本思想是在问题的每个决策阶段,都选择当前看起来最优的选择,即贪心地做出局部最优的决策,以期获得全局最优解

贪心算法和动态规划都常用于解决优化问题。它们之间存在一些相似之处,比如都依赖最优子结构性质,但工作原理不同

先通过例题“零钱兑换”了解贪心算法的工作原理。这道题已经在“完全背包问题”章节中介绍过

给定 n 种硬币,第 i 种硬币的面值为 coins[i-1],目标金额为 amt,每种硬币可以重复选取,问能够凑出目标金额的最少硬币数量。如果无法凑出目标金额,则返回 -1。

本题采取的贪心策略如下图所示。给定目标金额,我们贪心地选择不大于且最接近它的硬币,不断循环该步骤,直至凑出目标金额为止

image-20260413204514755

/* 零钱兑换:贪心 */
int coinChangeGreedy(int[] coins, int amt) {
// 假设 coins 列表有序
int i = coins.length - 1;
int count = 0;
// 循环进行贪心选择,直到无剩余金额
while (amt > 0) {
// 找到小于且最接近剩余金额的硬币
while (i > 0 && coins[i] > amt) i--;
// 选择 coins[i]
amt -= coins[i];
count++;
}
// 若未找到可行方案,则返回 -1
return amt == 0 ? count : -1;
}

贪心算法不仅操作直接、实现简单,而且通常效率也很高。在以上代码中,记硬币最小面值为 min,则贪心选择最多循环 amt/min 次,时间复杂度为 O(amt/min)。这比动态规划解法的时间复杂度 O(n*amt) 小了一个数量级

然而,对于某些硬币面值组合,贪心算法并不能找到最优解

image-20260413205850040

相较于动态规划,贪心算法的使用条件更加苛刻,其主要关注问题的两个性质

一般可以举反例,要进行严谨的数学证明,通常需要用到反证法或数学归纳法

贪心算法一般分为如下四步:

简单题#

简 455分发饼干#

假设你是一位很棒的家长,想要给你的孩子们一些小饼干。但是,每个孩子最多只能给一块饼干。

对每个孩子 i,都有一个胃口值 g[i],这是能让孩子们满足胃口的饼干的最小尺寸;并且每块饼干 j,都有一个尺寸 s[j] 。如果 s[j] >= g[i],我们可以将这个饼干 j 分配给孩子 i ,这个孩子会得到满足。你的目标是满足尽可能多的孩子,并输出这个最大数值。

为了满足更多的小孩,就不要造成饼干尺寸的浪费

大尺寸的饼干既可以满足胃口大的孩子也可以满足胃口小的孩子,那么就应该优先满足胃口大的

这里的局部最优就是大饼干喂给胃口大的,充分利用饼干尺寸喂饱一个,全局最优就是喂饱尽可能多的小孩

可以尝试使用贪心策略,先将饼干数组和小孩数组排序,然后从后向前遍历小孩数组,用大饼干优先满足胃口大的,并统计满足小孩数量

image-20260413213317855

public int findContentChildren(int[] g, int[] s) {
Arrays.sort(g);
Arrays.sort(s);
int count = 0;
int start = s.length - 1;
// 遍历胃口,先喂饱大胃口
for (int index = g.length - 1; index >= 0; index--) {
if(start >= 0 && g[index] <= s[start]) {
start--;
count++;
}
}
return count;
}
简 860柠檬水找零#

在柠檬水摊上,每一杯柠檬水的售价为 5 美元。顾客排队购买你的产品,(按账单 bills 支付的顺序)一次购买一杯。

每位顾客只买一杯柠檬水,然后向你付 5 美元、10 美元或 20 美元。你必须给每个顾客正确找零,也就是说净交易是每位顾客向你支付 5 美元。

注意,一开始你手头没有任何零钱。

给你一个整数数组 bills ,其中 bills[i] 是第 i 位顾客付的账。如果你能给每位顾客正确找零,返回 true ,否则返回 false 。

有如下三种情况:

账单是20的情况,为什么要优先消耗一个10和一个5呢?

因为美元10只能给账单20找零,而美元5可以给账单10和账单20找零,美元5更万能!

所以局部最优:遇到账单20,优先消耗美元10,完成本次找零。全局最优:完成全部账单的找零

public boolean lemonadeChange(int[] bills) {
int five = 0;
int ten = 0;
for (int i = 0; i < bills.length; i++) {
if (bills[i] == 5) five++;
else if (bills[i] == 10) {
five--;
ten++;
} else if (bills[i] == 20) {
if (ten > 0) {
ten--;
five--;
} else {
five -= 3;
}
}
if (five < 0 || ten < 0) return false;
}
return true;
}

中等题:两个维度权衡问题#

在出现两个维度相互影响的情况时,两边一起考虑一定会顾此失彼,要先确定一个维度,再确定另一个一个维度。

难 135分发糖果#

n 个孩子站成一排。给你一个整数数组 ratings 表示每个孩子的评分。

你需要按照以下要求,给这些孩子分发糖果:

  • 每个孩子至少分配到 1 个糖果。
  • 相邻两个孩子中,评分更高的那个会获得更多的糖果。

请你给每个孩子分发糖果,计算并返回需要准备的 最少糖果数目 。

1、先确定右边评分大于左边的情况(也就是从前向后遍历)

此时局部最优:只要右边评分比左边大,右边的孩子就多一个糖果;全局最优:相邻的孩子中,评分高的右孩子获得比左边孩子更多的糖果

// 从前向后
for (int i = 1; i < ratings.size(); i++) {
if (ratings[i] > ratings[i - 1]) candy[i] = candy[i - 1] + 1;
}

image-20260413215952372

2、再确定左孩子大于右孩子的情况(从后向前遍历)

如果 ratings[i] > ratings[i + 1],此时 candy[i](第 i 个小孩的糖果数量)就有两个选择,一个是 candy[i + 1] + 1(从右边这个加1得到的糖果数量),一个是 candy[i](之前比较右孩子大于左孩子得到的糖果数量)

那么贪心取局部最优:candy[i + 1] + 1 和 candy[i] 的最大值,保证第 i 个小孩的糖果数量既大于左边的也大于右边的;全局最优:相邻的孩子中,评分高的孩子获得更多的糖果

// 从后向前
for (int i = ratings.size() - 2; i >= 0; i--) {
if (ratings[i] > ratings[i + 1] ) {
candy[i] = max(candy[i], candy[i + 1] + 1);
}
}
public int candy(int[] ratings) {
int len = ratings.length;
int[] candy = new int[len];
candy[0] = 1;
for (int i = 1; i < len; i++) {
candy[i] = (ratings[i] > ratings[i - 1]) ? candy[i - 1] + 1 : 1;
}
for (int i = len - 2; i >= 0; i--) {
if (ratings[i] > ratings[i + 1]) {
candy[i] = Math.max(candy[i], candy[i + 1] + 1);
}
}
int res = 0;
for (int num : candy) res += num;
return res;
}
中 406根据身高重建队列#

假设有打乱顺序的一群人站成一个队列,数组 people 表示队列中一些人的属性(不一定按顺序)。每个 people[i] = [hi, ki] 表示第 i 个人的身高为 hi ,前面 正好 有 ki 个身高大于或等于 hi 的人。

请你重新构造并返回输入数组 people 所表示的队列。返回的队列应该格式化为数组 queue ,其中 queue[j] = [hj, kj] 是队列中第 j 个人的属性(queue[0] 是排在队列前面的人)。

输入:people = [[7,0],[4,4],[7,1],[5,0],[6,1],[5,2]]
输出:[[5,0],[7,0],[5,2],[6,1],[4,4],[7,1]]

遇到两个维度权衡的时候,一定要先确定一个维度,再确定另一个维度

1、先按照身高h来排序,从大到小排(身高相同的话则k小的站前面)

2、再优先按身高高的 people 的 k 来插入,后序插入节点也不会影响前面已经插入的节点,最终按照 k 的规则完成了队列

image-20260414110025469

排序完的people: [[7,0], [7,1], [6,1], [5,0], [5,2], [4,4]]

插入的过程:

局部最优:优先按身高高的people的k来插入。插入操作过后的people满足队列属性

全局最优:最后都做完插入操作,整个队列满足题目队列属性

public int[][] reconstructQueue(int[][] people) {
// 身高从大到小排(身高相同k小的站前面)
Arrays.sort(people, (a, b) -> {
if (a[0] == b[0]) return a[1] - b[1]; // a - b 是升序排列,故在a[0] == b[0]的狀況下,會根據k值升序排列
return b[0] - a[0]; // b - a 是降序排列,在a[0] != b[0],的狀況會根據h值降序排列
});
LinkedList<int[]> que = new LinkedList<>();
for (int[] p : people) {
que.add(p[1],p); // Linkedlist.add(index, value),會將value插入到指定index裡
}
return que.toArray(new int[0][]);
}

(a, b) -> a - b

a - b 的数学特性决定了:当 a 小的时候小于零,小于则 a 留前面,当 a 大的时候大于零,大于则 a 去后面。这就形成了升序的排列

(a, b) -> b - a

b - a 的数学特性决定了:当 a 小的时候大于零,大于则 a 去后面,当 a 大的时候小于零,小于则 a 留前面。这就形成了降序的排列

难题:贪心解决区间问题#

【🔥中】55跳跃游戏#

给你一个非负整数数组 nums ,你最初位于数组的 第一个下标 。数组中的每个元素代表你在该位置可以跳跃的最大长度。

判断你是否能够到达最后一个下标,如果可以,返回 true;否则,返回 false 。

输入: [2,3,1,1,4]
输出: true
解释: 我们可以先跳 1 步,从位置 0 到达 位置 1, 然后再从位置 1 跳 3 步到达最后一个位置。

当前位置元素如果是 3,究竟跳几步才是最优呢?其实跳几步无所谓,关键在于可跳的覆盖范围!

那么这个问题就转化为跳跃覆盖范围究竟可不可以覆盖到终点!

贪心算法局部最优解:每次取最大跳跃步数(取最大覆盖范围);整体最优解:最后得到整体最大覆盖范围,看是否能到终点

image-20260414151859302

i 每次移动只能在 cover 的范围内移动,每移动一个元素,cover 得到该元素数值(新的覆盖范围)的补充,让 i 继续移动下去

public boolean canJump(int[] nums) {
if (nums.length == 1) return true;
// 覆盖范围, 初始覆盖范围应该是0,因为下面的迭代是从下标0开始的
int cover = 0;
// 在覆盖范围内更新最大的覆盖范围
for (int i = 0; i <= cover; i++) {
cover = Math.max(cover, i + nums[i]);
if (cover >= nums.length - 1) return true;
}
return false;
}
【🔥中】45跳跃游戏 II#

给定一个长度为 n 的 0 索引整数数组 nums。初始位置在下标 0。

每个元素 nums[i] 表示从索引 i 向后跳转的最大长度。换句话说,如果你在索引 i 处,你可以跳转到任意 (i + j)处:

  • 0 <= j <= nums[i] 且
  • i + j < n

返回到达 n - 1 的最小跳跃次数。测试用例保证可以到达 n - 1。

本题要计算最少步数,那么就要想清楚什么时候步数才一定要加一呢?

贪心的思路,局部最优:如果移动下标达到了当前这一步的最大覆盖最远距离了,还没有到终点的话,那么就必须再走一步来增加覆盖范围,直到覆盖范围覆盖了终点;整体最优:一步尽可能多走,从而达到最少步数

这里需要统计两个覆盖范围,当前这一步的最大覆盖和下一步最大覆盖

可以把这个过程想象成“加油站”:

int jump(int[] nums) {
if (nums.length == 1) return 0;
int curDistance = 0; // 当前覆盖最远距离下标
int count = 0; // 记录走的最大步数
int nextDistance = 0; // 下一步覆盖最远距离下标
for (int i = 0; i < nums.length; i++) {
nextDistance = Math.max(nums[i] + i, nextDistance); // 更新下一步覆盖最远距离下标
if (i == curDistance) { // 遇到当前覆盖最远距离下标
count++; // 需要走下一步
curDistance = nextDistance; // 更新当前覆盖最远距离下标
if (nextDistance >= nums.length - 1) break; // 当前覆盖最远距到达集合终点,不用做count++操作了,直接结束
}
}
return count;
}
【🔥中】763划分字母区间#

给你一个字符串 s 。我们要把这个字符串划分为尽可能多的片段,同一字母最多出现在一个片段中。例如,字符串 "ababcc" 能够被分为 ["abab", "cc"],但类似 ["aba", "bcc"] 或 ["ab", "ab", "cc"] 的划分是非法的。

注意,划分结果需要满足:将所有划分结果按顺序连接,得到的字符串仍然是 s 。

返回一个表示每个字符串片段的长度的列表。

核心目标是:将字符串划分为尽可能多的片段,使得同一个字母只出现在一个片段中

  1. 记录边界:首先,我们需要知道每个字符的“势力范围”,即每个字符最后一次出现在哪里。如果我们在片段中遇到了字符 'a',而 'a' 最后一次出现在下标 10,那么无论我们怎么切分,当前的片段必须至少延伸到下标 10
  2. 动态扩展:我们从左到右遍历字符串
    • 维护一个变量 idx 表示当前片段必须达到的最远边界
    • 当我们遍历到下标 i 的字符时,查看该字符的最后出现位置 edge[char]
    • 如果 edge[char] > idx,说明当前片段必须延长,更新 idx = edge[char]
  3. 确定分割点:
    • 当我们遍历到的位置 i 等于当前最远边界 idx 时,说明当前片段内的所有字符,它们的最后一次出现都在 i 或 i 之前
    • 此时,我们可以安全地在这里切一刀,形成一个片段

假设输入字符串 S = "ababcbacadefegdehijhklij"

  1. 预处理记录最后出现位置:
    • a: 8, b: 5, c: 7, d: 14, e: 15, f: 11, g: 13, h: 19, i: 22, j: 23, k: 20, l: 21
  2. 遍历过程:
    • i=0 (‘a’): a 最后出现在 8。当前片段边界 idx 更新为 8
    • i=1 (‘b’): b 最后出现在 5。idx 保持 8 (8 > 5)
    • …
    • i=8 (‘a’): 此时 i 追上了 idx (8 == 8)。说明从 0 到 8 之间的所有字符(a, b, c)都不会在 8 之后出现
    • 切分: 第一个片段长度为 8 - (-1) = 9
    • 接着处理剩下的字符串,以此类推
public List<Integer> partitionLabels(String S) {
List<Integer> list = new LinkedList<>(); // 用于存储每个片段的长度
// 1. 预处理:记录每个字符在字符串中出现的【最后一次】下标位置
int[] edge = new int[26]; // 对应26个小写字母
char[] chars = S.toCharArray();
// 遍历字符串,更新每个字符的最后出现位置
for (int i = 0; i < chars.length; i++) {
edge[chars[i] - 'a'] = i;
}
// 2. 贪心策略:遍历字符串,确定分割点
int idx = 0; // 当前片段中所有字符能到达的【最远边界】
int last = 0; // 当前片段的开始位置,用于计算长度
for (int i = 0; i < chars.length; i++) {
// 获取当前字符的最远边界,并更新当前片段的整体最远边界
idx = Math.max(idx, edge[chars[i] - 'a']);
// 如果当前位置 i 达到了当前片段的最远边界 idx
// 说明从 "last + 1" 到 "i" 这一段包含的所有字符,都不会在 i 之后出现
// 这就是一个合法的分割点
if (i == idx) {
// 计算长度:当前位置 - 开始位置 + 1
list.add(i - last + 1);
// 更新上一次结束位置
last = i + 1;
}
}
return list;
}
中 56合并区间#

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

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

本题是判断区间重叠后要进行区间合并

先按左边界排序,让所有的相邻区间尽可能的重叠在一起,如果 intervals[i - 1][1] >= intervals[i][0],则一定有重叠

注意图中区间已经按照左边界排序:

image-20260414163548342

判断重复之后,如何去模拟合并区间呢?

新的左边界仍为左区间的左边界不变,新的右边界取两个区间右边界的最大值,加入到 result 数组;如果没有合并就把原区间加入到 result 数组

public int[][] merge(int[][] intervals) {
List<int[]> result = new ArrayList<>();
// 如果区间集合为空,直接返回空数组
if (intervals == null || intervals.length == 0) return result.toArray(new int[0][]);
// 1. 排序:按照区间的起始位置(左边界)进行升序排序
Arrays.sort(intervals, (a,b)->a[0]-b[0]);
// 2. 将第一个区间加入结果集
result.add(intervals[0]);
// 3. 遍历剩余区间
for (int i = 1; i < intervals.length; i++) {
// 获取结果集中最后一个区间(当前正在合并的区间)
int[] lastInterval = result.get(result.size() - 1);
// 情况一:如果 "最后一个区间的右边界" >= "当前遍历区间的左边界",则说明重叠
if (lastInterval[1] >= intervals[i][0]) lastInterval[1] = Math.max(lastInterval[1], intervals[i][1]); // 合并区间:更新右边界,取两者的最大值,因为左边界已经是最小的(已排序)
// 情况二:区间不重叠,直接将当前区间加入结果集
else result.add(intervals[i]);
}
// 将 List<int[]> 转换为二维数组返回
return result.toArray(new int[0][]);
}
中 452用最少数量的箭引爆气球#

有一些球形气球贴在一堵用 XY 平面表示的墙面上。墙面上的气球记录在整数数组 points ,其中points[i] = [xstart, xend] 表示水平直径在 xstart 和 xend之间的气球。你不知道气球的确切 y 坐标。

一支弓箭可以沿着 x 轴从不同点 完全垂直 地射出。在坐标 x 处射出一支箭,若有一个气球的直径的开始和结束坐标为 xstart,xend, 且满足 xstart ≤ x ≤ xend,则该气球会被 引爆 。可以射出的弓箭的数量 没有限制 。 弓箭一旦被射出之后,可以无限地前进。

给你一个数组 points ,返回引爆所有气球所必须射出的 最小 弓箭数 。

本题和56题一样,需要判断区间是否重叠

public int findMinArrowShots(int[][] points) {
// 1. 排序:根据气球直径的【起始坐标】从小到大排序
Arrays.sort(points, (a, b) -> Integer.compare(a[0], b[0]));
// 2. 初始化箭的数量,只要数组不为空,至少需要一支箭
int count = 1;
// 3. 遍历气球数组,从第二个气球开始
for (int i = 1; i < points.length; i++) {
// 情况一:当前气球的左边界 > 前一个气球的右边界,说明两个气球完全没有重叠(甚至不挨着),一支箭无法同时射爆它们,需要增加一支箭
// 注意:这里必须是 >,如果是 = 说明刚好挨着,一支箭在交界处也能射爆
if (points[i][0] > points[i - 1][1]) count++;
// 情况二:当前气球的左边界 <= 前一个气球的右边界,说明两个气球有重叠部分,可以用同一支箭射爆
else points[i][1] = Math.min(points[i][1], points[i - 1][1]); // 贪心策略:更新重叠区域的最小右边界
}
return count;
}

中 134加油站#

在一条环路上有 n 个加油站,其中第 i 个加油站有汽油 gas[i] 升。

你有一辆油箱容量无限的的汽车,从第 i 个加油站开往第i+1 个加油站需要消耗汽油 cost[i] 升。你从其中的一个加油站出发,开始时油箱为空。

给定两个整数数组 gas 和 cost ,如果你可以按顺序绕环路行驶一周,则返回出发时加油站的编号,否则返回 -1 。如果存在解,则 保证 它是 唯一 的。

public int canCompleteCircuit(int[] gas, int[] cost) {
int n = gas.length;
int i = 0; // i 代表我们尝试作为起点的那个加油站的下标
// 外层循环:尝试每一个可能的起点
// 注意:这里的 i 不是简单的 i++,而是跳跃式的
while (i < n) {
int sumOfGas = 0; // 记录当前尝试的路径中,累计获得的汽油量
int sumOfCost = 0; // 记录当前尝试的路径中,累计消耗的汽油量
int cnt = 0; // 记录当前从起点 i 出发,成功走过的加油站数量
// 内层循环:模拟从起点 i 出发,尝试走完一圈 (n 个站)
while (cnt < n) {
// 计算当前到达的加油站的实际下标
// 使用取模运算 % n 来处理环形数组(即走完最后一个回到第一个)
int j = (i + cnt) % n;
sumOfGas += gas[j]; // 加上当前站的油
sumOfCost += cost[j]; // 减去去下一站的路费
// 核心判断:如果累计消耗 > 累计油量,说明油不够了,车跑不动了
if (sumOfCost > sumOfGas) {
break; // 当前起点 i 失败,跳出内层循环
}
cnt++; // 如果油够,继续尝试下一个站
}
// 如果 cnt == n,说明内层循环完整跑完了一圈都没有 break
if (cnt == n) {
return i; // 找到了合法的起点,直接返回
} else {
// 如果没跑完一圈就失败了,我们需要更换起点
// 关键优化:i = i + cnt + 1
// 解释:从 i 到 i+cnt 这些站点都不可能作为起点(见下方原理解析)
// 所以下一个尝试的起点直接跳到失败点的下一个位置
i = i + cnt + 1;
}
}
// 如果所有可能的起点都试过了(或者跳过了),都没成功,返回 -1
return -1;
}

关键优化(跳跃策略):

十一、十大排序#

选择排序#

工作原理非常简单:开启一个循环,每轮从未排序区间选择最小的元素,将其放到已排序区间的末尾

设数组的长度为 n,选择排序的算法流程:

  1. 初始状态下,所有元素未排序,即未排序(索引)区间为 [0,n-1]
  2. 选取区间 [0,n-1] 中的最小元素,将其与索引 0 处的元素交换。完成后,数组前 1 个元素已排序
  3. 选取区间 [1,n-1] 中的最小元素,将其与索引 1 处的元素交换。完成后,数组前 2 个元素已排序
  4. 以此类推。经过 n-1 轮选择与交换后,数组前 n-1 个元素已排序
  5. 仅剩的一个元素必定是最大元素,无须排序,因此数组排序完成
/* 选择排序 */
void selectionSort(int[] nums) {
int n = nums.length;
// 外循环:未排序区间为 [i, n-1]
for (int i = 0; i < n - 1; i++) {
// 内循环:找到未排序区间内的最小元素
int k = i; // 用k来记录未排序区间内的最小元素
for (int j = i + 1; j < n; j++) {
if (nums[j] < nums[k]) k = j;
}
// 将该最小元素与未排序区间的首个元素交换
int temp = nums[i];
nums[i] = nums[k];
nums[k] = temp;
}
}

冒泡排序#

从数组最左端开始向右遍历,依次比较相邻元素大小,如果“左元素 > 右元素”就交换二者。遍历完成后,最大的元素会被移动到数组的最右端。每轮遍历都将剩余数组中的最大元素移动到最右端

  1. 首先,对 n 个元素执行“冒泡”,将数组的最大元素交换至正确位置
  2. 接下来,对剩余 n-1 个元素执行“冒泡”,将第二大元素交换至正确位置
  3. 以此类推,经过 n-1 轮“冒泡”后,前 大的元素都被交换至正确位置
  4. 仅剩的一个元素必定是最小元素,无须排序,因此数组排序完成

image-20260417193115311

/* 冒泡排序 */
void bubbleSort(int[] nums) {
// 外循环:未排序区间为 [0, i]
for (int i = nums.length - 1; i > 0; i--) {
boolean flag = false;
// 内循环:将未排序区间 [0, i] 中的最大元素交换至该区间的最右端
for (int j = 0; j < i; j++) {
if (nums[j] > nums[j + 1]) {
// 交换 nums[j] 与 nums[j + 1]
int tmp = nums[j];
nums[j] = nums[j + 1];
nums[j + 1] = tmp;
boolean = true;
}
}
if(!flag) break;
}
}

改进:boolean flag = false; boolean = true; if (!flag) break;

添加是否交换的标志,倘若某次循环未发生任何交换,说明排序已经完成,可以直接跳出循环

冒泡排序的核心在于 if 语句中的比较符号。你是希望把“大”的往后送,还是把“小”的往后送,完全由这个符号决定

如果你希望数组从小到大排列,你需要把大的元素像气泡一样浮到后面

  • 比较逻辑:如果 前一个 > 后一个,则交换
  • 结果:大的元素会被不断交换到右边,每一轮结束后,当前未排序部分中最大的那个元素会固定在末尾
if(arr[j] > arr[j + 1]) swap(arr[j], arr[j + 1]);

如果你希望数组从大到小排列,你需要把小的元素挤到后面去

  • 比较逻辑:如果 前一个 < 后一个,则交换
  • 结果:小的元素会被不断交换到右边,每一轮结束后,当前未排序部分中最小的那个元素会固定在末尾
if(arr[j] < arr[j + 1]) swap(arr[j], arr[j + 1]);

插入排序#

在未排序区间选择一个基准元素,将该元素与其左侧已排序区间的元素逐一比较大小,并将该元素插入到正确的位置

  1. 初始状态下,数组的第 1 个元素已完成排序
  2. 选取数组的第 2 个元素作为 base ,将其插入到正确位置后,数组的前 2 个元素已排序
  3. 选取第 3 个元素作为 base ,将其插入到正确位置后,数组的前 3 个元素已排序
  4. 以此类推,在最后一轮中,选取最后一个元素作为 base ,将其插入到正确位置后,所有元素均已排序

插入排序流程

/* 插入排序 */
void insertionSort(int[] nums) {
// 外循环:未排序区间为 [i, n-1]
// i 指向当前待插入的元素(摸到的这张“牌”)
for (int i = 1; i < nums.length; i++) {
// 暂存当前待插入的元素,防止在后续移动过程中被覆盖
int base = nums[i];
// j 指向“已排序”区间的最后一个元素
int j = i - 1;
// 内循环:在已排序区间 [0, i-1] 中寻找插入位置
// 只要 j 还在数组范围内,且已排序区的元素比 base 大,说明 base 应该插在这个元素的前面,所以把这个大元素向后挪一位
while (j >= 0 && nums[j] > base) {
nums[j + 1] = nums[j]; // 元素后移(腾位置)
j--; // 指针前移(继续向前寻找更小的元素)
}
// 循环结束时,j 指向的是第一个“小于或等于” base 的元素,所以 j + 1 就是 base 应该插入的正确空位
nums[j + 1] = base;
}
}

优势:

插入排序的时间复杂度为 O(n^2^),而快速排序的时间复杂度为 O(nlogn)。尽管插入排序的时间复杂度更高,但在数据量较小的情况下,插入排序通常更快

这个结论与线性查找和二分查找的适用情况的结论类似。快速排序这类 O(nlogn) 的算法属于基于分治策略的排序算法,往往包含更多单元计算操作。而在数据量较小时,n^2^ 和 nlogn 的数值比较接近,复杂度不占主导地位,每轮中的单元操作数量起到决定性作用

实际上,许多编程语言(例如 Java)的内置排序函数采用了插入排序,大致思路为:对于长数组,采用基于分治策略的排序算法,例如快速排序;对于短数组,直接使用插入排序

虽然冒泡排序、选择排序和插入排序的时间复杂度都为 O(n^2^),但在实际情况中,插入排序的使用频率显著高于冒泡排序和选择排序,主要有以下原因:

快速排序#

快速排序(quick sort)是一种基于分治策略的排序算法,运行高效,应用广泛

快速排序的核心操作是“哨兵划分”,其目标是:选择数组中的某个元素作为“基准数”,将所有小于基准数的元素移到其左侧,而大于基准数的元素移到其右侧

  1. 选取数组最左端元素作为基准数,初始化两个指针 i 和 j 分别指向数组的两端。
  2. 设置一个循环,在每轮中使用 i(j)分别寻找第一个比基准数大(小)的元素,然后交换这两个元素。
  3. 循环执行步骤 2. ,直到 i 和 j 相遇时停止,最后将基准数交换至两个子数组的分界线

PixPin_2026-04-16_21-44-59

/* 元素交换 */
void swap(int[] nums, int i, int j) {
int tmp = nums[i];
nums[i] = nums[j];
nums[j] = tmp;
}
/* 哨兵划分 */
int partition(int[] nums, int left, int right) {
// 以 nums[left] 为基准数
int i = left, j = right;
while (i < j) {
while (i < j && nums[j] >= nums[left])
j--; // 从右向左找首个小于基准数的元素
while (i < j && nums[i] <= nums[left])
i++; // 从左向右找首个大于基准数的元素
swap(nums, i, j); // 交换这两个元素
}
swap(nums, i, left); // 将基准数交换至两子数组的分界线
return i; // 返回基准数的索引
}

哨兵划分完成后,原数组被划分成三部分:左子数组、基准数、右子数组,且满足“左子数组任意元素 <= 基准数 <= 右子数组任意元素”。因此,接下来只需对这两个子数组进行递归哨兵划分从而实现排序

快速排序的整体流程:

  1. 首先,对原数组执行一次“哨兵划分”,得到未排序的左子数组和右子数组
  2. 然后,对左子数组和右子数组分别递归执行“哨兵划分”
  3. 持续递归,直至子数组长度为 1 时终止,从而完成整个数组的排序

image-20260416214717351

/* 快速排序 */
void quickSort(int[] nums, int left, int right) {
// 子数组长度为 1 时终止递归
if (left >= right) return;
// 哨兵划分
int pivot = partition(nums, left, right);
// 递归左子数组、右子数组
quickSort(nums, left, pivot - 1);
quickSort(nums, pivot + 1, right);
}

基准数优化#

快速排序在某些输入下的时间效率可能降低。举一个极端例子,假设输入数组是完全倒序的,由于我们选择最左端元素作为基准数,那么在哨兵划分完成后,基准数被交换至数组最右端,导致左子数组长度为 n-1、右子数组长度为 0。如此递归下去,每轮哨兵划分后都有一个子数组的长度为 0,分治策略失效,快速排序退化为“冒泡排序”的近似形式

为了进一步改进,我们可以在数组中选取三个候选元素(通常为数组的首、尾、中点元素),并将这三个候选元素的中位数作为基准数。这样一来,基准数“既不太小也不太大”的概率将大幅提升,时间复杂度劣化至 O(n^2^) 的概率大大降低

/* 选取三个候选元素的中位数 */
int medianThree(int[] nums, int left, int mid, int right) {
int l = nums[left], m = nums[mid], r = nums[right];
if ((l <= m && m <= r) || (r <= m && m <= l))
return mid; // m 在 l 和 r 之间
if ((m <= l && l <= r) || (r <= l && l <= m))
return left; // l 在 m 和 r 之间
return right;
}
/* 哨兵划分(三数取中值) */
int partition(int[] nums, int left, int right) {
// 选取三个候选元素的中位数
int med = medianThree(nums, left, (left + right) / 2, right);
// 将中位数交换至数组最左端
swap(nums, left, med);
// 以 nums[left] 为基准数
int i = left, j = right;
while (i < j) {
while (i < j && nums[j] >= nums[left])
j--; // 从右向左找首个小于基准数的元素
while (i < j && nums[i] <= nums[left])
i++; // 从左向右找首个大于基准数的元素
swap(nums, i, j); // 交换这两个元素
}
swap(nums, i, left); // 将基准数交换至两子数组的分界线
return i; // 返回基准数的索引
}

递归深度优化#

在某些输入下,快速排序可能占用空间较多。以完全有序的输入数组为例,设递归中的子数组长度为 m,每轮哨兵划分操作都将产生长度为 0 的左子数组和长度为 m-1 的右子数组,这意味着每一层递归调用减少的问题规模非常小(只减少一个元素),递归树的高度会达到 n-1,此时需要占用 O(n) 大小的栈帧空间

为了防止栈帧空间的累积,我们可以在每轮哨兵排序完成后,比较两个子数组的长度,仅对较短的子数组进行递归。由于较短子数组的长度不会超过 n/2,因此这种方法能确保递归深度不超过 logn,从而将最差空间复杂度优化至 O(logn)。代码如下所示:

/* 快速排序(递归深度优化) */
void quickSort(int[] nums, int left, int right) {
// 子数组长度为 1 时终止
while (left < right) {
// 哨兵划分操作
int pivot = partition(nums, left, right);
// 对两个子数组中较短的那个执行快速排序
if (pivot - left < right - pivot) {
quickSort(nums, left, pivot - 1); // 递归排序左子数组
left = pivot + 1; // 剩余未排序区间为 [pivot + 1, right]
} else {
quickSort(nums, pivot + 1, right); // 递归排序右子数组
right = pivot - 1; // 剩余未排序区间为 [left, pivot - 1]
}
}
}

归并排序#

归并排序(merge sort)是一种基于分治策略的排序算法,包含下图所示的“划分”和“合并”阶段

  1. 划分阶段:通过递归不断地将数组从中点处分开,将长数组的排序问题转换为短数组的排序问题
  2. 合并阶段:当子数组长度为 1 时终止划分,开始合并,持续地将左右两个较短的有序数组合并为一个较长的有序数组,直至结束

归并排序的划分与合并阶段

归并排序与二叉树后序遍历的递归顺序是一致的:

/* 合并左子数组和右子数组 */
void merge(int[] nums, int left, int mid, int right) {
// 左子数组区间为 [left, mid], 右子数组区间为 [mid+1, right]
// 创建一个临时数组 tmp,对应区间为 [0, right - left],用于存放合并后的结果
int[] tmp = new int[right - left + 1];
// 初始化左子数组和右子数组的起始索引
int i = left, j = mid + 1, k = 0;
// 当左右子数组都还有元素时,进行比较并将较小的元素复制到临时数组中
while (i <= mid && j <= right) {
if (nums[i] <= nums[j])
tmp[k++] = nums[i++];
else
tmp[k++] = nums[j++];
}
// 将左子数组和右子数组的剩余元素复制到临时数组中
while (i <= mid) tmp[k++] = nums[i++];
while (j <= right) tmp[k++] = nums[j++];
// 将临时数组 tmp 中的元素复制回原数组 nums 的对应区间
for (k = 0; k < tmp.length; k++) nums[left + k] = tmp[k];
}
/* 归并排序 */
void mergeSort(int[] nums, int left, int right) {
// 终止条件
if (left >= right) return; // 当子数组长度为 1 时终止递归
// 划分阶段
int mid = left + (right - left) / 2; // 计算中点
mergeSort(nums, left, mid); // 递归左子数组
mergeSort(nums, mid + 1, right); // 递归右子数组
// 合并阶段
merge(nums, left, mid, right);
}

对于链表,归并排序相较于其他排序算法具有显著优势,可以将链表排序任务的空间复杂度优化至 O(1)

【🔥中】148排序链表#

给你链表的头结点 head ,请将其按 升序 排列并返回 排序后的链表 。

img

时间复杂度是 O(nlogn) 的排序算法包括归并排序、堆排序和快速排序(快速排序的最差时间复杂度是 O(n^2^),其中最适合链表的排序算法是归并排序

一、由先总问题后子问题(自顶向下)递归实现的归并排序,考虑到递归调用的栈空间,空间复杂度是 O(logn)

  1. 找到链表的中间结点 head2 的前一个节点,并断开 head2 与其前一个节点的连接。这样我们就把原链表均分成了两段更短的链表。

  2. 分治,递归调用 sortList,分别排序 head(只有前一半)和 head2

  3. 排序后,我们得到了两个有序链表,那么合并两个有序链表,得到排序后的链表,返回链表头节点

class Solution {
public ListNode sortList(ListNode head) {
// 如果链表为空或者只有一个节点,无需排序
if (head == null || head.next == null) return head;
// 找到中间节点 head2,并断开 head2 与其前一个节点的连接
// 比如 head=[4,2,1,3],那么 middleNode 调用结束后 head=[4,2] head2=[1,3]
ListNode head2 = middleNode(head);
// 分治
head = sortList(head);
head2 = sortList(head2);
// 合并
return mergeTwoLists(head, head2);
}
// 876. 链表的中间结点(快慢双指针迭代法)
private ListNode middleNode(ListNode head) {
ListNode pre = head;
ListNode slow = head;
ListNode fast = head;
while (fast != null && fast.next != null) {
pre = slow; // 记录 slow 的前一个节点
slow = slow.next;
fast = fast.next.next;
}
pre.next = null; // 断开 slow 的前一个节点和 slow 的连接
return slow;
}
// 21. 合并两个有序链表(独立双指针迭代法)
private ListNode mergeTwoLists(ListNode list1, ListNode list2) {
ListNode dummy = new ListNode(); // 用哨兵节点简化代码逻辑
ListNode cur = dummy; // cur 指向新链表的末尾
while (list1 != null && list2 != null) {
if (list1.val < list2.val) {
cur.next = list1; // 把 list1 加到新链表中
list1 = list1.next;
} else { // 注:相等的情况加哪个节点都是可以的
cur.next = list2; // 把 list2 加到新链表中
list2 = list2.next;
}
cur = cur.next;
}
cur.next = list1 != null ? list1 : list2; // 拼接剩余链表
return dummy.next;
}
}

二、将其改成先子问题后总问题(自底向上)迭代实现,能优化到 O(1) 的空间复杂度

https://leetcode.cn/problems/sort-list/solutions/2993518/liang-chong-fang-fa-fen-zhi-die-dai-mo-k-caei/

堆排序#

堆排序(heap sort)基于堆数据结构实现,可以利用“建堆操作”和“元素出堆操作”实现堆排序

  1. 输入数组并建立小顶堆,此时最小元素位于堆顶
  2. 不断执行出堆操作,依次记录出堆元素,即可得到从小到大排序的序列

以上方法虽然可行,但需要借助一个额外数组来保存弹出的元素,比较浪费空间。通常使用一种更加优雅的实现方式

设数组的长度为 n,堆排序的流程:

  1. 输入数组并建立大顶堆。完成后,最大元素位于堆顶
  2. 将堆顶元素(第一个元素)与堆底元素(最后一个元素)交换。完成交换后,堆的长度减 1,已排序元素数量加 1
  3. 从堆顶元素开始,从顶到底执行堆化操作(sift down)。完成堆化后,堆的性质得到修复
  4. 循环执行第 2. 步和第 3. 步。循环 n-1 轮后,即可完成数组排序

实际上,元素出堆操作中也包含第 2. 步和第 3. 步,只是多了一个弹出元素的步骤

/* 堆的长度为 n ,从节点 i 开始,从顶至底堆化 */
void siftDown(int[] nums, int n, int i) {
while (true) {
// 判断节点 i, l, r 中值最大的节点,记为 ma
int l = 2 * i + 1;
int r = 2 * i + 2;
int ma = i;
if (l < n && nums[l] > nums[ma]) ma = l;
if (r < n && nums[r] > nums[ma]) ma = r;
// 若节点 i 最大或索引 l, r 越界,则无须继续堆化,跳出
if (ma == i) break;
// 交换两节点
int temp = nums[i];
nums[i] = nums[ma];
nums[ma] = temp;
// 循环向下堆化
i = ma;
}
}
/* 堆排序 */
void heapSort(int[] nums) {
// 建堆操作:堆化除叶节点以外的其他所有节点
for (int i = nums.length / 2 - 1; i >= 0; i--) siftDown(nums, nums.length, i);
// 从堆中提取最大元素,循环 n-1 轮
for (int i = nums.length - 1; i > 0; i--) {
// 交换根节点与最右叶节点(交换首元素与尾元素)
int tmp = nums[0];
nums[0] = nums[i];
nums[i] = tmp;
// 以根节点为起点,从顶至底进行堆化
siftDown(nums, i, 0);
}
}

桶排序、计数排序、基数排序#

十二、技巧#

单调栈#

单调栈主要用于解决 “下一个更大/更小元素” 或 “左右边界” 类问题

  • 从左到右+下一个更大—递减栈(栈底到栈顶递减,遇到大的就弹)
  • 从左到右+下一个更小—递增栈
  • 从右到左+下一个更大—递增栈
  • 从右到左+下一个更小—递减栈

注意处理相等元素和边界条件(如数组末尾补一个0值来强制清空栈)

【🔥中】739每日温度#

给定一个整数数组 temperatures ,表示每天的温度,返回一个数组 answer ,其中 answer[i] 是指对于第 i 天,下一个更高温度出现在几天后。如果气温在这之后都不会升高,请在该位置用 0 来代替。

image-20260415195748199

从左到右遍历+下一个更高—用单调递减栈

栈中记录还没找到下一个更大元素的那些数的下标

public int[] dailyTemperatures(int[] temperatures) {
int n = temperatures.length;
int[] res = new int[n];
Stack<Integer> stack = new Stack<>();
for (int i = 0; i < n; i++) {
int t = temperatures[i];
// 【及时循环去掉无用数据,并更新结果】第i天新温度大于栈顶第j天温度,循环弹出栈顶第j天,并且填入第j天的结果
while (!stack.isEmpty() && t > temperatures[stack.peek()]) {
int j = stack.pop();
res[j] = i - j;
}
// 【保持栈单调递减】第i天新温度不大于栈顶第j天温度,将第i天入栈
stack.push(i);
}
return res;
}

【🔥难】42接雨水#

给定 n 个非负整数表示每个宽度为 1 的柱子的高度图,计算按此排列的柱子,下雨之后能接多少雨水。

img

输入:height = [0,1,0,2,1,0,1,3,2,1,2,1]
输出:6
解释:上面是由数组 [0,1,0,2,1,0,1,3,2,1,2,1] 表示的高度图,在这种情况下,可以接 6 个单位的雨水(蓝色部分表示雨水)。

从左到右遍历+下一个更高—用递减栈

public int trap(int[] height) {
int res = 0;
Stack<Integer> stack = new Stack<>();
for (int i = 0; i < height.length; i++) {
int h = height[i];
// 【及时循环去掉无用数据,并更新结果】右墙柱子高度大于凹槽柱子高度,循环弹出栈顶凹槽柱子,并且结果加上由左墙、凹槽、右墙柱子计算得到的面积
while (!stack.isEmpty() && height[stack.peek()] <= h) {
int bottomH = height[stack.pop()];
if (stack.isEmpty()) break; // 如果没有左墙柱子,直接结束该循环
int left = stack.peek();
int height = Math.min(height[left], height[i]) - bottomH; // 面积的高
res += height * (i - left - 1);
}
// 【保持栈单调递减】
stack.push(i);
}
return res;
}

【🔥难】84柱状图中最大的矩形#

给定 n 个非负整数,用来表示柱状图中各个柱子的高度。每个柱子彼此相邻,且宽度为 1 。

求在该柱状图中,能够勾勒出来的矩形的最大面积。

img

输入:heights = [2,1,5,6,2,3]
输出:10
解释:最大的矩形为图中红色区域,面积为 10

首先,面积最大矩形的高度一定是 heights 中的元素

枚举每个 h=heights[i],作为矩形的高。那么矩形的宽最大是多少?我们需要知道:

比如示例中,选择 i=2 这个柱子作为矩形的高,那么左边小于 heights[2]=5 的最近元素的下标为 left=1,右边小于 heights[2]=5 的最近元素的下标为 right=4。矩形的宽就是 right−left−1=4−1−1=2,矩形面积为 h⋅(right−left−1)=5⋅2=10

如何用代码找到左右边界,则是通过维护一个单调递增栈(栈底到栈顶高度递增)

计算对应的矩形面积,更新答案的最大值

84题(算矩形):无论左右有没有墙,矩形都客观存在(哪怕延伸到边界),所以需要前后补0作为虚拟边界来强制计算

  • 前面补0:相当于在数组最左边放了一个高度为0的“哨兵”。这样,任何柱子左边都至少有一个比它矮的柱子(就是这个0),保证了计算 left 时栈永远不会空。
  • 后面补0:相当于在数组最右边放了一个高度为0的“哨兵”。遍历到这个0时,它会强制把栈里所有剩余的柱子都“挤兑”出来进行计算,确保每个柱子都能被处理到。

42题(接雨水):只有被“夹住”才能接水。左边没墙存不住(判空跳过),右边没墙流走了(不触发计算),所以完全不需要补0

  • 左边界不需要“哨兵”,依靠“判空”兜底:栈为空,即没有左墙时直接结束运算 if (stack.isEmpty()) break;
  • 右边界天然存在,无需“强制触发”:计算是由右边的“高墙”触发的。如果右边没有高墙,就不会触发计算,这符合物理事实(水会流走)。所以也不需要后面的哨兵来强制计算
public int largestRectangleArea(int[] heights) {
// 预处理数组:前后补0
int[] newHeights = new int[heights.length + 2];
newHeights[0] = 0;
System.arraycopy(heights, 0, newHeights, 1, heights.length);
newHeights[newHeights.length - 1] = 0;
Deque<Integer> stack = new LinkedList<>();
int res = 0;
for (int i = 0; i < newHeights.length; i++) {
int h = newHeights[i];
// 当当前高度小于栈顶高度时,触发面积计算
while (!stack.isEmpty() && h < newHeights[stack.peek()]) {
int height = newHeights[stack.pop()];
int width = i - stack.peek() - 1; // 左边界为新栈顶索引,右边界为i
res = Math.max(res, height * width);
}
stack.push(i);
}
return res;
}

维护前缀和—解决子数组和问题#

简 303区域和检索 - 数组不可变(前缀和模板题)#

实现 NumArray 类:

  • NumArray(int[] nums) 使用数组 nums 初始化对象
  • int sumRange(int left, int right) 返回数组 nums 中索引 left 和 right 之间的元素的 总和 ,包含 left 和 right 两点(也就是 nums[left] + nums[left + 1] + ... + nums[right] )
输入:
["NumArray", "sumRange", "sumRange", "sumRange"]
[[[-2, 0, 3, -5, 2, -1]], [0, 2], [2, 5], [0, 5]]
输出:
[null, 1, -1, -3]
解释:
NumArray numArray = new NumArray([-2, 0, 3, -5, 2, -1]);
numArray.sumRange(0, 2); // return 1 ((-2) + 0 + 3)
numArray.sumRange(2, 5); // return -1 (3 + (-5) + 2 + (-1))
numArray.sumRange(0, 5); // return -3 ((-2) + 0 + 3 + (-5) + 2 + (-1))

思路:

比如 nums=[1,2,3,4,5,6],要想计算子数组 [3,4,5] 的元素和,可以用前缀 [1,2,3,4,5] 的元素和,减去另一个前缀 [1,2] 的元素和,就得到了子数组 [3,4,5] 的元素和,即

3+4+5=(1+2+3+4+5)−(1+2)

所以任意子数组的和,都可以表示为两个前缀和的差

于是,预处理 nums 所有的前缀和,就可以 O(1) 计算任意子数组的元素和

nums 有 O(n^2^) 个子数组,如果把每个子数组都算一遍,就太慢了。但注意到 nums 只有 O(n) 个前缀,且子数组可以用两个前缀和之差表示,所以本题解的做法相当于把 O(n^2^) 的信息压缩到 O(n) 个前缀中

具体实现:

为方便描述,把 nums 记作 a,设其长度为 n

对于数组 a,计算它的长为 n+1 的前缀和数组 s,即 a 的前 0 个数的和,前 1 个数的和,前 2 个数的和……前 n 个数的和

image-20260419221600045

这里定义 s[0] 是为了让 s[1]=s[0]+a[0] 满足 s[i + 1] = s[i] + nums[i] 的公式,同时在后面计算子数组和 left=0 时能直接减去 s[0]

根据这个定义,前 i 个数的和,加上 a[i],就是前 i+1 个数的和,即 s[i+1]=s[i]+a[i]

通过前缀和,我们可以把子数组的元素和转化成两个前缀和的差,下标区间 [left,right] 的元素和等于前缀 [0,right] 的元素和减去另一个前缀 [0,left−1] 的元素和,即 s[right+1]−s[left]

class NumArray {
private final int[] s;
public NumArray(int[] nums) {
s = new int[nums.length + 1];
// 构造前缀和数组
for (int i = 0; i < nums.length; i++) s[i + 1] = s[i] + nums[i];
}
public int sumRange(int left, int right) {
return s[right + 1] - s[left];
}
}

【🔥中】560和为K的子数组(前缀和+1两数之和HashMap)#

给你一个整数数组 nums 和一个整数 k ,请你统计并返回 该数组中和为 k 的子数组的个数 。

子数组是数组中元素的连续非空序列。

前缀和的定义:

  • s 是一个长为 n+1 的数组,第一个数是 0,s[0]=0

  • s[i] = nums[0]+nums[1]+⋯+nums[i−1]

  • s[j]−s[i]=k1,即从 nums[i] 到 nums[j-1] 的元素和

  • s[j+1]−s[i]=k2,即从 nums[i] 到 nums[j] 的元素和

问题转化为:s 中有多少对下标 (i, j) 满足 0≤i<j≤n 且 s[j]−s[i]=k?

以 nums=[1,1,−1,1,−1],k=1 为例,其前缀和数组为 s=[0,1,2,1,2,1]。画出前缀和数组的折线图,如下:

lc560-2c.png

如果用二重循环暴力枚举有多少个 s[j]−s[i]=k,时间复杂度是 O(n^2^),太慢了。如何加速?

借鉴1两数之和的思路,可以把 s[j]−s[i]=k 移项,得 s[i]=s[j]−k 枚举当前的前缀和 s[j],看看曾经有多少个前缀和等于 s[j]−k(配对)。每当我们在左边找到一个值等于 s[j]−k 的前缀和,就找到了一个和为 k 的子数组(因为 s[j]−(s[j]−k)=k)

比如 s[j]=2,那么 s[i]=s[j]−k=2−1=1,我们要找的是 j 左边有多少个 s[i]=1。在上面的例子中,遍历到 s[4]=2 时,我们知道左边有 2 个 s[i]=1,所以新找到了 2 个和为 1 的子数组

用这个视角,再来算算上图中的那 6 个和为 1 的子数组

image-20260420151029428

一共有 0+1+1+1+2+1=6 个和为 k=1 的子数组

具体来说,在遍历 s[j] 的同时,用一个哈希表 cnt 统计 s[j] 的个数。哈希表的 key 是 s[j],value 是值为 s[j] 的前缀和的个数。遍历到 s[j] 时,从哈希表中就可以找到有 cnt[s[j]−k] 个 s[i],加入答案

public int subarraySum(int[] nums, int k) {
int n = nums.length;
int[] s = new int[n + 1];
// 构造前缀和数组
for (int i = 0; i < n; i++) s[i + 1] = s[i] + nums[i];
Map<Integer, Integer> cnt = new HashMap<>(); // 预分配空间
int res = 0;
for (int sj : s) {
res += cnt.getOrDefault(sj - k, 0);
cnt.put(sj, cnt.getOrDefault(sj, 0) + 1);
}
return res;
}

可以改用 Java8 提供的 merge 方法优雅地更新 Map 数据

// 含义:将 sj 对应的值与 1 相加,如果不存在则初始值为 0
cnt.merge(sj, 1, Integer::sum);

【🔥中】437路径总合 III(560的递归回溯版)#

给定一个二叉树的根节点 root ,和一个整数 targetSum ,求该二叉树里节点值之和等于 targetSum 的 路径 的数目。

路径 不需要从根节点开始,也不需要在叶子节点结束,但是路径方向必须是向下的(只能从父节点到子节点)。

img

输入:root = [10,5,-3,3,2,null,11,3,-2,null,1], targetSum = 8
输出:3
解释:和等于 8 的路径有 3 条,如图所示。

如果二叉树是一条链,本题就和 560. 和为 K 的子数组 完全一样了:统计有多少个非空连续子数组的元素和恰好等于 targetSum

560. 和为 K 的子数组437. 路径总和 III
连续子数组方向向下的路径
前缀从根节点开始的路径
做法:枚举子数组右端点,统计有多少个左端点做法:枚举路径的终点,统计有多少个起点

我们要解决的问题是:DFS 遍历这棵树,遍历到节点 node 时,假设 node 是路径的终点,那么有多少个起点,满足起点到终点 node 的路径总和恰好等于 targetSum?

和 560 题一样的套路:一边遍历二叉树,一边用哈希表 cnt 统计前缀和(从根节点开始的路径和)的出现次数。设从根到终点 node 的路径和为 s,那么起点的个数就是 cnt[s−targetSum],加入答案。对比 560 题,我们在枚举子数组的右端点(终点),统计有多少个左端点(起点),做法完全一致。

class Solution {
private int res;
public int pathSum(TreeNode root, int targetSum) {
// key:从根到 node 的节点值之和
// value:节点值之和的出现次数
// 注意在递归过程中,哈希表只保存根到 node 的路径的前缀的节点值之和
Map<Long, Integer> cnt = new HashMap<>();
cnt.put(0L, 1);
backtrack(root, 0, targetSum, cnt);
return res;
}
// s 表示从根到 node 的父节点的节点值之和(node 的节点值尚未计入)
private void backtrack(TreeNode node, long s, int targetSum, Map<Long, Integer> cnt) {
if (node == null) return;
// 在递归过程中构建"前缀和数组"(这里没有用数组,而是用s动态存储)
s += node.val;
// 把 node 当作路径的终点,统计有多少个起点
res += cnt.getOrDefault(s - targetSum, 0);
cnt.merge(s, 1, Integer::sum); // cnt[s]++
backtrack(node.left, s, targetSum, cnt);
backtrack(node.right, s, targetSum, cnt);
cnt.merge(s, -1, Integer::sum); // cnt[s]-- 恢复现场(撤销 cnt[s]++)
}
}

⚠️注意:

1、代码中的「恢复现场」用意何在?

答:举个例子,递归完 node 子树,如果接下来要递归 node 父节点的右子树,我们不恢复现场,那么 cnt 中还保存着 node 子树的数据。但对于 node 父节点的右子树来说,要计算的路径并不涉及到 node 子树的任何节点。如果不恢复现场,cnt 中统计的前缀和个数会更多,我们算出来的答案可能比正确答案更大

2、为什么递归参数 s 不需要恢复现场?

答:s 是基本类型,在函数调用的时候会复制一份往下传递,s += node.val 修改的仅仅是当前递归函数中的 s 参数,并不会影响到其他递归函数中的 s。注:如果把 s 声明在递归函数外,全局只有一个 s,那么执行 s += node.val 就会影响全局了,这种情况需要写 s -= node.val 恢复现场

3、560数组版中的初始化只需要 s[0]=0(默认),而437中的初始化还需要 cnt.put(0L, 1);

(1)在560数组版中,你显式地构造了一个前缀和数组 s

  1. 显式的 0:

    int[] s = new int[n + 1];
    // s[0] 默认就是 0,代表“空数组”的前缀和

    这个 s[0] 是实实在在存在于数组里的第一个元素

  2. 循环过程:

    for (int sj : s) { // 第一次循环时,sj 就是 s[0] (即 0)
    res += cnt.getOrDefault(sj - k, 0);
    // 此时 Map 是空的,查不到 sj-k,res 加 0
    cnt.put(sj, ...);
    // 紧接着,把 sj (也就是 0) 放入了 Map
    }

    结论:数组版通过遍历 s[0] 这个动作,自动完成了“把 0 放入 Map”的操作。所以不需要手动初始化

(2)在437递归回溯版中,没有显式的前缀和数组,前缀和是在递归过程中动态计算的

  1. 没有“第 0 步”的遍历: 递归是从 root 节点开始的。当你进入 backtrack 函数处理第一个节点时,你直接计算了 s += node.val
    • 假设根节点的值是 3,那么 s 直接变成了 3
    • 此时你需要查找 Map 中是否有 s - targetSum(即 3 - targetSum)
  2. 边界情况(从根节点开始的路径): 如果题目要求的路径恰好是从根节点开始,且根节点的值就等于 targetSum(比如 3 - 3 = 0)
    • 代码会去 Map 里找 0
    • 如果不初始化:Map 是空的,找不到 0,结果就会漏掉这一条路径

维护前缀最小值—【🔥中】155最小栈#

设计一个支持 push ,pop ,top 操作,并能在常数时间内检索到最小元素的栈。

实现 MinStack 类:

  • MinStack() 初始化堆栈对象。
  • void push(int val) 将元素val推入堆栈。
  • void pop() 删除堆栈顶部的元素。
  • int top() 获取堆栈顶部的元素。
  • int getMin() 获取堆栈中的最小元素。

给你一个数组 nums,如何计算每个前缀的最小值?

定义 preMin[i] 表示 nums[0] 到 nums[i] 的最小值

这可以从左到右计算:

preMin[0]=nums[0] preMin[1]=min(nums[0],nums[1]) preMin[2]=min(nums[0],nums[1],nums[2])=min(preMin[1],nums[2]) preMin[3]=min(nums[0],nums[1],nums[2],nums[3])=min(preMin[2],nums[3]) …… 一般地,我们有

preMin[i]=min(preMin[i−1],nums[i])

回到本题,把 nums 视作栈,本题相当于在 nums 的末尾动态地添加/删除元素

注意:

  1. 初始化的时候,在栈底加一个 ∞ 哨兵,确保栈为空 push 时 getMin 仍能得到一个值,这里是 Integer.MAX_VALUE

  2. 题目保证 pop,top,getMin 都是在非空栈上操作的

class MinStack {
// 注意不要使用 Stack 类,因为它继承自 Vector,是同步的,会导致一些性能问题
private final Deque<int[]> st = new ArrayDeque<>();
public MinStack() { // 初始化堆栈对象
// 添加栈底哨兵 Integer.MAX_VALUE
// 这里的 0 写成任意数都可以,反正用不到
st.push(new int[]{0, Integer.MAX_VALUE});
}
public void push(int val) { // 将元素val推入堆栈
st.push(new int[]{val, Math.min(getMin(), val)});
}
public void pop() { // 删除堆栈顶部的元素
st.pop();
}
public int top() { // 获取堆栈顶部的元素
return st.peek()[0];
}
public int getMin() { // 获取堆栈中的最小元素
return st.peek()[1];
}
}

维护分解的前后缀—【🔥中】238除了自身以外数组的乘积#

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

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

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

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

answer[i] 等于 nums 中除了 nums[i] 之外其余各元素的乘积。换句话说,如果知道了 i 左边所有数的乘积,以及 i 右边所有数的乘积,就可以算出 answer[i]

于是:

可以先计算出从 nums[0] 到 nums[i−2] 的乘积 pre[i−1],再乘上 nums[i−1],就得到了 pre[i],即 pre[i]=pre[i−1]⋅nums[i−1]

同理有 suf[i]=suf[i+1]⋅nums[i+1]

初始值:pre[0]=suf[n−1]=1。按照上文的定义,pre[0] 和 suf[n−1] 都是空子数组的元素乘积,我们规定这是 1,因为 1 乘以任何数 x 都等于 x,这样可以方便递推计算 pre[1],suf[n−2] 等

算出 pre 数组和 suf 数组后,有 answer[i]=pre[i]⋅suf[i]

public int[] productExceptSelf(int[] nums) {
int n = nums.length;
int[] pre = new int[n];
pre[0] = 1;
for (int i = 1; i < n; i++) pre[i] = pre[i - 1] * nums[i - 1];
int[] suf = new int[n];
suf[n - 1] = 1;
for (int i = n - 2; i >= 0; i--) suf[i] = suf[i + 1] * nums[i + 1];
int[] res = new int[n];
for (int i = 0; i < n; i++) res[i] = pre[i] * suf[i];
return res;
}

其他#

【🔥简】136只出现一次的数字(异或运算)#

给你一个 非空 整数数组 nums ,除了某个元素只出现一次以外,其余每个元素均出现两次。找出那个只出现了一次的元素。

你必须设计并实现线性时间复杂度的算法来解决此问题,且该算法只使用常量额外空间。

利用异或运算 a⊕a=0 的性质,可以用异或来「消除」所有出现了两次的元素,最后剩下的一定是只出现一次的元素

例如 nums=[4,1,2,1,2],把所有元素异或:

4⊕1⊕2⊕1⊕2 = 4⊕(1⊕1)⊕(2⊕2) = 4⊕0⊕0 = 4

其中用到了异或运算的交换律 a⊕b=b⊕a,以及结合律 (a⊕b)⊕c=a⊕(b⊕c)(类比加法)

代码中,初始化 ans=0 是因为 0⊕a=a,相当于我们从第一个数开始,和其他数异或

public int singleNumber(int[] nums) {
int ans = 0;
for (int x : nums) ans ^= x;
return ans;
}
名称运算符规则示例结果
按位与&全 1 则 1,否则 00101 & 00110001 (1)
按位或``有 1 则 1,全 0 则 00101
按位异或^相同为 0,不同为 10101 ^ 00110110 (6)
按位取反~0 变 1,1 变 0~(0000 0101)1111 1010 (-6)
左移<<向左移,低位补 00000 0011 << 20000 1100 (12)
右移>>向右移,高位补符号位1111 0100 >> 21111 1101 (-3)
无符号右移>>>向右移,高位补 01111 0100 >>> 20011 1101 (61)

【🔥简】169多数元素(擂台赛)#

给定一个大小为 n 的数组 nums ,返回其中的多数元素。多数元素是指在数组中出现次数 大于 ⌊ n/2 ⌋ 的元素。

你可以假设数组是非空的,并且给定的数组总是存在多数元素。

尝试设计时间复杂度为 O(n)、空间复杂度为 O(1) 的算法解决此问题。

用「擂台赛」打比方:

  1. 擂主登场:nums[0] 成为初始擂主,生命值为 1
  2. 挑战者出现:遍历后续元素,作为挑战者
  3. 比武:如果挑战者与擂主属于同一门派(值相同),那么擂主生命值加 1,否则擂主生命值减 1
  4. 擂主更迭:如果比武后,擂主生命值降为 0(同归于尽),那么下一个挑战者成为新的擂主,生命值为 1
  5. 最后在擂台上的那人,便是武林盟主(绝对众数)
public int majorityElement(int[] nums) {
int res = 0;
int hp = 0;
for (int x : nums) {
if (hp == 0) { // x 是初始擂主,生命值为 1
res = x;
hp = 1;
} else { // 比武,同门加血,否则扣血
int change = x == res ? 1 : -1;
hp += change;
}
}
return res;
}

【🔥中】31下一个排列(下一个字典序算法)#

整数数组的一个 排列 就是将其所有成员以序列或线性顺序排列。

  • 例如,arr = [1,2,3] ,以下这些都可以视作 arr 的排列:[1,2,3]、[1,3,2]、[3,1,2]、[2,3,1] 。

整数数组的 下一个排列 是指其整数的下一个字典序更大的排列。更正式地,如果数组的所有排列根据其字典顺序从小到大排列在一个容器中,那么数组的 下一个排列 就是在这个有序容器中排在它后面的那个排列。如果不存在下一个更大的排列,那么这个数组必须重排为字典序最小的排列(即,其元素按升序排列)。

  • 例如,arr = [1,2,3] 的下一个排列是 [1,3,2] 。
  • 类似地,arr = [2,3,1] 的下一个排列是 [3,1,2] 。
  • 而 arr = [3,2,1] 的下一个排列是 [1,2,3] ,因为 [3,2,1] 不存在一个字典序更大的排列。

给你一个整数数组 nums ,找出 nums 的下一个排列。

必须 原地 修改,只允许使用额外常数空间。

字典序就是从左到右,逐位比较

排列 [1,3,5,4,2] 的下一个排列是什么?

回顾是怎么算的:

  1. 从右向左,找第一个数字 x,满足 x 右边有大于 x 的数,这样可以把 x 变大。我们找到的数是 3。注意到,此时 3 右边的数一定是递减的,否则应该在 3 的右边找到我们要的数,所以 3 右侧相邻数字就是 3 右边最大的数。如果 3 右侧相邻数字小于 3,那么 3 右边必然没有大于 3 的数。因此,这一步可以简化为,从右向左,找第一个小于右侧相邻数字的数 x
  2. 找 3 右边最小的大于 3 的数,即 4。由于 3 右边的数是递减的,所以从右向左找到的第一个大于 3 的数,就是 3 右边最小的大于 3 的数。然后把 4 放到 3 的位置上,把 3 放到右边的三个位置中。这一步可以简化为交换 3 和 4。交换后得到 [1,4,5,3,2]。因此这一步是,从右向左找第一个大于 x 的数 y,它就是 x 右边最小的大于 x 的数,交换 x 和 y
  3. 把 4 右边的数从小到大排序。注意到,第二步交换后,4 右边的数 5,3,2 是递减的(证明见下面),所以只需要把 5,3,2 反转,就得到了答案 [1,4,2,3,5]。这一步为反转 y 右边的数,把右边的数变成最小的排列

特别地,如果第一步没有找到这样的 x,说明 nums 是递减的,是最后一个排列(字典序最大的排列)。此时跳过第二步,反转整个 nums,结果一定是递增的,即第一个排列(字典序最小的排列)

问:为什么第二步交换后,右边的序列仍然是递减的?

答:设交换前 nums=[…,x,c,b,a,…] 且 a<x<b<c,那么 x 右边最小的大于 x 的数是 b。交换 x 和 b 后,右边数字为 c,x,a,…,满足 c>x>a>⋯,仍然是递减的

public void nextPermutation(int[] nums) {
int n = nums.length;
// 第一步:从右向左找到第一个小于右侧相邻数字的数 nums[i]
int i = n - 2;
while (i >= 0 && nums[i] >= nums[i + 1]) i--;
// 如果找到了,进入第二步;否则跳过第二步,反转整个数组
if (i >= 0) {
// 第二步:从右向左找到 nums[i] 右边最小的大于 nums[i] 的数 nums[j]
int j = n - 1;
while (nums[j] <= nums[i]) j--;
// 交换 nums[i] 和 nums[j]
swap(nums, i, j);
}
// 第三步:反转 [i+1, n-1](如果上面跳过第二步,此时 i = -1)
reverse(nums, i + 1, n - 1);
}
private void swap(int[] nums, int i, int j) {
int tmp = nums[i];
nums[i] = nums[j];
nums[j] = tmp;
}
private void reverse(int[] nums, int left, int right) {
while (left < right) swap(nums, left++, right--);