skip to content

bisect and Sorted-List Maintenance

Holding a list sorted and querying it in O(log n) with bisect, including the left/right split that decides where duplicates land. Interviewers check that you know insort still pays O(n) to shift.

part ofPythonoverview, primer and where to startread it →
on this pageshow

questions

4

How do bisect.bisect_left and bisect.bisect_right differ on duplicate values?

level: middleimportance: must knowfreq 55%

answer

  1. Both return an insertion index
  2. They differ only on equal values
  3. One lands before the run, one after
  4. The pair brackets the duplicates
  5. Their difference counts occurrences

basics

~20 s

Both return an index where the value could be inserted keeping the list sorted. On a run of equal values bisect_left returns the index before the run and bisect_right just after it, bracketing the duplicates.

solid answer

~40 s

For `a = [10, 20, 20, 20, 30]`, `bisect.bisect_left(a, 20)` is 1 and `bisect.bisect_right(a, 20)` is 4. The invariants say it precisely: after `bisect_left` everything to the left is strictly less than x and everything from the index on is greater than or equal; after `bisect_right` the left part is less than or equal and the right part strictly greater. Two consequences carry most interviews. The slice between the two indices is exactly the run of equal values, so `right - left` counts occurrences in O(log n). And membership is `i = bisect_left(a, x)` followed by `i != len(a) and a[i] == x` — bisect itself never signals absence, it just returns where the value would go. `bisect.bisect` and `bisect.insort` are aliases for the right-hand variants.

code

pycon · 10 lines
pycon
>>> import bisect
>>> a = [10, 20, 20, 20, 30]
>>> bisect.bisect_left(a, 20)
1
>>> bisect.bisect_right(a, 20)
4
>>> a[bisect.bisect_left(a, 20):bisect.bisect_right(a, 20)]
[20, 20, 20]
>>> bisect.bisect_right(a, 20) - bisect.bisect_left(a, 20)
3

go deeper

for a junior

Recall that both functions return a position where the value would be inserted, never a boolean and never -1. Knowing that bisect_left points at the first equal element and bisect_right just past the last is the expected floor.

for a middle

Explain the invariants in both directions and derive the standard idioms live: bracketing a run, counting occurrences by subtraction, and a membership test with its length guard. Know that bisect and insort are the right-hand aliases.

for a senior

Show judgement about when a sorted list is the right structure at all versus a set or dict, and flag the real hazards: unchecked sortedness, expensive lt on rich elements, and tuple comparison falling through to an unorderable payload on ties.

for a principal

Own the guidance for the codebase: where ordered access genuinely pays over hashing, whether ties must be stable, and whether a hand-maintained sorted list should be replaced by an indexed store once ordering becomes a correctness requirement rather than a convenience.

