【算法训练营 · 二刷总结篇】贪心算法、图论部分

文章目录

贪心算法部分

贪心算法是后端面试中等题优化核心、高频考点(占比40%+) ,本质是每一步都做出当前局部最优的选择,最终希望得到全局最优解 。它比动态规划(DP)更高效(时间复杂度通常O(n)或O(nlogn)),但适用场景有严格限制。二刷的核心目标不是"会写贪心代码",而是:吃透贪心的两大核心性质(判断适用场景)+ 快速选对贪心策略(选准"局部最优"角度)+ 结合排序/堆等数据结构落地 + 区分贪心与DP的适用边界,同时结合Java特性(自定义排序、优先队列、数组操作)写出高效易读的代码,规避"贪心策略选错、性质判断失误"的核心坑。

核心知识点

✅ 核心定义与本质

贪心算法 = 局部最优选择 + 逐步推导全局最优,核心思想:

  • 对问题的每个决策阶段,不考虑后续所有可能的情况,只做出当前看起来最优的选择(比如"选最早结束的区间""选最大面值的硬币");
  • 最终通过所有局部最优的累积,得到全局最优解(仅当问题满足贪心两大性质时成立);
  • 与DP的本质区别:DP记录所有子问题的解,通过子问题推导全局最优(考虑后续);贪心无记录,仅一步一步选局部最优(不考虑后续)

✅ 贪心的两大核心性质(必须同时满足,缺一不可)

这是判断一个问题能否用贪心解决的唯一标准,二刷每道贪心题都要先验证这两个性质,再写代码。

  1. 贪心选择性质

    全局最优解可以通过一系列局部最优解的选择得到 ,且每个局部最优选择的做出,无需依赖后续的决策结果(即"当下选最优,后续不后悔")。

    ✅ 举例:活动选择问题(选最多不重叠区间)→ 每次选最早结束的区间,后续仍能选最多的区间,符合贪心选择性质。

    ❌ 反例:找零钱(面值[1,3,4],凑6)→ 贪心选4+1+1(总3枚),实际最优3+3(总2枚),不满足贪心选择性质。

  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/回溯:

  1. 区间问题:最多不重叠区间、区间合并、用最少点覆盖所有区间、无重叠区间;
  2. 数值分配/选择:找零钱(规范面值)、分糖果、分发饼干、买卖股票的最佳时机(多次交易);
  3. 序列构造/优化:摆动序列、最长递增子序列(贪心+二分优化)、重构字符串;
  4. 堆辅助贪心:任务调度器、前K个高频元素、最大数;
  5. 图论相关:Dijkstra算法(最短路径)、Prim算法(最小生成树)、Kruskal算法(最小生成树);
  6. 后端场景:任务调度(按执行时间排序)、资源分配(按优先级分配)、接口限流(按请求频率贪心处理)。

✅ 时间&空间复杂度(二刷必懂,应对追问)

贪心算法本身的操作是线性的(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实现贪心的核心要点(避坑基础)

  1. ※自定义排序是核心※ :贪心的"局部最优"往往需要先对数据按特定规则排序 (如区间按右端点升序、数组按降序),Java中常用两种排序方式:
    • 基本类型数组:Arrays.sort(nums)(默认升序),降序用Arrays.sort(nums, Collections.reverseOrder())(需转包装类);
    • 自定义对象/二维数组:Arrays.sort(arr, (o1, o2) -> 比较逻辑)(Lambda表达式,二刷必熟);
  2. 优先队列(堆)的使用 :需要动态选局部最优 (如动态选执行时间最短的任务、最大的元素)时,用Java的PriorityQueue,核心是自定义比较器;
  3. 边界条件处理:贪心的局部最优易忽略边界(如区间问题的空数组、分糖果的单个孩子),需提前判断;
  4. 贪心策略的落地 :将"局部最优"转化为可执行的代码逻辑(如"选最早结束的区间"→ 记录上一个区间的结束位置,遍历判断当前区间是否重叠);
  5. 避免浮点数精度:贪心的数值计算优先用int/long,避免float/double(如比例问题可转整数比)。

技巧方法

贪心的解题技巧高度依赖场景 ,但高频场景的贪心策略固定且可固化 。以下按后端面试考察频率排序,每个场景拆解核心贪心策略、解题思路、经典题、避坑点,二刷需逐个固化。

✅ 技巧一:区间问题(★★★★★ 占比35%+,贪心第一核心)

区间问题是贪心算法最经典、考察最多 的场景,后端场景中对应"任务调度、会议安排、资源占用",核心是通过排序确定贪心策略,再线性遍历判断

核心原理
  • 贪心策略的选择由问题目标决定(这是区间问题的关键,二刷记死);
  • 所有区间问题的第一步:按特定规则排序无排序,无贪心);
  • 线性遍历:用一个变量记录上一个最优区间的状态(如结束位置、合并后的右端点),逐个判断当前区间是否符合条件。
