LeetCode算法日记 - Day 48: 课程表II、火星词典
目录
1. 课程表II
1.1 题目解析
1.2 解法
1.3 代码实现
2. 火星词典
2.1 题目解析
2.2 解法
2.3 代码实现
1. 课程表II
https://leetcode.cn/problems/course-schedule-ii/submissions/664409369/
现在你总共有 numCourses
门课需要选,记为 0
到 numCourses - 1
。给你一个数组 prerequisites
,其中 prerequisites[i] = [ai, bi]
,表示在选修课程 ai
前 必须 先选修 bi
。
- 例如,想要学习课程
0
,你需要先完成课程1
,我们用一个匹配来表示:[0,1]
。
返回你为了学完所有课程所安排的学习顺序。可能会有多个正确的顺序,你只要返回 任意一种 就可以了。如果不可能完成所有课程,返回 一个空数组 。
示例 1:
输入:numCourses = 2, prerequisites = [[1,0]]
输出:[0,1]
解释:总共有 2 门课程。要学习课程 1,你需要先完成课程 0。因此,正确的课程顺序为 [0,1] 。
示例 2:
输入:numCourses = 4, prerequisites = [[1,0],[2,0],[3,1],[3,2]] 输出:[0,2,1,3] 解释:总共有 4 门课程。要学习课程 3,你应该先完成课程 1 和课程 2。并且课程 1 和课程 2 都应该排在课程 0 之后。 因此,一个正确的课程顺序是[0,1,2,3]
。另一个正确的排序是[0,2,1,3]
。
示例 3:
输入:numCourses = 1, prerequisites = [] 输出:[0]
提示:
1 <= numCourses <= 2000
0 <= prerequisites.length <= numCourses * (numCourses - 1)
prerequisites[i].length == 2
0 <= ai, bi < numCourses
ai != bi
- 所有
[ai, bi]
互不相同
1.1 题目解析
题目本质
这是一个“课程依赖 → 有向图的拓扑排序”问题。每个课程是节点,依赖 bi→ai
是有向边;要找一条满足所有依赖方向的线性序列(如果图有环则不存在)。
常规解法(直观想法)
“每次挑选所有前置都学完的课”,把它们加入结果,再继续挑选下一批。
如果用朴素做法,每轮都在全图里扫“谁的前置都完成”,时间会很大。
问题分析(为何直观不行)
-
朴素地“每轮全表扫描”找可学课程,最坏要扫很多轮,复杂度可达 O(n2+m)O(n^2+m)O(n2+m)(n=课程数,m=依赖数)。
-
同时还不好判断是否存在环(即无法完成)。
思路转折
为高效实现“可学课程”的筛选,必须预处理入度并用队列维护“当前入度为 0 的节点集合”。
这就是 Kahn 算法(BFS 拓扑排序):
-
入度为 0 ⇒ 没有未满足的前置 ⇒ 可以立刻学习;
-
学完就“删除出边”(后继入度减 1),新变成 0 的进队;
-
最后如果处理数量 < n,则有环,返回空。
1.2 解法
算法思想
-
用入度 in[x] 表示课程 x 还有多少门前置未完成;
-
初始把所有 in[x]==0 的课程入队;
-
每次出队一个课程 cur,将它放进结果,并把它指向的课程的入度都减一;减到 0 的再入队;
-
处理完的数量 index 等于 n 则有解,否则有环返回空数组。
正确性要点:只有当一个节点所有前驱都已出队(入结果)后,它的入度才会降为 0 并被入队,因此结果序列必然满足所有边的方向要求(拓扑序定义)。
i)建图 + 入度:对每条依赖 [a,b] 建边 b → a,并 in[a]++。
ii)初始化队列:把所有 in[i]==0 的节点入队。
iii)BFS 出队:
-
cur = q.poll() 写入 result[index++] = cur;
-
遍历 cur 的后继 x,做 in[x]--;若变 0 则 q.offer(x)。
iv)验环:若 index != n,说明仍有结点入度 > 0(存在环),返回空数组。
v)返回结果:长度为 n 的 result 即任意一组可行的学习顺序。
易错点
-
返回类型:不能 Arrays.asList();无解应返回 new int[0]。
-
写入时机:Kahn 法在出队时写入结果(result[index++]=cur),不是入队时。
-
判环方式:用“已处理计数
index==n
”判断 -
孤立点/无依赖:prerequisites 为空时,所有课都应在初始队列里,结果是 0..n-1。
1.3 代码实现
import java.util.*;class Solution {public int[] findOrder(int n, int[][] p) {int[] in = new int[n]; // 入度int[] result = new int[n]; // 结果序列Map<Integer, List<Integer>> edges = new HashMap<>(); // 邻接表:b -> [a1,a2...]// 1) 建图 + 入度统计(b -> a,表示先修 b 再修 a)for (int i = 0; i < p.length; i++) {int a = p[i][0], b = p[i][1];edges.computeIfAbsent(b, k -> new ArrayList<>()).add(a);in[a]++;}// 2) 入度为 0 的课程先入队Deque<Integer> q = new ArrayDeque<>();for (int i = 0; i < n; i++) {if (in[i] == 0) q.offer(i);}// 3) Kahn:不断出队,写入结果,并“删除”出边(后继入度--)int index = 0;while (!q.isEmpty()) {int cur = q.poll();result[index++] = cur; // 出队即进入拓扑序for (int next : edges.getOrDefault(cur, Collections.emptyList())) {if (--in[next] == 0) q.offer(next);}}// 4) 验环:若未处理满 n 个课程,说明存在环(无法完成)if (index != n) return new int[0];// 5) 返回任意一种合法拓扑序return result;}
}
复杂度分析
-
时间复杂度:O(n + m),因为每个课程最多入队出队一次,每条依赖边最多被遍历一次。
-
空间复杂度:O(n + m),因为需要存储入度数组、结果数组、队列以及邻接表。
2. 火星词典
https://leetcode.cn/problems/Jf1JuT/
现有一种使用英语字母的外星文语言,这门语言的字母顺序与英语顺序不同。
给定一个字符串列表 words
,作为这门语言的词典,words
中的字符串已经 按这门新语言的字母顺序进行了排序 。
请你根据该词典还原出此语言中已知的字母顺序,并 按字母递增顺序 排列。若不存在合法字母顺序,返回 ""
。若存在多种可能的合法字母顺序,返回其中 任意一种 顺序即可。
字符串 s
字典顺序小于 字符串 t
有两种情况:
- 在第一个不同字母处,如果
s
中的字母在这门外星语言的字母顺序中位于t
中字母之前,那么s
的字典顺序小于t
。 - 如果前面
min(s.length, t.length)
字母都相同,那么s.length < t.length
时,s
的字典顺序也小于t
。
示例 1:
输入:words = ["wrt","wrf","er","ett","rftt"] 输出:"wertf"
示例 2:
输入:words = ["z","x"] 输出:"zx"
示例 3:
输入:words = ["z","x","z"] 输出:"" 解释:不存在合法字母顺序,因此返回 ""。
提示:
1 <= words.length <= 100
1 <= words[i].length <= 100
words[i]
仅由小写英文字母组成
2.1 题目解析
题目本质
把单词表中相邻词的“首个不同字符的相对先后关系”抽象为有向图的边,问题即“在字符集合上做一次拓扑排序”,得到一种可行的字母顺序;若有环或出现非法前缀顺序,则不存在解。
常规解法
直接比较所有成对单词提取关系,再用 BFS/DFS 随便拼一个顺序。
问题分析
比较所有成对单词会产生冗余边,且不必要;若用 DFS 需要额外显式的状态标记与回溯,代码更易错。更稳妥的做法是只比较相邻单词,保证边的最小充分集,并用 Kahn 拓扑排序按入度推进。时间规模由单词总字符数 Σ 决定,字符种类最多 26。
思路转折
要想高效与严谨
-
必须仅比较相邻词,抽取“首个不同字符”的一条有向边
-
必须先把所有出现过的字符入图并初始化入度为 0
-
必须在相邻比较处做前缀非法性判定(如 abc 在 ab 前面)
-
之后用 Kahn 拓扑:从入度为 0 的字符开始层层剥离,若结束后仍有入度不为 0,说明有环,无解
2.2 解法
算法思想
-
用所有出现过的字符作为节点
-
比较相邻单词 a 和 b,定位首个不同字符 x 与 y,建立边 x → y,并给 y 的入度 +1
-
若 a 是 b 的前缀但更长,则无解
-
用队列收集所有入度为 0 的节点,反复出队并“削边”,同时把新的入度为 0 的节点入队
-
输出序列长度若小于节点数,说明存在环,无解
i)初始化
-
扫描 words 中每个字符,加入图节点集合,入度表置 0,邻接表置空集合
ii)建边
-
依次比较相邻的 words[i] 与 words[i+1]
-
自左到右找首个不同字符 x 与 y,若存在则插入边 x → y(用 Set 去重),并将 y 的入度加一
-
若不存在不同字符且前者长度大于后者,返回空串
iii)拓扑排序(Kahn)
-
将所有入度为 0 的字符入队
-
循环出队 u,加入结果,对每个 u 的出边 v,把 v 入度减一,若变为 0 则入队
iv)校验与返回
-
若结果长度等于节点数,返回结果,否则返回空串
易错点
-
只比较相邻词,避免冗余与矛盾边
-
前缀非法性要在相邻比较阶段立即判定
-
加边时务必去重,否则会错误地多次增加入度
-
结果长度需与不同字符总数比对,检测环
2.3 代码实现
import java.util.*;class Solution {// 邻接表:字符 -> 它后面能直接到达的字符集合(有向边 u -> v)Map<Character, Set<Character>> edges = new HashMap<>();// 入度表:字符 -> 入度值Map<Character, Integer> in = new HashMap<>();// 非法标记:遇到 "abc" 在 "ab" 前面这种前缀非法顺序时置为 trueboolean flag = false;public String alienOrder(String[] words) {// 1) 初始化所有出现过的字符:入度置 0,邻接表放空 Set,避免后续判空分支for (String w : words) {for (int i = 0; i < w.length(); i++) {char c = w.charAt(i);in.putIfAbsent(c, 0);edges.putIfAbsent(c, new HashSet<>());}}// 2) 只比较相邻单词提取“首个不同字符”的约束边(更小且充分的边集)for (int i = 0; i + 1 < words.length; i++) {add(words[i], words[i + 1]); // 在 add 里做前缀非法性判定与加边if (flag) return ""; // 发现非法前缀,直接无解}// 3) Kahn 拓扑排序:从入度为 0 的字符开始层层“削边”Queue<Character> q = new LinkedList<>();for (char ch : in.keySet()) {if (in.get(ch) == 0) q.offer(ch);}StringBuffer ret = new StringBuffer();while (!q.isEmpty()) {char cur = q.poll();ret.append(cur);// 当前点可能没有出边;如果没有,edges.get(cur) 是空 Set(已在初始化阶段 putIfAbsent)for (char nxt : edges.get(cur)) {in.put(nxt, in.get(nxt) - 1); // 削去一条入边if (in.get(nxt) == 0) q.offer(nxt);}}// 4) 若存在环,说明最终拓扑序长度 < 节点总数(不同字符总数)return ret.length() == in.size() ? ret.toString() : "";}// 只基于相邻单词 si、sj 抽取一条“首个不同字符”的有向边 u -> v// 规则:// - 从左到右找到首个不同字符 c1 != c2,则 edges[c1].add(c2),并给 c2 入度 +1(注意去重)// - 否则(前缀相同),如果 si 比 sj 更长(如 "abc" 在 "ab" 前),则非法,flag = truepublic void add(String si, String sj) {int n = Math.min(si.length(), sj.length());int i = 0;for (; i < n; i++) {char c1 = si.charAt(i), c2 = sj.charAt(i);if (c1 != c2) {if(!edges.containsKey(c1)){edges.put(c1, new HashSet<>());}// 去重加边:避免重复加同一条边导致入度被多次加 1if (!edges.get(c1).contains(c2)) {edges.get(c1).add(c2);in.put(c2, in.get(c2) + 1);}return; // 抽到一条边即可返回;相邻单词只贡献第一处差异的约束}}// 能走到这里说明前 n 位完全相同;若前者更长则非法(前缀顺序错误)if (si.length() > sj.length()) flag = true;// 否则相等或更短是合法的(不产生新边)}
}
复杂度分析
-
时间复杂度 O(Σ + V + E),Σ 为所有单词的总字符数,V ≤ 26,E ≤ 26×26
-
空间复杂度 O(V + E)