Complete Python Programming Notes – Basic to Advanced
📘 Python · Units 1–10

Complete Python Programming
Notes — Basic to Advanced

A structured reference covering primitive types, OOP, data structures, recursion, and more — with live examples.

10 Units
Python 3.x
Collapsible Examples
01

Primitive Types & Basics

1.1 Variables and Data Types

Python has six core primitive data types. Unlike many languages, Python infers types automatically — no declaration needed. Every value in Python is an object, even primitives.

TypeDescriptionExampleNotes
intWhole numbersage = 17Unlimited precision
floatDecimal numbersprice = 19.9964-bit double precision
boolTrue / Falseis_student = TrueSubclass of int
strText sequencename = "Alice"Immutable
complexComplex numbersz = 3 + 4jTwo 64-bit floats
NoneTypeAbsence of valueresult = NoneSingleton object
💡

Use type(x) to check a variable’s type at runtime, and isinstance(x, int) for type checking in conditions.

# Valid variable names
student_name = "John"      # Snake_case (Python convention ✓)
studentAge   = 20          # camelCase (works, not Pythonic)
_student     = "Jane"      # underscore prefix = private convention
student1     = "Bob"       # numbers allowed — NOT at start
STUDENT      = "Alice"     # ALL_CAPS = constants convention

# Invalid variable names (will raise SyntaxError)
# 1student  = "John"       # ✗ cannot start with a digit
# student-name = "John"    # ✗ hyphens not allowed
# class    = "Math"        # ✗ reserved keyword
# student name = "John"    # ✗ spaces not allowed

# Python reserved keywords — never use as identifiers:
# False  None   True   and    as    assert  async   await
# break  class  continue  def  del   elif    else    except
# finally for   from   global  if   import  in      is
# lambda nonlocal not   or    pass  raise   return  try
# while  with   yield

1.2 Operators

OperatorNameExampleResult
+Addition5 + 38
-Subtraction5 - 32
*Multiplication5 * 315
/Division (float)5 / 22.5
//Floor division5 // 22
%Modulo5 % 21
**Exponentiation5 ** 225
⚠️

PEMDAS order: Parentheses → Exponents (**) → Unary → * / // %+ - → Comparisons → notandor

# Operator precedence
result = 5 + 3 * 2 ** 2    # → 5 + 3*4 = 5+12 = 17
result = (5 + 3) * 2 ** 2  # → 8 * 4 = 32

# Assignment shorthand operators
x = 5
x += 3    # x = 8
x -= 2    # x = 6
x *= 4    # x = 24
x /= 3    # x = 8.0  (always float)
x //= 2   # x = 4.0
x %= 3    # x = 1.0
x **= 2   # x = 1.0

1.3 Input and Output

print() sends text to the console. input() reads a string from the user — always returns str, so cast as needed.

name, age = "Alice", 17

# f-strings (Python 3.6+) — preferred modern style
print(f"Hello, {name}! You are {age} years old.")
print(f"Next year you will be {age + 1}.")
print(f"Pi ≈ {3.14159:.2f}")          # Pi ≈ 3.14

# Alignment inside f-strings
print(f"{name:>10}")                  # "     Alice"  right-align
print(f"{name:<10}")                  # "Alice     "  left-align
print(f"{name:^10}")                  # "  Alice   "  center
print(f"{age:03d}")                   # "017"  zero-pad

# sep and end parameters
print("apple", "banana", sep=", ")   # apple, banana
print("Loading", end="...")           # no newline
print("Done!")                        # Loading...Done!

# Older styles (still valid)
print("Hello, {}!".format(name))      # .format()
print("Hello, %s!" % name)            # % formatting
name   = input("Enter your name: ")            # always str
age    = int(input("Enter your age: "))         # cast to int
height = float(input("Enter your height: "))    # cast to float

# Multiple values on one line
x, y = input("Enter two numbers: ").split()
x, y = int(x), int(y)
print(f"Sum: {x + y}")

# Validated input with try/except
try:
    age = int(input("Enter age: "))
    if not 0 <= age <= 150:
        print("Invalid age!")
    else:
        print(f"Valid age: {age}")
except ValueError:
    print("Please enter a valid number!")

1.4 Type Conversion

# Implicit: int + float → float
z = 5 + 2.5   # 7.5

# int()
int(3.14)       # 3  (truncates, does NOT round)
int("42")       # 42
int(True)       # 1
int("101", 2)   # 5  (binary string → decimal)

