japan.internet.com
japan.internet.com メンバーID
Twitter
Facebook
RSS
ピックアップ
2010年2月19日 10:00

編集距離アルゴリズムを使って文字列を変換する

著者Rod Stephensオリジナル版を読む海外海外発

はじめに

 私と同様に大量の文章を書く人なら、Microsoft Wordの変更履歴機能のことはよくご存じでしょう。この機能を利用すると、バージョンの異なるWordファイル間でどの箇所が変更されたかを簡単に見分けることができます。

 しかし、プレーンテキストファイルで変更箇所を知りたいときはどうすればよいでしょうか。バージョンの異なるデータファイルどうしを比較したい場合はどうでしょうか。また、プロジェクトが単体テストを通過しなくなった場合に、前の週にソースコードファイルのどの箇所が変更されたかを調べるにはどうすればよいでしょうか。

 ファイルの変更管理を実施していれば話は簡単です。きちんとした変更管理システムなら、バージョンの異なるファイル間の変更箇所を強調表示してくれるからです。ファイルの変更管理を実施していない場合や、変更管理の仕組みを理解したいと考えている場合は、変更箇所を調べるためのツールを自分で作成してみましょう。

 本稿では、2つのドキュメントや2つの文字列を比較して変更箇所を調べる方法を紹介します。相違点を見つけるためのアルゴリズムについて説明し、C#とVisual Basicで書かれたサンプルを示します(いずれもダウンロード可能です)。

編集距離

 本稿の最終的な目標は、2つのドキュメントがどの程度相違しているかを調べることですが、これから説明するアルゴリズムは2つの文字列を使って考えた方が理解しやすいので、そこから始めることにします。2つの文字列の相違点を見つける方法が分かれば、それを応用して、2つのドキュメントの相違点を見つけることや、文字、段落などで構成される2つのファイルの相違点を見つけることもできるようになります。

 2つの文字列の相違点を調べる場合に、実際に必要なのは最小差分を得ることです。もちろん、元の文字列の文字をすべて削除し、別の文字列の文字をすべて挿入して新しい文字列にすることも可能です。これで新しい文字列ができますが、この方法は2つの文字列の関係を把握するにはあまり役に立ちません。2つの文字列の中に共通する文字が多く含まれていた場合、この方法では、変換によって文字列のどの箇所が「変更された」かが分かりません。

 例えば、「cat」を「cart」に変換する場合、c、a、tを削除してから、c、a、r、tを挿入することも可能ですが、この操作には7つの変更が必要です。この場合は、単純に「cat」に「r」を挿入し、1つの変更で「cart」に変換する方がはるかに簡単です。こうすれば、2つの文字列のどの箇所が変更されたかをより正確に判別できます。

 編集距離は、2つの文字列が相違している度合い(相違度)を示す尺度です。編集距離を定義する方法はいくつかありますが、本稿では、単に文字列の変換に必要な削除操作と追加操作の最小回数として考えます。例えば、「cat」と「cart」の間の編集距離は1になります。

 catからcartへの変換のような単純なケースでは、編集距離を推測するのは簡単です。しかし類似点が少ない文字列どうしでは、最良解を見つけることが少し難しくなります。例えば、「parsnip」を「turnip」に変換する1つの方法は次のとおりです。

  1. arsnip(「p」を削除)
  2. rsnip(「a」を削除)
  3. trsnip(「t」を挿入)
  4. tursnip(「u」を挿入)
  5. turnip(「s」を削除)
 ここから5という編集距離が得られますが、これは最良解なのでしょうか。文字を見ても、どのように変更すれば最良の結果が得られるかがすぐに分かるとは限りません。

 編集距離を簡単に調べる1つの方法は、編集グラフを使うことです。編集グラフには、文字列を変換する際に取り得る手順が示されます。図1に、parsnipをturnipに変換する場合の編集グラフを示します。

