Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Data Structures & Algorithms (DSA) Module 1 – Foundations: problems, correctness and complexity

Space complexity and measuring memory

Separate input, auxiliary and output space, count the recursion stack, and measure the peak memory of Python code with tracemalloc and sys.getsizeof.

  • Beginner
  • 25 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7, Node.js 24.21.0 and quickjs 0.32.0
  • By MySmartCoPilot

What you will learn

  • Separate input space, auxiliary space and output space
  • Count the recursion stack in a space analysis
  • Measure the peak memory of a Python function with tracemalloc
  • Explain why a list of numbers takes more memory than an array of the same numbers

Before you start

On this page

Time is not the only cost of an algorithm. Memory runs out too, and on a phone, in a browser tab or in a server that handles thousands of requests at once it can run out first. Space complexity describes how the memory an algorithm needs grows with its input, with the same notation as running time; this lesson shows what to count and how to measure it in Python.

Input, auxiliary and output space

Split the memory an algorithm touches into three parts:

  • Input space holds the input itself. Every algorithm needs it, so it says nothing about the algorithm.
  • Auxiliary space is the extra memory the algorithm uses while it works: variables, temporary lists, sets, and the call stack of a recursive function.
  • Output space holds the result, when the result is new data rather than a yes, a no or one number.

When a lesson says an algorithm “uses O(1) space”, it means auxiliary space unless it says otherwise. An algorithm that works in place changes its input instead of building a new copy and needs only O(1) auxiliary space, or sometimes O(log n) for a recursion that halves its input. Reversing a list shows the difference: items.reverse() reverses the list in place, while items[::-1] builds a second list of n references.

Measuring memory in Python

Python gives you two tools, and they answer different questions.

  • sys.getsizeof(x) returns the size of the object x alone, not of the objects it refers to.
  • The tracemalloc module records the memory blocks Python allocates while it is tracing; tracemalloc.get_traced_memory() returns the current total and the peak, the most there was at any one moment.
One object's size, and the peak memory of building a million values Python · measure_memory.py
import sys
import tracemalloc
from array import array

# 1. sys.getsizeof() measures one object only, not the objects it refers to.
numbers = list(range(1_000, 2_000))
print(f"sys.getsizeof(numbers):          {sys.getsizeof(numbers):>7,} bytes for the list and its 1,000 references")
print(f"sum of sys.getsizeof() of items: {sum(sys.getsizeof(x) for x in numbers):>7,} bytes for the 1,000 int objects")


# 2. tracemalloc counts every block Python allocates while it is tracing.
def peak_mb(build):
    """Most memory, in megabytes, that Python had allocated at one time while build() ran."""
    tracemalloc.start()
    data = build()
    peak = tracemalloc.get_traced_memory()[1]
    tracemalloc.stop()
    del data
    return peak / 1_000_000


N = 1_000_000
print(f"\nPeak memory to build {N:,} values:")
for label, build in [
    ("list(range(N))", lambda: list(range(N))),
    ("array('q', range(N))", lambda: array("q", range(N))),
    ("[i % 256 for i in range(N)]", lambda: [i % 256 for i in range(N)]),
    ("bytes(i % 256 for i in range(N))", lambda: bytes(i % 256 for i in range(N))),
]:
    print(f"  {label:<34} {peak_mb(build):5.1f} MB")

Output

sys.getsizeof(numbers):            8,056 bytes for the list and its 1,000 references
sum of sys.getsizeof() of items:  28,000 bytes for the 1,000 int objects

Peak memory to build 1,000,000 values:
  list(range(N))                      40.0 MB
  array('q', range(N))                 8.2 MB
  [i % 256 for i in range(N)]          8.4 MB
  bytes(i % 256 for i in range(N))     1.0 MB

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 measure_memory.py

Version note

In your browser the Run button uses Pyodide, a WebAssembly build of CPython, and the numbers differ from the recorded ones: there a reference takes 4 bytes instead of 8 and an int object takes less room, so the list rows come out about half as large. The array('q') and bytes rows stay the same, because their items take 8 bytes and 1 byte in both builds.

In your browser, Pyodide 314.0.7 (CPython 3.14.2) prints:

sys.getsizeof(numbers):            4,028 bytes for the list and its 1,000 references
sum of sys.getsizeof() of items:  16,000 bytes for the 1,000 int objects