### Insertion point, not position Both functions answer the same question — *where would x go?* — and neither reports whether x is present. The result is always an integer in `0..len(a)`: `0` when x sorts below everything, `len(a)` when it sorts above everything. Nothing raises, and nothing returns a sentinel like -1. The two differ only in how they treat elements that compare **equal** to x, and the documented invariants are the cleanest way to remember which is which. For `i = bisect_left(a, x)`: every element of `a[:i]` is strictly less than x, and every element of `a[i:]` is greater than **or equal to** x. For `i = bisect_right(a, x)`: every element of `a[:i]` is less than **or equal to** x, and every element of `a[i:]` is strictly greater. So `bisect_left` lands on the first element not less than x, and `bisect_right` lands one past the last element not greater than x. If x is absent, the two agree; the split only becomes visible once duplicates exist. ```pycon >>> import bisect >>> a = [10, 20, 20, 20, 30] >>> bisect.bisect_left(a, 20), bisect.bisect_right(a, 20) (1, 4) ``` ### The four idioms that follow **Extract the run.** `a[bisect_left(a, x):bisect_right(a, x)]` is exactly the block of values equal to x. On the list above that is `[20, 20, 20]`. **Count occurrences.** `bisect_right(a, x) - bisect_left(a, x)` is the number of copies, computed in two logarithmic searches rather than a scan. Getting the subtraction backwards yields a negative number, which is a fine self-check. **Test membership.** `i = bisect_left(a, x); found = i != len(a) and a[i] == x`. The length guard matters: when x sorts above everything, `i == len(a)` and indexing would raise `IndexError`. If membership is *all* you need, a `set` is the better structure — bisect earns its keep when you also want order, ranges or neighbours. **Find the last value at or below x.** `bisect_right(a, x) - 1` is the index of the greatest element not exceeding x, the standard "most recent reading at or before this timestamp" lookup. A result of `-1` means there is none, so guard it rather than letting the negative index wrap to the end of the list. ### The insort side of the split `bisect.insort_left` and `bisect.insort_right` place a new item on the corresponding side of any equal-comparing items already present. For plain scalars the choice is invisible — one `20` is indistinguishable from another. It becomes observable the moment the items compare equal but differ in payload: tuples ordered by a leading timestamp, or records searched with a `key=` function that ignores the rest of the object. There, `insort_right` appends after the existing equals and preserves arrival order among ties (FIFO), while `insort_left` puts the newcomer in front of them (LIFO). Say which behaviour you want; the default alias `insort` is `insort_right`. One trap with tuples: if the leading fields tie, comparison falls through to the next field. Should that field be an unorderable payload — a dict, say — the insert raises `TypeError` only on the unlucky duplicate, so the bug shows up in production rather than in the happy-path test. Give the tuple a monotonically increasing tiebreaker before the payload. ### Bounds, and what is not checked Both functions take `lo` and `hi` to restrict the search to `a[lo:hi]` without slicing, which matters because a slice copies the segment and would swamp the logarithmic search. They also assume ascending order and never verify it: handed unsorted input they return a number computed from a false premise, with no exception at all. Finally, these functions call `__lt__` on the elements, so the cost per comparison is the element's own comparison cost. Deeply nested tuples or objects with an expensive `__lt__` turn a cheap logarithmic search into a measurable one; extracting a scalar key is the usual remedy. ### The names in other languages If it helps anchoring: `bisect_left` is the classic lower bound and `bisect_right` the upper bound found in other standard libraries. Interviewers sometimes use those names, and recognising the mapping is worth a sentence. ### Why an insertion point rather than a found index Returning a position that is always valid, present or absent, is what makes the API compose. One call answers membership, the predecessor, the successor, the count of equals and the place to insert, all by arithmetic on the same integer — no exception handling, no sentinel to test for, no second search. Contrast `list.index`, which raises `ValueError` when the value is missing and scans linearly when it is not. The price is that you must decide for yourself what absence means in your case, and that decision is exactly the `a[i] == x` guard people forget.

  • How do you find the most recent reading at or before a given timestamp in a sorted list?
    `i = bisect.bisect_right(times, t) - 1`. `bisect_right` lands one past the last entry not greater than t, so stepping back one gives that entry — and it correctly includes an exact match at t. Guard for `i < 0`, which means every recorded timestamp is later than t; without the guard the negative index silently wraps around to the end of the list.
  • Which insort variant keeps arrival order among items that compare equal?
    `bisect.insort_right`, which places the new item after the existing equals — the plain `bisect.insort` alias does the same. `insort_left` puts it in front of them, giving last-in-first-out order among ties. The difference is only observable when items compare equal but carry different payloads, such as tuples ordered by a leading timestamp or records searched with a key function.
  • What do the lo and hi parameters buy you?
    They restrict the search to `a[lo:hi]` without building a slice. That matters because slicing copies the segment, which is O(n) and would dwarf the logarithmic search. They are also how you search successive partitions of one large sorted list — a per-shard window — while keeping the returned index valid against the original list.

Arriving at a queue of people with identical tickets: bisect_left points at the head of that group, bisect_right at the empty spot just behind its tail.

saying these in an interview costs you the question

  • Says bisect returns -1 when the value is missing
  • Thinks bisect_right returns the index of the value itself
  • Claims the left/right choice never changes anything
  • Assumes bisect validates that the list is sorted
  • Counts duplicates by scanning forward from the index
  • Indexes a[i] after bisect_left without a length guard

context

open as a page

How does bisect.bisect_right turn a numeric score into a letter grade?

level: juniorimportance: should knowfreq 35%

basics

~10 s

Keep the bucket boundaries in one ascending list and the labels in a parallel sequence. bisect.bisect_right returns how many boundaries the score has passed, and that count is the index of its label.

open as a page

Why does bisect.insort slow down as an email-digest sender's pending list grows?

level: seniorimportance: should knowfreq 40%

basics

~20 s

Finding the slot is logarithmic, but placing the item is not. A Python list stores its elements contiguously, so inserting in the middle shifts every later entry, making each insort cost time proportional to the list length.

open as a page

In bisect, how does the key= parameter added in Python 3.10 treat the x argument?

level: middleimportance: nice to knowfreq 18%

basics

~20 s

The search functions apply key only to list elements, never to x, so you pass an already-extracted key such as 45. The insort functions are the exception: they apply key to the item being inserted.

open as a page