Lesson 2: Data Structures#

Introduction#

Data structures are like different types of containers for organizing your data. Just as you might use a filing cabinet for documents, a spice rack for spices, and a bookshelf for books, Python provides different data structures optimized for different purposes.

Why Data Structures Matter:

  • Efficiency: Choose the right structure for fast access and updates

  • Organization: Keep related data together logically

  • Readability: Make code intentions clear

  • Performance: Some operations are faster with certain structures

What You’ll Learn:

  • Lists: ordered, mutable sequences

  • Tuples: ordered, immutable sequences

  • Sets: unordered, unique elements

  • Dictionaries: key-value mappings

  • Comprehensions: elegant data transformation

  • Collections module: specialized data structures

  • Performance characteristics

  • When to use which structure

1. Lists - Ordered Mutable Sequences#

Lists are the most versatile data structure in Python. Think of them as dynamic arrays that can grow or shrink.

Creating Lists#

# Empty list
empty = []
also_empty = list()

# List with items
numbers = [1, 2, 3, 4, 5]
fruits = ["apple", "banana", "cherry"]

# Mixed types (not recommended, but possible)
mixed = [1, "hello", 3.14, True]

# Nested lists
matrix = [
    [1, 2, 3],
    [4, 5, 6],
    [7, 8, 9]
]

# List from string
chars = list("Python")  # ['P', 'y', 't', 'h', 'o', 'n']

# List from range
nums = list(range(5))  # [0, 1, 2, 3, 4]

print(f"Numbers: {numbers}")
print(f"Fruits: {fruits}")
print(f"Matrix: {matrix}")
print(f"Chars: {chars}")

Accessing Elements#

fruits = ["apple", "banana", "cherry", "date", "elderberry"]

# Positive indexing (0-based)
print(f"First: {fruits[0]}")    # apple
print(f"Second: {fruits[1]}")   # banana

# Negative indexing (from the end)
print(f"Last: {fruits[-1]}")    # elderberry
print(f"Second to last: {fruits[-2]}")  # date

# Out of range causes IndexError
# print(fruits[10])  # Error!

Slicing Lists#

Slicing syntax: list[start:stop:step]

  • start: inclusive (default 0)

  • stop: exclusive (default end)

  • step: increment (default 1)

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

print(f"First 3: {numbers[0:3]}")      # [0, 1, 2]
print(f"First 3 (short): {numbers[:3]}")  # [0, 1, 2]

print(f"Last 3: {numbers[-3:]}")       # [7, 8, 9]
print(f"Middle: {numbers[3:7]}")       # [3, 4, 5, 6]

# With step
print(f"Every 2nd: {numbers[::2]}")    # [0, 2, 4, 6, 8]
print(f"Every 3rd: {numbers[::3]}")    # [0, 3, 6, 9]

# Reverse
print(f"Reversed: {numbers[::-1]}")    # [9, 8, 7, ..., 0]

# Slice assignment
numbers[2:5] = [20, 30, 40]
print(f"After slice assignment: {numbers}")

Modifying Lists#

fruits = ["apple", "banana"]

# Append (add to end)
fruits.append("cherry")
print(f"After append: {fruits}")

# Insert at specific index
fruits.insert(1, "blueberry")
print(f"After insert: {fruits}")

# Extend (add multiple items)
fruits.extend(["date", "elderberry"])
print(f"After extend: {fruits}")

# Remove by value
fruits.remove("banana")
print(f"After remove: {fruits}")

# Pop (remove and return by index)
last = fruits.pop()  # removes last item
print(f"Popped: {last}, List: {fruits}")

second = fruits.pop(1)  # removes item at index 1
print(f"Popped: {second}, List: {fruits}")

# Clear all items
# fruits.clear()
# print(f"After clear: {fruits}")

List Operations#

# Concatenation
list1 = [1, 2, 3]
list2 = [4, 5, 6]
combined = list1 + list2
print(f"Combined: {combined}")

# Repetition
repeated = [0] * 5
print(f"Repeated: {repeated}")

# Membership testing
print(f"Is 3 in list1? {3 in list1}")
print(f"Is 10 in list1? {10 in list1}")

# Length
print(f"Length of combined: {len(combined)}")

