文章目录
- 贪心算法部分
-
- 核心知识点
-
- [✅ 核心定义与本质](#✅ 核心定义与本质)
- [✅ 贪心的两大核心性质(必须同时满足,缺一不可)](#✅ 贪心的两大核心性质(必须同时满足,缺一不可))
- [✅ 贪心 vs DP vs 回溯(二刷必懂区别,应对面试官追问)](#✅ 贪心 vs DP vs 回溯(二刷必懂区别,应对面试官追问))
- [✅ 贪心的适用场景(快速判断,二刷秒杀)](#✅ 贪心的适用场景(快速判断,二刷秒杀))
- [✅ 时间&空间复杂度(二刷必懂,应对追问)](#✅ 时间&空间复杂度(二刷必懂,应对追问))
- [✅ Java实现贪心的核心要点(避坑基础)](#✅ Java实现贪心的核心要点(避坑基础))
- 技巧方法
-
- [✅ 技巧一:区间问题(★★★★★ 占比35%+,贪心第一核心)](#✅ 技巧一:区间问题(★★★★★ 占比35%+,贪心第一核心))
- [✅ 技巧二:数值分配/选择问题(★★★★ 占比20%+,基础必熟)](#✅ 技巧二:数值分配/选择问题(★★★★ 占比20%+,基础必熟))
- [✅ 技巧三:序列构造/优化问题(★★★★ 占比15%+,中等题核心)](#✅ 技巧三:序列构造/优化问题(★★★★ 占比15%+,中等题核心))
- [✅ 技巧四:堆辅助贪心(★★★ 占比15%+,后端高频)](#✅ 技巧四:堆辅助贪心(★★★ 占比15%+,后端高频))
- [✅ 技巧五:经典贪心问题(★★★ 占比15%+,基础必刷)](#✅ 技巧五:经典贪心问题(★★★ 占比15%+,基础必刷))
- 通用代码模板
-
- [✔ 贪心算法通用解题步骤(所有场景的基础,二刷必背)](#✔ 贪心算法通用解题步骤(所有场景的基础,二刷必背))
- 总结
- 图论部分
-
- 核心知识点
-
- [✅ 图的基础定义与核心存储方式(Java实现必熟)](#✅ 图的基础定义与核心存储方式(Java实现必熟))
-
- [1. 两种核心存储方式(Java实现+适用场景)](#1. 两种核心存储方式(Java实现+适用场景))
- [2. 边的存储方式(辅助算法:Kruskal)](#2. 边的存储方式(辅助算法:Kruskal))
- [✅ 图的核心基本概念(二刷必辨,算法选择的依据)](#✅ 图的核心基本概念(二刷必辨,算法选择的依据))
- [✅ 图论核心算法分类与适用场景(二刷秒杀,快速匹配算法)](#✅ 图论核心算法分类与适用场景(二刷秒杀,快速匹配算法))
- [✅ 图论的后端实际应用(二刷必思,应对面试官场景追问)](#✅ 图论的后端实际应用(二刷必思,应对面试官场景追问))
- [✅ Java实现图论的核心要点(避坑基础)](#✅ Java实现图论的核心要点(避坑基础))
- 技巧方法
-
- [✅ 技巧一:并查集(DSU)(★★★★★ 占比25%+,图论工具之王)](#✅ 技巧一:并查集(DSU)(★★★★★ 占比25%+,图论工具之王))
- [✅ 技巧二:图的遍历(DFS/BFS)(★★★★★ 占比25%+,图论基础)](#✅ 技巧二:图的遍历(DFS/BFS)(★★★★★ 占比25%+,图论基础))
- [✅ 技巧三:最短路径算法(★★★★ 占比20%+,后端高频)](#✅ 技巧三:最短路径算法(★★★★ 占比20%+,后端高频))
- [✅ 技巧四:最小生成树(Kruskal/Prim)(★★★★ 占比15%+,后端工程化)](#✅ 技巧四:最小生成树(Kruskal/Prim)(★★★★ 占比15%+,后端工程化))
- [✅ 技巧五:拓扑排序(Kahn/DFS)(★★★ 占比10%+,后端核心场景)](#✅ 技巧五:拓扑排序(Kahn/DFS)(★★★ 占比10%+,后端核心场景))
- [✅ 技巧六:二分图判断(染色法)(★★★ 占比5%+,基础必熟)](#✅ 技巧六:二分图判断(染色法)(★★★ 占比5%+,基础必熟))
- 通用代码模板
-
- [✔ 模板1:并查集(DSU)(路径压缩+按秩合并,核心工具)](#✔ 模板1:并查集(DSU)(路径压缩+按秩合并,核心工具))
- [✔ 模板2:图的遍历(DFS递归+非递归,BFS,含环检测)](#✔ 模板2:图的遍历(DFS递归+非递归,BFS,含环检测))
- [✔ 模板3:Dijkstra(堆优化,正权图单源最短路径,后端核心)](#✔ 模板3:Dijkstra(堆优化,正权图单源最短路径,后端核心))
- [✔ 模板4:Kruskal(最小生成树,边贪心+并查集,后端高频)](#✔ 模板4:Kruskal(最小生成树,边贪心+并查集,后端高频))
- [✔ 模板5:Kahn算法(拓扑排序+环检测,后端任务调度核心)](#✔ 模板5:Kahn算法(拓扑排序+环检测,后端任务调度核心))
- [✔ 模板6:染色法(BFS,二分图判断)](#✔ 模板6:染色法(BFS,二分图判断))
- 总结
贪心算法部分
贪心算法是后端面试中等题优化核心、高频考点(占比40%+) ,本质是每一步都做出当前局部最优的选择,最终希望得到全局最优解 。它比动态规划(DP)更高效(时间复杂度通常O(n)或O(nlogn)),但适用场景有严格限制。二刷的核心目标不是"会写贪心代码",而是:吃透贪心的两大核心性质(判断适用场景)+ 快速选对贪心策略(选准"局部最优"角度)+ 结合排序/堆等数据结构落地 + 区分贪心与DP的适用边界,同时结合Java特性(自定义排序、优先队列、数组操作)写出高效易读的代码,规避"贪心策略选错、性质判断失误"的核心坑。
核心知识点
✅ 核心定义与本质
贪心算法 = 局部最优选择 + 逐步推导全局最优,核心思想:
- 对问题的每个决策阶段,不考虑后续所有可能的情况,只做出当前看起来最优的选择(比如"选最早结束的区间""选最大面值的硬币");
- 最终通过所有局部最优的累积,得到全局最优解(仅当问题满足贪心两大性质时成立);
- 与DP的本质区别:DP记录所有子问题的解,通过子问题推导全局最优(考虑后续);贪心无记录,仅一步一步选局部最优(不考虑后续)。
✅ 贪心的两大核心性质(必须同时满足,缺一不可)
这是判断一个问题能否用贪心解决的唯一标准,二刷每道贪心题都要先验证这两个性质,再写代码。
-
贪心选择性质
全局最优解可以通过一系列局部最优解的选择得到 ,且每个局部最优选择的做出,无需依赖后续的决策结果(即"当下选最优,后续不后悔")。
✅ 举例:活动选择问题(选最多不重叠区间)→ 每次选最早结束的区间,后续仍能选最多的区间,符合贪心选择性质。
❌ 反例:找零钱(面值[1,3,4],凑6)→ 贪心选4+1+1(总3枚),实际最优3+3(总2枚),不满足贪心选择性质。
-
最优子结构性质
问题的全局最优解包含其子问题的最优解 (与DP的最优子结构一致,这是贪心和DP的共性)。
✅ 举例:最短路径问题(Dijkstra算法)→ 从起点到终点的最短路径,包含起点到中间节点的最短路径。
简单一句话判断:就是我一步步选最优,最后是否可以得到全局最优,这中途不会有回退等后悔操作,如果举不出明显的反例,那么就可以试试贪心算法,否则就去使用dp来做。
✅ 贪心 vs DP vs 回溯(二刷必懂区别,应对面试官追问)
三者都解决最优化问题,但适用场景、效率、实现方式天差地别,二刷要能快速匹配问题与算法。
| 维度 | 贪心算法 | 动态规划(DP) | 回溯算法 |
|---|---|---|---|
| 核心思想 | 局部最优→全局最优 | 子问题最优→全局最优 | 暴力枚举所有路径→找最优 |
| 关键性质 | 贪心选择性质+最优子结构 | 最优子结构+无后效性+重叠子问题 | 无严格性质(枚举即可) |
| 是否记录 | 无记录,一步一步选 | 记录dp数组,存储子问题解 | 记录路径,回溯撤销选择 |
| 时间复杂度 | 低(O(n)/O(nlogn),多含排序) | 中(O(n²)/O(nm)) | 高(O(2ⁿ)/O(n!),易超时) |
| 空间复杂度 | 低(O(1)/O(n)) | 中(O(n)/O(nm),可优化) | 中(O(n),递归栈+路径) |
| 适用场景 | 满足两大性质的最优化问题 | 重叠子问题+最优子结构 | 方案数少、需枚举所有可能 |
| 核心优势 | 高效,代码简洁 | 适用范围广,能解贪心解不了的题 | 能枚举所有方案,无遗漏 |
| 核心缺陷 | 适用场景有限,易用错 | 状态定义易出错,需推转移方程 | 时间效率极低,仅适用于小规模问题 |
经典对比举例:找零钱问题
- 面值为人民币(1,5,10,20,50,100)→ 满足贪心选择性质,用贪心(选最大面值);
- 面值为[1,3,4]→ 不满足贪心选择性质,用DP;
- 面值种类少、金额小→ 可用回溯枚举所有组合。
✅ 贪心的适用场景(快速判断,二刷秒杀)
遇到以下问题,优先考虑贪心(验证两大性质后直接用),无需先想DP/回溯:
- 区间问题:最多不重叠区间、区间合并、用最少点覆盖所有区间、无重叠区间;
- 数值分配/选择:找零钱(规范面值)、分糖果、分发饼干、买卖股票的最佳时机(多次交易);
- 序列构造/优化:摆动序列、最长递增子序列(贪心+二分优化)、重构字符串;
- 堆辅助贪心:任务调度器、前K个高频元素、最大数;
- 图论相关:Dijkstra算法(最短路径)、Prim算法(最小生成树)、Kruskal算法(最小生成树);
- 后端场景:任务调度(按执行时间排序)、资源分配(按优先级分配)、接口限流(按请求频率贪心处理)。
✅ 时间&空间复杂度(二刷必懂,应对追问)
贪心算法本身的操作是线性的(O(n)) ,时间复杂度主要由辅助操作决定(如排序、堆操作):
- 仅线性遍历:O(n)(如跳跃游戏、买卖股票多次交易);
- 含排序(Arrays.sort):O(nlogn)(如区间问题、分糖果,这是贪心最常见的复杂度);
- 含堆操作(优先队列):O(nlogk)(k为堆的大小,如任务调度器O(nlog26)、前K个高频O(nlogk))。
空间复杂度:
- 无辅助空间:O(1)(如跳跃游戏、买卖股票);
- 含排序/堆:O(logn)(排序的递归栈)或 O(k)(堆的大小),通常视为O(1)或O(n)。
✅ Java实现贪心的核心要点(避坑基础)
- ※自定义排序是核心※ :贪心的"局部最优"往往需要先对数据按特定规则排序 (如区间按右端点升序、数组按降序),Java中常用两种排序方式:
- 基本类型数组:
Arrays.sort(nums)(默认升序),降序用Arrays.sort(nums, Collections.reverseOrder())(需转包装类); - 自定义对象/二维数组:
Arrays.sort(arr, (o1, o2) -> 比较逻辑)(Lambda表达式,二刷必熟);
- 基本类型数组:
- 优先队列(堆)的使用 :需要动态选局部最优 (如动态选执行时间最短的任务、最大的元素)时,用Java的
PriorityQueue,核心是自定义比较器; - 边界条件处理:贪心的局部最优易忽略边界(如区间问题的空数组、分糖果的单个孩子),需提前判断;
- 贪心策略的落地 :将"局部最优"转化为可执行的代码逻辑(如"选最早结束的区间"→ 记录上一个区间的结束位置,遍历判断当前区间是否重叠);
- 避免浮点数精度:贪心的数值计算优先用int/long,避免float/double(如比例问题可转整数比)。
技巧方法
贪心的解题技巧高度依赖场景 ,但高频场景的贪心策略固定且可固化 。以下按后端面试考察频率排序,每个场景拆解核心贪心策略、解题思路、经典题、避坑点,二刷需逐个固化。
✅ 技巧一:区间问题(★★★★★ 占比35%+,贪心第一核心)
区间问题是贪心算法最经典、考察最多 的场景,后端场景中对应"任务调度、会议安排、资源占用",核心是通过排序确定贪心策略,再线性遍历判断。
核心原理
- 贪心策略的选择由问题目标决定(这是区间问题的关键,二刷记死);
- 所有区间问题的第一步:按特定规则排序 (
无排序,无贪心); - 线性遍历:用一个变量记录上一个最优区间的状态(如结束位置、合并后的右端点),逐个判断当前区间是否符合条件。
高频子场景&核心贪心策略
| 区间问题类型 | 核心贪心策略 | 排序规则 | 后端场景对应 |
|---|---|---|---|
| 选最多不重叠区间 | 每次选最早结束的区间 | 按区间右端点升序 | 安排最多的会议/任务 |
| 区间合并 | 合并所有重叠/相邻区间,保留最小区间集 | 按区间左端点升序 | 合并重叠的资源占用段 |
| 用最少点覆盖所有区间 | 每次选当前区间的右端点作为覆盖点 | 按区间右端点升序 | 最少的监控点布置 |
| 无重叠区间(删最少) | 每次选「右边界最小」的区间,右边界越小,留给后面区间的 "空间" 就越大 | 按区间右端点升序 | 删最少任务使无冲突 |
核心思路(四步法,通用所有区间问题)
- 特判:区间数组为空,直接返回0/空数组;
- 排序:按问题对应的规则排序(如最多不重叠→右端点升序);
- 初始化 :记录上一个区间的状态(如
lastEnd = 排序后第一个区间的右端点,count = 1); - 线性遍历 :从第二个区间开始,逐个判断当前区间与上一个最优区间的关系:
- 符合条件(如不重叠):更新上一个区间状态,计数+1;
- 不符合条件:跳过当前区间;
- 返回结果:计数/合并后的区间数组。
高频经典题
- LeetCode 435. 无重叠区间(核心模板,选最多不重叠区间的变种);
- LeetCode 56. 区间合并(基础模板,按左端点排序,合并重叠区间);
- LeetCode 452. 用最少数量的箭引爆气球(等价于最少点覆盖区间,按右端点排序);
- LeetCode 253. 会议室II(进阶,堆辅助贪心,按开始时间排序+小根堆存结束时间)。
避坑点
- 排序规则选错:如选最多不重叠区间时按左端点排序,导致局部最优错误,全局解非最优;
- 区间边界判断错误 :混淆"左闭右开"和"左闭右闭"(如
interval[i][0] > lastEnd(不重叠) vsinterval[i][0] >= lastEnd(相邻也算不重叠),按题目要求判断); - 特判遗漏:区间数组为空或长度为1时,直接返回0/1/原数组,避免遍历越界;
- 合并区间时未更新右端点 :如合并
[1,3]和[2,5],需更新为[1,5],而非保留[1,3]。
✅ 技巧二:数值分配/选择问题(★★★★ 占比20%+,基础必熟)
这类问题是贪心的入门场景 ,核心是"将有限的资源按匹配规则 分配给需求方,使匹配数/满意度最大",或"按面值/大小规则选择数值,使数量最少/和最大"。
核心子场景&贪心策略
| 问题类型 | 核心贪心策略 | 排序规则 | 经典题目 |
|---|---|---|---|
| 分发饼干 | 用最小的能满足孩子的饼干满足孩子 | 孩子胃口升序、饼干尺寸升序 | LeetCode 455 |
| 分糖果 | 两次遍历:左到右(比左边多则+1)→ 右到左(比右边多则取最大值) | 无排序,线性遍历 | LeetCode 135 |
| 找零钱(规范面值) | 每次选最大面值的硬币 | 面值降序排序 | 经典贪心题 LeetCode 860(322、518不能使用,属于背包问题) |
| 买卖股票(多次交易) | 低买高卖,只要涨就赚(累加所有正差值) | 无排序,线性遍历 | LeetCode 122 |
核心思路(以分发饼干为例)
- 排序 :将需求方(孩子)和资源方(饼干)按升序排序;
- 双指针:用两个指针分别指向当前孩子和当前饼干;
- 匹配:饼干能满足孩子→匹配成功,两个指针都后移;饼干不能满足→换更大的饼干,饼干指针后移;
- 返回匹配数。
两个方向的贪心策略
135.分糖果这道题就告诉我们:贪心算法两头兼顾很容易顾此失彼。所以每次贪心只能贪一个方向上的,再将此方向上的结果与上一个方向进行叠加。
这题的贪心逻辑在于:
- 首先正序遍历,处理右孩子较大的情况
- 然后倒序遍历,处理左孩子较大的情况
解题代码:
java
class Solution {
public int candy(int[] ratings) {
int[] result = new int[ratings.length];
for(int i = 0; i < ratings.length; i++) result[i] = 1;
for(int i = 0; i < ratings.length; i++) {
if(i + 1 < ratings.length && ratings[i + 1] > ratings[i]) {
result[i + 1] = result[i] + 1;
}
}
for(int i = ratings.length - 1; i >= 0; i--) {
if(i - 1 >= 0 && ratings[i - 1] > ratings[i]) {
result[i - 1] = Math.max(result[i] + 1,result[i - 1]);
}
}
int count = 0;
for(int num : result) count += num;
return count;
}
}
核心思路(以买卖股票为例)
-
121. 买卖股票的最佳时机(只能买卖一次)
核心规则:仅一次买入+一次卖出,卖出必在买入后;
贪心策略:遍历中实时维护「当前左侧最低股价」,对每个股价计算「当前价-最低股价」的利润,持续刷新全局最大利润 (负利润直接舍弃,不交易)。
本质:抓「全局唯一的最优波峰-波谷差」,局部最优是「每个卖出价匹配左侧最低买入价」。
-
122. 买卖股票的最佳时机 II(可无限次买卖)
核心规则:卖出后可再次买入,不可同时持股;
贪心策略:遍历计算「相邻两天的股价差值」,只累加所有正差值,负差值直接舍弃 。
本质:抓「所有小涨幅」,利用「连续上涨总利润=每日正涨幅累加」的特性,局部最优是「赚每一次能赚的小利润」。
| 题目编号 | 核心交易规则 | 贪心核心思路 | 核心维护变量 | 局部最优→全局最优的体现 |
|---|---|---|---|---|
| 121 | 只能买卖一次 | 找左侧最低股价,算单次最大利润 | 最低股价min_price、最大利润max_profit |
每个卖出价的最优利润 → 全局单次最大利润 |
| 122 | 可无限次买卖 | 累加所有相邻正涨幅 | 总利润total_profit |
每一次的小正利润 → 全局累加最大利润 |
从买卖股票理解贪心的性质:无后效性
要理解两道题中局部最优的无后效性 ,先记住这个通俗核心定义 :
在遍历做贪心决策时,当下选的局部最优方案,只会给后续步骤保留「更优的决策基础」,不会让后续失去找到全局最优的可能;且后续的最优决策,完全不用回头推翻/修正前面的选择,只需要基于前面决策留下的「最新状态」即可。
简单说就是:前面的选择不拖后腿、不埋坑,后面的决策不用管过去,只看现在。
结合121、122题的贪心策略,分别拆解无后效性的具体体现(每道题先回顾局部最优动作,再讲无后效性,搭配例子更直观),这是最易理解的方式。
两道题的遍历都是从左到右单向进行,股价的时间顺序是固定的(只能先买后卖),这是无后效性的基础,所有决策都符合时间逻辑,不会出现"回头操作"。
👉121题(只能买卖一次)的无后效性
遍历到第i天,核心只做一个局部决策:更新「当前左侧所有天的最低股价min_price」 (取min(原min_price, prices[i]))。
这个动作的本质是:记录到目前为止,最划算的买入点,为后续所有天的"卖出决策"提供最优基础。
这个"更新min_price"的局部选择,只会让后续的决策基础更好,绝不会更差 ,且后续天的决策完全不用管"之前的min_price是多少",只需要用最新的min_price即可。
👉122题(可无限次买卖)的无后效性
遍历到第i天,核心只做一个局部决策:计算相邻差值diff=prices[i]-prices[i-1],若diff>0则累加,否则舍弃 。
这个动作的本质是:当天能赚小钱就赚,赚不到就不动,只做当下最划算的小决策。
这个"累加正diff/舍弃负diff"的局部选择,只和「第i-1天、第i天」这两天有关,和更早的天数完全无关;且前面的所有累加/舍弃操作,不会影响后面任意两天的diff计算和决策。
简单说:122题的每一个局部决策都是独立的,前面的决策只是"把赚的钱加起来",不会改变后面股价的差值,也不会限制后面的决策------后面该累加还是该舍弃,只看自己相邻两天的股价,不用管前面赚了多少、舍了多少。
前面所有的"舍弃/累加",都没改变后面任意两天的diff,后续该怎么决策还是怎么决策,最终累加的7就是全局最优------这就是无后效性的直观体现。
虽然两道题的局部最优动作不同,但无后效性的核心来源是一致的,也是贪心能解这两道题的关键:
- 局部决策只保留/积累「对后续有用的信息」 :
- 121题只保留"最新最低股价",更早的股价信息没用,直接舍弃;
- 122题甚至不用保留历史信息,只累加总利润,每一步决策独立;
- 局部决策不产生「负面约束」 :
不会因为前面选了局部最优,就让后续的可选范围变小、或能得到的利润变低,反而要么让后续基础更好(121),要么对后续完全无影响(122); - 后续决策的「输入条件」是固定/最优的 :
- 121题后续决策的输入是"最新min_price+当前股价",是最优输入;
- 122题后续决策的输入是"相邻两天的股价diff",是固定输入,不受前面影响。
- 121题:越更最低股价,后续卖的利润空间越大,前面的选择只铺路、不设限;
- 122题:赚小钱的决策互不干涉,前面赚或不赚,后面该赚还是能赚;
- 共性:前面的局部最优,只会让全局最优更易实现,绝不会让后续找不到全局最优。
避坑点
- 找零钱的贪心误用 :仅当面值为规范面值(如人民币、美元)时可用贪心,非规范面值必须用DP;
- 分糖果的单次遍历错误:仅左到右遍历会导致"右边比左边大但糖果更少"的情况,必须两次遍历;
- 双指针边界错误 :遍历结束条件是"孩子指针<孩子数 且 饼干指针<饼干数",避免越界;
- 买卖股票的贪心理解错误:多次交易的贪心不是"找最低点买最高点卖",而是累加所有正差值(等价于多次低买高卖)。
✅ 技巧三:序列构造/优化问题(★★★★ 占比15%+,中等题核心)
这类问题要求构造符合特定规则的序列 ,或优化序列的某个指标 (如长度、最大和),核心是通过贪心选择逐步构造序列,或通过贪心+二分优化时间复杂度。
核心子场景&贪心策略
| 问题类型 | 核心贪心策略 | 时间复杂度 | 经典题目 |
|---|---|---|---|
| 摆动序列 | 记录当前序列的趋势(上升/下降),仅当趋势变化时计数 | O(n) | LeetCode 376 |
| 最长递增子序列(LIS) | 贪心+二分:维护tails数组,尽可能用小值替换tails中的元素 | O(nlogn)(比DP的O(n²)优) | LeetCode 300 |
| 重构字符串 | 贪心选出现次数最多的字符,避免相邻重复 | O(nlog26)(堆辅助) | LeetCode 767 |
| 最大数 | 按两个数拼接后的大小排序,构造最大数 | O(nlogn)(自定义排序) | LeetCode 179 |
贪心策略总结:
- 摆动序列:只保留序列中的「波峰」和「波谷」,跳过中间连续上升 / 下降的冗余元素
- 最长递增子序列:纯贪心(比如 "每次选比当前大的最小元素")无法直接得到结果,但优化后的贪心逻辑满足「局部最优推全局最优」
- 核心观察:更长的递增子序列,依赖于 "前面的元素尽可能小"。比如,长度为 3 的 LIS,若末尾元素是 7 而非 10,后续能接的元素(如 8、9、18)会更多,更有可能形成更长的序列。
- 局部最优:为每个长度的 LIS 维护「最小可能的末尾元素」(比如长度 3 的 LIS,末尾越小越好);
- 全局最优:所有局部最优的 "最小末尾" 累积,最终能找到最长的 LIS 长度(因为更小的末尾给后续留了更多空间)。
核心思路(以LIS贪心+二分为例)
- 初始化 :定义tails数组,存储长度为i+1的递增子序列的最小末尾元素;
- 线性遍历 :对每个数字num:
- num>tails最后一个元素→直接加入tails(子序列长度+1);
- 否则→用二分找到tails中第一个≥num的位置,替换为num(维护最小末尾,为后续更长序列做准备);
- 返回tails的长度(即LIS的长度)。
代码:
java
class Solution {
public int lengthOfLIS(int[] nums) {
List<Integer> result = new ArrayList<>();
result.add(nums[0]);
for(int i = 1; i < nums.length; i++) {
int last = result.get(result.size() - 1);
if(nums[i] > last) result.add(nums[i]);
else if(nums[i] < last){
result.set(findFirst(result, nums[i]), nums[i]);
}
}
return result.size();
}
public int findFirst(List<Integer> list,int target) {
int left = 0;
int right = list.size() - 1;
while(left <= right) {
int middle = (left + right) / 2;
if(list.get(middle) == target) return middle;
else if(list.get(middle) > target) right = middle - 1;
else left = middle + 1;
}
return left;
}
}
避坑点
- LIS的贪心理解错误 :tails数组不是实际的递增子序列,只是用于计算长度的辅助数组;
- 最大数的排序规则错误 :易错写为"按数字大小降序",正确规则是
(a+b).compareTo(b+a) > 0(拼接后更大的排前面); - 重构字符串的边界判断 :出现次数最多的字符数超过(n+1)/2时,无法重构,直接返回空字符串;
- 摆动序列的趋势判断错误:忽略"平坡"(连续相同数字),需跳过平坡,仅判断上升/下降趋势。
✅ 技巧四:堆辅助贪心(★★★ 占比15%+,后端高频)
当贪心的局部最优需要动态更新 (如遍历过程中,局部最优解会变化,需实时选最值),单纯的排序+线性遍历无法满足,此时需要优先队列(堆) 辅助,动态维护局部最优。
核心原理
- 堆的特性:小根堆(默认)可快速获取最小值,大根堆可快速获取最大值;
- 核心思路:排序+堆→ 先按特定规则排序,再用堆动态存储需要判断的元素,每次从堆中取局部最优解;
- 后端场景对应:任务调度(动态选最早结束的任务)、资源调度(动态选优先级最高的请求)、高频统计(动态选前K个高频元素)。
核心子场景&贪心策略
| 问题类型 | 堆的类型 | 核心思路 | 经典题目 |
|---|---|---|---|
| 任务调度器 | 小根堆 | 按任务出现次数排序,堆存剩余次数,动态选冷却后可执行的任务 | LeetCode 621 |
| 前K个高频元素 | 小根堆 | 哈希表统计次数,堆存前K个高频,超过K则弹出最小值 | LeetCode 347 |
| 会议室II | 小根堆 | 按会议开始时间排序,堆存会议结束时间,动态判断是否需要新会议室 | LeetCode 253 |
| 最大滑动窗口 | 大根堆 | 堆存窗口内元素的索引+值,动态移除窗口外的元素 | LeetCode 239(也可用单调队列) |
核心思路(以任务调度器为例)
- 统计次数:用数组/哈希表统计每个任务的出现次数;
- 大根堆建堆:将任务次数加入大根堆(优先执行次数多的任务,局部最优);
- 模拟执行 :用变量记录当前时间,循环处理堆中任务:
- 每次取最多n+1个任务(n为冷却时间),执行后次数-1,若次数>0则加入临时列表;
- 若临时列表非空→当前时间 += n+1(冷却);否则→当前时间 += 执行的任务数;
- 将临时列表中的任务重新加入堆;
- 返回当前时间。
核心思路(以前K个高频元素为例)
哈希表统计次数,堆存前K个高频,超过K则弹出最小值
java
class Solution {
public int[] topKFrequent(int[] nums, int k) {
PriorityQueue<Map.Entry<Integer,Integer>> maxHeap = new PriorityQueue<>((a, b) -> b.getValue() - a.getValue());
Map<Integer, Integer> map = new HashMap<>();
for(int num : nums) map.put(num,map.getOrDefault(num, 0) + 1);
for(Map.Entry<Integer,Integer> entry : map.entrySet()) maxHeap.offer(entry);
int[] result = new int[k];
for(int i = 0; i < k; i++) result[i] = maxHeap.poll().getKey();
return result;
}
}
核心思路(以最大滑动窗口为例)
堆存窗口内元素的索引+值,动态移除窗口外的元素
java
class Solution {
public int[] maxSlidingWindow(int[] nums, int k) {
PriorityQueue<int[]> maxHeap = new PriorityQueue<>((a, b) -> b[1] - a[1]);
for(int i = 0; i < k; i++) maxHeap.offer(new int[]{i, nums[i]});
int[] result = new int[nums.length - k + 1];
int pointer = 1;
result[0] = maxHeap.peek()[1];
for(int right = k; right < nums.length; right++) {
int left = right - k + 1;
maxHeap.offer(new int[]{right, nums[right]});
int pos = maxHeap.peek()[0];
while(pos < left || pos > right) {
maxHeap.poll();
pos = maxHeap.peek()[0];
}
result[pointer++] = maxHeap.peek()[1];
}
return result;
}
}
避坑点
- 堆的类型选错:如任务调度器需要大根堆(优先次数多的),易错建小根堆;
- 堆中存储的元素错误 :如滑动窗口最大值需存储索引(判断是否在窗口内),而非直接存储值;
- 冷却时间/窗口边界处理错误:如任务调度器中,临时列表为空时无需冷却,直接累加执行数;
- Java堆的默认规则忘记 :Java的
PriorityQueue默认是小根堆 ,建大根堆需自定义比较器:new PriorityQueue<>((o1,o2)->o2-o1)。
✅ 技巧五:经典贪心问题(★★★ 占比15%+,基础必刷)
这类问题是贪心的入门经典题 ,无复杂的辅助数据结构,仅需线性遍历+简单的局部最优判断,二刷需做到"秒解"。
核心子场景&贪心策略
| 问题类型 | 核心贪心策略 | 经典题目 | 后端场景对应 |
|---|---|---|---|
| 跳跃游戏 | 维护能到达的最远距离,遍历过程中更新,若能到达终点则返回true | LeetCode 55 | 接口限流(判断请求是否可达) |
| 跳跃游戏II | 维护当前能跳的边界 和下一步能跳的最远距离,边界到达时步数+1 | LeetCode 45 | 路径规划(最少步数) |
| 加油站 | 累加剩余油量,若总剩余≥0则必有解,遍历找起点 | LeetCode 134 | 汽车导航(油量规划) |
| 分发饮料 | 按需求升序分配,每次选最小的满足需求的饮料 | 经典题 | 资源分配 |
核心思路(以跳跃游戏为例)
- 初始化 :记录能到达的最远距离
maxReach = 0; - 线性遍历:遍历每个位置i,若i>maxReach→无法到达当前位置,返回false;
- 更新最远距离 :
maxReach = Math.max(maxReach, i + nums[i]); - 提前终止:若maxReach≥nums.length-1→能到达终点,返回true;
- 遍历结束:返回false。
核心思路(以跳跃游戏Ⅱ为例)
核心思路:
循环遍历数组,每次都维护当前跳的边界以及下一次跳的最远距离,如果当前i不在当前跳的边界之内则步数 + 1,同时更新跳的边界。如果下一跳已经可以到达重点,则可以提前退出循环。
代码如下:
java
class Solution {
public int jump(int[] nums) {
int count = 0, range = 0, max = 0;
for (int i = 0; i < nums.length; i++) {
if(i > range) {
count++;
range = max;
}
max = Math.max(max, i + nums[i]);
if(max >= nums.length - 1) {
if(i != nums.length - 1) count++;
break;
}
}
return count;
}
}
避坑点
- 跳跃游戏的提前终止遗漏:遍历过程中若已能到达终点,直接返回true,无需遍历到底,优化效率;
- 跳跃游戏II的步数更新时机错误 :仅当到达当前边界时,步数才+1,并更新边界为下一步的最远距离;
- 加油站的总剩余油量判断遗漏:若总剩余油量<0,直接返回-1,无需遍历找起点;
- 遍历边界错误 :如跳跃游戏的遍历结束条件是
i < nums.length,而非i < maxReach(避免漏判)。
通用代码模板
贪心算法没有通用的代码模板 (因场景差异大),但有通用的解题步骤 ,且每个高频场景都有固定的代码模板。二刷需先掌握通用解题步骤,再固化各场景的模板,做到"看到场景,提笔就写"。
✔ 贪心算法通用解题步骤(所有场景的基础,二刷必背)
- 判断是否适用贪心 :验证贪心选择性质 和最优子结构性质(核心,避免用错);
- 确定贪心策略:明确"每一步的局部最优是什么"(如选最早结束的区间、选最大面值的硬币);
- 数据预处理 :通常是排序(按贪心策略对应的规则),或统计次数(哈希表/数组);
- 落地贪心策略:用线性遍历、双指针、堆等方式,逐步做出局部最优选择,记录结果;
- 边界处理:特判空数据、单元素数据,避免越界或逻辑错误。
总结
贪心算法二刷的核心逻辑:
- 两大性质是前提 :必须同时满足贪心选择性质 和最优子结构性质,才可用贪心,否则用DP/回溯;
- 贪心策略是核心 :不同场景的贪心策略固定可固化,重点记"区间问题按目标排序、数值分配按升序匹配、堆辅助动态选最值";
- Java实现靠两招 :自定义排序 (Lambda表达式)和优先队列(堆),二刷必须练熟,提笔就写;
- 边界处理是细节:特判空数据、混淆边界条件(如≥/>)、提前终止遍历,这些细节决定代码是否能AC;
- 与DP的边界要清晰:贪心高效但适用场景有限,DP适用范围广但效率稍低,二刷要能快速判断问题该用哪种算法。
图论部分
图论是后端面试中等/难题核心考察点(占比40%+) ,也是算法体系中偏工程化 的模块,后端实际场景(如社交网络、路径规划、任务调度、分布式节点连通)均有直接应用。图论的核心是将实际问题抽象为图模型,再选择匹配的算法求解 ,二刷的核心目标不是死记算法代码,而是:吃透图的两种核心存储方式+快速抽象问题为图模型+精准匹配算法(按图的类型/问题目标)+ 掌握Java工程化实现要点 + 规避环检测/边界处理/溢出等核心坑。
图论算法具有强模板性 ,二刷的关键是固化模板+灵活调整 ,以下从核心知识点、高频解题技巧、固化思维模板、二刷策略+避坑指南四个维度展开,所有代码均为Java可直接运行的工程化模板,贴合后端面试要求。
核心知识点
图论的所有算法都建立在图的定义、存储、基本概念 之上,二刷首先要做到"图的基础不丢分",尤其是存储方式的选择 和算法与图类型的匹配------这是解图论题的第一步,也是最关键的一步。
✅ 图的基础定义与核心存储方式(Java实现必熟)
图由顶点集(V)和 边集(E)组成,记为G=(V,E),后端面试中稀疏图 (边数M ≈ N,N为顶点数)占90%以上,因此邻接表是核心存储方式,邻接矩阵仅用于小规模稠密图。
1. 两种核心存储方式(Java实现+适用场景)
| 存储方式 | Java实现方式 | 时间复杂度(增/删/查) | 空间复杂度 | 适用场景 | 后端应用 |
|---|---|---|---|---|---|
| 邻接矩阵 | int[][] graph:graph[i][j]表示顶点i到j的边权(无权图用0/1,无边用∞/0) |
O(1) | O(N²) | 稠密图(M≈N²)、小顶点数(N<1000) | 小规模网络拓扑、矩阵类图问题 |
| 邻接表 | 无权图:List<List<Integer>> adj;加权图:List<List<int[]>> adj(int[] = {邻接顶点, 边权}) |
O(度(i)) | O(N+M) | 稀疏图(M≈N)、大顶点数 | 社交网络、路径规划、任务调度(后端主流) |
关键注意:
-
无向图的邻接表/矩阵需要双向存储(i→j和j→i均加边);
-
加权图的邻接矩阵用
Integer.MAX_VALUE表示无边(避免溢出,计算时需判断); -
Java中邻接表的初始化模板(必记):
java// 无权无向图:n为顶点数(顶点编号0~n-1,后端常用从0开始) List<List<Integer>> adj = new ArrayList<>(); for (int i = 0; i < n; i++) adj.add(new ArrayList<>()); adj.get(u).add(v); adj.get(v).add(u); // 加边u-v // 加权有向图:int[] = {v, weight} List<List<int[]>> adj = new ArrayList<>(); for (int i = 0; i < n; i++) adj.add(new ArrayList<>()); adj.get(u).add(new int[]{v, w}); // 加边u→v,权值w
2. 边的存储方式(辅助算法:Kruskal)
部分算法(如Kruskal最小生成树)需要直接操作边集 ,Java中用int[][] edges存储,每一行{u, v, w}表示顶点u和v之间的边(无向),权值为w。
✅ 图的核心基本概念(二刷必辨,算法选择的依据)
图的类型由顶点/边的特性 决定,直接决定"能用什么算法、不能用什么算法",二刷看到图论题,第一步先明确图的类型。
| 概念 | 定义 | 关键影响 |
|---|---|---|
| 有向图/无向图 | 边有方向(u→v≠v→u)/边无方向(u-v=v-u) | 遍历需标记方向、最短路径算法的边处理 |
| 加权图/无权图 | 边带权值(如距离、成本)/边无权重(仅表示连通) | 无权图最短路径用BFS,加权图用Dijkstra等 |
| 连通图/非连通图 | 无向图中任意两顶点都有路径相通/存在顶点对无路径 | 最小生成树:连通图生成1棵,非连通图生成森林 |
| 强连通图 | 有向图中任意两顶点u和v,u→v和v→u都有路径 | 拓扑排序仅适用于非强连通的有向图 |
| 有环图/无环图 | 图中存在从某顶点出发回到自身的路径/无此类路径 | 有环图无法拓扑排序、DAG可求最长路径 |
| 有向无环图(DAG) | 有向且无环的图 | 后端核心场景:任务依赖、拓扑排序 |
| 度 | 无向图:顶点的边数;有向图:入度(入边数)/出度(出边数) | 拓扑排序、Kahn算法的核心依据 |
| 二分图 | 顶点可分为两个集合,所有边的两个顶点分属不同集合(无奇数长度的环) | 染色法判断、匹配问题 |
✅ 图论核心算法分类与适用场景(二刷秒杀,快速匹配算法)
后端面试图论算法仅考察以下6类核心算法,无偏题、怪题,二刷需将"问题目标+图的类型"与"算法"一一对应,形成条件反射。
| 问题目标 | 图的类型 | 首选算法 | 时间复杂度(Java优化版) | 核心考点 |
|---|---|---|---|---|
| 连通性判断、路径搜索、环检测 | 无权图(有/无向) | DFS/BFS | O(N+M) | 访问标记(visited/onPath)、递归/非递归实现 |
| 无权图最短路径(单源) | 无权图(有/无向) | BFS | O(N+M) | 层序遍历、距离数组初始化 |
| 加权正权图最短路径(单源) | 加权正权图(有/无向) | Dijkstra(堆优化) | O(MlogN) | 优先队列、距离数组更新、过时边处理 |
| 加权图最短路径(多源) | 任意加权图(无负权环) | Floyd-Warshall | O(N³) | 动态规划、邻接矩阵、三重循环 |
| 加权图负权检测/最短路径 | 加权负权图(有/无向) | Bellman-Ford/SPFA | O(NM)/O(M)(平均) | 负权环检测、松弛操作 |
| 无向加权连通图最小生成树 | 无向加权连通图 | Kruskal(边排序+并查集)/Prim(堆优化) | O(MlogM)/O(MlogN) | 并查集、边排序、优先队列 |
| DAG拓扑序、环检测、最长路径 | 有向图 | Kahn算法(入度表+队列)/DFS拓扑排序 | O(N+M) | 入度统计、环检测、拓扑序生成 |
| 连通分量统计、环检测(无向图) | 无向图 | 并查集(DSU) | O(Mα(N))(α≈1) | 路径压缩、按秩合并 |
| 二分图判断 | 无向图 | 染色法(BFS/DFS) | O(N+M) | 颜色数组、相邻顶点颜色不同 |
核心总结 :后端面试中高频算法TOP5:DFS/BFS、并查集、Dijkstra(堆优化)、Kruskal、Kahn拓扑排序,二刷需重点固化这5个模板。
✅ 图论的后端实际应用(二刷必思,应对面试官场景追问)
图论是后端最贴近业务的算法模块,面试官常问"这个算法在实际工作中怎么用",二刷每道题都要关联实际场景:
- DFS/BFS:社交网络的好友深度搜索、爬虫的页面遍历、服务器节点的连通性检测;
- 并查集:分布式系统的节点合并、朋友圈统计、网络连通性判断、K8s节点调度;
- Dijkstra:地图导航的最短路径、微服务调用的最小延迟路径、CDN节点的最优选择;
- 最小生成树(Kruskal/Prim):通信网络的最小布线成本、云计算的节点组网最优方案;
- 拓扑排序:任务调度的依赖解析(如Maven打包、Jenkins流水线)、工作流引擎的节点执行顺序;
- 二分图判断:推荐系统的用户-物品匹配、电商的订单-仓库分配。
✅ Java实现图论的核心要点(避坑基础)
- 顶点编号 :优先将顶点映射为0~n-1的整数(后端主流,避免字符串/自定义对象,简化数组/集合操作);
- 访问标记 :
- 无权图遍历:
boolean[] visited(标记是否访问过); - 有向图环检测:额外加
boolean[] onPath(标记是否在当前递归栈/路径中);
- 无权图遍历:
- 距离/权值初始化 :
- 最短路径:距离数组初始化为
Integer.MAX_VALUE,起点初始化为0; - 最小生成树:权值和初始化为0,避免int溢出用
long;
- 最短路径:距离数组初始化为
- 优先队列(堆) :Java默认小根堆,Dijkstra/Prim直接用,大根堆需自定义比较器;
- 溢出处理 :加权图的权值和/距离优先用
long(如Dijkstra的距离数组long[] dist),避免int溢出; - 递归限制 :DFS递归实现可能因图的深度过大 导致栈溢出(如N>10000),二刷需掌握DFS非递归实现(用栈模拟);
- 无边判断 :邻接矩阵用
graph[i][j] == Integer.MAX_VALUE,邻接表直接遍历邻接节点(无邻接则无边)。
技巧方法
图论解题的通用步骤(二刷必背):
- 问题抽象:将业务问题转化为图模型(确定顶点、边、边的权值/方向);
- 选择存储:根据图的类型(稠密/稀疏)选择邻接表/邻接矩阵/边集;
- 匹配算法:根据问题目标+图的类型选择核心算法(如正权单源最短路径→Dijkstra);
- 模板落地:套用对应算法模板,调整边界条件(如起点/终点、环检测);
- 结果还原:将图算法的结果转化为业务问题的答案。
以下按后端面试考察频率排序,拆解每个核心算法的核心原理、解题思路、经典题、避坑点,二刷需逐个固化。
✅ 技巧一:并查集(DSU)(★★★★★ 占比25%+,图论工具之王)
并查集是图论的基础工具 ,并非独立的图遍历算法,但连通性判断、环检测、Kruskal算法 均依赖它,后端面试中常作为子模块考察,也会单独考基础题(如朋友圈、岛屿数量)。
核心原理
并查集维护一个顶点的父节点集合,支持两个核心操作:
- find(x) :查找顶点x的根节点(路径压缩优化,将x直接指向根节点,降低后续查找复杂度);
- union(x, y):将顶点x和y的集合合并(按秩/大小合并优化,避免树退化为链表);
- 核心性质 :若x和y的根节点相同,则x和y连通;否则不连通。
核心优化(必须同时实现,否则复杂度极高)
- 路径压缩 :
find时将节点的父节点直接指向根节点,使树的高度为1; - 按秩/大小合并 :
union时将秩小/节点数少 的树合并到秩大/节点数多的树下,保证树的高度尽可能小。
核心思路(四步法,通用所有并查集问题)
- 初始化 :创建
parent数组(parent[i] = i,每个节点的父节点是自己)、rank/size数组(rank[i] = 1/size[i] = 1); - 实现find:带路径压缩的查找根节点;
- 实现union:按秩/大小合并两个节点;
- 业务逻辑 :根据问题调用
find/union,判断连通性/统计连通分量。
高频经典题
- LeetCode 547. 省份数量(连通分量统计,基础模板);
- LeetCode 684. 冗余连接(无向图环检测,Kruskal基础);
- LeetCode 130. 被围绕的区域(结合DFS/并查集);
- LeetCode 990. 等式方程的可满足性(抽象为图的连通性)。
避坑点
- 未实现优化:仅写基础find/union,未路径压缩+按秩合并,导致时间复杂度飙升(O(N)→O(α(N)));
- 顶点编号映射错误:问题中顶点是字符串/非0开始的整数,未映射为0~n-1,导致数组越界;
- 无向图环检测错误:Kruskal中选边时,若u和v已连通,加边则形成环,直接跳过;
- 连通分量统计错误 :需遍历所有顶点,用
Set存储根节点,Set的大小即为连通分量数。
✅ 技巧二:图的遍历(DFS/BFS)(★★★★★ 占比25%+,图论基础)
DFS(深度优先搜索)和BFS(广度优先搜索)是图论的入门算法 ,也是解决所有图问题的基础,后端面试中常考连通性、环检测、路径搜索、无权图最短路径。
核心原理与适用场景
| 遍历方式 | 核心实现 | 核心特性 | 适用场景 |
|---|---|---|---|
| DFS | 递归/栈模拟 | 先深后广,回溯探索 | 连通性检测、环检测(有向图)、路径搜索、全排列型图问题 |
| BFS | 队列(LinkedList) | 先广后深,层序遍历 | 无权图最短路径(单源)、层序遍历、二分图染色 |
核心思路(DFS/BFS通用步骤)
- 初始化 :构建邻接表(核心)、创建
visited数组(标记访问过的顶点); - 处理非连通图:遍历所有顶点,若未访问则调用DFS/BFS(避免漏解);
- 核心遍历 :
- DFS:标记当前顶点为已访问→遍历所有邻接顶点→未访问则递归/入栈;
- BFS:当前顶点入队→标记为已访问→出队遍历邻接顶点→未访问则入队并标记;
- 业务逻辑:在遍历中统计结果(如连通分量、环、最短距离)。
高频子场景:有向图环检测(DFS)
有向图环检测需要额外的onPath数组(标记是否在当前递归栈中),核心逻辑:
- 进入顶点u:
visited[u] = true,onPath[u] = true; - 遍历邻接顶点v:若
v未访问则递归DFS(v);若v已访问且onPath[v] = true,则存在环; - 离开顶点u:
onPath[u] = false(回溯)。
高频子场景:无权图最短路径(BFS)
BFS是无权图最短路径的唯一最优算法,核心逻辑:
- 初始化
dist数组(dist[i] = -1/∞,表示未到达),起点dist[s] = 0; - 起点入队,层序遍历→每遍历一层,距离+1→
dist[v] = dist[u] + 1; - 到达终点时直接返回
dist[t](BFS的层序特性保证第一个到达的是最短路径)。
高频经典题
- LeetCode 200. 岛屿数量(二维网格抽象为图,DFS/BFS基础);
- LeetCode 797. 所有可能的路径(DAG路径搜索,DFS基础);
- LeetCode 207. 课程表(有向图环检测,DFS/Kahn);
- LeetCode 1091. 二进制矩阵中的最短路径(无权图最短路径,BFS)。
避坑点
- 非连通图处理遗漏:仅从起点遍历,未遍历所有顶点,导致漏统计连通分量;
- 有向图环检测忘记onPath:仅用visited数组,无法区分"已访问过的顶点"和"当前路径中的顶点",导致环检测错误;
- BFS忘记标记访问:入队前未标记visited,导致顶点多次入队,超时/死循环;
- 二维网格抽象图错误 :将网格的
(i,j)映射为顶点时,未判断边界(i<0 || i>=n || j<0 || j>=m),导致数组越界; - DFS递归栈溢出 :图的深度过大(如N>10000),需改用非递归DFS(用Stack模拟)。
✅ 技巧三:最短路径算法(★★★★ 占比20%+,后端高频)
后端面试中最短路径仅考3类:无权图BFS、正权图Dijkstra(堆优化)、多源Floyd,Bellman-Ford/SPFA仅作了解(考频低,主要考负权环检测)。
子场景1:Dijkstra算法(堆优化)(正权图单源最短路径,核心)
核心原理
贪心策略 :每次选择当前距离起点最近的未访问顶点 ,以该顶点为中介,松弛其所有邻接顶点的距离,最终得到起点到所有顶点的最短路径。
堆优化 :用小根堆快速获取当前距离最近的顶点,将时间复杂度从O(N²)降至O(MlogN)(后端主流实现)。
核心思路(六步法)
- 初始化 :构建加权邻接表(
List<List<int[]>> adj)、dist数组(long[] dist,初始化为Long.MAX_VALUE,避免溢出)、visited数组(标记是否已确定最短路径); - 起点初始化 :
dist[start] = 0,小根堆入队(0, start)((距离, 顶点)); - 堆遍历:堆非空时,出队当前距离最近的顶点u;
- 跳过过时边 :若当前出队的距离>
dist[u],说明该边是过时的,直接跳过; - 松弛操作 :遍历u的所有邻接顶点v,若
dist[v] > dist[u] + w,则更新dist[v] = dist[u] + w,并将(dist[v], v)入队; - 结果返回 :
dist[end]即为起点到终点的最短路径,若dist[end]仍为最大值,则无法到达。
关键注意 :Dijkstra不能处理负权边(贪心策略会失效),也不能检测负权环。
子场景2:Floyd-Warshall算法(多源最短路径)
核心原理
动态规划思想 :dp[k][i][j]表示经过前k个顶点,i到j的最短路径,优化为二维数组dp[i][j],三重循环依次将每个顶点作为中介,更新所有顶点对的最短路径。
适用场景:顶点数少(N<1000)的多源最短路径,实现简单(三重循环)。
核心思路(三步法)
- 初始化 :构建邻接矩阵
graph,graph[i][j]为i到j的边权,graph[i][i] = 0,无边为Integer.MAX_VALUE; - 三重循环 :
k(中介顶点)→i(起点)→j(终点),松弛操作:graph[i][j] = min(graph[i][j], graph[i][k] + graph[k][j])(需判断溢出); - 结果返回 :
graph[i][j]即为i到j的最短路径。
高频经典题
- LeetCode 743. 网络延迟时间(Dijkstra堆优化,基础模板);
- LeetCode 1514. 概率最大的路径(Dijkstra变种,大根堆);
- LeetCode 329. 矩阵中的最长递增路径(DFS+记忆化,图的最短路径变种);
- LeetCode 1334. 阈值距离内邻居最少的城市(Floyd/Dijkstra)。
避坑点
- Dijkstra未处理过时边:堆中可能存在多个同一顶点的不同距离,出队时若距离大于当前dist[u],必须跳过,否则会更新错误的距离;
- 溢出未处理 :加权图的距离优先用
long,避免int溢出(如dist[u] + w超过Integer.MAX_VALUE); - Floyd三重循环顺序错误:必须先遍历中介顶点k,再遍历i和j,否则松弛操作无效;
- 负权边误用Dijkstra:看到负权边直接排除Dijkstra,改用Bellman-Ford/SPFA;
- 堆中存储顺序错误 :Dijkstra堆中存储
(距离, 顶点),而非(顶点, 距离),否则比较器失效。
✅ 技巧四:最小生成树(Kruskal/Prim)(★★★★ 占比15%+,后端工程化)
最小生成树(MST)针对无向加权连通图 ,目标是找到一棵包含所有顶点的树,使边权和最小 ,后端面试中Kruskal算法考频远高于Prim(实现简单,依赖并查集)。
子场景1:Kruskal算法(边贪心,核心)
核心原理
边贪心策略 :将所有边按权值升序排序 ,依次选边,若选的边不会使当前生成树形成环(并查集判断u和v是否连通),则加入生成树,直到选够n-1条边(n为顶点数)。
核心依赖 :并查集(环检测+连通性判断),适合稀疏图(M≈N)。
核心思路(五步曲)
- 初始化 :将边集按权值升序排序,初始化并查集,最小权值和
sum = 0,选边数count = 0; - 遍历边集 :按权值从小到大遍历每条边
(u, v, w); - 环检测:用并查集判断u和v是否连通,若不连通则执行下一步,否则跳过;
- 合并并累加 :
union(u, v),sum += w,count += 1; - 终止条件 :若
count == n-1,直接返回sum(提前终止,优化效率);遍历结束后若count < n-1,则图非连通,无MST。
子场景2:Prim算法(点贪心,堆优化)
核心原理
点贪心策略 :从任意顶点出发,每次选择连接生成树内顶点和生成树外顶点的权值最小的边 ,将外顶点加入生成树,直到所有顶点都加入。
堆优化 :用小根堆 快速获取权值最小的边,适合稠密图(M≈N²)。
高频经典题
- LeetCode 1584. 连接所有点的最小费用(Kruskal/Prim,基础模板);
- LeetCode 1135. 最低成本联通所有城市(Kruskal,无向图MST);
- LeetCode 934. 最短的桥(DFS+BFS,MST变种)。
避坑点
- Kruskal边排序错误 :必须按权值升序排序,否则选的边权和不是最小;
- 未处理非连通图:遍历结束后若选边数<n-1,说明图是森林,无MST,需返回-1/无穷大;
- Prim忘记标记已加入的顶点 :用
inMST数组标记,避免重复加入,导致环; - 权值和溢出 :最小生成树的权值和优先用
long,避免int溢出; - 有向图误用MST算法 :Kruskal/Prim仅适用于无向图,有向图无最小生成树概念。
✅ 技巧五:拓扑排序(Kahn/DFS)(★★★ 占比10%+,后端核心场景)
拓扑排序针对有向无环图(DAG) ,目标是生成一个顶点序列 ,使所有有向边u→v都满足u在序列中出现在v之前,后端面试中Kahn算法考频远高于DFS拓扑排序(实现简单,易检测环)。
子场景1:Kahn算法(入度表+队列,核心)
核心原理
入度贪心策略 :每次选择入度为0的顶点 加入拓扑序,然后删除该顶点的所有出边(即邻接顶点的入度-1),重复此过程直到队列为空。
环检测 :若最终生成的拓扑序长度<总顶点数,则图中有环,无法拓扑排序。
核心思路(六步法)
- 初始化 :构建邻接表、统计入度表
inDegree(inDegree[v]为v的入边数); - 初始化队列:将所有入度为0的顶点入队(LinkedList);
- 遍历队列:出队顶点u,加入拓扑序列表;
- 更新入度 :遍历u的所有邻接顶点v,
inDegree[v] -= 1,若inDegree[v] == 0,则入队; - 环检测:若拓扑序列表的大小<总顶点数,返回空列表(有环);
- 结果返回:拓扑序列表。
子场景2:DFS拓扑排序
核心原理
后序遍历+栈 :对DAG进行DFS后序遍历,将遍历完成的顶点入栈,最终栈中弹出的顺序即为拓扑序。
环检测 :与有向图环检测一致,用onPath数组。
高频经典题
- LeetCode 207. 课程表(拓扑排序环检测,基础模板);
- LeetCode 210. 课程表II(生成拓扑序,Kahn基础);
- LeetCode 444. 序列重建(拓扑排序验证唯一拓扑序);
- LeetCode 1203. 项目管理(拓扑排序+分组,进阶)。
避坑点
- Kahn忘记统计入度:入度表是Kahn算法的核心,构建邻接表时必须同时统计每个顶点的入度;
- 环检测遗漏 :拓扑排序的核心考点是环检测,必须判断拓扑序长度是否等于总顶点数;
- DAG的最长路径:Kahn算法可求DAG的最长路径(将边权取反,求最短路径,或初始化距离数组为0,松弛时取max);
- 多入度0顶点的处理:Kahn算法中入度0的顶点可任意入队,拓扑序不唯一,若题目要求唯一拓扑序,需按特定规则(如字典序)入队。
✅ 技巧六:二分图判断(染色法)(★★★ 占比5%+,基础必熟)
二分图判断是图论的基础小模块 ,后端面试中偶考,核心是染色法(BFS/DFS),无复杂优化。
核心原理
染色法 :用颜色数组color标记顶点颜色(0:未染色,1/2:两种不同颜色),遍历所有顶点,对未染色的顶点染色,然后对其所有邻接顶点染相反颜色 ,若发现相邻顶点颜色相同,则不是二分图。
核心思路(四步法)
- 初始化 :构建邻接表、颜色数组
color(初始化为0); - 处理非连通图:遍历所有顶点,若未染色则调用BFS/DFS染色;
- 核心染色 :
- BFS:当前顶点u染为1,入队→出队遍历邻接顶点v→若v未染色则染为3-u(相反颜色)并入队→若v已染色且颜色与u相同,返回false;
- DFS:当前顶点u染为c→遍历邻接顶点v→若v未染色则递归DFS(v, 3-c)→若v已染色且颜色≠3-c,返回false;
- 结果返回:所有顶点染色完成,返回true。
高频经典题
- LeetCode 785. 判断二分图(染色法基础模板);
- LeetCode 886. 可能的二分法(抽象为二分图判断)。
避坑点
- 非连通图处理遗漏:仅从起点染色,未遍历所有顶点,导致漏判;
- 颜色标记错误:用0/1标记颜色,相邻顶点染为1-0,易出现颜色冲突(推荐用1/2,3-c求相反颜色);
- 有向图误用染色法 :染色法仅适用于无向图,有向图无二分图概念。
通用代码模板
图论算法模板性极强 ,二刷的核心是固化以下核心模板 ,遇到题目只需微调边界条件和业务逻辑,无需重新推导。所有模板均为**Java 8+**实现,贴合后端面试代码规范(简洁、有注释、无冗余)。
✔ 模板1:并查集(DSU)(路径压缩+按秩合并,核心工具)
java
class UnionFind {
private int[] parent; // 父节点数组
private int[] rank; // 秩数组(按秩合并)
// 初始化:n为顶点数(0~n-1)
public UnionFind(int n) {
parent = new int[n];
rank = new int[n];
for (int i = 0; i < n; i++) {
parent[i] = i; // 初始父节点为自身
rank[i] = 1; // 初始秩为1
}
}
// 查找根节点:带路径压缩
public int find(int x) {
if (parent[x] != x) {
parent[x] = find(parent[x]); // 路径压缩:x直接指向根节点
}
return parent[x];
}
// 合并两个节点:按秩合并
public void union(int x, int y) {
int rootX = find(x);
int rootY = find(y);
if (rootX == rootY) return; // 已连通,无需合并
// 秩小的合并到秩大的树下
if (rank[rootX] < rank[rootY]) {
parent[rootX] = rootY;
} else {
parent[rootY] = rootX;
if (rank[rootX] == rank[rootY]) {
rank[rootX]++; // 秩相等,合并后秩+1
}
}
}
// 判断两个节点是否连通
public boolean isConnected(int x, int y) {
return find(x) == find(y);
}
}
✔ 模板2:图的遍历(DFS递归+非递归,BFS,含环检测)
java
class GraphTraversal {
// ****************** DFS递归(有向图+环检测)******************
private boolean hasCycle; // 是否有环
private boolean[] visited; // 全局访问标记
private boolean[] onPath; // 当前递归栈标记
public boolean dfsCycle(List<List<Integer>> adj, int n) {
hasCycle = false;
visited = new boolean[n];
onPath = new boolean[n];
// 处理非连通图
for (int i = 0; i < n; i++) {
if (!visited[i]) dfs(adj, i);
}
return hasCycle;
}
private void dfs(List<List<Integer>> adj, int u) {
if (onPath[u]) hasCycle = true; // 发现环
if (visited[u] || hasCycle) return; // 剪枝
visited[u] = true;
onPath[u] = true;
// 遍历邻接顶点
for (int v : adj.get(u)) {
dfs(adj, v);
}
onPath[u] = false; // 回溯
}
// ****************** BFS(无权图最短路径)******************
// 求起点start到终点end的最短路径,无权图,n为顶点数
public int bfsShortestPath(List<List<Integer>> adj, int n, int start, int end) {
if (start == end) return 0;
int[] dist = new int[n]; // 距离数组
Arrays.fill(dist, -1); // -1表示未到达
Queue<Integer> q = new LinkedList<>();
dist[start] = 0;
q.offer(start);
while (!q.isEmpty()) {
int u = q.poll();
for (int v : adj.get(u)) {
if (dist[v] == -1) {
dist[v] = dist[u] + 1;
if (v == end) return dist[v]; // 提前终止
q.offer(v);
}
}
}
return -1; // 无法到达
}
}
✔ 模板3:Dijkstra(堆优化,正权图单源最短路径,后端核心)
java
class Dijkstra {
// 加权邻接表:adj.get(u) = {{v1, w1}, {v2, w2}},n为顶点数,start为起点
// 返回start到所有顶点的最短距离数组(long避免溢出)
public long[] dijkstra(List<List<int[]>> adj, int n, int start) {
long[] dist = new long[n];
Arrays.fill(dist, Long.MAX_VALUE);
dist[start] = 0;
// 小根堆:(距离, 顶点),默认按距离升序
PriorityQueue<long[]> pq = new PriorityQueue<>(Comparator.comparingLong(a -> a[0]));
pq.offer(new long[]{0, start});
while (!pq.isEmpty()) {
long[] cur = pq.poll();
long d = cur[0];
int u = (int) cur[1];
if (d > dist[u]) continue; // 跳过过时边,核心优化
// 松弛操作
for (int[] edge : adj.get(u)) {
int v = edge[0];
int w = edge[1];
if (dist[v] > dist[u] + w) {
dist[v] = dist[u] + w;
pq.offer(new long[]{dist[v], v});
}
}
}
return dist;
}
// 示例:求start到end的最短距离,无法到达返回-1
public int shortestPath(List<List<int[]>> adj, int n, int start, int end) {
long[] dist = dijkstra(adj, n, start);
return dist[end] == Long.MAX_VALUE ? -1 : (int) dist[end];
}
}
✔ 模板4:Kruskal(最小生成树,边贪心+并查集,后端高频)
java
class Kruskal {
// 边集:edges = {{u0, v0, w0}, {u1, v1, w1}},n为顶点数
// 返回最小生成树的权值和,非连通图返回-1
public long kruskal(int[][] edges, int n) {
// 1. 边按权值升序排序
Arrays.sort(edges, Comparator.comparingInt(a -> a[2]));
UnionFind uf = new UnionFind(n);
long sum = 0; // 权值和,long避免溢出
int count = 0; // 已选边数
// 2. 遍历边集
for (int[] edge : edges) {
int u = edge[0];
int v = edge[1];
int w = edge[2];
if (!uf.isConnected(u, v)) { // 无环
uf.union(u, v);
sum += w;
count++;
if (count == n - 1) break; // 选够n-1条边,提前终止
}
}
// 3. 检测是否连通
return count == n - 1 ? sum : -1;
}
// 并查集内部类(复用模板1)
class UnionFind {
private int[] parent;
private int[] rank;
public UnionFind(int n) {
parent = new int[n];
rank = new int[n];
for (int i = 0; i < n; i++) {
parent[i] = i;
rank[i] = 1;
}
}
public int find(int x) {
if (parent[x] != x) parent[x] = find(parent[x]);
return parent[x];
}
public void union(int x, int y) {
int rootX = find(x);
int rootY = find(y);
if (rootX == rootY) return;
if (rank[rootX] < rank[rootY]) parent[rootX] = rootY;
else {
parent[rootY] = rootX;
if (rank[rootX] == rank[rootY]) rank[rootX]++;
}
}
public boolean isConnected(int x, int y) {
return find(x) == find(y);
}
}
}
✔ 模板5:Kahn算法(拓扑排序+环检测,后端任务调度核心)
java
class Kahn {
// 邻接表,n为顶点数
// 返回拓扑序列表,有环返回空列表
public List<Integer> topologicalSort(List<List<Integer>> adj, int n) {
List<Integer> res = new ArrayList<>();
int[] inDegree = new int[n]; // 入度表
// 1. 统计入度
for (int u = 0; u < n; u++) {
for (int v : adj.get(u)) {
inDegree[v]++;
}
}
// 2. 入度为0的顶点入队
Queue<Integer> q = new LinkedList<>();
for (int i = 0; i < n; i++) {
if (inDegree[i] == 0) {
q.offer(i);
}
}
// 3. 遍历队列
while (!q.isEmpty()) {
int u = q.poll();
res.add(u);
// 更新邻接顶点入度
for (int v : adj.get(u)) {
inDegree[v]--;
if (inDegree[v] == 0) {
q.offer(v);
}
}
}
// 4. 环检测:拓扑序长度<顶点数,有环
return res.size() == n ? res : new ArrayList<>();
}
// 示例:检测是否有环(课程表问题)
public boolean canFinish(int numCourses, int[][] prerequisites) {
// 构建邻接表
List<List<Integer>> adj = new ArrayList<>();
for (int i = 0; i < numCourses; i++) adj.add(new ArrayList<>());
for (int[] p : prerequisites) {
int u = p[1], v = p[0];
adj.get(u).add(v); // 先修u,再修v:u→v
}
return !topologicalSort(adj, numCourses).isEmpty();
}
}
✔ 模板6:染色法(BFS,二分图判断)
java
class BipartiteCheck {
// 邻接表,n为顶点数,判断是否为二分图
public boolean isBipartite(List<List<Integer>> adj, int n) {
int[] color = new int[n]; // 0:未染色,1/2:两种颜色
Arrays.fill(color, 0);
Queue<Integer> q = new LinkedList<>();
// 处理非连通图
for (int i = 0; i < n; i++) {
if (color[i] != 0) continue;
// 初始化:染为1
color[i] = 1;
q.offer(i);
while (!q.isEmpty()) {
int u = q.poll();
for (int v : adj.get(u)) {
if (color[v] == 0) {
color[v] = 3 - color[u]; // 染相反颜色
q.offer(v);
} else if (color[v] == color[u]) {
return false; // 颜色相同,不是二分图
}
}
}
}
return true;
}
}
总结
图论二刷的核心逻辑(Java版+后端视角):
- 模型抽象是前提:将业务问题转化为"顶点+边"的图模型,是解所有图论题的第一步,也是最关键的一步;
- 存储选择是基础 :后端面试中邻接表是主流,必须熟练掌握其Java实现和初始化模板;
- 算法匹配是核心:根据"图的类型(有向/无向、加权/无权、连通/非连通)+ 问题目标(连通性、最短路径、MST、拓扑序)"快速匹配算法,形成条件反射;
- 模板固化是关键 :图论算法模板性极强,二刷需将并查集、DFS/BFS、Dijkstra、Kruskal、Kahn这5个核心模板闭卷手写,遇到题目只需微调;
- 工程细节是保障:处理溢出、过时边、非连通图、环检测等细节,是代码能AC的关键,也是后端面试官考察的工程能力。