Complete Python Programming Notes – Basic To Advanced


Unit 1: Primitive Types and Basics

1.1 Variables and Data Types

Primitive Data Types in Python

Data TypeDescriptionExampleSize/Memory
intWhole numbers (unlimited precision)age = 17Variable (arbitrary precision)
floatDecimal/floating-point numbersprice = 19.9964-bit (double precision)
boolBoolean (True/False)is_student = TrueSingle bit (practically 1 byte)
strString/sequence of charactersname = "Alice"Variable (1 byte per char + overhead)
complexComplex numbersz = 3 + 4j2 × 64-bit
NoneTypeRepresents absence of valueresult = NoneReference

Variable Naming Rules

# Valid variable names
student_name = "John"      # Snake case (Python convention)
studentAge = 20            # Camel case (also works but not Pythonic)
_student = "Jane"          # Starts with underscore (private convention)
student1 = "Bob"           # Can contain numbers (not at start)
STUDENT = "Alice"          # All caps (constants convention)

# Invalid variable names
# 1student = "John"        # Cannot start with number
# student-name = "John"    # Hyphen not allowed
# class = "Math"           # Cannot use reserved keywords
# student name = "John"    # No spaces allowed

Python Reserved Keywords

# Cannot use these as variable names

# ['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

Arithmetic Operators

OperatorNameExampleResult
+Addition5 + 38
-Subtraction5 - 32
*Multiplication5 * 315
/Division (float)5 / 22.5
//Integer division (floor)5 // 22
%Modulo (remainder)5 % 21
**Exponentiation5 ** 225
-Unary negation-5-5
+Unary positive+55

Operator Precedence (PEMDAS)

# Order of operations (highest to lowest)
# 1. Parentheses: ()
# 2. Exponentiation: **
# 3. Unary operators: +x, -x
# 4. Multiplication/Division/Modulo: *, /, //, %
# 5. Addition/Subtraction: +, -
# 6. Comparison operators
# 7. Logical NOT: not
# 8. Logical AND: and
# 9. Logical OR: or

result = 5 + 3 * 2 ** 2    # 5 + 3 * 4 = 5 + 12 = 17
result = (5 + 3) * 2 ** 2  # 8 * 4 = 32
result = 10 / 2 * 3         # 5 * 3 = 15 (left to right for same precedence)

Assignment Operators

x = 5        # Basic assignment
x += 3       # x = x + 3  (now 8)
x -= 2       # x = x - 2  (now 6)
x *= 4       # x = x * 4  (now 24)
x /= 3       # x = x / 3  (now 8.0)
x //= 2      # x = x // 2 (now 4.0)
x %= 3       # x = x % 3  (now 1.0)
x **= 2      # x = x ** 2 (now 1.0)

1.3 Input and Output

Print Function

# Basic output
print("Hello, World!")
print(42)
print(3.14159)

# Multiple arguments (automatically space-separated)
print("The answer is", 42, "and pi is", 3.14)

# Custom separators
print("apple", "banana", "cherry", sep=", ")  # apple, banana, cherry

# Custom end character (default is newline)
print("Loading", end="...")
print("Done!")  # Loading...Done!

# Formatted strings (f-strings) - Python 3.6+
name = "Alice"
age = 17
print(f"Hello, {name}! You are {age} years old.")
print(f"Next year you will be {age + 1}.")
print(f"Pi is approximately {3.14159:.2f}")  # Pi is approximately 3.14

# .format() method (older style)
print("Hello, {}! You are {} years old.".format(name, age))

# % formatting (oldest style)
print("Hello, %s! You are %d years old." % (name, age))

Input Function

# Input always returns a string
name = input("Enter your name: ")
print(f"Hello, {name}!")

# Convert input to other types
age = int(input("Enter your age: "))        # Convert to integer
height = float(input("Enter your height: ")) # Convert to float
is_student = input("Are you a student? (yes/no): ").lower() == "yes"

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

# Input validation
try:
    age = int(input("Enter age: "))
    if age < 0 or age > 150:
        print("Invalid age!")
    else:
        print(f"Valid age: {age}")
except ValueError:
    print("Please enter a valid number!")

1.4 Type Conversion (Casting)

# Implicit conversion (automatic)
x = 5       # int
y = 2.5     # float
z = x + y   # float (8.5) - int promoted to float

# Explicit conversion
# To integer
int(3.14)      # 3 (truncates)
int("42")      # 42
int(True)      # 1
int(False)     # 0
int("101", 2)  # 5 (binary to decimal)

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

# To string
str(42)        # "42"
str(3.14)      # "3.14"
str(True)      # "True"

# To boolean
bool(0)        # False
bool(1)        # True
bool("")       # False
bool("Hello")  # True
bool([])       # False
bool([1,2])    # True
bool(None)     # False

# Rounding
round(3.14159)     # 3
round(3.14159, 2)  # 3.14
round(2.5)         # 2 (rounds to even - banker's rounding)

Unit 2: Using Objects

2.1 Object-Oriented Concepts in Python

Everything is an Object

# In Python, even primitive types are objects
x = 5
print(x.__class__)           # <class 'int'>
print(dir(x))                # Shows all methods available

y = "Hello"
print(y.upper())             # HELLO
print(y.lower())              # hello
print(y.__len__())            # 5 (same as len(y))

Creating and Using Objects

# Creating instances of built-in classes
my_list = list()              # Create a list object
my_dict = dict()              # Create a dictionary object
my_str = str("Hello")         # Create a string object

# Using object methods
my_list.append(10)            # Call method on object
my_list.sort()                 # Another method

# Object attributes
class Person:
    def __init__(self, name):
        self.name = name      # Instance attribute

p = Person("Alice")
print(p.name)                  # Access attribute

2.2 String Methods (String Objects)

Common String Methods

text = "  Hello, World!  "

# Case conversion
text.upper()           # "  HELLO, WORLD!  "
text.lower()           # "  hello, world!  "
text.title()           # "  Hello, World!  "
text.capitalize()      # "  hello, world!  "
text.swapcase()        # "  hELLO, wORLD!  "

# Trimming and padding
text.strip()           # "Hello, World!" (remove both ends)
text.lstrip()          # "Hello, World!  " (remove left)
text.rstrip()          # "  Hello, World!" (remove right)
text.center(20)        # "  Hello, World!   "
text.ljust(20)         # "  Hello, World!    "
text.rjust(20)         # "    Hello, World!  "

# Searching
text.find("World")     # 9 (returns index, -1 if not found)
text.index("World")    # 9 (raises error if not found)
text.count("o")        # 2
text.startswith("  ")  # True
text.endswith("!")     # True

# Checking content
text.isalpha()         # False (has spaces and punctuation)
text.isdigit()         # False
text.isalnum()         # False
text.isspace()         # False

# Replacing
text.replace("World", "Python")  # "  Hello, Python!  "
text.replace("o", "0")            # "  Hell0, W0rld!  "

# Splitting and joining
words = text.split(",")           # ["  Hello", " World!  "]
parts = text.split()               # ["Hello,", "World!"] (splits on whitespace)
joined = "-".join(["a", "b", "c"]) # "a-b-c"

# Formatting
name = "Alice"
age = 17
template = "Name: {}, Age: {}"
template.format(name, age)         # "Name: Alice, Age: 17"

f"{name:>10}"                      # "     Alice" (right align, width 10)
f"{name:<10}"                      # "Alice     " (left align)
f"{name:^10}"                      # "  Alice   " (center)
f"{age:03d}"                       # "017" (zero pad)
f"{3.14159:.2f}"                   # "3.14"

String Slicing

text = "Hello, World!"

# Slicing syntax: [start:stop:step]
text[0:5]           # "Hello" (indices 0-4)
text[7:12]          # "World" (indices 7-11)
text[:5]            # "Hello" (start default 0)
text[7:]            # "World!" (stop default end)
text[-6:-1]         # "World" (negative indices from end)
text[::2]           # "Hlo ol!" (every 2nd character)
text[::-1]          # "!dlroW ,olleH" (reverse)

# Immutability - strings cannot be changed
# text[0] = "J"     # ERROR: strings are immutable
text = "J" + text[1:]  # Must create new string

2.3 Math Library

import math

# Constants
math.pi           # 3.141592653589793
math.e            # 2.718281828459045
math.tau          # 6.283185307179586 (2Ï€)
math.inf          # infinity
math.nan          # Not a Number

