CS1602Introduction to Computation
Lecture 5Part 2 Built-in Data StructuresAI Level 0

Lists

A single variable cannot hold a real problem. Lists are the container you will reach for most often in Python, and also the one that trips up beginners most — because underneath, a list is a reference, not a value.

By the end of this lecture you should be able to

  • Store and process a batch of data with a list instead of a hundred separate variables
  • Explain the model where a variable points at an object, and confirm it with id()
  • Tell the difference between changing a list and pointing a name somewhere new
  • Avoid the classic traps around nested lists and shallow copies
  • Judge roughly what each list operation costs
Contents

One variable per student does not scale

Say you want the average, the highest mark, and the number of failures for a class of 116 students.

With what you know so far, the only way in is this:

score1 = 87
score2 = 92
score3 = 65
# ...and 113 more

It is not just tedious. It does not work. To compute the average you would have to spell out all 116 names in one enormous sum, and if next year the class has 120 students you rewrite the whole thing.

Here is the real problem: variable names are fixed when you write the program, but how much data there is only becomes known when it runs. You need something that holds a batch of values together and lets you reach them by position rather than by name.

That is a list.

scores = [87, 92, 65, 78, 95]

print("count:  ", len(scores))
print("total:  ", sum(scores))
print("average:", sum(scores) / len(scores))
print("highest:", max(scores))

Creating and reading a list

Square brackets, commas between the items. The items can be of any type, and they do not have to match:

a = [1, 3, 9, 7]                 # four integers
b = ["Hello", "world"]           # two strings
c = [1, "two", 3.0, True]        # mixed, and perfectly legal
d = []                           # empty

print(a, b, c, d)
print(type(a))

list() turns anything iterable into a list:

print(list("hello"))          # a string, character by character
print(list(range(5)))         # a range of integers

Counting starts at zero

You read an item with square brackets and a position. The first item is at position 0. This catches almost everyone at least once.

a = [10, 20, 30, 40, 50]

print(a[0])     # first
print(a[4])     # fifth, and also last
print(a[-1])    # negative counts from the right, so -1 is the last one
print(a[-2])    # second from the end

For a list of length n, the valid positions run from 0 to n - 1. Step outside that and you get an IndexError:

a = [10, 20, 30]
print(a[3])

Going through a list

scores = [87, 92, 65, 78, 95]

for s in scores:
    print(s, "pass" if s >= 60 else "fail")

When you need the position as well as the item, use enumerate() rather than for i in range(len(scores)):

scores = [87, 92, 65]

for i, s in enumerate(scores):
    print(f"student {i + 1}: {s}")

Is it in there?

names = ["Ada", "Alan", "Grace"]
print("Alan" in names)
print("Linus" not in names)

A variable points at an object

This section is the heart of the lecture. If you remember one thing, remember this.

In Python a variable is not a box holding a value. It is an arrow pointing at an object. The statement x = y does not copy what is in y into x. It makes x point at whatever y is pointing at.

The built-in id() gives you an object’s identity — think of it as its address in memory. It makes the whole thing visible:

a = [1, 2, 3]
b = a          # b now points at the same list

print("id of a:", id(a))
print("id of b:", id(b))
print("same object?", a is b)

The two ids match. a and b are two names for one list.

So a change made through b shows up through a:

a = [1, 2, 3]
b = a

b.append(4)      # changes the list that b points at

print("a =", a)  # a changed too
print("b =", b)

Changing versus rebinding

Once the reference model clicks, two operations that look similar turn out to be nothing alike.

Rebinding points the name at a new object and leaves the old one untouched:

a = [1, 2, 3]
b = a
b = [9, 9, 9]      # b now points somewhere else

print("a =", a)    # unchanged
print("b =", b)

Mutating changes the object itself, and every name pointing at it sees the change:

a = [1, 2, 3]
b = a
b[0] = 9           # reaches inside the list

print("a =", a)    # changed
print("b =", b)

The rule of thumb: a bare name on the left of = means rebinding; anything with [...] on the left, or a method call, means mutation.

Adding, removing, changing

Lists are mutable. The methods below all change the list in place and return None.

MethodWhat it does
a.append(x)add one item at the end
a.extend(it)add every item of an iterable at the end
a.insert(i, x)insert x at position i, shifting the rest right
a.remove(x)delete the first item equal to x; ValueError if there is none
a.pop(i)delete the item at i and return it; with no argument, the last one
a.clear()empty the list
a.sort()sort in place
a.reverse()reverse in place
del a[i]delete the item at i (a statement, not a method)
a = [3, 1, 2]

a.append(4)        # [3, 1, 2, 4]
a.insert(0, 0)     # [0, 3, 1, 2, 4]
a.remove(3)        # [0, 1, 2, 4]
last = a.pop()     # takes 4 out, leaving [0, 1, 2]
a.sort()

print(a, "and we popped", last)

append and extend are easy to mix up:

a = [1, 2]
a.append([3, 4])
print("append:", a)     # the whole list goes in as a single item

b = [1, 2]
b.extend([3, 4])
print("extend:", b)     # the items go in one at a time

Slicing

A slice takes a stretch of a list. The syntax is a[start:stop], and it includes the start but stops before the end.

a = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

