nikkie-ftnextの日記

イベントレポートや読書メモを発信

SQLでいう「ORDER BY columnA DESC, columnB ASC」のようなソートは、Pythonではどう書く? 安定性を利用した複数回ソート または keyに指定した関数で負の数値を含むタプルを返す

はじめに

ミリアニ7話は水着回! nikkieです1

表題の疑問について、GPTから始めていくつか文献に当たったのですが、「ソート HOW TO」というドキュメントがめちゃ簡潔に回答していて感動したので一本したためます。

目次

前提:key引数を使ったソート

「ソート HOW TO」のStudentクラスにならった例です。
Python 3.11.4で動作確認しています。

python -i sort_example.pyと実行し、スクリプトを実行した(=クラスや変数の定義を読み込んだ)後に対話モードを起動します。

背の順(身長の昇順。小→大)に並び替えてみましょう。

>>> sorted(idols, key=lambda idol: idol.height_cm)
[('yuriko', 'B', 154), ('emily', 'A', 156), ('kotoha', 'A', 157)]

reverse引数をTrueに指定すれば、身長の降順(大→小)となります

今回考えていきたいのは、bloodの昇順にソートし、さらにheight_cmの降順にソート(すなわち、血液型内での降順)です

Pythonのソートは安定!

https://docs.python.org/ja/3/howto/sorting.html#sort-stability-and-complex-sorts

ソートが安定とは、

レコードの中に同じキーがある場合、元々の順序が維持されるということを意味します。

ドキュメントの例ですが

>>> data = [('red', 1), ('blue', 1), ('red', 2), ('blue', 2)]
>>> sorted(data, key=lambda t: t[0])
[('blue', 1), ('blue', 2), ('red', 1), ('red', 2)]

タプルの0番目の要素(文字列)をキーとして並べ替えました。

二つの blue のレコードが元々の順序を維持して、 ('blue', 1) が ('blue', 2) の前にあること注意してください。(原文ママ引用しています

sortedの返り値で[('blue', 2), ('blue', 1), ...]と並んでいない(=元々の順序のままとなっている)のですね!

みんなのPython勉強会で聞いたTimsort2という語とつながりました。

Python では Timsort アルゴリズムが利用されていて、効率良く複数のソートを行うことができます、これは現在のデータセット中のあらゆる順序をそのまま利用できるからです。

Timsortだから安定なのか!(※誤解してるかも)

安定であることを利用したソート

やりたいのは、「bloodの昇順にソートし、さらにheight_cmの降順にソート」です。
安定であることを使って、以下の2ステップに分解します。

  1. まずheight_cmの降順にソート
  2. bloodの昇順にソート
    • ここで安定であることを利用できる(bloodが同じ値なら、1の順序が壊れない)
>>> intermediate = sorted(idols, key=lambda idol: idol.height_cm, reverse=True)
>>> sorted(intermediate, key=lambda idol: idol.blood)
[('kotoha', 'A', 157), ('emily', 'A', 156), ('yuriko', 'B', 154)]

「ソート HOW TO」ではラッパー関数に抽象化しています3

from operator import attrgetter


def multisort(xs, specs):
    for key, reverse in reversed(specs):
        xs.sort(key=attrgetter(key), reverse=reverse)
    return xs
>>> # bloodの昇順にソートし、さらにheight_cmの降順にソート
>>> multisort(idols, [("blood", False), ("height_cm", True)])
[('kotoha', 'A', 157), ('emily', 'A', 156), ('yuriko', 'B', 154)]
>>> # bloodの降順にソートし、さらにheight_cmの昇順にソート
>>> multisort(idols, [("blood", True), ("height_cm", False)])
[('yuriko', 'B', 154), ('emily', 'A', 156), ('kotoha', 'A', 157)]

別解: keyにタプルを返す関数を渡す + 数値を負値にする

Effective Python 第2版』項目14より(GPT-4も返しました)

>>> sorted(idols, key=lambda idol: (idol.blood, -idol.height_cm))
[('kotoha', 'A', 157), ('emily', 'A', 156), ('yuriko', 'B', 154)]

key引数は

比較を行う前にリストの各要素に対して呼び出される関数 (または呼び出し可能オブジェクト) を指定するパラメータ (「Key 関数」)

であり、ソートに使うキー値を返します
上の例ではキー値として2要素のタプルを返しました。
血液型の文字列と、身長をマイナスにした数値からなるタプルです。

元のidolsの並びでキー値を求めると以下のようになります。

  • emily -> ("A", -156)
  • kotoha -> ("A", -157)
  • yuriko -> ("B", -154)

キー値(2要素のタプル)を並び替えると kotoha < emily < yuriko の順です

>>> ("A", -157) < ("A", -156) < ("B", -154)
True

これは今欲しい「bloodの昇順にソートし、さらにheight_cmの降順にソート」した順番ですよね

終わりに

「ソート HOW TO」を中心に、複数の条件でソートする方法を見てきました。

  • Pythonではソートが安定であることを使い、後の条件から逆順でソートを繰り返す
  • 数値が含まれる場合、key引数に渡す関数で数値を負値にする

1つの条件でソートする場合はkey引数で簡単に書けるんですよね。
複数条件はやり方がぱっと分からなかったのですが、今回調べて「ソート HOW TO」に出会えたことで、自分のものにできた感覚があります!

P.S. 例に使ったデータについての補足(仕掛け人活動)

Team 3rdから、田中琴葉さんと七尾百合子さん4でした!5

かわいいですね。

そして、私は仕掛け人なので6、エミリーちゃんです!

かわいいですね。


  1. 他の挨拶が浮かばなかったのでこれを選んだだけで深い意味はないです。信じてもらえないかもしれないですが、水着回はnikkieにとってアニメ視聴における重要な特徴量ではないです!
  2. PEP 20 – The Zen of Pythonを記したTimさんによるソート実装です
  3. attrgetterについては本記事ではなるべく使わないようにしていました。詳しくは「operator モジュール関数」の節をご覧ください
  4. 3話よいです😭
  5. この怪文書が役に立ったぞ