【面试高频题】热门数据结构面试题合集(并查集)

1,497 阅读16分钟

本文正在参加「金石计划」

并查集

今天通过 55 道题目来学习图论中维护连通性的利器 : 并查集。

815. 公交路线

给你一个数组 routes ,表示一系列公交线路,其中每个 routes[i] 表示一条公交线路,第 i 辆公交车将会在上面循环行驶。

  • 例如,路线 routes[0] = [1, 5, 7] 表示第 0 辆公交车会一直按序列 1 -> 5 -> 7 -> 1 -> 5 -> 7 -> 1 -> ... 这样的车站路线行驶。

现在从 source 车站出发(初始时不在公交车上),要前往 target 车站。 期间仅可乘坐公交车。

求出 最少乘坐的公交车数量 。如果不可能到达终点车站,返回 -1

示例 1:

输入:routes = [[1,2,7],[3,6,7]], source = 1, target = 6

输出:2

解释:最优策略是先乘坐第一辆公交车到达车站 7 , 然后换乘第二辆公交车到车站 6

示例 2:

输入:routes = [[7,12],[4,5,15],[6],[15,19],[9,12,13]], source = 15, target = 12

输出:-1

提示:

  • 1 <= routes.length <= 500.
  • 1 <= routes[i].length <= 10510^5
  • routes[i] 中的所有值 互不相同
  • sum(routes[i].length) <= 10510^5
  • 0 <= routes[i][j] < 10610^6
  • 0 <= source, target < 10610^6
基本分析

为了方便,我们令每个公交站为一个「车站」,由一个「车站」可以进入一条或多条「路线」。

问题为从「起点车站」到「终点车站」,所进入的最少路线为多少。

抽象每个「路线」为一个点,当不同「路线」之间存在「公共车站」则为其增加一条边权为 11 的无向边。

单向 BFS

由于是在边权为 11 的图上求最短路,我们直接使用 BFS 即可。

起始时将「起点车站」所能进入的「路线」进行入队,每次从队列中取出「路线」时,查看该路线是否包含「终点车站」:

  • 包含「终点车站」:返回进入该线路所花费的距离
  • 不包含「终点车站」:遍历该路线所包含的车站,将由这些车站所能进入的路线,进行入队

一些细节:由于是求最短路,同一路线重复入队是没有意义的,因此将新路线入队前需要先判断是否曾经入队。

代码:

class Solution {
    int s, t;
    int[][] rs;
    public int numBusesToDestination(int[][] _rs, int _s, int _t) {
        rs = _rs; s = _s; t = _t;
        if (s == t) return 0;
        int ans = bfs();
        return ans;
    }
    int bfs() {
        // 记录某个车站可以进入的路线
        Map<Integer, Set<Integer>> map = new HashMap<>();
        // 队列存的是经过的路线
        Deque<Integer> d = new ArrayDeque<>();
        // 哈希表记录的进入该路线所使用的距离
        Map<Integer, Integer> m = new HashMap<>();
        int n = rs.length;
        for (int i = 0; i < n; i++) {
            for (int station : rs[i]) {
                // 将从起点可以进入的路线加入队列
                if (station == s) {
                    d.addLast(i);
                    m.put(i, 1);
                }
                Set<Integer> set = map.getOrDefault(station, new HashSet<>());
                set.add(i);
                map.put(station, set);
            }
        }
        while (!d.isEmpty()) {
            // 取出当前所在的路线,与进入该路线所花费的距离
            int poll = d.pollFirst();
            int step = m.get(poll);

            // 遍历该路线所包含的车站
            for (int station : rs[poll]) {
                // 如果包含终点,返回进入该路线花费的距离即可
                if (station == t) return step;

                // 将由该线路的车站发起的路线,加入队列
                Set<Integer> lines = map.get(station);
                if (lines == null) continue;
                for (int nr : lines) {
                    if (!m.containsKey(nr)) {
                        m.put(nr, step + 1);
                        d.add(nr);
                    }
                }
            }
        }
        return -1;
    }
}
  • 时间复杂度:令路线的数量为 nn,车站的数量为 mm。建图的时间复杂度为 O(i=0n1len(rs[i]))O(\sum_{i=0}^{n-1} len(rs[i]))BFS 部分每个路线只会入队一次,最坏情况下每个路线都包含所有车站,复杂度为 O(nm)O(n * m)。整体复杂度为 O(nm+i=0n1len(rs[i]))O(n * m + \sum_{i=0}^{n-1} len(rs[i]))
  • 空间复杂度:O(nm)O(n * m)
双向 BFS(并查集预处理无解情况)

另外一个做法是使用双向 BFS

首先建图方式不变,将「起点」和「终点」所能进入的路线分别放入两个方向的队列,如果「遇到公共的路线」或者「当前路线包含了目标位置」,说明找到了最短路径。

另外我们知道,双向 BFS 在无解的情况下不如单向 BFS。因此我们可以先使用「并查集」进行预处理,判断「起点」和「终点」是否连通,如果不联通,直接返回 1-1,有解才调用双向 BFS

由于使用「并查集」预处理的复杂度与建图是近似的,增加这样的预处理并不会越过我们时空复杂度的上限,因此这样的预处理是有益的。一定程度上可以最大化双向 BFS 减少搜索空间的效益。

代码:

