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.

Before you start

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.

python
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

output
['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.

TypeExampleOrderedMutableDuplicatesIndex accessBest 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: yesNo index; use keysKey-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.

python
d = {'b': 1, 'a': 2}
print(list(d))
print({1, 2, 3} == {3, 2, 1})

Insertion order for dicts, no order for sets

output
['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.

LIST

Ordered + Mutable + Duplicates

[10,20,30]

TUPLE

Ordered + Immutable + Duplicates

(10,20,30)

SET

Unordered + Mutable + Unique

{10,20,30}

DICT

Key-Value + Ordered + Unique keys

{'a':1,'b':2}

Common mistake

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.

CampMeaningTypes
MutableCan be changed after creationlist, set, dict
ImmutableCannot be changed after creationtuple, 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.

python
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

output
['pen', 'book', 'pen', 'lamp']
3
{1: 'Abhi', 2: 'Sam', 3: 'Lee'}
TypeError
abc
What to remember

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.

StructureUse it whenExample
ListYou need an ordered collection that you may modifyItems in a shopping cart
TupleThe data is fixed and should not changeCoordinates (x, y)
SetYou need unique items and fast membership checksUnique usernames
DictionaryYou need to map a key to a valueUser 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.

Which structure fits?
Interview tip

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.

Key takeaway

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.

PropertyWhat it means for a list
OrderedItems keep the position you gave them
Any typeNumbers, strings, booleans and even other lists can sit side by side
MutableYou can replace, add and remove items in place
Duplicates allowedThe 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.

python
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])
output
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.

100↑ -5
201↑ -4
302↑ -3
403↑ -2
504↑ -1

Numbers under the cells are positive indexes 0 to 4; the labels are the matching negative indexes -5 to -1

python
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

output
10
30
50
Common mistake

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.

python
lst = [10, 20, 30, 40, 50]
print(lst[1:4])
print(lst[:3])
print(lst[2:])
print(lst[::2])
print(lst[::-1])
output
[20, 30, 40]
[10, 20, 30]
[30, 40, 50]
[10, 30, 50]
[50, 40, 30, 20, 10]
SliceReads asResult 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]
Common mistake

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.

MethodWhat it doesExample on lst = [10, 20, 30]Result
append(x)Add one item at the endlst.append(60)[10, 20, 30, 60]
extend(it)Add many items at the endlst.extend([70, 80])[10, 20, 30, 70, 80]
insert(i, x)Put x at index ilst.insert(1, 15)[10, 15, 20, 30]
remove(x)Drop the first x foundlst.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.

python
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)
output
[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.

Which removal fits?

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.

python
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)
output
2
2
[10, 10, 20, 30]
[30, 20, 10, 10]
None
[]
Common mistake

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.

OperationTimeWhy
Access by indexO(1)Jump straight to the position
Search by valueO(n)May have to check every item
Insert at the endO(1) amortizedUsually a free slot is waiting
Insert at any positionO(n)Later items shift over
Delete at the endO(1)Nothing needs to move
Delete at any positionO(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.

What lst.insert(0, x) does
  1. 1Call insert(0, x)list holds n items
  2. 2Shift every item rightn items move one slot
  3. 3Write x at index 0one assignment
  4. 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.

python
lst = [5, 1, 8, 3]
lst.append(10)
lst.sort()
print(lst)
print(lst[1:4])
output
[1, 3, 5, 8, 10]
[3, 5, 8]
Interview tip

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).

100↑ first
201
302
403↑ last used
None4
None5
None6
None7

Used = 4, Capacity = 8

MeaningIn the picture
Size (used)Slots that hold a real item. This is what len() reports.4 (slots 0 to 3)
CapacitySlots the current array can hold before it must grow.8 (slots 0 to 7)
Spare roomCapacity minus size.4 free slots
Why the spare room matters

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.

Growth trace
  1. 1cap 4 / size 4the array is full
  2. 2append(50)cap 8 / size 5, resize and copy
  3. 3append(60)size 6, free slot
  4. 4append(70)size 7, free slot
  5. 5append(80)size 8, fills the array
  6. 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.

python
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

output
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.

OperationTimeWhy
Access by indexO(1)Jump straight to the slot
Search in an unsorted listO(n)May have to check every item
append (end)O(1) amortizedFree slot, occasional resize
pop (end)O(1)Nothing needs to shift
OperationTimeWhy
Insert at the beginningO(n)Every item moves one slot right
Insert at any positionO(n)Items after it shift right
Delete at the beginningO(n)Every item moves one slot left
Delete at any positionO(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.

python
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

output
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.

Common mistake: inserting at the front

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).

