← Computer Programming I

These notes cover the core concepts reviewed during the Week 7 lecture session. The goal is to solidify your understanding of the foundational topics from Weeks 1-6 in preparation for the mid-term exam.

1. Control Flow (Loops & Conditionals)

Theory & Explanation

  • Definition: Control Flow is the order in which program statements are executed. By default, Python runs code sequentially (top to bottom). Control flow structures like loops and conditionals allow us to alter this default order.
  • Importance:
    • Conditionals (if/elif/else) enable programs to make decisions and run different code blocks based on whether a condition is True or False.
    • Repetition Structures (for/while) automate repetitive tasks, which is a fundamental strength of programming.
  • Relation to Previous Concepts: We use variables, relational operators (>, ==, etc.), and logical operators (and, or) to create the conditions that guide our control flow structures.

Syntax Overview

Conditional Statements

if condition1:
    # This block runs if condition1 is True.
elif condition2:
    # This block runs if condition1 is False and condition2 is True.
else:
    # This block runs if all previous conditions were False.

Note: Indentation is crucial. It defines which lines of code belong to which block.

for Loop (with range)

for variable in range(start, stop, step):
    # This block executes for each number in the sequence.

Note: The stop value is exclusive (the loop runs up to, but not including, stop).

while Loop

while condition:
    # This block executes as long as the condition remains True.
  • Common Mistake: Forgetting to update the variable that controls the while loop’s condition, which results in an infinite loop.

Example Problem: Analyzing a List of Numbers

  • Problem Statement: Write a function analyze_numbers(numbers) that takes a list of integers. The function should iterate through the list and count numbers in three categories: (1) positive and even, (2) positive and odd, and (3) negative. The number zero should be ignored. The function must return a tuple containing the three counts in that order: (positive_even_count, positive_odd_count, negative_count).
  • Example: analyze_numbers([1, 2, 3, 4, 5, 6, -1, -2, 0]) should return (3, 3, 2).

  • Solution Code:
    def analyze_numbers(numbers):
        """
        Analyzes a list of numbers and categorizes them.
        """
        positive_even_count = 0
        positive_odd_count = 0
        negative_count = 0
    
        for num in numbers:
            if num > 0:
                # A nested conditional to check for even or odd
                if num % 2 == 0:
                    positive_even_count += 1
                else: # num must be odd
                    positive_odd_count += 1
            elif num < 0:
                negative_count += 1
            # Note: The case where num == 0 is implicitly ignored.
    
        return (positive_even_count, positive_odd_count, negative_count)
    
    # --- Testing the function ---
    test_data = [1, 2, 3, 4, 5, 6, -1, -2, 0, 10, -5]
    result = analyze_numbers(test_data)
    print(result) # Expected output: (4, 3, 3)
    
  • Code Breakdown:
    1. Initialization: The three counter variables are initialized to 0 before the loop begins. This is essential for any counting or summation logic.
    2. List Traversal: A for loop is used to process each item in the input list numbers one by one.
    3. Conditional Logic: The main if/elif structure categorizes each number as positive or negative.
    4. Nested Logic: Inside the if num > 0: block, a second, nested if/else statement is used to further differentiate between even and odd numbers.
    5. Ignoring a Case: Because there is no else block to catch num == 0, those values are skipped, satisfying the problem requirement.
    6. Return Value: The function concludes by returning a tuple containing the final counts, which neatly packages the multiple results.

2. Functions

Theory & Explanation

  • Definition: A function is a named, reusable block of code that performs a specific, well-defined task.
  • Importance: Functions are the foundation of organized, readable, and maintainable code.
    • Abstraction: Hides complex logic behind a simple, descriptive name.
    • Reusability: Allows you to write code once and call it multiple times from different places (the DRY principle: Don’t Repeat Yourself).

Syntax Overview

