数据结构与算法 -- 图论的基础与进阶

在此之前我们介绍过二叉树相关的基础知识,其实图论也是类似于树的数据结构,我们依然可以通过DFS或者BFS求解相关的问题。

java 复制代码
class Node {
    public int val;
    public List<Node> neighbors;
    public Node() {
        val = 0;
        neighbors = new ArrayList<Node>();
    }
    public Node(int _val) {
        val = _val;
        neighbors = new ArrayList<Node>();
    }
    public Node(int _val, ArrayList<Node> _neighbors) {
        val = _val;
        neighbors = _neighbors;
    }
}

图的数据结构如上所示,每个节点有自身的值,还有一组邻居节点:

例如节点4:自身的值为4,邻居为5,6,7.

java 复制代码
public static void bfs(Node root) {

    Deque<Node> deque = new ArrayDeque<>();
    Set<Node> set = new HashSet<>();
    //加入根节点
    deque.offer(root);
    set.add(root);

    //开始宽度优先搜索
    while (!deque.isEmpty()) {
        //取出节点
        Node node = deque.poll();
        Log.d(TAG, "bfs: node == " + node);
        //遍历其邻居节点
        for (Node neighbor : node.neighbors) {
            if (set.contains(neighbor)) {
                continue;
            }
            //加入队列中
            deque.offer(neighbor);
            set.add(neighbor);
        }
    }
}

如果要遍历图,那么需要两个数据结构:ArrayDeque队列和HashSet,前者用于存储需要遍历的节点,后者用于判断当前节点是否遍历过。

1 克隆图

给你无向 连通 图中一个节点的引用,请你返回该图的 深拷贝(克隆)。

图中的每个节点都包含它的值 valint) 和其邻居的列表(list[Node])。

kotlin 复制代码
class Node {
    public int val;
    public List<Node> neighbors;
}

其实克隆图,就是在BFS的同时,将每个节点(自身 + 邻居)做一次深拷贝。

java 复制代码
public Node cloneGraph(Node node) {
    //当前图只有一个邻居,那就没了
    if(node == null){
        return node;
    }

    //存储要遍历的节点
    Deque<Node> queue = new ArrayDeque<>();
    //记录节点,可以用来判断是否遍历过
    Map<Node,Node> map = new HashMap<>();

    queue.offer(node);
    //克隆一个新节点,存到map里
    Node root = new Node(node.val,new ArrayList<>());
    map.put(node,root);

    while(!queue.isEmpty()){

        //取出节点,保证这个节点是没有被遍历过的。
        //但是一定是在map集合里的,因为它和入队是成对的。
        Node cur = queue.poll();
        //克隆一个新节点
        Node newNode = map.get(cur);

        //遍历当前节点的邻居
        for(Node neighbor : cur.neighbors){
            //新邻居
            Node newNeighbor;
            if(map.containsKey(neighbor)){
                //如果已经存在这个邻居了
                newNeighbor = map.get(neighbor);
            }else{
                //不存在,new 一个新节点
                newNeighbor = new Node(neighbor.val,new ArrayList<>());
                queue.offer(neighbor);
                map.put(neighbor,newNeighbor);
            }
            //给新节点赋值新邻居
            map.get(cur).neighbors.add(newNeighbor);
        }
    }

    return map.get(node);
}

这里还是使用了2个数据结构:ArrayDeque队列和HashMap,前者用于存储需要遍历的节点,后者存储旧节点与新节点的映射关系,同时可以用来判断当前节点是否访问过。

其实就是3步:

  • BFS遍历全部的节点;
  • 复制节点自身,存储到Map中;
  • 复制边,加到节点的空集合中。
java 复制代码
//获取所有的节点
private List<Node> bfs(Node node){
    Deque<Node> queue = new ArrayDeque<>();
    Set<Node> set = new HashSet<>();
    List<Node> res = new ArrayList<>();
    queue.offer(node);
    set.add(node);

    while(!queue.isEmpty()){
        Node n = queue.poll();
        res.add(n);

        for(Node neighbor : n.neighbors){
            if(set.contains(neighbor)){
                continue;
            }
            queue.offer(n);
            set.add(n);
        }
    }
    return res;
}

private Map<Node,Node> copyAll(List<Node> nodes){
    Map<Node,Node> map = new HashMap<>();
    for(Node node : nodes){
        map.put(node,new Node(node.val,new ArrayList<>()));
    }
    return map;
}

private Node clone(Map<Node,Node> map,Node root){

    //构建边
    for(Node key : map.keySet()){
        //key为老的节点
        for(Node neighbor : key.neighbors){
            //给新节点
            map.get(key).neighbors.add(map.get(neighbor));
        }
    }

    return map.get(root);
}

