Nested Conditionals and Loops in Python

Week 2 Day 2: Control Flow Fundamentals

Introduction to Nested Control Structures

Welcome to our deep dive into nested conditionals and loops in Python! Just as architectural complexity arises from combining simple elements, programming complexity emerges from nesting basic control structures. Today, we'll explore how to build sophisticated program logic by placing conditionals inside conditionals, loops inside loops, and various combinations of both.

Think of nested control structures as Russian nesting dolls—each one contains another, creating layers of decision-making and repetition that allow our programs to handle complex scenarios. In real-world programming, nesting is unavoidable and essential for solving multifaceted problems.

The code for this lesson can be found in the /week2/day2/nested_structures.py file in your course repository.

Nested Conditionals

A nested conditional is simply an if, elif, or else statement that contains another conditional statement within its block. This creates a hierarchy of decisions, where the inner decision is only considered when the outer condition is met.

Basic Structure

if outer_condition:
    # Outer condition is True
    if inner_condition:
        # Both outer and inner conditions are True
        perform_action_a()
    else:
        # Outer condition is True, but inner condition is False
        perform_action_b()
else:
    # Outer condition is False
    perform_action_c()

This pattern creates a decision tree with different execution paths. Let's look at some practical examples.

Example: Loan Approval System

def evaluate_loan_application(credit_score, income, loan_amount):
    """
    Evaluate a loan application based on multiple criteria.
    Returns a tuple of (approved, reason).
    """
    if credit_score >= 700:  # First level: Good credit
        if income >= 50000:  # Second level: Sufficient income
            if loan_amount <= income * 0.3:  # Third level: Reasonable loan amount
                return True, "Loan approved based on excellent criteria"
            else:
                return False, "Loan amount exceeds 30% of income"
        else:
            if loan_amount <= income * 0.15:  # Low income but very small loan
                return True, "Loan approved with limited amount"
            else:
                return False, "Insufficient income for requested loan amount"
    else:  # Lower credit score
        if credit_score >= 600:  # Medium credit
            if income >= 70000 and loan_amount <= income * 0.15:
                return True, "Loan approved with conditions"
            else:
                return False, "Credit score and income/loan ratio not sufficient"
        else:  # Poor credit
            return False, "Credit score below minimum threshold"

# Test with various scenarios
test_cases = [
    (750, 60000, 15000),  # Good credit, good income, reasonable loan
    (750, 60000, 30000),  # Good credit, good income, high loan
    (750, 40000, 5000),   # Good credit, lower income, small loan
    (650, 75000, 10000),  # Medium credit, good income, small loan
    (650, 50000, 10000),  # Medium credit, medium income, medium loan
    (550, 90000, 10000)   # Poor credit, excellent income, small loan
]

for case in test_cases:
    credit, income, loan = case
    approved, reason = evaluate_loan_application(credit, income, loan)
    
    print(f"Credit: {credit}, Income: ${income}, Loan: ${loan}")
    print(f"Decision: {'APPROVED' if approved else 'DENIED'}")
    print(f"Reason: {reason}")
    print("-" * 50)

This loan approval system demonstrates nested conditionals with three levels of decision-making. The decision tree branches based on credit score, income, and loan amount, creating a sophisticated approval process that reflects real-world financial decision-making.

Example: Weather Activity Recommender

def recommend_activity(temperature, precipitation, wind_speed):
    """
    Recommend an activity based on weather conditions.
    """
    if precipitation == "none":  # No precipitation
        if temperature > 80:  # Hot weather
            if wind_speed < 10:  # Low wind
                return "Go swimming"
            else:  # Windy
                return "Go sailing"
        elif temperature > 60:  # Warm weather
            if wind_speed < 15:  # Low to moderate wind
                return "Go hiking"
            else:  # Windy
                return "Go flying a kite"
        else:  # Cool weather
            return "Go for a scenic drive"
    elif precipitation == "rain":  # Rainy
        if temperature > 70:  # Warm rain
            return "Visit a museum"
        else:  # Cold rain
            return "Stay home and read a book"
    elif precipitation == "snow":  # Snowy
        if temperature > 25:  # Not too cold
            return "Go sledding"
        else:  # Very cold
            return "Build a snowman"
    else:  # Other conditions (fog, hail, etc.)
        return "Indoor activities recommended"

# Test with various weather conditions
weather_conditions = [
    (85, "none", 5),     # Hot, clear, calm
    (85, "none", 20),    # Hot, clear, windy
    (65, "none", 10),    # Warm, clear, light wind
    (65, "none", 25),    # Warm, clear, windy
    (45, "none", 5),     # Cool, clear, calm
    (75, "rain", 5),     # Warm, rainy, calm
    (55, "rain", 15),    # Cool, rainy, windy
    (30, "snow", 10),    # Cold, snowy, light wind
    (15, "snow", 5),     # Very cold, snowy, calm
    (65, "fog", 5)       # Warm, foggy, calm
]

