Python でリストを条件をつけて複数の要素を削除したい。










◯ 問題


リスト [3, 9, 4 ,1, 2, 5, 7, 6] から奇数の要素を削除してください。











◯ 解答


結論だけ言えば、リスト内包表記が一番オススメです




下記の書き方は「リスト内包表記」と呼ばれる書き方です。

lst = [3, 9, 4 ,1, 2, 5, 7, 6]
new_lst = [e for e in lst if e % 2 == 0]
new_lst
# [4, 2, 6]




(補足) これがもっとも簡単です。ただ、実際には new_lst という新しいリストを作るというズルをしています。 もし、どうしても新しいリスト new_lst を作るのでなく、純粋に元あるリスト lst から要素を削除しなければならない場合は こちら に書きました。 ただ、思いの外そういうシチュエーションって無いのかなと思ったりします。 無理せず簡単な方を使うのが better なことが多いかなという感じもします。







Step 1. 処理を適用する


f:id:domodomodomo:20180903171739j:plain


リストの各要素に処理を適用したいときが、よく あります。

lst = [3, 9, 4 ,1, 2, 5, 7, 6]

new_lst = []
for e in lst:
    new_lst.append(e * e)

new_lst
# [9, 81, 16, 1, 4, 25, 49, 36]


そんな よく ある書き方なので「リスト内包表記」という書き方が用意されています。 こういう専用の簡単な書き方のことを一般に 糖衣構文 と言います。

lst = [3, 9, 4 ,1, 2, 5, 7, 6]

new_lst = [e * e for e in lst]

new_lst
# [9, 81, 16, 1, 4, 25, 49, 36]



Step 2. 必要なものだけ取り出す。


f:id:domodomodomo:20180903171811j:plain


リスト内包表記は、なんだか便利そうですね。もしこれで必要な要素だけ取り出せたら、 もっと便利そうです。

lst = [3, 9, 4 ,1, 2, 5, 7, 6]

new_lst = []
for e in lst:
    if e % 2 == 0:
        new_lst.append(e * e)

new_lst
# [4, 2, 6]



これがその書き方です。

lst = [3, 9, 4 ,1, 2, 5, 7, 6]
new_lst = [e for e in lst if e % 2 == 0]
new_lst
# [4, 2, 6]


 リスト内包表記のメリットとデメリット

1. メリット

簡潔で読みやすいです。また、実行速度も速いです。 可読性、実行速度、なぜ速度が違うのかと言った、かなり細かいことは後編に書きました。

2. デメリット

メモリを消費します。よくみるとリスト内包表記は新しいリスト new_lst を1つ作ってしまっています。 その分だけメモリを消費してしまいます。

ジェネレータ式 を使うことで回避することができます。 本当の本当は ジェネレータ式 がオススメなのですが、地味に難しいです。 もし使用するメモリの量が気になるようになってきたら、オススメの機能です。 そうでなければ無理して使う必要はないかなと思います。

また、リスト内包表記で for 文の代替はできません。 複雑なことはできないので、もしあまりに複雑になりそうなら、素直に for 文で書いた方が便利です。 複雑なことには使えないと言うのは、糖衣構文全般に言えることです。











(後編)書き方の比較検討




この後は、各書き方ごとに (1) 可読性 (2) 実行速度 (3) 落とし穴を比較検討していきます。

落とし穴とは、一体なんでしょうか? このリストから要素を削除するという問題は、実は簡単そうに見えて結構奥が深いです。

重要性の低い、かなり重箱の隅をつつくような話をするので、無理に読み進められなくても大丈夫です。

1. 書き方の比較

書き方は大きく分けて2つに分けることができます。 上で見たように新しいリストに要素を条件で分類しつつ追加する方法と(1, 2)、 元からあるリストから要素を削除する方法です(3, 4)。

# 1. for 文
#     オススメしない, 書き方が面倒だから
lst = [3, 9, 4 ,1, 2, 5, 7, 6]
new_lst = []
for e in lst:
    if e % 2 == 0:
        new_lst.append(e * e)

