经典算法题之(七)------ 建模为二维数组的连通图问题

先看具体例子:LeetCode:547. 朋友圈

这道题可以多种变式,例如,有n个城市,连通关系存储在n*n矩阵中,问从某一个城市出发,可以最多到几个城市去?

即在求连通子图的个数,思想本身也不算难,很多问题经过分析之后都能套用到这套模型中去,确切地说,如果将一个图用一个场景复现出来:每个连通图是某个实际意义的“圈子”,给出每个圈子中有哪些人,或者每个节点在哪些“圈子”(此圈子非彼圈子)中,那么我们就可以将这个场景分析后还原出原来的图,从而用上面的朋友圈模型做。

但是真的必须解构成节点级别的图才能解题吗?

看下面两个问题(这两道都是我听别人描述过,自己复现的,输出和输入样例也是自己设计的):

1.翻译机问题

现在有n个人,m种语言,每一个会0-m种语言(0种代表不会这m种语言中的任意一种)。会同一种语言的才可以正常对话,当然,如果1会A,B语言,2会B,C语言,1会C,D语言,那么我们认为A和C是可以进行交流的(通过B)。如果两人之间没有任何媒介(一个或多个人),则需要一台翻译机进行翻译。

现给出这n个人的语言情况,求至少需要多少台翻译机他们才可以无障碍交流。

样例如下:

输入:

6 5

2 3

1 2

1 3

4 5

5

0

输出:

2

说明:

输入的第一行有两个数,第一个代表有多少个人(n),第二个代表有多少种语言(m),后续的n行分别表示n人掌握语言的情况,如“2 3”代表着第一个人掌握第2,3种语言,以此类推。如果某人不掌握m种语言的任意一个,则为0,输入样例保证此行不会再出现任何数字。

输出为需要的翻译机数目。

一看这道题,联想之前的朋友圈问题,这个当然可以“翻译”成人与人之间的连通的节点图:设置一个LinkedList<Integer>[] list,数组下标代表第几种语言,那么遍历每一个人会的语言的时候,如果存在第x种语言,则将此人序号放入list[x-1],这样遍历完毕就得到了每种语言会的人这个圈子,圈内人的两两之间必然是连通的,使用M(M-1)/2复杂度将所有圈子翻译到N*N连通关系中,就能套用之前的朋友圈模型。

但是真的必须这样吗,看看代码:

public class Solution {
    int N;         // 人数
    int M;         // 语言数
    int count;     // 结果
    // DFS函数
    public void DFS(boolean[][] flag, int row) {
        flag[row][M + 1] = true; // 先置访问标志
        for (int j = 0; j < M + 1; j++) {  // 0也可以一样处理
            if (flag[row][j]) {
                // row行往下遍历
                // 这样可以少递归一层(树的NULL节点)
                for (int i = row + 1; i < N; i++) {
                    if (!flag[i][M + 1] && flag[i][j])  
                        DFS(flag, i);
                }
            }
        }
    }
    // 处理输入问题
    public void interpreterNum() {
        Scanner scan = new Scanner(System.in);
        // 应对多样例连续输入,使用 while(scan.hasNext()){}
        while (scan.hasNext()) {
            count = 0;
            N = scan.nextInt();  // 人数
            M = scan.nextInt();  // 语言数
            scan.nextLine();     // 吃掉回车
            // 语言多出来2列:
            // 0列作为标识会0种语言的圈子(归为一个圈子,一台翻译机即可)
            // M+1列作为标识当前人有没有被访问过(标志位,默认值0未访问)
            boolean[][] flag = new boolean[N][M + 2];
            int i = 0;
            for (i = 0; i < N; i++) {
                String[] strArr = scan.nextLine().split(" ");
                for (String str : strArr) {
                    int index = Integer.valueOf(str);
                    flag[i][index] = true;  // 会0种语言同样适用
                }
            }
            for (int row = 0; row < N; row++) {
                if (!flag[row][M + 1]) {
                    count++;
                    DFS(flag, row);
                }
            }
            System.out.println(count-1);
        }
    }

    public static void main(String[] args) {
        Solution solution = new Solution();
        solution.interpreterNum();
    }
}

理解代码后,注意代码有几个细节:

将所有会0种语言的归为一个圈子(连通图),到时候用一台翻译机即可搞定

②  boolean[][] flag = new boolean[N][M + 2]

1)boolean空间是int的1/4

2)列的维度为M+2而不是M,0列用于记录会0种语言的,第M+1列用于标识当前行(即当前这个人)有没有遍历过,即visited数组。

当然,这个可以单独开辟visited[N]来标识,思路更清晰,这里只是展示了精简空间的写法,简练却不利于阅读。 

③  先置标志位判断当前层,而不是判断完了再置标志位。

④ 判断当前层的某一位上为1时,扫描当前行的“下面”所有行即代码中的“for (int i = row + 1; i < N; i++) {}”,不会扫描上面行了。这样结合interpreterNum()中的:

for (int row = 0; row < N; row++) {
    if (!flag[row][M + 1]) {
        count++;
        DFS(flag, row);
    }
}

假设现在一共6个人,134连通的,2,5连通,6连通。工作流程如下:

第一层循环(对应到代码即i = 0,第1个人):

     count++:

    判断第1行,未访问,第1个人置标志位

    判断第3行,未访问,第3个人置标志位

    判断第4行,未访问,第4个人置标志位

第二层循环(对应到代码即i = 1,第2个人):

    判断第2行,未访问,第2个人置标志位

    判断第5行,未访问,第5个人置标志位

第三层循环(对应到代码即i = 2,第3个人):

    已访问

第四层循环(对应到代码即i = 3,第4个人):

    已访问

第五层循环(对应到代码即i = 4,第5个人):

    已访问

第六层循环(对应到代码即i = 5,第6个人):

    未访问,第6个人置标志位

即,外层计数的同时,一路向下遍历,每一次都能传染一个连通子图,而在DFS内部也是自上而下的,这保证了相对有序的DFS,传统的DFS不加选择的传染四周节点(即肯定是四个方向或所有邻居),是否递归交给判断visited去做,但是判断本身一直是“全备不加选择”的。上例中当前节点只传染下方的节点,相当于在叶子节点剪枝了。如果传统DFS复杂度为N*M(M为方向的个数,因为对于每个节点都要判断M个方向),那么本题的这一句:

for (int i = row + 1; i < N; i++) {}

降了一半的复杂度,为N*M/2。当然可以写成:

for (int i = 0; i < N; i++) {}

但没必要。

⑤ 可以不写递归出口

但是要保证两点:

1)递归出口写在往下一层递归的判断条件上(剪枝条件)

2)一定保证首节点进来时,一定是满足条件的。

结合本题来看:

1)DFS没有写任何递归出口,但是在往下层递归时,有判断条件:

if (!flag[i][M + 1] && flag[i][j])  
    DFS(flag, i);

只有未被访问过且XXX才会下一层递归

2)在方法interpreterNum()中保证了第一次DFS肯定是满足条件的:

当然,为了符合常规递归函数的形式,第一句加上递归出口完全可以,这样也符合不让调用者保证输入的正确性这一思想。面试时候写出来思路更加清晰。

看不分析,再看一个问题。

2.发消息问题

其实和开头LeetCode那道朋友圈问题类似,条件是这样的:

m个微信群中一共包含了n个人(不重复),现在给指定序号为k的人发消息,这个人会把此消息发送到他/她所在的所有群中,这个群中的人看到这条消息又会重复此操作。请问最后会有多少个人收到消息?

样例如下:

输入:

5 7 2

1 2 3

1 2

1 3

4 5 6

7

输出:

3

说明:

输入的第一行有三个数,第一个代表有多少群(m),第二个代表一共有多少人(n),第三个代表最终给序号为多少(k)的人发消息。后续的m行分别表示m个群中包含的人(用序号表示),如“1 2 3”代表着第一个群中有序号为1,2,3的三个人,以此类推。

这道题重在建模,套用上题结构就很好做了。如果是行维取人序号,列维取群序号,则有点晕是不是。

反之,行维取群序号,列维取人序号,则每一行就变成了对于当前群,有哪些人在此群。若此人群里面,从当前列往下搜即得到这个人还加了哪些群,如果有其他群,递归此操作。

