Webページでは数式を表すためにLaTeX表記が使えるMathJaxを利用しています。 WebブラウザにはSafari/Chrome/Firefoxを使って下さい(IEでは表示できないようです。)

計算量の評価

big O 記法

$f(n)$ と $g(n)$ が $\mathbb{R}$ 上の関数であるとき、 \[ f(n)= \mathcal{O}(g(n)) \qquad \text{as $n\rightarrow \infty$} \] であるとは、正の実数 $c_1, c_2$ および $n_0$ が存在して、$n_0\leqq n$ なる $n$ に対して \[ c_1\lvert g(n) \rvert \leqq \lvert f(n) \rvert \leqq c_2\lvert g(n) \rvert \qquad \text{for $n_0\leqq n$} \] であるときをいう。 このとき、$f(n)$ は $g(n)$ のオーダー(order)であるといい、big $\mathcal{O}$ を使って、$f(n)= \mathcal{O}(g(n))$ あるいは $f(n) \sim \mathcal{O}(g(n))$ などと書く。 この 記法は $n$ が十分大きくなったときの関数 $f(n)$ の挙動を表していることに注意する。

$f_1(n)= \mathcal{O}(g_1(n))$ および $f_2(n)= \mathcal{O}(g_2(n))$ が正値関数であるとき、定義から次が成立する。 \begin{align*} & f_1(n)f_2(n)= \mathcal{O}(g_1(n)g_2(n))\\ & f_1(n) + f_2(n)= \mathcal{O}(g_1(n) + g_2(n)). \end{align*}

たとえば、次の多項式 $p(n)$ を考えてみる。 \[ p(n)= a_k n^k + a_{k-1} n^{k-1}+\dots +a_2 n^2 + a_1 n + a_0 \] $n>1$の時には、自明な関係 \[ 1\leqq n\leqq n^2 \leqq \dots \leqq n^{k-1}\leqq n^k \] から直ちに得られる関係 \begin{align*} a_k n^k \leqq p(n) & \leqq a_k n^k + a_{k-1} n^k + \dots + a_2 n^k + a_1 n^k + a_0 n^k\\ & \leqq n^k (a_k + a_{k-1} + \dots + a_1 + a_0) \end{align*} は、$p(n)=\mathcal{O}(n^k)$ であることを示している。

これより、多項式については $n$ の最大次数に着目して \begin{align*} p_0(n) & 5000000000=\mathcal{O}(1) p_1(n) &= 20000 n + 3000000=\mathcal{O}(n)\\ p_2(n) &= 0.000001 n^2 -31412 n - 2600 = \mathcal{O}(n^2)\\ p_3(n) &= 300 n^2 + 600 n + 2500 = \mathcal{O}(n^2) \end{align*} となり、$p_2(n)$ と $p_3(n)$ は同じ $n^2$ のオーダーで $n$ が十分に大きくなると同じ程度に増加し、$n$のオーダーの $p_1(n)$ を圧倒し、$p_1(n)$ は $p_0(n)$ を圧倒することがわかる(この場合、関数の$\log$-$\log$グラフを書いてみるとこの事実は分かりやすい)。

アルゴリズムの計算量オーダーを低くすることは、大きなデータサイス $n$ に対しては劇的な効果があることになる。 あるいは、指数的オーダーを持つ計算量アルゴリムは大きなデータサイス $n$ に対して手に負えない(intractable)ことになる。

big $\mathcal{O}$-記法:小さいオーダー順に
order名前
$\mathcal{O}(1)$定数
$\mathcal{O}(\log n)$対数的
$\mathcal{O}(n)$線形的
$\mathcal{O}(n\log n)$対数線形的
$\mathcal{O}(n^2)$2乗的
$\mathcal{O}(n^3)$3乗的
$\mathcal{O}(2^n)$指数的
$\mathcal{O}(n!)$階乗的

階乗的オーダーが指数的オーダーよりも大きいのは Stirling's の近似公式からわかる。 \[ n! \sim \sqrt{2\pi n} n^n \mathrm{e}^{-n} \] 比 $n! /2^n$ の対数をとると \[ \log \left(\frac{n!}{2^n} \right) \sim \left( n+\frac12\right) n -(1+\log 2)n +\text{定数} \] から、$n\rightarrow \infty$につれて比はいくらでも大きくなるからである。

演習: 巡回セールスマン問題(traveling salesman problem)は手に負えないことを示しなさい。

