What’s one thing you learned? What’s still confusing?
Mini-Project: Word Counter
Build a word-frequency counter — the foundation of every NLP pipeline.
Strings: Methods, f-strings & Text Processing
All string methods, f-string format specs, Unicode/UTF-8, bytes vs str, and regex basics.
Mini-Project: Password Strength Checker
Build a strength meter that scores a password against five rules.
Interactive Labs for This Track
Loop Visualizer
You're a factory robot repeating the same task on an assembly line — watch how loops automate repetitive work
List Slicing
You have a playlist of 50 songs — grab just tracks 10 through 20 with a single slice expression
Sorting Algorithms
You're organizing a library of 10,000 books — which sorting method is fastest?
Ask questions, share insights
obj.x works). Master dicts and you've understood the data structure that powers half of all production Python code.# Three ways to create a dictionary
# Method 1: dict literal
config = {
"learning_rate": 0.001,
"epochs": 50,
"batch_size": 32,
"optimizer": "adam",
}
# Method 2: dict() constructor with keyword arguments
point = dict(x=3, y=7, z=0)
# Method 3: dict.fromkeys() -- create from a sequence of keys with a default value
default_scores = dict.fromkeys(["alice", "bob", "charlie"], 0)
print(default_scores) # {'alice': 0, 'bob': 0, 'charlie': 0}
# Method 4: from a list of (key, value) pairs
pairs = [("name", "Alice"), ("age", 30), ("role", "engineer")]
person = dict(pairs)
print(person) # {'name': 'Alice', 'age': 30, 'role': 'engineer'}Which of these is a VALID dict key?
[] vs .get()config = {"learning_rate": 0.001, "epochs": 50}
# Bracket access -- raises KeyError if key is missing
lr = config["learning_rate"] # 0.001
# config["dropout"] # KeyError: 'dropout'
# .get() -- returns None (or a default) if key is missing
dropout = config.get("dropout") # None (no error)
dropout = config.get("dropout", 0.5) # 0.5 (custom default)
# Check membership before access
if "optimizer" in config:
print(config["optimizer"])
else:
print("No optimizer specified")
# A common pattern: access with fallback
batch_size = config.get("batch_size", 32) # safe -- uses 32 if not setA list has to scan every element until it finds a match — that's O(n). A dict hashes the key and jumps straight to the bucket — that's O(1). For a million items the dict is roughly a million times faster.
What does this print? config = {"lr": 0.001} print(config.get("epochs", 10))
.get() is the DEFAULT value — returned when the key is missing. So config.get("epochs", 10) reads as "give me the value for 'epochs', or 10 if it doesn't exist." This is the canonical safe-access pattern for reading config files and API responses where keys might be missing. Compare with config["epochs"] which would raise KeyError, or config.get("epochs") which would return None.Hit aKeyError? That'sd["missing_key"]on a dict that doesn't have the key. Switch tod.get("missing_key", default)to recover gracefully — full catalog in the error decoder.
d["key"] every millisecond.The hash function must produce the same value for a key every time. If a key could change, its hash would change, and Python would never find it again.
# This is WHY lists cannot be dict keys
# Imagine if you could do this:
# d = {}
# my_list = [1, 2, 3]
# d[my_list] = "value" # stored at bucket: hash([1,2,3]) % size
# my_list.append(4) # now hash([1,2,3,4]) % size != original bucket
# d[my_list] # Python looks in the WRONG bucket -- key "lost"!
# Tuples CAN be keys because they are immutable
locations = {}
locations[(40.7128, -74.0060)] = "New York"
locations[(51.5074, -0.1278)] = "London"
locations[(35.6762, 139.6503)] = "Tokyo"
print(locations[(40.7128, -74.0060)]) # New York -- works!
# Custom class with __hash__
class Color:
"""An immutable RGB color that can be used as a dict key."""
def __init__(self, r: int, g: int, b: int) -> None:
self.r = r
self.g = g
self.b = b
def __hash__(self) -> int:
# Combine the three components into one hash
return hash((self.r, self.g, self.b))
def __eq__(self, other: object) -> bool:
if not isinstance(other, Color):
return NotImplemented
return (self.r, self.g, self.b) == (other.r, other.g, other.b)
def __repr__(self) -> str:
return f"Color({self.r}, {self.g}, {self.b})"
palette = {}
red = Color(255, 0, 0)
blue = Color(0, 0, 255)
palette[red] = "brand red"
palette[blue] = "ocean blue"
# Look up the same color value (different object, same data)
print(palette[Color(255, 0, 0)]) # "brand red" -- works because __hash__ and __eq__ match==), they must have the same hash. Python enforces this contract. If you define __eq__, you must also define __hash__ (or Python sets it to None, making instances unhashable).| Operation | Average Case | Worst Case | Notes |
|---|---|---|---|
d[key] (access) | O(1) | O(n) | Worst case: all keys collide |
d[key] = val (insert) | O(1) | O(n) | O(n) only during rehash |
del d[key] (delete) | O(1) | O(n) | |
key in d (membership) | O(1) | O(n) | Much faster than key in list |
len(d) |
The "worst case O(n)" is a theoretical concern with adversarial inputs. In practice, Python's hash randomization (enabled by default since Python 3.3) makes catastrophic collision attacks infeasible.
inventory = {"apples": 5, "bananas": 12, "oranges": 8}
# .keys(), .values(), .items() -- return live views
print(list(inventory.keys())) # ['apples', 'bananas', 'oranges']
print(list(inventory.values())) # [5, 12, 8]
print(list(inventory.items())) # [('apples', 5), ('bananas', 12), ('oranges', 8)]
# .get(key, default) -- safe access
print(inventory.get("grapes")) # None
print(inventory.get("grapes", 0)) # 0
# .setdefault(key, default) -- get if exists, set and return default if not
# Useful for "get or initialize" patterns
inventory.setdefault("grapes", 0) # inserts "grapes": 0
inventory["grapes"] += 3 # now "grapes": 3
print(inventory.get("grapes")) # 3
# .update() -- merge another dict in-place
extras = {"mangos": 7, "apples": 20} # "apples" will overwrite
inventory.update(extras)
print(inventory["apples"]) # 20 (overwritten)
print(inventory["mangos"]) # 7 (new)
# .pop(key, default) -- remove and return value
removed = inventory.pop("bananas") # 12 (bananas removed)
missing = inventory.pop("durian", -1) # -1 (key not found, no error)
# .popitem() -- remove and return the LAST inserted (key, value) pair
key, val = inventory.popitem()
print(f"Removed last: {key} -> {val}")
# .clear() -- remove all items
temp = {"a": 1, "b": 2}
temp.clear()
print(temp) # {}
# .copy() -- shallow copy
original = {"data": [1, 2, 3], "name": "Alice"}
copy = original.copy()
copy["name"] = "Bob"
print(original["name"]) # "Alice" (unaffected for immutable values)
# Note: copy is shallow -- nested mutable objects ARE shared
copy["data"].append(99)
print(original["data"]) # [1, 2, 3, 99] -- watch out!You run `d = {'a': 1, 'b': 2}` then `d.update({'b': 20, 'c': 3})`. What is `d` now?
What does this print? counts = {} counts["a"] = counts.get("a", 0) + 1 counts["a"] = counts.get("a", 0) + 1 print(counts)
{'a': 2}. This is the canonical "increment a counter" pattern. On the first line, counts.get("a", 0) returns 0 (default) because "a" isn't in the dict yet — so we assign 0 + 1 = 1. On the second line, counts.get("a", 0) returns 1 (the current value), so we assign 1 + 1 = 2. The default in .get() makes this one-liner work for both first-time AND repeat keys, eliminating the if-else: if "a" not in counts: counts["a"] = 0; counts["a"] += 1.Dict comprehensions create dictionaries from any iterable using a concise syntax.
# Basic comprehension: {key_expr: value_expr for item in iterable}
squares = {n: n**2 for n in range(1, 8)}
print(squares) # {1: 1, 2: 4, 3: 9, 4: 16, 5: 25, 6: 36, 7: 49}
# With a condition
even_squares = {n: n**2 for n in range(1, 11) if n % 2 == 0}
print(even_squares) # {2: 4, 4: 16, 6: 36, 8: 64, 10: 100}
# Invert a dict (swap keys and values)
word_index = {"cat": 0, "dog": 1, "bird": 2}
index_word = {v: k for k, v in word_index.items()}
print(index_word) # {0: 'cat', 1: 'dog', 2: 'bird'}
# From two parallel lists
keys = ["name", "age", "city"]
values = ["Alice", 30, "London"]
record = {k: v for k, v in zip(keys, values)}
print(record) # {'name': 'Alice', 'age': 30, 'city': 'London'}
# Normalize string keys to lowercase
raw = {"Name": "Bob", "AGE": 42, "City": "Paris"}
normalized = {k.lower(): v for k, v in raw.items()}
print(normalized) # {'name': 'Bob', 'age': 42, 'city': 'Paris'}Real-world config files, API responses, and ML model specs are almost always nested dicts.
# Building a nested config object
model_config = {
"model": {
"name": "transformer",
"layers": 12,
"hidden_size": 768,
},
"training": {
"optimizer": "adam",
"lr": 0.0001,
"scheduler": {
"type": "cosine",
"warmup_steps": 1000,
},
},
"data": {
"max_length": 512,
"batch_size": 32,
},
}
# Safe nested access with .get()
lr = model_config.get("training", {}).get("lr", 0.001)
warmup = (model_config.get("training", {})
.get("scheduler", {})
.get("warmup_steps", 500))
print(f"LR: {lr}, Warmup: {warmup}") # LR: 0.0001, Warmup: 1000
# This pattern is far safer than chained []
# model_config["training"]["scheduler"]["missing_key"] # KeyError!
# Flattening a nested dict (useful for logging / serialization)
def flatten_dict(d: dict, prefix: str = "", sep: str = ".") -> dict:
"""Recursively flatten a nested dict with dotted keys."""
result = {}
for k, v in d.items():
full_key = f"{prefix}{sep}{k}" if prefix else k
if isinstance(v, dict):
result.update(flatten_dict(v, full_key, sep))
else:
result[full_key] = v
return result
flat = flatten_dict(model_config)
for key, val in flat.items():
print(f" {key}: {val}")
# model.name: transformer
# model.layers: 12
# training.optimizer: adam
# training.scheduler.type: cosine
# ...# Insertion order is preserved
steps = {}
steps["load_data"] = 1
steps["preprocess"] = 2
steps["train_model"] = 3
steps["evaluate"] = 4
steps["save"] = 5
for step, order in steps.items():
print(f" Step {order}: {step}")
# Always prints in insertion order
# Reversing a dict (Python 3.8+)
reversed_steps = dict(reversed(steps.items()))
print(list(reversed_steps.keys()))
# ['save', 'evaluate', 'train_model', 'preprocess', 'load_data']collections.OrderedDict for ordering -- that is now rarely necessary.| and |= for merging dicts -- cleaner than .update() when you want a new dict.defaults = {"timeout": 30, "retries": 3, "debug": False}
overrides = {"timeout": 60, "debug": True}
# | creates a NEW merged dict (right side wins on conflicts)
merged = defaults | overrides
print(merged) # {'timeout': 60, 'retries': 3, 'debug': True}
# Original dicts are unchanged
print(defaults["timeout"]) # 30
# |= updates in-place (like .update())
config = {"env": "prod", "port": 8080}
extra = {"port": 9090, "workers": 4}
config |= extra
print(config) # {'env': 'prod', 'port': 9090, 'workers': 4}
# Useful for layered config (CLI args override env vars override defaults)
base_config = {"lr": 0.001, "epochs": 10, "batch_size": 32}
env_config = {"lr": 0.0005}
cli_config = {"epochs": 20}
final = base_config | env_config | cli_config
print(final) # {'lr': 0.0005, 'epochs': 20, 'batch_size': 32}Watch how hash tables work internally. Insert keys, see hash computation, observe bucket placement and collision resolution:
Tests · Build a word frequency counter and safe nested config accessor!
.get() for safe access -- especially when reading external data, API responses, or config files where keys may be absentWhy can't you use a list as a dict key?
This program looks up player scores. One player ('dan') isn't in the dict, so the program crashes with a KeyError. Fix it so missing players show as 0.
alice 92 bob 78 cara 85 dan 0
Write a function `word_count(text)` that takes a string and returns a dict mapping each lowercase word to the number of times it appears. Split on whitespace and ignore case.
>>> word_count("the cat and the dog and the bird")
{'the': 3, 'cat': 1, 'and': 2, 'dog': 1, 'bird': 1}def word_count(text):
# TODO: return a dict of word -> count
pass
print(word_count("the cat and the dog and the bird"))| O(1) |
| O(1) |
| Stored as an attribute |
Iteration (for k in d) | O(n) | O(n) | Must visit every entry |
.keys(), .values(), .items() | O(1) | O(1) | Return view objects, not copies |