数据结构-图

图是一种较线性表和树更加复杂的数据结构。主要是表示节点之间多对多的关系。

在计算机科学中,图是一种网络结构的抽象模型,它是一组由边连接的顶点组成。一个图*G = (V, E)*由以下元素组成:

  • V:一组顶点
  • E:一组边,连接V中的顶点
    • 注意:顶点的集合是非空的

无向边、有向边、无向图、有向图、稀疏图、稠密图、出度、入度这些相关概念之后会补充。

首先,我们知道了一个数据结构之后,我们要知道如何去表示它。

表示图一般有四种方式邻接矩阵、邻接表、十字链表、邻接多重表

首先介绍一下邻接矩阵的表示方式:

邻接矩阵

在邻接矩阵中,我们用一个二维数组来表示图中顶点之间的连接如果两个顶点之间存在连接,则这两个顶点对应的二维数组下标的元素的值为1否则为0。下图是用邻接矩阵方式表示的图:

img

还会用一个一维数组表示顶点个数

var v=[A,B,C,D,E,F,G,H,I]

如果是加权的图,我们可以将邻接矩阵中二维数组里的值1改成对应的加权数。邻接矩阵方式存在一个缺点,如果图是非强连通的,则二维数组中会有很多的0,这表示我们使用了很多的存储空间来表示根本不存在的边。另一个缺点就是当图的顶点发生改变时,对于二维数组的修改会变得不太灵活。**

优点**:可以很清楚的知道每个节点的出度、入度和顶点之间的关系

缺点:当图是一个稀疏图时,既顶点很多,但是边很少的情况下,此时依然使用邻接矩阵的话,会浪费很多的存储空间

针对上面的缺点,所以我们有了邻接表的方式。使用数组加链表的形式,来表示稀疏图。节省存储空间。

邻接矩阵解决最小生成树和最短路径问题

最小生成树问题

解决这个问题,你首先要知道,什么是生成树?

生成树:在图论的数学领域中,如果连通图 G(V,E) 的一个子图是一棵包含 G 的所有顶点及 |V|-1 条边的树,则该子图称为 G(V,E) 的生成树(SpanningTree)。生成树是连通图的包含图中的所有顶点的极小连通子图一个图的生成树可以有多颗。 简单来说就是包含n个顶点和n-1条边的连通子图。称之为生成树

最小生成树问题是指:所有的生成树中权值和最小的即为最小生成树。

应用

  • 乡镇间电缆布线设计
  • 办公楼房网络设计
  • 建筑间电路设计

适用对象

  • 加权无向图
  • 连通图
  • 对于不连通的图,可以求所有是 连通分量 的最小生成树形成最小森林

