回数制限以内に列を並べ替える事ができたでしょうか?
今回のゲームの難しいところは、どんな数列が用意されるか分からない状態から始めないといけないというところにあります。
決められた範囲内ならどんな入力を与えられても、規定通りに動かせば、正しい答えが得られる手続きのことを、「アルゴリズム」と言いますが、今回の問題はまさに数列をソートするようなアルゴリズムを考えようというゲームでした。
ソートアルゴリズムには様々な種類が知られており、実は適切なアルゴリズムを選ぶことで、数列の長さが \(8\) であればどんな列が用意されていても \(20\) 回以内に並べ替えることが出来ます。
次節からはいくつかの有名なソートアルゴリズムを紹介していきます。
まずは比較的動きが単純なソートアルゴリズムを3つ程紹介します。
バブルソートは今回の問題において、恐らく最も直感的なソートアルゴリズムです。
ざっくりとバブルソートを説明すると、
隣り合う二つの要素を正しい順番に並べるのを繰り返す。
です。その手順は以下の通りです。
以下はバブルソートを実際に行っている様子を表したものです。
手数は多くなってしまいますが、一歩一歩確実に数列が昇順に並んでいくのが分かりやすいソートです。
続いて紹介するソートアルゴリズムは、挿入ソートと呼ばれるものです.
ざっくりと挿入ソートを説明すると、
前の要素から順に、正しい位置に入れていく。
です。その手順は以下の通りです。
以下は挿入ソートを実際に行っている様子を表したものです。
選択ソートも比較的イメージしやすいソートアルゴリズムです。
ざっくりと選択ソートを説明すると、
最も小さい要素を探し、それを先頭にもってくるのを繰り返す。
です。その手順は以下の通りです。
以下は選択ソートを実際に行っている様子を表したものです。
実際にトランプをソートするときなど知らず知らずの内にこのソートに近いものをしているかもしれません。(私はこれでした。)
さてここまで3つのソートアルゴリズムを紹介してきました。
どれも、比較的直感的なソートアルゴリズムですが、これらをそのまま今回のゲームに適用すると、確実には \(20\)
回以内で並べ替えることは出来ません。
次の節では、より質問回数が少なくて済むソートアルゴリズムを紹介します。
ざっくりとマージソートを説明すると、
半分ずつに区切ってソートをし、結果を合わせる。
です。その手順は以下の通りです。
ただし、各ステップで数列 \(A\) と数列 \(B\) の数列の要素を合わせた、 数列 \(C\) を作る方法は以下の通りです。
中々に複雑なので、下のgif画像を見るとイメージしやすいかもしれません。
以下はマージソートを実際に行っている様子を表したものです。
やや複雑な方法ですが、この手順に従えば質問回数は最大でも \(17\) 回となり、目標を達成できます!!
さて、ここまで比較的単純なソートアルゴリズム3種と、複雑だが質問回数の少ないソートを紹介してきました。
ここからは、これらのソートの違いが競技プログラミングにおいてどのような差異を産むかについてのお話です。
今回のゲームでは、数列の長さは \(8\) と小さい値であったため、操作回数にそこまでの違いはありませんでした。
それでは数列の長さを \(N\)
だとしたらどうでしょうか?
この時、それぞれのソートアルゴリズムを適用した場合に、どれぐらいの操作回数を要するかを解析してみると、シンプルなソートアルゴリズムで紹介した方は、大体 \(N^2\)
回、マージソートは
\(N\log_{2}N\) 回となります。(ここでの "大体" とは、\(N\)
を十分大きくすると、定数倍の範囲で近似できるという意味のため、かなりざっくりとした"大体"です。)
ここで、下の \(N\) と \(N^2\) や
\(N\log_{2} N\)
の対応表を見てみましょう。
\(N\) | \(N^2\) | \(N\log_{2}N\) |
---|---|---|
\(10\) | \(100\) | \(33\) |
\(100\) | \(10000\) | \(664\) |
\(1000\) | \(1000000\) | \(9966\) |
\(10000\) | \(100000000\) | \(132877\) |
\(100000\) | \(10000000000\) | \(1660964\) |
このように、\(N\)
の値が大きくなるにつれて、アルゴリズムの効率の差が大きくなっていきます。
そして、競技プログラミングで扱うのは効率の差が顕著にでるような大きな対象です。
そのため、競技プログラミングでは、ただ単に正解を与えるだけでなく、効率よく問題を解けるような解法を考える必要があるのです。
今回紹介出来なかった中にも興味深いソートアルゴリズムはたくさんあります。
また、シンプルなソートアルゴリズムの方で紹介した方でも、そこから少し工夫を凝らすことで効率の良いアルゴリズムに生まれ変わるといった事もあります。
このように、単純な並べ替え一つをとっても、アルゴリズムの世界というのは奥深いものなのです。
以上で並べ替えゲームの解説は終了です。
最後までお読みいただき、ありがとうございました。
この後におまけとして、ソートアルゴリズムをプログラミングで書いたものを載せておきます。
今回紹介したソートアルゴリズムを、プログラミングのコードで書いたらどうなるだろうのコーナーです。
#include <bits/stdc++.h>
using namespace std;
// これより上は, 最初の内はおまじないのようなものと思って大丈夫
int main() {
int N; // 配列の長さ
cin >> N; // 長さを受け取る
vector<int> A(N); // 配列を格納する
for (int i = 0; i < N; i++) {
cin >> A[i]; // 配列の要素を受け取る
}
for (int i = 0; i < N; i++) { // N回繰り返す
for (int j = 0; j < N - 1; j++) { // j=0,1,2,...,N-2と順に処理をする.
if (A[j] > A[j + 1]) { // Aのj番目とj+1番目を比較する
swap(A[j], A[j + 1]); // Aのj番目の方が大きければ交換する
}
}
}
for (int i = 0; i < N; i++) { // i=0,1,...,N-1と順に繰り返す
cout << A[i] << " \n"[i == N - 1]; // 配列の値を出力する.
}
}
n = int(input()) # 要素数
a = list(map(int, input().split())) # 整列したい配列を取得
for i in range(n-1): # 1からn-1まで繰り返し
j = i+1 # 挿入する値
while j > 0 and a[j-1] > a[j]: # 挿入する値より前に小さい値があれば
a[j], a[j-1] = a[j-1], a[j] # 交換していく
j -= 1
print(*a)
(一般的にはマージソートは再帰関数と呼ばれる方法で実装されますが、ゲームに則して非再起で書いたのでかなりごちゃごちゃしてしまいました。)
#include <bits/stdc++.h>
using namespace std;
// これより上は, 最初の内はおまじないのようなものと思って大丈夫
int main() {
int N; // 配列の長さ
cin >> N; // 長さを受け取る
int INF = 1e9 + 10;
int CN, d;
for (d = 30; d >= 0; d--) {
if (N & (1 << d)) {
CN = (1 << d) * 2;
break;
}
}
vector<int> A(CN, INF); // 配列を格納する
for (int i = 0; i < N; i++) {
cin >> A[i]; // 配列の要素を受け取る
}
for (int i = 0; i <= d; i++) {
int bit = (1 << i);
vector<int> nextA(CN);
for (int j = 0; j < CN / (bit * 2); j++) {
int L = 0, R = 0;
for (int k = 0; k < bit * 2; k++) {
if (L >= bit) {
nextA[j * (bit * 2) + k] = A[j * (bit * 2) + bit + R];
R++;
} else if (R >= bit) {
nextA[j * (bit * 2) + k] = A[j * (bit * 2) + L];
L++;
} else if (A[j * (bit * 2) + L] < A[j * (bit * 2) + bit + R]) {
nextA[j * (bit * 2) + k] = A[j * (bit * 2) + L];
L++;
} else {
nextA[j * (bit * 2) + k] = A[j * (bit * 2) + bit + R];
R++;
}
}
}
A = nextA;
}
for (int i = 0; i < N; i++) {
cout << A[i] << " \n"[i == N - 1];
}
}