けいさんりょう

2023-03-10 20:02
2023-03-10 20:02

時間計算量と空間計算量

アルゴリズムを実行する際には一般的に入力変数に依存してアルゴリズムの実行時間と使用するメモリの量が変化する。それぞれを以下のようによぶ。

  • 時間計算量(time complexity): アルゴリズムの実行時間
  • 空間計算量(space complexity): アルゴリズムに要求されるメモリの量

計算量の表記法

ある計算の上限・下限時間などを表記する方法として O, Ω, Θ 表記法がある。これらを漸近表記法とよぶ。

  • O 表記法(big-O notation):計算時間の上限を表す
    • n 個の要素を持つ配列のすべての要素を出力する時間計算量は O(n)O(n) と記述できるが O(2n)O(2n), O(n2)O(n^2) と表記することも可能
    • n 個の要素を持つ配列のすべての要素を出力する時間計算量は O(1)O(1) を超える例があることから O(1)O(1) とは記述できない
  • Ω 表記法(omega notation): 計算時間の下限を表す
    • n 個の要素を持つ配列のすべての要素を出力する時間計算量は O(n)O(n) と記述できるが O(1)O(1), O(log(n))O(log(n)) と表記することも可能
    • n 個の要素を持つ配列のすべての要素を出力する時間計算量は O(n2)O(n^2) を下回る例があることから O(n2)O(n^2) とは記述できない
  • Θ 表記法(theta notation): ある計算について O=ΩO = Ω なるオーダーが存在するとき、これを ΘΘ と表記できる
    • n 個の要素を持つ配列のすべての要素を出力する時間計算量は O(n)O(n) かつ Ω(n)Ω(n) であるため、この時間計算量は Θ(n)Θ(n) と記述できる
    • ソフトウェア開発の文脈では慣例的に O 表記法を Ω 表記法のような意味で利用することが多い

計算量の分類

計算は一般的に入力に依存して実行時間が変化するがそれぞれの入力に対して以下の 3 つに分類できる

  • 最悪時計算量(best case vomplexity): 最も時間を要する入力を与えた場合の計算量
  • 最良(最善)時計算量(worst case complexity): 最も時間を要しない入力を与えた場合の計算量
  • 平均時計算量(average case complexity): 入力がランダムに与えられたと仮定した場合の平均的な計算量

計算量の比較

計算量 O(f(n))O(f(n)) について n が非常に大きくなった場合、f(n)f(n)最も増加率高い項の影響が支配的 となり定数項や次数の低い項、係数は無視することができる。たとえば O(8n3+4n2+1)O(8n^3 + 4n^2 + 1) という計算量について増加率が最も高い項は 8n38n^3 となり n が非常に大きいときに O(n3)O(n^3) と考えても差し支えない。

項の影響力の大きさを表す 増加率 は以下のとおり

n!>2n>n2>nlog(n)>log(n!)>n>log(n)>1n! > 2^n > n^2 > n log(n) > log(n!) > n > log (n) > 1

償却計算量(ならし解析)

一般的な可変長配列は通常 O(1)O(1) で要素の追加が可能だが、例えば配列の要素数が 1, 2, 4, 8, 16, ... であるタイミングで要素を追加する際に 2倍のメモリを確保したのち、すべての既存の配列要素をコピーする処理が走るため O(n)O(n) の計算が必要となる。連続して要素の追加を行う際の各回の計算は次のような形となる。

  1. O(1)O(1):確保された 1 要素分のメモリに書き込み
  2. O(n)O(n): 確保された 1 要素分のメモリに不足が生じたので 2 要素分のメモリを確保し既存の n 個の要素をコピー
  3. O(n)O(n): 確保された 2 要素分のメモリに不足が生じたので 4 要素分のメモリを確保し既存の n 個の要素をコピー
  4. O(1)O(1): 確保された 4 要素分のメモリに書き込み
  5. O(n)O(n): 確保された 4 要素分のメモリに不足が生じたので 8 要素分のメモリを確保し既存の n 個の要素をコピー
  6. O(1)O(1): 確保された 8 要素分のメモリに書き込み
  7. O(1)O(1): 確保された 8 要素分のメモリに書き込み
  8. O(1)O(1): 確保された 8 要素分のメモリに書き込み
  9. O(n)O(n): 確保された 16 要素分のメモリに不足が生じたので 16 要素分のメモリを確保し既存の n 個の要素をコピー
  10. O(1)O(1): 確保された 16 要素分のメモリに書き込み
  11. O(1)O(1): 確保された 16 要素分のメモリに書き込み
  12. O(1)O(1): 確保された 16 要素分のメモリに書き込み

上記から可変長配列への要素の追加における計算量は定数項を無視できるので O(n)O(n) とできる。しかしながら最良時計算量と最悪時計算量の差が大きく、n が十分おおきいときに最悪時となるケースの頻度は低下するため実態を反映しきれていない状況でもある。

