本記事は『コードが動かないので帰れません! 新人プログラマーのためのエラーが怖くなくなる本』(桜庭洋之/望月幸太郎)の「3-3 二分探索で効率的に探そう」を抜粋したものです。掲載にあたって一部を編集しています。
二分探索で効率的に探そう
小さなコードであればプリントデバッグだけですぐに不具合の原因を特定することができます。しかし、コードが大きかったり、複数のシステムが連携したりするような場合には、原因が潜む場所として考えられる範囲が広く、特定に時間がかかってしまいます。
そんな場合には、闇雲にプリントデバッグをするのではなく、全体を見渡して原因となりそうな箇所を少しずつ絞り込んでいくと、効率的にデバッグができます。そこで、ここでは「二分探索」という考え方をデバッグに応用する手法を解説します。二分探索は、デバッグとは直接関係のないアルゴリズムの1つですが、不具合の原因を効率的に探すために役に立つ考え方です。まずは二分探索について簡単に解説します。
二分探索とは?
二分探索(にぶんたんさく)とはバイナリサーチ(Binary Search)とも呼ばれ、指定の値がソート済みの配列のどこにあるかを、効率的に探索するアルゴリズムの1つです。
まずは二分探索の仕組みについて、簡単な例を使って見ていきましょう。次の図のように数字が書かれた7枚のカードが裏向きに置いてあるとします(図1)。そして重要な条件として、このカードは、書かれた数字が小さい順に並んでいるものとします。
この7枚の中には「30」と数字の書かれたカードが含まれているのですが、そのカードを効率的に見つけ出すには、どのようにカードをめくっていけばよいでしょうか?
ただ単にカードを探し出すのであれば、カードを左端から順番にめくっていくこともできます。しかし、それは効率がよくありません。なぜなら、最も運が悪い場合「30」のカードが右端にあり、すべてのカードをめくらなくてはならないからです。同様に、ランダムにカードをめくる方法も効率がよいとはいえません。
効率よくカードを見つける方法
では、カードを効率よく見つける方法を見てみましょう。「カードが小さい順に並んでいる」という条件をうまく使うことがポイントです。まず、1枚目にめくるのは、すべてのカードの真ん中に位置するカードです。今回の例では左から4番目のカードです(図2)。
真ん中のカードをめくったら「18」が書かれていたとします。すると真ん中のカードを境に、左側のカードはすべて「18より小さいカード」となり、右側は「18より大きいカード」となります。探しているカードは「30」ですから、右側のグループに存在することがわかるわけです。
このように、端からカードをめくるのではなく、ちょうど真ん中のカードを調べることで、候補を一気に半分にまで絞り込むことができるのです。あとはこの処理を繰り返すことで、効率的に正解を見つけることができます(図3)。
このように二分探索は、何かを探す際に探索範囲を半分に区切りながら、対象を探していく考え方です。すべての要素を確認する手間が省けるため、効率的に探している対象を見つけることができます。
プリントデバッグで二分探索
それではこの二分探索の考え方を、デバッグに活用してみます。当然、コードは先ほどの例のように数の書かれたカードではありませんし、小さい順に並んでいるというわけでもありません。
ただし、システムは基本的に「INPUTから始まり、OUTPUTで出力」されます(図4)。つまり実行順序が存在するのです。さまざまなコードが実行されていく順序を、先ほどのカードと同じように見立てることで、二分探索の考え方を応用できます。
プリントデバッグを闇雲に仕込むよりも、怪しいと感じるコード範囲の真ん中あたりに仕込むことで、その範囲を分割することができます。もちろん、明らかに怪しいと感じる箇所があれば、はじめからそこにプリントデバッグを仕込んでもかまいません。
二分探索を用いたデバッグの具体例
具体的な例として「チケット料金の計算を行う関数」を考えてみましょう。チケットの料金は、次のルールによって計算されるとします。
- 18歳未満は子供料金1,500円
- 18歳以上は大人料金2,000円
- ただし、クーポンを使うと500円引き
次のコードは、上記の計算ルールをコードで実装した例です。このコードには不具合が含まれています。
function ticketPrice(age, useCoupon) { let price; if (age < 18) { price = 1500; } else { price = 2000; } if (useCoupon = true) { price = price - 500; } return price; }
では、この関数の動作を確認してみます。年齢は18歳で、クーポンを使用しない場合の料金を計算してみましょう。次のように関数を呼び出した場合、結果の料金はいくらになるでしょうか。
ticketPrice(18, false);
「18歳だと大人料金で、クーポンは使わないから2,000円かな?」
しかし、実際に関数を実行してみると料金は1,500円になってしまいます。これは正しくありません。どこかで計算間違いをしていることになり、関数に問題があると考えられます。
1500
本来、大人料金の2,000円になるべき料金が1,500円になってしまうのは、「年齢が18歳以上かを判定する処理」と「クーポンを利用するかを判定する処理」のどちらにも要因がありそうです。このように思い当たる原因が複数に分散しているときに、二分探索の考え方が効果を発揮します。実際に問題の場所を特定するために、二分探索の考え方を応用して、プリントデバッグをしてみましょう。
この関数を約半分に分割し、デバッグを行うことを考えてみます。関数のロジックは、「年齢の分岐」と「クーポン利用の分岐」の2つに区分できます。二分探索なので、2つの処理の中間にデバッグ用のコードを挿入しましょう。
function ticketPrice(age, useCoupon) { let price; if (age < 18) { price = 1500; } else { price = 2000; } // 追加 console.log(` 途中結果: ${price}`); if (useCoupon = true) { price = price - 500; } return price; }
書き換えたticketPrice()関数を先ほどと同様に実行してみます。
ticketPrice(18, false);
途中結果: 2000
年齢の分岐のあとに追加したプリントデバッグのコードにより、price変数の途中結果が表示されました。「2000」と出力されているため、この時点では正しく年齢の分岐ができていることが確認できます。
二分探索により前半のコードには問題がないことがわかりました。つまり不具合の原因であるコードは後半の「クーポン利用の分岐」にありそうです。改めて、該当のコードをよく見てみると、クーポン利用するかどうかのif 条件式が、本来は「==」と「=」を2 つ並べた等価演算子とするべきところが、「=」が1つの代入演算子になってしまっています。
// useCoupon == true が正しい if (useCoupon = true) { price = price - 500; }
代入になってしまっているため、useCoupon変数がどんな値であろうと真となり、常に500円引きされるようなコードになってしまっていたのです。
このように、コード自体も二分探索で半分に分割していくことで不具合を見つけやすくなります。このコード例は説明のわかりやすさを重視し、行数が少ないサンプルにしたため、二分探索を活用するほどではないかもしれません。しかし、この考え方を頭に入れておくとデバッグを効率的に進めたいときに、役に立つはずです。
エラーが示す箇所に問題がないときは?
エラーは不具合の箇所を行数で具体的に教えてくれます。しかし、いざコードを直そうと該当の行を見ても何が問題なのかわからない……というケースはよくあります。そんなときにも、二分探索の応用をすれば原因を効率的に特定できます。
例えば、次のコードをsyntax_error.htmlとして保存し、ブラウザで開くとコンソールにエラーが発生します。
<script> for (let i = 1; i < 10; i++) { console.log("{i} は"); if (i % 2 === 0) { console.log('2 の倍数'); } if (i % 3 === 0) { console.log('3 の倍数'); if (i % 4 === 0) { console.log('4 の倍数'); } if (i % 5 === 0) { console.log('5 の倍数'); } if (i % 6 === 0) { console.log('6 の倍数'); } if (i % 7 === 0) { console.log('7 の倍数'); } if (i % 8 === 0) { console.log('8 の倍数'); } if (i % 9 === 0) { console.log('9 の倍数'); } } </script>
<script> Uncaught SyntaxError: Unexpected end of input(at syntax_error.html:13:5) </script>
エラーには「syntax_error.html:13:5」とエラーが発生しているファイル名と行数が示されています。今回の例では13行目の5文字目がエラーの箇所であることが読み取れます。
しかし該当する13行目はと閉じタグであり、この行のコード自体には問題はなさそうです。いったいどこがエラーなのでしょうか?
このように、エラーが出て、問題の箇所も具体的に示してくれているのに、実際はその箇所が原因ではないというケースがあります。こんなときにも、二分探索が有効です。
方法は簡単で、先ほどと同じように、コードをおおよそ半分の位置で分割し、片方をコメントアウトします。まずは前半部分をコメントアウトしてみましょう。
<script> for (let i = 1; i < 10; i++) { console.log("{i} は"); // if (i % 2 === 0) { console.log('2 の倍数'); } // if (i % 3 === 0) { console.log('3 の倍数'); // if (i % 4 === 0) { console.log('4 の倍数'); } // if (i % 5 === 0) { console.log('5 の倍数'); } if (i % 6 === 0) { console.log('6 の倍数'); } if (i % 7 === 0) { console.log('7 の倍数'); } if (i % 8 === 0) { console.log('8 の倍数'); } if (i % 9 === 0) { console.log('9 の倍数'); } } </script>
この状態でコードを実行すると、エラーメッセージは表示されず正常に実行できました。つまり、コメントアウトをした前半部分に問題が潜んでいる可能性があります。今度は逆に、後半部分をコメントアウトし、前半部分のコメントアウトを解除します。
<script> for (let i = 1; i < 10; i++) { console.log("{i} は"); if (i % 2 === 0) { console.log('2 の倍数'); } if (i % 3 === 0) { console.log('3 の倍数'); if (i % 4 === 0) { console.log('4 の倍数'); } if (i % 5 === 0) { console.log('5 の倍数'); } // if (i % 6 === 0) { console.log('6 の倍数'); } // if (i % 7 === 0) { console.log('7 の倍数'); } // if (i % 8 === 0) { console.log('8 の倍数'); } // if (i % 9 === 0) { console.log('9 の倍数'); } } </script>
コードを実行するとやはりエラーメッセージが表示されます。さらにコードを分割してコメントアウトします。
<script> for (let i = 1; i < 10; i++) { console.log("{i} は"); // if (i % 2 === 0) { console.log('2 の倍数'); } // if (i % 3 === 0) { console.log('3 の倍数'); if (i % 4 === 0) { console.log('4 の倍数'); } if (i % 5 === 0) { console.log('5 の倍数'); } // if (i % 6 === 0) { console.log('6 の倍数'); } // if (i % 7 === 0) { console.log('7 の倍数'); } // if (i % 8 === 0) { console.log('8 の倍数'); } // if (i % 9 === 0) { console.log('9 の倍数'); } } </script>
正常に実行することができました。これで原因の箇所がだいぶ絞れました。直前にコメントアウトした2行に問題がありそうです。コードをよく見ると3の倍数を判定する5行目にif文の閉じる「}」がありません。つまり真の原因は、13行目ではなく5行目にあったということになります。
今回の例はすぐに視認できるような構文エラーでしたが、複雑なコードになると見た目では判断できないことがあります。そんなときには、二分探索でコードをコメントアウトしながら真の原因を見つける作業をすると効率的です。
なぜ違う箇所を示す?
ところで、先ほどのエラーではなぜ真の原因と異なる箇所の行数を示していたのでしょうか。例として、よりシンプルなコードで考えてみましょう。
for () { if () { }
このコードを見て私たちは、if文を閉じ忘れていると解釈するでしょう。しかし、コンピュータは、これをif文の閉じ忘れではなくfor文の閉じ忘れと解釈します。コンピュータの視点でコードを整形すると次のようになります。
for () { if () { }
つまり、コンピュータからすると、このコードのエラーは2行目ではなく3行目で起こっていることになります。このような理由により、if文やfor文などの範囲を示す構文ミスで起こるエラーでは、本来の原因とは異なる箇所を示してしまうのです。
もっと大きな単位で二分探索する
システムは、単一のコードだけでなく、たくさんの要素が組み合わさって動いています。例えば、一般的なWebアプリケーションでは、ブラウザで動くフロントエンド(JavaScriptやHTML/CSS)と、サーバーで動くバックエンド(PHPやRuby、データベース、インフラ)が連携して動いています(図7)。
このような広大なシステム要素の中から、不具合の原因を特定するのはとても困難です。
そこで先ほどの二分探索を応用してみましょう。システム全体をどのように分割していくのか、疑問に思うかもしれません。システムを二等分する位置を決めるのは難しいため、フロントエンドとバックエンド、サーバーとデータベースなど物理的な境界が明らかな単位で分割していくのがおすすめです。
今回の例では、まずフロントエンドとバックエンドの間で分割をしてみます(図8)。
まず、フロントエンド側から送信されるデータが正しいのかを確認します。送信されているデータが想定通りで正しかった場合は、フロントエンドには問題がないと判断できます(図9)。
この時点で、探索すべき範囲をバックエンド側に絞ることができました。注目すべきはサーバーサイドやデータベース、あるいはインフラや外部システムです。これを繰り返し、大きな単位で問題のない箇所を切り分けしていきます(図10)。
このように、厳密に半分の位置で分割できるわけではありませんが、まずはフロントやサーバーサイド、データベースなど、分割しやすい大きな単位で問題の切り分けをしていくだけでもデバッグが楽になります。