算法设计与分析第二次作业(leetcode 中 Redundant Connection问题求解)

本文详细解析了LeetCode上的684和685两道题目,分别涉及无向图和有向图中寻找冗余连接的问题。通过对深度优先搜索(DFS)和不相交集(Disjoint Set Union)两种方法的对比,阐述了各自的优缺点及复杂度。特别是Disjoint Set Union结合路径压缩和按秩合并策略的应用,显著提高了算法效率。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

随便写写

上周说过了,自己写了一个Redundant Connection II的题目,难度级别是hard的,但是看了评论区网友的解答自己又做了答案之后觉得挺简单的,就准备随便给个博客写一下(就不当做作业的博客了)。但是后来发现这个题还有一个无向图的版本Redundant Connection,仍然采用Redundant Connection II类似的做法,发现无法通过测试,我把错误样例分析了一下发现之前的做法根本不正确,只不过leetcode测试样例有限,没有测试出错误。于是把这道题拿出来仔细审视了一下,发现之前的做法不对(可见评论区网友也不一定对,虽然那篇评论浏览数很高,但是错的就是错的),希望以后自己以后对什么东西都能保持一定的理性思考,emmm,这很重要。
然后自己用dfs/disjoint union两种方法做了一遍,发现其中dfs方法不仅代码难写,而且复杂度也高,disjoint union恰恰相反。
下面是对于这道题 有向图/无向图 两个不同版本的题解(从中主要收获了学习disjoint set union数据结构,这是算法这门课程以后会涉及到的,但是在学数据结构的时候是没怎么用过)

正文

题目描述

这里写图片描述

问题分析

题目大概意思是:给出一个特殊的无向图结构,它是一个有n个节点的树 加 一条额外的边构成的图,我们需要找出额外的那条边,从而将图还原为树。如果有多个这样的边,我们就取最后一个


