Structs & Data Structures · intermediate · ~12 min
## What you will learn - Explain what sorting is, what *ascending*, *descending*, and *stable* mean, and why order matters for searching and reporting. - Read Big-O complexity (`O(n^2)`, `O(n log n)`) and use it to compare bubble, insertion, merge, quick, and heap sort. - Trace bubble sort and insertion sort by hand on a small array, step by step. - Use the standard library function `qsort` from `<stdlib.h>` correctly, including writing a safe comparator. - Write a comparator that returns `-1`, `0`, or `1` and avoid the classic subtraction-overflow bug. - Choose the right approach: a tiny hand-written sort for learning vs. `qsort` for real code.
Sorting means rearranging a collection of items so they follow a defined order. For numbers that order is usually ascending (smallest first) or descending (largest first). For text it might be alphabetical; for records it might be "by date" or "by price".
You already worked with collections in the Arrays lesson: a block of same-typed values laid out one after another in memory, reached by index. Sorting is one of the most common things you do to an array. Once an array is sorted, many other operations become dramatically faster and simpler.
A sorted array unlocks binary search (the next topic, searching): instead of scanning every element, you repeatedly halve the search range, turning an O(n) scan into an O(log n) lookup. Sorted data also makes it trivial to find the minimum/maximum (the ends), to find duplicates (they become neighbours), to merge two datasets, and to present results in a way humans can read.
There are many sorting algorithms — step-by-step recipes for producing sorted output. They differ in two main costs:
We describe time and space with Big-O notation, which captures how cost grows with the number of items n and ignores constant factors. A few more terms you will meet: a sort is in place if it needs only a constant amount of extra memory, and stable if it preserves the original relative order of items that compare equal. The rest of this lesson defines each algorithm, then shows the practical answer for C: use qsort.
Sorting is everywhere, usually hidden behind a feature you use without thinking:
ORDER BY, index building, and join algorithms all rely on sorting.Choosing the wrong algorithm has real consequences. An O(n^2) sort on a 1,000,000-element array does roughly a trillion operations and can take minutes; an O(n log n) sort does about 20 million and finishes in a blink. Understanding the trade-offs lets you reason about performance instead of guessing.
There is also a correctness and safety angle. A subtly wrong comparator can silently corrupt order, and in C an unsafe comparator can even trigger undefined behaviour. Knowing how to write a correct, overflow-free comparator is a real professional skill.
Definition. Ascending order means each element is less than or equal to the next; descending is the reverse. A sort is stable if two elements that compare equal keep their original relative order.
Stability matters when you sort by one key but care about a second. Example: sort employees by department, but within a department keep them in the order you received them (e.g. by hire date). A stable sort preserves that secondary order for free.
Input (by arrival): (Ann, Sales) (Bob, IT) (Cal, Sales)
Stable sort by dept: (Bob, IT) (Ann, Sales) (Cal, Sales) <- Ann still before Cal
Unstable sort: (Bob, IT) (Cal, Sales) (Ann, Sales) <- order of equals may flip
Pitfall. Assuming every sort is stable. Quicksort and heapsort are not stable; mergesort is.
Definition. Big-O describes how an algorithm's work grows as n grows, ignoring constants and lower-order terms.
n O(n log n) O(n^2)
10 ~33 100
100 ~664 10,000
1,000 ~9,966 1,000,000
1,000,000 ~20 million 1 trillion
The gap widens fast. For small n (say under ~50) the simple O(n^2) sorts are perfectly fine and sometimes faster due to low overhead. For large n, only O(n log n) is acceptable.
Knowledge check: If algorithm A is O(n^2) and B is O(n log n), is A always slower than B for every input size? (Answer: no — for very small n, constants can make A faster; Big-O describes growth, not exact time.)
Bubble sort repeatedly walks the array, swapping adjacent out-of-order pairs, so the largest element "bubbles" to the end each pass.
Pass 1 on [5,3,8,1]:
compare 5,3 -> swap -> [3,5,8,1]
compare 5,8 -> ok -> [3,5,8,1]
compare 8,1 -> swap -> [3,5,1,8] (8 is now in place)
Insertion sort grows a sorted region at the front, taking each new element and sliding it left into position — exactly how most people sort a hand of cards.
Both are O(n^2) worst case. Insertion sort is O(n) on already-nearly-sorted data, which is why real libraries use it for small subarrays.
When to use: tiny arrays, teaching, or nearly-sorted data. When NOT to: large unsorted arrays.
Pitfall. Off-by-one loop bounds. The inner loop of bubble sort must stop at n - 1 - i to avoid reading past the array.
| Algorithm | Average | Worst | Space | Stable? |
|---|---|---|---|---|
| Mergesort | O(n log n) | O(n log n) | O(n) | Yes |
| Quicksort | O(n log n) | O(n^2) | O(log n) | No |
| Heapsort | O(n log n) | O(n log n) | O(1) | No |
O(n) buffer.O(n^2). Good implementations randomize or median-pick the pivot.O(n log n) with O(1) extra space, but not stable and has higher constant factors.Knowledge check (predict): Quicksort's average case is O(n log n). What input pattern triggers its O(n^2) worst case if the pivot is always the first element? (Answer: an already-sorted or reverse-sorted array, because each partition removes only one element.)
qsortDefinition. qsort is the C standard library's general-purpose sort in <stdlib.h>. It sorts any array given the element size and a comparator function.
It is well-tested, fast, and type-agnostic, so in real C code you almost never write your own sort. The price is that you must supply a correct comparator — and the standard does not guarantee it is stable.
Knowledge check (explain): In your own words, why does qsort need both sizeof(int) and a comparator function instead of just the array?
qsort signature#include <stdlib.h>
void qsort(void *base, // pointer to the first element
size_t nmemb, // number of elements
size_t size, // size of ONE element, in bytes
int (*compar)(const void *, const void *));
// ^ a function pointer: takes two const void* and returns <0, 0, or >0
The comparator receives pointers to two elements. It must return:
0 if they are equal,int cmp_int(const void *pa, const void *pb) {
int a = *(const int *)pa; // cast void* back to int*, then dereference
int b = *(const int *)pb;
return (a > b) - (a < b); // -> -1, 0, or 1 with no overflow
}
The expression (a > b) - (a < b) yields 1, 0, or -1 and never overflows, unlike a - b.
Sorting puts data in order. That order is usually ascending (smallest to largest) or descending (largest to smallest).
Each algorithm has a different speed and memory cost. The notation O(...) (called Big-O) describes how the work grows as the number of items n grows.
O(n²). Slow on large inputs, but fine for small arrays.O(n log n). Stable (keeps equal items in their original order). Needs O(n) extra space.O(n log n) on average, but O(n²) in the worst case. Works in place (no extra array).O(n log n) guaranteed. In place, but not stable.In practice, use qsort from <stdlib.h>. It is well tested and fast, so you rarely need to write your own sort.
#include <stdio.h>
#include <stdlib.h>
/* Comparator for ascending int order.
Returns -1, 0, or 1 without the subtraction-overflow bug. */
static int cmp_int_asc(const void *pa, const void *pb) {
int a = *(const int *)pa;
int b = *(const int *)pb;
return (a > b) - (a < b);
}
/* A tiny insertion sort, shown for learning. O(n^2) but clear. */
static void insertion_sort(int *a, size_t n) {
for (size_t i = 1; i < n; i++) {
int key = a[i]; /* element to place into the sorted prefix */
size_t j = i;
while (j > 0 && a[j - 1] > key) {
a[j] = a[j - 1]; /* slide larger elements one step right */
j--;
}
a[j] = key; /* drop key into its hole */
}
}
static void print_array(const int *a, size_t n) {
for (size_t i = 0; i < n; i++) {
printf("%d%s", a[i], i + 1 < n ? " " : "\n");
}
}
int main(void) {
int data[] = {5, 3, 8, 1, 9, 2, 7};
size_t n = sizeof data / sizeof data[0]; /* element count, not byte count */
/* Make a copy so we can demonstrate both sorts on the same input. */
int *copy = malloc(n * sizeof *copy);
if (copy == NULL) { /* always check malloc */
fprintf(stderr, "out of memory\n");
return 1;
}
for (size_t i = 0; i < n; i++) copy[i] = data[i];
insertion_sort(data, n);
printf("insertion_sort: ");
print_array(data, n);
qsort(copy, n, sizeof *copy, cmp_int_asc);
printf("qsort: ");
print_array(copy, n);
free(copy); /* release what we allocated */
return 0;
}
The program sorts the same seven numbers two ways: with a hand-written insertion sort, and with the library qsort. Both produce ascending order.
insertion_sort: 1 2 3 5 7 8 9
qsort: 1 2 3 5 7 8 9
n == 0) or single element (n == 1) is already sorted; both functions handle it because their loops simply do not run.sizeof *copy (not sizeof copy) is the per-element size; mixing these up is a classic bug — see the mistakes section.Setup. data holds {5, 3, 8, 1, 9, 2, 7}. n = sizeof data / sizeof data[0] divides the total byte size by one element's size, giving 7. We malloc a copy and check it is not NULL before using it.
Insertion sort trace. The sorted region starts as just a[0]. Each pass takes key = a[i] and slides larger neighbours right until key fits.
| i | key | array before placing | array after placing |
|---|---|---|---|
| 1 | 3 | 5 3 8 1 9 2 7 | 3 5 8 1 9 2 7 |
| 2 | 8 | 3 5 8 1 9 2 7 | 3 5 8 1 9 2 7 |
| 3 | 1 | 3 5 8 1 9 2 7 | 1 3 5 8 9 2 7 |
| 4 | 9 | 1 3 5 8 9 2 7 | 1 3 5 8 9 2 7 |
| 5 | 2 | 1 3 5 8 9 2 7 | 1 2 3 5 8 9 7 |
| 6 | 7 | 1 2 3 5 8 9 7 | 1 2 3 5 7 8 9 |
After i = 6 the array is fully sorted: 1 2 3 5 7 8 9.
qsort call. qsort(copy, n, sizeof *copy, cmp_int_asc) tells the library: here are n elements starting at copy, each sizeof(int) bytes, and call cmp_int_asc to compare any two. Internally qsort repeatedly hands two element pointers to the comparator and rearranges bytes accordingly.
Inside the comparator. Suppose qsort compares 8 and 1. It passes pa pointing at the 8, pb at the 1. We cast and dereference: a = 8, b = 1. Then (a > b) - (a < b) = 1 - 0 = 1, meaning 8 comes after 1.
Cleanup. free(copy) returns the heap memory we requested. data was a local array, not heap memory, so it is not freed.
/* WRONG: can overflow for extreme values */
int cmp(const void *pa, const void *pb) {
return *(const int *)pa - *(const int *)pb;
}
Why it is wrong: if pa points at INT_MIN and pb at a large positive value, the subtraction overflows. Signed integer overflow is undefined behaviour in C, and even when it does not crash, the sign of the result can flip, sorting things backwards.
/* CORRECT */
int cmp(const void *pa, const void *pb) {
int a = *(const int *)pa, b = *(const int *)pb;
return (a > b) - (a < b);
}
Prevent it by never subtracting to compare; always use explicit comparison.
qsortqsort(arr, n, sizeof arr, cmp); /* WRONG: sizeof a pointer or whole array */
qsort needs the size of one element. Passing the array's total size or a pointer's size scrambles the data. Use sizeof arr[0] or sizeof *arr.
size_t n = sizeof data; /* WRONG: bytes, not element count */
size_t n = sizeof data / sizeof data[0]; /* CORRECT */
This only works on a real array in scope, not on a pointer — once an array decays to a pointer (e.g. inside a function), sizeof gives the pointer size, so pass n as a parameter.
Reading a[j] and a[j+1] with j going up to n instead of n - 1 reads one element past the end — a buffer over-read. Always double-check the upper bound of the inner loop.
qsort is stableThe C standard does not guarantee qsort keeps equal elements in their original order. If you need stability, sort by a composite key (add the original index as a tiebreaker) or use mergesort.
#include <stdlib.h>.int (*)(const void *, const void *).const usually means you cast void * to int * instead of const int * inside the comparator.sizeof *arr, not sizeof arr) and the element count.a - b and hit overflow. Swap the comparison or use (a > b) - (a < b).-Wall -Wextra and fix every warning.-fsanitize=address,undefined to catch out-of-bounds reads and overflow.printf inside the comparator to see which pairs are compared and what you return.a[j-1], a[j], and a[j+1]; one wrong bound is an out-of-bounds read or write (undefined behaviour). Keep inner loops within [0, n-1] and prove the bound on paper for small n.qsort trusts the size and nmemb you pass. A wrong size makes it read and write the wrong bytes, corrupting memory silently. Always use sizeof *arr for size and pass the real element count.cmp(a,b) < 0 then cmp(b,a) > 0). An inconsistent comparator can make qsort read out of bounds — this is real undefined behaviour, not just a wrong result.a - b) is signed overflow for extreme values, which is undefined. Use (a > b) - (a < b).malloc for NULL and free it exactly once when done, as in the example.Robust habit: compile with -Wall -Wextra -fsanitize=address,undefined while developing sorting code — most of these bugs surface immediately.
ORDER BY, to build indexes, and to run merge joins. The standard libraries of essentially every language ship a tuned sort (often an O(n log n) hybrid).sort command, log merging, package dependency ordering.O(n log n) and O(1) extra space matter and worst-case timing is required.Beginner rules
qsort in C) over hand-rolling one.-1/0/1 via comparison, never subtraction.sizeof *arr for element size and pass the real count.malloc, and free what you allocate.Advanced habits
n.Write int is_sorted(const int *a, size_t n) that returns 1 if a is in non-decreasing order, else 0.
n == 0 and n == 1 (both sorted).{1,2,2,5} -> 1; {1,3,2} -> 0.a[i-1] and a[i]; return 0 on the first violation.qsortSort an int array into descending order using qsort.
cmp_desc and call qsort with the correct size and count.{5,1,3} -> 5 3 1.(b > a) - (b < a).qsort, comparator, overflow-safe comparison.Implement insertion sort that also returns the number of element moves it performed.
size_t insertion_sort_count(int *a, size_t n); sort in place and return the count of shifts.0.Given struct Person { char name[32]; int age; };, sort an array by age ascending, breaking ties by name (use strcmp).
age, then falls back to strcmp(name).int d = (x->age > y->age) - (x->age < y->age); return d ? d : strcmp(x->name, y->name);qsort on non-int types, multi-key comparison, stability via tiebreaker.Write int *merge_sorted(const int *a, size_t na, const int *b, size_t nb, size_t *out_len) that returns a newly allocated array containing all elements of a and b in sorted order, assuming a and b are each already sorted.
O(na + nb) (the merge step of mergesort, no full re-sort); allocate the result, set *out_len, and let the caller free it; return NULL on allocation failure.a={1,4,6}, b={2,3,5} -> {1,2,3,4,5,6}.i and j, always copying the smaller front element.malloc/free, bounds, linear-time merging.n. Simple sorts (bubble, insertion) are O(n^2); merge, quick, and heap sort are O(n log n) — though quicksort's worst case is O(n^2).O(n) extra space; quicksort is in place but unstable with a bad worst case; heapsort is in place and worst-case O(n log n) but unstable.qsort(base, nmemb, size, compar) with a comparator that returns negative / zero / positive. Use sizeof *arr for size.qsort is stable.qsort and write a correct, overflow-free comparator; reserve hand-written sorts for learning, tiny arrays, or nearly-sorted data.