第5回 - CSV形式の読み込み、ハッシュテーブルの原理

  1. PythonでCSV形式を読み込む
  2. ハッシュテーブルとは
  3. ブレイクアウトルーム練習
  4. 本日の課題

雑談

IT業界における女性の社会進出はとくに遅れている: 米国における統計

1980年代になにが起こったのか? 「パソコン」の登場である。 当時、パソコンは「男の子向けのオモチャ」という位置づけで販売されていた:

この傾向は、消費者に対してある一定のメッセージを送っている。

Saturday Morning Breakfast Cereal (漫画)
  1. 男の子向けのオモチャ: 「メカブロックスだよ! 何通りにも組み合わせができて遠隔操作できるんだ!」
  2. 女の子向けのオモチャ: 「人形だよ!」
  3. 後年… 「なんでこの業界はみんな野郎ばっかりなんだ?」

しかし、女性でもコンピュータ・サイエンスのパイオニアは存在する:

1. PythonでCSV形式を読み込む

CSV (Comma Separated Values、カンマ区切り値) 形式とは、テキスト形式の一種で、 カンマによって区切られた値を並べたものである。 まず、テキストファイルの各行は表の一 (row) に相当する。 さらに各行のなかのカンマで囲まれた部分を カラム (column、列または欄) または フィールド (field) という。 空白や特殊な文字が入ったフィールドはさらに "〜" で囲まれる。 また CSVファイルは、Microsoft Excel で開くことができる:

table1.csv
Alice,USA,22
Bob,UK,25
"Yusuke Shinyama",Japan,13

エクセルで上の CSVファイルを開くと、以下のような表が表示される:

AliceUSA22
BobUK25
Yusuke ShinyamaJapan13

日本語のCSVをPythonでリスト(のリスト)として読み込むには、 以下のようにする:

readcsv.py (プログラムのファイル名をただの "csv.py" にすると、 正しく動かないので注意。)
import csv
with open("ファイル名", encoding="utf-8") as fp:
    変数名 = list(csv.reader(fp))
演習 5-1. CSVファイルの読み込み
  1. 上のテキストファイル table1.csv を作成し、 Excel で実際に開けることを確認せよ。
  2. Python を使ってこの CSV ファイルを読み込み、 リストのリストを表示せよ。

Python で上の CSV ファイルを読み込むと、 以下のような「リストのリスト」になっている:

[['Alice', 'USA', '22'], ['Bob', 'UK', '25'], ['Yusuke Shinyama', 'Japan', '13']]
(外側にある) リストの要素は、CSV (表) の各行に相当する。 各要素はさらにリストになっており (リストのリスト)、 その各要素は1行のカラム (列) に相当する。 なお、すべての内容は (22 のような数字でも) 文字列型になっていることに注意。これを数値として正しく処理するためには、 特定の要素に対して int() 関数などを使う必要がある。
演習 5-2. 郵便番号データベースを読み込む

以下の zip ファイルをダウンロードせよ:

この表は全国の郵便番号一覧を記した、約12万行の巨大な CSV ファイルである。 以下のプログラムを使って、この表を1行ずつ表示せよ:

postal.py
# CSVファイルを読み込む。
import csv
with open("KEN_ALL_ROME.CSV", encoding="utf-8") as fp:
    table = list(csv.reader(fp))
# 表を1行(row)ずつ処理する。
for row in table:
    print(row)

(CSV の元のデータは郵便局の 郵便番号データダウンロードページから取得した)

1.1. for文のもうひとつの使い方

演習 5-2. のプログラムを実行すると、以下のような内容が表示される:

['0600000', '北海道', '札幌市\u3000中央区', '以下に掲載がない場合', 'HOKKAIDO', 'SAPPORO SHI CHUO KU', 'IKANIKEISAIGANAIBAAI']
['0640941', '北海道', '札幌市\u3000中央区', '旭ケ丘', 'HOKKAIDO', 'SAPPORO SHI CHUO KU', 'ASAHIGAOKA']
['0600041', '北海道', '札幌市\u3000中央区', '大通東', 'HOKKAIDO', 'SAPPORO SHI CHUO KU', 'ODORIHIGASHI']
...
(Control + C を押して止める。)

