【源码解读之 Mybatis】【基础篇】-- 第篇:SqlSession的创建与生命周期

骨晌夭章建图函数

java

List[] buildGraph(int numCourses, int[][] prerequisites) {

// 图中共有 numCourses 个节点

List[] graph = new LinkedList[numCourses];

for (int i = 0; i < numCourses; i++) {

graph[i] = new LinkedList<>();

}

for (int[] edge : prerequisites) {

int from = edge[1], to = edge[0];

// 添加一条从 from 指向 to 的有向边

// 边的方向是「被依赖」关系,即修完课程 from 才能修课程 to

graph[from].add(to);

}

return graph;

}

环检测算法

DFS

java

// 记录一次递归堆栈中的节点

boolean[] onPath;

// 记录遍历过的节点,防止走回头路

boolean[] visited;

// 记录图中是否有环

boolean hasCycle = false;

boolean canFinish(int numCourses, int[][] prerequisites) {

List[] graph = buildGraph(numCourses, prerequisites);

visited = new boolean[numCourses];

onPath = new boolean[numCourses];

for (int i = 0; i < numCourses; i++) {

// 遍历图中的所有节点

traverse(graph, i);

}

// 只要没有循环依赖可以完成所有课程

return !hasCycle;

}

void traverse(List[] graph, int s) {

if (onPath[s]) {

// 出现环

hasCycle = true;

}

if (visited[s] || hasCycle) {

// 如果已经找到了环,也不用再遍历了

return;

}

// 前序代码位置

visited[s] = true;

onPath[s] = true;

for (int t : graph[s]) {

traverse(graph, t);

}

// 后序代码位置

onPath[s] = false;

}

BFS

java

// 主函数

public boolean canFinish(int numCourses, int[][] prerequisites) {

// 建图,有向边代表「被依赖」关系

List[] graph = buildGraph(numCourses, prerequisites);

// 构建入度数组

int[] indgree = new int[numCourses];

for (int[] edge : prerequisites) {

int from = edge[1], to = edge[0];

// 节点 to 的入度加一

indgree[to]++;

}

// 根据入度初始化队列中的节点

Queue q = new LinkedList<>();

for (int i = 0; i < numCourses; i++) {

if (indgree[i] == 0) {

// 节点 i 没有入度,即没有依赖的节点

// 可以作为拓扑排序的起点,加入队列

q.offer(i);

}

}

// 记录遍历的节点个数

int count = 0;

// 开始执行 BFS 循环

while (!q.isEmpty()) {

// 弹出节点 cur,并将它指向的节点的入度减一

int cur = q.poll();

count++;

for (int next : graph[cur]) {

indgree[next]--;

if (indgree[next] == 0) {

// 如果入度变为 0,说明 next 依赖的节点都已被遍历

q.offer(next);

}

}

}

// 如果所有节点都被遍历过,说明不成环

return count == numCourses;

}

这段 BFS 算法的思路:

1、构建邻接表,和之前一样,边的方向表示「被依赖」关系。

2、构建一个 indegree 数组记录每个节点的入度,即 indegree[i] 记录节点 i 的入度。

3、对 BFS 队列进行初始化,将入度为 0 的节点首先装入队列。

4、开始执行 BFS 循环,不断弹出队列中的节点,减少相邻节点的入度,并将入度变为 0 的节点加入队列。

5、如果最终所有节点都被遍历过(count 等于节点数),则说明不存在环,反之则说明存在环。

拓扑排序算法

对一个有向无环图(Directed Acyclic Graph简称DAG)G进行拓扑排序,是将G中所有顶点排成一个线性序列,使得图中任意一对顶点u和v,若边(u,v)∈E(G),则u在线性序列中出现在v之前。通常,这样的线性序列称为满足拓扑次序(Topological Order)的序列,简称拓扑序列。简单的说,由某个集合上的一个偏序得到该集合上的一个全序,这个操作称之为拓扑排序。

DFS

java

// 记录后序遍历结果

List postorder = new ArrayList<>();

// 记录是否存在环

boolean hasCycle = false;

boolean[] visited, onPath;

// 主函数

public int[] findOrder(int numCourses, int[][] prerequisites) {

List[] graph = buildGraph(numCourses, prerequisites);

visited = new boolean[numCourses];

onPath = new boolean[numCourses];

// 遍历图

for (int i = 0; i < numCourses; i++) {

traverse(graph, i);

}

// 有环图无法进行拓扑排序

if (hasCycle) {

return new int[]{};

}

// 逆后序遍历结果即为拓扑排序结果

Collections.reverse(postorder);

int[] res = new int[numCourses];

for (int i = 0; i < numCourses; i++) {

res[i] = postorder.get(i);

}

return res;

}