図1 turnipへの変換:この編集グラフの青色の経路が、「parsnip」を「turnip」に変換するための最短の手順を示している
図1 turnipへの変換:この編集グラフの青色の経路が、「parsnip」を「turnip」に変換するための最短の手順を示している
 このグラフを作成するには、図1に示すようにノードの配列を作成します。元の文字列の文字を最上部に横方向に記述し、変換後の文字列の文字を左側に縦方向に記述します。それぞれの丸(ポイント)について、すぐ下とすぐ右のポイントに接続するリンクを描きます。

 グラフ内で、両方の文字列内で同じ文字に対応しているポイントを一致ポイントと呼びます。例えば、「parsnip」と「turnip」にはどちらも「r」という文字が含まれるので、「parsnip」の「r」の下にあり、「turnip」の「r」の右にあるノードが一致ポイントになります。図1では、一致ポイントがピンク色で示されています。

 編集グラフを仕上げるには、図1に示すように、左上にあるノードからそれぞれの一致ポイントに到達するリンクを追加します。

 最初は分かりにくく見えますが、実際にはこのグラフは極めて単純です。目標は左上から右下隅に向かって経路をたどることです。右方向への移動は、それぞれ元の文字列から文字を1つ削除する操作に対応します。図1で、青色の経路の開始点から右方向に2つ移動していますが、これは「parsnip」から「p」と「a」を削除する操作に対応しています。

 下方向への移動は、それぞれ新しい文字列に文字を1つ挿入する操作に対応します。図1では、引き続き青色の経路に沿って下方向に2つ移動していますが、これは文字列に「t」と「u」を挿入する操作に対応しています。

 斜め方向への移動は、文字をそのまま残す操作に対応します。図1では、引き続き青色の経路に沿って斜め方向に移動していますが、これは「r」をそのまま残す操作に対応しています。

 これらの規則を使うと、文字列を変換するための編集距離と変更の最小回数を簡単に調べることができます。右および下へのリンクはコスト1、斜めのリンクはコスト0と考えて、編集グラフをたどる最短経路を見つけます。見方を変えると、これは斜めのリンクを最も多く使う経路を見つける必要があるということです。

 この観点で見ると、青色の経路が最良解を表していることがすぐに分かります(最短距離が同じになる経路がグラフに複数存在する場合がありますが、この場合は文字列を同じコストで変換する手順が複数あるということです)。

編集距離のプログラミング

 2つの任意の文字列について、編集グラフを作成し、そのグラフに対して最短経路の計算を行って最良解と編集距離を得るためのプログラムを作成できます。最短経路全般の検出方法については、私の記事「Network Know-How: Finding Shortest Paths」を参照ください。今回の編集グラフは最短経路の検出に都合のよい特殊な構造を備えているので、それを利用してプログラムを作成します。

 基本的な考え方は、左上隅の開始ノードからグラフ内のすべてのノードへの最短経路を計算することです。リンクはすべて右と下(斜めの場合はその両方)に向かうので、左上隅を起点として右方向と下方向に進む距離を計算します。

 最初に、左上隅のノードに距離0を割り当てます。このノードからそれ自体への経路は0だからです。次に、左端の列の各ノードに1、2、3という順序で値を割り当てます。これらのノードへの経路は、次にくる文字を変換後の文字列に追加する操作に対応します。図1では、「t」ノードのコストが1、「u」ノードのコストが2という具合になります。

 同様に、グラフの最上部の行の各ノードに1、2、3という順序で値を割り当てます。これらの経路は、元の文字列から文字を削除する操作に対応します。これで、グラフ内を進みながら他の値を記録できるようになります。

 2列目の上から下に並んでいる各ノードについて考えてみましょう。各ノードに到達するために取り得る経路は3つあります。

 1つ目は、上のノードから到達する経路です。この移動は文字列に新しい文字を1つ追加する操作に対応するので、このノードに到達するためのコストは、上のノードに到達するときにかかったコストより1大きくなります。

 2つ目は、左のノードから到達する経路です。この移動は元の文字列から文字を1つ削除する操作に対応するので、このノードに到達するためのコストは、左のノードに到達するときにかかったコストより1大きくなります。

 3つ目は、斜めのリンクに沿って到達する経路です。斜めのリンクのコストは0なので、コストは左上のノードに到達するときにかかったコストと同じになります。

 このノードに到達する最良のコストを見つけるために、アルゴリズムはこれら3つの経路をすべて検討し、最良の経路を選択します。アルゴリズムはこのノードの新しい距離を記録し、最良の経路を示すように求められた場合に備えて、それがどのノードからの経路かを記録します。

 図2に示すStringEditDistanceプログラムでは、このアルゴリズムを使っています。表示される結果は図1の青色の経路と同じになっている点に注目してください。つまり、「pa」を削除し、「tu」を追加し、「s」を削除しています。

