高级算法复习当中的一些经典问题分析以及代码实现

基于数组模拟堆实现的堆排序

数组模拟堆的下标变换(1-based)

在使用数组模拟堆时,通常采用 1-based 索引(即数组下标从 1 开始),这样可以简化父子节点的下标计算:

操作 下标变换公式
父节点下标 parent = i / 2
左孩子下标 left = 2 * i
右孩子下标 right = 2 * i + 1

注意:Java 数组默认是 0-based,因此实际实现时,通常会在数组头部留一个空位(heap[0] 不使用),或者使用 heap.length - 1 作为有效堆大小,并在计算时进行偏移。

什么是“满足堆结构 + 满足堆的偏序关系”?

堆是一种完全二叉树,用数组存储时要求:

  1. 满足堆结构
    即数组中的元素按层序遍历顺序排列,且必须是一棵完全二叉树(除了最后一层外,其他层全部填满,最后一层从左向右连续填充)。

  2. 满足堆的偏序关系(以最大堆为例):
    对于任意节点 i,其值 其所有子孙节点的值。
    即:

    1
    2
    heap[i] ≥ heap[2*i]  (左孩子)
    heap[i] ≥ heap[2*i+1] (右孩子)

    对于最小堆,则反过来:heap[i] ≤ 孩子

