Dynamic Programming

From bibbleWiki
Jump to navigation Jump to search

Introduction

Gosh hated doing this with LeetCode Patterns so this will not be fun.

Memorization

Not sure why this is referred to as this. You use memorization but you also us recursion

Fibonacci

So here is the almost famous Fibonacci code with recursion. The main thing is to make sure you cater for the base case.

const fib = (n) => {
 // First two numbers in Fibonacci are always 1
 if(n <= 2) return 1
 // Then the next value is the sum of the previous 2
 return fib(n-=1) + fib(n-2)
}

This takes forever when you use say 50 because of the stack. When we look at big O it is O(2ⁿ) time. Where n is 50 this works out to be 1.12e+15. To this faster we can draw it out and look for patterns and it some becomes clear we are doing things repeatably. A visualization makes this obvious

So to improve using memoiization by doing

const fib = (n, memo = {} ) => { 
 // Check if we have done this and just return the value
 if(n in memo) return memo[n]

 // First two numbers in Fibonacci are always 1
 if(n <= 2) return 1

 // Then the next value is the sum of the previous 2 and we store it
 memo[n] = fib(n-=1, memo) + fib(n-2, memo)

 // Now return
 return memo[n]

}

Caching this value means we bring the time complexity down to O(n). Greatly improving the speed.

GridTraveler

So here we are looking a grids where there maybe restrictions. e.g. cannot go up, cannot go back. In this example we can only go down or right.

Here is the decision tree with the options and co-ordinates.

Now the code. This using recursion and stops when either out of bounds or destination achieved.

const gridTraveler = (m,n) => {
 
 // Hit base case (1,1)
 if(m == 1 && n == 1) return 1

 // Out of bounds
 if(m == 0 || n == 0) return 0

 // We have posibbly two choices 
 // keep going until you hit a base case
 return gridTraveler(m -1, n) + gridTraveler(m, n -1)
}

console.log(gridTraveler(3,2))

The height of the tree is driven by the two number inputs (n and m). To move down the tree we need to have increase m and increase n. So the height (number of levels) n + m. Because we have two choices per node (mostly) this is a binary tree and therefore we have 2x2x2 n+m times. So the time complexity is O(2^(m + n)). The space complexity is the height to O(n+m). So now we can review the tree and spot the duplicates. I guess what was interesting was identifying 1,2 and a 2,1 have the same number of nodes so gridTraveler(1,2) = gridTraveler(2,1).

const gridTraveler = (m,n, cache = {}) => {

 // Create a unique key for the current state
 const key = m  + '_' + n
 // Create flipped key
 const flippedKey = n + '_' + m

 // Check cache
 if(key in cache) return cache[key]
 if(flippedKey in cache) return cache[flippedKey]

 // Hit base case (1,1)
 if(m == 1 && n == 1) return 1

 // Out of bounds
 if(m == 0 || n == 0) return 0

 // We have posibbly two choices 
 // keep going until you hit a base case
 cache[key] = gridTraveler(m -1, n, cache) + gridTraveler(m, n -1, cache)
 return cache[key]
}

console.log(gridTraveler(3,2))
console.log(gridTraveler(18,18))

To calculate the reduction we can see we only now roughly calculate for the height x width which is m x n.

gridTraveler Complexity Comparison
Version Time Complexity Space Complexity Notes Example: gridTraveler(18,18) Rough Speed
Brute Force O(2m+n) O(m + n) Exponential calls; deep recursion stack ~68.7 billion calls ❌ Likely crashes or hangs; >10 minutes or stack overflow
Memoized (no flipped key check) O(m × n) O(m × n) Stores both (m,n) and (n,m) separately 324 unique keys stored ✅ ~1–5 ms
Memoized (with flipped key check) O(m × n) O(m × n / 2) Avoids redundant computation; halves cache size ~162 unique keys stored ✅ ~0.5–3 ms

Memoization Recipe

