The post Another Project for 2024 appeared first on Red-Green-Code.
]]>Last week wrapped up the dynamic programming tutorial for this year. I hope you found it useful. Dynamic programming can be hard to grasp at first compared to other LeetCode topics. But once you internalize the steps to find a top-down solution, you may actually be happy when an interviewer asks you to solve a DP problem instead of something else. If dynamic programming still doesn’t make sense after going through the tutorial, this tip from last year may help you come up with a study plan.
Next, I’ll be doing something a bit different. Normally, I post every week about a project I’m working on. You can find projects from past years on the right side of the page, in the Getting Started section. I have decided to try out a more stealthy approach. Rather than the usual weekly post, I’ll be working on something in the background, so you won’t see new posts for a while. Let see how it goes, and I’ll report back. In the meantime, enjoy the archives, and good luck with your own projects this year.
(Image credit: DALL·E 3)
For an introduction to this year’s project, see A Project for 2024.
The post Another Project for 2024 appeared first on Red-Green-Code.
]]>The post Dynamic Programming Wrap-Up appeared first on Red-Green-Code.
]]>Over the past four months, we covered the key ideas in dynamic programming and solved a few practice problems. Let’s review.
Dynamic programming, unlike most other LeetCode topics, is a process for designing algorithms rather than a single algorithm. That makes DP practice a good way to learn the process of finding solutions, rather than just learning solutions.
If you can express the solution to a problem in terms of the solution to smaller subproblems, you may be able to use dynamic programming to solve it. DP is the right approach if the subproblems overlap, meaning the same subproblems appear repeatedly, and if they have optimal substructure, meaning you can use optimal solutions to subproblems to find an optimal solution to the original problem.
Overlapping subproblems and optimal substructure can seem like abstract concepts, so it helps to look at examples. We started with LeetCode 322: Coin Change. For that problem, we have an input amount and a set of coins. Each subproblem uses a smaller amount, which we calculate by subtracting one coin. The subproblems are overlapping because there are multiple ways to get the same amount using different combinations of coins. The subproblems have optimal substructure because if we know the optimal number of coins to make some amount $x$ and we make a larger amount $x+c$ using exactly one additional coin, then that number of coins is also optimal.
The final characteristic of dynamic programming solutions is the one that makes it time efficient. Since we know we’ll see the same subproblems repeatedly, we can store the result of each subproblem in a data structure the first time we see it, then retrieve it when we see it again. There are two ways to implement this technique: top-down and bottom-up. The top-down approach starts with the original problem and recursively calculates smaller problems until it reaches the base cases. The bottom-up approach starts with the base cases and builds larger solutions until it reaches the original problem.
Sometimes we don’t even need dynamic programming. For certain problems with optimal substructure, we can implement a more efficient solution using a greedy approach. If you discover that your dynamic programming solution only solves each subproblem once, and never returns to it, then there’s no need to store subproblem results. You can instead use an iterative solution that calculates each result in order until it arrives at the original problem. LeetCode 55: Jump Game can be solved using a dynamic programming approach, but there’s a more efficient greedy solution.
After you get some experience solving dynamic programming problems, you’re ready to learn about states and state transitions. A state is a set of parameters that uniquely identifies a subproblem, and a state transition defines how to get from one state to the next. Once you express these two ideas mathematically, it’s often a straightforward process to translate the mathematics into code.
LeetCode offers a lot of dynamic programming problems, so it’s easy to be overwhelmed with selection. A helpful resource is a curated list like the one at Tech Interview Handbook. All of the dynamic programming problems I used in this tutorial are from that list, including these remaining problems:
LeetCode 53: Maximum Subarray is a well-known problem that attracted attention from researchers for a few decades after it was proposed in the late 1970s. It asks us to find the maximum sum of a subarray. A related problem is finding the maximum product.
LeetCode 300: Longest Increasing Subsequence has a simple DP solution and a more complicated but more efficient solution involving a deck of cards.
We used LeetCode 1143: Longest Common Subsequence and LeetCode 416: Partition Equal Subset Sum to study multidimensional dynamic programming.
LeetCode 221: Maximal Square is a DP problem with a geometric flavor.
For LeetCode 62: Unique Paths, we see how dynamic programming can be used for counting. This problem asks us to count the number of ways for a robot to traverse a grid.
Another counting problem is LeetCode 70: Climbing Stairs, an easy problem that is often used to introduce dynamic programming concepts. We adapted its solution to solve a more challenging counting problem, LeetCode 91: Decode Ways.
This series covered the fundamental concepts of dynamic programming, using a set of practice problems to illustrate how they work. Next time you need to solve a DP problem, you’ll have a process you can use to get started. But as with any LeetCode topic, it takes more than just understanding concepts to solve problems quickly and accurately. I recommend solving the problems from this tutorial more than once over a period of time, and seeking out more problems from LeetCode’s extensive list. For tips on how to structure your practice, see my LeetCode series from last year.
(Image credit: DALL·E 3)
For an introduction to this year’s project, see A Project for 2024.
The post Dynamic Programming Wrap-Up appeared first on Red-Green-Code.
]]>The post LeetCode 91: Decode Ways appeared first on Red-Green-Code.
]]>Last week, we looked at a dynamic programming counting problem, Climbing Stairs. This week’s problem, LeetCode 91: Decode Ways, is also a counting problem. And although the problems may not seem very similar on the surface, we can adapt the Climbing Stairs solution to solve Decode Ways.
The input to the Decode Ways problem is a string where each character is a digit. This string of digits represents a message encoded using a simple 1-based mapping of letters to numbers: A
maps to 1
, B
maps to 2
, and so on through Z
and 26
. However, since there are no delimiters in the encoded message, there may be more than one way to decode a message. Our job is to return the number of ways that the input message can be decoded.
We can solve this problem using two key ideas:
At each position in the string, we have zero, one, or two options for decoding. If d1
is the current digit and d2
is the next digit, then:
If d1
is 0
, there is no possible decoding. The problem statement specifies that leading zeros aren’t allowed. So 1
decodes to A
, but 01
doesn’t decode to anything. If the current digit is 0
, this is an invalid position, and we’ll exit.
If d1 > 0
then we can decode d1
into a letter between A
(1) and I
(9), and move to the next character.
In addition to the previous case, if d1 > 0
and d1d2
represents a number between 10 and 26 (inclusive), we can decode the combined number into a letter between J
(10) and Z
(26), and move ahead two positions.
In the previous enumeration, if d1 > 0
, we have two options: 1) Decode the next digit into a single letter, or 2) Decode the next two digits into a single letter (if the two digits form a number in the appropriate range). If both cases apply at a position, our counting process splits into two paths: the path where we use a single digit, and the path where we use two digits.
This is where the solution starts to look similar to Climbing Stairs. In that problem, we could take either one or two steps from each position on the staircase. And we always have those two options. When we’re decoding the string, there are three options: decode zero, one, or two digits. Once we know which of those options applies, the rest of the implementation is just like Climbing Stairs.
As usual, dp[i]
represents the solution starting at position i
. So dp[0]
will be the number of ways to decode the entire string.
Look at the Climbing Stairs pseudocode to see how it compares with this solution.
input: string s
define an int array dp of the same length as s
return count(0, s)
int count(int i, string s)
// decoding is complete
if i is past the last character of s
return 1
d1 = digit at position i
// invalid decoding; leading 0's are not allowed
if d1 is 0
return 0
// only the single-digit decoding is possible here
if i is at the last character of s
return 1
// check the memo table
if dp[i] > 0
return dp[i]
// make the 2-digit number d1d2
d2 = digit at position i+1
num = d1*10+d2
// count the number of ways if we only decode one character
c = count(i+1, s)
// if applicable, count the number of ways if we decode two characters
if num <= 26
c += count(i+2, s)
// update memo table
dp[i] = c
return c
(Image credit: DALL·E 3)
For an introduction to this year’s project, see A Project for 2024.
The post LeetCode 91: Decode Ways appeared first on Red-Green-Code.
]]>The post LeetCode 70: Climbing Stairs appeared first on Red-Green-Code.
]]>For one subcategory of dynamic programming problems, the goal is to count the number of ways to do something. For example, in LeetCode 62: Unique Paths, we counted the number of ways a robot could move from the start position to the end position in a grid.
This week, we’ll tackle another counting problem, LeetCode 70: Climbing Stairs. It’s an Easy problem whose solution we’ll adapt to solve a Medium problem next week.
Here’s the Climbing Stairs problem:
You are climbing a staircase. It takes
n
steps to reach the top. At each position on the staircase, you can take one or two steps. How many ways are there to reach the top?
The examples explain that if n == 2
, there are two ways to reach the top: 1) Take one step followed by one step; 2) Take two steps. And if n == 3
, there are three ways to reach the top: 1) Take one step, then one step, then one step; 2) Take one step, then two steps; 3) Take two steps, then one step.
Suppose i
represents the current position on the staircase. i == 0
is the starting position at the bottom of the staircase (before the first step) and i == n
is the ending position at the top of the staircase (after the last step). So if n == 3
, it takes three steps to reach the top using the first pattern: climb from position 0 to 1, then from 1 to 2, then from 2 to 3.
Let’s extend the examples to establish more base cases. If n == 1
, that means we are standing in front of one step, and once we climb that one step, we’re done. There is only one way to do that. And n == 0
just means a floor with no staircase. For this case, it works best to say that there are 0 ways to climb a staircase with no stairs.
With our base cases defined, we can create our state and state transition. As with many DP counting problems, the key is to think about which actions can be taken at each position. From any stair on the staircase except the last one, we have two options: Take one step or take two steps. After we make that decision, we have the same two options again. So if $f(i)$ represents the number of ways to climb the staircase from step $i$, we can define $f(i)$ recursively as $f(i) = f(i+1) + f(i+2)$. In other words, the number of ways to reach the top from the current step is the number of ways to reach the top by first taking one step, combined with the number of ways to reach the top by first taking two steps.
To generalize our base case rule, we can return $n-i$ when $i \geq n-2$. This wraps up the recursion when we’re near the top of the staircase. And to complete a top-down dynamic programming implementation, we’ll memoize the return value from $f$ to avoid evaluating $f$ multiple times for the same $i$.
define an int array dp of size n
return count(0, n)
int count(int i, int n)
// base cases
if (i >= n-2)
return n-i
// check if we already calculated it
if dp[i] > 0
return dp[i]
// two options: one step or two steps
dp[i] = count(i+1, n) + count(i+2, n)
return dp[i]
Rahul Varma provides implementations using recursion, memoization (top-down), tabulation (bottom-up), and bottom-up with space optimization.
(Image credit: DALL·E 3)
For an introduction to this year’s project, see A Project for 2024.
The post LeetCode 70: Climbing Stairs appeared first on Red-Green-Code.
]]>The post LeetCode 221: Maximal Square appeared first on Red-Green-Code.
]]>Solutions to LeetCode problems of Medium or higher difficulty often require a key insight. Even once you understand the problem and the general technique required to solve it, there’s no way to implement an efficient solution without it. Fortunately, dynamic programming gives us heuristics that we can use to point towards the required idea. For LeetCode 221: Maximal Square, the key insight requires some geometric intuition. But once you figure that part out, the rest of the problem is straightforward.
The input for Maximal Square is a 2D char array, where each cell contains the character '0'
or the character '1'
(not the integers 0 and 1). The goal is to find the largest square containing only the character '1'
, and return its area.
Using our standard approach for dynamic programming problems, we can start with a dp
array of the same dimensions as the input array, where each dp[i][j]
stores the best solution at that position — maybe the area of the largest square with a corner at (i, j)
.
As usual with DP solutions, we need to find an approach where combining smaller subproblems gets us the solution to a larger subproblem, and eventually the original problem. For example, consider the area of the largest square with a top left corner at (0, 0)
. Since we have our memo table dp
, we can assume that we know the area of the largest nearby squares. How can we use that information?
To answer this question, we need to realize that:
If a cell is 0
, it can’t be the corner of a square.
If a cell is 1
, it might complete a square by filling in the last corner cell. More specifically, if the 1
cell is surrounded by squares whose sides are at least length s
, then we can use it to make a square with side length s+1
.
This implies that each element in our dp
array needs to store the length of a side, not the area of a square. If we fill our dp
array row by row from the top left to the bottom right of the input array, then we only need to check three adjacent cells: the cell to the right (East), the cell below (South), and the cell diagonally to the right and down (Southeast). The remaining adjacent cells will have already been checked in previous steps.
This can be tricky to visualize, so I recommend looking at the excellent diagrams by arkaung on LeetCode.
These two rules form the basis of our recursive function. If we want a function maxSide(i, j)
to return the side length of the largest square with its top left corner at (i, j)
, then:
If (i, j)
is '0'
, return 0, since (i, j)
can’t be part of a square.
Otherwise, return 1 + min(maxSide(i+1, j), maxSide(i+1, j+1), maxSide(i, j+1))
. This works because we can make a square side one larger than the smallest adjacent side. (See the References section below for a diagram illustrating that rule).
Once we add memoization to avoid repeatedly calculating the length at the same position, we’re done with the maxSide
function.
In the main function, we’ll call maxSide
for each position in the input matrix, keeping track of the maximum side length. That will give us the best top left corner starting position. Then we can return maxSide * maxSide
to calculate the area of the square with that corner.
matrix = input char array of size m x n containing '0' and '1' values
dp = int array of size m x n
max = 0
for i from 0 to m-1
for j from 0 to n-1
max = max(max, maxSide(i, j))
return max * max
function maxSide(int i, int j)
if i or j are out of bounds, return 0
if matrix[i][j] == '0', return 0
if dp[i][j] > 0
return dp[i][j]
dp[i][j] = 1 + min(maxSide(i+1, j), maxSide(i, j+1), maxSide(i+1, j+1))
return dp[i][j]
Optimization 1: As usual, since we only need information about adjacent cells, we can save space by only storing the adjacent row and column rather than the full dp
array.
Optimization 2: To avoid the overhead of recursive function calls, we can use a bottom-up approach. If we do that, the adjacent cells we’ll look at will be above, left, and above-left compared to the current cell. We’ll use these cells because, in the bottom-up approach, we need to look back at dp
values that we have previously calculated.
For a few bottom-up implementations, see Jianchao Li solution on LeetCode.
Here’s another good visual explanation of the key step, showing why we need to use the minimum side length.
(Image credit: DALL·E 3)
For an introduction to this year’s project, see A Project for 2024.
The post LeetCode 221: Maximal Square appeared first on Red-Green-Code.
]]>The post Using Dynamic Programming for Maximum Product Subarray appeared first on Red-Green-Code.
]]>Earlier this year, we solved LeetCode 53: Maximum Subarray, which asked us to find the sum of the subarray (contiguous non-empty sequence of elements) with the largest sum. This week, we’ll look at a related problem that asks for the largest product.
For the maximum sum subarray problem, the maximum sum at position i
in nums
is:
i-1
plus the input value nums[i]
, ornums[i]
itself.At each position, we pick the larger of those two options. Once we get to the end of the input array, the solution is the maximum value across all positions.
We can use a similar approach for the product version of the problem, with some additional complexity because we’re calculating products rather than sums. For sums, a positive element increases the subarray sum, a zero element doesn’t change it, and a negative element decreases it. For products, a positive element increases the subarray product, a zero element permanently drops it to 0
, and a negative element increases/decreases it and flips the sign (which could make the result larger or smaller).
For Maximum Sum Subarray, we used one dp
array to store the maximum sum at each position. For Maximum Product Subarray, we’ll use two: dpMax
to store the maximum products, and dpMin
to store the minimum products. This is useful because if nums[i]
is negative, we need to check if we get a maximum positive result when we multiply it with a negative product.
So while we had only two values to evaluate at each position with the sum problem, the product problem requires checking three values:
num = nums[i]
.i-1
times num.i-1
times num.At each position, we update dpMax
with the largest of these three values, and dpMin
with the smallest of the three. At the end, the solution is the largest of all the dpMax
values.
As with Maximum Sum Subarray, we can see from this summary that we only need the current max/min product and the previous max/min product. So we can use $O(1)$ space if we do away with the dp
arrays and just store those values. I’ll show the array solution here.
We know that dynamic programming improves the efficiency of a solution by calculating and storing results to be used later. Since we’re looking for the maximum product in this problem, an obvious idea is to use a dp
array where dp[i]
is the maximum product at position i
. But that doesn’t work when the input array contains negative numbers. The solution is to use a second array to store minimum products. We also need to recognize that a minimum product can become a maximum product because of the arithmetic rule of signs (a negative number times a negative number is a positive number).
When we’re designing a dynamic programming solution, we have to look for useful intermediate results to store, and think about how we can use these results to solve the problem more efficiently. Sometimes this will lead to a multidimensional approach, but for this problem we just need two 1D arrays (or a few scalar variables).
n = length of nums
dpMax = array of size n
dpMin = array of size n
max = dpMax[0] = dpMin[0] = nums[0]
for i from 1 to n-1
num = nums[i]
tryMax = dpMax[i-1] * num
tryMin = dpMin[i-1] * num
dpMax[i] = max(num, tryMax, tryMin)
dpMin[i] = min(num, tryMax, tryMin)
max = max(max, dpMax[i])
return max
For the $O(1)$ space approach, see chase1991’s solution. For a prefix product/suffix product approach, see my previous post on this problem, based on lee215’s solution on LeetCode.
(Image credit: DALL·E 3)
For an introduction to this year’s project, see A Project for 2024.
The post Using Dynamic Programming for Maximum Product Subarray appeared first on Red-Green-Code.
]]>The post LeetCode 62: Unique Paths appeared first on Red-Green-Code.
]]>Many LeetCode problems involve moving around a maze or grid. Mazes tend to be modeled as graphs, but for some problems of this type, dynamic programming is the right approach. We’ll see that in this week’s problem, LeetCode 62: Unique Paths.
Unique Paths takes only the two integers m
and n
as input, representing the dimensions of a grid with m
rows and n
columns. The goal is to calculate the number of paths that a robot could take from the top-left, at position (0, 0)
to the bottom-right, at position (m-1, n-1)
. The robot can only move down and right.
Two things to keep in mind:
This is not a maze problem, since there are no walls other than the edges of the grid. Every position is open.
We don’t need to store the paths from start to finish, or find the shortest path from start to finish. The goal is to count the paths. Counting is a common application of dynamic programming. For DP counting problems, the memo table contains counts.
Two implementation decisions we need to make for this problem: 1) Should we move from Start to Finish, or from Finish to Start, and; 2) Should we implement a Top-Down solution or a Bottom-Up solution?
Any combination of these two approaches will work. I’ll describe the one that I found most intuitive, which is 1) Finish to Start, and 2) Bottom-Up.
If m == 1
or n == 1
, then there’s only one path: directly from Start to Finish, either down to the finish or right to the finish. So the path count is 1
in this case.
For grids with m > 1
and n > 1
, consider the four cells at the bottom right corner. The goal is to get to the Finish position at cell F
:
-----
|C|D|
-----
|E|F|
-----
From cell C
, we have two options: move right to D
, or down to E
.
Cell D
or E
give us only one choice each: down to the finish from D
, or right to the finish from E
. So there are two unique paths from C
: right-down
and down-right
.
Generalizing this idea: If this four-cell block is placed somewhere in the grid, the number of unique paths to the finish from C
is the sum of the number of unique paths to the finish from D
plus the number of unique paths to the finish from E
. (We can’t get to F
in one move, since we can’t move diagonally).
To implement this idea with a bottom-up approach, we’ll need to handle a few special cases:
If C
is in the last column, then D
is past the right edge of the grid, so it contributes 0
paths.
If C
is in the last row, then E
is past the bottom edge of the grid, so it contributes 0
paths.
If C
is in the last column and the last row, then we’re at the Finish cell. To avoid getting a sum of 0 + 0 = 0
in this case, we’ll need to initialize a cell to 1
.
The simplest way to handle these special cases is to store counts in a 2D array of size m+1 x n+1
, with the extra row and column used to avoid the need for boundary checks. We’ll initialize the array to 0
, except for one cell initialized to 1
, the out-of-bounds cell either to the right of or below the finish cell. We could use less space if we didn’t store the whole grid, since we only need adjacent cells when calculating the count for a cell. To keep the code short, I’ll go with this simpler approach.
When we solved Longest Common Subsequence, we looped from right to left and bottom to top. If we do the same for this problem and store counts in an array dp
, then dp[row][col]
stores the number of unique paths from (row, col)
to (m-1, n-1)
, and our solution will be in dp[0][0]
when we finish.
if m == 1 or n == 1
return 1 // there's only one path in a 1xn or mx1 grid
create array dp of size [m+1][n+1]
dp[m][n-1] = 1 // initialize out-of-bounds cell to start the counting
for row from m-1 to 0
for col from n-1 to 0
// two options: move right or move down
dp[row][col] = dp[row+1][col] + dp[row][col+1]
// number of paths from start position
return dp[0][0]
For multiple implementations, including a fancy math solution, see archit91’s editorial on LeetCode.
(Image credit: DALL·E 3)
For an introduction to this year’s project, see A Project for 2024.
The post LeetCode 62: Unique Paths appeared first on Red-Green-Code.
]]>The post LeetCode 416: Partition Equal Subset Sum appeared first on Red-Green-Code.
]]>LeetCode 416: Partition Equal Subset Sum gives us another chance to practice multidimensional dynamic programming. The problem asks:
Given an integer array
nums
, returntrue
if you can partition the array into two subsets A and B such that the sum of the elements in A equals the sum of the elements in B. Returnfalse
if this is not possible.
If you’re not sure how to solve a LeetCode problem, it’s useful to write a brute-force solution. It will help you understand the problem, and you can run it on small test cases as an initial correctness check. With dynamic programming problems, there’s the additional benefit of a step-by-step process to make your brute-force solution much faster. Let’s try that for Partition Equal Subset Sum.
The standard brute-force approach for DP problems uses a recursive design. Consider a recursive function IsEqual
that returns true if the input array can be partitioned given a starting position in the array and subset sums for A and B. We can use the following parameters:
nums
: the input array, of size n
.pos
: the current position in the input array.sumA
: the current sum of the elements in subset A.sumB
: the current sum of the elements in subset B.As a base case, if we’re past the end of the array, we can just directly compare sumA
and sumB
. So if pos == n
, we return true
if sumA == sumB
and false
otherwise.
If we have not reached the end of the input array, then we have two choices: We can add the current number to sumA
, or we can add it to sumB
. Then we move to the next position in the array. If adding the current number to sumA
eventually results in equal subset sums, or adding the current number to sumB
eventually results in equal subset sums, then we have a valid solution. If it is not possible to get equal subset sums regardless of which subset we add the current number to, then a solution is not possible from this state. Here’s the recursive call to implement that:
return
IsEqual(nums, pos+1, sumA + nums[pos], sumB) ||
IsEqual(nums, pos+1, sumA, sumB + nums[pos])
As usual with recursive solutions, the calls to IsEqual
form a tree. Each call creates two child nodes, the left child where we add the current element to sumA
and the right child where we add it to sumB
. If either of these calls returns true
, we have found a path through the tree that ends at a leaf node with sumA == sumB
.
The recursive solution will return the correct result for small test cases. But if we draw out the call tree, we’ll find many nodes with the same pos
, sumA
, and sumB
values. That means we’re making many unnecessary recursive calls. To avoid an explosion of recursive calls, we should store the result of each unique call in a memo table.
As currently designed, we would need a 3D array for our memo table, since we have three parameters. Let’s check if that is really necessary.
Let total
be the sum of all the input array elements. We want to find sumA == sumB
. We need to use all the elements, so sumA + sumB == total
. Combining those two equations gives us sumA = total/2
and sumB = total/2
. So we only need to keep track of one of the two sums, which we can compare to total/2
. This lets us reduce the dimensions of our memo table to 2D.
Also, the problem details tell us that each input integer is between 1 and 100 (inclusive). So once our sum value exceeds total/2
, there’s no way to get a solution, since the sum can never decrease. We can immediately return false
in this case.
Finally, if total
is odd, there’s no way for the sums of both subsets to be total/2
. So in this case we can return false
before we make any recursive calls.
With these optimizations, we can implement our memo table using a boolean array dp
with dimensions n+1
and total+1
to store each result once we have calculated it the first time. As an implementation detail, we’ll want a boolean data type that can store three values: null
, false
, and true
. A null
value means we haven’t calculated that result yet.
bool CanPartition(int[] nums)
n = length of nums
total = sum of all elements in nums
// we can't divide an odd integer into two equal halves
if total is odd, return false
initialize bool dp[n+1][total+1]
return IsEqual(0, 0, nums, dp)
bool IsEqual(pos, sum, nums, dp, n)
// sum is too large (and it can never decrease)
if sum > total/2
return false
// finished processing; check the result
if pos == n
return sum == total/2
// case 1: don't use the current number in the sum
if dp[pos+1][sum] == null
dp[pos+1][sum] = IsEqual(pos+1, sum, nums, dp, n)
// case 2: do use the current number in the sum
newSum = sum + nums[pos]
if dp[pos+1][newSum] == null
dp[pos+1][newSum] = IsEqual(pos+1, newSum, nums, dp, n)
// if either case returned true, we have a true result
return dp[pos+1][sum] || dp[pos+1][newSum]
For an epic whiteboard editorial for this problem, see George Chrysochoou’s post on LeetCode.
(Image credit: DALL·E 3)
For an introduction to this year’s project, see A Project for 2024.
The post LeetCode 416: Partition Equal Subset Sum appeared first on Red-Green-Code.
]]>The post LeetCode 1143: Longest Common Subsequence appeared first on Red-Green-Code.
]]>LeetCode 1143: Longest Common Subsequence asks:
Given two strings, return the length of their longest common subsequence.
As we saw when solving the Longest Increasing Subsequence problem, a subsequence is formed by selecting elements from the input, with the only restriction being that they remain in their original order. For the LCS problem, if our two input strings are text1
and text2
, we can make a common subsequence using these steps: Iterate through text1
in order. For each character, either select it or don’t select it. When we get to the end of text1
, we’ll have a subsequence made up of characters from text1
. Then iterate through text2
and look for those same characters, in the same order. If we can find them all, we have found a common subsequence. We need to find the longest such subsequence.
In the process just described, we decide at each character position whether to include the character in the subsequence, or not include it. Two choices at each position gives us $O(2^n)$ subsequences, which is not practical unless the string is tiny. But the fact that we make a decision at each position is a hint that we could use dynamic programming to create a more efficient solution.
While the Longest Increasing Subsequence problem involved only one input array, the two input strings in this problem are an sign that a 2D table may be required to store our results.
You’ll see at the end that the dynamic programming implementation for this problem is fairly straightforward. But it can take some thinking to understand how it works. Here are the key ideas.
In the problems we have seen so far, we often start by creating a dp
array with the same dimensions as the input array. For LCS, we have two input strings, text1
and text2
. Let the length of text1
be m
and the length of text2
be n
. We can’t just use a dp1
array of size m
and a dp2
array of size n
because the character selections are related: If we select a character from text1
, we have to select the same character in the same order from text2
to make our common subsequence. So our dp
table needs to be two-dimensional, with n+1
rows and m+1
columns (the +1
s are an implementation detail, as explained below). Think of each row i
as representing the character text1[i]
and each column j
as representing the character text2[j]
. Then we can use dp[i][j]
to store the length of the longest common subsequence starting at text1[i]
and text2[j]
.
To fill out a 2D table using a bottom-up approach, we need two nested loops. We’ll use the outer loop to iterate through the rows, which represent the characters of text1
, and the inner loop to iterate through the columns, which represent the characters of text2
.
If dp[i][j]
is the length of the LCS starting at text1[i]
and text2[j]
, then our solution will be in dp[0][0]
when the loops complete. But that means we can’t start our loops at the first characters of text1
and text2
. How would we know what to store in dp[0][0]
?
But if we start at the last characters of the input strings, it’s easy to figure out the base case by hand: If the last characters match, then we can write 1
to the dp
table, since the matching characters give us a LCS of size 1 starting at the last characters of both strings. If the last characters don’t match, we write 0
. So we’ll run our loops in reverse, from the last characters to the first characters.
Now we’re at the core of the solution. We’re iterating from right to left on each string, building our LCS one character at a time and building our dp
table row by row, starting at the bottom right and moving left and up. For each text1[i]
and text2[j]
, there are two possibilities:
If text1[i]
and text2[j]
match, we can extend our LCS by one character. This means its length is one more than the best solution starting at the next position in both strings, text1[i+1]
and text2[j+1]
. In our table, that means dp[i][j]
= dp[i+1][j+1] + 1
.
If text1[i]
and text2[j]
don’t match, then those positions don’t contribute to the LCS. For this case, the length of the LCS is the longest of either the LCS starting at text1[i+1]
and text2[j]
or the LCS starting at text1[i]
and text2[j+1]
. In our table, that means dp[i][j] = max(dp[i+1][j], dp[i][j+1])
.
You might have noticed that we look only one row and one column ahead in the dp
table. So, as often happens with dynamic programming, we don’t actually need the full table. To keep the implementation simple, I’m going to leave it as-is. But you can optimize memory usage by saving only the part of the table you’re currently working on.
let m = length of text1
let n = length of text2
let dp = int array of size (m+1) x (n+1)
for row from m-1 to 0
for col from n-1 to 0
if text1[row] == text2[col]
dp[row][col] = dp[row+1][col+1] + 1
else
dp[row][col] = max(dp[row+1][col], dp[row][col+1])
return dp[0][0]
For an implementation of the regular and memory-optimized versions, see votrubac’s solution on LeetCode.
(Image credit: DALL·E 3)
For an introduction to this year’s project, see A Project for 2024.
The post LeetCode 1143: Longest Common Subsequence appeared first on Red-Green-Code.
]]>The post Multidimensional Dynamic Programming appeared first on Red-Green-Code.
]]>As we have seen in previous weeks, a key step in analyzing a dynamic programming problem is selecting state variables that identify the subproblems to be solved. The solutions to these subproblems are stored in a table, which allows us to retrieve them efficiently when they come up again. So far, we have looked at problems with only one state variable. But the dynamic programming process also works when more than one state variable is required.
State variables are closely related to the table used to store subproblem solutions. We can think of them as indices into the table. So in last week’s problem, Longest Increasing Subsequence, we had a variable i
storing a position in the input array. And we had an array dp
where dp[i]
stored the length of the longest increasing subsequence ending at position i
.
When we select state variables, the goal is to select the minimum number of variables required to solve the problem. This has two advantages. In terms of storage resources, using one state variable means we only need $O(n)$ space to store the solution to every subproblem. If we designed our dp
table for Longest Increasing Subsequence to store the length of the longest increasing subsequence starting at position i
and ending at position j
, we would need a two-dimensional array, or $O(n^2)$ space.
The other advantage of fewer state variables is a simpler solution. That doesn’t mean a solution that’s easier to invent. A solution with two variable might be more obvious, just as it’s easier to come up with brute force solution than an $O(n \log n)$ or $O(n)$ solution. But an extra state variable requires more complexity to keep track of. As with any LeetCode problem, the best approach is often to implement a more complex but more obvious solution first, and then optimize it. So you may start with multiple variables and discover that you can remove some of them.
For the LIS problem, we only need to store one solution for each ending position. In Coin Change, the only state variable required is amount, even though the input includes both an amount and an array of coins. But there are other problems that require more than one variable to identify a state. The additional variables may be explicitly identified in the problem statement, or you may come across them as you analyze the problem. There’s no step-by-step process to find these other variables. You just have to solve more problems and see a variety of patterns.
Next week, we’ll look at a well-known dynamic programming problem whose solution requires two state variables.
(Image credit: DALL·E 3)
For an introduction to this year’s project, see A Project for 2024.
The post Multidimensional Dynamic Programming appeared first on Red-Green-Code.
]]>