得到最小生成树的两个经典算法:Prim算法和Kruskal算法

  1. prime算法

    基本思想:从图中任意一个顶点开始,每次选择与当前顶点集距离最近的顶点,将对应的边加入到树中,直至所有顶点被处理完。

    所以就是根据邻接矩阵中, 找顶点集中权值最小的边即可。只是要去除每次访问的边。

    /**
     * 邻接矩阵
     * 值为顶点与顶点之间边的权值,0表示无自环,一个大数表示无边(比如10000)
     * */
    const MAX_INTEGER = Number.MAX_SAFE_INTEGER;//没有的边
    const MIN_INTEGER = Number.MIN_SAFE_INTEGER;//没有自环
     
    const matrix= [
        [MIN_INTEGER, 9, 2, MAX_INTEGER, 6],
        [9, MIN_INTEGER, 3, MAX_INTEGER, MAX_INTEGER],
        [2, 3, MIN_INTEGER, 5, MAX_INTEGER],
        [MAX_INTEGER, MAX_INTEGER, 5, MIN_INTEGER, 1],
        [6, MAX_INTEGER, MAX_INTEGER, 1, MIN_INTEGER]
    ];
    
    /**
     * 边对象
     * */
    function Edge(begin, end, weight) {
        this.begin = begin;
        this.end = end;
        this.weight = weight;
    }
     
    Edge.prototype.getBegin = function () {
        return this.begin;
    };
    Edge.prototype.getEnd = function () {
        return this.end;
    };
    Edge.prototype.getWeight = function () {
        return this.weight;
    };
     
    /*class Edge {
        constructor(begin, end, weight) {
            this.begin = begin;
            this.end = end;
            this.weight = weight;
        }
        getBegin() {
            return this.begin;
        }
        getEnd() {
            return this.end;
        }
        getWeight() {
            return this.weight;
        }
    }*/
    
    
    // 上面的是准备工作。
    
    /**
     * Prim算法
     * 以某顶点为起点,逐步找各顶点上最小权值的边构建最小生成树,同时其邻接点纳入生成树的顶点中,只要保证顶点不重复添加即可
     * 使用邻接矩阵即可
     * 优点:适合点少边多的情况
     * @param matrix 邻接矩阵
     * @return Array 最小生成树的边集数组
     * */
    function prim(matrix) {
        const rows = matrix.length,
            cols = rows,
            result = [],
            savedNode = [0];//已选择的节点
        let minVex = -1,
            minWeight = MAX_INTEGER;
        for (let i = 0; i < rows; i++) {
            let row = savedNode[i],
                edgeArr = matrix[row];
            for (let j = 0; j < cols; j++) {
                if (edgeArr[j] < minWeight && edgeArr[j] !== MIN_INTEGER) {
                    minWeight = edgeArr[j];
                    minVex = j;
                }
            }
     
            //保证所有已保存节点的相邻边都遍历到
            if (savedNode.indexOf(minVex) === -1 && i === savedNode.length - 1) {
                savedNode.push(minVex);
                result.push(new Edge(row, minVex, minWeight));
     
                //重新在已加入的节点集中找权值最小的边的外部边
                i = -1;
                minWeight = MAX_INTEGER;
     
                //已加入的边,去掉,下次就不会选这条边了
                matrix[row][minVex] = MAX_INTEGER;
                matrix[minVex][row] = MAX_INTEGER;
            }
        }
        return result;
    }
    
    
    
    
  2. Kruskal算法

    其主要的思路就是:遍历所有的边,按权值从小到大排序,每次选取当前权值最小的边,只要不构成回环,则加入生成树

    • 邻接矩阵转化为边集数组。

      与Prim算法不同,Kruskal算法是从最小权值的边开始的,所以使用边集数组更方便。所以需要将邻接矩阵转成边集数组,并且按照边的权重从小到大排序。

      /**
       * 邻接矩阵转边集数组的函数
       * @param matrix 邻接矩阵
       * @return Array 边集数组
       * */
      function changeMatrixToEdgeArray(matrix) {
          const rows = matrix.length,
              cols = rows,
              result = [];
          for (let i = 0; i < rows; i++) {
              const row = matrix[i];
              for(let j = 0 ; j < cols; j++) {
                  if(row[j] !== MIN_INTEGER && row[j] !== MAX_INTEGER) {
                      result.push(new Edge(i, j, row[j]));
                      matrix[i][j] = MAX_INTEGER;
                      matrix[j][i] = MAX_INTEGER;
                  }
              }
          }
          result.sort((a, b) => a.getWeight() - b.getWeight());
          return result;
      }
      
    • Kruskal算法的具体实现

      Kruskal算法的一个要点就是避免环路,这里采用一个数组来保存已纳入生成树的顶点和边(连线),其下标是边(连线)的起点,下标对应的元素值是边(连线)的终点。下标对应的元素值为0,表示还没有以它为起点的边(连线)。

      /**
       * kruskal算法
       * 遍历所有的边,按权值从小到大排序,每次选取当前权值最小的边,只要不构成回环,则加入生成树
       * 邻接矩阵转换成边集数组
       * 优点:适合点多边少的情况
       * @param matrix 邻接矩阵
       * @return Array 最小生成树的边集数组
       * */
      function kruskal(matrix) {
          const edgeArray = changeMatrixToEdgeArray(matrix),
              result = [],
              //使用一个数组保存当前顶点的边的终点,0表示还没有已它为起点的边加入
              savedEdge = new Array(matrix.length).fill(0);
       
          for (let i = 0, len = edgeArray.length; i < len; i++) {
              const edge = edgeArray[i];
              const n = findEnd(savedEdge, edge.getBegin());
              const m = findEnd(savedEdge, edge.getEnd());
              console.log(savedEdge, n, m);
              //不相等表示这条边没有与现有生成树形成环路
              if (n !== m) {
                  result.push(edge);
                  //将这条边的结尾顶点加入数组中,表示顶点已在生成树中
                  savedEdge[n] = m;
              }
          }
          return result;
      }
       
      /**
       * 查找连线顶点的尾部下标
       * @param arr 判断边与边是否形成环路的数组
       * @param start 连线开始的顶点
       * @return Number 连线顶点的尾部下标
       * */
      function findEnd(arr, start) {
          //就是一直循环,直到找到终点,如果没有连线,就返回0
          while (arr[start] > 0) {
              start = arr[start];
          }
          return start;
      }
      
      
最短路径问题

一、Dijkstra算法的思路

