社会人になると、おカネについての知識は 英語やプログラミングと同じくらい重要になる:
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個の要素をもつリストを作成するのに
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 # ダメ
同じく 第一の最後の授業で、 すべてのコンピュータは原始的な命令の組み合わせだけで動作することを説明した。 コンピュータ上で「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
b = []
for i in range(2000):
b = b + [0]
のようになっている。
つまりリストの要素数が 100倍になると
100倍のメモリ容量を消費するだけでなく、
実行時間も 100倍になるのである。
計算量の理論は、こうしたプログラムの「隠れた」実行時間・メモリ使用量を 細かく計算し、最終的に以下のような質問に答えるのが目的である:
たとえば 前回の 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)