I changed my copy, and the original changed too
New to some of the words above? Open a quick recap. It takes about two minutes.
Quick recapLists and dicts in two minutes· 2 min
A list keeps items in order. A dict maps keys to values. Both can be changed after they’re made.
nums = [3, 1, 2]nums[0] # 3: positions start at 0nums.append(5) # nums is now [3, 1, 2, 5]
pen = {"item": "pen", "price": 10}pen["price"] # 10pen["price"] = 9 # change one valueSee the problem
Section titled “See the problem”You’re building a shop’s sale page. It shows each item’s old price next to its sale price. You write sale_prices, which cuts every price by a percent, and call it like this:
cart = [{"item": "pen", "price": 10}, {"item": "book", "price": 50}]sale = sale_prices(cart, 10) # 10% offsale.append({"item": "tote bag", "price": 0}) # a free gift, on the sale onlyprint(cart)Your first version changes each price and returns the list. The page then shows this as the old prices:
[{'item': 'pen', 'price': 9}, {'item': 'book', 'price': 45}, {'item': 'tote bag', 'price': 0}]You never touched cart. Yet its prices are the sale prices, and it has the free bag. How did it change?
Define the task
Section titled “Define the task”- Input:
items, a list of dicts. Each has an"item"name and a whole-number"price", and maybe other keys. Alsopercent, a whole number from 0 to 100. - Output: a new list of new dicts, in the same order. Each is the same as before, except its price is cut and rounded down:
price * (100 - percent) // 100. - The hard part: the caller keeps using
items. After your function returns,itemsand every dict in it must be exactly as they were, even if the caller changes your result.
Example: sale_prices(cart, 10) gives [{'item': 'pen', 'price': 9}, {'item': 'book', 'price': 45}], and cart still says 10 and 50.
Pause and think
Section titled “Pause and think”Hints open one at a time:
Hint 1 of 3
Writing sale = cart doesn’t copy anything. What would it do?
Hint 2 of 3
cart.copy() makes a new list. What does the new list hold?
Hint 3 of 3
Make a new dict for each item with dict(item), set the new price on that, and collect them in a new list.
Understand the idea
Section titled “Understand the idea”Think of a name as a sticky label, not a box. cart = [...] makes a list and sticks the label cart on it. same = cart sticks a second label on the same list. Nothing is copied, so a change made through one label shows through the other.
Python calls the things labels point to objects, and the link from a name to its object a reference. Two questions tell them apart:
a == basks: are the values equal?a is basks: are these one object?
Watch the cart example. Names are on the left and objects on the right. Each arrow is a reference, and → #2 inside a list means that slot points to object #2. The player stops to ask what happens next: answer, or press Just show me. Then type your own lines under it.
Names
none yet
Objects
no lists, dicts or tuples yet
Step 1 of 7. Before the first line runs, there are no names and no objects.
cart = [{ "price": 10}, { "price": 50}] same = cart same.append( { "price": 5}) sale = cart.copy( ) sale.append( { "price": 0}) sale[ 0][ "price"] = 9
Numbers and text sit next to their names in the picture. They’re objects too, but they can never change. So sharing them can’t surprise you.
Climb the ladder
Section titled “Climb the ladder”All three rungs look at each item once, so all take O(n) time for n items. What changes is what they share with the caller.
Change the prices in place
The idea: Loop over the items, set each new price on the dict itself, and return the list.
On the cart: the loop sets the pen’s price to 9 and the book’s to 45, on the very dicts inside cart. Then it returns cart itself, so sale is cart is True.
def sale_prices(items: list[dict], percent: int) -> list[dict]: """Cut every price by percent, rounding down.""" for item in items: item["price"] = item["price"] * (100 - percent) // 100 return items- Time
O(n)- Space
O(1)- Why
- No new lists or dicts: it changes the ones it was given.
Where it breaks: the caller still needs cart. Its old prices are gone. And the free bag added to sale lands in cart too, because they’re one list. This is fine only when nobody else needs the old data.
Copy the list first
The idea: Make a new list with items.copy(), then change the prices in it.
The waste in Okay: it hands the caller’s own list back, so anything done to the result is done to cart.
On the cart: items.copy() makes a new list. The free bag now goes only into sale, and cart keeps 2 items. But the new list holds the same two dicts, and the loop sets prices on them. So cart shows 9 and 45 too.
Highlighted lines are new in this rung.
def sale_prices(items: list[dict], percent: int) -> list[dict]: """Cut every price by percent, rounding down, in a copy of the list.""" result = items.copy() # a new list, holding the same dicts for item in result: item["price"] = item["price"] * (100 - percent) // 100 return result- Time
O(n)- Space
O(n)was O(1)- Why
- A new list of n references. The dicts themselves aren’t copied.
What changed from the previous rung
def sale_prices(items: list[dict], percent: int) -> list[dict]: """Cut every price by percent, rounding down.""" for item in items: """Cut every price by percent, rounding down, in a copy of the list.""" result = items.copy() # a new list, holding the same dicts for item in result: item["price"] = item["price"] * (100 - percent) // 100 return items return resultWhere it breaks: a copy made with .copy(), list(items) or items[:] is shallow. Only the outer list is new. Anything you change inside it is still the caller’s.
New dicts for what changes
The idea: Build a new list of new dicts: dict(item) copies one item, then set the new price on the copy.
The waste in Better: it copies the list, which it doesn’t change, and shares the dicts, which it does.
On the cart: dict(item) makes a new pen dict with price 10, and the loop sets its price to 9. The same happens for the book. cart still points to the old dicts, so it keeps 10 and 50. Anything inside an item that the function doesn’t change, like a list of tags, stays shared. That’s safe, because nothing changes it.
Highlighted lines are new in this rung.
def sale_prices(items: list[dict], percent: int) -> list[dict]: """A new list of new dicts, each price cut by percent, rounding down.
The caller's list and dicts stay exactly as they were. """ result = [] for item in items: new_item = dict(item) # a new dict with the same keys and values new_item["price"] = item["price"] * (100 - percent) // 100 result.append(new_item) return result- Time
O(n · k)was O(n)- Space
O(n · k)was O(n)- Why
- One new dict per item, each with its k keys. Items have a few keys, so this is close to O(n).
What changed from the previous rung
def sale_prices(items: list[dict], percent: int) -> list[dict]: """Cut every price by percent, rounding down, in a copy of the list.""" result = items.copy() # a new list, holding the same dicts for item in result: item["price"] = item["price"] * (100 - percent) // 100 """A new list of new dicts, each price cut by percent, rounding down.
The caller's list and dicts stay exactly as they were. """ result = [] for item in items: new_item = dict(item) # a new dict with the same keys and values new_item["price"] = item["price"] * (100 - percent) // 100 result.append(new_item) return resultWhen Best isn’t best: if the data is yours alone, changing it in place is fine and saves memory. That’s when you just built it, or the caller asked for changes in place. Python works this way too: list.sort() sorts in place, while sorted() returns a new list. And when you’ll change things nested deeper, copy.deepcopy() copies every level for you, at a cost (see Compare).
Write it yourself
Section titled “Write it yourself”Now write it in the page. Press Run tests to check your code. Python runs in your browser, so nothing is sent anywhere. Your code is saved in this browser for next time.
In the tests, cart is the two-item cart from the top of the page, and bag is a free cloth bag. Try Okay and Better from the ladder first: each fails a different test.
Sale prices
Ctrl + Enter runs- Not run yet:
sale_prices([{'item': 'pen', 'price': 10}, {'item': 'book', 'price': 50}], 10)→[{'item': 'pen', 'price': 9}, {'item': 'book', 'price': 45}] - Not run yet:
sale = sale_prices(cart, 10); sale.append(bag); len(cart)→2 - Not run yet:
sale_prices(cart, 10), and cart must not change→[{'item': 'pen', 'price': 9}, {'item': 'book', 'price': 45}] - Not run yet: Extra test
- Not run yet: Extra test
- Not run yet: Extra test
- Not run yet: Extra test
Extra tests check tricky cases. You see their input only if they fail.
Try your own input
Compare
Section titled “Compare”| Way | Time | Extra memory | Good | Not so good | Use it when |
|---|---|---|---|---|---|
| Okay: change in place | O(n) | O(1) | No copies at all | Changes the caller’s data | Nobody else needs the old data |
| Better: shallow copy | O(n) | O(n) | The caller’s list keeps its items | Changes to the items still reach the caller | The items can’t change (numbers, text), or you won’t change them |
| Best: new dicts | O(n · k) | O(n · k) | The caller is untouched; only unchanged parts are shared | Copies each item | You change items the caller still holds |
copy.deepcopy(), then change | O(all the data) | O(all the data) | Safe at any depth | Copies parts you never change, so it’s slower | Deep data, changed in many places |
Common mistakes and edge cases
Section titled “Common mistakes and edge cases”isfor numbers and text: two equal numbers made separately may or may not be one object. It depends on Python’s internals. Compare values with==, and keepisforNoneand for “are these one object?”- Assigning inside a function:
nums = [...]inside a function moves only the function’s own name. The caller’s list is unchanged. To change the caller’s list, change the list itself:nums.append(...)ornums[:] = [...]. Practice 2 is about this. - Removing while looping: removing items from the list you’re looping over skips items. Loop over a copy, or build a new list.
- A list as a default value:
def add(item, items=[])makes that list once, when the function is defined. Every call that leavesitemsout shares it. Useitems=None, thenif items is None: items = [].
Say it in the interview
Section titled “Say it in the interview”Questions to ask first
- Does the caller still need the original data, or may I change it in place?
- How deep is the data? Will I change anything nested inside the items?
- How big is it? A copy costs as much memory again.
A 60-second walkthrough
“In Python, a name is a reference to an object, and assignment never copies. If I set prices on the dicts I’m given, the caller sees the change. Copying the list with
.copy()isn’t enough: that’s a shallow copy, so the dicts inside are still shared. So I build a new list of new dicts, withdict(item)and then the new price. Each item is copied once, so it’s linear, and the caller’s data stays untouched. If nobody else needed the original, I’d change it in place and save the memory.”
Likely follow-ups
- What’s the difference between a shallow and a deep copy? A shallow copy makes a new outer container that holds the same items. A deep copy copies every level, so nothing is shared.
- What’s the difference between
==andis?==compares values.ischecks for one object. UseisforNone. - Does Python pass arguments by value or by reference? By assignment: the parameter becomes another name for the caller’s object. Changing that object shows to the caller. Pointing the parameter at a new object doesn’t.
- When does
copy.deepcopy()hurt? On big data, because it copies everything. It also can’t copy some objects, like an open file.
Practice
Section titled “Practice”Three problems, from easy to hard. Each has hints, tests you can run here, and its own Okay, Better and Best answers. Try before you open them.
1. Make an empty grid (easy)
Section titled “1. Make an empty grid (easy)”Return a grid of rows lists, each holding cols zeros. Each row must be its own list, so setting one cell changes only that row.
Example: make_grid(2, 3) gives [[0, 0, 0], [0, 0, 0]]. The one-liner [[0] * cols] * rows looks right, but it isn’t. Why?
Hint 1 of 3
The outer * rows repeats one row object. It doesn’t make new rows.
Hint 2 of 3
Make a new row each time round a loop.
Hint 3 of 3
[0] * cols is fine: numbers can’t change, so setting a cell puts a new number in that slot.
Make an empty grid
Ctrl + Enter runs- Not run yet:
make_grid(2, 3)→[[0, 0, 0], [0, 0, 0]] - Not run yet:
grid = make_grid(3, 2); grid[0][0] = 1; grid→[[1, 0], [0, 0], [0, 0]] - Not run yet: Extra test
- Not run yet: Extra test
Extra tests check tricky cases. You see their input only if they fail.
Try your own input
Show the Okay, Better and Best answers
Two loops
The idea: Build each row by appending zeros, then append the row to the grid.
Each pass of the outer loop makes a new row, so no two rows are the same list. It works, but it takes seven lines.
def make_grid(rows: int, cols: int) -> list[list[int]]: """rows lists of cols zeros, each row its own list.""" grid = [] for _ in range(rows): row = [] for _ in range(cols): row.append(0) grid.append(row) return grid- Time
O(rows · cols)- Space
O(rows · cols)- Why
- Every cell is made once.
One loop, [0] * cols
The idea: Make each row in one step with [0] * cols, a new list each time round the loop.
[0] * cols puts the same 0 in every slot. That’s safe: numbers can’t change, so row[1] = 5 puts a new number in slot 1 and leaves the others alone. The trap is [row] * rows, which repeats a list.
Highlighted lines are new in this rung.
def make_grid(rows: int, cols: int) -> list[list[int]]: """rows lists of cols zeros, each row its own list.""" grid = [] for _ in range(rows): grid.append([0] * cols) # a new row each time round the loop return grid- Time
O(rows · cols)- Space
O(rows · cols)- Why
- The same cells, made faster by [0] * cols.
What changed from the previous rung
def make_grid(rows: int, cols: int) -> list[list[int]]: """rows lists of cols zeros, each row its own list.""" grid = [] for _ in range(rows): row = [] for _ in range(cols): row.append(0) grid.append(row) grid.append([0] * cols) # a new row each time round the loop return gridA comprehension
The idea: [[0] * cols for _ in range(rows)] runs [0] * cols again for every row.
The comprehension runs its expression once per row, so each row is new. Type grid = [[0] * 2 for _ in range(2)] into the player above, then grid[0][0] = 1, and compare it with [[0] * 2] * 2.
Highlighted lines are new in this rung.
def make_grid(rows: int, cols: int) -> list[list[int]]: """rows lists of cols zeros, each row its own list.""" return [[0] * cols for _ in range(rows)]- Time
O(rows · cols)- Space
O(rows · cols)- Why
- The same work in one line.
What changed from the previous rung
def make_grid(rows: int, cols: int) -> list[list[int]]: """rows lists of cols zeros, each row its own list.""" grid = [] for _ in range(rows): grid.append([0] * cols) # a new row each time round the loop return grid return [[0] * cols for _ in range(rows)]When Best isn’t best: for big number grids, NumPy’s numpy.zeros((rows, cols)) is faster and smaller. It isn’t part of standard Python, though.
2. Remove the negatives, in place (medium)
Section titled “2. Remove the negatives, in place (medium)”Remove the negative numbers from nums and keep the rest in order. Change the caller’s list itself, and return nothing, like list.sort() does. Other parts of the program hold this list, and they must see the change.
Example: after remove_negatives(nums) with nums = [3, -1, -2, 4, -5, 0], nums is [3, 4, 0].
Hint 1 of 3
Removing from the list you’re looping over skips the item after each one you remove.
Hint 2 of 3
Inside the function, nums = [...] points the function’s own name at a new list. The caller never sees it.
Hint 3 of 3
nums[:] = [...] replaces every item of the same list object.
Remove the negatives, in place
Ctrl + Enter runs- Not run yet:
nums = [3, -1, -2, 4, -5, 0]; remove_negatives(nums); nums→[3, 4, 0] - Not run yet:
nums = [-1, -1]; remove_negatives(nums); nums→[] - Not run yet: Extra test
- Not run yet: Extra test
- Not run yet: Extra test
Extra tests check tricky cases. You see their input only if they fail.
Try your own input
Show the Okay, Better and Best answers
Loop over a copy, remove from the list
The idea: Loop over nums.copy(), so removing doesn’t skip items, and call nums.remove(n) for each negative.
Right, and the caller sees the change. But on 60,000 numbers the searching and shifting take several seconds, so it fails the speed test.
def remove_negatives(nums: list[int]) -> None: """Remove the negative numbers from nums, in place.""" for n in nums.copy(): # loop over a copy, so removing doesn't skip items if n < 0: nums.remove(n)- Time
O(n²)- Space
O(n)- Why
- Each remove() searches from the front and shifts every later item left.
Build the kept list
The idea: Build the list of numbers to keep in one pass, and assign it to nums.
The waste in Okay: every remove() searches and shifts, when one pass can find all the items to keep.
Fast, but wrong. nums = [...] points the function’s own name at the new list. The caller’s list never changes, so the first test fails.
Highlighted lines are new in this rung.
def remove_negatives(nums: list[int]) -> None: """Remove the negative numbers from nums, in place.""" nums = [n for n in nums if n >= 0] # only moves the local name nums- Time
O(n)was O(n²)- Space
O(n)- Why
- One pass, with no shifting.
What changed from the previous rung
def remove_negatives(nums: list[int]) -> None: """Remove the negative numbers from nums, in place.""" for n in nums.copy(): # loop over a copy, so removing doesn't skip items if n < 0: nums.remove(n) nums = [n for n in nums if n >= 0] # only moves the local name numsWhere it breaks: assigning to a parameter never reaches the caller. Change the object instead.
Slice assignment: same list, new items
The idea: nums[:] = [...] replaces all the items of the caller’s list with the kept ones.
The waste in Better: it builds the right list, then drops it.
nums[:] = ... doesn’t move the name. It puts new items into the same list object, so every name that points to it sees them.
Highlighted lines are new in this rung.
def remove_negatives(nums: list[int]) -> None: """Remove the negative numbers from nums, in place.""" nums[:] = [n for n in nums if n >= 0] # puts new items into the same list- Time
O(n)- Space
O(n)- Why
- One pass to build the kept list, one to copy it into nums.
What changed from the previous rung
def remove_negatives(nums: list[int]) -> None: """Remove the negative numbers from nums, in place.""" nums = [n for n in nums if n >= 0] # only moves the local name nums nums[:] = [n for n in nums if n >= 0] # puts new items into the same listWhen Best isn’t best: if the caller doesn’t need the same list object, return the new list instead. Code that returns new data is easier to test and to reason about.
3. Deep copy by hand (hard)
Section titled “3. Deep copy by hand (hard)”Write deep_copy(value) without the copy module. value holds numbers, text, lists and dicts, nested to any depth. Return an equal value that shares no list or dict with it, so changing anything in the copy never changes value.
Example: deep_copy([1, [2, [3]]]) gives [1, [2, [3]]], and appending to the innermost list of the copy leaves value as it was.
Hint 1 of 3
A list’s copy is a new list of copies of its items.
Hint 2 of 3
A copy of an item is the same problem again: let deep_copy call itself.
Hint 3 of 3
Numbers and text can’t change, so they can be shared. Only lists and dicts need new objects.
Deep copy by hand
Ctrl + Enter runs- Not run yet:
c = deep_copy(value); c[0].append(9); value→[[1, 2], [3], 4] - Not run yet:
value = [1, [2, [3, [4]]]]; c = deep_copy(value); c[1][1][1].append(5); value→[1, [2, [3, [4]]]] - Not run yet:
c = deep_copy(value); c["tags"].append("y"); value→{'name': 'a', 'tags': ['x']} - Not run yet: Extra test
- Not run yet: Extra test
- Not run yet: Extra test
- Not run yet: Extra test
Extra tests check tricky cases. You see their input only if they fail.
Try your own input
Show the Okay, Better and Best answers
Copy two levels
The idea: Copy the outer list, and .copy() each list inside it.
Right for a list of lists. But it stops after two levels: in [1, [2, [3]]], the [3] is still shared. And a dict comes back as the same object.
def deep_copy(value): """A copy of nested lists and dicts that shares no list or dict with value.""" if not isinstance(value, list): return value # Copy the outer list, and each list inside it. return [item.copy() if isinstance(item, list) else item for item in value]- Time
O(n)- Space
O(n)- Why
- Each item of the first two levels is looked at once.
Let it call itself on lists
The idea: A list’s copy is a new list of deep_copy(item) for each item, so it goes as deep as the lists go.
The waste in Okay: it copies a fixed number of levels, when each level is the same problem as the last.
Any depth of lists now works. But a dict is still returned as itself, so lists inside dicts stay shared.
Highlighted lines are new in this rung.
def deep_copy(value): """A copy of nested lists and dicts that shares no list or dict with value.""" if not isinstance(value, list): return value # Each item gets the same treatment, however deep the lists go. return [deep_copy(item) for item in value]- Time
O(n)- Space
O(n)- Why
- Every list and item is visited once, at any depth. n is the size of all the data.
What changed from the previous rung
def deep_copy(value): """A copy of nested lists and dicts that shares no list or dict with value.""" if not isinstance(value, list): return value # Copy the outer list, and each list inside it. return [item.copy() if isinstance(item, list) else item for item in value] # Each item gets the same treatment, however deep the lists go. return [deep_copy(item) for item in value]Where it breaks: dicts. deep_copy({"tags": ["x"]}) hands back the very same dict.
Lists and dicts, at any depth
The idea: New lists and new dicts all the way down; numbers and text are shared, since they can’t change.
The waste in Better: it treats dicts as if they couldn’t change.
A dict comprehension builds the new dict, calling deep_copy on each value.
Highlighted lines are new in this rung.
def deep_copy(value): """A copy of nested lists and dicts that shares no list or dict with value.""" if isinstance(value, list): return [deep_copy(item) for item in value] if isinstance(value, dict): return {key: deep_copy(item) for key, item in value.items()} return value # numbers, text and None can't change, so sharing them is safe- Time
O(n)- Space
O(n)- Why
- Every list, dict and item is visited once.
What changed from the previous rung
def deep_copy(value): """A copy of nested lists and dicts that shares no list or dict with value.""" if not isinstance(value, list): return value # Each item gets the same treatment, however deep the lists go. return [deep_copy(item) for item in value] if isinstance(value, list): return [deep_copy(item) for item in value] if isinstance(value, dict): return {key: deep_copy(item) for key, item in value.items()} return value # numbers, text and None can't change, so sharing them is safeWhen Best isn’t best: in real code, use copy.deepcopy(). It also copies tuples, sets and your own classes. It handles a list that contains itself, which would make this version call itself forever. And a list held in two places is copied once, so the copy keeps that sharing.
Check yourself
Section titled “Check yourself”A few quick questions. Ones you miss are remembered, so you can find your weak spots later.
Flashcards
Section titled “Flashcards”Add these to your review deck. Each card comes back a little later every time you get it right.
Cheat sheet
Section titled “Cheat sheet”b = anever copies. It gives the same object a second name.a == b: equal values.a is b: one object. UseisforNone..copy(),list(a),a[:]anddict(d)are shallow: a new outside, the same items inside.copy.deepcopy(x)copies every level. Or make new objects for just the parts you change.[[0] * n for _ in range(m)], never[[0] * n] * m.- In a function,
nums[:] = ...changes the caller’s list.nums = ...doesn’t.
Go deeper
Section titled “Go deeper”- Objects, values and types, from the Python reference.
copy: shallow and deep copy operations.- From the Python FAQ: Why did changing list ‘y’ also change list ‘x’?, How do I write a function with output parameters? and Why are default values shared between objects?
- Draw your own: the memory diagram on the Python visualizer.
- Next: Python says my list can’t be a dict key, on objects that can and can’t change.
Can you do these now?
If you’re not sure of any of these, scroll back to that part before moving on.
Status: Not started