Peak memory to build 1,000,000 values:
  list(range(N))                      20.0 MB
  array('q', range(N))                 8.2 MB
  [i % 256 for i in range(N)]          4.2 MB
  bytes(i % 256 for i in range(N))     1.0 MB

The first two lines show what “shallow” means: sys.getsizeof() counts the list and its 1,000 references, 8 bytes each on this 64-bit computer, and nothing of the 1,000 int objects those references point to, which take more than three times as much again.

The second part shows why the type of container matters as much as the algorithm:

  • list(range(N)) peaked at about 40 MB: 8 MB for a million references, and about 32 MB for the million separate int objects they point to. tracemalloc counts the whole 32-byte block that holds each one, a little more than the 28 bytes that sys.getsizeof() reports for the object itself.
  • array('q', ...) stores the values themselves, as 8-byte C integers side by side, in a little over 8 MB.
  • The list of a million numbers from 0 to 255 takes little more than the 8 MB of its references, because CPython keeps one shared object for each whole number from −5 to 256 and hands out references to it instead of making new ones.
  • bytes stores each value in a single byte, which is enough for numbers from 0 to 255: 1 MB.
A list stores references to separate int objects; an array of type q stores the 8-byte values themselves, side by side.A list of three numbersAn array('q') of the same numbersthree references8 bytes eachint object1000int object1001int object10021000 | 1001 | 1002the values side by side,8 bytes each, no objectsthe same numbers, without the objects

The same three numbers in a list and in an array

Text description of the diagram

The diagram shows the numbers 1000, 1001 and 1002 stored two ways, from top to bottom.

  • A Python list holds one reference per item, 8 bytes each on a 64-bit computer. Each reference points to a separate int object below it, and each of those objects has its own header besides the number.
  • An array of type code 'q' holds the three values themselves, next to each other, 8 bytes each, with no separate objects.

For a million numbers the difference is large: in this lesson's measurement the list took 40 MB and the array about 8 MB.

The same effect in Java

Java’s ArrayList<Integer> holds references to Integer objects, and the Java documentation describes an Integer as an object with one field of type int inside. An int[] array holds the int values themselves. In code that handles millions of numbers, the primitive array saves memory for the same reason as Python’s array.

The recursion stack is memory too

A recursive function that has not returned yet keeps its frame, its arguments and local variables, on the call stack. Summing a linked list recursively makes one call per node, and all of them wait at once:

Iterative and recursive sums of a linked list Python · linked_sum.py
import sys


class Node:
    """One item of a singly linked list: a value and the next node (None at the end)."""

    def __init__(self, value, next_node=None):
        self.value = value
        self.next = next_node


def build(n):
    head = None
    for value in range(n, 0, -1):
        head = Node(value, head)
    return head


def sum_iterative(node):
    total = 0
    while node is not None:  # one frame, however long the list: O(1) extra space
        total += node.value
        node = node.next
    return total


def sum_recursive(node, depth=1, deepest=None):
    """The same sum; deepest[0] records how many calls were waiting at the deepest point."""
    deepest[0] = max(deepest[0], depth)
    if node is None:
        return 0
    return node.value + sum_recursive(node.next, depth + 1, deepest)


print("recursion limit:", sys.getrecursionlimit())
for n in (10, 100, 900):
    head = build(n)
    deepest = [0]
    assert sum_recursive(head, deepest=deepest) == sum_iterative(head) == n * (n + 1) // 2
    print(f"n = {n:>4}: both sums agree; the recursive one had {deepest[0]:>3} calls waiting at once")

head = build(5_000)
print("n = 5000: iterative sum", sum_iterative(head))
try:
    sum_recursive(head, deepest=[0])
except RecursionError as error:
    print("n = 5000: recursive sum failed:", error)

Output

recursion limit: 1000
n =   10: both sums agree; the recursive one had  11 calls waiting at once
n =  100: both sums agree; the recursive one had 101 calls waiting at once
n =  900: both sums agree; the recursive one had 901 calls waiting at once
n = 5000: iterative sum 12502500
n = 5000: recursive sum failed: maximum recursion depth exceeded

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 linked_sum.py