高频子场景&核心贪心策略
区间问题类型 核心贪心策略 排序规则 后端场景对应
选最多不重叠区间 每次选最早结束的区间 按区间右端点升序 安排最多的会议/任务
区间合并 合并所有重叠/相邻区间,保留最小区间集 按区间左端点升序 合并重叠的资源占用段
用最少点覆盖所有区间 每次选当前区间的右端点作为覆盖点 按区间右端点升序 最少的监控点布置
无重叠区间(删最少) 每次选「右边界最小」的区间,右边界越小,留给后面区间的 "空间" 就越大 按区间右端点升序 删最少任务使无冲突
核心思路(四步法,通用所有区间问题)
  1. 特判:区间数组为空,直接返回0/空数组;
  2. 排序:按问题对应的规则排序(如最多不重叠→右端点升序);
  3. 初始化 :记录上一个区间的状态(如lastEnd = 排序后第一个区间的右端点count = 1);
  4. 线性遍历 :从第二个区间开始,逐个判断当前区间与上一个最优区间的关系:
    • 符合条件(如不重叠):更新上一个区间状态,计数+1;
    • 不符合条件:跳过当前区间;
  5. 返回结果:计数/合并后的区间数组。
高频经典题
  1. LeetCode 435. 无重叠区间(核心模板,选最多不重叠区间的变种);
  2. LeetCode 56. 区间合并(基础模板,按左端点排序,合并重叠区间);
  3. LeetCode 452. 用最少数量的箭引爆气球(等价于最少点覆盖区间,按右端点排序);
  4. LeetCode 253. 会议室II(进阶,堆辅助贪心,按开始时间排序+小根堆存结束时间)。
避坑点
  1. 排序规则选错:如选最多不重叠区间时按左端点排序,导致局部最优错误,全局解非最优;
  2. 区间边界判断错误 :混淆"左闭右开"和"左闭右闭"(如interval[i][0] > lastEnd(不重叠) vs interval[i][0] >= lastEnd(相邻也算不重叠),按题目要求判断);
  3. 特判遗漏:区间数组为空或长度为1时,直接返回0/1/原数组,避免遍历越界;
  4. 合并区间时未更新右端点 :如合并[1,3][2,5],需更新为[1,5],而非保留[1,3]

✅ 技巧二:数值分配/选择问题(★★★★ 占比20%+,基础必熟)

这类问题是贪心的入门场景 ,核心是"将有限的资源按匹配规则 分配给需求方,使匹配数/满意度最大",或"按面值/大小规则选择数值,使数量最少/和最大"。

核心子场景&贪心策略
问题类型 核心贪心策略 排序规则 经典题目
分发饼干 最小的能满足孩子的饼干满足孩子 孩子胃口升序、饼干尺寸升序 LeetCode 455
分糖果 两次遍历:左到右(比左边多则+1)→ 右到左(比右边多则取最大值) 无排序,线性遍历 LeetCode 135
找零钱(规范面值 每次选最大面值的硬币 面值降序排序 经典贪心题 LeetCode 860(322、518不能使用,属于背包问题)
买卖股票(多次交易) 低买高卖,只要涨就赚(累加所有正差值) 无排序,线性遍历 LeetCode 122
核心思路(以分发饼干为例)
  1. 排序 :将需求方(孩子)和资源方(饼干)按升序排序;
  2. 双指针:用两个指针分别指向当前孩子和当前饼干;
  3. 匹配:饼干能满足孩子→匹配成功,两个指针都后移;饼干不能满足→换更大的饼干,饼干指针后移;
  4. 返回匹配数
两个方向的贪心策略

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;
    }
}
核心思路(以买卖股票为例)
  1. 121. 买卖股票的最佳时机(只能买卖一次)

    核心规则:仅一次买入+一次卖出,卖出必在买入后;

    贪心策略:遍历中实时维护「当前左侧最低股价」,对每个股价计算「当前价-最低股价」的利润,持续刷新全局最大利润 (负利润直接舍弃,不交易)。

    本质:抓「全局唯一的最优波峰-波谷差」,局部最优是「每个卖出价匹配左侧最低买入价」。

  2. 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就是全局最优------这就是无后效性的直观体现。

虽然两道题的局部最优动作不同,但无后效性的核心来源是一致的,也是贪心能解这两道题的关键:

  1. 局部决策只保留/积累「对后续有用的信息」
    • 121题只保留"最新最低股价",更早的股价信息没用,直接舍弃;
    • 122题甚至不用保留历史信息,只累加总利润,每一步决策独立;
  2. 局部决策不产生「负面约束」
    不会因为前面选了局部最优,就让后续的可选范围变小、或能得到的利润变低,反而要么让后续基础更好(121),要么对后续完全无影响(122);
  3. 后续决策的「输入条件」是固定/最优的
    • 121题后续决策的输入是"最新min_price+当前股价",是最优输入;
    • 122题后续决策的输入是"相邻两天的股价diff",是固定输入,不受前面影响。
  • 121题:越更最低股价,后续卖的利润空间越大,前面的选择只铺路、不设限
  • 122题:赚小钱的决策互不干涉,前面赚或不赚,后面该赚还是能赚
  • 共性:前面的局部最优,只会让全局最优更易实现,绝不会让后续找不到全局最优
