Skip to main content

Algorithms

Common algorithms implemented in Python

Algorithms are the step-by-step procedures that turn input into output, and Python coursework treats them as the bridge between syntax fluency and computer science. The five families below cover most intermediate assignments: Big-O analysis, sorting, searching, recursion plus dynamic programming, and graph traversal. Each section ships a runnable implementation that fits inside a typical CS50P, CSE 163, or CMU 10-601 problem set.

Big-O in plain English with 4 common classes

Big-O describes how long an algorithm takes as input size grows, ignoring constants. **O(1)** is constant time: indexing a list, getting a dict value. **O(log n)** is logarithmic: binary search, balanced-tree lookup. **O(n)** is linear: scanning a list once. **O(n log n)** is the sorting band: Python's TimSort, mergesort, heapsort. **O(n squared)** is quadratic and shows up in nested loops over the same data, like bubble sort or naive duplicate detection.

The mental model: double the input and ask how the time changes. Constant time stays flat. Linear time doubles. Quadratic time quadruples. Logarithmic time adds one step. This is what graders mean when they reject a working solution with "your code is correct but too slow". The complexity, not the language, is the bottleneck.

Memory complexity follows the same notation. A recursive function that holds n frames on the call stack uses O(n) extra space. An in-place sort uses O(1) auxiliary memory plus the call stack. Course rubrics at MIT 6.100L and Stanford CS106A award separate marks for time and space analysis.

Example

                      
                        # Four loops, four complexity classes.

def constant_lookup(values):
    # O(1): one index access, no loop over input
    return values[0]

def linear_scan(values, target):
    # O(n): one pass through the list
    for x in values:
        if x == target:
            return True
    return False

def quadratic_pairs(values):
    # O(n^2): two nested loops over the same data
    pairs = []
    for i in values:
        for j in values:
            pairs.append((i, j))
    return pairs

print(constant_lookup([10, 20, 30]))       # 10
print(linear_scan([1, 2, 3, 4], 3))        # True
print(len(quadratic_pairs([1, 2, 3])))     # 9
                      
                    

Sorting with sorted, list.sort, and the key parameter

Python's built-in **sorted()** returns a new sorted list. The method **list.sort()** sorts in place and returns None. Both use TimSort, a hybrid of mergesort and insertion sort with worst-case O(n log n) and best-case O(n) on partially sorted input. TimSort was invented for Python in 2002 and is now the default in Java and Android too.

The **key** parameter takes a function that returns the sort criterion for each element. `sorted(students, key=lambda s: s.gpa)` orders by GPA. Combine with `reverse=True` for descending order. For multi-level sorts, return a tuple from the key: `key=lambda s: (s.year, -s.gpa)` sorts by year ascending, then by GPA descending. TimSort is stable, which means equal keys keep their original order, so chained sorts compose correctly.

Writing your own sort is a learning exercise. In production code, the built-in beats hand-rolled bubble sort by 20x to 100x on lists of 10000 items because it is implemented in C. Implement bubble sort or insertion sort once to understand the mechanics, then use `sorted` for everything else.

Example

                      
                        # Built-in sort with key and stable behavior.

students = [
    {"name": "Alice", "year": 2, "gpa": 3.8},
    {"name": "Bob",   "year": 1, "gpa": 3.9},
    {"name": "Cara",  "year": 2, "gpa": 3.6},
]

# Sort by gpa descending
by_gpa = sorted(students, key=lambda s: s["gpa"], reverse=True)
for s in by_gpa:
    print(s["name"], s["gpa"])

# Multi-level: year ascending, then gpa descending
ordered = sorted(students, key=lambda s: (s["year"], -s["gpa"]))
print([s["name"] for s in ordered])
# ['Bob', 'Alice', 'Cara']
                      
                    

Linear search versus binary search with bisect

Linear search scans the list one element at a time. Time complexity is O(n). It works on any iterable, sorted or not, and is the right choice for a list of 50 items or for one-off lookups.