# Rounding
math.ceil(3.2)    # 4 (round up)
math.floor(3.8)   # 3 (round down)
math.trunc(3.7)   # 3 (truncate decimal)
math.modf(3.14)   # (0.14000000000000012, 3.0) (fractional, integer)

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

# Trigonometry (angles in radians)
math.sin(math.pi/2)   # 1.0
math.cos(0)           # 1.0
math.tan(math.pi/4)   # 1.0
math.asin(1)          # arcsin = π/2 ≈ 1.571
math.radians(180)     # Convert degrees to radians = π
math.degrees(math.pi) # Convert radians to degrees = 180

# Other useful functions
math.fabs(-5.5)       # 5.5 (absolute value as float)
math.factorial(5)     # 120 (5!)
math.gcd(12, 18)      # 6 (greatest common divisor)
math.isfinite(5)      # True
math.isinf(float('inf'))  # True
math.isnan(float('nan'))  # True

Random Module

import random

# Basic random
random.random()              # Random float [0.0, 1.0)
random.randint(1, 10)        # Random integer [1, 10] (inclusive)
random.randrange(10)         # Random integer [0, 9]
random.randrange(1, 10, 2)   # Random odd number 1,3,5,7,9

# Sequence operations
my_list = [1, 2, 3, 4, 5]
random.choice(my_list)        # Random element
random.choices(my_list, k=3)  # 3 random elements (with replacement)
random.sample(my_list, k=3)   # 3 unique random elements
random.shuffle(my_list)       # Shuffle in place

# Random distributions
random.uniform(1, 10)         # Random float [1, 10]
random.gauss(0, 1)            # Gaussian distribution (mean, stddev)
random.expovariate(1)         # Exponential distribution

# Setting seed for reproducibility
random.seed(42)               # Same sequence each time
print(random.random())        # Will be same every run with seed 42

Unit 3: Boolean Expressions and if Statements

3.1 Boolean Expressions

Comparison Operators

# All comparison operators return True/False
x = 5
y = 10

x == y          # False (equal to)
x != y          # True (not equal to)
x < y           # True (less than)
x > y           # False (greater than)
x <= y          # True (less than or equal)
x >= y          # False (greater than or equal)

# Chained comparisons (Python unique feature)
1 < x < 10      # True (same as 1 < x and x < 10)
5 == x == 5     # True

Logical Operators

a = True
b = False

# AND - both must be True
a and b          # False
a and True       # True

# OR - at least one must be True
a or b           # True
b or False       # False

# NOT - reverses boolean value
not a            # False
not b            # True

# Short-circuit evaluation
def expensive_function():
    print("Called!")
    return True

# Second expression not evaluated if first is False
False and expensive_function()  # expensive_function NOT called
True or expensive_function()    # expensive_function NOT called

# Truth values - what's considered True/False?
# False values: False, 0, 0.0, "" (empty string), [], (), {}, None
# All other values are True

if "":      # False
if "Hello": # True
if []:      # False
if [1,2]:   # True
if None:    # False

3.2 if Statements

Basic if Statement

age = 18

if age >= 18:
    print("You are an adult")
    print("You can vote")

# Indentation is crucial! (typically 4 spaces)

if-else Statement

temperature = 25

if temperature > 30:
    print("It's hot!")
    print("Stay hydrated")
else:
    print("It's not too hot")
    print("Enjoy the weather")

if-elif-else Chain

score = 85

if score >= 90:
    grade = "A"
    print("Excellent!")
elif score >= 80:
    grade = "B"
    print("Good job!")
elif score >= 70:
    grade = "C"
    print("Satisfactory")
elif score >= 60:
    grade = "D"
    print("Needs improvement")
else:
    grade = "F"
    print("Failed")

print(f"Grade: {grade}")

# Note: elif is else + if in one word

Nested Conditionals

age = 25
has_license = True

if age >= 18:
    if has_license:
        print("You can drive")
    else:
        print("Get a license first")
else:
    print("Too young to drive")

# Better to use logical operators:
if age >= 18 and has_license:
    print("You can drive")
elif age >= 18 and not has_license:
    print("Get a license first")
else:
    print("Too young to drive")

3.3 Conditional Expressions (Ternary Operator)

# Traditional if-else
if age >= 18:
    status = "adult"
else:
    status = "minor"

# Ternary operator (conditional expression)
status = "adult" if age >= 18 else "minor"

# Multiple conditions
category = "senior" if age >= 65 else "adult" if age >= 18 else "minor"

# Using with assignment
x = 5
y = 10
max_value = x if x > y else y

3.4 Common Patterns and Examples

Input Validation

# Validate numeric input
user_input = input("Enter a positive number: ")
if user_input.isdigit():
    number = int(user_input)
    if number > 0:
        print(f"Valid input: {number}")
    else:
        print("Number must be positive")
else:
    print("Please enter a valid number")

# Menu selection
print("1. Start game")
print("2. Load game")
print("3. Settings")
print("4. Quit")

choice = input("Choose option: ")

if choice == "1":
    start_game()
elif choice == "2":
    load_game()
elif choice == "3":
    show_settings()
elif choice == "4":
    quit_game()
else:
    print("Invalid choice")

Leap Year Detection

year = 2024

# Leap year rules:
# Divisible by 4
# But not divisible by 100, unless also divisible by 400

is_leap = (year % 4 == 0) and (year % 100 != 0 or year % 400 == 0)

if is_leap:
    print(f"{year} is a leap year")
else:
    print(f"{year} is not a leap year")

Character Type Checking

char = "A"

if char.isupper():
    print("Uppercase letter")
elif char.islower():
    print("Lowercase letter")
elif char.isdigit():
    print("Digit")
elif char.isspace():
    print("Whitespace")
else:
    print("Special character or punctuation")

Unit 4: Iteration (Loops)

4.1 while Loops

Basic while Loop

# Basic while loop
count = 0
while count < 5:
    print(f"Count: {count}")
    count += 1

# Infinite loop (be careful!)
# while True:
#     print("This will run forever")

# while loop with break
while True:
    user_input = input("Enter 'quit' to exit: ")
    if user_input == "quit":
        break
    print(f"You entered: {user_input}")

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

Common while Loop Patterns

# Input validation with while
while True:
    age = input("Enter your age (must be 0-120): ")
    if age.isdigit():
        age = int(age)
        if 0 <= age <= 120:
            break
    print("Invalid input, try again")
print(f"Valid age: {age}")

# Menu loop
choice = ""
while choice != "4":
    print("\n1. Option 1")
    print("2. Option 2")
    print("3. Option 3")
    print("4. Exit")
    choice = input("Choose option: ")

    if choice == "1":
        print("Option 1 selected")
    elif choice == "2":
        print("Option 2 selected")
    elif choice == "3":
        print("Option 3 selected")
    elif choice == "4":
        print("Goodbye!")
    else:
        print("Invalid choice")

# Sentinel-controlled loop
total = 0
count = 0
number = float(input("Enter a number (negative to stop): "))
while number >= 0:
    total += number
    count += 1
    number = float(input("Enter another number (negative to stop): "))

if count > 0:
    average = total / count
    print(f"Average: {average}")
else:
    print("No numbers entered")

4.2 for Loops

for Loop with range()

# range(stop) - from 0 to stop-1
for i in range(5):
    print(i)  # 0, 1, 2, 3, 4

# range(start, stop) - from start to stop-1
for i in range(2, 7):
    print(i)  # 2, 3, 4, 5, 6

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

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

# Common patterns
# Sum of numbers 1 to 100
total = 0
for i in range(1, 101):
    total += i
print(f"Sum: {total}")

# Factorial
n = 5
factorial = 1
for i in range(1, n + 1):
    factorial *= i
print(f"{n}! = {factorial}")

for Loop with Sequences

# Iterating through strings
word = "Hello"
for char in word:
    print(char)  # H, e, l, l, o

# With index
for i, char in enumerate(word):
    print(f"Index {i}: {char}")

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

# With index
for i, fruit in enumerate(fruits):
    print(f"{i}: {fruit}")

# Iterating through dictionaries
person = {"name": "Alice", "age": 17, "city": "New York"}

for key in person:
    print(f"{key}: {person[key]}")

for value in person.values():
    print(value)