class Solution {
    static int N = (int)1e6+10;
    static int[] p = new int[N];
    int find(int x) {
        if (p[x] != x) p[x] = find(p[x]);
        return p[x];
    }
    void union(int a, int b) {
        p[find(a)] = p[find(b)];
    }
    boolean query(int a, int b) {
        return find(a) == find(b);
    }
    int s, t;
    int[][] rs;
    public int numBusesToDestination(int[][] _rs, int _s, int _t) {
        rs = _rs; s = _s; t = _t;
        if (s == t) return 0;
        for (int i = 0; i < N; i++) p[i] = i;
        for (int[] r : rs) {
            for (int loc : r) {
                union(loc, r[0]);
            }
        }
        if (!query(s, t)) return -1;
        int ans = bfs();
        return ans;
    }
    // 记录某个车站可以进入的路线
    Map<Integer, Set<Integer>> map = new HashMap<>();
    int bfs() {
        Deque<Integer> d1 = new ArrayDeque<>(), d2 = new ArrayDeque<>();
        Map<Integer, Integer> m1 = new HashMap<>(), m2 = new HashMap<>();
        
        int n = rs.length;
        for (int i = 0; i < n; i++) {
            for (int station : rs[i]) {
                // 将从起点可以进入的路线加入正向队列
                if (station == s) {
                    d1.addLast(i);
                    m1.put(i, 1);
                }
                // 将从终点可以进入的路线加入反向队列
                if (station == t) {
                    d2.addLast(i);
                    m2.put(i, 1);
                }
                Set<Integer> set = map.getOrDefault(station, new HashSet<>());
                set.add(i);
                map.put(station, set);
            }
        }

        // 如果「起点所发起的路线」和「终点所发起的路线」有交集,直接返回 1
        Set<Integer> s1 = map.get(s), s2 = map.get(t);
        Set<Integer> tot = new HashSet<>();
        tot.addAll(s1);
        tot.retainAll(s2);
        if (!tot.isEmpty()) return 1;

        // 双向 BFS
        while (!d1.isEmpty() && !d2.isEmpty()) {
            int res = -1;
            if (d1.size() <= d2.size()) {
                res = update(d1, m1, m2);
            } else {
                res = update(d2, m2, m1);
            }
            if (res != -1) return res;
        }

        return 0x3f3f3f3f; // never
    }
    int update(Deque<Integer> d, Map<Integer, Integer> cur, Map<Integer, Integer> other) {
        int m = d.size();
        while (m-- > 0) {
            // 取出当前所在的路线,与进入该路线所花费的距离
            int poll = d.pollFirst();
            int step = cur.get(poll);

            // 遍历该路线所包含的车站
            for (int station : rs[poll]) {
                // 遍历将由该线路的车站发起的路线
                Set<Integer> lines = map.get(station);
                if (lines == null) continue;
                for (int nr : lines) {
                    if (cur.containsKey(nr)) continue;
                    if (other.containsKey(nr)) return step + other.get(nr);
                    cur.put(nr, step + 1);
                    d.add(nr);
                }
            }
        }
        return -1;
    }
}
  • 时间复杂度:令路线的数量为 nn,车站的个数为 mm。并查集和建图的时间复杂度为 O(i=0n1len(rs[i]))O(\sum_{i=0}^{n-1} len(rs[i]))BFS 求最短路径的复杂度为 O(nm)O(n * m)。整体复杂度为 O(nm+i=0n1len(rs[i]))O(n * m + \sum_{i=0}^{n-1} len(rs[i]))
  • 空间复杂度:O(nm+i=0n1len(rs[i]))O(n * m + \sum_{i=0}^{n-1} len(rs[i]))

675. 为高尔夫比赛砍树

你被请来给一个要举办高尔夫比赛的树林砍树。树林由一个 m×nm \times n 的矩阵表示, 在这个矩阵中:

  • 00 表示障碍,无法触碰
  • 11 表示地面,可以行走
  • 11 大的数 表示有树的单元格,可以行走,数值表示树的高度

每一步,你都可以向上、下、左、右四个方向之一移动一个单位,如果你站的地方有一棵树,那么你可以决定是否要砍倒它。

你需要按照树的高度从低向高砍掉所有的树,每砍过一颗树,该单元格的值变为 11(即变为地面)。

你将从 (0,0)(0, 0) 点开始工作,返回你砍完所有树需要走的最小步数。 如果你无法砍完所有的树,返回 1-1

可以保证的是,没有两棵树的高度是相同的,并且你至少需要砍倒一棵树。

示例 1:

输入:forest = [[1,2,3],[0,0,4],[7,6,5]]

输出:6

解释:沿着上面的路径,你可以用 6 步,按从最矮到最高的顺序砍掉这些树。

提示:

  • m==forest.lengthm == forest.length
  • n==forest[i].lengthn == forest[i].length
  • 1<=m,n<=501 <= m, n <= 50
  • 0<=forest[i][j]<=1090 <= forest[i][j] <= 10^9
基本分析

基本题意为:给定一个 n×mn \times m 的矩阵,每次能够在当前位置往「四联通」移动一个单位,其中 00 的位置代表障碍(无法访问),11 的位置代表平地(可直接访问,且无须进行任何决策),其余大于 11 的位置代表有树,经过该位置的时候可以考虑将树砍掉(相应值变为平地 11)。

同时题目限定了我们只能按照「从低到高」的顺序进行砍树,并且图中不存在高度相等的两棵树,这意味着 整个砍树的顺序唯一确定,就是对所有有树的地方进行「高度」排升序,即是完整的砍树路线。

而另外一个更为重要的性质是:点与点之间的最短路径,不会随着砍树过程的进行而发生变化(某个树点被砍掉,只会变为平地,不会变为阻碍点,仍可通过)。