此时,类比于上题,“人”这个元素就相当于“语言”,两个群中同时有一个人,则代表这两个群是可以“交流的”——发消息。注意到,上题是求多少人在一个圈子里(连通图个数),这道题是求一个确切的圈子里有多少人。代码还是需要改一改的。

但其实核心就两个:

① 怎么利用初始条件找序号为k的人的第一个群:很简单,行从上往下遍历,找到第一个k列为1的行就是第一个他/她加的群(连通图的任意一个节点,这就是DFS的源点)。

② 原来是求进行了多少次DFS,本题是求一次具体的DFS中遍历的多少个节点

上代码:

public class Solution {
    int M;  // 群个数
    int N;  // 人数
    int count;     // 结果
    // DFS函数
    public void DFS(boolean[][] flag, boolean[] visited_group, boolean[] visited_people, int row) {
        visited_group[row] = true; // 先置访问标志
        for (int j = 0; j < N; j++) {  // 0也可以一样处理
            if (flag[row][j]) {
                if(!visited_people[j]) {
                    // 群包含此人,且此人未被统计过,则统计入count
                    count++;
                    visited_people[j] = true;
                }
                for (int i = row + 1; i < M; i++) {
                    // !visited[i]放在前面有助于减少判断
                    if (!visited_group[i] && flag[i][j]) 
                        DFS(flag,visited_group,visited_people,i);
                }
            }
        }
    }
    // 处理输入问题
    public void friendNum() {
        Scanner scan = new Scanner(System.in);
        // 应对多样例连续输入,使用 while(scan.hasNext()){}
        while (scan.hasNext()) {
            count = 0;    //  一定要进行初始化呀
            String[] head = scan.nextLine().split(" ");
            M = Integer.valueOf(head[0]);   // 群个数
            N = Integer.valueOf(head[1]);      // 人数
            int K = Integer.valueOf(head[2]);  // 发消息的人序号

            boolean[] visited_group = new boolean[M];
            boolean[] visited_people = new boolean[N];
            boolean[][] flag = new boolean[M][N];
            int i = 0;
            for (i = 0; i < M; i++) {
                String[] strArr = scan.nextLine().split(" ");
                for (String str : strArr) {
                    int index = Integer.valueOf(str);
                    flag[i][index-1] = true; 
                }
            }
            for (int row = 0; row < M; row++) {
                if (flag[row][K-1])     // 找到连通图的一个节点
                    DFS(flag,visited_group,visited_people,row);
            }
            System.out.println(count);
        }
    }

    public static void main(String[] args) {
        Solution solution = new Solution();
        solution.friendNum();
    }
}

真正写的时候才发现,还要置“每个人”是否被访问的标志boolean[] visited_people,因为之前的逻辑如果检测当前群里包含某个人时,则以当前人为“联系点”(就跟之前的语言一样),往下搜索,找到其他包含这个人的群,递归进行。这个人在这个过程中其实被统计了多次,绝对不能每次往下递归时候都统计一次,要设一个访问标志位。

当然,如果将本题的问题放到之前的翻译机场景中,对应的问题是什么呢?

“指定一个人,统计这个人所在交流圈中使用多少种语言”

反之,翻译机个数问题在本例中对应的问题应该是:至少要加多少次好友才能保证发一次消息所有人都一定能收到。

 

3.分析

前面是具体的代码,把视野往“上”抬,这两道题其实给了我们解决传统二维数组连通图问题的新思路,上面叫传统二维数组连通图问题呢,其实就是本文第一行作为问题引入的LeetCode:547. 朋友圈 问题,对比这道题和后面介绍的翻译机及发消息问题,可以发现,朋友圈问题所构建的模型是原问题完全解构之后新建的,它建立的模型是完全对等对象间的联系:第i个人和第j个人是否连通。在矩阵上看,就是行跟列都是“人”,而我们这两道题行和列不是维度的,一个是人,一个是包含人的“集合”。

对于翻译机问题,之前已经分析过,使用O(输入)的复杂度,建立会某种语言的“圈子”,每个圈子使用O(M*(M-1))/2(M为此圈子大小)翻译到连通图,后面套用即可。

