【深大算法设计与分析】实验五 桥 实验报告 附代码、数据集

目录

一、实验目的与要求

二、实验内容与方法

三、实验步骤与过程

四、实验结论与体会

尾注:


一、实验目的与要求

实验目的:

1. 掌握图的连通性。

2. 掌握并查集的基本原理和应用。

实验要求:

1. 实现基准算法。

2. 设计的高效算法中必须使用并查集,如有需要,可以配合使用其他任何数据结构。

3. 验证算法正确性。

4. 使用文件 mediumG.txtlargeG.txt 中的无向图测试基准算法和高效算法的性能,记录两个算法的运行时间。

5. 设计的高效算法的运行时间作为评分标准之一。

6. 提交程序源代码。

7. 实验报告中要详细描述算法设计的思想,核心步骤,使用的数据结构。

二、实验内容与方法

1. 桥的定义

        在图论中,一条边被称为“桥”代表这条边一旦被删除,这张图的连通块数量会增加。等价地说,一条边是一座桥当且仅当这条边不在任何环上。一张图可以有零或多座桥。

                                      

              图 1 没有桥的无向连通图               图 2 有16个顶点和6个桥的图

                                                                     (桥以红色线段标示)

2. 求解问题

        找出一个无向图中所有的桥。

3. 算法

(1)基准算法

For every edge (u, v), do following

        a) Remove (u, v) from graph

        b) See if the graph remains connected (We can either use BFS or DFS)

        c) Add (u, v) back to the graph.

(2)应用并查集设计一个比基准算法更高效的算法。不要使用Tarjan算法,如果使用Tarjan算法,仍然需要利用并查集设计一个比基准算法更高效的算法。

三、实验步骤与过程

(1)数据预处理:

        此阶段需要从本地文件中读取图的信息,并定义数据结构存储图,而由于我们需要使用深度优先或宽度优先算法遍历整张图,使用邻接表会比使用邻接矩阵有更高的效率。

(2)基准算法:

        根据实验文档中给出的伪代码进行代码的编写:

        首先需要对图进行深度优先或者广度优先搜索,以此判断图中连通分量的数量:每次遍历结束则说明找到一个连通分量,如果还有点未被遍历则选取新的起点进行遍历,最后有多少次遍历就说明图中有多少个连通分量。

        接着进行循环,遍历每一条边:在循环中,尝试删除当前遍历的边,并进行深度优先或广度优先搜索,如果发现图的连通分量数量改变,说明当前删除的这条边是图的桥,反之不是。接下来还原删除的这条边,继续循环即可。

        时间复杂度:假设图中有v个节点,e条边。开始时进行深度优先遍历的时间复杂度为O(v+e),接下来进行了e次循环,每次循环都需要遍历整张图,因此此部分的时间复杂度为O(e(v+e)),综上所述,本算法的时间复杂度为\(O(ev^2)\)

        在此基准算法的基础上也可以进行优化:我们在删除每条边之后其实并不需要去遍历整张图,计算整张图的连通分量数量。我们可以直接以删除的边的某一个节点出发进行深度优先搜索,看是否能到达另一个节点,如果可以,则说明删除这条边并没有改变连通分量数量(因为两个点仍然连通),如果不行,即可说明这条边是桥。

图:简单数据集上的演示

        如图所示:边(2,3)为图中唯一的桥,如果去掉这条边,节点2和3将不能通过单次深度优先遍历同时经过。使用这种方法能避免直接统计整张图中的连通分量个数。

        这样一来,每次循环所需进行的遍历就从整张图变成了某个点的连通分量,所用时间会根据图中连通分量的大小而改变:一般而言,图中的连通分量越多(也即每个连通分量的平均大小越小),这种优化就有越显著的效果。

