応用情報技術者 2014年 秋期 午後 問03
マージソートに関する次の記述を読んで、設問1~3に答えよ。
マージソートは、整列(ソート)したいデータ(要素)列を、細かく分割した後に、併合(マージ)を繰り返して全体を整列する方法である。
ここでは、それぞれの要素数が1になるまでデータ列の分割を繰り返し、分割されたデータ列を昇順に並ぶように併合していくアルゴリズムを考える。例として、要素数が8の場合のアルゴリズムの流れを図1に示す。

再帰呼出しを使って記述したマージソートのアルゴリズムを図2に示す。
図2のアルゴリズムを連結リストに対して実行するプログラムを考える。ここでは、整列対象のデータとして正の整数を考える。連結リストは、複数のセルによって構成される。セルは、正の整数値を示すメンバvalueと、次のセルへのポインタを示すメンバnextによって構成される。連結リストの最後のセルのnextの値は、NULLである。連結リストのデータ構造を図3に示す。
〔連結リストの分割〕
図2中の(2)の処理を行う関数divideを考える。関数divideは、連結リストの先頭へのポインタ変数listを引数とし、分割後の後半の連結リストの先頭へのポインタを戻り値とする。連結リストの分割前後のイメージを図4に示す。

連結リストをセルの個数がほぼ同じになるように分割するために、ポインタ変数を二つ用意し、一方が一つ進むごとに、他方を二つずつ進める。後者のポインタが連結リストの終わりに達するまでこの処理を繰り返すと、前者のポインタは連結リストのほぼ中央のセルを指す。この方法を利用した関数divideのプログラムを図5に示す。
以下、連結リストのセルを指すポインタ変数をaとするとき、aが指すセルのメンバvalueをa->valueと表記する。
〔連結リストの併合〕
図2中の(4)の処理を行う関数mergeを考える。関数mergeは、二つの連結リストの先頭へのポインタ変数aとbを引数とし、併合後の連結リストの先頭へのポインタを戻り値とする。併合処理を行う際には、ダミーのセルを用意し(そのセルへのポインタをheadとする)、この後ろに併合後の連結リストを構成する。aとbが指すセルの値を比較しながら、値が小さい順に並ぶよう処理を進める。連結リストの併合の流れを図6(処理は、①、②、③、…と続く)に、関数mergeのプログラムを図7に示す。