解题思路&算法步骤及其复杂度分析
  • 方法一(一个很质朴的想法):

    1. 解题思路:
      对于一个特殊的无向图,它是一个n个节点的树,在它的节点中取两个本来不相连的节点连接起来,那么这个新形成的图必然构成环。因为从连接这两个点的路径必然包括:1.第一点->经过根节点->到第二点,2.第一点->直接到第二点,在无向图情形下,就形成 “第一点->…->根节点->…->第二点->第一点” 的环。我们通过遍历找到这个环,然后找到环上最后出现的边就行了。
    2. 算法步骤 :
      • 随便找一个点,然后对这个点进行深度优先遍历,那么必然能够在遍历完所有节点之前/之后找到环(如果这个起始点刚好在环上面,那么对它深度优先遍历能够回到起点;如果起始点不在环上,那么对它深度优先遍历也能到达图上任何的节点,因为这是个连通图,所以自然能遍历到环上的点,那么最后自然也能够找到环),将这一路经过的边存起来,记为line
      • 然后遍历line中的边,直到遇到一个点,它的终点就是整个集合的终点,这就是整个环的起点,以此截取环的部分,记为cycle
      • 之后遍历题目中输入的edges边集合,查找最后一个出现在cycle中的元素。
    3. 复杂度分析:
      首先是第一步中深度优先搜索一共需要遍历n个节点,并且对于每个节点都需要判断它所在的边是否在现有的cycle集合中,cycle集合中的元素个数为O(n)级别的,所以这个步骤的复杂度为nO(n)=O(n^2);然后是对cycle遍历查找环的起始边,cycle大小是O(n)级别的,所以这个复杂度为O(n);最后对于edges的遍历也是O(n),所以这个算法复杂度为O(n^2)
  • 方法二(使用disjoint set union):

    1. 背景知识

      • 首先我们需要知道叫做disjoint set union(不相交集)的数据结构:
        顾名思义,如果对一组集合的元素(在这个题目可以把节点看作这样的元素)按照某种规则分类,那么可以将它们分成若干个不相交的集合,这些集合就是不相交集。
      • 接下来我们需要了解一些关于disjoint set union的细节:
        它常被用来表示连通的图 以及 快速查询两个元素是否连通
        根据定义描述我们需要支持至少两个操作:1.查询两个元素是否连通(find x将返回一个该集合的id,这个id唯一对应该集合,对于两个连通的点该操作返回相同id),2.合并两个不相交集(union x, y会把两个点集合并,对应的实际含义就是连通两个非连通的子图,然后选出一个新的id代表这个集合)
        一个简单的实现是:每个点使用parent连接上一个点,一直追溯parent直到parent就是自己(可以认为是该子图的根),将这个点作为该集合的id;合并操作就是将某个集合id1设置为另一个集合id2的parent,如此一来这两个集合中任何点都能够追溯到id1;如下代码所示:

        int find(x) {
            while (parent[x] != x)
                x = parent[x];
            return x;
        }
        
        int union(x, y) {
            parent[find(x)] = find(y);
        }

        嗯,如果按照上面的想法做这个题目,代码会简单易懂,但是算法复杂度并不会改变,仍然是O(n^2),原因是外层是大小为n的edges容器,内层的find操作每次就是一个寻找树根的操作,这个操作对于一颗普通的比较均衡的树而言是O(log(n)),但是这里面是一未知状况的树,所以我们应该认为寻根的复杂度为O(n),所以总共的复杂度为O(n^2)

      • 但是其实有更好的方法,就是使用路径压缩和按秩合并的策略
        路径压缩:就是在进行find操作的时候,将路径上所有节点的父节点都设置为根节点,这样在下次对同一个元素进行find操作时复杂度就是O(1)。具体实现是将前面提到的伪代码中x = parent[x] 改为parent[x] = find(parent[x])
        按秩合并:就是使用带权合并的策略,我们将一棵树的高度记为rank,将它作为权,我们在合并两个union的时候,取权重高的作为父节点,这样能够使得一棵树尽可能的平衡(试想我们将高度高的树作为子树接到高度低的树上,那么树将偏向一边),从而在搜索的时候减少回溯的次数(只需要较少的次数就能够找到根)。具体实现是在合并l两点u,v分属的两个集合之前比较rank[find(u)]rank[find(v)],将权重更大的集合的id设置为权重小的集合的id的parent。
        相关的复杂度分析在稍后提到
    2. 解题思路:
      如果利用不相交集,这个题目可以这样做:将“两点是否连通”作为分类条件,那么不连通的两个点自然处于不相同的集合,连通的两点处于相同的集合。我们可以遍历给出的这个图的edges集合,其中每一条edge都连通两点,更准确广泛的说是连通这两个点所在的集合(通过这条边,与第一个点相连通的点集都能 和 与第二个点连通的点集相连),这样我们就把这两个集合合并,反复如此,直到遍历到的某条边的两个顶点已经是连通的,那么这条边会导致成环,这就是我们要找的边。

    3. 算法步骤 :

      • 遍历给出的edges集合
      • 对于集合中每一条边上的两点u,v判断find(x) == find(y)是否成立,如果成立说明这条边造成环(因为这条边连接了两个本来就连通的点,每条边只出现以此所以之前的连通不是通过这条边实现,所以现在有两条路径连接两个点,自然成环)并输出它,如果不成立则继续上述操作直到遇到造成环的边。
      • 上述操作都应使用前面背景知识中提到的路径压缩和按秩合并策略
    4. 复杂度分析:
      在算法导论一书的330页有详细的证明,这里就不说怎么证明(很复杂,如果在这里给出那么博客将增长一倍),如果有需要可以参考算法导论第三版P330
      根据算法导论一书,同时使用前面提到的两种策略情况下,使用n个find操作和n个union操作的复杂度是O(n*α(n)),其中α(n)在大多数应用场景下不超过4,所以这里我们可以认为这个复杂度为O(1),所以使用这个方法解题的复杂度只有O(n)
      可以说相比方法一是有了质的飞跃。

代码实现&运行结果分析

684使用方法一实现:
这个方法一看代码量就知道蛇皮麻烦,别人用的方法虽然也是O(n^2),但是在环的寻找上好多了,别人的方法在这里

