Algorithm Search⌘K

Introselect in Python

Quickselect that switches to median of medians when partitioning degrades, giving worst-case linear time.

File
introselect.py, 92 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
introselect.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/introselect/. The authors are listed in prov/AQN873.jsonl.

from typing import Protocol, Sequence, TypeVar


class Ordered(Protocol):
    def __lt__(self, other: "Ordered", /) -> bool: ...


T = TypeVar("T", bound=Ordered)


def median3(x: T, y: T, z: T) -> T:
    """Return the median of three items, with at most three comparisons."""
    if y < x:
        x, y = y, x
    if z < y:
        y = z
        if y < x:
            y = x
    return y


def partition3(b: list[T], lo: int, hi: int, x: T) -> tuple[int, int]:
    """Rearrange b[lo .. hi - 1] into items < x, items equal to x, items > x.

    Returns (p, q) with b[lo .. p - 1] < x, b[p .. q - 1] equal to x, and
    b[q .. hi - 1] > x. This is the Dutch national flag partition.
    """
    p, i, q = lo, lo, hi
    while i < q:
        if b[i] < x:
            b[p], b[i] = b[i], b[p]
            p += 1
            i += 1
        elif x < b[i]:
            q -= 1
            b[i], b[q] = b[q], b[i]
        else:
            i += 1
    return p, q


def mom_select(s: list[T], k: int) -> T:
    """Return the item of rank k (1-based) of s by median of medians, in O(n) time."""
    while True:
        n = len(s)
        if n <= 5:
            return sorted(s)[k - 1]
        medians = [sorted(s[i:i + 5])[(min(5, n - i) - 1) // 2] for i in range(0, n, 5)]
        x = mom_select(medians, (len(medians) + 1) // 2)
        less = [y for y in s if y < x]
        greater = [y for y in s if x < y]
        equal = n - len(less) - len(greater)
        if k <= len(less):
            s = less
        elif k <= len(less) + equal:
            return x
        else:
            s, k = greater, k - len(less) - equal


def introselect(a: Sequence[T], k: int, limit: int = 2) -> T:
    """Return the item of rank k (1-based) of a, in O(n) time in the worst case.

    Runs quickselect with a median-of-three pivot and three-way partition.
    After limit partitions in a row that keep more than 3/4 of the range, it finishes
    the current range with median of medians. The input is not changed.
    """
    n = len(a)
    if not 1 <= k <= n:
        raise ValueError("k must be between 1 and len(a)")
    b = list(a)
    lo, hi, t, bad = 0, n, k - 1, 0  # @line L1
    while hi - lo > 1:  # @line L2
        if bad >= limit:  # @line L3
            return mom_select(b[lo:hi], t - lo + 1)  # @line L3
        x = median3(b[lo], b[(lo + hi - 1) // 2], b[hi - 1])  # @line L4
        p, q = partition3(b, lo, hi, x)  # @line L5
        if p <= t < q:  # @line L6
            return x  # @line L6
        old = hi - lo  # @line L7
        if t < p:  # @line L8
            hi = p  # @line L8
        else:
            lo = q  # @line L8
        if 4 * (hi - lo) > 3 * old:  # @line L9
            bad += 1  # @line L9
        else:
            bad = 0  # @line L9
    return b[lo]  # @line L10
test_introselect.py 44 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/introselect/. The authors are listed in prov/AQN873.jsonl.

import random

import pytest

from introselect import introselect, median3, mom_select, partition3


def test_example() -> None:
    a = [31, 41, 59, 26, 53, 58, 97, 93, 23, 84]
    assert [introselect(a, k) for k in range(1, 11)] == sorted(a)
    assert a == [31, 41, 59, 26, 53, 58, 97, 93, 23, 84]


def test_helpers() -> None:
    for x in range(3):
        for y in range(3):
            for z in range(3):
                assert median3(x, y, z) == sorted([x, y, z])[1]
    b = [3, 7, 1, 3, 9, 2, 3, 8]
    p, q = partition3(b, 0, 8, 3)
    assert (p, q) == (2, 5)
    assert sorted(b[:2]) == [1, 2] and b[2:5] == [3, 3, 3] and sorted(b[5:]) == [7, 8, 9]
    assert mom_select(list(range(100, 0, -1)), 37) == 37


def test_edges() -> None:
    assert introselect([5], 1) == 5
    assert introselect([4] * 30, 9) == 4
    assert introselect(list(range(1000)), 500) == 499
    with pytest.raises(ValueError):
        introselect([], 1)


def test_random_against_sorted() -> None:
    rng = random.Random(1997)
    for _ in range(1500):
        n = rng.randint(1, 100)
        a = [rng.randint(-20, 20) for _ in range(n)]
        k = rng.randint(1, n)
        limit = rng.choice([0, 1, 2, 3])
        assert introselect(a, k, limit) == sorted(a)[k - 1]

What the next level needs

  • the tests did not run in this build

Read the full page about Introselect