Suggestion was that you

  • Make it work first
    • Visualize the problem as a tree
    • Implement the tree as recursion
    • Test it
  • Make it efficient
    • Add a memo object
    • Add base case to return memo values
    • Store return values into the memo

CanSum

This where we have a value and an array. We return true if we can sum e.g.

canSum(7, [5,3,4,7]) -> true
canSum(7, [5,2]) -> false

So we end up with a tree like this.

So this took a lifetime to understand. The parent has too possible outcomes either line 11, hits a return true at any point if canSum or none is found and false is returned (line 15)

const canSum = (targetSum, numbers) => {
  // Exit recursion if targetSum is 0
  if (targetSum === 0) return true;
  
  // Exit recursion if targetSum is negative
  if (targetSum < 0) return false;

  for (const num of numbers) {
    const remainder = targetSum - num;
    // If we found a valid path, we can return true
    if (canSum(remainder, numbers) === true) return true;
  }

  // After doing all of the possible combinations, we return false
  return false;
};

const result = canSum(7, [5, 3, 4, 7]);
console.log("Result = ", result);

Looking for the complexity we see the height of the tree is m. We look at the branch factor they are around 3 or the length of the array. So O(n^m) for time and O(m) space

So now we now look to how to memoize it. To does this we

  • Add a new base case to return memo if found
  • Identify key - in this case targetSum
  • Store targetSum/result for each return outside of base case
  • Add memo to each call

HowSum

Next howSum. I am on to it and starting to like the process. So here is the function. With howSum we return the combinations which make the targetSum.

const howSum = (targetSum : number, numbers: number[]): number[]

So to do this we need so return something for each call

const howSum = (targetSum : number, numbers: number[]): number[] | null => {
    if(targetSum === 0) return []
    if(targetSum < 0) return null
    
    for(let num of numbers) {
        const remainder = targetSum - num 

        // Call for the n number in the array
        const remainderResult = howSum(remainder, numbers)

        // Check we are not null
        if(remainderResult !== null) {
            // Return the originally array plus the current number
            return [...remainderResult, num]
        }
    }

    return null
}
console.log("Result is", howSum(7, [5,3,4,7]));

So now for the memoization

const howSum = (targetSum : number, numbers: number[], memo = {}): number[] | null => {
    if(memo[targetSum]) return memo[targetSum]
    if(targetSum === 0) return []
    if(targetSum < 0) return null

    for(let num of numbers) {
        const remainder = targetSum - num 

        // Call for the n number in the array
        const remainderResult = howSum(remainder, numbers)

        // Check we are not null
        if(remainderResult !== null) {
            // Return the originally array plus the current number
            memo[targetSum] = [...remainderResult, num]
            return memo[targetSum]
        }
    }

    memo[targetSum] = null
    return null
}

console.log("Result is", howSum(7, [5,3,4,7]));

BestSum

So next is bestSum. Same deal but we track the shortest array. I have implemented the memo to save space.

const bestSum = (targetSum : number, numbers: number[], memo: { [key: number]: number[] | null }): number[] | null => {
    if(memo[targetSum]) return memo[targetSum]
    if(targetSum == 0) return []
    if(targetSum < 0) return null

    let shortestCombination: number[] | null = null

    for(let num of numbers) {
        const remainder = targetSum - num 

        // Call for the n number in the array
        const remainderResult = bestSum(remainder, numbers, memo)

        // Check we are not null
        if(remainderResult !== null) {
            // Return the originally array plus the current number
            const combination = [...remainderResult, num]
            // Update the shortest result if necessary
            if(shortestCombination === null || combination.length < shortestCombination.length) {
                shortestCombination = combination
            }
        }
    }

    memo[targetSum] = shortestCombination
    return shortestCombination
}

console.log("Result is", bestSum(7, [5,3,4,7], {}));

Again we can memorize so we have

Summary of can, how and best

These are categorised as

  • canSum -> Decision Problem
  • howSum -> Combinatoric Problem
  • canSum -> Optimization Problem

CanConstruct

Next words. So this time we are given a word and a wordBank (array of words) and we need to determine if we can construct the word. So here is the tree.