図2 全部の経路を通過:StringEditDistanceプログラムは「parsnip」を5つのステップで「turnip」に変換する
図2 全部の経路を通過:StringEditDistanceプログラムは「parsnip」を5つのステップで「turnip」に変換する
 リスト1に示すのは、StringEditDistanceプログラムで編集グラフの構築とノードの距離計算に使われているMakeEditGraph関数です。この関数はグラフの配列を作成し、左端の列と最上部の行を初期化します。次に、配列内を左から右に移動しながら他のノードの距離を記録します。

 リスト2に示すのは、結果の表示に使われているDisplayResultsルーチンです。このルーチンは、終了ノードから開始ノードに向かって編集グラフの最良の経路を逆方向にたどります。経由する各ノードで、ルーチンはそのノードに到達するときに行われた変更の種類(削除、挿入、または文字の維持)をチェックし、出力の先頭に適切な文字を追加します。図2を見ると分かるように、削除された文字は打ち消し線付きの赤色、挿入された文字は下線付きの青色、両方の文字列で共通する文字は黒色で表示されます。

リスト1 2つの文字列の編集グラフを記録する
<pre><code>
// Fill in the edit graph for two strings.
private Node[,] MakeEditGraph(string string1, string string2)
{
    // Make the edit graph array.
    int num_cols = string1.Length + 1;
    int num_rows = string2.Length + 1;
    Node[,] nodes = new Node[num_rows, num_cols];

    // Initialize the leftmost column.
    for (int r = 0; r < num_rows; r++)
    {
        nodes[r, 0].distance = r;
        nodes[r, 0].direction = Direction.FromAbove;
    }

    // Initialize the top row.
    for (int c = 0; c < num_cols; c++)
    {
        nodes[0, c].distance = c;
        nodes[0, c].direction = Direction.FromLeft;
    }

    // Fill in the rest of the array.
    char[] chars1 = string1.ToCharArray();
    char[] chars2 = string2.ToCharArray();
    for (int c = 1; c < num_cols; c++)
    {
        // Fill in column c.
        for (int r = 1; r < num_rows; r++)
        {
            // Fill in entry [r, c].
            // Check the three possible paths to here.
            int distance1 = nodes[r - 1, c].distance + 1;
            int distance2 = nodes[r, c - 1].distance + 1;
            int distance3 = int.MaxValue;
            if (chars1[c - 1] == chars2[r - 1])
            {
                // There is a diagonal link.
                distance3 = nodes[r - 1, c - 1].distance;
            }

            // See which is cheapest.
            if ((distance1 <= distance2) && (distance1 <= distance3))
            {
                // Come from above.
                nodes[r, c].distance = distance1;
                nodes[r, c].direction = Direction.FromAbove;
            }
            else if (distance2 <= distance3)
            {
                // Come from the left.
                nodes[r, c].distance = distance2;
                nodes[r, c].direction = Direction.FromLeft;
            }
            else
            {
                // Come from the diagonal.
                nodes[r, c].distance = distance3;
                nodes[r, c].direction = Direction.FromDiagonal;
            }
        }
    }
    
    // Display the graph's nodes (for debugging).
    //DumpArray(nodes);

    return nodes;
}
</pre></code>

リスト2 変更箇所を表示する
<pre><code>
// Display the changes.
private void DisplayResults(string string1, string string2, Node[,] nodes, RichTextBox rch)
{
    // Build a list of the moves from finish to start.
    int num_rows = nodes.GetUpperBound(0) + 1;
    int num_cols = nodes.GetUpperBound(1) + 1;
    int r = num_rows - 1;
    int c = num_cols - 1;

    // Make some fonts.
    Font normal_font = rch.Font;
    Font insert_font = new Font(rch.Font, FontStyle.Underline);
    Font delete_font = new Font(rch.Font, FontStyle.Strikeout);

    // Continue until we reach the upper left corner.
    rch.Clear();
    while ((r > 0) || (c > 0))
    {
        switch (nodes[r, c].direction)
        {
            case Direction.FromAbove:
                rch.Select(0, 0);
                rch.SelectedText = string2.Substring(r - 1, 1);
                rch.Select(0, 1);
                rch.SelectionFont = insert_font;
                rch.SelectionColor = Color.Blue;
                r--;
                break;
            case Direction.FromLeft:
                rch.Select(0, 0);
                rch.SelectedText = string1.Substring(c - 1, 1);
                rch.Select(0, 1);
                rch.SelectionFont = delete_font;
                rch.SelectionColor = Color.Red;
                c--;
                break;
            case Direction.FromDiagonal:
                rch.Select(0, 0);
                rch.SelectedText = string2.Substring(r - 1, 1);
                rch.Select(0, 1);
                rch.SelectionFont = normal_font;
                rch.SelectionColor = Color.Black;
                r--;
                c--;
                break;
        }
    }

    // Dispose of the fonts we created.
    insert_font.Dispose();
    delete_font.Dispose();
}
</pre></code>

