第2回 - リストについて補足、計算量の理論、Big O 記法

  1. 前回の復習
  2. Python のリストについて補足
  3. 計算量の理論とは
  4. ブレイクアウトルーム練習
  5. 本日の課題

雑談

社会人になると、おカネについての知識は 英語やプログラミングと同じくらい重要になる:

0. 前回の復習

演習 2-1. 再帰的な関数
  1. 次の再帰的な関数をPythonのプログラムで表せ。
    • Pn = 1.0   (n が 0 の場合)
    • Pn = Pn-1 / 2  (それ以外)
    def P(n):
        if n == 0:
            return 1.0
        else:
            return ???????
    
  2. 次のプログラムはどのような関数を表すか。
    def F(n):
        if n == 0 or n == 1:
            return 1
        else:
            return F(n-1) + F(n-2)
    

1. Python のリストについて

1.1. 「参照」という考え方

第一の最後の授業で、 「文字」や「画像」といった概念がじつは幻想にすぎないことを説明した。 実際にはコンピュータの記憶装置は箱の羅列のようなもので、 原始的な命令で扱えるのデータは 8ビットか、せいぜい64ビットまでである。

Python を含む、ほとんどのプログラミング言語では この事実を微妙にごまかしている。

整数型の場合

整数・小数型のデータは小さいので、 これらのデータを変数に代入した場合、 メモリ上のデータが本当に移動・コピーされる。

a = 3
b = a       # 変数 a の値が変数 b にコピーされる。
b = b + 1   # b に1を足す。
print(b)    # "4"
print(a)    # "3"
a b 3 4

リストの場合

しかし文字列型やリスト型のデータは大きすぎるので、 これらは一度、メモリ上に作成されたら動かさず、ひとつのものを共有する。 つまりこれらのデータは不動産として扱われるのである。

a = [5,9,4,0,7,3]
b = a        # 変数 a の値が変数 b にコピーされる。
b[0] = 1     # b[0] に 1を代入。
print(b)     # [1,9,4,0,7,3]
print(a)     # [1,9,4,0,7,3] … ???

リスト型の場合、変数 ab には データそのものが入っているのではなく、そのデータへの参照 (アドレス) が入っていると考える。実際のリストは、変数の外に存在する。

a b 1 9 4 0 7 3

以下の代入文

b = a
は参照 (上の図でいえば、矢印) をコピーするだけで、 実際のデータはコピーしない。 「参照」は、Webサイトのアドレス (URL) のようなものである。 あるWebサイトの URL をコピーしても、そのWebサイトが 2つになるわけではない。
不動産の法則: Pythonでは、リスト (および文字列) は不動産として扱われ、 一度作成したデータは動かない。

(注意: この名前は新山が勝手に考えたもので、一般的ではない)

関数呼び出しにリストを渡す場合

「リストの参照をコピーする」という考えは、 とくに関数呼び出しのときに重要である。 たとえば、ふつうの関数呼び出しでは引数の値 (整数) は コピーされるため、以下の例では関数 assign() 呼び出し後でも 変数 a の値は変わらない:

# ふつうの関数
def assign(x):
    x = 999
    return

a = 123
assign(a)
print(a)     # 123
assign x a 999 123

ところが、以下の例では関数 destroy() には リストの参照がコピーされるだけで、リストはひとつである。 destroy()x で参照される リストの 1番目の要素に 999 を代入する。このような場合、 関数に渡したリスト a の値が変わってしまう。 このような処理を破壊的な操作とよぶ。

# 破壊的な関数
def destroy(x):
    x[1] = 999
    return

a = [0,1,2]
destroy(a)
print(a)     # [0,999,2]
destroy x a 0 999 2
演習 2-2. リストの参照

以下のプログラムの出力結果を予想せよ。

  1. x = [7,6,5]
    y = x
    x[1] = 4
    print(y[1])
    
  2. a = [1,2,3,4]
    b = a
    a[3] = b[0]
    print(b)
    

同じリストを2度作成する

リストは [5,9,4,0,7,3] などという式を評価した時点で 作成される。つまり、リストが2度書かれている場合、 a, b は実際に別々のリストとなり、 不動産の法則は成りたたない:

a = [5,9,4,0,7,3]  # 新しいリストが作成される。
b = [5,9,4,0,7,3]  # 新しいリストが作成される。
b[0] = 1     # b[0] に 1を代入。
print(b)     # [1,9,4,0,7,3]
print(a)     # [5,9,4,0,7,3]

この場合、メモリの内容は次のようになっている:

a b 5 9 4 0 7 3 1 9 4 0 7 3

リストを本当にコピーする