def function_name(parameter1, parameter2):
    """A docstring explaining what the function does."""
    # Body of the function
    # ...
    return some_value # Optional: sends a value back
  • Parameters vs. Arguments: In the definition, parameter1 is a parameter (a placeholder). When you call the function, like function_name(10, "hello"), the values 10 and "hello" are arguments.
  • return vs. print: This is a critical distinction. print() only displays a value to the console. return sends a value back to the calling code, allowing it to be stored in a variable or used in other calculations. A function without an explicit return statement implicitly returns None.
  • Variable Scope: Variables created inside a function are local and cannot be accessed from outside.

Example Problem: Calculating an Adjusted Grade

  • Problem Statement: Write a function calculate_grade(scores) that takes a list of numerical scores. It should handle three cases:
    1. If the list is empty, return the string "No scores".
    2. If the list has only one score, drop it. Since the list is now empty, return 0.0.
    3. Otherwise, drop the single lowest score and return the average of the remaining scores.
  • Example: calculate_grade([80, 90, 100, 70]) should drop 70 and return 90.0.

  • Solution Code:
    def calculate_grade(scores):
        """
        Calculates the average of a list of scores after dropping the lowest score.
        """
        # Edge Case 1: Handle an initially empty list.
        if not scores: # An empty list evaluates to False
            return "No scores"
    
        # The .remove() method modifies the list in-place.
        # To avoid this, work on a copy: scores_copy = scores.copy()
        lowest_score = min(scores)
        scores.remove(lowest_score)
    
        # Edge Case 2: Handle a list that becomes empty after removing an item.
        if not scores:
            return 0.0
    
        average = sum(scores) / len(scores)
        return average
    
    # --- Testing the function ---
    scores1 = [80, 90, 100, 70]
    print(calculate_grade(scores1)) # Expected output: 90.0
    
  • Code Breakdown:
    1. Defensive Programming: The function first checks for an empty list (if not scores:). This is a “guard clause” that handles invalid input immediately and makes the rest of the function cleaner.
    2. Using Built-in Functions: Python’s powerful built-in functions like min(), sum(), and len() simplify the code significantly.
    3. List Mutability: The .remove() method modifies the list it’s called on directly. This is called a “side effect.” If the original list needed to be preserved, we would have to operate on a copy (scores.copy()).
    4. Handling Edge Cases: The code correctly handles two different edge cases: an initially empty list and a list that becomes empty after the lowest score is removed. Robust functions anticipate such cases.

3. List Manipulation

Theory & Explanation

  • Definition: A list is a mutable (changeable), ordered sequence of elements. Lists are one of Python’s most common and versatile data structures for storing collections of data.
  • Importance: Most programs work with collections of data, not just single values. Lists allow us to store, access, and manipulate these collections efficiently.

Syntax Overview

  • Creation: my_list = [1, "a", True]
  • Indexing: my_list[0] (first element), my_list[-1] (last element)
  • Slicing: my_list[1:3] (creates a new list with elements from index 1 up to, but not including, index 3)
  • Common Methods:
    • .append(item): Adds an item to the end.
    • .insert(index, item): Inserts an item at a specific position.
    • .pop(): Removes and returns the last item.
    • .remove(value): Removes the first occurrence of value.