So this was much the same a canSum. You just need to reduce the word until empty return true.

const canConstruct = (target: string, wordBank: string[], memo: { [key: string]: boolean }): boolean => {
    if (target === "") return true
    if (target in memo) return memo[target]

    for (const word of wordBank) {
        // JS provide indexOf
        if (target.indexOf(word) === 0) {
            const suffix = target.slice(word.length)
            if (canConstruct(suffix, wordBank, memo)) {
                memo[target] = true 
                return true
            }
        }
    }

    memo[target] = false
    return false;
}

console.log("Result is", canConstruct("abcdef", ["ab", "abc", "cd", "def", "abcd"], {}));

CountConstruct

Track the totalCount. When the target becomes empty the word has been constructed so return 1.

const countConstruct = (target: string, wordBank: string[], memo: { [key: string]: number }): number => {
    if (target === "") return 1
    if (target in memo) return memo[target]

    let totalCount = 0

    for (const word of wordBank) {
        if (target.indexOf(word) === 0) {
            const suffix = target.slice(word.length)
            const currentCount = countConstruct(suffix, wordBank, memo)
            totalCount = totalCount + currentCount
        }
    }

    memo[target] = totalCount
    return totalCount;
}

console.log("Result is", countConstruct("abcdef", ["ab", "abc", "cd", "def", "abcd", "ef"], {}));

AllConstruct

So the question is to return all valid combinations of valid arrays for a give word with a given word array.

const allConstruct = (target: string, wordBank: string[], memo: { [key: string]: string[][] }): string[][] 

There are two key things

a) when a branch does not match all the way to the bottom then it returns [] as target.indexof is not true
b) if the branch does match the array is assembled in reverse order.
const allConstruct = (target: string, wordBank: string[], memo: { [key: string]: string[][] }): string[][] => {
    if (target in memo) return memo[target]

    // No target return empty 2D array
    if (target === "") return [[]]

    // Initialize to empty 2D array, if target.indexOf(word) === 0 then [] is returned
    let result: string[][] = []

    // Iterate over each word (element) to the end
    for (const word of wordBank) {
        console.log("Doing word: %s", word)

        // If prefix not valid stop recursion
        if (target.indexOf(word) === 0) {

            // If prefix is valid, continue recursion
            const suffix = target.slice(word.length)

            // Call recursion with the suffix
            const suffixWays = allConstruct(suffix, wordBank, memo)

            // Build the array for each valid suffix way, prepend the current word
            const targetWays = suffixWays.map(way => [word, ...way])

            // Push the result
            result.push(...targetWays)
        }
    }

    // There is one array for each valid combination or empty array
    memo[target] = result
    return result;
}

console.log("Result is", allConstruct("abcdef", ["ab", "abc", "cd", "def", "abcd", "ef"], {}));

Tabulation

Fib

Not really on board with this yes but there goes. For Fibonacci the approach was

  • Create an array with n+1
  • Initialize the array to zero
  • Initialize first two to correct values 0,1 (seed)
  • Iterate over the who array, adding value of i to elements, i+1, and i+2
  • Return position n
const fib = (n) => {
    const data = Array(n+1).fill(0)
    data[1] = 1
    for (let i = 0; i < n; i++) {
        data[i+1] = data[i+1] + data[i]
        data[i+2] = data[i+2] + data[i]
    }
    return data[n]
}

console.log("Value:", fib(1))
console.log("Value:", fib(2))
console.log("Value:", fib(3))
console.log("Value:", fib(4))
console.log("Value:", fib(5))
console.log("Value:", fib(6))

Grid Traveler

So starting to understand. I need to think about the base cases and relate the choices for current position, in this case the choice are right and down.