for key, value in person.items():
    print(f"{key}: {value}")

4.3 Nested Loops

# Multiplication table
for i in range(1, 6):
    for j in range(1, 6):
        print(f"{i * j:4}", end="")
    print()  # New line after each row

# Output:
#    1   2   3   4   5
#    2   4   6   8  10
#    3   6   9  12  15
#    4   8  12  16  20
#    5  10  15  20  25

# Drawing patterns
for i in range(1, 6):
    print("*" * i)

# *
# **
# ***
# ****
# *****

# More complex pattern
for i in range(5):
    for j in range(5 - i - 1):
        print(" ", end="")
    for j in range(2 * i + 1):
        print("*", end="")
    print()

#     *
#    ***
#   *****
#  *******
# *********

# Nested loops with lists
matrix = [
    [1, 2, 3],
    [4, 5, 6],
    [7, 8, 9]
]

for row in matrix:
    for element in row:
        print(element, end=" ")
    print()
# 1 2 3
# 4 5 6
# 7 8 9

4.4 Loop Control Statements

break Statement

# Exit loop immediately
for i in range(10):
    if i == 5:
        break
    print(i)  # Prints 0, 1, 2, 3, 4

# Find first occurrence
numbers = [3, 7, 2, 9, 4, 1]
target = 9
for i, num in enumerate(numbers):
    if num == target:
        print(f"Found at index {i}")
        break
else:
    print("Not found")  # Executes if loop completes without break

# Break in nested loops (only breaks inner loop)
for i in range(3):
    for j in range(3):
        if j == 1:
            break
        print(f"({i}, {j})")  # Only prints when j=0

continue Statement

# Skip current iteration
for i in range(10):
    if i % 2 == 0:
        continue
    print(i)  # Prints odd numbers: 1, 3, 5, 7, 9

# Process only valid items
data = [5, -2, 10, -8, 0, 3]
for value in data:
    if value <= 0:
        continue
    print(f"Processing: {value}")

else Clause with Loops

# else executes if loop completes without break
for i in range(5):
    print(i)
else:
    print("Loop completed normally")  # Executes

for i in range(5):
    if i == 3:
        break
    print(i)
else:
    print("Loop completed normally")  # Doesn't execute (break occurred)

# Common use: search loops
numbers = [2, 4, 6, 8, 9, 10]
for num in numbers:
    if num % 2 != 0:
        print(f"Found odd number: {num}")
        break
else:
    print("No odd numbers found")

4.5 Loop Examples and Patterns

Accumulator Pattern

# Sum of squares
numbers = [1, 2, 3, 4, 5]
sum_squares = 0
for n in numbers:
    sum_squares += n ** 2
print(f"Sum of squares: {sum_squares}")

# Building a string
words = ["hello", "world", "python"]
result = ""
for word in words:
    result += word + " "
print(result.strip())  # "hello world python"

# List comprehension (Pythonic alternative)
sum_squares = sum(n ** 2 for n in numbers)

Maximum/Minimum Pattern

numbers = [45, 23, 89, 12, 67, 34]

# Find maximum
max_value = numbers[0]  # Start with first element
for num in numbers[1:]:
    if num > max_value:
        max_value = num
print(f"Maximum: {max_value}")

# Using built-in functions
print(f"Max: {max(numbers)}")
print(f"Min: {min(numbers)}")

# Find index of maximum
max_index = 0
for i in range(1, len(numbers)):
    if numbers[i] > numbers[max_index]:
        max_index = i
print(f"Index of max: {max_index}, value: {numbers[max_index]}")

Filter Pattern

# Filter even numbers
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
evens = []
for num in numbers:
    if num % 2 == 0:
        evens.append(num)
print(f"Even numbers: {evens}")

# List comprehension alternative
evens = [num for num in numbers if num % 2 == 0]

# Filter strings by length
words = ["cat", "elephant", "dog", "butterfly", "ant"]
long_words = []
for word in words:
    if len(word) > 4:
        long_words.append(word)
print(f"Long words: {long_words}")

Count Pattern

# Count occurrences
text = "hello world"
count_e = 0
for char in text:
    if char == 'e':
        count_e += 1
print(f"Number of 'e's: {count_e}")

# Count vowels
vowels = "aeiou"
text = "Hello, how are you today?"
count = 0
for char in text.lower():
    if char in vowels:
        count += 1
print(f"Number of vowels: {count}")

Unit 5: Writing Classes

5.1 Class Definition Basics

Class Syntax

class ClassName:
    """Class docstring - describes what this class does"""

    # Class variable (shared by all instances)
    class_variable = "shared value"

    # Constructor - initializes new instances
    def __init__(self, param1, param2):
        """Initialize instance attributes"""
        # Instance variables (unique to each instance)
        self.instance_var1 = param1
        self.instance_var2 = param2
        self._private_var = None  # Convention: private starts with _
        self.__really_private = None  # Name mangling (more private)

    # Instance method
    def method_name(self, parameter):
        """Method docstring"""
        # Access instance variables using self
        return f"{self.instance_var1} {parameter}"

    # Special methods (__str__, __repr__, etc.)
    def __str__(self):
        """String representation for users"""
        return f"ClassName({self.instance_var1})"

    def __repr__(self):
        """String representation for developers"""
        return f"ClassName('{self.instance_var1}', '{self.instance_var2}')"

Simple Class Example

class Dog:
    """A simple Dog class"""

    # Class variable (same for all dogs)
    species = "Canis familiaris"
    dog_count = 0

    def __init__(self, name, age):
        """Initialize dog with name and age"""
        self.name = name
        self.age = age
        Dog.dog_count += 1  # Increment class variable

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

    def get_human_years(self):
        """Convert dog years to human years"""
        return self.age * 7

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

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

# Creating instances
my_dog = Dog("Buddy", 3)
your_dog = Dog("Max", 5)

# Using methods
print(my_dog.bark())          # Buddy says Woof!
print(my_dog.get_human_years())  # 21
print(my_dog)                 # Buddy (3 years old)

# Accessing attributes
print(my_dog.name)            # Buddy
print(my_dog.age)             # 3
print(Dog.species)            # Canis familiaris
print(Dog.dog_count)          # 2
print(my_dog.dog_count)       # 2 (also accessible through instance)

5.2 Instance Variables and Methods

Instance Variables

class Student:
    def __init__(self, name, age):
        # Instance variables created here
        self.name = name
        self.age = age
        self.grades = []      # Empty list for grades
        self._id = None       # "Protected" (by convention)
        self.__ssn = None     # "Private" (name mangling)

    # Instance method
    def add_grade(self, grade):
        """Add a grade to student's record"""
        if 0 <= grade <= 100:
            self.grades.append(grade)
            return True
        return False

    def average(self):
        """Calculate average grade"""
        if self.grades:
            return sum(self.grades) / len(self.grades)
        return 0

# Creating and using
s = Student("Alice", 17)
s.add_grade(85)
s.add_grade(92)
s.add_grade(78)
print(s.average())  # 85.0

# Dynamic attributes (can add anytime - but be careful!)
s.address = "123 Main St"  # Creates new attribute
print(s.address)  # 123 Main St

# Access control (by convention, not enforced)
# s._id = "12345"  # Works, but indicates "protected"
# s.__ssn = "123-45-6789"  # Actually stored as _Student__ssn

5.3 Class Variables and Methods

class BankAccount:
    # Class variables
    bank_name = "Python Bank"
    interest_rate = 0.02
    account_count = 0

    def __init__(self, owner, balance=0):
        self.owner = owner
        self.balance = balance
        BankAccount.account_count += 1
        self.account_number = BankAccount.account_count

    # Instance method
    def deposit(self, amount):
        self.balance += amount
        return self.balance

    # Class method (operates on class, not instance)
    @classmethod
    def set_interest_rate(cls, new_rate):
        """Set interest rate for all accounts"""
        cls.interest_rate = new_rate

    @classmethod
    def from_string(cls, account_string):
        """Alternative constructor - creates account from string"""
        owner, balance = account_string.split("-")
        return cls(owner, float(balance))

    # Static method (utility function related to class)
    @staticmethod
    def validate_amount(amount):
        """Check if amount is valid for transaction"""
        return amount > 0 and amount < 1000000

    def apply_interest(self):
        """Apply interest to this account"""
        interest = self.balance * BankAccount.interest_rate
        self.balance += interest
        return interest

