B2.4 Programming Algorithms
Big O, searching and sorting. Code in Python.
- B2.4.1: Big O notation
- B2.4.2: Linear and binary search
- B2.4.3: Bubble and selection sort
- Standard algorithms: sum, average, count, max, min
- Practice
B2.4.4 and B2.4.5 (recursion) are HL and will get their own page.
B2.4.1: Big O Notation
Big O notation describes how the number of operations (time) or memory (space) an algorithm needs grows as the input size n grows. It gives the upper bound, so it describes the worst case. It measures scalability, not actual seconds.
| Big O | Name | Operations grow... | Example |
|---|---|---|---|
| O(1) | Constant | not at all | Access by index, stack push/pop |
| O(log n) | Logarithmic | by 1 each time n doubles (problem halves each step) | Binary search |
| O(n) | Linear | in proportion to n | Linear search, one loop through a list |
| O(n log n) | Log linear | a bit faster than n | Merge sort, quicksort (average) |
| O(n²) | Quadratic | with n squared | Nested loops: bubble sort, selection sort |
| O(n³) | Cubic | with n cubed | Three nested loops |
| O(2n) | Exponential | doubles every time n increases by 1 | Brute forcing every password combination, naive recursive Fibonacci |
| O(n!) | Factorial | by multiplying by n each time | Trying every route in the travelling salesman problem |
Why it matters
| n | log n | n | n log n | n² | 2n |
|---|---|---|---|---|---|
| 10 | 3 | 10 | 33 | 100 | 1,024 |
| 1,000 | 10 | 1,000 | ~10,000 | 1,000,000 | impossible |
| 1,000,000 | 20 | 1,000,000 | ~20,000,000 | 1012 | impossible |
Fastest to slowest: O(1) < O(log n) < O(n) < O(n log n) < O(n²) < O(2n) < O(n!)
Working out Big O
- Find the operation that repeats most (usually inside the loops).
- Count how many times it runs in terms of n.
- Keep only the biggest term, drop constants. 3n + 5 → O(n). n² + n → O(n²).
| Code shape | Big O |
|---|---|
| No loop, fixed number of steps | O(1) |
| One loop over n items | O(n) |
| Two loops one after the other | O(n + n) = O(n) |
| A loop inside a loop, both over n | O(n × n) = O(n²) |
| Loop where the range halves each time | O(log n) |
def percent(x, y): # O(1): 2 operations, whatever the input
return x / y * 100
def find_max(values): # O(n): the loop body runs n times
highest = values[0]
for v in values:
if v > highest:
highest = v
return highest
def has_duplicate(values): # O(n²): n passes of the inner loop for each of n outer passes
for i in range(len(values)):
for j in range(i + 1, len(values)):
if values[i] == values[j]:
return True
return False
Time complexity = how the number of operations grows. Space complexity = how much extra memory is needed. Sorting in place (swapping inside the same list) is O(1) space.
B2.4.2: Linear and Binary Search
Linear search
Check every element in turn from index 0 until the target is found or the list ends. Works on unsorted data.
def linear_search(items, target):
for i in range(len(items)):
if items[i] == target:
return i # found: return the index
return -1 # not found
playlist = ["The Old Bridge", "Down by the Fountain", "Super Searchin'",
"Little Red Lies", "Geescht", "Watching Over You"]
print(linear_search(playlist, "Little Red Lies")) # 3
print(linear_search(playlist, "Yesterday")) # -1
Best case O(1) (first item). Worst case O(n) (last item or not there).
Binary search
Data must be sorted. Look at the middle element. If it is the target, stop. If the target is bigger, discard the left half; if smaller, discard the right half. Repeat on what is left.
def binary_search(items, target):
low = 0
high = len(items) - 1
while low <= high:
mid = (low + high) // 2
if items[mid] == target:
return mid
elif items[mid] < target:
low = mid + 1 # target is in the right half
else:
high = mid - 1 # target is in the left half
return -1
nums = [10, 11, 12, 15, 20, 25, 45, 84, 129, 139, 483, 586, 685]
print(binary_search(nums, 139)) # 9
Trace: searching for 139
| low | high | mid | items[mid] | Action |
|---|---|---|---|---|
| 0 | 12 | 6 | 45 | 45 < 139, so low = 7 |
| 7 | 12 | 9 | 139 | Found, return 9 |
Trace: searching for 13 (not in the list)
| low | high | mid | items[mid] | Action |
|---|---|---|---|---|
| 0 | 12 | 6 | 45 | 45 > 13, so high = 5 |
| 0 | 5 | 2 | 12 | 12 < 13, so low = 3 |
| 3 | 5 | 4 | 20 | 20 > 13, so high = 3 |
| 3 | 3 | 3 | 15 | 15 > 13, so high = 2 |
| 3 | 2 | low > high: stop, return -1 |
Each comparison halves the data, so worst case is O(log n). A million items need at most about 20 comparisons.
| Linear search | Binary search | |
|---|---|---|
| Data must be sorted? | No | Yes |
| Worst case | O(n) | O(log n) |
| Best case | O(1) | O(1) |
| Best for | Small or unsorted lists | Large sorted lists |
| Works on | Arrays, lists, linked lists, files | Structures with direct index access |
B2.4.3: Bubble Sort and Selection Sort
Bubble sort
Compare each adjacent pair and swap if they are in the wrong order. After each pass the largest unsorted value has "bubbled" to the end, so each pass checks one fewer element.
def bubble_sort(a):
n = len(a)
for i in range(n - 1): # n - 1 passes
swapped = False
for j in range(n - 1 - i): # last i items are already sorted
if a[j] > a[j + 1]:
a[j], a[j + 1] = a[j + 1], a[j]
swapped = True
if not swapped: # no swaps: already sorted, stop early
break
return a
print(bubble_sort([10, 8, 349, 3, 39, 1])) # [1, 3, 8, 10, 39, 349]
Trace (state at the end of each pass, green = in its final place):
| Pass | List | Swaps | |||||
|---|---|---|---|---|---|---|---|
| start | 10 | 8 | 349 | 3 | 39 | 1 | |
| 1 | 8 | 10 | 3 | 39 | 1 | 349 | 4 |
| 2 | 8 | 3 | 10 | 1 | 39 | 349 | 2 |
| 3 | 3 | 8 | 1 | 10 | 39 | 349 | 2 |
| 4 | 3 | 1 | 8 | 10 | 39 | 349 | 1 |
| 5 | 1 | 3 | 8 | 10 | 39 | 349 | 1 |
Pass 1 in detail: 10>8 swap, 10<349 no swap, 349>3 swap, 349>39 swap, 349>1 swap.
Selection sort
Find the smallest value in the unsorted part and swap it into the first unsorted position. The sorted part grows from the left by one each pass.
def selection_sort(a):
n = len(a)
for i in range(n - 1):
min_index = i
for j in range(i + 1, n): # find the smallest in the unsorted part
if a[j] < a[min_index]:
min_index = j
a[i], a[min_index] = a[min_index], a[i] # one swap per pass
return a
print(selection_sort([10, 8, 349, 3, 39, 1])) # [1, 3, 8, 10, 39, 349]
| Pass | Smallest found | List after swap | |||||
|---|---|---|---|---|---|---|---|
| start | 10 | 8 | 349 | 3 | 39 | 1 | |
| 1 | 1 (index 5) | 1 | 8 | 349 | 3 | 39 | 10 |
| 2 | 3 (index 3) | 1 | 3 | 349 | 8 | 39 | 10 |
| 3 | 8 (index 3) | 1 | 3 | 8 | 349 | 39 | 10 |
| 4 | 10 (index 5) | 1 | 3 | 8 | 10 | 39 | 349 |
| 5 | 39 (index 4) | 1 | 3 | 8 | 10 | 39 | 349 |
Selection sort can also find the largest each pass and place it at the end. Same idea, same Big O.
Comparison
| Bubble sort | Selection sort | |
|---|---|---|
| Time: worst / average | O(n²) | O(n²) |
| Time: best (already sorted) | O(n) with the swapped check | O(n²): always scans everything |
| Space | O(1), sorts in place | O(1), sorts in place |
| Swaps | Many, up to one per comparison | At most n − 1 (one per pass) |
| Advantages | Simple. Stops early on nearly sorted data. | Simple. Few swaps, good when swapping is expensive. |
| Disadvantages | Both are too slow for large data sets. Real programs use O(n log n) sorts like merge sort. | |
Standard Algorithms
All O(n): one pass through the list.
data = [1, 20, 39, 4, 12, 20, 4, 34, 2, 29, 20, 5, 66]
total = 0
count = 0
max_value = data[0]
max_index = 0
min_value = data[0]
for i in range(len(data)):
total = total + data[i] # sum
if data[i] == 20:
count = count + 1 # count occurrences
if data[i] > max_value:
max_value = data[i] # maximum (and where it is)
max_index = i
if data[i] < min_value:
min_value = data[i] # minimum
average = total / len(data)
print(total, round(average, 2), count, max_value, max_index, min_value)
# 256 19.69 3 66 12 1
Start max and min at data[0], not 0. If every value were negative, a max starting at 0 would be wrong. Built in shortcuts: sum(), max(), min(), list.count(), but in exams write the loop.
Practice
- Print the first element of a list.
- Print every element of a list, then print every element again.
- For every student, compare their score with every other student's score.
- A guessing game where each guess halves the possible range.
[10, 11, 12, 15, 20, 25, 45, 84, 129, 139, 483, 586, 685]
| low | high | mid | items[mid] | Action |
|---|---|---|---|---|
| 0 | 12 | 6 | 45 | 45 < 84, low = 7 |
| 7 | 12 | 9 | 139 | 139 > 84, high = 8 |
| 7 | 8 | 7 | 84 | Found, return 7 |
- It explains binary search using a binary search tree. Binary search works on a sorted list by checking the middle.
- Its Python binary search uses
while left < right, which can miss the last element. Use<=. - Its Python linear search stores the playlist in a set (no indexes) and uses
name.index, which doesn't work. - It calls quicksort O(log n). Quicksort is O(n log n) on average, O(n²) worst case.
- Its bubble sort trace has wrong rows, and its selection sort text says "highest" while its code finds the lowest.
- Its "exponential" example (202 pairings) is not exponential. Exponential means 2n: adding one item doubles the work.
- Big O = worst case growth. Drop constants, keep the biggest term.
- One loop O(n), nested loops O(n²), halving O(log n), no loop O(1).
- Linear search: any order, O(n). Binary search: sorted only, O(log n).
- Bubble: swap neighbours, biggest bubbles to the end. Selection: find the smallest, swap to the front.
- Both sorts: O(n²) time, O(1) space. Selection makes fewer swaps. Bubble can stop early.
- In traces, write the list after every pass and mark what is already in place.