java31-图

204 阅读7分钟

一、图的遍历

    1. 采用DFS的遍历去做,分为有向无环图和有环图
  • 无环图则不需要visit数组进行辅助
void traverse(int[][] graph,int s){
      //关于s的代码,是要干啥  查找所有可能的路径
      for(int v:graph[s]){
          traverse(graph,v);
      }
      //处理s,将s进行撤销
}
  • 有环图 (需要visited布尔数组,来存储是否访问过)
void traverse(int[][] graph,int s){
    if(visted[s]) return;
    visted[s] = true;
    for(int v:graph[s]){
        traverse(graph,v);
    }
}
  • 有环图处理路径相关的 (增加一个OnPath布尔数组,存储当前进去的路径)
void traverse(int[][] graph,int s){
    if(visted[s]) return;
    visted[s] = true;
    OnPath[s] = true;
    for(int v:graph[s]){
        traverse(graph,v);
    }
    OnPath[s] = false;
}
  • 2.采用BFS去做,一般是最短路径的问题
// 计算从起点 start 到终点 target 的最近距离
int BFS(Node start, Node target) {
    Queue<Node> q; // 核心数据结构
    Set<Node> visited; // 避免走回头路
    
    q.offer(start); // 将起点加入队列
    visited.add(start);
    int step = 0; // 记录扩散的步数

    while (q not empty) {
        int sz = q.size();
        /* 将当前队列中的所有节点向四周扩散 */
        for (int i = 0; i < sz; i++) {
            Node cur = q.poll();
            /* 划重点:这里判断是否到达终点 */
            if (cur is target)
                return step;
            /* 将 cur 的相邻节点加入队列 */
            for (Node x : cur.adj()) {
                if (x not in visited) {
                    q.offer(x);
                    visited.add(x);
                }
            }
        }
        /* 划重点:更新步数在这里 */
        step++;
    }
}

二、图的建立

一般在题目中给的是二维数组,代表着节点的关系,需要将题目的输入转为图的结构,转为邻接表进行存储

  • 邻接表的结构是一个链表类型的数组 List<Integer>[] list=new LinkerList[];
(有向图的建立)
List<Integer>[] buildGraph(int numCourse,int[][] prere){
    //numcourse是二维数组中的课程(节点)数量
    List<Integer>[] graph = new LinkedList[numCourse];
    //给对应的每一个链表数组中的节点创建一个链表
    for(int i = 0;i < numCourse; i++){
        graph[i] = new LinkedList<>();
    }
    //将链表进行连接起来
    for(int[] edge:prere){
            //要学习edge[0],需要先学习edge[1]
        int from = edge[1],to = edge[0];   
        graph[from].add(to);
    //  graph[to].add(from);   (无向图的建立)
    }
    return graph;
}

注意:

  • 树也可以看成图。实际上,树是一类特殊的图,树中一定不存在环。但图不一样,图中可能包含环。

  • 当沿着图中的边搜索一个图时,一定要确保程序不会因为沿着环的边不断在环中搜索而陷入死循环。

  • 避免死循环的办法是记录已经搜索过的节点在访问一个节点之前先判断该节点之前是否已经访问过,如果之前访问过那么这次就略过不再重复访问

  • 遍历图的起点,一般若是联通的图,则从0开始,若不是,则需要遍历所有的节点进行开始

三、拓扑排序

  • 将DFS在遍历的后序位置进行翻转,就是拓扑排序的结构,无环才可以进行拓扑排序
void traverse(TreeNode root) {
    // 前序遍历代码位置
    traverse(root.left)
    // 中序遍历代码位置
    traverse(root.right)
    // 后序遍历代码位置
}

//只有当左右节点遍历结束才访问根节点,这里的问题也是如此

四、图的扩展-二分图

实际的应用:比如电影和演员,加入使用map集合去存储,可以快速通过演员是到电影,而反向的话则需要重新制作一个map表,这时我们可以创建一个二分图,实现多对一及多对多的关系

  • 判断二分图

说白了就是遍历一遍图,一边遍历一边染色,看看能不能用两种颜色给所有节点染色,且相邻节点的颜色都不相同

不涉及到路径的问题,只需要一个visted数组来防止一直循环
void traverse(int[][] graph(List<Integer>[] graph),int s){
    if(visted[s]) return;
    visted[s] = true; 
    for(int v:graph[s]){
        tarverse(graph,v);
    }
}
也可以将if进行换位置
void traverse(List<Integer>[] graph,int s){
    visted[s] = true;
    for(int v:graph[s]){
        if(!visted[v]){
           traverse(graph,v);
        }
    }
}
//遍历的时候保证没有没访问过
  • 二分图的代码