# Using class methods
BankAccount.set_interest_rate(0.025)  # Change for all accounts
print(BankAccount.interest_rate)      # 0.025

# Alternative constructor
acc = BankAccount.from_string("John-500")
print(acc.owner)      # John
print(acc.balance)    # 500.0

# Static method
print(BankAccount.validate_amount(1000))    # True
print(BankAccount.validate_amount(-50))     # False

# Instance methods
acc1 = BankAccount("Alice", 1000)
acc2 = BankAccount("Bob", 2000)
print(BankAccount.account_count)  # 3 (including John)

5.4 Properties (Getters and Setters)

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

    # Getter for name
    @property
    def name(self):
        """Get the person's name"""
        return self._name.title()  # Format nicely

    # Setter for name
    @name.setter
    def name(self, value):
        """Set the person's name"""
        if not isinstance(value, str):
            raise TypeError("Name must be a string")
        if len(value) < 2:
            raise ValueError("Name must be at least 2 characters")
        self._name = value

    # Deleter for name
    @name.deleter
    def name(self):
        print(f"Deleting {self._name}")
        self._name = None

    # Property for age with validation
    @property
    def age(self):
        return self._age

    @age.setter
    def age(self, value):
        if not isinstance(value, (int, float)):
            raise TypeError("Age must be a number")
        if value < 0 or value > 150:
            raise ValueError("Age must be between 0 and 150")
        self._age = value

    # Computed property
    @property
    def email(self):
        if self._email:
            return self._email
        return f"{self._name.lower()}@example.com"

    @email.setter
    def email(self, value):
        if "@" not in value:
            raise ValueError("Invalid email address")
        self._email = value

    # Property with no setter (read-only)
    @property
    def is_adult(self):
        return self._age >= 18

# Using properties
p = Person("alice", 25)

print(p.name)      # Alice (automatically titled)
p.name = "bob"     # Works with setter
# p.name = "a"     # ValueError: Name must be at least 2 characters

print(p.age)       # 25
p.age = 30
# p.age = 200      # ValueError: Age must be between 0 and 150

print(p.email)     # bob@example.com
p.email = "bob@gmail.com"
print(p.email)     # bob@gmail.com

print(p.is_adult)  # True (read-only property)

5.5 Special Methods (Magic/Dunder Methods)

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

    # String representation
    def __str__(self):
        """Called by str() and print()"""
        return f"({self.x}, {self.y})"

    def __repr__(self):
        """Called by repr() and in interpreter"""
        return f"Vector({self.x}, {self.y})"

    # Arithmetic operations
    def __add__(self, other):
        """Self + other"""
        return Vector(self.x + other.x, self.y + other.y)

    def __sub__(self, other):
        """Self - other"""
        return Vector(self.x - other.x, self.y - other.y)

    def __mul__(self, scalar):
        """Self * scalar"""
        return Vector(self.x * scalar, self.y * scalar)

    def __rmul__(self, scalar):
        """scalar * self (right multiplication)"""
        return self.__mul__(scalar)

    def __neg__(self):
        """-self"""
        return Vector(-self.x, -self.y)

    # Comparison
    def __eq__(self, other):
        """Self == other"""
        return self.x == other.x and self.y == other.y

    def __lt__(self, other):
        """Self < other (by magnitude)"""
        return self.magnitude() < other.magnitude()

    # Length/magnitude
    def __abs__(self):
        """abs(self)"""
        return self.magnitude()

    def __len__(self):
        """len(self)"""
        return 2  # Vector has 2 components

    # Container emulation
    def __getitem__(self, index):
        """self[index]"""
        if index == 0:
            return self.x
        elif index == 1:
            return self.y
        raise IndexError("Vector index out of range")

    def __setitem__(self, index, value):
        """self[index] = value"""
        if index == 0:
            self.x = value
        elif index == 1:
            self.y = value
        else:
            raise IndexError("Vector index out of range")

    # Iterator support
    def __iter__(self):
        """Iterate over components"""
        yield self.x
        yield self.y

    # Boolean context
    def __bool__(self):
        """bool(self) - True if non-zero vector"""
        return self.x != 0 or self.y != 0

    # Callable object
    def __call__(self, scale):
        """self(scale) - scale the vector"""
        return Vector(self.x * scale, self.y * scale)

    def magnitude(self):
        return (self.x ** 2 + self.y ** 2) ** 0.5

# Demonstrate special methods
v1 = Vector(2, 3)
v2 = Vector(1, 4)

print(str(v1))           # (2, 3)
print(repr(v1))          # Vector(2, 3)

v3 = v1 + v2             # __add__
print(v3)                # (3, 7)

v4 = v1 * 3              # __mul__
print(v4)                # (6, 9)

v5 = 3 * v1              # __rmul__
print(v5)                # (6, 9)

print(v1 == Vector(2, 3)) # True
print(v1 < v2)            # Compare by magnitude

print(v1[0])              # 2 (__getitem__)
v1[1] = 5                 # __setitem__
print(v1)                 # (2, 5)

for component in v1:      # __iter__
    print(component)

print(bool(Vector(0, 0)))  # False
print(bool(v1))            # True

v6 = v1(2)                 # __call__
print(v6)                  # (4, 10)

5.6 Encapsulation and Data Hiding

class BankAccount:
    """Demonstrates encapsulation principles"""

    def __init__(self, account_number, owner, initial_balance=0):
        # Private attributes (by convention with _)
        self._account_number = account_number
        self._owner = owner
        self._balance = initial_balance
        self._transaction_history = []
        self._is_active = True

        # Name mangling for "really private" (rarely needed)
        self.__pin = None  # Will be mangled to _BankAccount__pin

    # Public interface
    def deposit(self, amount):
        """Public method to add money"""
        if self._validate_amount(amount):  # Call private method
            self._balance += amount
            self._add_transaction("DEPOSIT", amount)
            return True
        return False

    def withdraw(self, amount, pin):
        """Public method to remove money"""
        if not self._validate_pin(pin):
            print("Invalid PIN")
            return False

        if not self._validate_amount(amount):
            print("Invalid amount")
            return False

        if amount > self._balance:
            print("Insufficient funds")
            return False

        self._balance -= amount
        self._add_transaction("WITHDRAWAL", amount)
        return True

    def get_balance(self, pin):
        """Public method to check balance"""
        if self._validate_pin(pin):
            return self._balance
        return None

    def get_transaction_history(self, pin):
        """Public method to view history"""
        if self._validate_pin(pin):
            return self._transaction_history.copy()  # Return copy, not original
        return None

    def set_pin(self, old_pin, new_pin):
        """Change account PIN"""
        if self._validate_pin(old_pin) and self._validate_pin_format(new_pin):
            self.__pin = new_pin
            self._add_transaction("PIN_CHANGE", 0)
            return True
        return False

    # Private methods (by convention with _)
    def _validate_amount(self, amount):
        """Private validation method"""
        return amount > 0 and amount < 1000000

    def _validate_pin(self, pin):
        """Check if PIN matches"""
        return pin == self.__pin

    def _validate_pin_format(self, pin):
        """Validate PIN format"""
        return isinstance(pin, str) and len(pin) == 4 and pin.isdigit()

    def _add_transaction(self, type, amount):
        """Private method to record transactions"""
        self._transaction_history.append({
            'type': type,
            'amount': amount,
            'balance': self._balance
        })

    # Property for read-only attributes
    @property
    def account_number(self):
        """Read-only account number (masked)"""
        return "XXXX-XXXX-" + self._account_number[-4:]

    @property
    def owner(self):
        """Read-only owner name"""
        return self._owner

    @property
    def is_active(self):
        """Read-only account status"""
        return self._is_active

# Usage
acc = BankAccount("123456789", "Alice", 1000)
acc.set_pin(None, "1234")  # Set initial PIN

acc.deposit(500)  # No PIN needed for deposit
acc.withdraw(200, "1234")

balance = acc.get_balance("1234")
print(f"Balance: {balance}")

history = acc.get_transaction_history("1234")
for trans in history:
    print(f"{trans['type']}: ${trans['amount']}")

# Cannot access private attributes directly
# acc._balance = 1000000  # Works but strongly discouraged
# acc.__pin  # Error: no such attribute (name mangled)

