Chapter 1: Fundamentals
Master the building blocks of data structures and algorithms: understand time complexity, implement essential data structures like stacks, queues, and hash maps, then conquer the most important sorting algorithms and learn how to customize them for any use case.
- Topics
- 7
- Videos
- 7
- Min read
- 37
Chapter Overview
Every great programmer stands on a foundation of data structures and algorithms. Before you can solve complex problems, you need to understand how data is organized, how operations perform at scale, and which tools to reach for in different situations.
This chapter starts with Big O notation --- the universal language for reasoning about efficiency. You will learn to look at code and immediately estimate whether it will run in milliseconds or hours. Then you will build the fundamental data structures that appear in nearly every technical interview and production codebase: stacks, queues, and hash maps.
The second half focuses on sorting, one of the most studied problems in computer science. You will implement the simple quadratic sorts to understand the basics, then level up to merge sort and quick sort --- the divide-and-conquer algorithms that power real-world systems. Finally, you will learn to write custom comparators so you can sort anything by any criteria.
This chapter covers:
- Big O Notation: Analyzing time and space complexity
- Stacks & Queues: LIFO and FIFO data structures
- Hash Maps: O(1) lookups with key-value pairs
- Elementary Sorting: Bubble, selection, and insertion sort
- Advanced Sorting: Merge sort and quick sort
- Custom Comparators: Sorting with flexible criteria
Chapter Roadmap
Click any topic to jump in
Data Structures & Big O
The mental model: choosing how to organize data determines which operations are cheap. Big O lets you compare choices before you code.
Stacks and queues are the two simplest linear structures built on arrays or linked lists.
Stacks (LIFO)
Last-in-first-out storage that mirrors the call stack and solves matching problems like valid parentheses.
Queues (FIFO)
First-in-first-out storage that powers BFS, level-order traversal, and rate limiting via sliding windows.
Hash Maps
Average-case $O(1)$ lookups via hashing — the workhorse of counting, deduping, and complement lookups.
Once you can store and retrieve, the next question is how to order data efficiently.
Sorting Basics
Bubble, selection, insertion — the $O(n^2)$ baseline. Understanding stability matters more than raw speed here.
Merge & Quick Sort
Divide and conquer sorts that reach $O(n \log n)$ — merge sort is stable, quick sort is faster in practice.
Custom Comparators
Sorting by arbitrary criteria — key functions, multi-key, and the strict-weak-ordering contract.
Topics
7 topics in this chapter
Suppose you hold ten million user IDs and must answer, thousands of times per second, whether a given ID is banned. Scan a plain list and each query touches up to ten million entries; the service falls over the moment traffic spikes. Nothing is wrong with the loop itself. The problem is how the data is laid out.
As the first topic of the DSA plan, this one has no predecessor, so it builds the vocabulary every later topic relies on. A data structure is a layout plus a set of operations with known costs, and the craft is matching the layout to the operations your program performs most often. To compare layouts before writing code, we need a machine-independent measure of cost. Big O notation provides it: it describes how work grows as the input size n grows, ignoring constant factors and hardware.
We first look at what a data structure actually promises, then learn to read time complexity straight off loops, and finally measure the extra memory an algorithm needs. Stacks, queues, and hash maps in the next topics are all judged with exactly these tools.
Definition
A data structure is a concrete organization of data in memory together with the operations it supports and the cost of each. Big O notation, written , states that for all sufficiently large inputs the cost f(n) is at most a constant multiple of g(n), so it classifies algorithms by growth rate rather than by exact running time.
In this topic
- 1What Are Data Structures?
- 2Big O Notation
- 3Space Complexity
What Are Data Structures?
Every program repeatedly asks a few questions of its data: is this item present, which is the smallest, what arrived last. A data structure is a commitment to answer some of those questions cheaply at the expense of others. An array stores elements contiguously, so reading index i is O(1), but inserting at the front shifts every element, costing O(n). A linked list inverts that trade. A hash map answers membership in O(1) on average but forgets order entirely. The failure mode is choosing by habit: a list used for millions of membership checks silently turns a linear-time job into a quadratic one.
A data structure is a triple where is the set of states, are the supported operations, and is the cost model. Choosing a structure means minimising where is how often each operation is performed. For a workload with lookups and inserts, a hash set costs while a sorted array costs — the right choice can change the total by a polynomial factor.
Compare the time complexity of finding an element in an unsorted list vs. a sorted list vs. a hash set. Which would you choose if you need to check membership millions of times?
Big O Notation
Big O answers how the work grows when the input doubles, not how many seconds a program takes. Formally, means that beyond some input size the cost never exceeds for a fixed constant . Here n is the input size, f counts basic operations, and g is a simple reference curve such as n or n^2. To analyse code, count how often the innermost statement runs: one loop over n gives O(n), two nested loops give O(n^2), and halving the range each step gives O(log n). The classic trap is hidden work inside a call, like x in some_list, which is itself O(n).
iff there exist constants and such that for all . This definition ignores constants and lower-order terms because for any polynomial , the dominant term eventually dwarfs the rest: . Big O is an upper bound on growth, giving a machine-independent way to compare algorithms.
What is the time complexity of the following code?
for i in range(n):
for j in range(n):
print(i, j)
Space Complexity
Time is not the only budget: a program that is fast but needs more memory than the machine has simply crashes. Space complexity counts the extra memory an algorithm allocates as a function of the input size n, excluding the input itself. Local variables and fixed counters cost O(1); a new list proportional to the input costs O(n); recursion costs one stack frame per active call, so recursion depth d costs O(d). Insertion sort rearranges in place with O(1) extra space, while merge sort needs an O(n) buffer. The hidden failure mode is recursion, which consumes stack space even when no data structure is visibly allocated.
Space complexity counts the extra memory used as a function of input size , excluding the input itself. For a recursive algorithm with recursion depth and local variables of size , space is because the call stack holds frames simultaneously. Example: merge sort uses auxiliary space for the merge buffer plus for recursion, totalling .
What is the space complexity of creating a new list that contains the squares of every element in the input list?
Related Problems on PixelBank
Your editor must undo the most recent change first, a compiler must check that every opening brace is closed by the right partner, and a running program must remember where to return after each nested function call. All three share one pattern: the item you need next is always the one you touched last.
The previous topic, Data Structures Introduction, taught you to judge a structure by the cost of its operations. That pays off immediately here, because a structure that only ever touches its most recent element can make every operation O(1). That structure is the stack, which follows the Last-In, First-Out (LIFO) rule, like a pile of plates where you only ever take from the top.
We start with the three core operations and how a Python list implements them, then look inside the call stack that powers every function call and every recursion, and finish with the valid-parentheses problem, the canonical example of using a stack to track unmatched items. Queues, in the next topic, flip the rule to first-in, first-out.
Definition
A stack is an abstract data type holding a sequence of elements in which insertion (push) and removal (pop) both happen at one end, called the top. It obeys Last-In, First-Out order: of any two elements still present, the one pushed later is popped first. Backed by an array or linked list, push, pop, and peek each run in O(1).
In this topic
- 1LIFO Principle and Core Operations
- 2The Call Stack
- 3Valid Parentheses Problem
LIFO Principle and Core Operations
Many problems only care about the most recently seen unfinished item, and a stack hands exactly that item back in O(1). It supports push (add to the top), pop (remove and return the top), and peek (read the top without removing it). In Python a plain list works: append() pushes, pop() pops, and stack[-1] peeks. All three touch only the end of the underlying array, so they are O(1) amortised. Two failure modes recur: popping or peeking an empty stack raises an error, so check if stack: first, and using pop(0) or insert(0, x) quietly turns every operation into O(n).
A stack supports , , and , all in amortised time. The invariant is: if is pushed before and both are still present, then is popped before . This invariant makes stacks equivalent to evaluating expressions in reverse-Polish notation — a string of operations runs in time.
Trace the state of a stack after these operations: push(1), push(2), push(3), pop(), push(4), peek(), pop(), pop().
The Call Stack
When function A calls B, the machine must remember A's locals and where to resume once B finishes. It does this with a stack of frames: each call pushes a frame holding arguments, locals, and a return address, and each return pops it. Because the innermost call always finishes first, LIFO is exactly the right order. A recursion of depth d keeps d frames alive at once, so it costs O(d) memory even when it allocates nothing else. The failure mode is stack overflow: CPython caps depth near 1000 by default and raises RecursionError, so very deep recursion should become a loop with an explicit stack.
Every function call pushes a stack frame of size ; returning pops it. For a recursion of depth , the call stack uses memory. This is why tail-recursive algorithms can blow the stack in Python (no TCO) while iterative versions run in space — the recurrence solves to .
What happens to the call stack when factorial(4) is called recursively?
def factorial(n):
if n <= 1:
return 1
return n * factorial(n - 1)
Valid Parentheses Problem
Given a string of (, ), {, }, [, ], decide whether every opener is closed by its matching closer in the correct nesting order. Counting brackets is not enough, because ([)] has balanced counts but crossed nesting. The fix is to remember the unmatched openers in order: push each opener; on a closer, the stack top must be its partner, so pop and compare. The string is valid only if every closer matched and the stack is empty at the end. Each character is pushed and popped at most once: O(n) time and space. Classic bugs skip the empty-stack check on a closer or the final emptiness test.
The algorithm maintains the invariant: the stack contains exactly the unmatched opening brackets seen so far, in order. A string is valid iff the stack is empty at the end and every closing bracket matches the current top. Each character is pushed and popped at most once, giving time and space in the worst case (all opens).
Is the string "({[]})" valid? Trace through using a stack.
Related Problems on PixelBank
A printer receives jobs from twenty people, a web server gets requests faster than it can answer them, and a maze solver must explore rooms one ring at a time. In each case fairness or correctness demands that items leave in the order they arrived.
The stack from the previous topic does the opposite, handing back the newest item first, so it would starve the earliest requests. The queue fixes this with First-In, First-Out (FIFO) order: items join at the back and leave from the front, like a checkout line. Because both ends are active, the implementation matters more than it did for stacks, and a naive Python list quietly turns every removal into O(n).
We begin with the core operations and why collections.deque is the right tool. Then we use a queue to drive breadth-first search, which explores a graph level by level and finds shortest paths in unweighted graphs. We close with a sliding time window that counts recent requests in amortised O(1). Hash maps come next.
Definition
A queue is an abstract data type holding a sequence in which elements are inserted at the back (enqueue) and removed from the front (dequeue). It obeys First-In, First-Out order: elements leave in exactly the order they arrived. Implemented with a circular buffer or a double-ended queue, enqueue, dequeue, and peek all run in O(1).
In this topic
- 1FIFO Principle and Core Operations
- 2BFS and Level-Order Traversal
- 3Recent Counter Problem
FIFO Principle and Core Operations
Whenever work must be served in arrival order, a queue is the structure that guarantees it. It supports enqueue (add to the back), dequeue (remove from the front), and a peek at the front. Unlike a stack, a queue touches both ends, so the backing store must make both cheap. In Python, collections.deque provides append() and popleft() in O(1) because it is a doubly linked list of fixed-size blocks. The classic failure mode is a plain list with pop(0): every call shifts all remaining elements left, costing O(n), so n dequeues cost O(n^2) and a job that should take milliseconds can take minutes.
A queue supports and , both in with a circular buffer or doubly linked list. The invariant: elements leave in the order they arrived. Python's is backed by a doubly linked list of blocks, giving true on both ends — a plain list would be per because of the shift.
Why is collections.deque preferred over a regular list for queue operations in Python?
BFS and Level-Order Traversal
To find the fewest moves from a start node to every other node, we must explore all nodes one step away before any node two steps away. Breadth-First Search enforces that ordering with a queue: enqueue the source and mark it visited, then repeatedly dequeue a node and enqueue each unvisited neighbour, marking it visited as it enters. Since the queue always holds distance k before distance k+1, the first discovery of a node follows a shortest path in an unweighted graph. Total work is O(V + E) for V vertices and E edges. A classic bug is marking nodes when dequeued, not enqueued, which floods the queue with duplicates.
BFS visits nodes in order of distance from the source: all nodes at distance are processed before any at distance . This gives the invariant that the first time BFS reaches a node, it has found a shortest path (in an unweighted graph). Total work is because each vertex enters the queue once and each edge is examined twice.
Using BFS, in what order are nodes visited starting from node A in a graph where A connects to B and C, B connects to D, and C connects to D and E?
Recent Counter Problem
Rate limiters and dashboards ask how many requests arrived in the last 3000 milliseconds. Storing every timestamp and rescanning costs O(n) per query. The key observation is that times arrive in increasing order, so once a request falls out of the window it can never re-enter. A queue fits exactly: on ping(t), enqueue t, then dequeue from the front while the front is less than t - 3000; the queue length is the answer. Each timestamp is enqueued once and dequeued at most once, so the amortised cost is O(1) per call. The approach breaks if timestamps can arrive out of order.
The window slides monotonically with , so any request that falls out will never return. A queue naturally supports this: enqueue the new time, dequeue anything older than . Amortised cost per call is because each request is enqueued and dequeued exactly once — total work across calls is .
A RecentCounter receives pings at times [1, 100, 3001, 3002]. After each ping, how many requests are in the [t-3000, t] window?
Two Sum, duplicate detection, word counting, and caching all ask the same thing: have I seen this key before, and how fast can I find what I stored with it? The stacks and queues of the previous topics only expose their ends, so answering an arbitrary "is x here?" means scanning every element in O(n). Sorting and then binary searching gets to O(log n), but costs O(n log n) upfront and breaks down under frequent inserts.
A hash map removes the search entirely. A hash function turns the key into an array index, so a lookup jumps straight to the right slot in O(1) on average. That word "average" carries a lot of weight: two keys can land in the same slot, and how the table handles these collisions decides whether performance stays constant or degrades to linear.
We first see how keys become indices, then examine collision handling, load factor, and resizing, and finally apply the idea to Two Sum, turning a quadratic pair search into a single pass. Sorting follows next.
Definition
A hash map (hash table) stores key-value pairs in an array of m buckets, placing key k at index h(k) for a hash function h. With the load factor kept bounded by resizing, insert, lookup, and delete run in expected O(1) time; in the worst case, when all keys collide, they degrade to O(n).
In this topic
- 1Key-Value Storage and Hash Functions
- 2Collision Handling
- 3Two Sum Using Hash Map
Key-Value Storage and Hash Functions
Arrays give O(1) access, but only by integer index; a hash map extends that speed to arbitrary keys such as strings or tuples. A hash function maps each key to an integer, and taking it modulo the table size picks a bucket between 0 and m - 1. A lookup recomputes and goes straight there, with no scan. A good hash spreads keys uniformly, so each bucket holds about keys on average. Keys must be immutable: if a key's hash changed after insertion, lookups would search the wrong bucket, which is why Python forbids lists as dict keys. Python's dict is a heavily tuned open-addressing table.
A hash function distributes keys over buckets. Under the simple uniform hashing assumption, the expected number of keys per bucket is (the load factor). Lookup, insert, and delete are all expected time — when is kept bounded by resizing.
A hash map has 8 buckets. Key 'alice' hashes to 3, 'bob' to 7, 'carol' to 3. What happens?
Collision Handling
Because infinitely many keys map into m buckets, two keys will eventually share an index. Chaining keeps a small list per bucket, so a lookup scans only that list, of expected length . Open addressing stores everything in the array itself and probes a sequence of slots until it finds the key or an empty slot; expected probes for a miss are about , which explodes as approaches 1. Both keep O(1) average time only if the table resizes as it fills; Python dicts grow near two-thirds full. A weak or adversarial hash can pile every key into one bucket, giving O(n).
With chaining, each bucket holds a linked list; worst-case lookup is when all keys collide. With open addressing and load factor , the expected number of probes for an unsuccessful search is at most — this is why Python dicts resize once exceeds about . The proof uses the fact that probe sequences are uniform permutations under universal hashing.
If a hash map has n keys and 1 bucket, what is the lookup time? What if it has n keys and n buckets with a perfect hash?
Two Sum Using Hash Map
Given an array and a target, find the indices of two numbers that sum to the target. Checking every pair costs comparisons, which is O(n^2). The hash-map insight is to change the question: for the current number x, the only partner that works is its complement, target - x, so instead of searching for pairs we ask whether that complement has already been seen. Walk the array once, and for each x look up the complement in a dict mapping value to index; if found, return both indices, otherwise store x. That is O(n) time and O(n) space. Check before inserting, or x may pair with itself.
Brute force checks all pairs. The hash-map trick is a change of question: instead of asking is there a pair summing to target?, ask has the complement been seen? A single pass with lookups and inserts runs in expected time — a quadratic-to-linear speedup courtesy of hash operations.
Find two numbers in [2, 7, 11, 15] that sum to 9. Trace the hash map approach.
Related Problems on PixelBank
Hash maps, from the previous topic, answer "is x here?" in O(1), but they deliberately forget order: they cannot tell you the smallest element, the median, or the next value larger than x. Many problems need exactly that, and the standard move is to sort once and then exploit the order with binary search, two pointers, or grouping of equal items.
Sorting is so central that every language ships a tuned built-in, yet writing the simple versions by hand teaches how comparisons, swaps, and invariants interact. This topic covers the three elementary O(n^2) algorithms, each built on a different invariant. Bubble sort repeatedly swaps adjacent out-of-order pairs. Selection sort repeatedly moves the minimum of the unsorted part to the front. Insertion sort grows a sorted prefix one element at a time.
All three lose badly to the O(n log n) algorithms of the next topic on large random input, but insertion sort genuinely wins on small or nearly sorted arrays. We close with stability, the property that decides whether multi-key sorting works.
Definition
A comparison sort rearranges a sequence into nondecreasing order using only pairwise comparisons between elements. Bubble, selection, and insertion sort are the elementary comparison sorts: each uses O(1) extra space and O(n^2) comparisons in the worst case. A sort is stable if elements with equal keys keep their original relative order in the output.
In this topic
- 1Bubble Sort
- 2Selection Sort
- 3Insertion Sort
- 4Stability in Sorting
Bubble Sort
Bubble sort is the most direct way to fix disorder: whenever two neighbours are out of order, swap them. One left-to-right pass compares each adjacent pair and swaps when the left is larger, so the largest unsorted element is carried to the end like a rising bubble. After pass k, the last k positions are final, so each later pass can stop earlier. Worst and average cases need about comparisons, which is O(n^2). With an early-exit flag, a pass with zero swaps proves the array sorted, giving O(n) on already sorted input. It is stable and in place, but its many swaps make it slow in practice.
Bubble sort makes passes, each moving the largest remaining element to its final position. The number of comparisons is . The best case on a sorted array with the early-termination optimisation is — one pass confirms no swaps happened. Space is ; it is stable because equal elements are never swapped past each other.
Sort [5, 3, 8, 1, 2] using bubble sort. How many passes are needed?
Selection Sort
Selection sort builds the answer front to back by asking one question per position: which element belongs here? It keeps a sorted prefix and an unsorted suffix; each pass scans the suffix for its minimum and swaps it into the first unsorted slot. The scan always inspects every remaining element, so it performs exactly comparisons whatever the input: best, average, and worst cases are all O(n^2). Its one strength is making at most n - 1 swaps, useful when writes are expensive, as on flash memory. Its weaknesses are no adaptivity to sorted input and instability, because a long-distance swap can jump an element over its equal twin.
Selection sort performs exactly comparisons regardless of input — it scans the unsorted suffix to find the minimum. Best, average, and worst cases are all . However, it uses only swaps (one per pass), which is minimal for a comparison sort and makes it attractive when writes are expensive (e.g., flash memory).
Sort [64, 25, 12, 22, 11] using selection sort. Trace each step.
Insertion Sort
Insertion sort mirrors sorting a hand of cards: keep the left part sorted and slide each new card leftwards until it sits in place. For index i, save the value, shift every larger element of the sorted prefix one slot right, and drop the value into the gap. The number of shifts equals the number of inversions I, the pairs that are out of order, so the running time is O(n + I). Sorted input has I = 0 and runs in O(n); reverse-sorted input has I = n(n-1)/2 and runs in O(n^2). This adaptivity, plus stability and tiny constant factors, is why Timsort uses insertion sort for short runs.
Insertion sort shifts each new element into the sorted prefix. The number of shifts equals the number of inversions in the input: . Best case (sorted) has so ; worst case (reverse-sorted) has so . This adaptivity is why insertion sort is the inner loop of Timsort for small subarrays.
Sort [5, 2, 4, 6, 1, 3] using insertion sort.
Stability in Sorting
Suppose a spreadsheet is already ordered by name and you now sort it by grade; you probably expect students with equal grades to stay alphabetical. That expectation is stability: a sort is stable if any two elements with equal keys appear in the output in the same relative order as in the input. Bubble and insertion sort are stable because they only move an element past strictly larger neighbours; selection sort is not, since its long swaps can leapfrog an equal element. Stability enables multi-pass sorting: sort by the secondary key first, then by the primary key, and ties keep the secondary order. Python's sorted() guarantees stability.
A sort is stable if for any two equal keys with , the output preserves . Stability lets you sort by multiple keys in sequence: sort by the least-significant key first, then by the most-significant — because earlier order is preserved on ties, the final order is lexicographic. Without stability, you would need a true multi-key comparator.
Sort [("Alice", 90), ("Bob", 85), ("Carol", 90)] by grade. What order do stable vs unstable sorts produce?
Related Problems on PixelBank
The elementary sorts of the previous topic hit a wall. Sorting one million records with insertion sort takes on the order of 5 * 10^11 operations, hours of work, and every production database, spreadsheet, and search engine sorts far more than that. The quadratic cost comes from fixing disorder one element or one neighbouring pair at a time.
Divide and conquer breaks the wall: split the problem in half, solve each half recursively, and combine. Because there are only about log n levels of halving and each level does O(n) work, the total drops to O(n log n), roughly 2 * 10^7 steps for a million elements. A counting argument over the n! possible orderings shows no comparison sort can do asymptotically better.
We first state the divide-and-conquer recipe and its recurrence. Then we study merge sort, which splits trivially and does its work while merging, and quick sort, which does its work while partitioning around a pivot and needs no merge at all. The trade-off between merge sort's guarantees and quick sort's in-place speed sets up custom comparators next.
Definition
Divide and conquer solves a problem by splitting it into smaller instances, solving them recursively, and combining the results. Merge sort splits an array in half and merges the sorted halves, running in time with O(n) extra space and stable output. Quick sort partitions around a pivot and recurses, running in expected time in place.
In this topic
- 1Divide and Conquer
- 2Merge Sort
- 3Quick Sort
Divide and Conquer
When a problem on n items is hard, two problems on n/2 items plus a cheap combine step are often much easier. Divide and conquer has three steps: divide into subproblems, conquer each recursively down to a trivial base case, and combine the answers. Cost follows the recurrence , where a is the number of subproblems, n/b their size, and f(n) the divide-plus-combine work. Merge sort has a = b = 2 and f(n) = O(n), giving O(n log n). Merge sort splits easily and combines hard; quick sort partitions hard and combines trivially. The failure mode is unbalanced splits, where halving degrades into shaving off one element.
Divide and conquer splits a problem of size into subproblems of size plus work to combine. The master theorem solves : when , . Merge sort has and , so .
For an array of 16 elements, how many levels of recursion does merge sort use?
Merge Sort
Merging two already sorted lists is easy and linear, and merge sort turns sorting into repeated merging. Split the array at the midpoint, recursively sort each half, then merge: keep a pointer at the front of each half, copy the smaller front element to the output, and advance that pointer; when one half runs out, copy the rest of the other. There are log n levels, each doing O(n) merge work, so the cost is in the best, average, and worst cases. Taking from the left half on ties keeps it stable. The price is an O(n) auxiliary buffer, which matters when memory is tight.
Merge sort is the canonical sort: it splits the array in half, sorts each half recursively, and merges them in linear time. The recursion tree has depth and each level does merge work, giving total . It is stable and needs auxiliary space for the merge buffer. Lower bound: any comparison sort requires comparisons (information-theoretic argument over the possible outputs), so merge sort is asymptotically optimal.
Merge sorted arrays [1, 4, 7] and [2, 3, 8]. Trace the merge process.
Quick Sort
Quick sort avoids merge sort's buffer by doing the work before recursing. Choose a pivot, then partition the array in place so smaller elements sit left of the pivot and larger ones sit right; the pivot is now in its final position, so recurse on the two sides and no combine step is needed. Balanced splits give O(n log n), and a random pivot yields about comparisons on average. The failure mode is a bad pivot: always picking the smallest element, as a first-element pivot does on sorted input, gives the recurrence and O(n^2) time. It is in place but not stable.
Quick sort partitions around a pivot, then recurses on each side. With a random pivot, the expected recursion depth is and expected comparisons are . Worst case (already-sorted input with first-element pivot) is — the recurrence . Randomisation or median-of-three defeats the worst case with high probability. Quick sort is in-place ( stack) but not stable.
Partition [3, 6, 8, 10, 1, 2, 1] with pivot = 3.
Related Problems on PixelBank
Real data rarely sorts by one number ascending. An HR report wants employees by department, then by salary descending; a file browser wants folders before files, then case-insensitive names; a leaderboard wants the highest score first with the earliest submission breaking ties.
The merge sort and quick sort of the previous topic assumed a fixed less-than relation between elements, so the question now is how to tell a sorting routine what "less than" means for your data without rewriting the algorithm. Python offers two answers. A key function maps each element to a value that already has a natural order, and the sort compares those values; tuples make multi-key ordering almost free. For orderings that depend on how two elements relate, such as arranging numbers into the largest concatenation, functools.cmp_to_key adapts a classic comparison function.
We cover key functions first, then multi-key sorting with mixed directions, then comparison functions and the consistency rules they must obey, because a comparator that breaks transitivity can make a sort return garbage or even crash.
Definition
A custom comparator defines the order a sort uses in place of the default less-than relation. In Python it is given either as a key function k, where elements are ordered by comparing k(x), or as a comparison function cmp(a, b) returning a negative, zero, or positive number. Either way it must define a consistent, transitive ordering of the elements.
In this topic
- 1Sorting with Key Functions
- 2Multi-Key Sorting
- 3Custom Comparison Functions
Sorting with Key Functions
Most custom orders are really the default order applied to a derived value: a string's length, a record's date, a lowercase name. A key function captures that: sorted(xs, key=f) computes f(x) once per element and sorts elements by comparing those keys with the normal less-than. Typical keys are len, str.lower, and lambda r: r[1]. Calling the key n times, instead of on every one of the O(n log n) comparisons, is why keys beat comparison functions on speed. Keys must be mutually comparable: mixing None with integers raises TypeError in Python 3. Because Python's sort is stable, elements with equal keys keep their input order.
Given a key function where is totally ordered, sorting by key computes once per element and sorts by those values. This costs where is the cost of one key computation — decoupling what to compare on from how to compare keeps the sort at regardless of key complexity.
Sort ["banana", "apple", "cherry", "date"] by string length, then alphabetically for ties.
Multi-Key Sorting
Leaderboards and reports need a primary order plus tie-breakers, and a key can return a tuple to express all of them at once. Python compares tuples lexicographically: the first elements decide, and only on a tie does it look at the second, and so on. So key=lambda s: (-s.score, s.name) gives score descending, then name ascending. Negation reverses numeric fields but not strings; for a descending string field, use stable multi-pass sorting instead: sort by the secondary key first, then by the primary key with reverse=True. Common bugs are negating a string, which raises TypeError, and forgetting a tie-breaker, which leaves ties in arbitrary input order.
To sort by primary key then secondary key, use a composite key and rely on lexicographic tuple comparison: iff , or and . Equivalently, sort by first (stable), then by (stable) — both approaches are but the composite-key version is usually cleaner.
Sort students [("Alice", 90), ("Bob", 85), ("Carol", 90), ("Dave", 85)] by grade descending, then name ascending.
Custom Comparison Functions
Some orders cannot be computed from one element alone: whether a should precede b depends on both. The largest-number puzzle is the classic case, since 9 must precede 34 but the rule is about how the digits concatenate. A comparison function cmp(a, b) returns a negative number if a goes first, zero on a tie, and a positive number otherwise, and functools.cmp_to_key(cmp) wraps it so sorted can use it. It runs O(n log n) times, so it is slower than a key. It must be consistent: antisymmetric and transitive, so a before b and b before c imply a before c. Violations make results depend on input order.
A comparator is a function that must define a strict weak ordering: (1) irreflexive (), (2) asymmetric (), and (3) transitive. Violating transitivity causes sort algorithms to produce undefined output or even crash — this is why Python removed the parameter in 3.x and requires or .
Arrange numbers [3, 30, 34, 5, 9] to form the largest possible number as a string.