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 在最壞情況下同樣是線性的,不過一旦找到差異,比較就可以停止。