このプログラムの中で、次の部分に着目しよう:

# 表を1行(row)ずつ処理する。
for row in table:
    print(row)

これまではリストの要素をひとつずつ表示するのに、 以下のようにしていた:

a = [5,9,4,0]
for i in range(len(a)):
    x = a[i]
    print(x)
実は for文ではこうも書ける:
a = [5,9,4,0]
for x in a:
    print(x)

これらは同じ原理にもとづいている。 range(n) 関数は、 [0, 1, 2, ..., n-1] というリスト (のようなもの) を返す。 したがって、

for i in range(n):
    ....
という文は、この中の要素ひとつひとつを変数 i に代入し、 「結果として i が 0 から n-1 まで 変化しているように見えた」のである。

1.2. 文字列のスライス

Python では、 リストと同じく、文字列もスライスが可能である。

s = "HEADACHE"

この場合、sのスライスは、以下のようになる:

H E A D A C H E 0 1 2 3 4 5 6 7 8 s[0] s[-1] s[2:4] s[3:-2] s[:2] s[5:]
演習 5-3. 文字列のスライス
  1. s[2:6] の文字列を答えよ。
  2. s[4:] の文字列を答えよ。
  3. s[:2] の文字列を答えよ。
  4. s[-2:] の文字列を答えよ。
  5. s[-2:-1] の文字列を答えよ。

1.3. 文字列の検索

文字列に対してスライスが使えると、 ある文字列の中に含まれる部分文字列の検索をおこなうことができる。 これは、検索したい文字列をスライドさせながら各位置と比較することでおこなう:

V A C A T I O N 0 1 2 3 4 5 6 7 8 C A T i i+1 i+2 i+3
find.py
# ある文字列に含まれる部分文字列を発見する。
s = "VACATION"  # 検索対象の文字列
t = "CAT"       # 含まれる文字列
for i in range(6):  # 0〜5
    if s[i:i+3] == t:
        print("found", i)

2. ハッシュテーブルとは

演習 5-2. でやった問題をふたたび考える。 郵便番号を入力すると、その住所を表示するプログラムを作りたい。

$ python postal.py 1520001
東京都目黒区中央町

このように、ある情報 (e.g. 郵便番号) を別の情報 (e.g. 住所) に関連づける処理は、 業務用コンピュータの代表的な利用例である。数学的には、 これは2つの集合 K から V への写像 KV として定義される。 このとき、集合 K の要素を 「キー (key)」、集合 V の要素を 「バリュー (value)」と呼ぶ。

2.1. 逐次探索

今回の例では郵便番号 (row[0]) がキー、 住所 (row[1]+row[2]+row[3]) がバリューということになる。 これを使ってプログラム postal.py を改造すると、以下のようになる:

postal.py
# CSVファイルを読み込む。
import csv
with open("KEN_ALL_ROME.CSV", encoding="utf-8") as fp:
    table = list(csv.reader(fp))
# 郵便番号 (キー) を取得する。
import sys
key = sys.argv[1]
# 表を1行ずつ処理する。
for row in table:
    if row[0] == key:
        # キーが一致したら、それに対応するバリューを表示する。
        value = row[1]+row[2]+row[3]
        print(value)

郵便番号データは 12万行以上あるので、 逐次探索をすると毎回 (最悪) 12万回のループを実行することになり、 効率が悪い。n件のデータに対して、O(n) の計算量が必要である。

二分探索を使えば、最悪 O(log n) の計算量ですむが、 データをあらかじめキーが昇順になるようにソートしておく必要がある。 またキーを新しく追加・更新するたびに正しく並べ直さなければならない。

2.2. 逐次探索・改

郵便番号は 09 の数字から始まるので、 まずキーを「最初の1文字」で分類し、10個のリストに分割するという手がある。