(3)并查集:

        首先介绍并查集本身:

        并查集是一种树形的数据结构,可以用于:①查找两个元素是否属于一个集合;②将两个集合合并为一个。

        具体来讲,并查集是使用树的方式来维护集合的:初始时所有节点都是根节点,每棵树都只有一个节点,每个节点都有一个指针,初始为空,但是与常规的树不同,此处的指针指向的是此节点的父节点。接下来,如果进行了查找的操作,就只需寻找这两个点的根节点,如果根节点相同,就说明了两个点在同一个集合,反之则不在;如果进行了合并的操作,就将其中一个集合的根节点的指针指向另一个集合的根节点。

        接下来将并查集运用到本题中:本题中的“集合”即连通分量,最开始时,每个点都是一个连通分量。接下来需要遍历所有的边,对于一个边的两个顶点,如果他们不在一个连通分量中,就使用上述的方法将他们合并到一个集合中,如果已经在一个集合,就继续遍历。

        接下来遍历每条边,同样进行删去边的操作,再使用并查集的方式统计连通分量的个数,如果统计出的连通分量个数变多,则说明这条边就是桥。

        时间复杂度:假设图中有v个节点,e条边。本算法中,每次计算连通分量的个数时,需要遍历每条边,对于遍历到的每条边,需要判断两个顶点是否在同一个集合中,并可能进行合并集合的操作,此步骤的用时与树的高度相关,但树的高度会随数据集的变化而变化,我们假设树是平衡二叉树,且假设为最坏情况:只有一颗树,此时的树最高,那么树的高度为logv,此步骤的时间复杂度为O(elogv),而后续的删除边操作又需要遍历所有的边,因此算法的时间复杂度为\(O(e^2logv)\)

(4)寻找环边思路:

        从图论的角度分析“桥”的性质:桥可以表示为:去掉之后就会增加连通分量的个数的边;而一条边如果不是“桥”,则去掉它就不会改变连通分量的个数,它就一定是环边。

图:环边一定不是桥

        如图所示,去掉环边之后环依然可以连成一条“线”,因此不会增加连通分量个数。具体的证明如下:

        反证法:如果一条边既不是桥,又不是环边,那么移除这条边之后图中的连通分量数量并不会增加,但是由于这条边不是环边,这条边的两个顶点没有另一条路径可以互相到达,这两个点将会处在不同的连通分量,连通分量数量增加,与前提条件矛盾,故得证。

        这样一来,我们只需要计算图中的环边数量,再用边的总数减去这个数量,就可以计算出桥的数量了。

        接下来问题转化为:如何找出图中的所有环边?

        以某一个节点为起点的(此处是为了与可变起点的深度优先区分)深度优先遍历中,记录每一个节点的前驱节点和遍历深度,以此构建出图的生成树。所有不在生成树中的边必然是图的环边,这是因为在树中加入任意一条边是一定可以构成环的,且这条边本身就是环边。

        构建出生成树之后,接下来的操作就是判断生成树中的那些边是环边:

        ①遍历所有不在树中的边;

        ②对于遍历到的每一条边,将其添加到树中,则必定会形成一个环,找出此环中的所有边,将其记录为环边。

        ③最后,仍然判定为非环边的边就是桥。

 

图1:生成树                                 图2:完整的图

        如图所示:完成第一次遍历构建的生成树如图1。而当前有一条边(3,5)不在树中,我们需要尝试将这条边“添加”到树中形成一个环,并找到这个环具体由边(1,2)、(2,3)、(1,5)和(3,5)构成。最后,未被发现构成了环的边:(0,1)、(0,6)和(3,4)即为此图的桥。

        实际代码编写的思路如下:

        首先需要进行一部分所用数组的定义和初始化:

        由于我们需要记录下那些边在树中而哪些边不在,所以需要创建一个pair类型的数组,用于记录所有不在树中的边,方便遍历。

        对于上述的步骤②:程序实际上并不会将这条边“添加”到树中,而是会找出这条边的两个顶点,向上寻找树中这两个节点的最近公共祖先节点,并将过程中的所有边全部记为环边。

图:寻找最近公共祖先节点

        最后统计树中记为环边的边数,桥的数量即是树的总边数-树中的环边数。

(5)并查集+寻找环边优化+压缩路径:

        上述的寻找环边的方法依然不够高效,问题出在回溯上:每次回溯需要进行的步数等同于环的大小,而面对较大的数据集,环有可能很多且很大,且多个环共享了一些边。这样一来,很多不需再次判断的边还是被遍历到了,算法的运行效率就会被极大地拖慢。

        为了优化回溯的过程,可以结合使用图的生成树和并查集的方法:在上个算法的基础上,每次寻找到某两个节点的最近公共祖先节点,就将寻找过程中所经过的所有节点全部直接连接到这个祖先节点上。在上图的例子中,我们在找到节点3和5的最近公共祖先节点后,会将节点3直接连到5上面:

        如果图中原本还存在边(5,4),路径压缩之前就需要遍历节点2~5,但是压缩之后无需再遍历节点2。对于更加稠密、复杂的图,路径压缩会有更好的效果。

        实际代码编写的思路如下:

        由于存在并查集的优化,可能会生成一些原本不存在的边(如上图中的边(1,3)),因此不好使用保存边的方式保存所有环边。我们使用的是记录节点的方式:为每个节点维护一个状态,一般情况下,如果节点是桥的一个顶点,将其置为1,反之将其置为0。(特殊情况见下文)

        构建完生成树时所有的节点都有可能是桥的一部分,我们需要将状态初始化为1,但由于树的节点数比边数多1,将每棵树的根节点状态都置为0

        在找到两个节点的最近公共祖先后,我们将路程中经过的每一个节点都直接连接到此祖先节点下,也就是将路径中所有节点的父节点指针指向此祖先节点。并将路径中除此祖先节点的所有节点的状态值置为0。

        为什么要除开此祖先节点的补充说明:当我们找到一个这样的环,我们实际上找到的环边数量是此环的边数-1(新加进来的这条边早已确定是环边),但是构成环的节点数等于环的边数,因此我们不能将环中所有节点的状态都置为0,需要除开一个点。具体是哪个点其实并没有必定的规定,但是为了方便输出我们找到的所有桥边,选择了此祖先节点。