しかし、どうしても本当にデータをコピーしたい時もある。 このような場合は .copy() を使う。 (見慣れない構文だが、この方法は後日解説)

a = [5,9,4,0,7,3]
b = a.copy() # 変数 a のリストが実際にコピーされる。
b[0] = 1     # b[0] に 1を代入。
print(b)     # [1,9,4,0,7,3]
print(a)     # [5,9,4,0,7,3]
a b 5 9 4 0 7 3 1 9 4 0 7 3

a.copy() を実行すると内部で for文のようなものが実行され、 各要素がコピーされるため、そのぶん時間がかかる。 これは、以下のようにしてみるとわかる。

a = [0]*100000000  # ゼロが1億個の入ったリスト。
b = a              # リストの参照のコピー (一瞬で終わる)。
...
c = a.copy()       # リスト全体のコピー (時間がかかる)。

ちょっとした小技 : リストの要素のくり返し

Pythonの特別な書き方として、リストに代入する際

a = [0,0,0,0,0,0,0,0,0,0]
と書く代わりに、
a = [0] * 10
のように書くことができる。

1.2. 「スライス」とは

たとえば、以下のようなリストがあるとする:

a = [5,9,4,0,7,3,1,8]

この場合、aの部分列は、以下のようにして取り出せる (このような操作を、リストのスライスという) :

5 9 4 0 7 3 1 8 0 1 2 3 4 5 6 7 8 a[0] a[-1] a[2:4] a[3:-2] a[:2] a[5:]
s[i] i 番目の要素 (i >=0 の場合)

末尾から i 番目の要素 (i < 0 の場合)

s[i:j] 位置 i から位置 j の間にある要素 (このとき長さは j - i 要素になる)

(i が負の場合は末尾からの位置)

s[i:] 位置 i 以降の要素

(i が負の場合は末尾からの位置)

s[:i] 先頭から位置 i までの要素

(i が負の場合は末尾からの位置)

なお、リストをスライスした場合、もとのリストは変化しない。 リストのスライス内の要素はもとのリストと共有されておらず、 つねに新しいリストである。

演習 2-3. リストのスライス

以下のリストがあるとする:

#   0    1    2    3    4    5    6    7    8
s = ["H", "E", "A", "D", "A", "C", "H", "E"]
  1. s[2:6] の要素を答えよ。
  2. s[4:] の要素を答えよ。
  3. s[:2] の要素を答えよ。
  4. s[-2:] の要素を答えよ。

1.3. 二次元リスト

Python では、リストの中にさらにリストを入れることができる。 この機能を使うと、あたかもリストを二次元のリスト (行列) のように扱うことができる。 (この機能は第一の 小課題 8 で ひそかに使っていた)

m = [[5, 9, 4], [0, 7, 3]]
a = m[0]        # m の 0番目の要素 (リストの参照) をaにコピー
print(a)        # [5,9,4]
print(a[2])     # 4
b = m[1]        # m の 1番目の要素 (リストの参照) をbにコピー
print(b)        # [0,7,3]
print(b[0])     # 0

さらにこれは短縮して以下のように書くことができる:

print(m[0][2])  # 4
print(m[1][0])  # 0

実は「リストの中にリストが入る」というのは正しくない。 実際に入っているのは「リストの参照」であるので、 正しくは「リストの中にさらに『リストの参照』が入る」というべきである。 これを図に表すと以下のようになる:

m 5 9 4 0 7 3

参照ということは、つまり破壊的な操作が可能ということを意味する。 たとえば

a = m[0]        # m の 0番目の要素 (リストの参照) をaにコピー
a[2] = 8        # a の 2番目の要素を 8 に変更
print(m[0][2])  # 8

あるいはもっと簡単に

m[0][2] = 8     # (m の 0番目の要素) の 2番目の要素を 8 に変更
print(m[0][2])  # 8
演習 2-4. 二次元リストの練習

a = [[5, 9, 4, 0], [7, 3, 1]] のとき、以下の値は何か答えよ:

  1. a[0][3]
  2. a[1][2]
  3. a[1]
  4. a[1][3]

二次元リストの初期化

1次元の場合は、たとえば n個の要素をもつリストを作成するのに

a = []
for i in range(n):
    a = a + [0]
などとやればよいが、二次元 n×n の場合は以下のようにする:
m = []
for i in range(n):
    a = []
    for j in range(n):
       a = a + [0]
    m = m + [a]
あるいはもっと簡単に
m = []
for i in range(n):
    a = [0]*n
    m = m + [a]
あるいはもっと簡単に
m = []
for i in range(n):
    m = m + [[0]*n]

注意: 以下の方法はうまく動かない。なぜか考えよ。