# float()
float(5)        # 5.0
float("inf")    # infinity
float("nan")    # Not a Number

# bool() — falsy values:
bool(0)   # False
bool("")  # False
bool([])  # False
bool(None)# False
# Everything else is truthy

# Rounding  
round(3.14159, 2)  # 3.14
round(2.5)         # 2  ← banker's rounding (rounds to even!)
02

Using Objects

2.1 Everything is an Object

In Python, even numbers and strings are full objects with methods. You can inspect any object with dir(x) or check its type with x.__class__.

Methods are called with dot notation: object.method(args). Most string methods return a new string since strings are immutable.

2.2 String Methods

🔠
Case Methods
.upper() .lower() .title() .swapcase()
✂️
Trim & Pad
.strip() .lstrip() .rstrip() .center(n)
🔍
Search
.find() returns -1 if missing; .index() raises an error
🔀
Split & Join
.split(sep) → list; sep.join(list) → string
🔄
Replace
.replace(old, new) — returns new string, doesn't mutate
🧪
Check Content
.isalpha() .isdigit() .isalnum() .isspace()
text = "  Hello, World!  "

# Case
text.upper()     # "  HELLO, WORLD!  "
text.lower()     # "  hello, world!  "
text.title()     # "  Hello, World!  "

# Trim
text.strip()     # "Hello, World!"
text.lstrip()    # "Hello, World!  "
text.rstrip()    # "  Hello, World!"

# Search
text.find("World")    # 9  (index; -1 if not found)
text.index("World")   # 9  (raises ValueError if missing)
text.count("o")       # 2
text.startswith("  ") # True
text.endswith("!")    # True  ← on stripped version use .strip() first

# Replace & split
text.replace("World", "Python")  # "  Hello, Python!  "
words = text.split(",")           # ["  Hello", " World!  "]
parts = text.split()              # ["Hello,", "World!"]  (any whitespace)

# Join (inverse of split)
"-".join(["a", "b", "c"])         # "a-b-c"
text = "Hello, World!"

text[0:5]       # "Hello"
text[7:12]      # "World"
text[:5]        # "Hello"    (start defaults to 0)
text[7:]        # "World!"   (stop defaults to end)
text[-6:-1]     # "World"    (negative from end)
text[::2]       # "Hlo ol!"  (every 2nd char)
text[::-1]      # "!dlroW ,olleH"  (reverse!)

# Strings are IMMUTABLE — you cannot do text[0] = "J"
# Instead create a new string:
text = "J" + text[1:]   # "Jello, World!"

2.3 Math Library

import math

# Constants
math.pi    # 3.14159...
math.e     # 2.71828...
math.inf   # infinity

# Rounding
math.ceil(3.2)     # 4  (always up)
math.floor(3.8)    # 3  (always down)
math.trunc(3.7)    # 3  (towards zero)

# Powers & roots
math.sqrt(16)      # 4.0
math.pow(2, 3)     # 8.0
math.exp(2)        # e² ≈ 7.389
math.log(100, 10)  # 2.0  (log base 10)

# Trigonometry (radians!)
math.sin(math.pi/2)   # 1.0
math.cos(0)            # 1.0
math.radians(180)      # π
math.degrees(math.pi)  # 180.0

# Misc
math.factorial(5)  # 120
math.gcd(12, 18)   # 6
import random

random.random()              # float in [0.0, 1.0)
random.randint(1, 10)        # int in [1, 10] inclusive
random.randrange(1, 10, 2)   # odd number: 1,3,5,7,9

my_list = [1, 2, 3, 4, 5]
random.choice(my_list)       # one random element
random.sample(my_list, k=3)  # 3 unique elements (no repeats)
random.shuffle(my_list)      # shuffle in-place

# Reproducibility
random.seed(42)   # same sequence every run
print(random.random())  # always same value with this seed
03

Boolean Expressions & if Statements

3.1 Comparison & Logical Operators

OperatorMeaningExampleResult
==Equal to5 == 5True
!=Not equal5 != 3True
<Less than3 < 5True
>Greater than5 > 3True
<=Less or equal5 <= 5True
>=Greater or equal3 >= 5False
andBoth trueTrue and TrueTrue
orEither trueFalse or TrueTrue
notNegatenot FalseTrue
💡