综上,砍树的路线唯一确定,当我们求出每两个相邻的砍树点最短路径,并进行累加即是答案(整条砍树路径的最少步数)

BFS

因此,再结合数据范围只有 5050,并且点与点之间边权为 11(每次移动算一步),我们可以直接进行 BFS 进行求解。

先对整张图进行一次遍历,预处理出所有的树点(以三元组 (height,x,y)(height, x, y) 的形式进行存储),并对其以 heightheight 排升序,得到唯一确定的砍树路径。

之后就是计算砍树路径中相邻点的最短距离,运用 BFS 求解任意两点的最短路径复杂度为 O(n×m)O(n \times m),我们最多有 n×mn \times m 个树点,因此整体复杂度为 O(n2×m2)O(n^2 * \times m^2)

求解相邻点的最短距离的部分也是整个算法的复杂度上界,数据范围只有 5050,计算量不超过 10710^7,可以过。

代码:

class Solution {
    int N = 50;
    int[][] g = new int[N][N];
    int n, m;
    List<int[]> list = new ArrayList<>();
    public int cutOffTree(List<List<Integer>> forest) {
        n = forest.size(); m = forest.get(0).size();
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < m; j++) {
                g[i][j] = forest.get(i).get(j);
                if (g[i][j] > 1) list.add(new int[]{g[i][j], i, j});
            }
        }
        Collections.sort(list, (a,b)->a[0]-b[0]);
        if (g[0][0] == 0) return -1;
        int x = 0, y = 0, ans = 0;
        for (int[] ne : list) {
            int nx = ne[1], ny = ne[2];
            int d = bfs(x, y, nx, ny);
            if (d == -1) return -1;
            ans += d;
            x = nx; y = ny;
        }
        return ans;
    }
    int[][] dirs = new int[][]{{0,1},{0,-1},{1,0},{-1,0}};
    int bfs(int X, int Y, int P, int Q) {
        if (X == P && Y == Q) return 0;
        boolean[][] vis = new boolean[n][m];
        Deque<int[]> d = new ArrayDeque<>();
        d.addLast(new int[]{X, Y});
        vis[X][Y] = true;
        int ans = 0;
        while (!d.isEmpty()) {
            int size = d.size();
            while (size-- > 0) {
                int[] info = d.pollFirst();
                int x = info[0], y = info[1];
                for (int[] di : dirs) {
                    int nx = x + di[0], ny = y + di[1];
                    if (nx < 0 || nx >= n || ny < 0 || ny >= m) continue;
                    if (g[nx][ny] == 0 || vis[nx][ny]) continue;
                    if (nx == P && ny == Q) return ans + 1;
                    d.addLast(new int[]{nx, ny});
                    vis[nx][ny] = true;
                }
            }
            ans++;
        }
        return -1;
    }
}
  • 时间复杂度:预处理出所有树点的复杂度为 O(n×m)O(n \times m),对树点进行排序的复杂度为 O(nmlognm)O(nm \log{nm}),最多有 n×mn \times m 个树点,对每两个相邻树点运用 BFS 求最短路的复杂度为 O(n×m)O(n \times m),统计完整路径的复杂度为 O(n2×m2)O(n^2 \times m^2)
  • 空间复杂度:O(n×m)O(n \times m)
AStar 算法

由于问题的本质是求最短路,同时原问题的边权为 11,因此套用其他复杂度比 BFS 高的最短路算法,对于本题而言是没有意义,但运用启发式搜索 AStar 算法来优化则是有意义。

因为在 BFS 过程中,我们会无差别往「四联通」方向进行搜索,直到找到「当前树点的下一个目标位置」为止,而实际上,两点之间的最短路径往往与两点之间的相对位置相关。

举个 🌰,当前我们在位置 SS,我们目标位置是 TT,而 TTSS 的右下方,此时我们应当优先搜索方向"往右下方"的路径,当无法从"往右下方"的路径到达 TT,我们再考虑搜索其他大方向的路径。

如何设计这样带有优先级的搜索顺序,则是 AStar 算法「启发式函数」的设计过程,其本质是对应了对「最小步数」的估算,只有当我们确保「最小步数估算 \leq 实际最小步数」,AStar 算法的正确性才得以保证。

因此我们往往会直接使用「理论最小步数」来作为启发式函数的,对于本题,可直接使用「曼哈顿距离」作为「理论最小步数」。

因此,如果我们是要从源点 SS 到汇点 TT,并且当前位于中途点 xx 的话,点 xx 的最小步数估算包括两部分:到点 xx 的实际步数 + 从点 xx 到点 TT 的理论最小步数(曼哈顿距离)。使用「优先队列」按照「总的最小步数估算」进行出队,即可实现 AStar 算法的搜索顺序。

AStar 算法做过很多次了,相关合集可以在 这里 看到。 另外,网上很多对 AStar 正确性证明不了解的人,会缺少以下 map.get(nidx) > step + 1 判断逻辑。 简单来说,启发式函数的设计是针对汇点而言的,因此 AStar 算法搜索过程只确保对 TT 的出入队次序能够对应回到点 TTkk 短路,而对于其余点的出入队次序到其余点的最短路没有必然的对应关系,因此当某个点的最小步数被更新,我们是要将其进行再次入队的。

代码:

class Solution {
    int N = 50;
    int[][] g = new int[N][N];
    int n, m;
    List<int[]> list = new ArrayList<>();
    public int cutOffTree(List<List<Integer>> forest) {
        n = forest.size(); m = forest.get(0).size();
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < m; j++) {
                g[i][j] = forest.get(i).get(j);
                if (g[i][j] > 1) list.add(new int[]{g[i][j], i, j});
            }
        }
        if (g[0][0] == 0) return -1;
        Collections.sort(list, (a,b)->a[0]-b[0]);
        int x = 0, y = 0, ans = 0;
        for (int[] ne : list) {
            int nx = ne[1], ny = ne[2];
            int d = astar(x, y, nx, ny);
            if (d == -1) return -1;
            ans += d;
            x = nx; y = ny;
        }
        return ans;
    }
    int[][] dirs = new int[][]{{0,1},{0,-1},{1,0},{-1,0}};
    int getIdx(int x, int y) {
        return x * m + y;
    }
    int f(int X, int Y, int P, int Q) {
        return Math.abs(X - P) + Math.abs(Y - Q);
    }
    int astar(int X, int Y, int P, int Q) {
        if (X == P && Y == Q) return 0;
        Map<Integer, Integer> map = new HashMap<>();
        PriorityQueue<int[]> q = new PriorityQueue<>((a,b)->a[0]-b[0]);
        q.add(new int[]{f(X, Y, P, Q), X, Y});
        map.put(getIdx(X, Y), 0);
        while (!q.isEmpty()) {
            int[] info = q.poll();
            int x = info[1], y = info[2], step = map.get(getIdx(x, y));
            for (int[] di : dirs) {
                int nx = x + di[0], ny = y + di[1], nidx = getIdx(nx, ny);
                if (nx < 0 || nx >= n || ny < 0 || ny >= m) continue;
                if (g[nx][ny] == 0) continue;
                if (nx == P && ny == Q) return step + 1;
                if (!map.containsKey(nidx) || map.get(nidx) > step + 1) {
                    q.add(new int[]{step + 1 + f(nx, ny, P, Q), nx, ny});
                    map.put(nidx, step + 1);
                }
            }
        }
        return -1;
    }
}
  • 时间复杂度:启发式搜索分析时空复杂度意义不大
  • 空间复杂度:启发式搜索分析时空复杂度意义不大
AStar 算法 + 并查集预处理无解

我们知道,AStar 算法使用到了「优先队列(堆)」来进行启发式搜索,而对于一些最佳路径方向与两点相对位置相反(例如 TTSS 的右边,但由于存在障碍,最短路径需要先从左边绕一圈才能到 TT),AStar 反而会因为优先队列(堆)而多一个 log\log 的复杂度。

因此一个可行的优化是,我们先提前处理「无解」的情况,常见的做法是在预处理过程中运用「并查集」来维护连通性。

这种对于不影响复杂度上界的预处理相比后续可能出现的大量无效搜索(最终无解)的计算量而言,是有益的。

代码:

class Solution {
    int N = 50;
    int[][] g = new int[N][N];
    int n, m;
    int[] p = new int[N * N + 10];
    List<int[]> list = new ArrayList<>();
    void union(int a, int b) {
        p[find(a)] = p[find(b)];
    }
    boolean query(int a, int b) {
        return find(a) == find(b);
    }
    int find(int x) {
        if (p[x] != x) p[x] = find(p[x]);
        return p[x];
    }
    int getIdx(int x, int y) {
        return x * m + y;
    }
    public int cutOffTree(List<List<Integer>> forest) {
        n = forest.size(); m = forest.get(0).size();
        // 预处理过程中,同时使用「并查集」维护连通性
        for (int i = 0; i < n * m; i++) p[i] = i;
        int[][] tempDirs = new int[][]{{0,-1},{-1,0}};
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < m; j++) {
                g[i][j] = forest.get(i).get(j);
                if (g[i][j] > 1) list.add(new int[]{g[i][j], i, j});
                if (g[i][j] == 0) continue;
                // 只与左方和上方的区域联通即可确保不重不漏
                for (int[] di : tempDirs) {
                    int nx = i + di[0], ny = j + di[1];
                    if (nx < 0 || nx >= n || ny < 0 || ny >= m) continue;
                    if (g[nx][ny] != 0) union(getIdx(i, j), getIdx(nx, ny));
                }
            }
        }
        // 若不满足所有树点均与 (0,0),提前返回无解
        for (int[] info : list) {
            int x = info[1], y = info[2];
            if (!query(getIdx(0, 0), getIdx(x, y))) return -1;
        }
        Collections.sort(list, (a,b)->a[0]-b[0]);
        int x = 0, y = 0, ans = 0;
        for (int[] ne : list) {
            int nx = ne[1], ny = ne[2];
            int d = astar(x, y, nx, ny);
            if (d == -1) return -1;
            ans += d;
            x = nx; y = ny;
        }
        return ans;
    }
    int f(int X, int Y, int P, int Q) {
        return Math.abs(X - P) + Math.abs(Y - Q);
    }
    int[][] dirs = new int[][]{{0,1},{0,-1},{1,0},{-1,0}};
    int astar(int X, int Y, int P, int Q) {
        if (X == P && Y == Q) return 0;
        Map<Integer, Integer> map = new HashMap<>();
        PriorityQueue<int[]> q = new PriorityQueue<>((a,b)->a[0]-b[0]);
        q.add(new int[]{f(X, Y, P, Q), X, Y});
        map.put(getIdx(X, Y), 0);
        while (!q.isEmpty()) {
            int[] info = q.poll();
            int x = info[1], y = info[2], step = map.get(getIdx(x, y));
            for (int[] di : dirs) {
                int nx = x + di[0], ny = y + di[1], nidx = getIdx(nx, ny);
                if (nx < 0 || nx >= n || ny < 0 || ny >= m) continue;
                if (g[nx][ny] == 0) continue;
                if (nx == P && ny == Q) return step + 1;
                if (!map.containsKey(nidx) || map.get(nidx) > step + 1) {
                    q.add(new int[]{step + 1 + f(nx, ny, P, Q), nx, ny});
                    map.put(nidx, step + 1);
                }
            }
        }
        return -1;
    }
}
  • 时间复杂度:启发式搜索分析时空复杂度意义不大
  • 空间复杂度:启发式搜索分析时空复杂度意义不大


