何をすべきか、どのようにすべきかを知るために、課題を始める前に必ずこの仕様の全体を読んでください。
以下のように、ハッシュテーブルを使ってファイルのスペルチェックを行うプログラムを実装します。
$ ./speller texts/lalaland.txt
MISSPELLED WORDS
[...]
AHHHHHHHHHHHHHHHHHHHHHHHHHHHT
[...]
Shangri
[...]
fianc
[...]
Sebastian's
[...]
WORDS MISSPELLED:
WORDS IN DICTIONARY:
WORDS IN TEXT:
TIME IN load:
TIME IN check:
TIME IN size:
TIME IN unload:
TIME IN TOTAL:
課題のファイル内容
ダウンロード
CS50 IDEにログインし、ターミナルウィンドウで次の各コマンドを実行します。
cd ~/
(または単に引数なしのcd
) を実行して、ホームディレクトリにいることを確認します。mkdir pset5
を実行して、pset5
というディレクトリを作成 (新規作成) します。cd pset5
を実行して、そのディレクトリに移動 (ディレクトリを開く) します。wget http://cdn.cs50.net/2021/spring/psets/5/speller/speller.zip
を実行して、この問題が発生したディストリビューションを含む (圧縮された) ZIPファイルをダウンロードします。- ファイルを解凍するには、
unzip speller.zip
を実行します。 - ZIPファイルを削除するには、
rm speller.zip
を実行してからyes
またはy
を実行します。 ls
を実行します。speller
というディレクトリがZIPファイルの中にあるはずです。cd speller
を実行して、そのディレクトリに移動します。- lsを実行します。この問題のファイルが表示されます。
dictionaries/ dictionary.c dictionary.h keys/ Makefile speller.c texts/
理解を深める
理論的には、サイズnの入力において、実行時間nのアルゴリズムは、実行時間2nのアルゴリズムに対して、Oに関して 「漸近的に等価」 です。実際、アルゴリズムの実行時間を記述する際には、通常、支配的な (最も影響力のある) 項 (すなわち、nは2よりもはるかに大きくなる可能性があるので、この場合はn) に注目します。しかし現実の世界では、2nはnに比べて2倍遅く思えます。
今回の課題は、最速のスペルチェッカーを実装することです。しかし、 「最速」 というのは、漸近的な時間ではなく、実際の 「壁時計」 でのことです。
speller.c
には、ディスクからメモリに単語の辞書をロードした後、ファイルのスペルチェックを行うプログラムがあります。一方、この辞書はdictionary.c
というファイルに実装されています (speller.c
で実装することもできますが、プログラムが複雑になると、複数のファイルに分割すると便利な場合があります) 。一方、関数のプロトタイプはdictionary.c
自体ではなく、dictionary.h
に定義されています。このようにすると、speller.c
とdictionary.c
の両方に#ファイルを含めることができます。残念ながら、ロード部分やチェック部分を実装するのに十分な時間がありませんでした。どちらも (そして他にももう少し) あなたにお任せします。まずは各ファイルを概観しましょう。
dictionary.h
dictionary.h
を開くと、DICTIONARY_H
を参照している数行を含む新しい構文がいくつか表示されます。これらを気にする必要はありませんが、興味深いことに、これらの行はdictionary.c
とspeller.c
(これについては後で説明します) がこのファイルを#include
していても、clang
は一度だけコンパイルします。
次に、stdbool.h
というファイルを#include
する方法に注目してください。bool
自体が定義されているファイルです。CS50ライブラリには#include
が含まれていたため、以前は必要ありませんでした。
また、#define
という 「プリプロセッサ指令」 を使用して、LENGTH
という値45
を持つ 「定数」 を定義していることにも注意してください。これは、コード内で (誤って) 変更できない定数です。実際、clang
を使用すると、コード内でLENGTHが言及されるたびに、文字通り45
に置き換えられます。つまり、これは変数ではなく、単なる検索置換のトリックです。
最後に、check
、hash
、load
、size
、unload
の5つの関数のプロトタイプに注目してください。これらのうちの3つが*
のように引数としてポインタを取ることに注目してください。
bool check(const char *word);
unsigned int hash(const char *word);
bool load(const char *dictionary);
char *
をstring
と呼んでいたことを思い出してください。これら3つのプロトタイプは基本的には以下と同じです。
bool check(const string word);
unsigned int hash(const string word);
bool load(const string dictionary);
一方const
は、これらの文字列が引数として渡された場合、定数のままでなければならないと言っています。意図的にも誤って変更したとしても変更はできません。
dictionary.c
dictionary.c
を開きます。ファイルの上に、ハッシュテーブル内のノードを表すnode
というstruct
構造体を定義したことに注目してください。また、グローバルポインタ配列table
を宣言しました。これは、辞書内の単語を追跡するために使用するハッシュテーブルを (すぐ後で) 表します。配列にはN
個のノードポインタが含まれており、ここではN
を1
に設定しています。つまり、このハッシュテーブルには現在1つのバケットしかありません。N
を変更するなどして、バケットの数を増やしたい場合があるでしょう。
次に、load
、hash
、check
、size
、unload
が実装されていますが、コードのコンパイルをかろうじ通過しているだけです。最終的には、これらの関数を可能な限り賢く再実装して、このスペルチェッカーが宣伝どおりに機能するようにするのが仕事です。そして高速である必要があります!
speller.c
では、speller.c
を開いて、コードとコメントを見てみましょう。このファイルの内容を変更する必要はなく、ファイル全体を理解する必要もありませんが、その機能について理解してください。getrusage
という関数を使用して、check
、load
、size
、unload
をベンチマーク (実行に要する時間を計測) していることに注目してください。また、スペルチェックの対象となるファイルの内容を単語単位でcheck
を通していることにも注目してください。最終的には、ファイル内のスペルミスと多数の統計情報を報告します。
ちなみに、speller
の使い方は以下のとおりです。
Usage: speller [dictionary] text
ここで、dictionary
は小文字の単語を1行に1つずつ含むファイルで、text
はスペルチェックを行うファイルです。括弧が示唆するように、dictionary
の引数は任意です。この引数を省略すると、speller
はデフォルトでdictionaries/large
を使用します。つまり、以下の実行は、
$ ./speller text
以下の実行と同じことです。
$ ./speller dictionaries/large text
ここで、text
はスペルチェックを行うファイルです。前者の方がタイプしやすいと思っておけば十分でしょう (もちろん、dictionary.c
にload
を実装しない限り、speller
は辞書をロードすることはできません。それまでは「Could not load
」 と表示されます) 。
デフォルトの辞書には143,091語ありますが、そのすべてをメモリに読み込まなければなりません!実際にそのファイルを見て、その構造とサイズを把握してください。ファイル内のすべての単語は小文字で表示されます (簡略化のために固有名詞や略語も)。ファイルは、上から下に向かって辞書順にソートされ、1行に1つの単語だけが含まれます (各単語は\n
で終わります) 。45文字を超える単語はなく、複数回出現する単語もありません。開発中には、メモリ内の巨大な構造をデバッグするのに苦労しないように、ずっと少ない単語数のdictionary
をspeller
に用意しておくと便利です。dictionaries/small
はそのような辞書の1つです。これを使用するには、次のコマンドを実行します。
$ ./speller dictionaries/small text
ここで、text
はスペルチェックを行うファイルです。speller
自体がどのように機能するかを理解するまで、先に進まないでください!
おそらくspeller.c
を見るのに十分な時間をかけていなかったのではないでしょうか。少し戻って、もう一度復習してみてください。
texts/
あなたがspeller
の実装をテストできるように、La La Landのスクリプト、Affordable Care Act (医療費負担適正化法) のテキスト、トルストイの300万バイトの文章、The Federalist PapersとShakespeareからの抜粋、King James V BibleとKoranの全文など、たくさんのテキストを提供しています。pset5
ディレクトリ内のtexts
と呼ばれるディレクトリにあるそれぞれのファイルをのぞいてみてください。
speller.c
を注意深く読んだことでわかるように、speller
の出力はたとえば次のように実行されます。
$ ./speller texts/lalaland.txt
最終的には以下のようになります。
次に、出力の一部を示します。ここでは、 「スペルミス」 の例をいくつか抜粋して説明します。楽しみを台無しにしないように、ここでは統計結果を省きました。
MISSPELLED WORDS
[...]
AHHHHHHHHHHHHHHHHHHHHHHHHHHHT
[...]
Shangri
[...]
fianc
[...]
Sebastian's
[...]
WORDS MISSPELLED:
WORDS IN DICTIONARY:
WORDS IN TEXT:
TIME IN load:
TIME IN check:
TIME IN size:
TIME IN unload:
TIME IN TOTAL:
TIME IN load
は、speller
がload
の実行に費やす秒数を表します。TIME IN check
は、speller
がcheck
を実行するのに要した合計秒数を表します。TIME IN size
は、speller
がsize
の実行に費やす秒数を表します。TIME IN unload
は、speller
がunload
の実行に費やす秒数を表します。TIME IN TOTAL
は、これら4つの測定値の合計です。
これらの時間は、コードを変更しなくても、CS50 IDEが他に何をしているかによって、spellerの実行によって多少異なる場合があります。
なお、はっきりさせておくと、 「スペルミス」 とは単に、提供されているdictionary
に単語が含まれていないことを意味します。
Makefile
最後に、make
はコードのコンパイルを自動化するので、たくさんのスイッチを使って手動でclang
を実行する必要がないことを思い出してください。しかし、プログラムのサイズが大きくなると、make
はコンテキストからコードのコンパイル方法を推測できなくなります。この問題の場合のように、特に複数のソースファイル (.c
ファイル) が関係する場合には、プログラムのコンパイル方法をmake
に指示する必要があります。そこで、make
に正確に何をすべきかを指示する構成ファイルであるMakefile
を利用します。Makefile
を開くと、次の4行が表示されるはずです。
- 最初の行は、
make speller
(あるいは単にmake
) を実行するたびに次の行を実行するようmake
に指示します。 - 2行目は、
speller.c
をマシンコード (speller.o
) にコンパイルする方法をmake
に指示します。 - 3行目は、
dictionary.c
をマシンコード (dictionary.o
) にコンパイルする方法をmake
に指示します。 - 4行目は、
speller.o
とdictionary.o
をspeller
というファイルにリンクする方法をmake
に指示します。
必ずmake speller
(あるいは単にmake
) を実行してspeller
をコンパイルしてください。make dictionary
を実行しても動作しません。
仕様
さて、load
、hash
、size
、check
、unload
を可能な限り効率的に実行するには、ハッシュテーブルを使用して、TIME IN load
、TIME IN check
、TIME IN size
、TIME IN unload
をすべて最小化するように実装する必要があります。dictionary
とtext
にspeller
から異なる値を入力すると、これらのベンチマークは確かに異なるので、最小化することが何を意味するのかは明らかではありません。しかしそこにはこの問題の面白さではないにしても、挑戦のしがいがあります。この問題はそれをあなたが設計するチャンスです。使用するメモリは最小限にすることをお勧めしますが、あなたの究極の敵は時間です。課題に取り掛かる前に、いくつかの仕様があります。
speller.c
、dictionary.h
、Makefile
は変更できません。dictionary.c
は変更できます (実際、load
、hash
、size
、check
、unload
の順番に実装を完了する必要があります) が、load
、hash
、size
、check
、unload
の宣言 (プロトタイプ) は変更できません。ただし、dictionary.c
に新しい関数や (ローカルまたはグローバルな) 変数は追加することもできます。- ハッシュテーブルがより多くのバケットを持つことができるように、
dictionary.c
のN
の値を変更できます。 check
の実装では大文字と小文字を区別しないようにする必要があります。言い換えると、foo
が辞書にある場合、checkは大文字小文字を問わずtrueを返します。foo
、foO
、fOo
、fOO
、Foo
、FoO
、FOo
、FOO
のいずれもスペルミスとは見なされません。- 大文字の使用はさておき、
check
の実装では、実際に辞書にある単語に対してのみtrue
を返すようにしてください。ハードコーディングされた一般的な単語 (例えばthe
) に注意してください。これらの単語を含まないdictionary
は実装に渡すことがないようにします。しかも、所有格はdictionary
に載っているものだけです。言い換えると、たとえfoo
がdictionary
に存在していたとしても、foo's
がdictionary
に存在しない場合には、foo's
が指定されていればcheck
はfalse
を返します。 - プログラムに渡される
dictionary
は、私たちのdictionary
と全く同じように構成され、アルファベット順に1行に1語ずつ、それぞれが\nで終わるように、上から下にソートされていると仮定します。また、dictionary
には少なくとも1つの単語が含まれており、どの単語もLENGTH
(dictionary.hで定義されている定数) 文字より長くならず、どの単語も2回以上現れず、各単語は小文字のアルファベット文字と場合によってはアポストロフィを含み、どの単語もアポストロフィで始まらないと仮定することができます。 check
では、アルファベット (大文字または小文字) と、場合によってはアポストロフィを含む単語が渡されると想定できます。- スペルチェッカーは、
text
とオプションでdictionary
のみを入力として取ることができます。デフォルト辞書の 「理想的なハッシュ関数」 を導出するために、デフォルト辞書を 「前処理」 したくなるかもしれませんが (特にそれに慣れている場合) 、そのような前処理の出力をディスクに保存して、後でスペルチェッカーを実行するときにメモリにロードして利用することはできません。 - スペルチェッカーはメモリリークしてはいけません。必ず
valgrind
で漏れがないか確認してください。 - 自分のコードに組み込んだハッシュ関数の出所を挙げる限り、 (良い) ハッシュ関数をオンラインで検索することができます。
準備はできましたか?
load
を実装します。hash
を実装します。size
を実装します。check
を実装します。unload
を実装します。
ウォークスルー
このプレイリストには6つのビデオがあることに注意してください。
ヒント
2つの文字列を大文字小文字を区別せずに比較するには、strcasecmp
(strings.h
で宣言された) が便利です。また、foo
とFOO
のハッシュ値が同じになるように、ハッシュ関数で大文字と小文字を区別しないようにすることもできます。
最終的には、load
で割り当てたメモリをすべて解放 (free
) してください。valgrind
が一番新しい親友だということを思い出しましょう。valgrind
はプログラムが実際に実行されている間のリークを監視するので、以下に示すように、特定のdictionary
やtext
を使用している間にvalgrind
にspeller
を監視させたい場合は、必ずコマンドライン引数を指定してください。ただ、小さなテキストを使うのがベストです。そうでなければ、valgrindの実行にかなりの時間がかかります。
$ valgrind ./speller texts/cat.txt
spellerにtext
を指定せずにvalgrind
を実行した場合、load
とunload
の実装は実際には呼び出されません (そして解析されません) 。
valgrind
の出力の解釈方法がわからない場合は、help50
にヘルプを求めてください。
$ help50 valgrind ./speller texts/cat.txt
テスト
プログラムが正しくスペルミスの単語を出力しているかどうかをどのように確認しますか? speller
ディレクトリ内のkeys
ディレクトリ内にある 「answer keys」 を参照してください。たとえば、keys/lalaland.txt
には、プログラムがスペルミスと見なすべき単語がすべて含まれています。
したがって、次のように、1つのウィンドウ内で、テキストに対してプログラムを実行できます。
$ ./speller texts/lalaland.txt
そして、次のように別のウィンドウの同じテキストで、スタッフのソリューションをチェックできます。
$ ~cs50/2019/fall/pset5/speller texts/lalaland.txt
ウィンドウを並べて視覚的に比較できます。しかし、それはすぐに時間がかかりすぎると思うかもしれません。そのため、次のようにプログラムの出力をファイルに 「リダイレクト」 することもできます。
$ ./speller texts/lalaland.txt > student.txt
$ ~cs50/2019/fall/pset5/speller texts/lalaland.txt > staff.txt
以下のように、diff
のようなプログラムを使って、同じウィンドウ内で両方のファイルを並べて比較することができます。
$ diff -y student.txt staff.txt
あるいは、時間を節約するために、以下のようにスタッフのソリューションを実行せずに、プログラムの出力 (たとえば、student.txt
にリダイレクトしたとします) をアンサーキーの1つと比較することもできます。
$ diff -y student.txt keys/lalaland.txt
プログラムの出力がスタッフの出力と一致する場合、diff
は2つのカラムを出力します。これらのカラムは、おそらく一番下にある実行時間を除いて、同一でなければなりません。ただし、列が異なる場合は、その場所に>
または|
が表示されます。たとえば、次のように表示されます。
MISSPELLED WORDS MISSPELLED WORDS
TECHNO TECHNO
L L
> Thelonious
Prius Prius
> MIA
L L
つまり、左の列にThelonious
がなく、右の列にThelonious
があることからわかるように、スタッフの出力 (右) がスペルミスであっても、プログラム (出力が左) はThelonious
またはMIA
がスペルミスであるとは考えていないということです。
check50
コードをあまり手動でテストしないようにするには (完全ではありませんが) 、以下を実行することもできます。
$ check50 cs50/problems/2021/x/speller
check50
はメモリリークもチェックするので、必ずvalgrind
も実行してください。
style50
以下を実行し、style50
を使用してコードのスタイルを評価します。
style50 dictionary.c
スタッフの解法
どのようにしてコードの速さを評価する (そして修正する) のでしょうか?いつものように、以下のようにスタッフのソリューションを自由に試して、その数字をあなたのものと比較してください。
$ ~cs50/2019/fall/pset5/speller texts/lalaland.txt
ビッグボード
過去にこの問題に取り組んだことがあれば、以前は学生がスペルチェッカーのベンチマーク時間を他の学生と比較できる 「ビッグボード」 があったことをご存知でしょう。少し検討した結果、残念ながらビッグボードは2021年では引退させることに決めました!
提出方法
次のコマンドを実行し、GitHubのユーザー名とパスワードを入力してログインします。セキュリティのため、パスワードには実際の文字ではなくアスタリスク (*
) が表示されます。
submit50 cs50/problems/2021/x/speller