图:最终每个节点的状态(绿色表示1,橙色表示0

        最后是进行输出,如果我们只需要得到桥的数量,那么直接统计状态为1的节点的数量即可,如图,最终只剩三个状态为1的节点,桥的数量为3

        由于我们只记录了哪些节点是桥的顶点,想要真正输出桥边是哪些并不是一个显而易见的问题。回顾步骤②的实现,如果一个节点的状态值为1,此节点要么不是环边的节点,要么是某个环边的祖先节点。而由于我们对最近公共祖先节点的寻找是从下往上的,此结点一定会与其父节点构成桥,将其输出。

(6)算法运行测试:

        在给出的两个数据集上进行算法的测试,各个版本算法的运行时间如图(单位为秒):

        其中,midG数据集不存在桥,largeG数据集有8条桥边。

        实验所用代码如下:

#include <iostream>
#include <vector>
#include <chrono>
#include <fstream>

using namespace std;
using namespace std::chrono;

//基准算法
//对每一条边尝试去除,看块的数量是否会增多,如果增多则说明该边是桥
class Force_Bridges {
protected:
    vector<pair<int, int>> bridges;
    vector<vector<int>> map;
    vector<bool> visited;
    vector<pair<int, int>> edges;
    int edgeNumber;
    int vertexNumber;
    int blocks; //块的数目
    int count;
public:
    Force_Bridges(int edgeNumber, int vertexNumber) : edgeNumber(edgeNumber), vertexNumber(vertexNumber) {
        map.resize(vertexNumber);
    }
    void AddEdge(int head, int tail, bool init = false) {
        map[head].push_back(tail);
        map[tail].push_back(head);
        if (init) {
            edges.emplace_back(head, tail);
        }
    }
    void DeleteEdge(int head, int tail) {
        for (auto it = map[head].begin(); it != map[head].end(); it++) {
            if (*it == tail) {
                map[head].erase(it);
                break;
            }
        }
        for (auto it = map[tail].begin(); it != map[tail].end(); it++) {
            if (*it == head) {
                map[tail].erase(it);
                break;
            }
        }
    }

    //本算法在找块时用到的是DFS,DFS搜索时保存变量count,只要count不为零,就说明找到块了
    void DFS(int& current) {
        if (visited[current])
            return;
        visited[current] = true;
        count++;
        for (auto next : map[current]) {
            DFS(next);
        }
    }
    int CountBlocks() {
        int component = 0;
        visited.assign(vertexNumber, false);
        for (int i = 0; i < vertexNumber; i++) {
            count = 0;
            DFS(i);
            if (count) {
                component++;
            }
        }
        return component;
    }

    //算法核心
    //记录原始块数,并尝试删除边,看当前边数是否超过原始边数,如果是,则删除的边为桥
    void Force_findBridge() {
        blocks = CountBlocks();
        for (auto& edge : edges) {
            DeleteEdge(edge.first, edge.second);
            if (blocks < CountBlocks()) {
                bridges.emplace_back(edge.first, edge.second);
            }
            AddEdge(edge.first, edge.second);
        }
    }
    void printfBridge() {
        int ans = 0;
        for (auto& bridge : bridges) {
            //cout << bridge.first << '-' << bridge.second << endl;
            ans++;
        }
        cout << "桥的数量为" << ans << endl;
    }
};

//并查集优化
//对于上面的基准算法,用并查集代替DFS进行数块,从而优化
class Disjoint :public Force_Bridges {
protected:
    vector<pair<int, int>>edgesTemp;
    vector<int> root;
public:
    Disjoint(int edgeNumber, int vertexNumber) : Force_Bridges(edgeNumber, vertexNumber) {
        root.resize(vertexNumber);
    }
    void AddEdge(int head, int tail, bool init = false) {
        if (init) {
            edges.emplace_back(head, tail);
        }
        else {
            edgesTemp.emplace_back(head, tail);
        }
    }

    //优化的核心算法
    //首先将每一个点都当作块
    //然后根据边来连接他们,即连接到并查集
    //然后再数有多少个并查集
    int CountBlocks() {
        int component = 0;
        for (int i = 0; i < vertexNumber; i++) {
            root[i] = i;
        }
        for (auto& edge : edgesTemp) {
            merge(edge.first, edge.second);
        }
        for (int i = 0; i < vertexNumber; i++) {
            if (root[i] == i) {
                component++;
            }
        }
        return component;
    }

    //并查集的实现
    int findRoot(int& vertex) {
        if (root[vertex] == vertex) {
            return vertex;
        }
        return root[vertex] = findRoot(root[vertex]);
    }
    void merge(int& u, int& v) {
        int uRoot = findRoot(u);
        int vRoot = findRoot(v);
        if (uRoot != vRoot) {
            root[vRoot] = uRoot;
        }
    }

    void DeleteEdge(pair<int, int>edge) {
        for (auto it = edgesTemp.begin(); it != edgesTemp.end(); it++) {
            if (*it == edge) {
                edgesTemp.erase(it);
                break;
            }
        }
    }

    void Disjoint_findBridge() {
        edgesTemp = edges;
        blocks = CountBlocks();
        for (auto& edge : edges) {
            DeleteEdge(edge);
            if (blocks < CountBlocks()) {
                bridges.emplace_back(edge.first, edge.second);
            }
            AddEdge(edge.first, edge.second);
        }
    }
};

//逆序找桥
//桥边一定是非环边,环边一定不是桥,故我们可以先找环边,其他的一定是桥边
class LCA :public Force_Bridges {
protected:
    vector<pair<int, int>> notTreeEdges;
    vector<bool> notLoopNode;
    vector<int> depth;
    vector<int> father;
public:
    LCA(int edgeNumber, int vertexNumber) :Force_Bridges(edgeNumber, vertexNumber) {
        map.resize(vertexNumber);
        depth.resize(vertexNumber);
        notLoopNode.assign(vertexNumber, false);
        visited.assign(vertexNumber, false);
        father.resize(vertexNumber);
        for (int i = 0; i < vertexNumber; i++) {
            father[i] = i;
        }
    }

    //通过边创建树,但不是每一个边都一定会在树里面的
    void BuildTree(int& current, int deep, int& currentFather) {
        depth[current] = deep;
        father[current] = currentFather;
        visited[current] = true;
        for (auto& son : map[current]) {
            if (!visited[son]) {
                notLoopNode[son] = true;
                BuildTree(son, deep + 1, current);
            }
        }
    }
    void CreateTree() {
        for (int i = 0; i < vertexNumber; i++) {
            if (!visited[i]) {
                BuildTree(i, 0, i);
            }
        }
    }

    //找非环边
    void FindNotTreeEdge() {
        for (auto& edge : edges) {
            if (father[edge.first] != edge.second && father[edge.second] != edge.first) {
                notTreeEdges.emplace_back(edge.first, edge.second);
            }
        }
    }

    //根据已知的非树边,运用LCA找到路途上所有的环边
    void FindLoopEdge(pair<int, int>& edge) {
        int u = edge.first;
        int v = edge.second;
        while (true) {
            if (depth[u] > depth[v]) {
                notLoopNode[u] = false;
                u = father[u];
            }
            else if (depth[u] < depth[v]) {
                notLoopNode[v] = false;
                v = father[v];
            }
            else if (u != v) {
                notLoopNode[u] = false;
                u = father[u];
                notLoopNode[v] = false;
                v = father[v];
            }
            else {
                break;
            }
        }
    }

    void LCA_findBridge() {
        CreateTree();
        FindNotTreeEdge();
        for (auto& edge : notTreeEdges) {
            FindLoopEdge(edge);
        }
    }

    void printfBridge() {
        int ans = 0;
        for (int i = 0; i < vertexNumber; i++) {
            if (notLoopNode[i]) {
                //具体输出每一条桥边
                //cout << i << '-' << father[i] << endl;
                ans++;
            }
        }
        cout << "桥的数量为" << ans << endl;
    }
};

//在上面思想的基础上
//对于LCA找环边的过程,我们可以用并查集压缩
class LCA_CompressPath :public LCA {
public:
    LCA_CompressPath(int edgeNumber, int vertexNumber) :LCA(edgeNumber, vertexNumber) {
        ;
    }

    //并查集压缩
    void CompressPath(int current, int ancestor) {
        while (father[current] != ancestor) {
            int next = father[current];
            father[current] = ancestor;
            depth[current] = depth[ancestor] + 1;
            current = next;
        }
    }

    //找环边
    void FindLoopEdge(pair<int, int>& edge) {
        int u = edge.first;
        int v = edge.second;
        while (true) {
            if (depth[u] > depth[v]) {
                notLoopNode[u] = false;
                u = father[u];
            }
            else if (depth[u] < depth[v]) {
                notLoopNode[v] = false;
                v = father[v];
            }
            else if (u != v) {
                notLoopNode[u] = false;
                u = father[u];
                notLoopNode[v] = false;
                v = father[v];
            }
            else {//已经找到最近祖先节点,使用并查集进行路径压缩
                CompressPath(edge.first, father[u]);
                CompressPath(edge.second, father[u]);
                break;
            }
        }
    }
    void LCA_findBridge() {
        CreateTree();
        FindNotTreeEdge();
        for (auto& edge : notTreeEdges) {
            FindLoopEdge(edge);
        }
    }
};

int main() {
    //文件名:mediumDG或largeG
    fstream file("mediumDG.txt");//使用代码时需要填上完整路径
    if (!file.is_open()) {
        cout << "File Open Error!" << endl;
        return 0;
    }
    int edgeNumber;
    int vertexNumber;
    int head, tail;
    file >> vertexNumber >> edgeNumber;
    Force_Bridges test1(edgeNumber, vertexNumber);
    Disjoint test2(edgeNumber, vertexNumber);
    LCA test3(edgeNumber, vertexNumber);
    LCA_CompressPath test4(edgeNumber, vertexNumber);
    while (!file.eof()) {
        file >> head >> tail;
        test1.AddEdge(head, tail, true);
        test2.AddEdge(head, tail, true);
        test3.AddEdge(head, tail, true);
        test4.AddEdge(head, tail, true);
    }


    auto beginTime1 = system_clock::now();
    test1.Force_findBridge();
    duration<double> diff1 = system_clock::now() - beginTime1;
    cout << "基准算法:用时" << diff1.count() << 's' << endl;
    test1.printfBridge();
    cout << endl;

    auto beginTime2 = system_clock::now();
    test2.Disjoint_findBridge();
    duration<double> diff2 = system_clock::now() - beginTime2;
    cout << "简单并查集算法:用时" << diff2.count() << 's' << endl;
    test2.printfBridge();
    cout << endl;

    auto beginTime3 = system_clock::now();
    test3.LCA_findBridge();
    duration<double> diff3 = system_clock::now() - beginTime3;
    cout << "寻找环边算法:用时" << diff3.count() << 's' << endl;
    test3.printfBridge();
    cout << endl;

    auto beginTime4 = system_clock::now();
    test4.LCA_findBridge();
    duration<double> diff4 = system_clock::now() - beginTime4;
    cout << "寻找环边+并查集压缩路径优化算法:用时" << diff4.count() << 's' << endl;
    test4.printfBridge();
    cout << endl;
}

四、实验结论与体会

实验结论:

        本实验我们通过编写基准算法、简单并查集算法、寻找环边算法,并在寻找环边算法中引入了并查集压缩路径思想,分别解决了寻找图中桥的数量的问题。除此之外,我们还提出了基准算法的优化。

        完成代码的编写后我们进行了数据测试,对于midG数据集,所有算法都能在短时间内运行完毕,并均能得到正确结果;对于largeG数据集,只有最优化的算法能在短时间内得到结果,用时为0.412492s

实验体会:

       本实验的难点主要在于算法思想,从寻找桥过渡到寻找环边再到构建生成树并寻找最近祖先节点,最后到并查集思想的路径压缩优化,每一步的思维都比较跳跃,需要理清思路,一步步推向最终结果。

尾注:

        本实验是此课程的第五次实验,要想独自想出构建生成树寻找环边、记录节点的状态并输出桥边,以及使用并查集思想压缩路径优化运行时间三种方法并不容易。通过反复阅读或者查阅其他相关博客可以有助于加深此部分的理解。

        如有疑问欢迎讨论,如有好的建议与意见欢迎提出,如有发现错误则恳请指正!

  • 16
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值