代表的なアルゴリズムの計算量

ベクトルの内積

#!/usr/bin/env python
# -*- coding: utf-8 -*-
# inner product of two vectors given by numeric lists

import random
import time

def innner_product(v, w):
    n = len(v)
    ip = 0
    for i in range(size):
        ip += v[i] * w[i]
    return ip

n = int(raw_input("Length of each random vectors: "))
# preparing two random vectors which element is in [0,1)
v = [random.random() for i in range(n)]
w = [random.random() for i in range(n)]

start = time.time()
result = innner_product(v, w)
end = time.time()

print "used time: ", end - start
print "innver product = ", result

問題のサイズはベクトルの長さ n = len(v) である。

時間計算量を見積もろう。 9行目でベクトルの長さを知る時間 $T_{09}$、変数 ip をゼロにセットするための時間 $T_{10}$とで、$T_{09}+T_{10}$ 時間。

11行目のfor文では、カウンタ変数 i を1つずつ増分し手、最後に i の添字範囲をチェックする時間として (n + 1)$T_{11}$ 時間。 11行で、掛け算と足し算の時間 $T_{12}$ を n回する n$T_{12}$時間。 したがって、合計の計算時間 \[ T_{total} = T_{09} + T_{10} + (n + 1) T_{11} + nT_{12}=n(T_{11}+T_{12}) + T_{09}+T_{10}+T_{11} \] よって、$n$ が十分大きいと $T_{total}\approx n(T_{11}+T_{12})=\mathcal{O}(n)$ である。

一方、計算に要するメモリ使用量は、変数 n, i と ip にスカラー値格納のために $M_s$ + å$M_s$ + $M_s$、ベクトル v と w にn 個のスカラー値を格納するために n$M_s$ + n$M_s$、したがって合計 \[ M_{total}= nM_s + n M_s + M_s + M_s=(2n+2)M_s \] よって、$n$ が十分大きいと $M_{total}\approx 2nM_s=\mathcal{O}(n)$ である。

行列とベクトルの積

$m$ 行 $n$ 列の $m\times n$ 行列 $M$ と $n$次元ベクトル $v$ の積 $Mv$ は $m$ 次元ベクトルを返す。

#!/usr/bin/env python
# -*- coding: utf-8 -*-
# product of (m x n) matrix by n-vectors

import random
import time

def mat_vec_product(M, v):
    row = len(M)
    col = len(M[0])
    w = [0 for i in range(row)]
    for i in range(row):
        sum = 0
        for k in range(col):
            sum += M[i][k] * v[k]
        w[i] = sum
    return w

row = int(raw_input("Row size of random Matrix: "))
col = int(raw_input("Column size of random Matrix: "))
# preparing a random matrix and a random vectors with integer elements
M = [[random.randint(0, 3 * row) for i in range(col)] for k in range(row)]
v = [random.randint(0, 5 * col) for i in range(col)]

print M
print v
 
start = time.time()
result = mat_vec_product(M, v)
end = time.time()

print "used time: ", end - start
print "innver product = ", result

問題のサイズを決めるパラメータは行列の行数 m と 列数 n である。

行列の行数 m の取得(9行目)に$T_{09}$時間、列数 n の取得(10行目)に$T_{10}$時間、 m 次元空ベクトルの生成(11行目)に m に比例した m$T_{11} 時間かかる。

12行目の for文では、カウンタ変数 i の添字範囲のチェックを入れて (m+1)$T_{12}$時間、 13行目と16行目で m$T_{13}$ + m$T_{16}$ 時間。 14行目のfor文では、外側のfor文の各 i について (n + 1) $T_{14}$ 時間かかり、その内側(15行目)で n 回の繰り返す n$T_{15}$時間を、外側のfor文で m 回繰り返す。 したがって、合計時間は \begin{align*} T_{total} &=\big(T_{09}+T_{10}+ mT_{11}\big)+\big((m+1)T_{12} +mT_{13}+mT_{16}\big) +m\big((n+1)T_{14}+ nT_{15}\big)\\ &= mn(T_{14}+T_{15}) + m(T_{11}+T_{12}+T_{13}+T_{16}+T_{14}+T_{15}) +T_{09}+T_{10}+T_{12} \end{align*} したがって、$T_{total}\approx mn(T_{14}+T_{15})=\mathcal{O}(mn)$ となる。 正方行列の場合には $T_{total}=\mathcal{O}(n^2)$ である。