"0" "1" "2" "3" "4" "5" "6" "7" "8" "9" 0010201 0020853 ... 1006001 1057044 1310045 1630207 ... 2010016 2260027 ... 3002638 3114206 3390082 ... 4093423 4420029 ... 5012561 5180464 ... 6028151 6308343 6570032 ... 7038258 7320001 ... 8030815 "8120892 ... 9000006 ...
これは人間が紙の辞書をひくとき、実際にやっている方法でもある。 Python のプログラムでこれを実現するには、まず以下のようにする。
# まず 10個の (空のリストからなる) リストを作成。
index = [[], [], [], [], [], [], [], [], [], []]
for row in table:
    key = row[0]
    value = row[1]+row[2]+row[3]
    # キーの0文字目を数値に直す。
    i = int(key[0])
    # i番目のリストにキーとバリューの組を追加する。
    a = index[i]
    a.append([key, value])

ここで作られたリストのリストを「索引」あるいは 「インデックス (index)」とよぶ。 インデックスを使って検索をする処理 lookup() は以下のようになる:

def lookup(index, key):
    # キーの0文字目を数値に直す。
    i = int(key[0])
    # i番目のリストをとりだす。
    a = index[i]
    # そのリストに対して逐次探索をおこなう。
    for pair in a:
        # 一致するキーとバリューの組をとりだす。
        k = pair[0]
        v = pair[1]
        if k == key:
            return v
    # 見つからなかったら、空の文字列を返す。
    return ""

最後に、この関数を呼び出す:

import sys
key = sys.argv[1]
value = lookup(index, key)
print(value)

この例では、最初のインデックスを作成するのに ある程度の時間がかかるので、全体の実行時間はそれほど速くならないが、 検索時間だけをみると、このようにリストを10個に分割すれば 時間は約 1/10 に短縮されると思われる、が…

演習 5-4. 逐次検索・改を実行する
  1. この方法の計算量を Big-O 記法で答えよ。
  2. 実際には、ここでの検索時間は厳密に 1/10 にはならない。その理由を説明せよ。
  3. 上の方法で実際に郵便番号をキーとしたインデックスを作成し、 各リストにいくつの要素が追加されるかを表示せよ。

2.3. ハッシュテーブル

上の方法のインデックスをより一般化したものが ハッシュテーブル (hash table) である。 これはインデックス作成時にキーの最初の1文字を使うかわりに、 ハッシュ関数 (hash function) が返す値を使う。 ハッシュ関数とは、任意の文字列をなんらかの値 (ハッシュ) に 変換するような関数である:

ハッシュ関数 h(x) はどんな関数でもよいが、 同じ文字列に対してはつねに同じ値を返すように設計しなければならない。 また、2つの異なる文字列ができるだけ異なる値になるように 設計することが望ましい。一般的なハッシュ関数の例として考えられるのは、 与えられたキー (文字列) に含まれる文字コードをすべて足す、 などの方法である。

ハッシュ関数ができたら、これを使って何番目のリストを使うべきか決定する。 全体的な流れは以下のようになる:

  1. まず k個のリストを用意する。
  2. ハッシュ関数 h(x) が、与えられた文字列 x を整数に変換する。
  3. インデックス作成時および検索時には、(h(x) % k) 番目の リストを使用する。
0 1 2 3 4 ... ... k-1 7038258 5012561 1630207 ... 7320001 1057044 1310045 ... 4420029 6308343 9000006 ... 6570032 6028151 3390082 ... 0020853 1006001 8030815 ... 2260027 4093423 3114206 ...

ひとたびハッシュテーブルができれば、 関数 lookup() は従来とほぼ同じ方法で検索がおこなえる:

def lookup(index, key):
    k = len(index)
    # キーをハッシュに変換し、k の剰余をとる。
    i = h(key) % k
    # i番目のリストをとりだす。
    a = index[i]
    # そのリストに対して逐次探索をおこなう。
    for pair in a:
        # 一致するキーとバリューの組をとりだす。
        k = pair[0]
        v = pair[1]
        if k == key:
            return v
    # 見つからなかったら、空の文字列を返す。
    return ""