ところで一連の n 回の計算に対する時間平均実行時間は次のように表せる: (O(1)+O(n)+O(n)+O(1)+O(1)+O(n)+O(1)+...)/nO(nn)=O(1)(O(1) + O(n) + O(n) + O(1) + O(1) + O(n) + O(1) + ...) / n \sim O(\dfrac{n}{n}) = O(1)。これを 償却計算量 とよぶ。

時間計算量の具体例

ループの時間計算量

1重ループの時間計算量は O(n)O(n) となる。

for (int i = 0; i < n, i++) {
  doSomething()
}

2重ループの時間計算量は O(n2)O(n^2) となる。

for (int i = 0; i < n, i++) {
  for (int j = 0; j < n; j++ ) {
    doSomething()
  }
}

連続文の時間計算量

連続文の時間計算量はそれぞれの文の時間計算量の和となる。下記の例においては O(n+n)=O(2n)O(n)O(n + n) = O(2n) \sim O(n)

for (int i = 0; i < n, i++) {
  doSomething()
}

for (int j = 0; j < n, j++) {
  doSomething2()
}

if 文の時間計算量

if then A else B 文があったとき最悪時計算量は A, B どちらかの大きいほう、最良時計算量は小さいほうとなる。下記の例においては最悪時計算量 O(nlog(n))O(n log(n))、最良時計算量は O(n)O(n)

if (condition) {
  doOrderN()
} else {
  doOrderNLogN()
}

再帰の時間計算量

for 文や while 文に展開できる単純な再帰の場合は展開後にどのような計算量となるか考えれば再帰の計算量も導ける。例えば下記の例では単純に O(n)O(n) となる。

int f(int n) {
    if (n <= 0) {
        return 0;
    }
    return n + f(n - 1);
}

下記の例では return 文の箇所を 2 * f(n - 1) と最適化すればやはり O(n)O(n) の計算となるが、ベタに実行するとなると再帰が深くなるごとに 2 のベキ乗回の計算が走る。よって計算量は O(2n)O(2^n) となる。

int f(int n) {
    if (n <= 0) {
        return 0;
    }
    return f(n - 1) + f(n - 1);
}

やや複雑な計算量となるアルゴリズムの実例

log (n) の計算量

例えば下記の例においては i が n を超えない範囲にてループのたびに 2 分割されていくため、ループを抜けるタイミングにおいて 2^i = n となる。i を n の関数として表現するため両辺に 2 を底とした対数をとると、計算量は log2(n)log_2 (n) となることがわかる。

log2(2i)=log2(n)ilog2(2)=log2(n)i=log2(n)log_{2}(2^i) = log_{2} (n) \Leftrightarrow i log_{2} (2) = log_2 (n) \Leftrightarrow i = log_2 (n)
for (int i = 1; i <= n, i = i * 2) {
  doSomething()
}

あるいは 10 個の要素を持つソート済み配列 as = {1, 3, 6, 9, 13, 23, 31, 35, 40, 43} から 3 を 二分探索(binary search) で抽出するシチュエーションを考えたとき as[5], as[2], as[1] と探索していき、合致したインデックス 1 を返す流れとなり、このアルゴリズムの計算量は配列の要素数 n に対して log2(n)log_2 (n) となる。

int as[] = {1, 3, 6, 9, 13, 23, 31, 35, 40, 43};

int find(int input[], int target, int startIndex, int endIndex) {
    int n = (startIndex + endIndex) / 2;
    int extracted = input[n];
    if (extracted == target) {
        return n;
    } else if (extracted > target) {
        return n == endIndex ? -1 : find(input, target, startIndex, n);
    } else {
        return n == startIndex ? -1 : find(input, target, n, endIndex);
    }
}

なお、ある関数 f(n)f(n) の底の異なる対数 x=logaf(x)x = log_{a} f(x) および y=logbf(x)y = log_{b} f(x) を比較すると対数の定義よりそれぞれ f(x)=axf(x) = a^x および f(x)=byf(x) = b^y と書ける。したがって ax=bya^x = b^y とでき両辺に例えば常用対数をとれば

log10(ax)=log10(by)xlog10(a)=ylog10bx=log10(a)log10(b)yx=cy(c:Const)log_{10} (a^x) = log_{10} (b^y) \Leftrightarrow x log_{10} (a) = y log_{10} b\Leftrightarrow x = \dfrac{log_{10} (a)}{log_{10} (b)} y\Leftrightarrow x = cy (c: Const)

となり、f(n)f(n) の底の異なる対数同士は定数倍の違いでしかないことがわかる。したがって 計算量を考えるとき対数の底は切り捨てて考えることが可能 となる。

n log (n) の計算量

TODO

参考文献

Copyright © 53ningen.com