~/data-structures/sorted-containers

Sorted containers

Keep keys in order while they change, and find the nearest key at or below any value in O(log n).

what

A set or map that stays sorted as you add and remove keys. A binary search finds any value's neighbours: the floor below it and the ceiling above it.

use when

The data changes over time, and each query asks for the nearest key at or below (or above) some value.

time

O(log n) per operation in a tree; O(n) insert in a Python list

space

O(n)

You’ll recognise it when

  • Items arrive one at a time, and after each one you need them in order, not just at the end.
  • A query asks for the nearest key at or below a value, or at or above it: the latest price at or before time t, the closest free spot, the first server clockwise from a point.
  • A new interval must not overlap the ones you already have: bookings, memory blocks, ranges that merge as numbers arrive.
  • You need a player’s rank or the top k while scores keep changing.

A heap is the usual mix-up. It also stays ordered as items arrive, but it only shows you the smallest. If you need the neighbours of any value, or to delete any item, you need a sorted container.

The idea

Think of a paper diary. To check whether 2 pm to 3 pm is free, you don’t read the whole week. You find where 2 pm falls, then look at the appointment just before it and the one just after. If the one before ends by 2 and the one after starts at 3 or later, the slot is free. Nothing further away can get in the way.

A sorted container keeps keys in order while you add and remove them, so every “nearest” question becomes a binary search. The answer always sits right next to where the search lands:

  • the floor of x is the largest key <= x;
  • the ceiling of x is the smallest key >= x.

The strict versions, largest < x and smallest > x, are often called lower and higher.

The three languages differ a lot here. C++ and Java have balanced trees built in: std::set and std::map, TreeSet and TreeMap. Every search, insert and delete is O(log n). Python has no balanced tree in its standard library. The usual substitute is a plain list kept sorted with the bisect module: searching is O(log n), but inserting is O(n) because later items shift along. The third-party sortedcontainers.SortedList makes inserts fast too, but it’s often not available in interviews, and this site’s judge doesn’t have it either.

How it works

The example is a calendar that accepts a booking [start, end) only if it overlaps nothing already booked. Bookings are half-open, so one ending at 10 and one starting at 10 don’t clash.

Scroll through the steps and the graphic follows along. The top rows are the sorted container; the bottom is the same bookings on a timeline. Press edit to try your own bookings.

  1. Keep the accepted bookings sorted by start: starts in order, and ends[j] for the booking that starts at starts[j]. In C++ and Java, one map from start to end does both.
  2. For a new booking, binary search for its start: i = bisect_right(starts, start). Every booking before i starts at or before start; every booking from i on starts later.
  3. The booking at i - 1 is the floor: the last one starting at or before start. It clashes if it’s still running at start, that is if ends[floor] > start. Ending exactly at start is fine.
  4. The booking at i, called after, is the first one that starts later. It clashes if it starts before the new one ends: starts[after] < end.
  5. If neither clashes, insert the new booking at index i. The lists stay sorted without any re-sorting.
  6. A clash on the left: [12, 15) starts while [10, 14) is still running, so it’s rejected and nothing changes.
  7. A clash on the right: [14, 17) only touches [10, 14), which is fine, but it runs into [16, 20).
loading sorted-containers…

Why two neighbours are enough: accepted bookings never overlap, so every booking before the floor has ended by the time the floor starts, which is at or before start. Every booking after after starts even later than after does, so if after doesn’t reach into the new booking, nothing beyond it can.

import bisect
class Bookings:
"""Non-overlapping bookings [start, end)."""
def __init__(self):
self.starts = [] # sorted start times
self.ends = [] # ends[j] goes with starts[j]
def book(self, start, end):
# starts[:i] <= start < starts[i:]
i = bisect.bisect_right(self.starts, start)
floor, after = i - 1, i
# the last booking starting at or before `start`
if floor >= 0 and self.ends[floor] > start:
return False
# the first booking starting after `start`
n = len(self.starts)
if after < n and self.starts[after] < end:
return False
self.starts.insert(i, start)
self.ends.insert(i, end)
return True
#include <map>
using namespace std;
// Non-overlapping bookings [start, end).
class Bookings {
map<int, int> cal; // start -> end, sorted by start
public:
bool book(int start, int end) {
// the first booking starting after `start`
auto after = cal.upper_bound(start);
// the one before it is the floor
if (after != cal.begin()) {
auto floor = prev(after);
if (floor->second > start) {
return false;
}
}
if (after != cal.end() && after->first < end) {
return false;
}
cal.emplace_hint(after, start, end);
return true;
}
};
import java.util.TreeMap;
// Non-overlapping bookings [start, end).
class Bookings {
// start -> end, sorted by start
TreeMap<Integer, Integer> cal = new TreeMap<>();
boolean book(int start, int end) {
// the last booking starting at or before `start`
Integer floor = cal.floorKey(start);
if (floor != null && cal.get(floor) > start) {
return false;
}
// the first booking starting after `start`
Integer after = cal.higherKey(start);
if (after != null && after < end) {
return false;
}
cal.put(start, end);
return true;
}
}

In C++ and Java the tree does the searching. upper_bound(start) and higherKey(start) find after directly, and the floor is prev(after) or floorKey(start). Here are the same lookups side by side:

You want C++ set Java TreeSet Python list a
ceiling, >= x lower_bound(x) ceiling(x) a[bisect_left(a, x)]
higher, > x upper_bound(x) higher(x) a[bisect_right(a, x)]
floor, <= x prev(upper_bound(x)) floor(x) a[bisect_right(a, x) - 1]
lower, < x prev(lower_bound(x)) lower(x) a[bisect_left(a, x) - 1]
none found end(), or begin() before prev null index len(a) or -1
pop smallest erase(begin()) pollFirst() a.pop(0), O(n)
keys < x begin() to lower_bound(x) headSet(x) a[:bisect_left(a, x)]

std::map has the same lower_bound and upper_bound. TreeMap names them floorKey, ceilingKey and so on (or floorEntry to get the value too), with headMap and pollFirstEntry for the last two rows. Java’s headMap and tailMap are live views, not copies: removing a key through the view removes it from the map. To add in Python, bisect.insort(a, x) does the search and the insert in one call.

Why it’s O(log n)

std::map and TreeMap are balanced binary search trees (red-black trees in practice). They rebalance after every change, so their height stays at most about 2 log₂ n. A search, insert or delete walks one path from the root down, so each is O(log n).

A Python list is different. bisect is a binary search, so finding the spot is O(log n). But insert and pop(i) shift every later item one place, which is O(n). The shift is one fast block copy, so it’s fine in practice up to about 10⁵ inserts: that many inserts at random places take under a second, while 10⁶ take over a minute.

Operation std::set, TreeSet Python list + bisect
floor, ceiling, contains O(log n) O(log n)
insert, delete O(log n) O(n)
smallest, largest O(log n) or better O(1)
k-th smallest O(n) O(1): a[k]
rank of x O(n) O(log n): bisect_left(a, x)

The last two rows are where the list wins. A tree has no indexes, so finding the k-th key means walking k steps. Space is O(n) either way.

Common mistakes

bisect_left for the floor

bisect_left stops before any copies of x, so the item just left of it is strictly smaller than x. If x itself is in the list, you miss it.

a[bisect_left(a, x) - 1] # ✗ largest key < x
a[bisect_right(a, x) - 1] # ✓ largest key <= x

Not checking that a neighbour exists

In Python, index -1 silently reads the largest item. In C++, prev(s.begin()) is undefined behaviour. In Java, the lookup returns null, and storing it in an int throws a NullPointerException.

i = bisect_right(a, x)
return a[i - 1] # ✗ i == 0 reads a[-1]
return a[i - 1] if i > 0 else None # ✓

Checking only one neighbour

The floor catches a booking that is still running when the new one starts. after catches one that starts inside the new one. Each misses what the other catches, so interval problems need both.

Changing a key in place

If a score changes and you overwrite its entry where it sits, the list is no longer sorted, and every later search can be wrong. Remove the old entry, then insert the new one. A C++ set won’t let you edit a key at all, for the same reason.

board[j] = (-new, name) # ✗ the list may be out of order now
board.pop(bisect_left(board, (-old, name)))
insort(board, (-new, name)) # ✓ remove, then insert

Variations

  • Latest value at or before time t. Keep (time, value) pairs sorted by time and take the floor of t. If times only ever increase, appending keeps the list sorted, so only the lookup needs bisect.
  • Leaderboards. Keep (-score, name) pairs in a sorted list. The top k are the first k items, and a player’s rank is bisect_left(board, (-score, name)) + 1. Negating the score puts the highest first, and equal scores fall back to name order.
  • Merging ranges on insert. Keep disjoint ranges keyed by start. A new value or range can only touch its floor and its ceiling, so merge with those, and keep merging while the new range swallows the next one. A memory allocator does the same when a freed block joins its free neighbours.
  • Wrap-around lookups. For points on a circle, like servers on a hash ring, take the ceiling, and if there is none, wrap round to the smallest key.
  • Duplicates. C++ std::multiset keeps copies: s.erase(s.find(x)) removes one, while s.erase(x) removes them all. Java has no multiset, so use a TreeMap from key to count.

Check yourself

5 quick questions. Pick an answer to see why it's right or wrong.

  1. 1

    Price updates arrive as (time, price) pairs, in no particular order. Between updates, you’re asked: “what was the price at the latest time at or before t?” What fits best?

  2. 2

    What does this print?

    from bisect import bisect_left, bisect_right
    a = [2, 4, 4, 9]
    def floor(x):
    i = bisect_right(a, x)
    return a[i - 1] if i > 0 else None
    def ceiling(x):
    i = bisect_left(a, x)
    return a[i] if i < len(a) else None
    print(floor(4), ceiling(5), floor(1), ceiling(10))
  3. 3

    This C++ function should return the largest key <= x. It works in most tests but sometimes crashes or returns garbage. What’s wrong?

    set<int> s = {10, 20, 30};
    int floorOf(int x) {
    auto it = s.upper_bound(x);
    return *prev(it);
    }
  4. 4

    You call bisect.insort(a, x) for 10⁶ values in random order. bisect is O(log n), yet it takes over a minute. Why?

  5. 5

    What happens when this Java code runs?

    TreeMap<Integer, String> m = new TreeMap<>();
    m.put(10, "a");
    m.put(20, "b");
    int k = m.floorKey(5);
    System.out.println(k);

Practice problems

Solve these right here, in Python, C++ or Java. Tests run as you go.

All 11 problems on this topic

Further reading

esc