const gridTraveler = (m,n) => {
    const rows = m +1
    const cols = n +1
    // Initialize m+1 x n+1 table
    const table = Array(rows).fill(0).map(() => Array(cols).fill(0))
    // We know Base Cases
    // at 0,0 we the is 0 ways to travel and 
    // at 1,1 there is 1 way to travel
    table[1][1] = 1
    // In the recursion code the next recursion consisted of
    // move down 1 and moving right 1
    // Let iterate over our table adding the current position to
    // a) column below b) row + 1
    for(let row =0; row < rows; row++) {
        for(let col=0; col < cols; col++) {

            if(col+1 < cols) {
                const newRow = table[row][col+1] + table[row][col]
                console.log("updating column+1 at %d,%d with value %d + %d =  %d", row, col+1, table[row][col+1] , table[row][col], newRow)
                table[row][col+1] += table[row][col]
            }

            if(row+1 < rows) {
                const newCol = table[row+1][col] + table[row][col]
                console.log("updating row+1 at %d,%d with value %d + %d =  %d", row+1, col, table[row+1][col] , table[row][col], newCol)
                table[row+1][col] += table[row][col]
            }
        }

    }
    console.log(table)
    return table[m][n]
}

console.log("Answer: ", gridTraveler(18,18));

Tabulation Recipe

  • visualize the problem as a table
  • size the table based on the inputs
  • initialize the table with default values, e.g. 0, or false
  • seed the trivial answers into the table
  • iterate through the table
  • fill further positions based on the current position

CanSum

Gosh this was tricky to get my head around. Why it works as the robot says. We are basically applying every number in the array to each possible answer. So if we had 7 target sum then the worst would be [1], i.e. 1+1+1+1+1+1+1+1+1

const tab_canSum = (targetSum,numbers) => {    
    const rows = targetSum+1
    const table = Array(rows).fill(false)

    // Base cases
    // targetSum = 0 == true
    table[0] = true

    for(let col=0; col < targetSum+1; col++) {
        
        // We only update if the current position is true
        if(table[col] === true) {
            // Iterate over numbers array
            for(let num of numbers) {
                // If inbounds update to true
                if(col+num <  targetSum+1) table[col+num] = true;
            }
        }
    }

    return table[targetSum]

}

console.log("Can Sum =", tab_canSum(7,[5,3,4,7]))
console.log("Can Sum =", tab_canSum(7,[5,3]))

HowSum

Still struggled to do this the first time through. Now I understand. Given the question is

tab_howSum(7, [5, 3, 4, 7]))

It works because you the program is updating the value in the appropriate slot using the number it has just read. Gosh bet I cannot understand this next time. We start with an empty array 8x1

0 1 2 3 4 5 6 7
[] null null null null null null null

First time through we add the values from the array in there appropriate slot and this gives

0 1 2 3 4 5 6 7
[] null null [3] [4] [5] null [7]

Notice each slot contains same number as the index. What happens from here is we iterate over the slots knowing the current slot always adds up to it's index.

Second time Slot 1, is null nothing done Third time Slot 2, is null nothing done Now we are on slot 3. 3 does have contain an array and it adds up to 3. We take each of our array numbers and combine it with the current slot. We do this because we want to create an index to move this to. We only put arrays in a slot where the numbers in the array add up to the slot index. We overwrite it there is already something in the slot.

0 1 2 3 4 5 6 7
[] null null [3] [4] [5] [3,3] [4,3]

Notice each array adds up to its index.

const tab_howSum = (targetSum, numbers) => {
  // Create table
  const table = Array(targetSum+1).fill(null);

  // targetSum = 0 == true
  table[0] = [];
  
  // iterate of possible answers
  for (let i = 0; i < targetSum + 1; i++) {

    if (table[i] !== null) {
      for (let num of numbers) {
        if(i + num <= targetSum) table[i + num] = [...table[i], num];
      }

    }
  }
  return table[targetSum];
};

console.log("Can Sum =", tab_howSum(7, [5, 3, 4, 7]));

BestSum

This is becoming trivual but the main thing is we track the smallest for each possible number when updating