/* 图遍历框架 */
void traverse(Graph graph, boolean[] visited, int v) {
    visited[v] = true;
    // 遍历节点 v 的所有相邻节点 neighbor
    for (int neighbor : graph.neighbors(v)) {
        if (!visited[neighbor]) {
            // 相邻节点 neighbor 没有被访问过
            // 那么应该给节点 neighbor 涂上和节点 v 不同的颜色
            traverse(graph, visited, neighbor);
        } else {
            // 相邻节点 neighbor 已经被访问过
            // 那么应该比较节点 neighbor 和节点 v 的颜色
            // 若相同,则此图不是二分图
        }
    }
}

//对应的代码
class Solution {
    boolean[] visted;
    boolean[] color;
    boolean erfen=true;
    public boolean isBipartite(int[][] graph) {
        int n = graph.length;
        visted=new boolean[n];
        color=new boolean[n];  //存放对应节点的值
        for(int i=0;i<n;i++){
            traverse(graph,i);
        }
        return erfen;
    }
    void traverse(int[][] graph,int s){
        if(!erfen) return;
        visted[s]=true;
        for(int v:graph[s]){
            //如果没有访问过,则染不一样的颜色
            if(!visted[v]){
                color[v]=!color[s];  //与之不同
                traverse(graph,v);
            }else{
                //访问过,则判断颜色是否是相等的,相等则返回false
                if(color[v] == color[s]){
                    erfen = false;   //注意不是返回false
                }
            }
        }
    }
}

四、并查集 (高效处理图的连通问题)

社交网络中朋友圈的计算,我关注了一个人,咱俩认识,也关注一个人,合并指向同一个根。如果某两个节点被连通,则让其中的(任意)一个节点的根节点接到另一个节点的根节点上

class UF {
    /* 将 p 和 q 连接 */
    public void union(int p, int q);
    /* 判断 p 和 q 是否连通 */
    public boolean connected(int p, int q);
    /* 返回图中有多少个连通分量 */
    public int count();
}
假设有十个不相连的图,则连通分量为为10,没两个相连,连通分量--
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 的根节点 */   find的函数的意思
private int find(int x) {
    // 根节点的 parent[x] == x
    while (parent[x] != x)
        x = parent[x];
    return x;
}
//find函数的修改
int find(int x){
    if(parent[x]==x){
        return x;
    }
    return find(parent[x]);  //递归查找。返回上一层的根节点
}

/* 返回当前的连通分量个数 */
public int count() { 
    return count;
}

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

public boolean connected(int p, int q) {
    int rootP = find(p);
    int rootQ = find(q);
    return rootP == rootQ;
}

  • 进行合并总的模板:
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;
    }
}

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

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

总的思路构建并查集:并编写union接口

  • 首先创建一个parent数组(大的根的节点的指针是一直保持不变的,parent[x]=x指向自己),相当每一个对应的节点,方便后序进行合并,初始化连通分量为n

  • 合并并查集的时候,首先的找到当前所在的节点的根节点,find函数,使用递归进行优化查找路径

  • 合并并查集,分别找到对应的根,根相同,则直接返回,根不同,则将parent[rootQ] = rootP;,进行合并,连通分量--

  • 可以通过uf.count()函数查看合并之后的连通分量

例题:返回无向图中连通分量的个数

并查集的难点:理解parent数组的作用,parent[2]
数组的定义 int[] parent;
int [ ] arr = new int [ ] { 64 , 12 , 43 ,34,88} ; 数组的赋值\

五、最小生成树

什么是最小生成树?
就是在一个在图中找一颗包含图中所有节点的树。专业点就是生成树是含有所有节点的无环连通子图。最小生成树就是在众多的子树中找到权重最小的生成树
注意:一般是在无向加权图中计算最小的生成树

例题1:以图来判断树,有n个节点,和一个二元组来表示节点的之间的关系,判断这些边是否能组成一个树?

//组成一个树的关键是:无环,即两个节点之间已经连通了,再加入其他的边就不会成树
代码如下:
boolean validTree(int n, int[][] edges){
    Uf uf = new UF(n);
    //遍历所有的边
    for(int[] edge:edges){
        int u=edge[0];
        int v=edge[1];
        //connected id boolean类型的函数
        if(uf.connected(u,v)){
            return false;  //如果连通,则不是树
        }
        //将两个合并,可以是树的一部分
        uf.union(u,v);
    }
    //需要保证最后只形成了一棵树,即只有一个连通分量
    return uf.count()==1;
}

