Handbooks / Python / Chapter 2
Data Structures
42 pages · ~64 min✓ Reviewed
Builds on Functions & Arguments. Next up: Classes & Objects.
Part 1 · Data Structures
Choosing the Right Container for the Job
Every Python program is, at heart, a program that stores data and does something with it. A data structure is the way that data is organized in memory, and the choice decides how fast you can find, add, change and remove items. The same task can take a blink with one structure and minutes with another, even when the code looks almost identical.
This chapter walks through Python's built-in structures one at a time: lists, tuples, sets and dictionaries, then the hashing idea that powers sets and dictionaries, and finally stacks and queues built from a list and from collections.deque. For each one you will see what it is, which methods it offers, how it behaves inside, and what its operations cost in Big-O terms, so you can tell an O(1) step from an O(n) one.
By the end you will be able to pick the right structure for a problem and explain why. You will know when a set beats a list for membership checks, why a list makes a poor queue, why a list can't be a dictionary key, and how to answer the common interview questions that ask about exactly these trade-offs.
You only need Python 3.7 or newer (dictionary ordering is guaranteed from 3.7) and basic syntax: variables, loops, functions and print. Open a Python prompt or a scratch file and try each example as you read. No packages are required, because everything here comes from the standard library.
Part 2 · Data Structures Overview
What a data structure is and why it matters
A data structure is a way of organizing and storing data in computer memory so that we can access it, modify it and process it efficiently. The same pile of values can be cheap or painful to work with depending on how it is arranged, and the arrangement is exactly what a data structure decides.
Think of a kitchen. You could throw every utensil into one box, but you would dig through it for every spoon. Hang the ladles on hooks and put cutlery in a tray, and each lookup becomes quick. Python's built-in structures are those trays and hooks, each tuned for a different kind of lookup.
Why we bother
- Organized storage: related values live together in a shape you can reason about.
- Efficient operations: adding, finding and removing items cost less work.
- Lower time and space complexity: fewer steps and less memory for the same job.
- Optimized solutions: a well-chosen structure often turns a slow solution into a fast one without changing the idea behind it.
Here are the four built-in structures this handbook covers, each holding a small piece of data that suits it. Notice how the shape of each literal already hints at its job.
cart = ['pen', 'book', 'pen'] point = (4, 7) usernames = {'abhi', 'sam', 'abhi'} user = {1: 'Abhi', 2: 'Sam'} print(cart) print(point[0]) print(len(usernames)) print(user[2])
One value of each built-in structure
['pen', 'book', 'pen'] 4 2 Sam
The cart kept its duplicate 'pen', the tuple was read by position, the set quietly dropped the repeated username so only two remain, and the dictionary answered a question by key rather than by position.
The four built-in structures side by side
Each structure answers four questions: does it keep order, can you change it, does it allow repeats, and can you reach an item by index? The table puts the answers next to each other so the differences stand out.
| Type | Example | Ordered | Mutable | Duplicates | Index access | Best for |
|---|---|---|---|---|---|---|
| List | [1,2,3] | ✓ | ✓ | Yes | ✓ | Storing a collection of items, repeats allowed |
| Tuple | (1,2,3) | ✓ | ✗ | Yes | ✓ | A fixed collection, lighter and faster than a list |
| Set | {1,2,3} | ✗ (see note 1) | ✓ | No, items are unique | ✗ | Unique items and fast lookup, no indexing |
| Dictionary | {'a':1,'b':2} | ✓ (see note 2) | ✓ | Keys: no, values: yes | No index; use keys | Key-value mapping with very fast lookup |
Two footnotes explain the starred cells. Note 1: a set is unordered, so it has no concept of an index and nothing like s[0] exists. Note 2: a dictionary preserves insertion order since Python 3.7+, so it iterates in the order you added the keys.
The code below checks both footnotes. The dictionary lists its keys in the order they were inserted, not alphabetically, while two sets with the same members compare equal no matter how you wrote them.
d = {'b': 1, 'a': 2}
print(list(d))
print({1, 2, 3} == {3, 2, 1})Insertion order for dicts, no order for sets
['b', 'a'] True
A memory trick for each one
If the table is too much to hold in your head, boil each structure down to three words. These one-liners are worth memorizing for interviews.
Ordered + Mutable + Duplicates
[10,20,30]
Ordered + Immutable + Duplicates
(10,20,30)
Unordered + Mutable + Unique
{10,20,30}
Key-Value + Ordered + Unique keys
{'a':1,'b':2}
Expecting a set to keep your order or to support s[0]. Sets have no positions at all. If order matters, use a list or a tuple instead. Also remember that empty braces {} create an empty dictionary, not an empty set.
Mutable versus immutable
Python values fall into two camps. A mutable object can be changed in place after it is created, while an immutable object can never change, so any edit produces a new object instead. This single split explains why tuples are safe to share and why only some objects can serve as set items or dictionary keys later in the handbook.
| Camp | Meaning | Types |
|---|---|---|
| Mutable | Can be changed after creation | list, set, dict |
| Immutable | Cannot be changed after creation | tuple, str, int, float, bool, frozenset |
The example shows both camps in action. The list, set and dictionary all accept edits, but trying to assign into a tuple raises a TypeError, and a string method such as upper() hands back a new string while the original stays as it was.
cart.append('lamp') print(cart) usernames.add('lee') print(len(usernames)) user[3] = 'Lee' print(user) try: point[0] = 9 except TypeError as e: print(type(e).__name__) word = 'abc' word.upper() print(word)
Uses cart, point, usernames and user from the first page
['pen', 'book', 'pen', 'lamp'] 3 {1: 'Abhi', 2: 'Sam', 3: 'Lee'} TypeError abc
Mutable means editable in place. Immutable means every apparent change builds a new object. A frozenset is the immutable twin of a set.
Choosing the right structure
Knowing the properties is only half the job; the skill is matching them to the problem. Start from what you need to do with the data, then pick the structure that makes those operations natural and cheap.
| Structure | Use it when | Example |
|---|---|---|
| List | You need an ordered collection that you may modify | Items in a shopping cart |
| Tuple | The data is fixed and should not change | Coordinates (x, y) |
| Set | You need unique items and fast membership checks | Unique usernames |
| Dictionary | You need to map a key to a value | User info, id to name |
When you are unsure, walk through these questions in order. The first question that gets a yes decides the structure, and the list is the default when nothing special is required.
Always choose the right data structure. It improves both the readability of your code and its efficiency, and saying why you chose it is half the answer.
Right Data Structure = Efficient Code.
Part 3 · Python Lists
What a list is and how to read its items
A list is Python's everyday container: an ordered collection that you write between square brackets. Order means every item has a fixed position, so you can ask for "the second item" and always get the same one. Items can be of any data type, the list is mutable (you can change it after creating it), and duplicates are allowed.
| Property | What it means for a list |
|---|---|
| Ordered | Items keep the position you gave them |
| Any type | Numbers, strings, booleans and even other lists can sit side by side |
| Mutable | You can replace, add and remove items in place |
| Duplicates allowed | The same value may appear as many times as you like |
The list below mixes an integer, a string, a float and a boolean. The code reads one item, measures the list with len(), overwrites the first item to show that lists are mutable, and prints a list made of the same value three times.
my_list = [10, 'abhi', 3.14, True] print(my_list[1]) print(len(my_list)) my_list[0] = 99 print(my_list) print([7, 7, 7])
abhi 4 [99, 'abhi', 3.14, True] [7, 7, 7]
Indexing: two ways to count
Every item has a positive index that counts from the front, starting at 0, and a negative index that counts from the back, starting at -1. The negative form is handy because -1 is always the last item, no matter how long the list is.
Numbers under the cells are positive indexes 0 to 4; the labels are the matching negative indexes -5 to -1
my_list = [10, 20, 30, 40, 50] print(my_list[0]) print(my_list[2]) print(my_list[-1])
First item, middle item, last item
10 30 50
A list of 5 items has valid indexes 0 to 4 (and -5 to -1). Asking for my_list[5] raises an IndexError, because counting starts at 0, not 1.
Slicing: taking a piece of a list
Slicing copies a stretch of a list into a new list. The syntax is list[start : stop : step]. You begin at start, walk toward stop and take every step-th item. The stop index is excluded, so [1:4] gives positions 1, 2 and 3. Leave a part out and Python fills in a default: start at the beginning, stop at the end, step of 1. A negative step walks backward, which is why [::-1] reverses a list.
lst = [10, 20, 30, 40, 50] print(lst[1:4]) print(lst[:3]) print(lst[2:]) print(lst[::2]) print(lst[::-1])
[20, 30, 40] [10, 20, 30] [30, 40, 50] [10, 30, 50] [50, 40, 30, 20, 10]
| Slice | Reads as | Result on [10, 20, 30, 40, 50] |
|---|---|---|
lst[1:4] | From index 1 up to, not including, 4 | [20, 30, 40] |
lst[:3] | From the start up to index 3 | [10, 20, 30] |
lst[2:] | From index 2 to the end | [30, 40, 50] |
lst[::2] | Every second item | [10, 30, 50] |
lst[::-1] | Everything, stepping backward | [50, 40, 30, 20, 10] |
Expecting the stop index to be included. lst[1:4] stops before index 4, so the value 50 never shows up. Also remember that a slice is a new list: changing it does not change the original.
List methods: adding, removing and reordering
Because lists are mutable, they come with methods that change them in place. Start with the ones that add items. append(x) puts one item at the end, extend(iterable) adds every item of another collection at the end, and insert(i, x) places x at index i and shifts the later items to the right.
| Method | What it does | Example on lst = [10, 20, 30] | Result |
|---|---|---|---|
append(x) | Add one item at the end | lst.append(60) | [10, 20, 30, 60] |
extend(it) | Add many items at the end | lst.extend([70, 80]) | [10, 20, 30, 70, 80] |
insert(i, x) | Put x at index i | lst.insert(1, 15) | [10, 15, 20, 30] |
remove(x) | Drop the first x found | lst.remove(20) | [10, 30] |
pop([i]) | Remove and return the item at i (default: last) | lst.pop() then lst.pop(0) | returns 30, then 10 |
Each row above starts from a fresh [10, 20, 30]. The program below walks through adding and then removing on one list, so you can see each step change it. Note that pop() is the only one here that gives you something back: the removed value. del my_list[0] removes by position and returns nothing.
my_list = [10, 20, 30] my_list.append(40) print(my_list) my_list = [10, 20, 30] my_list.insert(1, 15) print(my_list) my_list.extend([50, 60]) print(my_list) my_list = [10, 15, 20, 30] my_list.remove(15) print(my_list) print(my_list.pop()) print(my_list) del my_list[0] print(my_list)
[10, 20, 30, 40] [10, 15, 20, 30] [10, 15, 20, 30, 50, 60] [10, 20, 30] 30 [10, 20] [20]
There are several ways to take things out, and the right one depends on what you know about the item and whether you need it back.
Searching, counting and ordering
index(x) tells you where the first x sits, count(x) tells you how many times it appears, sort() puts the items in ascending order and reverse() flips their order. Both sort() and reverse() work in place: they change the list itself and return None. clear() empties the list completely.
lst = [10, 20, 30, 10] print(lst.count(10)) print(lst.index(30)) lst.sort() print(lst) lst.reverse() print(lst) print(lst.sort()) lst.clear() print(lst)
2 2 [10, 10, 20, 30] [30, 20, 10, 10] None []
Writing lst = lst.sort(). Since sort() returns None, you replace your list with None and lose the data. Call lst.sort() on its own line. Also, remove(x) and index(x) raise a ValueError when x is not in the list.
What each list operation costs
Knowing the methods is half the job; the other half is knowing what each one costs as the list grows. A list keeps its items side by side, so jumping to an index is a direct calculation, while anything that has to look at or move many items grows with the list length n.
| Operation | Time | Why |
|---|---|---|
| Access by index | O(1) | Jump straight to the position |
| Search by value | O(n) | May have to check every item |
| Insert at the end | O(1) amortized | Usually a free slot is waiting |
| Insert at any position | O(n) | Later items shift over |
| Delete at the end | O(1) | Nothing needs to move |
| Delete at any position | O(n) | Later items shift back |
The pattern behind the O(n) rows is shifting. Putting a value at the front forces every existing item to move, as the steps below show.
- 1Call insert(0, x)list holds n items
- 2Shift every item rightn items move one slot
- 3Write x at index 0one assignment
- 4Cost: O(n)grows with the list
Here is a small program that combines several ideas: it appends, sorts in place, prints the whole list, then slices out the middle.
lst = [5, 1, 8, 3] lst.append(10) lst.sort() print(lst) print(lst[1:4])
[1, 3, 5, 8, 10] [3, 5, 8]
Lists are versatile and show up almost everywhere, so interviewers expect you to know the methods cold. When you pick one, say its cost out loud: append and pop at the end are cheap, while inserting or deleting anywhere else is O(n).
Part 4 · List Internals & Complexity
What a list really is
A Python list looks like a row of values, but underneath it is a dynamic array. The list does not hold your objects directly. It holds references (addresses) that point to the objects, and those references sit side by side in one contiguous block of memory. That is why jumping to my_list[3] is instant: Python takes the start of the block, moves over three slots and follows the reference it finds there.
The block is not fixed. The array can grow and shrink as you add and remove items. When the list outgrows its block, Python creates a new, larger array and copies the references across. Because of this, the list usually owns more slots than it uses. Its capacity is greater than or equal to its size.
Take my_list = [10, 20, 30, 40]. Four slots hold references to the four numbers. The block has room for eight, so the other four slots are empty and waiting (the picture shows them as None).
Used = 4, Capacity = 8
| Meaning | In the picture | |
|---|---|---|
| Size (used) | Slots that hold a real item. This is what len() reports. | 4 (slots 0 to 3) |
| Capacity | Slots the current array can hold before it must grow. | 8 (slots 0 to 7) |
| Spare room | Capacity minus size. | 4 free slots |
Capacity is usually larger than size. That extra space is what makes append cheap, because most appends only fill a slot that is already there.
Resizing and why append is amortized O(1)
When a list runs out of space, Python allocates a new array with more capacity, copies every reference into it and releases the old block. Here is a list that starts full, with capacity 4 and size 4, and keeps receiving items.
- 1cap 4 / size 4the array is full
- 2append(50)cap 8 / size 5, resize and copy
- 3append(60)size 6, free slot
- 4append(70)size 7, free slot
- 5append(80)size 8, fills the array
- 6append(90)next one would resize again
Note that the trace above is the idea, not the exact numbers. Textbooks say the capacity roughly doubles. Real CPython grows by about 1.125x plus a small constant, so the jumps are gentler. The shape of the argument is the same either way: each resize buys a run of cheap appends.
Now the cost. Most of the time append just drops the item into a free slot, which is O(1). Occasionally the array is full and a resize copies every element, which is O(n). Those expensive appends are rare, and the gaps between them get longer as the list grows. Over a large number of appends, the average cost per append is O(1).
This is amortized analysis: spread the rare big cost over all the appends. If n appends cost O(n) in total, then the amortized cost per append is O(1). The program below counts the work for 1000 appends, using a simple doubling rule starting at capacity 4.
capacity, size = 4, 0 copies, resizes = 0, 0 for _ in range(1000): if size == capacity: copies += size resizes += 1 capacity *= 2 size += 1 total = 1000 + copies print('appends:', 1000) print('resizes:', resizes) print('items copied:', copies) print('total work:', total) print('work per append:', total / 1000)
Counting the cost of 1000 appends with doubling
appends: 1000 resizes: 8 items copied: 1020 total work: 2020 work per append: 2.02
Only 8 resizes happened, and the copying added up to about one extra unit of work per append. Each append costs roughly 2 units on average, no matter how long the list gets. That flat average is what O(1) amortized means.
Complexity table and watching capacity grow
The array design decides what is cheap and what is not. Anything that goes straight to a slot, or works at the end, is quick. Anything that has to slide the other items along, or look at each one, grows with the length of the list.
| Operation | Time | Why |
|---|---|---|
| Access by index | O(1) | Jump straight to the slot |
| Search in an unsorted list | O(n) | May have to check every item |
| append (end) | O(1) amortized | Free slot, occasional resize |
| pop (end) | O(1) | Nothing needs to shift |
| Operation | Time | Why |
|---|---|---|
| Insert at the beginning | O(n) | Every item moves one slot right |
| Insert at any position | O(n) | Items after it shift right |
| Delete at the beginning | O(n) | Every item moves one slot left |
| Delete at any position | O(n) | Items after it shift left |
| Slice (copy) | O(k) | k is the number of elements copied |
You can watch the spare room appear. sys.getsizeof reports the bytes the list object uses, and that figure includes the whole reference array, not just the used part. So it stays flat while appends fill free slots and jumps when a resize happens.
import sys lst = [] print(0, sys.getsizeof(lst)) for i in range(1, 21): lst.append(i) print(len(lst), sys.getsizeof(lst))
Sizes shown are for 64-bit CPython 3.11
0 56 1 88 2 88 3 88 4 88 5 120 6 120 7 120 8 120 9 184 10 184 11 184 12 184 13 184 14 184 15 184 16 184 17 248 18 248 19 248 20 248
The empty list is 56 bytes. The first append jumps to 88, which means room for 4 references (each takes 8 bytes). The size then stays put until the 5th item, and so on. The jumps are 88, 120, 184 and 248 bytes, so capacity went 4, 8, 16 and 24. Early on it doubles, then the steps get smaller in proportion, which is the 1.125x growth showing up.
lst.insert(0, x) and lst.pop(0) look as harmless as append, but each one shifts every other item, so they are O(n). In a loop over a big list they turn into O(n squared).
In an interview, say amortized O(1). A single append can cost O(n) when it triggers a resize. Only the average over many appends is constant.
Part 5 · Python Tuples
What a Tuple Is and How to Build One
A tuple is an ordered collection of items that cannot be changed after it is created. Like a list, it keeps its items in a fixed order and allows duplicates, so (1, 2, 2) is perfectly valid. Unlike a list, it is a fixed-size sequence: nothing can be added, removed or replaced once it exists.
Because Python never has to leave spare room for growth, a tuple is also more memory efficient than a list holding the same items. Think of a tuple as a sealed envelope: you can read what is inside as often as you like, but you cannot swap the contents.
| Property | Tuple |
|---|---|
| Ordered | Yes, items keep their position |
| Mutable | No, fixed after creation |
| Duplicates | Allowed |
| Size | Fixed |
| Memory | Lighter than a list |
There are several ways to create a tuple. The usual form uses parentheses, but the parentheses are optional because the commas are what actually build the tuple. You can also convert any list with tuple(), write () for an empty tuple, and add a trailing comma to make a single-element tuple.
That last case is the classic trap. (10) is not a tuple: the parentheses are just grouping, so Python sees the integer 10. Only (10,), with the trailing comma, is a one-element tuple. The example below builds all five forms and then checks the types.
t1 = (1, 2, 3, 4) # parentheses t2 = 1, 2, 3, 4 # no parentheses t3 = tuple([1, 2, 3]) # from a list t4 = () # empty t5 = (10,) # one element print(t1, t2, t3, t4, t5) n = (10) print(type(n), type(t5))
(1, 2, 3, 4) (1, 2, 3, 4) (1, 2, 3) () (10,) <class 'int'> <class 'tuple'>
Writing t = (10) gives you an integer, not a tuple. Later code such as len(t) or t[0] then fails with a confusing error. Always write (10,) for a single-element tuple.
Indexing, Slicing and Immutability
Tuples are indexed exactly like lists. Positions count from 0 at the left, and negative positions count from the right starting at -1. For t = (10, 20, 30, 40, 50) the indices run 0 to 4, or equivalently -5 to -1.
t[1:4] picks the highlighted items; the stop index 4 is excluded
Slicing uses start:stop:step, and the stop index is excluded. A step of -1 walks backwards, which gives you a reversed copy. Reading and slicing never modify the original, and a slice of a tuple is itself a new tuple.
t = (10, 20, 30, 40, 50) print(t[0]) print(t[-1]) print(t[1:4]) print(t[::-1])
10 50 (20, 30, 40) (50, 40, 30, 20, 10)
Immutability means that once the tuple exists you cannot change, add or remove elements. Every operation that tries to modify it raises an error. The exact error depends on the operation: assigning to or deleting an index is a TypeError, while calling a list method that tuples simply do not have is an AttributeError.
t = (1, 2, 3) try: t[0] = 100 except TypeError as e: print('TypeError:', e) try: t.append(4) except AttributeError as e: print('AttributeError:', e) try: del t[1] except TypeError as e: print('TypeError:', e)
TypeError: 'tuple' object does not support item assignment AttributeError: 'tuple' object has no attribute 'append' TypeError: 'tuple' object doesn't support item deletion
Indexing and slicing work just as they do on a list. Anything that would change the tuple in place is rejected, and if you need a different tuple you must build a new one.
Packing, Unpacking, Swapping and Methods
Packing means putting several values into one tuple just by separating them with commas. Unpacking is the reverse: you assign the tuple's items to separate variables in one statement, matched by position. The number of names on the left must match the number of items.
Unpacking also gives Python its tidy swap. In x, y = y, x, the right side is evaluated first and packed into a temporary tuple, and only then are the items assigned to the names on the left. No third temporary variable is needed.
- 1Evaluate the right sidey and x are read, giving 10 and 5
- 2Pack a tuple(10, 5) is built
- 3Unpack to the leftx gets 10, y gets 5
t = 10, 20, 30 # packing print(t) a, b, c = t # unpacking print(a, b, c) x, y = 5, 10 x, y = y, x # swap print(x, y)
(10, 20, 30) 10 20 30 10 5
Since a tuple cannot change, it has only two methods. count(x) tells you how many times a value appears, and index(x) returns the position of its first occurrence. There is no append, remove, sort or anything else that would modify the contents.
| Method | What it returns | Example |
|---|---|---|
| count(x) | How many times x appears | t.count(10) gives 2 |
| index(x) | Index of the first x | t.index(30) gives 2 |
t = (10, 20, 30, 10) print(t.count(10)) print(t.index(30))
2 2
Tuple vs List and When to Choose a Tuple
Tuples and lists look alike, so the choice comes down to whether the data should change. The table below sets them side by side. The tuple trades flexibility for safety, a smaller footprint and a little extra speed.
| Feature | Tuple | List |
|---|---|---|
| Mutable | No (immutable) | Yes (mutable) |
| Syntax | ( ) | [ ] |
| Memory | Less | More |
| Speed | Faster | Slower |
| Methods | Few (count, index) | Many |
| Use case | Fixed, safe data | Dynamic data |
Reach for a tuple in four situations: the data should never change, you need a dictionary key, a function must return several values, or you want better performance than a list gives you. Dictionary keys must be hashable, and a list is not, because its contents can change. A tuple of hashable items can be a key, which makes it ideal for things like coordinates.
The example uses a coordinate pair as a dictionary key, shows that a list is rejected in the same position, and then returns two values from one function. Returning min(nums), max(nums) actually packs a tuple, and the caller unpacks it.
visits = {(12, 5): 'cafe'}
print(visits[(12, 5)])
try:
bad = {[1, 2]: 'cafe'}
except TypeError as e:
print('TypeError:', e)
def min_max(nums):
return min(nums), max(nums)
lo, hi = min_max([4, 9, 2])
print(lo, hi)cafe TypeError: unhashable type: 'list' 2 9
If asked why a tuple can be a dictionary key and a list cannot, answer that the tuple is immutable and therefore hashable, while a list can change and so cannot keep a stable hash.
If items need to be added, removed or updated, a tuple forces you to rebuild it every time. Use a list for dynamic data and keep tuples for values that are meant to stay fixed.
Part 6 · Python Sets
What a set is and what it can hold
A set is an unordered collection of unique items. If you add the same value twice, the set keeps one copy and says nothing. A set has no positions, so you cannot ask for the item at index 0. What you can ask is whether a value is in the set, and that check is very fast.
Two more facts shape everything else on this page. A set is mutable, so you can add and remove items after you create it. But every item inside it must be hashable, which in practice means immutable.
| Property | Set |
|---|---|
| Ordered | No, there is no index |
| Duplicates | Not allowed, they collapse into one |
| Mutable | Yes, the set itself can change |
| Elements must be | Hashable (immutable) |
| Membership test | Very fast, O(1) on average |
Curly braces create a set. Here the literal repeats 20 and 30, and the duplicates disappear on their own. The final line prints the set directly. On CPython this particular set happens to come out as {40, 10, 20, 30}, but treat that order as an accident.
s = {10, 20, 20, 30, 30, 40}
print(type(s))
print(len(s))
print(sorted(s))
print(s)Duplicates vanish as the set is built
<class 'set'> 4 [10, 20, 30, 40] {40, 10, 20, 30}
Only hashable items go in
Numbers, strings, tuples and frozensets are immutable, so they can live in a set. Lists, dicts and other sets can change after the fact, so Python refuses them and raises a TypeError.
| Allowed (hashable) | Rejected (unhashable) |
|---|---|
| int, float | list |
| str | dict |
| tuple of hashables | set |
| frozenset |
ok = {1, 2.5, 'hi', (1, 2), frozenset({3})}
print(len(ok))
try:
bad = {[1, 2]}
except TypeError as e:
print(e)5 unhashable type: 'list'
{} is an empty dict, not an empty set. Write set() when you need an empty set.
Adding, removing and sizing
A set is mutable, so it comes with methods that change it in place. Use add for one item and update for several at once. To take items out you have three choices that differ in how they treat a missing value, plus clear to empty the whole set.
| Operation | Call | Result |
|---|---|---|
| Add one | s.add(50) | {10, 20, 30, 40, 50} |
| Add many | s.update([5, 6]) | {5, 6, 10, 20, 30, 40} |
| Remove | s.remove(30) | drops 30, raises KeyError if absent |
| Discard | s.discard(100) | no error if the element is missing |
| Pop | s.pop() | removes and returns an arbitrary element |
| Clear | s.clear() | set() |
| Size | len(s) | 5 for a set of five items |
The walkthrough below applies the calls in order. Printing sorted(s) keeps the output stable, because the set itself has no order to show. pop hands back an arbitrary element, so the code checks only that the returned value is gone.
s = {10, 20, 30, 40}
s.add(50)
print(sorted(s))
s.update([5, 6])
print(sorted(s))
s.remove(30)
s.discard(100)
print(sorted(s))
print(len(s))
gone = s.pop()
print(gone in s, len(s))
s.clear()
print(s)[10, 20, 30, 40, 50] [5, 6, 10, 20, 30, 40, 50] [5, 6, 10, 20, 40, 50] 6 False 5 set()
s.remove(x) raises KeyError when x is not in the set. If the element may be missing, use discard. Also, pop() takes no argument and gives no choice of element, so never use it to get a particular item.
Set operations and fast membership
Sets follow the maths you may remember from school. Four operators combine two sets into a new one without changing either. The examples use A = {1, 2, 3} and B = {3, 4, 5}, which share exactly one element, 3.
| Operation | Operator | Meaning | Result |
|---|---|---|---|
| Union (∪) | A | B | every unique element from both | {1, 2, 3, 4, 5} |
| Intersection (∩) | A & B | elements common to both | {3} |
| Difference | A - B | in A but not in B | {1, 2} |
| Symmetric difference | A ^ B | in exactly one of the two | {1, 2, 4, 5} |
A = {1, 2, 3}
B = {3, 4, 5}
print(A | B)
print(A & B)
print(A - B)
print(A ^ B){1, 2, 3, 4, 5}
{3}
{1, 2}
{1, 2, 4, 5}Difference is the only one of the four where the order of the operands matters. A - B and B - A give different answers, while union, intersection and symmetric difference give the same result either way round.
Membership testing
The in and not in operators ask whether a value is present and return a bool. This is the job sets do best.
s = {10, 20, 30, 40}
print(20 in s)
print(999 in s)
print(100 not in s)True False True
The speed comes from hashing. A set does not scan its items one by one. It turns the value into a number with a hash function, uses that number to pick a slot in an internal table, and looks only there.
Membership costs about the same whether the set holds ten items or ten million, because the check goes straight to a slot. That is why it is O(1) on average, with no scan.
Order, cost, and when to reach for a set
Because a set has no order, two sets are equal when they hold the same elements, however you wrote them. Equality never looks at position.
s1 = {1, 2, 3, 4}
s2 = {4, 3, 2, 1}
print(s1 == s2)TrueThe order you see when printing or looping over a set can change between runs, versions and contents. If the order matters, keep the data in a list or a tuple. If you need a stable view of a set, use sorted(s).
Time complexity
| Operation | Methods | Average cost |
|---|---|---|
| Add | add, update | O(1) |
| Remove | remove, discard | O(1) |
| Membership | in, not in | O(1) |
| Pop | pop | O(1) |
| Combine | union, intersection, difference | O(n) |
The single-item operations are O(1) on average, because hashing finds the slot directly. The combining operations must look at the elements of the sets involved, so they grow with the size of the data, which is O(n). The word average matters here. If many items collide on the same slot, a set can degrade towards O(n), though that is rare.
When a set is the right choice
- You need unique items, for example distinct usernames.
- You need fast membership checks on a lot of data.
- Order does not matter to you.
- You want union, intersection, difference or symmetric difference.
Why is a list a poor choice for membership testing on a large dataset?
- A list is searched element by element, so a lookup is O(n).
- A set hashes straight to the slot, so a lookup is O(1) on average.
- With a million items, that gap decides the runtime.
999999 in big_list # O(n) scan 999999 in big_set # O(1) average
Part 7 · Python Dictionaries
What a Dictionary Is
A dictionary stores data as key-value pairs. You don't ask for item number 3. You ask for the value filed under a key such as 'age', and Python hands it back. Under the hood a dict is a hash table, which is why that lookup is so quick.
| Part | Rule | Why |
|---|---|---|
| Keys | Unique and hashable (immutable types such as str, int, tuple) | The hash table must be able to turn a key into a stable number |
| Values | Any type, and they may repeat | Values are only stored, never hashed |
| The dict itself | Mutable | You can add, change and delete pairs in place |
Values in one dict don't have to share a type. Here one record holds a string, an int, a list and a boolean, and you read each one through its key.
d = {'name': 'Abhi', 'age': 23, 'skills': ['Python', 'SQL', 'Git'], 'is_employed': True}
print(d['name'])
print(d['skills'][1])
print(len(d))Mixed value types in one dictionary
Abhi
SQL
4The key rules show up when you break them. Two keys may point at the same value, but a list can't be a key because it is mutable and therefore unhashable.
scores = {'a': 1, 'b': 1}
print(scores)
try:
bad = {[1, 2]: 'x'}
except TypeError as e:
print(e)Duplicate values are fine, mutable keys are not
{'a': 1, 'b': 1}
unhashable type: 'list'Ordered or unordered?
A dict is often described as unordered, and that starred word needs care. A dict has no index positions, so you can't ask for the third item. Since Python 3.7 it does remember the order in which keys were inserted, and loops and printing follow that order. Older versions made no such promise.
order = {}
order['z'] = 1
order['a'] = 2
order['m'] = 3
print(list(order))Insertion order is kept, not alphabetical order
['z', 'a', 'm']
A dict maps unique, hashable keys to any values, it is mutable, and on Python 3.7+ it remembers insertion order.
How a Dictionary Finds Things
A dict doesn't search through its pairs one by one. It passes the key to a hash function, which returns an integer. That integer is reduced to an index in the table, and the key-value pair is stored in that slot. To look the key up later, Python repeats the same steps and goes straight to the slot.
Here is that walkthrough for a table with 8 slots. The hash value 5721 is illustrative. Real string hashes change from one run of Python to the next, but the steps are always the same.
| Step | What happens | Result |
|---|---|---|
| 1 | Hash the key | hash('age') = 5721 |
| 2 | Reduce to a table index | 5721 % 8 = 1 |
| 3 | Store or read that slot | Slot 1 holds 'age': 23 |
Because each step is a single calculation, lookup, insert and delete take O(1) on average. The cost doesn't grow with the number of pairs in the dict.
When two keys want the same slot
A table with a limited number of slots can't give every key its own. Two different keys can land on the same index, which is called a collision. Python resolves collisions with open addressing: if the slot is taken, it probes other slots in the same table by a fixed pattern until it finds a free one. The toy hash below, which sums character codes, shows how easily collisions happen.
def slot(key, size=8): return sum(ord(c) for c in key) % size for k in ['age', 'name', 'ega']: print(k, slot(k))
A toy hash, not Python's real one
age 5 name 1 ega 5
'age' and 'ega' are different keys that both map to slot 5. A real dict would keep both and probe onward for the second one.
The O(1) speed comes from hashing. If many keys collide, Python has to probe through many slots, and in the worst case a lookup degrades to O(n).
Everyday Operations and Methods
Start with the basic operations. Square brackets add, read and update a key, del removes it, in tests membership, and a plain for loop walks the keys.
d = {'a': 1, 'b': 2}
d['c'] = 3 # add
print(d['a']) # access
d['b'] = 20 # update
del d['a'] # delete
print('c' in d) # membership
print(len(d)) # number of pairs
for k in d:
print(k, d[k])The basic operations on one small dict
1 True 2 b 20 c 3
Methods cover the cases where brackets are awkward. First, the ones that read data. get is the safe way to look up a key that may be missing, because it returns a default instead of raising KeyError.
| Method | What it does | Example |
|---|---|---|
| get(key, default) | Value for the key, or the default if the key is missing | d.get('city', 'NA') |
| keys() | All the keys | d.keys() |
| values() | All the values | d.values() |
| items() | Key-value pairs as tuples | d.items() |
Next, the methods that change the dict. update merges another dict in, and setdefault inserts a key only when it is missing.
| Method | What it does | Example |
|---|---|---|
| update(other) | Merges the pairs of another dict, overwriting matching keys | d.update({'age': 24}) |
| setdefault(key, val) | Returns the key's value, or inserts the key with val if missing | d.setdefault('city', 'Pune') |
| copy() | Returns a shallow copy | d2 = d.copy() |
| len(d) | Number of pairs | len(d) |
p = {'name': 'Abhi', 'age': 23}
print(p.get('age'))
print(p.get('city', 'NA'))
print(list(p.keys()))
print(list(p.values()))
print(list(p.items()))
p.update({'age': 24})
print(p)
print(p.setdefault('city', 'Pune'))
print(p.setdefault('city', 'Delhi'))
print(len(p))Reading, merging and setdefault
23 NA ['name', 'age'] ['Abhi', 23] [('name', 'Abhi'), ('age', 23)] {'name': 'Abhi', 'age': 24} Pune Pune 3
The second setdefault call didn't replace 'Pune' with 'Delhi'. The key already existed, so its current value came back unchanged. Now the removal methods.
| Method | What it removes | Returns |
|---|---|---|
| pop(key, default) | That key | Its value, or the default if the key is missing |
| popitem() | The most recently inserted pair (LIFO on 3.7+) | That pair as a tuple |
| clear() | Every pair | Nothing |
q = {'a': 1, 'b': 2, 'c': 3}
print(q.pop('a'))
print(q.pop('zz', 'none'))
print(q.popitem())
backup = q.copy()
q.clear()
print(q, backup)Removing items and taking a copy
1 none ('c', 3) {} {'b': 2}
copy() duplicates the dict but not the objects inside it. If a value is a list, both dicts share that same list, so changing it through one copy changes it in the other.
orig = {'skills': ['Python']}
dup = orig.copy()
dup['skills'].append('SQL')
print(orig)The inner list is shared
{'skills': ['Python', 'SQL']}Reading a key that isn't there with square brackets raises KeyError. Use get(key, default) when the key may be absent.
Comprehensions, Cost and When to Use a Dict
A dictionary comprehension builds a dict from a loop in one expression. The part before the colon is the key and the part after it is the value.
squares = {x: x * x for x in range(1, 6)}
print(squares)Each number becomes a key, its square the value
{1: 1, 2: 4, 3: 9, 4: 16, 5: 25}Time complexity
Every core operation goes through the hash table, so each one costs the same however large the dict grows.
| Operation | Syntax | Average case |
|---|---|---|
| Access | d[key] | O(1) |
| Search | key in d | O(1) |
| Insert / update | d[key] = value | O(1) |
| Delete | del d[key] | O(1) |
| Size | len(d) | O(1) |
The O(1) average comes from hashing. When many keys collide, the worst case degrades to O(n).
Use a dictionary whenever you need fast lookups by key. If a problem says 'find this by its id, name or word' and a list scan would be too slow, reach for a dict.
Part 8 · Hashing Deep Dive
From key to integer to slot
Every dict and set rests on one trick, which is hashing. A hash function turns a key into an integer, and that integer tells Python where in memory to look for the value. Python does not scan the collection comparing keys one by one. It computes a number and jumps close to the answer. That is why lookup, insertion and deletion are fast, O(1) on average for both dicts and sets.
A hash function takes an input, the key, and produces an integer, the hash value. Two properties matter. The output is always an integer, and the same input always gives the same hash value within one run of the program. Python exposes this through the built-in hash(). Integers hash to themselves, so hash(123) is 123 and hash(42) is 42. Strings, floats and tuples are mixed into much larger integers.
print(hash(123)) print(hash(42)) print(hash(123) == hash(123)) print(hash('Abhi') == hash('Abhi')) print(hash((1, 2, 3)) == hash((1, 2, 3))) print(type(hash('hello')).__name__)
Same input, same hash, and the result is always an int
123 42 True True True int
You will see numbers such as hash('Abhi') giving 274836809, hash((1,2,3)) giving 529344067 and hash(3.14) giving 322818021 in notes like these. Treat them as illustrations of the kind of number you get, not values to memorise. On a 64-bit Python the real ones are much longer. String hashes are also deliberately randomised for each new interpreter run, so hash('hello') will differ between runs. Only the property that matters is guaranteed: inside one run, equal keys hash equally.
Hashing converts a key to an integer, the integer locates the value, and that is how dicts and sets avoid scanning.
Hashable and unhashable objects
An object is hashable if it has a hash value that stays the same for its whole lifetime. Only hashable objects can be dict keys or set elements. Asking an unhashable object for its hash raises a TypeError.
| Hashable | Unhashable | |
|---|---|---|
| Types | int, float, str, bool, tuple of hashables, frozenset | list, set, dict |
| Mutable? | No, the contents cannot change | Yes, the contents can change |
| As a dict key or set element | Allowed | TypeError |
| Typical use | Keys, set members, cache keys | Values, working collections |
try: hash([1, 2, 3]) except TypeError as e: print(e) try: d = {[1, 2]: 'value'} except TypeError as e: print(e) print(hash((1, 2, 3)) == hash((1, 2, 3))) try: hash((1, [2, 3])) except TypeError as e: print(e)
Lists fail as keys; a tuple is hashable only if everything inside it is
unhashable type: 'list' unhashable type: 'list' True unhashable type: 'list'
The reason lists are refused is mutability. A dict files each entry under the slot its key's hash points to. Suppose a list could be a key and you later appended to it. Its contents, and therefore its hash, would change, and Python would look in a different slot and never find the entry it stored. That would break the contract between the key and the table. Python removes the whole problem by refusing unhashable keys up front with a TypeError, instead of letting the table be corrupted quietly.
A tuple is not automatically hashable. (1, [2, 3]) holds a list, so hashing it fails. If you need a composite key, make sure every part is itself hashable.
Dict and set internals, and collisions
Dicts and sets share the same machinery, and both are built on a hash table. A set hashes each element to an index and stores the element there. A dict does the same with each key, and stores the value alongside it. This is why the rules for set elements and dict keys are identical.
Two different keys can still end up wanting the same place. This is a collision. It happens when two keys have the same hash, or when different hashes are reduced to the same table index. In the illustration below, 'abc' and 'cab' both hash to 12345, and both therefore map to index 5. The numbers are made up for the picture. Real collisions between strings are rare.
- 1'abc' hashes to 12345goes to index 5
- 2'cab' hashes to 12345also wants index 5
- 3Collisionsame hash, different keys
- 4Probe onward'cab' is stored in another free slot
Python handles collisions inside the table, and you never write code for it. CPython uses open addressing with probing. When a slot is taken by a different key, it follows a fixed sequence to the next candidate slot and tries that. On lookup it follows the same sequence, comparing hashes and then keys with ==, until it finds the key or reaches an empty slot. The other classic technique, chaining, keeps a small list of entries at each slot. Both ways of dealing with collisions stay cheap as long as collisions are rare.
| Open addressing (CPython) | Chaining | |
|---|---|---|
| On collision | Probe to another slot in the same table | Append to a list kept at that slot |
| Where entries live | All in the table itself | In small lists hanging off the table |
| Cost when collisions are rare | About O(1) | About O(1) |
class Clash: def __init__(self, name): self.name = name def __hash__(self): return 5 def __eq__(self, other): return self.name == other.name a, b = Clash('abc'), Clash('cab') print(hash(a) == hash(b), a == b) d = {a: 1, b: 2} print(len(d), d[a], d[b])
A deliberate collision: every key hashes to 5, yet both entries survive
True False 2 1 2
The two keys have the same hash but are not equal, so Python keeps both and tells them apart with ==. That is the rule behind everything here: equal objects must have equal hashes, but equal hashes do not imply equal objects. The cost is speed. If many keys collide, probing grows long and the average O(1) drifts toward O(n).
Asked why a dict is fast, say hashing. Then add that heavy collisions can push it toward O(n), which is why the cheatsheets write O(1) average.
Making your own classes hashable
Now for classes you write yourself. By default Python hashes a custom object by its identity, so two objects that look the same are still different keys. The behaviour changes the moment you define __eq__ without __hash__. Python then sets __hash__ to None and the class becomes unhashable, because you changed what equality means and it will not guess a matching hash.
class Plain: def __init__(self, x): self.x = x print(Plain(1) == Plain(1)) print(len({Plain(1), Plain(1)})) class OnlyEq: def __eq__(self, other): return True try: hash(OnlyEq()) except TypeError as e: print(e)
Default identity hashing, then what happens when only __eq__ is defined
False 2 unhashable type: 'OnlyEq'
To make a class behave as a value, define both methods. __eq__ decides when two objects are the same. __hash__ must agree with it, so the simplest approach is to hash a tuple of exactly the fields that __eq__ compares.
class Point: def __init__(self, x, y): self.x = x self.y = y def __hash__(self): return hash((self.x, self.y)) def __eq__(self, other): return self.x == other.x and self.y == other.y p1 = Point(1, 2) p2 = Point(1, 2) print(p1 == p2, hash(p1) == hash(p2)) visited = {p1} print(p2 in visited) print(len({p1, p2, Point(3, 4)}))
Equal points now collapse to one set member
True True True 2
| Method | Job | Rule |
|---|---|---|
| eq | Says when two objects are the same | Compare the same fields you hash |
| hash | Picks the slot | Equal objects must return equal hashes |
Mutating a field that feeds __hash__ after the object is already inside a set or used as a dict key. Its hash changes and the table can no longer find it. Keep hashed fields fixed once the object is stored.
Hashing converts keys to integers, which gives O(1) average lookup. Only hashable objects can be dict keys or set elements. Collisions are handled inside the table. Once you understand hashing, dicts and sets stop being magic.
Part 9 · Stacks & Queues
Stacks: Last In, First Out
A stack follows the rule Last In, First Out (LIFO): the item you added most recently is the first one to come back out. All the action happens at a single end called the TOP. You add at the top and you remove from the top, and nothing in the middle is touched.
You meet stacks all the time without noticing. The undo command in an editor reverses your latest change first. The browser back button returns you to the page you visited most recently. The function call stack runs the function that was called last and finishes it before returning to its caller.
- 1push 10stack: 10
- 2push 20stack: 10, 20
- 3push 30stack: 10, 20, 30
- 4popreturns 30, leaves 10, 20
Python has no separate stack type, because a plain list already does the job. The end of a list is its cheap end, so append() is your push and pop() with no argument is your pop. Reading stack[-1] shows the top without removing it, which is called a peek.
stack = [] stack.append(10) # push stack.append(20) stack.append(30) print(stack) print(stack.pop()) # pop removes the top print(stack[-1]) # peek at the new top print(len(stack)) # size stack.clear() # empty the stack print(stack, len(stack) == 0)
A list used as a stack
[10, 20, 30] 30 20 2 [] True
Every one of these operations works on the end of the list. Nothing has to move, so each one costs the same no matter how many items the stack holds.
| Stack operation | List code | Time |
|---|---|---|
| Push | stack.append(x) | O(1) |
| Pop | stack.pop() | O(1) |
| Peek | stack[-1] | O(1) |
| isEmpty | len(stack) == 0 | O(1) |
stack.pop() takes the last item, which is O(1). stack.pop(0) takes the first item. That is no longer a stack, and it is O(n) because every other element has to be shifted. Keep both push and pop on the same end.
Queues: First In, First Out
A queue follows First In, First Out (FIFO): whoever arrived first is served first. New items join at the REAR and leave from the FRONT. Think of the line at a ticket counter. Print jobs are handled in the order they were submitted. Breadth first search (BFS) visits nodes in the order it discovered them.
| Stack | Queue | |
|---|---|---|
| Rule | LIFO | FIFO |
| Add at | TOP | REAR |
| Remove from | TOP | FRONT |
| Real life | Undo, back button, call stack | Ticket line, print jobs, BFS |
| Python tool | list | collections.deque |
For queues Python gives you collections.deque, short for double-ended queue. It is built so that adding or removing at either end is cheap. You enqueue with append(), which adds at the rear, and you dequeue with popleft(), which removes and returns the front item. Looking at q[0] shows the front without removing it.
from collections import deque q = deque() q.append(10) # enqueue at the rear q.append(20) q.append(30) print(q) print(q.popleft()) # dequeue from the front print(q[0]) # peek at the new front print(len(q)) # size q.clear() print(q, len(q) == 0)
A deque used as a queue
deque([10, 20, 30]) 10 20 2 deque([]) True
The queue operations you will use most often fit in one small table.
| Operation | Code | What it does | Time |
|---|---|---|---|
| Enqueue | append(x) | Add x at the rear | O(1) |
| Dequeue | popleft() | Remove and return the front | O(1) |
| Peek | q[0] | Get the front element | O(1) |
| isEmpty | len(q) == 0 | Check whether the queue is empty | O(1) |
| Size | len(q) | Count the items | O(1) |
deque is not a built-in name. Without from collections import deque at the top you get a NameError the moment you write deque().
Why a List Makes a Poor Queue
A list can be used as a queue, but it is not recommended. Enqueueing is fine, because append() adds at the end. The trouble is dequeueing, because the front of a list is the expensive end. You do it with pop(0), and that is O(n).
q = [] q.append(10) # enqueue q.append(20) q.append(30) print(q.pop(0)) # dequeue, costs O(n) print(q)
A queue built on a list: it works, but slowly
10 [20, 30]
The reason is how a list is laid out. Its items sit side by side in one block of memory, and the slot numbered 0 must always hold the first item. When pop(0) removes the front, a hole appears at position 0. To close it, every remaining element is shifted one position to the left. With 3 items that is trivial. With a million queued items, every single dequeue moves a million references.
Before pop(0): the front item 10 is about to be removed
After pop(0): 20, 30 and 40 each moved one slot left
A deque avoids this because it does not insist that the first item live in slot 0 of a single block, so popleft() removes the front without moving the rest. That is why both ends cost O(1).
| Queue step | List | deque |
|---|---|---|
| Enqueue | append(x): O(1) | append(x): O(1) |
| Dequeue | pop(0): O(n), shifts everything | popleft(): O(1) |
| Peek | q[0]: O(1) | q[0]: O(1) |
| isEmpty | len(q) == 0: O(1) | len(q) == 0: O(1) |
The code produces correct answers, so the problem is easy to miss. It only shows up as a slowdown when the queue gets long. If you dequeue from the front, use a deque.
Where Each One Is Used and How to Choose
Stacks and queues are really use cases, not separate data types. Both are built from a list or a deque, and what makes them what they are is the order in which items come out. Choose by asking which item you need next: the newest or the oldest.
| Stack uses (LIFO) | Queue uses (FIFO) |
|---|---|
| Undo and redo in editors | CPU task scheduling |
| Backtracking problems | Printer queue |
| Expression evaluation | Breadth first search (BFS) |
| Function call management (recursion) | Handling requests in web servers |
Whenever you choose between a list and a deque, say the time complexity out loud. For example: "I will use a deque because popleft() is O(1), while pop(0) on a list is O(n)." Naming the cost shows that you chose on purpose.
Stack means LIFO, so use a list. Queue means FIFO, so use a deque.
Part 10 · Choosing the Right Data Structure
Five Structures Side by Side
Python gives you several containers that look similar from a distance. Each one makes some operations cheap and others expensive. Choosing well means knowing which operations your program performs most, then picking the container that makes them fast. The table below compares the five structures you have met so far.
| Structure | Mutable | Ordered | Duplicates | Access | Search | Use it for |
|---|---|---|---|---|---|---|
| List | Yes | Yes | Yes | O(1) by index | O(n) | an ordered collection with frequent changes and duplicates |
| Tuple | No | Yes | Yes | O(1) by index | O(n) | fixed data, heterogeneous records, safer data |
| Set | Yes | No | No | N/A | O(1) avg | uniqueness, membership testing, set operations |
| Dict | Yes | Yes* | Keys: No | O(1) avg by key | O(1) avg by key | key-value mapping and fast lookup by key |
| deque | Yes | Yes | Yes | O(1) from both ends | O(n) | frequent insertions and deletions at both ends (queue, sliding window) |
The asterisk on the dict row matters: dictionaries remember insertion order since Python 3.7, but that order is not a sorted order. The word avg on the set and dict rows means the O(1) comes from hashing. A pile of collisions can push the worst case towards O(n).
Read the table in two passes. First look at the Mutable and Duplicates columns, which tell you what the data is allowed to look like. Then look at the Access and Search columns, which tell you how quickly you can get things back out.
A Decision Path You Can Follow
When you are unsure, walk through five questions in order. The first question that gets a yes decides the structure. If every answer is no, the plain list is your default.
The flowchart is a starting point, not a law. A real problem sometimes needs two structures at once, such as a dict for lookups plus a list for ordering. The questions still help, because each one asks about an operation your code must perform.
Write down what the program must do: look up by key, add at the front, check if something exists, never change. Then pick the structure that makes those operations cheap. Starting from the operations beats starting from habit.
Scenarios: Picking for Real Tasks
Here are the common scenarios, each with the structure that fits and the reason. The reasoning matters more than the answer, because it carries over to problems you have not seen yet.
| Task | Structure | Why |
|---|---|---|
| Items in a shopping cart | List | order matters and duplicates are allowed |
| Coordinates (x, y) of a point | Tuple | a fixed pair of values that should not change |
| Remove duplicates from a list | Set | a set keeps each value only once |
| Does this email already exist? | Set | membership check is O(1) on average |
| User info (id, name, email) | Dict | look up by id or email instead of scanning |
| Task queue (first in, first out) | deque | popleft() is O(1) at the front |
| Undo/redo in an editor | List as a stack | last in, first out with append() and pop() |
| Word frequency in a file | Dict | each word maps to its count |
The cart and the point show the line between list and tuple. A cart changes as the user shops, and the same item may appear twice. A point is a single fixed pair, so unpacking it into two names is natural and nobody can overwrite it by accident.
cart = ["pen", "book", "pen"] cart.append("lamp") point = (3, 4) x, y = point print(cart) print(len(cart)) print(x, y)
['pen', 'book', 'pen', 'lamp'] 4 3 4
Removing duplicates and checking for an existing email are both jobs for a set. Converting a list to a set drops repeats, and the in test hashes straight to the right slot instead of walking through every item. A set does not keep order, so if you need unique items in their original order, use list(dict.fromkeys(items)) instead.
emails = ["a@x.com", "b@x.com", "a@x.com"] unique = set(emails) print(len(unique)) print("a@x.com" in unique) print("z@x.com" in unique) print(list(dict.fromkeys(emails)))
2 True False ['a@x.com', 'b@x.com']
User records and task queues call for a dict and a deque. A dict gives you the record by id without scanning, and a second dict keyed by email gives you the reverse lookup. A deque removes work from the front in constant time, which is what a first-in, first-out queue needs.
from collections import deque users = {1: {"name": "Asha", "email": "asha@x.com"}} print(users[1]["name"]) by_email = {u["email"]: uid for uid, u in users.items()} print(by_email["asha@x.com"]) tasks = deque(["resize", "upload"]) tasks.append("notify") print(tasks.popleft()) print(list(tasks))
Asha 1 resize ['upload', 'notify']
Undo and redo behave like two stacks, and a plain list is a good stack because append() and pop() both work at the end in O(1). Counting words is the classic dict job: the word is the key and the running count is the value.
undo = [] for action in ["type a", "type b", "bold"]: undo.append(action) redo = [] redo.append(undo.pop()) print(redo[-1]) print(undo) text = "to be or not to be" counts = {} for w in text.split(): counts[w] = counts.get(w, 0) + 1 print(counts)
bold ['type a', 'type b'] {'to': 2, 'be': 2, 'or': 1, 'not': 1}
Mistakes, Tips and a Quick Reference
Most wrong choices come from using a familiar structure out of habit. These are the mistakes that show up most often, along with the fix for each.
Writing x in big_list scans items one by one, so it costs O(n). On a million items that is a million comparisons in the worst case. Build a set once and test against it, which costs O(1) on average.
A list is mutable, so it is unhashable and Python refuses it as a key. Use a tuple or a string instead. The example below shows the error and the fix.
try: d = {[1, 2]: "x"} except TypeError as e: print(e) d = {(1, 2): "x"} print(d[(1, 2)])
unhashable type: 'list'
xA dict used only to hold unique values wastes space on values you never read, so use a set. A list used as a queue with pop(0) shifts every remaining item left on each call, which costs O(n), so use a deque with popleft(). A tuple used where the data must change fails with a TypeError, so use a list.
| If you need to... | Reach for | Cost |
|---|---|---|
| read or change an item by position | list or tuple | O(1) |
| test whether a value exists | set or dict | O(1) avg |
| find a value by its key | dict | O(1) avg |
| add or remove at either end | deque | O(1) |
| protect data from changes | tuple | no changes possible |
The right choice of data structure can cut time complexity from O(n) to O(1) in many cases, with the same logic and far less waiting.
dynamic array
ordered, duplicates OK
best for general purpose
immutable list
ordered, duplicates OK
best for fixed data
hash table
unordered, unique items
best for membership tests
hash table
insertion ordered, unique keys
best for key-value mapping
doubly linked list
ordered, duplicates OK
best for queue/sliding window
There is no one-size-fits-all. Understand the trade-offs, name the operations you need, and let them choose the structure.
Part 11 · Summary & Best Practices
The Structures at a Glance
Every built-in structure answers two questions: does order matter, and can the contents change? A list is ordered, mutable and allows duplicates. A tuple is also ordered and allows duplicates, but it is immutable, so it is fixed once built. A set drops order and duplicates: it is unordered, mutable and stores only unique items. A dict stores key-value pairs. It keeps insertion order, is mutable, and every key is unique.
| Ordered | Mutable | Duplicates | Stores | |
|---|---|---|---|---|
| List | Yes | Yes | Allowed | Items by position |
| Tuple | Yes | No | Allowed | Items by position |
| Set | No | Yes | Not allowed | Unique items |
| Dict | Yes (insertion order) | Yes | Values yes, keys no | Key-value pairs |
Stacks and queues are not separate types. They are usage patterns. A stack is LIFO (last in, first out) and a plain list does it well with append() and pop(). A queue is FIFO (first in, first out) and collections.deque does it best, because popleft() is O(1) while list.pop(0) shifts every remaining item. Picking the right structure improves both time and space efficiency.
List = ordered + mutable. Tuple = ordered + fixed. Set = unique + fast membership. Dict = key to value. Stack = list. Queue = deque.
When to use what
Start from the operations you need, then choose the structure that makes them cheap. The table lists the situation each one is built for, and the diagram below turns it into a decision path.
| Structure | Reach for it when | Typical example |
|---|---|---|
| List | You need an ordered collection that changes often, and duplicates are fine | Shopping cart items |
| Tuple | The data is fixed, a heterogeneous record, or must be a dict key | Coordinates (x, y) |
| Set | Items must be unique, you test membership, or you need set maths | Usernames, removing duplicates |
| Dict | You map a key to a value and need fast lookup by key | User id to name |
| deque | You add or remove fast at both ends (queues, sliding windows) | Task queue, moving window |
Complexity Cheatsheet and Memory
This table puts the four core operations side by side for all five structures. A dash means the operation does not apply: a set has no index to access, and a tuple cannot be inserted into or deleted from because it is immutable.
| Operation | List | Tuple | Set | Dict | deque |
|---|---|---|---|---|---|
| Access (by index/key) | O(1) | O(1) | – | O(1) avg | O(1) |
| Search (by value) | O(n) | O(n) | O(1) avg | O(1) avg | O(n) |
| Insert | O(1) end / O(n) other | – | O(1) avg | O(1) avg | O(1) |
| Delete | O(1) end / O(n) other | – | O(1) avg | O(1) avg | O(1) |
| Membership test | O(n) | O(n) | O(1) avg | O(1) avg | O(n) |
Delete follows the same pattern as insert. A list is cheap at its end because there is no shifting, and costly anywhere else because every item behind the position moves. A deque is O(1) for inserts and deletes because it works at its two ends. Reaching an item in the middle of a deque is slower, so its O(1) access really means the ends.
The label avg means average case. When many keys collide in a set or dict, the worst case degrades to O(n). Hashing makes lookups fast in practice, but it does not make them a guarantee.
Rough memory usage
Speed is half the story. Memory differs too, and the figures here are rough guidance rather than exact numbers.
| Structure | Memory | Why |
|---|---|---|
| Tuple | Least | Fixed size and compact, nothing reserved for growth |
| deque | Moderate | Optimized for work at the ends |
| List | More | Stores references and over-allocates spare slots for growth |
| Set | More | Hash table overhead |
| Dict | Most | Key plus value plus hash table |
Code Snippets and Patterns
Comprehensions build a list, dict or set in one readable line. The list version squares the numbers 1 to 5. The dict version maps each number to its square. The set version keeps only the distinct remainders of dividing by 2, so ten numbers collapse into two values.
squares = [x*x for x in range(1, 6)] d = {x: x*x for x in range(1, 4)} s = {x % 2 for x in range(10)} print(squares) print(d) print(s)
[1, 4, 9, 16, 25] {1: 1, 2: 4, 3: 9} {0, 1}
A deque is imported from collections. It adds to the left as cheaply as to the right, which a list cannot do. Here appendleft(0) puts a new front item in place, and pop() takes the last one off the right.
from collections import deque dq = deque([1, 2, 3]) dq.appendleft(0) print(list(dq)) print(dq.pop()) print(list(dq))
[0, 1, 2, 3] 3 [0, 1, 2]
Pattern: Two Sum with a dict
Given numbers and a target, find the two positions whose values add up to it. Scanning every pair costs O(n squared). Instead, keep a dict seen that maps each value to its index. For each new number, one O(1) lookup asks whether its partner target - x has already been seen, so the whole job takes a single pass.
def two_sum(nums, target): seen = {} for i, x in enumerate(nums): if target - x in seen: return [seen[target - x], i] seen[x] = i return [] print(two_sum([2, 7, 11, 15], 9)) print(two_sum([3, 2, 4], 6))
[0, 1] [1, 2]
Pattern: sliding window with a deque
Imagine a strip of values and a window of three that slides along it. A deque with maxlen=3 does the sliding for you: appending a fourth item pushes the oldest one out of the left end automatically, in O(1).
from collections import deque strip = [4, 2, 12, 3, 8, 5] window = deque(maxlen=3) for v in strip: window.append(v) if len(window) == 3: print(list(window), sum(window))
[4, 2, 12] 18 [2, 12, 3] 17 [12, 3, 8] 23 [3, 8, 5] 16
Pattern: frequency count with a dict
Counting how often each item appears is a dict job. freq.get(ch, 0) returns the current count, or 0 for a character not yet seen, so no special first-time case is needed.
s = 'banana' freq = {} for ch in s: freq[ch] = freq.get(ch, 0) + 1 print(freq)
{'b': 1, 'a': 3, 'n': 2}Best Practices and Final Thoughts
The patterns above come down to a short list of habits. Each one swaps a slow default for a structure that fits the job.
| Instead of | Do this | Why |
|---|---|---|
| Scanning a list to look something up | Use a dict keyed by what you look up | O(1) average instead of O(n) |
Checking x in some_list repeatedly | Use a set for membership tests | O(1) average, and it removes duplicates for free |
Using list.pop(0) as a queue | Use a deque with popleft() | O(1) instead of shifting every item |
| Hand-rolling a moving window | Use deque(maxlen=k) | Old items fall off the end by themselves |
Removing or inserting items inside a for loop over the same list shifts the positions under the iterator, so items get skipped. Loop over a copy, or build a new list with a comprehension, and keep the original untouched while you iterate.
- Understand the time complexity of an operation before you choose a structure for it.
- Write clean, readable, pythonic code: a comprehension,
enumerateordict.getoften says in one line what a loop needs five for. - Remember the memory side: tuples are the lightest, while sets and dicts pay for their hash tables.
- Say the cost out loud when you compare two choices, such as list against deque.
Clarify the requirements before you name a data structure. Ask whether order matters, whether duplicates are allowed, how large the input is, and which operations will run most often. Then pick the structure and state its complexity.
Every problem has many solutions, but not every solution is efficient. Mastering data structures helps you write better, faster and more scalable code.
Part 12 · Check yourself
Quiz
Work out each answer before you open it. These questions ask you to predict output or find the bug, because that is what interviews do.
What does this print, and why?
- It prints
<class 'int'>and then<class 'tuple'>. - Parentheses alone only group an expression, so
(10)is just the integer 10. - The trailing comma is what makes
(10,)a one-element tuple.
t = (10) print(type(t)) u = (10,) print(type(u))
This loop is meant to remove every 2 from the list. What does it print?
- It prints
[1, 2, 3], so one 2 survives. - Removing an item shifts the later items left, but the loop's hidden index still moves forward, so it skips the second 2.
- Build a new list instead, for example
[n for n in nums if n != 2], or loop over a copy.
nums = [1, 2, 2, 3] for n in nums: if n == 2: nums.remove(n) print(nums)
A teammate's job queue is slow once it holds hundreds of thousands of tasks. Find the bug.
pop(0)on a list is O(n), because every remaining element shifts one slot left.- Each dequeue gets slower as the queue grows.
- Use
collections.dequewithappendandpopleft; both are O(1).
queue = [] queue.append('a') queue.append('b') first = queue.pop(0)
What does this print?
- It prints
True 3. - Sets drop duplicates, so
s2holds three items. - Sets ignore order, so the two compare equal.
s1 = {1, 2, 3}
s2 = {3, 2, 1, 1}
print(s1 == s2, len(s2))What does this print, and what would happen if you changed the key to a list?
- The first lookup prints
a, because a tuple of hashable items is itself hashable. - The last line raises
TypeError: unhashable type: 'list'. - A list is mutable, so its hash could change while it sits in the table. Python refuses it up front.
seen = {}
seen[(1, 2)] = 'a'
print(seen[(1, 2)])
seen[[1, 2]] = 'b'Summary
- Lists are ordered, mutable and allow duplicates; tuples are the same but immutable, so they can be dict keys.
- Sets keep unique, hashable items with no order, and dicts map unique keys to values in insertion order.
- List
appendis amortized O(1) thanks to spare capacity; inserting or deleting anywhere else is O(n). - Sets and dicts use hashing, so lookup, insert and delete are O(1) on average, and only hashable objects can be keys.
- Use a list as a stack (LIFO) and a
dequeas a queue (FIFO), becauselist.pop(0)is O(n). - Pick the structure by the operations you need most, and state its complexity when you explain your choice.