阅读视图

发现新文章,点击刷新页面。

每日一题-删列造序 II🟡

给定由 n 个字符串组成的数组 strs,其中每个字符串长度相等。

选取一个删除索引序列,对于 strs 中的每个字符串,删除对应每个索引处的字符。

比如,有 strs = ["abcdef", "uvwxyz"],删除索引序列 {0, 2, 3},删除后 strs["bef", "vyz"]

假设,我们选择了一组删除索引 answer,那么在执行删除操作之后,最终得到的数组的元素是按 字典序strs[0] <= strs[1] <= strs[2] ... <= strs[n - 1])排列的,然后请你返回 answer.length 的最小可能值。

 

    示例 1:

    输入:strs = ["ca","bb","ac"]
    输出:1
    解释: 
    删除第一列后,strs = ["a", "b", "c"]。
    现在 strs 中元素是按字典排列的 (即,strs[0] <= strs[1] <= strs[2])。
    我们至少需要进行 1 次删除,因为最初 strs 不是按字典序排列的,所以答案是 1。
    

    示例 2:

    输入:strs = ["xc","yb","za"]
    输出:0
    解释:
    strs 的列已经是按字典序排列了,所以我们不需要删除任何东西。
    注意 strs 的行不需要按字典序排列。
    也就是说,strs[0][0] <= strs[0][1] <= ... 不一定成立。
    

    示例 3:

    输入:strs = ["zyx","wvu","tsr"]
    输出:3
    解释:
    我们必须删掉每一列。
    

     

    提示:

    • n == strs.length
    • 1 <= n <= 100
    • 1 <= strs[i].length <= 100
    • strs[i] 由小写英文字母组成

    从左到右贪心 + 优化(Python/Java/C++/Go)

    例如 $\textit{strs}=[\texttt{ac},\texttt{ad},\texttt{ba},\texttt{bb}]$,竖着看就是

    $$
    \begin{aligned}
    & \texttt{ac} \
    & \texttt{ad} \
    & \texttt{ba} \
    & \texttt{bb} \
    \end{aligned}
    $$

    第一列是升序,可以不删。

    • 如果删第一列,那么需要完整地比较第二列的四个字母是不是升序。
    • 如果不删第一列,那么对于第二列,由于 $\texttt{d}$ 和 $\texttt{a}$ 前面的字母不同,只看第一列的字母就能确定 $\texttt{ad} < \texttt{ba}$,所以我们不需要比较 $\texttt{d}$ 和 $\texttt{a}$ 的大小。此时第二列分成了两组 $[\texttt{c},\texttt{d}]$ 和 $[\texttt{a},\texttt{b}]$,只需判断组内字母是不是升序,而不是完整地比较第二列的四个字母。

    由此可见,当列已经是升序时,不删更好,后面需要比较的字母更少,更容易满足要求,最终删除的列更少。

    如果列不是升序,那么一定要删(否则最终得到的数组不是字典序排列)。

    优化前

    ###py

    class Solution:
        def minDeletionSize(self, strs: List[str]) -> int:
            n, m = len(strs), len(strs[0])
            a = [''] * n  # 最终得到的字符串数组
            ans = 0
            for j in range(m):
                for i in range(n - 1):
                    if a[i] + strs[i][j] > a[i + 1] + strs[i + 1][j]:
                        # j 列不是升序,必须删
                        ans += 1
                        break
                else:
                    # j 列是升序,不删更好
                    for i, s in enumerate(strs):
                        a[i] += s[j]
            return ans
    

    ###java

    class Solution {
        public int minDeletionSize(String[] strs) {
            int n = strs.length;
            int m = strs[0].length();
            String[] a = new String[n]; // 最终得到的字符串数组
            Arrays.fill(a, "");
    
            int ans = 0;
            next:
            for (int j = 0; j < m; j++) {
                for (int i = 0; i < n - 1; i++) {
                    if ((a[i] + strs[i].charAt(j)).compareTo(a[i + 1] + strs[i + 1].charAt(j)) > 0) {
                        // j 列不是升序,必须删
                        ans++;
                        continue next;
                    }
                }
                // j 列是升序,不删更好
                for (int i = 0; i < n; i++) {
                    a[i] += strs[i].charAt(j);
                }
            }
            return ans;
        }
    }
    

    ###cpp

    class Solution {
    public:
        int minDeletionSize(vector<string>& strs) {
            int n = strs.size(), m = strs[0].size();
            vector<string> a(n); // 最终得到的字符串数组
            int ans = 0;
            for (int j = 0; j < m; j++) {
                bool del = false;
                for (int i = 0; i < n - 1; i++) {
                    if (a[i] + strs[i][j] > a[i + 1] + strs[i + 1][j]) {
                        // j 列不是升序,必须删
                        ans++;
                        del = true;
                        break;
                    }
                }
                if (!del) {
                    // j 列是升序,不删更好
                    for (int i = 0; i < n; i++) {
                        a[i] += strs[i][j];
                    }
                }
            }
            return ans;
        }
    };
    

    ###go

    func minDeletionSize(strs []string) (ans int) {
    n, m := len(strs), len(strs[0])
    a := make([]string, n) // 最终得到的字符串数组
    next:
    for j := range m {
    for i := range n - 1 {
    if a[i]+string(strs[i][j]) > a[i+1]+string(strs[i+1][j]) {
    // j 列不是升序,必须删
    ans++
    continue next
    }
    }
    // j 列是升序,不删更好
    for i, s := range strs {
    a[i] += string(s[j])
    }
    }
    return
    }
    

    复杂度分析

    • 时间复杂度:$\mathcal{O}(nm^2)$,其中 $n$ 是 $\textit{strs}$ 的长度,$m$ 是 $\textit{strs}[i]$ 的长度。比较 $\mathcal{O}(nm)$ 次大小,每次 $\mathcal{O}(m)$。
    • 空间复杂度:$\mathcal{O}(nm)$。

    优化

    回顾前文的例子:

    $$
    \begin{aligned}
    & \texttt{ac} \
    & \texttt{ad} \
    & \texttt{ba} \
    & \texttt{bb} \
    \end{aligned}
    $$

    第一列升序,不删。由于 $\textit{strs}[1][0] < \textit{strs}[2][0]$,后续 $a[1] < a[2]$ 必定成立,所以不需要比较这两个字符串。对于其余相邻字符串来说,由于第一列的字母都一样,所以只需比较第二列的字母,无需比较整个字符串

    怎么维护需要比较的下标(行号)呢?可以用哈希集合,或者布尔数组,或者创建一个下标列表,删除列表中的无需比较的下标。最后一种方法最高效,我们可以用 27. 移除元素 的方法,原地删除无需比较的下标,见 我的题解

    ###py

    class Solution:
        def minDeletionSize(self, strs: List[str]) -> int:
            n, m = len(strs), len(strs[0])
            check_list = list(range(n - 1))
    
            ans = 0
            for j in range(m):
                for i in check_list:
                    if strs[i][j] > strs[i + 1][j]:
                        # j 列不是升序,必须删
                        ans += 1
                        break
                else:
                    # j 列是升序,不删更好
                    new_size = 0
                    for i in check_list:
                        if strs[i][j] == strs[i + 1][j]:
                            # 相邻字母相等,下一列 i 和 i+1 需要继续比大小
                            check_list[new_size] = i  # 原地覆盖
                            new_size += 1
                    del check_list[new_size:]
            return ans
    

    ###java

    class Solution {
        public int minDeletionSize(String[] strs) {
            int n = strs.length;
            int m = strs[0].length();
            int size = n - 1;
            int[] checkList = new int[size];
            for (int i = 0; i < size; i++) {
                checkList[i] = i;
            }
    
            int ans = 0;
            next:
            for (int j = 0; j < m; j++) {
                for (int t = 0; t < size; t++) {
                    int i = checkList[t];
                    if (strs[i].charAt(j) > strs[i + 1].charAt(j)) {
                        // j 列不是升序,必须删
                        ans++;
                        continue next;
                    }
                }
                // j 列是升序,不删更好
                int newSize = 0;
                for (int t = 0; t < size; t++) {
                    int i = checkList[t];
                    if (strs[i].charAt(j) == strs[i + 1].charAt(j)) {
                        // 相邻字母相等,下一列 i 和 i+1 需要继续比大小
                        checkList[newSize++] = i; // 原地覆盖
                    }
                }
                size = newSize;
            }
            return ans;
        }
    }
    

    ###cpp

    class Solution {
    public:
        int minDeletionSize(vector<string>& strs) {
            int n = strs.size(), m = strs[0].size();
            vector<int> check_list(n - 1);
            ranges::iota(check_list, 0);
    
            int ans = 0;
            for (int j = 0; j < m; j++) {
                bool del = false;
                for (int i : check_list) {
                    if (strs[i][j] > strs[i + 1][j]) {
                        // j 列不是升序,必须删
                        ans++;
                        del = true;
                        break;
                    }
                }
                if (del) {
                    continue;
                }
                // j 列是升序,不删更好
                int new_size = 0;
                for (int i : check_list) {
                    if (strs[i][j] == strs[i + 1][j]) {
                        // 相邻字母相等,下一列 i 和 i+1 需要继续比大小
                        check_list[new_size++] = i; // 原地覆盖
                    }
                }
                check_list.resize(new_size);
            }
            return ans;
        }
    };
    

    ###go

    func minDeletionSize(strs []string) (ans int) {
    n, m := len(strs), len(strs[0])
    checkList := make([]int, n-1)
    for i := range checkList {
    checkList[i] = i
    }
    
    next:
    for j := range m {
    for _, i := range checkList {
    if strs[i][j] > strs[i+1][j] {
    // j 列不是升序,必须删
    ans++
    continue next
    }
    }
    // j 列是升序,不删更好
    newCheckList := checkList[:0] // 原地
    for _, i := range checkList {
    if strs[i][j] == strs[i+1][j] {
    // 相邻字母相等,下一列 i 和 i+1 需要继续比大小
    newCheckList = append(newCheckList, i)
    }
    }
    checkList = newCheckList
    }
    return
    }
    

    复杂度分析

    • 时间复杂度:$\mathcal{O}(nm)$,其中 $n$ 是 $\textit{strs}$ 的长度,$m$ 是 $\textit{strs}[i]$ 的长度。
    • 空间复杂度:$\mathcal{O}(n)$。

    专题训练

    见下面贪心题单的「§1.4 从最左/最右开始贪心」。

    分类题单

    如何科学刷题?

    1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
    2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
    3. 单调栈(基础/矩形面积/贡献法/最小字典序)
    4. 网格图(DFS/BFS/综合应用)
    5. 位运算(基础/性质/拆位/试填/恒等式/思维)
    6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
    7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
    8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
    9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
    10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
    11. 链表、树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA)
    12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

    我的题解精选(已分类)

    欢迎关注 B站@灵茶山艾府

    删列造序

    贪心的想法,我们从前往后的比较每一个字符,如果发现A[i][j]<A[i-1][j],那么就意味着这一列是必须删除的,但是这样做有一个问题:就是如果之前我们已经确定了这两列的大小关系,那么这个时候即使小于,也不用删除,比如acd,adc,对于这两个字符串来说,当我们比较前面两个字符时就已经确定大小关系了,那么当比较第三个字符时,即使d>c,也是不需要删除的。
    所以针对多个字符串,我们开一个数组vis[n],vis[i]表示第i行和第i-1行是否已经确定大小关系了。
    思路:
    从第0列开始比较,如果发现这一列中有不确定大小关系并且是小于前面行的,就意味着是必须删除的,这个时候就不需要更新vis,否则我们就跟新vis;

    ###java

    class Solution {
        public int minDeletionSize(String[] A) {
            int n = A.length;
            int m = A[0].length();
    
            int[] vis = new int[n];
            int ans = 0;
            for (int i = 0; i < m; i++) {
                boolean isDelete=false;
                for (int j = 1; j < n; j++) {
                    if(vis[j]==1)continue;
                    if(A[j].charAt(i)<A[j-1].charAt(i)){
                        isDelete = true;
                        break;
                    }
                }
                if(isDelete)ans++;
                else{
                    for (int j = 1; j < n; j++) {
                        if(A[j].charAt(i)>A[j-1].charAt(i)){
                            vis[j]=1;
                        }
                    }
                }
            }
            return ans;
        }
    }
    

    删列造序 II

    方法 1:贪心

    想法

    针对该问题,我们考虑保留哪些列去获得最终的有序结果,而不是删除哪些列。

    如果第一列不是字典序排列的,我们就必须删除它。

    否则,我们需要讨论是否保留第一列。会出现以下两种情况:

    • 如果我们不保留第一列,则最后答案的行需要保证有序;

    • 如果我们保留了第一列,那么最终答案的行(除去第一列)只需要在第一个字母相同的情况下需要保证有序。

      这个描述很难理解,看下面的例子:

      假设我们有 A = ["axx", "ayy", "baa", "bbb", "bcc"],当我们保留第一列之后,最终行变成 R = ["xx", "yy", "aa", "bb", "cc"],对于这些行,并不要求所有有序(R[0] <= R[1] <= R[2] <= R[3] <= R[4]),只需要达到一个较弱的要求:对于第一个字母相同的行保证有序(R[0] <= R[1]R[2] <= R[3] <= R[4])。

    现在,我们只将结论应用到第一列,但实际上这个结论对每列都合适。如果我们不能取用第一列,就删除它。否则,我们就取用第一列,因为无论如何都可以使要求更简单。

    算法

    首先没有任意列保留,对于每一列:如果保留后结果保持有序,就保留这一列;否则删除它。

    ###Java

    class Solution {
        public int minDeletionSize(String[] A) {
            int N = A.length;
            int W = A[0].length();
            int ans = 0;
    
            // cur : all rows we have written
            // For example, with A = ["abc","def","ghi"] we might have
            // cur = ["ab", "de", "gh"].
            String[] cur = new String[N];
            for (int j = 0; j < W; ++j) {
                // cur2 : What we potentially can write, including the
                //        newest column col = [A[i][j] for i]
                // Eg. if cur = ["ab","de","gh"] and col = ("c","f","i"),
                // then cur2 = ["abc","def","ghi"].
                String[] cur2 = Arrays.copyOf(cur, N);
                for (int i = 0; i < N; ++i)
                    cur2[i] += A[i].charAt(j);
    
                if (isSorted(cur2))
                    cur = cur2;
                else
                    ans++;
            }
    
            return ans;
        }
    
        public boolean isSorted(String[] A) {
            for (int i = 0; i < A.length - 1; ++i)
                if (A[i].compareTo(A[i+1]) > 0)
                    return false;
    
            return true;
        }
    }
    

    ###Python

    class Solution(object):
        def minDeletionSize(self, A):
            def is_sorted(A):
                return all(A[i] <= A[i+1] for i in xrange(len(A) - 1))
    
            ans = 0
            # cur : all rows we have written
            # For example, with A = ["abc","def","ghi"] we might have
            # cur = ["ab", "de", "gh"].
            cur = [""] * len(A)  
    
            for col in zip(*A):
                # cur2 : What we potentially can write, including the
                #        newest column 'col'.
                # Eg. if cur = ["ab","de","gh"] and col = ("c","f","i"),
                # then cur2 = ["abc","def","ghi"].
                cur2 = cur[:]
                for i, letter in enumerate(col):
                    cur2[i] = cur2[i] + letter
    
                if is_sorted(cur2):
                    cur = cur2
                else:
                    ans += 1
    
            return ans
    

    复杂度分析

    • 时间复杂度:$O(NW^2)$,其中 $N$ 是 A 的长度,$W$ 是 A[i] 的长度。
    • 空间复杂度:$O(NW)$。

    方法 2:优化贪心

    解释

    方法 1 可以用更少的空间和时间。

    核心思路是记录每一列的”割“信息。在第一个例子中,A = ["axx","ayy","baa","bbb","bcc"]R 也是相同的定义),第一列将条件 R[0] <= R[1] <= R[2] <= R[3] <= R[4] 切成了 R[0] <= R[1]R[2] <= R[3] <= R[4]。也就是说,"a" == column[1] != column[2] == "b" ”切割“了 R 中的一个条件。

    从更高层面上说,我们的算法只需要考虑新加进的列是否保证有序。通过维护”割“的信息,只需要比较新列的字符。

    ###Java

    class Solution {
        public int minDeletionSize(String[] A) {
            int N = A.length;
            int W = A[0].length();
            // cuts[j] is true : we don't need to check any new A[i][j] <= A[i][j+1]
            boolean[] cuts = new boolean[N-1];
    
            int ans = 0;
            search: for (int j = 0; j < W; ++j) {
                // Evaluate whether we can keep this column
                for (int i = 0; i < N-1; ++i)
                    if (!cuts[i] && A[i].charAt(j) > A[i+1].charAt(j)) {
                        // Can't keep the column - delete and continue
                        ans++;
                        continue search;
                    }
    
                // Update 'cuts' information
                for (int i = 0; i < N-1; ++i)
                    if (A[i].charAt(j) < A[i+1].charAt(j))
                        cuts[i] = true;
            }
    
            return ans;
        }
    }
    
    

    ###Python

    class Solution(object):
        def minDeletionSize(self, A):
            # cuts[i] is True : we don't need to check col[i] <= col[i+1]
            cuts = [False] * (len(A) - 1)
    
            ans = 0
            for col in zip(*A):
                if all(cuts[i] or col[i] <= col[i+1] for i in xrange(len(col) - 1)):
                    for i in xrange(len(col) - 1):
                        if col[i] < col[i+1]:
                            cuts[i] = True
                else:
                    ans += 1
            return ans
    

    复杂度分析

    • 时间复杂度:$O(NW)$,其中 $N$ 是 A 的长度,$W$ 是 A[i] 的长度。
    • 空间复杂度:额外空间开销 $O(N)$(在 Python 中,zip(*A) 需要 $O(NW)$ 的空间)。

    每日一题-删列造序🟢

    给你由 n 个小写字母字符串组成的数组 strs,其中每个字符串长度相等。

    这些字符串可以每个一行,排成一个网格。例如,strs = ["abc", "bce", "cae"] 可以排列为:

    abc
    bce
    cae

    你需要找出并删除 不是按字典序非严格递增排列的 列。在上面的例子(下标从 0 开始)中,列 0('a', 'b', 'c')和列 2('c', 'e', 'e')都是按字典序非严格递增排列的,而列 1('b', 'c', 'a')不是,所以要删除列 1 。

    返回你需要删除的列数。

     

    示例 1:

    输入:strs = ["cba","daf","ghi"]
    输出:1
    解释:网格示意如下:
      cba
      daf
      ghi
    列 0 和列 2 按升序排列,但列 1 不是,所以只需要删除列 1 。
    

    示例 2:

    输入:strs = ["a","b"]
    输出:0
    解释:网格示意如下:
      a
      b
    只有列 0 这一列,且已经按升序排列,所以不用删除任何列。
    

    示例 3:

    输入:strs = ["zyx","wvu","tsr"]
    输出:3
    解释:网格示意如下:
      zyx
      wvu
      tsr
    所有 3 列都是非升序排列的,所以都要删除。
    

     

    提示:

    • n == strs.length
    • 1 <= n <= 100
    • 1 <= strs[i].length <= 1000
    • strs[i] 由小写英文字母组成

    简单题,简单做(Python/Java/C++/Go)

    题意:字符网格中,有多少列不是递增的?

    对于每一列,从上到下遍历,如果发现上面的字母比下面的大,那么这一列不是递增的,答案加一。

    class Solution:
        def minDeletionSize(self, strs: List[str]) -> int:
            ans = 0
            for col in zip(*strs):
                if any(x > y for x, y in pairwise(col)):
                    ans += 1
            return ans
    
    class Solution:
        def minDeletionSize(self, strs: List[str]) -> int:
            ans = 0
            for col in zip(*strs):
                if list(col) != sorted(col):
                    ans += 1
            return ans
    
    class Solution {
        public int minDeletionSize(String[] strs) {
            int n = strs.length;
            int m = strs[0].length();
            int ans = 0;
            for (int j = 0; j < m; j++) {
                for (int i = 1; i < n; i++) { // 遍历 j 列的字母
                    if (strs[i - 1].charAt(j) > strs[i].charAt(j)) {
                        ans++;
                        break;
                    }
                }
            }
            return ans;
        }
    }
    
    class Solution {
    public:
        int minDeletionSize(vector<string>& strs) {
            int n = strs.size(), m = strs[0].size();
            int ans = 0;
            for (int j = 0; j < m; j++) {
                for (int i = 1; i < n; i++) { // 遍历 j 列的字母
                    if (strs[i - 1][j] > strs[i][j]) {
                        ans++;
                        break;
                    }
                }
            }
            return ans;
        }
    };
    
    func minDeletionSize(strs []string) (ans int) {
    n, m := len(strs), len(strs[0])
    for j := range m {
    for i := range n - 1 { // 遍历 j 列的字母
    if strs[i][j] > strs[i+1][j] {
    ans++
    break
    }
    }
    }
    return
    }
    

    复杂度分析

    • 时间复杂度:$\mathcal{O}(nm)$,其中 $n$ 是 $\textit{strs}$ 的长度,$m$ 是 $\textit{strs}[i]$ 的长度。
    • 空间复杂度:$\mathcal{O}(1)$。

    分类题单

    如何科学刷题?

    1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
    2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
    3. 单调栈(基础/矩形面积/贡献法/最小字典序)
    4. 网格图(DFS/BFS/综合应用)
    5. 位运算(基础/性质/拆位/试填/恒等式/思维)
    6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
    7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
    8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
    9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
    10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
    11. 链表、树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA)
    12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

    我的题解精选(已分类)

    欢迎关注 B站@灵茶山艾府

    【负雪明烛】算法图解:详解题意,一图胜千言

    大家好,我是 @负雪明烛。点击右上方的「+关注,优质题解不间断!

    题目大意

    题意是:对于输入的字符串数组 strs,让你找出有几列不是升序的。(题目保证了strs中每个字符串的长度相等)

    「升序」是指每个元素都大于等于前面的元素。「等于」也算是升序。

    注意,题目中所说的「删除」,是指某列不满足升序条件,而不是真的要把它从strs中直接删除。

    如下图所示:

    • 输入字符串 strs = ["abc", "bce", "cae"]
    • 按行排列成二维数组;
    • 逐一判断每列是否是升序的,如果不是升序,就是题目中所说的要被“删除”;
    • 其中第 1 列不是升序的,因此被删除;
    • 下图中的strs共需要删除 1 列。

    944. 删列造序.001.png

    解题方法

    一图胜千言,上图已经把解法阐明了。

    我们需要对每列进行遍历,判断每一列是不是都升序的:

    • 升序的列是符合条件的。
    • 非升序的列是不符合条件的,也就是题目中所谓要「删除」的。

    对于本题,肯定是两重 $for$ 循环:

    • 外层的 $for$ 循环是对进行循环:遍历所有的列。
    • 内层的 $for$ 循环是对进行循环:判断该列中的是否是升序的。
    • 当内层的 $for$ 循环找到「降序」的,那么把结果 $+1$,并把内层 $for$ 循环 $break$ 掉。

    代码如下:

    ###Python

    class Solution(object):
        def minDeletionSize(self, strs):
            rows = len(strs)
            cols = len(strs[0])
            res = 0
            for col in range(cols):
                for row in range(1, rows):
                    if strs[row][col] < strs[row - 1][col]:
                        res += 1
                        break
            return res
    

    ###C++

    class Solution {
        public:
        int minDeletionSize(vector<string>& strs) {
            const int rows = strs.size();
            const int cols = strs[0].size();
            int res = 0;
            for (int col = 0; col < cols; ++col) {
                for (int row = 1; row < rows; ++row) {
                    if (strs[row][col] < strs[row - 1][col]) {
                        res ++;
                        break;
                    }
                }
            }
            return res;
        }
    };
    

    ###Java

    class Solution {
        public int minDeletionSize(String[] strs) {
            int rows = strs.length;
            int cols = strs[0].length();
            int res = 0;
            for (int col = 0; col < cols; ++col) {
                for (int row = 1; row < rows; ++row) {
                    if (strs[row].charAt(col) < strs[row - 1].charAt(col)) {
                        res ++;
                        break;
                    }
                }
            }
            return res;
        }
    }
    

    复杂度

    • 时间复杂度:$O(M * N)$,M 和 N 分别是行列数;
    • 空间复杂度:$O(1)$

    总结

    1. 力扣题目有点像阅读理解题吗,如果看不懂题目,不妨在题解区找找「负雪明烛」的题解,每篇都会把题意讲清楚。

    我是 @负雪明烛 ,刷算法题 1000 多道,写了 1000 多篇算法题解,收获阅读量 300 万。
    关注我,你将不会错过我的精彩动画题解、面试题分享、组队刷题活动,进入主页 @负雪明烛 右侧有刷题组织,从此刷题不再孤单。

    【宫水三叶】简单模拟题

    模拟

    根据题意进行模拟即可。

    代码:

    ###Java

    class Solution {
        public int minDeletionSize(String[] strs) {
            int n = strs.length, m = strs[0].length(), ans = 0;
            out:for (int i = 0; i < m; i++) {
                for (int j = 0, cur = -1; j < n; j++) {
                    int t = (int) strs[j].charAt(i);
                    if (t < cur && ++ans >= 0) continue out;
                    cur = t;
                }
            }
            return ans;
        }
    }
    
    • 时间复杂度:$O(n \times m)$
    • 空间复杂度:$O(1)$

    加餐 & 加练

    今日份加餐 : 数位 DP 的经典运用 🎉🎉

    或是考虑加练如下「模拟」题 🍭🍭🍭

    题目 题解 难度 推荐指数
    6. Z 字形变换 LeetCode 题解链接 中等 🤩🤩🤩
    8. 字符串转换整数 (atoi) LeetCode 题解链接 中等 🤩🤩🤩
    12. 整数转罗马数字 LeetCode 题解链接 中等 🤩🤩
    59. 螺旋矩阵 II LeetCode 题解链接 中等 🤩🤩🤩🤩
    65. 有效数字 LeetCode 题解链接 困难 🤩🤩🤩
    73. 矩阵置零 LeetCode 题解链接 中等 🤩🤩🤩🤩
    89. 格雷编码 LeetCode 题解链接 中等 🤩🤩🤩🤩
    166. 分数到小数 LeetCode 题解链接 中等 🤩🤩🤩🤩
    260. 只出现一次的数字 III LeetCode 题解链接 中等 🤩🤩🤩🤩
    414. 第三大的数 LeetCode 题解链接 中等 🤩🤩🤩🤩
    419. 甲板上的战舰 LeetCode 题解链接 中等 🤩🤩🤩🤩
    443. 压缩字符串 LeetCode 题解链接 中等 🤩🤩🤩🤩
    457. 环形数组是否存在循环 LeetCode 题解链接 中等 🤩🤩🤩🤩
    528. 按权重随机选择 LeetCode 题解链接 中等 🤩🤩🤩🤩
    539. 最小时间差 LeetCode 题解链接 中等 🤩🤩🤩🤩
    726. 原子的数量 LeetCode 题解链接 困难 🤩🤩🤩🤩

    注:以上目录整理来自 wiki,任何形式的转载引用请保留出处。


    最后

    如果有帮助到你,请给题解点个赞和收藏,让更多的人看到 ~ ("▔□▔)/

    也欢迎你 关注我 和 加入我们的「组队打卡」小群 ,提供写「证明」&「思路」的高质量题解。

    所有题解已经加入 刷题指南,欢迎 star 哦 ~

    每日一题-找出知晓秘密的所有专家🔴

    给你一个整数 n ,表示有 n 个专家从 0n - 1 编号。另外给你一个下标从 0 开始的二维整数数组 meetings ,其中 meetings[i] = [xi, yi, timei] 表示专家 xi 和专家 yi 在时间 timei 要开一场会。一个专家可以同时参加 多场会议 。最后,给你一个整数 firstPerson

    专家 0 有一个 秘密 ,最初,他在时间 0 将这个秘密分享给了专家 firstPerson 。接着,这个秘密会在每次有知晓这个秘密的专家参加会议时进行传播。更正式的表达是,每次会议,如果专家 xi 在时间 timei 时知晓这个秘密,那么他将会与专家 yi 分享这个秘密,反之亦然。

    秘密共享是 瞬时发生 的。也就是说,在同一时间,一个专家不光可以接收到秘密,还能在其他会议上与其他专家分享。

    在所有会议都结束之后,返回所有知晓这个秘密的专家列表。你可以按 任何顺序 返回答案。

     

    示例 1:

    输入:n = 6, meetings = [[1,2,5],[2,3,8],[1,5,10]], firstPerson = 1
    输出:[0,1,2,3,5]
    解释:
    时间 0 ,专家 0 将秘密与专家 1 共享。
    时间 5 ,专家 1 将秘密与专家 2 共享。
    时间 8 ,专家 2 将秘密与专家 3 共享。
    时间 10 ,专家 1 将秘密与专家 5 共享。
    因此,在所有会议结束后,专家 0、1、2、3 和 5 都将知晓这个秘密。
    

    示例 2:

    输入:n = 4, meetings = [[3,1,3],[1,2,2],[0,3,3]], firstPerson = 3
    输出:[0,1,3]
    解释:
    时间 0 ,专家 0 将秘密与专家 3 共享。
    时间 2 ,专家 1 与专家 2 都不知晓这个秘密。
    时间 3 ,专家 3 将秘密与专家 0 和专家 1 共享。
    因此,在所有会议结束后,专家 0、1 和 3 都将知晓这个秘密。
    

    示例 3:

    输入:n = 5, meetings = [[3,4,2],[1,2,1],[2,3,1]], firstPerson = 1
    输出:[0,1,2,3,4]
    解释:
    时间 0 ,专家 0 将秘密与专家 1 共享。
    时间 1 ,专家 1 将秘密与专家 2 共享,专家 2 将秘密与专家 3 共享。
    注意,专家 2 可以在收到秘密的同一时间分享此秘密。
    时间 2 ,专家 3 将秘密与专家 4 共享。
    因此,在所有会议结束后,专家 0、1、2、3 和 4 都将知晓这个秘密。

     

    提示:

    • 2 <= n <= 105
    • 1 <= meetings.length <= 105
    • meetings[i].length == 3
    • 0 <= xi, yi <= n - 1
    • xi != yi
    • 1 <= timei <= 105
    • 1 <= firstPerson <= n - 1

    非常优雅清晰的 DFS 代码,(C++ / Python / Java / Kotlin)

    本篇题解需读者先掌握 DFS 基础知识。

    1. 视为无向图,专家为顶点,能分享秘密的专家之间存在无向边。
    2. 将 meetings 按时间分组且更早的组在前,每组皆改成邻接表。
    3. 在同一时刻中我们针对已访问的顶点(知晓秘密的专家)和相应邻接表向外探索即可。

    探索方式可以为 DFS, BFS, 并查集,本篇采用最简单的 DFS。

    Code

    ###C++

    class Solution {
    private: 
        void dfs(int u, const unordered_map<int, vector<int>>& adj, vector<bool>& visited) {
            for (int v : adj.at(u)) {
                if (!visited[v]) {
                    visited[v] = true; 
                    dfs(v, adj, visited);
                }
            }
        };
    
    public:
        vector<int> findAllPeople(int n, vector<vector<int>>& meetings, int firstPerson) {
            // 将 meetings 按时间排序
            sort(meetings.begin(), meetings.end(), [](const vector<int>& a, const vector<int>& b) {
                return a[2] < b[2];
            });
    
            // 根据时间先后,为每个时刻构建一个邻接表,置于列表中。考虑到本就是稀疏图并再次拆分,非常分散,故每个邻接表都用 hash map。
            vector<unordered_map<int, vector<int>>> multiAdj;
            for(int i = 0; i < meetings.size(); ++i){
                if(i == 0 || meetings[i][2] > meetings[i-1][2]) 
                    multiAdj.push_back({});
                auto& adj = multiAdj.back();
                int u = meetings[i][0], v = meetings[i][1];
                adj[u].push_back(v);
                adj[v].push_back(u);
            }
            
            vector<bool> visited(n, false);
            visited[0] = true; 
            visited[firstPerson] = true;
    
            for(const auto& adj : multiAdj){
                // 选取访问过的点 dfs 向外扩散,此处切忌遍历 0~n-1
                for(const auto& p : adj){
                    if(visited[p.first])
                        dfs(p.first, adj, visited);            
                }
            }
    
            // 筛选访问过的点构成结果,即所有知晓秘密的专家
            vector<int> result;
            for(int i = 0; i < n; ++i) {
                if(visited[i]) result.push_back(i);
            }
            return result;
        }
    };
    

    ###Python3

    class Solution:
        def findAllPeople(self, n: int, meetings: List[List[int]], firstPerson: int) -> List[int]:
            # 将 meetings 按时间排序
            meetings.sort(key=lambda x: x[2])
    
            # 根据时间先后,为每个时刻构建一个邻接表,置于列表中。考虑到本就是稀疏图并再次拆分,非常分散,故每个邻接表都用 hash map。
            multiAdj = []
            for i in range(len(meetings)):
                if i == 0 or meetings[i][2] > meetings[i-1][2]:
                    multiAdj.append({})
                adj = multiAdj[-1]
                u, v, time = meetings[i]
                if u not in adj:
                    adj[u] = []
                if v not in adj:
                    adj[v] = []
                adj[u].append(v)
                adj[v].append(u)
            
            visited = [False] * n
            visited[0] = True
            visited[firstPerson] = True
    
            def dfs(u: int, adj: Dict[int, List[int]]):
                for v in adj.get(u, []):
                    if not visited[v]:
                        visited[v] = True
                        dfs(v, adj)
    
            for adj in multiAdj:
                # 选取访问过的点 dfs 向外扩散,此处切忌遍历 0~n-1
                for p in adj.keys():
                    if visited[p]:
                        dfs(p, adj)
            
            # 筛选访问过的点构成结果,即所有知晓秘密的专家
            return [i for i in range(n) if visited[i]]
    

    ###Java

    class Solution {
        private void dfs(int u, Map<Integer, List<Integer>> adj, boolean[] visited) {
            if(adj.get(u).isEmpty()) return;
    
            for (int v : adj.get(u)) {
                if (!visited[v]) {
                    visited[v] = true;
                    dfs(v, adj, visited);
                }
            }
        }
    
        public List<Integer> findAllPeople(int n, int[][] meetings, int firstPerson) {
            // 将 meetings 按时间排序
            Arrays.sort(meetings, (a, b) -> Integer.compare(a[2], b[2]));
    
            // 根据时间先后,为每个时刻构建一个邻接表,置于列表中。考虑到本就是稀疏图并再次拆分,非常分散,故每个邻接表都用 hash map。
            List<Map<Integer, List<Integer>>> multiAdj = new ArrayList<>();
            for (int i = 0; i < meetings.length; i++) {
                if (i == 0 || meetings[i][2] > meetings[i - 1][2]) 
                    multiAdj.add(new HashMap<>());
                Map<Integer, List<Integer>> adj = multiAdj.get(multiAdj.size() - 1);
                int u = meetings[i][0], v = meetings[i][1];
                adj.computeIfAbsent(u, k -> new ArrayList<>()).add(v);
                adj.computeIfAbsent(v, k -> new ArrayList<>()).add(u);
            }
    
            boolean[] visited = new boolean[n];
            visited[0] = true;
            visited[firstPerson] = true;
    
            for (Map<Integer, List<Integer>> adj : multiAdj) {
                // 选取访问过的点 dfs 向外扩散,此处切忌遍历 0~n-1
                for (int p : adj.keySet()) {
                    if (visited[p]) 
                        dfs(p, adj, visited);
                }
            }
    
            // 筛选访问过的点构成结果,即所有知晓秘密的专家
            List<Integer> result = new ArrayList<>();
            for (int i = 0; i < n; i++) {
                if (visited[i]) result.add(i);
            }
            return result;
        }
    }
    

    ###Kotlin

    class Solution {
        fun findAllPeople(n: Int, meetings: Array<IntArray>, firstPerson: Int): List<Int> {
            // 将 meetings 按时间排序
            meetings.sortBy{ it[2] }
    
            // 根据时间先后,为每个时刻构建一个邻接表,置于列表中。考虑到本就是稀疏图并再次拆分,非常分散,故每个邻接表都用 hash map。
            val multiAdj = mutableListOf<MutableMap<Int, MutableList<Int>>>()
            for(i in meetings.indices){
                if(i == 0 || meetings[i][2] > meetings[i-1][2]) 
                    multiAdj += mutableMapOf()
                val adj = multiAdj.last()
                val (u, v, time) = meetings[i]
                adj.getOrPut(u){ mutableListOf() } += v
                adj.getOrPut(v){ mutableListOf() } += u
            }
            
            val visited = BooleanArray(n)
            visited[0] = true 
            visited[firstPerson] = true
    
            fun dfs(u: Int, adj: Map<Int, MutableList<Int>>){
                for(v in adj[u] ?: return) 
                    if(!visited[v]){
                        visited[v] = true 
                        dfs(v, adj)
                    }   
            }
    
            for(adj in multiAdj){
                // 选取访问过的点 dfs 向外扩散,此处切忌遍历 0~n-1
                for(p in adj.keys){
                    if(visited[p])
                        dfs(p, adj)            
                }
            }
    
            // 筛选访问过的点构成结果,即所有知晓秘密的专家
            return (0..<n).filter{ visited[it] }
        }
    }
    

    复杂度

    $n$ 为点数(专家数量),$m$ 为边数(meetings 的长度)
    考虑排序。

    • 时间复杂度: $\Theta(n + m\cdot log\ m)$
    • 空间复杂度: $\Theta(n + m)$

    推广

    以下皆为个人所著,兼顾了职场面试和本硕阶段的学术考试。

    点赞关注不迷路。祝君早日上岸,飞黄腾达!

    并查集+排序,Java双百详细题解

    image.png

    题意分析

    一共有n个专家,最开始只有0号专家知道秘密,在时刻0他会把秘密分享给另一个专家。之后每次开会,知道秘密的专家都会把秘密分享出去。目标是求出最后时刻,一共有多少专家知道秘密。

    解题思路

    每次分享秘密,可以看作把两个专家合并到一个集合中,因此采用并查集求解。
    特别的,本题的目标是求出祖先为0的节点有哪些。

    最开始,每个专家的祖先节点记为自己,由于秘密传播是通过会议进行的,时间靠后的会议不可能传播到时间靠前的会议,因此需要先对meetings数组按照会议时间排序。
    排序完成后,遍历所有时刻。同一时刻可能存在多场会议,由于秘密共享是瞬时发生的,且同一时刻的会议是乱序的,不存在先后,所以对每一时刻的处理分为两步:

    1. 第一轮遍历:首先判断两位专家中是否有人知道秘密,若有人知道秘密,则将两位专家的祖先节点都置为0。完成该操作后,无论两位专家是否有人知道秘密,都将两个专家合并,因为同一时刻的其他会议中,可能有其他知道秘密的专家将秘密分享给这两位中的任何一个,若存在此情况,则当前时刻过后,这两位专家也知道了秘密。
    2. 第二轮遍历:处理两种情况,
      • 场景一:第一轮遍历中,先遍历到某场会议,此时两位专家都不知道秘密,但在后面的遍历中,其中一位专家知道了秘密,由于上一步做了合并集合处理,此时将两位专家的祖先节点均置为0即可。
      • 场景二:第一轮遍历中,先遍历到某场会议,此时两位专家都不知道秘密,在后面的遍历中,这两位专家均没有被分享秘密,这时需要将两位专家从合并的集合中分离出来,如果不分离出来,在后面某时刻,如果这两位专家其中一个知道了秘密,那么会认为这两位专家都知道了秘密,但事实上,由于该时刻已过去,秘密无法分享给另一位专家。示例2即为此情况

    解题技巧

    由于需要按照时间对meetings数组进行升序排序,且之后要按照时间顺序进行遍历。所以考虑使用TreeMap对会议进行存储,key为时刻,value为当前时刻的会议列表。

    详细代码

    class Solution {
        
        // 并查集数组,记录每个元素的祖先节点
        public int[] p;
        
        // 查找每个元素的祖先,(路径压缩,并查集模板)
        public int find(int x) {
            if (p[x] != x) p[x] = find(p[x]);
            return p[x];
        }
            
        public List<Integer> findAllPeople(int n, int[][] meetings, int firstPerson) {
            p = new int[n+1];
            // 祖先数组初始化,将每个元素的祖先标记为自己
            for (int i = 1; i <= n; ++ i) p[i] = i;
            // 合并0号专家与firstPerson
            p[firstPerson] = 0;
            Map<Integer, List<int[]>> map = new TreeMap<>();
            // 构造以时刻为key,会议列表为value的Map,TreeMap将自动按照key升序排序
            for (int[] m : meetings) {
                // m[2]为会议时刻,每个时刻对应多场会议
                List<int[]> list = map.getOrDefault(m[2], new ArrayList<>());
                list.add(m);
                map.put(m[2], list);
            }
            // 对于每个时刻,遍历两次
            for (int x : map.keySet()) {
                // 第一轮遍历,合并集合
                for (int[] l : map.get(x)) {
                    int a = l[0], b = l[1];                
                    if (p[find(a)] == 0 || p[find(b)] == 0) { p[find(a)] = 0; p[find(b)] = 0; }
                    p[find(b)] = p[find(a)];
                }
                // 第二轮遍历,分场景讨论
                for (int[] l : map.get(x)) {
                    int a = l[0], b = l[1];
                    // 场景一:两位专家在前面的会议均不知道秘密,后面遍历中其中一位专家知道了秘密,瞬时共享,两人都将知道秘密
                    if (p[find(a)] == 0 || p[find(b)] == 0) { p[find(a)] = 0; p[find(b)] = 0; }
                    // 场景二:两位专家在该时刻始终都不知道秘密,将合并的集合分离开,防止后面时刻有一个专家知道秘密,将秘密分享给另一个专家
                    else { p[a] = a; p[b] = b; }
                }
            }       
            List<Integer> ans = new ArrayList<>();
            // 祖先为0的元素即为知道秘密的专家
            for (int i = 0; i < n; ++ i) {
                if (p[find(i)] == 0) ans.add(i);
            }        
            return ans;
        }
    }
    

    两种方法:DFS / 并查集(Python/Java/C++/Go)

    分析

    假设一开始 $0$ 和 $1$ 知道秘密。对比如下两种情况:

    • 时间 $1$,$1$ 和 $2$ 开会。时间 $2$,$2$ 和 $3$ 开会。秘密会传播给 $2$ 和 $3$,最终 $0,1,2,3$ 都知道秘密。
    • 时间 $1$,$2$ 和 $3$ 开会。时间 $2$,$1$ 和 $2$ 开会。第一场会议,参加会议的人都不知道秘密,所以秘密不会传播。秘密只会在第二场会议传播给 $2$,最终 $0,1,2$ 都知道秘密。

    所以要按照开会的先后顺序传播秘密,模拟这个过程。

    注意题目的这段话:

    • 秘密共享是瞬时发生的。也就是说,在同一时间,一个专家不光可以接收到秘密,还能在其他会议上与其他专家分享。

    解读:在同一时间发生的所有会议,可以视作一个无向图。专家是图中的节点,$\textit{meetings}[i]$ 是图的边,连接 $x_i$ 和 $y_i$。

    这个图可能有多个连通块。对于一个连通块,如果其中有知道秘密的专家,那么这个专家会把秘密分享给这个连通块中的其他专家。

    方法一:DFS

    1. 把 $\textit{meetings}$ 按照 $\textit{time}$ 从小到大排序。
    2. 创建一个哈希集合(或者布尔数组),记录知道秘密的专家。
    3. 遍历 $\textit{meetings}$,对于在同一时间发生的所有会议,创建一个无向图。
    4. 遍历同一时间参加会议的专家列表,如果 $x$ 知道秘密且没有访问过,那么从 $x$ 出发,DFS $x$ 所在连通块,把连通块中的点都标记为知道秘密,且访问过。
    5. 最后,把知道秘密的专家放入答案列表,返回答案列表。

    分组循环

    如何遍历 $\textit{time}$ 相同的会议?分组循环。

    适用场景:按照题目要求,数组会被分割成若干组,每一组的判断/处理逻辑是相同的。

    核心思想

    • 外层循环负责遍历组之前的准备工作(记录开始位置等),和遍历组之后的工作(传播秘密)。
    • 内层循环负责遍历组,找出这一组最远在哪结束,同时建图。

    这个写法的好处是,各个逻辑块分工明确,也不需要特判最后一组(易错点)。以我的经验,这个写法是所有写法中最不容易出 bug 的,推荐大家记住。

    class Solution:
        def findAllPeople(self, _, meetings: List[List[int]], firstPerson: int) -> List[int]:
            # 按照 time 从小到大排序
            meetings.sort(key=lambda m: m[2])
    
            # 一开始 0 和 firstPerson 都知道秘密
            have_secret = {0, firstPerson}
    
            # 分组循环
            m = len(meetings)
            i = 0
            while i < m:
                # 在同一时间发生的会议,建图
                g = defaultdict(list)
                time = meetings[i][2]
                while i < m and meetings[i][2] == time:
                    x, y, _ = meetings[i]
                    g[x].append(y)
                    g[y].append(x)
                    i += 1
    
                # 每个连通块只要有一个人知道秘密,那么整个连通块的人都知道秘密
                vis = set()  # 避免重复访问节点
    
                def dfs(x: int) -> None:
                    vis.add(x)
                    have_secret.add(x)
                    for y in g[x]:
                        if y not in vis:
                            dfs(y)
    
                # 遍历在 time 时间点参加会议的专家
                for x in g:
                    # 从知道秘密的专家出发,DFS 标记其余专家
                    if x in have_secret and x not in vis:
                        dfs(x)
    
            # 可以按任何顺序返回答案
            return list(have_secret)
    
    class Solution {
        public List<Integer> findAllPeople(int n, int[][] meetings, int firstPerson) {
            // 按照 time 从小到大排序
            Arrays.sort(meetings, (a, b) -> a[2] - b[2]);
    
            // 一开始 0 和 firstPerson 都知道秘密
            Set<Integer> haveSecret = new HashSet<>();
            haveSecret.add(0);
            haveSecret.add(firstPerson);
    
            // 分组循环
            int m = meetings.length;
            for (int i = 0; i < m;) {
                // 在同一时间发生的会议,建图
                Map<Integer, List<Integer>> g = new HashMap<>();
                int time = meetings[i][2];
                for (; i < m && meetings[i][2] == time; i++) {
                    int x = meetings[i][0];
                    int y = meetings[i][1];
                    g.computeIfAbsent(x, k -> new ArrayList<>()).add(y);
                    g.computeIfAbsent(y, k -> new ArrayList<>()).add(x);
                }
    
                // 每个连通块只要有一个人知道秘密,那么整个连通块的人都知道秘密
                Set<Integer> vis = new HashSet<>(); // 避免重复访问节点
                for (int x : g.keySet()) {
                    // 从知道秘密的专家出发,DFS 标记其余专家
                    if (haveSecret.contains(x) && !vis.contains(x)) {
                        dfs(x, g, vis, haveSecret);
                    }
                }
            }
    
            // 可以按任何顺序返回答案
            return new ArrayList<>(haveSecret);
        }
    
        private void dfs(int x, Map<Integer, List<Integer>> g, Set<Integer> vis, Set<Integer> haveSecret) {
            vis.add(x);
            haveSecret.add(x);
            for (int y : g.get(x)) {
                if (!vis.contains(y)) {
                    dfs(y, g, vis, haveSecret);
                }
            }
        }
    }
    
    class Solution {
    public:
        vector<int> findAllPeople(int, vector<vector<int>>& meetings, int firstPerson) {
            // 按照 time 从小到大排序
            ranges::sort(meetings, {}, [](auto& a) { return a[2]; });
    
            // 一开始 0 和 firstPerson 都知道秘密
            unordered_set<int> have_secret = {0, firstPerson};
    
            // 分组循环
            int m = meetings.size();
            for (int i = 0; i < m;) {
                // 在同一时间发生的会议,建图
                unordered_map<int, vector<int>> g;
                int time = meetings[i][2];
                for (; i < m && meetings[i][2] == time; i++) {
                    int x = meetings[i][0], y = meetings[i][1];
                    g[x].push_back(y);
                    g[y].push_back(x);
                }
    
                // 每个连通块只要有一个人知道秘密,那么整个连通块的人都知道秘密
                unordered_set<int> vis; // 避免重复访问节点
                auto dfs = [&](this auto&& dfs, int x) -> void {
                    vis.insert(x);
                    have_secret.insert(x);
                    for (int y : g[x]) {
                        if (!vis.contains(y)) {
                            dfs(y);
                        }
                    }
                };
                for (auto& [x, _] : g) { // 遍历在 time 时间点参加会议的专家
                    // 从知道秘密的专家出发,DFS 标记其余专家
                    if (have_secret.contains(x) && !vis.contains(x)) {
                        dfs(x);
                    }
                }
            }
    
            // 可以按任何顺序返回答案
            return vector(have_secret.begin(), have_secret.end());
        }
    };
    
    func findAllPeople(_ int, meetings [][]int, firstPerson int) []int {
    // 按照 time 从小到大排序
    slices.SortFunc(meetings, func(a, b []int) int { return a[2] - b[2] })
    
    // 一开始 0 和 firstPerson 都知道秘密
    haveSecret := map[int]bool{0: true, firstPerson: true}
    
    // 分组循环
    m := len(meetings)
    for i := 0; i < m; {
    // 在同一时间发生的会议,建图
    g := map[int][]int{}
    time := meetings[i][2]
    for ; i < m && meetings[i][2] == time; i++ {
    x, y := meetings[i][0], meetings[i][1]
    g[x] = append(g[x], y)
    g[y] = append(g[y], x)
    }
    
    // 每个连通块只要有一个人知道秘密,那么整个连通块的人都知道秘密
    vis := map[int]bool{} // 避免重复访问节点
    var dfs func(int)
    dfs = func(x int) {
    vis[x] = true
    haveSecret[x] = true
    for _, y := range g[x] {
    if !vis[y] {
    dfs(y)
    }
    }
    }
    for x := range g { // 遍历在 time 时间点参加会议的专家
    // 从知道秘密的专家出发,DFS 标记其余专家
    if haveSecret[x] && !vis[x] {
    dfs(x)
    }
    }
    }
    
    // 可以按任何顺序返回答案
    return slices.Collect(maps.Keys(haveSecret))
    }
    

    复杂度分析

    • 时间复杂度:$\mathcal{O}(m\log m)$,其中 $m$ 是 $\textit{meetings}$ 的长度。瓶颈在排序上。分组循环是 $\mathcal{O}(m)$ 的,每个 $\textit{meetings}[i]$ 恰好遍历一次。
    • 空间复杂度:$\mathcal{O}(m)$。

    方法二:并查集

    考虑用并查集,把参加会议的 $x$ 和 $y$ 合并到同一个集合中。一开始把 $\textit{firstPerson}$ 和 $0$ 合并。

    但是,回顾这个例子:

    • 假设一开始 $0$ 和 $1$ 知道秘密。时间 $1$,$2$ 和 $3$ 开会。时间 $2$,$1$ 和 $2$ 开会。第一场会议,参加会议的人都不知道秘密,所以秘密不会传播。秘密只会在第二场会议传播给 $2$,最终 $0,1,2$ 都知道秘密。

    如果用并查集合并 $0$ 和 $1$,$2$ 和 $3$,$1$ 和 $2$,最终发现 $0,1,2,3$ 都在同一个集合中,我们会误认为最终 $0,1,2,3$ 都知道秘密。

    解决办法:第一场会议,把 $2$ 和 $3$ 合并。合并后,发现 $2$ 不和 $0$ 在同一个集合,说明 $2$ 不知道秘密,那么撤销合并,重置 $2$ 的代表元为 $2$(并查集的初始值)。$3$ 同理。

    最后,和 $0$ 在同一个集合的专家(包括 $0$)就是知道秘密的专家。

    关于并查集的完整模板,见 数据结构题单

    class UnionFind:
        def __init__(self, n: int):
            # 一开始有 n 个集合 {0}, {1}, ..., {n-1}
            # 集合 i 的代表元是自己
            self.fa = list(range(n))  # 代表元
    
        # 返回 x 所在集合的代表元
        # 同时做路径压缩,也就是把 x 所在集合中的所有元素的 fa 都改成代表元
        def find(self, x: int) -> int:
            fa = self.fa
            # 如果 fa[x] == x,则表示 x 是代表元
            if fa[x] != x:
                fa[x] = self.find(fa[x])  # fa 改成代表元
            return fa[x]
    
        # 判断 x 和 y 是否在同一个集合
        def is_same(self, x: int, y: int) -> bool:
            # 如果 x 的代表元和 y 的代表元相同,那么 x 和 y 就在同一个集合
            # 这就是代表元的作用:用来快速判断两个元素是否在同一个集合
            return self.find(x) == self.find(y)
    
        # 把 from 所在集合合并到 to 所在集合中
        def merge(self, from_: int, to: int) -> None:
            x, y = self.find(from_), self.find(to)
            self.fa[x] = y  # 合并集合
    
    
    class Solution:
        def findAllPeople(self, n: int, meetings: List[List[int]], firstPerson: int) -> List[int]:
            # 按照 time 从小到大排序
            meetings.sort(key=lambda x: x[2])
    
            uf = UnionFind(n)
            # 一开始 0 和 firstPerson 都知道秘密
            uf.merge(firstPerson, 0)
    
            # 分组循环
            m = len(meetings)
            i = 0
            while i < m:
                start = i
                time = meetings[i][2]
                # 合并在同一时间发生的会议
                while i < m and meetings[i][2] == time:
                    x, y, _ = meetings[i]
                    uf.merge(x, y)
                    i += 1
    
                # 如果节点不和 0 在同一个集合,那么撤销合并,恢复成初始值
                for x, y, _ in meetings[start: i]:
                    if not uf.is_same(x, 0):
                        uf.fa[x] = x
                    if not uf.is_same(y, 0):
                        uf.fa[y] = y
    
            # 和 0 在同一个集合的专家都知道秘密
            return [i for i in range(n) if uf.is_same(i, 0)]
    
    class UnionFind {
        private final int[] fa; // 代表元
    
        UnionFind(int n) {
            // 一开始有 n 个集合 {0}, {1}, ..., {n-1}
            // 集合 i 的代表元是自己
            fa = new int[n];
            for (int i = 0; i < n; i++) {
                fa[i] = i;
            }
        }
    
        // 返回 x 所在集合的代表元
        // 同时做路径压缩,也就是把 x 所在集合中的所有元素的 fa 都改成代表元
        public int find(int x) {
            // 如果 fa[x] == x,则表示 x 是代表元
            if (fa[x] != x) {
                fa[x] = find(fa[x]); // fa 改成代表元
            }
            return fa[x];
        }
    
        // 判断 x 和 y 是否在同一个集合
        public boolean isSame(int x, int y) {
            // 如果 x 的代表元和 y 的代表元相同,那么 x 和 y 就在同一个集合
            // 这就是代表元的作用:用来快速判断两个元素是否在同一个集合
            return find(x) == find(y);
        }
    
        // 把 from 所在集合合并到 to 所在集合中
        public void merge(int from, int to) {
            int x = find(from);
            int y = find(to);
            fa[x] = y; // 合并集合
        }
    
        public void reset(int x) {
            fa[x] = x;
        }
    }
    
    class Solution {
        public List<Integer> findAllPeople(int n, int[][] meetings, int firstPerson) {
            // 按照 time 从小到大排序
            Arrays.sort(meetings, (a, b) -> a[2] - b[2]);
    
            UnionFind uf = new UnionFind(n);
            // 一开始 0 和 firstPerson 都知道秘密
            uf.merge(firstPerson, 0);
    
            // 分组循环
            int m = meetings.length;
            for (int i = 0; i < m; ) {
                int start = i;
                int time = meetings[i][2];
                // 合并在同一时间发生的会议
                for (; i < m && meetings[i][2] == time; i++) {
                    uf.merge(meetings[i][0], meetings[i][1]);
                }
    
                // 如果节点不和 0 在同一个集合,那么撤销合并,恢复成初始值
                for (int j = start; j < i; j++) {
                    int x = meetings[j][0];
                    int y = meetings[j][1];
                    if (!uf.isSame(x, 0)) {
                        uf.reset(x);
                    }
                    if (!uf.isSame(y, 0)) {
                        uf.reset(y);
                    }
                }
            }
    
            // 和 0 在同一个集合的专家都知道秘密
            List<Integer> ans = new ArrayList<>();
            for (int k = 0; k < n; k++) {
                if (uf.isSame(k, 0)) {
                    ans.add(k);
                }
            }
            return ans;
        }
    }
    
    class UnionFind {
    public:
        vector<int> fa; // 代表元
    
        UnionFind(int n) : fa(n) {
            // 一开始有 n 个集合 {0}, {1}, ..., {n-1}
            // 集合 i 的代表元是自己
            ranges::iota(fa, 0);
        }
    
        // 返回 x 所在集合的代表元
        // 同时做路径压缩,也就是把 x 所在集合中的所有元素的 fa 都改成代表元
        int find(int x) {
            // 如果 fa[x] == x,则表示 x 是代表元
            if (fa[x] != x) {
                fa[x] = find(fa[x]); // fa 改成代表元
            }
            return fa[x];
        }
    
        // 判断 x 和 y 是否在同一个集合
        bool is_same(int x, int y) {
            // 如果 x 的代表元和 y 的代表元相同,那么 x 和 y 就在同一个集合
            // 这就是代表元的作用:用来快速判断两个元素是否在同一个集合
            return find(x) == find(y);
        }
    
        // 把 from 所在集合合并到 to 所在集合中
        void merge(int from, int to) {
            int x = find(from), y = find(to);
            fa[x] = y; // 合并集合
        }
    };
    
    class Solution {
    public:
        vector<int> findAllPeople(int n, vector<vector<int>>& meetings, int firstPerson) {
            // 按照 time 从小到大排序
            ranges::sort(meetings, {}, [](auto& a) { return a[2]; });
    
            UnionFind uf(n);
            // 一开始 0 和 firstPerson 都知道秘密
            uf.merge(firstPerson, 0);
    
            // 分组循环
            int m = meetings.size();
            for (int i = 0; i < m;) {
                int start = i;
                int time = meetings[i][2];
                // 合并在同一时间发生的会议
                for (; i < m && meetings[i][2] == time; i++) {
                    uf.merge(meetings[i][0], meetings[i][1]);
                }
    
                // 如果节点不和 0 在同一个集合,那么撤销合并,恢复成初始值
                for (int j = start; j < i; j++) {
                    int x = meetings[j][0], y = meetings[j][1];
                    if (!uf.is_same(x, 0)) {
                        uf.fa[x] = x;
                    }
                    if (!uf.is_same(y, 0)) {
                        uf.fa[y] = y;
                    }
                }
            }
    
            // 和 0 在同一个集合的专家都知道秘密
            vector<int> ans;
            for (int i = 0; i < n; i++) {
                if (uf.is_same(i, 0)) {
                    ans.push_back(i);
                }
            }
            return ans;
        }
    };
    
    type unionFind struct {
    fa []int // 代表元
    }
    
    func newUnionFind(n int) unionFind {
    fa := make([]int, n)
    // 一开始有 n 个集合 {0}, {1}, ..., {n-1}
    // 集合 i 的代表元是自己
    for i := range fa {
    fa[i] = i
    }
    return unionFind{fa}
    }
    
    // 返回 x 所在集合的代表元
    // 同时做路径压缩,也就是把 x 所在集合中的所有元素的 fa 都改成代表元
    func (u unionFind) find(x int) int {
    // 如果 fa[x] == x,则表示 x 是代表元
    if u.fa[x] != x {
    u.fa[x] = u.find(u.fa[x]) // fa 改成代表元
    }
    return u.fa[x]
    }
    
    // 判断 x 和 y 是否在同一个集合
    func (u unionFind) same(x, y int) bool {
    // 如果 x 的代表元和 y 的代表元相同,那么 x 和 y 就在同一个集合
    // 这就是代表元的作用:用来快速判断两个元素是否在同一个集合
    return u.find(x) == u.find(y)
    }
    
    // 把 from 所在集合合并到 to 所在集合中
    func (u *unionFind) merge(from, to int) {
    x, y := u.find(from), u.find(to)
    u.fa[x] = y
    }
    
    func findAllPeople(n int, meetings [][]int, firstPerson int) (ans []int) {
    // 按照 time 从小到大排序
    slices.SortFunc(meetings, func(a, b []int) int { return a[2] - b[2] })
    
    uf := newUnionFind(n)
    // 一开始 0 和 firstPerson 都知道秘密
    uf.merge(firstPerson, 0)
    
    // 分组循环
    m := len(meetings)
    for i := 0; i < m; {
    start := i
    // 合并在同一时间发生的会议
    time := meetings[i][2]
    for ; i < m && meetings[i][2] == time; i++ {
    uf.merge(meetings[i][0], meetings[i][1])
    }
    
    // 如果节点不和 0 在同一个集合,那么撤销合并,恢复成初始值
    for j := start; j < i; j++ {
    x, y := meetings[j][0], meetings[j][1]
    if !uf.same(x, 0) {
    uf.fa[x] = x
    }
    if !uf.same(y, 0) {
    uf.fa[y] = y
    }
    }
    }
    
    // 和 0 在同一个集合的专家都知道秘密
    for i := range n {
    if uf.same(i, 0) {
    ans = append(ans, i)
    }
    }
    return
    }
    

    复杂度分析

    • 时间复杂度:$\mathcal{O}(n + m\log m)$,其中 $m$ 是 $\textit{meetings}$ 的长度。分组循环是 $\mathcal{O}(m)$ 的,每个 $\textit{meetings}[i]$ 恰好遍历两次。
    • 空间复杂度:$\mathcal{O}(n)$。忽略排序的栈开销。

    专题训练

    1. 图论题单的「§1.1 深度优先搜索(DFS)」。
    2. 数据结构题单的「七、并查集」。
    3. 双指针题单的「六、分组循环」。

    分类题单

    如何科学刷题?

    1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
    2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
    3. 单调栈(基础/矩形面积/贡献法/最小字典序)
    4. 网格图(DFS/BFS/综合应用)
    5. 位运算(基础/性质/拆位/试填/恒等式/思维)
    6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
    7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
    8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
    9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
    10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
    11. 链表、树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA)
    12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

    我的题解精选(已分类)

    欢迎关注 B站@灵茶山艾府

    ❌