Lists and Tuples: Slicing, Mutation, and the Cost of Every Method

7 minute read

Published:

TL;DR: A list is a dynamic array of references โ€” index and append are \(O(1)\), insert(0, x) and remove are \(O(n)\), and membership is a linear scan. Slicing always builds a new list, which makes xs[:] the shallow-copy idiom and xs[::-1] the reverse idiom. sort() mutates and returns None; sorted() returns a new list and accepts any iterable. Tuples are the immutable sibling, which is exactly why they can be dictionary keys.

What a list actually is

CPython implements list as a contiguous array of pointers to objects, plus a length and a capacity. Elements are not stored inline, so a list can hold objects of different types and any element can be any size. When the array fills up, CPython allocates a larger one โ€” with geometric over-allocation, so append is \(O(1)\) amortised, not \(O(1)\) in the worst case.

Everything else follows. Indexing is pointer arithmetic, so it is constant time. Inserting or deleting at position i must shift the \(n - i\) pointers after it, so it is \(O(n - i)\) โ€” free at the end, expensive at the front. Searching has no index to exploit, so x in xs is \(O(n)\).

Indexing and slicing

Indices start at 0. Negative indices count back from the end, with -1 the last element.

xs = ["a", "b", "c", "d", "e"]
print(xs[0], xs[-1], xs[-2])   # -> a e d

A slice xs[start:stop:step] returns a new list containing start up to but not including stop. Every part is optional.

print(xs[1:4])    # -> ['b', 'c', 'd']
print(xs[:3])     # -> ['a', 'b', 'c']
print(xs[3:])     # -> ['d', 'e']
print(xs[::2])    # -> ['a', 'c', 'e']
print(xs[::-1])   # -> ['e', 'd', 'c', 'b', 'a']
print(xs[1:5:2])  # -> ['b', 'd']

Two idioms worth memorising: xs[:] is a shallow copy, and xs[::-1] is a reversed copy. Both are \(O(n)\) in time and space.

Slices never raise IndexError โ€” out-of-range bounds are clamped, so xs[10:20] gives [] while xs[10] raises. That leniency is convenient and occasionally hides a bug.

Slices are also assignable, which lets you splice in place:

ys = [1, 2, 3, 4, 5]
ys[1:3] = ["x", "y", "z"]     # replacement need not be the same length
print(ys)                     # -> [1, 'x', 'y', 'z', 4, 5]
del ys[0:2]
print(ys)                     # -> ['y', 'z', 4, 5]

Every method, and what it costs

MethodEffectMutates?Complexity
xs.append(v)add v at the endyes\(O(1)\) amortised
xs.extend(it)append every item of an iterableyes\(O(k)\) for \(k\) new items
xs.insert(i, v)insert v before index iyes\(O(n)\)
xs.remove(v)delete the first v; ValueError if absentyes\(O(n)\)
xs.pop()remove and return the last itemyes\(O(1)\)
xs.pop(i)remove and return item iyes\(O(n - i)\)
xs.clear()empty the listyes\(O(n)\)
xs.sort()sort in place, returns Noneyes\(O(n \log n)\)
xs.reverse()reverse in place, returns Noneyes\(O(n)\)
xs.index(v)position of the first v; ValueError if absentno\(O(n)\)
xs.count(v)how many equal vno\(O(n)\)
xs.copy()shallow copy, same as xs[:]no\(O(n)\)
v in xsmembership testno\(O(n)\)
len(xs)lengthno\(O(1)\)

In one runnable sequence:

ys = [3, 1, 2]
ys.append(4)          # [3, 1, 2, 4]
ys.extend([5, 6])     # [3, 1, 2, 4, 5, 6]
ys.insert(1, 99)      # [3, 99, 1, 2, 4, 5, 6]
ys.remove(99)         # [3, 1, 2, 4, 5, 6]
print(ys.pop())       # -> 6      ys is now [3, 1, 2, 4, 5]
print(ys.pop(0))      # -> 3      ys is now [1, 2, 4, 5]
ys.reverse()          # [5, 4, 2, 1]
ys.sort()             # [1, 2, 4, 5]
print(ys.index(4), ys.count(2))   # -> 2 1
ys.clear()
print(ys)             # -> []

Note the asymmetry between append/extend: xs.append([1, 2]) adds one element that happens to be a list, while xs.extend([1, 2]) adds two. And if you find yourself calling insert(0, v) or pop(0) in a loop, you want collections.deque, which does both ends in \(O(1)\).

The classic trap โ€” [[0] * 3] * 3 does not build a grid. The outer * 3 repeats the reference, so all three rows are the same list object:
grid = [[0] * 3] * 3
grid[0][0] = 1
print(grid)   # -> [[1, 0, 0], [1, 0, 0], [1, 0, 0]]
Build the rows separately with a comprehension, which evaluates [0] * 3 afresh each time:
grid = [[0] * 3 for _ in range(3)]
grid[0][0] = 1
print(grid)   # -> [[1, 0, 0], [0, 0, 0], [0, 0, 0]]
The same aliasing bites copy.copy and xs[:] on nested lists; only copy.deepcopy duplicates the inner objects.