避坑点
  1. 找零钱的贪心误用 :仅当面值为规范面值(如人民币、美元)时可用贪心,非规范面值必须用DP;
  2. 分糖果的单次遍历错误:仅左到右遍历会导致"右边比左边大但糖果更少"的情况,必须两次遍历;
  3. 双指针边界错误 :遍历结束条件是"孩子指针<孩子数 饼干指针<饼干数",避免越界;
  4. 买卖股票的贪心理解错误:多次交易的贪心不是"找最低点买最高点卖",而是累加所有正差值(等价于多次低买高卖)。

✅ 技巧三:序列构造/优化问题(★★★★ 占比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贪心+二分为例)
  1. 初始化 :定义tails数组,存储长度为i+1的递增子序列的最小末尾元素
  2. 线性遍历 :对每个数字num:
    • num>tails最后一个元素→直接加入tails(子序列长度+1);
    • 否则→用二分找到tails中第一个≥num的位置,替换为num(维护最小末尾,为后续更长序列做准备);
  3. 返回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;
    }
}
避坑点
  1. LIS的贪心理解错误 :tails数组不是实际的递增子序列,只是用于计算长度的辅助数组;
  2. 最大数的排序规则错误 :易错写为"按数字大小降序",正确规则是(a+b).compareTo(b+a) > 0(拼接后更大的排前面);
  3. 重构字符串的边界判断 :出现次数最多的字符数超过(n+1)/2时,无法重构,直接返回空字符串;
  4. 摆动序列的趋势判断错误:忽略"平坡"(连续相同数字),需跳过平坡,仅判断上升/下降趋势。

✅ 技巧四:堆辅助贪心(★★★ 占比15%+,后端高频)

当贪心的局部最优需要动态更新 (如遍历过程中,局部最优解会变化,需实时选最值),单纯的排序+线性遍历无法满足,此时需要优先队列(堆) 辅助,动态维护局部最优。

核心原理
  • 堆的特性:小根堆(默认)可快速获取最小值,大根堆可快速获取最大值;
  • 核心思路:排序+堆→ 先按特定规则排序,再用堆动态存储需要判断的元素,每次从堆中取局部最优解;
  • 后端场景对应:任务调度(动态选最早结束的任务)、资源调度(动态选优先级最高的请求)、高频统计(动态选前K个高频元素)。
核心子场景&贪心策略
问题类型 堆的类型 核心思路 经典题目
任务调度器 小根堆 按任务出现次数排序,堆存剩余次数,动态选冷却后可执行的任务 LeetCode 621
前K个高频元素 小根堆 哈希表统计次数,堆存前K个高频,超过K则弹出最小值 LeetCode 347
会议室II 小根堆 按会议开始时间排序,堆存会议结束时间,动态判断是否需要新会议室 LeetCode 253
最大滑动窗口 大根堆 堆存窗口内元素的索引+值,动态移除窗口外的元素 LeetCode 239(也可用单调队列)
核心思路(以任务调度器为例)
  1. 统计次数:用数组/哈希表统计每个任务的出现次数;
  2. 大根堆建堆:将任务次数加入大根堆(优先执行次数多的任务,局部最优);
  3. 模拟执行 :用变量记录当前时间,循环处理堆中任务:
    • 每次取最多n+1个任务(n为冷却时间),执行后次数-1,若次数>0则加入临时列表;
    • 若临时列表非空→当前时间 += n+1(冷却);否则→当前时间 += 执行的任务数;
    • 将临时列表中的任务重新加入堆;
  4. 返回当前时间
核心思路(以前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;
    }
}
避坑点
  1. 堆的类型选错:如任务调度器需要大根堆(优先次数多的),易错建小根堆;
  2. 堆中存储的元素错误 :如滑动窗口最大值需存储索引(判断是否在窗口内),而非直接存储值;
  3. 冷却时间/窗口边界处理错误:如任务调度器中,临时列表为空时无需冷却,直接累加执行数;
  4. Java堆的默认规则忘记 :Java的PriorityQueue默认是小根堆 ,建大根堆需自定义比较器:new PriorityQueue<>((o1,o2)->o2-o1)

✅ 技巧五:经典贪心问题(★★★ 占比15%+,基础必刷)

这类问题是贪心的入门经典题 ,无复杂的辅助数据结构,仅需线性遍历+简单的局部最优判断,二刷需做到"秒解"。