メモリ使用量は、スカラー値を格納する$M_s$ が行列で mn$M_s$、掛けるベクトルにn$M_s$、仮ベクトルに m$M_s$、i, sum, k に 3$M_s$、よって \[ M_{total}=mn M_s+ nM_s + mM_s + 3M_s=(mn+n+m+3)M_s. \] したがって、$M_{total}\approx mnM_s=\mathcal{O}(mn)$ となる。 正方行列の場合には $M_{total}=\mathcal{O}(n^2)$ である。

行列と行列の積

$m \times r$ 行列 $A$と $r\times n$ 行列 $B$ の行列積 $C=AB$ は $m\times n$ 行列で次のように計算される。 \[ C_{ij}=\sum_{k=1}^r A_{ik} B_{kj},\qquad 1\leqq i \leqq n, 1\leqq j\leqq m \]

#!/usr/bin/env python
# -*- coding: utf-8 -*-
# product of (m x k) matrix by (k x n)-matrix

import random
import time

def mat_mat_malti(A, B):
    row = len(A)
    rect = len(A[0])
    col = len(B[0])
    C = [[0 for i in range(col)] for j in range(row)]
    for i in range(row):
        for j in range(col):
            sum = 0
            for k in range(rect):
                sum += A[i][k] * B[k][j]
            C[i][j] = sum
    return C

row = int(raw_input("Row size of random Matrix A: "))
rect = int(raw_input("Column size of random Matrix A: "))
col = int(raw_input("Column size of random Matrix B: "))
# preparing a random matrices with integer elements
A = [[random.randint(0, 2 * row) for i in range(rect)] for k in range(row)]
B = [[random.randint(0, 3 * rect) for i in range(col)] for k in range(rect)]

print A
print B
 
start = time.time()
result = mat_mat_malti(A, B)
end = time.time()

print "used time: ", end - start
print "Matrix Matrix Multiplication = ", result
演習: 問題サイズのパラメータは、行列$A$の行数 $m$ と 列数 $r$ および行列$B$の列数 $n$ である。 スクリプト matrix_matrix_product.py の行列同士の掛け算アルゴリズムの計算量を評価しなさい。

ベクトルの1-ノルム

$n$個の成分を持つベクトル $v=(v_0, v_1, \dots, v_{n-1})$の$L_1$ノルム $\Vert v\Vert_1$ は次のようである。

\[ \lVert v\rVert_1 =|v_0| + |v_1| +\dots + |v_{n-1}|. \]

以下は、再帰再訪--計算時間の「ベクトルの$L1$ノルム」のスクリプトを含んでいる。

#!/usr/bin/env python
# -*- coding: utf-8 -*-
# L1 norm of vector(list)

import math
import random
import time

def one_norm_recur(num_list):
    if len(num_list) == 1:
        onenorm = math.fabs(num_list[0])
    else:
        left_norm = one_norm_recur(num_list[: int(math.floor(len(num_list) / 2))])
        right_norm = one_norm_recur(num_list[int(math.floor(len(num_list) / 2)): ])
        onenorm left_norm + right_norm
    return onenorm

def one_norm_for(num_list):
    onenorm = 0
    for elemnt in num_list:
        onenorm += element
    return onenorm

n = int(raw_input("Length of random vector: "))
nlist = [random.random() for k in range(i)]

start_for = time.time()
result_for = one_norm_for(num_list)
end_for = time.time()
used_for = end_for - start_for

start_recur = time.time()
result_recur = one_norm_recur(num_list)
end_recur = time.time()
used_recur = end_recur - start_recur

print "1-norm = ", result_for
print "used time:"
print "\tby for", used_for
print "\tby recursion", used_recur

$L_1$ノルムの問題サイズはベクトルの長さ $n$ である。

演習: $L_1$ノルムを計算する関数 one_norm_for の時間およびメモリ計算量を評価しなさい。