new_lst
# [4, 2, 6]
# 2. リスト内包表記
#     オススメ
lst = [3, 9, 4 ,1, 2, 5, 7, 6]
new_lst = [e for e in lst if e % 2 == 0]
new_lst
# [4, 2, 6]
# 3. while 文(リストから直接削除したい場合)
#     オススメ
lst = [3, 9, 4 ,1, 2, 5, 7, 6]
tmp = []
while lst:
    e = lst.pop()
    if e % 2 == 0:
        tmp.append(e)

while tmp:
    lst.append(tmp.pop())

lst
# [4, 2, 6]
# 4. for 文(リストから直接削除したい場合)
#     オススメしない, 遅くなるから
lst = [3, 9, 4 ,1, 2, 5, 7, 6]

def reversed_enumerate(seq):
    return zip(reversed(range(len(lst))), reversed(lst))

for i, e in reversed_enumerate(lst):
    if e % 2 == 1:
        del lst[i]

lst
# [4, 2, 6]

2. 実行速度の比較

リスト内包表記が、一番速いです。

$ python 4_2_list_deletion.py
#
for_statement_append                     :  0.0000
list_comprehension                       :  0.0000
while_statement                          :  0.0000
for_statement_del                        :  0.0000
#
for_statement_append                     :  0.0000
list_comprehension                       :  0.0000
while_statement                          :  0.0000
for_statement_del                        :  0.0000
#
for_statement_append                     :  0.0001
list_comprehension                       :  0.0001
while_statement                          :  0.0003
for_statement_del                        :  0.0002
#
for_statement_append                     :  0.0012
list_comprehension                       :  0.0008
while_statement                          :  0.0027
for_statement_del                        :  0.0015
#
for_statement_append                     :  0.0103
list_comprehension                       :  0.0087
while_statement                          :  0.0308
for_statement_del                        :  0.0447
#
for_statement_append                     :  0.1141
list_comprehension                       :  0.0882
while_statement                          :  0.2957
for_statement_del                        :  3.8167
$

4_2_list_deletion.py

3. 各書き方の落とし穴

(1) なぜリスト内包表記が速いのか?

for 文で書くと属性参照が生じてしまいますlst.appendPython ではこの属性参照が殊の外重いのです。 そのため通常の for 文 1 よりも属性参照が無いリスト内包表記 2 の方が速くなります。

また 3, 4 の書き方は属性参照や関数呼び出しを行なっています。 実は Python は関数呼び出しも重いのです。 そのため 3, 4 よりも 2 の方が速くなるのです。

(2) なぜ for 文が while 文よりも遅いのか?

一般にプログラミングをしていて「配列」というとロッカーのように付け替えたりできないデータ構造を指している気がします。 反対に「リスト」というと自由に付け替えのできる電車のようなデータ構造を指している気がします。

f:id:domodomodomo:20181216181912p:plain



これによって何が起こるかというと、例えば今回の for 文は index を指定して del しています。 恐らくその度に、配列の要素を1つずつずらす処理が走っているのだと思われます。 del 文は要素数が多くなると悲劇的に遅くなります。

f:id:domodomodomo:20190106010121p:plain



Python の「リスト」は純粋な「リスト」ではなく「配列」に近いものです。 これはデメリットばかりではなく、リストの要素を探したいときは先頭から順番に探すのではなく、 ここ!だと見つけることができます。参照速度は O(1) で参照できます。 これだけだと何言ってるんだ?みたいな感じですが、別の機会でご紹介させてください。

また、末尾に付け足す append, 末尾から取り出す pop が、insert, remove とは別に専用に用意されているのは、 おそらくそのためです。

(3) for 文による実装への道

for 文で書くときは、すこし工夫が必要です。 while 文ではなく for 文でやろうとすると、少し沼にハマります。 新たに del 文, enumerate クラス, reversed クラスの3つを使います。

1. 間違い その1
# 間違いコード
lst = [3, 5, 6, 1, 2, 9, 7, 4, 8, 0]
for e in lst:
    if e % 2 != 0:
        del e

lst
# [3, 5, 6, 1, 2, 9, 7, 4, 8, 0]
# あれ?何も削除できていない (´;ω;`)ブワッ


なぜ、このコードが間違いかと言うと del e が、変数 e を削除しているだけだからです。 例えば次のコードをみてましょう。

# 動作確認コード
lst = [3, 5, 6, 1, 2, 9, 7, 4, 8, 0]
e = lst[3]
e  # 1
del e
e  # NamError

lst
# [3, 5, 6, 1, 2, 9, 7, 4, 8, 0]
# 変数 e を削除しても lst には何の影響もない


では、次のコードを見てみましょう。添字表記で行けば、うまく削除できそうですね。

# 動作確認コード
lst = [3, 5, 6, 1, 2, 9, 7, 4, 8, 0]
del lst[3]

lst
# [3, 5, 6, 2, 9, 7, 4, 8, 0]
# ちゃんと削除できました。
2. 間違い その2

早速書き換えてみました。それでも、間違っています。

# 間違いコード
lst = [3, 5, 6, 1, 2, 9, 7, 4, 8, 0]
for i, e in enumerate(lst):
    if e % 2 != 0:
        del lst[i]

lst
# [5, 6, 2, 7, 4, 8, 0]
# あれ...?


この原因は del lst[i] するたびにリストが短くなっているからです。しかし i は、リストが短くなっても、ループするたびに1ずつ加算されていきます。

# 確認コード
lst = [3, 5, 6, 1, 2, 9, 7, 4, 8, 0]
for i, e in enumerate(lst):
    i, e, lst
    if e % 2 != 0:
        del lst[i]

# lst は短くなっても i は1ずつ増える。
# (i, e, lst)
# (0, 3, [3, 5, 6, 1, 2, 9, 7, 4, 8, 0])
# (1, 6, [5, 6, 1, 2, 9, 7, 4, 8, 0])
# (2, 1, [5, 6, 1, 2, 9, 7, 4, 8, 0])
# (3, 9, [5, 6, 2, 9, 7, 4, 8, 0])
# (4, 4, [5, 6, 2, 7, 4, 8, 0])
# (5, 8, [5, 6, 2, 7, 4, 8, 0])
# (6, 0, [5, 6, 2, 7, 4, 8, 0])
3. 正しいコード

やっと、たどり着きました。 前から削除すると順番がずれるので reversed を使って、後ろから削除します。

# 正しいコード
lst = [3, 5, 6, 1, 2, 9, 7, 4, 8, 0]

def reversed_enumerate(seq):
    return zip(reversed(range(len(lst))), reversed(lst))

for i, e in reversed_enumerate(lst):
    if e % 2 == 1:
        del lst[i]

(4) del 文と pop メソッドの違い

削除する値を使いたいなら pop を使います。削除する値を使わないなら del を使います。

例えば while 文の例では削除した要素を再利用しているので pop を使いました。for 文の例では削除した要素を再利用しないので del を使いました。

del には、オブジェクトを削除すると言うよりも、名前を削除すると言う意味合いが強いです。名前とは変数名 val, 属性名 obj.attr , 添字表記 seq[0] が該当します。

class Person:
    def __init__(self, name):
        self.name = name

a = b = Person('Tom')
a is b
del b  # 変数 b を削除すると
a # オブジェクトそのものは消えないけど
b # NameError, 変数 b は消える
>>> a is b
True
>>>
>>> a
<__main__.Person object at 0x10488b1d0>
>>>
>>> b
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'b' is not defined
>>> 


del 文と pop メソッドを使ったので、おさらいがてらに説明させていただきました。

終わりに

遅いだの速いだの喚き散らかしましたが Python のコードは高速化しなくていいかなと思っています。 自分が読みやすいと思った書き方でやるのが、きっと一番です。その辺の話は、以下の記事で書かせていただきました。 実はこの記事は、以下の記事のサブ記事になります。