// 图遍历函数

void traverse(List[] graph, int s) {

if (onPath[s]) {

// 发现环

hasCycle = true;

}

if (visited[s] || hasCycle) {

return;

}

// 前序遍历位置

onPath[s] = true;

visited[s] = true;

for (int t : graph[s]) {

traverse(graph, t);

}

// 后序遍历位置

postorder.add(s);

onPath[s] = false;

}

BFS

java

// 主函数

public int[] findOrder(int numCourses, int[][] prerequisites) {

// 建图,和环检测算法相同

List[] graph = buildGraph(numCourses, prerequisites);

// 计算入度,和环检测算法相同

int[] indgree = new int[numCourses];

for (int[] edge : prerequisites) {

int from = edge[1], to = edge[0];

indgree[to]++;

}

// 根据入度初始化队列中的节点,和环检测算法相同

Queue q = new LinkedList<>();

for (int i = 0; i < numCourses; i++) {

if (indgree[i] == 0) {

q.offer(i);

}

}

// 记录拓扑排序结果

int[] res = new int[numCourses];

// 记录遍历节点的顺序(索引)

int count = 0;

// 开始执行 BFS 算法

while (!q.isEmpty()) {

int cur = q.poll();

// 弹出节点的顺序即为拓扑排序结果

res[count] = cur;

count++;

for (int next : graph[cur]) {

indgree[next]--;

if (indgree[next] == 0) {

q.offer(next);

}

}

}

if (count != numCourses) {

// 存在环,拓扑排序不存在

return new int[]{};

}

return res;

}

二分图判定算法

二分图的顶点集可分割为两个互不相交的子集,图中每条边依附的两个顶点都分属于这两个子集,且两个子集内的顶点不相邻。

给你一幅「图」,请你用两种颜色将图中的所有顶点着色,且使得任意一条边的两个端点的颜色都不相同,你能做到吗?

这就是图的「双色问题」,其实这个问题就等同于二分图的判定问题,如果你能够成功地将图染色,那么这幅图就是一幅二分图,反之则不是

DFS

java

// 记录图是否符合二分图性质

private boolean ok = true;

// 记录图中节点的颜色,false 和 true 代表两种不同颜色

private boolean[] color;

// 记录图中节点是否被访问过

private boolean[] visited;

// 主函数,输入邻接表,判断是否是二分图

public boolean isBipartite(int[][] graph) {

int n = graph.length;

color = new boolean[n];

visited = new boolean[n];

// 因为图不一定是联通的,可能存在多个子图

// 所以要把每个节点都作为起点进行一次遍历

// 如果发现任何一个子图不是二分图,整幅图都不算二分图

for (int v = 0; v < n; v++) {

if (!visited[v]) {

traverse(graph, v);

}

}

return ok;

}

// DFS 遍历框架

private void traverse(int[][] graph, int v) {

// 如果已经确定不是二分图了,就不用浪费时间再递归遍历了

if (!ok) return;

visited[v] = true;

for (int w : graph[v]) {

if (!visited[w]) {

// 相邻节点 w 没有被访问过

// 那么应该给节点 w 涂上和节点 v 不同的颜色

color[w] = !color[v];

// 继续遍历 w

traverse(graph, w);

} else {

// 相邻节点 w 已经被访问过

// 根据 v 和 w 的颜色判断是否是二分图

if (color[w] == color[v]) {

// 若相同,则此图不是二分图

ok = false;

}

}

}

}

BFS

java

// 记录图是否符合二分图性质

private boolean ok = true;

// 记录图中节点的颜色,false 和 true 代表两种不同颜色

private boolean[] color;

// 记录图中节点是否被访问过

private boolean[] visited;

public boolean isBipartite(int[][] graph) {

int n = graph.length;

color = new boolean[n];

visited = new boolean[n];

for (int v = 0; v < n; v++) {

if (!visited[v]) {

// 改为使用 BFS 函数

bfs(graph, v);

}

}

return ok;

}

// 从 start 节点开始进行 BFS 遍历

private void bfs(int[][] graph, int start) {

Queue q = new LinkedList<>();

visited[start] = true;

q.offer(start);

while (!q.isEmpty() && ok) {

int v = q.poll();

// 从节点 v 向所有相邻节点扩散

for (int w : graph[v]) {

if (!visited[w]) {

// 相邻节点 w 没有被访问过

// 那么应该给节点 w 涂上和节点 v 不同的颜色

color[w] = !color[v];

// 标记 w 节点,并放入队列

visited[w] = true;

q.offer(w);

} else {

// 相邻节点 w 已经被访问过

// 根据 v 和 w 的颜色判断是否是二分图

if (color[w] == color[v]) {

// 若相同,则此图不是二分图

ok = false;

}

}

}

}

}