for condition in weather_conditions:
    temp, precip, wind = condition
    activity = recommend_activity(temp, precip, wind)
    
    print(f"Weather: {temp}°F, {precip}, {wind} mph wind")
    print(f"Recommended activity: {activity}")
    print("-" * 50)

This activity recommender demonstrates how nested conditionals can model a decision process with multiple factors. The outer condition checks precipitation, while inner conditions evaluate temperature and wind speed, resulting in activity recommendations tailored to specific weather combinations.

Flattening Nested Conditionals

While nested conditionals are sometimes necessary, deeply nested structures can lead to "the pyramid of doom" or "arrow code"—code that indents so far right that it becomes difficult to read and maintain. Here are techniques to flatten nested conditionals:

Using Compound Conditions
# Nested approach
def check_eligibility_nested(age, citizenship, residence_years):
    if age >= 18:
        if citizenship == "yes":
            if residence_years >= 5:
                return "Eligible"
            else:
                return "Not eligible: insufficient residence years"
        else:
            return "Not eligible: not a citizen"
    else:
        return "Not eligible: under 18"

# Flattened with compound conditions
def check_eligibility_flat(age, citizenship, residence_years):
    if age < 18:
        return "Not eligible: under 18"
    if citizenship != "yes":
        return "Not eligible: not a citizen"
    if residence_years < 5:
        return "Not eligible: insufficient residence years"
    
    return "Eligible"

The flattened version is more readable and avoids the deep nesting. This approach uses early returns for failure cases, which is a common pattern in programming.

Using Logical Operators
# Nested approach
def check_order_nested(stock, credit, shipping):
    if stock > 0:
        if credit > 100:
            if shipping == "available":
                return "Order processed"
            else:
                return "No shipping available"
        else:
            return "Insufficient credit"
    else:
        return "Out of stock"

# Flattened with logical operators
def check_order_flat(stock, credit, shipping):
    if stock <= 0:
        return "Out of stock"
    if credit <= 100:
        return "Insufficient credit"
    if shipping != "available":
        return "No shipping available"
    
    return "Order processed"

# Alternative using a single compound condition
def check_order_compound(stock, credit, shipping):
    if stock > 0 and credit > 100 and shipping == "available":
        return "Order processed"
    else:
        # Determine the specific reason
        if stock <= 0:
            return "Out of stock"
        if credit <= 100:
            return "Insufficient credit"
        return "No shipping available"

Both flattened versions eliminate the deep nesting, making the code more maintainable. The first approach uses early returns for failure cases, while the second uses a compound condition for the success case. Either approach is valid, but early returns often lead to cleaner code.

Real-World Example: E-commerce Order Processing

def process_order(order):
    """
    Process an e-commerce order through a series of validation
    and fulfillment steps.
    """
    # Extract order details
    customer_id = order.get("customer_id")
    items = order.get("items", [])
    payment_method = order.get("payment_method")
    shipping_address = order.get("shipping_address")
    promo_code = order.get("promo_code")
    
    # Validate customer
    if not customer_id:
        return {"status": "error", "message": "Customer ID is required"}
    
    customer = get_customer(customer_id)
    if not customer:
        return {"status": "error", "message": "Customer not found"}
    
    if customer["status"] == "blocked":
        return {"status": "error", "message": "Customer account is blocked"}
    
    # Validate items
    if not items:
        return {"status": "error", "message": "No items in order"}
    
    # Check inventory and calculate total
    order_total = 0
    unavailable_items = []
    
    for item in items:
        product_id = item.get("product_id")
        quantity = item.get("quantity", 1)
        
        product = get_product(product_id)
        if not product:
            unavailable_items.append(f"Product {product_id} not found")
            continue
        
        if product["inventory"] < quantity:
            unavailable_items.append(f"Insufficient inventory for {product['name']}")
            continue
        
        item_price = product["price"] * quantity
        order_total += item_price
    
    if unavailable_items:
        return {
            "status": "error", 
            "message": "Some items are unavailable",
            "details": unavailable_items
        }
    
    # Apply promotion if valid
    if promo_code:
        promo = get_promotion(promo_code)
        if promo and promo["active"]:
            if promo["min_purchase"] <= order_total:
                if promo["type"] == "percentage":
                    discount = order_total * (promo["value"] / 100)
                else:  # Fixed amount
                    discount = promo["value"]
                
                order_total -= discount
            else:
                return {
                    "status": "error",
                    "message": f"Order total does not meet minimum for promo code {promo_code}"
                }
        else:
            return {"status": "error", "message": "Invalid or expired promo code"}
    
    # Validate payment method
    if not payment_method:
        return {"status": "error", "message": "Payment method is required"}
    
    if payment_method["type"] == "credit_card":
        if payment_method.get("expired", False):
            return {"status": "error", "message": "Credit card is expired"}
        
        if not validate_credit_card(payment_method):
            return {"status": "error", "message": "Invalid credit card information"}
    elif payment_method["type"] == "paypal":
        if not validate_paypal(payment_method):
            return {"status": "error", "message": "Invalid PayPal account"}
    else:
        return {"status": "error", "message": "Unsupported payment method"}
    
    # Validate shipping address
    if not shipping_address:
        return {"status": "error", "message": "Shipping address is required"}
    
    if not shipping_address.get("country") or not shipping_address.get("zip_code"):
        return {"status": "error", "message": "Incomplete shipping address"}
    
    # Check if we ship to the customer's country
    if shipping_address["country"] not in get_supported_countries():
        return {"status": "error", "message": f"We don't ship to {shipping_address['country']}"}
    
    # All validations passed, process the order
    return {
        "status": "success",
        "message": "Order processed successfully",
        "order_id": generate_order_id(),
        "total": order_total,
        "estimated_delivery": calculate_delivery_date(shipping_address)
    }

