Python Arena
Step 6 of 6

The k smallest and the median

Often you need only part of the order: the three fastest laps, the ten cheapest flights, the median salary. sorted(values)[:k] gives the k smallest, but it sorts the whole list, O(n log n), to keep only a few items. There are cheaper ways.

For one item, min(values) and max(values) go through the list once: O(n). A tempting way to get k items is to take the smallest, remove it, and repeat k times. Each round goes through the whole list again, so that's O(n·k): for the 5,000 smallest of 200,000 values, a billion steps.

heapq.nsmallest and nlargest

The module heapq has two functions made for this. heapq.nsmallest(k, values) returns the k smallest values from the smallest up, and heapq.nlargest(k, values) the k largest from the largest down. Both take key=, like sorted, and items with equal keys come in their original order:

import heapq

laps = [71.2, 68.9, 70.4, 69.5, 72.8, 68.9, 70.1]
print(heapq.nsmallest(3, laps))
print(heapq.nlargest(2, laps))

runners = [("Ann", 70.4), ("Bo", 68.9), ("Cy", 69.5), ("Di", 68.9)]
print(heapq.nsmallest(2, runners, key=lambda runner: runner[1]))
Output
[68.9, 68.9, 69.5]
[72.8, 71.2]
[('Bo', 68.9), ('Di', 68.9)]

They go through the values once and keep the k best seen so far in a heap, a structure that finds and replaces the worst of those k quickly (the module on trees and heaps builds one). Each value costs at most O(log k), so O(n log k) in all, with O(k) extra memory. For a small k that's close to O(n).

Quickselect

To find the value that would be at index k after sorting, the k-th smallest counting from 0, reuse the partition from quicksort. After a partition, the pivot is at its final index p. If p is k, that's the answer. If k is smaller than p, the answer is in the part before the pivot, otherwise in the part after it, and only that part needs more work: the other part is never touched again. That's quickselect.

With good pivots each round halves the part, so the work is about n + n/2 + n/4 + ..., less than 2n: O(n) on average. Bad pivots every time make it O(n²), like quicksort, and the quicksort step's two cures apply here too. Take the middle item as the pivot (or a random one, which no order of the items makes slow every time). And use the three-way partition: with the two-way one, a long run of equal values would come back round after round, one item fewer each time, just as in quicksort. With three parts, all the items equal to the pivot are done in one round.

Since only one part goes on, a loop is enough, with no recursion and no depth limit to worry about:

def partition3(values, low, high, pivot):
    lt = low
    i = low
    gt = high
    while i <= gt:
        if values[i] < pivot:
            values[lt], values[i] = values[i], values[lt]
            lt += 1
            i += 1
        elif values[i] > pivot:
            values[i], values[gt] = values[gt], values[i]
            gt -= 1
        else:
            i += 1
    return lt, gt


def kth_smallest(values, k):
    values = values[:]
    low = 0
    high = len(values) - 1
    while True:
        pivot = values[(low + high) // 2]
        lt, gt = partition3(values, low, high, pivot)
        if k < lt:
            high = lt - 1
        elif k > gt:
            low = gt + 1
        else:
            return pivot


times = [71, 64, 90, 58, 77, 69, 83]
print(kth_smallest(times, 0), kth_smallest(times, 3), kth_smallest(times, 6))
print(times)
Output
58 71 90
[71, 64, 90, 58, 77, 69, 83]

partition3 is the quicksort step's three-way partition, working only on the part from low to high. After it, the items equal to the pivot fill indexes lt to gt, which are their places in the sorted list. If k is among those indexes, the pivot is the answer. If k is smaller, only the part before lt goes on, and if it's bigger, only the part after gt. The pivot comes from the part itself, so at least one item is done each round and the loop ends.

Look at values = values[:]. Partitioning rearranges the list, and a function that quietly reorders its caller's list is a nasty surprise. The copy costs O(n), which keeps the whole O(n).

The median

The median is the middle of the sorted values: half of them are below it and half above. It tells you more about a typical salary or response time than the average, which a few huge values can pull far up. With an odd number of values n, it's the value at index n // 2 of the sorted list. With an even number, it's the average of the two in the middle, at n // 2 - 1 and n // 2. So the median of [40, 15, 25] is 25, and the median of [9, 30, 13, 20] is (13 + 20) / 2 = 16.5. One or two quickselects find it in O(n) on average, without sorting. (Python's statistics.median sorts the values: O(n log n).)

Your task

Challenge 1 of 3

A shop wants its three most expensive products, the most expensive first. Fill in the two blanks: the heapq function, and the index that makes the key a product's price. Products with equal prices keep their order. The program should print:

desk 120
rug 120
chair 85

Challenge 2

A delivery app needs the k shops closest to the customer, who is at the point (0, 0). Write k_closest(shops, k), where shops is a list of (x, y) tuples. Return a list of the k closest shops, the closest first, with shops at the same distance in the order they have in the list. The distance from (0, 0) to (x, y) is the square root of x² + y², but comparing x² + y² gives the same order without a square root. k is from 0 to the number of shops (for 0, return an empty list). It must be fast for the 5,000 closest of 200,000 shops.

For example, k_closest([(3, 4), (1, 1), (-2, 0), (0, 5)], 2) returns:

[(1, 1), (-2, 0)]

Challenge 3

Write median(values) with quickselect, without sorting: for an odd number of values, the one in the middle of the sorted order; for an even number, the average of the two in the middle. values is never empty, and the function must leave it as it is. It must be fast for 100,000 values, also when many of them are equal. Don't use sorted, sort, heapq or statistics.

For example, this program:

print(median([120, 45, 80]))
print(median([300, 90, 15, 60]))

prints:

80
75.0