Binary search needs a **sorted** list and runs in O(log n). It checks the middle element, discards half the list, and repeats. For 1 million items, linear search makes up to 1 million comparisons; binary search makes at most 20. The standard library module **bisect** implements this for you with `bisect_left` and `bisect_right`, which return the insertion index that would keep the list sorted.

Use bisect when the same sorted list is searched many times, such as a lookup table built once at startup. Use linear search when the list changes often, when sorting it first costs more than the savings, or when the data is small. Calling `bisect_left` returns the position where the target would go; combining it with an equality check tells you whether the target is present.

Example

                      
                        import bisect

def linear_search(values, target):
    # O(n), no sorting required
    for i, v in enumerate(values):
        if v == target:
            return i
    return -1

def binary_search(sorted_values, target):
    # O(log n), input must be sorted
    i = bisect.bisect_left(sorted_values, target)
    if i < len(sorted_values) and sorted_values[i] == target:
        return i
    return -1


nums = [2, 5, 7, 8, 11, 12, 20]
print(linear_search(nums, 11))    # 4
print(binary_search(nums, 11))    # 4
print(binary_search(nums, 99))    # -1
                      
                    

Recursion and when iteration beats it

Recursion is a function that calls itself on a smaller version of the problem. Two parts are required: a **base case** that returns directly, and a **recursive case** that reduces the problem and calls the function again. Without a base case, the call stack grows forever and you get RecursionError on item 1000 (Python's default limit, raised with `sys.setrecursionlimit`).

Recursion shines when the problem has a tree structure: file-system traversal, parsing nested JSON, expression evaluation, divide-and-conquer algorithms. It struggles when the recursion is deep and linear, because each call adds a frame to the stack. A factorial of 10000 written recursively crashes. The same factorial written iteratively runs in milliseconds.

Tail-call optimization, which some languages use to make linear recursion stack-safe, is intentionally absent in Python. The Python position is that explicit loops are clearer than implicit recursion. Rule of thumb: if the recursive call is the last thing the function does and the recursion is linear, an iterative `for` loop is faster and uses constant stack space.

Example

                      
                        def factorial_recursive(n):
    # base case
    if n == 0:
        return 1
    # recursive case
    return n * factorial_recursive(n - 1)


def factorial_iterative(n):
    # same result, constant stack
    result = 1
    for i in range(2, n + 1):
        result *= i
    return result


print(factorial_recursive(5))   # 120
print(factorial_iterative(5))   # 120

# Recursive version crashes on factorial(2000) with RecursionError.
# Iterative version handles it without issue.
                      
                    

Dynamic programming via functools.lru_cache

Dynamic programming solves a problem by combining solutions to overlapping subproblems and caching the results. The naive recursive Fibonacci has time complexity O(2^n) because `fib(5)` recomputes `fib(3)` three times and `fib(2)` five times. Caching shrinks it to O(n).

The decorator **@functools.lru_cache** does memoization for you. Apply it to a pure function (same input always returns the same output, no side effects) and Python stores recent results in an internal dictionary keyed by the argument tuple. The second call with the same arguments returns the cached value in O(1). The `maxsize` parameter caps cache memory; `maxsize=None` keeps everything, which fits coursework but not long-running services.

Memoization is one DP style; tabulation is the other. Tabulation fills an array bottom-up with explicit loops. Memoization is easier to write and reason about. Use tabulation when the recursion depth would exceed Python's stack limit or when you can drop the array down to constant space, which is the trick that turns Fibonacci into O(1) memory.

Example

                      
                        from functools import lru_cache

@lru_cache(maxsize=None)
def fib_memo(n):
    if n < 2:
        return n
    return fib_memo(n - 1) + fib_memo(n - 2)


def fib_tabulated(n):
    # iterative, constant memory
    a, b = 0, 1
    for _ in range(n):
        a, b = b, a + b
    return a


print(fib_memo(30))       # 832040
print(fib_tabulated(30))  # 832040
print(fib_memo.cache_info())
# CacheInfo(hits=28, misses=31, maxsize=None, currsize=31)
                      
                    

Graph traversal with BFS and DFS on a dict

A graph is a set of nodes connected by edges. The simplest Python representation is a **dict adjacency list**: keys are nodes, values are the list of neighbors. `graph["A"] = ["B", "C"]` says A connects to B and C. This format scales to thousands of nodes and is what scikit-learn, NetworkX, and most coursework expect.

Breadth-first search (BFS) explores the graph level by level. It uses a **queue** (deque from collections, which gives O(1) popleft) and a **visited set** to avoid revisiting nodes. BFS finds the shortest path in an unweighted graph. Depth-first search (DFS) goes as deep as possible before backtracking. It uses a stack (a list with append and pop, or recursion) and the same visited set. DFS is the right tool for cycle detection, topological sort, and connected-components analysis.

Both run in O(V + E) where V is the number of nodes and E the number of edges. For a graph with 1000 nodes and 5000 edges, that is 6000 operations. The visited set is the critical detail: without it, cycles trap the traversal in an infinite loop.

Example

                      
                        from collections import deque

graph = {
    "A": ["B", "C"],
    "B": ["A", "D", "E"],
    "C": ["A", "F"],
    "D": ["B"],
    "E": ["B", "F"],
    "F": ["C", "E"],
}

def bfs(g, start):
    visited = {start}
    queue = deque([start])
    order = []
    while queue:
        node = queue.popleft()
        order.append(node)
        for neighbor in g[node]:
            if neighbor not in visited:
                visited.add(neighbor)
                queue.append(neighbor)
    return order

def dfs(g, start, visited=None, order=None):
    if visited is None:
        visited, order = set(), []
    visited.add(start)
    order.append(start)
    for neighbor in g[start]:
        if neighbor not in visited:
            dfs(g, neighbor, visited, order)
    return order


print(bfs(graph, "A"))  # ['A', 'B', 'C', 'D', 'E', 'F']
print(dfs(graph, "A"))  # ['A', 'B', 'D', 'E', 'F', 'C']
                      
                    

Common pitfalls

Writing a recursive solution with no base case, or with a base case that the recursion never reaches. Python raises RecursionError at depth 1000.

State the base case first, before the recursive call. Then verify each recursive call moves strictly toward the base, like n - 1 toward 0.

Using binary search on an unsorted list. The algorithm returns wrong indices or -1 even when the target exists.

Sort the list first with sorted() or list.sort(). Better: use bisect.bisect_left, which makes the requirement explicit and handles edge cases.

Implementing bubble sort or selection sort in production code. Both run in O(n squared) and lose to Python built-in sorted by 50x on a list of 5000 items.

Call sorted(values) or values.sort(). Use the key parameter for custom criteria. Hand-rolled sorts are a teaching artifact, not a working tool.

Forgetting the visited set in BFS or DFS on a graph with cycles. The traversal revisits nodes endlessly and the program hangs.

Initialize visited = set() before the traversal and check if neighbor not in visited before queueing or recursing. Add to visited the moment you enqueue, not when you dequeue.

Applying @lru_cache to a function with mutable arguments like a list. Python raises TypeError because lists are unhashable.

Convert the argument to a tuple before the cached call, or refactor the function to accept hashable inputs. lru_cache uses the argument tuple as the dictionary key.

Naive Fibonacci with no memoization. fib(40) takes 40 seconds; fib(50) does not finish in a reasonable session.

Add @functools.lru_cache(maxsize=None) above the function definition, or rewrite iteratively with two rolling variables for O(n) time and O(1) memory.

When to use algorithms

Reach for algorithmic analysis when input size grows past a few thousand items and a naive loop becomes the bottleneck. Pick the right family: sorting and searching for ordered data, recursion for tree-shaped problems, dynamic programming for overlapping subproblems, graph traversal for any problem that maps to nodes plus edges.

Need Help?

Having trouble with this topic on an assignment? Our Python developers ship working code plus a walkthrough that helps you explain the code in class.