2 单词接龙

字典 wordList 中从单词 beginWord **和 endWord转换序列 是一个按下述规格形成的序列 beginWord -> s1 -> s2 -> ... -> sk

  • 每一对相邻的单词只差一个字母。
  • 对于 1 <= i <= k 时,每个 si 都在 wordList 中。注意, beginWord 不需要在 wordList 中。
  • sk == endWord

给你两个单词 beginWordendWord 和一个字典 wordList ,返回从 beginWordendWord最短转换序列 中的 单词数目 。如果不存在这样的转换序列,返回 0

示例 1:

rust 复制代码
输入: beginWord = "hit", endWord = "cog", wordList = ["hot","dot","dog","lot","log","cog"]
输出: 5
解释: 一个最短转换序列是 "hit" -> "hot" -> "dot" -> "dog" -> "cog", 返回它的长度 5。

示例 2:

erlang 复制代码
输入: beginWord = "hit", endWord = "cog", wordList = ["hot","dot","dog","lot","log"]
输出: 0
解释: endWord "cog" 不在字典中,所以无法进行转换。

解析

这道题目,其实最有意思的点在于看似是一个字符串替换的问题,实际上还是一道图/树的问题,对于单词表中的每个单词,都可以转换为其他的单词,例如:

hot = {dot,lot}

dot = {hot,dog,lot}

dog = {cog,dot,log}

lot = {hot,dot,log}

log = {cog,dog,lot}

那么从起点可以进行宽度优先搜索:

因此这道题的关键就是如何找到转换列表,简单的方式就是拿到字符串之后,对每个位置的字母都进行a - z的替换,看变换后的字符串是否在字典中。

java 复制代码
public static List<String> transform(String temp, List<String> wordList) {

    List<String> res = new ArrayList<>();
    //外层控制a-z 26个字符
    for (char ch = 'a'; ch <= 'z'; ch++) {

        for (int i = 0; i < temp.length(); i++) {
            if (temp.charAt(i) == ch) {
                continue;
            }
            String newWord = replace(temp,i,ch);
            if (wordList.contains(newWord)){
                res.add(newWord);
            }
        }
    }
    return res;
}

private static String replace(String temp, int index, char replaceStr) {
    char[] array = temp.toCharArray();
    array[index] = replaceStr;
    return new String(array);
}

题解

在获取到每个字符串在单词表中的可转换列表之后就非常简单了,通过BFS一层一层搜索,因为endWord是在wordList中的,所以一定存在某个单词的转换列表中,当遍历的过程中拿到endWord之后,就直接返回当前的层级。

java 复制代码
public int ladderLength(String beginWord, String endWord, List<String> wordList) {

    if(beginWord == null || beginWord.length() == 0 
        ||endWord == null || endWord.length() == 0
        || wordList == null || wordList.size() == 0){
            return 0;
    }

    Deque<String> queue = new ArrayDeque<>();
    Set<String> visited = new HashSet<>();

    queue.offer(beginWord);
    visited.add(beginWord);
    int length = 1;
    while(!queue.isEmpty()){

        int size = queue.size();
        length++;
        while(size > 0){

            String word = queue.poll();

            //找当前单词的可转换列表,并从中查找是否存在endWord
            for(String s : transform(word,wordList)){
                if(visited.contains(s)){
                    continue;
                }
                if(s.equals(endWord)){
                    return length;
                }
                queue.offer(s);
                visited.add(s);
            }

            size--;
        }

    }


    return 0;

}

public List<String> transform(String temp, List<String> wordList) {

    List<String> res = new ArrayList<>();
    //外层控制a-z 26个字符
    for (char ch = 'a'; ch <= 'z'; ch++) {

        for (int i = 0; i < temp.length(); i++) {
            if (temp.charAt(i) == ch) {
                continue;
            }
            String newWord = replace(temp,i,ch);
            if (wordList.contains(newWord)){
                res.add(newWord);
            }
        }
    }
    return res;
}

private String replace(String temp, int index, char replaceStr) {
    char[] array = temp.toCharArray();
    array[index] = replaceStr;
    return new String(array);
}

3 拓扑排序

在之前介绍Android启动优化的时候,通过拓扑排序实现了一个启动框架,是基于有向无环图,什么场景下会使用的拓扑排序呢?节点与节点之间存在依赖关系,以此来寻找一个最优路径。

3.1 基础概念

入度:指的是指向该节点的所有节点个数。

因为拓扑排序的前提是:有向、无环、图,因此每个节点都有指向或者被指向的节点,如果一个节点没有被指向的节点,那么入度为0.

