Algorithmic Efficiency and Big-O Complexity
Algorithmic efficiency measures how the number of computational operations grows as the input size increases. It is expressed using Big-O notation. Big-O does not measure clock time in seconds; it reflects the rate of growth in operations.
Search and Sort Complexity Overview
| Algorithm | Best-Case Time | Worst-Case Time | Behaviour and Key Characteristics |
|---|---|---|---|
| Linear Search | Checks elements sequentially one by one from the start. Works on unsorted data. | ||
| Binary Search | Divides the search range in half each step. Precondition: list must be sorted. | ||
| Simple Sort (Selection) | Always performs the full set of comparisons, even if the data is already sorted. | ||
| Bubble Sort | Compares adjacent pairs. ( best-case applies only if an early-exit flag detects no swaps were made). Unless a flag is shown, assume . | ||
| Insertion Sort | Highly efficient for nearly sorted data; quadratic if data is in reverse order. | ||
| Quicksort | Divide-and-conquer algorithm; degrades to if the pivot choices are repeatedly poor. |
Concrete Search Comparison
Searching for the value 23 in the sorted list [2, 5, 8, 12, 16, 23, 38] ():
- Linear Search: Compares 2, 5, 8, 12, 16, 23. It finds the item on the 6th comparison. If the target was absent or at the end, it would make 7 comparisons ().
- Binary Search: Checks the middle element (index 3, value 12). Since , the left half is discarded. The sublist is
[16, 23, 38]. It checks the new middle element (value 23), matching on the 2nd comparison. Doubling the list size adds only 1 extra comparison step ().
Quadratic Growth:
If an algorithm has a complexity of , doubling the input size from to multiplies the number of operations by . Tripling the input size to multiplies operations by .
Algorithmic Complexity and Big O Notation
Rather than timing code in seconds—which changes depending on the computer processor used—we measure algorithmic efficiency using Big O notation. Big O describes how an algorithm's running time or memory usage scales as the input size increases.
To see why this matters, consider input growth: if a list grows from 10 to 100 items ( larger), an algorithm performs 10 times more operations, while an algorithm performs times more operations.
| Complexity | Name | How Operations Grow | Example |
|---|---|---|---|
| Constant Time | Unaffected by input size | Reading a list element at a known index (L[0]) | |
| Logarithmic Time | Halves the remaining data space at each step | Binary search on a sorted list | |
| Linear Time | Grows directly in proportion to | Linear search across a list | |
| Log-Linear Time | Divides data efficiently while recombining | Quicksort in best and average cases | |
| Quadratic Time | Grows with the square of ; doubling quadruples work | Nested loops in bubble sort, simple sort, insertion sort |
Complexity is evaluated across three conditions:
- Best case: the minimum number of steps needed under ideal input conditions.
- Average case: the typical number of operations required for randomly ordered data.
- Worst case: the maximum number of operations required for the least favourable data arrangement.
Searching Algorithms: Linear and Binary Search
Searching algorithms find the position of a target item in a list.
Linear Search
Linear search examines each item sequentially from index 0 until the target is located or the list ends. It works on unsorted and sorted lists alike.
# Using a while loop with a found flag
names = ["John", "Mary", "Zoe", "Alex", "Séamas"]
target = "Zoe"
found = False
index = 0
while not found and index < len(names):
if names[index] == target:
found = True
else:
index += 1- Best case: 1 comparison (target is at index 0), so .
- Average case: checks roughly half the list ( steps), so .
- Worst case: comparisons (target is the last item or absent), so .
Binary Search
Binary search works by repeated halving. It compares the target with the middle element. If the target matches, the search ends; if smaller, the search continues in the left half; if larger, it continues in the right half. The essential prerequisite is that the list must be sorted.
def binary_search(numbers, target):
low = 0
high = len(numbers) - 1
while low <= high:
mid = (low + high) // 2
if numbers[mid] == target:
return mid
elif numbers[mid] < target:
low = mid + 1
else:
high = mid - 1
return -1 # Target not in list- Best case: 1 comparison (target is at the initial midpoint), so .
- Average and worst case: halving means a list of size takes roughly comparisons, so .
Each doubling of the list size adds just one comparison. A list of 1,000 items takes at most 10 comparisons (), while 1,000,000 items takes only about 20 comparisons.
If you only need to run a single search on an unsorted list, linear search () is faster overall than sorting first () just to run a binary search. If you need to search the same data repeatedly, sorting it once and using binary search saves time.
Simple Sort and Insertion Sort
Sorting organizes items into ascending or descending order. The data elements must be comparable.
Simple Sort (often taught as Selection Sort)
The syllabus lists 'Simple sort'. In classroom practice, simple sort is usually taught as repeatedly finding the smallest remaining item in the unsorted section and placing it into its correct position at the front, which is identical to selection sort.
def simple_sort(numbers):
n = len(numbers)
for i in range(n):
min_index = i
for j in range(i + 1, n):
if numbers[j] < numbers[min_index]:
min_index = j
numbers[i], numbers[min_index] = numbers[min_index], numbers[i]
return numbersTrace on [29, 10, 14, 37, 13]:
- Pass 1: find minimum (10), swap with index 0
[10, 29, 14, 37, 13] - Pass 2: find minimum from index 1 (13), swap with index 1
[10, 13, 14, 37, 29] - Pass 3: find minimum from index 2 (14), already at index 2
[10, 13, 14, 37, 29] - Pass 4: find minimum from index 3 (29), swap with index 3
[10, 13, 14, 29, 37]
Simple sort scans every unsorted item in every pass, so best, average, and worst cases are all .
Insertion Sort
Insertion sort mimics sorting a hand of playing cards. It steps through the list, picks the next item (the key), and shifts larger items in the sorted portion one slot to the right until the key reaches its place.
def insertion_sort(numbers):
for i in range(1, len(numbers)):
key = numbers[i]
j = i - 1
while j >= 0 and numbers[j] > key:
numbers[j + 1] = numbers[j] # Shift right
j -= 1
numbers[j + 1] = key
return numbersTrace on [5, 2, 4, 1]:
- , key = 2: , shift 5, insert 2
[2, 5, 4, 1] - , key = 4: , shift 5, stop before 2, insert 4
[2, 4, 5, 1] - , key = 1: shift 5, 4, and 2, insert 1 at index 0
[1, 2, 4, 5]
- Best case: when the list is already sorted. Each item is compared once with its neighbour on the left, requiring 0 shifts.
- Average and worst case: . A reversed list requires comparing and shifting past every item in the sorted section.
Bubble Sort
Bubble sort steps through a list, compares adjacent items, and swaps them if they are out of order. With each complete pass, the largest remaining item moves to the end of the unsorted segment.
def bubble_sort(numbers):
n = len(numbers)
for i in range(n):
swapped = False
for j in range(0, n - i - 1):
if numbers[j] > numbers[j + 1]:
numbers[j], numbers[j + 1] = numbers[j + 1], numbers[j]
swapped = True
if not swapped:
break # Early exit if no swaps occurred
return numbersUnderstanding Bubble Sort Complexity
The best-case complexity depends on whether the code includes an early-exit flag:
- Basic Bubble Sort (no flag): runs every pass regardless of order. It performs roughly comparisons on every list, meaning its best and worst cases are both .
- Optimised Bubble Sort (with
swappedflag): stops after one pass if no swaps are made. On an already sorted list, it makes comparisons and terminates, giving a best-case complexity of . - In the worst case (a reverse-sorted list), both versions make the maximum number of comparisons and swaps: .
Recursion and Quicksort
A recursive function is a function that calls itself to solve smaller instances of the same problem. Every recursive algorithm requires two components:
- A base case, which stops recursion and returns a result directly without another call.
- A recursive case, which calls the function again with inputs closer to the base case.
def factorial(n):
if n <= 1: # Base case
return 1
return n * factorial(n - 1) # Recursive caseTracing factorial(4):
factorial(4)waits forfactorial(3)waits forfactorial(2)waits forfactorial(1)hits the base case and returns 1.
The pending calls resolve backwards: , then , then . If the base case is missing or never reached, the program will trigger a RecursionError by exceeding Python's call limit.
Quicksort
Quicksort is a divide-and-conquer algorithm. It selects an item as a pivot, creates sublists of items smaller than, equal to, and greater than that pivot, and sorts the smaller and larger sublists recursively.
def quicksort(numbers):
if len(numbers) <= 1: # Base case
return numbers
pivot = numbers[len(numbers) // 2]
left = [x for x in numbers if x < pivot]
middle = [x for x in numbers if x == pivot]
right = [x for x in numbers if x > pivot]
return quicksort(left) + middle + quicksort(right)Pivot Selection and Partition Balance
- Best and average case: . Occurs when the pivot divides the list into roughly equal halves, producing a balanced recursion tree.
- Worst case: . Occurs when the pivot is repeatedly the extreme smallest or largest element. This produces an unbalanced partition where one sublist contains items and the other contains 0, eliminating the advantage of divide-and-conquer.
For example, in the list [60, 30, 80, 40, 10, 50, 20, 70, 90], picking 90 as the pivot places all 8 other items into the left sublist and leaves the right sublist empty, creating a worst-case partition.
Comparing Algorithms and Using Heuristics
Choosing the right algorithm depends on data size, whether the data is already ordered, and memory constraints.
| Algorithm | Advantages | Limitations |
|---|---|---|
| Linear Search | Simple to code; works on unsorted data | Slow on large datasets: worst case |
| Binary Search | Fast on large datasets: | Data must be sorted first; harder to write |
| Simple Sort | Easy to trace; makes at most swaps | Always comparisons, even if sorted |
| Insertion Sort | Fast on nearly sorted lists: best case ; sorts in place | Average and worst case are |
| Bubble Sort | Simple logic; early-exit flag detects sorted lists | Slow in practice: makes many swaps; worst case |
| Quicksort | Very fast on large datasets: average | Degrades to with poor pivots; recursive calls use call-stack memory |
Heuristics
Some problems have so many combinations that checking every possibility would take years, even on supercomputers. For example, in the Travelling Salesperson Problem (finding the shortest route visiting cities), 20 cities produce more than different possible routes, far too many to check one by one.
When calculating an exact answer is impractical, computer scientists use a heuristic: a practical problem-solving method designed to find a good-enough solution quickly.
- Nearest Neighbour Heuristic:
- Start at any city.
- Always visit the closest unvisited city next.
- When all cities have been visited, return to the start.
This delivers a usable route in seconds. However, because each local decision is made without seeing the full map, an early choice might force a long leg at the end.
- When to use heuristics: when computing resources or time are limited, and an approximate answer is acceptable (such as sat-nav routing, video game AI, or delivery scheduling).
- When to avoid heuristics: when an exact, guaranteed answer is mandatory (such as banking transactions, cryptography, or medical drug dosages).
- Trade-offs: using a heuristic can involve a loss of precision, accuracy, optimal performance, or completeness.