class Solution {
public:

    vector<vector<int>> cycle;
    bool isOver = false;
    int endPoint = -1;

    vector<int> res;

    bool myFind(int level, vector<vector<int>>& cycle, vector<int>& temp) {
        for ( auto i = cycle.begin() + level; i != cycle.end(); i++ ) {
            if (*i == temp) return true;
        }
        return false;
    }

    void BFS(int curNode, vector<vector<int>>& treeNode, vector<bool>& isVisited) {
        if (isVisited[curNode]) {
            endPoint = curNode;
            isOver = true;
            return;
        }

        isVisited[curNode] = true;
//cout <<curNode<< endl;

        for ( auto i = treeNode[curNode].begin(); i != treeNode[curNode].end(); i++ ) {
            int l = (*i) > curNode ? curNode : (*i);
            vector<int> temp = { l, (*i) + curNode - l };
            if ( !myFind(0, cycle, temp) ) {
                cycle.push_back(temp);
                BFS(*i, treeNode, isVisited);
                if (isOver) return;
            }
        }
        cycle.pop_back();
    }

    vector<int> findRedundantConnection(vector<vector<int>>& edges) {
        vector<vector<int>> treeNode(edges.size() + 1);
        vector<bool> isVisited(edges.size()+1, false);

        for ( auto i = edges.begin(); i != edges.end(); i++ ) {
            treeNode[(*i)[0]].push_back((*i)[1]);
            treeNode[(*i)[1]].push_back((*i)[0]);   
        }

        BFS(edges[0][0], treeNode, isVisited);

        int level = 0;
        int curNode = edges[0][0];
        while (curNode != endPoint) {
            curNode = cycle[level][0] == curNode ? cycle[level][1] : cycle[level][0];
            level++;
        }

        for ( auto i = edges.begin(); i != edges.end(); i++ ) {
            if ( myFind( level, cycle, (*i) ) ) {
                res = *i;
            }
        }

        return res;
    }

};

684使用方法二实现:


class Solution {
public:

    int findP(int x, int* & parents) {
        if ( parents[x] == x ) return x;
        else {
            parents[x] = findP(parents[x], parents);
            return parents[x];  
        }
    }

    void unionP(int x, int y, int* & parents, int* & ranks) {
        if (ranks[findP(x, parents)] < ranks[findP(y, parents)]) {
            parents[findP(x, parents)] = findP(y, parents);
            ranks[findP(y, parents)]++;
        } else {
            parents[findP(y, parents)] = findP(x, parents);
            ranks[findP(x, parents)]++;
        }
    }

    vector<int> findRedundantConnection(vector<vector<int>>& edges) {
        int* ranks = new int[edges.size() + 1];
        int* parents = new int[edges.size() + 1];
        for (int i = 0; i < edges.size() + 1; ++i)
        {
            ranks[i] = 0;
            parents[i] = i;
        }

        for ( auto i = edges.begin(); i != edges.end() ; i++) {
            if ( findP((*i)[0], parents) == findP((*i)[1], parents) ) {
                return (*i);
            } else {
                unionP((*i)[0], (*i)[1], parents, ranks);
            }
        }

        return *edges.begin();
    }
};

使用方法一和方法二的结果情况对比:(显然方法二更好,超过99%以上,,,)
这里写图片描述
685代码实现:这里说一下,685直接使用dfs也能够达到O(n)复杂度,这是因为它是单向的图,所以任意一点寻根必能找到根/环,这个操作复杂度为O(n),如果直接找到环那么遍历一遍edges即可得到解,如果是找到根,那么再多遍历一次也能够找到环,即使是两个O(n)操作相加,最终还是O(n)复杂度

class Solution {
public:

    vector<vector<int>> cycle;
    bool isOver = false;
    int endPoint = -1;

    vector<int> res;

