人的知识就好比一个圆圈,圆圈里面是已知的,圆圈外面是未知的。你知道得越多,圆圈也就越大,你不知道的也就越多。

0%

redo log

在 MySQL 中,如果每一次的更新操作都需要写进磁盘,然后磁盘也要找到对应的那条记录,然后再更新,整个过程 IO 成本、查找成本都很高。为了解决这个问题,MySQL 的设计者就采用了日志(redo log)来提升更新效率。

而日志和磁盘配合的整个过程,其实就是 MySQL 里的 WAL 技术,WAL 的全称是 Write-Ahead Logging,它的关键点就是先写日志,再写磁盘

具体来说,当有一条记录需要更新的时候,InnoDB 引擎就会先把记录写到 redo log(redolog buffer)里面,并更新内存(buffer pool),这个时候更新就算完成了。同时,InnoDB 引擎会在适当的时候(如系统空闲时),将这个操作记录更新到磁盘里面(刷脏页)。

redo log 是 InnoDB 存储引擎层的日志,又称重做日志文件,redo log 是循环写的,redo log 不是记录数据页更新之后的状态,而是记录这个页做了什么改动。

redo log 是固定大小的,比如可以配置为一组 4 个文件,每个文件的大小是 1GB,那么日志总共就可以记录 4GB 的操作。从头开始写,写到末尾就又回到开头循环写,如下图所示。

redo log 示例

图中展示了一组 4 个文件的 redo log 日志,checkpoint 是当前要擦除的位置,擦除记录前需要先把对应的数据落盘(更新内存页,等待刷脏页)。write pos 到 checkpoint 之间的部分可以用来记录新的操作,如果 write pos 和 checkpoint 相遇,说明 redolog 已满,这个时候数据库停止进行数据库更新语句的执行,转而进行 redo log 日志同步到磁盘中。checkpoint 到 write pos 之间的部分等待落盘(先更新内存页,然后等待刷脏页)。

有了 redo log 日志,那么在数据库进行异常重启的时候,可以根据 redo log 日志进行恢复,也就达到了 crash-safe。

redo log 用于保证 crash-safe 能力。innodb_flush_log_at_trx_commit 这个参数设置成 1 的时候,表示每次事务的 redo log 都直接持久化到磁盘。这个参数建议设置成 1,这样可以保证 MySQL 异常重启之后数据不丢失。

binlog

MySQL 整体来看,其实就有两块:一块是 Server 层,它主要做的是 MySQL 功能层面的事情;还有一块是引擎层,负责存储相关的具体事宜。redo log 是 InnoDB 引擎特有的日志,而 Server 层也有自己的日志,称为 binlog(归档日志)。

binlog 属于逻辑日志,是以二进制的形式记录的是这个语句的原始逻辑,依靠 binlog 是没有 crash-safe 能力的。

binlog 有两种模式,statement 格式的话是记 sql 语句,row 格式会记录行的内容,记两条,更新前和更新后都有。

sync_binlog 这个参数设置成 1 的时候,表示每次事务的 binlog 都持久化到磁盘。这个参数也建议设置成 1,这样可以保证 MySQL 异常重启之后 binlog 不丢失。

为什么会有两份日志呢?

因为最开始 MySQL 里并没有 InnoDB 引擎。MySQL 自带的引擎是 MyISAM,但是 MyISAM 没有 crash-safe 的能力,binlog 日志只能用于归档。而 InnoDB 是另一个公司以插件形式引入 MySQL 的,既然只依靠 binlog 是没有 crash-safe 能力的,所以 InnoDB 使用另外一套日志系统——也就是 redo log 来实现 crash-safe 能力。

redo log VS binlog

  1. redo log 是 InnoDB 引擎特有的;binlog 是 MySQL 的 Server 层实现的,所有引擎都可以使用。
  2. redo log 是物理日志,记录的是在某个数据页上做了什么修改;binlog 是逻辑日志,记录的是这个语句的原始逻辑。
  3. redo log 是循环写的,空间固定会用完;binlog 是可以追加写入的。追加写是指 binlog 文件写到一定大小后会切换到下一个,并不会覆盖以前的日志。