Dijkstra算法是针对单源点求最短路径的算法。

其主要思路如下:

  1. 将顶点分为两部分:已经知道当前最短路径的顶点集合Q和无法到达顶点集合R。

  2. 定义一个距离数组(distance)记录源点到各顶点的距离,下标表示顶点,元素值为距离。源点(start)到自身的距离为0,源点无法到达的顶点的距离就是一个大数(比如Infinity)。

  3. 以距离数组中值为非Infinity且值最小(即当前距离最短)的顶点V为中转跳点,假设V跳转至顶点W的距离加上顶点V至源点的距离还小于顶点W至源点的距离,那么就可以更新顶点W至源点的距离。即下面distance[V] + matrix[V][W] < distance[W],那么distance[W] = distance[V] + matrix[V][W]。

  4. 重复上一步骤,即遍历距离数组,同时无法到达顶点集合R为空。

    img

它的邻接矩阵如下:

img

二、求解步骤

第一步:假设源点为V0,那么目前最短路径的顶点集合Q中就只有{V0}和无法到达顶点集合R中有{V1, V2, V3, V4}

第二步:初始化distance数组,就是下面这样

img

第三步:以distance数组中值为非Infinity且值最小的顶点为中转跳点,这一步就是V0,依照如果distance[V] + matrix[V][W] < distance[W],那么distance[W] = distance[V] + matrix[V][W]的规则,distance数组就会变成下面这样,同时集合Q变成了{V0, V1, V2, V4},集合R变成了{V3},即已访问数组为[true, true, true, false, true]。同时计算出当前距离源点最短的节点,即V2。

img

第四步:重复第三步的方法,然后变成以V2为中转跳点,更新距离数组,同时更新已访问数组,变成[true, true, true, true, true]。

img

之后同理,遍历所有节点之后,输出

img

三、代码实现

这个代码没有考虑权值为负数的情况,还没验证负数的情况,目前是按照权值为正数实现的,之后考虑完善。

同时这是针对单源点求最短路径,如果求全图各顶点的最短路径,只需要遍历顶点然后使用Dijkstra算法,这样算上Dijkstra算法本身的时间复杂度,总的复杂度会是O(n^3)。

/**
 * Dijkstra算法:单源最短路径
 * 思路:
 * 1. 将顶点分为两部分:已经知道当前最短路径的顶点集合Q和无法到达顶点集合R。
 * 2. 定义一个距离数组(distance)记录源点到各顶点的距离,下标表示顶点,元素值为距离。源点(start)到自身的距离为0,源点无法到达的顶点的距离就是一个大数(比如Infinity)。
 * 3. 以距离数组中值为非Infinity的顶点V为中转跳点,假设V跳转至顶点W的距离加上顶点V至源点的距离还小于顶点W至源点的距离,那么就可以更新顶点W至源点的距离。即下面distance[V] + matrix[V][W] < distance[W],那么distance[W] = distance[V] + matrix[V][W]。
 * 4. 重复上一步骤,即遍历距离数组,同时无法到达顶点集合R为空。
 *
 * @param matrix 邻接矩阵,表示图
 * @param start 起点
 *
 *
 *
 * 如果求全图各顶点作为源点的全部最短路径,则遍历使用Dijkstra算法即可,不过时间复杂度就变成O(n^3)了
 * */
function Dijkstra(matrix, start = 0) {
    const rows = matrix.length,//rows和cols一样,其实就是顶点个数
        cols = matrix[0].length;
 
    if(rows !== cols || start >= rows) return new Error("邻接矩阵错误或者源点错误");
 
    //初始化distance
    let distance = new Array(rows).fill(Infinity);
    // 初始化访问节点
    let visited = new Array(rows).fill(false);
    distance[start] = 0;
 
    for(let i = 0; i < rows; i++) {
        // 更新节点访问
        visited[start] = true
        // 达到不了的顶点不能作为中转跳点
        if(distance[start] < Infinity) {
            for(let j = 0; j < cols; j++) {
                //通过比较distance[start] + matrix[start][j]和distance[j]的大小来决定是否更新distance[j]。
                if(matrix[start][j] + distance[start] < distance[j]) {
                    distance[j] = matrix[start][j] + distance[start];
                }
            }
        }
        
        // 找到当前最短路径顶点作为中转跳点
        let minIndex = -1;
        let min = Infinity;
        for(let k = 0; k < rows; k++) {
            if (!visited[k] && distance[k] < min) {
                min = distance[k];
                minIndex = k;
            }
        }
        start = minIndex
    }
    return distance;
}
 
