Difficulty: Medium, Asked-in: Google, Amazon, Intel, Morgan Stanley, LinkedIn
Key takeaway: An excellent problem to learn problem-solving using dynamic programming and application of the Fibonacci series. One can find a lot of similar DP problems asked during the coding interview.
There is a staircase of n steps and you can climb either 1 or 2 steps at a time. We need to count and return total number of unique ways to reach the n'th stair. The order of steps matters.
Input: n = 3, Output: 3
Explanation: There are 3 unique ways of climbing the staircase: [1,1,1], [2,1] and [1,2]
Input: n = 5, Output: 8
Explanation: There are 8 unique ways of climbing the staircase: [1,1,1,1,1], [1,1,1,2], [1,1,2,1], [1,2,1,1], [2,1,1,1], [1,2,2], [2,1,2], [2,2,1]
This is a counting problem where we need to count all unique ways to reach the nth stair. The critical question is: how do we count all possibilities?
We have two different choices at the start: we can either climb the 1st stair or the 2nd stair because we can only jump 1 or 2 steps at a time.
We can also think of it with another analogy: we can reach the nth stair from the (n - 1)th stair and the (n - 2)th stair by taking 1 and 2 steps, respectively. So the total number of ways to reach the nth stair is equal to the total number of ways to reach the (n - 1)th stair plus the total number of ways to reach the (n - 2)th stair => climbingStairs(n) = climbingStairs(n-1) + climbingStairs(n-2).
int climbingStairs(int i, int n)
{
if (i > n)
return 0;
if (i == n)
return 1;
return climbingStairs(i + 1, n) + climbingStairs(i + 2, n);
}
int countClimbStairs(int n)
{
return climbingStairs(0, n);
}
def climbingStairs(i, n):
if i > n:
return 0
if i == n:
return 1
return climbingStairs(i + 1, n) + climbingStairs(i + 2, n)
def countClimbStairs(n):
return climbingStairs(0, n)
Input size of the original problem = n. Input size of the 1st subproblem = n - 1. Input size of the 2nd subproblem = n - 2. So, we write the recurrence relation of the time complexity in terms of the input size, i.e., T(n) = T(n - 1) + T(n - 2), where T(1) = 1 and T(2) = 2.
The above recurrence relation is similar to the recurrence relation of the Fibonacci sequence. So time complexity is O(2^n). Think! There is another rough analogy: we have two choices with each stair (excluding the 0th and nth), i.e., either we include it in the solution or exclude it. The total number of possibilities is approximately 2^n, so the time complexity is O(2^n).
But why is the time complexity in exponential order? We can visualize it better using a recursion tree diagram, where we are solving the same subproblem again and again during recursion. Here is a basic example for n = 4.
Space complexity = O(n), The depth of recursion tree can go up to n and recursion will allocate call stack of size O(n). Think!
Since overlapping subproblems are present in this scenario, we can optimize the solution using dynamic programming. Here we solve each subproblem once, store the results in extra memory, and retrieve their results in O(1) instead of calculating them again. We need to take care of the following steps to solve the problem using a bottom-up approach:
Iterative structure to fill the table: Now we define an iterative structure to fill the table or build the solution in a bottom-up manner. We can get this idea from the recursive structure of the recursive solution. Here is an insight: Recursion is coming top-down and solving the larger problem via the solution of smaller subproblems. In a similar but reverse fashion, we need to go bottom-up and combine smaller problems to build the solution for the larger problem.
The total number of ways to reach the ith stair = The total number of ways to reach the (i - 1)th stair + The total number of ways to reach the (i - 2)th stair => stair[i] = stair[i-1] + stair[i-2]
int climbingStairs(int n)
{
if (n == 0)
return 0;
int stair[n + 1];
stair[1] = 1;
stair[2] = 2;
for (int i = 3; i <= n; i = i + 1)
stair[i] = stair[i - 1] + stair[i - 2];
return stair[n];
}
def climbingStairs(n):
if n == 0:
return 0
stair = [0] * (n + 1)
stair[1] = 1
stair[2] = 2
for i in range(3, n + 1):
stair[i] = stair[i - 1] + stair[i - 2]
return stair[n]
We are running a single loop and doing constant operations at each step of the iteration, so the time complexity is O(n). The space complexity is also O(n) because we are using extra space of size n+1.
If we observe the above approaches, the recursive and iterative structures are similar to the expression of the Fibonacci series. So rather than using recursion or using extra memory to store the solution of subproblems, we can think to apply the in-place solution to find the nth number of the Fibonacci series, where 1 and 2 are the first and second terms respectively.
fib(n) = fib(n-1) + fib(n-2), where fib(1) = 1 and fib(2) = 2.
int climbingStairs(int n)
{
if (n == 0 || n == 1)
return n;
int firstFib = 1;
int secondFib = 2;
for (int i = 3; i <= n; i = i + 1)
{
int nextFib = firstFib + secondFib;
firstFib = secondFib;
secondFib = nextFib;
}
return secondFib;
}
def climbingStairs(n):
if n == 0 or n == 1:
return n
firstFib = 1
secondFib = 2
for i in range(3, n + 1):
nextFib = firstFib + secondFib
firstFib = secondFib
secondFib = nextFib
return secondFib
We are just running a single loop and doing constant operations at each step of the iteration, so the time complexity is O(n). The space complexity is O(1) because we are only using a constant number of variables.
This is an efficient method to find the nth Fibonacci number, which uses this idea: if we multiply the matrix M = [[1,1],[1,0]] n times or calculate power(M, n), then we get the (n+1)th Fibonacci number as the element at the 0th row and 0th column, i.e., at the position (0, 0) in the final matrix.
We can apply the above method because the recurrence relation of this problem is similar to the Fibonacci sequence. The only change we need to do is to modify the base case to 1 and 2 instead of 0 and 1 in the Fibonacci series.
So, we can use the same initial matrix M and return the element at index (0,0) of the matrix M^n i.e. the (n+1)th Fibonacci number. We can do this because the nth term of the recurrence relation is equal to the (n+1) th Fibonacci number (Base cases are the 2nd and 3rd terms of the normal Fibonacci series).
int countWaysToClimbStairs(int n)
{
int M[][] = [[1, 1], [1, 0]]
int finalMatrix[][] = power(M, n)
return finalMatrix[0][0]
}
int[][] power(int X[][], int n)
{
int res[][] = [[1, 0], [0, 1]]
while (n > 0)
{
if (n % 2 == 1)
res = multiply(res, X)
X = multiply(X, X)
n = n/2
}
return res
}
int[][] multiply(int A[][], int B[][])
{
int C[][] = new int[2][2]
for (int i = 0; i < 2; i = i + 1)
{
for (int j = 0; j < 2; j = j + 1)
C[i][j] = A[i][0] * B[0][j] + A[i][1] * B[1][j]
}
return C
}
vector<vector<int>> multiply(vector<vector<int>> A, vector<vector<int>> B)
{
vector<vector<int>> C(2, vector<int>(2));
for (int i = 0; i < 2; i = i + 1)
{
for (int j = 0; j < 2; j = j + 1)
C[i][j] = A[i][0] * B[0][j] + A[i][1] * B[1][j];
}
return C;
}
vector<vector<int>> power(vector<vector<int>> X, int n)
{
vector<vector<int>> res = {{1, 0}, {0, 1}};
while (n > 0)
{
if (n % 2 == 1)
res = multiply(res, X);
X = multiply(X, X);
n = n / 2;
}
return res;
}
int countWaysToClimbStairs(int n)
{
vector<vector<int>> M = {{1, 1}, {1, 0}};
vector<vector<int>> finalMatrix = power(M, n);
return finalMatrix[0][0];
}
def multiply(A, B):
C = [[0, 0] for i in range(2)]
for i in range(2):
for j in range(2):
C[i][j] = A[i][0] * B[0][j] + A[i][1] * B[1][j]
return C
def power(X, n):
res = [[1, 0], [0, 1]]
while n > 0:
if n % 2 == 1:
res = multiply(res, X)
X = multiply(X, X)
n = n // 2
return res
def count_ways_to_climb_stairs(n):
M = [[1, 1], [1, 0]]
final_matrix = power(M, n)
return final_matrix[0][0]
If you have any queries/doubts/feedback, please write us at contact@enjoyalgorithms.com. Enjoy learning, Enjoy algorithms!