JAVA-数据结构与算法-修路问题(普里姆算法)和公交站问题(克鲁斯卡尔算法)

360 阅读5分钟

写在前面

修路问题(普里姆算法)

  • 最小生成树,给定一个带权的无向连通图,如何选择一颗生成树,使树上所有边上权的总和为最小;N个顶点,N-1条边
  • 普里姆算法,在包含n个顶点的连通图中,找出只有n-1条边,包含所有n个顶点的连通子图,极小连通子图
  • 创建最小生成树,每个顶点的之间都选去最小的权值作为连通点;由于每个顶点都有两种状态访问过/没有被访问过,那么两层for循环,就可以保证所有顶点都被扫描到,再通过mGraph.weight[i][j] < minWeight,保证再一次子图的遍历中,获取的边(向外生长的边)一定是最小的
class MinTree {

    //图的邻接矩阵
    /**
     * @param graph 图对象
     * @param verxs 定点个数
     * @param data 图的各个定点的值
     * @param weight 图的邻接矩阵
     */
    public void createGraph(MGraph graph, int verxs, char[] data, int[][] weight) {
        for (int i = 0; i < verxs; i++) {
            graph.data[i] = data[i];
            for (int j = 0; j < verxs; j++) {
                graph.weight[i][j] = weight[i][j];
            }
        }
    }

    //显示图
    public void showGraph(MGraph graph) {
        for (int[] link : graph.weight) {
            System.out.println(Arrays.toString(link));
        }
    }

    //prim算法
    /**
     * @param mGraph 图
     * @param v 从图的第几个顶点开始生成
     */
    public void prim(MGraph mGraph, int v) {
        //标记顶点是否被访问过,默认都为0,都没有被访问过
        int visited[] = new int[mGraph.verxs];
        //当前节点标记为已访问
        visited[v] = 1;
        //记录两个顶点的下标
        int h1 = -1;
        int h2 = -1;
        //当遇到比minWeight小的权值就会进行替换
        int minWeight= 10000;
        int sumWeight = 0;
        for (int k = 1; k < mGraph.verxs; k++) { //这里的mGraph.verxs的含义指的是应该循环的次数
            //因为有mGraph.verxs顶点,会有边数mGraph.verxs-1
            //要生成n-1条边,也就是要遍历n-1次,找到每一次权值最小的边
            // 每循环一次,visited[]中被遍历过的点都会改变,那么i和j能够遍历范围也会改变,找到每一次的最小权值的边
            //遍历子图
            for (int i = 0; i < mGraph.verxs; i++) { //结合条件visited[i] == 1 ,遍历所有节点中被访问过的节点
                for (int j = 0; j < mGraph.verxs; j++) { //结合条件visited[j] == 0 遍历所有节点中没有访问过的节点
                    //同时这两个节点直接要连通,而且权值最小
                    if (visited[i] == 1 && visited[j] == 0 && mGraph.weight[i][j] < minWeight) {
                        //寻找已经访问过的节点和未访问过的节点,权值最小的边
                        minWeight = mGraph.weight[i][j];
                        h1 = i;
                        h2 = j;
                    }
                }
            }
            //退出一次,就找到了当下子图最小的权值边
            System.out.println("边<" + mGraph.data[h1] + "," + mGraph.data[h2] + "> 权值:" + minWeight);
            //将当前这个节点标记为被访问过的
            visited[h2] = 1;
            sumWeight += minWeight;
            //重置minWeight 重新寻找下一次子图
            minWeight = 10000;
        }
        System.out.println(sumWeight);
    }

}
class MGraph {
    int verxs; //表示图的节点个数
    char[] data; //存放节点数据
    int[][] weight; //存放边,邻接矩阵

    public MGraph(int verxs) {
        this.verxs = verxs;
        data = new char[verxs];
        weight = new int[verxs][verxs];
    }
}
  • 调用
char[] data = {'A', 'B', 'C', 'D', 'E', 'F', 'G'};
int verxs = data.length;
//描述邻接矩阵,10000表示不连通
int[][] weight = {
        // A B C D E F G
        {10000,5,7,10000,10000,10000,2}, //A
        {5,10000,10000,9,10000,10000,3}, //B
        {7,10000,10000,10000,8,10000,10000}, //C
        {10000,9,10000,10000,10000,4,10000}, //D
        {10000,10000,8,10000,10000,5,4}, //E
        {10000,10000,10000,4,5,10000,6}, //F
        {2,3,10000,10000,4,6,10000} //G
};
MGraph mGraph = new MGraph(verxs);
MinTree minTree = new MinTree();
minTree.createGraph(mGraph,verxs,data,weight);
minTree.showGraph(mGraph);
minTree.prim(mGraph,1);

