|
27 | 27 | - [演算法流程](#演算法流程) |
28 | 28 | - [插入注音時的處理](#插入注音時的處理) |
29 | 29 | - [節點更新機制](#節點更新機制) |
30 | | - - [最佳路徑演算法:DAG 最短路徑](#最佳路徑演算法dag-最短路徑) |
| 30 | + - [最佳路徑演算法:Viterbi](#最佳路徑演算法viterbi) |
31 | 31 | - [實際範例](#實際範例) |
32 | 32 | - [語言模型架構](#語言模型架構) |
33 | 33 | - [McBopomofoLM:統一介面](#mcbopomofolm統一介面) |
@@ -209,108 +209,69 @@ void ReadingGrid::update() { |
209 | 209 | } |
210 | 210 | ``` |
211 | 211 |
|
212 | | -#### 最佳路徑演算法:DAG 最短路徑 |
| 212 | +#### 最佳路徑演算法:Viterbi |
213 | 213 |
|
214 | | -`walk()` 方法使用 **有向無環圖(DAG)最短路徑演算法** 找出分數最高的路徑(`reading_grid.cpp:216`): |
| 214 | +`walk()` 方法使用 **Viterbi 演算法** 找出分數最高的路徑(`reading_grid.cpp`)。 |
215 | 215 |
|
216 | | -**步驟 1:建立 DAG** |
| 216 | +Reading Grid 本身是一個線性格架(lattice):每個位置只能連接到更後方的位置,構成天然的拓撲順序。因此不需要額外的拓撲排序,直接按位置順序前向掃描即可。 |
217 | 217 |
|
218 | | -```cpp |
219 | | -ReadingGrid::WalkResult ReadingGrid::walk() { |
220 | | - // 1. 將所有 Node 轉換為圖的 Vertex(頂點) |
221 | | - std::vector<VertexSpan> vspans(spans_.size()); |
222 | | - for (size_t i = 0; i < spans_.size(); i++) { |
223 | | - const Span& span = spans_[i]; |
224 | | - for (size_t j = 1; j <= span.maxLength(); j++) { |
225 | | - NodePtr node = span.nodeOf(j); |
226 | | - if (node != nullptr) { |
227 | | - vspans[i].emplace_back(Vertex(node)); |
228 | | - } |
229 | | - } |
230 | | - } |
| 218 | +演算法使用一個動態規劃(DP)表格,每個 entry 記錄到達該位置的最大累計分數和回溯指標: |
231 | 219 |
|
232 | | - // 2. 建立邊(Edge):連接相鄰節點 |
233 | | - for (size_t i = 0; i < vspans.size(); i++) { |
234 | | - for (Vertex& v : vspans[i]) { |
235 | | - size_t nextPos = i + v.node->spanningLength(); |
236 | | - // 連接到下一個位置的所有節點 |
237 | | - for (Vertex& nv : vspans[nextPos]) { |
238 | | - v.edges.push_back(&nv); |
239 | | - } |
240 | | - } |
241 | | - } |
| 220 | +```cpp |
| 221 | +struct State { |
| 222 | + size_t fromIndex = 0; |
| 223 | + ReadingGrid::NodePtr fromNode = nullptr; |
| 224 | + double maxScore = -std::numeric_limits<double>::infinity(); |
| 225 | +}; |
242 | 226 | ``` |
243 | 227 |
|
244 | | -**步驟 2:拓撲排序** |
| 228 | +**步驟 1:前向傳遞(Forward Pass)** |
245 | 229 |
|
246 | | -使用非遞迴的深度優先搜尋(DFS)進行拓撲排序(`reading_grid.cpp:166`): |
| 230 | +從位置 0 開始,依序掃描每個位置。對於每個位置上所有可能的候選詞節點,計算「到達該詞末端位置」的累計分數,並執行鬆弛(relaxation): |
247 | 231 |
|
248 | 232 | ```cpp |
249 | | -std::vector<Vertex*> TopologicalSort(Vertex* root) { |
250 | | - std::vector<Vertex*> result; |
251 | | - std::stack<State> stack; |
252 | | - stack.emplace(root); |
253 | | -
|
254 | | - while (!stack.empty()) { |
255 | | - State& state = stack.top(); |
256 | | - Vertex* v = state.v; |
257 | | -
|
258 | | - if (state.edgeIter != v->edges.end()) { |
259 | | - Vertex* nv = *state.edgeIter; |
260 | | - ++state.edgeIter; |
261 | | - if (!nv->topologicallySorted) { |
262 | | - stack.emplace(nv); |
263 | | - continue; |
264 | | - } |
| 233 | +const size_t readingLen = readings_.size(); |
| 234 | +std::vector<State> viterbi(readingLen + 1); |
| 235 | +viterbi[0].maxScore = 0.0; |
| 236 | +
|
| 237 | +for (size_t i = 0; i < readingLen; ++i) { |
| 238 | + const ReadingGrid::Span& span = spans_[i]; |
| 239 | + const size_t maxSpanLen = span.maxLength(); |
| 240 | +
|
| 241 | + for (size_t spanLen = 1; spanLen <= maxSpanLen; ++spanLen) { |
| 242 | + const ReadingGrid::NodePtr& node = span.nodeOf(spanLen); |
| 243 | + if (node == nullptr) { |
| 244 | + continue; |
265 | 245 | } |
266 | 246 |
|
267 | | - v->topologicallySorted = true; |
268 | | - result.push_back(v); |
269 | | - stack.pop(); |
| 247 | + // 鬆弛操作:若經由目前節點到達目標位置的分數更高,則更新 |
| 248 | + double score = viterbi[i].maxScore + node->score(); |
| 249 | + State& target = viterbi[i + spanLen]; |
| 250 | + if (score > target.maxScore) { |
| 251 | + target.maxScore = score; |
| 252 | + target.fromNode = node; |
| 253 | + target.fromIndex = i; |
| 254 | + } |
270 | 255 | } |
271 | | - return result; |
272 | 256 | } |
273 | 257 | ``` |
274 | 258 |
|
275 | | -**步驟 3:鬆弛演算法(Relaxation)** |
276 | | - |
277 | | -對拓撲排序後的頂點依序執行鬆弛操作,找出最大權重路徑(`reading_grid.cpp:134`): |
| 259 | +核心觀察:因為使用對數機率(log probability),分數越大代表機率越高,所以鬆弛操作使用 `>` 而非 `<`。 |
278 | 260 |
|
279 | | -```cpp |
280 | | -void Relax(Vertex* u, Vertex* v) { |
281 | | - double w = v->node->score(); // 獲取節點的對數機率 |
| 261 | +**步驟 2:回溯路徑(Backward Pass)** |
282 | 262 |
|
283 | | - // 因為我們要找最大權重,所以用 > 而非 < |
284 | | - if (v->distance < u->distance + w) { |
285 | | - v->distance = u->distance + w; |
286 | | - v->prev = u; // 記錄前驅節點 |
287 | | - } |
288 | | -} |
| 263 | +從 DP 表格的末端回溯,透過 `fromIndex` 和 `fromNode` 指標重建最佳路徑: |
289 | 264 |
|
290 | | -// 主流程 |
291 | | -std::vector<Vertex*> ordered = TopologicalSort(&root); |
292 | | -for (auto it = ordered.rbegin(); it != ordered.rend(); ++it) { |
293 | | - Vertex* u = *it; |
294 | | - for (Vertex* v : u->edges) { |
295 | | - Relax(u, v); |
296 | | - } |
| 265 | +```cpp |
| 266 | +for (size_t curr = readingLen; curr > 0; curr = viterbi[curr].fromIndex) { |
| 267 | + result.nodes.emplace_back(std::move(viterbi[curr].fromNode)); |
297 | 268 | } |
| 269 | +std::reverse(result.nodes.begin(), result.nodes.end()); |
298 | 270 | ``` |
299 | 271 |
|
300 | | -**步驟 4:回溯路徑** |
301 | | -
|
302 | | -從終點回溯找出完整路徑: |
| 272 | +**複雜度與效能** |
303 | 273 |
|
304 | | -```cpp |
305 | | -std::vector<NodePtr> walked; |
306 | | -Vertex* it = &terminal; |
307 | | -while (it->prev != nullptr) { |
308 | | - walked.push_back(it->prev->node); |
309 | | - it = it->prev; |
310 | | -} |
311 | | -// 反轉得到正確順序 |
312 | | -result.nodes = std::vector<NodePtr>(walked.rbegin() + 1, walked.rend()); |
313 | | -``` |
| 274 | +演算法的時間複雜度為 O(V+E),其中 V 是可達的格架位置數,E 是候選詞轉移數。由於省去了圖的建構(Vertex/Edge)、拓撲排序等步驟,實測在 8001 個注音的壓力測試中,從原本 DAG 版本的約 8ms 降至約 1ms,提升約 8 倍。 |
314 | 275 |
|
315 | 276 | ### 實際範例 |
316 | 277 |
|
@@ -878,9 +839,7 @@ LC_ALL=C sort -o phrase.occ phrase.occ |
878 | 839 | | Reading Grid 主邏輯 | `Source/Engine/gramambular2/reading_grid.h` | `ReadingGrid` | |
879 | 840 | | 插入注音處理 | `Source/Engine/gramambular2/reading_grid.cpp:51` | `insertReading()` | |
880 | 841 | | 節點更新 | `Source/Engine/gramambular2/reading_grid.cpp:417` | `update()` | |
881 | | -| 最佳路徑演算法 | `Source/Engine/gramambular2/reading_grid.cpp:216` | `walk()` | |
882 | | -| 拓撲排序 | `Source/Engine/gramambular2/reading_grid.cpp:166` | `TopologicalSort()` | |
883 | | -| 鬆弛演算法 | `Source/Engine/gramambular2/reading_grid.cpp:134` | `Relax()` | |
| 842 | +| 最佳路徑演算法(Viterbi) | `Source/Engine/gramambular2/reading_grid.cpp` | `walk()` | |
884 | 843 |
|
885 | 844 | ### 語言模型 |
886 | 845 |
|
@@ -929,10 +888,11 @@ LC_ALL=C sort -o phrase.occ phrase.occ |
929 | 888 | - [Wiki: Gramambular 演算法](https://github.com/openvanilla/McBopomofo/wiki/程式架構_Gramambular) |
930 | 889 | - [Wiki: 詞庫開發說明](https://github.com/openvanilla/McBopomofo/wiki/詞庫開發說明) |
931 | 890 | - [X/Twitter 演算法說明串](https://x.com/McBopomofo/status/1559356063622631424) |
932 | | -- DAG 最短路徑演算法:Cormen et al., *Introduction to Algorithms*, 3rd Edition |
| 891 | +- Viterbi 演算法:Jurafsky & Martin, *Speech and Language Processing*, 3rd Edition, Chapter 8 |
| 892 | +- 格架上的 Viterbi:[vene.ro/blog/shortest-paths-in-lattices](https://vene.ro/blog/shortest-paths-in-lattices.html) |
933 | 893 |
|
934 | 894 | --- |
935 | 895 |
|
936 | | -**文件版本**:1.2 |
937 | | -**最後更新**:2025-10-12T13:12:00+08:00 |
| 896 | +**文件版本**:1.3 |
| 897 | +**最後更新**:2026-02-14T22:49:00+08:00 |
938 | 898 | **適用版本**:McBopomofo 2.x 及以上 |
0 commit comments