Python Interview Questions with Answers
Most Asked Python Interview Questions for Software Engineer Roles
Introduction
This page provides a complete collection of Python Interview Questions and Answers designed for Python developers, software engineers, data scientists, automation engineers, and candidates preparing for technical interviews. Python is a high-level, interpreted, and general-purpose programming language known for its simple syntax, powerful libraries, and wide range of applications including web development, automation, artificial intelligence, machine learning, data science, and backend development. This interview guide covers beginner, intermediate, and advanced Python concepts including Python fundamentals, OOP concepts, functions, decorators, generators, exception handling, collections, file handling, multithreading, multiprocessing, Django, Flask, APIs, machine learning concepts, and real-world coding interview problems.
Why Python?
- Simple, readable syntax that accelerates development
- Vast ecosystem of libraries for every domain (NumPy, Pandas, Django, TensorFlow, etc.)
- Highly versatile – from web development to AI and data science
- Strong community support and extensive documentation
- Widely used in industry, making it a top skill for technical interviews
Most Asked Python Interview Questions
1. What is Python? What are the benefits of using Python?
Python is a high-level, interpreted, and general-purpose programming language designed to be simple, readable, and easy to learn. It was created by Guido van Rossum and released in 1991.
Python emphasizes code readability and allows developers to write programs with fewer lines of code compared to languages like C++ or Java. It supports multiple programming paradigms:
- Object-Oriented Programming (OOP)
- Procedural Programming
- Functional Programming
Python is widely used in web development, data science, artificial intelligence, automation, and software development.
Benefits of Using Python
- Easy to Learn and Use: Simple and readable syntax makes it beginner-friendly.
- Interpreted Language: Executed line-by-line, making debugging easier.
- Platform Independent: Runs on Windows, Linux, and macOS without changes.
- Used in Modern Technologies
- Artificial Intelligence & Machine Learning
- Data Science & Analytics
- Web Development
- Automation & Scripting
Python is both compiled and interpreted, but it is commonly referred to as an interpreted language.
Compilation StepWhen you run a Python program, the source code (.py file) is first compiled into bytecode (.pyc files). This step happens automatically and is not visible to the user.
Interpretation StepThe generated bytecode is then executed by the Python Virtual Machine (PVM) line by line.
How Python Works Internally- You write code
→ example.py - Python compiles it
→ Bytecode (.pyc) - Python Virtual Machine executes it
A dynamically typed language is a programming language where the type of a variable is determined at runtime.
- You don't need to declare the data type.
- The language decides it during execution.
x = 10 # x is an integer
x = "Hello" # now x becomes a stringIn Python, indentation is not just for readability — it is a mandatory part of the syntax used to define blocks of code.
Unlike other languages that use curly braces {}, Python uses indentation (spaces or tabs) to group statements together.
- Defines code blocks for loops, functions, and conditionals
- Inconsistent indentation raises an
IndentationError - Standard convention is 4 spaces per level
# Python indentation example
if True:
print("Inside block")
print("Still inside")
print("Outside block")Python provides several built-in data types to store different kinds of values.
- int — Integer numbers
- float — Decimal numbers
- str — Text / String
- bool — True or False
- list — Ordered, mutable collection
- tuple — Ordered, immutable collection
- dict — Key-value pairs
- set — Unordered unique elements
# Python data types
x = 10 # int
y = 3.14 # float
name = "Python" # str
flag = True # bool
nums = [1,2,3] # list
info = {"a":1} # dict
t = (1, 2) # tuple
s = {1, 2, 3} # setMutable objects can be changed after creation. Immutable objects cannot be modified once created.
- Mutable: list, dict, set
- Immutable: int, float, str, tuple, bool
Immutable objects are hashable and can be used as dictionary keys, while mutable objects cannot.
# Mutable vs Immutable
# Mutable
my_list = [1, 2, 3]
my_list[0] = 99
print(my_list) # [99, 2, 3]
# Immutable
my_tuple = (1, 2, 3)
# my_tuple[0] = 99 # TypeErrorBoth List and Tuple are ordered sequences in Python, but they differ in mutability.
- List is mutable — elements can be added, removed, or changed
- Tuple is immutable — once created, it cannot be changed
- Tuples are faster and use less memory than lists
- Tuples can be used as dictionary keys; lists cannot
# List vs Tuple
my_list = [1, 2, 3] # mutable
my_tuple = (1, 2, 3) # immutable
my_list.append(4)
print(my_list) # [1, 2, 3, 4]
# my_tuple.append(4) # AttributeErrorA Dictionary is an unordered collection of key-value pairs. Each key must be unique and immutable.
Dictionaries are used for fast lookups, counting frequencies, and representing structured data.
- Keys must be immutable (str, int, tuple)
- Values can be any type
- Average O(1) lookup, insert, delete
- Ordered by insertion order since Python 3.7
# Dictionary example
student = {
"name": "Alice",
"age": 22,
"grade": "A"
}
print(student["name"]) # Alice
student["age"] = 23
print(student)List Comprehension provides a concise way to create lists from existing iterables in a single line.
It is more readable and typically faster than using a traditional for loop to build a list.
- Syntax:
[expression for item in iterable if condition] - Can include optional filtering conditions
- More Pythonic than using
map()andfilter() - Can be nested for multi-dimensional data
# List comprehension
squares = [x**2 for x in range(1, 6)]
print(squares) # [1, 4, 9, 16, 25]
evens = [x for x in range(10) if x % 2 == 0]
print(evens) # [0, 2, 4, 6, 8]A Lambda function is an anonymous, single-expression function defined using the lambda keyword.
Lambda functions are useful for short, throwaway functions especially as arguments to higher-order functions.
- Syntax:
lambda arguments: expression - Can take multiple arguments
- Returns the value of the expression automatically
- Commonly used with
map(),filter(),sorted()
# Lambda function
square = lambda x: x ** 2
print(square(5)) # 25
add = lambda a, b: a + b
print(add(3, 4)) # 7*args allows a function to accept any number of positional arguments as a tuple.
**kwargs allows a function to accept any number of keyword arguments as a dictionary.
*argscollects extra positional arguments**kwargscollects extra keyword arguments- Both can be combined in the same function
- Useful for flexible and generic function signatures
# *args and **kwargs
def greet(*args):
for name in args:
print(f"Hello, {name}!")
greet("Alice", "Bob", "Charlie")
def show_info(**kwargs):
for key, value in kwargs.items():
print(f"{key}: {value}")
show_info(name="Alice", age=22)OOP is a programming paradigm that organizes code around objects and classes rather than functions and procedures.
Python supports OOP with classes, objects, inheritance, encapsulation, and polymorphism.
- Class — blueprint for creating objects
- Object — instance of a class
__init__— constructor methodself— reference to the current instance
# OOP - Class and Object
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
print(f"{self.name} makes a sound")
dog = Animal("Dog")
dog.speak()Inheritance allows a child class to inherit attributes and methods from a parent class, enabling code reuse.
Python supports single, multiple, multi-level, and hierarchical inheritance.
- Child class extends parent class
- Use
super()to call parent methods - Method overriding allows customizing behavior
- Promotes the DRY (Don't Repeat Yourself) principle
# Inheritance
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
print(f"{self.name} makes a sound")
class Dog(Animal):
def speak(self):
print(f"{self.name} says Woof!")
d = Dog("Buddy")
d.speak()Encapsulation is the concept of hiding the internal details of an object and restricting direct access to its data.
In Python, it is achieved using private attributes (prefixed with __) and public getter/setter methods.
- Single underscore
_var— convention for protected - Double underscore
__var— name mangling (private) - Use getters and setters to control access
- Protects data integrity
# Encapsulation
class BankAccount:
def __init__(self, balance):
self.__balance = balance # private
def get_balance(self):
return self.__balance
def deposit(self, amount):
self.__balance += amount
acc = BankAccount(1000)
acc.deposit(500)
print(acc.get_balance()) # 1500Polymorphism means "many forms" — the same method name behaves differently based on the object calling it.
Python achieves polymorphism through method overriding in subclasses and duck typing.
- Same interface, different implementations
- Enables writing generic code
- Supports duck typing ("if it quacks like a duck...")
- Used heavily in Python's built-in functions like
len()
# Polymorphism
class Cat:
def speak(self):
return "Meow"
class Dog:
def speak(self):
return "Woof"
animals = [Cat(), Dog()]
for a in animals:
print(a.speak())Abstraction hides complex implementation details and only exposes what is necessary to the user.
In Python, abstraction is achieved using Abstract Base Classes (ABC) from the abc module.
- Abstract methods must be implemented by subclasses
- Cannot instantiate an abstract class directly
- Defines a contract for subclasses
- Improves code maintainability
# Abstraction
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self):
pass
class Circle(Shape):
def __init__(self, r):
self.r = r
def area(self):
return 3.14 * self.r ** 2
c = Circle(5)
print(c.area())A Decorator is a function that takes another function as input, adds extra behavior, and returns it.
Decorators use the @ syntax and are commonly used for logging, authentication, caching, and timing.
- Functions are first-class objects in Python
- Decorators wrap functions without modifying their code
- Can be stacked (multiple decorators)
- Built-in decorators:
@staticmethod,@classmethod,@property
# Decorators
def my_decorator(func):
def wrapper():
print("Before function")
func()
print("After function")
return wrapper
@my_decorator
def say_hello():
print("Hello!")
say_hello()A Generator is a function that uses yield to return values one at a time, pausing execution between each yield.
Generators are memory-efficient because they produce items lazily on demand instead of storing the entire sequence.
- Uses
yieldinstead ofreturn - Returns a generator object (iterator)
- Ideal for large data streams
- Supports generator expressions like list comprehensions
# Generators
def count_up(n):
i = 1
while i <= n:
yield i
i += 1
gen = count_up(5)
for num in gen:
print(num)Exception Handling allows programs to gracefully handle runtime errors instead of crashing.
Python uses try, except, else, and finally blocks for error management.
try— code that might raise an exceptionexcept— handles specific or general exceptionselse— runs if no exception occurredfinally— always runs (cleanup code)
# Exception Handling
try:
result = 10 / 0
except ZeroDivisionError as e:
print(f"Error: {e}")
except ValueError as e:
print(f"Value Error: {e}")
finally:
print("This always runs")Python provides built-in functions to create, read, write, and close files using the open() function.
The with statement (context manager) is the recommended way to handle files as it ensures automatic closing.
"r"— read mode"w"— write mode (overwrites)"a"— append mode"rb"/"wb"— binary modes
# File Handling
# Write to file
with open("test.txt", "w") as f:
f.write("Hello, Python!")
# Read from file
with open("test.txt", "r") as f:
content = f.read()
print(content)These are higher-order functions that operate on iterables and are core to functional programming in Python.
- map() — applies a function to every element
- filter() — returns elements that satisfy a condition
- reduce() — reduces a sequence to a single value
reduce() requires importing from functools in Python 3.
# Map, Filter, Reduce
from functools import reduce
nums = [1, 2, 3, 4, 5]
doubled = list(map(lambda x: x * 2, nums))
print(doubled) # [2, 4, 6, 8, 10]
evens = list(filter(lambda x: x % 2 == 0, nums))
print(evens) # [2, 4]
total = reduce(lambda a, b: a + b, nums)
print(total) # 15An Iterator is an object that implements the __iter__() and __next__() methods to traverse elements one at a time.
All iterators are also iterables, but not all iterables are iterators.
__iter__returns the iterator object itself__next__returns the next element- Raises
StopIterationwhen exhausted - Generators are a simple way to create iterators
# Iterators
class Counter:
def __init__(self, low, high):
self.low = low
self.high = high
def __iter__(self):
return self
def __next__(self):
if self.low > self.high:
raise StopIteration
val = self.low
self.low += 1
return val
for num in Counter(1, 5):
print(num)A Shallow Copy creates a new object but references the same nested objects as the original.
A Deep Copy creates a completely independent clone of the original object and all nested objects.
copy.copy()— shallow copycopy.deepcopy()— deep copy- Shallow copy is faster but shares inner references
- Deep copy is slower but fully independent
# Shallow vs Deep Copy
import copy
original = [[1, 2], [3, 4]]
shallow = copy.copy(original)
deep = copy.deepcopy(original)
original[0][0] = 99
print(shallow) # [[99, 2], [3, 4]] - affected
print(deep) # [[1, 2], [3, 4]] - not affectedA Local variable is defined inside a function and accessible only within that function.
A Global variable is defined outside any function and accessible throughout the module.
- Use
globalkeyword to modify a global variable inside a function - Use
nonlocalkeyword to modify enclosing scope variable in nested functions - Local scope takes priority over global scope
- Avoid excessive global variables for cleaner code
# Global and Local Variables
x = "global"
def my_func():
x = "local"
print(x) # local
my_func()
print(x) # global
def modify_global():
global x
x = "modified"
modify_global()
print(x) # modifiedSlicing extracts a portion of a sequence (list, string, tuple) using the syntax [start:stop:step].
It is a powerful and concise way to work with subsequences without modifying the original.
start— inclusive start index (default 0)stop— exclusive end index (default end)step— increment between elements- Negative step reverses the sequence
# Slicing
my_list = [0, 1, 2, 3, 4, 5]
print(my_list[1:4]) # [1, 2, 3]
print(my_list[:3]) # [0, 1, 2]
print(my_list[3:]) # [3, 4, 5]
print(my_list[::2]) # [0, 2, 4]
print(my_list[::-1]) # [5, 4, 3, 2, 1, 0]Python strings are immutable sequences of characters with many built-in methods for manipulation and analysis.
strip()— removes leading/trailing whitespacesplit()— splits string into a listjoin()— joins list elements into a stringreplace()— replaces substringsfind()/index()— search for substringstartswith()/endswith()— prefix/suffix check
# String Methods
s = " Hello, Python! "
print(s.strip()) # "Hello, Python!"
print(s.lower()) # " hello, python! "
print(s.upper()) # " HELLO, PYTHON! "
print(s.replace("Python", "World"))
print(s.split(",")) # [' Hello', ' Python! ']
print("Python" in s) # Truef-strings (formatted string literals) are the modern way to embed expressions inside strings using f"..." syntax.
Introduced in Python 3.6, they are faster and more readable than format() or % formatting.
- Prefix string with
forF - Embed expressions inside
{} - Support format specifiers like
:.2f - Can call functions inside the braces
# f-strings
name = "Alice"
age = 22
print(f"Name: {name}, Age: {age}")
print(f"5 + 3 = {5 + 3}")
print(f"Pi is approximately {3.14159:.2f}")enumerate() adds a counter to an iterable and returns it as an enumerate object with index-value pairs.
zip() combines multiple iterables element-by-element into tuples, stopping at the shortest iterable.
enumerate()is great for loops needing both index and valuezip()is great for parallel iteration- Both return lazy iterators
zip()can be used to unzip withzip(*pairs)
# enumerate and zip
fruits = ["apple", "banana", "cherry"]
for i, fruit in enumerate(fruits, start=1):
print(f"{i}. {fruit}")
names = ["Alice", "Bob"]
scores = [95, 87]
for name, score in zip(names, scores):
print(f"{name}: {score}")Dictionary Comprehension provides a concise way to create dictionaries from iterables in a single expression.
It follows the same pattern as list comprehension but uses curly braces with key-value pairs.
- Syntax:
{key: value for item in iterable if condition} - More concise than using a loop with
dict.update() - Can transform and filter existing dictionaries
- More Pythonic way to build dictionaries
# Dictionary Comprehension
squares = {x: x**2 for x in range(1, 6)}
print(squares)
# {1: 1, 2: 4, 3: 9, 4: 16, 5: 25}
filtered = {k: v for k, v in squares.items() if v > 5}
print(filtered)
# {3: 9, 4: 16, 5: 25}Python Sets are unordered collections of unique elements that support mathematical set operations.
Sets are optimized for membership testing and eliminating duplicates efficiently.
|orunion()— all elements from both sets&orintersection()— common elements-ordifference()— elements in first but not second^orsymmetric_difference()— elements in either but not both
# Set Operations
a = {1, 2, 3, 4}
b = {3, 4, 5, 6}
print(a | b) # Union: {1,2,3,4,5,6}
print(a & b) # Intersection: {3, 4}
print(a - b) # Difference: {1, 2}
print(a ^ b) # Symmetric: {1,2,5,6}Recursion is when a function calls itself to solve a smaller version of the same problem.
Every recursive function needs a base case to prevent infinite recursion. Python has a default recursion limit of 1000.
- Base case — stops the recursion
- Recursive case — reduces the problem
- Python default limit:
sys.setrecursionlimit() - Can be replaced by iterative + stack for large inputs
# Recursion
def factorial(n):
if n == 0:
return 1
return n * factorial(n - 1)
print(factorial(5)) # 120
def fibonacci(n):
if n <= 1:
return n
return fibonacci(n-1) + fibonacci(n-2)
print(fibonacci(7)) # 13Python provides two main sorting methods: sorted() (returns a new list) and .sort() (sorts in-place).
Both use the Timsort algorithm with O(n log n) time complexity and support custom sort keys.
sorted()— works on any iterable, returns new list.sort()— only for lists, modifies in-placekeyparameter — custom sorting functionreverse=True— descending order
# Sorting
nums = [3, 1, 4, 1, 5, 9, 2, 6]
print(sorted(nums)) # ascending
print(sorted(nums, reverse=True)) # descending
students = [("Alice", 85), ("Bob", 92), ("Charlie", 78)]
students.sort(key=lambda x: x[1], reverse=True)
print(students)In advanced usage, *args and **kwargs can be combined with regular parameters and used for function composition.
They are also used for unpacking arguments when calling functions using the * and ** operators.
- Order:
def f(pos, *args, kw_only, **kwargs) - Useful in decorators and wrapper functions
- Enables highly flexible API design
- Can unpack with
f(*list_args, **dict_kwargs)
# *args and **kwargs advanced
def mixed(a, b, *args, **kwargs):
print(f"a={a}, b={b}")
print(f"args={args}")
print(f"kwargs={kwargs}")
mixed(1, 2, 3, 4, 5, name="Alice", age=22)A Context Manager defines a runtime context for executing code, most commonly used with the with statement.
It ensures proper resource management (like file closing or DB connections) using __enter__ and __exit__ methods.
__enter__— called when entering thewithblock__exit__— called when leaving the block (even on error)- Can also be created using
@contextmanagerdecorator - Common use: file handling, locks, database connections
# Context Manager
class ManagedFile:
def __init__(self, name):
self.name = name
def __enter__(self):
self.file = open(self.name, "w")
return self.file
def __exit__(self, exc_type, exc_val, exc_tb):
self.file.close()
print("File closed")
with ManagedFile("demo.txt") as f:
f.write("Hello from context manager")The @property decorator allows a method to be accessed like an attribute, enabling controlled access to private data.
It is used to implement getters, setters, and deleters in a Pythonic way without calling methods explicitly.
@property— defines a getter@attr.setter— defines a setter with validation@attr.deleter— defines a deleter- Enables attribute-style access while maintaining encapsulation
# Property Decorator
class Temperature:
def __init__(self, celsius):
self._celsius = celsius
@property
def celsius(self):
return self._celsius
@celsius.setter
def celsius(self, value):
if value < -273.15:
raise ValueError("Too cold!")
self._celsius = value
@property
def fahrenheit(self):
return self._celsius * 9/5 + 32
t = Temperature(25)
print(t.fahrenheit) # 77.0
t.celsius = 30
print(t.celsius) # 30A class method receives the class cls as the first argument and can access/modify class state.
A static method receives no implicit first argument and behaves like a regular function inside the class namespace.
@classmethod— used for factory methods and class-level logic@staticmethod— used for utility functions related to the class- Class methods can modify class variables
- Static methods cannot access class or instance variables
# Class Methods and Static Methods
class MathUtils:
multiplier = 2
@classmethod
def multiply(cls, x):
return cls.multiplier * x
@staticmethod
def add(a, b):
return a + b
print(MathUtils.multiply(5)) # 10
print(MathUtils.add(3, 4)) # 7Dunder methods (double underscore methods) are special methods that Python calls automatically for built-in operations.
They allow custom objects to emulate built-in types and integrate seamlessly with Python's operators.
__init__— constructor__str__/__repr__— string representation__add__,__mul__— operator overloading__len__,__getitem__— sequence protocol
# Dunder / Magic Methods
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __add__(self, other):
return Vector(self.x + other.x, self.y + other.y)
def __str__(self):
return f"Vector({self.x}, {self.y})"
def __len__(self):
return 2
v1 = Vector(1, 2)
v2 = Vector(3, 4)
print(v1 + v2) # Vector(4, 6)
print(len(v1)) # 2Multiple Inheritance allows a class to inherit from more than one parent class simultaneously.
Python uses the MRO (Method Resolution Order) with the C3 linearization algorithm to determine which method is called.
- MRO defines the search order for methods
- Use
ClassName.__mro__to view the order - The Diamond Problem is resolved by MRO
super()follows MRO automatically
# Multiple Inheritance
class A:
def hello(self):
print("Hello from A")
class B(A):
def hello(self):
print("Hello from B")
class C(A):
def hello(self):
print("Hello from C")
class D(B, C):
pass
d = D()
d.hello() # Hello from B (MRO)
print(D.__mro__)The Walrus Operator (:=) is the assignment expression operator introduced in Python 3.8.
It allows assignment inside expressions, reducing redundant variable evaluations in loops and comprehensions.
- Assigns and returns value in one expression
- Useful in
whileloops to avoid double evaluation - Can simplify list comprehensions with intermediate values
- Should be used sparingly to maintain readability
# Walrus Operator :=
import re
data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
# Without walrus
filtered = [y for x in data if (y := x * 2) > 10]
print(filtered)
# Useful in while loops
while chunk := input("Enter text (empty to stop): "):
print(f"You entered: {chunk}")Type Hints allow you to annotate function parameters and return values with expected types, improving code clarity.
Introduced in Python 3.5, they are not enforced at runtime but are used by tools like mypy for static type checking.
- Use
:for parameter types and->for return types - Import complex types from
typingmodule - Improves IDE autocomplete and error detection
- Checked by tools like mypy, pyright, pylance
# Type Hints
def greet(name: str) -> str:
return f"Hello, {name}!"
def add(a: int, b: int) -> int:
return a + b
from typing import List, Dict, Optional
def process(items: List[int]) -> Dict[str, int]:
return {"sum": sum(items), "count": len(items)}
print(greet("Alice"))
print(add(3, 4))
print(process([1, 2, 3, 4]))Dataclasses (introduced in Python 3.7) automatically generate boilerplate code like __init__, __repr__, and __eq__ for classes that primarily store data.
They reduce repetitive code while providing a clean way to define data-centric classes.
- Use
@dataclassdecorator - Auto-generates
__init__,__repr__,__eq__ - Support default values with
field() - Can be made immutable with
frozen=True
# Dataclasses
from dataclasses import dataclass, field
@dataclass
class Student:
name: str
age: int
grades: list = field(default_factory=list)
def average(self) -> float:
return sum(self.grades) / len(self.grades) if self.grades else 0
s = Student("Alice", 22, [85, 90, 92])
print(s)
print(s.average()) # 89.0A Named Tuple is a subclass of tuple that assigns names to each position, making code more readable and self-documenting.
It combines the immutability of tuples with the readability of attribute access.
- Access by name:
p.xor by index:p[0] - Immutable like regular tuples
- More memory-efficient than a dict
- Available in
collectionsmodule
# Named Tuple
from collections import namedtuple
Point = namedtuple("Point", ["x", "y"])
p = Point(3, 4)
print(p.x, p.y) # 3 4
print(p[0], p[1]) # 3 4
distance = (p.x**2 + p.y**2) ** 0.5
print(f"Distance: {distance}") # 5.0Counter is a subclass of dictionary that counts the occurrences of elements in an iterable.
It is one of the most useful tools for frequency analysis and is commonly used in coding interviews.
- Returns a dict-like object with element counts
most_common(n)— returns top n elements- Supports arithmetic operations (+, -, &, |)
- Available in
collectionsmodule
# Counter
from collections import Counter
words = ["apple", "banana", "apple", "cherry", "banana", "apple"]
count = Counter(words)
print(count) # Counter({'apple': 3, ...})
print(count.most_common(2)) # [('apple', 3), ('banana', 2)]
print(count["cherry"]) # 1defaultdict is a subclass of dict that provides a default value for missing keys automatically, avoiding KeyError.
It is very useful for grouping, counting, and building graph adjacency lists.
- Pass a default factory:
int,list,set - Accessing a missing key creates it with the default
- Avoids boilerplate
if key not in dictchecks - Available in
collectionsmodule
# defaultdict
from collections import defaultdict
word_count = defaultdict(int)
sentence = "the cat sat on the mat the cat"
for word in sentence.split():
word_count[word] += 1
print(dict(word_count))
graph = defaultdict(list)
graph["A"].append("B")
graph["A"].append("C")
print(dict(graph))OrderedDict is a dictionary subclass that remembers the insertion order of keys (most useful before Python 3.7).
It provides extra methods like move_to_end() which are not available in regular dicts.
- Maintains insertion order explicitly
move_to_end(key)— moves item to front or back- Used in LRU Cache implementation
- Available in
collectionsmodule
# OrderedDict
from collections import OrderedDict
od = OrderedDict()
od["banana"] = 3
od["apple"] = 2
od["cherry"] = 5
for key, value in od.items():
print(f"{key}: {value}")
od.move_to_end("banana")
print(list(od.keys()))Regular Expressions (regex) are sequences of characters that define search patterns for string matching and manipulation.
Python's re module provides functions to search, match, find, replace, and split strings using regex patterns.
re.search()— find pattern anywhere in stringre.findall()— return all matches as listre.sub()— replace pattern with stringre.compile()— precompile pattern for reuse
# Regular Expressions
import re
text = "My phone is 123-456-7890 and backup is 987-654-3210"
# Find all phone numbers
phones = re.findall(r"d{3}-d{3}-d{4}", text)
print(phones)
# Search
match = re.search(r"d+", text)
print(match.group()) # 123
# Replace
clean = re.sub(r"d{3}-d{3}-d{4}", "XXX-XXX-XXXX", text)
print(clean)Threading allows concurrent execution of multiple threads within the same process, sharing memory space.
Due to the GIL (Global Interpreter Lock), Python threads are best for I/O-bound tasks, not CPU-bound tasks.
- Use
threading.Threadto create threads - GIL prevents true parallelism for CPU-bound tasks
- Great for I/O-bound tasks (network, file operations)
- Use
Lockto prevent race conditions
# Threading
import threading
import time
def worker(name, delay):
print(f"{name} started")
time.sleep(delay)
print(f"{name} finished")
t1 = threading.Thread(target=worker, args=("Thread-1", 2))
t2 = threading.Thread(target=worker, args=("Thread-2", 1))
t1.start()
t2.start()
t1.join()
t2.join()
print("All threads done")Multiprocessing creates separate processes with their own memory space, bypassing the GIL for true parallelism.
It is ideal for CPU-bound tasks like scientific computations and data processing.
- Each process has its own Python interpreter and memory
- Bypasses GIL — true parallelism
Pool.map()— parallel function execution- Higher overhead than threads (separate memory)
# Multiprocessing
from multiprocessing import Process, Pool
import os
def square(n):
return n * n
def show_pid(name):
print(f"{name}: PID={os.getpid()}")
if __name__ == "__main__":
with Pool(4) as p:
results = p.map(square, [1, 2, 3, 4, 5])
print(results) # [1, 4, 9, 16, 25]Asyncio is Python's built-in library for writing concurrent code using the async/await syntax.
It uses a single thread with an event loop to handle many I/O operations concurrently, making it very efficient for network applications.
async def— defines a coroutineawait— suspends coroutine until result is readyasyncio.gather()— runs multiple coroutines concurrently- Best for I/O-bound tasks with many concurrent connections
# Asyncio
import asyncio
async def fetch_data(name, delay):
print(f"Fetching {name}...")
await asyncio.sleep(delay)
print(f"{name} done!")
return f"Data from {name}"
async def main():
results = await asyncio.gather(
fetch_data("API-1", 2),
fetch_data("API-2", 1),
fetch_data("API-3", 3)
)
print(results)
asyncio.run(main())The Singleton Pattern ensures that a class has only one instance throughout the application's lifecycle.
In Python, it is implemented by overriding __new__ to check if an instance already exists before creating a new one.
- Only one instance ever created
- Global access point to that instance
- Common use: database connections, config managers, logging
- Can also be implemented with metaclasses or modules
# Singleton Pattern
class Singleton:
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
s1 = Singleton()
s2 = Singleton()
print(s1 is s2) # TrueThe Factory Pattern provides an interface for creating objects without specifying the exact class to be instantiated.
It centralizes object creation logic and makes it easy to add new types without modifying client code.
- Decouples object creation from usage
- Promotes Open/Closed Principle
- Makes code more extensible
- Common in frameworks and plugin systems
# Factory Pattern
class Dog:
def speak(self): return "Woof!"
class Cat:
def speak(self): return "Meow!"
class AnimalFactory:
@staticmethod
def create(animal_type):
animals = {"dog": Dog, "cat": Cat}
cls = animals.get(animal_type.lower())
if cls:
return cls()
raise ValueError(f"Unknown animal: {animal_type}")
animal = AnimalFactory.create("dog")
print(animal.speak()) # Woof!The Observer Pattern defines a one-to-many dependency where multiple observers are notified when the subject's state changes.
It is the foundation of event-driven programming and reactive systems.
- Decouples publisher from subscribers
- Used in event systems, GUI frameworks, and message queues
- Supports multiple listeners per event
- Basis of Python's
signallibraries
# Observer Pattern
class EventEmitter:
def __init__(self):
self._listeners = {}
def on(self, event, callback):
self._listeners.setdefault(event, []).append(callback)
def emit(self, event, *args):
for cb in self._listeners.get(event, []):
cb(*args)
emitter = EventEmitter()
emitter.on("data", lambda x: print(f"Received: {x}"))
emitter.on("data", lambda x: print(f"Logged: {x}"))
emitter.emit("data", "Hello!")Binary Search efficiently finds a target in a sorted array by repeatedly dividing the search space in half.
It achieves O(log n) time complexity, making it much faster than linear search for large datasets.
- Requires sorted input
- Time Complexity O(log n)
- Space Complexity O(1) iterative
- Python has
bisectmodule for binary search
# Binary Search
def binary_search(arr, target):
left, right = 0, len(arr) - 1
while left <= right:
mid = (left + right) // 2
if arr[mid] == target:
return mid
elif arr[mid] < target:
left = mid + 1
else:
right = mid - 1
return -1
arr = [1, 3, 5, 7, 9, 11, 13]
print(binary_search(arr, 7)) # 3
print(binary_search(arr, 6)) # -1Bubble Sort repeatedly swaps adjacent elements if they are in the wrong order, gradually moving larger elements to the end.
It is the simplest sorting algorithm but inefficient for large datasets.
- Time Complexity O(n²)
- Space Complexity O(1)
- Stable sorting algorithm
- Best for educational purposes and small datasets
# Bubble Sort
def bubble_sort(arr):
n = len(arr)
for i in range(n):
for j in range(0, n-i-1):
if arr[j] > arr[j+1]:
arr[j], arr[j+1] = arr[j+1], arr[j]
return arr
print(bubble_sort([64, 34, 25, 12, 22, 11, 90]))Merge Sort is a divide and conquer algorithm that splits the array in half, recursively sorts each half, and merges them.
It guarantees O(n log n) performance in all cases, making it reliable for large datasets.
- Time Complexity O(n log n) always
- Space Complexity O(n)
- Stable sorting algorithm
- Best for linked lists and external sorting
# Merge Sort
def merge_sort(arr):
if len(arr) <= 1:
return arr
mid = len(arr) // 2
left = merge_sort(arr[:mid])
right = merge_sort(arr[mid:])
return merge(left, right)
def merge(left, right):
result = []
i = j = 0
while i < len(left) and j < len(right):
if left[i] <= right[j]:
result.append(left[i]); i += 1
else:
result.append(right[j]); j += 1
return result + left[i:] + right[j:]
print(merge_sort([38, 27, 43, 3, 9, 82, 10]))Quick Sort selects a pivot element and partitions the array around it, then recursively sorts the partitions.
It is one of the fastest sorting algorithms in practice due to excellent cache performance.
- Average Time Complexity O(n log n)
- Worst Case O(n²) with bad pivot selection
- Space Complexity O(log n)
- Not stable but very fast in practice
# Quick Sort
def quick_sort(arr):
if len(arr) <= 1:
return arr
pivot = arr[len(arr) // 2]
left = [x for x in arr if x < pivot]
mid = [x for x in arr if x == pivot]
right = [x for x in arr if x > pivot]
return quick_sort(left) + mid + quick_sort(right)
print(quick_sort([3, 6, 8, 10, 1, 2, 1]))A Stack is a LIFO (Last In First Out) data structure that supports push, pop, and peek operations.
In Python, a stack can be implemented using a list or collections.deque for O(1) operations.
push()— add to top O(1)pop()— remove from top O(1)peek()— view top without removing O(1)- Used in: undo/redo, expression evaluation, DFS
# Stack Implementation
class Stack:
def __init__(self):
self.items = []
def push(self, item):
self.items.append(item)
def pop(self):
if not self.is_empty():
return self.items.pop()
def peek(self):
return self.items[-1] if self.items else None
def is_empty(self):
return len(self.items) == 0
def size(self):
return len(self.items)
s = Stack()
s.push(1); s.push(2); s.push(3)
print(s.pop()) # 3
print(s.peek()) # 2A Queue is a FIFO (First In First Out) data structure. Python's collections.deque provides O(1) enqueue and dequeue operations.
Python also provides queue.Queue for thread-safe queue operations in multi-threaded programs.
enqueue()— add to rear O(1)dequeue()— remove from front O(1)- Use
dequefor best performance - Used in: BFS, task scheduling, print queues
# Queue Implementation
from collections import deque
class Queue:
def __init__(self):
self.items = deque()
def enqueue(self, item):
self.items.append(item)
def dequeue(self):
if not self.is_empty():
return self.items.popleft()
def is_empty(self):
return len(self.items) == 0
def size(self):
return len(self.items)
q = Queue()
q.enqueue("A"); q.enqueue("B"); q.enqueue("C")
print(q.dequeue()) # A
print(q.size()) # 2A Linked List is a linear data structure where each node contains data and a pointer to the next node.
Unlike arrays, linked lists provide O(1) insertion and deletion at known positions but O(n) for access by index.
- Dynamic size — grows as needed
- Efficient insert/delete at head or with pointer
- No contiguous memory required
- Foundation for stacks, queues, and hash tables
# Linked List
class Node:
def __init__(self, data):
self.data = data
self.next = None
class LinkedList:
def __init__(self):
self.head = None
def append(self, data):
new_node = Node(data)
if not self.head:
self.head = new_node
return
curr = self.head
while curr.next:
curr = curr.next
curr.next = new_node
def display(self):
curr = self.head
while curr:
print(curr.data, end=" -> ")
curr = curr.next
print("None")
ll = LinkedList()
ll.append(1); ll.append(2); ll.append(3)
ll.display()A Binary Search Tree (BST) is a binary tree where left children are smaller and right children are larger than the parent.
It enables efficient O(log n) average-case search, insert, and delete operations.
- Left subtree has smaller values
- Right subtree has larger values
- Inorder traversal gives sorted output
- Can degrade to O(n) if unbalanced
# Binary Tree
class TreeNode:
def __init__(self, val):
self.val = val
self.left = None
self.right = None
class BST:
def __init__(self):
self.root = None
def insert(self, val):
self.root = self._insert(self.root, val)
def _insert(self, node, val):
if not node:
return TreeNode(val)
if val < node.val:
node.left = self._insert(node.left, val)
else:
node.right = self._insert(node.right, val)
return node
def inorder(self, node):
if node:
self.inorder(node.left)
print(node.val, end=" ")
self.inorder(node.right)
bst = BST()
for v in [5, 3, 7, 1, 4]:
bst.insert(v)
bst.inorder(bst.root) # 1 3 4 5 7BFS explores all neighbors of a node before moving deeper, visiting nodes level by level using a queue.
It is used to find the shortest path in unweighted graphs and level-order tree traversal.
- Uses a queue (FIFO)
- Time Complexity O(V+E)
- Space Complexity O(V)
- Finds shortest path in unweighted graphs
# Graph - BFS
from collections import deque
def bfs(graph, start):
visited = set()
queue = deque([start])
visited.add(start)
while queue:
node = queue.popleft()
print(node, end=" ")
for neighbor in graph[node]:
if neighbor not in visited:
visited.add(neighbor)
queue.append(neighbor)
graph = {"A": ["B","C"], "B": ["D"], "C": ["E"], "D": [], "E": []}
bfs(graph, "A") # A B C D EDFS explores as far as possible along each branch before backtracking, using a stack (or recursion).
It is used for cycle detection, topological sort, and solving maze/pathfinding problems.
- Uses a stack or recursion
- Time Complexity O(V+E)
- Space Complexity O(V)
- Used in topological sort, cycle detection
# Graph - DFS
def dfs(graph, node, visited=None):
if visited is None:
visited = set()
visited.add(node)
print(node, end=" ")
for neighbor in graph[node]:
if neighbor not in visited:
dfs(graph, neighbor, visited)
graph = {"A": ["B","C"], "B": ["D"], "C": ["E"], "D": [], "E": []}
dfs(graph, "A") # A B D C EThe Two Sum Problem finds two numbers in an array that add up to a given target and returns their indices.
Using a HashMap, we store complements as we iterate, achieving O(n) time and O(n) space.
- Brute force: O(n²)
- HashMap approach: O(n)
- Store complement as key, index as value
- Most common first coding interview question
# Two Sum Problem
def two_sum(nums, target):
seen = {}
for i, num in enumerate(nums):
complement = target - num
if complement in seen:
return [seen[complement], i]
seen[num] = i
return []
print(two_sum([2, 7, 11, 15], 9)) # [0, 1]
print(two_sum([3, 2, 4], 6)) # [1, 2]lru_cache (Least Recently Used Cache) is a decorator from functools that automatically memoizes function results.
It caches the most recent calls and evicts the least recently used when the cache is full.
@lru_cache(maxsize=None)— unlimited cache- Function must have hashable arguments
- Dramatically speeds up recursive algorithms
- Available in Python 3.2+; use
@cachein Python 3.9+
# Fibonacci with memoization
from functools import lru_cache
@lru_cache(maxsize=None)
def fib(n):
if n <= 1:
return n
return fib(n-1) + fib(n-2)
print([fib(i) for i in range(10)])
# [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]The Coin Change Problem finds the minimum number of coins needed to make a given amount using available coin denominations.
It is solved using bottom-up dynamic programming where dp[i] represents the min coins needed for amount i.
- Time Complexity O(n * amount)
- Space Complexity O(amount)
- Returns -1 if amount cannot be made
- Classic DP interview problem
# Dynamic Programming - 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
print(coin_change([1, 5, 6, 9], 11)) # 2The Longest Common Subsequence (LCS) finds the longest subsequence present in both strings while maintaining relative order.
It is solved using a 2D DP table where dp[i][j] stores the LCS length of first i and j characters.
- Time Complexity O(m*n)
- Space Complexity O(m*n)
- Used in DNA analysis and diff tools
- Subsequence need not be contiguous
# Longest Common Subsequence
def lcs(s1, s2):
m, n = len(s1), len(s2)
dp = [[0]*(n+1) for _ in range(m+1)]
for i in range(1, m+1):
for j in range(1, n+1):
if s1[i-1] == s2[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]
print(lcs("ABCBDAB", "BDCAB")) # 4Kadane's Algorithm finds the maximum sum contiguous subarray in O(n) time using a dynamic programming approach.
At each step, it decides whether to extend the current subarray or start a new one from the current element.
- Time Complexity O(n)
- Space Complexity O(1)
- Handles all-negative arrays (returns max single element)
- Classic DP and greedy problem
# Kadane's Algorithm - Max Subarray
def max_subarray(nums):
max_sum = current = nums[0]
for num in nums[1:]:
current = max(num, current + num)
max_sum = max(max_sum, current)
return max_sum
print(max_subarray([-2,1,-3,4,-1,2,1,-5,4])) # 6Valid parentheses checking ensures every opening bracket has a matching closing bracket in the correct order.
A stack is pushed with opening brackets and popped when a matching closing bracket is encountered.
- Use a stack data structure
- Use a mapping dict for bracket pairs
- Time Complexity O(n)
- Space Complexity O(n)
# Valid Parentheses
def is_valid(s):
stack = []
mapping = {")": "(", "}": "{", "]": "["}
for char in s:
if char in mapping:
top = stack.pop() if stack else "#"
if mapping[char] != top:
return False
else:
stack.append(char)
return not stack
print(is_valid("()[]{}")) # True
print(is_valid("([)]")) # FalseReversing a linked list changes the direction of all next pointers so the last node becomes the new head.
This is done iteratively using three pointers: prev, curr, and nxt.
- Time Complexity O(n)
- Space Complexity O(1) iterative
- Can also be done recursively with O(n) space
- Classic linked list interview question
# Reverse Linked List
class Node:
def __init__(self, data):
self.data = data
self.next = None
def reverse_list(head):
prev = None
curr = head
while curr:
nxt = curr.next
curr.next = prev
prev = curr
curr = nxt
return prev
# Build list: 1 -> 2 -> 3
head = Node(1)
head.next = Node(2)
head.next.next = Node(3)
# Reverse it
new_head = reverse_list(head)
while new_head:
print(new_head.data, end=" ")
new_head = new_head.nextFloyd's Tortoise and Hare Algorithm uses two pointers moving at different speeds to detect a cycle in a linked list.
If a cycle exists, the fast pointer will eventually meet the slow pointer inside the cycle.
- Slow pointer moves 1 step at a time
- Fast pointer moves 2 steps at a time
- Time Complexity O(n)
- Space Complexity O(1) — no extra memory
# Detect Cycle in Linked List (Floyd's)
class Node:
def __init__(self, data):
self.data = data
self.next = None
def has_cycle(head):
slow = fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow is fast:
return True
return False
# Create cycle: 1->2->3->4->2
n1, n2, n3, n4 = Node(1), Node(2), Node(3), Node(4)
n1.next = n2; n2.next = n3; n3.next = n4; n4.next = n2
print(has_cycle(n1)) # TrueThe N-Queens Problem places N queens on an N×N chessboard so that no two queens attack each other.
It is solved using backtracking, placing one queen per row and checking column and diagonal conflicts.
- Backtracking approach
- Track columns and two diagonal sets
- Time Complexity O(n!)
- Classic constraint satisfaction problem
# N-Queens Problem
def solve_n_queens(n):
result = []
def backtrack(row, cols, diag1, diag2, board):
if row == n:
result.append(["".join(r) for r in board])
return
for col in range(n):
if col in cols or (row-col) in diag1 or (row+col) in diag2:
continue
board[row][col] = "Q"
cols.add(col); diag1.add(row-col); diag2.add(row+col)
backtrack(row+1, cols, diag1, diag2, board)
board[row][col] = "."
cols.discard(col); diag1.discard(row-col); diag2.discard(row+col)
board = [["."]*n for _ in range(n)]
backtrack(0, set(), set(), set(), board)
return len(result)
print(solve_n_queens(4)) # 2Dijkstra's Algorithm finds the shortest path from a source node to all other nodes in a weighted graph with non-negative edge weights.
Python's heapq module provides an efficient min-heap priority queue for the implementation.
- Uses a min-heap priority queue
- Time Complexity O((V+E) log V)
- Does not work with negative weights
- Used in GPS navigation and routing
# Dijkstra's Algorithm
import heapq
def dijkstra(graph, start):
dist = {node: float("inf") for node in graph}
dist[start] = 0
heap = [(0, start)]
while heap:
d, u = heapq.heappop(heap)
if d > dist[u]:
continue
for v, w in graph[u]:
if dist[u] + w < dist[v]:
dist[v] = dist[u] + w
heapq.heappush(heap, (dist[v], v))
return dist
graph = {"A":[("B",1),("C",4)], "B":[("C",2),("D",5)], "C":[("D",1)], "D":[]}
print(dijkstra(graph, "A"))A Trie is a tree-like data structure used to store strings where each node represents a single character of a word.
It enables O(m) search, insert, and prefix lookup where m is the length of the word.
- Each node stores children as a dictionary
is_endflag marks end of a word- Supports prefix search efficiently
- Used in autocomplete and spell checking
# Trie Data Structure
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 ch in word:
if ch not in node.children:
node.children[ch] = TrieNode()
node = node.children[ch]
node.is_end = True
def search(self, word):
node = self.root
for ch in word:
if ch not in node.children:
return False
node = node.children[ch]
return node.is_end
trie = Trie()
trie.insert("hello")
print(trie.search("hello")) # True
print(trie.search("hell")) # FalseAn LRU (Least Recently Used) Cache evicts the least recently accessed item when the cache reaches its capacity.
Python's OrderedDict makes it easy to implement with O(1) get and put operations.
- Get and Put both O(1)
move_to_end()marks item as recently usedpopitem(last=False)evicts least recently used- Also available via
@functools.lru_cache
# LRU Cache
from collections import OrderedDict
class LRUCache:
def __init__(self, capacity):
self.cap = capacity
self.cache = OrderedDict()
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, value):
if key in self.cache:
self.cache.move_to_end(key)
self.cache[key] = value
if len(self.cache) > self.cap:
self.cache.popitem(last=False)
lru = LRUCache(2)
lru.put(1, 1); lru.put(2, 2)
print(lru.get(1)) # 1
lru.put(3, 3)
print(lru.get(2)) # -1 (evicted)Python's heapq module implements a min-heap, where the smallest element is always at the root.
For a max-heap, negate the values when pushing and negate again when popping.
heappush(heap, val)— insert in O(log n)heappop(heap)— remove min in O(log n)heapify(list)— convert list to heap in O(n)nlargest(k)/nsmallest(k)for top-k problems
# Heap / Priority Queue
import heapq
# Min-Heap
heap = []
heapq.heappush(heap, 5)
heapq.heappush(heap, 1)
heapq.heappush(heap, 3)
print(heapq.heappop(heap)) # 1
# Max-Heap (negate values)
max_heap = []
for val in [5, 1, 3, 9]:
heapq.heappush(max_heap, -val)
print(-heapq.heappop(max_heap)) # 9
# nlargest and nsmallest
nums = [3, 1, 4, 1, 5, 9, 2, 6]
print(heapq.nlargest(3, nums)) # [9, 6, 5]
print(heapq.nsmallest(3, nums)) # [1, 1, 2]The Sliding Window Maximum problem finds the maximum element in every window of size k as it slides through the array.
A monotonic deque (decreasing) stores indices so the front always holds the max of the current window.
- Uses a monotonic deque
- Time Complexity O(n)
- Space Complexity O(k)
- Classic deque application in interviews
# Sliding Window Maximum
from collections import deque
def max_sliding_window(nums, k):
dq = deque()
result = []
for i, num in enumerate(nums):
while dq and dq[0] < i - k + 1:
dq.popleft()
while dq and nums[dq[-1]] < num:
dq.pop()
dq.append(i)
if i >= k - 1:
result.append(nums[dq[0]])
return result
print(max_sliding_window([1,3,-1,-3,5,3,6,7], 3))
# [3, 3, 5, 5, 6, 7]The Power Set is the collection of all possible subsets of a set, including the empty set and the full set.
For a set of n elements, there are 2ⁿ subsets. It is generated iteratively by doubling subsets at each step.
- Iterative approach: O(n * 2ⁿ)
- Can also use backtracking
- Can also be done using bit manipulation
- Classic combinatorics interview problem
# Subset Generation
def subsets(nums):
result = [[]]
for num in nums:
result += [curr + [num] for curr in result]
return result
print(subsets([1, 2, 3]))
# [[], [1], [2], [1,2], [3], [1,3], [2,3], [1,2,3]]A Permutation is an arrangement of all elements in every possible order. For n elements, there are n! permutations.
Python's itertools.permutations() can generate them directly, or use backtracking for a custom solution.
- Recursive backtracking approach
- Time Complexity O(n * n!)
- Also available via
itertools.permutations() - Classic backtracking interview problem
# Permutations
def permute(nums):
if len(nums) <= 1:
return [nums]
result = []
for i, num in enumerate(nums):
rest = nums[:i] + nums[i+1:]
for perm in permute(rest):
result.append([num] + perm)
return result
print(permute([1, 2, 3]))The Trapping Rain Water problem calculates how much water can be trapped between elevation bars after rainfall.
The two-pointer approach uses left and right max heights tracked simultaneously for an O(n) O(1) solution.
- Two pointer technique
- Time Complexity O(n)
- Space Complexity O(1)
- Also solvable with prefix/suffix max arrays
# Trapping Rain Water
def trap(height):
left, right = 0, len(height) - 1
left_max = right_max = water = 0
while left < right:
if height[left] < height[right]:
if height[left] >= left_max:
left_max = height[left]
else:
water += left_max - height[left]
left += 1
else:
if height[right] >= right_max:
right_max = height[right]
else:
water += right_max - height[right]
right -= 1
return water
print(trap([0,1,0,2,1,0,1,3,2,1,2,1])) # 6This problem returns an array where each element is the product of all other elements except itself, without using division.
It uses two passes — one for left running products and one for right running products — achieving O(n) time and O(1) extra space.
- No division allowed
- Time Complexity O(n)
- Space Complexity O(1) extra
- Two-pass prefix and suffix product technique
# Product of Array Except Self
def product_except_self(nums):
n = len(nums)
output = [1] * n
left = 1
for i in range(n):
output[i] = left
left *= nums[i]
right = 1
for i in range(n-1, -1, -1):
output[i] *= right
right *= nums[i]
return output
print(product_except_self([1, 2, 3, 4])) # [24,12,8,6]The Longest Increasing Subsequence (LIS) finds the length of the longest subsequence where elements are in strictly increasing order.
The DP approach has O(n²) complexity, while the binary search + patience sorting approach achieves O(n log n).
- DP approach: O(n²) time, O(n) space
- Binary search approach: O(n log n)
- Elements need not be contiguous
- Classic DP interview problem
# Longest Increasing Subsequence
def lis(nums):
dp = [1] * len(nums)
for i in range(1, len(nums)):
for j in range(i):
if nums[j] < nums[i]:
dp[i] = max(dp[i], dp[j] + 1)
return max(dp)
print(lis([10, 9, 2, 5, 3, 7, 101, 18])) # 4Edit Distance (Levenshtein Distance) is the minimum number of operations (insert, delete, replace) needed to transform one string into another.
A 2D DP table is built where dp[i][j] is the edit distance between the first i characters of s1 and j characters of s2.
- Time Complexity O(m*n)
- Space Complexity O(m*n)
- Used in spell checkers and DNA analysis
- Three operations: insert, delete, replace
# Edit Distance
def edit_distance(s1, s2):
m, n = len(s1), len(s2)
dp = [[0]*(n+1) for _ in range(m+1)]
for i in range(m+1): dp[i][0] = i
for j in range(n+1): dp[0][j] = j
for i in range(1, m+1):
for j in range(1, n+1):
if s1[i-1] == s2[j-1]:
dp[i][j] = dp[i-1][j-1]
else:
dp[i][j] = 1 + min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1])
return dp[m][n]
print(edit_distance("sunday", "saturday")) # 3The Word Break Problem checks if a string can be segmented into a sequence of valid dictionary words.
dp[i] is True if the substring s[0:i] can be formed from words in the dictionary.
- Bottom-up dynamic programming
- Time Complexity O(n²)
- Space Complexity O(n)
- Used in NLP and text segmentation
# Word Break Problem
def word_break(s, word_dict):
dp = [False] * (len(s) + 1)
dp[0] = True
word_set = set(word_dict)
for i in range(1, len(s)+1):
for j in range(i):
if dp[j] and s[j:i] in word_set:
dp[i] = True
break
return dp[len(s)]
print(word_break("leetcode", ["leet","code"])) # TrueThe Number of Islands problem counts connected groups of '1's (land) in a 2D grid surrounded by '0's (water).
DFS is used to sink (mark as visited) all connected land cells when a new island is discovered.
- DFS or BFS on 2D grid
- Time Complexity O(m*n)
- Space Complexity O(m*n) recursion stack
- Classic graph traversal problem
# Number of Islands
def num_islands(grid):
if not grid:
return 0
count = 0
def dfs(r, c):
if r<0 or r>=len(grid) or c<0 or c>=len(grid[0]) or grid[r][c]=="0":
return
grid[r][c] = "0"
dfs(r+1,c); dfs(r-1,c); dfs(r,c+1); dfs(r,c-1)
for r in range(len(grid)):
for c in range(len(grid[0])):
if grid[r][c] == "1":
count += 1
dfs(r, c)
return count
grid=[["1","1","0"],["0","1","0"],["0","0","1"]]
print(num_islands(grid)) # 2Spiral Order Traversal visits matrix elements layer by layer in a clockwise direction from the outermost ring inward.
Four boundary pointers (top, bottom, left, right) shrink after each directional pass through the matrix.
- Four boundary variable approach
- Time Complexity O(m*n)
- Space Complexity O(1) extra
- Classic matrix interview problem
# Spiral Matrix
def spiral_order(matrix):
result = []
top, bottom = 0, len(matrix)-1
left, right = 0, len(matrix[0])-1
while top <= bottom and left <= right:
for i in range(left, right+1): result.append(matrix[top][i])
top += 1
for i in range(top, bottom+1): result.append(matrix[i][right])
right -= 1
if top <= bottom:
for i in range(right, left-1, -1): result.append(matrix[bottom][i])
bottom -= 1
if left <= right:
for i in range(bottom, top-1, -1): result.append(matrix[i][left])
left += 1
return result
print(spiral_order([[1,2,3],[4,5,6],[7,8,9]]))Topological Sort produces a linear ordering of vertices in a DAG (Directed Acyclic Graph) where every edge goes from earlier to later.
Kahn's Algorithm (BFS-based) uses in-degree counting and processes nodes with zero in-degree first.
- Only works on Directed Acyclic Graphs
- Time Complexity O(V+E)
- Used in build systems and task scheduling
- Can also be done using DFS with a stack
# Topological Sort
from collections import deque
def topo_sort(graph, n):
in_degree = [0] * n
for u in graph:
for v in graph[u]:
in_degree[v] += 1
queue = deque([i for i in range(n) if in_degree[i] == 0])
result = []
while queue:
node = queue.popleft()
result.append(node)
for neighbor in graph.get(node, []):
in_degree[neighbor] -= 1
if in_degree[neighbor] == 0:
queue.append(neighbor)
return result if len(result) == n else []
print(topo_sort({0:[1,2], 1:[3], 2:[3], 3:[]}, 4))Union-Find (Disjoint Set Union) tracks elements partitioned into non-overlapping sets with efficient union and find operations.
Path compression and union by rank optimize both operations to near O(1) amortized time.
- Path compression flattens the tree during find
- Union by rank keeps tree balanced
- Near O(1) amortized per operation
- Used in cycle detection and Kruskal's MST
# Union Find
class UnionFind:
def __init__(self, n):
self.parent = list(range(n))
self.rank = [0] * n
def find(self, x):
if self.parent[x] != x:
self.parent[x] = self.find(self.parent[x])
return self.parent[x]
def union(self, x, y):
px, py = self.find(x), self.find(y)
if px == py: return False
if self.rank[px] < self.rank[py]: px, py = py, px
self.parent[py] = px
if self.rank[px] == self.rank[py]: self.rank[px] += 1
return True
uf = UnionFind(5)
uf.union(0, 1); uf.union(1, 2)
print(uf.find(0) == uf.find(2)) # TrueThe Minimum Cost Path problem finds the path from the top-left to the bottom-right of a matrix with the minimum total cost.
It is solved using bottom-up dynamic programming where each cell stores the minimum cost to reach it.
- Can move right, down, or diagonally
- Time Complexity O(m*n)
- Space Complexity O(m*n)
- Classic 2D DP grid problem
# Min Cost Path (DP)
def min_cost_path(cost):
m, n = len(cost), len(cost[0])
dp = [[0]*n for _ in range(m)]
dp[0][0] = cost[0][0]
for i in range(1, m): dp[i][0] = dp[i-1][0] + cost[i][0]
for j in range(1, n): dp[0][j] = dp[0][j-1] + cost[0][j]
for i in range(1, m):
for j in range(1, n):
dp[i][j] = cost[i][j] + min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1])
return dp[m-1][n-1]
cost = [[1,2,3],[4,8,2],[1,5,3]]
print(min_cost_path(cost)) # 8String Compression encodes consecutive repeated characters as the character followed by its count.
If the compressed string is not smaller, the original string is returned. It is a classic string manipulation problem.
- Time Complexity O(n)
- Space Complexity O(n)
- Run-length encoding technique
- Common in data compression algorithms
# String Compression
def compress(s):
result = []
i = 0
while i < len(s):
char = s[i]
count = 0
while i < len(s) and s[i] == char:
i += 1
count += 1
result.append(char)
if count > 1:
result.append(str(count))
return "".join(result)
print(compress("aabbbcccc")) # a2b3c4
print(compress("abcd")) # abcdTwo strings are Anagrams if they contain the same characters with the same frequencies in any order.
Group Anagrams clusters strings from a list that are anagrams of each other using a sorted key as the dictionary key.
- Use
Counteror sorting to check anagrams - Time Complexity O(n * k log k) for grouping
- Use sorted string as HashMap key
- Very common string interview question
# Anagram Check
from collections import Counter
def is_anagram(s, t):
return Counter(s) == Counter(t)
print(is_anagram("anagram", "nagaram")) # True
print(is_anagram("rat", "car")) # False
# Group Anagrams
def group_anagrams(strs):
groups = {}
for s in strs:
key = tuple(sorted(s))
groups.setdefault(key, []).append(s)
return list(groups.values())
print(group_anagrams(["eat","tea","tan","ate","nat","bat"]))The Top K Frequent Elements problem returns the K most frequently occurring elements from a given list.
It can be solved efficiently using Counter.most_common(k) or a bucket sort approach for O(n) time.
- Using Counter: O(n log n)
- Using bucket sort: O(n)
- Using heap: O(n log k)
- Very common interview problem
# Top K Frequent Elements
from collections import Counter
def top_k_frequent(nums, k):
count = Counter(nums)
return [x for x, _ in count.most_common(k)]
print(top_k_frequent([1,1,1,2,2,3], 2)) # [1, 2]
# Using bucket sort approach O(n)
def top_k_bucket(nums, k):
count = Counter(nums)
buckets = [[] for _ in range(len(nums)+1)]
for num, freq in count.items():
buckets[freq].append(num)
result = []
for i in range(len(buckets)-1, 0, -1):
result.extend(buckets[i])
if len(result) >= k: break
return result[:k]Pascal's Triangle is a triangular array where each number is the sum of the two numbers directly above it in the previous row.
Each row starts and ends with 1, and it reveals many mathematical patterns like binomial coefficients.
- Each row has one more element than the previous
- Row n gives binomial coefficients C(n,0) to C(n,n)
- Time Complexity O(n²)
- Space Complexity O(n²)
# Pascal's Triangle
def generate_pascal(num_rows):
triangle = []
for i in range(num_rows):
row = [1] * (i + 1)
for j in range(1, i):
row[j] = triangle[i-1][j-1] + triangle[i-1][j]
triangle.append(row)
return triangle
for row in generate_pascal(5):
print(row)Fast Exponentiation (Exponentiation by Squaring) computes base^exp in O(log n) time instead of O(n).
It works by halving the exponent at each step and squaring the base, reducing the number of multiplications drastically.
- Time Complexity O(log n)
- Space Complexity O(log n) recursive
- Handles negative exponents
- Foundation of modular exponentiation in cryptography
# Power Function (Fast Exponentiation)
def my_pow(base, exp):
if exp == 0: return 1
if exp < 0:
base = 1 / base
exp = -exp
if exp % 2 == 0:
return my_pow(base * base, exp // 2)
return base * my_pow(base * base, exp // 2)
print(my_pow(2, 10)) # 1024
print(my_pow(2.0, -2)) # 0.25Matrix Multiplication produces a new matrix where each element is the dot product of the corresponding row of the first matrix and column of the second.
For matrices A (m×k) and B (k×n), the result is a matrix of size (m×n). The number of columns in A must equal the number of rows in B.
- Time Complexity O(m*k*n)
- Space Complexity O(m*n)
- Python also supports
@operator for matrix multiplication with NumPy - Foundation of neural network computations
# Matrix Multiplication
def matrix_multiply(A, B):
rows_A, cols_A = len(A), len(A[0])
rows_B, cols_B = len(B), len(B[0])
if cols_A != rows_B:
raise ValueError("Incompatible dimensions")
result = [[0]*cols_B for _ in range(rows_A)]
for i in range(rows_A):
for j in range(cols_B):
for k in range(cols_A):
result[i][j] += A[i][k] * B[k][j]
return result
A = [[1,2],[3,4]]
B = [[5,6],[7,8]]
for row in matrix_multiply(A, B):
print(row)A Decorator with Arguments requires an extra outer function that accepts the arguments and returns the actual decorator.
This three-level function nesting pattern is the standard way to create parameterized decorators in Python.
- Outer function accepts decorator arguments
- Middle function is the actual decorator
- Inner function is the wrapper
- Use
functools.wrapsto preserve original function metadata
# Decorator with Arguments
def repeat(times):
def decorator(func):
def wrapper(*args, **kwargs):
for _ in range(times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(3)
def greet(name):
print(f"Hello, {name}!")
greet("Alice")
# Hello, Alice!
# Hello, Alice!
# Hello, Alice!A Generator Pipeline chains multiple generators together where each generator lazily processes the output of the previous one.
This pattern is extremely memory-efficient for large data streams since values are only computed when needed.
- Each stage processes one element at a time
- O(1) memory regardless of data size
- Lazy evaluation — nothing computed until consumed
- Used in data pipelines, ETL processes, and stream processing
# Generator Pipeline
def integers():
n = 1
while True:
yield n
n += 1
def squares(gen):
for n in gen:
yield n * n
def take(n, gen):
for _ in range(n):
yield next(gen)
pipeline = take(5, squares(integers()))
print(list(pipeline)) # [1, 4, 9, 16, 25]A Metaclass is a class whose instances are classes. It controls the creation, behavior, and structure of classes in Python.
The default metaclass in Python is type. Custom metaclasses allow you to intercept and modify class creation.
- Classes are instances of their metaclass
typeis the default metaclass- Override
__call__to control instantiation - Used in ORMs, frameworks, and Singleton patterns
# Metaclass
class SingletonMeta(type):
_instances = {}
def __call__(cls, *args, **kwargs):
if cls not in cls._instances:
cls._instances[cls] = super().__call__(*args, **kwargs)
return cls._instances[cls]
class Database(metaclass=SingletonMeta):
def __init__(self):
self.connection = "Connected"
db1 = Database()
db2 = Database()
print(db1 is db2) # True
print(db1.connection) # ConnectedA Descriptor is an object that defines how attribute access is handled by implementing __get__, __set__, or __delete__ methods.
Descriptors are the mechanism behind Python's property, staticmethod, and classmethod built-ins.
__get__— called on attribute access__set__— called on attribute assignment__delete__— called on attribute deletion- Used to implement validation, type checking, and lazy loading
# Descriptors
class Validator:
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, type=None):
if obj is None: return self
return obj.__dict__.get(self.name)
def __set__(self, obj, value):
if not isinstance(value, int):
raise TypeError(f"{self.name} must be an integer")
if value < 0:
raise ValueError(f"{self.name} must be non-negative")
obj.__dict__[self.name] = value
class Product:
price = Validator()
quantity = Validator()
p = Product()
p.price = 100
p.quantity = 50
print(p.price, p.quantity) # 100 50An Async Context Manager is a context manager that uses async with syntax and supports awaitable __aenter__ and __aexit__ methods.
It is used for managing async resources like database connections, HTTP sessions, and file handles in async code.
__aenter__— async setup on entering the block__aexit__— async cleanup on exiting the block- Used with
async withstatement - Common in
aiohttp,asyncpg, andaiofileslibraries
# Async Context Manager
import asyncio
class AsyncDB:
async def __aenter__(self):
print("Connecting to DB...")
await asyncio.sleep(0.1)
return self
async def __aexit__(self, *args):
print("Closing DB connection...")
await asyncio.sleep(0.1)
async def query(self, sql):
await asyncio.sleep(0.1)
return f"Result of: {sql}"
async def main():
async with AsyncDB() as db:
result = await db.query("SELECT * FROM users")
print(result)
asyncio.run(main())Flask is a lightweight Python web framework used to build REST APIs quickly with minimal boilerplate code.
It provides routing, request handling, and JSON response utilities out of the box, making it perfect for building backend services.
@app.route()— defines URL endpointsrequest.json— parses incoming JSON bodyjsonify()— converts dict to JSON response- Supports GET, POST, PUT, DELETE HTTP methods
# Full REST API with Flask
from flask import Flask, jsonify, request
app = Flask(__name__)
users = [
{"id": 1, "name": "Alice", "email": "alice@example.com"},
{"id": 2, "name": "Bob", "email": "bob@example.com"}
]
@app.route("/users", methods=["GET"])
def get_users():
return jsonify(users)
@app.route("/users/<int:user_id>", methods=["GET"])
def get_user(user_id):
user = next((u for u in users if u["id"] == user_id), None)
return jsonify(user) if user else ("Not Found", 404)
@app.route("/users", methods=["POST"])
def create_user():
data = request.json
data["id"] = len(users) + 1
users.append(data)
return jsonify(data), 201
if __name__ == "__main__":
app.run(debug=True)