# Simulate the helper functions
def get_customer(customer_id):
    # In a real system, this would query a database
    customers = {
        "C001": {"name": "Alice Smith", "status": "active"},
        "C002": {"name": "Bob Jones", "status": "blocked"},
    }
    return customers.get(customer_id)

def get_product(product_id):
    # In a real system, this would query a database
    products = {
        "P001": {"name": "Laptop", "price": 999.99, "inventory": 5},
        "P002": {"name": "Smartphone", "price": 499.99, "inventory": 10},
        "P003": {"name": "Headphones", "price": 99.99, "inventory": 0},
    }
    return products.get(product_id)

def get_promotion(promo_code):
    # In a real system, this would query a database
    promotions = {
        "SUMMER20": {"active": True, "type": "percentage", "value": 20, "min_purchase": 100},
        "WELCOME10": {"active": False, "type": "percentage", "value": 10, "min_purchase": 50},
        "FREESHIP": {"active": True, "type": "fixed", "value": 15, "min_purchase": 75},
    }
    return promotions.get(promo_code)

def validate_credit_card(payment_info):
    # In a real system, this would call a payment processor API
    return len(payment_info.get("card_number", "")) == 16

def validate_paypal(payment_info):
    # In a real system, this would call PayPal's API
    return "@" in payment_info.get("email", "")

def get_supported_countries():
    return ["USA", "Canada", "UK", "Australia", "Germany", "France", "Japan"]

def generate_order_id():
    import random
    return f"ORD-{random.randint(10000, 99999)}"

def calculate_delivery_date(address):
    import datetime
    # In a real system, this would calculate based on shipping method and distance
    today = datetime.datetime.now()
    delivery_date = today + datetime.timedelta(days=5)
    return delivery_date.strftime("%Y-%m-%d")

# Test with a sample order
sample_order = {
    "customer_id": "C001",
    "items": [
        {"product_id": "P001", "quantity": 1},
        {"product_id": "P002", "quantity": 2}
    ],
    "payment_method": {
        "type": "credit_card",
        "card_number": "1234567890123456",
        "expired": False
    },
    "shipping_address": {
        "street": "123 Main St",
        "city": "Boston",
        "state": "MA",
        "zip_code": "02108",
        "country": "USA"
    },
    "promo_code": "SUMMER20"
}

result = process_order(sample_order)
print("Order Processing Result:")
for key, value in result.items():
    print(f"{key}: {value}")

This e-commerce order processing example demonstrates a real-world approach to nested conditionals. Instead of deeply nested structures, it uses early returns for validation failures, creating a flatter, more maintainable code structure. Each section validates one aspect of the order, returning an error if the validation fails, or continuing to the next validation if it passes.

Nested Loops

Nested loops occur when one loop is placed inside another. The inner loop completes its entire cycle of iterations for each iteration of the outer loop. This pattern is essential for working with multi-dimensional data structures or when you need to compare each element with every other element.

Basic Structure

for outer_item in outer_sequence:
    # This code runs for each item in the outer sequence
    for inner_item in inner_sequence:
        # This code runs for each item in the inner sequence
        # For each outer_item, the inner loop runs completely
        process(outer_item, inner_item)

With nested loops, if the outer sequence has M items and the inner sequence has N items, the inner loop body executes M × N times. This multiplicative effect is something to be mindful of when dealing with large datasets.

Example: Multiplication Table

def print_multiplication_table(n):
    """
    Print a multiplication table for numbers 1 through n.
    """
    # Print the header row
    print("   |", end="")
    for i in range(1, n + 1):
        print(f"{i:3}", end="")
    print("\n---+" + "---" * n)
    
    # Print each row of the table
    for i in range(1, n + 1):
        print(f"{i:2} |", end="")
        
        for j in range(1, n + 1):
            print(f"{i * j:3}", end="")
            
        print()  # Move to the next line after each row

# Generate a 10x10 multiplication table
print_multiplication_table(10)

This multiplication table example demonstrates the classic use case for nested loops: working with two-dimensional data. The outer loop iterates through the rows, while the inner loop generates the columns for each row.

Example: Finding Prime Pairs (Twin Primes)