417. 太平洋大西洋水流问题

有一个 m × n 的矩形岛屿,与 太平洋 和 大西洋 相邻。 “太平洋” 处于大陆的左边界和上边界,而 “大西洋” 处于大陆的右边界和下边界。

这个岛被分割成一个由若干方形单元格组成的网格。给定一个 m x n 的整数矩阵 heights , heights[r][c]heights[r][c] 表示坐标 (r,c)(r, c) 上单元格 高于海平面的高度 。

岛上雨水较多,如果相邻单元格的高度 小于或等于 当前单元格的高度,雨水可以直接向北、南、东、西流向相邻单元格。水可以从海洋附近的任何单元格流入海洋。

返回 网格坐标 result 的 2D 列表 ,其中 result[i]=[ri,ci]result[i] = [r_i, c_i] 表示雨水可以从单元格 (ri,ci)(r_i, c_i) 流向 太平洋和大西洋 。

示例 1:

输入: heights = [[1,2,2,3,5],[3,2,3,4,4],[2,4,5,3,1],[6,7,1,4,5],[5,1,1,2,4]]

输出: [[0,4],[1,3],[1,4],[2,2],[3,0],[3,1],[4,0]]

示例 2:

输入: heights = [[2,1],[1,2]]

输出: [[0,0],[0,1],[1,0],[1,1]]

提示:

  • m==heights.lengthm == heights.length
  • n==heights[r].lengthn == heights[r].length
  • 1<=m,n<=2001 <= m, n <= 200
  • 0<=heights[r][c]<=1050 <= heights[r][c] <= 10^5
基本分析

整理题意,需要我们统计能够同时流向两片海域的格子。

从源点(格子)流向汇点(海域)是按照高度从高到低(非严格)的规则,那么反过来从海域到格子则是按照从低到高(非严格)规则进行,同时本身处于边缘的格子与海域联通。

因此我们可以使用两遍 DFS/BFS 进行求解:分别从与当前海域直接相连的边缘格子出发,统计能够流向当前海域的格子集合,两片海域求得的集合交集即是答案。

BFS(多源 BFS)

使用 BFS 进行求解:目的是构造出两个答案矩阵 res1res_1res2res_2resk[i][j]=trueres_k[i][j] = true 代表格子 (i,j)(i, j) 能够流向海域,起始将所有与海域相连的格子放入队列,然后跑一遍 BFS ,所有能够进入队列的格子均能够与海域联通。

最后统计所有满足 res1[i][j]=res2[i][j]=trueres_1[i][j] = res_2[i][j] = true 的格子即是答案。

代码:

class Solution {
    int n, m;
    int[][] g;
    public List<List<Integer>> pacificAtlantic(int[][] heights) {
        g = heights;
        m = g.length; n = g[0].length;
        Deque<int[]> d1 = new ArrayDeque<>(), d2 = new ArrayDeque<>();
        boolean[][] res1 = new boolean[m][n], res2 = new boolean[m][n];
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                if (i == 0 || j == 0) {
                    res1[i][j] = true;
                    d1.addLast(new int[]{i, j});
                }
                if (i == m - 1 || j == n - 1) {
                    res2[i][j] = true;
                    d2.addLast(new int[]{i, j});
                }
            }
        }
        bfs(d1, res1); bfs(d2, res2);
        List<List<Integer>> ans = new ArrayList<>();
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                if (res1[i][j] && res2[i][j]) {
                    List<Integer> list = new ArrayList<>();
                    list.add(i); list.add(j);
                    ans.add(list);
                }
            }
        }
        return ans;
    }
    int[][] dirs = new int[][]{{1,0},{-1,0},{0,1},{0,-1}};
    void bfs(Deque<int[]> d, boolean[][] res) {
        while (!d.isEmpty()) {
            int[] info = d.pollFirst();
            int x = info[0], y = info[1], t = g[x][y];
            for (int[] di : dirs) {
                int nx = x + di[0], ny = y + di[1];
                if (nx < 0 || nx >= m || ny < 0 || ny >= n) continue;
                if (res[nx][ny] || g[nx][ny] < t) continue;
                d.addLast(new int[]{nx, ny});
                res[nx][ny] = true;
            }
        }
    }
}
  • 时间复杂度:BFS 和统计答案的复杂度均为 O(m×n)O(m \times n)。整体复杂度为 O(m×n)O(m \times n)
  • 空间复杂度:O(m×n)O(m \times n)
并查集

其中维护连通性部分可以使用「并查集」来做:起始将与海域 A 联通的边缘格子与 S 联通,将与海域 B 联通的边缘格子与 T 联通,然后跑一遍 DFS/BFS,最后将既和 S 联通又和 T 联通的格子加入答案。

代码:

class Solution {
    int N = 200 * 200 + 10;
    int[] p1 = new int[N], p2 = new int[N];
    int n, m, tot, S, T;
    int[][] g;
    void union(int[] p, int a, int b) {
        p[find(p, a)] = p[find(p, b)];
    }
    int find(int[] p, int x) {
        if (p[x] != x) p[x] = find(p, p[x]);
        return p[x];
    }
    boolean query(int[] p, int a, int b) {
        return find(p, a) == find(p, b);
    }
    int getIdx(int x, int y) {
        return x * n + y;
    }
    public List<List<Integer>> pacificAtlantic(int[][] _g) {
        g = _g;
        m = g.length; n = g[0].length; tot = m * n; S = tot + 1; T = tot + 2;
        for (int i = 0; i <= T; i++) p1[i] = p2[i] = i;
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                int idx = getIdx(i, j);
                if (i == 0 || j == 0) {
                    if (!query(p1, S, idx)) dfs(p1, S, i, j);
                }
                if (i == m - 1 || j == n - 1) {
                    if (!query(p2, T, idx)) dfs(p2, T, i, j);
                }
            }
        }
        List<List<Integer>> ans = new ArrayList<>();
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                int idx = getIdx(i, j);
                if (query(p1, S, idx) && query(p2, T, idx)) {
                    List<Integer> list = new ArrayList<>();
                    list.add(i); list.add(j);
                    ans.add(list);
                }
            }
        }
        return ans;
    }
    int[][] dirs = new int[][]{{1,0},{-1,0},{0,1},{0,-1}};
    void dfs(int[] p, int ori, int x, int y) {
        union(p, ori, getIdx(x, y));
        for (int[] di : dirs) {
            int nx = x + di[0], ny = y + di[1];
            if (nx < 0 || nx >= m || ny < 0 || ny >= n) continue;
            if (query(p, ori, getIdx(nx, ny)) || g[nx][ny] < g[x][y]) continue;
            dfs(p, ori, nx, ny);
        }
    }
}
  • 时间复杂度:O(n×m)O(n \times m)
  • 空间复杂度:O(n×m)O(n \times m)

765. 情侣牵手

N 对情侣坐在连续排列的 2N 个座位上,想要牵到对方的手。 计算最少交换座位的次数,以便每对情侣可以并肩坐在一起。 一次交换可选择任意两人,让他们站起来交换座位。

人和座位用 0 到 2N-1 的整数表示,情侣们按顺序编号,第一对是 (0, 1),第二对是 (2, 3),以此类推,最后一对是 (2N-2, 2N-1)。

这些情侣的初始座位  row[i] 是由最初始坐在第 i 个座位上的人决定的。

示例 1:

输入: row = [0, 2, 1, 3]
输出: 1
解释: 我们只需要交换row[1]和row[2]的位置即可。

说明:

  • len(row) 是偶数且数值在 [4, 60]范围内。
  • 可以保证row 是序列 0...len(row)-1 的一个全排列。
并查集

首先,我们总是以「情侣对」为单位进行设想:

  1. 当有两对情侣相互坐错了位置,ta们两对之间形成了一个环。需要进行一次交换,使得每队情侣独立(相互牵手)

  2. 如果三对情侣相互坐错了位置,ta们三对之间形成了一个环,需要进行两次交换,使得每队情侣独立(相互牵手)

  3. 如果四对情侣相互坐错了位置,ta们四对之间形成了一个环,需要进行三次交换,使得每队情侣独立(相互牵手)

也就是说,如果我们有 k 对情侣形成了错误环,需要交换 k - 1 次才能让情侣牵手。

于是问题转化成 n / 2 对情侣中,有多少个这样的环。

可以直接使用「并查集」来做。

由于 0和1配对、2和3配对 ... 因此互为情侣的两个编号除以 2 对应同一个数字,可直接作为它们的「情侣组」编号:

 class Solution {
    int[] p = new int[70];
    void union(int a, int b) {
        p[find(a)] = p[find(b)];
    }
    int find(int x) {
        if (p[x] != x) p[x] = find(p[x]);
        return p[x];
    }
    public int minSwapsCouples(int[] row) {
        int n = row.length, m = n / 2;
        for (int i = 0; i < m; i++) p[i] = i;
        for (int i = 0; i < n; i += 2) union(row[i] / 2, row[i + 1] / 2);
        int cnt = 0;
        for (int i = 0; i < m; i++) {
            if (i == find(i)) cnt++;
        }
        return m - cnt;
    }
}
  • 时间复杂度:O(n)O(n)
  • 空间复杂度:O(n)O(n)
贪心

还是以「情侣对」为单位进行分析:

由于题目保证有解,我们也可以从前往后(每两格作为一步)处理,对于某一个位置而言,如果下一个位置不是应该出现的情侣的话。

则对下一个位置进行交换。

同时为了方便我们找到某个值的下标,需要先对 row 进行预处理(可以使用哈希表或数组)。

class Solution {
    public int minSwapsCouples(int[] row) {
        int n = row.length;
        int ans = 0;
        int[] cache = new int[n];
        for (int i = 0; i < n; i++) cache[row[i]] = i;
        for (int i = 0; i < n - 1; i += 2) {
            int a = row[i], b = a ^ 1;
            if (row[i + 1] != b) {
                int src = i + 1, tar = cache[b];
                cache[row[tar]] = src;
                cache[row[src]] = tar;
                swap(row, src, tar);
                ans++;
            }
        }
        return ans;
    }
    void swap(int[] nums, int a, int b) {
        int c = nums[a];
        nums[a] = nums[b];
        nums[b] = c;
    }
}
  • 时间复杂度:O(n)O(n)
  • 空间复杂度:O(n)O(n)
证明/分析

我们这样的做法本质是什么?

其实相当于,当我处理到第 k 个位置的时候,前面的 k - 1 个位置的情侣已经牵手成功了。我接下来怎么处理,能够使得总花销最低。

