社会人になると、おカネについての知識は 英語やプログラミングと同じくらい重要になる:
def P(n): if n == 0: return 1.0 else: return???????
def F(n): if n == 0 or n == 1: return 1 else: return F(n-1) + F(n-2)
第一の最後の授業で、 「文字」や「画像」といった概念がじつは幻想にすぎないことを説明した。 実際にはコンピュータの記憶装置は箱の羅列のようなもので、 原始的な命令で扱えるのデータは 8ビットか、せいぜい64ビットまでである。
True
/ False
などのデータ型は 64ビットあれば扱える。
Python を含む、ほとんどのプログラミング言語では この事実を微妙にごまかしている。
整数・小数型のデータは小さいので、 これらのデータを変数に代入した場合、 メモリ上のデータが本当に移動・コピーされる。
a = 3 b = a # 変数 a の値が変数 b にコピーされる。 b = b + 1 # b に1を足す。 print(b) # "4" print(a) # "3"
しかし文字列型やリスト型のデータは大きすぎるので、 これらは一度、メモリ上に作成されたら動かさず、ひとつのものを共有する。 つまりこれらのデータは不動産として扱われるのである。
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] … ???
リスト型の場合、変数 a
や b
には
データそのものが入っているのではなく、そのデータへの参照
(アドレス) が入っていると考える。実際のリストは、変数の外に存在する。
以下の代入文
は参照 (上の図でいえば、矢印) をコピーするだけで、 実際のデータはコピーしない。 「参照」は、Webサイトのアドレス (URL) のようなものである。 あるWebサイトの URL をコピーしても、そのWebサイトが 2つになるわけではない。b = a
(注意: この名前は新山が勝手に考えたもので、一般的ではない)
「リストの参照をコピーする」という考えは、
とくに関数呼び出しのときに重要である。
たとえば、ふつうの関数呼び出しでは引数の値 (整数) は
コピーされるため、以下の例では関数 assign()
呼び出し後でも
変数 a
の値は変わらない:
# ふつうの関数 def assign(x): x = 999 return a = 123 assign(a) print(a) # 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]
以下のプログラムの出力結果を予想せよ。
x = [7,6,5] y = x x[1] = 4 print(y[1])
a = [1,2,3,4] b = a a[3] = b[0] print(b)
リストは [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]
この場合、メモリの内容は次のようになっている:
しかし、どうしても本当にデータをコピーしたい時もある。
このような場合は .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.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
たとえば、以下のようなリストがあるとする:
a = [5,9,4,0,7,3,1,8]
この場合、a
の部分列は、以下のようにして取り出せる
(このような操作を、リストのスライスという) :
s[i] |
i 番目の要素 (i >=0 の場合)
末尾から i 番目の要素 ( |
s[i:j] |
位置 i から位置 j の間にある要素
(このとき長さは j - i 要素になる)
(i が負の場合は末尾からの位置) |
s[i:] |
位置 i 以降の要素
(i が負の場合は末尾からの位置) |
s[:i] |
先頭から位置 i までの要素
(i が負の場合は末尾からの位置) |
なお、リストをスライスした場合、もとのリストは変化しない。 リストのスライス内の要素はもとのリストと共有されておらず、 つねに新しいリストである。
以下のリストがあるとする:
# 0 1 2 3 4 5 6 7 8
s = ["H", "E", "A", "D", "A", "C", "H", "E"]
s[2:6]
の要素を答えよ。
s[4:]
の要素を答えよ。
s[:2]
の要素を答えよ。
s[-2:]
の要素を答えよ。
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
実は「リストの中にリストが入る」というのは正しくない。 実際に入っているのは「リストの参照」であるので、 正しくは「リストの中にさらに『リストの参照』が入る」というべきである。 これを図に表すと以下のようになる:
参照ということは、つまり破壊的な操作が可能ということを意味する。 たとえば
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
a = [[5, 9, 4, 0], [7, 3, 1]]
のとき、以下の値は何か答えよ:
a[0][3]
a[1][2]
a[1]
a[1][3]
1次元の場合は、たとえば n個の要素をもつリストを作成するのに
などとやればよいが、二次元 n×n の場合は以下のようにする:a = [] for i in range(n): a = a + [0]
あるいはもっと簡単に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 # ダメ
同じく 第一の最後の授業で、 すべてのコンピュータは原始的な命令の組み合わせだけで動作することを説明した。 コンピュータ上で「Pythonのプログラムが動く」ように見えるのも、 じつは幻想である。本来、コンピュータでは原始的な (ネイティブ) 命令だけを使ったプログラムしか動かない。 Pythonのプログラムが実行されているように見えるのは、 「Pythonプログラムを実行する」ような架空の演算装置を エミュレータ (仮想機械) として動かしているからである。 このエミュレータ自身はネイティブ命令だけを使って書かれている。
Pythonに限らず、すべてのコンピュータ上のプログラムは 演算装置上でネイティブ命令を 1つずつ実行することにより動作する。
現在のコンピュータでは:
計算量の理論の大原則は非常に単純である:
たとえば
とa = 1
を実行させた場合、後者はほぼ 10倍の実行時間がかかる。a = 1 a = 2 a = 3 a = 4 a = 5 a = 6 a = 7 a = 8 a = 9 a = 10
同様に
およびa = [0] * 20
を比べた場合、後者はほぼ 100倍のメモリ容量を消費する。b = [0] * 2000
計算量の理論で厄介なのは、じつはこうした操作に 「隠れた」実行時間・メモリ使用量があることである。 たとえば
は、最終的には原始的な命令として実行されるので、内部的にはb = [0] * 2000
のようになっている。 つまりリストの要素数が 100倍になると 100倍のメモリ容量を消費するだけでなく、 実行時間も 100倍になるのである。b = [] for i in range(2000): b = b + [0]
計算量の理論は、こうしたプログラムの「隠れた」実行時間・メモリ使用量を 細かく計算し、最終的に以下のような質問に答えるのが目的である:
たとえば 前回の 2進数 (0 と 1) の組み合わせをすべて出力する関数 combo で n=100 とすると、 これは1秒間に 1ギガ個の組み合わせを表示できたとして
以下のプログラムのうち、もっとも実行時間がかかる(と思われる)ものはどれか?
n = 0 for i in range(10): n = n + 1
n = 0 for i in range(1000): n = n + 1
n = 0 for i in range(100): for j in range(100): n = n + 1
関数には序列 (オーダー, order) というものがある。
→ したがって、x2 のほうがオーダーが大きい。
→ したがって、x2 のほうがオーダーが大きい。
→ したがって、やはり x2 のほうがオーダーが大きい。
同様に…
さらに…
ちなみに、O(x) より小さいオーダーの関数もある。
数学的に厳密な定義は以下のとおり:
次の関数を、オーダーの小さい順に並べよ:
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
ループとして処理されているからである。
上の関数 sum(n)
には実は隠れた計算量
(= 一定時間だと思われているがそうではないもの) がある。
それはどこか。
「オーダー」という考え方は、(定数要素によって左右される) 実際の計算時間を
考えなくてよいので便利であるが、計算量のオーダーが小さいからといって、
かならずしも実行時間が短いわけではないことに注意!
たとえば以下の関数 doit()
は実行時間がかかるが、
その計算量は O(1) である。(ループの回数が入力値 n に比例しないため)
def doit(n): s = n for i in range(1000000000): s = s + (i+1) return s
ブレイクアウトルーム演習の方法:
まず、以下のプログラム 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
を使って
「×」の字を描くようにせよ:
[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]
.py
ファイルのみでよい)
二次元リストを使って、与えられた大きさ 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) として
# 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])
.py
ファイルのみでよい)
あるリストを与えると、その中の要素の順列すべてを
列挙するような関数 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']
def perm(a, n): if n == 0: print(a) return else:???? ...return b = ["R", "G", "B", "C", "M", "Y"] perm(b, 6)