本文转载自力扣讨论区,作者:灵茶山艾府(endlesscheng)。 原文链接:https://leetcode.cn/circle/discuss/01LUak/
一、图的遍历
§1.1 深度优先搜索(DFS)
找连通块、判断是否有环(如 207 题)等。部分题目做法不止一种。
模板(计算每个连通块的大小):
def solve(n: int, edges: list[list[int]]) -> list[int]:
# 节点编号从 0 到 n-1
g = [[] for _ in range(n)]
for x, y in edges:
g[x].append(y)
g[y].append(x) # 无向图
vis = [False] * n
def dfs(x: int) -> int:
vis[x] = True # 避免重复访问节点
size = 1
for y in g[x]:
if not vis[y]:
size += dfs(y)
return size
# 计算每个连通块的大小
ans = []
for i, b in enumerate(vis):
if not b: # i 没有访问过
size = dfs(i)
ans.append(size)
return ansclass Solution {
public List<Integer> solve(int n, int[][] edges) {
// 节点编号从 0 到 n-1
List<Integer>[] g = new ArrayList[n];
Arrays.setAll(g, _ -> new ArrayList<>());
for (int[] e : edges) {
int x = e[0];
int y = e[1];
g[x].add(y);
g[y].add(x); // 无向图
}
// 计算每个连通块的大小
List<Integer> ans = new ArrayList<>();
boolean[] vis = new boolean[n];
for (int i = 0; i < n; i++) {
if (!vis[i]) { // i 没有访问过
int size = dfs(i, vis, g);
ans.add(size);
}
}
return ans;
}
private int dfs(int x, boolean[] vis, List<Integer>[] g) {
vis[x] = true; // 避免重复访问节点
int size = 1;
for (int y : g[x]) {
if (!vis[y]) {
size += dfs(y, vis, g);
}
}
return size;
}
}vector<int> solve(int n, vector<vector<int>>& edges) {
// 节点编号从 0 到 n-1
vector<vector<int>> g(n);
for (auto& e : edges) {
int x = e[0], y = e[1];
g[x].push_back(y);
g[y].push_back(x); // 无向图
}
vector<int8_t> vis(n);
// lambda 递归
auto dfs = [&](this auto&& dfs, int x) -> int {
vis[x] = true; // 避免重复访问节点
int size = 1;
for (int y : g[x]) {
if (!vis[y]) {
size += dfs(y);
}
}
return size;
};
// 计算每个连通块的大小
vector<int> ans;
for (int i = 0; i < n; i++) {
if (!vis[i]) { // i 没有访问过
int size = dfs(i);
ans.push_back(size);
}
}
return ans;
}func solve(n int, edges [][]int) (ans []int) {
// 节点编号从 0 到 n-1
g := make([][]int, n)
for _, e := range edges {
x, y := e[0], e[1]
g[x] = append(g[x], y)
g[y] = append(g[y], x) // 无向图
}
vis := make([]bool, n)
var dfs func(int) int
dfs = func(x int) int {
vis[x] = true // 避免重复访问节点
size := 1
for _, y := range g[x] {
if !vis[y] {
size += dfs(y)
}
}
return size
}
// 计算每个连通块的大小
for i, b := range vis {
if !b { // i 没有访问过
size := dfs(i)
ans = append(ans, size)
}
}
return
}- 547. 省份数量
- 1971. 寻找图中是否存在路径
- 797. 所有可能的路径 1383
- 1306. 跳跃游戏 III 1397
- 841. 钥匙和房间 1412
- 2316. 统计无向图中无法互相到达点对数 1604
- 1319. 连通网络的操作次数 1633
- 2492. 两个城市间路径的最小分数 1680
- 3310. 移除可疑的方法 1711
- 2685. 统计完全连通分量的数量 1769
- 2192. 有向无环图中一个节点的所有祖先 1788
- 3387. 两天自由外汇交易后的最大货币数 1788
- 924. 尽量减少恶意软件的传播 1869 附思考题
- 2101. 引爆最多的炸弹 1880
- 721. 账户合并
- 207. 课程表 三色标记法判环
- 802. 找到最终的安全状态 1962
- 3695. 交换元素后的最大交替和 1984
- 928. 尽量减少恶意软件的传播 II 1985
- 2092. 找出知晓秘密的所有专家 2004
- 3108. 带权图里旅途的最小代价 2109
- LCP 07. 传递信息
- 261. 以图判树(会员题)
- 323. 无向图中连通分量的数目(会员题)
思维扩展:
- 1298. 你能从盒子里获得的最大糖果数 1825
§1.2 广度优先搜索(BFS)
求最短路等。要求边权都是
模板(单源最短路):
# 计算从 start 到各个节点的最短路长度
# 如果节点不可达,则最短路长度为 -1
# 节点编号从 0 到 n-1,边权均为 1
def bfs(n: int, edges: list[list[int]], start: int) -> list[int]:
g = [[] for _ in range(n)]
for x, y in edges:
g[x].append(y)
g[y].append(x) # 无向图
dis = [-1] * n # -1 表示尚未访问到
dis[start] = 0
q = deque([start])
while q:
x = q.popleft()
for y in g[x]:
if dis[y] < 0:
dis[y] = dis[x] + 1
q.append(y)
return disclass Solution {
// 计算从 start 到各个节点的最短路长度
// 如果节点不可达,则最短路长度为 -1
// 节点编号从 0 到 n-1,边权均为 1
public int[] bfs(int n, int[][] edges, int start) {
List<Integer>[] g = new ArrayList[n];
Arrays.setAll(g, _ -> new ArrayList<>());
for (int[] e : edges) {
int x = e[0], y = e[1];
g[x].add(y);
g[y].add(x); // 无向图
}
int[] dis = new int[n];
Arrays.fill(dis, -1); // -1 表示尚未访问到
Queue<Integer> q = new ArrayDeque<>();
dis[start] = 0;
q.offer(start);
while (!q.isEmpty()) {
int x = q.poll();
for (int y : g[x]) {
if (dis[y] < 0) {
dis[y] = dis[x] + 1;
q.offer(y);
}
}
}
return dis;
}
}// 计算从 start 到各个节点的最短路长度
// 如果节点不可达,则最短路长度为 -1
// 节点编号从 0 到 n-1,边权均为 1
vector<int> bfs(int n, vector<vector<int>>& edges, int start) {
vector<vector<int>> g(n);
for (auto& e : edges) {
int x = e[0], y = e[1];
g[x].push_back(y);
g[y].push_back(x); // 无向图
}
vector<int> dis(n, -1); // -1 表示尚未访问到
queue<int> q;
dis[start] = 0;
q.push(start);
while (!q.empty()) {
int x = q.front();
q.pop();
for (int y : g[x]) {
if (dis[y] < 0) {
dis[y] = dis[x] + 1;
q.push(y);
}
}
}
return dis;
}// 计算从 start 到各个节点的最短路长度
// 如果节点不可达,则最短路长度为 -1
// 节点编号从 0 到 n-1,边权均为 1
func bfs(n int, edges [][]int, start int) []int {
g := make([][]int, n)
for _, e := range edges {
x, y := e[0], e[1]
g[x] = append(g[x], y)
g[y] = append(g[y], x) // 无向图
}
dis := make([]int, n)
for i := range dis {
dis[i] = -1 // -1 表示尚未访问到
}
dis[start] = 0
q := []int{start}
for len(q) > 0 {
x := q[0]
q = q[1:]
for _, y := range g[x] {
if dis[y] < 0 {
dis[y] = dis[x] + 1
q = append(q, y)
}
}
}
return dis
}- 3243. 新增道路查询后的最短距离 I 1568
- 1311. 获取你好友已观看的视频 1653
- 3015. 按距离统计房屋对数目 I 1658
- 1129. 颜色交替的最短路径 1780
- 2039. 网络空闲的时刻 1865
- 2608. 图中的最短环 1904
- 815. 公交路线 1964
- 3807. 修复边以遍历图的最小成本(会员题)
§1.3 图论建模 + BFS 最短路
把状态抽象成图上的点,用 BFS 遍历这张图,计算从初始状态到目标状态的最短路长度。
可以锻炼状态设计能力。
- 433. 最小基因变化
- 1284. 转化为全零矩阵的最少反转次数 1811
- 773. 滑动谜题 1815
- 752. 打开转盘锁 1878
- 3690. 拆分合并数组 1982
- 301. 删除无效的括号
- 514. 自由之路
- 847. 访问所有节点的最短路径 2201
- 854. 相似度为 K 的字符串 2377
- 127. 单词接龙 双向广搜
- 488. 祖玛游戏
- 3991. 使用前缀反转对数组进行排序(会员题)
- 3141. 最大汉明距离(会员题)
专题:跳跃游戏
- 2998. 使 X 和 Y 相等的最少操作次数 1795
- 1345. 跳跃游戏 IV 1810
- 2059. 转化数字的最小运算数 1850
- 1654. 到家的最少跳跃次数 2124
- 3629. 通过质数传送到达终点的最少跳跃次数 2139
- LCP 09. 最小跳跃次数
注:关于网格图的 DFS 和 BFS,请看 网格图题单。
二、拓扑排序