Both functions do the same additions in the same time, O(n). The iterative one keeps a total and one node reference, so its auxiliary space is O(1). The recursive one has n + 1 calls waiting at its deepest point (one per node, plus the call that reaches the end), so its auxiliary space is O(n), although it creates no list at all. Python limits how deep the stack may grow, so that runaway recursion stops with an error instead of crashing the interpreter. The limit here was 1,000 frames, which is why the list of 5,000 nodes fails with RecursionError, while the loop sums it without trouble.

When a recursion’s depth grows with n, write the loop instead, or make sure the depth only grows like log n, as in a recursion that halves its input on every call.

Trading memory for time

Many fast algorithms are fast because they store something. Prefix sums are a clear case: keep the running totals of a list, and the sum of any range becomes one subtraction.

1,000 range sums, with and without prefix sums Python · range_sums.py
import random
from itertools import accumulate

rng = random.Random(9)
n, q = 1_000, 1_000
values = [rng.randint(1, 100) for _ in range(n)]
queries = [sorted(rng.sample(range(n + 1), 2)) for _ in range(q)]  # ranges lo up to, not including, hi

# 1. No extra memory: add up each range again.
additions = 0
slow = []
for lo, hi in queries:
    total = 0
    for i in range(lo, hi):
        total += values[i]
        additions += 1
    slow.append(total)

# 2. n + 1 extra numbers: prefix[i] is values[0] + ... + values[i - 1].
prefix = [0, *accumulate(values)]
fast = [prefix[hi] - prefix[lo] for lo, hi in queries]  # one subtraction per query

assert fast == slow
print(f"recomputing each range: {additions:>7,} additions, no extra list")
print(f"prefix sums:            {n - 1:>7,} additions to build them, then {q:,} subtractions; {len(prefix):,} numbers stored")

Output

recomputing each range: 338,873 additions, no extra list
prefix sums:                999 additions to build them, then 1,000 subtractions; 1,001 numbers stored

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 range_sums.py

Answering many range-sum queries
Criterion Each range againPrefix sums
Memory nonen + 1 numbers
Setup nonen − 1 additions
Per query up to n additionsone subtraction
When to choose a few queries, or no memory to sparemany queries on data that does not change

The same trade appears again and again: the set that found duplicates in one pass, tables that remember the answers to smaller problems in dynamic programming, indexes in databases. The CS2023 curriculum names time and space trade-offs as a core topic of complexity analysis. The trade also runs the other way: the exercise below keeps the time the same and cuts the memory from O(n²) to O(n), by handing out results one at a time instead of storing them all.

Key takeaways

  • Count auxiliary space, the memory an algorithm adds on top of its input and output; “in place” means O(1) of it, or O(log n) for a halving recursion.
  • sys.getsizeof() measures one object without the objects it refers to; tracemalloc measures everything Python allocates, including the peak.
  • A list of numbers stores references to separate int objects (about 40 MB for a million on 64-bit CPython), while array('q') stores 8-byte values (about 8 MB).
  • Every call a recursion keeps waiting is memory: recursion over n items needs O(n) stack, and Python stops it at its recursion limit.
  • Prefix sums, hash sets and memoisation spend memory to save time; a generator can save memory without changing how the running time grows.

Exercise

Exercise · Easy · Python

Hand out every prefix of a text in linear memory

The starter code builds every prefix of a text, "a", "ab", "abc" and so on, into one list. For a text of n characters the list holds strings of 1, 2, …, n characters at the same time, about n²/2 characters in all: O(n²) memory, even when the caller only ever looks at one prefix at a time.

Rewrite prefixes(text) so that it hands out the same prefixes, in the same order, one at a time, without keeping them all. A generator function (one that uses yield) does exactly that: the caller's for loop asks for the next prefix only when it is ready for it. Then the memory needed at any moment is O(n): the current prefix, and the text itself.

The sample tests check the prefixes of a few texts, check that prefixes no longer returns a list, and measure with tracemalloc the peak memory of a loop over the prefixes of a 2,000-character text: it must stay under 100,000 bytes. The list version needs about 2 MB for that text.

Starter code · prefixes.py

def prefixes(text):
    """Every prefix of text, shortest first: "a", "ab", "abc" for "abc"."""
    # This keeps all n prefixes at once. Rewrite it to hand them out one at a time.
    return [text[:end] for end in range(1, len(text) + 1)]