m = [[0]*n]*n   # ダメ

2. 計算量の理論とは

同じく 第一の最後の授業で、 すべてのコンピュータは原始的な命令の組み合わせだけで動作することを説明した。 コンピュータ上で「Pythonのプログラムが動く」ように見えるのも、 じつは幻想である。本来、コンピュータでは原始的な (ネイティブ) 命令だけを使ったプログラムしか動かない。 Pythonのプログラムが実行されているように見えるのは、 「Pythonプログラムを実行する」ような架空の演算装置を エミュレータ (仮想機械) として動かしているからである。 このエミュレータ自身はネイティブ命令だけを使って書かれている。

l ネイティブ実行 プログラム 命令 ... 演算装置 Pythonによる実行 Python 命令 ... 架空の 演算装置 (プログラム) 演算装置

Pythonに限らず、すべてのコンピュータ上のプログラムは 演算装置上でネイティブ命令を 1つずつ実行することにより動作する。

現在のコンピュータでは:

計算量の理論の大原則は非常に単純である:

  1. コンピュータの速度は無限ではない。 プログラムの実行時間は実行したネイティブ命令の個数に比例する。
  2. コンピュータの容量は無限ではない。 プログラムは搭載されたメモリ容量以上の変数・リストは利用できない。

たとえば

a = 1
a = 1
a = 2
a = 3
a = 4
a = 5
a = 6
a = 7
a = 8
a = 9
a = 10
を実行させた場合、後者はほぼ 10倍の実行時間がかかる。

同様に

a = [0] * 20
および
b = [0] * 2000
を比べた場合、後者はほぼ 100倍のメモリ容量を消費する。

計算量の理論で厄介なのは、じつはこうした操作に 「隠れた」実行時間・メモリ使用量があることである。 たとえば

b = [0] * 2000
は、最終的には原始的な命令として実行されるので、内部的には
b = []
for i in range(2000):
    b = b + [0]
のようになっている。 つまりリストの要素数が 100倍になると 100倍のメモリ容量を消費するだけでなく、 実行時間も 100倍になるのである。

計算量の理論は、こうしたプログラムの「隠れた」実行時間・メモリ使用量を 細かく計算し、最終的に以下のような質問に答えるのが目的である:

質問: このプログラムは、現実的な時間で答えが出せるのか?

たとえば 前回の 2進数 (0 と 1) の組み合わせをすべて出力する関数 combo で n=100 とすると、 これは1秒間に 1ギガ個の組み合わせを表示できたとして

2100 ÷ 109 ÷ (60×60×24×365) = 40196936841331.48
つまり 400兆年かかる。 したがってこれは現実的に「無理」で、 何かほかの手を考えなければいけないということになる。
演習 2-5. 計算量の予想

以下のプログラムのうち、もっとも実行時間がかかる(と思われる)ものはどれか?

  1. n = 0
    for i in range(10):
        n = n + 1
    
  2. n = 0
    for i in range(1000):
        n = n + 1
    
  3. n = 0
    for i in range(100):
        for j in range(100):
            n = n + 1
    

2.1. Big O 記法とは

関数には序列 (オーダー, order) というものがある。

オーダーが小さい関数は、 オーダーが大きい関数にいつか必ず追い抜かれる。

同様に…

関数のオーダーは、その中のもっとも大きな項で決まる。

さらに…

関数のオーダーは通常「Big O 記法」をもちいて O(x) のように表す。
O(x) < O(x2) < O(x3) < ... < O(x100) < ... < O(2x) < O(3x)

ちなみに、O(x) より小さいオーダーの関数もある。

O(1) < O(log x) < O(√x) < O(x)

数学的に厳密な定義は以下のとおり:

lim [x → ∞] f(x) / g(x) ≤ C ならば、f(x) は O(g(x)) である。
演習 2-6. 関数の序列

次の関数を、オーダーの小さい順に並べよ:

  1. 3x-100
  2. 5
  3. 5x
  4. 0.4 x2

2.1. プログラムの計算量

Big O 記法は、あるアルゴリズム (プログラム) の計算量や メモリ使用量を表すのによく使われる。

def sum(n):
    s = 0
    for i in range(n):
        s = s + (i+1)
    return s

この関数 sum(n) は、与えられた値 n に対して n回の同じ処理を繰り返すので「O(n) オーダーの計算量をもつ」ということができる。 いっぽうでこの関数は変数を1つしか使っていないので、 メモリ使用量はつねに一定 O(1) である。

def fill(n):
    a = [0] * n
    return a
