レーベンシュタイン距離で文字列の類似度を高速に取得する
はじめに
文字列の類似度について考えます。具体的には、レーベンシュタイン距離を使用して文字列の類似度を測定します。ただし、レーベンシュタイン距離では、速度が遅いため、高速化します。高速化に伴ってレーベンシュタイン距離とは別物になってしまいますが、類似度としての役割は果たせます。
最終的には、文字列類似度としてレーベンシュタイン距離ではなくn-gramを使用する方法を採用しました。レーベンシュタイン距離では、高速化しても遅すぎるのとn-gramの類似度が使用目的に合致していたため、採用しました。
※ウェブページで使用したかったため、使用言語はJavaScriptです
レーベンシュタイン距離
レーベンシュタイン距離の概要としては、文字列Aから文字列Bへ変更するのに、挿入・削除・置換を何回行えば変更できるかの問題を解くアルゴリズムです。
オーダーは、O(N*M)で正直遅いです。ただ、単純でわかりやすいので文字列の類似度としては、一般的です。次のコードは、レーベンシュタイン距離のWikipediaの擬似コードをそのまま、JavaScriptに書き換えたものです。
levenshteinDistance.jsfunction levenshteinDistance(str1, str2) {
var r, c, cost,
d = [];
for (r=0; r<=str1.length; r++) {
d[r] = [r];
}
for (c=0; c<=str2.length; c++) {
d[0][c] = c;
}
for (r=1; r<=str1.length; r++) {
for (c=1; c<=str2.length; c++) {
//cost = str1[r-1] == str2[c-1] ? 0: 1;
cost = str1.charCodeAt(r-1) == str2.charCodeAt(c-1) ? 0: 1;
d[r][c] = Math.min(d[r-1][c]+1, d[r][c-1]+1, d[r-1][c-1]+cost);
}
}
return d[str1.length][str2.length];
}
レーベンシュタイン距離(最適化 + 中断)
上記コードに最適化と中断処理を追加したコード。(配列の作成方法は、C言語等と異なりJavaScript固有の最適化処理を組み込んでいます)
計算量は、O(N*M)です。ただし、編集距離が短い場合、他2つより明らかに遅いです。ですが、編集距離が長くなると、中断の数値次第では善戦します。
levenshteinDistance.jsfunction levenshteinDistance(str1, str2, threshold) {
var s1, s2, len1, len2;
if (str1.length < str2.length) {
s1 = str1;
s2 = str2;
} else {
s1 = str2;
s2 = str1;
}
len1 = s1.length;
len2 = s2.length;
if (threshold == null || threshold == 0 || threshold > len2) {
threshold = len2;
}
if (len2-len1 >= threshold || len1 == 0) {
return threshold;
}
var r, c, min, ins, sub,
//d = new Array(len2);
d = [];
for (c=1; c<=len2; c++) {
//d[c-1] = c;
d.push(c);
// Packed Array
// see http://nmi.jp/2019-06-09-The-reason-you-should-avoid-new-array-100
}
for (r=0; r<len1; r++) {
ins = min = r + 1;
minDistance = len2;
for (c=0; c<len2; c++) {
//sub = ins - (s1[r] != s2[c] ? 0: 1);
sub = ins - (s1.charCodeAt(r) != s2.charCodeAt(c) ? 0: 1);
ins = d[c] + 1;
//del = min + 1;
//min = del < ins ? (del < sub ? del: sub): (ins < sub ? ins: sub);
min = min < ins ? (min < sub ? min+1: sub): (ins < sub ? ins: sub);
d[c] = min;
if (min < minDistance) { minDistance = min; }
}
if (minDistance >= threshold) { return threshold; }
}
return min > threshold ? threshold: min;
}
function levenshtein(str1, str2, threshold) {
threshold = threshold != null ? threshold: 0;
var m = Math.max(str1.length, str2.length);
var d = levenshteinDistance(str1, str2, (1-threshold)*m);
return 1 - (d / m);
}
O(ND)
O(ND)アルゴリズム。原著論文は、「E. W. Myers. An O(ND) difference algorithm and its variations. Algorithmica, 1, 251-266 (1986)」です。レーベンシュタイン距離とは、違いますがだいたい一緒です(備考参照)。
Nは、str1とstr2の文字列長の合計。Dは、編集距離です。編集距離が少なければ少ないほど高速に計算できます。
editDistanceOND.jsfunction editDistanceOND(str1, str2) {
var len1 = str1.length,
len2 = str2.length,
max = len1 + len2,
offset = max + 1,
x, y, D, k, kk,
V = new Array(offset*2+1);
V[1+offset] = 0;
for (D=0; D<=max; D++) {
for (k=-D; k<=D; k+=2) {
kk = k + offset;
x = (k == -D || (k != D && V[kk-1] < V[kk+1])) ? V[kk+1]: V[kk-1]+1;
y = x - k;
//while (x < len1 && y < len2 && str1[x] == str2[y]) {
while (x < len1 && y < len2 && str1.charCodeAt(x) == str2.charCodeAt(y)) {
x++;
y++;
}
V[kk] = x;
if (x >= len1 && y >= len2) { return D; }
}
}
return -1;
}
function editOND(str1, str2) {
var m = Math.max(str1.length, str2.length);
var d = editDistanceOND(str1, str2);
return 1 - (d / m);
}
O(NP)
O(NP)アルゴリズム。原著論文は、「S. Wu, U. Manber, and G. Myers. An O(NP) sequence comparison algorithm. Information Processing Letter. 35(6) 317-332 (1990)」です。レーベンシュタイン距離とは、違いますがだいたい一緒です(備考参照)。
Nは、str1とstr2の文字列長の合計。Pは、m,n(m>=n)として、P=(D-(m-n))/2です。なので、おおまかにO(ND)の半分の処理時間になります。
editDistanceONP.jsfunction snake(k, y, str1, str2) {
var x = y - k;
while (x < str1.length && y < str2.length && str1.charCodeAt(x) == str2.charCodeAt(y)) {
x++;
y++;
}
return y;
}
function editDistanceONP(str1, str2) {
var s1, s2;
if (str1.length < str2.length) {
s1 = str1;
s2 = str2;
} else {
s1 = str2;
s2 = str1;
}
var x, y, k, p,
v0, v1,
len1 = s1.length,
len2 = s2.length,
delta = len2 - len1,
offset = len1 + 1,
dd = delta + offset,
dc = dd - 1,
de = dd + 1,
max =len1 + len2 + 3,
//fp = new Array(max);
fp = [];
for (p=0; p<max; p++) {
//fp[p] = -1;
fp.push(-1);
}
for (p=0; fp[dd]!=len2; p++) {
for (k=-p; k<delta; k++) {
kk = k + offset;
v0 = fp[kk-1] + 1;
v1 = fp[kk+1];
fp[kk] = snake(k, (v0 > v1 ? v0: v1), s1, s2);
}
for (k=delta+p; k>delta; k--) {
kk = k + offset;
v0 = fp[kk-1] + 1;
v1 = fp[kk+1];
fp[kk] = snake(k, (v0 > v1 ? v0: v1), s1, s2);
}
v0 = fp[dc] + 1;
v1 = fp[de];
fp[dd] = snake(delta, (v0 > v1 ? v0: v1), s1, s2);
}
return delta + (p - 1) * 2;
}
function editONP(str1, str2) {
var m = Math.max(str1.length, str2.length);
var d = editDistanceONP(str1, str2);
return 1 - (d / m);
}
※原著論文のURL先は、既にリンク切れしています
備考
O(ND)・O(NP)アルゴリズムは、レーベンシュタイン距離とは別物です。編集距離は、削除・挿入から算出されます。置換は、削除・挿入の2手順となります。レーベンシュタイン距離と同じ値を取得することはできません。ですが、レーベンシュタイン距離側をO(ND)・O(NP)アルゴリズムと同様の値を返すように変更することはできます。コード例を下に示す。
cost = str1.charCodeAt(r-1) == str2.charCodeAt(c-1) ? 0: 1;
↓置換の編集距離を1から2に変更する
cost = str1.charCodeAt(r-1) == str2.charCodeAt(c-1) ? 0: 2;