核心子场景&贪心策略
问题类型 核心贪心策略 经典题目 后端场景对应
跳跃游戏 维护能到达的最远距离,遍历过程中更新,若能到达终点则返回true LeetCode 55 接口限流(判断请求是否可达)
跳跃游戏II 维护当前能跳的边界下一步能跳的最远距离,边界到达时步数+1 LeetCode 45 路径规划(最少步数)
加油站 累加剩余油量,若总剩余≥0则必有解,遍历找起点 LeetCode 134 汽车导航(油量规划)
分发饮料 需求升序分配,每次选最小的满足需求的饮料 经典题 资源分配
核心思路(以跳跃游戏为例)
  1. 初始化 :记录能到达的最远距离maxReach = 0
  2. 线性遍历:遍历每个位置i,若i>maxReach→无法到达当前位置,返回false;
  3. 更新最远距离maxReach = Math.max(maxReach, i + nums[i])
  4. 提前终止:若maxReach≥nums.length-1→能到达终点,返回true;
  5. 遍历结束:返回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;
    }
}
避坑点
  1. 跳跃游戏的提前终止遗漏:遍历过程中若已能到达终点,直接返回true,无需遍历到底,优化效率;
  2. 跳跃游戏II的步数更新时机错误 :仅当到达当前边界时,步数才+1,并更新边界为下一步的最远距离;
  3. 加油站的总剩余油量判断遗漏:若总剩余油量<0,直接返回-1,无需遍历找起点;
  4. 遍历边界错误 :如跳跃游戏的遍历结束条件是i < nums.length,而非i < maxReach(避免漏判)。

通用代码模板

贪心算法没有通用的代码模板 (因场景差异大),但有通用的解题步骤 ,且每个高频场景都有固定的代码模板。二刷需先掌握通用解题步骤,再固化各场景的模板,做到"看到场景,提笔就写"。

✔ 贪心算法通用解题步骤(所有场景的基础,二刷必背)

  1. 判断是否适用贪心 :验证贪心选择性质最优子结构性质(核心,避免用错);
  2. 确定贪心策略:明确"每一步的局部最优是什么"(如选最早结束的区间、选最大面值的硬币);
  3. 数据预处理 :通常是排序(按贪心策略对应的规则),或统计次数(哈希表/数组);
  4. 落地贪心策略:用线性遍历、双指针、堆等方式,逐步做出局部最优选择,记录结果;
  5. 边界处理:特判空数据、单元素数据,避免越界或逻辑错误。

总结

贪心算法二刷的核心逻辑:

  1. 两大性质是前提 :必须同时满足贪心选择性质最优子结构性质,才可用贪心,否则用DP/回溯;
  2. 贪心策略是核心 :不同场景的贪心策略固定可固化,重点记"区间问题按目标排序、数值分配按升序匹配、堆辅助动态选最值";
  3. Java实现靠两招自定义排序 (Lambda表达式)和优先队列(堆),二刷必须练熟,提笔就写;
  4. 边界处理是细节:特判空数据、混淆边界条件(如≥/>)、提前终止遍历,这些细节决定代码是否能AC;
  5. 与DP的边界要清晰:贪心高效但适用场景有限,DP适用范围广但效率稍低,二刷要能快速判断问题该用哪种算法。

图论部分

图论是后端面试中等/难题核心考察点(占比40%+) ,也是算法体系中偏工程化 的模块,后端实际场景(如社交网络、路径规划、任务调度、分布式节点连通)均有直接应用。图论的核心是将实际问题抽象为图模型,再选择匹配的算法求解 ,二刷的核心目标不是死记算法代码,而是:吃透图的两种核心存储方式+快速抽象问题为图模型+精准匹配算法(按图的类型/问题目标)+ 掌握Java工程化实现要点 + 规避环检测/边界处理/溢出等核心坑

图论算法具有强模板性 ,二刷的关键是固化模板+灵活调整 ,以下从核心知识点、高频解题技巧、固化思维模板、二刷策略+避坑指南四个维度展开,所有代码均为Java可直接运行的工程化模板,贴合后端面试要求。

核心知识点

图论的所有算法都建立在图的定义、存储、基本概念 之上,二刷首先要做到"图的基础不丢分",尤其是存储方式的选择算法与图类型的匹配------这是解图论题的第一步,也是最关键的一步。

✅ 图的基础定义与核心存储方式(Java实现必熟)

图由顶点集(V) 边集(E)组成,记为G=(V,E),后端面试中稀疏图 (边数M ≈ N,N为顶点数)占90%以上,因此邻接表是核心存储方式,邻接矩阵仅用于小规模稠密图。

1. 两种核心存储方式(Java实现+适用场景)
存储方式 Java实现方式 时间复杂度(增/删/查) 空间复杂度 适用场景 后端应用
邻接矩阵 int[][] graphgraph[i][j]表示顶点i到j的边权(无权图用0/1,无边用∞/0) O(1) O(N²) 稠密图(M≈N²)、小顶点数(N<1000) 小规模网络拓扑、矩阵类图问题
邻接表 无权图:List<List<Integer>> adj;加权图:List<List<int[]>> adjint[] = {邻接顶点, 边权} 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个模板。

