此篇文章讲解了图的两种数据结构,怎样构造图,和图遍历的模板。
由此引出leetcode 797所有可能通路 ,leetcode 207 课程表 ,leetcode 210 课程表II的解答。
解决了图问题中常出现:DFS遍历图,回溯法遍历图,环检测,拓扑排序的问题。
目录
四、图的遍历和回溯两种方法解决 leetcode 797 所有可能的路径
一、 图的基础
(1)图有两种数据结构构成。
1)邻接矩阵 :即用V-V矩阵来代表每个顶点和边,V代表顶点。如果是无加权图则用简单的二维布尔数组表示。加权图则可以用二维数组来表示边的距离。它的好处是访问很快,缺点是费空间。如果图有很多顶点但是边不多,则造成不必要的空间损失。这时候,接邻列表就很有优势了。

2)接邻列表:每个顶点接一个列表,代表相邻的顶点。这样空间使用就从上一个表示方法 V * V
降到了 V + E. 每次添加边,提供了固定的时间。 同时节约了空间。

(2)leetcode中图常用的构造方法
leetcode中使用邻接表的构图比较多,以下是建立图的函数。
List<Integer>[] buildGraph(int numVertex, int[][] edges){
List<Integer>[] graph = new LinkedList[numCourses];
for(int[] edge : edges){
int from = edge[0];
int to = edge[1];
graph[from].add(to);
}
(3)图的遍历方法
图的数据结构就是多叉树的延伸,多叉树就是二叉树的延伸,二叉树就是链表的延伸。我们二叉树的数据结构中也有两种:一是用数组表示的堆,二是更加常见的用列表表示的树。因为leetcode中更加常出现的结构是邻接列表,也就是底层数据结构是列表。所以与二叉树的遍历存在着非常高的相似性。我们一起来看一下。
二叉树的遍历:
class TreeNode{
int val;
TreeNode leftchild;
TreeNode rightchild;
}
traverse(TreeNode root){
if(root == null) return;
//前序遍历
traverse(root.leftchild);
//中序遍历
traverse(root.rightchild);
//后序遍历
}
与多叉树的遍历十分的类似。多叉树的遍历框架:
class TreeNode{
int val;
TreeNode[] children;
}
traverse(TreeNode root){
if(root == null) return;
for(TreeNode node : children){
traverse(node);
}
}
图和多叉树不同之处在于,图可能有环的。你从图的一个节点开始遍历,很可能又会到这个节点。所以我们会加入visit[] 数组来避免你走回头路:
class Vertex{
int val;
Vertex[] neighbours;}
void traverse(Graph g, Vertex s){
if(visit[s]) return
// 将当前节点 s 标记为已经遍历
visit[s] = true;
for(Vertex v : g.neighbours(s)){
traverse(g, v)
}
}
那有些图不是所有的节点都是相连怎么办呢?加一个for循环将所有节点都作为起点都调用一下traverse()函数就可以了。如下图:
class Vertex{
int val;
Vertex[] neighbours;}
void traverseAllGraph(Graph g, int v){
visited = new boolean[v];
for(int i=0; i< v; i++){
traverse(g,i);
}
}
void traverse(Graph g, Vertex s){
if(visit[s]) return
// 将当前节点 s 标记为已经遍历
visit[s] = true;
for(Vertex v : g.neighbours(s)){
traverse(g, v)
}
}
我想知道哪里有环怎么办呢?这就引出了判断有向图是否存在环的问题。
递归函数看成一个在递归树上游走的指针的话,traverse就是在图上游走的指针,只需要在添加一个boolean 数组onStack 记录当前traverse经过的途径就可以了。
class Vertex{
int val;
Vertex[] neighbours;}
boolean[] onStack;
boolean[] visited;
boolean hasCycle;
void traverseAllGraph(Graph g, int v){
visited = new boolean[v];
for(int i=0; i< v; i++){
traverse(g,i);
}
}
void traverse(Graph g, Vertex s){
if(onStack[s] = true){
hasCycle = true;//发现环
}
if(visit[s]) return
// 将当前节点 s 标记为已经遍历
visit[s] = true;
//当前节点开始遍历
onStack[s] = true;
for(Vertex v : g.neighbours(s)){
traverse(g, v)
}
// 离开节点s
onStack[s] = false;
}
是不是有点儿回溯那味儿了?
result = [];
backtracking(路径, 选择列表){
if(达到目标条件{
result.add(路径);
return;
}
for 选择 in 选择列表:
做选择
backtracing(路径,选择列表)
撤销选择
}
图的遍历框架很像回溯算法的框架。区别是回溯算法将离开节点放在for 循环里面。这两种写法的区别就是是否遍历了root节点。对于回溯算法来说,根节点是否遍历并不重要,因为回溯算法更加注重枝叶节点,如排列组合等。而图的便利是需要遍历根节点的,所以这里的记录是否访问过该节点的visit放在了for循环的外面。
那如果是我不仅要想知道有没有环,还想把环输出数来怎么办呢?
我们可以加一个数组将路径记录下来,如下:
class Vertex{
int val;
Vertex[] neighbours;}
boolean[] onStack;
boolean[] visited;
Stack<Integer> Cycle = new Stack<Integer>();
int[] edgeTo;
void traverseAllGraph(Graph g, int v){
visited = new boolean[v];
for(int i=0; i< v; i++){
traverse(g,i);
}
}
void traverse(Graph g, Vertex s){
if(onStack[s] = true){
//发现环,放入Cycle中记录下来
for(int x = v; x!= s; x = edgeTo[x]){
Cycle.push(x);}
Cycle.push(s);
Cycle.push(v);
}
if(visit[s]) return
// 将当前节点 s 标记为已经遍历
visit[s] = true;
//当前节点开始遍历
onStack[s] = true;
for(Vertex v : g.neighbours(s)){
edgeTo[s] = v;
traverse(g, v);
}
// 离开节点s
onStack[s] = false;
}
二、leetcode 207 课程表

会了前面的图的基本知识,这道题目手到擒来。
class Solution {
private Boolean[] visited;
private Boolean[] onStack;
public boolean canFinish(int numCourses, int[][] prerequisites) {
visited = new Boolean[numCourses];
onStack = new Boolean[numCourses];
//构造图:将实际的修课问题抽象为图问题
List<Integer>[] graphic = new List[numCourses];
for(int i = 0; i<numCourses; i++){
graphic[i] = new LinkedList<>();
}
for(int[] pre : prerequisites){
graphic[pre[0]].add(pre[1]);
}
// 遍历图中的每个顶点,找是否有环
for(int i = 0; i<numCourses; i++){
if(dfs(graphic, i)){
return false;
}
}
return true;
}
private boolean dfs(List<Integer>[] graph, int v){
if(visited[v]) return true;
if(onStack[v]) return false;
visited[v] = true;
onStack[v] = true;
for(int w : graph[v]){
if(dfs(graph, w)){
return true;
}
}
onStack[v] = false;
return false;
}
}
如果想输出那个地方有环呢?这就是Leetcode 210 课程表II 的内容了
三、3种方法解答 Leetcode 210 课程表II

方法1)图遍历
根据前面的模板,我们再引入 edgeTo[] 数组记录路径,很容易得到答案:
class Solution {
private boolean[] marked;
private int[] edgeTo;
private Stack<Integer> cycle;
private boolean[] onStack;
public boolean canFinish(int numCourses, int[][] prerequisites) {
marked = new boolean[numCourses];
edgeTo = new int[numCourses];
onStack = new boolean[numCourses];
//构造图:将实际的修课问题抽象为图问题
List<Integer>[] graphic = new List[numCourses];
for(int i = 0; i<numCourses; i++){
graphic[i] = new LinkedList<>();
}
for(int[] pre : prerequisites){
graphic[pre[0]].add(pre[1]);
}
// 遍历图中的每个顶点,找是否有环
for(int i = 0; i<numCourses; i++){
if(!marked[i]){
dfs(graphic, i);
}
}
return !this.hasCycle();
}
private void dfs(List<Integer>[] graph, int v){
marked[v] = true;
onStack[v] = true;
for(int w : graph[v]){
if(this.hasCycle()) return;
if(!marked[w]){
edgeTo[w] = v;
dfs(graph, w);
}else if(onStack[w]){
cycle = new Stack<Integer>();
for(int x=v; x!=w;x=edgeTo[x] ){
cycle.push(x);
}
cycle.push(w);
cycle.push(v);
}
}
onStack[v] = false;
}
private boolean hasCycle(){
return cycle != null;
}
}
方法2) 拓扑排序
这是一个经典的算法。拓扑排序就是后序遍历的反顺序。如果一幅图里面有环,是无法进行拓扑排序的。树就一定可以进行拓扑排序,排序之后的顺序正好是树往下长得箭头,全部变成往上。
本题拓扑排序的结果就是上课的顺序。
boolean[] visited;
// 记录后序遍历结果
Stack<Integer> postorder = new ListedList<>();
int[] findOrder(int numCourses, int[][] prerequisites) {
// 先保证图中无环
if (!canFinish(numCourses, prerequisites)) {
return new int[]{};
}
// 建图,参考上面代码
List<Integer>[] graph = buildGraph(numCourses, prerequisites);
// 进行 DFS 遍历
visited = new boolean[numCourses];
for (int i = 0; i < numCourses; i++) {
traverse(graph, i);
}
int[] res = new int[numCourses];
for (int i = 0; i < numCourses; i++) {
res[i] = postorder.get(i);
}
return res;
}
void traverse(List<Integer>[] graph, int s) {
if (visited[s]) {
return;
}
visited[s] = true;
for (int t : graph[s]) {
traverse(graph, t);
}
// 后序遍历位置
postorder.push(s);
}
// 参考上一题的解法
boolean canFinish(int numCourses, int[][] prerequisites);
// 参考前文代码
List<Integer>[] buildGraph(int numCourses, int[][] prerequisites);
四、图的遍历和回溯两种方法解决 leetcode 797 所有可能的路径
这是一个有环的图输出所有可能的从初始节点到目标节点的所有路径。直接套用图的遍历模板就可以解答。这里重点解答一下用回溯的方法怎样完成图的遍历,以及两者的区别是什么。

