Sorting Field Notes
Insertion sort, merge sort, quick sort, bucket sort. Each one traced on the exact arrays from the videos, with every swap drawn. After this page you can write all four from memory, say their Big-O, and explain which one to pick and why.
0Why four sorts, not one
Sorting a hand of five playing cards: you pick up each new card and slide it left until it fits. That is insertion sort. Sorting a full deck of 52: you split it into two piles, give one to a friend, each of you sorts a half, then you merge the two sorted piles. That is merge sort. Same job, different tool, because the size of the job changed.
Sorting means "rearrange a list so each item is less than or equal to the next one". Every sort in this topic is built from two moves: compare two values, and move a value. The four algorithms differ in which values they compare and how far they move them. That choice decides the running time, the memory used, and whether equal items keep their original order.
The videos build the algorithms in this order because each one fixes a weakness of the one before. Insertion sort is simple but O(n²). Merge sort is O(n log n) always, but needs extra memory. Quick sort is O(n log n) on average with no extra memory, but can degrade to O(n²). Bucket sort is O(n), but only when the values live in a small, known range.
1Insertion sort
Simple, easy to implement and understand. Not efficient for large data sets. It is the sort to learn first because the idea of a sorted portion that grows shows up again in merge sort.
The idea: a sorted part that grows by one
Given [2,3,4,1,6], the goal is [1,2,3,4,6]. Insertion sort sorts portions of the array at a time. An array of size 1 is already sorted (there is nothing to compare it with). So we assume the first element is sorted: it is its own subarray. The next subarray is size 2 from the start, [2,3,...]: compare the two. Then size 3, and so on, until the sorted portion is the whole array.
For the full array we need two pointers, i and j:
ipoints at the element we are currently inserting into the sorted portion.jstarts one index to the left ofi.- Goal: find the position where
arr[i]should be inserted into the sorted portion. - Keep swapping it with
arr[j]until we find the correct position; after each swap shiftjleft by 1. - Stop once the element is greater than or equal to the element to its left.
def insertionSort(arr):
# Traverse through 1 to len(arr)
for i in range(1, len(arr)):
j = i - 1
while j >= 0 and arr[j + 1] < arr[j]:
# arr[j] and arr[j + 1] are out of order so swap them
tmp = arr[j + 1]
arr[j + 1] = arr[j]
arr[j] = tmp
j -= 1
return arr
The three tmp lines are NeetCode's swap. In Python you write the same thing as one line: arr[j], arr[j+1] = arr[j+1], arr[j]. Both versions are tested.
def insertion_sort_py(arr):
for i in range(1, len(arr)):
j = i - 1
while j >= 0 and arr[j + 1] < arr[j]:
arr[j], arr[j + 1] = arr[j + 1], arr[j] # tuple swap, no tmp
j -= 1
return arr
Note the condition arr[j + 1] < arr[j]. arr[j + 1] is the element we are inserting; it moves left one slot per swap, so its index is always j + 1. This works on any data type as long as two values can be compared: numbers, strings, tuples. Insertion sort is easier to write iteratively; the next two sorts are recursive.
i=3 is the only pass on this array that swaps. The value 1 (orange) bubbles left one slot per swap while j walks left. The loop stops when j falls off the left edge, or when the element to the left is not bigger.Pass i | Inserting | Sorted part before | Compares | Swaps | Array after |
|---|---|---|---|---|---|
| 1 | 3 | [2] | 3 < 2? no | 0 | [2, 3, 4, 1, 6] |
| 2 | 4 | [2, 3] | 4 < 3? no | 0 | [2, 3, 4, 1, 6] |
| 3 | 1 | [2, 3, 4] | 1 < 4, 1 < 3, 1 < 2, then j = -1 | 3 | [1, 2, 3, 4, 6] |
| 4 | 6 | [1, 2, 3, 4] | 6 < 4? no | 0 | [1, 2, 3, 4, 6] |
The outer loop starts at i = 1, not 0. Index 0 is the sorted portion of size 1. Starting at 0 gives j = -1 immediately and the while condition j >= 0 fails, so it would not crash, but it is a wasted turn and it shows you did not understand the setup.
Stability: do equal values keep their order?
Stable means "the relative order of equal elements is preserved after sorting". Take [7,3,7]. There are two 7s, at index 0 and index 2. Call them 7a and 7b so we can tell them apart. Pass i=1: 3 swaps with 7a. Pass i=2: is 7b < 7a? No, they are equal, so the while loop stops. The two 7s keep their relative order. Insertion sort is stable. Not all sorts are; quick sort below is not.
< in the while condition. An equal element never swaps past its twin. If you wrote <= instead, 7b would jump over 7a and the sort would become unstable. Same output for plain numbers, different output when the 7s are records like (7, "Ann") and (7, "Bob").Time and space
- Best case, O(n): the array is already sorted. The inner while loop never executes; the outer loop still visits each element once.
- Worst case, O(n²): the inner loop executes up to
ntimes for each of thenelements. The worst-case input is an array sorted in reverse (descending) order: every new element must travel all the way to the front. On[5,4,3,2,1]that is 4 + 3 + 2 + 1 = 10 swaps, and in general n(n-1)/2. - Average case (random order): still O(n²). On average each element travels about halfway, and n²/4 is still O(n²).
- Space, O(1): it sorts in place (inside the original array) and allocates no additional data structure. Only
i,j,tmp.
NeetCode writes insertion sort as O(n²)* in the final table. The star means "but O(n) if the input is already or nearly sorted". Interviewers like this: if you are given a sorted array with one new item at the end, insertion sort finishes in one pass, and merge sort still does all its n log n work.
2Merge sort
Extremely common. Many language standard libraries use it or a variant of it (Python's sorted() is a merge sort hybrid, see Patterns). Idea: keep splitting the array into halves until each subarray has size one, then sort by merging two sorted subarrays at a time, working back up.
You have 1,000 exam papers to sort by student number. Split the pile in two and hand each half to a helper. Each helper splits again, and so on, until every person holds one paper (which is "sorted"). Then pairs of people merge their piles: look at the top paper of each pile, take the smaller, repeat. Piles double in size at each step until one sorted pile remains.
This is divide and conquer: divide the problem into subproblems, solve them, then combine the solutions. The recursion has two branches (left half, right half), like Fibonacci from the recursion topic.
mergeSort(): the recursive part is short
# Implementation of MergeSort
def mergeSort(arr, s, e):
if e - s + 1 <= 1:
return arr
# The middle index of the array
m = (s + e) // 2
# Sort the left half
mergeSort(arr, s, m)
# Sort the right half
mergeSort(arr, m + 1, e)
# Merge sorted halfs
merge(arr, s, m, e)
return arr
- Base case: length
e - s + 1is 1 or less. Already sorted, return. - Compute the middle index
m = (s + e) // 2. - Recursively
mergeSortthe left halfs..mand the right halfm+1..e. - Merge the two sorted halves with
merge().
s and e are inclusive start and end indexes. You call it as mergeSort(arr, 0, len(arr) - 1). Passing indexes instead of slicing keeps everything in the one original array.
merge() for a subarray does not run until both recursive mergeSort() calls on it have returned. So [3,2] is merged into [2,3] (step ①, the mergeSort(arr, s, m) branch) before the code even looks at [4] (the mergeSort(arr, m+1, e) branch). Merging happens at every level of the recursion, not only at the bottom row.merge(): the three-pointer part does the work
Two sorted halves sit next to each other in arr: s..m and m+1..e. Copy them into temporary lists L and R, then walk both with one pointer each and write the smaller value back into arr.
ktracks where the next element inarrmust be placed. Starts ats.ipoints to the element in the left subarrayLbeing compared to thejelement in the right subarrayR.- One of
iorjincrements depending on which element is smaller;kincrements regardless.
# Merge in-place
def merge(arr, s, m, e):
# Copy the sorted left & right halfs to temp arrays
L = arr[s: m + 1]
R = arr[m + 1: e + 1]
i = 0 # index for L
j = 0 # index for R
k = s # index for arr
# Merge the two sorted halfs into the original array
while i < len(L) and j < len(R):
if L[i] <= R[j]:
arr[k] = L[i]
i += 1
else:
arr[k] = R[j]
j += 1
k += 1
# One of the halfs will have elements remaining
while i < len(L):
arr[k] = L[i]
i += 1
k += 1
while j < len(R):
arr[k] = R[j]
j += 1
k += 1
L = [2,3,4], R = [1,6], k starts at s = 0. The grey arrow is the previous step; the orange one is the current step. The i/j walk is the two pointer technique from the arrays topic: it combines two sorted arrays in O(n + m) time because each pointer only ever moves forward.| Step | Compare L[i] <= R[j] | Take | i | j | k | arr after |
|---|---|---|---|---|---|---|
| 1 | 2 ≤ 1? no | R[0] = 1 | 0 | 1 | 1 | [1, _, _, _, _] |
| 2 | 2 ≤ 6? yes | L[0] = 2 | 1 | 1 | 2 | [1, 2, _, _, _] |
| 3 | 3 ≤ 6? yes | L[1] = 3 | 2 | 1 | 3 | [1, 2, 3, _, _] |
| 4 | 4 ≤ 6? yes | L[2] = 4 | 3 | 1 | 4 | [1, 2, 3, 4, _] |
| 5 | i = len(L), first loop ends | R[1] = 6 (leftover loop) | 3 | 2 | 5 | [1, 2, 3, 4, 6] |
The two "leftover" loops at the end are not optional. When one half runs out, the other half still has elements, and they are already sorted, so they are copied straight in. Only one of the two loops actually runs on any given call. Forgetting them is the most common merge sort bug: the output has the right length but the last few slots keep their old values.
The same algorithm in idiomatic Python
In interviews people often write the slicing version: it returns a new list instead of sorting in place. Same algorithm, same Big-O, and easier to read. Know both; the index version is what NeetCode shows and it is what you need for the in-place variants.
def merge_sort_py(arr):
if len(arr) <= 1:
return arr
m = len(arr) // 2
left = merge_sort_py(arr[:m]) # sorted copy of the left half
right = merge_sort_py(arr[m:]) # sorted copy of the right half
out, i, j = [], 0, 0
while i < len(left) and j < len(right):
if left[i] <= right[j]:
out.append(left[i]); i += 1
else:
out.append(right[j]); j += 1
out.extend(left[i:]) # leftovers, one of these is empty
out.extend(right[j:])
return out
Time: O(n log n), and where the log comes from
Count the levels of the recursion. Each level halves the length: n, then n/2, then n/4, ... down to 1. If it takes x halvings to reach 1, then n / 2x = 1, so n = 2x, so x = log2 n. There are log n levels. Now count the cost per level: at any level, the merges together touch every one of the n elements exactly once (compare and place), so each level costs n. Total = levels × cost per level = n log n.
Space: O(n)
Two things use memory. The call stack is as deep as the tree is tall: log n frames. The temporary lists L and R: at any single moment the temp arrays in use add up to at most n elements. Total O(n + log n) = O(n). It is a sum, not a product, because the temp arrays are not all allocated at the same time; each merge() frees its L and R when it returns.
Stability
Merge sort is stable. Look at the comparison: if L[i] <= R[j]. On a tie we take the left element first. Everything in L came before everything in R in the original array, so equal elements keep their relative order. Change <= to < and the sort becomes unstable.
Insertion sort is O(n²) in the worst case. Merge sort is O(n log n) in the worst, average, and best case, so merge sort is superior for general input. Insertion sort is preferred only when the array is small, or already or nearly sorted, because then it skips almost all the swapping and finishes in O(n). For n = 1,000,000: n² is 1012, n log n is about 2 × 107. Fifty thousand times fewer steps.
3Quick sort
Another divide and conquer sort. Idea: pick one element and call it the pivot. Partition the array so that every value to the left of the pivot is less than or equal to it, and every value to the right is greater. Then recurse on the left part and on the right part.
Lining up students by height with no measuring tape. Pick one student (the pivot) and say "shorter than her, stand on the left; taller, stand on the right". She is now in her final spot. Repeat the same trick inside the left group and inside the right group. Nobody ever needs to be merged back together, because each group is already on the correct side.
Picking a pivot
Options: the first index; the last index; the median of the first, last and middle values; or a random index. Ideally the pivot divides the array into two roughly equal halves, because then the recursion is log n deep. This lesson picks the last element, which keeps the code short.
Recursive partitioning
Partition so that elements <= pivot are on the left and the rest on the right, then recursively quick sort the left part and the right part until the base case (size 1). Unlike merge sort there is no merge step: partitioning alone sorts the array, because after each partition the pivot sits at its final index and nothing ever crosses it again. Quick sort is, in a sense, the opposite of merge sort. Merge sort has a simple recursive step and a complex merge(); quick sort puts the complexity into the partition and has trivial recursion.
Partition example: [6,2,4,1,3], pivot = 3 (the last element). The result has all elements smaller than the pivot on the left and the rest on the right. Then run recursively until the base case. No new memory is allocated; only the s and e indexes change to work on a smaller part of the original array.
# Implementation of QuickSort
def quickSort(arr: list[int], s: int, e: int) -> list[int]:
if e - s + 1 <= 1:
return arr
pivot = arr[e]
left = s # pointer for left side
# Partition: elements smaller than pivot on left side
for i in range(s, e):
if arr[i] < pivot:
tmp = arr[left]
arr[left] = arr[i]
arr[i] = tmp
left += 1
# Move pivot in-between left & right sides
arr[e] = arr[left]
arr[left] = pivot
# Quick sort left side
quickSort(arr, s, left - 1)
# Quick sort right side
quickSort(arr, left + 1, e)
return arr
Read left as "the first slot that is not yet known to be smaller than the pivot". Every time i finds a small value, it swaps that value into slot left and moves left one to the right. When the loop ends, slots s..left-1 hold the small values, so left is exactly where the pivot belongs.
left only moves when a small value is found. Between left and i sit the values already known to be bigger than the pivot (6, then 6 and 4). Each swap throws one small value over that group. The final swap ③ drops the pivot into slot left, exactly between the two groups. Note the pivot is not sorted with its neighbors yet: 3 is between [2,1] and [6,4], both still unsorted.i | arr[i] | arr[i] < 3? | Action | left after | Array after |
|---|---|---|---|---|---|
| 0 | 6 | no | none | 0 | [6, 2, 4, 1, 3] |
| 1 | 2 | yes | swap arr[0] and arr[1] | 1 | [2, 6, 4, 1, 3] |
| 2 | 4 | no | none | 1 | [2, 6, 4, 1, 3] |
| 3 | 1 | yes | swap arr[1] and arr[3] | 2 | [2, 1, 4, 6, 3] |
| - | - | loop ends | arr[4] = arr[2]; arr[2] = 3 | 2 | [2, 1, 3, 6, 4] |
The recursion then works on each side. Both are size 2, so one more partition each and the array is sorted.
| Call | Subarray | Pivot | Partition result | Next calls |
|---|---|---|---|---|
| quickSort(arr, 0, 4) | [6, 2, 4, 1, 3] | 3 | [2, 1, 3, 6, 4] | (0, 1) and (3, 4) |
| quickSort(arr, 0, 1) | [2, 1] | 1 | [1, 2] | (0, -1) and (1, 1): both base case |
| quickSort(arr, 3, 4) | [6, 4] | 4 | [4, 6] | (3, 2) and (4, 4): both base case |
| result | [1, 2, 3, 4, 6] | |||
The base case e - s + 1 <= 1 also catches e < s, which happens when the pivot lands at the very start or end (empty side). quickSort(arr, 0, -1) above is such a call. Without the <=, an empty side would read arr[e] with a bad index.
Time and space
The partition loop is O(n) per level: i visits every element once. How many levels? In the best case the pivot is the median and splits the array in the middle every time, so there are log n levels, and the total is O(n log n), the same picture as merge sort's levels diagram. In the worst case the pivot is always the smallest or largest element. Then one side has n - 1 elements and the other has 0. The recursion tree is a chain of height n, each level still doing O(n) partition work: n + (n-1) + (n-2) + ... = O(n²).
left never moves and the "left part" is the whole array minus one. The tree becomes a chain of height n instead of a bushy tree of height log n. Space follows the height: O(log n) stack frames in the best case, O(n) in the worst.Randomized quick sort
The fix is to not let the input decide the pivot. Pick the pivot randomly, or shuffle the array first. A sorted array is then no worse than any other. This gives an expected O(n log n) instead of the typical worst case O(n²). The worst case is still possible, but only by bad luck, not by bad input. The code changes by two lines: swap a random element into the last slot, then partition exactly as before.
import random
def quick_sort_random(arr, s, e):
if e - s + 1 <= 1:
return arr
r = random.randint(s, e) # any index in s..e
arr[r], arr[e] = arr[e], arr[r] # move it to the end, so the rest is unchanged
pivot = arr[e]
left = s
for i in range(s, e):
if arr[i] < pivot:
arr[left], arr[i] = arr[i], arr[left]
left += 1
arr[e], arr[left] = arr[left], arr[e]
quick_sort_random(arr, s, left - 1)
quick_sort_random(arr, left + 1, e)
return arr
Stability: quick sort is not stable
Quick sort exchanges non-adjacent elements, so an element can jump over an equal element. The lesson's example is [7,3,7,4,5] with pivot 5 (the last index). Tag the 7s as 7a (index 0) and 7b (index 2) and watch the partition loop.
i=3 is the unstable move. It exchanges index 1 and index 3, which are not neighbors, so 7a leaps over 7b: the array is [3,4,7b,7a,5], the two 7s reversed. In this small example the final pivot swap happens to throw 7b to the end and the order comes back, but the algorithm never tries to protect it. Run it on [3,4,7a,7b] with pivot 7b: the pivot swap exchanges index 2 and 3 and the output is [3,4,7b,7a], unstable for good. The check script confirms both runs.Sort a table of employees by department after it is already sorted by name. A stable sort gives you names in order inside each department for free. An unstable sort scrambles them. When an interviewer asks "is your sort stable?", the answer for insertion and merge is yes, for quick sort and the counting version of bucket sort it is no.
4Bucket sort
The last sort in the course. Not as widely used as the others, but important, because it is the one sort that beats O(n log n). It works when the values are within a specific, known range.
Sorting a bag of coins by value. You do not compare coins with each other. You put three cups on the table labeled 1, 2 and 5, drop each coin in its cup, then pour the cups out in order: all the 1s, then all the 2s, then all the 5s. The cups are the buckets. It only works because you knew in advance there are exactly three kinds of coin.
The idea: count, then write back
Take an array of size 6 with values in the inclusive range 0-2. Create a "bucket" for each possible value: 0, 1, 2. A bucket here is just a position in a small array that stores the frequency (count) of that value. After counting, overwrite the original array with each value repeated count times, in order.
def bucketSort(arr):
# Assuming arr only contains 0, 1 or 2
counts = [0, 0, 0]
# Count the quantity of each val in arr
for n in arr:
counts[n] += 1
# Fill each bucket in the original array
i = 0
for n in range(len(counts)):
for j in range(counts[n]):
arr[i] = n
i += 1
return arr
itracks the next insertion position inarr.ntracks the current position incounts, which is also the value being written.jtracks how many timescounts[n]has been written.
Example: arr = [2,1,2,0,0,2]. Counting gives counts = [2,1,3]: two 0s, one 1, three 2s. Then n=0: the inner loop runs 2 times, writing arr[0] and arr[1] = 0. Continue for each number and the array becomes [0,0,1,2,2,2].
counts, so "where does this go" is answered by arithmetic, not by comparing. The orange arrows are the write-back: counts[0] = 2 sends two 0s, counts[1] = 1 sends one 1, counts[2] = 3 sends three 2s. The original values are overwritten, not moved.n (value) | counts[n] | Inner loop j | Writes | i after | arr after |
|---|---|---|---|---|---|
| 0 | 2 | 0, 1 | arr[0] = 0, arr[1] = 0 | 2 | [0, 0, 2, 0, 0, 2] |
| 1 | 1 | 0 | arr[2] = 1 | 3 | [0, 0, 1, 0, 0, 2] |
| 2 | 3 | 0, 1, 2 | arr[3] = 2, arr[4] = 2, arr[5] = 2 | 6 | [0, 0, 1, 2, 2, 2] |
Time: a nested loop that is O(n), not O(n²)
The first loop counts: n steps. The second part has a loop inside a loop, and the reflex is to say O(n²). Look closer. The outer loop runs once per bucket (3 times). The inner loop runs counts[n] times: 2, then 1, then 3. Add them: 2 + 1 + 3 = 6 = n. The total number of writes is exactly n, no matter how the values are distributed, because every element is counted once and written once. So the whole algorithm is O(n) (strictly O(n + k) where k is the number of buckets, and k is a small constant here).
Ask "how many times does the inner loop body run in total?", not "how many loops are there?". Here the inner body runs n times across all outer iterations. The same reasoning appears in sliding window and in BFS, where a nested loop over neighbors still totals O(V + E).
Stability
The counting version overwrites the original array. There is no swapping and no moving of the original elements; the output is built from the counts. So the relative order of equal elements cannot be preserved: the "two 2s" in the output are not the original 2s at all. For plain integers this does not matter, since a 2 is a 2. For records sorted by a key it does, and then you need the stable prefix-sum version of counting sort, which is outside this lesson.
Closing: the course table
Bucket sort is O(n) but only works if the data is within a specified range. The safest general bet is merge sort or quick sort.
| Algorithm | Big-O | Note |
|---|---|---|
| Insertion Sort | O(n²)* | If fully or nearly sorted, O(n) |
| Merge Sort | O(n log n) | |
| Quick Sort | O(n log n)* | Worst case O(n²) |
| Bucket Sort | O(n)* | Assuming all values are in a specified range |
5Moves that solve most sorting problems
In an interview you will rarely write merge sort from scratch. You will call Python's sort with the right key, and you will be asked to explain which algorithm you would use and why. This section covers both.
Which sort to say, and why
| The situation | Say | Because |
|---|---|---|
| "Sort this" with no other constraint | Merge sort (or built-in sort) | Guaranteed O(n log n), stable. No surprises. |
| Memory is tight, must be in place | Quick sort, randomized pivot | O(1) extra space, O(n log n) expected, fast in practice. Mention the O(n²) worst case and the fix. |
| Input is small, or nearly sorted | Insertion sort | O(n) when nearly sorted, tiny constant factor. Real libraries switch to it for small pieces. |
| Values are integers in a small known range (0-2, 0-9, ages, grades) | Bucket / counting sort | O(n + k). Beats every comparison sort. |
| Data is a linked list | Merge sort | Needs no random access; splitting with slow/fast pointers and merging with dummy node is natural. LeetCode 148. |
| Only need the k-th largest, not a full sort | Quickselect (partition only one side) or a heap | O(n) average. Partition is the same code as quick sort, minus one recursive call. LeetCode 215. |
| Equal keys must keep their order | Merge sort or built-in sort | Both stable. Never quick sort. |
Move 1: sorted() and list.sort() with key and reverse
Python's built-in sort is Timsort: a hybrid of merge sort and insertion sort. It is stable, O(n log n) worst case, and O(n) on already sorted input (it detects runs). sorted(x) returns a new list and works on any iterable. x.sort() sorts the list in place and returns None, a classic bug source.
a = [3, 1, 2]
b = sorted(a) # b = [1, 2, 3], a untouched
a.sort() # a is now [1, 2, 3]; a.sort() itself returns None
sorted([3, 1, 2], reverse=True) # [3, 2, 1]
sorted(["banana", "fig", "apple", "kiwi"], key=len) # ['fig', 'kiwi', 'apple', 'banana']
sorted(["peach", "fig", "grape"], key=len) # ['fig', 'peach', 'grape'] (stable: peach stays before grape)
key is a function called once per element; the sort compares the results, not the elements. Think of it as "sort by this". Stability means elements with equal keys keep their input order.
key=lambda p: p[0] the letters are never looked at. Without a key, Python compares tuples element by element (first by number, then by letter), so (2,'a') would come before (2,'b').Move 2: sorting tuples and multi-key sorts
Tuples compare position by position, so returning a tuple from key gives you "sort by A, then by B". To sort one field descending and another ascending, negate the numeric field. This is the whole trick behind Top K Frequent Elements and most "sort by frequency" questions.
from collections import Counter
pairs = [(2, "b"), (1, "z"), (2, "a")]
sorted(pairs) # [(1,'z'), (2,'a'), (2,'b')] whole tuple
sorted(pairs, key=lambda p: p[0]) # [(1,'z'), (2,'b'), (2,'a')] by number, stable
sorted(pairs, key=lambda p: (-p[0], p[1])) # [(2,'a'), (2,'b'), (1,'z')] number desc, letter asc
def sort_by_freq_then_value(nums):
# most frequent first; ties broken by the smaller value first
cnt = Counter(nums)
return sorted(cnt, key=lambda x: (-cnt[x], x))
def top_k_frequent(nums, k): # LeetCode 347, the O(n log n) version
cnt = Counter(nums)
return sorted(cnt, key=lambda x: -cnt[x])[:k]
def merge_intervals(intervals): # LeetCode 56: sort by start, then sweep
intervals.sort(key=lambda iv: iv[0])
out = []
for start, end in intervals:
if out and start <= out[-1][1]: # overlaps the last kept interval
out[-1][1] = max(out[-1][1], end)
else:
out.append([start, end])
return out
Move 3: a custom comparator with cmp_to_key
Sometimes "which is smaller" cannot be expressed as a key per element; it depends on the pair. Largest Number is the classic: put a before b if the string a + b is bigger than b + a. Python 3 removed the cmp argument, so wrap a two-argument function with functools.cmp_to_key. Return negative for "a first", positive for "b first", 0 for tie.
from functools import cmp_to_key
def largest_number(nums): # LeetCode 179
def compare(a, b):
if a + b > b + a:
return -1 # a should come first
if a + b < b + a:
return 1
return 0
strs = sorted(map(str, nums), key=cmp_to_key(compare))
return "0" if strs[0] == "0" else "".join(strs)
largest_number([3, 30, 34, 5, 9]) # "9534330"
Move 4: counting sort when the range is small
Whenever the problem says "values are between 0 and k" and k is small, say the phrase "counting sort, O(n + k)". It is the bucket sort from lesson 13 with the bucket count as a parameter. Sort Colors (values 0, 1, 2) is exactly bucketSort.
def counting_sort(arr, k):
# values are integers in 0..k inclusive
counts = [0] * (k + 1)
for n in arr:
counts[n] += 1
i = 0
for v in range(k + 1):
for _ in range(counts[v]):
arr[i] = v
i += 1
return arr
counting_sort([4, 1, 3, 1, 0, 4], k=4) # [0, 1, 1, 3, 4, 4]
If the values are negative or huge (say, up to 109) counting sort needs a counts array of that size and stops being a good idea. Then use a dictionary Counter plus sorted() on the keys, or go back to a comparison sort.
6Big-O summary
Say the best, average and worst cases separately. Interviewers listen for the difference between quick sort's average and worst.
| Algorithm | Best | Average | Worst | Extra space | Stable |
|---|---|---|---|---|---|
| Insertion sort | O(n) | O(n²) | O(n²) | O(1) | yes |
| Merge sort | O(n log n) | O(n log n) | O(n log n) | O(n) | yes |
| Quick sort | O(n log n) | O(n log n) | O(n²) | O(log n)* stack | no |
| Bucket / counting sort | O(n + k) | O(n + k) | O(n + k) | O(k) counts | no (this version) |
Python sorted() (Timsort) | O(n) | O(n log n) | O(n log n) | O(n) | yes |
- Insertion best case O(n): already sorted input; the inner loop never runs. Worst case: reverse sorted input.
- Merge space O(n): temp arrays (n at any moment) plus the call stack (log n). Summed, not multiplied.
- Quick sort O(log n)* stack: O(log n) when the pivot splits evenly; O(n) in the worst case (sorted input, last-index pivot). Randomizing the pivot makes the worst case unlikely, not impossible.
- Bucket k: the number of possible values. With k = 3 it is a constant and the lesson simply writes O(n). With k = 109 it is useless.
7Mistakes to watch for
After the first swap the element you are inserting is no longer at index i; it is at j + 1. Writing while j >= 0 and arr[i] < arr[j] compares the wrong value and produces a wrong order. Either use arr[j + 1] as NeetCode does, or save cur = arr[i] before the loop and shift instead of swap.
Without it, j reaches -1 and arr[-1] silently reads the last element (Python negative indexing). No crash, wrong answer. Put j >= 0 first so the and short-circuits before indexing.
When L or R runs out, the other one still has sorted elements to copy. Without the two tail loops the last slots of arr keep their old, unsorted values. The length looks right, the content is wrong.
L = arr[s:m+1] and R = arr[m+1:e+1]. Python slices exclude the end, and m, e are inclusive, so both need +1. arr[s:m] loses the middle element every time.
Still sorts correctly, but takes the right element first on ties, which breaks stability. Say "I use <= so the merge is stable" out loud; it is a free point.
After partition the pivot is final. Recurse on s..left-1 and left+1..e. Including the pivot (s..left) on an array of equal elements makes the size never shrink: infinite recursion.
An empty side gives e = s - 1. With if s == e: return the code reads arr[e] out of range or loops forever. Use if e - s + 1 <= 1 (or if s >= e).
It is O(n log n) on average, O(n²) in the worst case. Name the worst case (sorted input, fixed pivot) and the fix (random pivot). Then say merge sort has no such case.
result = nums.sort() makes result equal to None. sort() is in place. Use sorted(nums) when you need the value, and nums.sort() as a statement when you want in place.
Interview one-liners
- "Insertion sort keeps a sorted prefix and slides each new element left; O(n²) worst, O(n) on nearly sorted input, stable, in place."
- "Merge sort splits in halves, log n levels, n work per level to merge, so O(n log n) in every case; O(n) extra space; stable because I take from the left on ties."
- "Quick sort partitions around a pivot and recurses on both sides; no merge needed. O(n log n) average, O(n²) if the pivot is always the max or min, which a random pivot makes unlikely. In place, not stable."
- "Bucket sort counts how many of each value, then writes them back in order: O(n + k). It only works when values are in a small known range."
- "A nested loop is not automatically O(n²); I count how many times the inner body runs in total. In bucket sort that total is n."
- "In Python I call
sorted()with akey; it is Timsort, stable, O(n log n), and I would say merge sort if asked what is underneath." - "To sort by two fields I return a tuple from
key, negating a number to make it descending."
8Practice, in this order
Solve without the notes open. For 912, implement merge sort first, then quick sort, then compare the run time on the same input.
| # | Problem | Move it trains | Level |
|---|---|---|---|
| 88 | Merge Sorted Array | the merge() three-pointer step, in place from the back | Easy |
| 1122 | Relative Sort Array | counting sort with a small range, then custom order | Easy |
| 242 | Valid Anagram | sort both strings, or count (bucket idea) | Easy |
| 1051 | Height Checker | counting sort, compare with original | Easy |
| 75 | Sort Colors | bucketSort for values 0/1/2; then the one-pass partition version | Medium |
| 912 | Sort an Array | write merge sort and quick sort from memory | Medium |
| 147 | Insertion Sort List | insertion sort on a linked list (dummy node + walk) | Medium |
| 148 | Sort List | merge sort on a linked list: slow/fast split, merge two lists | Medium |
| 56 | Merge Intervals | sort by start with key, then sweep | Medium |
| 347 | Top K Frequent Elements | Counter + sort by frequency; then the bucket-by-count O(n) version | Medium |
| 179 | Largest Number | custom comparator with cmp_to_key | Medium |
| 215 | Kth Largest Element in an Array | quick sort's partition reused as quickselect | Medium |
Your next step today: LeetCode 912. Write mergeSort and merge from memory, submit, then write quickSort and submit again. If quick sort times out on a test with many equal values, that is the O(n²) case; add the random pivot.
9Self-check
Answer out loud before opening. If you cannot answer in one breath, reread that section.
Q1In insertion sort, what do i and j point to, and when does the inner loop stop?
i is the element being inserted into the sorted portion; j starts at i - 1 and walks left. The inner loop stops when j becomes -1 or when the element being inserted (now at j + 1) is greater than or equal to arr[j].
Q2Trace it: insertion sort on [2,3,4,1,6]. What is the array after each pass i = 1, 2, 3, 4, and how many swaps in total?
i=1: [2,3,4,1,6] (0 swaps). i=2: [2,3,4,1,6] (0). i=3: [1,2,3,4,6] (3 swaps: 1 passes 4, then 3, then 2). i=4: [1,2,3,4,6] (0). Total 3 swaps.
Q3What does "stable" mean, and why is insertion sort stable on [7,3,7]?
Equal elements keep their original relative order. On pass i=2 the second 7 is compared with the first 7: 7 < 7 is false, so no swap happens and the first 7 stays first. The strict < is what makes it stable.
Q4Best case, worst case, and space of insertion sort? Name the worst-case input.
Best O(n) on already sorted input (inner loop never runs). Worst O(n²) on reverse-sorted input (every element walks to the front). Average is also O(n²). Space O(1), in place.
Q5In mergeSort(arr, s, e), why must both recursive calls return before merge() runs? On [3,2,4,1,6], which merge happens first?
merge() assumes both halves are already sorted; they only become sorted when their recursive calls finish. The first merge is merge(arr, 0, 0, 1), turning [3,2] into [2,3], before the code touches [4].
Q6Trace it: merge L = [2,3,4] and R = [1,6]. Give i, j, k after each step.
Step 1: 2 ≤ 1 false, write 1: i=0, j=1, k=1. Step 2: 2 ≤ 6, write 2: i=1, j=1, k=2. Step 3: write 3: i=2, j=1, k=3. Step 4: write 4: i=3, j=1, k=4. First loop ends (i = len(L)). Leftover loop writes 6: j=2, k=5. Result [1,2,3,4,6].
Q7Explain in two sentences why merge sort is O(n log n).
Each level halves the subarray size, so it takes log n halvings to reach size 1: log n levels. Each level's merges together touch all n elements once, so n work per level, and n × log n total.
Q8Why is merge sort's space O(n) and not O(n log n)?
The call stack holds log n frames and the temp arrays in use at one moment total at most n. They add: O(n + log n) = O(n). The temp arrays of different levels are not all alive at the same time; each merge() releases its L and R when it returns.
Q9Trace it: partition [6,2,4,1,3] with pivot 3. Give left and the array after each i, and the array after the pivot swap.
i=0: 6 < 3 no, left=0, [6,2,4,1,3]. i=1: 2 < 3, swap arr[0] and arr[1], left=1, [2,6,4,1,3]. i=2: 4 < 3 no, left=1. i=3: 1 < 3, swap arr[1] and arr[3], left=2, [2,1,4,6,3]. Pivot swap arr[4] with arr[2]: [2,1,3,6,4]. Then recurse on [2,1] and [6,4].
Q10What input makes quick sort O(n²) when the pivot is the last element, and what is the fix?
An already sorted (or reverse sorted) array: the pivot is always the max (or min), so one side has n - 1 elements and the other 0, giving a recursion chain of height n with O(n) partition work per level. Fix: choose the pivot randomly (or shuffle first), giving expected O(n log n).
Q11Why is quick sort not stable? Use [7,3,7,4,5].
Partition swaps non-adjacent elements. With pivot 5, at i=3 the value 4 swaps with the 7 at index 1 (originally index 0), moving that 7 to index 3, behind the other 7. An element jumped over its equal, so relative order is not protected.
Q12Bucket sort has a loop inside a loop. Why is it O(n) and not O(n²)? Use counts = [2,1,3].
The inner loop runs counts[n] times for each bucket: 2 + 1 + 3 = 6 = n total writes. Every element is counted once and written once. Count the total inner-loop iterations, not the number of loops.
Q13What is the difference between sorted(a) and a.sort(), and what algorithm do they use?
sorted(a) returns a new sorted list and leaves a alone; a.sort() sorts in place and returns None. Both are Timsort: a stable merge/insertion hybrid, O(n log n) worst case, O(n) on sorted input.