那么拓扑排序的基本法则:

  • 统计每个节点的入度
  • 将每个入度为0的点放入队列Queue中,作为起始节点
  • 取出队列中的每个节点,并去掉这个点的所有连边,同时其他点的入度-1.
  • 一旦发现新的节点入度为0,则将这个节点加入队列中。

3.2 课程表问题

你这个学期必须选修 numCourses 门课程,记为 0numCourses - 1

在选修某些课程之前需要一些先修课程。 先修课程按数组 prerequisites 给出,其中 prerequisites[i] = [ai, bi] ,表示如果要学习课程 ai必须 先学习课程 bi

  • 例如,先修课程对 [0, 1] 表示:想要学习课程 0 ,你需要先完成课程 1

请你判断是否可能完成所有课程的学习?如果可以,返回 true ;否则,返回 false

示例 1:

lua 复制代码
输入: numCourses = 2, prerequisites = [[1,0]]
输出: true
解释: 总共有 2 门课程。学习课程 1 之前,你需要完成课程 0 。这是可能的。

示例 2:

lua 复制代码
输入: numCourses = 2, prerequisites = [[1,0],[0,1]]
输出: false
解释: 总共有 2 门课程。学习课程 1 之前,你需要先完成​课程 0 ;并且学习课程 0 之前,你还应先完成课程 1 。这是不可能的。

分析

这里只是一个基本的case,例如一共有4门课,prerequisites = [[1,0],[2,0],[3,1],[3,2]]

要想上1,2课程,必须要学0课程;要想上3课程,必须要学1,2课程。

那么整体的依赖关系就是:

1 ==> 0

2 ==> 0

3 ==> 1,2

求入度数,可以按照二维数组的第一列来计算:

java 复制代码
//队列,只存入度为0的点
Deque<Integer> queue = new ArrayDeque<>();
//计算入度数
int[] inDegree = new int[numCourses];
for(int i = 0;i < prerequisites.length;i++){
    //第i行第0列,代表这个值具有入度数
    inDegree[prerequisites[i][0]]++;
}
for(int i = 0;i< inDegree.length;i++){
    if(inDegree[i] == 0){
        //入队
        queue.offer(i);
    }
}

计算每个节点的边,可以认为每个节点的neighbors,此时需要一个List数组,用来做节点和边的映射关系,从上图从右向左的顺序依次入列。

java 复制代码
//队列,只存入度为0的点

//计算边,也就是每个点的neighbor
List[] graph = new List[numCourses];
//初始化
for(int i = 0;i < graph.length;i++){
    graph[i] = new ArrayList<>();
}

for(int i = 0;i < prerequisites.length;i++){
    //第i行第0列,代表这个值具有入度数
    inDegree[prerequisites[i][0]]++;
    //顺序需要变一下
    graph[prerequisites[i][1]].add(prerequisites[i][0]);
}
for(int i = 0;i< inDegree.length;i++){
    if(inDegree[i] == 0){
        //入队
        queue.offer(i);
    }
}

边的映射关系:

0 ==> 1,2

1 ==> 3

2 ==> 3

因为0的入度为0,当0从队列中出队后,需要将对应的neighbors的入度数-1,也就是1,2的入度数-1,此时1,2会被入队(因为1,2的入度数为1,满足入队的条件)。

以此类推。

题解

java 复制代码
public boolean canFinish(int numCourses, int[][] prerequisites) {
    if(numCourses == 0 || prerequisites == null){
        return false;
    }
    if(prerequisites.length == 0){
        return true;
    }
    //队列,只存入度为0的点
    Deque<Integer> queue = new ArrayDeque<>();
    //计算入度数
    int[] inDegree = new int[numCourses];

    //计算边,也就是每个点的neighbor
    List[] graph = new List[numCourses];
    //初始化
    for(int i = 0;i < graph.length;i++){
        graph[i] = new ArrayList<>();
    }

    for(int i = 0;i < prerequisites.length;i++){
        //第i行第0列,代表这个值具有入度数
        inDegree[prerequisites[i][0]]++;
        graph[prerequisites[i][1]].add(prerequisites[i][0]);
    }
    for(int i = 0;i< inDegree.length;i++){
        if(inDegree[i] == 0){
            //入队
            queue.offer(i);
        }
    }

    int[] arr = new int[numCourses];
    //完成的课程数
    int count = 0;

    while(!queue.isEmpty()){

        int pos = queue.poll();
        count++;

        //查看这个位置的所有边
        for(int i = 0;i < graph[pos].size();i++){
            int nextPos = (int)graph[pos].get(i);
            //对应的位置的边减一
            inDegree[nextPos]--;
            if(inDegree[nextPos] == 0){
                queue.offer(nextPos);
            }
        }

    }
    return count == numCourses;
}

3.3 抽象的拓扑排序

