Princeton Algorithms, Part II, WordNet
普林斯頓大學算法課 WordNet 題解與代碼
首先要理解什麼是 WordNet,這裏定義了同義詞集、下位詞、上位詞、邏輯門等計算語言學的複雜概念,確實不太好懂,但是總體上說,它是一個有根的有向無環圖 a rooted DAG,但是它不一定是樹。
同義詞集列表中第一個字段是 id,第二個字段是同義詞集,構成同義詞集的各個名詞之間用空格分隔,第三個字段與本次作業無關。
上位詞列表中第一個字段表示同義詞的 id,後續字段是改同義詞的上位詞 id 號,一個詞可以有多個上位詞,所以可能有多個字段,這就相當於是確定了 DAG 中的邊的關係。
在 WordNet 的類的構造中,參數爲 null 或者單詞不是 WordNet 中有效的單詞,給出異常,這個比較好做。難以理解的是 The input to the constructor does not correspond to a rooted DAG. 這個要求,我們如何才能確定自己構造的是不是一個有根的有向無環圖?實際上,algs4.jar 中的 Digraph(有向圖)給出了 2 個函數,indegree() 和 outdegree() 可以很方便地計算一個頂點的入度和出度,我們可以通過這個來判斷是不是一個 rooted DAG。
除了是不是單根,還需要調用 DirectedCycle 或者 Topological 去檢查是不是有環。
private void validate(Digraph g) {
assert g != null;
int vertexNumber = g.V();
int rootNumber = 0;
for (int i = 0; i < vertexNumber; i++) {
// 出度爲 0 的點是根節點(沒有上位詞的同義詞集)
if (g.outdegree(i) == 0) {
rootNumber++;
}
}
// 根節點不足 1 或者大於 1 都不滿足條件
if (rootNumber != 1) {
throw new IllegalArgumentException();
}
// The program uses neither 'DirectedCycle' nor 'Topological' to check whether the digraph is a DAG.
DirectedCycle dc = new DirectedCycle(g);
if (dc.hasCycle()) {
throw new IllegalArgumentException();
}
}
最近公共祖先比較好理解,推廣到頂點的集合也很簡單,就是找到所有 SAP 的最短的。
求公共祖先的過程是一個廣度優先搜索的過程。
while (!q.isEmpty()) {
int x = q.poll();
Iterable<Integer> bag = g.adj(x);
// 加入後面的點
for (int vv : bag) {
if (!visited[vv]) {
q.add(vv);
visited[vv] = true;
// 更新距離
int d = distanceV.get(x);
int dd = distanceV.getOrDefault(vv, d + 1);
distanceV.put(vv, dd);
}
}
}
具體的策略是,先對 v 點做一次廣搜,直到根結點,在每一次搜索的時候記錄下 depth。
然後對 w 做一次廣搜,搜索過程中遇到符合條件的(搜索過的),都是祖先,記錄下 depth 並相加,取最小的 depth 就是最近公共祖先。
最多對所有的點訪問 2 次(從 v 出發一次,從 w 出發一次),所以時間複雜度只與點的個數有關。
if (distanceV.containsKey(x)) {
// 更新最短的 LCA
int minDistance = distanceV.get(x) + distanceW.get(x);
if (sap[0] == -1 || minDistance < sap[0]) {
sap[0] = minDistance;
sap[1] = x;
}
}
// 這裏不是 else 的關係,要繼續往上找
Iterable<Integer> bag = g.adj(x);
for (int vv : bag) {
if (!visited[vv]) {
q.add(vv);
visited[vv] = true;
// 更新距離
int d = distanceW.get(x);
int dd = distanceW.getOrDefault(vv, d + 1);
distanceW.put(vv, dd);
}
}
另外在實現的時候需要注意性能和安全,例如有些可以在構造的時候就緩存出來的值就在構造的時候緩存好,不要等用的時候再去遍歷。
validate(word);
// 直接查緩存就可以了
return synsetToIdMap.containsKey(word);
以及有些返回類型是 Iterable 的,一定要確保返回類型不可變,不要返回原來的那個,要 new 一個新的去返回。
// 時刻記得做成不可變的
this.g = new Digraph(g);
// 時刻注意這種類型返回的時候一定要不可變,所以這裏返回的時候返回一個新的,不返回原來那個
return new ArrayList<>(synsetToIdMap.keySet());
Princeton 的作業質量高還在於它的異常處理,需要注意所有不合法情況的判斷,本題中對應的是單詞爲 null,更進一步的是單詞不存在於單詞集中。而對於圖,則需要滿足 DAG 和 rooted 這兩個條件。
封裝的情況也需要考慮,例如本題,在搜索 sap(int, int) 和 sap(Iterable, Iterable) 的時候,可以將這兩個功能抽象出來,做一次封裝。
最簡單的當然是寫 sap(int, int),然後對於 sap(Iterable, Iterable) 的情況,做一次雙重循環,對於每一個 i 對於每一個 j 做一次 sap(i, j) 並取最小值。
但是這樣的寫法是不是真的足夠好?在搜索的時候是不是重複搜索了很多?
所以我們可以按下面代碼所述,進行更合理的封裝和優化,對於已經搜索過的,就不再搜索了。
需要注意的是本題存在的映射關係有:“已知 id 獲取同義詞集”,“已知單詞獲取該單詞對應的同義詞集”這兩種。其中,“已知單詞獲取該單詞對應的同義詞集”可以由“已知單詞獲取 id”和“已知 id 獲取同義詞集”完成,所以我們只需要保存“已知單詞獲取 id”和“已知 id 獲取同義詞集”這樣兩個 HashMap 即可。
Outcast 就非常簡單了,WordNet 寫好了以後直接調用,取最大值就好。
完整代碼參見 https://gitlab.jxtxzzw.com/jxtxzzw/coursera_assignments 或者 https://www.jxtxzzw.com/archives/5263