Midterm Review: Python Problem Sets 4

Problem 1: Sort Tuples by Second Element

Write a function that sorts a list of tuples by their second element in ascending order.

def sort_by_second(data: list[tuple]) -> list[tuple]:
    # Your code here

Example usage:

data = [("a", 3), ("b", 1), ("c", 2)]
print(sort_by_second(data))  # Output: [("b", 1), ("c", 2), ("a", 3)]
Solution
def sort_by_second(data: list[tuple]) -> list[tuple]:
    return sorted(data, key=lambda x: x[1])

Alternatively, using list.sort():

def sort_by_second(data: list[tuple]) -> list[tuple]:
    data.sort(key=lambda x: x[1])
    return data

Explanation: Both the sorted() function and the .sort() method sort the tuples using a lambda function as the key. The key lambda x: x[1] extracts the second element of each tuple for comparison. sorted() returns a new sorted list, while .sort() sorts the list in place.

Problem 2: List Comprehension for Divisible Numbers

Create a list comprehension to generate all numbers divisible by 3 in a given range, eg. 1 to 100 inclusive.

def generate_divisible_by_3(lowerbound: int, upperbound: int) -> list[int]:
    # Your return statement here

Example usage:

print(generate_divisible_by_3(1, 12))  # Output: [3, 6, 9, 12]
Solution
def generate_divisible_by_3(lowerbound: int, upperbound: int) -> list[int]:
    return [x for x in range(lowerbound, upperbound + 1) if x % 3 == 0]

Explanation: The list comprehension iterates through the range from lowerbound to upperbound (inclusive) and includes only numbers where x % 3 == 0 (divisible by 3).

Problem 3: Reverse List Slice

Write a function that extracts and reverses a slice of a list (start to end indices inclusive). It should handle out-of-bound cases gracefully. You are not allowed to use the built-in slice operations.

def reverse_slice(lst: list[int], start: int, end: int) -> list[int]:
    # Your code here

Example usage:

lst = [10, 20, 30, 40, 50]
print(reverse_slice(lst, 1, 3))  # Output: [40, 30, 20]
print(reverse_slice(lst, 2, 8))  # Output: [50, 40, 30]
Solution
def reverse_slice(lst: list[int], start: int, end: int) -> list[int]:
    # Clamp indices to valid range
    start = max(0, min(start, len(lst) - 1))
    end = max(0, min(end, len(lst) - 1))
 
    # Extract and reverse
    result = []
    for i in range(end, start - 1, -1):
        result.append(lst[i])
    return result

Explanation: The function first clamps the start and end indices to valid ranges (0 to len(lst)-1). Then it iterates from the end index down to the start index and appends each element to the result list, effectively reversing the slice.

Problem 4: Apply Transformation Callback

Write a Python function apply_transformation that accepts a list of integers and a callback function. apply_transformation should return a new list where each element is transformed according to the callback function. The callback function itself should be one that accepts an integer value and returns the transformed value.

Your function should have the following signature:

def apply_transformation(numbers, transform_function):
    # Your code here

Example usage:

my_list = [1, 2, 3]
number_doubler_lambda = ... # create your lambda here
number_squarer_lambda = ... # create your lambda here
 
doubled_nums = apply_transformation(my_list, number_doubler_lambda)
squared_nums = apply_transformation(my_list, number_squarer_lambda)
 
print(doubled_nums) # outputs: [2, 4, 6]
print(squared_nums) # outputs: [1, 4, 9]
Solution

Solution:

def apply_transformation(numbers, transform_function):
    return [transform_function(x) for x in numbers]

Example usage:

my_list = [1, 2, 3]
number_doubler_lambda = lambda x: x * 2
number_squarer_lambda = lambda x: x ** 2
 
doubled_nums = apply_transformation(my_list, number_doubler_lambda)
squared_nums = apply_transformation(my_list, number_squarer_lambda)
 
print(doubled_nums)  # [2, 4, 6]
print(squared_nums)  # [1, 4, 9]

Explanation: The function uses a list comprehension to apply the callback function to each element in the numbers list.


The following would be equivalent to the above:

def number_doubler(x):
    return x * 2
 
def number_squarer(x):
    return x ** 2
 