Union-Find并查集

大白话就是当我们需要判断两个元素是否在同一个集合里的时候,我们就要想到用并查集。

并查集主要有两个功能:

将两个元素添加到一个集合中。

判断两个元素在不在同一个集合

名称"并查集"直接体现了它的核心功能:合并集合与查询元素所属集合。在英文中,它通常被称为"Union-Find"数据结构或"Disjoint-Set"数据结构。

并查集的基本思想是使用树形结构来表示每个集合,树的根节点作为集合的代表元素。

并查集核心特性:

快速查找:能够快速判断两个元素是否属于同一集合

快速合并:能够快速将两个集合合并为一个

路径压缩:优化查找操作,使树的高度尽量小

按秩合并:优化合并操作,减少树的高度增长

Union-Find 算法主要需要实现这两个 API:

java

class UF {

/* 将 p 和 q 连接 */

public void union(int p, int q);

/* 判断 p 和 q 是否连通 */

public boolean connected(int p, int q);

/* 返回图中有多少个连通分量 */

public int count();

}

这里所说的「连通」是一种等价关系,也就是说具有如下三个性质:

1、自反性:节点p和p是连通的。

2、对称性:如果节点p和q连通,那么q和p也连通。

3、传递性:如果节点p和q连通,q和r连通,那么p和r也连通。

比如说有一幅图,0~9 任意两个不同的点都不连通,调用connected都会返回 false,连通分量为 10 个。

如果现在调用union(0, 1),那么 0 和 1 被连通,连通分量降为 9 个。

再调用union(1, 2),这时 0,1,2 都被连通,调用connected(0, 2)也会返回 true,连通分量变为 8 个。

基础算法

设定树的每个节点有一个指针指向其父节点,如果是根节点的话,这个指针指向自己。比如说刚才那幅 10 个节点的图,一开始的时候没有相互连通,就是这样:

java

class UF {

// 记录连通分量

private int count;

// 节点 x 的节点是 parent[x]

private int[] parent;

/* 构造函数,n 为图的节点总数 */

public UF(int n) {

// 一开始互不连通

this.count = n;

// 父节点指针初始指向自己

parent = new int[n];

for (int i = 0; i < n; i++)

parent[i] = i;

}

/* 其他函数 */

}

如果某两个节点被连通,则让其中的(任意)一个节点的根节点接到另一个节点的根节点上:

java

public void union(int p, int q) {

int rootP = find(p);

int rootQ = find(q);

if (rootP == rootQ)

return;

// 将两棵树合并为一棵

parent[rootP] = rootQ;

// parent[rootQ] = rootP 也一样

count--; // 两个分量合二为一

}

/* 返回某个节点 x 的根节点 */

private int find(int x) {

// 根节点的 parent[x] == x

while (parent[x] != x)

x = parent[x];

return x;

}

/* 返回当前的连通分量个数 */

public int count() {

return count;

}

这样,如果节点p和q连通的话,它们一定拥有相同的根节点:

java

public boolean connected(int p, int q) {

int rootP = find(p);

int rootQ = find(q);

return rootP == rootQ;

}

至此,Union-Find 算法就基本完成了。

那么这个算法的复杂度是多少呢?我们发现,主要 APIconnected和union中的复杂度都是find函数造成的,所以说它们的复杂度和find一样。

find主要功能就是从某个节点向上遍历到树根,其时间复杂度就是树的高度。我们可能习惯性地认为树的高度就是logN,但这并不一定。logN的高度只存在于平衡二叉树,对于一般的树可能出现极端不平衡的情况,使得「树」几乎退化成「链表」,树的高度最坏情况下可能变成N。

所以说上面这种解法,find,union,connected的时间复杂度都是 O(N)。这个复杂度很不理想的,你想图论解决的都是诸如社交网络这样数据规模巨大的问题,对于union和connected的调用非常频繁,每次调用需要线性时间完全不可忍受。

平衡性优化

要知道哪种情况下可能出现不平衡现象,关键在于union过程:

java

public void union(int p, int q) {

int rootP = find(p);

int rootQ = find(q);

if (rootP == rootQ)

return;

// 将两棵树合并为一棵

parent[rootP] = rootQ;

// parent[rootQ] = rootP 也可以

count--;

}

我们一开始就是简单粗暴的把p所在的树接到q所在的树的根节点下面,那么这里就可能出现「头重脚轻」的不平衡状况,比如下面这种局面:

长此以往,树可能生长得很不平衡。我们其实是希望,小一些的树接到大一些的树下面,这样就能避免头重脚轻,更平衡一些。解决方法是额外使用一个size数组,记录每棵树包含的节点数,我们不妨称为「重量」:

java

class UF {

private int count;

private int[] parent;

// 新增一个数组记录树的"重量"

private int[] size;

public UF(int n) {

this.count = n;

parent = new int[n];

// 最初每棵树只有一个节点

// 重量应该初始化 1

size = new int[n];

for (int i = 0; i < n; i++) {

parent[i] = i;

size[i] = 1;

}

}

/* 其他函数 */

}

比如说size[3] = 5表示,以节点3为根的那棵树,总共有5个节点。这样我们可以修改一下union方法:

java

public void union(int p, int q) {

int rootP = find(p);

int rootQ = find(q);

if (rootP == rootQ)

return;

// 小树接到大树下面,较平衡

if (size[rootP] > size[rootQ]) {

parent[rootQ] = rootP;

size[rootP] += size[rootQ];

} else {

parent[rootP] = rootQ;

size[rootQ] += size[rootP];

}

count--;

}

这样,通过比较树的重量,就可以保证树的生长相对平衡,树的高度大致在logN这个数量级,极大提升执行效率。

此时,find,union,connected的时间复杂度都下降为 O(logN),即便数据规模上亿,所需时间也非常少。

路径压缩

其实我们并不在乎每棵树的结构长什么样,只在乎根节点。

因为无论树长啥样,树上的每个节点的根节点都是相同的,所以能不能进一步压缩每棵树的高度,使树高始终保持为常数?

如图所示,这样每个节点的父节点就是整棵树的根节点,find就能以 O(1) 的时间找到某一节点的根节点,相应的,connected和union复杂度都下降为 O(1)。

要做到这一点主要是修改find函数逻辑,非常简单,但你可能会看到两种不同的写法。

第一种是在find中加一行代码:

java

private int find(int x) {

while (parent[x] != x) {

// 这行代码进行路径压缩

parent[x] = parent[parent[x]];

x = parent[x];

}

return x;

}

用语言描述就是,每次 while 循环都会把一对儿父子节点改到同一层,这样每次调用find函数向树根遍历的同时,顺手就将树高缩短了。

路径压缩的第二种写法是这样:

java

// 第二种路径压缩的 find 方法

public int find(int x) {

if (parent[x] != x) {

parent[x] = find(parent[x]);

}

return parent[x];

}

这个递归过程有点不好理解,你可以自己手画一下递归过程。我把这个函数做的事情翻译成迭代形式,方便你理解它进行路径压缩的原理:

java

// 这段迭代代码方便你理解递归代码所做的事情

public int find(int x) {

// 先找到根节点

int root = x;

while (parent[root] != root) {

root = parent[root];

}

// 然后把 x 到根节点之间的所有节点直接接到根节点下面

int old_parent = parent[x];

while (x != root) {

parent[x] = root;

x = old_parent;

old_parent = parent[old_parent];

}

return root;

}

这种路径压缩的效果如下:

比起第一种路径压缩,显然这种方法压缩得更彻底,直接把一整条树枝压平,一点意外都没有。就算一些极端情况下产生了一棵比较高的树,只要一次路径压缩就能大幅降低树高,从 摊还分析 的角度来看,所有操作的平均时间复杂度依然是 O(1),所以从效率的角度来说,推荐你使用这种路径压缩算法。

另外,如果使用路径压缩技巧,那么size数组的平衡优化就不是特别必要了。所以你一般看到的 Union Find 算法应该是如下实现:

java

class UF {

// 连通分量个数

private int count;

// 存储每个节点的父节点

private int[] parent;

// n 为图中节点的个数

public UF(int n) {

this.count = n;

parent = new int[n];

for (int i = 0; i < n; i++) {

parent[i] = i;

}

}

// 将节点 p 和节点 q 连通

public void union(int p, int q) {

int rootP = find(p);

int rootQ = find(q);

if (rootP == rootQ)

return;

parent[rootQ] = rootP;

// 两个连通分量合并成一个连通分量

count--;

}

// 判断节点 p 和节点 q 是否连通

public boolean connected(int p, int q) {

int rootP = find(p);

int rootQ = find(q);

return rootP == rootQ;

}

public int find(int x) {

if (parent[x] != x) {

parent[x] = find(parent[x]);

}

return parent[x];

}

// 返回图中的连通分量个数

public int count() {

return count;

}

}