設問1:〔連結リストの分割〕について、(1)〜(3)に答えよ。
問題文を見る模範解答
ア:bがNULLと等しくない
イ:b ← b->next
ウ:a->next
解説
解答の論理構成
-
ループ条件 ア
- 【問題文】には「後者のポインタが連結リストの終わりに達するまでこの処理を繰り返す」とあります。
- divideでは“後者”がbなので、“終わり”とはbがNULLになる瞬間です。
- よってwhile文はbがまだNULLでない間だけ繰り返します。
- すなわち ア = bがNULLと等しくない となります。
-
ループ内の2ステップ移動 イ
- a は毎回1つ進めるために a ← a->next が置かれています。
- b を2つ進めるためには、①ループ冒頭で b ← b->next、②その直後の if 文でさらに1回進める処理が必要です。
- if文のガードは既に「bがNULLと等しくない」ですから、内部で行う追加1ステップが イ です。
- よって イ = b ← b->next になります。
-
リストの切断位置 ウ
- ループ終了時点でaは分割点(前半の最後のセル)を指しています。
- p ← a->next で後半先頭を保存したあと、分割のために a->next を NULL に書き換えます。
- したがって ウ = a->nextです。
以上より
ア:bがNULLと等しくない
イ:b ← b->next
ウ:a->next
ア:bがNULLと等しくない
イ:b ← b->next
ウ:a->next
誤りやすいポイント
- bとb->nextを混同する
while ( b->next が NULL ではない ) とすると奇数要素リストで分割位置が一つ後ろにずれます。 - ループ内でbを2回進め忘れる
イ を入れ忘れるとaとbの距離が1セルに縮まり、ほぼ中央になりません。 - 切断前にpを退避しない
先に a->next ← NULL を実行すると後半先頭が失われ、戻り値が不正になります。
FAQ
Q: ループ条件にb != NULLとb->next != NULLのどちらを使うか迷います。
A: divideの目的は「bが終端に到達した瞬間にループを抜ける」ことです。bが一足先に終端へ到達するため、条件は「bがNULLと等しくない」になります。
A: divideの目的は「bが終端に到達した瞬間にループを抜ける」ことです。bが一足先に終端へ到達するため、条件は「bがNULLと等しくない」になります。
Q: 奇数個の要素の場合、前半と後半の要素数はどうなりますか。
A: aは中央のセルを指した状態で分割するので、前半の方が1要素多い結果になります。
A: aは中央のセルを指した状態で分割するので、前半の方が1要素多い結果になります。
Q: a->next ← NULL を行わずに戻り値だけ返してはいけませんか。
A: 併合時に前半と後半を独立に再帰呼び出しするため、リストを物理的に切断しておかないと循環参照のような状態になり、無限ループや重複処理を招きます。
A: 併合時に前半と後半を独立に再帰呼び出しするため、リストを物理的に切断しておかないと循環参照のような状態になり、無限ループや重複処理を招きます。
関連キーワード: マージソート、連結リスト、ポインタ、再帰、分割と併合
設問1:〔連結リストの分割〕について、(1)〜(3)に答えよ。
問題文を見る模範解答
8
解説
解答の論理構成
-
初期配置を確認
- プログラム冒頭でa ← list、b ← a->nextなので、図3の先頭セル"6"を指すaと、その直後のセル"4"を指すbが用意されます。
- 直後のif ( b が NULL と等しくない ) b ← b->nextにより、bはさらに一つ進み、セル"3"を指します。
-
ループ条件を把握
-
while行には「連結リストの終わりまで繰り返す」とあるため、アは「bがNULLと等しくない」すなわち bが末尾を過ぎていない ことを意味します。
-
ループ本体ではa ← a->next b ← b->next if ( b が NULL と等しくない ) b ← b->nextと記述されており、aは1セル、bは合計2セルずつ前進します。
-
-
実際の前進を追跡
連結リストの値列は図3より
"6" → "4" → "3" → "8" → "7" → "2" → "1" → "5" です。 -
α到達時の状態
- whileループを抜けた直後(α位置)でaはセル"8"を指しています。
- この時点でa->next(セル"7")を切り離すことで、前半リストが"6" "4" "3" "8"、後半リストが"7" "2" "1" "5"に分割されるしくみです。
したがって、設問の答えは 8 となります。
誤りやすいポイント
- bの二重前進を見落として、aを1セル遅く数えてしまう。
- while条件を「b->nextがNULL」と誤解し、ループを1回余分または不足させる。
- 図3の値列を途中で読み違え、"7"と"8"の位置を取り違える。
FAQ
Q: なぜbを2セルずつ動かすのですか?
A: 「後者のポインタが連結リストの終わりに達するまでこの処理を繰り返す」とあるとおり、bを速く進めることで、aがほぼ中央に到達した時点でbは末尾付近になり、均等分割ができます。
A: 「後者のポインタが連結リストの終わりに達するまでこの処理を繰り返す」とあるとおり、bを速く進めることで、aがほぼ中央に到達した時点でbは末尾付近になり、均等分割ができます。
Q: 要素数が奇数でも同じ方法で中央を取れますか?
A: はい。偶奇にかかわらず、bが2セル進むたびにaが1セル進むため、aは常に前半と後半の境界付近を指します。奇数の場合は前半が1要素多い状態で分割されます。
A: はい。偶奇にかかわらず、bが2セル進むたびにaが1セル進むため、aは常に前半と後半の境界付近を指します。奇数の場合は前半が1要素多い状態で分割されます。
Q: α以降でa->next ← NULLとするのはなぜでしょう?
A: p ← a->nextで後半リストの先頭を保存した後、a->next ← NULLで前半と後半を物理的に切り離し、2本の独立した連結リストを生成するためです。
A: p ← a->nextで後半リストの先頭を保存した後、a->next ← NULLで前半と後半を物理的に切り離し、2本の独立した連結リストを生成するためです。
関連キーワード: マージソート、連結リスト、ポインタ操作、二分分割、走査アルゴリズム
設問1:〔連結リストの分割〕について、(1)〜(3)に答えよ。
問題文を見る(3)奇数2N+1個のセルから成る連結リストを関数divideで分割すると、前半と後半の連結リストのセルの個数はそれぞれ幾つになるか式で答えよ。
模範解答
前半:N+1
後半:N
解説
解答の導き方
まず要素数を とおきます。図5の冒頭にある次の代入から初期の指し示し位置が読み取れます:
「a ← list」および「b ← a->next」。その直後に「if ( bがNULLと等しくない )」「b ← b->next」とあるので、要素数が少なくとも3個ある場合は a が先頭(1番目のセル)を指し、b は3番目のセルを指していることがわかります。
「a ← list」および「b ← a->next」。その直後に「if ( bがNULLと等しくない )」「b ← b->next」とあるので、要素数が少なくとも3個ある場合は a が先頭(1番目のセル)を指し、b は3番目のセルを指していることがわかります。
while 部分を見ると、各ループで「a ← a->next」が1回実行され、さらに「b ← b->next」が1回(条件付きでさらにもう1回)実行されます。つまり 1 ループあたり a は必ず1セル進み、b は最大で2セル進む動作です。
ここでwhileが 回回るとすると、aは 回進むのでループ後のaの位置は先頭から 番目になります。したがって前半のセル数は です。したがって を求めれば答えが決まります。
次に b の動きを回数で数えます。図5 の流れでは b は最初に「b ← a->next」と設定され、その直後の if によって 1 回だけ進みます(これにより b は初期に 3 番目にある)。以降、while の内部で実行される「b ← b->next」は、各ループで必ず1回、さらに条件付きで1回実行されます。a->next(2番目のセル)から NULL に到達するまでに必要な next の遷移回数はちょうど 回です(2番目のセルから末尾の NULL まで辿る回数)。
プログラム上の「b ← b->next」の総実行回数は次の和になります。まず if の 1 回、次に while 内の各ループでの最初の 1 回が 回、さらに条件付きの 1 回が r 回(r は各ループで条件が成立する回数で、r は または のどちらか)です。したがって総回数は です。これが先に示した に等しいはずです。
ここでrが だと仮定すると総回数は となり奇数になりますが、総回数は (偶数)でなければなりません。したがって矛盾し、rは でなければなりません。よって
1 + k + (k-1) = 2k = 2N
が成立し、 です。
結局aは 回進み、前半のセル数は 、すなわち です。全体が 個なので後半のセル数は です。よって解は次の通りです。
前半:
後半:
後半:
(確認例)(全体7個)ならループは 回でaは4番目に止まり、前半4個・後半3個となり , に一致します。
誤りやすいポイント
- b の初期位置を誤解して「b は2番目を指す」としてしまう。図5 の「if ( bがNULLと等しくない )」「b ← b->next」を見落とすと誤りやすいです。
- ループ回数の数え方でoff-by-oneを起こす。特に条件付きのbの二回目の移動が何回実行されるか( 回か 回か)を間違えると答えを間違えます。
- 小さいケース( や )を確認せずに一般式を決めてしまい、境界での動作を誤る。(1個のとき)は前半1、後半0になります。
- 「前半と後半は常に同数になる」と思い込み、奇数個のときの中央の扱いを忘れる。
FAQ
Q: 偶数個 のときは前半・後半はそれぞれ何個になりますか?
A: 前半 , 後半 になります。理由は上と同様にbの移動回数を数えると、偶数個のときは条件付きの移動回数が各ループで成立する(r = )ため、ループ回数 は となりaは 番目で止まり、前後が等しくなります。
A: 前半 , 後半 になります。理由は上と同様にbの移動回数を数えると、偶数個のときは条件付きの移動回数が各ループで成立する(r = )ため、ループ回数 は となりaは 番目で止まり、前後が等しくなります。
Q: なぜ最初にbを二つ進めているのですか?
A: 図5 の「b ← a->next」と続く「if ( bがNULLと等しくない )」「b ← b->next」により b を先に進めることで、while ループ中に a が1回、b が最大2回進む関係が作られ、結果として奇数個のときは前半が1個多く、偶数個のときは等分になるよう中央で分割できます。
A: 図5 の「b ← a->next」と続く「if ( bがNULLと等しくない )」「b ← b->next」により b を先に進めることで、while ループ中に a が1回、b が最大2回進む関係が作られ、結果として奇数個のときは前半が1個多く、偶数個のときは等分になるよう中央で分割できます。
Q: 関数divideが返す値は何を指しますか?
A: 図5 の「p ← a->next」により戻り値は後半連結リストの先頭を指します(この直後で a->next を NULL にして分割します)。
A: 図5 の「p ← a->next」により戻り値は後半連結リストの先頭を指します(この直後で a->next を NULL にして分割します)。
関連キーワード: 連結リスト、マージソート、分割統治、スロー・ファストポインタ法、中点検出
設問2:
問題文を見る模範解答
エ:aがNULLと等しくない
オ:「aがNULLと等しい」
もしくは
「bがNULLと等しくない」
カ:head->next
解説
解答の論理構成
-
併合ループの継続条件
図7にはwhile ( エ 、かつ、bが NULL と等しくない )とあります。ここでbについては既に “bがNULLと等しくない” が書かれているので、残る エ にはaが空でないことを示す条件を入れれば、両方のリストに要素がある間ループします。
➡ エ = 「aがNULLと等しくない」 -
残余リストの判定
ループを抜けると、どちらかのリストが空になっています。図7のif ( オ ) // 要素が残っている連結リストを連結するは“どちらに要素が残っているか”を判定する式です。
・aが空ならbに要素が残る
・逆にbが空ならaに要素が残る
よって- 「aがNULLと等しい」
- または同値の 「bがNULLと等しくない」
のいずれでも正しく動作します。
➡ オ = 「aがNULLと等しい」(同値条件として「bがNULLと等しくない」も可)
-
返却値
ダミーセルheadを使っているため、完成した連結リストの実際の先頭はhead->nextです。図7のreturn カに入るのはhead->nextになります。
➡ カ = head->next
誤りやすいポイント
- ダミーセルを使う場合、返却時にheadをそのまま戻してしまうミス。
- オ で “要素が残っている方”を判定するのにaがNULLと等しくない と書いてしまい、条件が逆になるミス。
- while条件にaとbの両方を同時に書き、二重否定で混乱してループが早く終わるロジックエラー。
FAQ
Q: ダミーセルを入れる利点は何ですか?
A: 先頭ノードの追加・削除時に特別扱いをせず、一律で“現在ノードの後ろに挿入”という処理に統一できるため、コードが簡潔かつバグが入りにくくなります。
A: 先頭ノードの追加・削除時に特別扱いをせず、一律で“現在ノードの後ろに挿入”という処理に統一できるため、コードが簡潔かつバグが入りにくくなります。
Q: オ に二つの書き方があるのはなぜ?
A: ループ終了時にはaとbのどちらかが必ずNULLです。aがNULLと等しい でもbがNULLと等しくない でも“要素が残っている方がbである”という事実を表現できるため、同値条件として両方認められます。
A: ループ終了時にはaとbのどちらかが必ずNULLです。aがNULLと等しい でもbがNULLと等しくない でも“要素が残っている方がbである”という事実を表現できるため、同値条件として両方認められます。
Q: マージソートは配列より連結リストの方が向いているのですか?
A: 連結リストでは「分割」「併合」がポインタの付け替えだけで済むため、要素の大量コピーが不要になります。対して配列では併合のたびに配列領域へのコピーが発生するため、連結リストの方がメモリ効率が良い場合があります。
A: 連結リストでは「分割」「併合」がポインタの付け替えだけで済むため、要素の大量コピーが不要になります。対して配列では併合のたびに配列領域へのコピーが発生するため、連結リストの方がメモリ効率が良い場合があります。
関連キーワード: 連結リスト、マージソート、ポインタ操作、ダミーノード、併合アルゴリズム
設問3:
問題文を見る32個のセルから成る連結リストに対し、図2のアルゴリズムに相当するプログラムを実行した場合、関数mergeは何回呼び出されるか答えよ。
模範解答
31
解説
解答の導き方
結論:関数mergeは32個のセルから成る連結リストに対して31回呼び出されます。以下に段階的に説明します。
-
アルゴリズムの振る舞いの確認
問題文に「ここでは、それぞれの要素数が1になるまでデータ列の分割を繰り返し」とあり、分割は要素数が1の単位まで行われます。したがって最終的に得られる単位リスト(葉)の数は入力要素数と一致し、今回なら葉の数は32です。 -
再帰呼び出しを木構造で考える
マージソート全体の再帰呼び出しを根付き二分木(以下、再帰木)で表すと、各ノードはある部分列に対するマージソート呼び出しを表します。図2の(2)に「分割する」とあり、要素数が2以上の呼び出しは必ず前半と後半の二つの再帰呼び出しを行います。図2の(4)に「前半と後半の二つのマージソート済みデータ列を…併合する」とあるように、戻ってくる際に併合(merge)が実行されます。つまり再帰木の葉は分割の終端(要素数1の呼び出し)であり、内部ノード(非葉)は併合を行う呼び出しに対応します。 -
木の性質から併合回数を求める(形式的導出)
以下を定義します。葉の数を 、内部ノード(=併合を行う呼び出し)の数を 、全ノード数を とします。明らかに です。
根付き木の辺(親→子の接続)の総数は常に ですから辺の数を とすると です。
本問題の再帰木では、内部ノードは分割時に必ず二つの子を生成するため、全辺数は内部ノードが持つ子辺の総和で表せます。したがって です。
以上を合わせると
となり整理すると
が得られます。今回 なので 、つまりmergeの呼び出し回数は31回です。 -
直感的な確認(別解)
分割が終わると32個の単独リストができ、各mergeは二つのリストを一つにまとめるのでリストの総数は1回のmergeでちょうど1つ減ります。32個を1個にするには32−1=31回のmergeが必要、という直感的な確認も成り立ちます。
以上より、mergeの呼び出し回数は31回であると確定できます。
誤りやすいポイント
-
二分木の扱いで「辺」と「内部ノード」を混同する
「辺の数 = ノード数 − 1」という一般事実と「内部ノードと葉の関係」を正しく区別する必要があります。本問では内部ノード数 と葉数 の関係は です(導出は本文参照)。 -
merge回数を比較回数や計算量 と混同する
mergeの呼び出し回数は (ここで は要素数)であり、比較回数や実行時間のオーダー()とは別物です。両者を取り違えると誤答になります。 -
分割の終端条件を誤解する
問題文が明示する「要素数が1になるまで」の分割を見落とすと葉数の数え方を誤ります。分割の終端条件が変われば答えも変わる点に注意してください。 -
ノードの子数を過小評価する誤り
要素数が奇数の場合などに「片方の再帰呼び出しが空になる」と考えてしまうと内部ノードの子数が2でないと誤認することがありますが、本アルゴリズムの分割ルールでは要素数が2以上であれば両方に少なくとも1要素が入るため、内部ノードは常に2子です。 -
が2のべき乗でない場合の誤解
再帰木が完全二分木でなくても、内部ノードは常に2子であるため は一般に成立します。2のべき乗でないと特別扱いが必要、と考えるのは誤りです。
FAQ
Q: 要素数が一般に のとき、mergeの呼び出し回数はどう表せますか?
A: 要素数 に対して、分割が「要素数1になるまで」行われる場合、mergeの呼び出し回数は です()。(理由:葉数 、内部ノード数 より)
A: 要素数 に対して、分割が「要素数1になるまで」行われる場合、mergeの呼び出し回数は です()。(理由:葉数 、内部ノード数 より)
Q: なぜ内部ノードは常に2子になるのですか?
A: 図2の(2)に示されるように、要素数が2以上の呼び出しは「前半と後半のデータ列に分割する」処理を行い、必ず二つの再帰呼び出しを行います。したがってその呼び出しは必ず二つの子ノードを持ちます。
A: 図2の(2)に示されるように、要素数が2以上の呼び出しは「前半と後半のデータ列に分割する」処理を行い、必ず二つの再帰呼び出しを行います。したがってその呼び出しは必ず二つの子ノードを持ちます。
Q: mergeの呼び出し回数と比較回数はどう違いますか?
A: mergeの呼び出し回数は関数mergeが呼ばれる回数(=内部ノード数)で固定的に です。一方で要素間の比較回数は各merge内での処理回数の合計になり、合計としては のオーダーになります。呼び出し回数と比較回数は別の指標です。
A: mergeの呼び出し回数は関数mergeが呼ばれる回数(=内部ノード数)で固定的に です。一方で要素間の比較回数は各merge内での処理回数の合計になり、合計としては のオーダーになります。呼び出し回数と比較回数は別の指標です。
関連キーワード: マージソート、分割統治法、再帰、完全二分木、連結リスト







