0Pricing
Coding Interview Prep · レッスン

比較を使わないソートとPythonのsort()

整数配列に対するカウントソートと基数ソートを学び、組み込みのsort呼び出しでPythonのTimsortが内部的にどう動くかを理解します。

「比較を使わないソートとPythonのsort()」はCoddyKit上の無料Coding Interview Prepレッスンです。 これはレッスン4/4です。 下記で完全なレッスンを無料で読むことができます。その後、ブラウザ内の組み込みコードエディタと24時間対応のAIチューターでハンズオン演習できます。 これはCoding Interview Prep学習パスの一部であり、ウェブとCoddyKitアプリ全体で進捗が同期されます。 Coding Interview Prepコースには全4レッスンが含まれています。

比較ソートにおける O(n log n) の下限

要素同士の比較だけで順序を決定するソートアルゴリズムは、最悪の場合、少なくとも Ω(n log n) 回の比較を必要とします。これは決定木の議論によって証明されます。n 個の要素をソートするには、n! 通りの順序を区別する必要があります。二分決定木(各ノードが比較を表します)には、少なくとも log₂(n!) ≈ n log₂(n) 個のレベルが必要です。この下限を破るには、要素に関する追加情報(たとえば、値がある範囲に収まる整数であること)が必要です。

import math

for n in [5, 10, 100, 1000]:
    lower_bound = n * math.log2(n)
    factorial_log = sum(math.log2(i) for i in range(1, n+1))
    print(f'n={n}: n*log2(n)={lower_bound:.1f}, log2(n!)={factorial_log:.1f}')

# n log n is a tight bound on comparison-based sorting

カウントソート:出現頻度でソート

カウントソートは各値の出現回数を数え、そのカウントからソート済み配列を再構成します。あらかじめ値の範囲 [0, k) を知っている必要があります。時間計算量は O(n + k)、空間計算量は O(k) です。n に対して k が小さい場合(たとえば、0~120歳の年齢や1桁の数をソートする場合)、カウントソートはすべての比較ソートより高速です。k が大きい場合は、O(k) の領域コストによって実用的でなくなります。

def counting_sort(arr, k=None):
    if not arr: return []
    if k is None: k = max(arr) + 1
    count = [0] * k
    for n in arr:
        count[n] += 1
    result = []
    for val, freq in enumerate(count):
        result.extend([val] * freq)
    return result

arr = [4, 2, 2, 8, 3, 3, 1]
print(counting_sort(arr))  # [1, 2, 2, 3, 3, 4, 8]
# O(n + k) where k = 9 (max value + 1)

累積カウントを用いた安定な計数ソート

安定な計数ソート(キーでオブジェクトを並べ替える場合に重要)では、cum[v]が出力における値 v の開始位置を示すように累積カウントを計算します。入力配列を右から左へ走査し、各要素を位置 cum[key] - 1 に配置して、その位置をデクリメントします。これにより安定なソートが実現され、同じキーを持つ要素は元の相対的な順序で並びます。

def counting_sort_stable(arr, k):
    count = [0] * k
    for n in arr: count[n] += 1
    # Cumulative counts: count[v] = first position for value v
    for i in range(1, k): count[i] += count[i-1]
    output = [0] * len(arr)
    # Fill from right to maintain stability
    for n in reversed(arr):
        count[n] -= 1
        output[count[n]] = n
    return output

print(counting_sort_stable([4,2,2,8,3,3,1], 9))
# [1, 2, 2, 3, 3, 4, 8]

基数ソート:桁ごとに並べ替える

基数ソートは、最下位桁(LSD)から最上位桁(MSD)へ、各桁の位置で安定なソート(計数ソートなど)を使って整数を桁ごとに並べ替えます。d 回のパス(桁ごとに1回)が完了すると、配列全体がソートされます。時間計算量は O(d × (n + k)) です。ここで d は桁数、k は基数(通常は10)です。W を上限とする n 個の整数では d = log_k(W) となるため、全体では O(n log_k(W)) になります。

