Difficulty: Medium; Asked in: Google, Amazon, Walmart, Adobe, eBay
Key takeaway: An excellent problem to learn performance optimization and problem-solving using dynamic programming. The efficient single-loop solution is intuitive and worth exploring.
Given an array of positive integers, write a program to reach the last index using the minimum number of jumps.
Important note: Before moving on to the solutions, we recommend trying this problem on paper for at least 15 or 30 minutes. Enjoy problem-solving!
Input: A[] = [1, 3, 5, 8, 10, 2, 6, 7, 6, 8, 9], Output: 3
Explanation: 1-> 3 -> 8 -> 9 or 1-> 3 -> 10 -> 9
Input: A[] = [2, 3, 1, 1, 4], Output: 2
Explanation: 2->3->4. The minimum number of jumps to reach the last index is 2. We jump 1 step from index 0 to 1, then 3 steps to the last index.
There can be multiple possible ways to reach the end, but our objective is to find the path with the minimum number of jumps. So one basic solution is to calculate the jump count for all possible ways to reach the end and return the minimum value among them. The critical question is: How can we explore all the possible ways to reach the end? Let's think!
When we need to explore all possible ways, we can apply the concept of recursion, where we solve the larger problem by using the solutions to smaller subproblems.
Here is an idea for a recursive solution: We start from the 0th index and recursively call for all the indices reachable from the 0th index. In other words, we can calculate the minimum number of jumps needed to reach the last index by considering the minimum number of jumps required to reach the last index from an index that is reachable from the 0th index. (Think about it!)
Recursive structure
Suppose our initial function call is minJumps(A[], start, end), where the end index of A[] is end, and the start index is start. From the start index, we can take a single jump of i number of steps, where 1 <= i <= A[start].
What would be the smaller subproblem if we take a single jump of i number of steps from the start? The answer is simple: Find the minimum number of jumps to reach the last index from the (start + i)th index, i.e., minJumps(A[], start + i, end).
To find the overall minimum jump count, we recursively calculate the jump count for all possible values of i (1 <= i <= A[start]) and find the minimum among them. We also add 1 because we take one jump to reach index i from the start index.
For all i reachable from start, i.e., i = 1 to A[start]:
minJumps(A[], start, end) = 1 + min(minJumps(A[], start + i, end))
Base case: This is a case of a single or zero-element array where no jump is required, i.e., if (start >= end), we return 0.
int minJump(int A[], int start, int end)
{
if (start >= end)
return 0;
int minJumpCount = INT_MAX;
for (int i = 1; i <= A[start] && i < end; i = i + 1)
{
int jumpCount = 1 + minJump(A, start + i, end);
if (jumpCount < minJumpCount)
minJumpCount = jumpCount;
}
return minJumpCount;
}
def minJump(A, start, end):
if start >= end:
return 0
minJumpCount = sys.maxsize
for i in range(1, A[start] + 1):
if i < end:
jumpCount = 1 + minJump(A, start + i, end)
if jumpCount < minJumpCount:
minJumpCount = jumpCount
return minJumpCount
We are recursively exploring all possible ways to reach the end. So there are two possibilities for each index from 1 to n - 1: Either we take a jump from that index or do not take a jump. So the total number of possible ways is 2^(n-1) or O(2^n), which is exponential.
We can also analyze it by writing a recurrence relation. Suppose the time complexity to reach the end in an n-size array is T(n). Now after taking i number of jumps at the start, the input size of the smaller sub-problem is n - i. So the time complexity of the input size n - i is T(n - i).
After this, we are recursively calling for all indexes reachable from the first index. So the maximum possible jump is equal to the value of A[start]. In the worst case, the value of this jump can be n - 1.
So here is the recurrence relation: T(n) = c + ∑(i = 1 to A[start]) T(n - i). If we put A[start] = n - 1, we get the upper bound: T(n) = c + ∑(i = 1 to n - 1) T(n - i). So term T(n) is the sum of the constant c and all previous terms T(n-1), T(n-2), ..., T(1).
Equation 1: T(n) = c + T(1) + T(2) + ... + T(n-1).
If we put n = n - 1 in the above equation, we will get:
Equation 2: T(n - 1) = c + T(1) + T(2) + ... + T(n-2)
If we subtract the equation 1 and 2, we will get =>T(n) - T(n - 1) = T(n - 1) => T(n) = 2T(n - 1). Now we have a simple recurrence relation and we can easily solve it using the substitution method.
T(n)
= 2*T(n - 1)
= 2^2*T(n - 2)
= 2^3*T(n - 3)
... and so on
= 2^(n - 1)*T(1)
= 2^(n - 1)
= O(2^n)
So finding the min jump is an exponential function of n, which is inefficient. In retrospect, this is not surprising because we are exploring all possibilities. If we create a recursion tree, we can notice overlapping subproblems. For example, if we observe the following picture, the sub-problem minJump(2, 5) is coming two times, minJump(3, 5) is coming three times, and so on.
We can use dynamic programming to solve this problem efficiently.
Since we have identified this as a dynamic programming problem, we can efficiently solve it using a bottom-up approach. Here our goal is to calculate the solution of smaller sub-problems iteratively and store their results in a table.
Iterative structure to fill the table: We can define an iterative structure to fill the table using the recursive solution. This approach of storing results in a table is similar to the longest-increasing subsequence problem.
jumps[i] = For (j = 0 to i - 1) min (jumps [j] + 1), where j + A[j] >= i.
int minJump(int A[], int n)
{
int jump[n];
jump[0] = 0;
for (int i = 1; i < n; i = i + 1)
jump[i] = INT_MAX;
for (int i = 1; i < n; i = i + 1)
{
for (int j = 0; j < i; j = i + 1)
{
if (i <= j + A[j] && jump[j] != INT_MAX)
jump[i] = min(jump[i], jump[j] + 1);
}
}
return jump[n - 1];
}
def minJump(A, n):
jump = [0] + [sys.maxsize] * (n - 1)
for i in range(1, n):
for j in range(i):
if i <= j + A[j] and jump[j] != sys.maxsize:
jump[i] = min(jump[i], jump[j] + 1)
return jump[n -1]
Time complexity = Time complexity of initializing jump[] array + Time complexity of nested loops to store values in jump[] array = O(n) + O(n^2) = O(n^2). Similarly, space complexity = O(n) for using n size jump[] array.
The above dynamic programming solution is much more efficient than brute force solution. But critical questions are: Can we further improve the time complexity, space complexity, or both? Can we try to remove inner loop of the above solution? Can we track the value of minimum jump using some variable?
Here is an improvement insight: From any jump point i, we can reach any index from (i + 1) to (i + A[i]), and there will be some value between range A [i + 1] to A [i + A[i]], which can provide the farthest reach from that range. So we define lower and upper end of the current jump point and calculate the farthest reach in that range. Whenever we reach the upper endpoint of a range, we update the upper end with the value of farthest reach and increment jump count. We continue this process till the value of farthest reachable index is greater than n - 1.
Let's define four variables to simulate the above process:
int minJump(int A[], int n)
{
if (n <= 1)
return 0;
int jump = 0, currStart = 0;
int currEnd = 0, currMaxReach = -1;
while (currStart < n - 1)
{
currMaxReach = max(currMaxReach, currStart + A[currStart]);
if (currMaxReach >= n - 1)
{
jump = jump + 1;
break;
}
if (currStart == currEnd)
{
jump = jump + 1;
currEnd = currMaxReach;
}
currStart = currStart + 1;
}
return jump;
}
def minJump(A, n):
if n <= 1:
return 0
jump = 0
currStart = 0
currEnd = 0
currMaxReach = -1
while currStart < n - 1:
currMaxReach = max(currMaxReach, currStart + A[currStart])
if currMaxReach >= n - 1:
jump = jump + 1
break
if currStart == currEnd:
jump = jump + 1
currEnd = currMaxReach
currStart = currStart + 1
return jump
In this approach, we define three variables: currMaxReach, stepsCount, and jump. We set both the jump and stepsCount variables to the value of the first index of the array. Here, currMaxReach is the maximum we can reach from that index, which is the index plus the value of the index (the jump value). So, we keep updating it in each iteration (starting from 1 to n - 2), so that whenever we move forward, the variable currMaxReach stores the max reach by using currMaxReach = max(currMaxReach, A[start] + start).
Also, at each iteration, we reduce our stepsCount variable by 1. As we move forward, we consume 1 step each time. So, whenever we run out of steps, it means we need to take one jump. So, we increase the jump variable and update the stepsCount variable to the value (currMaxReach - start), which is the maximum reach possible from the current index. This means that we can take those steps, and then we need to jump again.
So, we return jump + 1 as our output in this solution since we only jump after running out of steps. Also, we need to note that: we moved only till the second last element and not the last element since at the last step, we do not need to consume one more step as we are already there and no need to jump more.
int minJump(int A[], int n)
{
if (n <= 1)
return 0;
int currMaxReach = A[0];
int stepsCount = A[0];
int jump = 0;
for (int start = 1; start < n - 1; start = start + 1)
{
currMaxReach = max(currMaxReach, start + A[start]);
stepsCount = stepsCount - 1;
if (stepsCount == 0)
{
jump = jump + 1;
stepsCount = currMaxReach - start;
}
}
return jump + 1;
}
def minJump(A, n):
if n <= 1:
return 0
currMaxReach = A[0]
stepsCount = A[0]
jump = 0
for start in range(1, n - 1):
currMaxReach = max(currMaxReach, start + A[start])
stepsCount = stepsCount - 1
if stepsCount == 0:
jump = jump + 1
stepsCount = currMaxReach - start
return jump + 1
In both the single loop solution, we are doing constant operations at each iteration. So time complexity = O(n). We are using constant extra space, so space complexity = O(1)
If you have any queries or feedback, please write us at contact@enjoyalgorithms.com. Enjoy learning, Enjoy algorithms!