def is_prime(n):
    """Check if a number is prime."""
    if n < 2:
        return False
    
    # Check divisibility up to the square root
    for i in range(2, int(n**0.5) + 1):
        if n % i == 0:
            return False
    
    return True

def find_twin_primes(limit):
    """
    Find all twin primes up to the given limit.
    Twin primes are pairs of primes that differ by 2.
    """
    twin_primes = []
    
    for i in range(3, limit + 1):
        # Check if both i and i-2 are prime
        if is_prime(i) and is_prime(i - 2):
            twin_primes.append((i - 2, i))
    
    return twin_primes

# Find all twin primes up to 100
twin_primes = find_twin_primes(100)
print("Twin prime pairs up to 100:")
for pair in twin_primes:
    print(pair)

This twin prime finder uses a nested loop structure, though the inner loop is hidden within the is_prime function. For each number in the outer loop, the inner loop checks its divisibility by all smaller numbers, demonstrating how nested loops are often compartmentalized into separate functions for clarity.

Example: Working with Matrices

def matrix_operations():
    # Define two 3x3 matrices
    matrix_a = [
        [1, 2, 3],
        [4, 5, 6],
        [7, 8, 9]
    ]
    
    matrix_b = [
        [9, 8, 7],
        [6, 5, 4],
        [3, 2, 1]
    ]
    
    # Add matrices: C = A + B
    print("Matrix Addition:")
    matrix_c = []
    
    for i in range(len(matrix_a)):
        row = []
        for j in range(len(matrix_a[0])):
            row.append(matrix_a[i][j] + matrix_b[i][j])
        matrix_c.append(row)
    
    print_matrix(matrix_c)
    
    # Multiply matrices: D = A * B
    print("\nMatrix Multiplication:")
    matrix_d = []
    
    for i in range(len(matrix_a)):
        row = []
        for j in range(len(matrix_b[0])):
            value = 0
            for k in range(len(matrix_b)):
                value += matrix_a[i][k] * matrix_b[k][j]
            row.append(value)
        matrix_d.append(row)
    
    print_matrix(matrix_d)
    
    # Transpose matrix A
    print("\nTranspose of Matrix A:")
    matrix_a_transpose = []
    
    for j in range(len(matrix_a[0])):
        row = []
        for i in range(len(matrix_a)):
            row.append(matrix_a[i][j])
        matrix_a_transpose.append(row)
    
    print_matrix(matrix_a_transpose)

def print_matrix(matrix):
    """Print a matrix in a readable format."""
    for row in matrix:
        print("[", end="")
        for j, val in enumerate(row):
            print(f"{val:3}", end="")
            if j < len(row) - 1:
                print(",", end="")
        print(" ]")

# Run the matrix operations
matrix_operations()

This matrix operations example showcases nested loops for working with multi-dimensional arrays. Matrix addition uses two levels of nesting (rows and columns), while matrix multiplication requires three levels (rows, columns, and the summation index).

Optimizing Nested Loops

Nested loops can become performance bottlenecks, especially with large datasets. Here are strategies to optimize them:

Minimize Work in Inner Loops
# Less efficient - function call in inner loop
def calculate_distances_inefficient(points):
    distances = []
    
    for i in range(len(points)):
        for j in range(i + 1, len(points)):
            # Function call inside inner loop
            distance = calculate_distance(points[i], points[j])
            distances.append((i, j, distance))
    
    return distances

# More efficient - calculations directly in inner loop
def calculate_distances_efficient(points):
    distances = []
    
    for i in range(len(points)):
        x1, y1 = points[i]
        
        for j in range(i + 1, len(points)):
            x2, y2 = points[j]
            
            # Direct calculation without function call
            distance = ((x2 - x1)**2 + (y2 - y1)**2)**0.5
            distances.append((i, j, distance))
    
    return distances

def calculate_distance(p1, p2):
    """Calculate Euclidean distance between two points."""
    x1, y1 = p1
    x2, y2 = p2
    return ((x2 - x1)**2 + (y2 - y1)**2)**0.5

# Test with sample points
points = [(1, 2), (5, 6), (3, 8), (7, 3), (2, 5)]

# Compare performance (in a real scenario with many points, the difference would be more significant)
import time

start = time.time()
result1 = calculate_distances_inefficient(points)
end = time.time()
print(f"Inefficient method: {end - start:.6f} seconds")

start = time.time()
result2 = calculate_distances_efficient(points)
end = time.time()
print(f"Efficient method: {end - start:.6f} seconds")

The efficient version eliminates the function call overhead in the inner loop and pre-computes values used in each iteration of the inner loop. For large datasets, these optimizations can significantly improve performance.

Breaking Out of Nested Loops
def find_value_in_matrix(matrix, target):
    """
    Find the first occurrence of a target value in a matrix.
    Returns the position as (row, column) or None if not found.
    """
    for i in range(len(matrix)):
        for j in range(len(matrix[i])):
            if matrix[i][j] == target:
                return (i, j)  # Found it, return position
    
    return None  # Not found