# Can access through property
print(acc.account_number)  # XXXX-XXXX-6789
print(acc.owner)           # Alice

Unit 6: Arrays (Python Lists)

6.1 List Basics

Creating Lists

# Empty list
empty_list = []
empty_list = list()

# List with initial values
numbers = [1, 2, 3, 4, 5]
fruits = ["apple", "banana", "orange"]
mixed = [1, "hello", 3.14, True]  # Lists can hold mixed types

# Using list() constructor
chars = list("hello")  # ['h', 'e', 'l', 'l', 'o']
range_list = list(range(5))  # [0, 1, 2, 3, 4]

# List comprehension
squares = [x**2 for x in range(10)]  # [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
evens = [x for x in range(20) if x % 2 == 0]

# Nested lists (2D arrays)
matrix = [
    [1, 2, 3],
    [4, 5, 6],
    [7, 8, 9]
]

Accessing List Elements

fruits = ["apple", "banana", "cherry", "date", "elderberry"]

# Positive indexing (from beginning)
first = fruits[0]      # "apple"
second = fruits[1]     # "banana"
last = fruits[4]       # "elderberry"

# Negative indexing (from end)
last = fruits[-1]      # "elderberry"
second_last = fruits[-2]  # "date"

# Slicing [start:stop:step]
fruits[1:4]           # ["banana", "cherry", "date"]
fruits[:3]            # ["apple", "banana", "cherry"] (start default 0)
fruits[2:]            # ["cherry", "date", "elderberry"] (stop default end)
fruits[-3:]           # ["cherry", "date", "elderberry"]
fruits[::2]           # ["apple", "cherry", "elderberry"]
fruits[::-1]          # ["elderberry", "date", "cherry", "banana", "apple"]

# Modifying elements
fruits[1] = "blueberry"  # ["apple", "blueberry", "cherry", "date", "elderberry"]
fruits[1:3] = ["grape", "kiwi"]  # Replace slice

6.2 List Operations and Methods

Basic List Operations

a = [1, 2, 3]
b = [4, 5, 6]

# Concatenation
c = a + b  # [1, 2, 3, 4, 5, 6]

# Repetition
d = a * 3  # [1, 2, 3, 1, 2, 3, 1, 2, 3]

# Membership
print(2 in a)      # True
print(7 not in a)  # True

# Length
len(a)  # 3

# Minimum and maximum (if all elements comparable)
min(a)  # 1
max(a)  # 3

# Sum (if all numeric)
sum(a)  # 6

List Methods

numbers = [5, 2, 8, 1, 9]

# Adding elements
numbers.append(3)        # [5, 2, 8, 1, 9, 3] (add at end)
numbers.insert(2, 7)      # [5, 2, 7, 8, 1, 9, 3] (insert at index)
numbers.extend([4, 6])    # [5, 2, 7, 8, 1, 9, 3, 4, 6] (add multiple)

# Removing elements
numbers.pop()             # Removes and returns last element (6)
numbers.pop(2)            # Removes and returns element at index 2 (7)
numbers.remove(8)         # Removes first occurrence of 8
numbers.clear()           # Removes all elements

# Finding elements
numbers = [5, 2, 8, 1, 9, 2]
numbers.index(8)          # 2 (index of first occurrence)
numbers.index(2)          # 1 (first occurrence)
numbers.count(2)          # 2 (number of occurrences)

# Sorting
numbers.sort()            # [1, 2, 2, 5, 8, 9] (in-place)
numbers.sort(reverse=True) # [9, 8, 5, 2, 2, 1]
sorted(numbers)           # Returns new sorted list (original unchanged)

# Reversing
numbers.reverse()         # Reverse in-place

# Copying
new_list = numbers.copy()  # Shallow copy
new_list = numbers[:]      # Also makes a copy

6.3 Iterating Through Lists

fruits = ["apple", "banana", "cherry"]

# Basic for loop
for fruit in fruits:
    print(fruit)

# With index using range
for i in range(len(fruits)):
    print(f"{i}: {fruits[i]}")

# Using enumerate (most Pythonic)
for i, fruit in enumerate(fruits):
    print(f"{i}: {fruit}")

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

# While loop
i = 0
while i < len(fruits):
    print(fruits[i])
    i += 1

# List comprehension
upper_fruits = [fruit.upper() for fruit in fruits]
# ['APPLE', 'BANANA', 'CHERRY']

# Conditional list comprehension
long_fruits = [fruit for fruit in fruits if len(fruit) > 5]
# ['banana', 'cherry']

# Nested loops with 2D lists
matrix = [[1, 2], [3, 4], [5, 6]]
for row in matrix:
    for element in row:
        print(element, end=" ")
    print()
# 1 2
# 3 4
# 5 6

6.4 Common List Patterns

# Sum of elements
numbers = [1, 2, 3, 4, 5]
total = 0
for num in numbers:
    total += num
print(total)  # 15

# Using built-in sum
total = sum(numbers)

# Average
average = sum(numbers) / len(numbers)

# Filter even numbers
evens = [num for num in numbers if num % 2 == 0]

# Transform all elements
doubled = [num * 2 for num in numbers]

# Find maximum
max_value = numbers[0]
for num in numbers[1:]:
    if num > max_value:
        max_value = num

# Using built-in
max_value = max(numbers)
min_value = min(numbers)

# Check if all elements satisfy condition
all_positive = all(num > 0 for num in numbers)
any_even = any(num % 2 == 0 for num in numbers)

# Join list elements to string
words = ["Hello", "World"]
sentence = " ".join(words)  # "Hello World"

# Flatten nested list
nested = [[1, 2], [3, 4], [5, 6]]
flat = [num for row in nested for num in row]
# [1, 2, 3, 4, 5, 6]

# Remove duplicates
items = [1, 2, 2, 3, 3, 3, 4]
unique = list(set(items))  # [1, 2, 3, 4] (order not preserved)

# Preserve order while removing duplicates
seen = set()
unique = []
for item in items:
    if item not in seen:
        unique.append(item)
        seen.add(item)

6.5 List Algorithms

Linear Search

def linear_search(arr, target):
    """Find index of target in list"""
    for i in range(len(arr)):
        if arr[i] == target:
            return i
    return -1  # Not found

# With index built-in
def linear_search_builtin(arr, target):
    try:
        return arr.index(target)
    except ValueError:
        return -1

# Test
numbers = [5, 2, 8, 1, 9, 3]
print(linear_search(numbers, 8))   # 2
print(linear_search(numbers, 10))  # -1

Bubble Sort

def bubble_sort(arr):
    """Sort list using bubble sort algorithm"""
    n = len(arr)
    # Make a copy to avoid modifying original
    result = arr.copy()

    for i in range(n - 1):
        swapped = False
        for j in range(n - 1 - i):
            if result[j] > result[j + 1]:
                # Swap elements
                result[j], result[j + 1] = result[j + 1], result[j]
                swapped = True
        if not swapped:
            break  # List is already sorted

    return result

# Test
numbers = [64, 34, 25, 12, 22, 11, 90]
sorted_numbers = bubble_sort(numbers)
print(f"Original: {numbers}")
print(f"Sorted: {sorted_numbers}")

Binary Search (on sorted list)

def binary_search(arr, target):
    """Find target in sorted list using binary search"""
    left = 0
    right = 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  # Not found

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

Finding Duplicates

def find_duplicates(arr):
    """Find all duplicate elements in list"""
    seen = set()
    duplicates = []

    for item in arr:
        if item in seen and item not in duplicates:
            duplicates.append(item)
        else:
            seen.add(item)

    return duplicates

# Test
numbers = [1, 2, 3, 2, 4, 3, 5, 6, 3]
print(find_duplicates(numbers))  # [2, 3]

Unit 7: ArrayList (Python Lists – Dynamic)

7.1 List as Dynamic Array

Python lists are already dynamic arrays – they automatically grow and shrink.

# Lists are dynamic - no fixed size
dynamic_list = []
print(len(dynamic_list))  # 0

# Add elements - list grows automatically
dynamic_list.append(10)
dynamic_list.append(20)
dynamic_list.append(30)
print(len(dynamic_list))  # 3
print(dynamic_list)       # [10, 20, 30]

# Remove elements - list shrinks
dynamic_list.pop()        # Remove last
print(len(dynamic_list))  # 2
print(dynamic_list)       # [10, 20]

