← メディア一覧

クイックソートはなぜ無限ループしないのか

14分49秒 | アルゴリズム基礎FE

基本情報技術者試験の頻出テーマを解説した音声コンテンツです。

トランスクリプト(字幕テキスト)

自分自身を呼び出すって聞くと、こうなんだか合わせ鏡を覗き込んでいるみたいな、ちょっと不思議な感覚になりませんか? 今日のテーマはこの「再帰呼び出し」です。あなたからいただいたソースをもとに、この一見すると無限ループと紙一重に見える概念を、えー、一緒に深く掘り下げていきましょう。 ええ、特に今回はですね、ソースの中心にあった効率的なソートアルゴリズム「クイックソート」を解剖しながら再帰の本質に迫っていきます。 単にどう動くかだけじゃなくて、なぜこの方法が強力なのか、そして、どんな弱点があるのかまで見ていくと、そのエレガントさの裏にある設計思想がこう見えてくるんですよ。 そうですね。自分を呼び出す処理がなぜ暴走せずに、こう、きちんと答えにたどり着けるのか、それからプログラマーはなぜループ処理っていうわかりやすい選択肢があるのに、 わざわざこの再帰という手法を選ぶことがあるのか、そのあたりが今日の核心になりそうです。では、さっそく始めましょうか。えーっと、まずは誰もが最初に抱く疑問からなんですけど、 ソースにもありましたけど、自分自身を呼び出すって、一歩間違えたら、もう永遠に自分を呼び出し続けてコンピューターがフリーズするみたいな、そんなイメージが強いんですが、これって正しいですか? まったくもって正しいです。ええ、そして、それは一歩間違えたらどころか、ま、初心者が再帰関数を書くときには、ほぼ必ず一度は通る道ですね。 私も昔、停止条件を書き忘れた再帰処理を実行して、一瞬でプログラムをクラッシュさせたことがあります。あー、やっぱりあるんですね。ええ。 専門的にはスタックオーバーフローというエラーですが、これは再帰にとって最も古典的で、最も重要な失敗例なんです。スタックオーバーフロー、なるほど。 もう専門用語があるくらいよくあることなんですね。では、その暴走する無限ループと正しく機能する再帰を分ける、その決定的な違いってなんなんでしょう? それはですね、たった一つ、出口が用意されているかどうか。専門的にはベースケースとか、停止条件と呼びます。 ここまで来たら、もう自分を呼び出すのはやめなさいという絶対的な命令がプログラムのどこかに組み込まれているんです。出口、ですか? ええ。ソースにあったマトリョーシカ人形の例えが、あれ、秀逸でしたね。はい、わかりやすかったです。 一番大きな人形を開ける。中から小さな人形が出てきたらそれも開ける。この「開ける」という行為自体は人形のサイズが変わってもまったく同じ。これが再帰です。 そして、一番小さな人形を開けて中が空っぽだったら、もう「開ける」という作業はできませんよね。できませんね。その「中身が空っぽ」というのが、 この処理における絶対的な出口、つまり停止条件なんです。このゴールがあるからこそ、無限に人形を開け続けることにはならないと。なるほど。 マトリョーシカの例えはすごくわかりやすいんですけど、一つ疑問が湧いてきました。人形なら、開けた外側のものが手元に残りますけど、コンピューターの処理の場合、 どうやって一つ前の自分が何をしていたか、どこまで進んでいたか、覚えていられるんですか?あー、そこが再帰を理解する上で次の、そして最も重要なステップです。 コンピューターはそのために、コールスタックと呼ばれる特別な記憶領域を使います。「コール」は呼び出し、「スタック」は積み重ねるものっていう意味ですね。 コールスタック、積み重ねる。なんだか映画のインセプションみたいに、夢の中の夢へ入っていくような、そんなイメージですかね?まさに。 その例えは完璧です。そのイメージでいきましょう。お、よかった。まず問題を解決するために、私たちは第一階層の夢に入ります。つまり最初の関数を呼び出す。 すると、コンピューターはコールスタックに「今、第一階層の処理です」と書いたメモを一枚置くんです。ほうほう。 そして、その処理の途中で、より小さな問題を解くために自分自身を呼び出したとします。これが第二階層の夢に入ること。 すると、コンピューターは先ほどのメモの上に「今、第二階層の処理中です。第一階層の途中から来ました」と書いた新しいメモを積み重ねます。 なるほど、どんどんメモが上に積み重なっていくんですね。その通りです。さらに第三階層、第四階層へと深く潜るたびに、メモは上に、上にと積まれていくわけです。 コンピューターは常に一番上にあるメモ、つまり一番深い階層の夢で何が起きているかだけを見ています。このおかげで、どれだけ深く潜っても、 帰り道、つまり、どの夢からどの夢に戻ればいいかを見失わずに済むんです。面白い。では、その夢の仕組みがソースにあったクイックソートでどう使われているか具体的に見ていきたいです。 いいですね。クイックソートはその名の通り、非常に高速なソートアルゴリズムで、再帰の考え方を巧みに利用しています。ソースの記述通り、ステップは大きく三つです。 まずステップ1は「分割」。データの集まりからどれでもいいので基準となる値「ピボット」を一つ選びます。ピボット。軸みたいな意味ですね。 ええ。そして残りのデータをそのピボットより小さい数のグループと、大きい数のグループの二つに完全に分けます。 ここで重要なのは、このピボットの選び方がクイックソートの性能を、もう天国と地獄ほどに左右するということなんです。へー、どれでもいいと言いながら、選び方がそんなに重要なんですか? そうなんですよ。たとえば、常に一番左のデータをピボットに選ぶという単純なルールにすると、もしデータが最初からほとんどソートされていた場合、分割が極端に偏ってしまう。 小さいグループが0個で、大きいグループが残り全部、みたいな状況が続いてしまうんです。あー、それは効率が悪そうですね。ええ、非常に悪くなる、理論上の最悪ケースですね。 なるほど。なるべく均等に分かれてくれたほうが効率がよさそうだなっていうのはなんとなくわかります。おっしゃる通りです。だから多くの実装では、 ランダムにピボットを選んだり、いくつかの候補から中央値に近いものを選んだりする工夫がされています。この理論上の最悪ケースと現実のデータに対する平均的な速さのギャップこそが、 アルゴリズムの面白さであり、奥深さなんです。分割の段階にもそんな駆け引きがあったとは。では、その次のステップは? ステップ2が「再帰」です。ここが本題ですね。先ほど分けた小さいグループと大きいグループ、そのそれぞれに対して、まったく同じ「クイックソート」という処理をもう一度実行するように命令するんです。 ああ!ここで夢に入るわけですね!それぞれのグループが独立して新しいより浅い夢の階層に入って、またピボットを選んで分割を始める。 まさにその通り。そしてステップ3が「停止」です。この分割作業を繰り返していくと、グループのデータの数はどんどん減っていきますよね。あえ。 そして、やがてグループのデータの数が1個、あるいは0になります。そうなったらもうそれ以上分割はできません。1個のデータはもうすでにソート済みですもんね。それが出口ですもんね。 はい、それがベースケースです。では、ホストさん、ちょっと想像してみてください。はい。今、手元に10個のバラバラの数字のデータがあります。これをクイックソートします。 まずピボットを選んで、仮に4個のグループと5個のグループに分かれたとしましょう。次にこの二つのグループがそれぞれ再帰に入ります。5個のグループはさらに2個のグループと2個のグループに分かれ、 はい。そしてその2個のグループがさらに夢に入って、ついに1個と1個に分かれる。はい、ここでストップ!夢の底に到達ですね。 素晴らしい。その通りです。そのデータが1個になった瞬間にその階層の夢での仕事は完了です。どんなに大きなデータの集まりから始めても、分割を繰り返せば必ず1個か0個に なるという明確なゴールがある。だからこそクイックソートはスタックオーバーフローを起こさずに必ず処理を終えられるんです。なるほど。よくわかりました。 では、夢の底、一番深い階層で処理が終わったら、次は何が起こるんですか?そこで全部終わりっていうわけではないですよね。 もちろんです。そこからが後半戦、統治のフェーズです。夢の底に着いたら、今度は戻ってこなければならない。先ほどのコールスタックのメモを今度は一 枚ずつ剥がしていく作業が始まります。徐々にトーンを上げていく、夢から醒めていくように、一つ上の階層に戻ってくるわけですね。 第三階層で処理を終えたら、その結果を持って第二階層へ。そうです。一番深い階層、つまりデータが1個になった場所では、ソート済みのリスト1個ができましたという結果を、 自分を呼び出した一つ上の階層に返します。これをリターンすると言います。ふむふむ。 元である一つ上の階層は、たとえば、小さいグループからのリターンと、大きいグループからのリターン、そして真ん中にあったピボットを合体させる。 これでその階層におけるより大きなソート済みグループが完成するわけです。そしてその完成したグループをさらに一つ上の階層にリターンするんです。 なるほど、小さなパーツがソートされた状態で戻ってきて、一つ上の階層で合体する。その合体したものがまた上の階層で別のパーツと合体して、それが連鎖 していって最終的に第一階層、つまり現実に帰還したときには、全部のデータが綺麗にソートされた巨大な一つのリストが出来上がっている。最後に全部をガチャンコするイメージですね。 そのガチャンコという表現は非常に的確です。まさにその通り。この分割してそれぞれを解決し、最後に統合するという考え方は、分割統治法と呼ばれていて、 プログラミングにおける非常に強力な問題解決手法の一つなんです。しかしこうして聞くと、コールスタックに情報をどんどん積んでいくっていうのはなんだか危うい気もしますね。 この再帰という手法、ループ処理で書くのに比べて、どんなメリットとかデメリットがあるんでしょうか?あー、非常に良い質問です。そこが再帰をただ知っているだけでなく、 使いこなすための鍵になりますね。まずメリットは、コードが非常に簡潔で読みやすくなる場合が多いことです。問題の構造そのものをコードで表現できる。 たとえば、ファイルとかフォルダーの構造を調べるときを考えてみてください。あるフォルダーの中身をリストアップするには、ファイルを表示して、サブフォルダーがあったらそのサブフォルダーに対してまったく同じ処理をするですよね。 ああー、確かに。これは再帰で書くと驚くほど直感的に書けるんですよ。確かにループで書こうとすると今自分がどの階層にいるのか、管理が複雑になりそうですね。 そうなんです。一方で、デメリットは先ほど少し触れたスタックオーバーフローの危険性。再帰が深くなりすぎると、コールスタックに積めるメモの限界を超えてプログラムがクラッシュしてしまう。 あと、関数の呼び出しには単純なループよりも少しだけコンピューターのコストがかかるので、パフォーマンスが若干劣る場合もありますね。 なるほど、エレガントで読みやすいけれどメモリー使用量と深い階層には注意が必要、という、まあ、トレードオフがあるわけですね。ええ。 ですから、再帰が向いている問題と素直にループで書いたほうが安全で効率的な問題がある。その見極めができるかどうかが腕の見せ所ですね。 いやー、スッキリしました。それでは今回の内容をまとめてみましょうか。おねがいします。 まずキーポイントの1つ目。再帰呼び出しは暴走する無限ループと隣り合わせですけど、「停止条件」という絶対的な出口があるからこそ、安全に機能すると。 そしてその処理の過程は、コンピューターのコールスタックという記憶領域によってきちんと管理されていました。 ええ。このスタックの動きをイメージできるかが再帰理解の第一歩ですね。ソースにも、試験ではスタックの動きも出やすいとありましたが、 まさにこの夢の階層のイメージがその動きを理解する大きな助けになるはずです。2つ目のキーポイントは、クイックソートという具体例を通して見た再帰の強力さと繊細さです。 ただ分割するだけじゃなくて、ピボットの選び方という一つの要素が全体のパフォーマンスを劇的に変えてしまう。理論上の性能と現実世界での平均的な性能が違うこともあるというアルゴリズムの奥深さに触れられました。 ええ。美しいアルゴリズムほどその裏には細やかな工夫が隠されているものです。そして3つ目。再帰は万能薬ではないということ。 コードの可読性や問題構造との適合性っていう大きなメリットがある一方で、メモリー使用量やスタックオーバーフローのリスクというデメリットも抱えている。 ループ処理との使い分け、そのトレードオフを意識することが重要だということですね。その通りです。適切な場所で使ってこそ再帰はその真価を発揮します。 ありがとうございました。さて、ここまで再帰という考え方を深掘りしてきましたが、最後に一つあなたに問いかけてみたいことがあります。 この大きな問題を自分とまったく同じ構造を持つ小さな問題に分割して解決するという考え方、これプログラミングだけの話ではないかもしれません。といいますと? あなたがいま抱えている何か大きな目標とか課題を思い浮かべてみてください。それをただ漠然と小さなタスクに分割するんじゃなくて、その課題全体を解決するためのアプローチそのものが、 その課題の小さな一部分を解決するためにもまったく同じように適用できないかという視点で見てみるんです。もしかしたらあなたの問題の中に、解決策の自己相似形、つまり小さな自分が隠れているかもしれないんです。

このコンテンツは Web society で視聴・学習できます。