Python chaining: 1 < x < 10 is valid and equivalent to 1 < x and x < 10. Falsy values: False, 0, 0.0, "", [], (), {}, None.

Short-circuit evaluation: False and f() never calls f(). True or f() never calls f() either. Useful for efficiency and avoiding errors.

3.2 if / elif / else

score = 85

if score >= 90:
    grade = "A"
elif score >= 80:
    grade = "B"
elif score >= 70:
    grade = "C"
elif score >= 60:
    grade = "D"
else:
    grade = "F"

print(f"Grade: {grade}")   # Grade: B

# Ternary (conditional expression)  — one-liner
status = "adult" if score >= 18 else "minor"

# Nested conditions → prefer logical operators
age, has_license = 25, True
if age >= 18 and has_license:
    print("Can drive")
elif age >= 18:
    print("Get a licence first")
else:
    print("Too young to drive")

# Leap year (elegant boolean expression)
year = 2024
is_leap = (year % 4 == 0) and (year % 100 != 0 or year % 400 == 0)
print(f"{year} leap year: {is_leap}")  # True
04

Iteration (Loops)

4.1 while Loops

Use while when you don't know the number of iterations upfront — e.g. waiting for valid input. Always ensure the condition eventually becomes False, or use break.

# 1. Input validation loop
while True:
    age = input("Enter age (0-120): ")
    if age.isdigit() and 0 <= int(age) <= 120:
        age = int(age)
        break
    print("Invalid — try again")

# 2. Skip even numbers with continue
number = 0
while number < 10:
    number += 1
    if number % 2 == 0:
        continue          # skip even
    print(f"Odd: {number}")

# 3. Sentinel-controlled: stop on negative input
total, count = 0, 0
n = float(input("Enter number (negative to stop): "))
while n >= 0:
    total += n
    count += 1
    n = float(input("Next number: "))

if count:
    print(f"Average: {total / count}")

4.2 for Loops

# range(stop), range(start, stop), range(start, stop, step)
for i in range(5):         # 0,1,2,3,4
    print(i)

for i in range(2, 7):      # 2,3,4,5,6
    print(i)

for i in range(10, 0, -2): # 10,8,6,4,2
    print(i)

# Iterating collections
fruits = ["apple", "banana", "cherry"]
for fruit in fruits:
    print(fruit)

# enumerate() — index + value (Pythonic)
for i, fruit in enumerate(fruits, start=1):
    print(f"{i}. {fruit}")

# Dictionary iteration
person = {"name": "Alice", "age": 17}
for key, value in person.items():
    print(f"{key}: {value}")

# else on for (runs if no break occurred)
numbers = [2, 4, 6, 8]
for n in numbers:
    if n % 2 != 0:
        print(f"Odd found: {n}")
        break
else:
    print("All even!")  # ← runs here

4.3 Nested Loops & Patterns

# Multiplication table (5×5)
for i in range(1, 6):
    for j in range(1, 6):
        print(f"{i*j:4}", end="")
    print()
# Output:
#    1   2   3   4   5
#    2   4   6   8  10
#    ...

# Growing star triangle
for i in range(1, 6):
    print("*" * i)

# Centred diamond
for i in range(5):
    print(" " * (4 - i) + "*" * (2*i + 1))

4.4 Common Loop Patterns

numbers = [1, 2, 3, 4, 5]

# Accumulator
total = sum(n**2 for n in numbers)   # 55

# Filter (list comprehension)
evens = [n for n in numbers if n % 2 == 0]   # [2, 4]

# Max (manual + built-in)
max_val = numbers[0]
for n in numbers[1:]:
    if n > max_val:
        max_val = n
# or simply: max_val = max(numbers)

# Count vowels
text = "Hello, how are you today?"
count = sum(1 for c in text.lower() if c in "aeiou")

# all() / any()
all_positive = all(n > 0 for n in numbers)   # True
any_even     = any(n % 2 == 0 for n in numbers)  # True
05

Writing Classes

5.1 Class Definition Basics

