タイプミスを修正するためのさまざまなアプローチについてはすでに十分に書かれているので、この記事では既に知られていることを繰り返しませんが、スペルチェッカーをゼロから書く方法を示します-シンプルですが、非常に有能です。 必要なのは、正しい単語のリストと少しのC ++だけです。
ここで単語のリストを取りましたが、他の辞書でもかまいません。 プレフィックスツリー(別名Trie)に保存します。 これはややエキゾチックで 、完全に最適ではなく、むしろ危険ですが、おそらく最も簡潔な実装です:
struct trie_t: map<char, trie_t> { }
trie_t
構造は、すべての
map
メソッドを継承し
map
。 単語の文字はこの
map
のキーに書き込まれ、値は再び...を継承する
trie_t
構造です。 やや瞑想的ですが、プロトタイプの場合はそうなります。
ノードの重みを構造に追加します。後で重宝します:
struct trie_t: map< char, trie_t > { size_t w; trie_t() : w(0) {} }
この関数を使用して、単語をツリーに書き込みます。
trie_t & add( trie_t & t, const string & s ) { t.w++; return !s.empty() ? add( t[ s[0] ], s.substr(1) ) : t['$']; }
単語の最初の文字を取得してマップキーに書き込み、対応するサブツリーへのリンクを取得します(必要に応じて、すぐに作成されます)。 このサブツリーに残りの行を再帰的に書き込みます。 行が使い果たされたら、最後のノードに「停止標識」、つまり空のサブツリーを持つキー「
$
」を追加します。
指定されたファイルから単語を読み取った後、それらをすべて辞書に入力します。
trie_t glossary; ifstream fi( argv[1] ); string word; while( getline( fi, word ) ) add( glossary, word );
その結果、ツリーの各ノードの
w
フィールドには、このノードを通過する単語の数が記録されます。
これらすべてで何ができるでしょうか? 継承されたイテレータで各ノードのキーとサブツリーをソートすることにより、ツリーの内容を印刷できます。
void print( const trie_t & t, const string & prefix = "" ) { for( map<char, trie_t>::const_iterator it = t.begin(); it != t.end(); ++it ) if ( it->first == '$' ) cout << prefix << endl; else print( it->second, prefix + it->first ); }
ツリー内の特定の単語の有無を判断できます。
bool is_known( trie_t & t, const string & s ) { return !s.empty() ? is_known( t[ s[0] ], s.substr(1) ) : t.find('$') != t.end(); }
この関数では、正しく機能しますが、簡潔にするためにエラーが発生しています。 このような「明確な」検索は、とにかく役に立たなくなるため、修正しません。
ファジー検索
木を一種の複数の分岐した道路と考える場合、単語検索機能はこの道路を歩いている旅行者です。 旅行者にはルート(検索ワード)が与えられ、それに応じて、ノードからノードに移動し、渡されたルートの文字を消します。 文字が終わった場合、「一時停止標識」が表示されているかどうかを確認するために残ります。
旅行者は、いわば、一度にすべての方向に同時に送られると想像してください。 各フォークの各クローンは、誰かが最終的なストップ(つまり、ツリーの葉)に到達するまで、まったく同じように動作します。 旅行者の総数は、ツリーに書かれた単語の数と等しくなり、それぞれが独自の個別の軌跡をたどります。
それらのすべてが最初に同じ単語ルートを与えられたと仮定します。 各トランジションで、各旅行者は、以前と同様に、ルートのコピーで1文字を消します。 しかし、移行の実際の方向が取り消し線の文字と一致しない場合、旅行者は罰金を受け取ります。 その結果、旅の終わりに、誰もがいくらかの負債を抱えることになります。 この値を増やして並べ替え、すべてを一列に並べます。
検索語が辞書に存在する場合、1人の旅行者が罰なしに最後まで進みます-彼はソートされたリストの最初になります。 単語が辞書にない場合、リーダーは最小数の違反で道を進んだ人になります。
実際、これはほぼアルゴリズム全体です。 秘Theは、リーダーが移動したパスが特定の単語(またはタイプミスがない場合は単語自体)の最も近い修正であり、違反(指定されたルートからの逸脱)の数がタイプミスの数になることです。
始めるには
もちろん、道路をツリー、分岐点をノード、旅行者を仮説、ルートからの逸脱を編集操作と呼ぶ方が正しいでしょう。 しかし、彼らは何とか生きているので、そのままにしておきましょう。 旅行者に関するすべての情報を保存する構造を導入します。
struct rambler_t { string todo; string done; size_t cost; const trie_t * road; rambler_t( const string & t, const string & d, const size_t c, const trie_t * r ) : todo( t ), done( d ), cost( c ), road( r ) {} };
以下がフィールドに保存されます。
-
todo
は行くべき道です。 最初は、これは同じ旅行ルート、つまり修正された単語です。 最後に空の行があります。 -
done
パスの渡された部分。 最初-空行、最後-渡されたノードのリスト、つまり修正された単語。 - cost-課される罰金の額。 開始時-ゼロ、終了時-方法
- road-旅行者の場所。 始まり-木の根、終わり-葉の一つ。
したがって、旅行者は出発点にあります。
rambler_t R( word, "", 0, &glossary );
最初のステップ
可能な方向はすべて、イテレータを使用して整理できます。これは、辞書を印刷するときにすでに役立ちます。
for( map<char, trie_t>::const_iterator it = R.road->begin(); it != R.road->end(); ++it ) { char dest = it->first; // const trie_t * road = it->second; // // }
ステップを完了すると、構造フィールドの値は次のように変わります。
R.done = R.done + dest; R.todo = R.todo.substr(1); R.road = R.road[ dest ];
運賃支払いでは、2つのオプションが可能です。 選択した方向が「一般線」と一致する場合、遷移は自由です。それ以外の場合は+1:
R.cost = R.cost + ( dest == todo[0] ? 0 : 1 );
さて、最初の一歩が踏み出されました。 一度に多くの最初のステップがあると言う方が正しいです-孤独な放浪者の代わりに、チーム全体が現れました:
typedef vector<rambler_t> team_t;
目的のために全員がポイントに達するまで、それまでチームを増やし、段階的に繰り返すだけです。 微妙な違いがあります。
単語の文字を別の文字に置き換えることに対応する上記の「間違った順番」は、タイプミスの種類の1つにすぎません。 削除と挿入の少なくとも2つがあります。
削除する
この状況は「支払われましたが、行きませんでした」:
todo
から最初の文字を削除し、ペナルティを科しますが、移行を行わず、何も追加しません。
R.done = R.done; R.todo = R.todo.substr(1); R.road = R.road; R.cost = R.cost + 1;
その結果、
todo
からの手紙は途中で失われます。これは削除するのと同じです。
挿入
ここでは、逆に「所定の場所で実行」します。ツリーの奥深くに移動し、
done
に文字を追加しますが、
todo
からは何も削除しません。
R.done = R.done + dest; R.todo = R.todo; R.road = R.road[ dest ]; R.cost = R.cost + 1;
完了したレターは、計画の外に、どこからともなく現れます。これは、それを挿入することに相当します。
父と子
各ステップでは
team
ベクトルには、移動を続ける人と
finished
人が含まれます。つまり、
todo
空で、現在のツリーノードに単語の終わりを示す
void step_forward( const rambler_t & R, team_t & team, team_t & finished ) { char next = R.todo[0]; // const string & todo = next ? R.todo.substr(1) : ""; for( map<char, trie_t>::const_iterator it = R.road->begin(); it != R.road->end(); ++it ) { const trie_t * road = &it->second; // char dest = it->first; // if ( next == dest ) team.push_back( rambler_t( todo, // R.done + dest, R.cost, road )); else { team.push_back( rambler_t( todo, // R.done + dest, R.cost + 1, road )); team.push_back( rambler_t( R.todo, // R.done + dest, R.cost + 1, road )); if ( !next && dest == '$' ) finished.push_back( R ); // ! } } if ( next ) team.push_back( rambler_t( todo, // R.done, R.cost + 1, R.road )); }
自然選択
そして、2番目のニュアンス:雪崩のようなプロセスの新規および新規参加者の各ステップでの生成は、非合理的に行動します。 木の上でぼんやりと群がっている群れは、うまく制御されていません。ほとんどの部分は役に立たず、リソースをむさぼり食います。 私たちは制限を導入し、彼らのために競争を手配します。
最初に、罰金の最大許容量を割り当て、それを超えたすべての人を捨て、所定のパスから離れすぎます。
第二に、遅れているすべての人々を遠くから落とします。 確かに、追放された部外者の間で、誰かがレースの最後にリーダーを追い越すかもしれないというリスクがありますが、最適化のために彼らはそれに耐えなければなりません。
遅れている人を識別するために、すべての人が成功する可能性、つまり、上限を超えても失格なく残りの道がカバーされる可能性を推定します。 チャンスは次のように推定されます。
double ramb_chances( const rambler_t & a ) { return log( ( a.road->w + 1 ) * ( a.done.size() + 1 ) ) / ( 1 << a.cost ); }
対数があるのはなぜですか? どんなビットシフト? なぜこのような機能があるのですか? 説明しようとすることができます。 明らかに、すでに受け取った罰金の量が多ければ多いほど、終わりに達する可能性は低くなります。 また、サブツリー内の単語が多いほど、それらの中から少なくとも1つの適切な単語を見つける可能性が高くなります。 そして、すでに移動した経路の長さは、いわば「持久力」を証明しています。
私は認めざるを得ません、後者は非常に残酷なものです。 しかし、どのヒューリスティックでも、主なことは働くことです。 これは動作します。 そして、いずれにせよ、私たちは完成に近づいています。 罰金の最大額を
max_cost
、最大チームサイズを
team_size
として
team_size
ます。
パイオニアをすべての道路の先頭に置きます:
team_t walkers, leads; walkers.push_back( rambler_t( word, "", 0, &glossary ) );
次の「世代」を生み出す一歩を踏み出します。
team_t next_g; for( size_t i = 0; i < walkers.size(); ++i ) step_forward( walkers[i], next_g, leads );
パスを完了した
next_g
は
leads
記録され、まだ到達していない人は
next_g
に分類され
next_g
。 私たちは彼らに到達する可能性がありますが、下降することはありません:
sort( next_g.begin(), next_g.end(), ramb_chance_cmp );
そして、まだ先に進む人がいるまでこれを繰り返します。 ペナルティが
max_cost
超えた
max_cost
、および最高の
team_size
入力しなかった人は無視し
team_size
。
while ( !walkers.empty() ) { team_t next_g; for( size_t i = 0; i < min( walkers.size(), team_size ); ++i ) if ( walkers[i].cost < max_cost ) step_forward( walkers[i], next_g, leads ); walkers.swap( next_g ); sort( walkers.begin(), walkers.end(), ramb_chance_cmp ); }
ちなみに、
leads
は同じ
done
線を持つ複数の構造を含めることができます。 彼らがどこから来たと思いますか?
一意の要素のみを残します。
sort( leads.begin(), leads.end(), ramb_done_cmp ); // done leads.resize( distance( leads.begin(), unique( leads.begin(), leads.end(), ramb_uniq ) ) ); // sort( leads.begin(), leads.end(), ramb_chance_cmp ); //
ほとんどすべてです。
上記のすべてを
spellcheck
関数に追加し、それに単語、最大ペナルティ、候補者の数を渡します。 次を使用できます。
while( getline( cin, word ) ) { team_t fixed = spellcheck( word, word.size()/2, 512 ); cout << word << endl; for( size_t i = 0; i < fixed.size(); ++i ) cout << '\t' << fixed[i].done << ' ' << fixed[i].cost << endl; cout << endl; }
512個の候補の制限と、指定された単語の長さの半分の最大偏差は、手動で選択しました。 このようなパラメーターを使用すると、アルゴリズムのパフォーマンスは1秒あたり約10ワードに
、
、
、
、
という言葉に対応しますが、
を
変えることはありません。
これですべてです。 始めましょう。 次に、タイプミスを修正するためのシンプルだが効果的なメカニズムの動作の例を示します。
2 3 3 3 2 2 2
彼のすべての結果が完璧というわけではありませんが、これは修正可能です。
旅の結果
多くの人が、インフォームドサーチの種類の1つであるA *アルゴリズム 、またはそのバリエーション-反復的な深化とメモリ制限を備えたA *アルゴリズムの説明で学んだと確信しています。 もちろん、説明されているアルゴリズムは、実際に戦闘で使用されるものに対する最初の大まかな近似にすぎません。
本物の戦闘スペルチェッカーは他に何ができるはずですか?
- 参照辞書には単語の初期形式のみが存在するため、彼はガンマベアをガンマベアに修正し
、これは正しくありません。 形態を理解するか、すべての形式の単語を辞書に含める必要があります。 最初の方が優れていますが、より困難です。
- すべての単語は等しいです。 これにより、最適な仮説の選択が不正確になります。
1 1
- すべてのエラーは同等です。 ここから、このような不確実な結果が生まれます。
1 1
を
置換する可能性は
を
に置換する可能性が高いため、
する可能性
高くなります。
- 「プントスイッチング。」 ここではすべてが簡単です。
q-
、w-
、a-
の形式の対応表と、別のタイプのタイプミス-レイアウトの変更を追加します。 できた
- 言葉の接着と接着。 これはもう少し複雑です。 ツリーのリーフノードからそのルートにジャンプし、「ルート」のギャップを個別に処理することを「旅行者」に教える必要があります。
- コンテキスト修正。 さまざまな状況で同じ単語をさまざまな方法で修正する必要があります。 たとえば
て
、
、
、
、
を
。 このようなタイプミスを修正するには、統計および/または文法規則が必要です。 言い換えれば、言語モデル。 これはまったく異なる話です。
副作用
最終的には、説明したアプローチの可能性について言及するのは間違いであり、タスクの範囲を超えてしまいます。
- 特定の「ルート」の最後に文字を追加した場合のペナルティをキャンセルすると、 オートコンプリートの問題の解決策が得られます。
- 文字の音声対応のプログラムテーブルに挿入すると、あいまいさの程度の音訳認識システムが得られます。 アルゴリズムは、最も可能性の高い単語を見つけます。
→ j, zh, g, dj j → , , , ...
- 数字と文字の対応で指定されたルールに従ってツリーを遷移すると、スペルチェッカーがT9セットシステムに変わります。
2 → ... 9 →
- 入力ミスの確率ではなく、キー間の距離(またはタッチスクリーン上の画像)でのみ計算されるエラーのコストは、単語入力のシステム「 Swype 」の基礎として機能します。
- ファジー検索のタスクは、音声認識システム、手書きテキストの一部です...しかし、十分です。 これについてはすでに多くのことが書かれています。
プログラムの全文はここにあります 。 このプログラムは、有名なナノスペルチェッカーのピーター・ノーヴィグよりもわずかに長いですが、性能においては劣らず、その能力で大幅に上回っています。
ご清聴ありがとうございました!
役に立たないリンク
- インスピレーションの源: クエリ完了のためのオンラインスペルチェック
- Peter Norwigの世界最短のスペルチェッカー
- 「クラスメート」という単語を書く1200の方法 -エラーモデルの計算に使用できます
- 代替アプローチ: one 、 two 、 three
ミハイル・ドリニン、
検索クエリチームリーダー