ハッシュテーブルの検索時間は二分探索ほど速くないが、 キーの追加や変更の処理が簡単なため広く利用されている。 また、ハッシュテーブルによる検索時間は k の値に調整できるところも有利である。

演習 5-5. ハッシュテーブルの計算量
  1. k = 1 の場合、この方式の実行時間はどうなるか。
  2. k = n の場合、 ハッシュ関数 h に対してある理想的な条件が満たされると この方式の計算時間はほぼ一定になり、二分探索よりも速くなる。 「ある理想的な条件」とは何か。
  3. 一般的にいって、ハッシュテーブルを使って n個のものから検索するときの 計算量はいくらになるか。Big-O 記法で答えよ。

一般的に、ハッシュテーブルを使うと現実の検索時間は短縮されるが、 その計算量はたかだか 1/k になるだけである。 この k は定数であるので、Big-O 記法で表した計算量は変わらない。

前節の「郵便番号の最初の数字を 0 〜 9 の整数に変換する」処理は、ある意味 ハッシュ関数の一例であるが、郵便番号にしか使えないし、ハッシュの値は 0 から 9 までに固定されている。さらに、得られる値が一様に分布していない。 どのような文字列に対しても一様に分布するハッシュ関数を 設計するのは意外に難しく、これは現在でもさかんに研究されている テーマのひとつである。ちなみに最悪のハッシュ関数とは、 どんな文字列に対しても同じ値を返す関数である。 これではすべての文字に対して同じリストを使うことになってしまい、 事実上、逐次探索と変わらない:

# 最悪のハッシュ関数
def h(x):
    return 123

ハッシュ関数の応用

ハッシュ値はどんなデータに対しても適用できる。 これを応用したものが電子署名で、あるデータのハッシュ値を使って、 そのデータが改竄されていないかチェックするものである。 ここでは、ハッシュ関数の以下のような特性を利用している:

  1. もし2つのハッシュ値が違っていれば、元のデータ (キー) は確実に違っている。
  2. 元のデータ (キー) が違っていても、2つのハッシュ値が違うとは限らない。 偶然に同じハッシュになるということもありうる。(ただし、その可能性は非常に小さい。)
  3. ハッシュ関数として、 暗号学的ハッシュ関数 と呼ばれるものを使っており、これはたとえハッシュ値がわかっても 元のデータを容易に推測できないようになっている。

ハッシュ関数の別の応用としてビットコインに代表される ブロックチェーン技術があるが、 ここでは触れない。

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

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

  1. ブレイクアウトルーム中はカメラを使うこと。
  2. まず自己紹介をする。名前・所属・趣味などを簡単に説明する。
  3. 最初の問題をやる担当者が PC の画面共有をおこない、課題のプログラムを考えながら書く。 このとき周囲の人は手助けする。
  4. 終わったら、次の担当者が次の問題をやる。これを繰り返す。
  5. 全部終わったら、適当に雑談する (← これが本当の目的)。
演習 5-6. 文字列の検索
  1. 文字列検索プログラム find.py を一般化し、 関数 find(s, t) として実現せよ。 これは与えられた文字列 s の中に含まれる t の位置を 整数として返し、見つからなければ -1 を返すものとする:
    def find(s, t):
        ...
        return i
    
    print(find("VACATION", "CAT"))  # 2
    print(find("VACATION", "DOG"))  # -1
    
  2. 以下のプログラムは「東京都」の中のすべての住所を表示するものである。 このプログラムを改良し、上の関数 find を使って、 「東京都にあって、なおかつ『が丘』が含まれる住所」を 表示させよ。全部でいくつあるか?
    import csv
    with open("KEN_ALL_ROME.CSV", encoding="utf-8") as fp:
        table = list(csv.reader(fp))
    # 表を1行(row)ずつ処理する。
    for row in table:
        # 都道府県名 (1番目の要素) が東京都の住所を表示。
        if row[1] == "東京都":
            print(row[1]+row[2]+row[3])
    

