Lists & Tuples
Topic 6 of 8, with 4 concept checks. Ordered collections, comprehensions, and copying
Choose mutability before choosing a sequence
Sequence choices
Compare lists and tuples by the changes an algorithm must make, then reason about indexing, unpacking, copying, aliasing, and iteration from that mutability decision.
Core lesson 01
Lists: mutable, []. Tuples: immutable, (), hashable if their contents are hashable.
Beyond the surface syntax, mutability is the real distinction. A list can have elements added, removed, or replaced without changing its identity. A tuple's contents are fixed at creation. Because tuples can't change, they can be hashed (as long as everything inside them is also hashable), which makes them usable as dict keys or set members — something lists can never do.
point = (3, 4)
# point[0] = 5 # TypeError: 'tuple' object does not support item assignment
lookup = {(0,0): "origin", (1,1): "diag"} # tuple as dict key -- fine
# lookup[[0,0]] = "x" # TypeError: unhashable type: 'list'What to remember
What's the key structural difference between a list and a tuple?
Common footguns
- Believing a tuple is deeply immutable — `t = ([1,2], 3)`; `t[0].append(9)` works fine, because the *tuple's slots* are fixed, not the mutability of what's inside them.
Core lesson 02
[0, 4, 16] — the if filters to even x first (0, 2, 4), then each is squared.
A list comprehension `[expr for item in iterable if cond]` reads like a for-loop written inline: iterate, filter with `if`, then apply `expr` to what survives, collecting results into a new list. This is Python's preferred, more readable, and often faster alternative to building a list with .append() in a loop.
result = [x**2 for x in range(5) if x % 2 == 0]
print(result) # [0, 4, 16]
# equivalent manual loop:
result2 = []
for x in range(5):
if x % 2 == 0:
result2.append(x**2)
print(result2 == result) # TrueWhat to remember
What does [x**2 for x in range(5) if x % 2 == 0] produce?
Common footguns
- Nesting multiple for/if clauses in one comprehension until it's harder to read than the loop it replaced — split it up or use a generator function instead.
Core lesson 03
append(x) adds x as ONE new element (even if x is a list). extend(iterable) adds each of its elements individually.
Both mutate the list in place and both accept any object, but they treat that object differently. `.append(x)` always adds exactly one new slot containing `x` itself, whatever it is. `.extend(iterable)` instead iterates over its argument and appends each yielded item — meaning `.extend("abc")` adds three characters, not one string.
lst = [1, 2]
lst.append([3, 4])
print(lst) # [1, 2, [3, 4]]
lst2 = [1, 2]
lst2.extend([3, 4])
print(lst2) # [1, 2, 3, 4]
lst3 = [1, 2]
lst3.extend("ab")
print(lst3) # [1, 2, 'a', 'b']What to remember
What's the difference between .append() and .extend()?
Common footguns
- Meaning to flatten one list into another but calling `.append()` by habit, ending up with an unwanted nested list.
lst = [1,2] lst.append([3,4]) -> [1, 2, [3,4]] one new slot, holds a list lst.extend([3,4]) -> [1, 2, 3, 4] unpacks, one slot per item
Core lesson 04
Shallow: new = old.copy() or old[:] (outer list is new, inner objects still shared). Deep: copy.deepcopy(old) (everything is new).
A shallow copy creates a new outer container but fills it with references to the *same* inner objects as the original — mutating a nested list still affects both copies. A deep copy recursively copies every nested object too, so the two structures become fully independent, at the cost of more time and memory.
import copy
original = [[1, 2], [3, 4]]
shallow = original.copy()
shallow[0].append(99)
print(original) # [[1, 2, 99], [3, 4]] <- original changed too!
deep = copy.deepcopy(original)
deep[0].append(-1)
print(original) # unaffected by the deep copy editWhat to remember
How do you shallow-copy vs deep-copy a list of nested lists?
Common footguns
- Assuming `.copy()` or `list[:]` gives full independence — it only protects the outer list, not anything nested inside.
original = [[1,2], [3,4]]
shallow = original.copy() deep = copy.deepcopy(original)
shallow --+ +--[1,2]--+ deep ----+ +--[1,2]'-+ (new)
\/ \/
original --+ original --+
\ \
+--[3,4]--+ (shared!) +--[3,4]'-+ (new)Python lab
Browser Python lab
Runtime · idle
Python loads on your first run. Your code stays in this browser.
Best practices
- Prefer list comprehensions for simple transforms/filters, but avoid deeply nested ones — readability first.
- Use tuples for fixed, heterogeneous data; lists for homogeneous, growable collections.
- Don't mutate a list while iterating over it directly — iterate a copy or build a new list.
- Use collections.namedtuple or a dataclass instead of a bare tuple when field meaning matters.
Apply the concept in Interview practice
Merge Sorted ArrayeasyLeetCode #88 · O(m+n) time, O(1) space
Merge from the back using three pointers (ends of both arrays plus a write pointer) to avoid overwriting unread values.
Open problemRotate ArraymediumLeetCode #189 · O(n) time, O(1) space
Reverse the whole array, then reverse the first k elements, then reverse the rest — rotates in place.
Open problem2D Array - DSeasyHackerRank · O(n) time
Slide a 3x3 'hourglass' window across the 6x6 grid, summing each and tracking the maximum.
Open problemRemove Duplicates from Sorted ArrayeasyLeetCode #26 · O(n) time, O(1) space
Two pointers: a slow pointer marks the last unique position, a fast pointer scans ahead, copying forward only when a new value is found.
Open problemMove ZeroeseasyLeetCode #283 · O(n) time, O(1) space
Two pointers: slide all non-zero elements to the front in order, then fill the remaining positions with zeroes.
Open problemConcept checks
What's the key structural difference between a list and a tuple?
Hint
One can be modified after creation.
Think mutability and syntax ([] vs ()).
Answer
Lists: mutable, []. Tuples: immutable, (), hashable if their contents are hashable.
Beyond the surface syntax, mutability is the real distinction. A list can have elements added, removed, or replaced without changing its identity. A tuple's contents are fixed at creation. Because tuples can't change, they can be hashed (as long as everything inside them is also hashable), which makes them usable as dict keys or set members — something lists can never do.
point = (3, 4)
# point[0] = 5 # TypeError: 'tuple' object does not support item assignment
lookup = {(0,0): "origin", (1,1): "diag"} # tuple as dict key -- fine
# lookup[[0,0]] = "x" # TypeError: unhashable type: 'list'Watch out
- Believing a tuple is deeply immutable — `t = ([1,2], 3)`; `t[0].append(9)` works fine, because the *tuple's slots* are fixed, not the mutability of what's inside them.
What does [x**2 for x in range(5) if x % 2 == 0] produce?
Hint
Walk through range(5): 0,1,2,3,4.
Filter first with the if clause, then square what remains.
Answer
[0, 4, 16] — the if filters to even x first (0, 2, 4), then each is squared.
A list comprehension `[expr for item in iterable if cond]` reads like a for-loop written inline: iterate, filter with `if`, then apply `expr` to what survives, collecting results into a new list. This is Python's preferred, more readable, and often faster alternative to building a list with .append() in a loop.
result = [x**2 for x in range(5) if x % 2 == 0]
print(result) # [0, 4, 16]
# equivalent manual loop:
result2 = []
for x in range(5):
if x % 2 == 0:
result2.append(x**2)
print(result2 == result) # TrueWatch out
- Nesting multiple for/if clauses in one comprehension until it's harder to read than the loop it replaced — split it up or use a generator function instead.
What's the difference between .append() and .extend()?
Hint
One adds a single new element, the other adds each element of an iterable.
Try appending a list vs extending with a list — the results differ.
Answer
append(x) adds x as ONE new element (even if x is a list). extend(iterable) adds each of its elements individually.
Both mutate the list in place and both accept any object, but they treat that object differently. `.append(x)` always adds exactly one new slot containing `x` itself, whatever it is. `.extend(iterable)` instead iterates over its argument and appends each yielded item — meaning `.extend("abc")` adds three characters, not one string.
lst = [1, 2]
lst.append([3, 4])
print(lst) # [1, 2, [3, 4]]
lst2 = [1, 2]
lst2.extend([3, 4])
print(lst2) # [1, 2, 3, 4]
lst3 = [1, 2]
lst3.extend("ab")
print(lst3) # [1, 2, 'a', 'b']lst = [1,2] lst.append([3,4]) -> [1, 2, [3,4]] one new slot, holds a list lst.extend([3,4]) -> [1, 2, 3, 4] unpacks, one slot per item
Watch out
- Meaning to flatten one list into another but calling `.append()` by habit, ending up with an unwanted nested list.
How do you shallow-copy vs deep-copy a list of nested lists?
Hint
Slicing or .copy() only copies one level deep.
For fully independent nested structures, you need the copy module.
Answer
Shallow: new = old.copy() or old[:] (outer list is new, inner objects still shared). Deep: copy.deepcopy(old) (everything is new).
A shallow copy creates a new outer container but fills it with references to the *same* inner objects as the original — mutating a nested list still affects both copies. A deep copy recursively copies every nested object too, so the two structures become fully independent, at the cost of more time and memory.
import copy
original = [[1, 2], [3, 4]]
shallow = original.copy()
shallow[0].append(99)
print(original) # [[1, 2, 99], [3, 4]] <- original changed too!
deep = copy.deepcopy(original)
deep[0].append(-1)
print(original) # unaffected by the deep copy editoriginal = [[1,2], [3,4]]
shallow = original.copy() deep = copy.deepcopy(original)
shallow --+ +--[1,2]--+ deep ----+ +--[1,2]'-+ (new)
\/ \/
original --+ original --+
\ \
+--[3,4]--+ (shared!) +--[3,4]'-+ (new)Watch out
- Assuming `.copy()` or `list[:]` gives full independence — it only protects the outer list, not anything nested inside.