在正式介绍本文的内容之前,先回顾一下数据结构的分类情况,以便更好地理解树和图的遍历过程。
数据结构从逻辑上的线性关系来划分,可以分为线性表和非线性表两类。
所谓线性表,指的是数据在存放过程中符合一个接着一个的状态,如果把它画在纸上就像是一条具有一定线性关系的线段。纯粹的线性表结构包括数组和链表,在数组和链表的基础上又定义出了栈和队列,但它们本质上依然属于线性关系。
非线性表结构主要包括树和图,树结构在于一个节点下面挂着多个节点,以父子之间的上下节点关系形式存在,除了父子关系之外,一般不存在同级节点间的一些瓜葛。而图则可以看成是在树的基础上,各同级节点之间也产生了相互关联,甚至于任何两个节点间,不管远近关系如何,都可以产生联系,因此图更像是一个关系网密切的网状结构。此外,树结构重在节点,其节点内包含了数据和父/子指针等一切数据,而图结构除了节点外,边信息也是至关重要的。
除了这两种大的分类,还包括一些衍生出来的其他结构,比如堆结构可以看作是一个由数组或链表实现的二叉树,并查集可以看作是一个包含了节点集合的树等等。
由此可见,线性表可以看成是一对一的关系,树为一对多的关系,而图则是最为复杂的多对多关系。而之所以把树和图放在一起讲解,主要是因为它们都具有一对多的特点,那么在一定程度上,其遍历过程就都可以按照宽度优先和深度优先的形式去进行。
前置知识到此结束,下面正式开始我们的主角:树和图的遍历算法。
一、二叉树的遍历算法
1、二叉树的深度优先遍历
在二叉树中,所谓的深度优先遍历就是指我们熟悉的先序、中序和后序遍历,它们都是从根节点出发,向着树的深度方向扎下去做遍历操作,而这些遍历的实现,本质上都是递归调用的结果,因此这些遍历过程都可以通过在递归序上做不同时机的打印行为得到。
这里先上二叉树的节点结构:
// binary tree node structure
public static class Node {
public int value;
public Node left;
public Node right;
public Node(int data) {
this.value = data;
}
}
1.1 递归序
递归序,顾名思义就是,一棵树上的所有节点在递归过程中如果被遍历到,就记录下来,最终递归过程结束每个节点被遍历的情况所呈现出来的序列,这一序列能够显示出整个递归过程的踪迹,也就是说,通过递归序,能看到递归过程是如何在节点间一步一步走完的。在递归序中,每个节点都会出现三次,即对应下方代码中的:
1位置:递归函数刚刚开始遍历树,假如当前传入树的节点是头节点head(不为空),这时递归过程第一次遍历到它;
2位置:调用完head节点的左孩子后,递归过程会回到当前的head结点处;
3位置:调用完head节点的右孩子后,递归过程会再次回到当前节点。
经历过三次相遇,递归过程才完整地遍历完head节点。
// recursive process
public static void f(Node head) {
if (head == null) {
return;
}
// 1
f(head.left);
// 2
f(head.right);
// 3
}
这里,只进行了递归过程,没有做打印处理。如果把1、2、3处都做打印处理,便能够显式地看到整个递归序的遍历结果。
举例说明:
1.2 先序遍历
先序遍历是在递归序的基础上,在刚来到以当前节点为头结点的位置时,就做打印操作,最终打印结果为:头-左-右。
// pre-order recursive process
public static void pre(Node head) {
if (head == null) {
return;
}
// 第一次遍历到当前节点就打印
System.out.println(head.value);
pre(head.left);
pre(head.right);
}
我们知道,对于任意一个递归行为,都可以用非递归操作来代替,所以这里我们把先序遍历的非递归版本也放在下面,以供参考:
// pre-order non-recursive process
public static void pre2(Node head) {
if(head != null){
// 传入的节点不为空,就创建一个栈,把头节点压栈
Stack<Node> stack = new Stack<>();
stack.push(head);
// 只要栈不为空,就一直弹栈、打印、压栈
while(!stack.isEmpty){
// 把当前栈顶元素弹出
Node cur = stack.pop();
// 弹出即打印
System.out.print(cur.value + " ");
// 将当前弹出节点的右孩子、左孩子依次压栈
// 这里是由于弹栈时会按照逆序来弹,因此要保证弹出顺序为左-右,压栈顺序就得是右-左
if(cur.right != null){
stack.push(cur.right);
}
if(cur.left != null){
stack.push(cur.left);
}
}
}
System.out.println();
}
1.3 中序遍历
中序遍历是在递归序的基础上,在调用完当前节点的左孩子时,才做打印操作,最终打印结果为:左-头-右。
// in-order recursive process
public static void in(Node head) {
if (head == null) {
return;
}
in(head.left);
// 第二次遍历到当前节点才打印
System.out.println(head.value);
in(head.right);
}
非递归版本实现:
// in-order non-recursive process
public static void in2(Node head) {
if(head != null){
// 传入的节点不为空,就创建一个栈,把头节点压栈
Stack<Node> stack = new Stack<>();
stack.push(head);
// 只要当前节点存在左孩子或者栈不为空,就持续该循环
while(head.left != null || !stack.isEmpty){
// 如果当前节点的左孩子存在,就先把左孩子压栈,然后head来到左孩子处,再重新进入外层循环
// 也就是说,如果head节点有很多个左孩子,那这个过程就会一直把所有的左孩子压进去
if(head.left != null){
stack.push(head.left);
head = head.left;
}else{
//如果进入当前条件,说明,当前节点head已经没有左孩子了,此时要把栈顶元素弹出并打印,然后去看cur有没有右孩子
Node cur = stack.pop();
// 弹出即打印
System.out.print(cur.value + " ");
// 如果cur节点有右孩子,就让head指向右孩子,然后head会重新进入外层循环,去看它所指的这个右孩子是否有左孩子
// 如果有,就进行同样的压左孩子最后弹出打印并判断有无右孩子这一系列过程
head = cur.right;
}
}
}
System.out.println();
}
1.4 后序遍历
后序遍历是在递归序的基础上,在调用完当前节点的左孩子和后孩子后,才做打印操作,最终打印结果为:左-右-头。
// pos-order recursive process
public static void pos(Node head) {
if (head == null) {
return;
}
pos(head.left);
pos(head.right);
// 第三次遍历到当前节点才打印
System.out.println(head.value);
}
后序遍历的结果为左-右-头,我们观察到它的逆序为头-右-左,这和先序遍历的非递归版本非常相似,因此在后序遍历的非递归版本中,只需要对先序遍历非递归版本中的压栈过程调换顺序,先压左孩子,再压右孩子,便能得到头-右-左的遍历结果,再将这一结果压入一个新的栈,然后弹出,便能得到后序遍历的结果左-右-头。
非递归版本实现:
// pos-order non-recursive process
public static void pos2(Node head) {
if(head != null){
// 传入的节点不为空,就创建一个栈,把头节点压栈
Stack<Node> stack = new Stack<>();
stack.push(head);
// 只要栈不为空,就一直循环下去
while(!stack.isEmpty){
// 把当前栈顶元素弹出
Node cur = stack.pop();
// 这里弹出不打印,而是把结果压入一个新的栈res中,等整个流程操作完,再把res中的元素依次弹出打印
Stack<Node> res = new Stack<>();
res.push(cur);
// 将当前弹出节点的左孩子、右孩子依次压栈
// 这里对先序遍历中的压栈顺序做了调换,先压左孩子,再压右孩子,由于便能得到弹出顺序右-左
if(cur.left != null){
stack.push(cur.left);
}
if(cur.right != null){
stack.push(cur.right);
}
}
}
// 代码运行到这里,说明已经按照头-右-左的顺序把结果放进res中了,接下来把res中的元素依弹出打印
while(!res.isEmpty()){
System.out.print(res.pop().value + " ");
}
System.out.println();
}
2、二叉树的宽度优先遍历
所谓二叉树的宽度优先遍历,就是指二叉树的层序遍历过程。不同于前面几种深度优先的遍历方法的压栈操作,层序遍历是一层一层的扫描,只要某个节点先被扫描到,就先打印,所以对于层序来说,通常采用队列的结构来进行操作。
// level traversal binary tree
public static void level(Node head){
if(head == null){
return;
}
Queue<Node> queue = new Queue<>(); // 创建一个队列
queue.add(head); // 将传进来的头结点入队
while(!queue.isEmpty){ // 只要队列不为空,就一直弹出节点
Node cur = queue.poll();
System.out.print(cur.value + " "); // 出队即打印
if(cur.left != null){
queue.add(cur.left); // 如果左孩子不为空,就让左孩子入队
}
if(cur.right != null){
queue.add(cur.right); // 如果右孩子不为空,就让右孩子入队
}
}
System.out.println();
}
可以看出,层序遍历的过程代码和先序遍历非常相似,都是在节点被弹出时打印,也都是要依次把左右孩子压入。不同点在于,先序遍历使用栈结构,并且由于栈的先进后出,所以要先压右孩子,再压左孩子;而层序遍历则采用队列结构,因此先压左孩子,再压右孩子。
二、图的遍历算法
在遍历图之前,首先需要对一张图的整体结构有个了解,这里专门创建了一个万能的图结构,下面所介绍的遍历过程都是根据这张图结构来的。
这里也放上节点类Node的相关结构:
// Node class
public class Node {
public int value; // 节点的值
public int in; // 入度
public int out; // 出度
public ArrayList<Node> nexts; // 存放所有直接邻居
public ArrayList<Edge> edges; // 存放由此节点出发往下的所有边
public Node(int value) {
this.value = value;
in = 0;
out = 0;
nexts = new ArrayList<>();
edges = new ArrayList<>();
}
}
1、图的深度优先遍历
图的深度优先遍历通常被称为Depth First Search,即DFS。由于图有多条连通路,因此规定图的深度优先遍历过程为:沿着根节点的某一条路一直走下去,直到这个节点下方再也没有节点或者来到重复节点为止,然后回退去看当前节点的上一个节点是否有另一条通路,如果有就去这条通路遍历到底,如果没有就继续返回上一个节点去查看。直到把根节点下面所有节点的通路都走一遍,遍历过程才停止。对于深度优先遍历,通常采用栈来实现,为了防止出现重复遍历,还需要借助一个set注册表,在遍历时只有没注册过的节点才能入栈,如果表中已经存在该节点,就不做处理。具体实现如下:
// DFS
public void dfs(Node start){
if(start == null){
return;
}
Stack<Node> stack = new Stack<>(); // 用栈来实现深度优先遍历
Set<Node> set=new HashSet<>(); // 用一张set表来表示当前节点是否被注册过
stack.push(start);
set.push(start); // 在往栈中压入新结点时,将其同步压入注册表中
System.out.println(start.value); // 压入即打印
while(!stack.isEmpty){
Node cur = stack.pop();
for(Node next : cur.nexts){ // 遍历当前被弹出节点的next节点们
// 如果这些next节点没有被注册过,就执行下面的程序
// 如果被注册过,就不执行
if(!set.contains(next)){
stack.push(cur); // 将弹出的节点再压回去
stack.push(next); // 将next节点压栈
set.push(next); // 将next节点注册上
System.out.println(next.value); // 注册(压入)即打印
//这里,只要有一个next节点压栈了 那其他的next节点就都不再继续遍历了 回到循环最上面 去弹出节点
break;
}
}
}
}
需要注意的是,在DFS中,只要压入了新节点就打印,而且在压入新节点之前,还需要把它的父节点重新压回栈中去,如此一来,相当于栈中每时每刻都保留了当前的整个遍历路径。此外,在遍历当前节点的直接邻居时,是只遍历其中的一个,把他压栈之后就立即停止,然后弹出当前压入的节点,去看顺着他是否还能找到更深一层的节点,这个处理过程是深度优先遍历的特征所在。
2、图的宽度优先遍历
图的宽度优先遍历算法又叫广度优先算法 Breadth First Search,简称BFS。宽度优先遍历是在遍历的过程中,只要某一个节点有直接邻居,就先把它的所有直接邻居遍历完,然后再依次遍历这些直接邻居的直接邻居,类似于二叉树的层序遍历过程。在进行宽度优先遍历的时候,通常需要用到队列结构,保证先遍历到的先弹出打印,另外和深度优先遍历一样,依然需要一个注册表set,来保证不会出现重复遍历的情况。具体过程如下:
// BFS
public void bfs(Node start){
if(start == null){
return;
}
Queue<Node> queue = new Queue<>(); // 准备一个队列
HashSet<Node> set = new HashSet<>(); // 准备一个注册表
queue.add(start);
set.add(start);
while(!queue.isEmpty){
Node cur = queue.poll(); // 只要队列不空,就一直往外弹
System.out.println(cur.value); // 弹出即打印
for(Node next : cur.nexts){ // 遍历当前弹出节点的直接邻居
if(!set.contains(next)){ // 如果直接邻居没有被注册过
queue.add(next); // 就将邻居放进队列
set.add(next); // 放进队列后在set中注册上
}
}
}
}
相比图的深度优先遍历,其宽度优先遍历同样要借助一张注册表来防止重复遍历的问题,这是图结构本身各节点间相同联通的属性所造成的。但不同点在于,首先宽度优先不是采用栈结构,而是采用队列的方式,再者宽度优先也并不是只遍历当前节点的某一个直接邻居就停止,然后往下去找更深层次的节点。与之相反,宽度优先要先找到当前节点的所有直接邻居,而后才往下去找。
另外,相比二叉树的层序遍历来说,图的宽度优先基本上与树的层序相一致,都要用队列来实现,也都要先把相同层次的节点处理完了才去处理下一层的节点。两者的区别在于,二叉树只有两个叉,并且各相同层次的节点间不构成联通关系,而图相当于是多叉树并且同一层次甚至任意层次的节点间都能相互联通在一起,所以树只需要关注左右两个孩子,而图需要关注所有的直接邻居;另外为了遍历的节点不重复,图还需要用一张注册表来过滤重复节点。
3、图的拓扑排序算法
对于图的遍历,除了深度优先DFS和宽度优先BFS两种方法之外,还可以通过拓扑序(Topological Order)来实现。图的拓扑序是针对有向无环图而言的,这里可以借助编译过程中的依赖关系来理解,假设A包依赖B和C包,而B包又依赖C包,C包依赖D包,那么编译过程就需要先编译D包,然后才能编译C包,再由C包到B包,最后B、C两个包也编译好了再去编译A包。用图形表示就是下面这样的:
这里的拓扑序就是对应于这样的依赖关系,因此它的拓扑序为 A-B-C-D。如果有多个元素都依赖于同一个元素,也就是好几个节点都指向同一个节点的话,那么这几个节点的顺序可能不是唯一的,例如以下:
这样的图它的拓扑序既可以是A-B-C-D,也可以是B-A-C-D。
由于我们强调拓扑序是面向有向图的,那么要求它无环也就不难理解了,如果有向图还带着环,那它就永远也不能遍历完所有节点,就像循环依赖一样,无法得知何时才是循环的尽头,也就不知道该从哪个包开始编译起了。
注意:本篇文章除非特别说明,否则所有对图的求解过程依然是基于这样的一个万能的图结构(建议先进链接阅读一边),主要结构如下:
// graph structure
public class Graph {
public HashMap<Integer, Node> nodes;
public HashSet<Edge> edges;
public Graph() {
nodes = new HashMap<>();
edges = new HashSet<>();
}
}
// node structure
public class Node {
public int value;
public int in;
public int out;
public ArrayList<Node> nexts;
public ArrayList<Edge> edges;
public Node(int value) {
this.value = value;
in = 0;
out = 0;
nexts = new ArrayList<>();
edges = new ArrayList<>();
}
}
// edge structure
public class Edge {
public int weight;
public Node from;
public Node to;
public Edge(int weight, Node from, Node to) {
this.weight = weight;
this.from = from;
this.to = to;
}
}
3.1 BFS实现图的拓扑序
BFS求解图的拓扑排序主要包含以下步骤:
- 在图中找到所有入度为0的点输出;
- 把所有入度为0的点在图中删掉,继续找入度为0的点输出,周而复始;
- 图的所有点都被删除后,依次输出的顺序就是拓扑排序。
上代码:
public static List<Node> topologySort(Graph graph){
// 先搞一张存放节点入度的Map出来,取名inMap
HashMap<Node, Integer> inMap = new HashMap<>();
// 然后搞一个存放入度为0的队列
List<Node> zeroInQueue = new LinkedList<>();
// 遍历这张图,把所有节点及其入度存进inMap中,并找到所有入度为0的节点存进zeroInQueue中
for(Node node : graph.nodes.value){
inMap.put(node, node.in);
if(node.in == 0){
zeroInQueue.add(node);
}
}
// 准备一个收集答案的链表,把从入度为0的表中弹出的节点,依次装进来
List<Node> result = new ArrayList<>();
while(!zeroInQueue.isEmpty()){
Node cur = zeroInQueue.poll(); // 从zeroInQueue中依次弹出节点,这些节点的入度都为0
result.add(cur); // 从zeroInQueue中弹出的节点,直接装进result中
// 然后要消除当前弹出的入度为0的节点对于它直接邻居的影响
// 也就是说,要找到当前节点的所有next节点,把它们的入度减1
for(Node next : cur.nexts){
inMap.put(next, next.in - 1); // 在inMap中把这些节点的入度减1
if(inMap.get(next) == 0){ // 如果减完1,有节点的入度变为0了,就把他装进zeroInQueue中
zeroInQueue.add(next);
}
}
}
// 以上所有流程走完,result就依次收集到了整个过程中入度为0的节点,也就是我们要的拓扑序
return result;
}
附上一道利用BFS求解拓扑序的题目:LintCode第127题。注意:下面采用DFS方法求解图的拓扑序算法的过程我们依然是以此题为背景的。
题目描述:
给定一个有向图,图节点的拓扑排序定义如下:
1.对于图中的每一条有向边 A -> B , 在拓扑排序中A一定在B之前;
2.拓扑排序中的第一个节点可以是图中的任何一个没有其他节点指向它的节点。
求解:针对给定的有向图找到任意一种拓扑排序的顺序。
给定节点结构及求解输入:
class DirectedGraphNode {
public int label;
public ArrayList<DirectedGraphNode> neighbors;
public DirectedGraphNode(int x) {
label = x;
neighbors = new ArrayList<>();
}
}
public class Solution{
/**
* @param graph: A list of Directed graph node
* @return: Any topological order for the given graph.
*/
// TODO
}
需要注意的是,这道题给出的节点结构是不含有入度属性的,所以根据上述代码,在创建出inMap表后,需要手动把values位置的入度给设置好,可以通过下述方式:
public class Solution{
// 题目本身定义的结构名字有点长,但名字长只是用来唬人的,换汤不换药,不用害怕
public static ArrayList<DirectedGraphNode> topologicalOrderBFS(ArrayList<DirectedGraphNode> graph){
// 照样是先搞出来一个存放所有节点入度的hashMap,取名inMap
HashMap<DirectedGraphNode, Integer> inMap = new HashMap<>();
// 然后这一步本来我们是要遍历整张图,把所有节点及其入度存进inMap中
// 但是题目定义的节点没有入度的概念,所以需要我们手动去操作一下
for(DirectedGraphNode node : graph){
// 先把所有节点放进inMap中,然后所有结点入度都设为0
inMap.put(node, 0);
}
for(DirectedGraphNode node : graph){
// 这里再遍历整个图一次,找到所有节点的直接邻居,把每一个直接邻居的入度加1
for(DirectedGraphNode next : node.neighbors){
inMap.put(next, inMap.get(next) + 1);
}
}
// 经过两次遍历的操作,inMap表已经被填好了,接下来的操作就和上面求拓扑序的做法一模一样了
List<DirectedGraphNode> zeroInQueue = new LinkedList<>();
// hashMap的keySet()方法能够返回一个存放着所有key的Set表
// 这里key就是节点,inMap.keySet()就会返回一个含有所有节点的集合
for(DirectedGraphNode cur : inMap.keySet()){
if(inMap.get(cur) == 0){
zeroInQueue.add(cur);
}
}
// 准备一个要返回的结果链表
List<DirectedGraphNode> result = new ArrayList<>();
while(!zeroInQueue.isEmpty()){
DirectedGraphNode cur = zeroInQueue.poll();
result.add(cur);
for(DirectedGraphNode next : cur.neighbors){
inMap.put(next, inMap.get(next) - 1);
if(inMap.get(next) == 0){
zeroInQueue.add(next);
}
}
}
return result;
}
}
3.2 DFS实现图的拓扑序
还是上面这道LintCode第127题,下面来看通过DFS的方式如何进行求解:
DFS的求解实际上有两种方法:一种是通过比较节点所能遍历到的最大深度来决定拓扑序,另一种是通过比较节点往下遍历到最后一共经历的点次来决定拓扑序。
3.2.1 比较深度信息
我们的思路是,通过递归的方法,求解某个节点能够往下遍历到的最大深度,沿途把每一个节点能遍历到的最大深度记录在一个缓存表中。然后把这些节点根据所能遍历的最大深度进行排序,深度越大,其拓扑序越靠前。
记录表结构,用于记录节点及其能够遍历到的最大深度:
// Record structure
public static class Record{
public DirectedGraphNode node; // 节点
public int depth; // 往下遍历的最大深度
public Record(DirectedGraphNode node, int depth){
this.node = node;
this.depth = depth;
}
}
由于后面要排序,所以这里也需要自定义一个比较器。这个比较器要对节点进行排序,而排序依据是该节点往下遍历到的最大深度,所以比较器应该是一个Record类型,其规则是哪个节点的depth大,哪个节点就排前面:
// comparator structure
public static class NodeComparator implements Comparator<Record>{
@Override
public int compare(Record o1, Record o2){
return o2.depth - o1.depth;
}
}
下面是拓扑序的求解过程:
public static ArrayList<DirectedGraphNode> topologicalOrderDFS(ArrayList<DirectedGraphNode> graph){
// 先搞出一张缓存表order
// key: 当前节点
// value: 由此节点往下的最大深度记录Record(里面存了节点和节点的深度)
HashMap<DirectedGraphNode, Record> order = new HashMap<>();
// 把所有结点的深度信息填入缓存表中
for(DirectedGraphNode cur : graph){
// 这里的process函数就是用来填缓存表的,见下面的process方法
// 把遍历到的每一个节点和整个缓存表传进去,这里的缓存表只有一张,在整个process递归的过程中都是共用这一张
process(cur, order);
}
// 经历过上述代码,所有节点的深度信息就都存在缓存表中了
// 这里把缓存表中的记录Record都拿出来装进一个arrayList中
ArrayList<Record> recordArr = new ArrayList<>();
// hashMap的values()方法会返回一个包含所有value的集合
for(Record r : order.values()) {
recordArr.add(r);
}
// 把这些记录排个序
recordArr.sort(new NodeComparator());
// 从排好序的记录Record中依次取出节点,放入一个arrayList中返回
ArrayList<DirectedGraphNode> ans = new ArrayList<>();
for(Record r : recordArr){
ans.add(r.node);
}
return ans;
}
// 传进来一个节点,和一个全局的缓存表
public static Record process(DirectedGraphNode node, HashMap<DirectedGraphNode, Record> order){
// 先检查缓存中是否有当前节点的记录,如果有,直接从缓存表中取出返回即可
if(order.contains(node)){
return order.get(node);
}
// 如果缓存中没有该记录,就去查当前节点的所有直接邻居,让它们把自己的记录传上来
// 用一个新的Record记录去接收这些邻居的记录,找到邻居中最大的那个深度记录,再加上当前节点自己这一层的深度1之后,把这个新记录存在缓存中,然后返回新记录。
int follow = 0;
for(DirectedGraphNode next : node.neighbors){
// 收集直接邻居中深度最大的那条记录
follow = Math.max(follow, process(next, order).depth);
}
// 经历过上一个for循环,follow就抓到了当前节点的所有邻居中最大的深度
// 这里的ans就表示当前节点可遍历的最大深度记录
Record<DirectedGraphNode, int> ans = new Record<>(node, follow + 1);
// 返回之前,先存进缓存中
order.put(node, ans);
return ans;
}
3.2.2 比较点次信息
通过比较节点往下遍历到最后,一共经历的点次多少,来排拓扑序,比较规则是,遍历点次越多,拓扑序越靠前。这种方法依然是利用递归的思想,并且建立好缓存表,记录遍历过程沿途的节点信息。
这里我们依然用到了Record记录表和比较器,但是由于比较点次信息和比较深度信息的比较规则不同,所以这里的记录表和比较器也会有所不同。
// Record structure
public static class Record{
public DirectedGraphNode node;
public long nodes;
public Record(DirectedGraphNode node, long nodes){
this.node = node;
this.nodes = nodes;
}
}
// comparator structure
public static class NodeComparator implements Comparator<Record>{
@Override
public int compare(Record o1, Record o2){
// 由于我们在Record中定义的nodes是long类型的,所以这里要做个转化
// 哪个节点的点次多,该节点就排前面
return o1 == o2 ? 0 : (o1.nodes > o2.nodes ? -1 : 1 );
}
}
比较点次信息求解拓扑序过程如下:
public static ArrayList<DirectedGraphNode> topologicalOrderDFS2(ArrayList<DirectedGraphNode> graph){
// 依然先搞出一张缓存表
HashMap<DirectedGraphNode, Record> order = new HashMap<>();
for(DirectedGraphNode node : graph){
// 把当前节点和全局缓存表传进process函数中
process(node, order);
}
// 从for循环出来之后,所有节点往下遍历的最大深度信息就被填入缓存表中了
ArrayList<Record> recordArr = new ArrayList<>();
for(Record r : order.values()){
// 把缓存表中的记录拿出来,加入recordArr中
recordArr.add(r);
}
// 在recordArr中对这些节点做个排序,谁的点次多,谁排前面
recordArr.sort(new NodeComparator());
// 排好序后,把这些节点依次放入ans中返回
ArrayList<DirectedGraphNode> ans = new ArrayList<>();
for(Record r : recordArr){
ans.add(r.node);
}
return ans;
}
public static Record<DirectedGraphNode> process(DirectedGraphNode node, HashMap<DirectedGraphNode, Record> order){
// 如果缓存表中存在该节点的记录,就直接返回记录
if(order.contains(node)){
return order.get(node);
}
// 缓存中不存在当前节点的记录,那就需要先把当前节点往下遍历的点次信息收集上来再返回
long nodes = 0;
// 收集当前节点所有直接邻居的点次记录
for(DirectedGraphNode next : node.neighbors){
nodes+=process(next, order).nodes;
}
// 创建一个新的记录表ans
// 它存的点次记录应该是收集到的所有邻居的点次记录再加1,因为当前节点自己也算一个点次
Record<DirectedGraphNode,long> ans = new Record(node, nodes + 1);
// 把当前节点及其记录表存入缓存中
order.put(node, ans);
return ans;
}
大功告成。
三、图的最小生成树算法
图的最小生成树算法是针对带权值的无向图而言的,说的是如果想要把一个图转化成一棵树,那么求出总权值最小情况下的这颗树,返回组成这棵树的边的集合。
图的最小生成树算法有两种:Kruskal算法和Prim算法。下面对这两种算法分别进行介绍。
1、最小生成树算法之Kruskal算法
Kruskal算法需要用到 并查集 的数据结构,这种算法是从权值最小的边开始考虑,依次向较大权值的边行进,在行进过程中把符合条件的边并入并查集中。具体条件是:如果当前考虑的边不会与并查集中的边形成闭环,就选择要当前边,把它并入并查集结构中,否则不要当前边,去考察下一条边是否符合条件。Kruskal算法的主要求解步骤如下:
- 总是从权值最小的边开始考虑,顺序考察权值依次变大的边;
- 当前的边要么进入最小生成树的集合(并查集),要么丢弃;
- 如果当前的边进入最小生成树的集合中不会形成环,就要当前边;
- 如果当前的边进入最小生成树的集合中会形成环,就不要当前边;
- 考察完所有边之后,最小生成树的集合也得到了。
并查集的结构如下:
// union-find structure
public static class UnionFind{
// key:当前节点 value:当前节点的父节点
HashMap<Node, Node> fatherMap;
// key:当前节点 value:当前节点所在集合的节点个数
HashMap<Node, Integer> sizeMap;
public UnionFind(){
fatherMap = new HashMap<>();
sizeMap = new HashMap<>();
}
// 创建一个并查集
// 假设传进来集合元素共有nodes.length个
// 把每一个元素都设成一个单独的集合,这个并查集初始状况可以看作由这样的nodes.length个集合组成的一个大集合
// 后续在操作过程中,这些集合的个数可能会由于集合之间的合并等而发生变化
// 每一个集合都是一个由节点组成的树,通过找到集合的根节点来确定这个集合
public void makeSets(Collection<Node> nodes){
// 将fatherMap和sizeMap都清空
fatherMap.clear();
sizeMap.clear();
// 然后把所有初始节点的父节点都设为自己,且当前所在集合的节点个数设为1(当前节点本身)
for(Node node : nodes){
fatherMap.put(node, node);
sizeMap.put(node, 1);
}
}
// 查找当前节点所在集合中的根节点
private findAncestor(Node node){
// 设置一个栈,用于做路径压缩
// 所谓路径压缩就是把从当前节点出发一路向上找当前集合根节点的过程中遇到的所有节点都直接挂在根节点下,
// 从而降低集合树的高度,便于后续查找根节点操作
Stack<Node> path = new Stack<>();
// 初始时刻所有节点的父节点都指向自己
// 但是后续发生合并后,每个集合中将只有根节点的父节点指向自己
// 因此找到当前集合的根节点的标志就是当前节点的父节点是自己
while(node != fatherMap.get(node)){
// 向上的过程中,如果遇到的节点不是根节点,就把它压栈,并让node指向它的父节点
path.put(node);
node = father.get(node);
}
// 从上一个while循环跳出来,就说明已经找到根节点了,此时node指向根节点
while(!path.isEmpty()){
// 把沿途的节点依次从栈中弹出,并挂在根节点下
parent.put(path.pop(), node);
}
// 把根节点返回
return node;
}
public boolean isSameSet(Node a, Node b){
return findAncestor(a) == findAncestor(b);
}
// 合并过程不再注释
// 可参考:(https://blog.csdn.net/JE_9664/article/details/120102450?spm=1001.2014.3001.5501)
public void union(Node a, Node b){
if(a == null || b == null){
return;
}
Node aHead = findAncestor(a);
Node bHead = findAncestor(b);
if(aHead != bHead){
int aSetSize = sizeMap.get(a);
int bSetSize = sizeMap.get(b);
Node big = aSetSize >= bSetSize ? aHead : bHead;
Node small = big == aSetSize ? bSetSize : aSetSize;
fatherMap.put(small, big);
sizeMap.put(small, aSetSize + bSetSize);
sizeMap.remove(small);
}
}
}
有了并查集,该如何应用到Kruskal算法当中呢?我们知道,Kruskal算法要求依次参考边的信息,也就是从权值最小的边开始,去查看如果选择要了当前边,那么所有选择过的边会不会构成闭环。那么怎样去存放这些边信息并判断是否构成闭环呢?
首先可以用一个优先级队列存放图的所有边,由于优先级队列本身就是一个小根堆,所以把这些边插入优先级队列,并以此弹出就能够按照从小到大的顺序去考察了。这里由于边信息是自定义的引用数据类型,因此我们需要给优先级队列传入一个比较器:
// Edge Comparator
public class EdgeComparator implements Comparator<Edge>{
@Override
public int compare(Edge e1, Edge e2){
e1.weight - e2.weight;
]
}
再者,对于判断边是否会构成闭环,这里我们先将图中所有节点存入并查集中,在并查集初始化状态,这些节点会单独成一个集合,当一条边到来时,查看其两头连接的节点是否属于同一个集合中。如果它们已经存在于同一个集合中了,那么选择这条边必将导致闭环的存在,因此应该舍弃;如果这两个节点不在同一个集合中,就选择要当前边,并将两个连接节点合并在同一个集合中。
上代码:
public Set<Edge> kruskalMST(Graph graph){
UnionFind unionFind = new UnionFind();
unionFind.makeSets(graph.nodes.values()); // HashMap的values()方法能够返回一个Collection类型的value集合
// 创建一个优先级队列,把自定义的比较器扔进去
PriorityQueue<Edge> priorityQueue = new PriorityQueue<>(new EdgeComparator());
// 将每条边都装进优先级队列中
for(Edge edge : graph.edges){
priorityQueue.add(edge);
}
// result集合用于收集选择的边返回
Set<Edge> result = new HashSet<>();
// 每次从优先级队列中弹出的边就是权值从小到大的边
while(!priorityQueue.isEmpty()){
Edge edge = priorityQueue.poll();
// 如果两个节点是同一个集合,就不执行if语句的内容
if(!unionFind.isSameSet(edge.from, edge.to)){
// 如果不是,就把当前边装进result,并把两个节点合并到一个集合中
result.add(edge);
unionFind.union(edge.from, edge.to);
}
}
return result;
}
打完收工。
2、最小生成树算法之Prim算法
Prim算法求解最小生成树的思路如下:
- 可以从任意节点出发来寻找最小生成树;
- 某个点加入到被选取的点中后,解锁这个点出发的所有新的边;
- 在所有解锁的边中选最小的边,然后看看这个边会不会形成环;
- 如果会,不要当前边,继续考察剩下解锁的边中最小的边,重复步骤3;
- 如果不会,要当前边,将该边的指向点加入到被选取的点中,重复步骤2;
- 当所有点都被选取,最小生成树就得到了。
在具体操作过程中,也需要有一个优先级队列来弹出权值从小到大的边,同样,我们需要先定义比较器:
// Edge Comparator
public class EdgeComparator implements Comparator<Edge>{
@Override
public int compare(Edge e1, Edge e2){
e1.weight - e2.weight;
]
}
不同的是,这里我们的边信息不是一股脑全部装进优先级队列里的,而是当某一个节点解锁以后,由它解锁出来了之后的边才放入队列。
public Set<Edge> primMST(Graph graph){
// 小根堆,存放解锁的边
PriorityQueue<Edge> priorityQueue<>();
// 搞一个Set表出来,存放解锁的节点
HashSet<Node> nodeSet = new HashSet<>();
// 结果集
Set<Edge> result = new HashSet<>();
// 这里可以随便选一个点开始解锁
for(Node node : graph.nodes.values){
// 如果这个点解锁过了,就不进行下面的过程
// 如果到最后所有节点都解锁了,此过程也会停止
// 只有没解锁过的节点,才开始解锁
if(!nodeSet.contains(node)){
// 把当前节点装进nodeSet表中
nodeSet.add(node);
// 把当前节点下面所有的连通边放入优先级队列
for(Edge edge : node.edges){
priorityQueue.add(edge);
}
while(!priorityQueue.isEmpty()){
// 每次都会弹出当前队列中权值最小的那条边
Edge edge = priorityQueue.poll();
// 可能待解锁的一个新节点
Node toNode = edge.to;
// 如果这个待解锁的新节点不存在nodeSet表中,说明真的可以解锁,那就解锁它
if(!nodeSet.contains(toNode)){
nodeSet.add(toNode);
// 当前这条边确实能解锁出一个新的节点,那就把它装进result中
result.add(edge);
// 再去考察这个新解锁节点带动解锁出的其他边,也就是把它们也装进优先级队列中
for(Edge nextEdge : toNode.nexts){
priorityQueue.add(mextEdge);
}
}
}
}
// 这里可以在外层for循环只遍历一个节点时就停止,因为在for循环内部,会不断引入未解锁过的新节点
// 之所以写了一个for循环,是为了防止森林的出现。在for循环下,这个图就不会生成多个最小生成树从而形成森林
// 但是如果题设默认不会生成森林的话,可以在外层for循环遍历一次到这里时直接break掉即可
break;
}
return result;
}
搞定。
可以看出,对于Kruskal算法,它注重的是对于每条边的考察,需要对所有边遍历一次才能找出最小生成树的所有返回边;而对于Prim算法来说,他只要所有节点都解锁过了,就直接停止,不再继续往下去考察其他边信息。因此对于边数量远远大于节点数量的图来说,用Prim算法更好一些。但在通常情况下,两者并无太大差别。
需要注意的是:这两种求解最小生成树的算法都属于贪心算法。另外,在有向图中,如果指定以某个节点为根节点求解其最大生成树也是可行的。
四、Djikstra算法
Djikstra算法适用于无负权值的图,作用是:对于给定的一个源点,求出图中其他所有的点到该源点的最短距离,这里不论图是否为有向都适用。求解思路如下:
- Dijkstra算法必须指定一个源点;
- 生成一个源点到各个点的最小距离表,一开始只有一条记录,即原点到自己的最小距离为0,源点到其他所有点的最小距离都为正无穷大;
- 从距离表中拿出没拿过记录里的最小记录,通过这个点发出的边,更新源点到各个点的最小距离表,不断重复这一步;
- 源点到所有的点记录如果都被拿过一遍,过程停止,最小距离表得到了。
下面我们通过两种方式实现Djikstra算法。
1、基础版本实现
// Djikstra-1 algorithm code
// 必须指定一个源点from
public HashMap<Node, Integer> djikstra1(Node from){
// 先搞出一张距离表
HashMap<Node, Integer> distanceMap = new HashMap<>();
// 一开始先把源点放进去,源点到源点自身的距离设为0
distanceMap.put(from, 0);
// 再搞出一张selectedNodes的表,将所有已经参与过选择的节点装进去
// 当这个表装满的时候,也就是不存在没有选择过的点的时候,距离表就已经被填好了
HashSet<Node> selectedNodes = new HashSet<>();
// 在没有选择过的点中,拿到距离最小的那个点。通过额外封装一个函数来实现
Node minNode = getMinDistanceAndUnselectedNode(distanceMap, selectedNodes);
// 距离最小的点是在循环中不断更改,当操作完当前最小点后,就会把这个点锁住,去操作剩下点中距离最小的
while(minNode != null){
// 拿到当前距离最小点的距离
int distance = distanceMap.get(minNode);
for(Edge edge : minNode.edges){
// 去依次操作这个点的所有边及其连通点
Node toNode = edge.to;
if(!distanceMap.contains(toNode)){
// 如果当前边连通的点不存在于距离表中,就先加入,再把它的距离设为距离最小点的距离加上当前边的权值
distanceMap.put(toNode, distance + edge.weight);
} else {
// 如果已经存在距离表中了,就看这个连通点的距离能否通过当前边的借入变得更小,如果更小就替换,否则不替换
distanceMap.put(toNode, Math.min(distanceMap.get(toNode), distance + edge.weight));
}
}
// 操作完所有的边,就把当前节点放入已经选择过的表中,后续不再选择这个节点
selectedNodes.put(minNode);
// 在剩下的节点中更新一下最小距离的节点
minNode = getMinDistanceAndUnselectedNode(distanceMap, selectedNodes);
}
return distanceMap;
}
// getMinDistanceAndUnselectedNode method
public Node getMinDistanceAndUnselectedNode(HashMap<Node, Integer> distanceMap, HashSet<Node> seletedNodes){
Node minNode = null;
int minDistance = Integer.MAX_VALUE;
// 用一个Entry表,去遍历距离表的entrySet
for(Entry<Node, Integer> entry : distanceMap.entrySet()){
// 取出当前entry中存的节点
Node node = entry.getKey();
// 取出当前entry中节点的距离
int distance = entry.getValue();
// 如果这个entry中的节点没有被选择过,同时其距离小于minDistance,就设置minNode为当前的节点,minDistance为当前节点的距离
if(!seletedNodes.contains(node) && distance < minDistance){
minNode = node;
minDistance = distance;
}
}
return minNode;
}
这种方法可以认为是一种基础版实现,没有过多花里胡哨的数据结构来迷人眼,直观明了。但是由于定义的getMinDistanceAndUnselectedNode方法需要去遍历剩余节点的entry集合,才能选出距离最小的节点,比较费时,因此在下面的加强堆版本中,对此做了优化。
2、加强堆实现
由于Djikstra基础版实现过程,需要不断地去查找剩余节点中距离最小的那个,这个过程用一个函数去遍历比较浪费时间,所以需要采用加强堆结构优化该过程。这里我们先给出加强堆的结构:
// greater heap structure
public static class NodeHeap{
// 一个堆中维护的节点表
private int[] nodes;
// 反向索引表,能够很快地找到当前节点在堆中的位置,是系统提供的堆无法完成的一个功能
private HashMap<Node, Integer> heapIndexMap;
// 距离表
private HashMap<Node, Integer> distanceMap;
// 堆中节点个数
private int size;
public NodeHeap(int size){
nodes = int[size]; // 给堆数组分配空间
heapIndexMap = new HashMap<>();
distanceMap = new HashMap<>();
this.size = 0; // 初始时刻堆中没有节点
}
// 判断堆是否为空
public boolean isEmpty(){
return size == 0;
}
// 从反向索引表中去查看当前节点是否来过堆中
public boolean isEntered(Node node){
return heapIndexMap.containsKey(node);
}
// 某个节点是否存在堆中
public boolean inHeap(Node node){
return isEntered(node) && heapIndexMap.get(node) != -1;
}
// 向上查找,看父节点的值是否比自己大,如果是,就交换位置,直到其父不再比自己大或找到根停止
public void heapInsert(Node node, int index){
while(distanceMap.get(nodes[index]) < distanceMap.get(nodes[(index - 1) / 2])){
swap(index, (index - 1) / 2);
index = (index - 1) / 2;
}
}
// 往下找,看左右孩子中有没有比自己小的,如果有,找到最小的,跟自己交换,如果没有,就停止
public void heapify(int index, int size){
int left = index * 2 +1;
while(left < size){
int smallest = (left + 1) < size && distanceMap.get(nodes[left + 1]) < distanceMap.get(nodes[left]) ? left + 1 : left;
smallest = distanceMap.get(nodes[smallest]) < distanceMap.get(nodes[index]) ? smallest : index;
if(smallest == index){
break;
}
swap(smallest, index);
index = smallest;
left = 2 * index +1;
}
}
// 交换算法,注意要在heapIndexMap中同步更新
private void swap(int index1, int index2) {
heapIndexMap.put(nodes[index1], index2);
heapIndexMap.put(nodes[index2], index1);
Node temp = nodes[index1];
nodes[index1] = nodes[index2];
nodes[index2] = temp;
}
// 有一个节点node,现在发现了一个从源节点出发到达node的距离为distance,判断要不要更新,如果需要的话就更新
public void addOrUpdateOrIgnore(Node node, int distance){
// update
if(inHeap(node)){
distanceMap.put(node, Math,min(ditanceMap.get(node), distance));
heapInsert(node, heapIndexMap.get(node));
}
// add
if (!isEntered(node)) {
nodes[size] = node;
heapIndexMap.put(node, size);
distanceMap.put(node, distance);
insertHeapify(node, size++);
}
}
public NodeRecord pop() {
NodeRecord nodeRecord = new NodeRecord(nodes[0], distanceMap.get(nodes[0]));
swap(0, size - 1);
heapIndexMap.put(nodes[size - 1], -1);//在反向索引表中,不删除这个节点,而是把他的索引设置为-1,证明她曾将来过
distanceMap.remove(nodes[size - 1]);
nodes[size - 1] = null;
heapify(0, --size);
return nodeRecord;
}
}
真正的Djikstra算法代码很少:
public static HashMap<Node, Integer> dijkstra2(Node head, int size) {
NodeHeap nodeHeap = new NodeHeap(size);
nodeHeap.addOrUpdateOrIgnore(head, 0);
HashMap<Node, Integer> result = new HashMap<>();
while (!nodeHeap.isEmpty()) {
NodeRecord record = nodeHeap.pop();
Node cur = record.node;
int distance = record.distance;
for (Edge edge : cur.edges) {
nodeHeap.addOrUpdateOrIgnore(edge.to, distance + edge.weight);
}
result.put(cur, distance);
}
return result;
}
Djikstra算法依然属于贪心算法。