✅ 图论的后端实际应用(二刷必思,应对面试官场景追问)

图论是后端最贴近业务的算法模块,面试官常问"这个算法在实际工作中怎么用",二刷每道题都要关联实际场景:

  1. DFS/BFS:社交网络的好友深度搜索、爬虫的页面遍历、服务器节点的连通性检测;
  2. 并查集:分布式系统的节点合并、朋友圈统计、网络连通性判断、K8s节点调度;
  3. Dijkstra:地图导航的最短路径、微服务调用的最小延迟路径、CDN节点的最优选择;
  4. 最小生成树(Kruskal/Prim):通信网络的最小布线成本、云计算的节点组网最优方案;
  5. 拓扑排序:任务调度的依赖解析(如Maven打包、Jenkins流水线)、工作流引擎的节点执行顺序;
  6. 二分图判断:推荐系统的用户-物品匹配、电商的订单-仓库分配。

✅ Java实现图论的核心要点(避坑基础)

  1. 顶点编号 :优先将顶点映射为0~n-1的整数(后端主流,避免字符串/自定义对象,简化数组/集合操作);
  2. 访问标记
    • 无权图遍历:boolean[] visited(标记是否访问过);
    • 有向图环检测:额外加boolean[] onPath(标记是否在当前递归栈/路径中);
  3. 距离/权值初始化
    • 最短路径:距离数组初始化为Integer.MAX_VALUE,起点初始化为0;
    • 最小生成树:权值和初始化为0,避免int溢出用long
  4. 优先队列(堆) :Java默认小根堆,Dijkstra/Prim直接用,大根堆需自定义比较器;
  5. 溢出处理 :加权图的权值和/距离优先用long(如Dijkstra的距离数组long[] dist),避免int溢出;
  6. 递归限制 :DFS递归实现可能因图的深度过大 导致栈溢出(如N>10000),二刷需掌握DFS非递归实现(用栈模拟);
  7. 无边判断 :邻接矩阵用graph[i][j] == Integer.MAX_VALUE,邻接表直接遍历邻接节点(无邻接则无边)。

技巧方法

图论解题的通用步骤(二刷必背):

  1. 问题抽象:将业务问题转化为图模型(确定顶点、边、边的权值/方向);
  2. 选择存储:根据图的类型(稠密/稀疏)选择邻接表/邻接矩阵/边集;
  3. 匹配算法:根据问题目标+图的类型选择核心算法(如正权单源最短路径→Dijkstra);
  4. 模板落地:套用对应算法模板,调整边界条件(如起点/终点、环检测);
  5. 结果还原:将图算法的结果转化为业务问题的答案。

以下按后端面试考察频率排序,拆解每个核心算法的核心原理、解题思路、经典题、避坑点,二刷需逐个固化。

✅ 技巧一:并查集(DSU)(★★★★★ 占比25%+,图论工具之王)

并查集是图论的基础工具 ,并非独立的图遍历算法,但连通性判断、环检测、Kruskal算法 均依赖它,后端面试中常作为子模块考察,也会单独考基础题(如朋友圈、岛屿数量)。

核心原理

并查集维护一个顶点的父节点集合,支持两个核心操作:

  1. find(x) :查找顶点x的根节点(路径压缩优化,将x直接指向根节点,降低后续查找复杂度);
  2. union(x, y):将顶点x和y的集合合并(按秩/大小合并优化,避免树退化为链表);
  3. 核心性质 :若x和y的根节点相同,则x和y连通;否则不连通。
核心优化(必须同时实现,否则复杂度极高)
  1. 路径压缩find时将节点的父节点直接指向根节点,使树的高度为1;
  2. 按秩/大小合并union时将秩小/节点数少 的树合并到秩大/节点数多的树下,保证树的高度尽可能小。
核心思路(四步法,通用所有并查集问题)
  1. 初始化 :创建parent数组(parent[i] = i,每个节点的父节点是自己)、rank/size数组(rank[i] = 1/size[i] = 1);
  2. 实现find:带路径压缩的查找根节点;
  3. 实现union:按秩/大小合并两个节点;
  4. 业务逻辑 :根据问题调用find/union,判断连通性/统计连通分量。
高频经典题
  1. LeetCode 547. 省份数量(连通分量统计,基础模板);
  2. LeetCode 684. 冗余连接(无向图环检测,Kruskal基础);
  3. LeetCode 130. 被围绕的区域(结合DFS/并查集);
  4. LeetCode 990. 等式方程的可满足性(抽象为图的连通性)。
避坑点
  1. 未实现优化:仅写基础find/union,未路径压缩+按秩合并,导致时间复杂度飙升(O(N)→O(α(N)));
  2. 顶点编号映射错误:问题中顶点是字符串/非0开始的整数,未映射为0~n-1,导致数组越界;
  3. 无向图环检测错误:Kruskal中选边时,若u和v已连通,加边则形成环,直接跳过;
  4. 连通分量统计错误 :需遍历所有顶点,用Set存储根节点,Set的大小即为连通分量数。