Union-Find 算法的复杂度可以这样分析:构造函数初始化数据结构需要 O(N) 的时间和空间复杂度;连通两个节点union、判断两个节点的连通性connected、计算连通分量count所需的时间复杂度均为 O(1)。

到这里,相信你已经掌握了 Union-Find 算法的核心逻辑,总结一下我们优化算法的过程:

1、用parent数组记录每个节点的父节点,相当于指向父节点的指针,所以parent数组内实际存储着一个森林(若干棵多叉树)。

2、用size数组记录着每棵树的重量,目的是让union后树依然拥有平衡性,保证各个 API 时间复杂度为 O(logN),而不会退化成链表影响操作效率。

3、在find函数中进行路径压缩,保证任意树的高度保持在常数,使得各个 API 时间复杂度为 O(1)。使用了路径压缩之后,可以不使用size数组的平衡优化。

优点

查找和合并操作的平均时间复杂度接近O(1)

实现简单,易于理解

空间复杂度低,只需要两个数组

适用于处理大量动态连通性问题

缺点

不支持分裂操作(将一个集合分成两个)

不方便查询集合中的所有元素

在某些特殊情况下,性能可能退化

应用场景

Kruskal最小生成树算法:在Kruskal算法中,并查集是核心数据结构。该算法按权重从小到大遍历边,使用并查集判断加入某条边是否会形成环,从而高效构建最小生成树。

网络连通性问题:并查集可高效解决动态连通性问题,比如判断网络中两个节点是否连通、社交网络中用户间的关系连接等。当关系变化时,只需执行简单的union操作,判断连通性时使用find操作即可。

等价类划分:在编译器设计、电路分析等领域,并查集可用于等价类识别与合并。当系统发现两个元素等价时执行union操作,需要判断等价关系时使用find操作,这种动态维护等价关系的能力正是并查集的优势所在。

判断无向图中的环:当向无向图中添加边时,如果边的两个端点已在同一个集合中,则添加这条边会形成环。在很多图算法和网络设计问题中都可以使用这一特性。

Kruskal 最小生成树算法

Kruskal 的 关键就是 并查集算法

先说「树」和「图」的根本区别:树不会包含环,图可以包含环。

如果一幅图没有环,完全可以拉伸成一棵树的模样。说的专业一点,树就是「无环连通图」。

那么什么是图的「生成树」呢,其实按字面意思也好理解,就是在图中找一棵包含图中的所有节点的树。专业点说,生成树是含有图中所有顶点的「无环连通子图」。

容易想到,一幅图可以有很多不同的生成树,比如下面这幅图,红色的边就组成了两棵不同的生成树:

对于加权图,每条边都有权重,所以每棵生成树都有一个权重和。比如上图,右侧生成树的权重和显然比左侧生成树的权重和要小。

那么最小生成树很好理解了,所有可能的生成树中,权重和最小的那棵生成树就叫「最小生成树」。

PS:一般来说,我们都是在无向加权图中计算最小生成树的,所以使用最小生成树算法的现实场景中,图的边权重一般代表成本、距离这样的标量。

所谓最小生成树,就是图中若干边的集合(我们后文称这个集合为mst,最小生成树的英文缩写),你要保证这些边:

1、包含图中的所有节点。

2、形成的结构是树结构(即不存在环)。

3、权重和最小。

前两条其实可以很容易地利用 Union-Find 算法做到,关键在于第 3 点,如何保证得到的这棵生成树是权重和最小的。

这里就用到了贪心思路:将所有边按照权重从小到大排序,从权重最小的边开始遍历,如果这条边和mst中的其它边不会形成环,则这条边是最小生成树的一部分,将它加入mst集合;否则,这条边不是最小生成树的一部分,不要把它加入mst集合。

这样,最后mst集合中的边就形成了最小生成树,算法代码如下:

java

int minimumCost(int n, int[][] connections) {

// 城市编号为 1...n,所以初始化大小为 n + 1

UF uf = new UF(n + 1);

// 对所有边按照权重从小到大排序

Arrays.sort(connections, (a, b) -> (a[2] - b[2]));

// 记录最小生成树的权重之和

int mst = 0;

for (int[] edge : connections) {

int u = edge[0];

int v = edge[1];

int weight = edge[2];

// 若这条边会产生环,则不能加入 mst

if (uf.connected(u, v)) {

continue;

}

// 若这条边不会产生环,则属于最小生成树

mst += weight;

uf.union(u, v);

}

// 保证所有节点都被连通

// 按理说 uf.count() == 1 说明所有节点被连通

// 但因为节点 0 没有被使用,所以 0 会额外占用一个连通分量

return uf.count() == 2 ? mst : -1;