# Alternative approach using a flag and break
def find_value_with_break(matrix, target):
    """
    Same functionality using break statements.
    """
    found = False
    position = None
    
    for i in range(len(matrix)):
        if found:
            break
            
        for j in range(len(matrix[i])):
            if matrix[i][j] == target:
                position = (i, j)
                found = True
                break
    
    return position

# Another approach: wrapping in a function and using return
def find_value_with_function(matrix, target):
    """
    Same functionality using a separate function.
    """
    def search():
        for i in range(len(matrix)):
            for j in range(len(matrix[i])):
                if matrix[i][j] == target:
                    return (i, j)
        return None
    
    return search()

# Test with a sample matrix
sample_matrix = [
    [1, 2, 3],
    [4, 5, 6],
    [7, 8, 9]
]

# All three methods should find the position of 5
print(find_value_in_matrix(sample_matrix, 5))
print(find_value_with_break(sample_matrix, 5))
print(find_value_with_function(sample_matrix, 5))

# All three methods should return None for 10
print(find_value_in_matrix(sample_matrix, 10))
print(find_value_with_break(sample_matrix, 10))
print(find_value_with_function(sample_matrix, 10))

This example demonstrates three approaches to breaking out of nested loops:

  1. The first function simply returns from the function when the value is found.
  2. The second uses a flag variable to signal when to break from both loops.
  3. The third encapsulates the nested loops in a separate function and returns from that function.

All three approaches are valid, but returning from a function is often the cleanest solution.

Real-World Example: Image Processing

def apply_image_filter(image, filter_type):
    """
    Apply a filter to an image represented as a 2D array of pixel values.
    Each pixel is a value from 0 (black) to 255 (white).
    """
    # Get image dimensions
    height = len(image)
    width = len(image[0]) if height > 0 else 0
    
    # Create a new image to store the result
    result = [[0 for _ in range(width)] for _ in range(height)]
    
    if filter_type == "invert":
        # Invert the colors (255 - pixel_value)
        for i in range(height):
            for j in range(width):
                result[i][j] = 255 - image[i][j]
    
    elif filter_type == "blur":
        # Apply a simple box blur (average of surrounding pixels)
        for i in range(height):
            for j in range(width):
                # Calculate average of neighboring pixels
                total = 0
                count = 0
                
                # Check a 3x3 area around the current pixel
                for di in range(-1, 2):
                    for dj in range(-1, 2):
                        ni, nj = i + di, j + dj
                        
                        # Check if the neighbor is within bounds
                        if 0 <= ni < height and 0 <= nj < width:
                            total += image[ni][nj]
                            count += 1
                
                # Calculate the average
                result[i][j] = total // count
    
    elif filter_type == "edge_detect":
        # Simple edge detection using differences between adjacent pixels
        for i in range(1, height - 1):
            for j in range(1, width - 1):
                # Calculate gradient in x and y directions
                gx = abs(image[i+1][j] - image[i-1][j])
                gy = abs(image[i][j+1] - image[i][j-1])
                
                # Combine gradients
                result[i][j] = min(255, gx + gy)
                
        # Set border pixels to 0
        for i in range(height):
            result[i][0] = result[i][width-1] = 0
        for j in range(width):
            result[0][j] = result[height-1][j] = 0
    
    else:
        # Unknown filter, return the original image
        return image
    
    return result