# Lists can hold any type
dynamic_list.append("hello")
dynamic_list.append(True)
dynamic_list.append([1, 2])
print(dynamic_list)       # [10, 20, 'hello', True, [1, 2]]

7.2 List Capacity and Performance

import sys
import time

# Lists have internal capacity that may be larger than length
my_list = []
for i in range(10):
    my_list.append(i)
    print(f"Length: {len(my_list):2}, Size: {sys.getsizeof(my_list):4} bytes")

# Output shows list size increases in chunks (not every append)

# Time complexity of list operations
# - Append: O(1) amortized
# - Insert: O(n)
# - Remove: O(n)
# - Get by index: O(1)
# - Search: O(n)

# Demonstrate performance difference
def test_list_performance():
    n = 100000

    # Append at end (fast)
    start = time.time()
    lst = []
    for i in range(n):
        lst.append(i)
    end = time.time()
    print(f"Append at end: {end - start:.4f} seconds")

    # Insert at beginning (slow)
    start = time.time()
    lst = []
    for i in range(n):
        lst.insert(0, i)
    end = time.time()
    print(f"Insert at beginning: {end - start:.4f} seconds")

# test_list_performance()

7.3 List vs Array (if needed)

# For numerical work, array module provides typed arrays
from array import array

# Array with type code 'i' for signed integers
int_array = array('i', [1, 2, 3, 4, 5])

# Array with type code 'f' for floats
float_array = array('f', [1.5, 2.7, 3.2])

# Arrays are more memory efficient for same type
import sys
list_int = [1, 2, 3, 4, 5]
array_int = array('i', [1, 2, 3, 4, 5])

print(f"List size: {sys.getsizeof(list_int)} bytes")
print(f"Array size: {sys.getsizeof(array_int)} bytes")

# Arrays support similar operations
array_int.append(6)
array_int.insert(2, 10)
print(array_int[0])  # 1

# But all elements must be same type
# array_int.append(3.14)  # TypeError

Unit 8: 2D Arrays (Nested Lists)

8.1 Creating 2D Lists

# Method 1: Direct initialization
matrix = [
    [1, 2, 3],
    [4, 5, 6],
    [7, 8, 9]
]

# Method 2: Create empty and fill
rows, cols = 3, 4
matrix = [[0] * cols for _ in range(rows)]
# [[0, 0, 0, 0],
#  [0, 0, 0, 0],
#  [0, 0, 0, 0]]

# WRONG way - creates references to same row
wrong_matrix = [[0] * cols] * rows
# Modifying one row affects all!
# wrong_matrix[0][0] = 1  # Changes all rows!

# Method 3: Using nested list comprehension
matrix = [[i * j for j in range(1, 4)] for i in range(1, 4)]
# [[1, 2, 3],
#  [2, 4, 6],
#  [3, 6, 9]]

# Method 4: From user input
def create_matrix_from_input(rows, cols):
    matrix = []
    for i in range(rows):
        row = []
        for j in range(cols):
            value = int(input(f"Enter value for [{i}][{j}]: "))
            row.append(value)
        matrix.append(row)
    return matrix

8.2 Accessing and Modifying 2D Lists

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

# Access individual elements
element = matrix[1][2]  # Row 1, Column 2 = 6
print(element)

# Modify element
matrix[2][1] = 10  # Change 8 to 10

# Get number of rows
rows = len(matrix)

# Get number of columns (assuming rectangular)
cols = len(matrix[0])

# Access entire row
second_row = matrix[1]  # [4, 5, 6]

# Access column (using list comprehension)
col_2 = [row[2] for row in matrix]  # [3, 6, 9]

8.3 Iterating Through 2D Lists

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

# Method 1: Nested for loops with indices
for i in range(len(matrix)):
    for j in range(len(matrix[i])):
        print(f"matrix[{i}][{j}] = {matrix[i][j]}")

# Method 2: Nested for loops with elements
for row in matrix:
    for element in row:
        print(element, end=" ")
    print()  # New line after each row

# Output:
# 1 2 3
# 4 5 6
# 7 8 9

# Method 3: Using enumerate
for i, row in enumerate(matrix):
    for j, value in enumerate(row):
        print(f"({i},{j}): {value}")

# Method 4: List comprehension for flattened view
flattened = [value for row in matrix for value in row]
# [1, 2, 3, 4, 5, 6, 7, 8, 9]

# Method 5: While loops
i = 0
while i < len(matrix):
    j = 0
    while j < len(matrix[i]):
        print(matrix[i][j], end=" ")
        j += 1
    print()
    i += 1

8.4 Common 2D Array Operations

Matrix Addition

def add_matrices(A, B):
    """Add two matrices"""
    if len(A) != len(B) or len(A[0]) != len(B[0]):
        raise ValueError("Matrices must have same dimensions")

    rows, cols = len(A), len(A[0])
    result = [[0] * cols for _ in range(rows)]

    for i in range(rows):
        for j in range(cols):
            result[i][j] = A[i][j] + B[i][j]

    return result

# Test
A = [[1, 2], [3, 4]]
B = [[5, 6], [7, 8]]
C = add_matrices(A, B)
print(C)  # [[6, 8], [10, 12]]

Matrix Multiplication

def multiply_matrices(A, B):
    """Multiply two matrices"""
    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("Cannot multiply: incompatible dimensions")

    # Initialize result matrix
    result = [[0] * cols_B for _ in range(rows_A)]

    # Multiply
    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

# Test
A = [[1, 2], [3, 4]]
B = [[5, 6], [7, 8]]
C = multiply_matrices(A, B)
print(C)  # [[19, 22], [43, 50]]

Transpose Matrix

def transpose(matrix):
    """Return transpose of matrix"""
    rows, cols = len(matrix), len(matrix[0])

    # Create new matrix with swapped dimensions
    result = [[0] * rows for _ in range(cols)]

    for i in range(rows):
        for j in range(cols):
            result[j][i] = matrix[i][j]

    return result

# Using list comprehension (more concise)
def transpose_zip(matrix):
    return [list(row) for row in zip(*matrix)]

# Test
matrix = [[1, 2, 3], [4, 5, 6]]
print(transpose(matrix))      # [[1, 4], [2, 5], [3, 6]]

Find Maximum in 2D Array

def find_max(matrix):
    """Find maximum element in 2D array"""
    if not matrix or not matrix[0]:
        return None

    max_val = matrix[0][0]
    max_pos = (0, 0)

    for i, row in enumerate(matrix):
        for j, value in enumerate(row):
            if value > max_val:
                max_val = value
                max_pos = (i, j)

    return max_val, max_pos

# Test
matrix = [[1, 5, 3], [7, 2, 8], [4, 6, 9]]
max_val, (row, col) = find_max(matrix)
print(f"Max: {max_val} at [{row}][{col}]")  # Max: 9 at [2][2]

Sum Each Row and Column

def row_sums(matrix):
    """Calculate sum of each row"""
    return [sum(row) for row in matrix]

def col_sums(matrix):
    """Calculate sum of each column"""
    rows, cols = len(matrix), len(matrix[0])
    return [sum(matrix[i][j] for i in range(rows)) for j in range(cols)]

# Test
matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
print(f"Row sums: {row_sums(matrix)}")    # [6, 15, 24]
print(f"Col sums: {col_sums(matrix)}")    # [12, 15, 18]

Rotate Matrix 90 Degrees

def rotate_clockwise(matrix):
    """Rotate matrix 90 degrees clockwise"""
    n = len(matrix)
    result = [[0] * n for _ in range(n)]

    for i in range(n):
        for j in range(n):
            result[j][n - 1 - i] = matrix[i][j]

    return result

# Using zip (more Pythonic)
def rotate_clockwise_zip(matrix):
    return [list(row)[::-1] for row in zip(*matrix)]

# Test
matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
rotated = rotate_clockwise(matrix)
for row in rotated:
    print(row)
# [7, 4, 1]
# [8, 5, 2]
# [9, 6, 3]

Check if Matrix is Symmetric

def is_symmetric(matrix):
    """Check if matrix is symmetric (equal to its transpose)"""
    n = len(matrix)

    for i in range(n):
        for j in range(i + 1, n):  # Only check upper triangle
            if matrix[i][j] != matrix[j][i]:
                return False
    return True

