3607.电网维护

目标

给你一个整数 c,表示 c 个电站,每个电站有一个唯一标识符 id,从 1 到 c 编号。

这些电站通过 n 条 双向 电缆互相连接,表示为一个二维数组 connections,其中每个元素 connections[i] = [ui, vi] 表示电站 ui 和电站 vi 之间的连接。直接或间接连接的电站组成了一个 电网 。

最初,所有 电站均处于在线(正常运行)状态。

另给你一个二维数组 queries,其中每个查询属于以下 两种类型之一 :

  • [1, x]:请求对电站 x 进行维护检查。如果电站 x 在线,则它自行解决检查。如果电站 x 已离线,则检查由与 x 同一 电网 中 编号最小 的在线电站解决。如果该电网中 不存在 任何 在线 电站,则返回 -1。
  • [2, x]:电站 x 离线(即变为非运行状态)。

返回一个整数数组,表示按照查询中出现的顺序,所有类型为 [1, x] 的查询结果。

注意:电网的结构是固定的;离线(非运行)的节点仍然属于其所在的电网,且离线操作不会改变电网的连接性。

示例 1:

输入: c = 5, connections = [[1,2],[2,3],[3,4],[4,5]], queries = [[1,3],[2,1],[1,1],[2,2],[1,2]]
输出: [3,2,3]
解释:
最初,所有电站 {1, 2, 3, 4, 5} 都在线,并组成一个电网。
查询 [1,3]:电站 3 在线,因此维护检查由电站 3 自行解决。
查询 [2,1]:电站 1 离线。剩余在线电站为 {2, 3, 4, 5}。
查询 [1,1]:电站 1 离线,因此检查由电网中编号最小的在线电站解决,即电站 2。
查询 [2,2]:电站 2 离线。剩余在线电站为 {3, 4, 5}。
查询 [1,2]:电站 2 离线,因此检查由电网中编号最小的在线电站解决,即电站 3。

示例 2:

输入: c = 3, connections = [], queries = [[1,1],[2,1],[1,1]]
输出: [1,-1]
解释:
没有连接,因此每个电站是一个独立的电网。
查询 [1,1]:电站 1 在线,且属于其独立电网,因此维护检查由电站 1 自行解决。
查询 [2,1]:电站 1 离线。
查询 [1,1]:电站 1 离线,且其电网中没有其他电站,因此结果为 -1。

说明:

  • 1 <= c <= 10^5
  • 0 <= n == connections.length <= min(10^5, c * (c - 1) / 2)
  • connections[i].length == 2
  • 1 <= ui, vi <= c
  • ui != vi
  • 1 <= queries.length <= 2 * 10^5
  • queries[i].length == 2
  • queries[i][0] 为 1 或 2。
  • 1 <= queries[i][1] <= c

思路

c 个电站,编号为 1 ~ cconnections[i] = [ui, vi] 表示电站 uivi 相连,所有连通的电站组成了一个电网。查询 queries[i] = [operation, x],如果 operation2 表示将电站 x 离线,如果 operation1 并且 x 在线,返回 x,否则返回 x 所在电网中在线电站的最小编号,如果没有在线电站返回 -1

使用 有序集合 数组 维护不同电网的在线电站。如果离线就将其从有序集合中删掉 O(logn),否则先判断集合是否为空,如果集合为空返回 -1,再判断电站是否在集合中 O(logn),如果在则直接返回查询电站编号,否则取集合最小的编号。

代码


/**
 * @date 2025-11-06 9:03
 */
public class ProcessQueries3607 {

    private class UnionFind {
        private int[] fa;

        public UnionFind() {
        }

        public UnionFind(int n) {
            this.fa = new int[n];
            Arrays.setAll(this.fa, i -> i);
        }

        public int find(int x) {
            if (fa[x] != x) {
                fa[x] = find(fa[x]);
            }
            return fa[x];
        }

        public void merge(int x, int y) {
            int a = find(x);
            int b = find(y);
            if (a != b) {
                fa[b] = a;
            }
        }

    }

    public int[] processQueries(int c, int[][] connections, int[][] queries) {
        UnionFind uf = new UnionFind(c + 1);
        for (int[] connection : connections) {
            uf.merge(connection[0], connection[1]);
        }
        TreeSet<Integer>[] set = new TreeSet[c + 1];
        Arrays.setAll(set, i -> new TreeSet<>());
        for (int i = 1; i <= c; i++) {
            set[uf.find(i)].add(i);
        }
        List<Integer> list = new ArrayList<>();
        boolean[] off = new boolean[c + 1];
        for (int[] query : queries) {
            int operation = query[0];
            int node = query[1];
            int network = uf.find(node);
            if (operation == 1) {
                if (set[network].size() > 0) {
                    if (off[node]) {
                        list.add(set[network].first());
                    } else {
                        list.add(node);
                    }
                } else {
                    list.add(-1);
                }
            } else {
                off[node] = true;
                set[network].remove(node);
            }
        }
        return list.stream().mapToInt(i -> i).toArray();
    }
}

性能

1061.按字典序排列最小的等效字符串

目标

给出长度相同的两个字符串s1 和 s2 ,还有一个字符串 baseStr 。

其中 s1[i] 和 s2[i] 是一组等价字符。

  • 举个例子,如果 s1 = "abc" 且 s2 = "cde",那么就有 'a' == 'c', 'b' == 'd', 'c' == 'e'。

等价字符遵循任何等价关系的一般规则:

  • 自反性 :'a' == 'a'
  • 对称性 :'a' == 'b' 则必定有 'b' == 'a'
  • 传递性 :'a' == 'b' 且 'b' == 'c' 就表明 'a' == 'c'

例如, s1 = "abc" 和 s2 = "cde" 的等价信息和之前的例子一样,那么 baseStr = "eed" , "acd" 或 "aab",这三个字符串都是等价的,而 "aab" 是 baseStr 的按字典序最小的等价字符串

利用 s1 和 s2 的等价信息,找出并返回 baseStr 的按字典序排列最小的等价字符串。

示例 1:

输入:s1 = "parker", s2 = "morris", baseStr = "parser"
输出:"makkek"
解释:根据 A 和 B 中的等价信息,我们可以将这些字符分为 [m,p], [a,o], [k,r,s], [e,i] 共 4 组。每组中的字符都是等价的,并按字典序排列。所以答案是 "makkek"。

示例 2:

输入:s1 = "hello", s2 = "world", baseStr = "hold"
输出:"hdld"
解释:根据 A 和 B 中的等价信息,我们可以将这些字符分为 [h,w], [d,e,o], [l,r] 共 3 组。所以只有 S 中的第二个字符 'o' 变成 'd',最后答案为 "hdld"。

示例 3:

输入:s1 = "leetcode", s2 = "programs", baseStr = "sourcecode"
输出:"aauaaaaada"
解释:我们可以把 A 和 B 中的等价字符分为 [a,o,e,r,s,c], [l,p], [g,t] 和 [d,m] 共 4 组,因此 S 中除了 'u' 和 'd' 之外的所有字母都转化成了 'a',最后答案为 "aauaaaaada"。

说明:

  • 1 <= s1.length, s2.length, baseStr <= 1000
  • s1.length == s2.length
  • 字符串s1, s2, and baseStr 仅由从 'a' 到 'z' 的小写英文字母组成。

思路

定义 s1[i]s2[i] 是等价字符,返回 baseStr 字典序最小的等价字符串。

可以使用并查集,用字典序小的字符代表等价字符,然后逐个替换 baseStr 即可。

代码


/**
 * @date 2025-06-05 0:15
 */
public class SmallestEquivalentString1061 {

    public class UnionFind {
        private int[] father;

        public UnionFind() {
            this.father = new int[26];
            Arrays.setAll(father, i -> i);
        }

        public void merge(int a, int b) {
            int x = find(a);
            int y = find(b);
            if (x == y) {
                return;
            }
            if (x < y) {
                father[y] = x;
            } else {
                father[x] = y;
            }
        }

        public int find(int a) {
            if (father[a] != a) {
                father[a] = find(father[a]);
            }
            return father[a];
        }
    }

    public String smallestEquivalentString(String s1, String s2, String baseStr) {
        int n = s1.length();
        UnionFind uf = new UnionFind();
        for (int i = 0; i < n; i++) {
            uf.merge(s1.charAt(i) - 'a', s2.charAt(i) - 'a');
        }
        StringBuilder sb = new StringBuilder();
        for (char c : baseStr.toCharArray()) {
            sb.append((char) ('a' + uf.find(c - 'a')));
        }
        return sb.toString();
    }

}

性能

3244.新增道路查询后的最短距离II

目标

给你一个整数 n 和一个二维整数数组 queries。

有 n 个城市,编号从 0 到 n - 1。初始时,每个城市 i 都有一条单向道路通往城市 i + 1( 0 <= i < n - 1)。

queries[i] = [ui, vi] 表示新建一条从城市 ui 到城市 vi 的单向道路。每次查询后,你需要找到从城市 0 到城市 n - 1 的最短路径的长度。

所有查询中不会存在两个查询都满足 queries[i][0] < queries[j][0] < queries[i][1] < queries[j][1]

返回一个数组 answer,对于范围 [0, queries.length - 1] 中的每个 i,answer[i] 是处理完前 i + 1 个查询后,从城市 0 到城市 n - 1 的最短路径的长度。

示例 1:

输入: n = 5, queries = [[2, 4], [0, 2], [0, 4]]
输出: [3, 2, 1]
解释:
新增一条从 2 到 4 的道路后,从 0 到 4 的最短路径长度为 3。
新增一条从 0 到 2 的道路后,从 0 到 4 的最短路径长度为 2。
新增一条从 0 到 4 的道路后,从 0 到 4 的最短路径长度为 1。

示例 2:

输入: n = 4, queries = [[0, 3], [0, 2]]
输出: [1, 1]
解释:
新增一条从 0 到 3 的道路后,从 0 到 3 的最短路径长度为 1。
新增一条从 0 到 2 的道路后,从 0 到 3 的最短路径长度仍为 1。

说明:

  • 3 <= n <= 10^5
  • 1 <= queries.length <= 10^5
  • queries[i].length == 2
  • 0 <= queries[i][0] < queries[i][1] < n
  • 1 < queries[i][1] - queries[i][0]
  • 查询中不存在重复的道路。
  • 不存在两个查询都满足 i != j 且 queries[i][0] < queries[j][0] < queries[i][1] < queries[j][1]

思路

n 个城市,刚开始每一个城市 i 有一条单项道路通向城市 i + 1,有一个二维数组 queriesqueries[i] 表示增加一条从 queries[i][0]queries[i][1] 的单项道路,返回 answer 数组,answer[i] 表示增加了 queries[i] 之后从城市 0 到城市 n - 1 的最短路径。增加的路径保证不会出现相交的情况。

比昨天的题 3243.新增道路查询后的最短距离I 多了一个条件,新增的路径不会交叉。昨天首先考虑的就是今天这个思路,因为起点与终点是固定的,可以通过查询区间的关系来减小最短路径。当时考虑的差分数组解决不了区间包含的关系。

定义区间数组 intervalinterval[i] = j, 表示 i -> j 有直达道路。如果发现查询的路径 [l, r] 已经被包含,直接略过。否则,循环记录区间 (l, r) 内的路径数,保存下一跳的城市 next = interval[l],将 interval[l] 置为 r,l = next 直到 l >= r。注意最后一跳到 r 是没有计数的,相当于减去了将前面多余的步数,与直达的效果一样。 interval[l] = r 是第一次进入循环必须进行的操作,幸运的是它并不影响我们标记其它区间内的元素,当有更大的查询路径时,直接在 l 处就跳过了。

核心点是如何表示区间,如何判断区间是否重合,如何针对大区间减少的路径计数。

代码


/**
 * @date 2024-11-20 10:10
 */
public class ShortestDistanceAfterQueries3244 {

