DSA Field Notes
Course map / 04 Sorting
NeetCode · DSA for Beginners · Lessons 10-13

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

Analogy

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.

INSERTION 2 3 4 1 sorted part take the next item, slide it left into place O(n²) · stable · in place MERGE 2 3 4 1 6 1 2 3 4 6 split in halves, merge two sorted halves O(n log n) · stable · O(n) extra QUICK 2 1 3 6 4 < pivot pivot > pivot partition around a pivot, recurse on both sides O(n log n) avg, O(n²) worst not stable, in place BUCKET 0 1 2 2 1 3 counts 0 0 1 2 2 2 count each value, write them back in order O(n) · small known range
Four ways to answer the same question. Insertion sort is the one you do by hand. Merge and quick sort are divide and conquer: split the problem, solve the parts, combine. Bucket sort does not compare at all; it counts. Orange marks the cells that were just written.

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:

  • i points at the element we are currently inserting into the sorted portion.
  • j starts one index to the left of i.
  • 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 shift j left 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.

pass i=3 sorted so far j i 2 3 4 1 6 1 < 4 → swap ①, j → 1 j=1 j 2 3 1 4 6 1 < 3 → swap ②, j → 0 j=0 j 2 1 3 4 6 1 < 2 → swap ③, j → -1 j=-1 1 2 3 4 6 j < 0 → stop. sorted part is 0..3 0 1 2 3 4
The dashed box is the sorted portion; it grows by one each pass. Pass 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 iInsertingSorted part beforeComparesSwapsArray after
13[2]3 < 2? no0[2, 3, 4, 1, 6]
24[2, 3]4 < 3? no0[2, 3, 4, 1, 6]
31[2, 3, 4]1 < 4, 1 < 3, 1 < 2, then j = -13[1, 2, 3, 4, 6]
46[1, 2, 3, 4]6 < 4? no0[1, 2, 3, 4, 6]
Keep in mind

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.

i=1 7a 3 7b 3 < 7a → swap. Then j = -1, stop. i=2 3 7a 7b 7b < 7a? No, equal → while stops. 7a stays left of 7b: stable
Stability comes from the strict < 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 n times for each of the n elements. 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.
Why the star in O(n²)*

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.

Analogy

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 + 1 is 1 or less. Already sorted, return.
  • Compute the middle index m = (s + e) // 2.
  • Recursively mergeSort the left half s..m and the right half m+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.

split merge 3 2 4 1 6 s=0 e=4 m=2 3 2 4 s=0 e=2 m=1 1 6 s=3 e=4 m=3 3 2 4 1 6 size 1: base case 3 2 2 3 ① merge(arr, 0, 0, 1) 2 3 4 ② merge(arr,0,1,2) → 1 6 ③ merge(arr, 3, 3, 4) 1 2 3 4 6 ④ merge(arr, 0, 2, 4) teal = split (divide) orange = merge (combine), numbered in the order the code runs them
Order of calls matters. 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.

  • k tracks where the next element in arr must be placed. Starts at s.
  • i points to the element in the left subarray L being compared to the j element in the right subarray R.
  • One of i or j increments depending on which element is smaller; k increments 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 i 2 3 4 R j 1 6 arr k 1 2 0 1 2 3 4 step ① (grey): 2 ≤ 1? no → arr[0] = R[0] = 1, j → 1 step ② (orange): 2 ≤ 6? yes arr[k=1] = L[i=0] = 2 i → 1, k → 2 (j stays) k always moves. i or j moves, never both in one step.
Three pointers, one write per step. This is the final merge of the example: 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.
StepCompare L[i] <= R[j]Takeijkarr after
12 ≤ 1? noR[0] = 1011[1, _, _, _, _]
22 ≤ 6? yesL[0] = 2112[1, 2, _, _, _]
33 ≤ 6? yesL[1] = 3213[1, 2, 3, _, _]
44 ≤ 6? yesL[2] = 4314[1, 2, 3, 4, _]
5i = len(L), first loop endsR[1] = 6 (leftover loop)325[1, 2, 3, 4, 6]
Keep in mind

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.

level 0 n = 8 merge work: n level 1 n/2 n/2 2 × n/2 = n level 2 n/4 n/4 n/4 n/4 4 × n/4 = n level 3 1 1 1 1 1 1 1 1 8 × 1 = n
log n rows, each costing n. For n = 8 there are log2 8 = 3 halvings, so 4 rows. Every row has the same total width, n, because splitting never adds or removes elements; it only changes how many pieces there are. That is why the cost is n × log n and not n × 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.

Merge vs insertion

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.

Analogy

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.

i=0 left i pivot 6 2 4 1 3 6 < 3? no. nothing moves i=1 left i 2 6 4 1 3 2 < 3 → swap arr[left=0], arr[i=1] ① left → 1 i=2 left i 2 6 4 1 3 4 < 3? no. nothing moves i=3 left i 2 1 4 6 3 1 < 3 → swap arr[left=1], arr[i=3] ② left → 2 done left 2 1 3 6 4 loop over: arr[e] = arr[left] ③ arr[left] = pivot. 3 is final. 0 1 2 3 4 recurse on [2,1] and on [6,4]
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.
iarr[i]arr[i] < 3?Actionleft afterArray after
06nonone0[6, 2, 4, 1, 3]
12yesswap arr[0] and arr[1]1[2, 6, 4, 1, 3]
24nonone1[2, 6, 4, 1, 3]
31yesswap arr[1] and arr[3]2[2, 1, 4, 6, 3]
--loop endsarr[4] = arr[2]; arr[2] = 32[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.

CallSubarrayPivotPartition resultNext 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]
Keep in mind

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²).