def radix_sort(arr):
    if not arr: return []
    max_val = max(arr)
    exp = 1  # current digit position (1, 10, 100, ...)
    while max_val // exp > 0:
        arr = counting_sort_by_digit(arr, exp)
        exp *= 10
    return arr

def counting_sort_by_digit(arr, exp):
    n = len(arr)
    output = [0] * n
    count = [0] * 10
    for n_ in arr: count[(n_ // exp) % 10] += 1
    for i in range(1, 10): count[i] += count[i-1]
    for n_ in reversed(arr):
        d = (n_ // exp) % 10
        count[d] -= 1
        output[count[d]] = n_
    return output

print(radix_sort([170, 45, 75, 90, 802, 24, 2, 66]))
# [2, 24, 45, 66, 75, 90, 170, 802]

バケットソート:バケットに振り分ける

バケットソートは、値の範囲に基づいて要素を固定数のバケットに振り分け、各バケットをソートし(要素数が少ないバケットには挿入ソートを使います)、最後に連結します。区間 [0, 1) に一様に分布するデータでは、n 個のバケットを使うと平均時間計算量は O(n) になります。計算量は平均で O(n + k)、最悪の場合(すべての要素が1つのバケットに入る場合)で O(n²) です。データの分布が分かっていて、おおよそ一様である場合に特に有効です。

def bucket_sort(arr):
    if not arr: return []
    n = len(arr)
    min_v, max_v = min(arr), max(arr)
    if min_v == max_v: return arr[:]
    buckets = [[] for _ in range(n)]
    # Map each value to a bucket index
    for v in arr:
        idx = int((v - min_v) / (max_v - min_v + 1e-9) * n)
        idx = min(idx, n - 1)
        buckets[idx].append(v)
    result = []
    for bucket in buckets:
        bucket.sort()  # insertion sort for small buckets
        result.extend(bucket)
    return result

print(bucket_sort([0.78, 0.17, 0.39, 0.26, 0.72, 0.94, 0.21]))
# sorted list

PythonのTimsortの仕組み

Pythonの sorted() と list.sort() は、2002年に Tim Peters が設計したTimsortを使用します。Timsortはマージソートと挿入ソートを組み合わせたハイブリッドアルゴリズムです。まず「自然なラン」(すでにソート済みの部分列)を検出し、挿入ソートを使って最大64要素のランを構築します。その後、マージソートの手法でランをマージします。このとき、片方のランが優勢な場合に要素をまとめてスキップするギャロッピングや、ランの長さをスタックに積む処理など、いくつかの最適化を行います。

# Timsort properties:
# - Stable
# - O(n log n) worst case
# - O(n) best case (data already sorted)
# - O(n) auxiliary space
# - Highly optimised for real-world data with runs

import time

# Nearly sorted data: Timsort is extremely fast
nearly_sorted = list(range(10000))
nearly_sorted[-1] = 0  # one mis-placed element

t = time.perf_counter()
not_used = sorted(nearly_sorted)
elapsed = time.perf_counter() - t
print(f'Timsort on nearly-sorted n=10000: {elapsed*1000:.3f} ms')

Pythonのsort()とsorted():主な違い

list.sort()はリストをその場でソートし、Noneを返します。また、リストに対してのみ使用できます。sorted(iterable)は任意のイテラブル(タプル、ジェネレータ、辞書など)に使用でき、新しいリストを返します。どちらも key と reverse パラメータを受け取ります。よくあるバグは、lst.sort()の戻り値を変数に代入し、なぜ None なのか分からなくなることです。元のデータを保持したままソート済みの結果が必要な場合は、必ず sorted() を使用してください。

nums = [3, 1, 4, 1, 5, 9]

# in-place: returns None
result = nums.sort()
print(result)  # None  (common bug!)
print(nums)    # [1, 1, 3, 4, 5, 9]  (modified)

nums2 = [3, 1, 4, 1, 5, 9]
# out-of-place: returns new list
result2 = sorted(nums2)
print(result2)  # [1, 1, 3, 4, 5, 9]
print(nums2)    # [3, 1, 4, 1, 5, 9]  (unchanged)

面接でのカスタムソートキー

Pythonのソートでは、要素ごとに1回評価される key 関数を受け取ります(すべてのペアに対して呼び出されるCのコンパレータとは異なります)。面接でよく使うソートキーには、文字列の長さに対する len、降順にする lambda x: -x、複数キーでソートする lambda x: (x[1], x[0])、大文字と小文字を区別しないソートに使う str.lower があります。Pythonのソートは安定であることが保証されているため、複数キーのソートも正しく動作します。

# Sort by length, then alphabetically
words = ['banana', 'fig', 'apple', 'date', 'kiwi']
print(sorted(words, key=lambda w: (len(w), w)))
# ['fig', 'date', 'kiwi', 'apple', 'banana']

# Sort integers as strings (largest concatenation first)
nums = [3, 30, 34, 5, 9]
print(sorted(map(str, nums), key=lambda a: a*10, reverse=True))
# ['9', '5', '34', '3', '30']  => '9534330'

# Descending sort
print(sorted([3,1,4,1,5], reverse=True))  # [5,4,3,1,1]

面接で各ソートを使う場面

状況に応じて適切なソートを選択します。

  • Pythonのsorted()/list.sort()を使用します:すべての面接問題での基本選択です。Timsortは最適です
  • 計数ソート:値が小さく、範囲が限定された整数(0からkまでで、kが小さい場合)に使用します
  • 基数ソート:ビット幅または桁数が分かっている多数の整数をソートする場合に使用します
  • バケットソート:既知の範囲内で浮動小数点数が一様に分布している場合に使用します
  • マージソートを実装します

# Problem: sort array of 0s, 1s, 2s efficiently
# Counting sort: O(n), O(1) space  (k=3 is tiny)

def sort_012(arr):
    count = [0, 0, 0]
    for n in arr:
        count[n] += 1
    i = 0
    for val in range(3):
        for _ in range(count[val]):
            arr[i] = val; i += 1

arr = [2, 0, 2, 1, 1, 0]
sort_012(arr)
print(arr)  # [0, 0, 1, 1, 2, 2]

ソートせずに並べ替える:ヒープで求めるTop-K

面接問題の多くは、完全なソートを必要とせずに「ソートのような」結果を求めます。上位k個の要素を求める場合、サイズkの最小ヒープを使うと O(n log k) で処理できます。kがnよりはるかに小さい場合、O(n log n) より高速です。k番目に大きい要素を求める場合、クイックセレクトの平均計算量は O(n) です。中央値を求める場合、2つのヒープを使う方法では挿入1回あたり O(log n) です。これらの部分ソートの手法は、完全なソートより高速な代替手段として知っておく価値があります。

import heapq

# Top-k with heap: O(n log k)
def top_k(nums, k):
    return heapq.nlargest(k, nums)  # uses heap of size k internally

print(top_k([3,2,1,5,6,4], 2))    # [6, 5]

# kth largest: quickselect O(n) average
import random
def kth_largest(nums, k):
    def _select(lo, hi, target):
        if lo >= hi: return nums[lo]
        rand_i = random.randint(lo, hi)
        nums[rand_i], nums[hi] = nums[hi], nums[rand_i]
        pivot = nums[hi]; i = lo - 1
        for j in range(lo, hi):
            if nums[j] >= pivot: i+=1; nums[i],nums[j]=nums[j],nums[i]
        nums[i+1],nums[hi]=nums[hi],nums[i+1]
        p = i + 1
        if p == target: return nums[p]
        return _select(lo, p-1, target) if target < p else _select(p+1, hi, target)
    return _select(0, len(nums)-1, k-1)

print(kth_largest([3,2,1,5,6,4], 2))  # 5

複数キーソートにおける安定性

安定性によって、複数キーのソートを正しく行えるようになります。まず副キーで安定にソートし、次に主キーで安定にソートします。主キーが同じ要素については、副キーによる順序が維持されます。この手法はデータベースの ORDER BY col1, col2 や、基数ソート(アルゴリズム全体を正しく動作させるには各桁のパスが安定でなければなりません)で使われます。Pythonのsortは常に安定なので、このパターンを確実に利用できます。

data = [
    ('Alice', 'Math',    90),
    ('Bob',   'Science', 85),
    ('Carol', 'Math',    90),
    ('Dave',  'Science', 90),
]
# Sort by score DESC, then by subject ASC (for ties)
# Step 1: sort by subject (secondary)
data.sort(key=lambda x: x[1])
# Step 2: sort by score DESC (primary, stable)
data.sort(key=lambda x: x[2], reverse=True)
for row in data:
    print(row)
# All score=90 rows: Math before Science (preserved from step 1)

理解度チェック

このレッスンで扱った Data Structures & Algorithms — Coding Interview Prep の概念について、理解度を確認しましょう。

レッスンのまとめ

このレッスンでは、比較ベースのソートの下限は O(n log n) であり、この下限を破るには、値の範囲が限定された整数のような非比較ベースの情報が必要であること、計数ソートは出現頻度を数えることで O(n + k) を達成し、基数ソートは桁を処理して全体で O(d × (n + k)) となり、バケットソートは一様分布を利用して平均 O(n) を達成すること、そしてPythonのTimsortは実用上のデフォルトであり、安定で、最悪計算量が O(n log n)、最良計算量が O(n) で、実際のデータに対して手作業で実装したどの代替手法よりも高速であることを学びました。次は、古典的な二分探索を習得します。

よくある質問

「比較を使わないソートとPythonのsort()」レッスンは無料ですか?

はい。「比較を使わないソートとPythonのsort()」の完全なテキストはこのウェブで無料で読めます。インタラクティブに演習し(組み込みコードエディタと24時間対応のAIチューター)、Coding Interview Prepコースの残りをアンロックするには、CoddyKit PROにアップグレードしてください。 Coding Interview Prepコースには全4レッスンが含まれています。

「比較を使わないソートとPythonのsort()」で何を学びますか?

整数配列に対するカウントソートと基数ソートを学び、組み込みのsort呼び出しでPythonのTimsortが内部的にどう動くかを理解します。 ブラウザで直接実行するハンズオンコードでCoding Interview Prepを演習し、24時間対応のAIチューターがレッスンを進める中での質問に答えます。

Coding Interview Prepを始めるのに経験は必要ですか?

事前経験は必要ありません。CoddyKitのCoding Interview Prepは初級者から上級者向けに構成されているため、ここから始めるか最初から始めて、自分のペースで進むことができます。 これはレッスン4/4です。

「比較を使わないソートとPythonのsort()」レッスンにはどのくらい時間がかかりますか?

ほとんどのCoddyKitレッスンは約5~10分かかります。各レッスンはコンパクトでインタラクティブなので、着実に進歩し、ウェブとアプリ全体で正確に前回の場所から再開できます。

このCoding Interview Prepレッスンでコードを書いて実行できますか?

はい。すべてのCoding Interview Prepレッスンに組み込みコードエディタが含まれているため、ブラウザでリアルコードを書いて実行し、即座のAIフィードバックを取得できます。ローカル設定は不要です。

このコースのすべてのレッスン

  1. バブルソートと挿入ソート
  2. マージソート:分割、整列、マージ
  3. クイックソートとピボット選択
  4. 比較を使わないソートとPythonのsort()
← Coding Interview Prepに戻る