对于群发消息问题,给出的群就是已经建好的“能够连通的圈子”,直接翻译连通关系即可,具体操作时可以用一个数组存下当前群的人,然后O(M*(M-1)/2)翻译关系即可,后面套用。

但是,可以明显看到,上述方法在时间和空间上远远不如本文采用的方法。

本文,空间上:O(M*N); 时间:O(M*N) (每一行只访问一次,但是行内需要完整遍历)。

而上面所说的套用朋友圈问题的方法:

1)翻译机问题

空间上:

新建的LinkedList是类,开销太大,几乎没有可比性。如果将单独一个数组元素视为O(l),则最差也达到了O(M*N)——所有人会所有语言,则每种语言的链表中都有所有人,即O(M*N)。

因为每种语言会的人起初是不知道的,如果用数组,则每种语言都需要N长度的数组,总共也是O(M*N),考虑到数组所占空间小,而且这种标记数组的形式,可以使用boolean(1/4的int空间)类型,如果真的采用这种方法,建议用boolean[m][n],而不是LinkedList<Integer>[]。

综上,空间上:O(M*N)(建语言圈子)+O(N^2)(人—人连通关系矩阵)

时间上:O(N^2)(翻译关系,最差)+O(N^2)(深搜)

PS:所有加数的平方和小于和平方,故翻译关系最大也就O(N^2),最好情况出现在每一种语言人数均匀分布:n/m。

深搜的复杂度是N^2,看起来好像就是每个节点(连通关系)只访问一次,但其实是这样的推算的:每个人只会被访问一次O(N),访问这个人时,会从头遍历他认识的人O(N)。

2)群发消息问题

使用一个数组暂存当前群成员(可以使用Scanner.nextLine().spit(" ")直接得到),用于翻译群成员连通关系。

空间上:O(N)(建群圈子)+O(N*2)(人—人连通关系矩阵)

PS:为什么这里建圈子只需要O(N)空间了?因为这里圈子是直接给出的,每一行输入即一个圈子,大小是确定的,O(N)作为一个临时变量,存一行的圈子,然后翻译到人—人连通关系矩阵中,接下来该空间即可释放,再申请O(N)作为临时变量,以此类推。即本场景下,不要存储之前的圈子信息,因为圈子不会更新,直接翻译即可,然后处理下个圈子。

时间上:O(N^2)(翻译关系,最差)+O(N^2)(深搜)

分析和翻译机问题一样。

综上,对比列表如下:

        圈—人模型          人-人模型(翻译机)     人-人模型(群发消息)

空间:O(M*N)        O(M*N)+O(N^2)                 O(N)+O(N^2)

时间:O(M*N)              O(N^2)                               O(N^2)

基本上是本文这个方法胜出,但是,唯一的一个突破口是群发消息问题中:如果M远远大于N

此时本文的方法需要的时间空间复杂度非常之高,而群发消息则只与N有关。

因为对于一个群来说,每个人要么在这个群中,要么不在,故一共有2^n-1种可能的群(不可能全不在),这也是M的最大值。考虑一种极端情况:20个人组了2^20-1约1M,即100万个群(麻痹,有点厉害),此时圈人模型高达O(20*1百万),而人人模型才O(20*20)。

但是,一般而言这种情况较为少见,因为常理而言圈子都是远小于人的,如1万人组了100个圈子。但是也不能掉以轻心,最稳妥的方法是注意题目中给出的M和N的范围,可以根据这个选择策略。当然,如果真的有题目为难,不说M和N的大小或者二者都在一个极大的范围内,那么意味着两种极端情况都有(可以说他极为用心,也可以说他极为阴险。。。):1万人组了100个圈子 和 100个人组了1万个圈子。此时可以两种策略同时用,先判断M和N的相对大小,若M<<N(如M/N < 0.1),则圈—人模型,若M>>N(如M/N > 10),则人-人模型,若相差不多,二者选哪个都行。此时代码量比较大就是了。

BTW,目前为止,二维数组的连通图问题可以归为三类,难度一次上升:

1)几何连通图

LeetCode:200. 岛屿数量,这种题目就是之前的专题讲过的二维数组的迷宫问题,直接使用DFS或BFS即可。

2)平等的连通关系

LeetCode:547. 朋友圈

3)个体与集合的连通

本题的两个题

 

 

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值