🚀 Alguni Start learning

Unit 4: Sort, Sweep, Grab

What sorting buys you.

Unit 4 of 25 in Competitive programming for kids. Its 4 lessons are Sort First, Think Second, Two Pointers, Grab the Best Thing Now and Fitting In the Most Events — below is everything each one explains, and a question or two from it to try.

Every sample on this page was run through real Python before it shipped, and prints exactly what it says it prints.

This unit opens with a fortnight’s trial of everything — no card needed — or with a family plan, bought in the iPhone app. The first two units of every track are free for ever. Try it in the app.

🗃️ Sort First, Think Second

In a contest, nobody writes a sort

You spent a whole unit of the Python track on bubble sort and merge sort, and that was worth doing — but here you call sort() and think about something else.

It costs about n log n, which for a million items is twenty million steps. Affordable. Two nested loops over the same million is not.

Python

import math

for n in [1000, 100000, 1000000]:
    print(n, n * math.floor(math.log2(n)), n * n)

It prints

1000 9000 1000000
100000 1600000 10000000000
1000000 19000000 1000000000000

Sorting by a part of each item

Contest data is usually pairs — a name and an age, a start and an end, a weight and a value. key says which part to sort on, and reverse=True turns it round.

sorted() gives a new list; .sort() changes the one you have.

Python

people = [("ann", 12), ("bob", 9), ("cal", 15)]

print(sorted(people, key=lambda p: p[1]))
print(sorted(people, key=lambda p: p[1], reverse=True))

It prints

[('bob', 9), ('ann', 12), ('cal', 15)]
[('cal', 15), ('ann', 12), ('bob', 9)]

Sorting puts the answer next to itself

"Which two of these numbers are closest together?" is a question about every pair — a million million of them for a million numbers.

Sort first and the two closest are neighbours, so one pass finds them. That is the whole trick, and it turns up again and again.

Python

numbers = sorted([9, 3, 7, 3, 12])
print(numbers)

gap = numbers[1] - numbers[0]
for i in range(1, len(numbers)):
    gap = min(gap, numbers[i] - numbers[i - 1])

print(gap)

It prints

[3, 3, 7, 9, 12]
0

Why the closest pair must be neighbours

Suppose the two closest numbers had something sitting between them once sorted. Then that middle number is closer to each of them than they are to each other — so they were not the closest pair after all.

A contradiction, so it cannot happen. That kind of one-line argument is what lets you skip work safely.

Try it yourself

Sorting a million items costs roughly how many steps?

  • A million
  • Twenty million
  • A million million
  • A thousand

What does this print?

Python

pairs = [(3, "c"), (1, "a"), (2, "b")]
print(sorted(pairs, key=lambda p: p[0])[0])

Answer them in the app

👉 Two Pointers

Find two numbers that add up to a target

Checking every pair is O(n * n). On a sorted list there is a one-pass way: a finger at each end.

Too small? The left finger has to move right — nothing smaller can help. Too big? The right finger moves left. They can only ever move towards each other, so it is O(n).

Python

numbers = [1, 4, 5, 6, 7, 9, 11]
target = 13

left = 0
right = len(numbers) - 1
while left < right:
    total = numbers[left] + numbers[right]
    if total == target:
        print(numbers[left], numbers[right])
        break
    if total < target:
        left += 1
    else:
        right -= 1

It prints

4 9

Why moving a finger cannot lose the answer

Say the total is too small. The left number is now paired with the biggest partner there is, and it still fell short — so that left number is in no pair that works, ever. Dropping it loses nothing.

That argument is the difference between a fast algorithm and a lucky one.

The same idea as a sliding window

Now: the longest run of neighbours whose total stays under a limit. Push the right edge along, and whenever the total goes over, drag the left edge up until it fits.

Each edge only ever moves right, so together they take 2n steps however wide the window gets.

Python

numbers = [2, 4, 1, 3, 5]
limit = 8

left = 0
total = 0
best = 0
for right in range(len(numbers)):
    total += numbers[right]
    while total > limit:
        total -= numbers[left]
        left += 1
    best = max(best, right - left + 1)

print(best)

It prints

3

A while inside a for is not always n times n

It looks like two nested loops, and it is not. The inner while can only run as many times in total as the left edge can move, and the left edge can move n times in its whole life.

So the whole thing is O(n). Counting total moves rather than moves per round is a habit worth building.

Try it yourself

Why must the list be sorted before the two-finger search?

  • So it looks tidy
  • So that moving a finger reliably makes the total bigger or smaller
  • Because `sort()` is fast
  • It does not have to be

What does this print?

Python

numbers = [2, 3, 6, 8, 10]
target = 11

left = 0
right = 4
steps = 0
while left < right:
    steps += 1
    total = numbers[left] + numbers[right]
    if total == target:
        break
    if total < target:
        left += 1
    else:
        right -= 1