    public int[] shortestDistanceAfterQueries(int n, int[][] queries) {
        int ql = queries.length;
        int[] res = new int[ql];
        int[] interval = new int[n - 1];
        Arrays.setAll(interval, i -> i + 1);
        int shortestPath = n - 1;
        for (int i = 0; i < ql; i++) {
            int l = queries[i][0];
            int r = queries[i][1];
            while (interval[l] < r) {
                int next = interval[l];
                interval[l] = r;
                l = next;
                shortestPath--;
            }
            res[i] = shortestPath;
        }
        return res;
    }

}

性能

3235.判断矩形的两个角落是否可达

目标

给你两个正整数 xCorner 和 yCorner 和一个二维整数数组 circles ,其中 circles[i] = [xi, yi, ri] 表示一个圆心在 (xi, yi) 半径为 ri 的圆。

坐标平面内有一个左下角在原点,右上角在 (xCorner, yCorner) 的矩形。你需要判断是否存在一条从左下角到右上角的路径满足:路径 完全 在矩形内部,不会 触碰或者经过 任何 圆的内部和边界,同时 只 在起点和终点接触到矩形。

如果存在这样的路径,请你返回 true ,否则返回 false 。

示例 1:

输入:X = 3, Y = 4, circles = [[2,1,1]]
输出:true
解释:
黑色曲线表示一条从 (0, 0) 到 (3, 4) 的路径。

示例 2:

输入:X = 3, Y = 3, circles = [[1,1,2]]
输出:false
解释:
不存在从 (0, 0) 到 (3, 3) 的路径。

示例 3:

输入:X = 3, Y = 3, circles = [[2,1,1],[1,2,1]]
输出:false
解释:
不存在从 (0, 0) 到 (3, 3) 的路径。

示例 4:

输入:X = 4, Y = 4, circles = [[5,5,1]]
输出:true
解释:

说明:

  • 3 <= xCorner, yCorner <= 10^9
  • 1 <= circles.length <= 1000
  • circles[i].length == 3
  • 1 <= xi, yi, ri <= 10^9

思路

有一个以原点为左下顶点, [xCorner, yCorner] 为右上顶点的矩形,还有一些圆 circlescircles[i, j, r] 表示圆的圆心在 (i, j) 半径为 r。问是否存在一条从原点到 [xCorner, yCorner] 的路径,满足路径在矩形内部(不与矩形边界重合),且不触碰或经过任何园的内部与边界。

评论说这是史上分数最高的题目,周赛全球也没几个人做出来,直接放弃了。

代码

性能

685.冗余连接II

目标

在本问题中,有根树指满足以下条件的 有向 图。该树只有一个根节点,所有其他节点都是该根节点的后继。该树除了根节点之外的每一个节点都有且只有一个父节点,而根节点没有父节点。

输入一个有向图,该图由一个有着 n 个节点(节点值不重复,从 1 到 n)的树及一条附加的有向边构成。附加的边包含在 1 到 n 中的两个不同顶点间,这条附加的边不属于树中已存在的边。

结果图是一个以边组成的二维数组 edges 。 每个元素是一对 [ui, vi],用以表示 有向 图中连接顶点 ui 和顶点 vi 的边,其中 ui 是 vi 的一个父节点。

返回一条能删除的边,使得剩下的图是有 n 个节点的有根树。若有多个答案,返回最后出现在给定二维数组的答案。

示例 1:

输入:edges = [[1,2],[1,3],[2,3]]
输出:[2,3]

示例 2:

输入:edges = [[1,2],[2,3],[3,4],[4,1],[1,5]]
输出:[4,1]

说明:

  • n == edges.length
  • 3 <= n <= 1000
  • edges[i].length == 2
  • 1 <= ui, vi <= n

思路

有一颗 n 个节点的树,节点编号 1 ~ n。使用 edges 表示向树中两个没有直接连接的节点之间加一条边之后的边的集合,找出一条可以删除的边使得 edges 变为一颗有 n 个节点的树。如果有多种选择,返回 edges 中最后出现的那个,即下标最大的边。与 冗余连接 不同的是 edges有向边 的集合。

如果直接使用昨天无向图寻找环的做法会有两个问题:

  • 无法处理 a -> b, b -> a 的情况,因为在无向图中为了防止环,直接回避了这种情况
  • 并不是删去环上任意一条边都可以的,因为边是有向的,如果某个节点出现两个父节点,那么一定要删去以该节点为终点的边

官网题解使用的还是并查集。// todo

代码


/**
 * @date 2024-10-28 8:51
 */
public class FindRedundantDirectedConnection685 {

    List<Integer>[] g;
    Set<Integer> loop;
    List<Integer> path;
    int start;
    int end;

    public int[] findRedundantDirectedConnection(int[][] edges) {
        int n = edges.length;
        g = new List[n + 1];
        for (int i = 0; i <= n; i++) {
            g[i] = new ArrayList<>();
        }
        int[] degree = new int[n + 1];
        Set<Integer> e = new HashSet<>(n);
        int end = -1;
        int[] self = null;
        for (int[] edge : edges) {
            int from = edge[0];
            int to = edge[1];
            int fromto = from << 10 | to;
            int tofrom = to << 10 | from;
            if (e.contains(fromto)) {
                self = new int[]{from, to};
            }
            e.add(fromto);
            e.add(tofrom);
            g[from].add(to);
            g[to].add(from);
            if (degree[to] == 1) {
                end = to;
            } else {
                degree[to]++;
            }
        }

        if (self != null) {
            if (end == -1) {
                for (int i = n - 1; i >= 0; i--) {
                    if ((self[0] == edges[i][0] && edges[i][1] == self[1])
                            || (self[0] == edges[i][1] && edges[i][0] == self[1])) {
                        return edges[i];
                    }
                }
            } else {
                return new int[]{self[0] == end ? self[1] : self[0], end};
            }

        }

        loop = new HashSet<>(n);
        path = new ArrayList<>();
        loop.add(1);
        path.add(1);
        dfs(0, 1);
        loop = new HashSet<>();
        for (int i = path.size() - 1; i >= 0; i--) {
            loop.add(path.get(i));
            if (start == path.get(i)) {
                break;
            }
        }
        if (end == -1) {
            for (int i = n - 1; i >= 0; i--) {
                if (loop.contains(edges[i][0]) && loop.contains(edges[i][1])) {
                    return edges[i];
                }
            }
        } else {
            for (int i = n - 1; i >= 0; i--) {
                if (edges[i][1] == end && loop.contains(edges[i][0])) {
                    return edges[i];
                }
            }
        }

        return null;
    }