4. 本日の課題

小課題5. ハッシュ関数の実装 (1月24日締切)

以下のプログラム findcode.py は、授業中にやった postal.py とは逆に、住所から郵便番号を検索するプログラムである。 これは、100個のリストを使ったハッシュテーブルを作成し、 それを使って住所から郵便番号を検索している。 ところが、ここで使っているハッシュ関数 h は、 つねに同じ値を返す「最悪の関数」になっているため、 このままで実行すると1回の検索に時間がかかる。 ハッシュ関数を改良して、実行時間を 1/10以下に短縮せよ。

findcode.py
# キーに対応したハッシュを計算する関数 h を定義する。
def h(key): return 123
# 検索をおこなう関数 lookup を定義する。 def lookup(index, key): # キーをハッシュに変換し、k の剰余をとる。 i = h(key) % 100 # i番目のリストをとりだす。 a = index[i] # そのリストに対して逐次探索をおこなう。 for pair in a: # 一致するキーとバリューの組をとりだす。 k = pair[0] v = pair[1] if k == key: return v # 見つからなかったら、空の文字列を返す。 return "" # 郵便番号の一覧を読み込む。 import csv with open("KEN_ALL_ROME.CSV", encoding="utf-8") as fp: table = list(csv.reader(fp)) # 100個の (空のリストからなる) リストを作成。 index = [] for i in range(100): index.append([]) # このリストを使ったハッシュテーブルを作成する。 for row in table: key = row[1]+row[2]+row[3] # キー: 住所 value = row[0] # バリュー: 郵便番号 # キーをハッシュに変換する。 i = h(key) % 100 # i番目のリストにキーとバリューの組を追加する。 a = index[i] a.append([key, value]) # 指定された住所から郵便番号を検索し、実行時間を計測する。 import sys key = sys.argv[1] import time t0 = time.time() # 最初の時刻 (秒数) を取得する。 # 郵便番号を検索する (時間計測のため、1000回繰り返している)。 for i in range(1000): value = lookup(index, key) t1 = time.time() # 最後の時刻 (秒数) を取得する。 # 見つかった郵便番号を表示する。 print(value) # かかった時間を表示する。 print(t1 - t0)

実行例 (実行時間は正確にこの値になるとは限らない):

$ python findcode.py 東京都目黒区大岡山
1520033
0.021525859832763672
$ python findcode.py 長野県中野市中野
3830013
0.02087545394897461
チャレンジ課題B. 交差迷路の生成 (1月31日締切)

中課題 2.でやった迷路生成プログラム genmaze.py を 改造し「各通路が交差する迷路」を生成するプログラム crossmaze.py を作成せよ。 ここでは壁を # で表すのではなく、 通路を -|+ で表すものとする:

$ python crossmaze.py 15 15
+-+-+-+---+-+-+
| | | |
| | +-|-+---+-+
| | | | |
+ + + +-|-+ + +
    | | |   | |
+-+-+ + +-+-+-+
  | |         |
+---+---+-+-+ |
| | |   | |   |
+ + +-+ + + +-+
| | | |       |
| + +-|-+---+ +
| | | | |
+ + + + +-+---+

ヒント

基本的な迷路生成方法は genmaze.py と同じであるが、 以下の変更を加える:

ちなみに関数 showmaze() は次のようになる:

# showmaze: 与えらえた迷路を表示する。
def showmaze(m):
    for y in range(len(m)):
        # 空の文字列を用意し、1文字ずつ足していく。
        s = ""
        for x in range(len(m[y])):
            # 座標 (x,y) の情報を加える。
            if m[y][x] == 1:
                s = s + "-"
            elif m[y][x] == 2:
                s = s + "|"
            elif m[y][x] == 3:
                s = s + "+"
            else:
                s = s + " "
        print(s)
    return

Yusuke Shinyama