my_list = [1, 2, 3]
 
doubled_nums = apply_transformation(my_list, number_doubler_lambda)
squared_nums = apply_transformation(my_list, number_squarer_lambda)
 
print(doubled_nums)  # [2, 4, 6]
print(squared_nums)  # [1, 4, 9]

Bonus: Would these function definitions meet the same purpose?

def number_doubler(x):
    return lambda x: x * 2
 
def number_squarer(x):
    return lambda x: x ** 2

Answer to bonus: No, this would not be the same. The functions number_doubler and number_squarer as defined here return lambda functions instead of performing the transformation directly. Basically, they return a reference to a function that performs the operation. To use them, you would need to call the returned function reference separately, which is not the intended behavior for this problem.

Problem 5: Sort by Rightmost Digit

Write an expression that sorts a list of integers based on their right-most digit.

Example:

mylist =  [5, 4, 21, 20, 39]
# Your expression here
print(mylist) # output: [20, 21, 4, 5, 39]
Solution
mylist = [5, 4, 21, 20, 39]
mylist.sort(key=lambda x: x % 10)
print(mylist)  # [20, 21, 4, 5, 39]

Alternatively, using sorted():

mylist = sorted([5, 4, 21, 20, 39], key=lambda x: x % 10)
print(mylist)  # [20, 21, 4, 5, 39]

Explanation: The key lambda x: x % 10 extracts the rightmost digit of each number. The numbers are sorted by their rightmost digit in ascending order: 20 and 21 both end in 0 and 1 (20 < 21), then 4 and 5 (both end in 4 and 5), then 39 (ends in 9).

Problem 6: Mutable Default Argument Behavior

What is the output of this code, and why?

def func(x=[1,2,3]):
    x.append(4)
    return x
 
print(func())
print(func(x=[5,6,7]))
print(func())
Solution

Output:

[1, 2, 3, 4]
[5, 6, 7, 4]
[1, 2, 3, 4, 4]

Explanation: This demonstrates the mutable default argument pitfall.

  • First call: func() uses the default list [1, 2, 3], appends 4, returns [1, 2, 3, 4]. This default list is stored in memory.
  • Second call: func(x=[5, 6, 7]) creates a new list, appends 4, returns [5, 6, 7, 4]. This doesn’t affect the default.
  • Third call: func() reuses the same default list from the first call, which is now [1, 2, 3, 4] (modified), appends 4, returns [1, 2, 3, 4, 4].

The default list is created once at function definition time and reused across all calls.

Problem 7: Extended Slice Assignment

What happens when you run this code?

lst = [1, 2, 3, 4, 5]
lst[1:4:2] = [10, 20]
print(lst)
Solution

Output:

[1, 10, 3, 20, 5]

Explanation: The slice lst[1:4:2] selects elements at indices 1 and 3 (step of 2 from index 1 to 4, exclusive). This selects the elements at positions 1 and 3, which are 2 and 4. The assignment [10, 20] replaces these two elements with 10 and 20 respectively, resulting in [1, 10, 3, 20, 5].

Problem 8: Unpacking and Sorting

What is the output of this code?

d = [4, 1, 6]
lst = [*d, *sorted(d)]
print(lst)
Solution

Output:

[4, 1, 6, 1, 4, 6]

Explanation: The list d is [4, 1, 6]. The expression [*d, *sorted(d)] unpacks d (giving 4, 1, 6) and then unpacks sorted(d) (which is [1, 4, 6], giving 1, 4, 6). These are combined into a single list: [4, 1, 6, 1, 4, 6].

Problem 9: Multiple Slice Reversal

What is printed and why?

lst = [1, 2, 3, 4, 5]
new_lst = lst[::-1][:3][::-1]
print(new_lst)
Solution

Output:

[3, 4, 5]

Explanation:

  • lst[::-1] reverses the list: [5, 4, 3, 2, 1]
  • [:3] takes the first 3 elements: [5, 4, 3]
  • [::-1] reverses again: [3, 4, 5]

The operations are applied left to right (chained slicing).

Problem 10: Closure with Default Parameter

What is the output of this code?

def create_multipliers():
    multipliers = []
    for i in range(3):
        def multiplier(x, i=i):
            return x * i
        multipliers.append(multiplier)
    return multipliers
 