行単位でのファイルの比較

 図3に示すサンプルプログラムのFileStringEditDistanceでは、同じアルゴリズムを使って2つのファイルを文字単位で比較しています。FileStringEditDistanceプログラムは、ファイルどうしを比較するうえでそれなりに役立ちます。図3を見ると、どの文字が追加または削除されたかを簡単に判別できます。

図3 ファイルどうしの比較:FileStringEditDistanceプログラムは、2つのファイルを文字列として扱って相違点を見つける
図3 ファイルどうしの比較:FileStringEditDistanceプログラムは、2つのファイルを文字列として扱って相違点を見つける
 しかし困ったことに、FileStringEditDistanceプログラムには大きな欠点があります。それは、大量のメモリを消費することです。図3で使われているファイルのサイズは比較的小さいものの、2,500を超える文字数が含まれるため、編集グラフの配列には650万を超えるエントリが格納されます。私の著書では、1つの章に5万を優に超える文字数が含まれることがあります。そのようなファイルの2つのバージョンを表す編集グラフには、実に25億を超えるエントリが格納されることになります。これでは大量のメモリが消費されるだけでなく、グラフの作成に膨大な時間がかかってしまいます。

 幸い、ファイルを行単位で比較するようにアルゴリズムを変更するのは簡単です。2つの文字列を2つの文字列の配列に置き換えるだけで済みます。2つの文字列内の文字を比較する代わりに、2つの文字列の配列内の文字列を比較します。図4に示すFileLineEditDistanceサンプルプログラムでは、この方法を使ってファイルを行単位で比較しています。

 サンプルをダウンロードしてテキストファイルを参照し、相違点を確認してください。実際、図4に示すように、FileLineEditDistanceプログラムを使ってファイルどうしを比較できます。

図4 行単位での比較:FileLineEditDistanceプログラムは、ファイルを文字単位ではなく行単位で比較する
図4 行単位での比較:FileLineEditDistanceプログラムは、ファイルを文字単位ではなく行単位で比較する

差分比較のバリエーション

 サンプルをダウンロードして実際に試してください。そしてもし余力があれば、サンプルに手を加えてみてください。さまざまなバリエーションを試す余地があります。

 例えば、サンプルでは編集グラフを通る最短経路が1つだけ検出されますが、それは必ずしも最良の経路とは限りません。例として、文字列BBBBCとBCの相違点を見つけたいとします。StringEditDistanceプログラムでは、中間の一連のBが削除されてBBBBCという結果が得られますが、先頭の一連のBを削除して、BBBBCのように残りの2文字をまとめて残した方が望ましい場合があります。

 FileLineEditDistanceプログラムのバリエーションとしては、行を比較して、行が相違している場合に、その行に含まれるテキストを比較するという処理が考えられます。結果はFileStringEditDistanceプログラムで得られる結果とほとんど変わりませんが、巨大な編集グラフを構築せずに済みます。

 FileLineEditDistanceプログラムのよくあるもう1つのバリエーションは、2つのファイルを横に並べて表示し、対応する行を強調表示するという処理です(Visual Source Safeの差分比較ツールで行われている方法です)。

 ファイルをすべてソース管理システムで保管している場合や、Microsoft Wordの変更履歴システムが気に入っている場合は、こうしたテクニックが必要になることはないでしょう。しかし、その範疇に入らないファイルでは、これらの手段が役に立ちます。アプリケーションにこの機能を追加すれば、ユーザーはバージョンの異なるスクリプト、メモ、その他のテキスト間の変更箇所を簡単に見つけることができるようになります。

著者紹介

Rod Stephens(Rod Stephens)
10冊以上の書籍と200点以上の雑誌記事の著者にしてコンサルタント。著作の大部分はVisual Basicに関するものである。これまで、修復ディスパッチ、燃料税トラッキング、プロフェッショナルなフットボールトレーニング、廃水処理、地図作成、チケット販売などの種々雑多なアプリケーションに従事してきた。彼のWebサイト「VB Helper」は、1か月に7百万以上のヒットを記録しており、Visual Basicプログラマ向けに3つのニューズレターと何千ものヒントや例を提供している。

関連テーマ
プリンター用
記事を転送
この記事をクリップ!
厳選した九州のお野菜とお米をお届け
厳選した九州のお野菜とお米をお届け 野菜の木では、老舗料亭 沙羅の木が厳選した九州のお野菜とお米をお届けします。 毎週、隔週での定期のご購入も可能です。 入会費、年会費、送料、荷造手数料は無料です。
注目のトピックス
Copyright 2012 internet.com K.K. (Japan) All Rights Reserved.