sort against sorted, and the key argument

xs.sort() sorts the list in place and returns None. sorted(iterable) leaves its input alone, accepts any iterable, and returns a new list. Both use Timsort, which is \(O(n \log n)\) worst case and close to \(O(n)\) on data that is already partially ordered.

words = ["banana", "kiwi", "apple", "fig"]
print(sorted(words))                     # -> ['apple', 'banana', 'fig', 'kiwi']
print(words.sort())                      # -> None      <- the trap
print(words)                             # -> ['apple', 'banana', 'fig', 'kiwi']

x = xs.sort() is one of the most common beginner bugs; it silently binds None.

key takes a function applied to each element, whose result is compared instead of the element. It is called exactly once per element, so it is cheap even when the function is not.

words = ["banana", "kiwi", "apple", "fig"]
print(sorted(words, key=len))                 # -> ['fig', 'kiwi', 'apple', 'banana']
print(sorted(words, key=len, reverse=True))   # -> ['banana', 'apple', 'kiwi', 'fig']

Return a tuple from key to sort by several fields at once. Timsort is stable โ€” equal keys keep their original relative order โ€” so descending by score then ascending by name is one call:

pairs = [("bob", 3), ("ann", 5), ("cid", 3)]
print(sorted(pairs, key=lambda p: (-p[1], p[0])))
# -> [('ann', 5), ('bob', 3), ('cid', 3)]

The -p[1] trick only works for numbers; for a descending string field, sort twice and let stability do the work, or use functools.cmp_to_key.

Tuples

A tuple is an immutable sequence. It supports indexing, slicing, in, len, count and index โ€” everything a list does except the mutating half of the API.

t = (1, 2, 3)
t[0] = 9
# TypeError: 'tuple' object does not support item assignment

The commas make the tuple, not the parentheses, which produces the notorious one-element case:

print(type(("x",)).__name__)   # -> tuple
print(type(("x")).__name__)    # -> str    just a parenthesised string

A trailing comma is required for a singleton. The empty tuple is ().

Unpacking works on any iterable and is the reason tuples appear everywhere:

a, b, c = (1, 2, 3)
print(a, b, c)        # -> 1 2 3

a, b = b, a           # swap, no temporary needed

Starred unpacking (PEP 3132) absorbs a variable number of items into a list. Exactly one star is allowed:

first, *rest = [1, 2, 3, 4]
print(first, rest)      # -> 1 [2, 3, 4]

*init, last = [1, 2, 3, 4]
print(init, last)       # -> [1, 2, 3] 4

a, *mid, z = [1, 2, 3, 4, 5]
print(a, mid, z)        # -> 1 [2, 3, 4] 5

When the immutability matters

Tuples are hashable when their contents are, so they can be dictionary keys and set members โ€” a list cannot:

d = {(0, 0): "origin"}
print(d[(0, 0)])      # -> origin

{[0, 0]: "x"}
# TypeError: unhashable type: 'list'

That is the main practical reason to reach for a tuple: coordinates, (row, col) cells, (year, month) buckets, memoisation keys. Multiple return values are also tuples โ€” return x, y builds one, and the caller unpacks it. Beyond that, a tuple documents that a collection is a fixed record of heterogeneous fields rather than a homogeneous sequence you will iterate over. When the fields deserve names, collections.namedtuple or typing.NamedTuple gives you a tuple with attribute access at no extra memory cost.

Key Insight โ€” immutable does not mean unchangeable all the way down: a tuple freezes its references, not the objects they point at. So t = ([1, 2], 3) allows t[0].append(9), giving ([1, 2, 9], 3), and hash(t) still fails with TypeError: unhashable type: 'list'. Hashability is recursive, immutability of the container is not โ€” which is precisely why a tuple containing a list cannot be a dictionary key.

Next up: dicts and sets, where hashing buys back the \(O(1)\) membership test that lists cannot offer.

Recap

  • A list is a dynamic array of references: index and append are \(O(1)\); insert(0, v), pop(0), remove and in are \(O(n)\). Use collections.deque for a queue.
  • Slicing copies; xs[:] is the shallow-copy idiom, xs[::-1] the reverse. Out-of-range slice bounds clamp instead of raising.
  • xs.sort() mutates and returns None; sorted(it) returns a new list. Both are stable Timsort, and key is called once per element.
  • [[0] * 3] * 3 aliases one row three times โ€” use a comprehension.
  • Commas make tuples; a one-element tuple needs the trailing comma. Starred unpacking takes at most one star.
  • Tuples are hashable only if every element is, which is what qualifies them as dict keys.

References

  1. Python Software Foundation. Data Structures: more on lists.
  2. Python Software Foundation. Sequence Types โ€” list, tuple, range.
  3. Python Software Foundation. Sorting Techniques (the HOWTO).
  4. Python Wiki. TimeComplexity โ€” costs of the built-in container operations.
  5. Hettinger, R. PEP 3132 โ€” Extended Iterable Unpacking.
  6. Peters, T. listsort.txt โ€” the design notes for Timsort.