分两种情况讨论:

a. 现在处理第 k 个位置,使其牵手成功:

那么我要使得第 k 个位置的情侣也牵手成功,那么必然是保留第 k 个位置的情侣中其中一位,再进行修改,这样的成本是最小的(因为只需要交换一次)。

而且由于前面的情侣已经牵手成功了,因此交换的情侣必然在 k 位置的后面。

然后我们再考虑交换左边或者右边对最终结果的影响。

分两种情况来讨论:

  1. 与第 k 个位置的匹配的两个情侣不在同一个位置上:这时候无论交换左边还是右边,后面需要调整的「情侣对数量」都是一样。假设处理第 k 个位置前需要调整的数量为 n 的话,处理完第 k 个位置(交换左边或是右边),需要调整的「情侣对数量」都为 n - 1

  1. 与第 k 个位置的匹配的两个情侣在同一个位置上:这时候无论交换左边还是右边,后面需要调整的「情侣对数量」都是一样。假设处理第 k 个位置前需要调整的数量为 n 的话,处理完第 k 个位置(交换左边或是右边),需要调整的「情侣对数量」都为 n - 2

因此对于第 k 个位置而言,交换左边还是右边,并不会影响后续需要调整的「情侣对数量」。

b. 现在先不处理第 k 个位置,等到后面的情侣处理的时候「顺便」处理第 k 位置:

由于我们最终都是要所有位置的情侣牵手,而且每一个数值对应的情侣数值是唯一确定的。

因此我们这个等“后面”的位置处理,其实就是等与第 k 个位置互为情侣的位置处理(对应上图的就是我们是在等 【0 x】和【8 y】或者【0 8】这些位置被处理)。

由于被处理都是同一批的联通位置,因此和「a. 现在处理第 k 个位置」的分析结果是一样的。

不失一般性的,我们可以将这个分析推广到第一个位置,其实就已经是符合「当我处理到第 k 个位置的时候,前面的 k - 1 个位置的情侣已经牵手成功了」的定义了。

综上所述,我们只需要确保从前往后处理,并且每次处理都保留第 k 个位置的其中一位,无论保留的左边还是右边都能得到最优解。

778. 水位上升的泳池中游泳

在一个 N x N 的坐标方格 grid 中,每一个方格的值 grid[i][j]grid[i][j] 表示在位置 (i,j)(i,j) 的平台高度。

现在开始下雨了。当时间为 tt 时,此时雨水导致水池中任意位置的水位为 tt 。

你可以从一个平台游向四周相邻的任意一个平台,但是前提是此时水位必须同时淹没这两个平台。

假定你可以瞬间移动无限距离,也就是默认在方格内部游动是不耗时的。

当然,在你游泳的时候你必须待在坐标方格里面。

你从坐标方格的左上平台 (0,0)(0, 0) 出发,最少耗时多久你才能到达坐标方格的右下平台 (N1,N1)(N-1,N-1)

示例 1:

输入: [[0,2],[1,3]]

输出: 3

解释:
时间为0时,你位于坐标方格的位置为 (0, 0)。
此时你不能游向任意方向,因为四个相邻方向平台的高度都大于当前时间为 0 时的水位。
等时间到达 3 时,你才可以游向平台 (1, 1). 因为此时的水位是 3,坐标方格中的平台没有比水位 3 更高的,所以你可以游向坐标方格中的任意位置

示例2:

输入: [[0,1,2,3,4],[24,23,22,21,5],[12,13,14,15,16],[11,17,18,19,20],[10,9,8,7,6]]
输出: 16

解释:
 0  1  2  3  4
             5
12 13 14 15 16
11  
10  9  8  7  6

提示:

  • 2 <= N <= 50.
  • grid[i][j] 是 [0, ..., N*N - 1] 的排列。
Kruskal

由于在任意点可以往任意方向移动,所以相邻的点(四个方向)之间存在一条无向边。

边的权重 ww 是指两点节点中的最大高度。

按照题意,我们需要找的是从左上角点到右下角点的最优路径,其中最优路径是指途径的边的最大权重值最小,然后输入最优路径中的最大权重值。

我们可以先遍历所有的点,将所有的边加入集合,存储的格式为数组 [a,b,w][a, b, w] ,代表编号为 aa 的点和编号为 bb 的点之间的权重为 ww(按照题意,ww 为两者的最大高度)。

对集合进行排序,按照 ww 进行从小到达排序。

当我们有了所有排好序的候选边集合之后,我们可以对边从前往后处理,每次加入一条边之后,使用并查集来查询左上角的点和右下角的点是否连通。

当我们的合并了某条边之后,判定左上角和右下角的点联通,那么该边的权重即是答案。

这道题和前天的 1631. 最小体力消耗路径 几乎是完全一样的思路。

你甚至可以将那题的代码拷贝过来,改一下对于 ww 的定义即可。

代码:

class Solution {
    int n;
    int[] p;
    void union(int a, int b) {
        p[find(a)] = p[find(b)];
    }
    boolean query(int a, int b) {
        return find(a) == find(b);
    }
    int find(int x) {
        if (p[x] != x) p[x] = find(p[x]);
        return p[x];
    }
    