🏗️
__init__
Constructor — runs when an instance is created. Sets up instance attributes.
🔑
self
Reference to the current instance. First param in every instance method.
📦
Instance vars
Unique to each object. Created with self.name = value.
🌐
Class vars
Shared by all instances. Defined at class level, accessed via ClassName.var.
🎨
@property
Computed or validated attributes — looks like a variable, acts like a method.
Dunder methods
__str__ __add__ __len__ etc. — operator overloading.
class Dog:
    species   = "Canis familiaris"   # class variable (shared)
    dog_count = 0

    def __init__(self, name, age):
        self.name = name             # instance variable
        self.age  = age
        Dog.dog_count += 1

    def bark(self):
        return f"{self.name} says Woof!"

    def get_human_years(self):
        return self.age * 7

    def __str__(self):
        return f"{self.name} ({self.age} yrs)"

    def __repr__(self):
        return f"Dog('{self.name}', {self.age})"

buddy = Dog("Buddy", 3)
max_  = Dog("Max",   5)

print(buddy.bark())            # Buddy says Woof!
print(buddy.get_human_years()) # 21
print(str(buddy))              # Buddy (3 yrs)
print(Dog.dog_count)           # 2

5.2 Properties — Getters & Setters

class Person:
    def __init__(self, name, age):
        self._name = name
        self._age  = age

    @property
    def name(self):
        return self._name.title()   # auto-format

    @name.setter
    def name(self, value):
        if not isinstance(value, str) or len(value) < 2:
            raise ValueError("Invalid name")
        self._name = value

    @property
    def age(self):
        return self._age

    @age.setter
    def age(self, value):
        if not 0 <= value <= 150:
            raise ValueError("Age out of range")
        self._age = value

    @property           # read-only computed
    def is_adult(self):
        return self._age >= 18

p = Person("alice", 25)
print(p.name)       # Alice  ← auto title-cased
p.age = 30          # uses setter
print(p.is_adult)   # True
# p.age = 999       # ← raises ValueError

5.3 Special (Dunder) Methods

class Vector:
    def __init__(self, x, y):
        self.x, self.y = x, y

    def __str__(self):   return f"({self.x}, {self.y})"
    def __repr__(self):  return f"Vector({self.x}, {self.y})"

    def __add__(self, o): return Vector(self.x + o.x, self.y + o.y)
    def __sub__(self, o): return Vector(self.x - o.x, self.y - o.y)
    def __mul__(self, s): return Vector(self.x * s, self.y * s)
    def __rmul__(self, s):return self.__mul__(s)   # 3 * v

    def __eq__(self, o):  return self.x == o.x and self.y == o.y
    def __abs__(self):    return (self.x**2 + self.y**2) ** 0.5
    def __len__(self):    return 2
    def __iter__(self):   yield self.x; yield self.y
    def __bool__(self):   return self.x != 0 or self.y != 0
    def __call__(self, s):return Vector(self.x * s, self.y * s)

v1, v2 = Vector(2, 3), Vector(1, 4)
print(v1 + v2)          # (3, 7)
print(3 * v1)           # (6, 9)
print(abs(v1))          # 3.605...
print(list(v1))         # [2, 3]
print(bool(Vector(0,0)))# False

5.4 Encapsulation & Class / Static Methods

class BankAccount:
    interest_rate  = 0.02   # class variable
    account_count  = 0

    def __init__(self, owner, balance=0):
        self._owner   = owner           # "protected" (by convention)
        self._balance = balance
        self.__pin    = None            # name-mangled → _BankAccount__pin
        BankAccount.account_count += 1

    # ---------- instance methods ----------
    def deposit(self, amount):
        if self._validate(amount):
            self._balance += amount

    def get_balance(self, pin):
        if pin == self.__pin:
            return self._balance

    # ---------- @classmethod — operates on the class ----------
    @classmethod
    def set_rate(cls, rate):
        cls.interest_rate = rate

    @classmethod
    def from_string(cls, s):        # alternative constructor
        owner, balance = s.split("-")
        return cls(owner, float(balance))

    # ---------- @staticmethod — utility, no self/cls ----------
    @staticmethod
    def _validate(amount):
        return 0 < amount < 1_000_000

    @property
    def owner(self):
        return self._owner            # read-only

acc = BankAccount.from_string("Alice-500")
print(acc.owner)              # Alice
BankAccount.set_rate(0.025)   # changes for ALL accounts
06

Arrays (Python Lists)

6.1 Creating & Accessing Lists

📋

Python lists are ordered, mutable, and allow duplicates. They can hold mixed types and grow/shrink dynamically. Indexing is zero-based; negative indices count from the end.

numbers = [1, 2, 3, 4, 5]
mixed   = [1, "hello", 3.14, True]
chars   = list("hello")        # ['h','e','l','l','o']
rng     = list(range(5))       # [0, 1, 2, 3, 4]

# List comprehensions (Pythonic + fast)
squares = [x**2 for x in range(10)]
evens   = [x for x in range(20) if x % 2 == 0]

# Indexing
fruits = ["apple", "banana", "cherry", "date"]
fruits[0]   # "apple"
fruits[-1]  # "date"

# Slicing  [start:stop:step]
fruits[1:3]    # ["banana", "cherry"]
fruits[:2]     # ["apple", "banana"]
fruits[-2:]    # ["cherry", "date"]
fruits[::-1]   # reversed copy

# Modifying
fruits[1] = "blueberry"
fruits[1:3] = ["grape", "kiwi"]   # replace a slice

6.2 List Methods

MethodActionReturns
.append(x)Add x at endNone
.insert(i, x)Insert x at index iNone
.extend(iterable)Add all items from iterableNone
.pop(i)Remove & return item at i (default last)item
.remove(x)Remove first occurrence of xNone
.index(x)Find first index of xint
.count(x)Count occurrences of xint
.sort()Sort in-placeNone
.reverse()Reverse in-placeNone
.copy()Shallow copylist
.clear()Remove all elementsNone

6.3 List Algorithms

# --- Linear Search O(n) ---
def linear_search(arr, target):
    for i, val in enumerate(arr):
        if val == target:
            return i
    return -1

# --- Bubble Sort O(n²) ---
def bubble_sort(arr):
    a = arr.copy()
    for i in range(len(a) - 1):
        swapped = False
        for j in range(len(a) - 1 - i):
            if a[j] > a[j+1]:
                a[j], a[j+1] = a[j+1], a[j]
                swapped = True
        if not swapped:
            break          # already sorted
    return a

# --- Binary Search O(log n) — list must be sorted! ---
def binary_search(arr, target):
    lo, hi = 0, len(arr) - 1
    while lo <= hi:
        mid = (lo + hi) // 2
        if arr[mid] == target: return mid
        elif arr[mid] < target: lo = mid + 1
        else:                   hi = mid - 1
    return -1

nums = [11, 12, 22, 25, 34, 64, 90]
print(binary_search(nums, 25))   # 3
print(binary_search(nums, 30))   # -1
07

ArrayList (Dynamic Lists)

Python Lists as Dynamic Arrays

Python lists are already dynamic arrays — they resize automatically. Internally, Python over-allocates capacity in chunks, so append() is O(1) amortised. Inserting or removing from the beginning is O(n) because every element must shift.

OperationTime ComplexityNote
Access by indexO(1)Direct memory lookup
Append (end)O(1) amortisedOccasional resize is O(n)
Insert at index iO(n)Elements after i must shift
Remove by indexO(n)Elements after shift left
Search (unsorted)O(n)Linear scan
Search (sorted)O(log n)Binary search
import sys

# Lists grow dynamically
lst = []
for i in range(8):
    lst.append(i)
    print(f"len={len(lst):2}  bytes={sys.getsizeof(lst)}")
# Size jumps in chunks — not every append triggers a resize

# For numeric performance: array module (homogeneous, C-style)
from array import array
int_arr = array('i', [1, 2, 3, 4, 5])   # 'i' = signed int
flt_arr = array('f', [1.5, 2.7])         # 'f' = float

import sys
lst_5  = [1, 2, 3, 4, 5]
arr_5  = array('i', [1, 2, 3, 4, 5])
print(f"list  : {sys.getsizeof(lst_5)} bytes")
print(f"array : {sys.getsizeof(arr_5)} bytes")  # much smaller
# array is more memory-efficient but only one type allowed

For heavy numerical work, consider NumPy arrays — vectorised operations, broadcasting, and much faster than Python lists for math.

08

2D Arrays (Nested Lists)

8.1 Creating 2D Lists

🚨

Common trap: [[0]*cols]*rows creates references to the same row — modifying one row modifies all. Always use a list comprehension: [[0]*cols for _ in range(rows)]

matrix = [[1, 2, 3],
          [4, 5, 6],
          [7, 8, 9]]

# Safe creation
rows, cols = 3, 4
grid = [[0] * cols for _ in range(rows)]

# Access
val = matrix[1][2]        # 6  (row 1, col 2)
matrix[2][1] = 10         # modify element

# Dimensions
r = len(matrix)            # 3
c = len(matrix[0])         # 3

# Get column (no built-in)
col2 = [row[2] for row in matrix]   # [3, 6, 9]

# Iterate all elements
for i, row in enumerate(matrix):
    for j, val in enumerate(row):
        print(f"[{i}][{j}] = {val}")

# Flatten to 1D
flat = [v for row in matrix for v in row]
# [1, 2, 3, 4, 5, 6, 7, 8, 9]

8.2 Matrix Operations

def add_matrices(A, B):
    rows, cols = len(A), len(A[0])
    return [[A[i][j] + B[i][j] for j in range(cols)] for i in range(rows)]

def multiply_matrices(A, B):
    rA, cA, cB = len(A), len(A[0]), len(B[0])
    R = [[0]*cB for _ in range(rA)]
    for i in range(rA):
        for j in range(cB):
            for k in range(cA):
                R[i][j] += A[i][k] * B[k][j]
    return R

# Transpose (swap rows/cols) — Pythonic with zip
def transpose(m):
    return [list(row) for row in zip(*m)]

# Rotate 90° clockwise — Pythonic
def rotate_cw(m):
    return [list(row[::-1]) for row in zip(*m)]

# Is symmetric?
def is_symmetric(m):
    n = len(m)
    return all(m[i][j] == m[j][i] for i in range(n) for j in range(i+1, n))

A = [[1,2],[3,4]]
B = [[5,6],[7,8]]
print(add_matrices(A, B))      # [[6,8],[10,12]]
print(multiply_matrices(A, B)) # [[19,22],[43,50]]
print(transpose(A))            # [[1,3],[2,4]]
09

Inheritance

9.1 Basic Inheritance

Inheritance lets a child class reuse code from a parent class. Use super() to call the parent's methods. Override a method by redefining it in the child class — Python automatically calls the most specific version.

🐾
Inheritance
Child class gets all parent methods & attributes automatically.
🔁
Override
Redefine a parent method in the child to change behaviour.
🔼
super()
Call parent's version of a method from the child.
🎭
Polymorphism
Same interface, different behaviour depending on the object type.
🚫
Abstract class
Can't be instantiated; forces subclasses to implement marked methods.
📐
MRO
Method Resolution Order — Python's algorithm for finding which class provides a method.
class Animal:
    def __init__(self, name, age):
        self.name = name
        self.age  = age

    def speak(self):
        return f"{self.name} makes a sound"

    def eat(self, food):
        return f"{self.name} eats {food}"

class Dog(Animal):
    def __init__(self, name, age, breed):
        super().__init__(name, age)      # call parent __init__
        self.breed = breed

    def speak(self):                     # override
        return f"{self.name} says Woof!"

    def fetch(self, item):
        return f"{self.name} fetches the {item}"

class Cat(Animal):
    def __init__(self, name, age, color):
        super().__init__(name, age)
        self.color = color

    def speak(self):
        return f"{self.name} says Meow!"

# Polymorphism in action
animals = [Dog("Buddy", 3, "Retriever"), Cat("Whiskers", 5, "orange")]
for a in animals:
    print(a.speak())          # each calls its own speak()
    print(a.eat("treats"))    # inherited from Animal

print(isinstance(animals[0], Animal))  # True

9.2 Abstract Base Classes

from abc import ABC, abstractmethod
import math

class Shape(ABC):
    @abstractmethod
    def area(self): pass

    @abstractmethod
    def perimeter(self): pass

    def description(self):           # concrete shared method
        return f"I am a {self.__class__.__name__}"

class Circle(Shape):
    def __init__(self, r):    self.r = r
    def area(self):           return math.pi * self.r ** 2
    def perimeter(self):      return 2 * math.pi * self.r

class Rectangle(Shape):
    def __init__(self, w, h): self.w, self.h = w, h
    def area(self):           return self.w * self.h
    def perimeter(self):      return 2 * (self.w + self.h)

# Shape()  ← TypeError: Can't instantiate abstract class
c = Circle(5)
r = Rectangle(4, 6)
print(f"Circle area: {c.area():.2f}")       # 78.54
print(f"Rectangle area: {r.area()}")        # 24
print(c.description())                       # I am a Circle

9.3 super() Method Chaining

class Person:
    def __init__(self, name, age):
        self.name, self.age = name, age

    def introduce(self):
        return f"Hi, I'm {self.name}, {self.age} years old"

