HOWTO · Python

在 Python 中逐字元比較兩個字串

瞭解如何在 Python 中逐字元比較兩個字串。

本頁內容

有時候單純的相等性檢查並不夠:你需要知道兩個字串有多少個位置上的字元相同,或是它們究竟從哪個位置開始不同。Python 沒有任何單一內建函式會回傳逐位置的相符計數,但 zip() 搭配產生器運算式可以把這件事寫成一行。

本文先介紹 zip() 作法,接著說明需要用到位置本身時的索引迴圈寫法、如何處理長度不同的字串與非字串輸入、相符計數與 == 及漢明距離(Hamming distance)的關係,以及這項比較的成本。

使用 zip() 逐字元比較兩個字串

逐位置比較兩個字串最簡短的方式,是用 zip() 把字元配對,再計算相等配對的數量。zip(first, second) 會依序產生 (first[0], second[0])、(first[1], second[1]) 等元組,一旦較短的字串用完就停止,所以結果永遠是針對共同前綴的計數。

把這些配對透過產生器運算式送進 sum(),每次比較為 True 就計為一。執行以下範例:

本節中的程式碼以及顯示的標籤或輸出仍為英文。

def count_matching_positions(first, second):
    return sum(a == b for a, b in zip(first, second))


print(count_matching_positions("apple", "apricot"))
print(count_matching_positions("homophones", "homonyms"))
print(count_matching_positions("apple", "applepie"))
2
4
5

"apple" 和 "apricot" 只有前兩個位置相符,"homophones" 和 "homonyms" 在 zip() 產生的八個位置中有四個相符,而 "apple" 與 "applepie" 共同前綴的五個字元全部相符。最後這個結果很重要:五個相符位置不代表兩個字串相等,只代表 zip() 所比較的部分全部相符。

關於這裡「字元」的意義有一點需要注意:Python 是以 Unicode 碼位(code point)逐一迭代字串,而不是以讀者眼中的一個字母為單位。寫成 e 後接一個組合重音符號的 "é" 是兩個碼位,所以在用 unicodedata.normalize() 正規化之前,兩個看起來完全相同的字串可能產生不同的計數。

需要位置資訊時使用索引迴圈

zip() 能告訴你字元是否相符,但會捨棄索引。當你需要知道是哪個位置不同——例如回報第一個分歧點,或把比較限制在某個切片範圍內——就用 range() 以較短的長度來驅動迴圈:

本節中的程式碼以及顯示的標籤或輸出仍為英文。

def compare_with_positions(first, second):
    size = min(len(first), len(second))
    matches = 0
    for i in range(size):
        if first[i] == second[i]:
            matches += 1
        else:
            print(f"position {i}: {first[i]!r} versus {second[i]!r}")
    return matches


print("matches:", compare_with_positions("homophones", "homonyms"))
position 4: 'p' versus 'n'
position 5: 'h' versus 'y'
position 6: 'o' versus 'm'
position 7: 'n' versus 's'
matches: 4

想要明確控制索引時,while 迴圈可以完成同樣的工作:初始化 i = 0,以 while i < size 為條件執行迴圈,並自行遞增 i。enumerate(zip(first, second)) 是第三種選擇,它直接提供 (index, (char1, char2)) 元組,不需要手動維護計數器。

處理不同長度與非字串輸入

這些 zip() 範例都遵循同一份約定:兩個引數都必須是字串,且比較只涵蓋共同前綴。有兩個後續影響值得刻意處理。

首先,zip() 絕對不會檢查較長字串的尾部。如果尾部多出來的部分應該影響結果——例如做相等性判斷時——就自行檢查長度,或改用 itertools.zip_longest(first, second, fillvalue=...) 來配對字串,它會為較短的輸入補齊,讓每個位置都被走訪。

其次,傳入 None 或其他非字串屬於程式錯誤,而不是剛好找不到相符字元的比較。對 count_matching_positions("apple", None) 回傳 0,會把錯誤的呼叫與真實的結果混為一談。應改為拒絕該輸入,讓呼叫方得知問題:

本節中的程式碼以及顯示的標籤或輸出仍為英文。

def count_matching_positions(first, second):
    if not isinstance(first, str) or not isinstance(second, str):
        raise TypeError("expected two strings")
    return sum(a == b for a, b in zip(first, second))


try:
    count_matching_positions("apple", None)
except TypeError as exc:
    print("TypeError:", exc)

print(count_matching_positions("apple", "applepie"))
TypeError: expected two strings
5

至於 len(first) != len(second) 是否也該拒絕,取決於任務本身:共同前綴的計數單獨來看是合理的,而相等性檢查則應把不同長度視為不同的字串。

計算相符數量、測試相等性或計算漢明距離

逐位置的相符計數所回答的問題與字串比較不同,選錯工具會造成難以察覺的錯誤:

  • first == second 測試的是整個字串的相等性:每個位置都相符且長度相同。即使 "apple" 與 "applepie" 的共同前綴完全相符,"apple" == "applepie" 仍是 False。
  • all(a == b for a, b in zip(first, second)) 看起來類似,但只能證明共同前綴相等——它對 "apple" 與 "applepie" 會回傳 True。加上 len(first) == len(second) 才能成為真正的相等性測試,或者直接使用 ==。
  • 漢明距離的方向相反:它計算的是字元不同的位置數量,而且只定義在等長字串上。對於等長輸入,它恰好等於 len(first) 減去相符計數。

本節中的程式碼以及顯示的標籤或輸出仍為英文。

def hamming_distance(first, second):
    if len(first) != len(second):
        raise ValueError("Hamming distance needs strings of equal length")
    return sum(a != b for a, b in zip(first, second))


first, second = "apple", "applepie"
print(first == second)
print(sum(a == b for a, b in zip(first, second)))
print(hamming_distance("karolin", "kathrin"))
False
5
3

"karolin" 和 "kathrin" 在位置 2、3、4 不同,所以它們的漢明距離是 3——又因為兩個字串長度相同,它也等於 7 - 4。

瞭解時間與空間複雜度

zip() 計數和索引迴圈最多都只需檢查 min(len(first), len(second)) 個字元對,所以執行時間是 O(min(len(first), len(second)))——對較短的輸入而言是線性的。最壞情況發生在兩個字串於整個共同前綴上全部相符時;一個遇到第一個不相符就停止的迴圈可以提早結束。

輔助空間為 O(1):zip() 和產生器運算式都是惰性迭代器,一次只保留一個配對,而計數器只是一個整數。若用 list(zip(first, second)) 把配對實體化,反而會耗費 O(min(len(first), len(second))) 空間且沒有任何好處,所以應保留產生器的寫法。

補充說明,first == second 在最壞情況下同樣是線性的,不過一旦找到差異,比較就可以停止。