✅ 技巧二:图的遍历(DFS/BFS)(★★★★★ 占比25%+,图论基础)

DFS(深度优先搜索)和BFS(广度优先搜索)是图论的入门算法 ,也是解决所有图问题的基础,后端面试中常考连通性、环检测、路径搜索、无权图最短路径

核心原理与适用场景
遍历方式 核心实现 核心特性 适用场景
DFS 递归/栈模拟 先深后广,回溯探索 连通性检测、环检测(有向图)、路径搜索、全排列型图问题
BFS 队列(LinkedList) 先广后深,层序遍历 无权图最短路径(单源)、层序遍历、二分图染色
核心思路(DFS/BFS通用步骤)
  1. 初始化 :构建邻接表(核心)、创建visited数组(标记访问过的顶点);
  2. 处理非连通图:遍历所有顶点,若未访问则调用DFS/BFS(避免漏解);
  3. 核心遍历
    • DFS:标记当前顶点为已访问→遍历所有邻接顶点→未访问则递归/入栈;
    • BFS:当前顶点入队→标记为已访问→出队遍历邻接顶点→未访问则入队并标记;
  4. 业务逻辑:在遍历中统计结果(如连通分量、环、最短距离)。
高频子场景:有向图环检测(DFS)

有向图环检测需要额外的onPath数组(标记是否在当前递归栈中),核心逻辑:

  • 进入顶点u:visited[u] = trueonPath[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的层序特性保证第一个到达的是最短路径)。
高频经典题
  1. LeetCode 200. 岛屿数量(二维网格抽象为图,DFS/BFS基础);
  2. LeetCode 797. 所有可能的路径(DAG路径搜索,DFS基础);
  3. LeetCode 207. 课程表(有向图环检测,DFS/Kahn);
  4. LeetCode 1091. 二进制矩阵中的最短路径(无权图最短路径,BFS)。
避坑点
  1. 非连通图处理遗漏:仅从起点遍历,未遍历所有顶点,导致漏统计连通分量;
  2. 有向图环检测忘记onPath:仅用visited数组,无法区分"已访问过的顶点"和"当前路径中的顶点",导致环检测错误;
  3. BFS忘记标记访问:入队前未标记visited,导致顶点多次入队,超时/死循环;
  4. 二维网格抽象图错误 :将网格的(i,j)映射为顶点时,未判断边界(i<0 || i>=n || j<0 || j>=m),导致数组越界;
  5. DFS递归栈溢出 :图的深度过大(如N>10000),需改用非递归DFS(用Stack模拟)。

✅ 技巧三:最短路径算法(★★★★ 占比20%+,后端高频)

后端面试中最短路径仅考3类:无权图BFS、正权图Dijkstra(堆优化)、多源Floyd,Bellman-Ford/SPFA仅作了解(考频低,主要考负权环检测)。

子场景1:Dijkstra算法(堆优化)(正权图单源最短路径,核心)
核心原理

贪心策略 :每次选择当前距离起点最近的未访问顶点 ,以该顶点为中介,松弛其所有邻接顶点的距离,最终得到起点到所有顶点的最短路径。
堆优化 :用小根堆快速获取当前距离最近的顶点,将时间复杂度从O(N²)降至O(MlogN)(后端主流实现)。

核心思路(六步法)
  1. 初始化 :构建加权邻接表(List<List<int[]>> adj)、dist数组(long[] dist,初始化为Long.MAX_VALUE,避免溢出)、visited数组(标记是否已确定最短路径);
  2. 起点初始化dist[start] = 0,小根堆入队(0, start)(距离, 顶点));
  3. 堆遍历:堆非空时,出队当前距离最近的顶点u;
  4. 跳过过时边 :若当前出队的距离>dist[u],说明该边是过时的,直接跳过;
  5. 松弛操作 :遍历u的所有邻接顶点v,若dist[v] > dist[u] + w,则更新dist[v] = dist[u] + w,并将(dist[v], v)入队;
  6. 结果返回dist[end]即为起点到终点的最短路径,若dist[end]仍为最大值,则无法到达。

关键注意 :Dijkstra不能处理负权边(贪心策略会失效),也不能检测负权环。

子场景2:Floyd-Warshall算法(多源最短路径)
核心原理

动态规划思想dp[k][i][j]表示经过前k个顶点,i到j的最短路径,优化为二维数组dp[i][j],三重循环依次将每个顶点作为中介,更新所有顶点对的最短路径。
适用场景:顶点数少(N<1000)的多源最短路径,实现简单(三重循环)。