Common mistake: saying append is always O(1)

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.

PropertyTuple
OrderedYes, items keep their position
MutableNo, fixed after creation
DuplicatesAllowed
SizeFixed
MemoryLighter 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.

python
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))
output
(1, 2, 3, 4) (1, 2, 3, 4) (1, 2, 3) () (10,)
<class 'int'> <class 'tuple'>
Common mistake: the missing comma

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.

100↑ t[0] / t[-5]
201
302
403
504↑ t[4] / t[-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.

python
t = (10, 20, 30, 40, 50)
print(t[0])
print(t[-1])
print(t[1:4])
print(t[::-1])
output
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.

python
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)
output
TypeError: 'tuple' object does not support item assignment
AttributeError: 'tuple' object has no attribute 'append'
TypeError: 'tuple' object doesn't support item deletion
Read freely, never edit

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.

How x, y = y, x works when x is 5 and y is 10
  1. 1Evaluate the right sidey and x are read, giving 10 and 5
  2. 2Pack a tuple(10, 5) is built
  3. 3Unpack to the leftx gets 10, y gets 5
python
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)
output
(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.

MethodWhat it returnsExample
count(x)How many times x appearst.count(10) gives 2
index(x)Index of the first xt.index(30) gives 2
python
t = (10, 20, 30, 10)
print(t.count(10))
print(t.index(30))
output
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.

FeatureTupleList
MutableNo (immutable)Yes (mutable)
Syntax( )[ ]
MemoryLessMore
SpeedFasterSlower
MethodsFew (count, index)Many
Use caseFixed, safe dataDynamic 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.

Tuple or list?

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.

python
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)
output
cafe
TypeError: unhashable type: 'list'
2 9
Interview tip

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.

Common mistake: picking a tuple for data you must edit

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.

PropertySet
OrderedNo, there is no index
DuplicatesNot allowed, they collapse into one
MutableYes, the set itself can change
Elements must beHashable (immutable)
Membership testVery 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.

python
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

output
<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, floatlist
strdict
tuple of hashablesset
frozenset
python
ok = {1, 2.5, 'hi', (1, 2), frozenset({3})}
print(len(ok))
try:
    bad = {[1, 2]}
except TypeError as e:
    print(e)
output
5
unhashable type: 'list'
Common mistake

{} 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.

OperationCallResult
Add ones.add(50){10, 20, 30, 40, 50}
Add manys.update([5, 6]){5, 6, 10, 20, 30, 40}
Removes.remove(30)drops 30, raises KeyError if absent
Discards.discard(100)no error if the element is missing
Pops.pop()removes and returns an arbitrary element
Clears.clear()set()
Sizelen(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.

python
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)
output
[10, 20, 30, 40, 50]
[5, 6, 10, 20, 30, 40, 50]
[5, 6, 10, 20, 40, 50]
6
False 5
set()
Common mistake

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.