$L_1$ノルム計算の再帰版 one_norm_recur の時間計算量を評価する。 簡単のためにベクトルの長さ $n$ を $n=2^k$ と仮定しても一般性を失わない($k=\log n$)。 10行目の判断で時間 $T_{10}$と15行目の足し算計算に時間 $T_{14}$を費やすのであるが、13行目と14行目で半分の長さの$L_1$ノルム計算をしていることを考慮すると \[ T_{total}(2^k) = T_{10} + T_{14} + 2T_{total}(2^{k-1}) \] の関係、つまり $c= T_{10} + T_{14}$ を定数とすると \[ T_{total}(n)= c + 2T_{total}(n/2) \] が成立する。 このことから、次のように書くことができる。 \begin{align*} T_{total}(n) &= c + 2T_{total}(n/2)\\ &= c + 2(c + 2 T_{total}(n/2^2))= c + 2c + 2^2 T_{total}(n/2^2)\\ &= c + 2c + 2^2(c + 2T_{total}(n/2^3))=c + 2c + 2^2c + 2^3T_{total}(n/2^3)\\ & \vdots\\ &= c + 2c + 2^2 c+ 2^3 c+ \dots + 2^{k-2}c + 2^{k-1}T_{total}(2)\\ &= c\sum_{i=0}^{k-1} 2^i + n(T_{10} + T_{14})\\ &= \frac{1-2^k}{1-2}c + dn \qquad d = T_{10} + T_{14}\\ &=(c+d)n -c=\mathcal{O}(n) \end{align*}

メモリ計算量を考えよう。 $L_1$ノルムを計算するために長さ $n$ のベクトルを保持するための入力メモリ量 $M_{input}(n)$ とは区別して、関数内で再帰呼び出しするために必要なメモリを考える必要がある。 サイズ $n$ の計算に再帰呼び出しで必要なメモリ$M_{recur}(n)$は \[ M_{recur}(n) = c + M_{recur}(n/2) \] である。$c$ には left_norm や right_norm (など)に必要な固定領域である。 この漸化式は上の時間計算量と同様に評価でき、$M_{recur}(n)=\mathcal{O}(n)$ である。

入力メモリ量 $M_{input}(n)$ は明らかに $=\mathcal{O}(n)$ であることから、関数 one_norm_recur のメモリ計算量 $M_{total}(n)$ は次のようになる。 \[ M_{total}(n) = M_{input}(n) + M_{recur}(n) = \mathcal{O}(n) + \mathcal{O}(n) = \mathcal{O}(n). \]

先の関数 one_norm_recur は、リスト分割して再帰呼び出ししている様子の1行の表現が長くなって見づらい。 そこで次のように、リストを左右に分割してそれぞれを left_list と right_list に代入してコードを分かりやすくした関数を one_norm_recur2 としてみた。 しかしながら、以下の計算から明らかになるように、オーダーが上がったメモリ計算量を必要とするようになる。結果、$n$が大きくなると無視できない影響を及ぼしてしまう。

def one_norm_recur2(num_list):
    if len(num_list) == 1:
        onenorm = math.fabs(num_list[0])
    else:
        left_list = num_list[: int(math.floor(len(num_list) / 2))]
        right_list = num_list[int(math.floor(len(num_list) / 2)): ]
        left_norm = one_norm_recur(left_list)
        right_norm = one_norm_recur(right_list)
        onenorm left_norm + right_norm
    return onenorm

この場合、5行目と6行目のリスト(長さは約半分の $n/2$) left_list と right_list のために必要なメモリが発生するため $M_{recur}(n)$ は次のようになる。 \[ M_{recur}(n) = c + d n + 2M_{recur}(n/2) \]

したがって、 \begin{align*} M_{recur}(n) &= c + dn+ 2M_{recur}(n/2)\\ &= c + dn + 2(c + dn/2 + 2M_{recur}(n/2^2))= c + 2c + 2dn + 2^2M_{recur}(n/2^2)\\ & \vdots\\ &= c + 2c + 2^2 c+ 2^3 c+ \dots + \frac{n}{2}c + dkn + nM_{recur}(1)\\ &= c\sum_{i=0}^{k-1} 2^i +d (n\log n) + en\\ &= cn - c +d(n\log n) + en\\ &=\mathcal{O}(n\log n) \end{align*} つまり、関数 one_norm_recur2 のメモリ計算量 $M_{total}(n)$ は次のようになる。 \[ M_{total}(n) = M_{input}(n) + M_{recur}(n) = \mathcal{O}(n) + \mathcal{O}(n\log n) = \mathcal{O}(n\log n). \] 同じアルゴリズムでありながら、再帰計算の場合にはメモリ計算量のオーダーが $\mathcal{O}(n)$ から $\mathcal{O}(n\log n)$ へと上がってしまうことがわかった。

Fibonacci数の計算

Fibonacci数の計算は、再帰再訪--計算時間の「Fibonacci数列」で紹介した。