把拓扑排序想象成一个黑盒,给它一堆杂乱的先修课约束,它会给你一个井井有条的课程学习安排。
这一种在图上的「排序」,可以把杂乱的点排成一排。前提条件是图中无环,从而保证每条边都是从排在前面的点,指向排在后面的点。即对于任意有向边
§2.1 拓扑排序
模板:
# 返回有向无环图(DAG)的其中一个拓扑序
# 如果图中有环,返回空列表
# 节点编号从 0 到 n-1
def topologicalSort(n: int, edges: list[list[int]]) -> list[int]:
g = [[] for _ in range(n)]
in_deg = [0] * n
for x, y in edges:
g[x].append(y)
in_deg[y] += 1 # 统计 y 的先修课数量
topo_order = []
q = deque(i for i, d in enumerate(in_deg) if d == 0) # 没有先修课,可以直接上
while q:
x = q.popleft()
topo_order.append(x)
for y in g[x]:
in_deg[y] -= 1 # 修完 x 后,y 的先修课数量减一
if in_deg[y] == 0: # y 的先修课全部上完
q.append(y) # 加入学习队列
if len(topo_order) < n: # 图中有环
return []
return topo_orderclass Solution {
// 返回有向无环图(DAG)的其中一个拓扑序
// 如果图中有环,返回空列表
// 节点编号从 0 到 n-1
public List<Integer> topologicalSort(int n, int[][] edges) {
List<Integer>[] g = new ArrayList[n];
Arrays.setAll(g, _ -> new ArrayList<>());
int[] inDeg = new int[n];
for (int[] e : edges) {
int x = e[0];
int y = e[1];
g[x].add(y);
inDeg[y]++; // 统计 y 的先修课数量
}
Queue<Integer> q = new ArrayDeque<>();
for (int i = 0; i < n; i++) {
if (inDeg[i] == 0) { // 没有先修课,可以直接上
q.offer(i); // 加入学习队列
}
}
List<Integer> topoOrder = new ArrayList<>();
while (!q.isEmpty()) {
int x = q.poll();
topoOrder.add(x);
for (int y : g[x]) {
inDeg[y]--; // 修完 x 后,y 的先修课数量减一
if (inDeg[y] == 0) { // y 的先修课全部上完
q.offer(y); // 加入学习队列
}
}
}
if (topoOrder.size() < n) { // 图中有环
return List.of();
}
return topoOrder;
}
}// 返回有向无环图(DAG)的其中一个拓扑序
// 如果图中有环,返回空列表
// 节点编号从 0 到 n-1
vector<int> topologicalSort(int n, vector<vector<int>>& edges) {
vector<vector<int>> g(n);
vector<int> in_deg(n);
for (auto& e : edges) {
int x = e[0], y = e[1];
g[x].push_back(y);
in_deg[y]++; // 统计 y 的先修课数量
}
queue<int> q;
for (int i = 0; i < n; i++) {
if (in_deg[i] == 0) { // 没有先修课,可以直接上
q.push(i); // 加入学习队列
}
}
vector<int> topo_order;
while (!q.empty()) {
int x = q.front();
q.pop();
topo_order.push_back(x);
for (int y : g[x]) {
in_deg[y]--; // 修完 x 后,y 的先修课数量减一
if (in_deg[y] == 0) { // y 的先修课全部上完
q.push(y); // 加入学习队列
}
}
}
if (topo_order.size() < n) { // 图中有环
return {};
}
return topo_order;
}// 返回有向无环图(DAG)的其中一个拓扑序
// 如果图中有环,返回 nil
// 节点编号从 0 到 n-1
func topologicalSort(n int, edges [][]int) []int {
g := make([][]int, n)
inDeg := make([]int, n)
for _, e := range edges {
x, y := e[0], e[1]
g[x] = append(g[x], y)
inDeg[y]++ // 统计 y 的先修课数量
}
q := make([]int, 0, n)
topoOrder := q
for i, d := range inDeg {
if d == 0 { // 没有先修课,可以直接上
q = append(q, i) // 加入学习队列
}
}
for len(q) > 0 {
x := q[0]
q = q[1:]
for _, y := range g[x] {
inDeg[y]-- // 修完 x 后, y 的先修课数量减一
if inDeg[y] == 0 { // y 的先修课全部上完
q = append(q, y) // 加入学习队列
}
}
}
if cap(q) > 0 { // 图中有环
return nil
}
return topoOrder[:n]
}学习拓扑排序前,请先完成 1557. 可以到达所有点的最少点数目,有助于理解拓扑排序。
- 210. 课程表 II
- 2115. 从给定原材料中找到所有可以做出的菜 1679
- 2392. 给定条件下构造矩阵 1961
- 802. 找到最终的安全状态 1962
- 1591. 奇怪的打印机 II 2291
- 1203. 项目管理 2419
- 1632. 矩阵转换后的排名 2530
- 2603. 收集树中金币 2712
- LCR 114. 火星词典
- 444. 序列重建(会员题)拓扑序是否唯一
- 3481. 应用替换(会员题)也可以记忆化搜索
- 269. 火星词典(会员题)
- 2371. 最小化网格中的最大值(会员题)同 1632 题
思维扩展:
§2.2 在拓扑序上 DP
一般是刷表法。
- 851. 喧闹和富有 1783
- 2050. 并行课程 III 2084
- 3620. 恢复网络路径
- 1857. 有向图中最大颜色值 2313
- 1136. 并行课程(会员题)
§2.3 基环树
- 2359. 找到离给定两个节点最近的节点 1715
- 2360. 图中的最长环 1897
- 684. 冗余连接 做法不止一种
- 685. 冗余连接 II
- 2876. 有向图访问计数 2210
- 2127. 参加会议的最多员工数 2449
- 2836. 在传球游戏中最大化函数值 2769 做法不止一种
- LCP 21. 追逐游戏
- 2204. 无向图中到环的距离(会员题)
思维扩展:
三、最短路
§3.1 单源最短路:Dijkstra 算法
模板:
# 返回从起点 start 到每个点的最短路长度 dis,如果节点 x 不可达,则 dis[x] = math.inf
# 要求:没有负数边权
# 时间复杂度 O(n + mlogm),其中 m 是 edges 的长度。注意堆中有 O(m) 个元素
def shortestPathDijkstra(n: int, edges: list[list[int]], start: int) -> list[int]:
# 注:如果节点编号从 1 开始(而不是从 0 开始),可以把 n 加一
g = [[] for _ in range(n)] # 邻接表
for x, y, wt in edges:
g[x].append((y, wt))
# g[y].append((x, wt)) # 无向图加上这行
dis = [math.inf] * n
dis[start] = 0 # 起点到自己的距离是 0
h = [(0, start)] # 堆中保存 (起点到节点 x 的最短路长度,节点 x)
while h:
dis_x, x = heappop(h)
if dis_x > dis[x]: # x 之前出堆过
continue
for y, wt in g[x]:
new_dis_y = dis_x + wt
if new_dis_y < dis[y]:
dis[y] = new_dis_y # 更新 x 的邻居的最短路
# 懒更新堆:只插入数据,不更新堆中数据
# 相同节点可能有多个不同的 new_dis_y,除了最小的 new_dis_y,其余值都会触发上面的 continue
heappush(h, (new_dis_y, y))
return disclass Solution {
// 返回从起点 start 到每个点的最短路长度 dis,如果节点 x 不可达,则 dis[x] = Integer.MAX_VALUE
// 要求:没有负数边权
// 时间复杂度 O(n + mlogm),注意堆中有 O(m) 个元素
private int[] shortestPathDijkstra(int n, int[][] edges, int start) {
// 注:如果节点编号从 1 开始(而不是从 0 开始),可以把 n 加一
List<int[]>[] g = new ArrayList[n]; // 邻接表
Arrays.setAll(g, _ -> new ArrayList<>());
for (int[] e : edges) {
int x = e[0];
int y = e[1];
int wt = e[2];
g[x].add(new int[]{y, wt});
// g[y].add(new int[]{x, wt}); // 无向图加上这行
}
int[] dis = new int[n]; // **如果数据范围大,改成 long[]**
Arrays.fill(dis, Integer.MAX_VALUE);
// 堆中保存 (起点到节点 x 的最短路长度,节点 x)
PriorityQueue<int[]> pq = new PriorityQueue<>(Comparator.comparingInt(a -> a[0]));
dis[start] = 0; // 起点到自己的距离是 0
pq.offer(new int[]{0, start});
while (!pq.isEmpty()) {
int[] top = pq.poll();
int disX = top[0];
int x = top[1];
if (disX > dis[x]) { // x 之前出堆过
continue;
}
for (int[] e : g[x]) {
int y = e[0];
int wt = e[1];
int newDisY = disX + wt;
if (newDisY < dis[y]) {
dis[y] = newDisY; // 更新 x 的邻居的最短路
// 懒更新堆:只插入数据,不更新堆中数据
// 相同节点可能有多个不同的 newDisY,除了最小的 newDisY,其余值都会触发上面的 continue
pq.offer(new int[]{newDisY, y});
}
}
}
return dis;
}
}// 返回从起点 start 到每个点的最短路长度 dis,如果节点 x 不可达,则 dis[x] = LLONG_MAX
// 要求:没有负数边权
// 时间复杂度 O(n + mlogm),注意堆中有 O(m) 个元素
vector<long long> shortestPathDijkstra(int n, vector<vector<int>>& edges, int start) {
// 注:如果节点编号从 1 开始(而不是从 0 开始),可以把 n 加一
vector<vector<pair<int, int>>> g(n); // 邻接表
for (auto& e : edges) {
int x = e[0], y = e[1], wt = e[2];
g[x].emplace_back(y, wt);
// g[y].emplace_back(x, wt); // 无向图加上这行
}
vector<long long> dis(n, LLONG_MAX);
// 堆中保存 (起点到节点 x 的最短路长度,节点 x)
priority_queue<pair<long long, int>, vector<pair<long long, int>>, greater<>> pq;
dis[start] = 0; // 起点到自己的距离是 0
pq.emplace(0, start);
while (!pq.empty()) {
auto [dis_x, x] = pq.top();
pq.pop();
if (dis_x > dis[x]) { // x 之前出堆过
continue;
}
for (auto& [y, wt] : g[x]) {
auto new_dis_y = dis_x + wt;
if (new_dis_y < dis[y]) {
dis[y] = new_dis_y; // 更新 x 的邻居的最短路
// 懒更新堆:只插入数据,不更新堆中数据
// 相同节点可能有多个不同的 new_dis_y,除了最小的 new_dis_y,其余值都会触发上面的 continue
pq.emplace(new_dis_y, y);
}
}
}
return dis;
}// 返回从起点 start 到每个点的最短路长度 dis,如果节点 x 不可达,则 dis[x] = math.MaxInt
// 要求:没有负数边权
// 时间复杂度 O(n + mlogm),注意堆中有 O(m) 个元素
func shortestPathDijkstra(n int, edges [][]int, start int) []int {
// 注:如果节点编号从 1 开始(而不是从 0 开始),可以把 n 加一
type edge struct{ to, wt int }
g := make([][]edge, n) // 邻接表
for _, e := range edges {
x, y, wt := e[0], e[1], e[2]
g[x] = append(g[x], edge{y, wt})
// g[y] = append(g[y], edge{x, wt}) // 无向图加上这行
}
dis := make([]int, n)
for i := range dis {
dis[i] = math.MaxInt
}
dis[start] = 0 // 起点到自己的距离是 0
// 堆中保存 (起点到节点 x 的最短路长度,节点 x)
h := &hp{{0, start}}
for h.Len() > 0 {
p := heap.Pop(h).(pair)
disX, x := p.dis, p.x
if disX > dis[x] { // x 之前出堆过
continue
}
for _, e := range g[x] {
y := e.to
newDisY := disX + e.wt
if newDisY < dis[y] {
dis[y] = newDisY // 更新 x 的邻居的最短路
// 懒更新堆:只插入数据,不更新堆中数据
// 相同节点可能有多个不同的 newDisY,除了最小的 newDisY,其余值都会触发上面的 continue
heap.Push(h, pair{newDisY, y})
}
}
}
return dis
}
type pair struct{ dis, x int }
type hp []pair
func (h hp) Len() int { return len(h) }
func (h hp) Less(i, j int) bool { return h[i].dis < h[j].dis }
func (h hp) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *hp) Push(v any) { *h = append(*h, v.(pair)) }
func (h *hp) Pop() (v any) { a := *h; *h, v = a[:len(a)-1], a[len(a)-1]; return }- 743. 网络延迟时间
- 3341. 到达最后一个房间的最少时间 I 1721 网格图
- 3112. 访问消失节点的最少时间 1757 理解原理
- 2642. 设计可以求最短路径的图类 1811
- 3604. 有向图中到达终点的最少时间 1845
- 1514. 概率最大的路径 1846
- 3650. 边反转的最小路径总成本 1854
- 3342. 到达最后一个房间的最少时间 II 1862 网格图
- 1631. 最小体力消耗路径 1948 网格图 做法不止一种
- 1786. 从第一个节点出发到最后一个节点的受限路径数 2079
- 3924. 有限重边的最小阈值路径 2080 也可以 0-1 BFS
- 3123. 最短路径中的边 2093
- 1976. 到达目的地的方案数 2095
- 778. 水位上升的泳池中游泳 2097 网格图 做法不止一种
- 2662. 前往目标的最小代价 2154
- 3377. 使两个整数相等的数位操作 2186
- 3928. 购买苹果的最低成本 II 2187
- 2045. 到达目的地的第二短时间 2202 也可以 BFS
- 3419. 图的最大边权的最小值 2243 做法不止一种
- 882. 细分图中的可到达节点 2328
- 2203. 得到要求路径的最小带权子图 2364
- 2577. 在网格图中访问一个格子的最少时间 2382 网格图
- 818. 赛车 2392
- 1928. 规定时间内到达终点的最小花费 2413
- 787. K 站中转内最便宜的航班 类似 1928 题
- 2699. 修改图中的边权 2874
- 1810. 隐藏网格下的最小消耗路径(会员题)
- 2093. 前往目标城市的最小费用(会员题)
- 2473. 购买苹果的最低成本(会员题)
- 2737. 找到最近的标记节点(会员题)
分层图最短路:
- 3970. 最多 K 个连续相同字符的最短路径 1840
- 3977. 有限电量到达目标节点的最少时间 2103
- LCP 35. 电动车游城市
- 3599. 划分数组得到最小 XOR 做法不止一种
- 3594. 所有人渡河所需的最短时间 2604
- 3778. 排除一个最大权重边的最小距离(会员题)
- 2714. 找到 K 次跨越的最短路径(会员题)
SPFA 与差分约束:
力扣上的 SPFA 题很少。
- 2589. 完成所有任务的最少时间 2381 做法不止一种
§3.2 全源最短路:Floyd 算法
Floyd 算法本质是三维 DP。理解 DP 一定要基于空间优化前的写法。
模板:
# 返回一个二维列表,其中 (i,j) 这一项表示从 i 到 j 的最短路长度
# 如果无法从 i 到 j,则最短路长度为 math.inf
# 允许负数边权
# 如果计算完毕后,存在 i,使得从 i 到 i 的最短路长度小于 0,说明图中有负环
# 节点编号从 0 到 n-1
# 时间复杂度 O(n^3 + m),其中 m 是 edges 的长度
def shortestPathFloyd(self, n: int, edges: list[list[int]]) -> list[list[int]]:
f = [[math.inf] * n for _ in range(n)]
for i in range(n):
f[i][i] = 0
for x, y, wt in edges:
f[x][y] = min(f[x][y], wt) # 如果有重边,取边权最小值
f[y][x] = min(f[y][x], wt) # 无向图
for k in range(n):
for i in range(n):
if f[i][k] == math.inf: # 针对稀疏图的优化
continue
for j in range(n):
f[i][j] = min(f[i][j], f[i][k] + f[k][j])
return fclass Solution {
// 返回一个二维列表,其中 (i,j) 这一项表示从 i 到 j 的最短路长度
// 如果无法从 i 到 j,则最短路长度为 Long.MAX_VALUE / 2
// 允许负数边权
// 如果计算完毕后,存在 i,使得从 i 到 i 的最短路长度小于 0,说明图中有负环
// 节点编号从 0 到 n-1
// 时间复杂度 O(n^3 + m),其中 m 是 edges 的长度
public long[][] shortestPathFloyd(int n, int[][] edges) {
final long INF = Long.MAX_VALUE / 2; // 防止加法溢出
long[][] f = new long[n][n];
for (int i = 0; i < n; i++) {
Arrays.fill(f[i], INF);
f[i][i] = 0;
}
for (int[] e : edges) {
int x = e[0];
int y = e[1];
int wt = e[2];
f[x][y] = Math.min(f[x][y], wt); // 如果有重边,取边权最小值
f[y][x] = Math.min(f[y][x], wt); // 无向图
}
for (int k = 0; k < n; k++) {
for (int i = 0; i < n; i++) {
if (f[i][k] == INF) { // 针对稀疏图的优化
continue;
}
for (int j = 0; j < n; j++) {
f[i][j] = Math.min(f[i][j], f[i][k] + f[k][j]);
}
}
}
return f;
}
}// 返回一个二维列表,其中 (i,j) 这一项表示从 i 到 j 的最短路长度
// 如果无法从 i 到 j,则最短路长度为 LLONG_MAX / 2
// 允许负数边权
// 如果计算完毕后,存在 i,使得从 i 到 i 的最短路长度小于 0,说明图中有负环
// 节点编号从 0 到 n-1
// 时间复杂度 O(n^3 + m),其中 m 是 edges 的长度
vector<vector<long long>> shortestPathFloyd(int n, vector<vector<int>>& edges) {
const long long INF = LLONG_MAX / 2; // 防止加法溢出
vector f(n, vector<long long>(n, INF));
for (int i = 0; i < n; i++) {
f[i][i] = 0;
}
for (auto& e : edges) {
int x = e[0], y = e[1];
long long wt = e[2];
f[x][y] = min(f[x][y], wt); // 如果有重边,取边权最小值
f[y][x] = min(f[y][x], wt); // 无向图
}
for (int k = 0; k < n; k++) {
for (int i = 0; i < n; i++) {
if (f[i][k] == INF) { // 针对稀疏图的优化
continue;
}
for (int j = 0; j < n; j++) {
f[i][j] = min(f[i][j], f[i][k] + f[k][j]);
}
}
}
return f;
}// 返回一个二维列表,其中 (i,j) 这一项表示从 i 到 j 的最短路长度
// 如果无法从 i 到 j,则最短路长度为 math.MaxInt / 2
// 允许负数边权
// 如果计算完毕后,存在 i,使得从 i 到 i 的最短路长度小于 0,说明图中有负环
// 节点编号从 0 到 n-1
// 时间复杂度 O(n^3 + m),其中 m 是 edges 的长度
func shortestPathFloyd(n int, edges [][]int) [][]int {
const inf = math.MaxInt / 2 // 防止加法溢出
f := make([][]int, n)
for i := range f {
f[i] = make([]int, n)
for j := range f[i] {
f[i][j] = inf
}
f[i][i] = 0
}
for _, e := range edges {
x, y, wt := e[0], e[1], e[2]
f[x][y] = min(f[x][y], wt) // 如果有重边,取边权最小值
f[y][x] = min(f[y][x], wt) // 无向图
}
for k := range n {
for i := range n {
if f[i][k] == inf { // 针对稀疏图的优化
continue
}
for j := range n {
f[i][j] = min(f[i][j], f[i][k]+f[k][j])
}
}
}
return f
}- 2642. 设计可以求最短路径的图类 1811 动态加边
- 1334. 阈值距离内邻居最少的城市 1855
- 2976. 转换字符串的最小成本 I 1882
- 2959. 关闭分部的可行集合数目 2077
- 2977. 转换字符串的最小成本 II 2696
Bitset 优化 Floyd
四、最小生成树
涉及到 Kruskal 算法和 Prim 算法。前者一般用于稀疏图,后者一般用于稠密图。
注:如果要求最大生成树,把边权从大到小排序。
Kruskal 算法模板(用到了并查集,完整模板见 数据结构题单):
class UnionFind:
def __init__(self, n: int):
# 一开始有 n 个集合 {0}, {1}, ..., {n-1}
# 集合 i 的代表元是自己
self._fa = list(range(n)) # 代表元
self.cc = n # 连通块个数
# 返回 x 所在集合的代表元
# 同时做路径压缩,也就是把 x 所在集合中的所有元素的 fa 都改成代表元
def find(self, x: int) -> int:
# 如果 fa[x] == x,则表示 x 是代表元
if self._fa[x] != x:
self._fa[x] = self.find(self._fa[x]) # fa 改成代表元
return self._fa[x]
# 把 from 所在集合合并到 to 所在集合中
# 返回是否合并成功
def merge(self, from_: int, to: int) -> bool:
x, y = self.find(from_), self.find(to)
if x == y: # from 和 to 在同一个集合,不做合并
return False
self._fa[x] = y # 合并集合。修改后就可以认为 from 和 to 在同一个集合了
self.cc -= 1 # 成功合并,连通块个数减一
return True
# 计算图的最小生成树的边权之和
# 如果图不连通,返回 math.inf
# 节点编号从 0 到 n-1
# 时间复杂度 O(n + mlogm),其中 m 是 edges 的长度
def mstKruskal(n: int, edges: list[list[int]]) -> int:
edges.sort(key=lambda e: e[2])
uf = UnionFind(n)
sum_wt = 0
for x, y, wt in edges:
if uf.merge(x, y):
sum_wt += wt
if uf.cc > 1: # 图不连通
return math.inf
return sum_wtclass UnionFind {
private final int[] fa; // 代表元
public int cc; // 连通块个数
UnionFind(int n) {
// 一开始有 n 个集合 {0}, {1}, ..., {n-1}
// 集合 i 的代表元是自己
fa = new int[n];
for (int i = 0; i < n; i++) {
fa[i] = i;
}
cc = n;
}
// 返回 x 所在集合的代表元
// 同时做路径压缩,也就是把 x 所在集合中的所有元素的 fa 都改成代表元
public int find(int x) {
// 如果 fa[x] == x,则表示 x 是代表元
if (fa[x] != x) {
fa[x] = find(fa[x]); // fa 改成代表元
}
return fa[x];
}
// 把 from 所在集合合并到 to 所在集合中
// 返回是否合并成功
public boolean merge(int from, int to) {
int x = find(from);
int y = find(to);
if (x == y) { // from 和 to 在同一个集合,不做合并
return false;
}
fa[x] = y; // 合并集合。修改后就可以认为 from 和 to 在同一个集合了
cc--; // 成功合并,连通块个数减一
return true;
}
}
class Solution {
// 计算图的最小生成树的边权之和
// 如果图不连通,返回 Long.MAX_VALUE
// 节点编号从 0 到 n-1
// 时间复杂度 O(n + mlogm),其中 m 是 edges 的长度
public long mstKruskal(int n, int[][] edges) {
Arrays.sort(edges, Comparator.comparingInt(e -> e[2]));
UnionFind uf = new UnionFind(n);
long sumWt = 0;
for (int[] e : edges) {
int x = e[0], y = e[1], wt = e[2];
if (uf.merge(x, y)) {
sumWt += wt;
}
}
if (uf.cc > 1) { // 图不连通
return Long.MAX_VALUE;
}
return sumWt;
}
}class UnionFind {
vector<int> fa; // 代表元
public:
int cc; // 连通块个数
UnionFind(int n) : fa(n), cc(n) {
// 一开始有 n 个集合 {0}, {1}, ..., {n-1}
// 集合 i 的代表元是自己
ranges::iota(fa, 0);
}
// 返回 x 所在集合的代表元
// 同时做路径压缩,也就是把 x 所在集合中的所有元素的 fa 都改成代表元
int find(int x) {
// 如果 fa[x] == x,则表示 x 是代表元
if (fa[x] != x) {
fa[x] = find(fa[x]); // fa 改成代表元
}
return fa[x];
}
// 把 from 所在集合合并到 to 所在集合中
// 返回是否合并成功
bool merge(int from, int to) {
int x = find(from), y = find(to);
if (x == y) { // from 和 to 在同一个集合,不做合并
return false;
}
fa[x] = y; // 合并集合。修改后就可以认为 from 和 to 在同一个集合了
cc--; // 成功合并,连通块个数减一
return true;
}
};
// 计算图的最小生成树的边权之和
// 如果图不连通,返回 LLONG_MAX
// 节点编号从 0 到 n-1
// 时间复杂度 O(n + mlogm),其中 m 是 edges 的长度
long long mstKruskal(int n, vector<vector<int>>& edges) {
ranges::sort(edges, {}, [](const auto& e) { return e[2]; });
UnionFind uf(n);
long long sum_wt = 0;
for (auto& e : edges) {
int x = e[0], y = e[1], wt = e[2];
if (uf.merge(x, y)) {
sum_wt += wt;
}
}
if (uf.cc > 1) { // 图不连通
return LLONG_MAX;
}
return sum_wt;
}type unionFind struct {
fa []int // 代表元
cc int // 连通块个数
}
func newUnionFind(n int) unionFind {
fa := make([]int, n)
// 一开始有 n 个集合 {0}, {1}, ..., {n-1}
// 集合 i 的代表元是自己
for i := range fa {
fa[i] = i
}
return unionFind{fa, n}
}
// 返回 x 所在集合的代表元
// 同时做路径压缩,也就是把 x 所在集合中的所有元素的 fa 都改成代表元
func (u unionFind) find(x int) int {
// 如果 fa[x] == x,则表示 x 是代表元
if u.fa[x] != x {
u.fa[x] = u.find(u.fa[x]) // fa 改成代表元
}
return u.fa[x]
}
// 把 from 所在集合合并到 to 所在集合中
// 返回是否合并成功
func (u *unionFind) merge(from, to int) bool {
x, y := u.find(from), u.find(to)
if x == y { // from 和 to 在同一个集合,不做合并
return false
}
u.fa[x] = y // 合并集合。修改后就可以认为 from 和 to 在同一个集合了
u.cc-- // 成功合并,连通块个数减一
return true
}
// 计算图的最小生成树的边权之和
// 如果图不连通,返回 math.MaxInt
// 节点编号从 0 到 n-1
// 时间复杂度 O(n + mlogm),其中 m 是 edges 的长度
func mstKruskal(n int, edges [][]int) int {
slices.SortFunc(edges, func(a, b []int) int { return a[2] - b[2] })
uf := newUnionFind(n)
sumWt := 0
for _, e := range edges {
x, y, wt := e[0], e[1], e[2]
if uf.merge(x, y) {
sumWt += wt
}
}
if uf.cc > 1 { // 图不连通
return math.MaxInt
}
return sumWt
}- 1584. 连接所有点的最小费用 1858
- 3600. 升级后最大生成树稳定性 2301 做法不止一种
- 1489. 找到最小生成树里的关键边和伪关键边 2572
- 1135. 最低成本连通所有城市(会员题)
- 1168. 水资源分配优化(会员题)
思维扩展
五、欧拉路径/欧拉回路
涉及到 Hierholzer 算法。
- 332. 重新安排行程
- 753. 破解保险箱 2274
- 2097. 合法重新排列数对 2651
六、强连通分量/双连通分量
涉及到 Tarjan 算法。
- 1192. 查找集群内的关键连接 2085
- 1568. 使陆地分离的最少天数 2209
- LCP 54. 夺回据点
- 3383. 施法所需最低符文数量(会员题)
七、二分图染色
模板(交替染色法):
# 返回图的二染色
# 如果是二分图,返回每个节点的颜色,用 1 和 2 表示两种颜色
# 如果不是二分图,返回空列表
# 时间复杂度 O(n+m),n 是点数,m 是边数
def colorBipartite(n: int, edges: list[list[int]]) -> list[int]:
# 建图(节点编号从 0 到 n-1)
g = [[] for _ in range(n)]
for x, y in edges:
g[x].append(y)
g[y].append(x)
# colors[i] = 0 表示未访问节点 i
# colors[i] = 1 表示节点 i 为红色
# colors[i] = 2 表示节点 i 为蓝色
colors = [0] * n
def dfs(x: int, c: int) -> bool:
colors[x] = c # 节点 x 染成颜色 c
for y in g[x]:
# 邻居 y 的颜色与 x 的相同,说明不是二分图,返回 False
# 或者继续递归,发现不是二分图,返回 False
if colors[y] == c or \
colors[y] == 0 and not dfs(y, 3 - c): # 1 和 2 交替染色
return False
return True
# 可能有多个连通块
for i, c in enumerate(colors):
if c == 0 and not dfs(i, 1):
# 从节点 i 开始递归,发现 i 所在连通块不是二分图
return []
return colorsclass Solution {
// 返回图的二染色
// 如果是二分图,返回每个节点的颜色,用 1 和 2 表示两种颜色
// 如果不是二分图,返回空列表
// 时间复杂度 O(n+m),n 是点数,m 是边数
private int[] colorBipartite(int n, int[][] edges) {
// 建图(节点编号从 0 到 n-1)
List<Integer>[] g = new ArrayList[n];
Arrays.setAll(g, _ -> new ArrayList<>());
for (int[] e : edges) {
int x = e[0], y = e[1];
g[x].add(y);
g[y].add(x);
}
// colors[i] = 0 表示未访问节点 i
// colors[i] = 1 表示节点 i 为红色
// colors[i] = 2 表示节点 i 为蓝色
int[] colors = new int[n];
for (int i = 0; i < n; i++) {
if (colors[i] == 0 && !dfs(i, 1, g, colors)) {
// 从节点 i 开始递归,发现 i 所在连通块不是二分图
return new int[]{};
}
}
return colors;
}
private boolean dfs(int x, int c, List<Integer>[] g, int[] colors) {
colors[x] = c; // 节点 x 染成颜色 c
for (int y : g[x]) {
// 邻居 y 的颜色与 x 的相同,说明不是二分图,返回 false
// 或者继续递归,发现不是二分图,返回 false
if (colors[y] == c ||
colors[y] == 0 && !dfs(y, 3 - c, g, colors)) { // 1 和 2 交替染色
return false;
}
}
return true;
}
}// 返回图的二染色
// 如果是二分图,返回每个节点的颜色,用 1 和 2 表示两种颜色
// 如果不是二分图,返回空列表
// 时间复杂度 O(n+m),n 是点数,m 是边数
vector<int8_t> colorBipartite(int n, vector<vector<int>>& edges) {
// 建图(节点编号从 0 到 n-1)
vector<vector<int>> g(n);
for (auto& e : edges) {
int x = e[0], y = e[1];
g[x].push_back(y);
g[y].push_back(x);
}
// colors[i] = 0 表示未访问节点 i
// colors[i] = 1 表示节点 i 为红色
// colors[i] = 2 表示节点 i 为蓝色
vector<int8_t> colors(n);
// lambda 递归
auto dfs = [&](this auto&& dfs, int x, int8_t c) -> bool {
colors[x] = c; // 节点 x 染成颜色 c
for (int y : g[x]) {
// 邻居 y 的颜色与 x 的相同,说明不是二分图,返回 false
// 或者继续递归,发现不是二分图,返回 false
if (colors[y] == c ||
colors[y] == 0 && !dfs(y, 3 - c)) { // 1 和 2 交替染色
return false;
}
}
return true;
};
// 可能有多个连通块
for (int i = 0; i < n; i++) {
if (colors[i] == 0 && !dfs(i, 1)) {
// 从节点 i 开始递归,发现 i 所在连通块不是二分图
return {};
}
}
return colors;
}// 返回图的二染色
// 如果是二分图,返回每个节点的颜色,用 1 和 2 表示两种颜色
// 如果不是二分图,返回空列表
// 时间复杂度 O(n+m),n 是点数,m 是边数
func colorBipartite(n int, edges [][]int) []int8 {
// 建图(节点编号从 0 到 n-1)
g := make([][]int, n)
for _, e := range edges {
x, y := e[0], e[1]
g[x] = append(g[x], y)
g[y] = append(g[y], x)
}
// colors[i] = 0 表示未访问节点 i
// colors[i] = 1 表示节点 i 为红色
// colors[i] = 2 表示节点 i 为蓝色
colors := make([]int8, n)
var dfs func(int, int8) bool
dfs = func(x int, c int8) bool {
colors[x] = c // 节点 x 染成颜色 c
for _, y := range g[x] {
// 邻居 y 的颜色与 x 的相同,说明不是二分图,返回 false
// 或者继续递归,发现不是二分图,返回 false
if colors[y] == c ||
colors[y] == 0 && !dfs(y, 3-c) { // 1 和 2 交替染色
return false
}
}
return true
}
// 可能有多个连通块
for i, c := range colors {
if c == 0 && !dfs(i, 1) {
// 从节点 i 开始递归,发现 i 所在连通块不是二分图
return nil
}
}
return colors
}- 785. 判断二分图 1625
- 886. 可能的二分法 1795
- 3710. 最大划分因子 2135
关于二分图的最大匹配,见下面网络流的题目。其中标有「一对一」的题目也可以用带权二分图最大匹配做。
八、网络流
由于有其他做法(比如状压 DP),难度分仅供参考。
- 1947. 最大兼容性评分和 1704 一对一
- 3376. 破解锁的最少时间 I 1793 一对一
- 2850. 将石头分散到网格图的最少移动次数 2001 一对多
- 1879. 两个数组最小的异或值之和 2145 一对一
- 1349. 参加考试的最大学生数 2386 二分图最大独立集
- 2172. 数组的最大与和 2392 多对一
- 3276. 选择矩阵中单元格的最大得分 2403
- 1595. 连通两组点的最小成本 2538 带权二分图最小边覆盖
- 3257. 放三个车的价值之和最大 II 2553
- LCP 04. 覆盖 二分图最大匹配·模板题
- LCP 38. 守卫城堡 最小割
- 1820. 最多邀请的个数(会员题)二分图最大匹配·模板题
- 2403. 杀死所有怪物的最短时间(会员题)同 3376 题
- 3385. 破解锁的最少时间 II(会员题)同 3376 题
- 1066. 校园自行车分配 II(会员题)一对一,但不是完美匹配
- 4004. 使循环数组余额非负的最少移动次数 II(会员题)
- 2123. 使矩阵中的 1 互不相邻的最小操作数(会员题)二分图最大独立集
模拟费用流
九、其他
- 1042. 不邻接植花 1712
- 1761. 一个图中连通三元组的最小度数 2005
- 2508. 添加边使所有节点度数都为偶数 2060
- 1579. 保证图可完全遍历 2132
- 2065. 最大化一张图中的路径价值 2178
- 1697. 检查边长度限制的路径是否存在 2300
- 2242. 节点序列的最大得分 2304
- 2493. 将节点分成尽可能多的组 2415 推荐
- 1782. 统计点对的数目 2457
- 3666. 使二进制字符串全为 1 的最少操作次数 2477 进阶 BFS / Gale-Ryser 定理
- 2612. 最少翻转操作数 2824 进阶 BFS
- 3435. 最短公共超序列的字母出现频率 3028
- 466. 统计重复个数 做到
- LCP 16. 游乐园的游览计划
- 277. 搜寻名人(会员题)
- 1724. 检查边长度限制的路径是否存在 II(会员题)
- 2077. 殊途同归(会员题)三元环计数
- 3656. 判断是否存在简单图(会员题)Erdős–Gallai 定理
十、树上算法
见 链表、树、回溯 题单的第三章节。
关联题单
- 关于网格图的 DFS 和 BFS,见 网格图题单。
算法题单
- 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
- 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
- 单调栈(基础/矩形面积/贡献法/最小字典序)
- 网格图(DFS/BFS/综合应用)
- 位运算(基础/性质/拆位/试填/恒等式/思维)
- 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
- 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
- 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
- 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
- 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
- 链表、树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA)
- 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)
如果你发现有题目可以补充进来,欢迎评论反馈。