update 流程

有了对 redo log 和 binlog 这两个日志的概念性理解后,再来看执行器和 InnoDB 引擎在执行这个 update 语句时的内部流程。

  1. 执行器先找引擎取 ID=2 这一行。ID 是主键,引擎直接用树搜索找到这一行。如果 ID=2 这一行所在的数据页本来就在内存中,就直接返回给执行器;否则,需要先从磁盘读入内存,然后再返回。
  2. 执行器拿到引擎给的行数据,把这个值加上 1,比如原来是 N,现在就是 N+1,得到新的一行数据,再调用引擎接口写入这行新数据。
  3. 引擎将这行新数据更新到内存(InnoDB Buffer Pool)中,同时将这个更新操作记录到 redo log 里面,此时 redo log 处于 prepare 状态。然后告知执行器执行完成了,随时可以提交事务。
  4. 执行器生成这个操作的 binlog,并把 binlog 写入磁盘。
  5. 执行器调用引擎的提交事务接口,引擎把刚刚写入的 redo log 改成提交(commit)状态,更新完成。

下图为 update 语句的执行流程图,图中灰色框表示是在 InnoDB 内部执行的,绿色框表示是在执行器中执行的。

update 流程

其中将 redo log 的写入拆成了两个步骤:prepare 和 commit,这就是两阶段提交(2PC)。

两阶段提交(2PC)

MySQL 使用两阶段提交主要解决 binlog 和 redo log 的数据一致性的问题。

redo log 和 binlog 都可以用于表示事务的提交状态,而两阶段提交就是让这两个状态保持逻辑上的一致。下图为 MySQL 二阶段提交简图:

两阶段提交

两阶段提交原理描述:

  1. InnoDB redo log 写盘,InnoDB 事务进入 prepare 状态。
  2. 如果前面 prepare 成功,binlog 写盘,那么再继续将事务日志持久化到 binlog,如果持久化成功,那么 InnoDB 事务则进入 commit 状态(在 redo log 里面写一个 commit 记录)

备注: 每个事务 binlog 的末尾,会记录一个 XID event,标志着事务是否提交成功,也就是说,recovery 过程中,binlog 最后一个 XID event 之后的内容都应该被 purge。

参考资料

  1. MySQL 日志系统之 redo log 和 binlog

Mysql 基础架构示意图如下:
MySQL基础架构示意图

大体来说,MySQL 可以分为 Server 层和存储引擎层两部分。

Server 层

Server 层包括连接器、查询缓存、分析器、优化器、执行器等,涵盖 Mysql 的大多数核心服务功能,以及所有的内置函数(如日期、时间、数学和加密函数等),所有跨存储引擎的功能都在这一层实现,比如存储过程、触发器、视图等。

连接器

连接器负责跟客户端建立连接、获取权限、维持和管理连接。

长连接

长连接是指连接成功后,如果客户端持续有请求,则一直使用同一个请求。短连接则指每次执行完很少的几次查询就断开连接,下次查询再重新建立一个。

由于建立连接的过程通常都比较复杂,所以建议尽量使用长连接。但是全采用长连接后,有些时候 MySQL 占用内存涨的特别快,这是因为 MySQL 在执行过程中临时使用的内存是管理在连接对象里面的。这些资源会在连接断开的时候才释放。所以如果长连接累积下来,可能导致内存占用太大,被系统强行杀掉(OOM),从现象看就是 MySQL 异常重启了。
有两种方案可以解决上述问题:

  • 定期断开长连接。使用一段时间,或者程序里面判断执行过一个占用内存的大查询后,断开连接,之后要查询再重连。
  • 在每次执行一个比较大的操作后,通过执行 mysql_reset_connection 来重新初始化连接资源。这个过程不需要重连和重新做权限验证,但是会将连接恢复到刚刚创建完时的状态。