[1, 2, 3, 4, 5] pivot 5 = max → left n-1, right 0 ∅ [1, 2, 3, 4] ∅ pivot 4 = max → left n-2, right 0 [1, 2, 3] ∅ pivot 3 = max → ... [1, 2] ∅ [1] base case height = n, not log n partition work per level: n + (n-1) + (n-2) + ... + 1 = n(n+1)/2 → O(n²) a sorted (or reverse sorted) input is the worst case for last-index pivot
Sorted input is the worst case when the pivot is the last element. Every partition finds nothing smaller than the pivot, so 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.

start 7a 3 7b 4 5 pivot left = 0. i=0: 7a < 5? no i=1 3 7a 7b 4 5 3 < 5 → swap arr[0], arr[1] left → 1. i=2: 7b < 5? no i=3 3 4 7b 7a 5 4 < 5 → swap arr[1], arr[3] 7a jumped over 7b: reversed final 3 4 5 7a 7b pivot swap: arr[4] = arr[2], arr[2] = 5 left = 2, the pivot's final index
The swap at 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.
Why stability matters in practice

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.

Analogy

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
  • i tracks the next insertion position in arr.
  • n tracks the current position in counts, which is also the value being written.
  • j tracks how many times counts[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].

arr 2 1 2 0 0 2 ① for n in arr: counts[n] += 1 counts 0 1 2 2 1 3 index = value, cell = count ② for n in 0..2: write n, counts[n] times arr 0 0 1 2 2 2 0 1 2 3 4 5 2 + 1 + 3 = 6 writes = n
No comparisons at all. The value's own number is its index in 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 jWritesi afterarr after
020, 1arr[0] = 0, arr[1] = 02[0, 0, 2, 0, 0, 2]
110arr[2] = 13[0, 0, 1, 0, 0, 2]
230, 1, 2arr[3] = 2, arr[4] = 2, arr[5] = 26[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).

Nested loops are not automatically O(n²)

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.

AlgorithmBig-ONote
Insertion SortO(n²)*If fully or nearly sorted, O(n)
Merge SortO(n log n)
Quick SortO(n log n)*Worst case O(n²)
Bucket SortO(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 situationSayBecause
"Sort this" with no other constraintMerge sort (or built-in sort)Guaranteed O(n log n), stable. No surprises.
Memory is tight, must be in placeQuick sort, randomized pivotO(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 sortedInsertion sortO(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 sortO(n + k). Beats every comparison sort.
Data is a linked listMerge sortNeeds 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 sortQuickselect (partition only one side) or a heapO(n) average. Partition is the same code as quick sort, minus one recursive call. LeetCode 215.
Equal keys must keep their orderMerge sort or built-in sortBoth 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.

input (2, 'b') (1, 'z') (2, 'a') key = 2 key = 1 key = 2 output (1, 'z') (2, 'b') (2, 'a') sorted(pairs, key=lambda p: p[0]) key is computed once per item, then only keys are compared (2,'b') and (2,'a') tie on key, so they keep input order: b before a. Without key: sorted(pairs) compares whole tuples → (2,'a') before (2,'b')
The key decides what "smaller" means; stability decides the ties. With 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]
Keep in mind

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.

AlgorithmBestAverageWorstExtra spaceStable
Insertion sortO(n)O(n²)O(n²)O(1)yes
Merge sortO(n log n)O(n log n)O(n log n)O(n)yes
Quick sortO(n log n)O(n log n)O(n²)O(log n)* stackno
Bucket / counting sortO(n + k)O(n + k)O(n + k)O(k) countsno (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

Insertion sort: comparing arr[i] instead of arr[j + 1]

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.

Forgetting j >= 0 in the while condition

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.

Merge sort: dropping the leftover loops

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.

Merge sort: wrong slice bounds

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.

Merge sort: using < instead of <= on the tie

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.

Quick sort: recursing on the pivot again

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.

Quick sort: base case only checks e == s

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).

Saying "quick sort is O(n log n)" with no qualifier

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.

Using x.sort() as an expression

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 a key; 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.

#ProblemMove it trainsLevel
88Merge Sorted Arraythe merge() three-pointer step, in place from the backEasy
1122Relative Sort Arraycounting sort with a small range, then custom orderEasy
242Valid Anagramsort both strings, or count (bucket idea)Easy
1051Height Checkercounting sort, compare with originalEasy
75Sort ColorsbucketSort for values 0/1/2; then the one-pass partition versionMedium
912Sort an Arraywrite merge sort and quick sort from memoryMedium
147Insertion Sort Listinsertion sort on a linked list (dummy node + walk)Medium
148Sort Listmerge sort on a linked list: slow/fast split, merge two listsMedium
56Merge Intervalssort by start with key, then sweepMedium
347Top K Frequent ElementsCounter + sort by frequency; then the bucket-by-count O(n) versionMedium
179Largest Numbercustom comparator with cmp_to_keyMedium
215Kth Largest Element in an Arrayquick sort's partition reused as quickselectMedium

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.