この関数 fill(n) は、与えられた値 n に対して 「O(n) の計算量」および 「O(n) のメモリ使用量」が必要である。 なぜなら、[0] * n の部分は内部的には forループとして処理されているからである。
演習 2-7. 隠れた計算量

上の関数 sum(n) には実は隠れた計算量 (= 一定時間だと思われているがそうではないもの) がある。 それはどこか。

注意

「オーダー」という考え方は、(定数要素によって左右される) 実際の計算時間を 考えなくてよいので便利であるが、計算量のオーダーが小さいからといって、 かならずしも実行時間が短いわけではないことに注意! たとえば以下の関数 doit() は実行時間がかかるが、 その計算量は O(1) である。(ループの回数が入力値 n に比例しないため)

def doit(n):
    s = n
    for i in range(1000000000):
        s = s + (i+1)
    return s

3. ブレイクアウトルーム演習

ブレイクアウトルーム演習の方法:

  1. ブレイクアウトルーム中はカメラを使うこと。
  2. まず自己紹介をする。名前・所属・趣味などを簡単に説明する。
  3. 最初の問題をやる担当者を決める。 その人が自分の PC の画面共有をおこない、課題のプログラムを考えながら書く。 このとき周囲の人は手助けすること。
  4. 終わったら、次の担当者が次の問題をやる。これを繰り返す。
  5. 授業終了の5分前にブレイクアウトルームを終了させるので、 それまでに全問題を終わらせる。
演習 2-8. リストのリストを使った演習

まず、以下のプログラム grid.py を実行せよ。

# grid.py
m = [[0,0,0,0,0], [0,0,0,0,0], [0,0,0,0,0], [0,0,0,0,0], [0,0,0,0,0]]
m[0][0] = 1
m[1][1] = 1
m[2][2] = 1
m[3][3] = 1
m[4][4] = 1
for i in range(len(m)):
    print(m[i])
  1. 上のプログラムを変更し、5×5のマス目に 1 を使って 「×」の字を描くようにせよ:
    [1, 0, 0, 0, 1]
    [0, 1, 0, 1, 0]
    [0, 0, 1, 0, 0]
    [0, 1, 0, 1, 0]
    [1, 0, 0, 0, 1]
    
  2. 上のプログラムをさらに変更し、マスの大きさを 20×20 にせよ。 (ヒント: 手で全部書くのはつらいので、上の方法を参考にしてループを使うべし)

4. 本日の課題

小課題2. リストのリストを使った図形描画 (12月20日締切)

二次元リストを使って、与えられた大きさ n の正方形に収まる円を描くような プログラム circle.py を書け。正方形の大きさはコマンド引数から与えるものとする。 (多少いびつな円でもよい、nが偶数のときは考えなくてもよい)

$ python circle.py 3
[0, 1, 0]
[1, 1, 1]
[0, 1, 0]

$ python circle.py 7
[0, 0, 0, 1, 0, 0, 0]
[0, 1, 1, 1, 1, 1, 0]
[0, 1, 1, 1, 1, 1, 0]
[1, 1, 1, 1, 1, 1, 1]
[0, 1, 1, 1, 1, 1, 0]
[0, 1, 1, 1, 1, 1, 0]
[0, 0, 0, 1, 0, 0, 0]

ヒント

n×n の二次元リストを作成し、円の半径を r、 中心座標を (xc, yc) として

(x - xc)2 + (y - yc)2r2
が成り立つような要素を 1 にすればよい。 おそらく典型的なプログラムは以下のようになる:
# circle.py
import sys
n = int(sys.argv[1])
# リストを初期化する。
m = []
for i in range(n):
    m = m + [[0]*n]

r = n // 2   # 半径
for y in range(n):
    for x in range(n):
????
# 結果を表示する。 for i in range(n): print(m[i])
チャレンジ課題A. すべての組合せを表示する (12月27日締切)

あるリストを与えると、その中の要素の順列すべてを 列挙するような関数 perm を書け。 この関数には対象となるリストと、その要素数を引数として 与えるものとする。

実行例

a = [1,2,3]
perm(a, 3)
実行結果:
[1, 2, 3]
[2, 1, 3]
[2, 3, 1]
[3, 2, 1]
[3, 1, 2]
[1, 3, 2]

この関数を使って、リスト ["R", "G", "B", "C", "M", "Y"] の 要素すべての組み合せを出力するプログラム perm.py を提出せよ:

実行結果 (一部)

$ python perm.py
['R', 'G', 'B', 'C', 'M', 'Y']
['G', 'R', 'B', 'C', 'M', 'Y']
...(中略)...
['Y', 'G', 'C', 'R', 'B', 'M']
['G', 'Y', 'C', 'R', 'B', 'M']

ヒント


Yusuke Shinyama