核心思路(三步法)
  1. 初始化 :构建邻接矩阵graphgraph[i][j]为i到j的边权,graph[i][i] = 0,无边为Integer.MAX_VALUE
  2. 三重循环k(中介顶点)→i(起点)→j(终点),松弛操作:graph[i][j] = min(graph[i][j], graph[i][k] + graph[k][j])(需判断溢出);
  3. 结果返回graph[i][j]即为i到j的最短路径。
高频经典题
  1. LeetCode 743. 网络延迟时间(Dijkstra堆优化,基础模板);
  2. LeetCode 1514. 概率最大的路径(Dijkstra变种,大根堆);
  3. LeetCode 329. 矩阵中的最长递增路径(DFS+记忆化,图的最短路径变种);
  4. LeetCode 1334. 阈值距离内邻居最少的城市(Floyd/Dijkstra)。
避坑点
  1. Dijkstra未处理过时边:堆中可能存在多个同一顶点的不同距离,出队时若距离大于当前dist[u],必须跳过,否则会更新错误的距离;
  2. 溢出未处理 :加权图的距离优先用long,避免int溢出(如dist[u] + w超过Integer.MAX_VALUE);
  3. Floyd三重循环顺序错误:必须先遍历中介顶点k,再遍历i和j,否则松弛操作无效;
  4. 负权边误用Dijkstra:看到负权边直接排除Dijkstra,改用Bellman-Ford/SPFA;
  5. 堆中存储顺序错误 :Dijkstra堆中存储(距离, 顶点),而非(顶点, 距离),否则比较器失效。

✅ 技巧四:最小生成树(Kruskal/Prim)(★★★★ 占比15%+,后端工程化)

最小生成树(MST)针对无向加权连通图 ,目标是找到一棵包含所有顶点的树,使边权和最小 ,后端面试中Kruskal算法考频远高于Prim(实现简单,依赖并查集)。

子场景1:Kruskal算法(边贪心,核心)
核心原理

边贪心策略 :将所有边按权值升序排序 ,依次选边,若选的边不会使当前生成树形成环(并查集判断u和v是否连通),则加入生成树,直到选够n-1条边(n为顶点数)。
核心依赖 :并查集(环检测+连通性判断),适合稀疏图(M≈N)。

核心思路(五步曲)
  1. 初始化 :将边集按权值升序排序,初始化并查集,最小权值和sum = 0,选边数count = 0
  2. 遍历边集 :按权值从小到大遍历每条边(u, v, w)
  3. 环检测:用并查集判断u和v是否连通,若不连通则执行下一步,否则跳过;
  4. 合并并累加union(u, v)sum += wcount += 1
  5. 终止条件 :若count == n-1,直接返回sum(提前终止,优化效率);遍历结束后若count < n-1,则图非连通,无MST。
子场景2:Prim算法(点贪心,堆优化)
核心原理

点贪心策略 :从任意顶点出发,每次选择连接生成树内顶点和生成树外顶点的权值最小的边 ,将外顶点加入生成树,直到所有顶点都加入。
堆优化 :用小根堆 快速获取权值最小的边,适合稠密图(M≈N²)。

高频经典题
  1. LeetCode 1584. 连接所有点的最小费用(Kruskal/Prim,基础模板);
  2. LeetCode 1135. 最低成本联通所有城市(Kruskal,无向图MST);
  3. LeetCode 934. 最短的桥(DFS+BFS,MST变种)。
避坑点
  1. Kruskal边排序错误 :必须按权值升序排序,否则选的边权和不是最小;
  2. 未处理非连通图:遍历结束后若选边数<n-1,说明图是森林,无MST,需返回-1/无穷大;
  3. Prim忘记标记已加入的顶点 :用inMST数组标记,避免重复加入,导致环;
  4. 权值和溢出 :最小生成树的权值和优先用long,避免int溢出;
  5. 有向图误用MST算法 :Kruskal/Prim仅适用于无向图,有向图无最小生成树概念。

✅ 技巧五:拓扑排序(Kahn/DFS)(★★★ 占比10%+,后端核心场景)

拓扑排序针对有向无环图(DAG) ,目标是生成一个顶点序列 ,使所有有向边u→v都满足u在序列中出现在v之前,后端面试中Kahn算法考频远高于DFS拓扑排序(实现简单,易检测环)。

子场景1:Kahn算法(入度表+队列,核心)
核心原理

入度贪心策略 :每次选择入度为0的顶点 加入拓扑序,然后删除该顶点的所有出边(即邻接顶点的入度-1),重复此过程直到队列为空。
环检测 :若最终生成的拓扑序长度<总顶点数,则图中有环,无法拓扑排序。

核心思路(六步法)
  1. 初始化 :构建邻接表、统计入度表inDegreeinDegree[v]为v的入边数);
  2. 初始化队列:将所有入度为0的顶点入队(LinkedList);
  3. 遍历队列:出队顶点u,加入拓扑序列表;
  4. 更新入度 :遍历u的所有邻接顶点v,inDegree[v] -= 1,若inDegree[v] == 0,则入队;
  5. 环检测:若拓扑序列表的大小<总顶点数,返回空列表(有环);
  6. 结果返回:拓扑序列表。