Example Problem: Filtering and Transforming Data

  • Problem Statement: Write a function filter_and_double(data) that takes a list of integers. It should create and return a new list containing only the odd numbers from the original list, with each of those numbers doubled. The original list must not be changed.

  • Example: filter_and_double([1, 2, 3, 4, 5]) should return [2, 6, 10].

  • Solution Code:
    def filter_and_double(data):
        """
        Creates a new list containing doubled odd numbers from the input list.
        """
        new_list = [] # Initialize an empty list to accumulate results
    
        for number in data:
            # Step 1: Filter (select only odd numbers)
            if number % 2 != 0:
                # Step 2: Transform (double the number) and append
                doubled_value = number * 2
                new_list.append(doubled_value)
    
        return new_list
    
    # --- Testing the function ---
    original_data = [1, 2, 3, 4, 5, 6, 7]
    processed_data = filter_and_double(original_data)
    print(f"Original: {original_data}")   # Expected: [1, 2, 3, 4, 5, 6, 7]
    print(f"Processed: {processed_data}") # Expected: [2, 6, 10, 14]
    
  • Code Breakdown:
    1. The Accumulator Pattern: This is a fundamental programming pattern. We start with an empty collection (new_list = []), loop through our data, and conditionally add processed items to the collection. The final collection is then returned.
    2. Non-Destructive Operation: The function creates and returns a new list. The original data list is read but never modified. This is good practice, as it prevents unexpected side effects in other parts of a program.
    3. Separation of Logic: The logic inside the loop is clear and separated: first, we filter with an if statement, and second, we transform the data (* 2) before appending it.

Theory & Explanation

  • Nested Lists: A nested list is a list that contains other lists as elements. This is the standard way to represent 2D data like grids, matrices, or tables (data with rows and columns).
  • Linear Search: This is our first formal algorithm. It finds a target value in a list by checking each element one by one, from start to finish, until a match is found or the list ends.

Syntax Overview

  • Creation: grid = [[1, 2, 3], [4, 5, 6]]
  • Accessing: Use two indices: grid[row][col]. For example, grid[1][0] accesses the element 4.
  • Traversing: Nested loops are used to process every element.
    # Using indices
    for r in range(len(grid)):
        for c in range(len(grid[r])):
            print(grid[r][c])
    
    # More Pythonic way (without indices)
    for row_list in grid:
        for item in row_list:
            print(item)
    

Example Problem: Finding an Item in a Grid

  • Problem Statement: Write a function find_item_location(warehouse_grid, target_item) that searches a 2D grid for a target_item. If the item is found, the function should return its (row, column) coordinates as a tuple. If the item is not in the grid, it should return None.

  • Example: Given grid = [[101, 102], [201, 202]], calling find_item_location(grid, 201) should return (1, 0).

  • Solution Code:
    def find_item_location(warehouse_grid, target_item):
        """
        Performs a linear search on a nested list to find an item's location.
        """
        num_rows = len(warehouse_grid)
    
        for r in range(num_rows):
            num_cols = len(warehouse_grid[r])
            for c in range(num_cols):
                # Core logic: check if the current element is the target
                if warehouse_grid[r][c] == target_item:
                    return (r, c) # Found: exit immediately with the coordinates
    
        # This line is only reached if the loops finish without finding the item
        return None
    
    # --- Testing the function ---
    warehouse = [[11, 23, 76], [45, 98, 50], [88, 62, 37]]
    print(find_item_location(warehouse, 98))  # Expected output: (1, 1)
    print(find_item_location(warehouse, 100)) # Expected output: None
    
  • Code Breakdown:
    1. Nested Iteration: The outer loop iterates through the row indices (r), and the inner loop iterates through the column indices (c). Together, they visit every cell in the grid.
    2. Accessing by Index: The expression warehouse_grid[r][c] is used to access the value at the current row r and column c.
    3. Efficient Exit: As soon as the target_item is found, return (r, c) is executed. This immediately stops both loops and exits the entire function, making the search efficient.
    4. Handling the “Not Found” Case: The return None statement is placed after the loops have completed. It will only ever be reached if the if condition was never met, meaning the item was not found anywhere in the grid.

Part 2: Consolidation Problems & Solutions

These problems are designed to test your ability to integrate multiple concepts from the first six weeks. Each solution combines loops, conditionals, functions, and data structures (lists, tuples) to solve a more complex task.

Problem 1: Student Score Processor

  • Concepts Practiced: Functions, List of Tuples, Tuple Unpacking, Conditionals, Looping, Accumulator Pattern.

Problem Statement

You have student score data stored as a list of tuples, where each tuple is (student_id_string, score_integer). A single student may have multiple scores recorded.

Write a function get_student_summary(all_scores, target_student_id) that does the following:

  1. Accepts the list of score tuples and a target_student_id string.
  2. Finds all scores belonging to the target_student_id.
  3. Calculates both the average of that student’s scores and the total count of their scores.
  4. Returns a tuple containing (score_count, average_score).
  5. If the target_student_id is not found, the function should return (0, 0.0).

Example: Given scores_data = [('101', 88), ('102', 95), ('101', 92)]:

  • get_student_summary(scores_data, '101') should return (2, 90.0).
  • get_student_summary(scores_data, '999') should return (0, 0.0).

Solution & Explanation

def get_student_summary(all_scores, target_student_id):
    """
    Finds all scores for a specific student, calculates the count and average.
    """
    # 1. Accumulator Pattern: Create an empty list to store matching scores.
    student_scores = []

    # 2. Traversal & Tuple Unpacking: Loop through the list.
    #    On each iteration, unpack the tuple into `student_id` and `score`.
    for student_id, score in all_scores:
        # 3. Filtering: Check if the current student_id matches our target.
        if student_id == target_student_id:
            student_scores.append(score)

    # 4. Process the results:
    #    This `if` statement elegantly handles the "not found" case. If no
    #    scores were found, `student_scores` will be empty and evaluate to False.
    if not student_scores:
        return (0, 0.0)

    # 5. Calculation: If scores were found, calculate the count and average.
    score_count = len(student_scores)
    total_score = sum(student_scores)
    average_score = total_score / score_count

    return (score_count, average_score)

# --- Testing the solution ---
scores_data = [
    ('101', 88), ('102', 95), ('101', 92),
    ('103', 78), ('102', 80), ('101', 75)
]
summary_101 = get_student_summary(scores_data, '101')
summary_999 = get_student_summary(scores_data, '999')

print(f"Summary for student 101: {summary_101}")
print(f"Summary for student 999: {summary_999}")

Expected Output:

Summary for student 101: (3, 85.0)
Summary for student 999: (0, 0.0)

Key Idea: This solution uses a two-stage process. First, it iterates through the raw data to filter and accumulate the relevant information (the scores for one student). Second, it processes that smaller, cleaned-up list to perform the final calculations.


Problem 2: Grid Boundary Checker

  • Concepts Practiced: Nested Lists, Functions, Nested Loops, Conditionals, Indexing, Boundary Checking Logic.

Problem Statement

You are given a 2D list of integers (grid) representing a game board, and a coordinate (row, col).

Write a function sum_boundary_values(grid, row, col) that calculates and returns the sum of all values on the “boundary” of that coordinate. The boundary is defined as all cells in the same row AND all cells in the same column as the given (row, col). The value of the cell at (row, col) itself should be excluded from the sum.

Your function must work for any valid coordinate without causing an index error.

Example: Given game_grid = [[1, 1, 1], [1, 10, 1], [1, 1, 1]]:

  • sum_boundary_values(game_grid, 1, 1) should sum the elements in row 1 (1 + 1) and column 1 (1 + 1), excluding the 10. The result is 4.

Solution & Explanation

def sum_boundary_values(grid, row, col):
    """
    Calculates the sum of values in the same row and column as the
    given coordinate, excluding the coordinate's value itself.
    """
    if not grid:
        return 0

    total_sum = 0
    num_rows = len(grid)
    num_cols = len(grid[0]) # Assumes a non-empty, rectangular grid

    # 1. Sum the row: Iterate through each column index `c`.
    for c in range(num_cols):
        # 2. Exclude the center point: Use a conditional to skip the main cell.
        if c != col:
            total_sum += grid[row][c]

    # 3. Sum the column: Iterate through each row index `r`.
    for r in range(num_rows):
        # 4. Exclude the center point again.
        if r != row:
            total_sum += grid[r][col]

    return total_sum