查询缓存

MySQL 拿到一个查询请求后,会先到查询缓存看看,之前是不是执行过这条语句。之前执行过的语句及其结果可能会以 key-value 对的形式,被直接缓存在内存中。

不建议使用查询缓存,因为它通常弊大于利,除非业务表是一张静态表,很长时间才更新一次。

从 MySQL 8.0 开始,已经将查询缓存的整块功能删掉了。

分析器

分析器会先做“词法分析”,然后做“语法分析”,根据词法分析的结果,语法分析器会根据语法规则,判断输入的 SQL 语句是否满足 MySQL 语法。如果你的语句不对,就会收到“You have an error in your SQL syntax”的错误提醒。

优化器

优化器是在表里面有多个索引的时候,决定使用哪个索引;或者在一个语句有多表关联(join)的时候,决定各个表的连接顺序。

执行器

开始执行之前,会判断当前用户是否有权限执行操作,如果没有,就会返回没有权限的错误,如果有,就打开表继续执行。打开表的时候,执行器会根据表的引擎定义,去使用这个引擎提供的接口。

存储引擎层

存储引擎层负责数据的存储和提取。其架构模式是插件式的,支持 InnoDB、MyISAM、Memory 等多个存储引擎,最常用且默认的是 InnoDB。

Java SE 1.6为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”,在Java SE 1.6中,锁一共4中状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不饿能降级的策略,目的是为了提高获得锁和释放锁的效率。

偏向锁

当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需要简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。

撤销

当其它线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态;如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark word要么重新偏向于其它线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。

轻量级锁

加锁

线程在执行同步块之前,JVM会先在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获取锁;如果失败,表示其它线程竞争锁,当前线程便尝试使用自旋锁来获取锁。

解锁

轻量级解锁时,会使用原子的CAS操作将Dispalced Mark Word替换回对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁

概念

采用归并思想/分治法:把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组就都有序了。

时间复杂度

  • 平均:O(nlog(n))
  • 最好:O(nlog(n))
  • 最坏:O(nlog(n))

空间复杂度