    private boolean dfs(int parent, int current) {
        for (Integer next : g[current]) {
            if (next == parent) {
                continue;
            }
            if (loop.contains(next)) {
                start = next;
                return true;
            } else {
                loop.add(next);
                path.add(next);
                if (dfs(current, next)) {
                    return true;
                }
                path.remove(path.size() - 1);
                loop.remove(next);
            }
        }
        return false;
    }

}

性能

684.冗余连接

目标

树可以看成是一个连通且 无环 的 无向 图。

给定往一棵 n 个节点 (节点值 1~n) 的树中添加一条边后的图。添加的边的两个顶点包含在 1 到 n 中间,且这条附加的边不属于树中已存在的边。图的信息记录于长度为 n 的二维数组 edges ,edges[i] = [ai, bi] 表示图中在 ai 和 bi 之间存在一条边。

请找出一条可以删去的边,删除后可使得剩余部分是一个有着 n 个节点的树。如果有多个答案,则返回数组 edges 中最后出现的那个。

示例 1:

输入: edges = [[1,2], [1,3], [2,3]]
输出: [2,3]

示例 2:

输入: edges = [[1,2], [2,3], [3,4], [1,4], [1,5]]
输出: [1,4]

说明:

  • n == edges.length
  • 3 <= n <= 1000
  • edges[i].length == 2
  • 1 <= ai < bi <= edges.length
  • ai != bi
  • edges 中无重复元素
  • 给定的图是连通的

思路

有一颗 n 个节点的树,节点编号 1 ~ n。使用 edges 表示向树中两个没有直接连接的节点之间加一条边之后的边的集合,找出一条可以删除的边使得 edges 变为一颗有 n 个节点的树。如果有多种选择,返回 edges 中最后出现的那个,即下标最大的边。

我们可以选择一个根节点,比如从节点 1 出发,使用回溯记录已经访问过的节点,如果发现回到已访问过的非父节点说明出现了环。如果只是寻找环的上的任一条边的话,直接返回即可。

麻烦点在于题目要求返回 edges 中最后出现的边,因此我们需要记录访问的路径,从环开始的节点往后的节点都是在环上的。最后从后向前遍历 edges 找到第一个两端点都在环上的边。

官网题解使用的是并查集。// todo

代码


/**
 * @date 2024-10-27 16:34
 */
public class FindRedundantConnection684 {
    List<Integer>[] g;
    Set<Integer> loop;
    List<Integer> path;
    int start;

    public int[] findRedundantConnection(int[][] edges) {
        int n = edges.length;
        g = new List[n + 1];
        for (int i = 0; i <= n; i++) {
            g[i] = new ArrayList<>();
        }
        for (int[] edge : edges) {
            g[edge[0]].add(edge[1]);
            g[edge[1]].add(edge[0]);
        }
        loop = new HashSet<>(n);
        path = new ArrayList<>();
        dfs(0, 1);
        loop = new HashSet<>();
        for (int i = path.size() - 1; i >= 0; i--) {
            loop.add(path.get(i));
            if (start == path.get(i)) {
                break;
            }
        }
        for (int i = n - 1; i >= 0; i--) {
            if (loop.contains(edges[i][0]) && loop.contains(edges[i][1])) {
                return edges[i];
            }
        }
        return null;
    }

    private boolean dfs(int parent, int current) {
        for (Integer next : g[current]) {
            if (next == parent) {
                continue;
            }
            if (loop.contains(next)) {
                start = next;
                return true;
            } else {
                loop.add(next);
                path.add(next);
                if (dfs(current, next)) {
                    return true;
                }
                path.remove(path.size() - 1);
                loop.remove(next);
            }
        }
        return false;
    }

}

性能

721.账户合并

目标

给定一个列表 accounts,每个元素 accounts[i] 是一个字符串列表,其中第一个元素 accounts[i][0] 是 名称 (name),其余元素是 emails 表示该账户的邮箱地址。

现在,我们想合并这些账户。如果两个账户都有一些共同的邮箱地址,则两个账户必定属于同一个人。请注意,即使两个账户具有相同的名称,它们也可能属于不同的人,因为人们可能具有相同的名称。一个人最初可以拥有任意数量的账户,但其所有账户都具有相同的名称。

合并账户后,按以下格式返回账户:每个账户的第一个元素是名称,其余元素是 按字符 ASCII 顺序排列 的邮箱地址。账户本身可以以 任意顺序 返回。

示例 1:

输入:accounts = [["John", "johnsmith@mail.com", "john00@mail.com"], ["John", "johnnybravo@mail.com"], ["John", "johnsmith@mail.com", "john_newyork@mail.com"], ["Mary", "mary@mail.com"]]
输出:[["John", 'john00@mail.com', 'john_newyork@mail.com', 'johnsmith@mail.com'],  ["John", "johnnybravo@mail.com"], ["Mary", "mary@mail.com"]]
解释:
第一个和第三个 John 是同一个人,因为他们有共同的邮箱地址 "johnsmith@mail.com"。 
第二个 John 和 Mary 是不同的人,因为他们的邮箱地址没有被其他帐户使用。
可以以任何顺序返回这些列表,例如答案 [['Mary','mary@mail.com'],['John','johnnybravo@mail.com'],
['John','john00@mail.com','john_newyork@mail.com','johnsmith@mail.com']] 也是正确的。

示例 2:

输入:accounts = [["Gabe","Gabe0@m.co","Gabe3@m.co","Gabe1@m.co"],["Kevin","Kevin3@m.co","Kevin5@m.co","Kevin0@m.co"],["Ethan","Ethan5@m.co","Ethan4@m.co","Ethan0@m.co"],["Hanzo","Hanzo3@m.co","Hanzo1@m.co","Hanzo0@m.co"],["Fern","Fern5@m.co","Fern1@m.co","Fern0@m.co"]]
输出:[["Ethan","Ethan0@m.co","Ethan4@m.co","Ethan5@m.co"],["Gabe","Gabe0@m.co","Gabe1@m.co","Gabe3@m.co"],["Hanzo","Hanzo0@m.co","Hanzo1@m.co","Hanzo3@m.co"],["Kevin","Kevin0@m.co","Kevin3@m.co","Kevin5@m.co"],["Fern","Fern0@m.co","Fern1@m.co","Fern5@m.co"]]

说明:

  • 1 <= accounts.length <= 1000
  • 2 <= accounts[i].length <= 10
  • 1 <= accounts[i][j].length <= 30
  • accounts[i][0] 由英文字母组成
  • accounts[i][j] (for j > 0) 是有效的邮箱地址

思路

现有一个账号名称与邮箱列表组成的二维数组,如果两个账号对应的邮箱有重合,那么认为这两个账号属于同一个人,名称一定相同。但是名称相同不代表账户相同。现在需要将同一个人的账号合并,返回格式为,[名称,邮箱1,邮箱2,...],其中邮箱按 ASCII 排序。注意,同一个记录的邮箱列表中也可能存在相同邮箱,比如 ["Kevin","Kevin4@m.co","Kevin2@m.co","Kevin2@m.co"]

直接的想法是比较名称相同的账户邮箱是否有重合,如果有则合并。先将数据整理一下,换为 Map<name, List<List<Integer>>>,然后判断集合是否有共同元素,有则合并,没有则保留。那么使用什么方式处理集合呢?如果两两比较,时间复杂度为 O(n^2),好在一个账户邮箱最多 9 个,账户数量最多1000个,数据量不大。

如果两个集合有公共邮箱,那么可以使用 a.removeAll(b) 这个函数,它的返回值是布尔类型,如果a集合调用函数之后发生变化,即移除了a与b的公共元素,则返回 true,否则 false。因此当返回 true 时,直接与b合并,否则放回队列。需要注意的问题是,集合列表 {a,b} {c,d} {d,e} {e,f} {f,b} 将 第一个集合 {a,b} 与后面的集合依次两两比较时,直到最后一个才合并为{a,b,f},错过了与前面集合的合并,因此我们需要重新与前面的集合比较。

由于需要反复地比较这些集合,又要将属于同一账户的邮箱集合从集合列表中删除,涉及到集合的动态添加与删除。如果使用 ArrayList,尽管可以使用迭代器来动态添加与删除元素,但是从中间删除效率不高,需要移动数组元素。因此我们选择队列来保存这些集合,由于我们的操作主要在首尾两端,可以使用 ArrayDequeArrayDeque 双端操作效率比 LinkedList 更高,尽管它们都能在 O(1) 时间内完成操作,但是 LinkedList 需要额外的指针操作以及潜在的缓存不命中(不是连续分配的)问题,而 ArrayDeque 基于循环数组实现,只需调整头尾指针即可。

官网题解使用的是并查集,其实刚开始我也想到了使用并查集,但之前都是在图问题中用的,如果两个节点有边连接直接合并,但本题如何判断能否合并或者说是否连通呢?通常我们使用数组列表建图,但这里节点数据的类型不同,考虑使用map,key为邮箱,value为账户下标列表。遍历原二维数组,记录已合并的下标,如果邮箱对应有其它账户下标则进入dfs。

// todo 并查集

代码


/**
 * @date 2024-07-15 8:40
 */
public class AccountsMerge721 {
    public List<List<String>> accountsMerge_v1(List<List<String>> accounts) {
        int n = accounts.size();
        Map<String, List<Integer>> map = new HashMap<>();
        for (int i = 0; i < n; i++) {
            for (int j = 1; j < accounts.get(i).size(); j++) {
                map.computeIfAbsent(accounts.get(i).get(j), x -> new ArrayList<>()).add(i);
            }
        }

        boolean[] visited = new boolean[n];
        List<List<String>> res = new ArrayList<>();
        for (int i = 0; i < n; i++) {
            if (visited[i]) {
                continue;
            }
            visited[i] = true;
            List<String> account = accounts.get(i);
            int size = account.size();
            Set<String> mailList = new HashSet<>(account.subList(1, size));
            for (int j = 1; j < size; j++) {
                String mail = accounts.get(i).get(j);
                for (Integer index : map.get(mail)) {
                    if (visited[index]) {
                        continue;
                    }
                    dfs(index, accounts, map, mailList, visited);
                }
            }
            ArrayList<String> item = new ArrayList<>(mailList);
            Collections.sort(item);
            item.add(0, accounts.get(i).get(0));
            res.add(item);
        }
        return res;
    }

    public void dfs(int index, List<List<String>> accounts, Map<String, List<Integer>> map, Set<String> mailList, boolean[] visited) {
        visited[index] = true;
        List<String> account = accounts.get(index);
        int size = account.size();
        mailList.addAll(account.subList(1, size));
        for (int j = 1; j < size; j++) {
            String mail = accounts.get(index).get(j);
            for (Integer next : map.get(mail)) {
                if (visited[next]) {
                    continue;
                }
                dfs(next, accounts, map, mailList, visited);
            }
        }
    }

