先看具体例子: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)个体与集合的连通
本题的两个题