    vector<int> findRedundantDirectedConnection(vector<vector<int>>& edges) {
        vector<vector<int>> parents(edges.size()+1);
        int order [edges.size() + 1]{};
        vector<bool> isVisited(edges.size()+1, false);
        int twoParentNode = -1;

        int count = 0;
        for ( auto i = edges.begin(); i != edges.end(); i++ ) {
            count++;
            parents[(*i)[1]].push_back((*i)[0]);
            order[(*i)[1]] = count;
            if ( parents[(*i)[1]].size() > 1 ) {
                twoParentNode = (*i)[1];
            }
        }

        if ( twoParentNode != -1 ) {
            int curNode = parents[twoParentNode][0];
            while ( parents[curNode].size() != 0 && curNode != twoParentNode ) {
                curNode = parents[curNode][0];
            }

            if ( parents[curNode].size() == 0 ) {
                return {parents[twoParentNode][1], twoParentNode};
            } else {
                return {parents[twoParentNode][0], twoParentNode};
            }
        }

        int curNode = edges[0][0];
        isVisited[curNode] = true;
        while ( !isVisited[parents[curNode][0]] ) {
            curNode = parents[curNode][0];
            isVisited[curNode] = true;
        }

        int max = -1;
        vector<int> res;
        isVisited.assign( edges.size(), false );
        while ( !isVisited[curNode] ) {
            isVisited[curNode] = true;
            if ( max < order[curNode] ) {
                max = order[curNode];
                res = {parents[curNode][0], curNode};
            }
            curNode = parents[curNode][0];
        }

        return res;
    }

};

对应结果:
这里写图片描述
因为是O(N)复杂度所以效果也很不错。

国科大的算法设计分析相关1-5章复习题 第一章样例: 1.讲义习题一: 第1(执行步改为关键操作数)、第2、3、6、7题 习题一 1答:执行步4pmn+3pm+2m+1;关键操作2n*m*p 2方法一答:2n-2次 方法二答:2n-2次 3 1)证明:任给c,n>c,则10n2>cn 。不存在c使10n22c时,logn>c,从而n2logn>=cn2,同上。 6 答:logn,n2/3,20n,4n2,3n,n! 7 答:1)6+n 2) 3)任意n 2.讲义习题二:第5题。 答:c、e是割点。每点的DFN、L值:A1,1、B2,1、C3,1、D4,4、E5,1、F6,5、G7,5。最大连通分支CD、EFG、ABCE。 3.考虑下述选择排序算法: 输入:n个不等的整数的数组A[1..n] 输出:按递增次序排序的A For i:=1 to n-1 For j:=i+1 to n If A[j]<A[i] then A[i] A[j] 问:(1)最坏情况下做多少次比较运算?答1+2+..+n-1=n(n-1)/2 (2)最坏情况下做多少次交换运算?在什么输入时发生? n(n-1)/2,每次比较都交换,交换次数n(n-1)/2。 4.考虑下面的每对函数f(n)g(n) ,比较他们的阶。 (1) f(n)=(n2-n)/2, g(n)=6n (2)f(n)=n+2 , g(n)=n2 (3)f(n)=n+nlogn, g(n)=n (4)f(n)=log(n!), g(n)= 答:(1)g(n)=O(f(n)) (2)f(n)=O(g(n) (3)f(n)=O(g(n) (4)f(n)=O(g(n) 5.在表中填入true或false . 答案: f(n) g(n) f(n)=O(g(n) f(n)=(g(n)) f(n)=(g(n)) 1 2n3+3n 100n2+2n+100 F T F 2 50n+logn 10n+loglogn T T T 3 50nlogn 10nloglogn F T F 4 logn Log2n T F F 5 n! 5n F T F 6.用迭代法求解下列递推方程: (1) (2) ,n=2k 答:(1)T(n)=T(n-1)+n-1=T(n-2)+n-2+n-1 =…=T(1)+1+2+…+n-1=n(n-1)/2=O(n2) (2)T(n)=2T(n/2)+n-1=2(2T(n/4)+n/2-1)+n-1 =4T(n/4)+n-2+n-1=4(2T(n/23)+n/4-1)+n-2+n-1 =23T(n/23)+n-4+n-2+n-1
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值