    public List<List<String>> accountsMerge(List<List<String>> accounts) {
        Map<String, Queue<Set<String>>> map = new HashMap<>();
        for (List<String> account : accounts) {
            String name = account.get(0);
            map.putIfAbsent(name, new ArrayDeque<>());
            Queue<Set<String>> queue = map.get(name);
            Set<String> mails = new TreeSet<>();
            for (int i = 1; i < account.size(); i++) {
                mails.add(account.get(i));
            }
            queue.offer(mails);
        }
        List<List<String>> res = new ArrayList<>();
        for (Map.Entry<String, Queue<Set<String>>> entry : map.entrySet()) {
            Queue<Set<String>> queue = entry.getValue();
            List<Set<String>> merged = new ArrayList<>();
            while (!queue.isEmpty()) {
                Set<String> mails = queue.poll();
                int size = queue.size();
                int cnt = 0;
                for (int i = 0; i < size; i++) {
                    Set<String> m = queue.poll();
                    if (m.removeAll(mails)) {
                        // 存在问题,(a,b)(c,d)(d,e)(e,f)(f,b) 最后一个才合并(a,b,f),错过了与前面集合的合并
                        mails.addAll(m);
                        // 这里扩展了执行次数,与前面比较过的元素重新比较
                        size += cnt;
                        cnt = 0;
                    } else {
                        queue.add(m);
                        cnt++;
                    }
                }
                merged.add(mails);
            }

            for (Set<String> set : merged) {
                List<String> l = new ArrayList<>();
                l.add(entry.getKey());
                l.addAll(set);
                res.add(l);
            }
        }

        return res;
    }
}

性能

使用队列

使用dfs

928.尽量减少恶意软件的传播II

目标

给定一个由 n 个节点组成的网络,用 n x n 个邻接矩阵 graph 表示。在节点网络中,只有当 graph[i][j] = 1 时,节点 i 能够直接连接到另一个节点 j。

一些节点 initial 最初被恶意软件感染。只要两个节点直接连接,且其中至少一个节点受到恶意软件的感染,那么两个节点都将被恶意软件感染。这种恶意软件的传播将继续,直到没有更多的节点可以被这种方式感染。

假设 M(initial) 是在恶意软件停止传播之后,整个网络中感染恶意软件的最终节点数。

我们可以从 initial 中删除一个节点,并完全移除该节点以及从该节点到任何其他节点的任何连接。

请返回移除后能够使 M(initial) 最小化的节点。如果有多个节点满足条件,返回索引 最小的节点 。

示例 1:

输入:graph = [[1,1,0],[1,1,0],[0,0,1]], initial = [0,1]
输出:0

示例 2:

输入:graph = [[1,1,0],[1,1,1],[0,1,1]], initial = [0,1]
输出:1

示例 3:

输入:graph = [[1,1,0,0],[1,1,1,0],[0,1,1,1],[0,0,1,1]], initial = [0,1]
输出:1

说明:

  • n == graph.length
  • n == graph[i].length
  • 2 <= n <= 300
  • graph[i][j] 是 0 或 1.
  • graph[i][j] == graph[j][i]
  • graph[i][i] == 1
  • 1 <= initial.length < n
  • 0 <= initial[i] <= n - 1
  • initial 中每个整数都不同

思路

这个和昨天的题的区别是移除节点之后原来连通的区域可能就断开了。刚开始想,昨天的需要排除掉同一连通区域存在多个感染节点的情况,今天这个就不能排除了。但是其影响的节点数也不能通过连通区域节点个数来计算。处理起来就比较复杂了,不能简单地根据直接相连的节点数来判断。当连通区域中含有多个感染节点时,需要区分边缘感染节点与中间感染节点,边缘感染节点又与孤立的感染节点相同,都是减少1。然后还要考虑连通区域仅有一个感染节点的情况。

区分 单一边缘感染节点 与 孤立感染节点

2、3 感染,返回3

0 - 1
|
3

2

无需区分 多个边缘感染节点 与 孤立感染节点

1、2、3 感染,返回1

0 - 1
|
3

2

区分 中间感染节点 与 孤立感染节点,并且不能仅根据直接相连的非感染节点来判断

0 、2、4、8 感染,返回8

     7
     | \
0    4 - 6
     |
1 -  8 - 3
     |
2    5

错了好多次,终于调试过了。正面确实不太好求解。总结一下就是:

  1. 连通区域存在多个感染节点
    • 去掉边缘的感染节点,感染节点总数减少1,全是边缘感染节点与包含中间感染节点是一样的
    • 去掉非边缘感染节点,需要dfs获取不含感染节点路径的节点总数
  2. 连通区域仅有1个感染节点(可以是孤立感染节点、边缘节点、中间节点)
    • 感染节点总数减少连通区域节点个数

最终答案需要获取以上减少最多的节点,如果存在多个,返回下标最小的。