The sample tests · test_prefixes.py
import tracemalloc

from prefixes import prefixes


def test_prefixes_in_order():
    """gives every prefix, shortest first"""
    assert list(prefixes("abc")) == ["a", "ab", "abc"]
    assert list(prefixes("x")) == ["x"]


def test_empty_text():
    """gives nothing for an empty text"""
    assert list(prefixes("")) == []


def test_not_a_list():
    """hands the prefixes out one at a time instead of returning a list"""
    assert not isinstance(prefixes("abc"), list)


def test_linear_memory():
    """a loop over the prefixes of a 2,000-character text peaks under 100,000 bytes"""
    text = "ab" * 1000
    tracemalloc.start()
    try:
        total = 0
        for prefix in prefixes(text):
            total += len(prefix)
        peak = tracemalloc.get_traced_memory()[1]
    finally:
        tracemalloc.stop()
    assert total == 2000 * 2001 // 2
    assert peak < 100_000, f"peak memory was {peak:,} bytes"
A hint

Replace the list comprehension with a loop that yields each prefix: for end in range(1, len(text) + 1): yield text[:end]. A function that contains yield returns a generator when it is called, and runs only as far as the next yield each time the caller asks for another value.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Check yourself

7 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 7 sorted(items) returns a new list that holds the n items of items in order. Which kind of space is that new list?

    Choose one answer.

    Show the answer to question 1

    Answer: Output space

    The new list is the result, so it is output space. The list that was passed in is input space, and any extra memory the function needs only while it works is auxiliary space. A list of references still takes n slots of memory.

  2. Question 2 of 7 What is the auxiliary space of this function, which reverses a list of n items?

    Read the code, then choose one answer.

    def reverse_in_place(items):
        i, j = 0, len(items) - 1
        while i < j:
            items[i], items[j] = items[j], items[i]
            i += 1
            j -= 1
    Show the answer to question 2

    Answer: O(1)

    Apart from the list it was given, the function keeps two indices, whatever n is. It works in place, so its auxiliary space is O(1). items[::-1] would instead build a second list of n references.

  3. Question 3 of 7 A recursive function makes one call per node of a linked list of n nodes, and allocates nothing else. What is its auxiliary space?

    Choose one answer.

    Show the answer to question 3

    Answer: O(n), for the calls waiting on the stack

    Each call keeps its frame until the calls after it return, so at the deepest point about n frames are on the stack at once. The iterative version needs O(1).

  4. Question 4 of 7 Why does sys.getsizeof(numbers) report about 8 KB for a list of the 1,000 numbers from 1,000 to 1,999, when the numbers themselves take about 28 KB more?

    Choose one answer.

    Show the answer to question 4

    Answer: It measures only the list and its references, not the objects they refer to

    sys.getsizeof() is shallow by design: it counts the memory of one object. To include everything a structure allocates, measure with tracemalloc while you build it.

  5. Question 5 of 7 In this lesson's recorded run on 64-bit CPython, about how many megabytes did list(range(1_000_000)) take at its peak?

    Type a number.

    Show the answer to question 5

    Answer: 40 MB (anything from 39 to 41 counts)

    About 8 MB for a million 8-byte references and about 32 MB for the million separate int objects, 40 MB in all. array('q', range(1_000_000)) stored the same numbers in a little over 8 MB.

  6. Question 6 of 7 A list of a million numbers, each between 0 and 255, took only a little over 8 MB. Why so little?

    Choose one answer.

    Show the answer to question 6

    Answer: CPython keeps one shared object for each whole number from −5 to 256, so the list holds only references

    The C API documentation says CPython keeps an array of int objects for all integers from −5 to 256 and returns a reference to the existing object. The list pays only its 8 bytes per reference.

  7. Question 7 of 7 Which of these spend extra memory to save time?

    Choose every answer that is right.

    Show the answer to question 7

    Answer:

    • Keeping a set of the items seen so far to find duplicates in one pass
    • Remembering the answers to smaller problems in dynamic programming
    • Storing prefix sums to answer range-sum queries

    Prefix sums, a set of seen items and a table of remembered answers all store extra data so that later steps are quicker. Reversing in place needs O(1) extra memory, and a generator saves memory without adding work.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.