Solutions 09: Collections¶
Overview¶
Chapter 09 exercises cover Python's four built-in collection types: lists, tuples, sets, and dictionaries. This solution guide explains the reasoning behind each exercise and when to choose each collection type.
Notes Before Checking Solutions¶
The most important skill in this chapter is not memorizing methods — it is knowing which collection to reach for. Lists for ordered, mutable sequences. Tuples for fixed data. Sets for unique values and membership tests. Dictionaries for key-value lookups. When you internalize that, the rest follows naturally.
Warm-up Exercise Solutions¶
Exercise 1: Work with Lists¶
numbers = [1, 2, 3, 4, 5]
# Access
print(numbers[0]) # 1 (first)
print(numbers[-1]) # 5 (last)
print(numbers[1:4]) # [2, 3, 4]
# Modify
numbers.append(6) # [1, 2, 3, 4, 5, 6]
numbers.extend([7, 8]) # [1, 2, 3, 4, 5, 6, 7, 8]
numbers.insert(0, 0) # [0, 1, 2, 3, 4, 5, 6, 7, 8]
numbers.remove(0) # [1, 2, 3, 4, 5, 6, 7, 8]
popped = numbers.pop() # removes and returns 8
append() vs extend():
- append(x) adds x as a single element: [1, 2].append([3, 4]) → [1, 2, [3, 4]]
- extend(iterable) adds each element of the iterable: [1, 2].extend([3, 4]) → [1, 2, 3, 4]
remove() vs pop():
- remove(x) removes the first occurrence of value x. Raises ValueError if not found.
- pop(i) removes and returns the element at index i. Defaults to the last element.
items = [3, 1, 4, 1, 5, 9, 2, 6]
items.sort() # sorts in place: [1, 1, 2, 3, 4, 5, 6, 9]
items.reverse() # reverses in place: [9, 6, 5, 4, 3, 2, 1, 1]
copy = items.copy() # independent copy
sort() vs sorted(): sort() modifies the list in place and returns None. sorted() returns a new sorted list and leaves the original unchanged. Use sorted() when you need to keep the original order.
Exercise 2: Work with Tuples¶
point = (10, 20)
colors = ("red", "green", "blue")
single = (42,) # the comma is required for a single-element tuple
empty = ()
# Access — same as lists
print(colors[0]) # red
print(colors[-1]) # blue
print(colors[0:2]) # ('red', 'green')
# Tuple unpacking
x, y = point
print(f"x={x}, y={y}") # x=10, y=20
r, g, b = colors
print(f"r={r}, g={g}, b={b}")
Why the comma for single-element tuples? Without the comma, (42) is just 42 in parentheses — Python treats it as a grouped expression, not a tuple. The comma is what makes it a tuple: (42,).
Tuples as dictionary keys:
Lists cannot be dictionary keys because they are mutable (and therefore not hashable). Tuples can be keys because they are immutable.
Exercise 3: Work with Sets¶
numbers = {1, 2, 3, 4, 5}
empty = set() # {} creates an empty dict, not a set
numbers.add(6) # {1, 2, 3, 4, 5, 6}
numbers.remove(1) # {2, 3, 4, 5, 6}
a = {1, 2, 3, 4}
b = {3, 4, 5, 6}
print(a | b) # {1, 2, 3, 4, 5, 6} — union
print(a & b) # {3, 4} — intersection
print(a - b) # {1, 2} — difference (in a but not b)
print(a ^ b) # {1, 2, 5, 6} — symmetric difference
Set operations are fast. Checking x in some_set is O(1) — it does not matter how large the set is. Checking x in some_list is O(n) — it scans the list from the beginning. Use sets when you need fast membership tests.
Remove duplicates:
Note: converting to a set and back to a list removes duplicates but does not preserve order. If order matters, use dict.fromkeys() (Python 3.7+):
Exercise 4: Work with Dictionaries¶
person = {"name": "Alice", "age": 30, "city": "New York"}
# Access
print(person["name"]) # Alice
print(person["age"]) # 30
# Add and modify
person["job"] = "Engineer"
person["age"] = 31
# Methods
print(person.keys()) # dict_keys(['name', 'age', 'city', 'job'])
print(person.values()) # dict_values(['Alice', 31, 'New York', 'Engineer'])
print(person.items()) # dict_items([...])
# Safe access
print(person.get("salary", "Not specified")) # Not specified
# Iterate
for key, value in person.items():
print(f" {key}: {value}")
# Remove
del person["job"]
# Update from another dict
person.update({"age": 32, "city": "Boston"})
get() vs direct access: person["salary"] raises KeyError if the key does not exist. person.get("salary", "Not specified") returns the default value instead. Use get() when the key might not be present.
Dictionary order: Since Python 3.7, dictionaries maintain insertion order. Iterating over a dict gives keys in the order they were added.
Practice Exercise Solutions¶
Exercise 5: Combine Collections¶
# List of dictionaries — common pattern for tabular data
students = [
{"name": "Alice", "grade": 90},
{"name": "Bob", "grade": 85},
{"name": "Carol", "grade": 92},
]
for student in students:
print(f" {student['name']}: {student['grade']}")
# Dictionary with lists — group related values
inventory = {
"apples": [10, 5, 8],
"bananas": [15, 12, 20],
}
for fruit, quantities in inventory.items():
total = sum(quantities)
print(f" {fruit}: {total} total")
Nested collections are very common in real programs. JSON data, API responses, and database results often come back as lists of dictionaries. Getting comfortable with data["key"] and data[0]["key"] patterns is essential.
Exercise 6: Process Collections¶
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
# Filter
evens = [n for n in numbers if n % 2 == 0]
# [2, 4, 6, 8, 10]
# Transform
squared = [n ** 2 for n in numbers]
# [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]
# Count occurrences
items = ["apple", "banana", "apple", "cherry", "banana", "apple"]
counts = {}
for item in items:
counts[item] = counts.get(item, 0) + 1
# {"apple": 3, "banana": 2, "cherry": 1}
counts.get(item, 0) + 1: If item is not yet in counts, get() returns 0. Adding 1 gives the first count. On subsequent iterations, get() returns the current count and we add 1 again.
Group by first letter:
words = ["apple", "apricot", "banana", "blueberry", "cherry"]
by_first_letter = {}
for word in words:
first = word[0]
if first not in by_first_letter:
by_first_letter[first] = []
by_first_letter[first].append(word)
# {"a": ["apple", "apricot"], "b": ["banana", "blueberry"], "c": ["cherry"]}
A cleaner version using setdefault():
setdefault(key, default) returns the value for key if it exists, otherwise inserts key with default and returns default.
Exercise 7: Choose the Right Collection¶
| Use case | Collection | Reason |
|---|---|---|
| Ordered, changeable sequence | list |
Supports indexing, appending, sorting |
| Fixed, unchangeable data | tuple |
Immutable, can be a dict key |
| Unique values, fast membership test | set |
No duplicates, O(1) lookup |
| Key-value pairs | dict |
Fast lookup by key |
# List — shopping list (order matters, items can be added/removed)
shopping_list = ["milk", "eggs", "bread"]
shopping_list.append("butter")
# Tuple — RGB color (fixed, three values, can be a dict key)
rgb_color = (255, 128, 0)
# Set — visited cities (unique values, order does not matter)
visited_cities = {"New York", "Boston", "Chicago"}
visited_cities.add("Denver")
# Dict — phone book (look up by name)
phone_book = {"Alice": "555-1234", "Bob": "555-5678"}
Challenge Exercise Solutions¶
Challenge 1: Analyze Text with Collections¶
def analyze_text(text):
"""Analyze text and return statistics."""
words = text.lower().split()
# Count word frequencies using a dict
word_counts = {}
for word in words:
word_counts[word] = word_counts.get(word, 0) + 1
# Unique words using a set
unique_words = set(words)
# Sort by frequency (descending)
sorted_words = sorted(word_counts.items(), key=lambda x: x[1], reverse=True)
return {
"total_words": len(words),
"unique_words": len(unique_words),
"word_counts": word_counts,
"most_common": sorted_words[:5],
}
sorted(word_counts.items(), key=lambda x: x[1], reverse=True):
- word_counts.items() gives (word, count) pairs.
- key=lambda x: x[1] sorts by the count (second element of each pair).
- reverse=True sorts in descending order (highest count first).
Challenge 2: Build a Contact Manager¶
def add_contact(contacts, name, phone, email):
"""Add a contact."""
contacts[name] = {"phone": phone, "email": email}
def remove_contact(contacts, name):
"""Remove a contact."""
if name in contacts:
del contacts[name]
else:
print(f"Contact '{name}' not found.")
def list_contacts(contacts):
"""List all contacts."""
if not contacts:
print("No contacts.")
return
for name, info in contacts.items():
print(f"{name}: {info['phone']}, {info['email']}")
def search_contact(contacts, name):
"""Search for a contact."""
info = contacts.get(name)
if info:
print(f"{name}: {info['phone']}, {info['email']}")
else:
print(f"Contact '{name}' not found.")
Why use contacts.get(name) instead of contacts[name]? contacts[name] raises KeyError if the name is not in the dictionary. contacts.get(name) returns None instead, which we can check with if info:.
Challenge 3: Implement a Simple Inventory System¶
class Inventory:
"""Simple inventory system."""
def __init__(self):
self.items = {}
def add_item(self, name, quantity, price):
"""Add an item to inventory."""
if name in self.items:
# Item already exists — just increase quantity
self.items[name]["quantity"] += quantity
else:
self.items[name] = {"quantity": quantity, "price": price}
def remove_item(self, name, quantity):
"""Remove items from inventory."""
if name not in self.items:
print(f"Item '{name}' not found.")
return
self.items[name]["quantity"] -= quantity
if self.items[name]["quantity"] <= 0:
del self.items[name]
def get_total_value(self):
"""Calculate total inventory value."""
total = 0
for item in self.items.values():
total += item["quantity"] * item["price"]
return total
def list_items(self):
"""List all items."""
for name, info in self.items.items():
value = info["quantity"] * info["price"]
print(f"{name}: {info['quantity']} @ ${info['price']:.2f} = ${value:.2f}")
Why delete the item when quantity reaches zero? An item with zero quantity is not in stock. Keeping it in the dictionary with quantity: 0 would clutter the inventory and require extra checks everywhere. Deleting it keeps the data clean.
Challenge 4: Find Patterns in Collections¶
# Find duplicates
items = [1, 2, 2, 3, 3, 3, 4, 4, 4, 4]
duplicates = [x for x in set(items) if items.count(x) > 1]
print(f"Duplicates: {duplicates}") # [2, 3, 4]
# Find common elements between two lists
list1 = [1, 2, 3, 4, 5]
list2 = [3, 4, 5, 6, 7]
common = list(set(list1) & set(list2))
print(f"Common: {common}") # [3, 4, 5]
# Find missing elements
all_numbers = set(range(1, 6))
present = {1, 2, 4, 5}
missing = all_numbers - present
print(f"Missing: {missing}") # {3}
# Flatten nested list
nested = [[1, 2], [3, 4], [5, 6]]
flat = [item for sublist in nested for item in sublist]
print(f"Flattened: {flat}") # [1, 2, 3, 4, 5, 6]
Flattening with a nested comprehension: [item for sublist in nested for item in sublist] reads as "for each sublist in nested, for each item in sublist, yield item." The outer loop comes first, the inner loop second.
Group consecutive numbers:
numbers = [1, 2, 3, 5, 6, 7, 9, 10]
groups = []
current_group = [numbers[0]]
for n in numbers[1:]:
if n == current_group[-1] + 1:
current_group.append(n)
else:
groups.append(current_group)
current_group = [n]
groups.append(current_group) # don't forget the last group
print(f"Groups: {groups}") # [[1, 2, 3], [5, 6, 7], [9, 10]]
The key insight: we check if the current number is exactly one more than the last number in the current group. If yes, it belongs to the same group. If no, we start a new group.
Common Mistakes¶
Using {} to create an empty set. {} creates an empty dictionary. Use set() for an empty set.
Modifying a list while iterating over it. This can skip elements or cause unexpected behavior. Iterate over a copy: for item in items.copy():.
Forgetting that sort() returns None. sorted_list = my_list.sort() gives you None. Use sorted_list = sorted(my_list) instead.
Using a list when a set would be faster. If you are checking membership frequently (if x in items), use a set. List membership checks scan the entire list; set checks are nearly instant.
Accessing a dict key that might not exist. Use .get() with a default value, or check with if key in dict: first.
What to Review Next¶
- Review the matching handbook chapter if any exercise felt difficult.
- Revisit the matching exercise set and try solving it again without looking at the solution.
- Continue with the next handbook chapter: Chapter 10 - Functions