给定一个有向图,图节点的拓扑排序定义如下:

  • 对于图中的每一条有向边 A -> B , 在拓扑排序中A一定在B之前.
  • 拓扑排序中的第一个节点可以是图中的任何一个没有其他节点指向它的节点.

针对给定的有向图找到任意一种拓扑排序的顺序.

样例 1:

输入:

less 复制代码
graph = {0,1,2,3#1,4#2,4,5#3,4,5#4#5}

输出:

csharp 复制代码
[0, 1, 2, 3, 4, 5]

解释:

图如下所示:

拓扑排序可以为:

[0, 1, 2, 3, 4, 5]

[0, 2, 3, 1, 5, 4]

...

您只需要返回给定图的任何一种拓扑顺序。

分析

这道题其实就是一个比较抽象的拓扑排序,它不像课程表那道题一样,给你一个实际的数据,这里其实是有一个抽象的Node,就是文章开始介绍的那种数据结构,每个节点都有自己的值,还有对应的边也就是neighbors。

那么对于这道题来说,我们关键要找到入度数为0的点,剩下的边其实已经帮我们找好了。

我们采用一个Map来映射node与入度数的关系,注意只需要在遍历节点的neighbors的时候,进行入度数的统计即可,因为每个neighbor都意味着有节点指向它。 随后再次遍历图,如果map中没有这个节点,说明这个节点入度为0,此时入队即可。

java 复制代码
// 统计入度数
Map<DirectedGraphNode,Integer> inDegree = new HashMap<>();
// 遍历
for(DirectedGraphNode node : graph){
    //统计邻居
    for(DirectedGraphNode neighbor : node.neighbors){
        if(inDegree.containsKey(neighbor)){
            inDegree.put(neighbor,inDegree.get(neighbor)+1);
        }else{
            inDegree.put(neighbor,1);
        }
    }
}
Deque<DirectedGraphNode> queue = new ArrayDeque<>();
for(DirectedGraphNode node : graph){
    if(!inDegree.containsKey(node)){
        //说明没有指向这个节点的边
        queue.offer(node);
    }
}

题解

整体的算法思想还是那4步,BFS的同时需要关注那些入度为0的节点,做好边数的统计。

java 复制代码
/**
 * @param graph: A list of Directed graph node
 * @return: Any topological order for the given graph.
 */
public ArrayList<DirectedGraphNode> topSort(ArrayList<DirectedGraphNode> graph) {
    if(graph == null || graph.size() == 0){
        return null;
    }

    // 统计入度数
    Map<DirectedGraphNode,Integer> inDegree = new HashMap<>();
    // 遍历
    for(DirectedGraphNode node : graph){
        //统计邻居
        for(DirectedGraphNode neighbor : node.neighbors){
            if(inDegree.containsKey(neighbor)){
                inDegree.put(neighbor,inDegree.get(neighbor)+1);
            }else{
                inDegree.put(neighbor,1);
            }
        }
    }
    Deque<DirectedGraphNode> queue = new ArrayDeque<>();
    for(DirectedGraphNode node : graph){
        if(!inDegree.containsKey(node)){
            //说明没有指向这个节点的边
            queue.offer(node);
        }
    }

    ArrayList<DirectedGraphNode> res = new ArrayList<>();

    while(!queue.isEmpty()){

        //弹出
        DirectedGraphNode node = queue.poll();
        res.add(node);

        //对应边的node入度-1;

        for(DirectedGraphNode neighbor : node.neighbors){
            if(inDegree.get(neighbor) == 1){
                queue.offer(neighbor);
            }
            inDegree.put(neighbor,inDegree.get(neighbor)-1);
        }

    }
    return res;
}
相关推荐
劲夫学编程22 分钟前
leetcode:杨辉三角
算法·leetcode·职场和发展
毕竟秋山澪24 分钟前
孤岛的总面积(Dfs C#
算法·深度优先
浮生如梦_2 小时前
Halcon基于laws纹理特征的SVM分类
图像处理·人工智能·算法·支持向量机·计算机视觉·分类·视觉检测
励志成为嵌入式工程师4 小时前
c语言简单编程练习9
c语言·开发语言·算法·vim
师太,答应老衲吧4 小时前
SQL实战训练之,力扣:2020. 无流量的帐户数(递归)
数据库·sql·leetcode
捕鲸叉5 小时前
创建线程时传递参数给线程
开发语言·c++·算法
A charmer5 小时前
【C++】vector 类深度解析:探索动态数组的奥秘
开发语言·c++·算法
wheeldown5 小时前
【数据结构】选择排序
数据结构·算法·排序算法
观音山保我别报错6 小时前
C语言扫雷小游戏
c语言·开发语言·算法
TangKenny8 小时前
计算网络信号
java·算法·华为