# Test
symmetric = [[1, 2, 3], [2, 4, 5], [3, 5, 6]]
asymmetric = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
print(is_symmetric(symmetric))   # True
print(is_symmetric(asymmetric))  # False

8.5 Jagged Arrays (Rows of Different Lengths)

# Python supports jagged arrays (rows can have different lengths)
jagged = [
    [1, 2, 3],
    [4, 5],
    [6, 7, 8, 9]
]

# Process jagged array
for i, row in enumerate(jagged):
    print(f"Row {i} has {len(row)} elements: {row}")

# Sum all elements in jagged array
total = sum(sum(row) for row in jagged)
print(f"Total: {total}")

# Find maximum element
max_val = max(max(row) for row in jagged)
print(f"Max: {max_val}")

# Check if jagged array contains value
def contains(jagged, target):
    for row in jagged:
        if target in row:
            return True
    return False

print(contains(jagged, 5))  # True
print(contains(jagged, 10)) # False

Unit 9: Inheritance

9.1 Basic Inheritance

# Base class (parent)
class Animal:
    """Base class for all animals"""

    def __init__(self, name, age):
        self.name = name
        self.age = age
        self._alive = True

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

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

    def sleep(self):
        """Animal sleeps"""
        return f"{self.name} is sleeping"

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

# Derived class (child)
class Dog(Animal):
    """Dog class inherits from Animal"""

    def __init__(self, name, age, breed):
        # Call parent class constructor
        super().__init__(name, age)
        self.breed = breed

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

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

    # Override __str__
    def __str__(self):
        return f"{self.name} is a {self.breed} ({self.age} years old)"

# Another derived class
class Cat(Animal):
    """Cat class inherits from Animal"""

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

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

    def purr(self):
        return f"{self.name} is purring"

    def __str__(self):
        return f"{self.name} is a {self.color} cat ({self.age} years old)"

# Using the classes
dog = Dog("Buddy", 3, "Golden Retriever")
cat = Cat("Whiskers", 5, "orange")

# Inherited methods
print(dog.eat("bone"))      # Buddy eats bone
print(cat.sleep())          # Whiskers is sleeping

# Overridden methods
print(dog.speak())          # Buddy says Woof!
print(cat.speak())          # Whiskers says Meow!

# New methods
print(dog.fetch("ball"))    # Buddy fetches the ball
print(cat.purr())           # Whiskers is purring

# Polymorphism - same interface, different behavior
animals = [dog, cat]
for animal in animals:
    print(animal)           # Calls appropriate __str__
    print(animal.speak())   # Calls appropriate speak method

9.2 Inheritance Hierarchy

# Multi-level inheritance
class Vehicle:
    """Base class for all vehicles"""

    def __init__(self, make, model, year):
        self.make = make
        self.model = model
        self.year = year
        self._speed = 0

    def start(self):
        return f"{self.make} {self.model} is starting"

    def stop(self):
        self._speed = 0
        return f"{self.make} {self.model} is stopping"

    def accelerate(self, amount):
        self._speed += amount
        return f"Speed: {self._speed} mph"

class Car(Vehicle):
    """Car inherits from Vehicle"""

    def __init__(self, make, model, year, doors):
        super().__init__(make, model, year)
        self.doors = doors

    def honk(self):
        return "Beep beep!"

class ElectricCar(Car):
    """ElectricCar inherits from Car"""

    def __init__(self, make, model, year, doors, battery_capacity):
        super().__init__(make, model, year, doors)
        self.battery_capacity = battery_capacity
        self._charge = 100

    def charge(self):
        self._charge = 100
        return f"Battery charged to {self._charge}%"

    def accelerate(self, amount):
        # Override with electric-specific behavior
        self._speed += amount * 1.2  # Electric cars accelerate faster
        self._charge -= amount * 0.5
        return f"Speed: {self._speed} mph, Charge: {self._charge}%"

# Test hierarchy
tesla = ElectricCar("Tesla", "Model 3", 2023, 4, 75)

# Methods from all levels
print(tesla.start())           # Vehicle method
print(tesla.honk())            # Car method
print(tesla.charge())          # ElectricCar method
print(tesla.accelerate(10))    # Overridden method

# Check inheritance
print(isinstance(tesla, ElectricCar))  # True
print(isinstance(tesla, Car))          # True
print(isinstance(tesla, Vehicle))      # True
print(issubclass(ElectricCar, Car))    # True

9.3 Method Resolution Order (MRO)

class A:
    def method(self):
        return "A"

class B(A):
    def method(self):
        return "B"

class C(A):
    def method(self):
        return "C"

class D(B, C):
    pass

# Check MRO
print(D.__mro__)
# (<class '__main__.D'>, <class '__main__.B'>, 
#  <class '__main__.C'>, <class '__main__.A'>, <class 'object'>)

d = D()
print(d.method())  # "B" (follows MRO)

# Multiple inheritance example
class Flyer:
    def fly(self):
        return "Flying"

class Swimmer:
    def swim(self):
        return "Swimming"

class Duck(Flyer, Swimmer):
    def quack(self):
        return "Quack!"

duck = Duck()
print(duck.fly())   # Flying
print(duck.swim())  # Swimming
print(duck.quack()) # Quack!

9.4 Abstract Base Classes

from abc import ABC, abstractmethod
import math

class Shape(ABC):
    """Abstract base class for shapes"""

    @abstractmethod
    def area(self):
        """Calculate area - must be implemented by subclasses"""
        pass

    @abstractmethod
    def perimeter(self):
        """Calculate perimeter - must be implemented"""
        pass

    def description(self):
        """Concrete method - available to all subclasses"""
        return f"I am a {self.__class__.__name__}"

class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius

    def area(self):
        return math.pi * self.radius ** 2

    def perimeter(self):
        return 2 * math.pi * self.radius

class Rectangle(Shape):
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def area(self):
        return self.width * self.height

    def perimeter(self):
        return 2 * (self.width + self.height)

# Cannot instantiate abstract class
# s = Shape()  # TypeError!

# Can instantiate concrete subclasses
circle = Circle(5)
rect = Rectangle(4, 6)

print(circle.description())  # I am a Circle
print(f"Circle area: {circle.area():.2f}")
print(f"Rectangle area: {rect.area()}")

9.5 Polymorphism

class Payment(ABC):
    """Abstract payment class"""

    @abstractmethod
    def pay(self, amount):
        pass

class CreditCard(Payment):
    def __init__(self, number, expiry):
        self.number = number
        self.expiry = expiry

    def pay(self, amount):
        return f"Paid ${amount} with Credit Card ending in {self.number[-4:]}"

class PayPal(Payment):
    def __init__(self, email):
        self.email = email

    def pay(self, amount):
        return f"Paid ${amount} with PayPal account {self.email}"

class Cash(Payment):
    def pay(self, amount):
        return f"Paid ${amount} in cash"

# Polymorphic function
def process_payment(payment_method, amount):
    """Process payment regardless of method type"""
    print(payment_method.pay(amount))
    # Could add logging, receipt generation, etc.

# Test polymorphism
payments = [
    CreditCard("1234-5678-9012-3456", "12/25"),
    PayPal("user@example.com"),
    Cash()
]

for payment in payments:
    process_payment(payment, 100)
    # Each calls its own pay method

9.6 super() and Method Chaining

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

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

class Employee(Person):
    def __init__(self, name, age, employee_id, department):
        super().__init__(name, age)  # Call parent constructor
        self.employee_id = employee_id
        self.department = department

    def introduce(self):
        # Extend parent method
        base_intro = super().introduce()
        return f"{base_intro}. I work in {self.department} (ID: {self.employee_id})"

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

    def introduce(self):
        # Chain to parent's introduce
        base_intro = super().introduce()
        return f"{base_intro} and manage a team of {self.team_size} people"

# Demonstrate method chaining
mgr = Manager("Alice", 35, "E123", "Engineering", 8)
print(mgr.introduce())
# Hi, I'm Alice, 35 years old. I work in Engineering (ID: E123) and manage a team of 8 people

Unit 10: Recursion

10.1 Introduction to Recursion

# Recursion is a function that calls itself

def countdown(n):
    """Simple recursive countdown"""
    if n <= 0:
        print("Blastoff!")
    else:
        print(n)
        countdown(n - 1)  # Recursive call

