大厂手撕题目不乏图相关的问题,如何对图有一个很好的认识,以及很好的表示方法是很关键的。
今天正好做了几道题,分享一下代码模板和求解思路,方便回顾与发散思维。
这些题目的主要求解方式为【拓扑排序,并查集,邻接表结合dfs】,其他内容主要为数据结构中的图结构的表示。
冗余连接
并查集求解:
class Solution {
int[] parent;
public int[] findRedundantConnection(int[][] edges) {
// 并查集问题
int n = edges.length;
parent = new int[1005 + 1];
for(int i=0;i<1005;i++){
parent[i] = i; // 初始化并查集
}
for(int i=0;i<edges.length;i++){
int a = edges[i][0];
int b = edges[i][1];
// 判断这两个点是否已经被加入一个并查集(一棵树)
if(find(a) == find(b))return edges[i];
else insert(a, b);
}
return null;
}
public int find(int a){
if(a == parent[a])return a;
parent[a] = find(parent[a]);
return parent[a];
}
public void insert(int a, int b){
if(find(a) == find(b))return;
parent[find(b)] = a;
}
}
对于并查集的学习,可以看acwing的并查集专项AcWing 836. 合并集合 - AcWing ,我个人觉得很明白。
不过并查集有很多扩展,基础的并查集来说主要包含几个结构
int[] parent; // 父节点数组
int find(int u); // 查找u的根是谁,集合归属判别依据
void insert(int u, int v); // 合并方法
加边法结合宽搜:
我个人是更倾向于参考资料里的【加边法】判别
// 加边法 + bfs
class Solution {
public int[] findRedundantConnection(int[][] edges) {
int n = edges.length;
boolean[] visited = new boolean[n];
List<List<Integer>> graph = new ArrayList<>();
for(int i = 0; i < n; ++i) graph.add(new ArrayList<>());
for(int[] edge : edges){
int u = edge[0] - 1, v = edge[1] - 1;
if(bfs(u, v, visited, graph)) { // 通过 bfs 判断 u, v 是否连通
return new int[]{u + 1, v + 1};
}
graph.get(u).add(v); // 加边 (u, v)
graph.get(v).add(u); // 加边 (v, u)
Arrays.fill(visited, false);
}
return null; // 本题保证必有答案
}
public boolean bfs(int u, int v, boolean[] visited, List<List<Integer>> graph){
Queue<Integer> q = new ArrayDeque<>();
q.add(u);
while(!q.isEmpty()){
u = q.remove();
visited[u] = true;
for(int w : graph.get(u)){
if(w == v) return true; // 判明 u, v 连通立即返回 true
if(!visited[w]) q.add(w);
}
}
return false;
}
}
/**
作者:yukiyama
链接:https://leetcode.cn/circle/discuss/FyPTTM/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
**/
因为这种思路最直观,最易于理解:
构造邻接表,每添加一条边,我们就判断当前建成的图是否有环,某一次判断要插入的边已经存在,则可找到目标解.
省份数量
并查集求解:
很多题目都是并查集思路求解,但是中间过程需要做一些适配,比如只要遇到两个城市是相连的,我们就约定以靠前的城市为根,将后者的根像靠前的根看齐。
class Solution {
int[] parent;
public int findCircleNum(int[][] isConnected) {
//一道并查集的经典问题
// 我们尝试运用并查集的思想去求解问题
if(isConnected == null ||isConnected[0] == null || isConnected.length < 1)return 0; //没有城市是相连的
int n = isConnected.length;
// 总共有n个城市
parent = new int[n];
// 初始化并查集
for(int i=0;i<n;i++){
parent[i] = i;
}
Set<Integer> set = new HashSet<>();
// 我们最后看parent数组有几个不同的数即可。
//开始遍历图,以合并城市的连接性
for(int i=0;i<n;i++){
for(int j =0;j<n;j++){
if(isConnected[i][j] == 1){
// 确认i和j两个身份的连接关系
insert(i, j);
}
}
}
for(int i=0;i<n;i++){
// System.out.println("i: " + i + " parent[i]: "+parent[i]);
set.add(find(i));
}
return set.size();
}
public void insert(int u, int v){
if(find(u)==find(v))return;
parent[find(v)] = find(u);
}
public int find(int u){
if(u == parent[u])return u;
parent[u] = find(parent[u]);
return parent[u];
}
}
课程表
逆邻接表结合DFS求解:
class Solution {
public boolean canFinish(int numCourses, int[][] prerequisites) {
// 我们构建一个逆连接表,判断每门课的一个前置课程是否有自己即可。
int len = prerequisites.length;
if(len == 0)return true; //肯定能完成修读
int[] vis = new int[numCourses];
// 我们需要构建一个逆邻接表,谁需要先上,我们就让他先上
Map<Integer, List<Integer>> map = new HashMap<>();
for(int[] pre:prerequisites){
if(!map.containsKey(pre[1])){
List<Integer> ints = new ArrayList<>();
ints.add(pre[0]);
map.put(pre[1], ints);
}else{
List<Integer> lst = map.get(pre[1]);
lst.add(pre[0]);
map.put(pre[1], new ArrayList<Integer>(lst));
}
}
// 遍历这个逆邻接表
for(int i=0;i<numCourses; i++){
// 如果遇到环路
if(dfs(i, map, vis))return false;
}
// 没有遇到环路,成功到达终点
return true;
}
public boolean dfs(int i, Map<Integer, List<Integer>> map, int[] vis){
// 判断这个点是否有环路
// 我们可以定义vis的属性
// vis = 2 代表正在访问
// vis = 1 表示已经可以上课
// vis = 0表示没访问过
if(vis[i] == 2)return true; //本来已经正在访问了,又访问一边
if(vis[i] == 1)return false; // 没环路依赖,可以安全访问
// 开始访问
vis[i] = 2;
// 看i课有没有后续课程
if(map.containsKey(i)){
List<Integer> lst = map.get(i);
// 我们开始上i的后置课程
for(int j=0;j<lst.size(); j++){
if(dfs(lst.get(j), map, vis))return true;
}
}
// 访问完毕
vis[i] = 1;
return false;
}
}
对于Java语言邻接表构建的方案,很灵活。介绍一些参考:
在Java中实现邻接表有多种数据结构可供选择,以下是其中几种常用的数据结构及其优势:
1. Map + List
这种实现方式使用Map存储节点与其对应的边列表,其中边列表可以使用List、Set等数据结构存储。该实现方式的优势在于方便快速地查找某个节点所对应的边列表,同时也支持添加和删除节点以及更新节点属性。
2. ArrayList + LinkedList
这种实现方式使用ArrayList存储节点数组,每个节点通过一个LinkedList来表示它的邻居节点。该实现方式的优势在于可以快速地遍历整个邻接表,同时也支持添加和删除节点以及更新节点属性。
3. HashMap + HashSet
这种实现方式使用HashMap存储节点与其对应的邻居节点集合,其中邻居节点集合可以使用HashSet、TreeSet等数据结构存储。该实现方式的优势在于能够快速地定位某个节点,并且可以快速地进行邻居节点的添加和删除操作。
4. 自定义数据结构
拓扑排序求解:
拓扑排序的核心即找入度为0的结点,以及在算法执行过程中把结点的入度不断-1以及判断。
class Solution {
public boolean canFinish(int numCourses, int[][] prerequisites) {
// 拓扑排序
int len = prerequisites.length;
if(len == 0)return true;
int[] in_degree = new int[numCourses];
Map<Integer, List<Integer>> map = new HashMap<>(); //构建邻接表
// 邻接表存放的是后继节点的集合。
for(int[] pre : prerequisites){
in_degree[pre[0]] ++;
if(!map.containsKey(pre[1])){
List<Integer> lst = new ArrayList<>();
lst.add(pre[0]);
map.put(pre[1], lst);
}else{
// 邻接表项已经存在
List<Integer> lst = map.get(pre[1]);
lst.add(pre[0]);
map.put(pre[1], lst);
}
}
// 首先遍历一遍节点,入度为0的加入队列
Queue<Integer> q = new LinkedList<>();
for(int i=0;i<numCourses;i++){
if(in_degree[i]==0)q.offer(i);
}
// 我们记录入度能清为0,或者本来就为0的课程
int counter = 0;
while(!q.isEmpty()){
int top = q.poll();
counter++;
if(map.containsKey(top)){
List<Integer> lst = map.get(top);
// 把后续课程的入度--
for(int i=0;i<lst.size();i++){
in_degree[lst.get(i)]--;
if(in_degree[lst.get(i)] == 0){
q.offer(lst.get(i));
}
}
}
}
return counter == numCourses;
}
}
腐烂的橘子
宽搜标准解法,中间变量来进行进度跟进:
class Solution {
int round = 0; //回合数
int[] dx = new int[]{-1, 0, 1, 0};
int[] dy = new int[]{0, 1, 0, -1};
public int orangesRotting(int[][] grid) {
// 每一汇合我们都以腐烂的橘子为起点向外扩散。
if(grid == null || grid[0] == null)return -1; //没有橘子,不会腐烂
// 我们引入队列,队列中的元素为腐烂元素的下标,腐烂橘子传递过一次之后不能再被使用。
// 我们定义,使用过的腐烂橘子将变为3, 而0就保留为空白
Queue<int[]> q = new LinkedList<>();
int m = grid.length;
int n = grid[0].length;
int org_num = 0; // 所有橘子综述
int bad_num = 0;
// 每个节点只需要操作一次即可。
int[][] vis = new int[m][n];
// 我们还需要计算一个联通分量, 或者可以限制轮次
for(int i=0;i<m;i++){
for(int j=0;j<n;j++){
// 遍历一次数组需要完成几个任务
// 将腐烂橘子的坐标加入队列
// 判断每个是1橘子的地方是否和其他橘子联通, 只要有一个1橘子不是联通的,则不肯满足题意
// 统计总橘子的个数
if(grid[i][j] == 0)continue;
else if(grid[i][j] == 2){
vis[i][j] = 1;//首次访问
q.offer(new int[]{i,j});
org_num ++;
bad_num ++;
}else{
// System.out.println("we meet a grid == 1");
// System.out.println("i: " + i +" j:" + j);
org_num ++;
int has_org = 0;
// 这一个剪枝不是最完美的,比如这个样例
// [[2],[2],[1],[0],[1],[1]]
// 个人理解如果想规避这种样例,需要引入类似岛屿数量的解题过程。
for(int k = 0;k<4;k++){
int x = i + dx[k];
int y = j + dy[k];
// System.out.println("x: " + x +" y:" + y);
if(x >=0 && x < m && y >= 0 && y < n){
// i,j对应的位置时一个正常的橘子
// x,y 对应的是其四个正方形的位置
// 如果x,y都没有橘子,那就不行
// System.out.println(grid[x][y]);
if(grid[x][y]>0){
has_org++;
}
}
}
if(has_org == 0)return -1;
// System.out.println("it's not alone.");
}
}
}
// 所有的腐烂橘子都在队列里,和总橘子数目相等
// System.out.println("here");
// 全是新鲜橘子,或者一个橘子也没有
if(org_num == 0 || q.size() == org_num)return 0;
if(q.size() == 0 )return -1;// 没有腐烂的
// 迭代正式开始,我们每一次操作队列都要清空当前代的元素,可以类比二叉树的层次遍历
while(!q.isEmpty()){
// 先把当前代需要处理的结点提取出来。
int size = q.size();
++round;
// 这一轮新污染的橘子
int bad_new = 0;
for(int i=0;i<size;i++){
int[] tmp = q.poll();
int x = tmp[0];
int y = tmp[1];
grid[x][y] = 3; //腐烂,且已经用过
// 腐烂橘子向四周进行扩展
for(int k = 0;k<4;k++){
int nx = x + dx[k];
int ny = y + dy[k];
if(nx>=0 && nx < m && ny >=0 && ny < n){
if(vis[nx][ny] == 0 && grid[nx][ny] == 1){
vis[nx][ny] = 1;
q.offer(new int[]{nx, ny});
bad_new++;
// bad_num ++;
}
}
}
}
bad_num += bad_new;
// 这一轮腐败扩散已经结束,我们先判别是否全部已经腐烂
if(bad_num == org_num)return round;
}
// 每一回合结束,我们需要判断每个不为0的位置是否为2,如果已经为2,则终止判断,输出已经走过的回合数
// 如果没有提前返回,到此说明 bad_num != org_num, 有橘子没有腐烂
return -1;
}
}
参考资料: