ろぐれこーど

限界組み込みエンジニアの学習記録とちょっぴりポエム

数値を含む文字列のリストを数値順でソート (numerical sort, python)

pythonにはリストをソートするための関数sorted()がありますが、数値を含む文字列のリストをソートすると意図しないものとなります。

l = [
    "test2",
    "test1",
    "test12",
    "test4",
    "test10"
]
sorted(l)
# ["test1", "test10", "test12", "test2", "test4"] となる

これは数値が文字列として解釈されてしまうためです。これを数値順にソートする(numerical sortとか言うらしい)方法を調べたのでメモしておきます。

結論

以下のサイトまんまです。

stackoverflow.com

関数を定義し、sort()の引数keyを指定します。文字列のパースに正規表現を使うため、reをインポートする必要があります。

import re

def atoi(text):
    return int(text) if text.isdigit() else text

def natural_keys(text):
    return [ atoi(c) for c in re.split(r'(\d+)', text) ]

sorted(l, key=natural_keys)

複数ヶ所に数値が含まれる文字列でもうまいこといきます。

L = [
    "10_22-sample13",
    "10_22-sample2",
    "10_22-sample1",
    "10_23-sample4",
    "10_22-sample10"
]

sorted(L, key=natural_key)
# ['10_22-sample1',
# '10_22-sample2',
# '10_22-sample10',
# '10_22-sample13',
# '10_23-sample4']

とりあえず動かしたければ、atoi()natural_keys()をコピペしておけばなんとかなります。(元サイトには小数に対応したバージョンもあります)

補足説明あれこれ

reによる文字列分割

正規表現ライブラリとしておなじみのreです。正規表現の基礎は以下のサイトがわかりやすいかもです。

murashun.jp

pythonライブラリとしての使い方は以下サイトをよく見ます。

note.nkmk.me

よく使うのはre.search()re.split()でしょうか。re.split()はその名の通り、マッチしたパターンで文字列を分割するメソッドです。

ここでre.split()に指定する表現を()で囲んでグループ化すると、区切り文字列のパターンを含むリストを取得できます。(いまいちどういう理屈かわかっていませんが・・・正規表現ムズカシイ)

s = "11aaa22bb33cde"

re.split(r'\d+', s)
# ['', 'aaa', 'bb', 'cde']
# 区切りの文字列(数値)を含まないリストが返される

re.split(r'(\d+)', s)
# ['', '11', 'aaa', '22', 'bb', '33', 'cde']
# 区切りの文字列を含むリストが返される

文字列だったときと順番が変わらないのが良いですね。これにより、ソートしたい文字列を「数値部分」と「文字列部分」に分割できます。

natural_keys()の処理について

re.split()により、数値と文字列に分割されたリストが返されることがわかりました。natural_keys()ではそのリストの数値部分のみ、int()で整数型にキャストします。

先の例で行くと、natural_keys()に与えた文字列は以下のようになります。atoi()で数値のみキャストしている形ですね。

s = "11aaa22bb33cde"
natural_keys(s)
# ['', 11, 'aaa', 22, 'bb', 33, 'cde']
# 数値はint型になる

1行で処理を書いているのでわかりにくいかもしれませんが、やっていることは以下と同義です。

def atoi(text):
    if text.isdigit():
        return int(text)
    else:
        return text
    
def natural_keys(text):
    l = re.split(r'(\d+)', text)   # textを数値部分と文字列部分に分割
    result = []
    for s in l:
        result.append(atoi(s))     # 数値部分のみint型にキャスト
    return result

引数key

引数keyや関数オブジェクトについて、下のサイトで少し説明しています。

qiita.com

sorted()の引数keyには、関数オブジェクトを渡してソートの基準を定めることができます。より厳密には、「比較を行う前にリストの各要素に対して呼び出される関数」を指定できます。

例えば、いくつかの要素を持つタプルからなるリストを、特定のインデックスをキーとしてソートする処理は以下のようになります。

tl = [("bbb", 5, "C"), ("aaa", 9, "D"), ("ccc", 2, "E")]
sorted(tl, key=lambda x:x[1])   # keyに要素の二番目を返す関数を指定
#  [('ccc', 2, 'E'), ('bbb', 5, 'C'), ('aaa', 9, 'D')]  
# 二番目の数値でソートされた結果が返される

上記処理では、各要素のタプルの二番目の値で比較された結果をsorted()の戻り値として返されます。

要約すると、

  1. リストの各要素を入力としてkeyに指定した関数を実行し、戻り値を得る
  2. 得られた戻り値を使って要素同士を比較し、ソートする

というステップを踏んでいます。

ではkeyにnatural_keys()を指定するとどうなるか。

  1. ソートしたいリストL の要素Sを、「数値部分」と「文字列部分」に分けたリストl を作る
  2. リストl の数値部分のみint型にキャストする
  3. リストl 同士を比較して、要素Sをソートする

という流れになります。リスト同士を比較すると先頭の要素から順に比較され、数値部分は数値の大小でソートされます。文字列部分が全て一緒なら、実質数値のみでソートされて所望の出力が得られるわけですね。

まとめ

re.split()sorted()をうまく使ったきれいなコードだと思いました。こういった処理が簡潔にかけるのがスクリプト言語の強みですね。

当然ですが、文字列部分が異なる要素を持つリストは数値のみでソートできない場合があります。その時は文字列部分をすべて取り除くのが良いですかね。ケースによります。

また上記の方法では小数や負の数を考慮したソートはできません。このケースについては同じサイトで説明されていますが、正規表現が複雑でめんどくさかったのでここでは扱わないことにします・・・利用するだけなら同じようにコピペすれば使えます。