# Count occurrences
nums = [1, 2, 2, 3, 2, 4]
print(f"Count of 2: {nums.count(2)}")

# Find index
print(f"Index of first 2: {nums.index(2)}")
print(f"Index of 2 after position 2: {nums.index(2, 2)}")

Sorting Lists#

numbers = [3, 1, 4, 1, 5, 9, 2, 6]

# sort() modifies the list in place
numbers.sort()
print(f"Sorted: {numbers}")

# Reverse sort
numbers.sort(reverse=True)
print(f"Reverse sorted: {numbers}")

# sorted() returns a new sorted list
original = [3, 1, 4, 1, 5]
new_sorted = sorted(original)
print(f"Original: {original}")
print(f"New sorted: {new_sorted}")

# Sort by custom key
words = ["banana", "pie", "Washington", "book"]
words.sort(key=str.lower)  # Case-insensitive sort
print(f"Case-insensitive sort: {words}")

# Sort by length
words.sort(key=len)
print(f"Sorted by length: {words}")

# Reverse a list
numbers = [1, 2, 3, 4, 5]
numbers.reverse()
print(f"Reversed: {numbers}")

List Copying#

Important: Assignment doesn’t copy lists!

# This doesn't create a copy!
original = [1, 2, 3]
reference = original  # Both point to the same list
reference.append(4)
print(f"Original: {original}")  # [1, 2, 3, 4] - modified!
print(f"Reference: {reference}")  # [1, 2, 3, 4]

# Create a shallow copy
original = [1, 2, 3]
copy1 = original.copy()
copy2 = original[:]
copy3 = list(original)

copy1.append(4)
print(f"Original: {original}")  # [1, 2, 3] - unchanged
print(f"Copy: {copy1}")  # [1, 2, 3, 4]

# Deep copy for nested lists
import copy
nested = [[1, 2], [3, 4]]
deep = copy.deepcopy(nested)
deep[0].append(999)
print(f"Original nested: {nested}")  # [[1, 2], [3, 4]]
print(f"Deep copy: {deep}")  # [[1, 2, 999], [3, 4]]

2. Tuples - Immutable Sequences#

Tuples are like lists, but they cannot be modified after creation. They’re useful for data that shouldn’t change.

Creating Tuples#

# Empty tuple
empty = ()
also_empty = tuple()

# Tuple with items
coordinates = (10, 20)
rgb = (255, 128, 0)

# Single item tuple (note the comma!)
single = (5,)  # With comma: tuple
not_tuple = (5)  # Without comma: just an int in parentheses

print(f"Type of (5,): {type(single)}")
print(f"Type of (5): {type(not_tuple)}")

# Tuples without parentheses
point = 10, 20  # Still a tuple!
print(f"Point: {point}, Type: {type(point)}")

# Convert list to tuple
my_list = [1, 2, 3]
my_tuple = tuple(my_list)
print(f"Tuple from list: {my_tuple}")

Tuple Operations#

coords = (10, 20, 30)

# Indexing and slicing (just like lists)
print(f"First: {coords[0]}")
print(f"Last: {coords[-1]}")
print(f"First two: {coords[:2]}")

# Concatenation
tuple1 = (1, 2, 3)
tuple2 = (4, 5, 6)
combined = tuple1 + tuple2
print(f"Combined: {combined}")

# Repetition
repeated = (0,) * 5
print(f"Repeated: {repeated}")

# Membership
print(f"Is 20 in coords? {20 in coords}")

# Length, count, index
print(f"Length: {len(coords)}")

# Tuples are immutable!
# coords[0] = 99  # TypeError: tuple doesn't support item assignment

Tuple Unpacking#

# Basic unpacking
point = (10, 20)
x, y = point
print(f"x: {x}, y: {y}")

# Swap variables (Python magic!)
a, b = 5, 10
a, b = b, a  # Swap!
print(f"a: {a}, b: {b}")

# Unpacking with *
numbers = (1, 2, 3, 4, 5)
first, *middle, last = numbers
print(f"First: {first}")
print(f"Middle: {middle}")  # List of middle elements
print(f"Last: {last}")

# Unpacking in function returns
def get_stats(nums):
    return min(nums), max(nums), sum(nums) / len(nums)

minimum, maximum, average = get_stats([1, 2, 3, 4, 5])
print(f"Min: {minimum}, Max: {maximum}, Avg: {average}")

Named Tuples#

from collections import namedtuple

# Create a named tuple type
Point = namedtuple('Point', ['x', 'y'])
Person = namedtuple('Person', ['name', 'age', 'city'])

# Create instances
p = Point(10, 20)
print(f"Point: {p}")
print(f"X coordinate: {p.x}")
print(f"Y coordinate: {p[1]}")  # Can still use index

# Named tuples are more readable than regular tuples
alice = Person(name="Alice", age=30, city="NYC")
print(f"Name: {alice.name}")
print(f"Age: {alice.age}")
print(f"City: {alice.city}")

3. Sets - Unordered Unique Elements#

Sets are unordered collections of unique elements. Perfect for membership testing and removing duplicates.

Creating Sets#

# Using curly braces
fruits = {"apple", "banana", "cherry"}
numbers = {1, 2, 3, 4, 5}

# Empty set (must use set(), not {})
empty = set()  # Correct
not_set = {}  # This is an empty dictionary!

print(f"Type of set(): {type(empty)}")
print(f"Type of {{}}: {type(not_set)}")

# From list (removes duplicates)
numbers_with_dupes = [1, 2, 2, 3, 3, 3, 4]
unique_numbers = set(numbers_with_dupes)
print(f"Original: {numbers_with_dupes}")
print(f"Unique: {unique_numbers}")

# From string
letters = set("hello")
print(f"Letters: {letters}")  # {'h', 'e', 'l', 'o'}

Set Operations#

fruits = {"apple", "banana", "cherry"}

# Add single element
fruits.add("date")
print(f"After add: {fruits}")

# Add multiple elements
fruits.update(["elderberry", "fig"])
print(f"After update: {fruits}")

# Remove element (raises error if not found)
fruits.remove("banana")
print(f"After remove: {fruits}")

# Discard element (no error if not found)
fruits.discard("grape")  # No error even though grape isn't in the set

# Pop (remove arbitrary element)
item = fruits.pop()
print(f"Popped: {item}")

# Membership testing (FAST in sets!)
print(f"Is apple in fruits? {'apple' in fruits}")

# Length
print(f"Number of fruits: {len(fruits)}")

Set Mathematics#

a = {1, 2, 3, 4, 5}
b = {4, 5, 6, 7, 8}

# Union (all elements from both sets)
print(f"Union: {a | b}")  # {1, 2, 3, 4, 5, 6, 7, 8}
print(f"Union: {a.union(b)}")

# Intersection (common elements)
print(f"Intersection: {a & b}")  # {4, 5}
print(f"Intersection: {a.intersection(b)}")

# Difference (in a but not in b)
print(f"Difference (a - b): {a - b}")  # {1, 2, 3}
print(f"Difference (b - a): {b - a}")  # {6, 7, 8}

# Symmetric difference (in either set, but not both)
print(f"Symmetric diff: {a ^ b}")  # {1, 2, 3, 6, 7, 8}
print(f"Symmetric diff: {a.symmetric_difference(b)}")

# Subset and superset
small = {1, 2}
large = {1, 2, 3, 4}
print(f"Is {small} subset of {large}? {small.issubset(large)}")
print(f"Is {large} superset of {small}? {large.issuperset(small)}")

# Disjoint (no common elements)
set1 = {1, 2, 3}
set2 = {4, 5, 6}
print(f"Are they disjoint? {set1.isdisjoint(set2)}")

Frozen Sets (Immutable Sets)#

# Frozen sets are immutable
frozen = frozenset([1, 2, 3, 4])
print(f"Frozen set: {frozen}")

# Can be used as dictionary keys
dict_with_set_keys = {
    frozenset([1, 2]): "first",
    frozenset([3, 4]): "second"
}
print(f"Dict with frozenset keys: {dict_with_set_keys}")

# Cannot modify frozen sets
# frozen.add(5)  # AttributeError!

4. Dictionaries - Key-Value Mappings#

Dictionaries map keys to values, like a real dictionary maps words to definitions.

Creating Dictionaries#

# Using curly braces
student = {
    "name": "Alice",
    "age": 20,
    "major": "CS",
    "gpa": 3.8
}

# Using dict() constructor
person = dict(name="Bob", age=25, city="NYC")

# From list of tuples
pairs = [("a", 1), ("b", 2), ("c", 3)]
letter_to_num = dict(pairs)

# Dict comprehension
squares = {x: x**2 for x in range(5)}
print(f"Squares: {squares}")

# Empty dictionary
empty = {}
also_empty = dict()

print(f"Student: {student}")
print(f"Person: {person}")

Accessing Dictionary Values#

student = {"name": "Alice", "age": 20, "major": "CS"}

# Using square brackets (raises KeyError if key doesn't exist)
print(f"Name: {student['name']}")
# print(student['gpa'])  # KeyError!

# Using get() (returns None or default if key doesn't exist)
print(f"Age: {student.get('age')}")
print(f"GPA: {student.get('gpa')}")  # None
print(f"GPA: {student.get('gpa', 0.0)}")  # 0.0 (default)

# Check if key exists
print(f"Has 'name'? {'name' in student}")
print(f"Has 'gpa'? {'gpa' in student}")

Modifying Dictionaries#

student = {"name": "Alice", "age": 20}

# Add or update key-value pair
student["major"] = "CS"  # Add new
student["age"] = 21  # Update existing
print(f"After updates: {student}")

# Update multiple items
student.update({"gpa": 3.8, "year": 3})
print(f"After bulk update: {student}")

# Remove items
del student["year"]  # Delete by key
print(f"After del: {student}")

gpa = student.pop("gpa")  # Remove and return value
print(f"Popped GPA: {gpa}")
print(f"After pop: {student}")

# Pop with default (no error if key missing)
minor = student.pop("minor", "Undeclared")
print(f"Minor: {minor}")

# popitem() removes and returns arbitrary (key, value)
# item = student.popitem()

# Clear all items
# student.clear()

Iterating Over Dictionaries#

grades = {"Alice": 95, "Bob": 87, "Charlie": 92}

# Iterate over keys (default)
print("Keys:")
for name in grades:
    print(f"  {name}")

# Iterate over keys explicitly
print("\nKeys (explicit):")
for name in grades.keys():
    print(f"  {name}")

# Iterate over values
print("\nValues:")
for grade in grades.values():
    print(f"  {grade}")

# Iterate over key-value pairs
print("\nKey-value pairs:")
for name, grade in grades.items():
    print(f"  {name}: {grade}")

Nested Dictionaries#

# Dictionary of dictionaries
students = {
    "alice": {"age": 20, "major": "CS", "gpa": 3.8},
    "bob": {"age": 21, "major": "Math", "gpa": 3.6},
    "charlie": {"age": 19, "major": "Physics", "gpa": 3.9}
}

# Access nested values
print(f"Alice's major: {students['alice']['major']}")
print(f"Bob's GPA: {students['bob']['gpa']}")

# Iterate over nested dict
for name, info in students.items():
    print(f"{name.capitalize()}: {info['major']}, GPA: {info['gpa']}")

Dictionary Methods#

# setdefault() - get value or set default if key doesn't exist
counts = {}
word = "hello"
for char in word:
    counts.setdefault(char, 0)
    counts[char] += 1
print(f"Character counts: {counts}")

# fromkeys() - create dict with keys from iterable
keys = ['a', 'b', 'c']
default_dict = dict.fromkeys(keys, 0)
print(f"Dict from keys: {default_dict}")

# Merge dictionaries (Python 3.9+)
dict1 = {"a": 1, "b": 2}
dict2 = {"b": 3, "c": 4}
merged = dict1 | dict2  # dict2 values win on conflict
print(f"Merged: {merged}")

5. Comprehensions#

Comprehensions provide concise syntax for creating collections.

List Comprehensions#

# Basic syntax: [expression for item in iterable]
squares = [x**2 for x in range(10)]
print(f"Squares: {squares}")

# With condition: [expression for item in iterable if condition]
even_squares = [x**2 for x in range(10) if x % 2 == 0]
print(f"Even squares: {even_squares}")

# String manipulation
words = ["hello", "world", "python"]
uppercase = [word.upper() for word in words]
print(f"Uppercase: {uppercase}")

# Nested loops
pairs = [(x, y) for x in range(3) for y in range(3)]
print(f"Pairs: {pairs}")

# If-else expression
labels = ["even" if x % 2 == 0 else "odd" for x in range(5)]
print(f"Labels: {labels}")

# Flatten nested list
matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
flat = [num for row in matrix for num in row]
print(f"Flattened: {flat}")

Dictionary Comprehensions#

# Basic syntax: {key: value for item in iterable}
squares_dict = {x: x**2 for x in range(5)}
print(f"Squares dict: {squares_dict}")

# From lists
names = ["Alice", "Bob", "Charlie"]
lengths = {name: len(name) for name in names}
print(f"Name lengths: {lengths}")

# With condition
even_squares = {x: x**2 for x in range(10) if x % 2 == 0}
print(f"Even squares: {even_squares}")

# Swap keys and values
original = {"a": 1, "b": 2, "c": 3}
swapped = {v: k for k, v in original.items()}
print(f"Original: {original}")
print(f"Swapped: {swapped}")

# Filter dictionary
grades = {"Alice": 95, "Bob": 75, "Charlie": 92, "Dave": 65}
honor_roll = {name: grade for name, grade in grades.items() if grade >= 90}
print(f"Honor roll: {honor_roll}")

Set Comprehensions#

# Basic syntax: {expression for item in iterable}
squares_set = {x**2 for x in range(10)}
print(f"Squares set: {squares_set}")

# Remove duplicates and transform
text = "hello world"
unique_upper = {char.upper() for char in text if char.isalpha()}
print(f"Unique uppercase letters: {unique_upper}")

# With condition
even_squares = {x**2 for x in range(10) if x % 2 == 0}
print(f"Even squares set: {even_squares}")

Generator Expressions#

Similar to list comprehensions but create generators (memory efficient for large datasets).

# Generator expression (uses parentheses)
squares_gen = (x**2 for x in range(10))
print(f"Generator: {squares_gen}")
print(f"Values: {list(squares_gen)}")

# Memory efficient for large datasets
# List comprehension - creates entire list in memory
squares_list = [x**2 for x in range(1000000)]  # Uses lots of memory

# Generator - creates values on demand
squares_gen = (x**2 for x in range(1000000))  # Uses minimal memory

# Use in sum()
total = sum(x**2 for x in range(100))
print(f"Sum of squares: {total}")

6. Collections Module#

Python’s collections module provides specialized container types.

Counter - Count Occurrences#

from collections import Counter

# Count elements
words = ["apple", "banana", "apple", "cherry", "banana", "apple"]
counts = Counter(words)
print(f"Counts: {counts}")

# Most common
print(f"Most common 2: {counts.most_common(2)}")

# Count letters
text = "hello world"
letter_counts = Counter(text)
print(f"Letter counts: {letter_counts}")

# Arithmetic operations
c1 = Counter(a=3, b=1)
c2 = Counter(a=1, b=2)
print(f"c1 + c2: {c1 + c2}")  # Add counts
print(f"c1 - c2: {c1 - c2}")  # Subtract counts

defaultdict - Default Values#

from collections import defaultdict

# Regular dict - need to check if key exists
word_groups = {}
words = ["apple", "apricot", "banana", "blueberry"]
for word in words:
    first_letter = word[0]
    if first_letter not in word_groups:
        word_groups[first_letter] = []
    word_groups[first_letter].append(word)
print(f"Regular dict: {word_groups}")

# defaultdict - automatic default values
word_groups = defaultdict(list)  # Default value is empty list
for word in words:
    word_groups[word[0]].append(word)
print(f"defaultdict: {dict(word_groups)}")

# With int (for counting)
counts = defaultdict(int)  # Default value is 0
for char in "hello":
    counts[char] += 1
print(f"Counts: {dict(counts)}")

deque - Double-Ended Queue#

from collections import deque

# Create deque
d = deque([1, 2, 3])
print(f"Initial: {d}")

# Add to right
d.append(4)
print(f"After append: {d}")

# Add to left
d.appendleft(0)
print(f"After appendleft: {d}")

# Remove from right
d.pop()
print(f"After pop: {d}")

# Remove from left
d.popleft()
print(f"After popleft: {d}")

# Rotate
d.rotate(1)  # Rotate right
print(f"After rotate(1): {d}")

d.rotate(-2)  # Rotate left
print(f"After rotate(-2): {d}")

7. Performance Comparison#

Different data structures have different performance characteristics.

import time

# Create large collections
size = 100000
my_list = list(range(size))
my_set = set(range(size))

# Membership testing: set is MUCH faster
target = size - 1  # Last element

# List membership
start = time.time()
result = target in my_list  # O(n) - checks every element
list_time = time.time() - start

# Set membership
start = time.time()
result = target in my_set  # O(1) - hash lookup
set_time = time.time() - start

print(f"List membership: {list_time:.6f} seconds")
print(f"Set membership: {set_time:.6f} seconds")
print(f"Set is {list_time/set_time:.0f}x faster!")

Time Complexity Summary#

Operation

List

Set

Dict

Access by index/key

O(1)

N/A

O(1)

Search (membership)

O(n)

O(1)

O(1)

Insert at end

O(1)

O(1)

O(1)

Insert at beginning

O(n)

O(1)

N/A

Delete

O(n)

O(1)

O(1)

Iteration

O(n)

O(n)

O(n)

8. When to Use Which Structure#

Use Lists when:

  • You need ordered data

  • You need to access elements by index

  • You need to maintain duplicates

  • Order matters

Use Tuples when:

  • Data shouldn’t change

  • You want to use as dictionary keys

  • Returning multiple values from functions

  • Memory efficiency matters

Use Sets when:

  • You need unique elements only

  • Fast membership testing is important

  • Set operations (union, intersection, etc.)

  • Order doesn’t matter

Use Dictionaries when:

  • You need key-value mapping

  • Fast lookup by key

  • Representing structured data

  • Counting or grouping items

Exercises#

Exercise 1: List Manipulation#

Create a function that:

  1. Takes a list of numbers

  2. Removes duplicates (preserving order)

  3. Returns a new list with only even numbers, squared

Example: [1, 2, 2, 3, 4, 5, 6, 6][4, 16, 36]

# Your code here

Exercise 2: Dictionary Operations#

Given this list of students:

students = [
    {"name": "Alice", "age": 20, "grade": 95, "major": "CS"},
    {"name": "Bob", "age": 21, "grade": 87, "major": "Math"},
    {"name": "Charlie", "age": 19, "grade": 92, "major": "CS"},
    {"name": "Dave", "age": 20, "grade": 78, "major": "Physics"}
]
  1. Create a dictionary mapping majors to list of student names

  2. Find the average grade for each major

  3. Get names of all CS students with grade >= 90

students = [
    {"name": "Alice", "age": 20, "grade": 95, "major": "CS"},
    {"name": "Bob", "age": 21, "grade": 87, "major": "Math"},
    {"name": "Charlie", "age": 19, "grade": 92, "major": "CS"},
    {"name": "Dave", "age": 20, "grade": 78, "major": "Physics"}
]

# Your code here

Exercise 3: Set Operations#

Given two lists of user IDs:

yesterday = [101, 102, 103, 104, 105, 106]
today = [103, 104, 105, 106, 107, 108, 109]

Find:

  1. Users active both days

  2. Users who churned (active yesterday but not today)

  3. New users (active today but not yesterday)

  4. Total unique users across both days

yesterday = [101, 102, 103, 104, 105, 106]
today = [103, 104, 105, 106, 107, 108, 109]

# Your code here

Exercise 4: Comprehensions#

Use comprehensions to:

  1. Create a list of all even numbers from 1 to 50

  2. Create a dict mapping numbers 1-10 to their cubes

  3. Create a set of all vowels in the sentence “The quick brown fox jumps”

  4. Flatten this nested list: [[1, 2], [3, 4], [5, 6]]

# Your code here

Exercise 5: Word Frequency#

Given a text string:

text = "the quick brown fox jumps over the lazy dog the fox was quick"
  1. Count frequency of each word using Counter

  2. Find the 3 most common words

  3. Create a dict of words grouped by their length using defaultdict

from collections import Counter, defaultdict

text = "the quick brown fox jumps over the lazy dog the fox was quick"

# Your code here

Exercise 6: Data Transformation#

Transform this data structure:

data = [
    ("Alice", "Math", 95),
    ("Bob", "Math", 87),
    ("Alice", "Science", 92),
    ("Bob", "Science", 85),
    ("Charlie", "Math", 90)
]

Into a nested dict:

{
    "Alice": {"Math": 95, "Science": 92},
    "Bob": {"Math": 87, "Science": 85},
    "Charlie": {"Math": 90}
}
data = [
    ("Alice", "Math", 95),
    ("Bob", "Math", 87),
    ("Alice", "Science", 92),
    ("Bob", "Science", 85),
    ("Charlie", "Math", 90)
]

# Your code here

Self-Check Quiz#

1. What’s the difference between lists and tuples?

Answer Lists are mutable (can be changed) while tuples are immutable (cannot be changed after creation).

2. Can a set contain duplicate elements?

Answer No, sets automatically remove duplicates and only store unique elements.

3. What is the time complexity of checking if an item is in a set?

Answer O(1) average case - sets use hash tables for fast lookup.

4. What’s returned by dict.get('key') if the key doesn’t exist?

Answer None (or a default value if provided as second argument).

5. How do you create an empty set?

Answer Use `set()` - NOT `{}` which creates an empty dictionary.

6. What’s the difference between list.append() and list.extend()?

Answer append() adds a single element; extend() adds multiple elements from an iterable.

7. Can you use a list as a dictionary key?

Answer No, dictionary keys must be immutable. Use a tuple instead.

8. What does list[::-1] do?

Answer Reverses the list using slice notation with step -1.

9. What’s the purpose of defaultdict?

Answer Provides default values for missing keys, avoiding KeyError and simplifying code.

10. When should you use a tuple instead of a list?

Answer When data shouldn't change, for dictionary keys, for function returns, or when memory efficiency matters.

Key Takeaways#

Lists are ordered, mutable sequences - use for ordered collections

Tuples are ordered, immutable sequences - use for unchanging data

Sets are unordered unique elements - use for membership testing and uniqueness

Dictionaries map keys to values - use for key-value relationships

Comprehensions provide concise syntax for creating collections

Collections module provides specialized data structures (Counter, defaultdict, deque)

Performance matters - sets and dicts use O(1) lookup; lists use O(n)

Choose the right structure based on your needs (order, mutability, uniqueness)

Shallow vs deep copy - be careful when copying nested structures

Set operations (union, intersection, difference) are powerful for data analysis

Pro Tips#

💡 Use sets for fast membership testing - x in my_set is much faster than x in my_list

💡 Use Counter for frequency counting - simpler than manual dictionary updates

💡 Use get() with default - avoid KeyError: dict.get('key', default)

💡 Comprehensions over loops - more Pythonic and often faster

💡 Tuple unpacking - elegant way to swap variables: a, b = b, a

💡 Use defaultdict when building dicts incrementally

💡 frozenset for dict keys - when you need a set as a dictionary key

💡 deque for queues - O(1) operations on both ends

💡 Named tuples for clarity - more readable than indices: point.x vs point[0]

💡 Generator expressions for large datasets - save memory

Common Mistakes to Avoid#

Modifying list while iterating - causes unexpected behavior ✅ Iterate over a copy or use list comprehension

Using {} for empty set - creates empty dict! ✅ Use set()

Forgetting tuple needs comma - (5) is not a tuple ✅ Use (5,) for single-element tuple

Direct assignment for copying - creates reference! ✅ Use .copy(), [:], or copy.deepcopy()

Using mutable objects as dict keys - lists can’t be keys ✅ Use immutable types like strings, numbers, tuples

Assuming dict order before Python 3.7 - dicts were unordered ✅ Dicts are ordered in Python 3.7+, but don’t rely on it for older code

Next Steps#

You now understand Python’s core data structures! Next lessons:

  1. Classes and OOP - Create your own custom data types

  2. Iterators and Generators - Advanced iteration techniques

  3. Algorithms - Sorting, searching, and problem-solving

  4. File I/O - Reading and writing data to files

  5. Data Analysis with Pandas - Working with tabular data

Practice Projects:

  • Build a contact management system (dicts and lists)

  • Create a word frequency analyzer (Counter)

  • Implement a simple database using nested dicts

  • Design a task queue using deque

Master these data structures - they’re the foundation of all Python programming! 🚀