すのものの「タイピング練習プログラムの採点アルゴリズム」(その2)

このページは 2001 年四月に書いたものに少々加筆したものです。 初めはこれが「その1」の後半になる予定でした。 厳密に書こうとしすぎていると気づいたので、 いまの「その1」の形に書き直したのですが、 そこで使われなかった部分を一応まとめたのがこの「その2」です。

「採点」の定義

零個の文字からなる文字列を「空文字列」と呼ぶ。

txt と usr を同数の文字列に分割する。 ただし、それらを τ1τ2...τn および υ1υ2...υn とするとき、各 i について τi と υi の組は

のいずれかでなければならない。 このような分割を「採点」と呼ぶ。 「誤打」「挿入」「脱落」「転置」の組の数の総和をこの採点の「エラー数」という。

与えられた txt と usr に対し少なくとも一つの採点が存在する。 与えられた txt と usr に対し採点は一通りとは限らない。

エラー数が最も少ない採点を「エラー数最小の採点」と呼ぶ。 これも一通りとは限らない。 エラー数最小の採点のエラー数も「エラー数」という。 呼び名は同じであるが紛れはあまりないと思う。

注) txt も usr も空文字列の場合は、必然的に零個の文字列に分割することになり、 「エラー数 0」である。

付) txt と usr を上下に並べて書き、文字列の組 τi と υi を線分で結んでみれば、 この定義が妥当なものであることがわかるだろう。 前の例では
a b c d   e f g h i jk l
| | | | | | | | | | | |
a z c d y e f   h i kj l
のようになる。 ここでは表現上の都合により線分が垂直になるように文字を並べたが、 文字を等間隔に並べて線分を (必要なら)斜めに引いたほうがわかりやすいかもしれない。

付) 「採点」の定義には次のようなものも考えられよう。 文字列 txt と比較して 「エラー 1」と判定される文字列は最初に例であげたようなもの、と定義する。 その文字列と比較して「エラー 1」と判定される文字列は 「エラー 2 以下」とする。 その文字列と比較して「エラー 1」と判定される文字列は 「エラー 3 以下」とする。 こうやって帰納的に「エラー n 以下」を定義したうえで 「エラー n 以下」であるが「エラー (n-1) 以下」でない文字列を 「エラー n」と定義する。 上で述べた「採点」でエラー数 n の場合、 この定義でエラー n 以下になることは明らかであろう。 しかし、両者は一致はしない。 「txt が abcd, usr が bdac」の場合、 転置をくり返すと abcdbacdbadcbdac だからこの意味ではエラー 3 以下となるが、 上で述べた定義に従えばエラー数最小の採点でのエラー数は 4 になる。

「エラー数最小の採点」を行なうアルゴリズム

txt と usr が与えられたとする。

txt または usr が空文字列の場合、

となることは明らかであろう。

次に、txt も usr も空文字列でない場合を考える。 それらが次のような文字列であったとしよう。 ここで ti や uj は一文字を表わしている。

t1 t2 t3 ...
u1 u2 u3 ...

「エラー数最小の採点」(の一つ)で最初の部分がどう判定されたかを考えると、 それは次のいずれかである。

採点とは txt と usr をしかるべき条件をみたす文字列に分割することだったということを思い出すと、 残りの部分、すなわち は、 それらが空文字列であってもそうでなくても、 この採点をそこに制限することによって「採点」されているとみなせる。 すなわち、これらは同数の文字列に分割されており、 その分割は採点の条件を満たしている。 その採点は「エラー数最小の採点」である。 なぜなら、 もしもそうでなければ、 それを「エラー数最小の採点」 で置き換えることにより全体の採点でエラー数がより少ないものが得られ、 全体が「エラー数最小の採点」 で採点されているという最初の仮定に反するからである。

全体のエラー数は

となる。

よって、 txt の文字数と usr の文字数の積がより小さい場合に問題は帰着した。 その積が 0 の場合は最初に考察を済ませてある。

再帰を使ったプログラムは計算量が多すぎて失敗

上のアイディアを、再帰を用いたプログラムにしてみた。 ただし、簡単のため、「転置」はエラーの種類に含めないことにした。

#include <stdio.h>
#include <string.h> /* strlen() */

#define MIN3(X,Y,Z) ((X)<(Y)?(X)<(Z)?(X):(Z):(Y)<(Z)?(Y):(Z))

int compare(char* p, char* q);

unsigned long count = 0;    /* compare() の呼ばれた回数 */
int errors;                 /* エラーの数 */

int main(int argc, char* argv[]) {
    char *p, *q;

    p = argc > 1 ? argv[1] : "";
    q = argc > 2 ? argv[2] : "";
    errors = compare(p, q);
    putchar('\n');
    printf("\"%s\" and \"%s\", ", p, q);
    printf("%lu times, %d error(s).", count, errors);
    return 0;
}

/* 文字列 p と q を比較してエラーの数を返す(再帰バージョン)*/
int compare(char* p, char* q) {
    int n, n1, n2;          /* エラーの数を一時的にいれる変数 */

    if (count++ % 100000 == 0) {
        putchar('.');       /* 止まっていないことをアピールするための出力 */
        fflush(stdout);
    }

    if (*p == '\0') {       /* p が空文字列の場合 */
        return strlen(q);       /* 「エラー数は q の長さ」が答え(挿入)*/
    }
    if (*q == '\0') {       /* q が空文字列の場合 */
        return strlen(p);       /* 「エラー数は p の長さ」が答え(脱落)*/
    }
                            /* p も q も空文字列でない場合 */
                                /* 正常または誤打と仮定してみる */
    n = compare(p+1, q+1);      /* p+1 と q+1 を比較(ここで自分自身を呼ぶ) */
    if (*p != *q) {             /* *p と *q が一致しない場合は */
        n++;                    /* 誤打の分として 1 だけ増やす */
    }
    n1 = compare(p+1, q) + 1;   /* 脱落と仮定してみる(ここで自分自身を呼ぶ)*/
    n2 = compare(p, q+1) + 1;   /* 挿入と仮定してみる(ここで自分自身を呼ぶ)*/

    return MIN3(n, n1, n2);     /* 三つを比較して最小のものを返す */
}

「わかるのがエラー数だけ」という欠陥もあるが、 それ以前に計算量が多すぎる。 txt と usr の文字数を縦横にとって関数の呼ばれた回数を表にすると次のようになる。

012345 678910
0 1111 1111111
1 1471013 161922252831
2 17193761 91127169217271331
3 1103794193 346565862124917382341
4 11361193481 1021193333615473846112541
5 116913461021 252454791077419609 3354454547
6 11912756519335479 134832973760121113275 201367
7 122169862336110774 2973772958162817336214 650857
8 1252171249547319609 60121162817398593897625 1884697
9 1282711738846133544 113275336214897625 21938444976167
10 13133123411254154547 20136765085718846974976167 12146179

(この表はプログラムのデバッグも兼ねて実験により数値を求めたが、 各マスの数は 「左上のマスの数」+「上のマスの数」+「左のマスの数」+1としても計算できる。)

上の表の範囲で、すでに実用的でなくなっている。 実際の練習文は 60 文字ぐらいあるので、これではとても使えない。

付) 再帰を用いる方法は、割と最近まで思いつかなかった。 このプログラムの本質的部分を書いたのは 2001-04-12 (4) である。


すのもの Sunomono 2006-06-07 (3) 00:33:11 +0900