代码里是按边缘感染节点与中间感染节点分的:

  1. 边缘感染节点
    • 孤立感染节点,减1
    • 连通区域内有多个边缘感染节点,减1
    • 连通区域内仅有一个边缘感染节点,减连通区域节点个数
  2. 中间感染节点(如果存在中间节点就不考虑边缘节点了,因为题目中限制了1 <= initial.length < n,一定存在可以减少2个的中间节点,分析到这里时我以为我发现了官网的漏洞,错误的实现也能通过,想要贡献测试用例呢,结果提示测试用例非有效值。如果是小于等于n这个解法就得多一个判断条件initial.length == n,直接取最小下标
    • 仅有一个中间感染节点,连通区域节点个数
    • 有多个中间感染节点,dfs搜索不含感染节点路径上的非感染节点个数,如果有感染节点,那么它也是减1,不过这里不再比较了,原因上面也说过了。

代码中d存的是中间节点(包括指向自身的边大于2),如果d为空则表示连通区域全是边缘感染节点(边为2),或孤立感染节点(边为1)。

对于全是边缘感染节点与孤立感染节点的情况,取下标最小即可。而对于中间感染节点,通过dfs来查找连通个数。如果通过dfs查找的个数为1,并且它还是中间感染节点,那么它周围全是感染节点。按道理来说,应该与边缘节点一起取最小下标。但是题目给出了限制,那么一定存在一个可以减少2的中间节点。

通过上面的分析只是说明了该问题正向分析的复杂性,如果不是不断尝试,很难直接把上面的所有情况想清楚。所以,上面的分析也没有太大的用处,过一段时间重做这个题,还是会踩坑。

官网题解使用的是逆向思维,统计的是从每个非感染节点出发不经过感染节点所经历的个数,在dfs过程中使用状态机来标识感染节点的个数。如果只遇到了1个感染节点,那么累加刚才遍历的节点个数,而如果有多个,那么就只能减少它自己。因此,如果存在只遇到一个感染节点的情况,就取个数最大的。否则取下标最小的。

其实,只遇到一个感染节点的情况包括了上面的单一边缘感染节点、中间单一感染节点以及多个中间感染节点(dfs非感染个数不为0的情况,即路径上不含有感染节点)的情况,而遇到多个感染节点,则说明被多个感染节点包围/半包围(对应全是边缘节点、边缘与中间、全中间,后面两种情况上面的算法忽略掉了),并且取最小下标直接包括了孤立感染节点。

可以发现同样是一步处理,我们赋予它不同的内涵,其所应对的场景就大为不同。

代码


/**
 * @date 2024-04-17 8:46
 */
public class MinMalwareSpread928 {
    public int[] u;
    TreeSet<Integer> s;
    HashSet<Integer> d = new HashSet<>();
    List<Integer>[] g;

    public void merge(int x, int y) {
        HashSet<Integer> tmp = new HashSet<>();
        int rx = find(x, tmp);
        int ry = find(y, tmp);
        d.addAll(tmp);
        if (s.contains(rx) && s.contains(ry)) {
            if (rx > ry) {
                u[rx] = ry;
            } else if (rx < ry) {
                u[ry] = rx;
            }
        } else if (s.contains(ry)) {
            u[rx] = ry;
        } else {
            u[ry] = rx;
        }
    }

    public int find(int x, HashSet<Integer> tmp) {
        if (x != u[x]) {
            if (s.contains(x) && s.contains(u[x])) {
                if (g[x].size() > 2) {
                    tmp.add(x);
                }
                if (g[u[x]].size() > 2) {
                    tmp.add(u[x]);
                }
            }
            x = find(u[x], tmp);
        }
        return u[x];
    }

    public int find(int x) {
        if (x != u[x]) {
            x = find(u[x]);
        }
        return u[x];
    }

    public int count(int x) {
        int cnt = 0;
        int rt = find(x);
        for (int i = 0; i < u.length; i++) {
            if (rt == find(i)) {
                cnt++;
            }
        }
        return cnt;
    }

    public int countMalware(int x) {
        int cnt = 0;
        int rt = find(x);
        for (int i = 0; i < u.length; i++) {
            if (rt == find(i) && s.contains(i)) {
                cnt++;
            }
        }
        return cnt;
    }

    public int adjacencyUninfected(int x, int parent) {
        int cnt = 1;
        boolean[] visited = new boolean[u.length];
        for (Integer node : g[x]) {
            if (parent == node || node == x || visited[node]) {
                continue;
            }
            visited = new boolean[u.length];
            if (!s.contains(node)) {
                int subCnt = dfs(node, x, visited);
                if (subCnt != 0) {
                    cnt += subCnt;
                }
            }
        }
        return cnt;
    }

    public int dfs(int x, int parent, boolean[] visited) {
        if (s.contains(x)) {
            return 0;
        }
        int cnt = 1;
        for (Integer node : g[x]) {
            if (parent == node || node == x || visited[node]) {
                visited[node] = true;
                continue;
            }
            visited[node] = true;
            if (s.contains(node)) {
                return 0;
            }
            int subCnt = dfs(node, x, visited);
            if (subCnt == 0) {
                return 0;
            } else {
                cnt += subCnt;
            }
        }
        return cnt;
    }

    public int minMalwareSpread(int[][] graph, int[] initial) {
        int n = graph.length;
        g = new ArrayList[n];
        u = new int[n];
        for (int i = 0; i < n; i++) {
            g[i] = new ArrayList<>(n);
            u[i] = i;
        }

        s = new TreeSet<>();
        for (int i : initial) {
            s.add(i);
        }
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < n; j++) {
                if (graph[i][j] == 1) {
                    g[i].add(j);
                    merge(i, j);
                }
            }
        }
        int res = Integer.MAX_VALUE;
        int tmp = Integer.MAX_VALUE;
        TreeSet<Integer> ini = new TreeSet<>((x, y) -> count(y) - count(x) == 0 ? (adjacencyUninfected(y, -1) - adjacencyUninfected(x, -1) == 0 ? x - y : adjacencyUninfected(y, -1) - adjacencyUninfected(x, -1)) : count(y) - count(x));
        if (d.isEmpty()) {
            // d为空表示连通区域仅有1个感染节点
            for (int i : initial) {
                if (countMalware(i) == 1 && count(i) > 1) {
                    // 连通区域节点数大于1
                    if (tmp == Integer.MAX_VALUE) {
                        tmp = i;
                    } else {
                        int ci = count(i);
                        int ct = count(tmp);
                        if (ci > ct) {
                            // 取连通区域节点数大的
                            tmp = i;
                        } else if (ci == ct) {
                            // 如果相等取下标小的
                            tmp = Math.min(i, tmp);
                        }
                    }
                } else {
                    // 对于孤立节点,直接取索引最小的即可
                    res = Math.min(i, res);
                }
            }
            // 如果全部是孤立节点,取res,否则取tmp
            return tmp == Integer.MAX_VALUE ? res : tmp;
        } else {
            ini.addAll(d);
        }

        return ini.first();
    }
}

性能

924.尽量减少恶意软件的传播

目标

给出了一个由 n 个节点组成的网络,用 n × n 个邻接矩阵图 graph 表示。在节点网络中,当 graph[i][j] = 1 时,表示节点 i 能够直接连接到另一个节点 j。

一些节点 initial 最初被恶意软件感染。只要两个节点直接连接,且其中至少一个节点受到恶意软件的感染,那么两个节点都将被恶意软件感染。这种恶意软件的传播将继续,直到没有更多的节点可以被这种方式感染。

假设 M(initial) 是在恶意软件停止传播之后,整个网络中感染恶意软件的最终节点数。

如果从 initial 中移除某一节点能够最小化 M(initial), 返回该节点。如果有多个节点满足条件,就返回索引最小的节点。

请注意,如果某个节点已从受感染节点的列表 initial 中删除,它以后仍有可能因恶意软件传播而受到感染。

示例 1:

输入:graph = [[1,1,0],[1,1,0],[0,0,1]], initial = [0,1]
输出:0

示例 2:

输入:graph = [[1,0,0],[0,1,0],[0,0,1]], initial = [0,2]
输出:0

示例 3:

输入:graph = [[1,1,1],[1,1,1],[1,1,1]], initial = [1,2]
输出:1

说明:

  • n == graph.length
  • n == graph[i].length
  • 2 <= n <= 300
  • graph[i][j] == 0 或 1.
  • graph[i][j] == graph[j][i]
  • graph[i][i] == 1
  • 1 <= initial.length <= n
  • 0 <= initial[i] <= n - 1
  • initial 中所有整数均不重复

思路

初始 已感染恶意软件的节点集合中去掉一个节点使得整个网络的感染节点数量最小,返回这个节点。注意,从初始被感染的集合中去除,并不代表后续不会再被感染。如果还有与它连通的恶意节点,那么仍会被感染,最终计算感染节点时要算上。

因此,如果被感染节点是连通的,去掉任一感染节点后,总的感染节点数量不会改变。这时需要将索引最小的节点返回。

刚开始的想法是先排除相互的连通的感染节点,然后取剩余节点中连接节点个数最多的那个。

这个想法没错,但是具体实现的时候,仅仅判断直接相连的两个节点是否同时在感染列表显然是不对的,因为存在间接连接的情况。并且直接从感染集合移除还好影响后续其它节点的判断。

于是想到了使用并查集。

官网的解法类似,将连通的节点染成同一颜色,然后在感染节点中看是否有颜色唯一的节点,即该连通区域中只有一个感染节点,然后找出连通区域节点数最大的,如果有多个颜色唯一节点,返回下标最小的。如果没有颜色唯一的节点,那么移除任一感染节点,总的感染数都不会减少,直接取下标最小的即可。

判断区域是否连通可以使用并查集,也可以使用深度优先搜索。

代码

/**
 * @date 2024-04-16 8:29
 */
public class MinMalwareSpread924 {

    public int[] u;
    TreeSet<Integer> s;
    HashSet<Integer> d = new HashSet<>();

    public void merge(int x, int y) {
        HashSet<Integer> tmp = new HashSet<>();
        int rx = find(x, tmp);
        int ry = find(y, tmp);
        d.addAll(tmp);
        if (s.contains(rx) && s.contains(ry)) {
            if (rx > ry) {
                u[rx] = ry;
            } else if (rx < ry) {
                u[ry] = rx;
            }
        } else if (s.contains(ry)) {
            u[rx] = ry;
        } else {
            u[ry] = rx;
        }
    }

    public int find(int x, HashSet<Integer> tmp) {
        if (x != u[x]) {
            if (s.contains(x) && s.contains(u[x])) {
                tmp.add(x);
                tmp.add(u[x]);
            }
            x = find(u[x], tmp);
        }
        return u[x];
    }

    public int find(int x) {
        if (x != u[x]) {
            x = find(u[x]);
        }
        return u[x];
    }

    public int count(int x) {
        int cnt = 0;
        int rt = find(x);
        for (int i = 0; i < u.length; i++) {
            if (rt == find(i)) {
                cnt++;
            }
        }
        return cnt;
    }

    public int minMalwareSpread(int[][] graph, int[] initial) {
        int n = graph.length;
        List<Integer>[] g = new ArrayList[n];
        u = new int[n];
        for (int i = 0; i < n; i++) {
            g[i] = new ArrayList<>(n);
            u[i] = i;
        }

        s = new TreeSet<>();
        for (int i : initial) {
            s.add(i);
        }
        int res = s.first();
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < n; j++) {
                if (graph[i][j] == 1) {
                    g[i].add(j);
                    merge(i, j);
                }
            }
        }
        if (s.size() == d.size()) {
            return res;
        }
        TreeSet<Integer> ini = new TreeSet<>((x, y) -> count(y) - count(x) == 0 ? x - y : count(y) - count(x));
        for (int i : initial) {
            if (!d.contains(i)) {
                ini.add(i);
            }
        }

        return ini.first();
    }

}

性能

2368.受限条件下可到达节点的数目

目标

现有一棵由 n 个节点组成的无向树,节点编号从 0 到 n - 1 ,共有 n - 1 条边。

给你一个二维整数数组 edges ,长度为 n - 1 ,其中 edges[i] = [ai, bi] 表示树中节点 ai 和 bi 之间存在一条边。另给你一个整数数组 restricted 表示 受限 节点。

在不访问受限节点的前提下,返回你可以从节点 0 到达的 最多 节点数目。

注意,节点 0 不 会标记为受限节点。

思路

自然的想法是构建图,将受限节点从中删除,然后深度优先遍历,同时记录节点个数。这里构建的图主要是为了获取其连通节点进行dfs,HashSet不太适合。因为数据可能并不是连续存储的,要先计算元素的Hash值,然后从桶中取出链表或者红黑树,才能找到元素。在本例中,性能会下降一倍。

代码

/**
 * @date 2024-03-02 15:39
 */
public class ReachableNodes {
    public int res = 1;
    boolean[] isRestricted;

    public int reachableNodes(int n, int[][] edges, int[] restricted) {
        List<Integer>[] g = new ArrayList[edges.length + 1];
        isRestricted = new boolean[edges.length + 1];
        for (int i : restricted) {
            isRestricted[i] = true;
        }
        for (int i = 0; i < g.length; i++) {
            g[i] = new ArrayList<>();
        }
        for (int[] edge : edges) {
            if (isRestricted[edge[0]] || isRestricted[edge[1]]) {
                continue;
            }
            g[edge[0]].add(edge[1]);
            g[edge[1]].add(edge[0]);
        }
        dfs(0, -1, g);
        return res;
    }

    public void dfs(int root, int parent, List<Integer>[] g) {
        for (Integer n : g[root]) {
            if (n == parent) {
                continue;
            }
            res++;
            dfs(n, root, g);
        }
    }
}

性能

看了官网的答案还可以使用并查集,耗时只要10ms,有时间可以看看。