print(numbers[left], numbers[right], steps)

Answer them in the app

🍰 Grab the Best Thing Now

The greedy plan

Take whatever looks best right now, never change your mind, never look back.

It is the fastest kind of algorithm there is and it is wrong surprisingly often. The skill is telling the two apart before submitting.

Where greedy works: making change with real coins

Pay 63 pence with 1, 2, 5, 10, 20 and 50 pence pieces. Take the biggest coin that fits, again and again: 50, 10, 2, 1. Four coins, and no arrangement does better.

Real coin systems are built so that this is true.

Python

def greedy(coins, amount):
    used = 0
    for c in sorted(coins, reverse=True):
        while amount >= c:
            amount -= c
            used += 1
    return used

print(greedy([1, 2, 5, 10, 20, 50], 63))

It prints

4

Where greedy breaks: invented coins

Now the coins are 1, 4 and 5, and you owe 8. Greedy grabs the 5, then needs three 1s: four coins.

Two 4s would have done it. Greedy never considers that, because taking the 5 looked better at the time.

Python

def greedy(coins, amount):
    used = 0
    for c in sorted(coins, reverse=True):
        while amount >= c:
            amount -= c
            used += 1
    return used

def best(coins, amount):
    if amount == 0:
        return 0
    answer = 99
    for c in coins:
        if c <= amount:
            answer = min(answer, 1 + best(coins, amount - c))
    return answer

print(greedy([1, 4, 5], 8), best([1, 4, 5], 8))

It prints

4 2

So how do you know?

You prove it, with an exchange argument: take any best-possible answer, and show you can swap its first choice for the greedy choice without making it worse. If you can, greedy is safe.

If you cannot find that argument in a minute or two, assume greedy is wrong and reach for the search or the DP instead. The next lesson has an argument that works.

Try it yourself

Greedy gives 4 coins and the best answer is 2. What does that tell you?

  • The greedy code has a bug
  • Greedy is the wrong plan for these coins — not every coin set allows it
  • The best answer is wrong
  • You need a bigger coin

What does this print?

Python

def greedy(coins, amount):
    used = 0
    for c in sorted(coins, reverse=True):
        while amount >= c:
            amount -= c
            used += 1
    return used

print(greedy([1, 3, 4], 6))

Answer them in the app

🏆 Fitting In the Most Events

One hall, many bookings

Each event has a start and an end. Two events cannot overlap in the hall. Fit in as many as you can — their lengths do not matter, only the count.

There are three obvious greedy plans. Two of them are wrong.

Wrong plan one: earliest start

Take whatever begins soonest. One long booking at the start eats the whole day.

Python

events = [(1, 10), (2, 3), (4, 5)]

chosen = []
finished = 0
for start, end in sorted(events):
    if start >= finished:
        chosen.append((start, end))
        finished = end

print(chosen)

It prints

[(1, 10)]

Wrong plan two: shortest event

Take the shortest one first. Here the short middle booking blocks two long ones that would have fitted perfectly either side of it.

Python

events = [(0, 10), (9, 11), (10, 20)]

chosen = []
for start, end in sorted(events, key=lambda e: e[1] - e[0]):
    if all(end <= s or start >= f for s, f in chosen):
        chosen.append((start, end))

print(sorted(chosen))

It prints

[(9, 11)]

Right plan: earliest finish

Take the event that ends soonest, then the next one that starts after it, and so on.

It beats both wrong plans on their own data — and unlike them, it can be proved.

Python

def most_events(events):
    count = 0
    finished = 0
    for start, end in sorted(events, key=lambda e: e[1]):
        if start >= finished:
            count += 1
            finished = end
    return count

print(most_events([(1, 10), (2, 3), (4, 5)]))
print(most_events([(0, 10), (9, 11), (10, 20)]))
print(most_events([(1, 3), (2, 5), (3, 9), (6, 8)]))

It prints

2
2
2

The proof, in two sentences

Take any best possible timetable and look at its first event. Swapping it for the one that finishes soonest cannot clash with anything later, because it frees the hall earlier — so the swapped timetable is just as big.

Repeat down the list and you have turned any best answer into the greedy one. So greedy is a best answer too.

Try it yourself

Why is "finishes soonest" the right thing to grab?

  • It is the shortest event
  • It leaves the hall free as early as possible, so it can never block more than another choice would
  • It is the first one in the input
  • It has the smallest start time

What does this print?

Python

events = [(1, 4), (3, 5), (0, 6), (5, 7), (3, 9), (8, 9)]

count = 0
finished = 0
for start, end in sorted(events, key=lambda e: e[1]):
    if start >= finished:
        count += 1
        finished = end

print(count)

Answer them in the app