/**
 * 邻接矩阵
 * 值为顶点与顶点之间边的权值,0表示无自环,一个大数表示无边(比如10000)
 * */
const MAX_INTEGER = Infinity;//没有边或者有向图中无法到达
const MIN_INTEGER = 0;//没有自环
 
const matrix= [
    [MIN_INTEGER, 9, 2, MAX_INTEGER, 6],
    [9, MIN_INTEGER, 3, MAX_INTEGER, MAX_INTEGER],
    [2, 3, MIN_INTEGER, 5, MAX_INTEGER],
    [MAX_INTEGER, MAX_INTEGER, 5, MIN_INTEGER, 1],
    [6, MAX_INTEGER, MAX_INTEGER, 1, MIN_INTEGER]
];
 
 
console.log(Dijkstra(matrix, 0));//[ 0, 5, 2, 7, 6 ]

四、优化

从上面的解析步骤中可以发现,当前如果所有节点都已经访问过,其实就已经拿到最优解,此时可以结束代码,直接输出。所以最终可以优化代码为如下:

function Dijkstra(matrix, start = 0) {
const rows = matrix.length,//rows和cols一样,其实就是顶点个数
cols = matrix[0].length;

function Dijkstra(matrix, start = 0) {
    const rows = matrix.length,//rows和cols一样,其实就是顶点个数
        cols = matrix[0].length;
 
    if(rows !== cols || start >= rows) return new Error("邻接矩阵错误或者源点错误");
 
    //初始化distance
    let distance = new Array(rows).fill(Infinity);
    // 初始化访问节点
    let visited = new Array(rows).fill(false);
    distance[start] = 0;
 
    // 存在节点未访问则循环
    while(visited.some(item => !item)) {
        // 更新节点访问
        visited[start] = true
        // 达到不了的顶点不能作为中转跳点
        if(distance[start] < Infinity) {
            for(let j = 0; j < cols; j++) {
                //通过比较distance[start] + matrix[start][j]和distance[j]的大小来决定是否更新distance[j]。
                if(matrix[start][j] + distance[start] < distance[j]) {
                    distance[j] = matrix[start][j] + distance[start];
                }
            }
        }
        
        // 找到当前最短路径顶点作为中转跳点
        let minIndex = -1;
        let min = Infinity;
        for(let k = 0; k < rows; k++) {
            if (!visited[k] && distance[k] < min) {
                min = distance[k];
                minIndex = k;
            }
        }
        start = minIndex
    }
    return distance;
}

五、带路径节点

改变distance数组的元素,不在只记录距离,同时记录“中转节点”。

function Node(val, pre) {
  this.val = val // 当前距离
  this.pre = pre || null // 中转(前置)节点
}
 
function Dijkstra(matrix, start = 0) {
    const rows = matrix.length,//rows和cols一样,其实就是顶点个数
        cols = matrix[0].length;
 
    if(rows !== cols || start >= rows) return new Error("邻接矩阵错误或者源点错误");
 
    //初始化distance
    let distance = new Array(rows)
    for (let i =0;i<rows;++i) {
        distance[i] = new Node(Infinity)
    }
    // 初始化访问节点
    let visited = new Array(rows).fill(false);
    distance[start] = new Node(0);
 
    // 存在节点未访问则循环
    while(visited.some(item => !item)) {
        // 更新节点访问
        visited[start] = true
        // 达到不了的顶点不能作为中转跳点
        if(distance[start].val < Infinity) {
            for(let j = 0; j < cols; j++) {
                //通过比较distance[start] + matrix[start][j]和distance[j]的大小来决定是否更新distance[j]。
                if(matrix[start][j] + distance[start].val < distance[j].val) {
                    distance[j].val = matrix[start][j] + distance[start].val;
                    distance[j].pre = start
                }
            }
        }
        
        // 找到当前最短路径顶点作为中转跳点
        let minIndex = -1;
        let min = Infinity;
        for(let k = 0; k < rows; k++) {
            if (!visited[k] && distance[k].val < min) {
                min = distance[k].val;
                minIndex = k;
            }
        }
        start = minIndex
    }
    return distance;
}

邻接表

图的另外一种实现方式是邻接表,它是对邻接矩阵的一种改进。邻接表由图中每个顶点的相邻顶点列表所组成。如下图所示,我们可以用数组链表字典散列表来表示邻接表。

img

优点:节省了存储空间

适用场景:稀疏图和拓扑排序

在javacript中,在我做过的题目中,一般采取以下的操作方式。

  1. 首先用一维数组记录每个顶点的入度数,其中数组的坐标表示顶点。
  2. 然后用一个Map来记录每个顶点的出度。

邻接表解决拓扑排序问题

这里我们想问以下,邻接表这种数据结构一般用来处理什么样的问题呢?

一般邻接表表示的方式,是有来解决拓扑排序问题的。

什么是拓扑排序

比如吃自助火锅,有一套约定俗成的流程,首先先打开包装,然后放入粉、佐料、菜,然后在加水,最后盖上盖子;这是有一套先后顺序的,你不可能没打开包装就放佐料,也可以说这是有一套依赖关系的,盖盖子依赖加水,加水依赖放入粉、佐料、菜,继而依赖打开包装

这种关系通常使用有向图来表示,如果这套流程能够成功的帮助你最后吃到火锅(无环),那这种依赖顺序就是拓扑排序,即拓扑排序是针对有向无环图

说到底,就是要求各个节点之间有先后关系。然后我们在有向图中箭头表示这种先后关系。

例题:你这个学期必须选修 numCourse 门课程,记为 0numCourse-1

在选修某些课程之前需要一些先修课程。 例如,想要学习课程 0 ,你需要先完成课程 1 ,我们用一个匹配来表示他们:[0,1]

给定课程总量以及它们的先决条件,请你判断是否可能完成所有课程的学习?

示例 1:

输入: 2, [[1,0]] 
输出: true
解释: 总共有 2 门课程。学习课程 1 之前,你需要完成课程 0。所以这是可能的。

示例 2:

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

提示:

  • 输入的先决条件是由 边缘列表 表示的图形,而不是 邻接矩阵
  • 你可以假定输入的先决条件中没有重复的边
  • 1 <= numCourses <= 10^5[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vii7eYSl-1601215042339)(https://camo.githubusercontent.com/9fc1e20ea3122d82a7a082427f437ebb1f32421f/687474703a2f2f7265736f757263652e6d757969792e636e2f696d6167652f32303230303631393031353335352e706e67)]

所以我们可以使用 邻接表 来表示有向图中各个节点的依赖关系,同时维护一个入度表,则入度表中入度为 0 的节点所表示的课程是可以立即开始学习的(没有先决条件条件或先觉条件已完成)

拓扑排序的基本步骤:

  • 创建一个队列,并将临接表中所有入度为 0 的节点放入队列中

  • 若队列非空,则从队列中出队第一个节点,

    numCourse —
    

    (学习该课程),然后将将依赖该课程所有临接节点的入度减

    1
    
    • 若减 1 后节点入度为 0,则该课程又是可立即学习课程,将该节点添加到队尾
    • 若减 1 后节点入度不为 0 ,则继续遍历下一节点
  • 当队列为空,检查 numCourses === 0 (所有课程是否全部学习结束)即可

  • let canFinish = function(numCourses, prerequisites) {
        // 如果没有先决条件,即所有的课程均没有依赖关系
        // 直接返回 true
        if (prerequisites.length === 0) {
            return true
        }
    
        // 维护入度表 (一维数组)
        let inDegree = new Array(numCourses).fill(0)
        // 维护临接表 (Map存储入度信息,键是顶点,值是支持的顶点,也就是弧尾)
        let adj = new Map()
        
        for (let e of prerequisites) {
            inDegree[e[0]]++
            if(!adj.has(e[1])) adj.set(e[1], [])
            let vEdge = adj.get(e[1])
            vEdge.push(e[0])
        }
    
        let queue = []
        // 首先加入入度为 0 的结点
        for (let i = 0; i < numCourses; i++) {
            if (inDegree[i] === 0) {
                queue.push(i)
            }
        }
    
        while (queue.length > 0) {
            // 从队首移除
            var v = queue.shift() 
            // 出队一门课程
            numCourses--
            if(!adj.has(v)) continue
            // 遍历当前出队结点的所有临接结点
            for(let w of adj.get(v)) {
                inDegree[w]--
                if (inDegree[w] === 0) {
                    queue.push(w)
                }
            }
        }
        return numCourses === 0
    }
    

ush(e[0])
}

  let queue = []
  // 首先加入入度为 0 的结点
  for (let i = 0; i < numCourses; i++) {
      if (inDegree[i] === 0) {
          queue.push(i)
      }
  }

  while (queue.length > 0) {
      // 从队首移除
      var v = queue.shift() 
      // 出队一门课程
      numCourses--
      if(!adj.has(v)) continue
      // 遍历当前出队结点的所有临接结点
      for(let w of adj.get(v)) {
          inDegree[w]--
          if (inDegree[w] === 0) {
              queue.push(w)
          }
      }
  }
  return numCourses === 0

}

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值