OperationOperatorMeaningResult
Union (∪)A | Bevery unique element from both{1, 2, 3, 4, 5}
Intersection (∩)A & Belements common to both{3}
DifferenceA - Bin A but not in B{1, 2}
Symmetric differenceA ^ Bin exactly one of the two{1, 2, 4, 5}
python
A = {1, 2, 3}
B = {3, 4, 5}
print(A | B)
print(A & B)
print(A - B)
print(A ^ B)
output
{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.

python
s = {10, 20, 30, 40}
print(20 in s)
print(999 in s)
print(100 not in s)
output
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.

Why it is O(1)

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.

python
s1 = {1, 2, 3, 4}
s2 = {4, 3, 2, 1}
print(s1 == s2)
output
True
Do not rely on set order

The 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

OperationMethodsAverage cost
Addadd, updateO(1)
Removeremove, discardO(1)
Membershipin, not inO(1)
PoppopO(1)
Combineunion, intersection, differenceO(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

Should this be a set?
  • 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.

PartRuleWhy
KeysUnique and hashable (immutable types such as str, int, tuple)The hash table must be able to turn a key into a stable number
ValuesAny type, and they may repeatValues are only stored, never hashed
The dict itselfMutableYou 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.

python
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

output
Abhi
SQL
4

The 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.

python
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

output
{'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.

python
order = {}
order['z'] = 1
order['a'] = 2
order['m'] = 3
print(list(order))

Insertion order is kept, not alphabetical order

output
['z', 'a', 'm']
In one line

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.

StepWhat happensResult
1Hash the keyhash('age') = 5721
2Reduce to a table index5721 % 8 = 1
3Store or read that slotSlot 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.

python
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

output
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.

Why O(1) is only average

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.

python
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

output
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.

MethodWhat it doesExample
get(key, default)Value for the key, or the default if the key is missingd.get('city', 'NA')
keys()All the keysd.keys()
values()All the valuesd.values()
items()Key-value pairs as tuplesd.items()

Next, the methods that change the dict. update merges another dict in, and setdefault inserts a key only when it is missing.

MethodWhat it doesExample
update(other)Merges the pairs of another dict, overwriting matching keysd.update({'age': 24})
setdefault(key, val)Returns the key's value, or inserts the key with val if missingd.setdefault('city', 'Pune')
copy()Returns a shallow copyd2 = d.copy()
len(d)Number of pairslen(d)
python
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

output
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.

MethodWhat it removesReturns
pop(key, default)That keyIts 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 pairNothing
python
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

output
1
none
('c', 3)
{} {'b': 2}
Common mistake: copy() is shallow

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.

python
orig = {'skills': ['Python']}
dup = orig.copy()
dup['skills'].append('SQL')
print(orig)

The inner list is shared

output
{'skills': ['Python', 'SQL']}
Common mistake: d[key] on a missing key

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.

python
squares = {x: x * x for x in range(1, 6)}
print(squares)

Each number becomes a key, its square the value

output
{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.

OperationSyntaxAverage case
Accessd[key]O(1)
Searchkey in dO(1)
Insert / updated[key] = valueO(1)
Deletedel d[key]O(1)
Sizelen(d)O(1)
Remember

The O(1) average comes from hashing. When many keys collide, the worst case degrades to O(n).

Interview tip

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.

python
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

output
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.

The one-line version

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.

HashableUnhashable
Typesint, float, str, bool, tuple of hashables, frozensetlist, set, dict
Mutable?No, the contents cannot changeYes, the contents can change
As a dict key or set elementAllowedTypeError
Typical useKeys, set members, cache keysValues, working collections
python
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

output
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.

Can this object be a dict key?
Common mistake

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.

Two keys, one slot (illustrative numbers)
  1. 1'abc' hashes to 12345goes to index 5
  2. 2'cab' hashes to 12345also wants index 5
  3. 3Collisionsame hash, different keys
  4. 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 collisionProbe to another slot in the same tableAppend to a list kept at that slot
Where entries liveAll in the table itselfIn small lists hanging off the table
Cost when collisions are rareAbout O(1)About O(1)
python
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

output
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).

Interview tip

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.

python
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

output
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.

python
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

output
True True
True
2
MethodJobRule
eqSays when two objects are the sameCompare the same fields you hash
hashPicks the slotEqual objects must return equal hashes
Common mistake

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.

Summary

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.

Stack with the TOP on the right
  1. 1push 10stack: 10
  2. 2push 20stack: 10, 20
  3. 3push 30stack: 10, 20, 30
  4. 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.

python
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

output
[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 operationList codeTime
Pushstack.append(x)O(1)
Popstack.pop()O(1)
Peekstack[-1]O(1)
isEmptylen(stack) == 0O(1)
Common mistake: popping from the wrong end

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.

StackQueue
RuleLIFOFIFO
Add atTOPREAR
Remove fromTOPFRONT
Real lifeUndo, back button, call stackTicket line, print jobs, BFS
Python toollistcollections.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.

python
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

output
deque([10, 20, 30])
10
20
2
deque([]) True

The queue operations you will use most often fit in one small table.

OperationCodeWhat it doesTime
Enqueueappend(x)Add x at the rearO(1)
Dequeuepopleft()Remove and return the frontO(1)
Peekq[0]Get the front elementO(1)
isEmptylen(q) == 0Check whether the queue is emptyO(1)
Sizelen(q)Count the itemsO(1)
Common mistake: dropping the import

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).

python
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

output
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.

100
201
302
403

Before pop(0): the front item 10 is about to be removed

200
301
402

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 stepListdeque
Enqueueappend(x): O(1)append(x): O(1)
Dequeuepop(0): O(n), shifts everythingpopleft(): O(1)
Peekq[0]: O(1)q[0]: O(1)
isEmptylen(q) == 0: O(1)len(q) == 0: O(1)
Common mistake: list.pop(0) as a queue

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 editorsCPU task scheduling
Backtracking problemsPrinter queue
Expression evaluationBreadth first search (BFS)
Function call management (recursion)Handling requests in web servers
Which structure do I need?
Interview tip

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.

Key takeaway

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.

StructureMutableOrderedDuplicatesAccessSearchUse it for
ListYesYesYesO(1) by indexO(n)an ordered collection with frequent changes and duplicates
TupleNoYesYesO(1) by indexO(n)fixed data, heterogeneous records, safer data
SetYesNoNoN/AO(1) avguniqueness, membership testing, set operations
DictYesYes*Keys: NoO(1) avg by keyO(1) avg by keykey-value mapping and fast lookup by key
dequeYesYesYesO(1) from both endsO(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.

Which structure do I need?

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.

Think in operations

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.

TaskStructureWhy
Items in a shopping cartListorder matters and duplicates are allowed
Coordinates (x, y) of a pointTuplea fixed pair of values that should not change
Remove duplicates from a listSeta set keeps each value only once
Does this email already exist?Setmembership check is O(1) on average
User info (id, name, email)Dictlook up by id or email instead of scanning
Task queue (first in, first out)dequepopleft() is O(1) at the front
Undo/redo in an editorList as a stacklast in, first out with append() and pop()
Word frequency in a fileDicteach 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.

python
cart = ["pen", "book", "pen"]
cart.append("lamp")
point = (3, 4)
x, y = point
print(cart)
print(len(cart))
print(x, y)
output
['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.

python
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)))
output
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.

python
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))
output
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.