    public int swimInWater(int[][] grid) {
        n = grid.length;
        
        // 初始化并查集
        p = new int[n * n];
        for (int i = 0; i < n * n; i++) p[i] = i;

        // 预处理出所有的边
        // edge 存的是 [a, b, w]:代表从 a 到 b 所需要的时间为 w
        // 虽然我们可以往四个方向移动,但是只要对于每个点都添加「向右」和「向下」两条边的话,其实就已经覆盖了所有边了
        List<int[]> edges =  new ArrayList<>();
        for (int i = 0; i < n ;i++) {
            for (int j = 0; j < n; j++) {
                int idx = getIndex(i, j);
                p[idx] = idx;
                if (i + 1 < n) {
                    int a = idx, b = getIndex(i + 1, j);
                    int w = Math.max(grid[i][j], grid[i + 1][j]);
                    edges.add(new int[]{a, b, w});
                }
                if (j + 1 < n) {
                    int a = idx, b = getIndex(i, j + 1);
                    int w = Math.max(grid[i][j], grid[i][j + 1]);
                    edges.add(new int[]{a, b, w});
                }
            }
        }

        // 根据权值 w 升序
        Collections.sort(edges, (a,b)->a[2]-b[2]);

        // 从「小边」开始添加,当某一条边别应用之后,恰好使用得「起点」和「结点」联通
        // 那么代表找到了「最短路径」中的「权重最大的边」
        int start = getIndex(0, 0), end = getIndex(n - 1, n - 1);
        for (int[] edge : edges) {
            int a = edge[0], b = edge[1], w = edge[2];
            union(a, b);
            if (query(start, end)) {
                return w;
            }
        }   
        return 0;
    }
    int getIndex(int i, int j) {
        return i * n + j;
    }
}

节点的数量为 nnn * n,无向边的数量严格为 2n(n1)2 * n * (n - 1),数量级上为 n2n^2

  • 时间复杂度:获取所有的边复杂度为 O(n2)O(n^2),排序复杂度为 O(n2logn)O(n^2\log{n}),遍历得到最终解复杂度为 O(n2)O(n^2)。整体复杂度为 O(n2logn)O(n^2\log{n})
  • 空间复杂度:使用了并查集数组。复杂度为 O(n2)O(n^2)

注意:假定 Collections.sort() 使用 Arrays.sort() 中的双轴快排实现。

二分 + BFS/DFS

在与本题类型的 1631. 最小体力消耗路径中,有同学问到是否可以用「二分」。

答案是可以的。

题目给定了 grid[i][j]grid[i][j] 的范围是 [0,n21][0, n^2 - 1],所以答案必然落在此范围。

假设最优解为 minmin 的话(恰好能到达右下角的时间)。那么小于 minmin 的时间无法到达右下角,大于 minmin 的时间能到达右下角。

因此在以最优解 minmin 为分割点的数轴上具有两段性,可以通过「二分」来找到分割点 minmin

注意:「二分」的本质是两段性,并非单调性。只要一段满足某个性质,另外一段不满足某个性质,就可以用「二分」。其中 33. 搜索旋转排序数组 是一个很好的说明例子。

接着分析,假设最优解为 minmin,我们在 [l,r][l, r] 范围内进行二分,当前二分到的时间为 midmid 时:

  1. 能到达右下角:必然有 minmidmin \leqslant mid,让 r=midr = mid

  2. 不能到达右下角:必然有 min>midmin > mid,让 l=mid+1l = mid + 1

当确定了「二分」逻辑之后,我们需要考虑如何写 checkcheck 函数。

显然 checkcheck 应该是一个判断给定 时间/步数 能否从「起点」到「终点」的函数。

我们只需要按照规则走特定步数,边走边检查是否到达终点即可。

实现 checkcheck 既可以使用 DFS 也可以使用 BFS。两者思路类似,这里就只以 BFS 为例。

代码:

class Solution {
    int[][] dirs = new int[][]{{1,0}, {-1,0}, {0,1}, {0,-1}};
    public int swimInWater(int[][] grid) {
        int n = grid.length;
        int l = 0, r = n * n;
        while (l < r) {
            int mid = l + r >> 1;
            if (check(grid, mid)) {
                r = mid;
            } else {
                l = mid + 1;
            }
        }
        return r;
    }
    boolean check(int[][] grid, int time) {
        int n = grid.length;
        boolean[][] visited = new boolean[n][n];
        Deque<int[]> queue = new ArrayDeque<>();
        queue.addLast(new int[]{0, 0});
        visited[0][0] = true;
        while (!queue.isEmpty()) {
            int[] pos = queue.pollFirst();
            int x = pos[0], y = pos[1];
            if (x == n - 1 && y == n - 1) return true;

            for (int[] dir : dirs) {
                int newX = x + dir[0], newY = y + dir[1];
                int[] to = new int[]{newX, newY};
                if (inArea(n, newX, newY) && !visited[newX][newY] && canMove(grid, pos, to, time)) {
                    visited[newX][newY] = true;
                    queue.addLast(to);
                }
            }
        }
        return false;
    }
    boolean inArea(int n, int x, int y) {
        return x >= 0 && x < n && y >= 0 && y < n;
    }
    boolean canMove(int[][] grid, int[] from, int[] to, int time) {
        return time >= Math.max(grid[from[0]][from[1]], grid[to[0]][to[1]]);
    }
}
  • 时间复杂度:在 [0,n2][0, n^2] 范围内进行二分,复杂度为 O(logn)O(\log{n});每一次 BFS 最多有 n2n^2 个节点入队,复杂度为 O(n2)O(n^2)。整体复杂度为 O(n2logn)O({n^2}\log{n})
  • 空间复杂度:使用了 visited 数组。复杂度为 O(n2)O(n^2)

总结

在图论中,往往解题关键之一是维护块的连通性。

而并查集则是维护连通块的利器之一,而并查集能够高效维护连通性的关键是「路径压缩」和「按秩合并」,需要重点理解。