公交站问题(克鲁斯卡尔算法)

  • 生成最小生成树
  • 首先构造一个只含n个顶点的森林,然后依权值从小到大从连通网中选择边加入到森林中,并使森林中不产生回路,直至森林变成一棵树为止
  • 核心是,将图中所有的边进行排序,为了能获取涵盖所有点的边,也就是说,其中的边不能有回路,因为有回路代表着,这两个点明明已经在这条路径上了,但是通过这条回路再次加入,这条边的加入就是浪费
class Krusal {
    private int edgeNum; //边的个数
    private char[] vertexs; //顶点数组
    private int[][] matrix; //邻接矩阵
    private static final int INF = Integer.MAX_VALUE;

    public Krusal(char[] vertexs, int[][] matrix) {
        //复制的方式,用深拷贝
        int vlen = vertexs.length;
        this.vertexs = new char[vlen];
        for (int i = 0; i < vlen; i++) {
            this.vertexs[i] = vertexs[i];
        }
        this.matrix = new int[vlen][vlen];
        for (int i = 0; i < vlen; i++) {
            for (int j = 0; j < vlen; j++) {
                this.matrix[i][j] = matrix[i][j];
            }
        }
        //统计边
        for (int i = 0; i < vlen; i++) {
            for (int j = i+1; j < vlen; j++) {
                if (this.matrix[i][j] != INF) {
                    edgeNum ++;
                }
            }
        }
    }

    //算法
    //要保证每一次加入的边都有新的点,那么p1的最终点和p2的最终点都不能相同
    public void krusalAlgorithm() {
        int index = 0; //表示最后结果的数组索引
        int[] ends = new int[vertexs.length]; //保存已有最小生成树,每个边的顶点的终点,初始为0,0,0,0,0,0,0
        //结果数组
        EData[] res = new EData[edgeNum];
        //获取图中所有边得集合
        EData[] edges = getEdges();
        //排序,针对边的算法
        sortEdges(edges);
        //遍历所有的边,将边添加到最小生成树中,判断准备加入的边是否形成回路,如果没有,就加入,否则不能加入
        for (int i = 0; i < edgeNum; i++) {
            //获取到第i条边的第一个顶点
            int p1 = getPosition(edges[i].start);
            //获取另一个顶点
            int p2 = getPosition(edges[i].end);
            //获取p1这个顶点在最小生成树的终点
            int m = getEnd(ends, p1); //如果没有加入则返回自己本身,那么此时m=p1
            //获取p2这个顶点在最小生成树的终点
            int n = getEnd(ends, p2);
            //那么对于最开始的时候 m=p1 n=p2 也就是设置p1的终点是p2
            //如果此时p1 p2的终点不是一个,说明这个新的边可以加入这个路径中,并且将p1的终点m指向p2的终点n
            //由于`getEnd`的方法中,采取while方式寻找,就可以找到p1出发的终点的终点,
            //那么如果此时待加入的路径是p1和p2的终点n,由于n此时未加入,终点就是本身
            //为通过`getEnd`,通过找到p1的终点找到p1终点的终点是n,
            //那么说明这几个点都在一个路径上,如果加入就不是新的点了,产生回路 
            //是否构成回路
            if (m != n) {
                //设置m的终点是n,如果不构成回路,那么
                ends[m] = n;
                res[index++] = edges[i]; //这条边可以加入
            }
        }
        //统计并打印最小生成树
        //输入n-1条边,最后一条的下标是vertexs.length-2,但是切割的时候是[),左闭右开
        Spliterator<EData> spliterator = Arrays.spliterator(res, 0, vertexs.length-1);
        spliterator.forEachRemaining(eData -> System.out.println(eData));
    }


    public void print() {
        for (int i = 0; i < vertexs.length; i++) {
            for (int j = 0; j < vertexs.length; j++) {
                System.out.printf("%13d", matrix[i][j]);
            }
            System.out.println();
        }
    }

    //对边进行排序
    public void sortEdges(EData[] edges) {
        //冒泡
        //不断确定倒数第i个大的数,只需要确定edges.length-1个数即可
        //前一个和后一个进行比,比到最后的时候 最大的下标为j+1 也就是edges.length - 1 + 1 - i
        for (int i = 0; i < edges.length - 1; i++) {
            for (int j = 0; j < edges.length - 1 - i; j++) {
                if (edges[j].weight > edges[j+1].weight) {
                    EData temp = edges[j];
                    edges[j] = edges[j+1];
                    edges[j+1] = temp;
                }
            }
        }
    }

    //输入顶点值,返回顶点对应的下标
    public int getPosition(char ch) {
        for (int i = 0; i < vertexs.length; i++) {
            if (vertexs[i] == ch) {
                return i;
            }
        }
        return -1;
    }

    //获取图中的边,放入EData
    public EData[] getEdges() {
        int index = 0;
        EData[] edges = new EData[edgeNum];
        for (int i = 0; i < vertexs.length; i++) {
            for (int j = i + 1; j < vertexs.length; j++) { //跳过自己,并且不会重复 例如i=0 扫描 j=1,2,3,4,5,6;i=1 扫描j=2,3,4,5,6
                if (matrix[i][j] != INF) {
                    edges[index++] = new EData(vertexs[i], vertexs[j], matrix[i][j]);
                }
            }
        }
        return edges;
    }

    //获取下标为i的顶点的终点,用于判断两个顶点的终点是否相同
    //数组记录了各个顶点对应的重点,且在遍历过程中逐步形成的,会不断变化
    //返回下标为i的终点下标
    public int getEnd(int[] ends, int i) {
        while (ends[i] != 0) {
            i = ends[i];
        }
        return i;
    }
}

  • 边类
class EData {
    char start; //起点
    char end; //终点
    int weight; //权值

    public EData(char start, char end, int weight) {
        this.start = start;
        this.end = end;
        this.weight = weight;
    }

    @Override
    public String toString() {
        return "EData{" +
                "start=" + start +
                ", end=" + end +
                ", weight=" + weight +
                '}';
    }
}
  • 调用
char[] vertexs = {'A', 'B', 'C', 'D', 'E', 'F', 'G'};
int[][] matrix = { //0表示自己
        // A B C D E F G
        {0,12,INF,INF,INF,16,14},
        {12,0,10,INF,INF,7,INF},
        {INF,10,0,3,5,6,INF},
        {INF,INF,3,0,4,INF,INF},
        {INF,INF,5,4,0,2,8},
        {16,7,6,INF,INF,16,14},
        {14,INF,INF,INF,8,9,0}
};
Krusal krusal = new Krusal(vertexs, matrix);
krusal.krusalAlgorithm();

小结

  • 普里姆和克鲁斯卡尔都是求最小生成树的算法,按我的理解本上是求最小路径,也就是说这条路径要经过所有的点,而且路径上的权重和最小。那么这样就需要注意到,在遍历所有点或者边时,加过的点不能重复加,并且加进去的边要是最小的,而以上两个算法就是针对于不同的角度
  • 而这两个算法则有一个默认的大前提每次往路径里添加的点都要是新的点
  • 普里姆算法,针对的是顶点,将所有顶点分成两个部分,一个部分是已经在这个路径里的点(遍历过的点),一个是不在这个路径上的点(没有遍历过的点),所有才会有了两层for循环,保证了上面的大前提,每次发生关系的必定是遍历过的点和没有遍历过的点,检索所有遍历过的点与没有遍历过的点之间的权重值,并找出最小的那个权重值,再将新的点加入遍历过的集合中,由于图中的点必定是有邻接点的,所以也就有了一个隐藏的条件,n个点只需要n-1条边就能将其连接,那么也只需要整体遍历n-1次,就可以将所有的点都遍历到了,这也就是最外面的for循环次数的由来
  • 克鲁斯卡尔算法,针对的是边,做了很多的铺垫工作,最重要的则是将所有边取出并排序,所以在这个铺垫工作中,最好将其看做有向图不要取重复的边,通过每次一次都尝试加入最短的边,但是也要保证一个大前提新加入的点要是全新的点,所以其就通过记录终点的方式,这里就有了三种情况

一种是一条全新的边,跟原来的边完全没有关系,那么这条边的两个顶点就是其本身,但是一端必定指向另一段(ends[m]=n,这里的m和n分别是p1、p2)

第二种情况,这条边上的一个点已经被包含在这个路径中了,那么如果一个顶点是全新的点,那通过end[i]只能找到本身,就也可以添加,因为一个全新的点是不可能出现在ends数组中的,那么新的点的终点就将指向这个路径的终点,那么这句话是什么意思呢,请看第三种情况的解释

第三种情况,这条边上的两个点都已经被包含在这个路径上了,那么这里就要解释ends数组添加终点的特性。查询ends数组时,会按照添加顺序依次查询到这条路径最终的终点,也就是说只要这个点已经被添加到这条路径上后,这个点的终点和这条路径的其他终点都会是一样的,举例说明,路径中包含A-B 和 B-C,此时添加A-C,很显然根据添加顺序ends[A]=B, ends[B]=C,在查询的时候通过while循环,当查询A终点的时候,返回的就是C,而且只要在B-C之前添加,返回的终点都会是C,比如A-B E-B B-C,查询E终点时,显然已经仍然会是C,这也就说明了,当两个点都被包含在这个路径时,这两个点的终点都会是最后一次被成功添加的边的终点。而对应的,第二种情况中,在检测一个全新的点时,是无法返回出这个路径的终点的,只能返回本身

但是,这里就会有一个疑问,如果终点是0呢,也就是说所有的点的终点都是A,但是这在一开始就做了限制,在获取所有边的时候,规定小下标指向大下标,整个算法也是基于这个规则下来写的,因为最终的目标时找出这么多条边组合出的最短路径,并且这个路径可以涵盖所有的点