python
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)
output
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.

Mistake: list for membership testing

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.

Mistake: a list as a dict key

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.

python
try:
    d = {[1, 2]: "x"}
except TypeError as e:
    print(e)
d = {(1, 2): "x"}
print(d[(1, 2)])
output
unhashable type: 'list'
x
Mistake: the wrong container for the job

A 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 forCost
read or change an item by positionlist or tupleO(1)
test whether a value existsset or dictO(1) avg
find a value by its keydictO(1) avg
add or remove at either enddequeO(1)
protect data from changestupleno changes possible
Remember

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.

List

dynamic array

ordered, duplicates OK

best for general purpose

Tuple

immutable list

ordered, duplicates OK

best for fixed data

Set

hash table

unordered, unique items

best for membership tests

Dict

hash table

insertion ordered, unique keys

best for key-value mapping

deque

doubly linked list

ordered, duplicates OK

best for queue/sliding window

Key takeaway

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.

OrderedMutableDuplicatesStores
ListYesYesAllowedItems by position
TupleYesNoAllowedItems by position
SetNoYesNot allowedUnique items
DictYes (insertion order)YesValues yes, keys noKey-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.

One line to remember

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.

StructureReach for it whenTypical example
ListYou need an ordered collection that changes often, and duplicates are fineShopping cart items
TupleThe data is fixed, a heterogeneous record, or must be a dict keyCoordinates (x, y)
SetItems must be unique, you test membership, or you need set mathsUsernames, removing duplicates
DictYou map a key to a value and need fast lookup by keyUser id to name
dequeYou add or remove fast at both ends (queues, sliding windows)Task queue, moving window
Picking a structure

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.

OperationListTupleSetDictdeque
Access (by index/key)O(1)O(1)–O(1) avgO(1)
Search (by value)O(n)O(n)O(1) avgO(1) avgO(n)
InsertO(1) end / O(n) other–O(1) avgO(1) avgO(1)
DeleteO(1) end / O(n) other–O(1) avgO(1) avgO(1)
Membership testO(n)O(n)O(1) avgO(1) avgO(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.

Common mistake: reading avg as always

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.

StructureMemoryWhy
TupleLeastFixed size and compact, nothing reserved for growth
dequeModerateOptimized for work at the ends
ListMoreStores references and over-allocates spare slots for growth
SetMoreHash table overhead
DictMostKey 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.

python
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)
output
[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.

python
from collections import deque

dq = deque([1, 2, 3])
dq.appendleft(0)
print(list(dq))
print(dq.pop())
print(list(dq))
output
[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.

python
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))
output
[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).

python
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))
output
[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.

python
s = 'banana'
freq = {}
for ch in s:
    freq[ch] = freq.get(ch, 0) + 1
print(freq)
output
{'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 ofDo thisWhy
Scanning a list to look something upUse a dict keyed by what you look upO(1) average instead of O(n)
Checking x in some_list repeatedlyUse a set for membership testsO(1) average, and it removes duplicates for free
Using list.pop(0) as a queueUse a deque with popleft()O(1) instead of shifting every item
Hand-rolling a moving windowUse deque(maxlen=k)Old items fall off the end by themselves
Common mistake: changing a list while looping over it

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, enumerate or dict.get often 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.
Interview tip

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.

Final thought

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.deque with append and popleft; 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 s2 holds 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 append is 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 deque as a queue (FIFO), because list.pop(0) is O(n).
  • Pick the structure by the operations you need most, and state its complexity when you explain your choice.