class Employee(Person):
    def __init__(self, name, age, emp_id, dept):
        super().__init__(name, age)
        self.emp_id = emp_id
        self.dept   = dept

    def introduce(self):
        return super().introduce() + f". I work in {self.dept}"

class Manager(Employee):
    def __init__(self, name, age, emp_id, dept, team_size):
        super().__init__(name, age, emp_id, dept)
        self.team_size = team_size

    def introduce(self):
        return super().introduce() + f" and manage {self.team_size} people"

mgr = Manager("Alice", 35, "E123", "Engineering", 8)
print(mgr.introduce())
# Hi, I'm Alice, 35 years old. I work in Engineering and manage 8 people
10

Recursion

10.1 The Two Rules of Recursion

🛑
Base Case
Condition that stops the recursion. Every recursive function must have at least one.
🔄
Recursive Case
The function calls itself with a simpler / smaller version of the problem.
📚
Call Stack
Each call adds a frame. Python limits to ~1000 by default (sys.setrecursionlimit).
🧠
Memoization
Cache results to avoid redundant computation. Use @functools.lru_cache.

10.2 Classic Recursion Examples

def factorial(n):
    if n <= 1:                      # base case
        return 1
    return n * factorial(n - 1)    # recursive case

# Call trace for factorial(5):
# factorial(5) = 5 * factorial(4)
#              = 5 * 4 * factorial(3)
#              = 5 * 4 * 3 * factorial(2)
#              = 5 * 4 * 3 * 2 * factorial(1)
#              = 5 * 4 * 3 * 2 * 1 = 120

print(factorial(5))   # 120
print(factorial(10))  # 3628800
from functools import lru_cache

# 1. Naive recursive — O(2^n) — very slow for large n
def fib_naive(n):
    if n <= 1: return n
    return fib_naive(n-1) + fib_naive(n-2)

# 2. Memoized — O(n) — fast with caching
@lru_cache(maxsize=None)
def fib_memo(n):
    if n <= 1: return n
    return fib_memo(n-1) + fib_memo(n-2)

# 3. Iterative — O(n) time, O(1) space — best for single values
def fib_iter(n):
    a, b = 0, 1
    for _ in range(n):
        a, b = b, a + b
    return a

print(fib_iter(35))   # 9227465  (instant)
# fib_naive(35)       # noticeably slow!
# --- Recursive Binary Search ---
def bin_search(arr, target, lo=0, hi=None):
    if hi is None: hi = len(arr) - 1
    if lo > hi: return -1
    mid = (lo + hi) // 2
    if arr[mid] == target: return mid
    if arr[mid] < target:  return bin_search(arr, target, mid+1, hi)
    return                         bin_search(arr, target, lo, mid-1)

# --- Palindrome ---
def is_palindrome(s):
    s = s.lower().replace(" ", "")
    if len(s) <= 1: return True
    if s[0] != s[-1]: return False
    return is_palindrome(s[1:-1])

print(is_palindrome("A man a plan a canal Panama"))  # True

# --- Tower of Hanoi ---
def hanoi(n, src, tgt, aux):
    if n == 1:
        print(f"Move disk 1: {src} → {tgt}")
        return
    hanoi(n-1, src, aux, tgt)
    print(f"Move disk {n}: {src} → {tgt}")
    hanoi(n-1, aux, tgt, src)

hanoi(3, 'A', 'C', 'B')   # Prints 7 moves
# Pattern 1: Tail recursion (last op is the call)
def tail_factorial(n, acc=1):
    if n <= 1: return acc
    return tail_factorial(n-1, n * acc)   # no work after call

# Pattern 2: Divide & conquer power — O(log n)
def fast_power(base, exp):
    if exp == 0: return 1
    half = fast_power(base, exp // 2)
    return half * half if exp % 2 == 0 else base * half * half

# Pattern 3: Mutual recursion
def is_even(n): return True  if n == 0 else is_odd(n-1)
def is_odd(n):  return False if n == 0 else is_even(n-1)

print(is_even(4))  # True
print(fast_power(2, 10))  # 1024
⚠️

When to use recursion: Natural for tree/graph traversal, divide-and-conquer, and problems defined recursively (like Fibonacci). Prefer iteration when the depth could be large or performance is critical. Python's default recursion limit is 1000 — check with import sys; sys.getrecursionlimit().

Scroll to Top