Mastering Python Logic and Code Fluency
Advanced Data Structures
Beyond Basic Storage
You've learned how to store data in lists and dictionaries. Now it's time to treat them less like simple containers and more like powerful tools. Advanced data structures in Python are all about manipulating data efficiently. The goal is to write cleaner, faster, and more readable code—code that feels natural to the language, or what developers call "Pythonic".
Let's start with a common task: creating a new list based on an existing one. The standard approach involves an empty list, a for loop, and the append() method. It works, but it's not the sharpest tool in the shed.
# The traditional way
numbers = [1, 2, 3, 4, 5]
squares = []
for n in numbers:
squares.append(n * n)
# squares is now [1, 4, 9, 16, 25]
This is clear, but a bit verbose. Python offers a more elegant way.
Elegant Data Creation
List comprehensions let you build a new list in a single, descriptive line. They combine the loop and the append operation into a format that reads almost like plain English: "make a new list of expression for each item in the old list."
# The list comprehension way
numbers = [1, 2, 3, 4, 5]
squares = [n * n for n in numbers]
# squares is again [1, 4, 9, 16, 25]
See the difference? It's not just shorter; it's more expressive. You can even add conditions.
# Square only the even numbers
even_squares = [n * n for n in numbers if n % 2 == 0]
# even_squares is [4, 16]
This same logic applies to dictionaries. Dictionary comprehensions use curly braces {} and a key: value pair to build dictionaries on the fly.
# Dictionary comprehension
stocks = ['AAPL', 'GOOG', 'TSLA']
prices = [170, 140, 260]
stock_prices = {stock: price for stock, price in zip(stocks, prices)}
# stock_prices is {'AAPL': 170, 'GOOG': 140, 'TSLA': 260}
Generators for Big Data
Comprehensions are great, but they have one potential drawback: they create the entire new data structure in memory at once. If you're working with millions of items, that can consume a lot of RAM.
This is where generator expressions come in. They look almost identical to list comprehensions, but they use parentheses () instead of square brackets []. This small change has a huge impact. Instead of building a full list, a generator creates an object that yields one item at a time, on demand. This is a form of lazy evaluation—it doesn't do the work until you ask for the next result.
# A list comprehension (creates a list in memory)
squares_list = [n * n for n in range(1000000)]
# A generator expression (creates an iterator object)
squares_gen = (n * n for n in range(1000000))
# You can loop over it just like a list
for square in squares_gen:
# Process each square one by one without storing them all
pass
When you only need to iterate over a sequence once, like in a for loop or when passing it to a function, a generator is far more memory-efficient.
The Right Tool for the Job
Beyond lists and dictionaries, Python's standard library offers specialised data structures that solve common problems with incredible efficiency. Let's look at a few.
You already know about sets. While they are useful for storing unique items, their primary superpower in data processing is fast deduplication. To find all unique items in a list, you could write a loop, but converting it to a set is vastly faster.
ids = [101, 102, 105, 102, 108, 101]
# The fastest way to get unique IDs
unique_ids = list(set(ids))
# unique_ids is [108, 101, 102, 105] (order not guaranteed)
For more complex needs, we turn to the collections module. It's a goldmine of high-performance container data types. Two of the most useful are defaultdict and namedtuple.
A defaultdict is like a regular dictionary, except you provide a default value for keys that don't exist yet. This completely avoids the common need to check if a key exists before adding to it, which is perfect for grouping or counting items.
from collections import defaultdict
sentence = "the quick brown fox jumps over the lazy dog"
word_counts = defaultdict(int) # Default value for new keys is int() which is 0
for word in sentence.split():
word_counts[word] += 1
# No need for an 'if word in word_counts:' check!
# word_counts['the'] is 2
A namedtuple gives you a way to create simple, immutable object types without the full boilerplate of a class. It lets you access elements by name instead of just by index, making your code much more readable.
from collections import namedtuple
# Define the blueprint for a 'Point'
Point = namedtuple('Point', ['x', 'y'])
p1 = Point(10, 20)
print(p1.x) # Access by name: 10
print(p1[1]) # Access by index: 20
Using the right data structure doesn't just make your code cleaner; it makes it faster. Different structures have different performance characteristics, especially for searching.
| Data Type | Search Operation | Average Time Complexity |
|---|---|---|
list | x in my_list | |
set | x in my_set | |
dict | key in my_dict |
A search in a list requires, on average, checking half the items (). But for sets and dictionaries, the time is constant () regardless of size, thanks to an internal mechanism called . For large datasets, choosing a set over a list for membership testing can be the difference between a program that runs in seconds versus one that takes hours.
Which line of code correctly creates a list of squares for all odd numbers in a list called numbers?
When is it most advantageous to use a generator expression over a list comprehension?
By mastering these advanced structures, you move from simply writing code to engineering efficient, elegant solutions.