Java 实现堆排序(升序,使用最大堆)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public class HeapSort {

// 堆排序主方法
public static void heapSort(int[] arr) {
int n = arr.length;

// 1. 构建最大堆(从最后一个非叶子节点开始下沉)
for (int i = n / 2 - 1; i >= 0; i--) {
sink(arr, n, i);
}

// 2. 逐个交换堆顶(最大值)到末尾,然后调整堆
for (int i = n - 1; i > 0; i--) {
// 交换堆顶(arr[0])与当前末尾元素
swap(arr, 0, i);
// 对剩余部分(长度 i)重新下沉堆顶
sink(arr, i, 0);
}
}

// 下沉操作(sink):将节点 k 下沉到合适位置,维护最大堆性质
private static void sink(int[] arr, int n, int k) {
while (2 * k + 1 < n) { // 有左孩子
int child = 2 * k + 1; // 左孩子下标
// 如果右孩子存在且比左孩子大,则选右孩子
if (child + 1 < n && arr[child] < arr[child + 1]) {
child++;
}
// 如果当前节点 ≥ 较大的孩子,则已满足堆性质
if (arr[k] >= arr[child]) {
break;
}
// 否则交换,继续向下
swap(arr, k, child);
k = child;
}
}

private static void swap(int[] arr, int i, int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
}

说明:上面代码使用的是 0-based 下标(Java 原生方式)。
若想严格按 1-based 实现,只需将数组长度设为 n+1,下标从 1 开始,孩子 = 2*i,父 = i/2,但需注意遍历范围调整。

sink操作的含义与目的

含义

sink 是将一个节点(通常是不满足堆性质的节点)向下移动,直到它到达一个合适的位置,使得以该节点为根的子树重新满足最大堆性质。

为什么这么做?

  • 建堆 阶段:从最后一个非叶子节点开始下沉,可以高效地将无序数组调整为堆(时间复杂度 O(n))。
  • 排序 阶段:交换堆顶和末尾元素后,堆顶元素可能被破坏堆性质,需要一次下沉将其修复,使堆顶再次成为最大值,以便下一次交换。

下沉过程的核心逻辑

  1. 找出当前节点较大的孩子;
  2. 如果当前节点 ≥ 较大孩子 → 结束;
  3. 否则交换当前节点和较大孩子,继续向下检查。

时间复杂度与空间复杂度分析

阶段 时间复杂度 说明
建堆 O(n) 从 n/2 到 1 下沉,每个节点下沉高度不同,总和为 O(n)
排序(n-1 次交换 + 下沉) O(n log n) 每次下沉 O(log n),共 n-1 次
总体 O(n log n) 最坏、平均、最好均为 O(n log n)
空间 复杂度 说明
辅助空间 O(1) 原地排序,仅使用常数个额外变量
递归栈(若递归实现) O(log n) 但本实现为迭代,故为 O(1)

关键点

  • 建堆 O(n) 而非 O(n log n) 是因为每个节点下沉高度与其所在层数有关,求和后为线性。
  • 整体 O(n log n) 稳定,不受数据分布影响(不像快排有最坏情况)。
  • 空间 O(1) 是堆排序相对于归并排序(O(n))的最大优势。

如果读者想要阅读有关各种基础排序算法的分析以及对比,可以移步有关于排序算法

线性时间选择算法_BFPRT

问题定义

给定一个包含 n 个元素的无序数组,要求找出其中第 k 小的元素(即顺序统计量),且最坏情况时间复杂度为 O(n)

  • 快速排序的 partition 方法平均 O(n),最坏 O(n²)。
  • BFPRT(Blum-Floyd-Pratt-Rivest-Tarjan)算法,也称为 Median of Medians,是首个证明选择问题可在最坏 O(n) 内解决的算法。

算法核心思想

BFPRT 的核心在于选取一个足够好的 pivot,保证每次递归划分后,数据规模能够按固定比例缩减,从而递推式可解出 O(n)。

算法步骤

  1. 分组:将数组分为 ⌈n/5⌉ 组,每组 5 个元素,最后一组可能不足 5 个。
  2. 每组排序取中位数:对每组内部进行排序(插入排序),取每组的中位数(即第 3 个元素)。
  3. 递归求中位数的中位数:将所有组的中位数组成一个新数组,递归调用 BFPRT 求该数组的中位数,记为 pivot
  4. 划分(partition):以 pivot 为基准,对原数组进行类似快速排序的划分,得到 pivot 的最终位置 p
  5. 递归选择
    • p == k,返回 pivot
    • p > k,递归处理左半部分([left, p-1])。
    • p < k,递归处理右半部分([p+1, right]),并调整 k 值为 k - (p - left) - 1

时间复杂度分析(线性证明)

BFPRT 的递推式为:

1
T(n) = T(n/5) + T(7n/10) + O(n)

其中:

  • T(n/5):递归求中位数的中位数。
  • T(7n/10):划分后,最多有 7n/10 个元素需要递归处理。
  • O(n):分组排序(每组常数时间)+ 划分操作。

为什么是 7n/10?

  • 每组 5 个元素排序后,中位数是该组第 3 小(即至少有 2 个元素 ≤ 它,2 个 ≥ 它)。
  • 所有组的中位数组成新数组,取其中的中位数 pivot
  • 至少有 ⌈n/5⌉ / 2 组的中位数 ≤ pivot,这意味这些组中至少有 3 个元素 ≤ pivot(每组中位数本身 + 它前面的 2 个)。
  • 因此,至少 3 × (⌈n/5⌉ / 2) ≈ 3n/10 个元素 ≤ pivot
  • 同理,至少有 3n/10 个元素 ≥ pivot
  • 所以,划分后左右两侧最多各占 7n/10,递归处理的规模 ≤ 7n/10

解递推式

T(n) ≤ c·n,代入递推式:

1
2
3
T(n) ≤ c·(n/5) + c·(7n/10) + d·n
= c·(0.2n + 0.7n) + d·n
= (0.9c + d)·n

c = 10d,则 T(n) ≤ c·n,故 T(n) = O(n)

关键:由于 0.9 < 1,递归规模按比例缩减,保证了线性收敛。

Java 实现代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
public class BFPRT {

public static int select(int[] arr, int k) {
return select(arr, 0, arr.length - 1, k);
}

private static int select(int[] arr, int left, int right, int k) {
if (right - left + 1 <= 5) {
insertionSort(arr, left, right);
return arr[left + k];
}

// 1. 分组,每组 5 个,取中位数
int numGroups = (right - left + 1) / 5;
int[] medians = new int[numGroups];
for (int i = 0; i < numGroups; i++) {
int groupLeft = left + i * 5;
int groupRight = groupLeft + 4;
insertionSort(arr, groupLeft, groupRight);
medians[i] = arr[groupLeft + 2]; // 中位数
}

// 2. 递归求中位数的中位数
int pivot = select(medians, 0, medians.length - 1, medians.length / 2);

// 3. 划分
int p = partition(arr, left, right, pivot);

// 4. 判断并递归
int rank = p - left;
if (rank == k) return arr[p];
else if (rank > k) return select(arr, left, p - 1, k);
else return select(arr, p + 1, right, k - rank - 1);
}

private static int partition(int[] arr, int left, int right, int pivot) {
int pivotIndex = -1;
for (int i = left; i <= right; i++) {
if (arr[i] == pivot) {
pivotIndex = i;
break;
}
}
swap(arr, pivotIndex, right);
int i = left;
for (int j = left; j < right; j++) {
if (arr[j] < pivot) {
swap(arr, i, j);
i++;
}
}
swap(arr, i, right);
return i;
}

private static void insertionSort(int[] arr, int left, int right) {
for (int i = left + 1; i <= right; i++) {
int key = arr[i];
int j = i - 1;
while (j >= left && arr[j] > key) {
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = key;
}
}

private static void swap(int[] arr, int i, int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
}

空间复杂度

类型 复杂度 说明
辅助数组 O(n/5) = O(n) 存储每组中位数
递归栈 O(log n) 每次规模缩减为 7n/10,递归深度 O(log n)
总体 O(n) 主要消耗来自辅助数组

若复用原数组空间,可将中位数写回原数组前 n/5 个位置,辅助空间可降至 O(1)(但实现复杂度增加)。

实际上……

  • 实际工程中,通常优先使用 Randomized Select(随机化快排 partition),期望 O(n),常数极小,运行速度远快于 BFPRT。
  • BFPRT 仅用于必须保证最坏 O(n) 的极端场景(如恶意数据输入、安全审计)。
  • 若需要处理 Top K 问题且 k 较小,使用 大小为 k 的大顶堆(O(n log k))更简洁高效。

BFS & DFS

BFS 和 DFS 是什么(有向图视角)

BFS(广度优先搜索):从起点开始,按层级(距离)逐层探索图。使用队列实现,先访问距离为 0 的顶点,再访问距离为 1 的所有邻接点,再距离为 2…… 适合求最短路径(无权图)、层级遍历、拓扑排序等。在有向图中可用于检测环、计算最短路径长度等。

DFS(深度优先搜索):从起点开始,沿着一条路径一直走到”最深”无法前进为止,再回溯。使用栈(或递归)实现。适合遍历连通性、路径搜索、拓扑排序、强连通分量等。在有向图中常用于发现、生成拓扑序、求强连通分量(SCC)。

两者区别:BFS 强调”广度/最近”,DFS 强调”深度/一条路走到黑”。时间复杂度均为 O(V + E)。

拓扑排序(Topological Sort)

概念:对有向无环图(DAG)进行线性排序,使得对于任意有向边 u→v,u 在序列中出现在 v 之前。常用于任务依赖调度。

实现方式

  1. DFS 版:后序遍历(递归完成后记录顶点),最后反转结果。
  2. Kahn 算法(BFS + 入度):反复删除入度为 0 的点,更新邻居入度。

例题:课程表(LeetCode 207)。给你 numCourses 门课和 prerequisites 数组,问能否完成所有课程。

Kahn 算法(Java 实现)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public class CourseSchedule {

public static boolean canFinish(int numCourses, int[][] prerequisites) {
// 构建邻接表和入度数组
List<List<Integer>> graph = new ArrayList<>();
int[] indegree = new int[numCourses];

for (int i = 0; i < numCourses; i++) {
graph.add(new ArrayList<>());
}

// 建图:prerequisites[i] = [v, u] 表示 u → v
for (int[] pre : prerequisites) {
int u = pre[1], v = pre[0];
graph.get(u).add(v);
indegree[v]++;
}

// 将所有入度为 0 的节点加入队列
Queue<Integer> queue = new LinkedList<>();
for (int i = 0; i < numCourses; i++) {
if (indegree[i] == 0) {
queue.offer(i);
}
}

int count = 0;
while (!queue.isEmpty()) {
int u = queue.poll();
count++;

for (int v : graph.get(u)) {
indegree[v]--;
if (indegree[v] == 0) {
queue.offer(v);
}
}
}

return count == numCourses;
}
}

若最终处理的节点数等于课程数,则无环,可完成。

DFS 版本拓扑排序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
public class TopologicalSortDFS {

public static List<Integer> topologicalSort(int numCourses, int[][] prerequisites) {
List<List<Integer>> graph = new ArrayList<>();
for (int i = 0; i < numCourses; i++) {
graph.add(new ArrayList<>());
}

for (int[] pre : prerequisites) {
graph.get(pre[1]).add(pre[0]);
}

boolean[] visited = new boolean[numCourses];
boolean[] onStack = new boolean[numCourses]; // 检测环
Deque<Integer> stack = new ArrayDeque<>();

for (int i = 0; i < numCourses; i++) {
if (!visited[i]) {
if (!dfs(i, graph, visited, onStack, stack)) {
return new ArrayList<>(); // 有环,无法拓扑排序
}
}
}

// 反转得到拓扑序
List<Integer> result = new ArrayList<>();
while (!stack.isEmpty()) {
result.add(stack.pop());
}
return result;
}

private static boolean dfs(int node, List<List<Integer>> graph,
boolean[] visited, boolean[] onStack,
Deque<Integer> stack) {
visited[node] = true;
onStack[node] = true;

for (int neighbor : graph.get(node)) {
if (!visited[neighbor]) {
if (!dfs(neighbor, graph, visited, onStack, stack)) {
return false;
}
} else if (onStack[neighbor]) {
return false; // 检测到环
}
}

onStack[node] = false;
stack.push(node); // 后序遍历
return true;
}
}

任务调度(Task Scheduling)

典型场景:在有向依赖图中,求完成所有任务的最短时间(允许并行)或检测是否可调度。

例题:任务调度 II(LeetCode 621,冷却时间)或一般 DAG 最长路径(关键路径相关)。

简单版本(无冷却时间):用拓扑排序 + 动态规划计算最早完成时间。

Java 实现(DAG 最长路径 / 关键路径基础)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
public class TaskScheduling {

/**
* 计算 DAG 中从源点到各点的最长路径(项目最短完成时间)
* @param n 节点数
* @param edges 边数组,每个元素为 [from, to, weight]
* @return 从源点出发到各点的最早完成时间
*/
public static int[] earliestFinishTime(int n, int[][] edges) {
// 构建邻接表和入度数组
List<List<int[]>> graph = new ArrayList<>();
int[] indegree = new int[n];

for (int i = 0; i < n; i++) {
graph.add(new ArrayList<>());
}

for (int[] edge : edges) {
int from = edge[0], to = edge[1], weight = edge[2];
graph.get(from).add(new int[]{to, weight});
indegree[to]++;
}

// 拓扑排序(Kahn 算法)
Queue<Integer> queue = new LinkedList<>();
int[] ve = new int[n]; // 最早发生时间
Arrays.fill(ve, 0);

for (int i = 0; i < n; i++) {
if (indegree[i] == 0) {
queue.offer(i);
}
}

while (!queue.isEmpty()) {
int u = queue.poll();

for (int[] edge : graph.get(u)) {
int v = edge[0], w = edge[1];
// 状态转移:ve[v] = max(ve[v], ve[u] + w)
ve[v] = Math.max(ve[v], ve[u] + w);

indegree[v]--;
if (indegree[v] == 0) {
queue.offer(v);
}
}
}

return ve;
}

/**
* 计算项目的最短完成时间(关键路径长度)
*/
public static int criticalPathLength(int n, int[][] edges) {
int[] ve = earliestFinishTime(n, edges);
int maxTime = 0;
for (int time : ve) {
maxTime = Math.max(maxTime, time);
}
return maxTime;
}
}

关键路径(Critical Path)

在 AOE 网(Activity On Edge,有向带权图)中,关键路径是从源点到汇点的最长路径,其长度即项目最短完成时间。路径上的活动称为关键活动,延迟会影响总工期。

求解步骤(有向无环图):

  1. 拓扑排序。
  2. 正向计算最早发生时间 ve[](类似最长路径)。
  3. 逆向计算最晚发生时间 vl[]。
  4. ve[i] == vl[i] 的边为关键路径上的边。

例题:给定任务依赖和耗时,求项目最短完成时间。

完整 Java 实现(含关键路径识别)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
public class CriticalPath {

/**
* 计算关键路径
* @param n 节点数
* @param edges 边数组 [from, to, weight]
* @return 关键路径上的边列表
*/
public static List<int[]> findCriticalPath(int n, int[][] edges) {
// 构建正向图和反向图
List<List<int[]>> graph = new ArrayList<>();
List<List<int[]>> reverseGraph = new ArrayList<>();
int[] indegree = new int[n];
int[] outdegree = new int[n];

for (int i = 0; i < n; i++) {
graph.add(new ArrayList<>());
reverseGraph.add(new ArrayList<>());
}

for (int[] edge : edges) {
int from = edge[0], to = edge[1], weight = edge[2];
graph.get(from).add(new int[]{to, weight});
reverseGraph.get(to).add(new int[]{from, weight});
indegree[to]++;
outdegree[from]++;
}

// 1. 拓扑排序
List<Integer> topoOrder = new ArrayList<>();
Queue<Integer> queue = new LinkedList<>();
for (int i = 0; i < n; i++) {
if (indegree[i] == 0) {
queue.offer(i);
}
}

while (!queue.isEmpty()) {
int u = queue.poll();
topoOrder.add(u);

for (int[] edge : graph.get(u)) {
int v = edge[0];
indegree[v]--;
if (indegree[v] == 0) {
queue.offer(v);
}
}
}

// 2. 正向计算 ve[](最早发生时间)
int[] ve = new int[n];
for (int u : topoOrder) {
for (int[] edge : graph.get(u)) {
int v = edge[0], w = edge[1];
ve[v] = Math.max(ve[v], ve[u] + w);
}
}

// 3. 逆向计算 vl[](最晚发生时间)
int projectDuration = ve[topoOrder.get(topoOrder.size() - 1)];
int[] vl = new int[n];
Arrays.fill(vl, projectDuration);

Collections.reverse(topoOrder);
for (int u : topoOrder) {
for (int[] edge : reverseGraph.get(u)) {
int v = edge[0], w = edge[1];
vl[v] = Math.min(vl[v], vl[u] - w);
}
}

// 4. 找出关键路径上的边(ve[u] == vl[u] && ve[v] == vl[v] && ve[v] - ve[u] == w)
List<int[]> criticalEdges = new ArrayList<>();
for (int[] edge : edges) {
int from = edge[0], to = edge[1], weight = edge[2];
if (ve[from] == vl[from] && ve[to] == vl[to] && ve[to] - ve[from] == weight) {
criticalEdges.add(edge);
}
}

return criticalEdges;
}
}

强连通分量(Strongly Connected Components, SCC)

定义:有向图中,任意两点互相可达的最大子图。

经典算法:Kosaraju(两次 DFS)或 Tarjan。

Kosaraju 算法

  1. DFS 遍历原图,记录完成时间顺序(栈)。
  2. 转置图(反向所有边)。
  3. 按栈弹出顺序在转置图上 DFS,每次 DFS 得到一个 SCC。

应用:缩点、2-SAT、图的连通性分析。

例题:求有向图中 SCC 的数量(LeetCode 类似题)。

Kosaraju 算法 Java 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
public class KosarajuSCC {

private int V;
private List<List<Integer>> adj;
private List<List<Integer>> reverseAdj;

public KosarajuSCC(int V) {
this.V = V;
this.adj = new ArrayList<>();
this.reverseAdj = new ArrayList<>();

for (int i = 0; i < V; i++) {
adj.add(new ArrayList<>());
reverseAdj.add(new ArrayList<>());
}
}

public void addEdge(int from, int to) {
adj.get(from).add(to);
reverseAdj.get(to).add(from); // 同时构建反向图
}

/**
* 求所有强连通分量
* @return 包含所有 SCC 的列表,每个 SCC 是节点列表
*/
public List<List<Integer>> findSCCs() {
boolean[] visited = new boolean[V];
Deque<Integer> stack = new ArrayDeque<>();

// 第一步:在原图上 DFS,按完成时间入栈
for (int i = 0; i < V; i++) {
if (!visited[i]) {
dfsOrder(i, visited, stack);
}
}

// 第二步:在反向图上按栈顺序 DFS
Arrays.fill(visited, false);
List<List<Integer>> sccs = new ArrayList<>();

while (!stack.isEmpty()) {
int node = stack.pop();
if (!visited[node]) {
List<Integer> scc = new ArrayList<>();
dfsReverse(node, visited, scc);
sccs.add(scc);
}
}

return sccs;
}

private void dfsOrder(int node, boolean[] visited, Deque<Integer> stack) {
visited[node] = true;
for (int neighbor : adj.get(node)) {
if (!visited[neighbor]) {
dfsOrder(neighbor, visited, stack);
}
}
stack.push(node); // 后序入栈
}

private void dfsReverse(int node, boolean[] visited, List<Integer> scc) {
visited[node] = true;
scc.add(node);
for (int neighbor : reverseAdj.get(node)) {
if (!visited[neighbor]) {
dfsReverse(neighbor, visited, scc);
}
}
}
}

Tarjan 算法 Java 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
public class TarjanSCC {

private int V;
private List<List<Integer>> adj;
private int[] disc; // 发现时间
private int[] low; // 能追溯到的最早祖先
private boolean[] onStack;
private Deque<Integer> stack;
private int time;
private List<List<Integer>> sccs;

public TarjanSCC(int V) {
this.V = V;
this.adj = new ArrayList<>();
for (int i = 0; i < V; i++) {
adj.add(new ArrayList<>());
}
this.disc = new int[V];
this.low = new int[V];
this.onStack = new boolean[V];
this.stack = new ArrayDeque<>();
this.time = 0;
this.sccs = new ArrayList<>();

Arrays.fill(disc, -1);
Arrays.fill(low, -1);
}

public void addEdge(int from, int to) {
adj.get(from).add(to);
}

public List<List<Integer>> findSCCs() {
for (int i = 0; i < V; i++) {
if (disc[i] == -1) {
tarjan(i);
}
}
return sccs;
}

private void tarjan(int u) {
disc[u] = low[u] = time++;
stack.push(u);
onStack[u] = true;

for (int v : adj.get(u)) {
if (disc[v] == -1) {
tarjan(v);
low[u] = Math.min(low[u], low[v]);
} else if (onStack[v]) {
low[u] = Math.min(low[u], disc[v]);
}
}

// 如果 u 是 SCC 的根节点
if (low[u] == disc[u]) {
List<Integer> scc = new ArrayList<>();
int v;
do {
v = stack.pop();
onStack[v] = false;
scc.add(v);
} while (v != u);
sccs.add(scc);
}
}
}

贪心

贪心算法是什么:一种局部最优决策思想,每一步都选择当前看起来最好的选择,希望最终得到全局最优。需要满足贪心选择性质(局部最优能导向全局最优)和最优子结构

对应的两大算法(最小生成树):

  • Prim 算法:从一个顶点开始,逐步扩展“已连接集合”,每次选择连接集合与未连接集合之间权值最小的边。
  • Kruskal 算法:将所有边按权值从小到大排序,依次加入不形成环的边(使用并查集判断环)。

零钱兑换(Coin Change)

贪心思路:优先使用面值最大的硬币(适用于规范币系,如人民币、美元)。

LeetCode 322 零钱兑换(求最少硬币数)—— 贪心不一定正确,经典用 DP。但在美国币制等规范情况下贪心成立。

贪心示例(假设币系规范,Java 实现)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class CoinChangeGreedy {

/**
* 贪心算法求解零钱兑换(仅适用于规范币系)
* @param coins 硬币面值数组
* @param amount 目标金额
* @return 最少硬币数,无法兑换返回 -1
*/
public static int coinChange(int[] coins, int amount) {
// 降序排序
Arrays.sort(coins);
int count = 0;

for (int i = coins.length - 1; i >= 0; i--) {
count += amount / coins[i];
amount %= coins[i];
}

return amount == 0 ? count : -1;
}
}

注意使用贪心算法在这个地方有时不能获得最优解(贪心失效)

动态规划版本(通用解法)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class CoinChangeDP {

/**
* 动态规划求解零钱兑换(通用解法)
* @param coins 硬币面值数组
* @param amount 目标金额
* @return 最少硬币数,无法兑换返回 -1
*/
public static int coinChange(int[] coins, int amount) {
int[] dp = new int[amount + 1];
Arrays.fill(dp, amount + 1); // 初始化为最大值
dp[0] = 0;

for (int i = 1; i <= amount; i++) {
for (int coin : coins) {
if (coin <= i) {
dp[i] = Math.min(dp[i], dp[i - coin] + 1);
}
}
}

return dp[amount] > amount ? -1 : dp[amount];
}
}

单源最短路径

Prim(最小生成树,贪心)

用于无向加权图求 MST(最小生成树)。

思路:维护一个集合 S(已选顶点),初始 S = {任意点}。每次选择连接 S 与 V-S 之间最小权边,加入对应顶点,直到 S = V。

优先队列实现复杂度 O(E log V)。

Prim 算法 Java 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
public class PrimMST {

static class Edge implements Comparable<Edge> {
int to, weight;

Edge(int to, int weight) {
this.to = to;
this.weight = weight;
}

@Override
public int compareTo(Edge other) {
return Integer.compare(this.weight, other.weight);
}
}

/**
* Prim 算法求最小生成树的总权重
* @param n 节点数
* @param edges 边数组 [from, to, weight]
* @return MST 的总权重
*/
public static int primMST(int n, int[][] edges) {
// 构建邻接表
List<List<Edge>> graph = new ArrayList<>();
for (int i = 0; i < n; i++) {
graph.add(new ArrayList<>());
}

for (int[] edge : edges) {
int from = edge[0], to = edge[1], weight = edge[2];
graph.get(from).add(new Edge(to, weight));
graph.get(to).add(new Edge(from, weight)); // 无向图
}

boolean[] visited = new boolean[n];
PriorityQueue<Edge> pq = new PriorityQueue<>();

// 从节点 0 开始
visited[0] = true;
for (Edge edge : graph.get(0)) {
pq.offer(edge);
}

int mstWeight = 0;
int edgeCount = 0;

while (!pq.isEmpty() && edgeCount < n - 1) {
Edge edge = pq.poll();

if (visited[edge.to]) {
continue;
}

visited[edge.to] = true;
mstWeight += edge.weight;
edgeCount++;

for (Edge nextEdge : graph.get(edge.to)) {
if (!visited[nextEdge.to]) {
pq.offer(nextEdge);
}
}
}

return edgeCount == n - 1 ? mstWeight : -1; // 不连通返回 -1
}
}

Kruskal(最小生成树,贪心)

思路:边排序 + 并查集。依次加入不形成环的最小边。

复杂度 O(E log E)。

两者都是贪心思想的经典体现。

Kruskal 算法 Java 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
public class KruskalMST {

static class Edge implements Comparable<Edge> {
int from, to, weight;

Edge(int from, int to, int weight) {
this.from = from;
this.to = to;
this.weight = weight;
}

@Override
public int compareTo(Edge other) {
return Integer.compare(this.weight, other.weight);
}
}

// 并查集
static class UnionFind {
int[] parent;
int[] rank;

UnionFind(int n) {
parent = new int[n];
rank = new int[n];
for (int i = 0; i < n; i++) {
parent[i] = i;
}
}

int find(int x) {
if (parent[x] != x) {
parent[x] = find(parent[x]); // 路径压缩
}
return parent[x];
}

boolean union(int x, int y) {
int rootX = find(x);
int rootY = find(y);

if (rootX == rootY) {
return false; // 已在同一集合,会形成环
}

// 按秩合并
if (rank[rootX] < rank[rootY]) {
parent[rootX] = rootY;
} else if (rank[rootX] > rank[rootY]) {
parent[rootY] = rootX;
} else {
parent[rootY] = rootX;
rank[rootX]++;
}

return true;
}
}

/**
* Kruskal 算法求最小生成树的总权重
* @param n 节点数
* @param edges 边数组 [from, to, weight]
* @return MST 的总权重
*/
public static int kruskalMST(int n, int[][] edges) {
List<Edge> edgeList = new ArrayList<>();
for (int[] edge : edges) {
edgeList.add(new Edge(edge[0], edge[1], edge[2]));
}

// 按权重排序
Collections.sort(edgeList);

UnionFind uf = new UnionFind(n);
int mstWeight = 0;
int edgeCount = 0;

for (Edge edge : edgeList) {
if (uf.union(edge.from, edge.to)) {
mstWeight += edge.weight;
edgeCount++;

if (edgeCount == n - 1) {
break; // MST 已完成
}
}
}

return edgeCount == n - 1 ? mstWeight : -1; // 不连通返回 -1
}
}

动规(Dynamic Programming)

最长公共子序列(LCS)

子问题分析:考虑两个字符串 X[1..i] 和 Y[1..j] 的 LCS。

dp 数组定义

  • dp[i][j] = X 前 i 个字符与 Y 前 j 个字符的最长公共子序列长度。

递推式

  • 若 X[i-1] == Y[j-1]:dp[i][j] = dp[i-1][j-1] + 1
  • 否则:dp[i][j] = max(dp[i-1][j], dp[i][j-1])

初始化dp[0][*] = 0dp[*][0] = 0

题解示例(LeetCode 1143,Java 实现):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class LongestCommonSubsequence {

/**
* 最长公共子序列
* @param text1 字符串1
* @param text2 字符串2
* @return LCS 的长度
*/
public static int longestCommonSubsequence(String text1, String text2) {
int m = text1.length();
int n = text2.length();

// dp[i][j] 表示 text1 前 i 个字符与 text2 前 j 个字符的 LCS 长度
int[][] dp = new int[m + 1][n + 1];

for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (text1.charAt(i - 1) == text2.charAt(j - 1)) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}

return dp[m][n];
}
}

抽牌拿取最大值(石子游戏 / 牌堆取牌最优策略)

经典问题:一排牌,每个玩家轮流从两端取一张,求先手能获得的最大分数(两人均最优)。

子问题分析:考虑子数组 piles[i..j],当前玩家能比对手多拿多少分(或先手最大得分)。

dp 数组定义

  • dp[i][j] = 在子数组 piles[i..j] 中,先手玩家比后手玩家多拿的分数(或直接定义先手最大得分)。

递推式(以差值定义):

  • dp[i][j] = max(piles[i] - dp[i+1][j], piles[j] - dp[i][j-1])

初始化:当 i == j 时,dp[i][j] = piles[i](先手全拿)。

题解(LeetCode 877 石子游戏,简化版先手必胜,Java 实现):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class StoneGame {

/**
* 石子游戏 - 判断先手是否能获胜
* @param piles 石子堆数组
* @return 先手是否能获胜
*/
public static boolean stoneGame(int[] piles) {
int n = piles.length;

// dp[i][j] 表示在子数组 piles[i..j] 中,先手比后手多拿的石子数
int[][] dp = new int[n][n];

// 初始化:单个元素时,先手全拿
for (int i = 0; i < n; i++) {
dp[i][i] = piles[i];
}

// 按区间长度递增计算
for (int length = 2; length <= n; length++) {
for (int i = 0; i <= n - length; i++) {
int j = i + length - 1;
// 先手可以选择左端或右端,然后变成后手
dp[i][j] = Math.max(
piles[i] - dp[i + 1][j], // 选左端
piles[j] - dp[i][j - 1] // 选右端
);
}
}

return dp[0][n - 1] > 0; // 先手得分多于后手
}
}

回文判断 / 分割(Palindrome Partitioning)

1. 回文判断

  • 子问题:s[i..j] 是否为回文。
  • dp[i][j] = (s[i] == s[j]) and (j-i <= 1 or dp[i+1][j-1])

2. 回文分割 II(最少分割次数)(LeetCode 132):

  • 子问题:s[0..i] 分割成回文子串的最少次数。
  • dp[i] = min(dp[j] + 1) for j < i where s[j+1..i] 是回文。

递推式(Java 实现)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
public class PalindromePartitioning {

/**
* 回文分割 II - 求最少分割次数
* @param s 输入字符串
* @return 最少分割次数
*/
public static int minCut(String s) {
int n = s.length();

// 预处理:isPalindrome[i][j] 表示 s[i..j] 是否为回文
boolean[][] isPalindrome = new boolean[n][n];

// 单个字符都是回文
for (int i = 0; i < n; i++) {
isPalindrome[i][i] = true;
}

// 长度为 2 的子串
for (int i = 0; i < n - 1; i++) {
isPalindrome[i][i + 1] = (s.charAt(i) == s.charAt(i + 1));
}

// 长度 >= 3 的子串
for (int len = 3; len <= n; len++) {
for (int i = 0; i <= n - len; i++) {
int j = i + len - 1;
isPalindrome[i][j] = (s.charAt(i) == s.charAt(j))
&& isPalindrome[i + 1][j - 1];
}
}

// dp[i] 表示 s[0..i] 的最少分割次数
int[] dp = new int[n];
Arrays.fill(dp, Integer.MAX_VALUE);

for (int i = 0; i < n; i++) {
if (isPalindrome[0][i]) {
dp[i] = 0; // s[0..i] 本身就是回文,无需分割
} else {
for (int j = 0; j < i; j++) {
if (isPalindrome[j + 1][i] && dp[j] != Integer.MAX_VALUE) {
dp[i] = Math.min(dp[i], dp[j] + 1);
}
}
}
}

return dp[n - 1];
}
}

先用二维 dp 预处理所有子串是否回文,再用一维 dp 求最小分割次数。