Solutions to Algorithms Illustrated (Part 1 - Chapter 3 - Divide-and-Conquer Algorithms)
Problem 3.1
This problem asks to find the asymptotic running time of fast exponentiation algorithm, assuming each multiplication and division can be performed in constant time.
Solution
- The fast exponentiation algorithm works by repeatedly squaring the base and reducing the exponent by half at each step. The key observation is that the number of times we can halve the exponent before it reaches zero is logarithmic in relation to the size of the exponent. Thus, the running time of the fast exponentiation algorithm is , where is the exponent.
Problem 3.2
It asks for an algorithm to find the maximum element in an unimodal array which takes time, where is the size of the array.
Solution
- We can use divide-and-conquer approach here.
- Divide array into left and right subarray, and respectively, preserving the order. By the definition of unimodal array, if max element is in then, (given distint elements constraint). Similarly, if max element is in then, .
- Thus, we can compare the last element of and first element of to determine which subarray contains the maximum element. We can then recursively apply the same logic to the chosen subarray until we find the maximum element.
Python Code Implementation:
def find_max_unimodal(arr):
def find_max(left, right):
if left == right:
return arr[left]
mid = (left + right) // 2
if arr[mid] > arr[mid + 1]:
return find_max(left, mid)
else:
return find_max(mid + 1, right)
return find_max(0, len(arr) - 1)
- The time complexity of this algorithm is , as we are halving the search space at each step.
Problem 3.3
In this problem we are given a sorted array with distinct integers, and we need to find an index such that in time. Elements can be negative, possitve or zero.
Solution
- We can use a modified binary search approach here.
- At each step, we compare the middle element with its index .
- If , we have found the index and return it.
- If , then the desired index must be in the left subarray, since all elements to the right will be greater than their indices (due to the distinctness and sorted property).
- If , then the desired index must be in the right subarray.
- We continue this process recursively until we find the index or exhaust the search space.
Python Code Implementation:
def find_index_equal_value(arr):
def binary_search(left, right):
if left > right:
return -1 # Not found
mid = (left + right) // 2
# distinct elements guarantee unambiguity
if arr[mid] == mid:
return mid
elif arr[mid] > mid:
return binary_search(left, mid - 1)
else:
return binary_search(mid + 1, right)
return binary_search(0, len(arr) - 1)
- The time complexity of this algorithm is , as we are halving the search space at each step.
Problem 3.4
In this problem we are asked to find a local minimum in an grid of distinct numbers with only comparisons.
Solution
-
We can use hill climbing approach here (analogous to gradient descent in continuous environments). As all values are distinct we can assume there are no valleys and atleast one minimum is guaranteed to be present (this can be proved easily given the definite value of ).
-
In naive approach, we can start from a cell and jump to the smallest neighboring cell. Repeat this until we reach a local minimum at which point all the neighboring cells will be larger than the current cell.
-
Worst case time complexity of the naive approach is . Example, imagine zig-zag shaped decreasing numbers.
-
To improve the time complexity, we can use divide-and-conquer approach.
-
Motivation: If we divide the grid into 4 quadrants of grid size, a local mimimum can be found in one of the quadrants. Once we know in which quadrant a local minimum can be found, we can recurse on the quadrant grid using that same process until base case is reached.
-
Dividing the quandrant in half (in each dimension) in each recursion implies there will be approximately recursive calls in worst case.
-
Hence, in each recursion we need to do work to achieve time complexity.
-
We can use something similar to mean value theorem to select a quadrant to recurse in each recursion.
- Given the grid of size , find minimum element from the elements in row and column (clearly this row and column divides the grid into 4 quadrants). As all values are distinct we are guaranteed to find a unique mimimum. Here we do comparisons.
- Then check the neighbours of this minimum element. If the minimum is smaller than all its neighbours then we have found our local minimum. Here we do comparisons.
- If any of the neighbours are smaller than the above selected element, then the quadrant of the smallest neighbor is selected for further recurson.
- Base case: In the base case of grid of size , the single element of the grid is our local minimum. The proof is evident from the above selection criteria of the quadrant.
-
Now we need to prove that the quadrant chosen using the above method for recursion is guaranteed to contain a local minimum.
- Let the minimum element found in step 1 be .
- If is smaller than all its neighbors, we are done.
- Otherwise, let’s say one of its neighbors, say , is smaller than .
- Since all elements are distinct, must be less than .
- Now, consider the quadrant that contains . Since is less than , and was the minimum in its row and column, it follows that there must be a local minimum in the quadrant containing . This is because if we keep moving in the direction of decreasing values, we will eventually reach a point where all neighboring values are greater (a local minimum). This is similar to the naive approach but we are narrowing down our search space by jumping to the point near a local minimum.
-
It takes comparisons in recursion where starts from . Hence total comparisons will be:
Python Code Implementation:
def find_local_minimum(grid):
n = len(grid)
quadrant = [(0, n - 1, 0, n - 1)] # (row_start, row_end, col_start, col_end)
def get_neighbors(i, j):
neighbors = []
if i > 0:
neighbors.append((i - 1, j))
if i < n - 1:
neighbors.append((i + 1, j))
if j > 0:
neighbors.append((i, j - 1))
if j < n - 1:
neighbors.append((i, j + 1))
return neighbors
def find_min_in_cross(row_start, row_end, col_start, col_end):
min_val = float('inf')
min_pos = (-1, -1)
mid_row = (row_start + row_end) // 2
mid_col = (col_start + col_end) // 2
for j in range(col_start, col_end + 1):
if grid[mid_row][j] < min_val:
min_val = grid[mid_row][j]
min_pos = (mid_row, j)
for i in range(row_start, row_end + 1):
if grid[i][mid_col] < min_val:
min_val = grid[i][mid_col]
min_pos = (i, mid_col)
return min_pos
def search(row_start, row_end, col_start, col_end):
if row_start == row_end and col_start == col_end:
return (row_start, col_start)
mid_row = (row_start + row_end) // 2
mid_col = (col_start + col_end) // 2
min_i, min_j = find_min_in_cross(row_start, row_end, col_start, col_end)
neighbors = get_neighbors(min_i, min_j)
smallest_neighbor = (min_i, min_j)
for ni, nj in neighbors:
if grid[ni][nj] < grid[smallest_neighbor[0]][smallest_neighbor[1]]:
smallest_neighbor = (ni, nj)
if smallest_neighbor == (min_i, min_j):
return (min_i, min_j)
else:
if smallest_neighbor[0] <= mid_row and smallest_neighbor[1] <= mid_col:
return search(row_start, mid_row, col_start, mid_col)
elif smallest_neighbor[0] <= mid_row and smallest_neighbor[1] > mid_col:
return search(row_start, mid_row, mid_col + 1, col_end)
elif smallest_neighbor[0] > mid_row and smallest_neighbor[1] <= mid_col:
return search(mid_row + 1, row_end, col_start, mid_col)
else:
return search(mid_row + 1, row_end, mid_col + 1, col_end)
return search(0, n - 1, 0, n - 1)
# Example usage:
# Distinct elements grid
grid = [
[20, 8, 21, 23],
[14, 13, 12, 11],
[15, 9, 8, 7],
[16, 17, 18, 19]
]
local_min_pos = find_local_minimum(grid)
print(f"Local minimum found at position: {local_min_pos} with value: {grid[local_min_pos[0]][local_min_pos[1]]}")