def print_image(image, char_map=' .:-=+*#%@'):
    """
    Print an image using ASCII characters to represent different pixel values.
    """
    for row in image:
        for pixel in row:
            # Map pixel value to an ASCII character based on brightness
            idx = min(pixel * len(char_map) // 256, len(char_map) - 1)
            print(char_map[idx], end='')
        print()

# Create a simple 10x10 image (a diagonal line)
image = [[0 for _ in range(10)] for _ in range(10)]
for i in range(10):
    image[i][i] = 255  # White diagonal

# Apply different filters
print("Original Image:")
print_image(image)

print("\nInverted Image:")
inverted = apply_image_filter(image, "invert")
print_image(inverted)

print("\nBlurred Image:")
blurred = apply_image_filter(image, "blur")
print_image(blurred)

print("\nEdge Detection:")
edges = apply_image_filter(image, "edge_detect")
print_image(edges)

This image processing example demonstrates how nested loops are used to process two-dimensional data. The outer loop iterates over rows, while the inner loop processes each pixel in the row. The blur filter uses four levels of nested loops: two for iterating over the image and two more for the surrounding pixels.

Combining Conditionals and Loops

The most sophisticated control flow patterns emerge when we combine conditionals with loops. This combination allows for dynamic iteration behavior based on conditionals, enabling complex algorithms and data processing.

Example: Conway's Game of Life

def game_of_life(grid, generations):
    """
    Implement Conway's Game of Life cellular automaton.
    The grid is a 2D array where 1 represents a live cell and 0 represents a dead cell.
    """
    # Get grid dimensions
    rows = len(grid)
    cols = len(grid[0]) if rows > 0 else 0
    
    # Make a copy of the initial grid
    current_grid = [[grid[i][j] for j in range(cols)] for i in range(rows)]
    
    # Run for the specified number of generations
    for gen in range(generations):
        # Create a new grid for the next generation
        next_grid = [[0 for _ in range(cols)] for _ in range(rows)]
        
        # Apply the rules to each cell
        for i in range(rows):
            for j in range(cols):
                # Count live neighbors
                live_neighbors = 0
                
                for di in range(-1, 2):
                    for dj in range(-1, 2):
                        if di == 0 and dj == 0:
                            continue  # Skip the cell itself
                            
                        ni, nj = i + di, j + dj
                        
                        # Check bounds and count live neighbors
                        if 0 <= ni < rows and 0 <= nj < cols and current_grid[ni][nj] == 1:
                            live_neighbors += 1
                
                # Apply the rules of Life
                if current_grid[i][j] == 1:  # Cell is alive
                    if live_neighbors < 2 or live_neighbors > 3:
                        next_grid[i][j] = 0  # Cell dies
                    else:
                        next_grid[i][j] = 1  # Cell survives
                else:  # Cell is dead
                    if live_neighbors == 3:
                        next_grid[i][j] = 1  # Cell becomes alive
                    else:
                        next_grid[i][j] = 0  # Cell remains dead
        
        # Update current grid for the next generation
        current_grid = next_grid
        
        # Print the current generation
        print(f"Generation {gen + 1}:")
        print_grid(current_grid)
        print()
    
    return current_grid

def print_grid(grid):
    """Print the grid using characters for better visualization."""
    for row in grid:
        for cell in row:
            print("■" if cell == 1 else "□", end=" ")
        print()

# Test with a glider pattern
glider = [
    [0, 0, 0, 0, 0],
    [0, 0, 1, 0, 0],
    [0, 0, 0, 1, 0],
    [0, 1, 1, 1, 0],
    [0, 0, 0, 0, 0]
]

print("Initial Grid:")
print_grid(glider)
print()

# Run for 4 generations
game_of_life(glider, 4)

Conway's Game of Life is a classic example of combining nested loops with conditionals. The algorithm uses loops to iterate over each cell in the grid and conditionals to apply the rules of the game based on the cell's current state and its neighbors.

Example: Data Filtering and Transformation

def process_student_data(students, courses):
    """
    Process and analyze student course data.
    
    students: List of student records
    courses: List of course records
    """
    # Gather statistics by course and student
    course_stats = {}
    student_stats = {}
    
    # Initialize course statistics
    for course in courses:
        course_id = course["id"]
        course_stats[course_id] = {
            "enrollment": 0,
            "pass_count": 0,
            "fail_count": 0,
            "top_students": []
        }
    
    # Process each student's data
    for student in students:
        student_id = student["id"]
        student_name = student["name"]
        
        # Initialize stats for this student
        student_stats[student_id] = {
            "name": student_name,
            "courses_taken": 0,
            "courses_passed": 0,
            "gpa": 0.0,
            "top_course": None,
            "top_grade": 0
        }
        
        # Skip inactive students
        if student["status"] != "active":
            continue
        
        total_grade_points = 0
        total_credits = 0
        
        # Process each enrollment for this student
        for enrollment in student["enrollments"]:
            course_id = enrollment["course_id"]
            grade = enrollment["grade"]
            
            # Skip if the course isn't in our course list
            if course_id not in course_stats:
                continue
            
            # Update student count for this course
            course_stats[course_id]["enrollment"] += 1
            
            # Get course details
            course = next((c for c in courses if c["id"] == course_id), None)
            if not course:
                continue
                
            credits = course["credits"]
            student_stats[student_id]["courses_taken"] += 1
            
            # Calculate grade points for GPA
            if grade >= 90:
                grade_letter = "A"
                grade_points = 4.0
            elif grade >= 80:
                grade_letter = "B"
                grade_points = 3.0
            elif grade >= 70:
                grade_letter = "C"
                grade_points = 2.0
            elif grade >= 60:
                grade_letter = "D"
                grade_points = 1.0
            else:
                grade_letter = "F"
                grade_points = 0.0
            
            # Update course pass/fail stats
            if grade_points >= 1.0:  # D or better
                course_stats[course_id]["pass_count"] += 1
                student_stats[student_id]["courses_passed"] += 1
            else:
                course_stats[course_id]["fail_count"] += 1
            
            # Track student's best course
            if grade > student_stats[student_id]["top_grade"]:
                student_stats[student_id]["top_grade"] = grade
                student_stats[student_id]["top_course"] = course["name"]
            
            # Add to GPA calculation
            total_grade_points += grade_points * credits
            total_credits += credits
            
            # Check if this student should be in the top students for this course
            if grade >= 90:
                # Add student to top students for this course
                if len(course_stats[course_id]["top_students"]) < 3:
                    # Top 3 list not full yet
                    course_stats[course_id]["top_students"].append({
                        "name": student_name,
                        "grade": grade
                    })
                else:
                    # Check if this student should replace someone in the top 3
                    min_top_grade = min(s["grade"] for s in course_stats[course_id]["top_students"])
                    if grade > min_top_grade:
                        # Replace the lowest grade in the top 3
                        for i, s in enumerate(course_stats[course_id]["top_students"]):
                            if s["grade"] == min_top_grade:
                                course_stats[course_id]["top_students"][i] = {
                                    "name": student_name,
                                    "grade": grade
                                }
                                break
        
        # Calculate GPA if the student took any courses
        if total_credits > 0:
            student_stats[student_id]["gpa"] = round(total_grade_points / total_credits, 2)
    
    # Add pass rate to course stats
    for course_id, stats in course_stats.items():
        if stats["enrollment"] > 0:
            stats["pass_rate"] = round(stats["pass_count"] / stats["enrollment"] * 100, 1)
        else:
            stats["pass_rate"] = 0.0
    
    return {
        "course_stats": course_stats,
        "student_stats": student_stats
    }

# Sample data
courses = [
    {"id": "CS101", "name": "Introduction to Programming", "credits": 3},
    {"id": "CS102", "name": "Data Structures", "credits": 4},
    {"id": "MATH101", "name": "Calculus I", "credits": 4},
    {"id": "ENG101", "name": "English Composition", "credits": 3}
]

students = [
    {
        "id": "S001",
        "name": "Alice Smith",
        "status": "active",
        "enrollments": [
            {"course_id": "CS101", "grade": 95},
            {"course_id": "MATH101", "grade": 87},
            {"course_id": "ENG101", "grade": 82}
        ]
    },
    {
        "id": "S002",
        "name": "Bob Johnson",
        "status": "active",
        "enrollments": [
            {"course_id": "CS101", "grade": 72},
            {"course_id": "CS102", "grade": 65},
            {"course_id": "MATH101", "grade": 55}
        ]
    },
    {
        "id": "S003",
        "name": "Charlie Brown",
        "status": "inactive",
        "enrollments": [
            {"course_id": "CS101", "grade": 85},
            {"course_id": "ENG101", "grade": 91}
        ]
    },
    {
        "id": "S004",
        "name": "Diana Prince",
        "status": "active",
        "enrollments": [
            {"course_id": "CS101", "grade": 92},
            {"course_id": "CS102", "grade": 88},
            {"course_id": "MATH101", "grade": 94},
            {"course_id": "ENG101", "grade": 89}
        ]
    }
]

# Process the data
results = process_student_data(students, courses)

# Print student statistics
print("Student Statistics:")
for student_id, stats in results["student_stats"].items():
    print(f"\n{stats['name']} (ID: {student_id}):")
    print(f"  Courses Taken: {stats['courses_taken']}")
    print(f"  Courses Passed: {stats['courses_passed']}")
    print(f"  GPA: {stats['gpa']}")
    print(f"  Best Course: {stats['top_course']} ({stats['top_grade']})")

# Print course statistics
print("\nCourse Statistics:")
for course_id, stats in results["course_stats"].items():
    course_name = next(c["name"] for c in courses if c["id"] == course_id)
    print(f"\n{course_name} ({course_id}):")
    print(f"  Enrollment: {stats['enrollment']} students")
    print(f"  Pass Rate: {stats['pass_rate']}%")
    print("  Top Students:")
    for student in stats["top_students"]:
        print(f"    - {student['name']} ({student['grade']})")

This comprehensive data processing example combines multiple levels of loops with various conditional checks to analyze student performance data. It demonstrates several important patterns:

This example reflects real-world data processing scenarios where you need to filter, transform, and aggregate information across multiple related datasets.

Best Practices and Pitfalls

Here are some guidelines to help you write clean, efficient, and maintainable code with nested control structures:

Best Practices

  1. Keep nesting shallow: Try to limit nesting to 2-3 levels. Deep nesting makes code hard to read and maintain.
    # Instead of this:
    if condition1:
        if condition2:
            if condition3:
                # Deep nesting
                
    # Consider this:
    if not condition1:
        return  # Early return
    if not condition2:
        return  # Early return
    if not condition3:
        return  # Early return
        
    # Main logic here (no nesting)
  2. Extract complex conditions into named variables:
    # Instead of this:
    if user.age >= 18 and user.country in allowed_countries and user.verified and not user.is_banned:
        # Allow access
        
    # Use this:
    is_adult = user.age >= 18
    is_from_allowed_country = user.country in allowed_countries
    is_verified = user.verified
    is_not_banned = not user.is_banned
    
    if is_adult and is_from_allowed_country and is_verified and is_not_banned:
        # Allow access
  3. Extract nested logic into functions:
    # Instead of this:
    for user in users:
        for post in user.posts:
            # Complex nested logic here
            
    # Use this:
    def process_post(post):
        # Complex logic here
        
    for user in users:
        for post in user.posts:
            process_post(post)
  4. Use early returns or continues to flatten code:
    # Instead of this:
    def process_order(order):
        if order.is_valid:
            if order.has_items:
                if order.payment_confirmed:
                    # Process the order
                else:
                    return "Payment not confirmed"
            else:
                return "No items in order"
        else:
            return "Invalid order"
            
    # Use this:
    def process_order(order):
        if not order.is_valid:
            return "Invalid order"
            
        if not order.has_items:
            return "No items in order"
            
        if not order.payment_confirmed:
            return "Payment not confirmed"
            
        # Process the order
  5. Watch out for loop variable overwrites:
    # Problematic: inner loop overwrites outer loop variable
    for i in range(5):
        # Some code
        for i in range(3):  # Overwrites outer i!
            print(i)
        print(f"Outer i: {i}")  # i is now 2, not the outer loop value
        
    # Better: use different variable names
    for i in range(5):
        # Some code
        for j in range(3):  # Different variable name
            print(j)
        print(f"Outer i: {i}")  # i is still the outer loop value
  6. Be mindful of performance with nested loops:
    # O(n^2) time complexity
    for i in range(n):
        for j in range(n):
            # Operations here execute n^2 times
            
    # If n is large, consider if there's a more efficient algorithm

Common Pitfalls

  1. Infinite loops: Always ensure loop conditions can eventually become false.
    # Dangerous - may never terminate
    while True:
        # ...
        if some_condition:
            break  # Make sure this condition can be met!
        
    # Also problematic if i never changes inside the loop
    i = 0
    while i < 10:
        # Operations that don't modify i
        # Whoops, i never changes!
  2. Off-by-one errors: Be careful with loop bounds, especially with nested loops.
    # Correct matrix traversal
    for i in range(height):
        for j in range(width):
            process_pixel(image[i][j])
            
    # Incorrect: might access out of bounds
    for i in range(height):
        for j in range(length):  # Should be width, not length!
            process_pixel(image[i][j])
  3. Indentation errors: In Python, indentation defines the structure, so be careful with it.
    # Intended behavior
    for i in range(5):
        if i % 2 == 0:
            print(f"{i} is even")
        else:
            print(f"{i} is odd")
            
    # Incorrect indentation changes behavior
    for i in range(5):
        if i % 2 == 0:
            print(f"{i} is even")
    else:  # Incorrectly indented - belongs to 'for', not 'if'
        print(f"{i} is odd")  # Only executes after loop completes normally
  4. Modifying collections during iteration:
    # Problematic: modifying list while iterating
    for item in my_list:
        if condition(item):
            my_list.remove(item)  # Modifies the list being iterated
            
    # Better: create a new list
    new_list = [item for item in my_list if not condition(item)]
    # Or use a copy
    for item in my_list[:]:  # Iterate over a copy
        if condition(item):
            my_list.remove(item)
  5. Assuming loop body always executes:
    # Dangerous assumption
    for item in items:
        # Code here might never run if items is empty
        found_item = item
        break
        
    # If items is empty, found_item will be undefined
    print(found_item)  # NameError if items was empty

Conclusion

Nested conditionals and loops are powerful tools that allow you to create sophisticated control flow in your programs. By nesting decision structures and repetition, you can handle complex data processing, multi-dimensional data, and intricate algorithms.

As you've seen through the examples in this lesson, real-world programming often requires the combination of these control structures. From image processing to data analysis, game simulations to order processing systems, nested control structures are essential for tackling complex problems.

However, with great power comes great responsibility. Deep nesting can lead to code that's difficult to read and maintain, so it's important to follow best practices like extracting complex logic into functions, using early returns, and keeping nesting levels shallow when possible.

As you continue your Python journey, you'll develop an intuition for when to use nested structures and when to refactor them into separate functions or alternative patterns. This balance between expressiveness and readability is at the heart of good programming practice.

For further practice, try implementing the examples from this lesson, modifying them to solve your own problems, or refactoring them to make them more efficient and readable. Remember that mastering control flow is a foundational skill that will serve you throughout your programming career.

Practice Exercises

To reinforce your understanding of nested control structures, try these exercises:

  1. Pattern Printing: Write a program that uses nested loops to print the following pattern:

    *
    **
    ***
    ****
    *****

    Then modify it to print a pyramid:

        *
       ***
      *****
     *******
    *********
  2. Matrix Diagonal Sum: Write a function that calculates the sum of the main diagonal and the anti-diagonal of a square matrix.

  3. Nested Data Processing: Given a list of dictionaries representing students, where each student has a list of subject grades, calculate the average grade for each student and the overall class average.

  4. Sudoku Validator: Write a function that checks if a completed 9x9 Sudoku puzzle is valid according to the game rules (each row, column, and 3x3 sub-grid must contain all digits 1-9 without repetition).

  5. Text Analysis: Write a program that reads a text file and counts the frequency of each word, ignoring case and punctuation. Then print the top 5 most frequent words.

  6. Advanced Challenge: Implement a simplified version of the A* pathfinding algorithm to find the shortest path through a 2D grid from a start point to an end point, avoiding obstacles. This will require nested loops and complex conditional logic.