funcs = create_multipliers()
print([f(2) for f in funcs])
Solution

Output:

[0, 2, 4]

Explanation: The key here is the default parameter i=i in the function definition. This captures the current value of the loop variable i at the time the function is defined.

  • When i=0: the function multiplies by 0, so f(2) returns 2 * 0 = 0
  • When i=1: the function multiplies by 1, so f(2) returns 2 * 1 = 2
  • When i=2: the function multiplies by 2, so f(2) returns 2 * 2 = 4

If the function definition for multiplier did not include the default parameter i=i, then this could produce a situation referred to as a “late binding” closure, where all functions would use the final value of i (which would be 2), resulting in the print statement print([f(2) for f in funcs]) to output [4, 4, 4]

Problem 11: Multiple Sorts

What does this code print?

items = [(4, 444), (1, 11), (3, 3333)]
sorted_items = sorted(sorted(items, key=lambda x: x[1]), key=lambda x: x[0])
print(sorted_items)
Solution

Output:

[(1, 11), (3, 3333), (4, 444)]

Explanation: The code performs two sorts:

  • First sorted(items, key=lambda x: x[1]) sorts by the second element (444, 11, 3333): [(1, 11), (3, 3333), (4, 444)]
  • Then sorted(..., key=lambda x: x[0]) sorts by the first element (1, 3, 4): [(1, 11), (3, 3333), (4, 444)]

The final sort is the one that matters since it overwrites the previous ordering. The items are sorted by their first element in ascending order.

Problem 12: List Comprehension with Sorted

What’s the output of this code?

items = [11, 22, 33, 44, 55]
counts = [1, 2, 3, 4, 5]
result = [(items[i], counts[i]) for i in range(len(sorted(items, reverse=True)))]
print(result)
Solution

Output:

[(11, 1), (22, 2), (33, 3), (44, 4), (55, 5)]

Explanation: The len(sorted(items, reverse=True)) evaluates to len([55, 44, 33, 22, 11]), which is 5. So the range is range(5) which gives indices 0, 1, 2, 3, 4. The list comprehension creates tuples by pairing items and counts at each index: (items[0], counts[0]), (items[1], counts[1]), etc. The sorted() call is only used to determine the length, not to reorder the result.

Problem 13: List Copy in Function

What’s printed and why?

def func(items, value):
    items.append(value)
    return items
 
numbers = [1, 2, 3]
print(func(numbers[:], 4))
print(numbers)
Solution

Output:

[1, 2, 3, 4]
[1, 2, 3]

Explanation: The key here is numbers[:], which creates a shallow copy of the list. When func(numbers[:], 4) is called, the function receives a copy of the list, not the original. The function appends 4 to the copy and returns it. The original numbers list remains unchanged because the function modified only the copy, not the original.

Problem 14: In-place List Sort in Slice

What’s printed?

mylist = [1, 2, 3, 4, 5]
mylist[1:4] = sorted(mylist[1:4], reverse=True)
print(mylist)
Solution

Output:

[1, 4, 3, 2, 5]

Explanation:

  • mylist[1:4] is the slice [2, 3, 4]
  • sorted([2, 3, 4], reverse=True) returns [4, 3, 2]
  • The assignment mylist[1:4] = [4, 3, 2] replaces the slice with the sorted (reversed) elements
  • The result is [1, 4, 3, 2, 5]

Problem 15: Sorted and Reversed

What’s the output of this code?

nums1 = list(range(5))
nums2 = sorted(nums1, reverse=True)[::-1]
print(nums1)
print(nums2)
Solution

Output:

[0, 1, 2, 3, 4]
[0, 1, 2, 3, 4]

Explanation:

  • list(range(5)) creates [0, 1, 2, 3, 4] and assigns it to nums1
  • sorted(nums1, reverse=True) sorts it in descending order: [4, 3, 2, 1, 0]
  • [::-1] reverses this sorted list: [0, 1, 2, 3, 4]
  • So nums2 is [0, 1, 2, 3, 4]
  • Both nums1 and nums2 are the same

Note that sorted() creates a new list, so nums1 is not modified. Both variables end up with the same list contents.