O(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
25
26
27
28
29
30
31
32
33
34
35
36
public class MergeSort implements Sort {

@Override
public int[] sort(int[] arr) {
return sort(arr, 0, arr.length - 1);
}

private int[] sort(int[] arr, int l, int h) {
// 递归终止条件,返回单个元素数组
if (l == h) {
return new int[]{arr[l]};
}

// 二等分,分别递归排序
int mid = l + (h - l) / 2;
int[] leftArr = sort(arr, l, mid);
int[] rightArr = sort(arr, mid + 1, h);

int[] newArr = new int[leftArr.length + rightArr.length];

int m = 0, i = 0, j = 0;
while (i < leftArr.length && j < rightArr.length) {
newArr[m++] = leftArr[i] < rightArr[j] ? leftArr[i++] : rightArr[j++];
}

while (i < leftArr.length) {
newArr[m++] = leftArr[i++];
}

while (j < rightArr.length) {
newArr[m++] = rightArr[j++];
}

return newArr;
}
}

概念

采用归并思想/分治法:通过一趟排序将待排记录分隔成独立的两部分,其中一部分记录的关键字均比另一部分的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序。

时间复杂度

  • 平均:O(nlog(n))
  • 最好:O(nlog(n))
  • 最坏:O(n²)

空间复杂度

O(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
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public class QuickSort implements Sort {

@Override
public int[] sort(int[] arr) {
return sort(arr, 0, arr.length - 1);
}

private int[] sort(int[] arr, int l, int h) {
// 选择第一个元素作为分区点
int pivot = arr[l];

int i = l;
int j = h;

while (i < j) {
while (arr[i] < pivot) {
i++;
}

while (arr[j] > pivot) {
j--;
}

if (arr[i] == arr[j]) {
i++;
} else {
int tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}
}

if (i - 1 > l) {
sort(arr, l, i - 1);
}

if (j + 1 < h) {
sort(arr, j + 1, h);
}

return arr;
}
}

概念

基数排序对要排序的数据是有要求的,需要可以分割出独立的“位”来比较,而且位之间有递进的关系。
如果a数据的高位比b数据大,那剩下的低位就不用比较了。
除此之外,每一位的数据范范围不能太大,要可以用线性排序算法来排序,否则,基数排序的时间复杂度就无法做到O(n) 了。

时间复杂度

  • 平均:O(n * k)
  • 最好:O(n * k)
  • 最坏:O(n * k)

空间复杂度

O(n + k)

稳定性

稳定

概念

计数排序其实是桶排序的一种特殊情况。当要排序的n个数据,所处的范围并不大的时候,比如最大值是k,我们就可以把数据划分成k个桶。每个桶内的数据值都是相同的,省掉了桶内排序的时间。

计数排序只能用在数据范围不大的场景中,如果数据范围k比要排序的数据n大很多,就不适合用计数排序了。而且,计数排序只能给非负整数排序,如果要排序的数据是其他类型的,要将其在不改变相对大小的情况下,转化为非负整数。

时间复杂度

  • 平均:O(n + k)
  • 最好:O(n + k)
  • 最坏:O(n + k)

空间复杂度

O(n + k)

稳定性

稳定

代码

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
public class CountingSort implements Sort {

@Override
public int[] sort(int[] arr) {
int n = arr.length;

// 查找数组中数据的范围
int max = arr[0];
for (int i = 1; i < n; ++i) {
if (max < arr[i]) {
max = arr[i];
}
}

// 申请一个计数数组 c,下标大小 [0, max]
int[] c = new int[max + 1];
for (int i = 0; i <= max; ++i) {
c[i] = 0;
}

// 计算每个元素的个数,放入 c 中
for (int value : arr) {
c[value]++;
}

// 依次累加
for (int i = 1; i <= max; ++i) {
c[i] = c[i - 1] + c[i];
}

// 临时数组 r,存储排序之后的结果
int[] r = new int[n];
// 计算排序的关键步骤,有点难理解
for (int i = n - 1; i >= 0; --i) {
int index = c[arr[i]] - 1;
r[index] = arr[i];
c[arr[i]]--;
}

// 将结果拷贝给 a 数组
System.arraycopy(r, 0, arr, 0, n);

return arr;
}

}

概念

将要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行排序。桶内排完序之后,再把每个桶里的数据按照顺序依次取出,组成的序列就是有序的了。

桶排序比较适合用在外部排序中。所谓的外部排序就是数据存储在外部磁盘中,数据量比较大,内存有限,无法将数据全部加载到内存中。

时间复杂度

  • 平均:O(n + k)
  • 最好:O(n)
  • 最坏:O(n²)

空间复杂度

O(n + k)

稳定性

稳定

要求

  • 要排序的数据需要很容易就能划分成 m 个桶,并且,桶与桶之间有着天然的大小顺序。
  • 数据在各个桶之间的分布是比较均匀的。

代码

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
public class BucketSort implements Sort {

@Override
public int[] sort(int[] arr) {
// 遍历数据,获取数据范围
// 通常不需要该步骤,比如学分0-100,年龄0-120
int max = Integer.MIN_VALUE;
int min = Integer.MAX_VALUE;
for (int value : arr) {
max = Math.max(max, value);
min = Math.min(min, value);
}

// 根据数据范围和数据量计算桶个数
// 通常不需要该步骤
int bucketNum = (max - min) / arr.length + 1;
ArrayList<ArrayList<Integer>> bucketArr = new ArrayList<>(bucketNum);
for (int i = 0; i < bucketNum; i++) {
bucketArr.add(new ArrayList<>());
}

// 遍历数据,将每个元素放入桶
for (int value : arr) {
// 计算数据应放入的桶下标
int num = (value - min) / (arr.length);
bucketArr.get(num).add(value);
}

// 对每个桶进行排序
for (ArrayList<Integer> integers : bucketArr) {
Collections.sort(integers);
}

// 依次从桶中取出数据
int[] result = new int[arr.length];

int i = 0;
for (ArrayList<Integer> values : bucketArr) {
for (Integer value : values) {
result[i++] = value;
}
}

return result;
}
}

我们可以把源文件与源文件之间的依赖关系,抽象成一个有向图。每个源文件对应图中的一个顶点,源文件之间的依赖关系就是顶点之间的边。
如果a先于b执行,也就是说b依赖于a,那么就在就在顶点a和顶点b之间,构建一条从a指向b的边。而且,这个图不仅要是有向图,还要是一个有向无环图,也就是不能存在像 a->b->c->a 这样的循环依赖关系。因为图中一旦出现环,拓扑排序就无法工作了。实际上,拓扑排序本身就是基于有向无环图的一个算法。

Kahn算法

Kahn 算法实际上用的是贪心算法思想。它在定义数据结构的时候,如果s需要先于t执行,那就添加一条s指向t的边。所以,如果某个顶点入度为0, 也就表示,没有任何顶点必须先于这个顶点执行,那么这个顶点就可以执行了。
我们先从图中,找出一个入度为0的顶点,将其输出到拓扑排序的结果序列中(对应代码中就是把它打印出来),并且把这个顶点从图中删除(也就是把这个顶点可达的顶点的入度都减1)。我们循环执行上面的过程,直到所有的顶点都被输出。最后输出的序列,就是满足局部依赖关系的拓扑排序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public void topoSortByKahn() {
int[] inDegree = new int[v]; // 统计每个顶点的入度
for (int i = 0; i < v; ++i) {
for (int j = 0; j < adj[i].size(); ++j) {
int w = adj[i].get(j); // i->w
inDegree[w]++;
}
}
LinkedList<Integer> queue = new LinkedList<>();
for (int i = 0; i < v; ++i) {
if (inDegree[i] == 0) queue.add(i);
}
while (!queue.isEmpty()) {
int i = queue.remove();
System.out.print("->" + i);
for (int j = 0; j < adj[i].size(); ++j) {
int k = adj[i].get(j);
inDegree[k]--;
if (inDegree[k] == 0) queue.add(k);
}
}
}

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
public void topoSortByDFS() {
// 先构建逆邻接表,边 s->t 表示,s 依赖于 t,t 先于 s
LinkedList<Integer> inverseAdj[] = new LinkedList[v];
for (int i = 0; i < v; ++i) { // 申请空间
inverseAdj[i] = new LinkedList<>();
}
for (int i = 0; i < v; ++i) { // 通过邻接表生成逆邻接表
for (int j = 0; j < adj[i].size(); ++j) {
int w = adj[i].get(j); // i->w
inverseAdj[w].add(i); // w->i
}
}
boolean[] visited = new boolean[v];
for (int i = 0; i < v; ++i) { // 深度优先遍历图
if (visited[i] == false) {
visited[i] = true;
dfs(i, inverseAdj, visited);
}
}
}

private void dfs(
int vertex, LinkedList<Integer> inverseAdj[], boolean[] visited) {
for (int i = 0; i < inverseAdj[vertex].size(); ++i) {
int w = inverseAdj[vertex].get(i);
if (visited[w] == true) continue;
visited[w] = true;
dfs(w, inverseAdj, visited);
} // 先把 vertex 这个顶点可达的所有顶点都打印出来之后,再打印它自己
System.out.print("->" + vertex);
}

这个算法包含两个关键部分。第一部分是通过邻接表构造逆邻接表。第二部分是这个算法的核心,也就是递归处理每个顶点。

概念

  • 图(有向图、无向图、带权图)
  • 顶点
  • 度(入度、出度)

存储

邻接矩阵

邻接矩阵的底层依赖一个二维数组。
对于无向图来说,如果顶点i与顶点j之间有边,我们就将A[i][j]和A[j][i]标记为1。
对于有向图来说,如果顶点i到顶点j之间,有一条箭头从顶点i指向顶点j的边,那我们就将A[i][j] 标记为 1。同理,如果有一条箭头从顶点 j 指向顶点 i 的边,我们就将 A[j][i] 标记为 1。
对于带权图,数组中就存储相应的权重。
邻接矩阵存储示例

优缺点

优点

邻接矩阵的存储方式简单、直接,因为基于数组,所以在获取两个顶点的关系时,就非常高效。其次,用邻接矩阵存储图的另外一个好处是方便计算。

缺点

对于无向图来说,如果我们将其用对角线划分为上下两部分,那我们只需要利用上面或者下面这样一半的空间就足够了,另外一半白白浪费掉了。
如果我们存储的是稀疏图(Sparse Matrix),也就是说,顶点很多,但每个顶点的边并不多,那邻接矩阵的存储方法就更加浪费空间了。

邻接表存储

每个顶点对应一条链表,链表中存储的是与这个顶点相连接的其他顶点。
如下图:是一个有向图的邻接表存储方式,每个顶点对应的链表里面,存储的是指向的顶点。
邻接表存储示例(有向图)
对于无向图来说,也是类似的,不过,每个顶点的链表中存储的,是跟这个顶点有边相连的顶点。

优缺点

邻接表存储起来比较节省空间,但是使用起来就比较耗时间。

搜索

广度优先

直观地讲,它其实就是一种“地毯式”层层推进的搜索策略,即先查找离起始顶点最近的,然后是次近的,依次往外搜索。
广度优先搜索示例

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
public void bfs(int s, int t) {
if (s == t) {
return;
}

// 记录已经被访问的顶点
boolean[] visited = new boolean[v];
visited[s] = true;

// 存储已经访问,但相连的顶点还没有被访问的顶点
Queue<Integer> queue = new LinkedList<>();
queue.add(s);

// 记录搜索路径(prev[w] 存储的是,顶点 w 是从哪个前驱顶点遍历过来)
int[] prev = new int[v];
for (int i = 0; i < v; ++i) {
prev[i] = -1;
}

while (queue.size() != 0) {
int w = queue.poll();
for (int i = 0; i < adj[w].size(); ++i) {
int q = adj[w].get(i);
if (!visited[q]) {
prev[q] = w;
if (q == t) {
print(prev, s, t);
return;
}
visited[q] = true;
queue.add(q);
}
}
}
}

/**
* 递归打印 s->t 的路径
*/
private void print(int[] prev, int s, int t) {
if (prev[t] != -1 && t != s) {
print(prev, s, prev[t]);
}
System.out.print(t + " ");
}

深度优先

最直观的例子就是“走迷宫”。
深度优先搜索示例

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
/**
* 是否已找到
*/
private boolean found = false;

/**
* 深度优先遍历
*/
public void dfs(int s, int t) {
found = false;

// 记录已经被访问的顶点
boolean[] visited = new boolean[v];

// 记录搜索路径(prev[w] 存储的是,顶点 w 是从哪个前驱顶点遍历过来)
int[] prev = new int[v];
for (int i = 0; i < v; ++i) {
prev[i] = -1;
}

recurDfs(s, t, visited, prev);

print(prev, s, t);
}

/**
* 递归遍历
*/
private void recurDfs(int w, int t, boolean[] visited, int[] prev) {
if (found) {
return;
}

visited[w] = true;

if (w == t) {
found = true;
return;
}

for (int i = 0; i < adj[w].size(); ++i) {
int q = adj[w].get(i);
if (!visited[q]) {
prev[q] = w;
recurDfs(q, t, visited, prev);
}
}
}