这道题目就是考察图的遍历,这里题目给出的是有向无环图,所以不需要visit的辅助了
1)图的遍历解法
直接套用前面的图的遍历框架,以0为起点,你并记录所有遍历的路径在path中,如到到终点即到达N-1,就将路径path放在result中,并离开,离开的时候将终点这个节点也撤销掉。
模板 1)添加路径
2)判断是否到达目标(节点是否为N-1)
3)访问下一个节点
4)移除路径
class Solution {
List<List<Integer>> result = new LinkedList<>();
public List<List<Integer>> allPathsSourceTarget(int[][] graph) {
LinkedList<Integer> path = new LinkedList<>();
traverse(path, 0, graph);
return result;
}
private void traverse(LinkedList<Integer> path, int num,int[][] graph){
//1)添加路径
path.add(num);
// 2)目标
if(num == graph.length - 1){
result.add(new LinkedList<>(path));// java是值传递,这里注意不可以result.add(path);
path.removeLast();
return;
}
//3)处理下一个节点
for(int v : graph[num]){
traverse(path, v, graph);
}
//4)离开节点
path.removeLast();
}
}
2)回溯算法解法
题目中一般涉及所有的可能都可以用回溯算法,回溯算法的框架为:
result = [];
backtracking(路径, 选择列表){
if(达到目标条件{
result.add(路径);
return;
}
for 选择 in 选择列表:
做选择
backtracing(路径,选择列表)
撤销选择
}
我们可以看出回溯算法中做选择和撤销选择是在for里面做的,而图的遍历中做选择和撤销选择是在for循环的外面做的。那回溯算法没有访问根节点怎么办?没关系,我们在循环前提前放进去就可以了。
class Solution {
List<List<Integer>> result = new ArrayList<>();
public List<List<Integer>> allPathsSourceTarget(int[][] graph) {
List<Integer> path = new ArrayList<>();
//将根节点加入path
path.add(0);
backtracking(0, path, graph);
return result;
}
private void backtracking(int num, List<Integer> path,int[][] graph) {
if (num == graph.length - 1) {
result.add(new ArrayList<>(path));
return;
}
for (int v : graph[num]) {
path.add(v);//做出选择
backtracking(v, path,graph);//做出下一步的选择
path.remove(path.size() - 1); // 撤销选择
}
}
}
回溯法和图遍历的区别就是在for循环内做出选择和撤销选择。这样做有什么区别呢?区别就在初始节点root并没有在递归内加入到路径中去。所以在回溯法递归之前,我们先将根节点加入到路径中。path.add(0) 图遍历中就没有这一步。为什么回溯的结构会是这样嫩?因为应用回溯法的场合大都是关注枝叶,并不关注更节点的。所以使用这样的框架。
本文详细介绍了图的基础知识,包括图的两种数据结构、LeetCode中图的构造方法以及图的遍历方法。结合LeetCode题目207和210,探讨了图遍历和拓扑排序在解决课程表问题中的应用,并提供了解题策略。此外,还讲解了如何使用图遍历和回溯算法解决LeetCode 797题目的所有可能路径。

1716

被折叠的 条评论
为什么被折叠?