const tab_bestSum = (targetSum, numbers) => {
  // Create table
  const table = Array(targetSum + 1).fill(null);

  // targetSum = 0 == true
  table[0] = [];
  let shortestCombination = null;

  // iterate of possible answers
  for (let i = 0; i < targetSum + 1; i++) {
    if (table[i] !== null) {
      for (let num of numbers) {
        if (i + num <= targetSum) {
          // Only update if the current length is greater
          const current_length = table[i + num] === null ? targetSum : table[i + num].length;
          const new_length = [...table[i], num].length;
          if (current_length > new_length) {
            table[i + num] = [...table[i], num];
          }
        }
      }
    }
  }
  return table[targetSum];
};

console.log("Best Sum =", tab_bestSum(7, [5, 3, 4, 7]));
console.log("Best Sum =", tab_bestSum(8, [1, 4, 5]));

CanConstruct

First one I have done myself. Must be getting better at this.

const tab_canConstruct = (targetWord, wordBank) => {
  const rows = targetWord.length + 1;
  const table = Array(rows).fill(false);

  // Base cases
  // targetSum = 0 == true
  table[0] = true;

  for (let col = 0; col < targetWord.length + 1; col++) {

    if (table[col] === true) {

      // Iterate over words array
      for (let word of wordBank) {

        // Get the prefix
        const current_index = targetWord.slice(col).indexOf(word);

        // If we are a prefix
        if (current_index === 0) {
            console.log("Current slot:%d, setting slot %d to true for word %s",col, col + word.length,word);
            table[col + word.length] = true;
          // }
        }
      }
    } 
  }
  return table[targetWord.length];
};
console.log("Can Construct =", tab_canConstruct("abcdef", ["ab", "abc", "cd", "def", "abcd"]));

CountConstruct

So here we are with count construct. Did this all myself again. Starting to feel chuffed.

const tab_countConstruct = (targetWord, wordBank) => {
  const rows = targetWord.length + 1;
  const table = Array(rows).fill(0);

  // Base cases
  table[0] = 1;

  for (let col = 0; col < targetWord.length + 1; col++) {

    if (table[col] > 0) {

      // Iterate over words array
      for (let word of wordBank) {

        // Get the prefix
        const current_index = targetWord.slice(col).indexOf(word);

        // If we are a prefix
        if (current_index === 0) {
            console.log("Current slot:%d, setting slot %d to true for word %s",col, col + word.length, word);
            table[col + word.length] = table[col]  + 1
          // }
        }
      }
    } 
  }
  return table[targetWord.length];
};

console.log("Can Construct =", tab_countConstruct("abcdef", ["ab", "abc", "cd", "def", "abcd"]));

AllConstruct

Some sloppy mistakes mostly around the questions but all can right in the end.

const tab_allConstruct = (targetWord, wordBank) => {
  const rows = targetWord.length + 1;
  const table = Array(rows)
    .fill([])
    .map(() => []);

  // Base cases
  table[0] = [[]];

  // console.log(table);

  for (let col = 0; col < targetWord.length + 1; col++) {
      // Iterate over words array
      for (let word of wordBank) {
        // Get the prefix
        const current_index = targetWord.slice(col).indexOf(word);

        // If we are a prefix
        if (current_index === 0) {
            const newCombinations = table[col].map((curr)=> [...curr, word])
            table[col + word.length].push(...newCombinations)
        }
      }
  }
  return table[targetWord.length];
};

console.log("All Construct =",tab_allConstruct("purple", ["purp", "p", "ur", "le", "pupl"]));
console.log("All Construct =",tab_allConstruct("abcdef", ["ab", "abc", "cd", "def", "abcd","ef", "c"]));

// ABC
// [A, AB, C]

// 0 [[A], [AB]]
// 1 [[A], [AB]]
// 2 [[A, C], [AB, C]]

Iain's Final Thoughts

These questions were from freeCodeCamp on Dynamic Programming. Hist final thoughts were

  • notice any overlapping subproblems (the same problem in maybe a different branch)
  • decide what is the trivially smallest input
  • think recursively to use memoization
  • think iteratively to use tabulation
  • draw a strategy first