演習: n番目のFibonacci数を返す関数としてfor文を使って定義する関数 fibonacci_for(n) の時間およびメモリ計算量を評価しなさい。

再帰呼び出すしを使ってn番目のFibonacci数を返す杉の関数 fibonacci_recur の計算量評価を考える。

def fibonacci_recur(n):
    if n == 0:
        return 1
    elif n == 1:
        return 1
    else:
        return fibonacci_recur(n - 1) + fibonacci_recur(n - 2)
演習: n番目のFibonacci数を返す再帰関数 fibonacci_recur(n) の時間およびメモリ計算量を評価しなさい。

二項係数

二項係数 $\binom{n}{k}$ は \[ \binom{n}{k} = \frac{n!}{(n-k)! \times k!} \] で定義される。 次の二項係数を計算するスクリプトは、明らかに推奨できない。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

def fact(n):
    if n == 0:
        return 1
    else:
        return n * fact(n - 1)

def binomial_0(n, k):
    return fact(n) / fact(n - k) / fact(k)

n = int(raw_input("Binomial coefficent n > k :  n = "))
k = int(raw_input("\t  k = "))
start_time = time.time()
bino_0 = binomial_0(n, k)
end_time = time.time()
print "Primitive + for version: used time = :", end_time - start_time, "[sec]"
print "\tBinomial coefficent = ", bino_0

二項係数$\binom{n}{k}$の計算で $n$ が大きくなると階乗 $n!$ を求めること自体が以下の2つんぴみで困難になるからだ。 その困難の1つは階乗計算に再帰的定義を使っていることにある。 この方法では再帰の深さ $n$ に応じて計算量が増大することによる。 再帰の深さががシステム設定を越えてしまうと、再帰呼び出しに呼び出しにも失敗する。

演習: 上の再帰定義による階乗計算に必要な時間およびメモリ計算量を評価しなさい。

上の二項係数のスクリプトでは $\binom{400}{1}, \binom{400}{399}$が 400 であることは直ぐにわかるにも関わらず巨大整数 400! を計算しなくてはならない。 システムごとに異なった、階乗の場合では許容可能な整数桁を簡単に上回って正しい計算ができないことを計算の不安定性ということがある。

計算スピードを上げるために、階乗計算を次のように for文で書いてみよう。

def fact2(n):
    if n == 0:
        return 1
    else:
        fa = 1
        for i in range(1, n + 1):
            fa *= i
        return fa
演習: 上のfor文を使う階乗計算に必要な時間およびメモリ計算量を評価しなさい。 階乗計算にfor文をつかった繰り返し文をつかって二項係数の定義そのままに計算する方法と、上の再帰的方法による計算計算量を比較してみなさい。 どちらの方法でも計算の不安定性は依然として残っているのだが、計算時間には優位な差があることを確認しなさい。

では、次のように二項係数で成立する漸化式 \[ \binom{n}{k} = \binom{n - 1}{k- 1} + \binom{n - 1}{k} \] を使って再帰的によって計算するのはどうだろうか。 この場合、$k\approx n/2$ 程度で再帰呼び出しの深さが最大になることに注意しよう。

def binomial_1(n, k):
    
    if k == 0:
        return 1
    elif n == k:
        return 1
    else:
        return binomial_1(n - 1, k- 1) + binomial_1(n - 1, k)
演習: 上のような二項係数の計算に必要な時間およびメモリ計算量を評価しなさい。 worst case $k\approx n/2$ ではどうか。

さらに、二項係数の次の関係式に注目しよう。 \begin{align*} \binom{n}{k} &= \frac{n\times (n-1)\times (n-2)\times \dots \times (n-k+1)} {k\times (k-1)\times\dots\times 1} &= \frac{n}{k}\binom{n-1}{k-1} \end{align*}

この関係を使うと次の二項係数の再帰的定義を書くことができる。 ただし、n と k の掛け算の位置には配慮が必要である。

演習: なぜそのような配慮が必要かを説明しなさい。
def binomial_2(n, k):
    if k == 0:
        return 1
    else:
        return binomial_2(n - 1, k- 1) * n / k
演習: この二項係数計算の計算量を評価しなさい。
演習: 以上で、都合3つの二項係数 $\binom{n}{k}$ の計算方法を考えた(forを使う定義そのものと、2つの再帰的定義)。 これら3つの計算量の評価をまとめ、さらに$n=1,2,3,\dots$ , $k=\approx n/2$ についての計算時間を比較する実験を行いなさい。