countdown(5)
# 5
# 4
# 3
# 2
# 1
# Blastoff!

# Every recursive function needs:
# 1. Base case - condition to stop recursion
# 2. Recursive case - function calls itself with modified parameters

10.2 Factorial

# Iterative factorial
def factorial_iterative(n):
    result = 1
    for i in range(1, n + 1):
        result *= i
    return result

# Recursive factorial
def factorial_recursive(n):
    # Base case
    if n <= 1:
        return 1
    # Recursive case
    return n * factorial_recursive(n - 1)

# Trace factorial_recursive(5):
# factorial_recursive(5) = 5 * factorial_recursive(4)
#                      = 5 * (4 * factorial_recursive(3))
#                      = 5 * (4 * (3 * factorial_recursive(2)))
#                      = 5 * (4 * (3 * (2 * factorial_recursive(1))))
#                      = 5 * (4 * (3 * (2 * 1)))
#                      = 5 * (4 * (3 * 2))
#                      = 5 * (4 * 6)
#                      = 5 * 24
#                      = 120

print(factorial_recursive(5))  # 120

10.3 Fibonacci Sequence

# Recursive Fibonacci (inefficient)
def fib_recursive(n):
    """Return nth Fibonacci number"""
    if n <= 1:
        return n
    return fib_recursive(n - 1) + fib_recursive(n - 2)

# Trace fib_recursive(5):
# fib(5) = fib(4) + fib(3)
#        = (fib(3) + fib(2)) + (fib(2) + fib(1))
#        = ((fib(2) + fib(1)) + (fib(1) + fib(0))) + ((fib(1) + fib(0)) + 1)
#        = etc.

# This recalculates same values many times - inefficient for large n

# Memoized Fibonacci (with caching)
def fib_memoized(n, memo={}):
    if n in memo:
        return memo[n]

    if n <= 1:
        return n

    memo[n] = fib_memoized(n - 1, memo) + fib_memoized(n - 2, memo)
    return memo[n]

# Iterative Fibonacci (most efficient for single values)
def fib_iterative(n):
    if n <= 1:
        return n

    a, b = 0, 1
    for _ in range(2, n + 1):
        a, b = b, a + b
    return b

# Performance comparison
import time

def time_function(func, n):
    start = time.time()
    result = func(n)
    end = time.time()
    return result, end - start

n = 35
# rec_result, rec_time = time_function(fib_recursive, n)  # Very slow!
memo_result, memo_time = time_function(fib_memoized, n)
iter_result, iter_time = time_function(fib_iterative, n)

print(f"Fib({n}) = {iter_result}")
# print(f"Recursive time: {rec_time:.4f}s")  # Very slow for n=35
print(f"Memoized time: {memo_time:.4f}s")
print(f"Iterative time: {iter_time:.4f}s")

10.4 Power Function

# Recursive power function
def power(base, exponent):
    """Calculate base^exponent recursively"""
    # Base case
    if exponent == 0:
        return 1

    # Recursive case
    return base * power(base, exponent - 1)

# More efficient: divide and conquer
def power_fast(base, exponent):
    """More efficient power using divide and conquer"""
    if exponent == 0:
        return 1

    if exponent % 2 == 0:
        # If exponent is even: base^exp = (base^(exp/2))^2
        half = power_fast(base, exponent // 2)
        return half * half
    else:
        # If exponent is odd: base^exp = base * base^(exp-1)
        return base * power_fast(base, exponent - 1)

print(power(2, 10))       # 1024
print(power_fast(2, 10))  # 1024

10.5 Sum of Digits

def sum_digits(n):
    """Sum digits of a number recursively"""
    # Base case
    if n < 10:
        return n

    # Recursive case
    return n % 10 + sum_digits(n // 10)

# Alternative: convert to string
def sum_digits_str(n):
    return sum(int(digit) for digit in str(n))

print(sum_digits(12345))  # 15 (1+2+3+4+5)
print(sum_digits_str(12345))  # 15

10.6 Palindrome Check

def is_palindrome(s):
    """Check if string is palindrome recursively"""
    # Clean string
    s = s.lower().replace(" ", "")

    # Base cases
    if len(s) <= 1:
        return True

    # Check first and last characters
    if s[0] != s[-1]:
        return False

    # Recursive case
    return is_palindrome(s[1:-1])

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

10.7 Binary Search (Recursive)

def binary_search_recursive(arr, target, left, right):
    """Recursive binary search"""
    # Base case - not found
    if left > right:
        return -1

    # Calculate middle
    mid = (left + right) // 2

    # Base case - found
    if arr[mid] == target:
        return mid

    # Recursive cases
    if arr[mid] < target:
        return binary_search_recursive(arr, target, mid + 1, right)
    else:
        return binary_search_recursive(arr, target, left, mid - 1)

# Wrapper function
def search(arr, target):
    return binary_search_recursive(arr, target, 0, len(arr) - 1)

# Test
numbers = [2, 5, 8, 12, 16, 23, 38, 45, 56, 72]
print(search(numbers, 23))  # 5
print(search(numbers, 10))  # -1

10.8 Tower of Hanoi

def tower_of_hanoi(n, source, target, auxiliary):
    """
    Solve Tower of Hanoi puzzle recursively
    Move n disks from source to target using auxiliary
    """
    if n == 1:
        print(f"Move disk 1 from {source} to {target}")
        return

    # Move n-1 disks from source to auxiliary
    tower_of_hanoi(n - 1, source, auxiliary, target)

    # Move largest disk from source to target
    print(f"Move disk {n} from {source} to {target}")

    # Move n-1 disks from auxiliary to target
    tower_of_hanoi(n - 1, auxiliary, target, source)

# Solve for 3 disks
tower_of_hanoi(3, 'A', 'C', 'B')
# Output shows steps to move all disks from A to C

10.9 Recursion vs Iteration Comparison

import sys

# Recursion depth limit
print(f"Recursion limit: {sys.getrecursionlimit()}")  # Usually 1000

# Pros of recursion:
# - Elegant for problems with recursive structure (trees, fractals)
# - Code can be shorter and clearer
# - Natural for divide-and-conquer algorithms

# Cons of recursion:
# - Function call overhead
# - Risk of stack overflow
# - May be less efficient (though memoization helps)

# Example: Tree traversal (natural for recursion)
class TreeNode:
    def __init__(self, value):
        self.value = value
        self.left = None
        self.right = None

def inorder_traversal(node):
    """Inorder tree traversal - naturally recursive"""
    if node:
        inorder_traversal(node.left)
        print(node.value, end=" ")
        inorder_traversal(node.right)

# Create tree
root = TreeNode(5)
root.left = TreeNode(3)
root.right = TreeNode(8)
root.left.left = TreeNode(1)
root.left.right = TreeNode(4)

inorder_traversal(root)  # 1 3 4 5 8

10.10 Common Recursion Patterns

# Pattern 1: Linear recursion (one recursive call)
def linear_recursion(n):
    """Single recursive call"""
    if n <= 0:
        return
    print(n)
    linear_recursion(n - 1)

# Pattern 2: Binary recursion (two recursive calls)
def binary_recursion(n):
    """Two recursive calls (like binary tree)"""
    if n <= 0:
        return
    print(n)
    binary_recursion(n - 1)
    binary_recursion(n - 2)

# Pattern 3: Tail recursion (recursive call at end)
def tail_recursion(n, accumulator=1):
    """Recursive call is the last operation"""
    if n <= 1:
        return accumulator
    return tail_recursion(n - 1, n * accumulator)

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

def is_odd(n):
    if n == 0:
        return False
    return is_even(n - 1)

print(is_even(4))  # True
print(is_odd(4))   # False

Summary: Python Topics Overview

UnitTopicKey Concepts
1Primitive Typesint, float, bool, str, operators, input/output
2Using Objectsstring methods, math library, random, object references
3Boolean Expressionsif/elif/else, comparison operators, logical operators
4Iterationwhile loops, for loops, range(), break/continue
5Writing Classesinit, methods, attributes, properties, encapsulation
6Arrays (Lists)list operations, indexing, slicing, list methods
7ArrayListdynamic arrays, list performance, array module
82D Arraysnested lists, matrix operations, jagged arrays
9Inheritanceparent/child classes, super(), polymorphism, MRO
10Recursionbase case, recursive case, factorial, Fibonacci

Scroll to Top