Python Interview Prep — The Complete Guide
How to use this guide: This is not a textbook. Read it like a cheat sheet. Each section is self-contained. If you have 1 day — go straight to Section 5. If you have a week — read top to bottom.
OOPs in Python
The Four Pillars
| Pillar | One-liner |
|---|---|
| Encapsulation | Hide data, control access through methods |
| Inheritance | Child class reuses parent class properties/methods |
| Polymorphism | Same method, different behavior per object |
| Abstraction | Hide implementation, expose only the interface |
Class and Object
class Dog:
def __init__(self, name):
self.name = name # instance attribute
def bark(self):
print("Woof!")
dog1 = Dog("Johnny") # object = instance of class
dog1.bark() # Woof!Encapsulation
class Account:
def __init__(self, balance):
self.__balance = balance # __ prefix = private (name mangling)
def get_balance(self):
return self.__balance
def set_balance(self, amount):
if amount >= 0:
self.__balance = amount
acc = Account(1000)
acc.get_balance() # 1000
# acc.__balance # AttributeError — private!| Access Type | Syntax | Accessible From |
|---|---|---|
| Public | name | Anywhere |
| Protected | _name | Class + subclasses (convention only) |
| Private | __name | Class only (name mangling) |
Inheritance
class Animal:
def has_legs(self):
print("Has 4 legs")
class Dog(Animal): # Dog inherits Animal
def bark(self):
print("Woof!")
dog = Dog()
dog.has_legs() # inherited from Animal
dog.bark() # own methodsuper() — calling parent method:
class Dog(Animal):
def sound(self):
super().sound() # call parent first
print("Woof")Polymorphism
Same interface, different behavior:
class Animal:
def sound(self): print("Some Sound")
class Dog(Animal):
def sound(self): print("Woof") # method overriding
class Cat(Animal):
def sound(self): print("Meow")
animals = [Dog(), Cat()]
for a in animals:
a.sound() # Woof, then Meow — runtime polymorphismAbstraction
from abc import ABC, abstractmethod
class PaymentGateway(ABC):
@abstractmethod
def pay(self, amount): # contract — every child MUST implement this
pass
class UpiPayment(PaymentGateway):
def pay(self, amount):
print(f"Paid {amount} via UPI")
class CreditCardPayment(PaymentGateway):
def pay(self, amount):
print(f"Paid {amount} via Credit Card")
# Polymorphic usage
def checkout(method: PaymentGateway, amount):
method.pay(amount)
checkout(UpiPayment(), 300) # Paid 300 via UPI
checkout(CreditCardPayment(), 500) # Paid 500 via Credit Card
# PaymentGateway() # TypeError — can't instantiate abstract classMethod Overloading
Python does not support true method overloading. Second definition replaces the first.
# ❌ WRONG — second replaces first
class Calc:
def add(self, a, b): return a + b
def add(self, a, b, c): return a + b + c # overwrites above!
# ✅ Use default args or *args
class Calc:
def add(self, *nums):
return sum(nums)
c = Calc()
c.add(1, 2) # 3
c.add(1, 2, 3) # 6Interview Questions — OOPs
Q: What is the difference between Encapsulation and Abstraction?
Encapsulation = hiding data (using __private).
Abstraction = hiding implementation (using ABC, interfaces).
Q: Can you instantiate an abstract class?
No. ABC with @abstractmethod prevents direct instantiation. You must subclass and implement all abstract methods.
Q: What is MRO (Method Resolution Order)?
Python uses C3 linearization to determine which method to call in multiple inheritance. Use ClassName.__mro__ to inspect.
class A: pass
class B(A): pass
class C(A): pass
class D(B, C): pass
print(D.__mro__) # D → B → C → A → objectQ: __init__ vs __new__?
__new__ creates the object. __init__ initializes it. You almost never override __new__ unless building singletons or metaclasses.
Python One-liner Cheatsheet
Frequency and Counting
from collections import Counter, defaultdict
Counter("abracadabra") # char frequency dict
Counter(lst).most_common(k) # top k elements
{k: v for k, v in Counter(s).items() if v > 1} # only duplicates
freq = defaultdict(int)
freq[item] += 1 # auto-init to 0, safe incrementList Manipulations
lst[::-1] # reverse list
sorted(lst, key=lambda x: (-x[1], x[0])) # sort by value desc, key asc
list(set(lst)) # remove duplicates (order lost)
[x for x in lst if x > 0] # filter positives
# Flatten nested list
[x for sub in nested for x in sub]
# Running max via accumulate
import itertools
list(itertools.accumulate(lst, max))
# Partition list
evens = [x for x in lst if x % 2 == 0]
odds = [x for x in lst if x % 2 != 0]String Tricks
''.join(sorted(word)) # anagram key
s[::-1] == s # palindrome check
' '.join(s.split()) # normalize whitespace
sum(c.isdigit() for c in s) # count digits in string
Counter(s1) == Counter(s2) # anagram check
len(set(s)) == len(s) # all unique chars?Dictionary
d.get(k, 0) # safe get, no KeyError
d.setdefault(k, []).append(v) # group items
{v: k for k, v in d.items()} # invert dict
sorted(d, key=d.get, reverse=True) # sort keys by value desc
{k: v for k, v in d.items() if v > 0} # filter dict entries
{**d1, **d2} # merge — d2 wins on conflicts
d1 | d2 # merge (Python 3.9+)Math and Numbers
pow(base, exp, mod) # fast modular exponentiation O(log n)
divmod(17, 5) # (3, 2) — quotient + remainder
math.gcd(a, b) # greatest common divisor
math.isqrt(n) # integer square root (no float)
math.comb(n, r) # C(n,r) combinations
x & (x - 1) == 0 # is power of 2?
bin(n).count('1') # count set bits
sum(int(d) for d in str(n)) # digit sumMatrix Operations
list(zip(*matrix)) # transpose matrix
[[row[i] for row in m] for i in range(len(m[0]))] # transpose manual
[cell for row in matrix for cell in row] # flatten matrix
[[0] * m for _ in range(n)] # n×m zero matrix — CORRECT wayHeap
import heapq
heapq.nlargest(k, lst) # k largest — O(n log k)
heapq.nsmallest(k, lst) # k smallest
heapq.heappush(h, -val) # max-heap trick (negate values)
heapq.heappush(h, (priority, counter, item)) # priority queue with tiebreakerBinary Search
import bisect
bisect.bisect_left(arr, target) # leftmost insertion point
bisect.bisect_right(arr, target) - 1 # rightmost occurrence index
# Count elements in range [lo, hi]:
bisect.bisect_right(arr, hi) - bisect.bisect_left(arr, lo)Graph and Tree
from collections import defaultdict, deque
graph = defaultdict(list)
graph[u].append(v) # build adjacency list
# BFS shortest path
dist = {start: 0}
q = deque([start])
while q:
n = q.popleft()
for nb in graph[n]:
if nb not in dist:
dist[nb] = dist[n] + 1
q.append(nb)Itertools Essentials
import itertools
list(itertools.combinations(lst, r)) # C(n,r) — order doesn't matter
list(itertools.permutations(lst, r)) # P(n,r) — order matters
list(itertools.product(a, b)) # cartesian product
list(itertools.chain(*nested)) # flatten one level
list(itertools.accumulate(lst)) # prefix sums
list(itertools.islice(gen, k)) # first k items from any iteratorPython Gotchas
Mutable Default Arguments
# ❌ BUG — list is shared across ALL calls
def append_to(element, to=[]):
to.append(element)
return to
append_to(1) # [1]
append_to(2) # [1, 2] ← NOT [2]!
# ✅ FIX
def append_to(element, to=None):
if to is None:
to = []
to.append(element)
return toLate Binding Closures
# ❌ BUG — all lambdas share same 'i' variable
funcs = [lambda: i for i in range(5)]
[f() for f in funcs] # [4, 4, 4, 4, 4]
# ✅ FIX — capture current value
funcs = [lambda i=i: i for i in range(5)]
[f() for f in funcs] # [0, 1, 2, 3, 4]List Multiplication Shallow Copy
# ❌ BUG — all rows are the SAME list object
grid = [[0] * 3] * 3
grid[0][0] = 1
print(grid) # [[1,0,0], [1,0,0], [1,0,0]] — all rows changed!
# ✅ FIX
grid = [[0] * 3 for _ in range(3)]
grid[0][0] = 1
print(grid) # [[1,0,0], [0,0,0], [0,0,0]] — only first rowModifying Container While Iterating
# ❌ DANGEROUS — skips elements unpredictably
for x in lst:
if x % 2 == 0:
lst.remove(x)
# ❌ DANGEROUS — RuntimeError on dict
for k in d:
if d[k] > 1: del d[k]
# ✅ FIX — iterate over copy
for x in lst[:]:
if x % 2 == 0: lst.remove(x)
for k in list(d.keys()):
if d[k] > 1: del d[k]
# ✅ BETTER — use comprehension
lst = [x for x in lst if x % 2 != 0]
d = {k: v for k, v in d.items() if v <= 1}sort() vs sorted()
lst = [3, 1, 2]
lst.sort() # IN PLACE, returns None
new = sorted(lst) # returns NEW list, original unchanged
lst = [3, 1, 2].sort() # ❌ lst = None!
lst = sorted([3, 1, 2]) # ✅ correctis vs ==
[1, 2, 3] == [1, 2, 3] # True — value comparison ✅
[1, 2, 3] is [1, 2, 3] # False — different objects
# Use 'is' ONLY for None, True, False:
if x is None: ...
if flag is True: ...
# Integer caching trap (-5 to 256):
a = 256; b = 256; a is b # True — CPython caches these
a = 257; b = 257; a is b # False — outside cache range
# Rule: NEVER use 'is' to compare integers or stringsFloat Comparison
0.1 + 0.2 == 0.3 # False! (floating point precision)
0.1 + 0.2 # 0.30000000000000004
import math
math.isclose(0.1 + 0.2, 0.3) # True ✅String Building in Loops
# ❌ O(n²) — creates new string object every iteration
result = ""
for char in data:
result += char
# ✅ O(n) — collect then join once
parts = []
for char in data:
parts.append(char)
result = ''.join(parts)bool is a Subclass of int
isinstance(True, int) # True
True + True # 2
sum([True, False, True]) # 2 — counts True values!
# Useful pattern: count True values in list
booleans = [True, False, True, True]
sum(booleans) # 3Shallow vs Deep Copy
import copy
original = [[1, 2], [3, 4]]
shallow = original.copy() # or original[:]
shallow[0].append(99)
print(original) # [[1, 2, 99], [3, 4]] — modified!
deep = copy.deepcopy(original)
deep[0].append(100)
print(original) # unchanged ✅Time and Space Complexity Master Table
╔══════════════╦════════════════════╦════════════════════════════════════════╗
║ STRUCTURE ║ OPERATION ║ TIME NOTES ║
╠══════════════╬════════════════════╬════════════════════════════════════════╣
║ ║ Index access/assign║ O(1) ║
║ ║ Append ║ O(1)* *amortized ║
║ LIST ║ Pop last ║ O(1) ║
║ ║ Pop(i) / Insert(i) ║ O(n) shifts elements ║
║ ║ Search (in) ║ O(n) linear scan ║
║ ║ Sort ║ O(n log n) Timsort ║
║ ║ Slice [a:b] ║ O(b-a) creates new list ║
║ ║ len() ║ O(1) stored as attribute ║
╠══════════════╬════════════════════╬════════════════════════════════════════╣
║ ║ Get / Set / Delete ║ O(1) avg O(n) worst case ║
║ DICT ║ k in d ║ O(1) avg ║
║ ║ Iterate ║ O(n) ║
║ ║ .keys()/.values() ║ O(1) returns view object ║
╠══════════════╬════════════════════╬════════════════════════════════════════╣
║ ║ add / remove ║ O(1) avg ║
║ SET ║ x in s ║ O(1) avg hash lookup ║
║ ║ Union / Difference ║ O(n+m) ║
║ ║ Intersection ║ O(min(n,m)) ║
╠══════════════╬════════════════════╬════════════════════════════════════════╣
║ ║ append/appendleft ║ O(1) both ends ║
║ DEQUE ║ pop/popleft ║ O(1) both ends ║
║ ║ Access d[i] ║ O(n) NOT O(1)! ║
╠══════════════╬════════════════════╬════════════════════════════════════════╣
║ ║ heappush/heappop ║ O(log n) ║
║ HEAPQ ║ heapify ║ O(n) NOT O(n log n)! ║
║ ║ heap[0] peek ║ O(1) ║
║ ║ nlargest/nsmallest ║ O(n log k) ║
╠══════════════╬════════════════════╬════════════════════════════════════════╣
║ BISECT ║ bisect_left/right ║ O(log n) sorted list only ║
║ ║ insort ║ O(n) search O(log n)+shift O(n) ║
╠══════════════╬════════════════════╬════════════════════════════════════════╣
║ TRIE ║ insert/search ║ O(L) L = word length ║
╠══════════════╬════════════════════╬════════════════════════════════════════╣
║ UNION-FIND ║ find / union ║ O(α(n))≈O(1)path compression+rank ║
╠══════════════╬════════════════════╬════════════════════════════════════════╣
║ ║ BFS / DFS ║ O(V+E) Space O(V) ║
║ GRAPHS ║ Dijkstra ║ O((V+E)logV)with heap ║
║ ║ Topological Sort ║ O(V+E) ║
╚══════════════╩════════════════════╩════════════════════════════════════════╝
Day-Before Revision
Tier 1 — Know These Cold
These appear in almost every Python interview. Write them from memory.
# ── Two Sum ───────────────────────────────────────────────────
def two_sum(nums, target):
seen = {}
for i, num in enumerate(nums):
if target - num in seen:
return [seen[target - num], i]
seen[num] = i
# Time: O(n), Space: O(n)
# ── Sliding Window — Longest No-Repeat Substring ──────────────
def longest_no_repeat(s):
char_idx = {}
left = max_len = 0
for right, ch in enumerate(s):
if ch in char_idx and char_idx[ch] >= left:
left = char_idx[ch] + 1
char_idx[ch] = right
max_len = max(max_len, right - left + 1)
return max_len
# Time: O(n), Space: O(1)
# ── Binary Search ─────────────────────────────────────────────
def binary_search(arr, target):
left, right = 0, len(arr) - 1
while left <= right:
mid = left + (right - left) // 2 # avoids overflow
if arr[mid] == target: return mid
elif arr[mid] < target: left = mid + 1
else: right = mid - 1
return -1
# ── BFS Shortest Path ─────────────────────────────────────────
from collections import deque
def bfs(graph, start, end):
queue = deque([(start, [start])])
visited = {start}
while queue:
node, path = queue.popleft()
if node == end: return path
for nb in graph[node]:
if nb not in visited:
visited.add(nb)
queue.append((nb, path + [nb]))
return []
# ── Backtracking — All Subsets ────────────────────────────────
def subsets(nums):
result = []
def bt(start, curr):
result.append(curr[:]) # copy — not reference!
for i in range(start, len(nums)):
curr.append(nums[i])
bt(i + 1, curr)
curr.pop()
bt(0, [])
return result
# ── DP — Coin Change ──────────────────────────────────────────
def coin_change(coins, amount):
dp = [float('inf')] * (amount + 1)
dp[0] = 0
for i in range(1, amount + 1):
for coin in coins:
if coin <= i:
dp[i] = min(dp[i], dp[i - coin] + 1)
return dp[amount] if dp[amount] != float('inf') else -1
# ── LRU Cache ─────────────────────────────────────────────────
from collections import OrderedDict
class LRUCache:
def __init__(self, cap):
self.cache = OrderedDict()
self.cap = cap
def get(self, key):
if key not in self.cache: return -1
self.cache.move_to_end(key)
return self.cache[key]
def put(self, key, val):
if key in self.cache: self.cache.move_to_end(key)
self.cache[key] = val
if len(self.cache) > self.cap:
self.cache.popitem(last=False) # evict LRU (first item)
# ── Kadane's Algorithm — Max Subarray ─────────────────────────
def max_subarray(nums):
max_sum = curr = nums[0]
for n in nums[1:]:
curr = max(n, curr + n)
max_sum = max(max_sum, curr)
return max_sum
# Time: O(n), Space: O(1)
# ── Merge Intervals ───────────────────────────────────────────
def merge_intervals(intervals):
intervals.sort(key=lambda x: x[0])
merged = [intervals[0]]
for start, end in intervals[1:]:
if start <= merged[-1][1]:
merged[-1][1] = max(merged[-1][1], end)
else:
merged.append([start, end])
return merged
# ── Reverse Linked List ───────────────────────────────────────
def reverse_list(head):
prev, curr = None, head
while curr:
nxt = curr.next
curr.next = prev
prev = curr
curr = nxt
return prev
# ── Tree Max Depth ────────────────────────────────────────────
def max_depth(root):
if not root: return 0
return 1 + max(max_depth(root.left), max_depth(root.right))Tier 2 — High Probability
# ── Union Find ────────────────────────────────────────────────
class UF:
def __init__(self, n):
self.p = list(range(n))
def find(self, x):
if self.p[x] != x: self.p[x] = self.find(self.p[x]) # path compression
return self.p[x]
def union(self, x, y):
px, py = self.find(x), self.find(y)
if px == py: return False
self.p[px] = py
return True
# ── Trie ──────────────────────────────────────────────────────
class TrieNode:
def __init__(self):
self.children = {}
self.is_end = False
class Trie:
def __init__(self): self.root = TrieNode()
def insert(self, word):
node = self.root
for c in word:
node = node.children.setdefault(c, TrieNode())
node.is_end = True
def search(self, word):
node = self.root
for c in word:
if c not in node.children: return False
node = node.children[c]
return node.is_end
def starts_with(self, prefix):
node = self.root
for c in prefix:
if c not in node.children: return False
node = node.children[c]
return True
# ── Monotonic Stack — Next Greater Element ────────────────────
def next_greater(nums):
result = [-1] * len(nums)
stack = [] # stack of indices
for i, val in enumerate(nums):
while stack and nums[stack[-1]] < val:
result[stack.pop()] = val
stack.append(i)
return result
# ── Prefix Sum — Subarray Sum = K ────────────────────────────
def subarray_sum_k(nums, k):
count = prefix = 0
freq = {0: 1} # empty prefix seen once
for num in nums:
prefix += num
count += freq.get(prefix - k, 0)
freq[prefix] = freq.get(prefix, 0) + 1
return count
# ── Cycle Detection — Fast & Slow ────────────────────────────
def has_cycle(head):
slow = fast = head
while fast and fast.next:
slow, fast = slow.next, fast.next.next
if slow == fast: return True
return False
# ── Topological Sort — Kahn's Algorithm ──────────────────────
from collections import deque
def topo_sort(n, edges):
graph = [[] for _ in range(n)]
indegree = [0] * n
for u, v in edges:
graph[u].append(v)
indegree[v] += 1
queue = deque(i for i in range(n) if indegree[i] == 0)
order = []
while queue:
node = queue.popleft()
order.append(node)
for nb in graph[node]:
indegree[nb] -= 1
if indegree[nb] == 0: queue.append(nb)
return order if len(order) == n else [] # empty list = cycle existsAlgorithm Decision Tree
Problem arrives
│
├─ Array + "find pair/subarray" → TWO POINTERS / SLIDING WINDOW
├─ Sorted array + "find target" → BINARY SEARCH
├─ "All combinations/permutations" → BACKTRACKING
├─ "Shortest path" (unweighted) → BFS
├─ "Shortest path" (weighted) → DIJKSTRA
├─ "Connected components" / "cycle" → DFS or UNION-FIND
├─ "Optimal" + overlapping subproblems → DP
├─ "K largest/smallest" → HEAP (min-heap size k)
├─ "Prefix/word search" → TRIE
├─ "Next greater/smaller element" → MONOTONIC STACK
├─ "Range sum query" (static) → PREFIX SUM
├─ "Frequency/counting" → COUNTER / HASH MAP
└─ "Cycle in linked list" → FAST & SLOW POINTERS
Final Checklist
□ Can I write Two Sum from memory in < 2 minutes?
□ Can I trace BFS/DFS on a small graph?
□ Do I know heappush vs heapreplace?
□ Can I explain why [[]] * n is a bug?
□ Do I know when to use deque vs list?
□ Can I implement LRU Cache from scratch?
□ Do I know the complexity of:
list.append() → O(1) amortized
list.pop(0) → O(n) ← use deque!
dict lookup → O(1) average
heappush → O(log n)
sorted() → O(n log n)
□ Have I reviewed mutable default argument gotcha?
□ Do I know when to use @functools.cache?
□ Can I write Union-Find from memory?
□ Have I set sys.setrecursionlimit(10**6) in my head?
□ Am I ready to narrate my thought process out loud?
Data Structures — Foundation Reference
List
Memory Layout of [10, 20, 30]:
list object:
┌─────────────────────────┐
│ ob_size = 3 (length) │
│ allocated = 4 (capacity)│
│ ob_item ──────┐ │
└───────────────│─────────┘
▼
┌──────┬──────┬──────┬──────┐
│ →10 │ →20 │ →30 │ null │ ← pointers, not actual values
└──────┴──────┴──────┴──────┘
Key insight: List stores pointers, not values. That's why [1, "hello", None] is valid.
Amortized resizing pattern: 0 → 4 → 8 → 16 → 24 → 32 → ...
Critical patterns:
# Stack (LIFO) — efficient with list
stack = []
stack.append(x) # push O(1)
stack.pop() # pop O(1)
stack[-1] # peek O(1)
# Queue (FIFO) — NEVER use list.pop(0)
from collections import deque
q = deque()
q.append(x) # enqueue O(1)
q.popleft() # dequeue O(1)
# list.pop(0) is O(n) — every element shifts left
# Slicing
lst[2:5] # [2,3,4] — O(b-a)
lst[::-1] # reverse — creates NEW list
lst[-3:] # last 3 elements
# ⚠️ Traps
grid = [[0]*3]*3 # ❌ shared rows
grid = [[0]*3 for _ in range(3)] # ✅ independent rows
result = [3,1,2].sort() # ❌ result = None
result = sorted([3,1,2]) # ✅ returns new listDict
CPython Dict (Python 3.6+) — "compact dict":
Two arrays:
1. indices (sparse): [slot → entry_index] ← indexed by hash(key) % size
2. entries (dense): [(hash, key, value)] ← append-only → insertion order!
Lookup:
slot = hash(key) % table_size
→ check indices[slot] → get entry index → verify key matches
→ collision? → probe: slot = (5*slot + 1 + perturb) % size
Why dicts preserve insertion order since Python 3.7: the entries array is append-only.
# Must-know patterns
d.get(k, default) # never KeyError
d.setdefault(k, []).append(v) # group-by pattern
Counter(lst).most_common(k) # top k frequent
# defaultdict
from collections import defaultdict
adj = defaultdict(list) # graph adjacency list
freq = defaultdict(int) # frequency counter
# ⚠️ Traps
{True: 'a', 1: 'b'} # {True: 'b'} — True == 1 == same key!
d = {} # empty dict, NOT empty set — use set() for set
# Modify while iterating
for k in list(d.keys()): # ✅ copy keys first
if condition: del d[k]Set
Internally: hash table with only keys (no values).
Same hash collision rules as dict.
empty = set() # NOT {} — that's an empty dict!
s.add(x) # O(1) avg
s.discard(x) # O(1), no error if missing
s.remove(x) # O(1), KeyError if missing
x in s # O(1) — the main reason to use set
s1 & s2 # intersection
s1 | s2 # union
s1 - s2 # difference (in s1, not s2)
s1 ^ s2 # symmetric difference
# ⚠️ Only hashable types — no lists or dicts in sets
visited = set()
visited.add((r, c)) # ✅ tuples are hashable
# visited.add([r, c]) # ❌ TypeErrorDeque (Double-Ended Queue)
from collections import deque
d = deque([1, 2, 3])
d.append(4) # right O(1)
d.appendleft(0) # left O(1)
d.pop() # right O(1)
d.popleft() # left O(1) ← the whole point
d = deque(maxlen=3) # circular buffer — auto-drops oldest
d.append(1); d.append(2); d.append(3)
d.append(4) # deque([2,3,4]) — 1 dropped
# ⚠️ Random access d[i] is O(n) — use list if you need indexingHeapq (Min-Heap)
Binary heap as flat array:
Index: 0 1 2 3 4
Value: [1, 3, 2, 7, 5]
Tree: 1
/ \
3 2
/ \
7 5
Parent of i = (i-1) // 2
Left child = 2*i + 1
Right child = 2*i + 2
import heapq
heap = []
heapq.heappush(heap, 5)
heapq.heappush(heap, 1)
heapq.heappush(heap, 3)
heap[0] # 1 — peek min, O(1)
heapq.heappop(heap) # 1 — remove min, O(log n)
nums = [5, 3, 8, 1, 2]
heapq.heapify(nums) # O(n) — in-place, modifies original!
# Max-heap: negate values
heapq.heappush(h, -val)
largest = -heapq.heappop(h)
# Priority queue with tiebreaker (avoids comparison errors on equal priority)
import itertools
counter = itertools.count()
heapq.heappush(pq, (priority, next(counter), item))
# ⚠️ heapify modifies the list in-place
# ⚠️ No decrease-key — push new entry and skip stale ones in DijkstraIterators and Generators
This comes up frequently in Python interviews.
Iterable vs Iterator
ITERABLE: has __iter__() → returns an iterator
Examples: list, tuple, str, dict, set, range
Can be iterated MULTIPLE times (fresh iterator each time)
ITERATOR: has __next__() → returns next value or raises StopIteration
Also has __iter__() returning itself
Can only go FORWARD — cannot reset
Once exhausted — done forever
lst = [1, 2, 3]
# What a for loop actually does:
_it = iter(lst) # calls lst.__iter__()
while True:
try:
x = next(_it) # calls _it.__next__()
print(x)
except StopIteration:
break
# Test: is it an iterator or just iterable?
lst = [1, 2, 3]
hasattr(lst, '__iter__') # True — iterable
hasattr(lst, '__next__') # False — NOT an iterator
it = iter(lst)
hasattr(it, '__iter__') # True — also iterable
hasattr(it, '__next__') # True — iterator
# Iterators are one-use
it = iter([1, 2, 3])
list(it) # [1, 2, 3]
list(it) # [] — exhausted!
# Iterables are reusable
lst = [1, 2, 3]
list(lst) # [1, 2, 3]
list(lst) # [1, 2, 3] — fresh iterator each timeGenerators
# Generator function — uses yield instead of return
def count_up(n):
i = 1
while i <= n:
yield i # PAUSE here, return value to caller
i += 1 # RESUME here on next next() call
gen = count_up(3)
next(gen) # 1
next(gen) # 2
next(gen) # 3
# next(gen) # StopIteration
# Generator expression — lazy list comprehension
squares = (x**2 for x in range(1_000_000)) # no memory used yet!
next(squares) # 0 — computed on demand
# Memory comparison
import sys
sys.getsizeof([x**2 for x in range(10000)]) # ~87,624 bytes
sys.getsizeof((x**2 for x in range(10000))) # ~112 bytes
# yield from — delegate to sub-generator
def flatten(nested):
for item in nested:
if isinstance(item, (list, tuple)):
yield from flatten(item)
else:
yield item
list(flatten([1, [2, [3, 4]], 5])) # [1, 2, 3, 4, 5]Real use case — process large files in O(1) memory:
def read_large_file(path):
with open(path) as f:
for line in f:
yield line.strip()
for line in read_large_file('10gb.log'):
if 'ERROR' in line:
print(line) # only one line in memory at a timeInterview Questions — Iterators and Generators
Q: What is the difference between iter() and a generator?
iter() wraps an existing sequence. A generator function produces values on-the-fly using yield — nothing is computed until next() is called.
Q: Can a generator be iterated twice?
No. Once exhausted it raises StopIteration forever. You need to call the generator function again to get a fresh generator.
Q: When would you use a generator over a list?
When working with large or infinite sequences where loading everything into memory is impractical — file reading, streaming data, infinite sequences.
Decorators
What Is a Decorator
A decorator is a function that takes a function and returns a modified version of it. The @ syntax is just sugar for func = decorator(func).
import functools
def my_decorator(func):
@functools.wraps(func) # preserves __name__, __doc__
def wrapper(*args, **kwargs):
print("before")
result = func(*args, **kwargs)
print("after")
return result # always return!
return wrapper
@my_decorator # equivalent to: say_hi = my_decorator(say_hi)
def say_hi(name):
print(f"Hi {name}!")
say_hi("Alice")
# before
# Hi Alice!
# afterAlways use @functools.wraps — without it, func.__name__ becomes 'wrapper' which breaks logging and debugging.
Decorator with Arguments (3-level nesting)
def retry(max_attempts=3, delay=1.0):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except Exception as e:
if attempt == max_attempts: raise
print(f"Attempt {attempt} failed. Retrying...")
import time; time.sleep(delay)
return wrapper
return decorator
@retry(max_attempts=3, delay=0.5)
def call_api(url):
...Built-in Decorators
class Circle:
def __init__(self, radius):
self._radius = radius
@property
def radius(self): # getter — access as attribute
return self._radius
@radius.setter
def radius(self, value): # setter — validates on assignment
if value < 0: raise ValueError("Negative radius")
self._radius = value
@property
def area(self): # read-only computed property
import math
return math.pi * self._radius ** 2
c = Circle(5)
c.radius # 5 — calls getter
c.radius = 10 # calls setter (with validation)
c.area # 314.159...
# c.area = 5 # AttributeError — no setter defined
class Pizza:
def __init__(self, ingredients):
self.ingredients = ingredients
@classmethod
def margherita(cls): # factory method — alternative constructor
return cls(['mozzarella', 'tomatoes'])
@staticmethod
def is_vegetarian(ingredients): # utility — no self or cls needed
return 'pepperoni' not in ingredients
p = Pizza.margherita() # no need to call __init__ directly
import functools
@functools.cache # unlimited memoization (Python 3.9+)
def fib(n):
if n <= 1: return n
return fib(n-1) + fib(n-2)
fib(50) # instant — O(n) with cache vs O(2^n) without
fib.cache_info() # CacheInfo(hits=48, misses=51, ...)
fib.cache_clear() # bust the cache
from dataclasses import dataclass, field
@dataclass
class Employee:
name: str
dept: str
salary: float
skills: list = field(default_factory=list) # correct mutable default
# Auto-generates: __init__, __repr__, __eq__
e1 = Employee("Alice", "Eng", 120_000)
e2 = Employee("Alice", "Eng", 120_000)
e1 == e2 # True — compares by value
@dataclass(frozen=True) # immutable — can be used as dict key
class Point:
x: float
y: floatInterview Questions — Decorators
Q: What does @functools.wraps do and why is it important?
It copies __name__, __doc__, and other attributes from the original function to the wrapper. Without it, tools like debuggers, loggers, and help() show wrapper instead of the original function name.
Q: What is the execution order when decorators are stacked?
@outer # applied second (outermost layer)
@inner # applied first (innermost layer)
def func(): pass
# Equivalent to: func = outer(inner(func))
# When called: outer's wrapper runs first → calls inner's wrapper → calls funcQ: Why can't @functools.cache accept a list argument?
Because the cache key is built from the arguments, and lists are not hashable. Use tuples instead.
Q: @staticmethod vs @classmethod?
@staticmethod — no implicit argument. Just a function namespaced inside a class.
@classmethod — receives cls as first argument. Used for factory methods and alternative constructors.
DSA Patterns
Two Pointers
Use when: sorted array, pairs/triplets, palindrome, remove duplicates.
# Pattern A — Converging (opposite ends)
def two_sum_sorted(nums, target):
left, right = 0, len(nums) - 1
while left < right:
s = nums[left] + nums[right]
if s == target: return [left, right]
elif s < target: left += 1
else: right -= 1
return []
# Pattern B — Fast & Slow (same direction)
def remove_duplicates(nums):
slow = 0
for fast in range(1, len(nums)):
if nums[fast] != nums[slow]:
slow += 1
nums[slow] = nums[fast]
return slow + 1
# Pattern C — Floyd's Cycle Detection
def has_cycle(head):
slow = fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow == fast: return True
return FalseSliding Window
Use when: contiguous subarray/substring, max/min with constraint.
# Fixed size
def max_sum_subarray(nums, k):
window = sum(nums[:k])
max_sum = window
for i in range(k, len(nums)):
window += nums[i] - nums[i-k]
max_sum = max(max_sum, window)
return max_sum
# Variable size
def min_subarray_len(target, nums):
left = curr = 0
min_len = float('inf')
for right in range(len(nums)):
curr += nums[right]
while curr >= target:
min_len = min(min_len, right - left + 1)
curr -= nums[left]
left += 1
return min_len if min_len != float('inf') else 0Binary Search
Use when: sorted data, "find minimum X that satisfies condition".
# Classic
def binary_search(arr, target):
left, right = 0, len(arr) - 1
while left <= right:
mid = left + (right - left) // 2
if arr[mid] == target: return mid
elif arr[mid] < target: left = mid + 1
else: right = mid - 1
return -1
# Binary search on ANSWER SPACE — "minimize the maximum"
def ship_within_days(weights, days):
def can_ship(capacity):
day_count, load = 1, 0
for w in weights:
if load + w > capacity:
day_count += 1
load = w
else:
load += w
return day_count <= days
left, right = max(weights), sum(weights)
while left < right:
mid = left + (right - left) // 2
if can_ship(mid): right = mid
else: left = mid + 1
return leftBFS and DFS
from collections import deque
# BFS — level-by-level (shortest path, level order)
def bfs_levels(root):
if not root: return []
result, queue = [], deque([root])
while queue:
level = []
for _ in range(len(queue)): # process entire level
node = queue.popleft()
level.append(node.val)
if node.left: queue.append(node.left)
if node.right: queue.append(node.right)
result.append(level)
return result
# DFS — grid flood fill
def num_islands(grid):
rows, cols = len(grid), len(grid[0])
count = 0
def dfs(r, c):
if r<0 or r>=rows or c<0 or c>=cols or grid[r][c]!='1': return
grid[r][c] = '#' # mark visited
for dr, dc in [(0,1),(0,-1),(1,0),(-1,0)]:
dfs(r+dr, c+dc)
for r in range(rows):
for c in range(cols):
if grid[r][c] == '1':
count += 1
dfs(r, c)
return countBacktracking
Use when: "find ALL solutions", permutations, subsets, combinations.
# Universal template
def backtrack(start, current, result, choices):
result.append(current[:]) # ← copy, not reference!
for i in range(start, len(choices)):
current.append(choices[i]) # choose
backtrack(i + 1, current, result, choices)
current.pop() # undo
# Combination sum (can reuse elements)
def combination_sum(candidates, target):
result = []
candidates.sort()
def bt(start, curr, remaining):
if remaining == 0:
result.append(curr[:])
return
for i in range(start, len(candidates)):
if candidates[i] > remaining: break # pruning
curr.append(candidates[i])
bt(i, curr, remaining - candidates[i]) # i not i+1 = reuse allowed
curr.pop()
bt(0, [], target)
return resultDynamic Programming
# Top-down (memoization)
import functools
@functools.cache
def fib(n):
if n <= 1: return n
return fib(n-1) + fib(n-2)
# Bottom-up (tabulation)
def coin_change(coins, amount):
dp = [float('inf')] * (amount + 1)
dp[0] = 0
for i in range(1, amount + 1):
for coin in coins:
if coin <= i:
dp[i] = min(dp[i], dp[i - coin] + 1)
return dp[amount] if dp[amount] != float('inf') else -1
# LCS — 2D DP
def lcs(text1, text2):
m, n = len(text1), len(text2)
dp = [[0] * (n+1) for _ in range(m+1)]
for i in range(1, m+1):
for j in range(1, n+1):
if text1[i-1] == text2[j-1]:
dp[i][j] = dp[i-1][j-1] + 1
else:
dp[i][j] = max(dp[i-1][j], dp[i][j-1])
return dp[m][n]
# Knapsack (iterate weight BACKWARDS for 0/1 variant)
def knapsack(weights, values, capacity):
dp = [0] * (capacity + 1)
for i in range(len(weights)):
for w in range(capacity, weights[i]-1, -1): # ← backwards!
dp[w] = max(dp[w], dp[w - weights[i]] + values[i])
return dp[capacity]Common DP formulas:
| Problem | Recurrence |
|---|---|
| Fibonacci | dp[i] = dp[i-1] + dp[i-2] |
| House Robber | dp[i] = max(dp[i-1], dp[i-2] + nums[i]) |
| Coin Change | dp[i] = min(dp[i-coin] + 1) for each coin |
| LCS | dp[i][j] = dp[i-1][j-1]+1 if match, else max(dp[i-1][j], dp[i][j-1]) |
| Knapsack | dp[w] = max(dp[w], dp[w-wt]+val) — iterate w backwards |
Monotonic Stack
Use when: "next greater/smaller element", "largest rectangle", "trapped water".
# Next greater element — decreasing stack
def next_greater(nums):
result = [-1] * len(nums)
stack = []
for i, val in enumerate(nums):
while stack and nums[stack[-1]] < val:
result[stack.pop()] = val # val is next greater for popped index
stack.append(i)
return result
# Time: O(n) — each element pushed/popped at most once
# Sliding window max — monotonic deque
from collections import deque
def sliding_window_max(nums, k):
result, dq = [], deque()
for i in range(len(nums)):
while dq and dq[0] < i - k + 1: dq.popleft() # out of window
while dq and nums[dq[-1]] < nums[i]: dq.pop() # smaller, useless
dq.append(i)
if i >= k - 1: result.append(nums[dq[0]])
return resultQuick Reference — Critical Imports
from collections import Counter, defaultdict, deque, OrderedDict
from itertools import combinations, permutations, product, accumulate, chain, islice
import heapq, bisect, math, functools, sys
sys.setrecursionlimit(10**6) # set this before deep recursion problemsThis document was created with ❤️ for Python Developers who want to crack backend interviews, by Anubhaw. Feel free to suggest improvements or connect @ ContactMe