雲端 Mac 上的 iOS 編輯距離驗收:Unicode 字元與計算預算

雲端 Mac 上的 iOS 編輯距離驗收:Unicode 字元與計算預算

搜尋框需要為拼字錯誤提供候選詞時,編輯距離通常只要幾十行程式碼,卻很容易在中文、組合重音與表情符號輸入上算錯。將測試放在雲端 Mac 的固定工作目錄中,能讓跨時區成員共用同一份 Swift 測試樣本;重點不是比較機器跑得多快,而是明確定義「一個字元」的意義,以及每次比較允許使用多少計算資源。

明確定義距離與字元契約

本文採用 Levenshtein 距離:插入、刪除、替換各計一次,相鄰字元交換不算單次操作。因此,ab 到 ba 的距離是二,不是一。自動校正若需要支援交換操作,就應更換演算法,而不是只調整預期值。

字串以 Swift Character 拆分,也就是擴展字素叢集。一個家庭表情符號可能包含多個純量,但在比較時視為一個字元;預組形式與分解形式的等價重音字元,在 Swift 的字元相等語意下可以相等。

大小寫折疊、全半形轉換與移除重音,都不屬於距離函式的職責。產品若需要這些規則,應建立獨立的前處理層,並同時保留原始文字。

距離較小,只表示在所選操作模型下需要的修改較少,不代表兩個詞的語意相近,也不能直接作為身分比對或安全判斷的依據。

實作帶有計算預算的滾動陣列

在雲端 Mac 上確認 xcrun swift --version 可以執行,再將以下內容儲存為 edit-distance.swift。實作只保留前後兩列,並將較短的輸入放在欄方向,避免儲存整個矩陣。

輸入設有三道限制:每個字串最多 16384 個 UTF-8 位元組、512 個字元,兩個字串的長度乘積最多 65536。這些數字是工程預算範例,不是效能測試結論。正式環境應依候選詞數量與互動延遲目標調整。

enum DistanceError: Error {
    case inputLimit
    case workLimit
}

func distance(_ lhs: String, _ rhs: String) throws -> Int {
    guard lhs.utf8.count <= 16_384,
          rhs.utf8.count <= 16_384 else {
        throw DistanceError.inputLimit
    }

    let a = Array(lhs)
    let b = Array(rhs)
    guard a.count <= 512, b.count <= 512 else {
        throw DistanceError.inputLimit
    }

    if a.isEmpty { return b.count }
    if b.isEmpty { return a.count }

    guard a.count <= 65_536 / b.count else {
        throw DistanceError.workLimit
    }

    let columns = a.count <= b.count ? a : b
    let rows = a.count <= b.count ? b : a
    var previous = Array(0...columns.count)
    var current = Array(repeating: 0, count: columns.count + 1)

    for i in rows.indices {
        current[0] = i + 1
        for j in columns.indices {
            let substitution = previous[j]
                + (rows[i] == columns[j] ? 0 : 1)
            current[j + 1] = min(
                previous[j + 1] + 1,
                min(current[j] + 1, substitution)
            )
        }
        swap(&previous, &current)
    }
    return previous[columns.count]
}

以除法檢查可避免先乘再判斷所造成的整數溢位。空字串會在除法之前回傳結果,排除除以零的風險。位元組限制則在建立陣列之前檢查,避免少量字元夾帶大量組合標記時,單靠字元數限制仍接受過大的輸入。

加入可直接執行的驗收樣本

將以下程式碼接在同一個檔案末尾,再執行 xcrun swift edit-distance.swift。這裡使用明確的失敗函式,不依賴可能在最佳化組態下停用的斷言。

func require(_ condition: Bool) {
    if !condition { fatalError("Distance contract failed") }
}

let fixtures: [(String, String, Int)] = [
    ("", "", 0),
    ("", "构建", 2),
    ("kitten", "sitting", 3),
    ("构建", "构件", 1),
    ("ab", "ba", 2),
    ("\u{00E9}", "e\u{0301}", 0),
    ("👨‍👩‍👧‍👦", "", 1)
]

for (a, b, expected) in fixtures {
    let forward = try distance(a, b)
    let backward = try distance(b, a)
    require(forward == expected)
    require(backward == expected)
}

do {
    _ = try distance(
        String(repeating: "a", count: 257),
        String(repeating: "b", count: 257)
    )
    fatalError("Expected work limit")
} catch DistanceError.workLimit {
}

這組樣本分別驗證初始化、替換、交換語意與 Unicode 比較單位。預算超限的拒絕測試只接受指定錯誤;其他錯誤會繼續向外傳遞,不能以過於寬泛的捕捉方式將其誤判為通過。

以性質測試涵蓋輸入組合

固定樣本能抓出已知錯誤,組合測試則適合找出列交換、邊界初始化與重複字元處理的缺陷。繼續使用 a、b 與一個中文字元,產生長度不超過三的所有短字串,並對任意兩個字串驗證:

再針對少量三元組檢查三角不等式。這些性質成立,並不能證明演算法完全正確,因此必須搭配已知結果使用。對超出預算的輸入,不應要求其回傳距離,也不應讓它參與這些性質比較。

分開測試前處理層

若搜尋會忽略大小寫,應先獨立驗證前處理輸出,再驗證輸出之間的距離。不要在動態規劃迴圈中呼叫與目前地區設定有關的轉換,否則同一組詞的結果可能隨環境改變,而且每個儲存格都會重複承擔轉換成本。

整合至流水線並限制總計算量

將指令稿與應用程式碼一併提交,在單元測試之前執行;若以非零狀態結束,就阻擋後續步驟。記錄原始碼提交識別碼、Swift 版本與失敗樣本,不必依賴實際經過時間設定毫秒級門檻;共用機器上的工作負載變化,會讓這類門檻產生雜訊。

單次比較有預算,不代表整個搜尋請求也有預算。候選詞應先依業務類別或長度差縮小範圍,再限制候選數量。需要取消時,應在候選詞之間檢查取消狀態;同步距離函式不會因為外層工作取消就自動中斷。

上線前須補齊四項:空輸入策略、超限錯誤的呈現方式、候選詞總預算,以及前處理版本。超限時應明確回傳「未計算」,不能偽裝成一個很大的距離。如此才能避免搜尋排序將資源保護措施誤當成文字不相似的證據。

常見問題

編輯距離應該按位元組還是 Character 計算?

面向使用者可見文字的拼字建議,通常適合按 Character 計算。UTF-8 位元組與 UTF-16 程式碼單元可能拆開字元;語言學層面的比較,則需要另外定義斷詞與正規化契約。

滾動陣列可以解決超長輸入的所有成本嗎?

不行。它只將額外空間降至較短輸入長度的線性量級,時間仍與兩個輸入長度的乘積相關,因此仍須限制位元組數、字元數及動態規劃單元數。

把建置工作交給資料中心

租用專屬實體 Mac mini,將 Xcode 建置與 macOS CI 部署在全年穩定運作的主機上。依工作需求挑選規格,再依團隊所在地選擇節點。

選擇雲端 Mac

把建置工作交給資料中心

專屬實體 Mac mini,全年 365 天穩定運作。