Algorithm Search⌘K

Counting inversions by merge sort in Python

Count cross-inversions while merging two sorted halves; runs in O(n log n).

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

from typing import Protocol, Sequence, TypeVar


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


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


def sort_and_count(a: list[T], lo: int = 0, hi: int | None = None) -> int:
    """Return the number of inversions of a[lo:hi] and sort a[lo:hi] in place.

    An inversion is a pair p < q with a[p] > a[q]; equal items are not counted.
    Time O(n log n) and O(n) extra space for n = hi - lo. The recursion depth is
    about lg n, so the Python stack is not a problem.
    """
    if hi is None:
        hi = len(a)
    if hi - lo <= 1:  # @line L1
        return 0
    mid = lo + (hi - lo) // 2  # @line L2
    x = sort_and_count(a, lo, mid)  # @line L3
    y = sort_and_count(a, mid, hi)  # @line L4
    return x + y + merge_and_count(a, lo, mid, hi)  # @line L5


def merge_and_count(a: list[T], lo: int, mid: int, hi: int) -> int:
    """Merge the sorted parts a[lo:mid] and a[mid:hi]; return the pairs (left > right)."""
    b = a[lo:mid]  # @line L6
    i, j, k, c = 0, mid, lo, 0  # @line L7
    while i < len(b) and j < hi:  # @line L8
        if not a[j] < b[i]:  # @line L9: b[i] <= a[j], with only <
            a[k] = b[i]
            i += 1
        else:
            a[k] = a[j]  # @line L10
            j += 1
            c += len(b) - i
        k += 1  # @line L11
    while i < len(b):  # @line L12
        a[k] = b[i]  # @line L13
        i += 1
        k += 1
    return c  # @line L14


def count_inversions(items: Sequence[T]) -> int:
    """Return the number of inversions of items, without changing it."""
    return sort_and_count(list(items))


def smaller_to_the_right(items: Sequence[T]) -> list[int]:
    """For each index p, return the number of q > p with items[q] < items[p].

    This is the per-item variant of the page: sort (value, index) pairs and,
    when a left item is placed, add the number of right items already placed.
    """
    n = len(items)
    counts = [0] * n
    order = list(range(n))
    width = 1
    buf = [0] * n
    while width < n:  # bottom-up merges, so no recursion is needed
        for lo in range(0, n, 2 * width):
            mid = min(lo + width, n)
            hi = min(lo + 2 * width, n)
            i, j, k = lo, mid, lo
            while i < mid and j < hi:
                if not items[order[j]] < items[order[i]]:
                    counts[order[i]] += j - mid
                    buf[k] = order[i]
                    i += 1
                else:
                    buf[k] = order[j]
                    j += 1
                k += 1
            while i < mid:
                counts[order[i]] += j - mid
                buf[k] = order[i]
                i += 1
                k += 1
            while j < hi:
                buf[k] = order[j]
                j += 1
                k += 1
            order[lo:hi] = buf[lo:hi]
        width *= 2
    return counts
test_inversion_count_by_merge_sort.py 55 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/inversion-count-by-merge-sort/. The authors are listed in prov/GBRJQD.jsonl.

import random

from inversion_count_by_merge_sort import count_inversions, smaller_to_the_right, sort_and_count


def brute(a: list[int]) -> int:
    return sum(1 for i in range(len(a)) for j in range(i + 1, len(a)) if a[i] > a[j])


def test_example() -> None:
    a = [7, 5, 3, 1, 8, 6, 4, 2]
    assert sort_and_count(a) == 18
    assert a == [1, 2, 3, 4, 5, 6, 7, 8]
    assert count_inversions([2, 4, 1, 3, 5]) == 3


def test_edges() -> None:
    assert count_inversions([]) == 0
    assert count_inversions([5]) == 0
    assert count_inversions([1, 2, 3, 4]) == 0
    assert count_inversions([4, 3, 2, 1]) == 6
    assert count_inversions([2, 2, 2, 2]) == 0
    assert count_inversions([2, 2, 1]) == 2
    assert count_inversions([1, 3, 2, 3, 1]) == 4


def test_large_count_is_exact() -> None:
    n = 70000
    assert count_inversions(list(range(n, 0, -1))) == n * (n - 1) // 2


def test_input_kept() -> None:
    a = [3, 1, 2]
    assert count_inversions(a) == 2
    assert a == [3, 1, 2]


def test_subrange() -> None:
    a = [9, 3, 2, 1, 0]
    assert sort_and_count(a, 1, 4) == 3
    assert a == [9, 1, 2, 3, 0]


def test_random_against_brute_force() -> None:
    rng = random.Random(4417)
    for _ in range(600):
        a = [rng.randint(0, 12) for _ in range(rng.randint(0, 40))]
        b = list(a)
        assert sort_and_count(b) == brute(a)
        assert b == sorted(a)
        want = [sum(1 for q in range(p + 1, len(a)) if a[q] < a[p]) for p in range(len(a))]
        assert smaller_to_the_right(a) == want

What the next level needs

  • the tests did not run in this build

Read the full page about Counting inversions by merge sort