//总结:无向图找环,可以使用并查集,不断的进行组合,最后检查连通分量为1即可

例题2、最小生成树(Kruskal 261、1135、1584)

  • 包含所有的节点
  • 不包含环
  • 权重最小 并查集可以实现前两个,第三个的需要使用贪心算法
    算法思想:将权重从小到大进行排序,每次选择权重最小的时候,判断是否已经连通,若连通则舍弃,若不连通,则加入mst集合,是最小生成树的一部分
//最低的成本连通所有的城市
//其中connections为三元的 conn[0] conn[1] conn[2]为cost
int minimumCost(int n,int[][] conn){
//编号从1-n开始。所以初始化的大小为n+1
    UF uf = new UF(n+1);  //不理解??
    //对所有边的权重从小到大排序
    Arrays.sort(conn,(a,b)->(a[2]-b[2]));
    int mst=0;
    for(int[] edge:conn){
        int u=edge[0];
        int v=edge[1];
        int weight=edge[2];
        //若这条边产生环,不加入mst
        if(uf.connected(u,v)){
            continue;
        }
        //若这条边不会产生环
        uf.union(u,v);
        mst+=weight;
    }
    //三元运算符
    //以为节点0没有被使用,所以节点0会占用一个连通分量
    return uf.count()==2 ? mst:-1;
}

//扩展,上边的都涉及到一维的,比如conn[A,B,5],代表的是A和B之间的距离为5,假若这里给的是坐标 points[[2,2],[3,6],cost],如何去求?

//分别对应的是点的坐标
points = [[0,0],[2,2],[3,10],[5,2],[7,0]]
//进行转为三元的edge[A,B,cost]
对应的是一个链表类型的数组

List<int[]> edges = new ArrayList<>();\
    for (int i = 0; i < n; i++) {\  代表着数组的行
        for (int j = i + 1; j < n; j++) {\  代表着数组
            int xi = points[i][0], yi = points[i][1];\
            int xj = points[j][0], yj = points[j][1];\
            // 用坐标点在 points 中的索引表示坐标点\
            edges.add(new int[] {\
                i, j, Math.abs(xi - xj) + Math.abs(yi - yj)\
            });\
        }\
    }

例题3、最小生成树(Prim 261、1135、1584)

Kruskal 算法是在一开始的时候就把所有的边排序,然后从权重最小的边开始挑选属于最小生成树的边组建最小生成树

Prim 算法是从一个起点的切分(一组横切边)开始执行类似 BFS 算法的逻辑,借助切分定理优先级队列动态排序的特性,从这个起点「生长」出一棵最小生成树。

  • //假设从起点进行一个一个元素的分析:
  • edge[from, to, weight] 对应的邻接表private List<int[]>[] graph;
  • 选中第一个节点,首先将所有的边加入优先队列,要和kruskal一样,加入数组,就要加入n个数组,太庞大了,选择优先队列,根据权重从小到大进行排序
  • 怎么选择一个节点,并保存所有的边?选择0这个点,使用一个布尔数组进行记录,INMST【0】代表节点0已经在最小的生成树中,将cut方法提出,类似于遍历,遍历一个节点所有相邻的边,此时的判断相邻的点是否已经在生成树的集合中。若不在则加入队列,否则不加入进行跳过
  • 判断当前的队列是否为空,类似于BFS,弹出队列,int to = edge[1],int weight=edge[2],判断节点to 是否已经在INMST的集合中,在continue,不在权重++,INMST[to]=true,然后接着执行cut方法,类似于遍历
  • 判断最小生成树是否包含图中所有的节点,遍历其大小

总结:prim算法是基于图的邻接表进行实现的,因为加入优先队列进行排序的时候,通过邻接表的性质来得到相邻的边 ,此外,已经加入的边不加入优先队列

补充:优先队列的创建的代码

private PriorityQueue<int[]> pq;  //根据权重进行排序

//进行初始化
this.pq=new PriorityQueue<>(a,b)->{
    return a[2] - b[2];
}

//数组排序的规则 按照数组中的第三个元素进行排序,从小到大
Arrays.sort(conn,(a,b)->(a[2]-b[2]));

总结:kruscal和prim算法

  • 思想都是利用贪心算法,每次找最小的
  • kruscal是先将权重进行排好序,在进行一次挑选最小的
  • prim算法是每次选择最小的