# --- Testing the solution ---
game_grid = [
    [1,  1,  1,  1],
    [1, 10,  1,  1],
    [1,  1,  1,  1],
    [1,  1,  1,  1]
]
result1 = sum_boundary_values(game_grid, 1, 1)
result2 = sum_boundary_values(game_grid, 0, 0)

print(f"Boundary sum at (1, 1): {result1}")
print(f"Boundary sum at (0, 0): {result2}")

Expected Output:

Boundary sum at (1, 1): 6
Boundary sum at (0, 0): 6

Key Idea: The problem can be broken down into two independent parts: summing the relevant row elements and summing the relevant column elements. The crucial detail is the if condition inside each loop, which prevents the value at the target coordinate (row, col) from being added to the sum.


Problem 3: Find Best Value Product

  • Concepts Practiced: List of Tuples, Functions, “Find Maximum” Algorithm, Conditionals, Logical Operators.

Problem Statement

Your inventory is a list of tuples, each representing a product: (product_id, category, price, stock_level).

Write a function find_best_value(inventory, target_category, max_price) that finds the “best value” product based on three criteria:

  1. The product must belong to the target_category.
  2. The product’s price must be less than or equal to max_price.
  3. Of all products meeting these criteria, the one with the highest stock_level is the “best value”.

The function should return the product_id of the best value product. If no products match the criteria, return None.

Example: Given inventory_data = [('P101', 'Electronics', 499.99, 15), ('P104', 'Electronics', 399.00, 40)]:

  • find_best_value(inventory_data, 'Electronics', 500.00) should identify that P104 has a higher stock level (40) than P101 (15) and return 'P104'.

Solution & Explanation

def find_best_value(inventory, target_category, max_price):
    """
    Finds the product ID of the item with the highest stock that meets
    category and price criteria.
    """
    # 1. "Find Maximum" Pattern: Initialize variables to track the best-so-far.
    #    We start with `highest_stock_so_far = -1` because any real stock level
    #    will be greater than it.
    best_product_id = None
    highest_stock_so_far = -1

    # 2. Iterate and Unpack: Loop through each product tuple.
    for product_id, category, price, stock_level in inventory:
        # 3. Filter: Check if the current product meets all criteria.
        #    The `and` operator ensures both conditions must be true.
        if category == target_category and price <= max_price:
            # 4. Compare: If it's a valid product, check if it's the new "best".
            if stock_level > highest_stock_so_far:
                # 5. Update: If it is, update both tracking variables.
                highest_stock_so_far = stock_level
                best_product_id = product_id

    # 6. After the loop, `best_product_id` will hold the ID of the best
    #    product found, or `None` if no products matched the criteria.
    return best_product_id

# --- Testing the solution ---
inventory_data = [
    ('P101', 'Electronics', 499.99, 15), ('P102', 'Books', 24.50, 80),
    ('P103', 'Electronics', 549.99, 25), ('P104', 'Electronics', 399.00, 40),
    ('P105', 'Books', 19.99, 120), ('P106', 'Home Goods', 89.99, 50)
]

best_electronic = find_best_value(inventory_data, 'Electronics', 500.00)
best_book = find_best_value(inventory_data, 'Books', 30.00)
best_toy = find_best_value(inventory_data, 'Toys', 100.00)

print(f"Best value electronic under $500: {best_electronic}")
print(f"Best value book under $30: {best_book}")
print(f"Best value toy under $100: {best_toy}")

Expected Output:

Best value electronic under $500: 'P104'
Best value book under $30: 'P105'
Best value toy under $100: None

Key Idea: This solution implements the classic “Find Maximum” algorithm. It maintains “state” using the best_product_id and highest_stock_so_far variables. As it iterates through the list, it compares each valid item to the best one it has seen so far, and updates its state only when it finds a better item. The initial values for the state variables are chosen to ensure the very first valid item will become the “best-so-far”.