print(a[2:5])      # positions 2, 3, 4
print(a[:3])       # from the beginning up to position 2
print(a[7:])       # from position 7 to the end
print(a[:])        # the whole thing — and this gives you a new list
print(a[-3:])      # the last three

A third number sets the step:

a = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

print(a[::2])      # every other item
print(a[1::2])     # every other item, starting at position 1
print(a[::-1])     # a negative step reverses; returns a new list

A slice gives you a new list, and that matters:

a = [1, 2, 3]
b = a[:]          # a fresh list

print(a is b)     # False — two different objects
b[0] = 99
print("a =", a)   # untouched

Where copying goes wrong

a[:] made a copy, so the problem looks solved. But it is a shallow copy: it duplicates the outer list and nothing more. Anything nested inside is still shared.

With a list of lists, that shows up immediately:

a = [[1, 2], [3, 4]]
b = a[:]                # shallow copy

b[0][0] = 99            # reaches into the inner list

print("a =", a)         # a changed as well
print("b =", b)
print("same inner list?", a[0] is b[0])

b really is a new outer list. But its two items are still arrows pointing at the original inner lists.

For a copy that goes all the way down, use copy.deepcopy():

import copy

a = [[1, 2], [3, 4]]
b = copy.deepcopy(a)

b[0][0] = 99
print("a =", a)         # safe this time
print("b =", b)
How you write itHow deep it copiesWhen to use it
b = anot at all — just another namewhen you genuinely want one object under two names
b = a[:] or a.copy()the outer list onlywhen the items are numbers, strings, or other immutable values
copy.deepcopy(a)every levelwhen there are lists, dicts, or other mutable objects inside

The matrix that changes three rows at once

To build a 3×3 grid of zeros, the obvious thing to write is:

matrix = [[0] * 3] * 3
print(matrix)              # looks exactly right

matrix[0][0] = 1
print(matrix)              # every row changed

[[0] * 3] * 3 means “take this one list [0, 0, 0] and repeat the reference three times.” All three rows are the same list.

Build it with a comprehension instead, so each pass creates a genuinely new list:

matrix = [[0] * 3 for _ in range(3)]

matrix[0][0] = 1
print(matrix)              # only the first row changed

Deleting while you iterate

The other classic. Here we delete from a list inside a for loop that is walking it:

a = [1, 2, 2, 3, 4]

for x in a:
    if x == 2:
        a.remove(x)

print(a)      # not what you wanted

Why? The loop keeps an internal position that advances by one each time round. Removing an item shifts everything after it left by one, so some items get skipped entirely.

The fix is to stop editing in place and build a new list instead:

a = [1, 2, 2, 3, 4]

b = [x for x in a if x != 2]      # a comprehension says it most clearly
print(b)

List comprehensions

They have shown up a few times already, so here they are properly. A comprehension builds one sequence out of another:

[expression for name in iterable if condition]

It does the same job as a loop, in less space and with fewer places to slip:

# the long way
squares = []
for i in range(10):
    squares.append(i ** 2)

# the comprehension
squares2 = [i ** 2 for i in range(10)]

print(squares == squares2)
print(squares2)

With a condition attached:

scores = [87, 92, 45, 78, 55, 95]

passed = [s for s in scores if s >= 60]
print("passed:  ", passed)
print("pass rate:", len(passed) / len(scores))

What things cost

One last piece of intuition. In memory a list is a contiguous row of slots, each holding an arrow to an item. That layout decides which operations are cheap and which are not:

OperationSpeedWhy
a[i] — read by positionfastthe slot’s address is one calculation away
a.append(x)fastadds a slot at the end, disturbing nothing
a.pop() — drop the lastfastsame
a.insert(0, x)slowevery existing item shifts one slot right
a.remove(x)slowscan to find it, then shift everything after it left
x in aslowcompare from the front, one item at a time
a.pop(0) — drop the firstslowevery remaining item shifts left

“Slow” here means something specific: the longer the list, the slower, in direct proportion. On a list of a million items, a.insert(0, x) moves a million things.

Summary

  • A list holds a batch of values you reach by position. Counting starts at 0; -1 is the last item
  • A variable is an arrow pointing at an object. b = a does not copy the list; it adds a second name
  • Methods that mutate in place — append, sort, reverse, extend — all return None
  • a[i:j] includes the start, stops before the end, and gives you a new list
  • a[:] copies only the outer level; for nested data use copy.deepcopy()
  • [[0] * n] * m makes m references to one row — build grids with a comprehension
  • Never modify a structure while iterating over it
  • Cheap at the end, expensive at the front, expensive to search

Exercises

  1. Without using max(), write a function that returns the largest value in a list. Return None for an empty list.

  2. Write a function that removes duplicates from a list while keeping the original order. You may not use set — it loses the ordering.

  3. Given a matrix as a list of lists, write a function that returns its transpose. Build the result with [[0] * n] * m first and see what happens, then fix it.

  4. The code below is meant to double every even number in the list. It does not. Find out why and correct it:

    a = [1, 2, 3, 4, 5, 6]
    for i in range(len(a)):
        if a[i] % 2 == 0:
            a.insert(i, a[i] * 2)
  5. Design a small experiment with id() that demonstrates the following: a slice produces a new outer list, but the objects nested inside it are still shared.

The lab problems for this lecture are in Lab 5.