子场景2:DFS拓扑排序
核心原理

后序遍历+栈 :对DAG进行DFS后序遍历,将遍历完成的顶点入栈,最终栈中弹出的顺序即为拓扑序。
环检测 :与有向图环检测一致,用onPath数组。

高频经典题
  1. LeetCode 207. 课程表(拓扑排序环检测,基础模板);
  2. LeetCode 210. 课程表II(生成拓扑序,Kahn基础);
  3. LeetCode 444. 序列重建(拓扑排序验证唯一拓扑序);
  4. LeetCode 1203. 项目管理(拓扑排序+分组,进阶)。
避坑点
  1. Kahn忘记统计入度:入度表是Kahn算法的核心,构建邻接表时必须同时统计每个顶点的入度;
  2. 环检测遗漏 :拓扑排序的核心考点是环检测,必须判断拓扑序长度是否等于总顶点数;
  3. DAG的最长路径:Kahn算法可求DAG的最长路径(将边权取反,求最短路径,或初始化距离数组为0,松弛时取max);
  4. 多入度0顶点的处理:Kahn算法中入度0的顶点可任意入队,拓扑序不唯一,若题目要求唯一拓扑序,需按特定规则(如字典序)入队。

✅ 技巧六:二分图判断(染色法)(★★★ 占比5%+,基础必熟)

二分图判断是图论的基础小模块 ,后端面试中偶考,核心是染色法(BFS/DFS),无复杂优化。

核心原理

染色法 :用颜色数组color标记顶点颜色(0:未染色,1/2:两种不同颜色),遍历所有顶点,对未染色的顶点染色,然后对其所有邻接顶点染相反颜色 ,若发现相邻顶点颜色相同,则不是二分图

核心思路(四步法)
  1. 初始化 :构建邻接表、颜色数组color(初始化为0);
  2. 处理非连通图:遍历所有顶点,若未染色则调用BFS/DFS染色;
  3. 核心染色
    • BFS:当前顶点u染为1,入队→出队遍历邻接顶点v→若v未染色则染为3-u(相反颜色)并入队→若v已染色且颜色与u相同,返回false;
    • DFS:当前顶点u染为c→遍历邻接顶点v→若v未染色则递归DFS(v, 3-c)→若v已染色且颜色≠3-c,返回false;
  4. 结果返回:所有顶点染色完成,返回true。
高频经典题
  1. LeetCode 785. 判断二分图(染色法基础模板);
  2. LeetCode 886. 可能的二分法(抽象为二分图判断)。
避坑点
  1. 非连通图处理遗漏:仅从起点染色,未遍历所有顶点,导致漏判;
  2. 颜色标记错误:用0/1标记颜色,相邻顶点染为1-0,易出现颜色冲突(推荐用1/2,3-c求相反颜色);
  3. 有向图误用染色法 :染色法仅适用于无向图,有向图无二分图概念。

通用代码模板

图论算法模板性极强 ,二刷的核心是固化以下核心模板 ,遇到题目只需微调边界条件和业务逻辑,无需重新推导。所有模板均为**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版+后端视角):

  1. 模型抽象是前提:将业务问题转化为"顶点+边"的图模型,是解所有图论题的第一步,也是最关键的一步;
  2. 存储选择是基础 :后端面试中邻接表是主流,必须熟练掌握其Java实现和初始化模板;
  3. 算法匹配是核心:根据"图的类型(有向/无向、加权/无权、连通/非连通)+ 问题目标(连通性、最短路径、MST、拓扑序)"快速匹配算法,形成条件反射;
  4. 模板固化是关键 :图论算法模板性极强,二刷需将并查集、DFS/BFS、Dijkstra、Kruskal、Kahn这5个核心模板闭卷手写,遇到题目只需微调;
  5. 工程细节是保障:处理溢出、过时边、非连通图、环检测等细节,是代码能AC的关键,也是后端面试官考察的工程能力。
相关推荐
没有医保李先生2 小时前
嵌入式面试八股文整理(持续更新)
算法
mit6.8242 小时前
ai五层结构
算法
F_D_Z2 小时前
最长连续序列的长度LongestConsecutive
算法·哈希表·最长连续序列
DeepModel2 小时前
【回归算法】广义线性模型(GLM)详解
人工智能·算法·回归
沪漂阿龙2 小时前
大模型采样策略终极指南:Top-k、Top-p与结构化输出最佳实践
人工智能·算法·机器学习
DeepModel2 小时前
【回归算法】局部加权回归(LWR)详解
人工智能·算法·回归
浅念-2 小时前
C++ STL list 容器
开发语言·数据结构·c++·经验分享·笔记·算法·list
重生之后端学习2 小时前
39. 组合总和
java·数据结构·算法·职场和发展·深度优先
Frostnova丶2 小时前
LeetCode 868. 二进制间距
算法·leetcode