Skip to Content
Course content

237: Dynamic Programming Patterns in Java

Click on the "Edit" button in the top corner of the screen to edit your slide content.

A few years ago, I was mentoring a developer who was building a route-optimization tool for a logistics client. He had written a beautiful, clean recursive function to find the most cost-effective way to combine shipments. It worked perfectly during his local tests with three or four stops. But the moment we pushed it to a staging environment with real-world data—say, twenty stops—the application didn't just slow down; it effectively froze. He spent two days hunting for a memory leak, convinced the JVM was choking on object allocation. The reality was simpler: he was recalculating the same sub-problems millions of times over. His code was essentially trying to solve the same puzzle from scratch every time it encountered a familiar state.

That's the "wall" you hit when you need Dynamic Programming (DP). At its core, DP isn't some mystical mathematical art; it's just recursion with a memory. If you've already spent the CPU cycles to find the answer to a specific sub-problem, why on earth would you do it again? In Java, we handle this by trading a bit of memory (usually in the form of an array or a Map) to buy back a massive amount of time.

Trading Space for Time with Memoization

The most intuitive way to dive into DP is the "top-down" approach, which we call memoization. You keep your recursive structure—which is often easier to reason about—but you wrap it in a cache. Before your method does any heavy lifting, it checks if the answer is already in the cache. If it is, it returns it immediately. If not, it calculates the result and stores it before returning.

Take the 0/1 Knapsack problem: you have a bag with a weight limit, and a set of items with specific weights and values. You want to maximize the value. A naive recursive approach branches exponentially. But notice that you'll often hit the same state: "What is the max value I can get with 5kg remaining and 3 items left to consider?"

public class KnapsackMemo {
    private Integer[][] memo;

    public int solve(int[] weights, int[] values, int capacity, int n) {
        memo = new Integer[n + 1][capacity + 1];
        return helper(weights, values, capacity, n);
    }

    private int helper(int[] weights, int[] values, int capacity, int n) {
        if (n == 0 || capacity == 0) return 0;

        // Check the cache first
        if (memo[n][capacity] != null) return memo[n][capacity];

        if (weights[n - 1] > capacity) {
            return memo[n][capacity] = helper(weights, values, capacity, n - 1);
        } else {
            // Max of: excluding the item OR including the item
            int include = values[n - 1] + helper(weights, values, capacity - weights[n - 1], n - 1);
            int exclude = helper(weights, values, capacity, n - 1);
            return memo[n][capacity] = Math.max(include, exclude);
        }
    }
}

I prefer starting with this method because it preserves the logic of the problem. However, you're still paying the overhead of the call stack. For very deep problems, you'll hit a StackOverflowError, which is where the second pattern comes in.

Building the Solution from the Ground Up

The "bottom-up" approach, or tabulation, flips the script. Instead of starting with the big problem and breaking it down, you solve the smallest possible sub-problems first and use those results to build up to the final answer. This is almost always done using an iterative loop and a 2D array (a table).

In the Knapsack example, we build a table where the rows represent the items we've considered and the columns represent the increasing capacity of the bag. By the time we reach the bottom-right cell of the table, we have the answer for the full capacity and all items, and we never once had to make a recursive call.

public int solveTabulation(int[] weights, int[] values, int capacity) {
    int n = weights.length;
    int[][] dp = new int[n + 1][capacity + 1];

    for (int i = 1; i <= n; i++) {
        for (int w = 1; w <= capacity; w++) {
            if (weights[i - 1] <= w) {
                // Max of taking the item or leaving it
                dp[i][w] = Math.max(values[i - 1] + dp[i - 1][w - weights[i - 1]], dp[i - 1][w]);
            } else {
                dp[i][w] = dp[i - 1][w];
            }
        }
    }
    return dp[n][capacity];
}

You'll notice that the tabulation approach is generally faster and more memory-stable. One pro tip: if you look closely at the tabulation code, you'll see we only ever reference the previous row (i - 1). If you're really squeezed for memory, you can actually optimize this further by using a single-dimensional array and updating it backwards. It's a common trick in production code to reduce space complexity from O(N*W) to O(W).




📋 Practical Task

The Budget-Constrained Server Rack Optimizer

You are designing a system to maximize the "Compute Power" of a server rack. The rack has a maximum power draw limit (in Watts). You have a list of available server blades, each with a specific power consumption and a corresponding compute power rating. You cannot split a blade; you either install it or you don't.

Your Task: Implement a class RackOptimizer with a method getMaxComputePower(int maxPower, int[] powerDraws, int[] computeRatings). Use the bottom-up (tabulation) dynamic programming pattern to find the maximum compute power possible without exceeding the maxPower limit.

Requirements:

  • The solution must run in O(N * W) time, where N is the number of blades and W is the maxPower.
  • Do not use recursion to avoid potential stack overflow on large blade sets.
  • Handle edge cases where the powerDraws array might be empty or the maxPower is zero.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.