五,迪杰斯特拉算法 (给一个起点,计算到其他节点的最小的距离)

图中的节点一般就抽象成一个数字(索引),所以才有前面的parent[x] = x,图的具体实现一般是「邻接矩阵」或者「邻接表」。

基础必备:

  • 使用邻接表进行表示一个图
// graph[s] 存储节点 s 指向的节点(出度)
List<Integer>[] graph;
  • 对于图还需要获得他的相邻节点
List<Integer>[] graph;   //graph[s]表示相邻的节点
// 输入节点 s,返回 s 的相邻节点 
List<Integer> adj(int s) {
    return graph[s];
    }
  • 对于加权的图,还需要直到两个节点的边的权重是多少?
// 返回节点 from 到节点 to 之间的边的权重
int weight(int from, int to);

这个 `weight` 方法可以根据实际情况而定,因为不同的算法题,题目给的
「权重」含义可能不一样,我们存储权重的方式也不一样

5.1 二叉树的层级遍历

void levelTraverse(TreeNode root){
    if(root == null) return 0;
    Queue<Treenode> q = new TreeNode<>();
    q.offer(root);
    int depth=1;
    
    while(!q.isEmpty()){
        int size = q.size();
        for(int i=0;i<size;i++){
            TreeNode cur=q.poll();
            printf("节点%s在第%s层",cur,depth);
            
            //将下层的节点放入队列
            if(cur.left!=null){
                q.offer(cur.left);
            }
            if(cur.right!=null){
                q.offer(cur.right);
            }
        }
        depth++;
    }
}

while循环和for循环正是设计的巧妙之处,while向下遍历,for循环层层之间进行遍历

5.2 多叉树的遍历

void levelTraverse(TreeNode root){
    if(root==null) return;
    Queue<Integer> q=new LinkedList<>();
    q.offer(root);
    int depth=1;
    while(!q.isEmpty()){
        int size = q.size();
        for(int i=0;i<size;i++){
            TreeNode cur=q.poll();
            for(TreeNode child:cur.children){
                q.offer(child);
            }
        }
        depth++;
    }
}

5.3 BFS广度优先搜索

//不同的是需要加一个visit数组进行存储
List<list<Integer>> graph;
boolean[] visted = new boolean[n];
void BFS(Graph graph,int s){
    Queue<Integer> q=new LinkedList<>();
    q.offer(s);
    visted[s];
    int step=0;
    while(!q.isEmpty()){
        int size=q.size();
        for(int i=0;i<size;i++){
            int v = q.poll();
            for(int z: graph(v)){
                if(!visted[z]){
                    q.offer(z);
                    visted[v];
                }
            }
        }
        step++;
    }
}

但是:对于加权图的问题,while中的for循环没有多大的意义。

为什么?有了刚才的铺垫,这个不难理解,刚才说 for 循环是干什么用的来着?

是为了让二叉树一层一层往下遍历,让 BFS 算法一步一步向外扩散,因为这个层数 depth,或者这个步数 step,在之前的场景中有用。

但现在我们想解决「加权图」中的最短路径问题,「步数」已经没有参考意义了,「路径的权重之和」才有意义,所以这个 for 循环可以被去掉。

类比:二叉树中的代码去掉for循环 没有for循环,不知道节点在那一层
如果你想同时维护 depth 变量,让每个节点 cur 知道自己在第几层,可以想其他办法,比如新建一个 State 类,记录每个节点所在的层数

class State {
    // 记录 node 节点的深度
    int depth;
    TreeNode node;

    State(TreeNode node, int depth) {
        this.depth = depth;
        this.node = node;
    }
}

// 输入一棵二叉树的根节点,遍历这棵二叉树所有节点
void levelTraverse(TreeNode root) {
    if (root == null) return 0;
    Queue<State> q = new LinkedList<>();
    q.offer(new State(root, 1));

    // 遍历二叉树的每一个节点
    while (!q.isEmpty()) {
        State cur = q.poll();
        TreeNode cur_node = cur.node;
        int cur_depth = cur.depth;
        printf("节点 %s 在第 %s 层", cur_node, cur_depth);

        // 将子节点放入队列
        if (cur_node.left != null) {
            q.offer(new State(cur_node.left, cur_depth + 1));
        }
        if (cur_node.right != null) {
            q.offer(new State(cur_node.right, cur_depth + 1));
        }
    }
}

5.4 迪杰斯特拉算法

prim更新的是未标记集合到已标记集合之间的距离

Dijkstra更新的是源点到未标记集合之间的距离

参考:labuladong.github.io/