Optimisation: Dynamic Programming and Greedy

Table of Contents

Dynamic Programming

  1. Unique Paths

Expand Code
 1def uniquePaths(self, m: int, n: int) -> int:
 2  def grid():
 3    dp = [[0] * n for _ in range(m)]
 4    for i in range(m):
 5      dp[i][0] = 1
 6    for j in range(n):
 7      dp[0][j] = 1
 8    for i in range(1, m):
 9      for j in range(1, n):
10        dp[i][j] = dp[i-1][j] + dp[i][j-1]
11    return dp[-1][-1]
12  
13  def optimized():
14    # f(i,j) = f(i-1,j) + f(i,j-1)
15    # remove the first diemsion
16    # f(j) = f(j) + f(j-1) <- as long as f(j-1) is from the current iteration
17    # and f(j) is from the previous iteration
18    # so we need to fill this from left to right
19    dp = [1] * n
20    for i in range(1, m):
21      for j in range(1, n):
22        dp[j] = dp[j] + dp[j-1]
23    return dp[-1]
24  
25  return optimized()
26
27def uniquePathsWithObstacles(self, obstacleGrid: List[List[int]]) -> int:
28  # dp[i][j] = 0 if obstacle
29  # dp[i][j] = dp[i-1][j] + dp[i][j-1]
30  # we can remove first dimension as it only depends on i-1
31  # dp[j] = dp[j] (from previous row) + dp[j-1] (current row)
32  # we need to fill it from left to right
33  # initialization: if there is an obstacle in the first row,
34  # all other cells after that would be unreachable
35  m, n = len(obstacleGrid), len(obstacleGrid[0]) if len(obstacleGrid) > 0 else 0
36  dp = [0] * n
37  j = 0
38  while j < n and not obstacleGrid[0][j]:
39    dp[j] = 1
40    j += 1
41  for i in range(1, m):
42    # this step is very important
43    # we not only need to check for obstacles in the current cell, but also previous cell
44    dp[0] = 1 if not obstacleGrid[i][0] and dp[0] else 0
45    for j in range(1, n):
46      dp[j] = dp[j] + dp[j-1] if not obstacleGrid[i][j] else 0
47  return dp[-1]
  1. Longset Common Subsequence

Expand Code
 1def lcs():
 2  # say, we have sene i characters from text1 and j characters from text2
 3  # the lcs(i, j) contains the longest common subsequence.
 4  # if text1[i+1] == text2[j+1] then we increase lcs by 1
 5  # else lcs is the max of lcs(i, j), lcs(i+1, j) and lcs(i, j+1)
 6  # in the end, the final lcs is found at lcs(m, n)
 7  m, n = len(text1), len(text2)
 8  # dp table
 9  lcs = [[0] * (n+1) for _ in range(m+1)]
10  # for every lcs(i+1, j+1), we need lcs(i, j), lcs(i+1, j) and lcs(i, j+1)
11  # that is, previous row and previous column of the same row
12  # so we have to fill the array from left to right, top to bottom
13  for i in range(m):
14    for j in range(n):
15      if text1[i] == text2[j]:
16        lcs[i+1][j+1] = lcs[i][j] + 1
17      else:
18        lcs[i+1][j+1] = max(lcs[i+1][j], lcs[i][j+1])
19
20  return lcs[m][n]
21
22def lcs_memory_optimized():
23  # f(i,j) = length of lcs from s[0..i-1] and t[0...j-1]
24  # transition rule:
25  # f(i+1,j+1) = f(i,j) + 1 if s[i] = t[j]
26  # f(i+1,j+1) = max(f(i+1,j), f(i,j+1))
27  """ IMPORTANT THIS """
28  # cannot be reduced to one dimension
29  # f(j+1) = f(j) + 1 if s[i] == t[j] (need f(j) from previous iteration)
30  # f(j+1) = f(j) (current iteration) + f(j+1) (previous iteration)
31  # need to keep track of the previous row
32  m, n = len(s), len(t)
33  dp_prev, dp = [0] * (n + 1), [0] * (n + 1)
34  for i in range(m):
35    for j in range(n):
36      if s[i] == t[j]:
37        dp[j+1] = dp_prev[j] + 1
38      else:
39        dp[j+1] = max(dp[j], dp_prev[j+1])
40    dp_prev = dp[:]
41  return dp[-1]
42
43def construct(stack, indicators, i, j):
44  if i == 0 or j == 0:
45    return
46  if indicators[i][j] == 1:
47    construct(stack, indicators, i-1, j-1)
48    # need to keep this in mind that we only add characters in this case
49    stack.append(text1[i-1])
50  elif indicators[i][j] == 2:
51    construct(stack, indicators, i-1, j)
52  else:
53    construct(stack, indicators, i, j-1)
54
55def lcs_str() -> str:
56  # need to keep additional storage for longest common subsequence string
57  # just need to store an indicator:
58  # 0 means it came from same row, previous column
59  # 1 means it came from previous row, previous column
60  # 2 means it came from previous row, same column
61  # also need to keep track of the index??
62  m, n = len(text1), len(text2)
63  prev_row, curr_row = [0] * (n+1), [0] * (n+1)
64  indicators = [[0] * (n+1) for _ in range(m+1)]
65
66  for i in range(m):
67    for j in range(n):
68      if text1[i] == text2[j]:
69        curr_row[j+1] = prev_row[j] + 1
70        indicators[i+1][j+1] = 1
71      else:
72        if prev_row[j+1] < curr_row[j]:
73          curr_row[j+1] = curr_row[j]
74          indicators[i+1][j+1] = 0
75        else:
76          curr_row[j+1] = prev_row[j+1]
77          indicators[i+1][j+1] = 2
78
79    # copy current row to previous
80    for k in range(len(curr_row)):
81      prev_row[k] = curr_row[k]
82
83  stack = []
84  construct(stack, indicators, m, n)
85  return ''.join(stack)
  1. Longest Increasing Subsequence: [princeton.edu] Patience Sorting: Longest increasing subsequence

Expand Code
 1def use_patience_solitaire():
 2  # we take the numbers as cards and deal them one by one to create decks
 3  # (a) for a card, check the decks from left to right
 4  # (b) if the card is smaller than the card on that deck, it goes on top
 5  # (c) else we create a new deck with the card
 6  # Note:
 7  # (a) if we form the decks this way, the within a given deck, the numbers are
 8  #     arranged in decreasing order
 9  # (b) the top card of the decks would be in increasing order from left to right
10  # (c) the number of decks created this way is the length of longest increasing subsequence
11  decks = [nums[0]]
12  for num in nums[1:]:
13    if decks and decks[-1] < num:
14      decks.append(num)
15    else:
16      insert_index = bisect_left(decks, num)
17      decks[insert_index] = num
18  return len(decks)
19
20def use_dp():
21  # lis[i] is the longest increasing subsequence ending at nums[i]
22  # once I see nums[i+1], I need to consider all previous lis[k]
23  # instances k <= i, where nums[k] < nums[i+1]. The new number is
24  # able to create an lis of an increased length by 1
25  # base case: every lis[i] is at least 1
26  n = len(nums)
27  dp = [1] * n
28  max_len = 1
29  for i in range(1, n):
30    for k in range(i):
31      if nums[k] < nums[i]:
32        dp[i] = max(dp[i], dp[k] + 1)
33        max_len = max(max_len, dp[i])
34  return max_len
  1. Longest Palindromic Subsequence

Expand Code
 1def longestPalindromeSubseq(self, s: str) -> int:
 2  def lps(s):
 3    # f(i,j) = length of lps after seeing s[i+1...j-1]
 4    # need to compute f(i-1,j+1) after seeing s[i] and s[j]
 5    # transition rule:
 6    # f(i-1,j+1) = f(i,j) + 2 if s[i] == s[j]
 7    # f(i-1,j+1) = max(f(i-1,j), f(i,j+1)) otherwise
 8    # normalized equations
 9    # f(i,j) = f(i+1,j-1) + 2 if s[i] == s[j]
10    # f(i,j) = max(f(i,j-1), f(i+1,j)) otherwise
11    # need to fill rows from bottom to top
12    # cannot remove dimension i
13    # when state (i,j) is updated, we need to know
14    #   j-1 from next row
15    #   j-1 from current row and j from next row
16    # so we need to keep track of the previous row
17    n = len(s)
18    dp_prev, dp = [0] * n, [0] * n
19    for i in range(n-1, -1, -1):
20      dp[i] = 1 # important because s[i..i] is a palindrome
21      for j in range(i+1, n):
22        if s[i] == s[j]:
23          dp[j] = dp_prev[j-1] + 2
24        else:
25          dp[j] = max(dp[j-1], dp_prev[j])
26      dp_prev = dp[:]
27    return dp[-1]
28
29  def lcs(s, t):
30    # f(i,j) = length of lcs from s[0..i-1] and t[0...j-1]
31    # need to compute f(i+1,j+1) after seeing s[i] and t[j]
32    # transition rule:
33    # f(i+1,j+1) = f(i,j) + 1 if s[i] = t[j]
34    # f(i+1,j+1) = max(f(i+1,j), f(i,j+1))
35    """ IMPORTANT THIS """
36    # cannot be reduced to one dimension
37    # f(j+1) = f(j) + 1 if s[i] == t[j] (need f(j) from previous iteration)
38    # f(j+1) = f(j) (current iteration) + f(j+1) (previous iteration)
39    # need to keep track of the previous row
40    m, n = len(s), len(t)
41    dp_prev, dp = [0] * (n + 1), [0] * (n + 1)
42    for i in range(m):
43      for j in range(n):
44        if s[i] == t[j]:
45          dp[j+1] = dp_prev[j] + 1
46        else:
47          dp[j+1] = max(dp[j], dp_prev[j+1])
48      dp_prev = dp[:]
49    return dp[-1]
50
51def longestPalindromeSubstr(self, s: str) -> int:
52  def lps(s):
53    # f(i,j) = length of lps after seeing s[i+1...j-1]
54    # need to compute f(i-1,j+1) after seeing s[i] and s[j]
55    # transition rule:
56    # f(i-1,j+1) = f(i,j) + 2 if s[i] == s[j] and f(i,j) = len(s[i+1...j-1])
57    # f(i-1,j+1) = max(f(i-1,j), f(i,j+1)) otherwise
58    # normalized equations
59    # f(i,j) = f(i+1,j-1) + 2 if s[i] == s[j]
60    # f(i,j) = max(f(i,j-1), f(i+1,j)) otherwise
61    # need to fill rows from bottom to top
62    # cannot remove dimension i
63    # when state (i,j) is updated, we need to know
64    #   j-1 from next row
65    #   j-1 from current row and j from next row
66    # so we need to keep track of the previous row
67    n = len(s)
68    dp_prev, dp = [0] * n, [0] * n
69    start, size = 0, 1
70    for i in range(n-1, -1, -1):
71      dp[i] = 1
72      for j in range(i+1, n):
73        if s[i] == s[j] and dp_prev[j-1] == j-1-i:
74          dp[j] = dp_prev[j-1] + 2
75          if dp[j] > size:
76            start = i
77            size = dp[j]
78        else:
79          dp[j] = max(dp[j-1], dp_prev[j])
80      dp_prev = dp[:]
81    return s[start:start + size]
82  return lps(s)
  1. Knapsack

  • [leetcode.com] LC Discuss Post

  • [cp-algorithms.com] Knapsack Problem

  • [Similar to Bounded Knapsack] Count Ways to Target Sum

    Expand Code
      1from functools import cache
      2
      3def findTargetSumWays(self, nums: List[int], target: int) -> int:
      4  """
      5  IMPORTANT!! NEED TO KEEP IN MIND THAT RUNNING SUM HAS TO BE IN THE STATE.
      6  WE CANNOT MEMOIZE/USE DP IF WE KEEP THE RECURSIVE CALL STRUCTURE REDUCING
      7  TARGET. THAT METHOD IS NOT STATELESS AS COUNT CHANGES ACROSS CALLS.
      8  """
      9  def bounded_knapsack():
     10    # f(i, j) = number of ways to obtain target j after seeing i nums
     11    # f(0, 0) = 1
     12    # f(i+1, j) = f(i, j-nums[i]) + f(i, j+nums[i])
     13    # update rule: 
     14    # every f(i, j) would update f(i+1, j-nums[i]) and f(i+1, j+nums[i])
     15    """ 
     16    This is super important because if we loop the other way, we'd miss out on many updates.
     17    """
     18    # range for j: [-sum(abs(nums)), sum(abs(nums))]
     19    # range for i: 0...n-1
     20    # gotta be careful with offset
     21    n = len(nums)
     22    max_sum = sum(nums)
     23    if target > max_sum or target < -max_sum:
     24      return 0
     25    max_range = 2 * max_sum + 1
     26    dp = [[0] * max_range for _ in range(n + 1)]
     27    # dp[-][max_sum] actually represents curr sum = 0
     28    dp[0][max_sum] = 1 # important
     29    for i in range(n):
     30      """
     31      Incorrect way of doing it:
     32      for j in range(nums[i], max_range - nums[i]):
     33        dp[i+1][j] = dp[i][j-nums[i]] + dp[i][j+nums[i]]
     34      """
     35      # deciding on the range of j
     36      for j in range(nums[i], max_range - nums[i]):
     37        dp[i+1][j-nums[i]] += dp[i][j]
     38        dp[i+1][j+nums[i]] += dp[i][j]
     39    return dp[-1][max_sum + target]
     40
     41  def memoized_with_annotation():
     42    @cache
     43    def dfs(index, curr_sum):
     44      if index == len(nums):
     45        return 1 if curr_sum == target else 0
     46      sub_curr = dfs(index + 1, curr_sum - nums[index])
     47      add_curr = dfs(index + 1, curr_sum + nums[index])
     48      return sub_curr + add_curr
     49    return dfs(0, 0)
     50
     51  def memoized():
     52    # the idea is - every time we reach target 0 after seeing n numbers, we increase the count
     53    # memo dimension: index X max_range
     54    # note that the state here is current_sum as opposed to target
     55    # the reason is - to make the function calls stateless, we cannot use
     56    # reduced target as every time we call the same target the overall count
     57    # increases. on the other hand, current sum with the same index remains same
     58    # and hence can be memoized
     59    # range of curr_sum: 2*max_sum + 1
     60    # max_sum = 10, range: [-10,...-1, 0, 1,...10]
     61    #               index: [  0,    9,10,11,   21]
     62    max_sum = sum([abs(num) for num in nums])
     63    max_range = 2 * max_sum + 1
     64    memo = [[-1] * max_range for _ in range(len(nums))]
     65
     66    def dfs(index, curr_sum):
     67      # not storing index=n entries because we only need 1
     68      if index == len(nums):
     69        return 1 if curr_sum == target else 0
     70      curr_sum_index = curr_sum + max_sum
     71      if memo[index][curr_sum_index] == -1:
     72        sub_curr = dfs(index + 1, curr_sum - nums[index])
     73        add_curr = dfs(index + 1, curr_sum + nums[index])
     74        memo[index][curr_sum_index] = sub_curr + add_curr
     75      return memo[index][curr_sum_index]
     76
     77    return dfs(0, 0)
     78
     79  def recursive_stateless():
     80    def dfs(index, curr_sum):
     81      if index == len(nums):
     82        return 1 if curr_sum == target else 0
     83      sub_curr = dfs(index + 1, curr_sum - nums[index])
     84      add_curr = dfs(index + 1, curr_sum + nums[index])
     85      return sub_curr + add_curr
     86    return dfs(0, 0)
     87
     88  def recursive_with_external_variable():
     89    count = 0
     90    def dfs(index, curr_sum):
     91      nonlocal count
     92      if index == len(nums): # reached the end
     93        if curr_sum == target: # achieved the target
     94          count += 1
     95      else:
     96        dfs(index + 1, curr_sum - nums[index])
     97        dfs(index + 1, curr_sum + nums[index])
     98        # no need to do anything here as the state is updated on
     99        # reaching the target to the external variable
    100    dfs(0, 0)
    101    return count
    102  return bounded_knapsack()
    
  • Unbounded Fixed Target Sum [Backtracking]

  • Unbounded Knapsack: Coin Change

    Expand Code
     1def coinChange(self, coins: List[int], amount: int) -> int:
     2  def unbounded_knapsack():
     3    # f(i, j) = min number of coins from the set coins[0...i] to make up for amount j
     4    # transition rule
     5    # f(i, j) = min(f(i-1, j), f(i, j-coins[i]) + 1)
     6    """ 
     7    if appears that we are only considering the case where we take coins[i] just once.
     8    but when we PROCESS THIS LEFT TO RIGHT, so, if f(i, j-k*coins[i]) really was
     9    the optimal ans for something we'd have considered it.
    10    
    11    !!!!!IMPORTANT!!!!!
    12    when the state (i,j) is updated, all (i,k<j) states should have been updated with
    13    the knowledge that coins[i] exists in this world!
    14    """
    15    # removing the first dimension
    16    # f(j) = min(f(j), f(j-coins[i]) + 1)
    17    n = len(coins)
    18    dp = [inf] * (amount + 1)
    19    dp[0] = 0
    20    for coin in coins:
    21      for j in range(coin, amount + 1):
    22        dp[j] = min(dp[j], dp[j-coin] + 1)
    23    return dp[-1] if dp[-1] < inf else -1
    24
    25  def bfs_bottom_up():
    26    # the idea is to start traversing on the state transition
    27    # graph. start node is amount, and destination node is 0.
    28    # for each node in this graph, we have coins outgoing edges
    29    # O(V+E) = O(S+S*N)
    30    queue = deque([0])
    31    visited = set([])
    32    steps = 0
    33    while queue:
    34      size = len(queue)
    35      for _ in range(size):
    36        curr_amount = queue.popleft()
    37        if curr_amount == amount:
    38          return steps
    39        for coin in coins:
    40          new_amount = curr_amount + coin
    41          if new_amount <= amount and new_amount not in visited:
    42            queue.append(new_amount)
    43            visited.add(new_amount)
    44      steps += 1
    45    return -1
    46  def bfs_top_down():
    47    # the idea is to start traversing on the state transition
    48    # graph. start node is amount, and destination node is 0.
    49    # for each node in this graph, we have coins outgoing edges
    50    # O(V+E) = O(S+S*N)
    51    queue = deque([amount])
    52    visited = set([])
    53    steps = 0
    54    while queue:
    55      size = len(queue)
    56      for _ in range(size):
    57        curr_amount = queue.popleft()
    58        if curr_amount == 0:
    59          return steps
    60        for coin in coins:
    61          remaining_amount = curr_amount - coin
    62          if remaining_amount >= 0 and remaining_amount not in visited:
    63            queue.append(remaining_amount)
    64            visited.add(remaining_amount)
    65      steps += 1
    66    return -1
    67
    68def coinChangeCountWays(self, amount: int, coins: List[int]) -> int:
    69  """ Note: f(i,j) = f(i-1,j) + f(i,j-coins[i]) """
    70  # f(i-1,j) := don't use j-th coin
    71  # f(i,j-coins[i]) := use j-th coin
    72  dp = [0] * (amount + 1)
    73  for coin in coins:
    74    dp[0] = 1
    75    for j in range(coin, amount + 1):
    76      dp[j] += dp[j-coin]
    77  return dp[-1]
    
  1. Edit distance

Expand Code
 1def minEditDistance(self, word1: str, word2: str) -> int:
 2  # f(i,j) = edit distance after seeing s[0...i-1] and t[0...j-1]
 3  # f(i+1, j+1) = f(i, j) if s[i] == t[j]
 4  # else
 5  """ 
 6  Trick: 
 7    (1) Think of the next character we'd look after each operation.
 8    (2) Normalize the equations afterwards
 9  """
10  # replace: f(i+1,j+1) = f(i,j) + 1
11  # delete:  f(i+1,j) = f(i,j) + 1 => f(i+1,j+1) = f(i,j+1) + 1
12  # insert:  f(i, j+1) = f(i,j) + 1 => f(i+1,j+1) = f(i+1,j) + 1
13  """
14  Final equations:
15    f(i+1,j+1) = f(i,j) if s[i] == s[j] else (min(f(i,j),f(i,j+1),f(i+1,j))) + 1
16  
17  Cannot be reduced to lower dimensions - need both (i,j) and (i+1,j) at the same time
18  """
19  m, n = len(word1), len(word2)
20  dp = [[0] * (n+1) for _ in range(m+1)]
21  """ Key: IMPORTANT TO INITIALIZE PROPERLY """
22  for i in range(m + 1):
23    dp[i][0] = i # all delete
24  for j in range(n + 1):
25    dp[0][j] = j # all insert
26  for i in range(m):
27    for j in range(n):
28      if word1[i] == word2[j]:
29        dp[i+1][j+1] = dp[i][j]
30      else:
31        dp[i+1][j+1] = min(dp[i][j+1], dp[i+1][j], dp[i][j]) + 1
32  return dp[-1][-1]
  1. Assembly Line Scheduling

  2. Matrix Chain Multiplication/Burst Baloons

  3. Optimal BST

  4. Viterbi Algorithm

  5. Scheduling to Maximise Profit

  6. Maximal Square, Maximal Rectangle

Expand Code
 1def maximalSquare(self, matrix: List[List[str]]) -> int:
 2  # if the current entry in the matrix is 1, then it can contribute
 3  # to 3 possible squares, one ending with top (i-1, j), one on the left
 4  # (i, j-1) and one ending on the corner (i-1, j-1)
 5  # case by case explanation:
 6  # if the square ending at (i-1, j) is the min, then we know for sure
 7  # that the one ending at (i, j-1) covers at least as many rows as (i-1, j). 
 8  # this is guaranteed since they are SQUARES not RECTANGLES.
 9  m, n = len(matrix), len(matrix[0]) if len(matrix) > 0 else 0
10  prev_row = [1 if x == '1' else 0 for x in matrix[0]]
11  curr_row = [0] * n
12  result = max(prev_row)
13  for i in range(1, m):
14    curr_row[0] = 1 if matrix[i][0] == '1' else 0
15    result = max(result, curr_row[0])
16    for j in range(1, n):
17      if matrix[i][j] == '1':
18        curr_row[j] = min(curr_row[j-1], prev_row[j-1], prev_row[j]) + 1
19        result = max(result, curr_row[j])
20    for j in range(n):
21      prev_row[j] = curr_row[j]
22      curr_row[j] = 0
23  return result * result
24  
25def maximalRectangle(self, matrix: List[List[str]]) -> int:
26  # the approach for maximal square doesn't apply here
27  # we solve it by folding it and forming histograms
28  def max_consecutive_ones(arr):
29    left = 0
30    res = 0
31    for right in range(len(arr)):
32      if arr[right] == 0:
33        left = right + 1
34      else:
35        res = max(right - left + 1, res)
36    return res
37
38  def largest_histogram(heights):
39    # we scan from left to right and check the min of every interval
40    # everything that gets popped is the min of some interval
41    max_area = 0
42    minstack = [-1]
43    for i, height in enumerate(heights):
44      while minstack[-1] != -1 and heights[minstack[-1]] >= height:
45        prev_min_index = minstack.pop()
46        # prev_min_index is the min of the range(stack[-1] + 1, i - 1)
47        left, right = minstack[-1] + 1, i - 1 # IMPORTANT: THIS IS WHY WE NEED -1
48        width = right - left + 1
49        max_area = max(max_area, width * heights[prev_min_index])
50      minstack.append(i)
51    # everything that remains on stack is also min of some range
52    while minstack[-1] != -1:
53      prev_min_index = minstack.pop()
54      # prev_min_index is the min of the range(stack[-1] + 1, n - 1)
55      left, right = minstack[-1] + 1, len(heights) - 1
56      width = right - left + 1
57      max_area = max(max_area, width * heights[prev_min_index])
58    return max_area
59
60  m, n = len(matrix), len(matrix[0]) if len(matrix) > 0 else 0
61  if not m or not n:
62    return 0
63
64  histogram = [1 if x == '1' else 0 for x in matrix[0]]
65  max_area = max_consecutive_ones(histogram)
66  for i in range(1, m):
67    for j in range(n):
68      histogram[j] = histogram[j] + 1 if matrix[i][j] == '1' else 0
69    max_area = max(max_area, largest_histogram(histogram))
70  return max_area
  1. Word Break, Word Break 2

Expand Code
 1def wordBreak(self, s: str, wordDict: List[str]) -> bool:
 2  def good_approach():
 3    """ O(n*m*k) """
 4    # f(i) := s[0...i] can be segmented or not
 5    # f(i) = true of any f(j-1, j <= i) and s[j,i] in in dict
 6    words = set(wordDict)
 7    n = len(s)
 8    dp = [False] * n
 9
10    for i in range(n):
11      for word in words:
12        size = len(word)
13        j = i-size+1
14        if (j == 0 or (j > 0 and dp[j-1])) and (j >= 0 and s[j:i+1] == word):
15          dp[i] = True
16          break
17
18    return dp[-1]
19
20  def bad_approach():
21    """ O(n^2) """
22    # f(i) := s[0...i] can be segmented or not
23    # f(i) = true of any f(j-1, j <= i) and s[j,i] in in dict
24    words = set(wordDict)
25    n = len(s)
26    dp = [False] * n
27
28    for i in range(n):
29      for j in range(i+1):
30        if (j == 0 or dp[j-1]) and s[j:i+1] in words:
31          dp[i] = True
32          break
33    
34    return dp[-1]
35  
36  return good_approach()
37
38def wordBreakII(self, s: str, wordDict: List[str]) -> List[str]:
39  def backtrack(curr, start_index):
40    nonlocal result
41    n = len(s)
42    if start_index >= n:
43      ans = ' '.join(curr)
44      result.append(ans)
45      return
46    
47    for j in range(start_index, n):
48      if is_word(start_index, j):
49        size = j - start_index + 1
50        word = s[start_index: start_index + size]
51        curr.append(word)
52        backtrack(curr, start_index + size)
53        curr.pop()
54  
55  def is_word(i, j):
56    nonlocal words
57    # curr = s[i:j+1]
58    # return curr in words
59  
60
61  words = set(wordDict)
62  n = len(s)
63  # if dp[i][j] = True => s[i...j] is a valid word in the dict
64  dp = [[False] * n for _ in range(n)]
65
66  # TODO implement using trie
67  print(dp)
68  result = []
69  backtrack([], 0)
70  return result

Greedy

  1. Single CPU Scheduling (Unordered With Delay Restrictions)

Expand Code
 1""" WHEN TASKS HAS TO BE EXECUTED IN ANY ORDER """
 2def taskScheduler(self, tasks: List[str], n: int) -> int:
 3  def heap_queue():
 4    # greedy:
 5    # since the order does not matter, pick the those teasks
 6    # in the decreasing order of counts.
 7    # 
 8    # IMPORTANT
 9    # process tasks with one tick at a time
10    # to do that, we can maintain two data structures
11    # (1) for the tasks which can be processed right away
12    # (2) tasks which are in the queue to be available for processing later
13    counts = Counter(tasks)
14    # we don't need to reduce counts in the original dict
15    # we can totally maintain the states using the two data structures we have
16    # since we don't are about the type of the task, we don't need to keep
17    # the task name as well
18    # ready: priority queue -> counts
19    # queued: counts, start_time
20    ready = [-count for count in counts.values()]
21    heapq.heapify(ready)
22    queued = deque()
23    curr_time = 0
24    while ready or queued:
25      if queued and queued[0][1] < curr_time:
26        count, _ = queued.popleft()
27        heapq.heappush(ready, count)
28      if ready:
29        count = 1 + heapq.heappop(ready)
30        if count:
31          queued.append((count, curr_time + n))
32      curr_time += 1
33    return curr_time
34
35  def idle_counting():
36    # visualisation:
37    # 
38  
39  return heap_queue()
40
41""" WHEN TASKS HAS TO BE EXECUTED IN GIVEN ORDER """
42def taskSchedulerII(self, tasks: List[int], space: int) -> int:
43  available_from = {}
44  curr_day = 0
45  for task in tasks:
46    if task in available_from:
47      """ IMPORTANT """
48      # while doing timejump remember that we don't go back in past
49      curr_day = max(curr_day, available_from[task]) # time jump
50    curr_day += 1 # execute task
51    available_from[task] = curr_day + space
52  return curr_day
  1. Resource Allocation

Expand Code
  1def canAttendAllMeetings(self, intervals: List[List[int]]) -> bool:
  2  def overlaps(first, second):
  3    return first[1] > second[0]
  4
  5  n = len(intervals)
  6  # sort the intervals by start time
  7  # sorting helps because if any given interval (curr)
  8  # doesn't overlaps with the immediate next interval
  9  # then none of the next intervals would be overlapping with curr
 10  intervals.sort(key=lambda x:x[0])
 11  for i in range(1, n):
 12    if overlaps(intervals[i-1], intervals[i]):
 13      return False
 14  return True
 15
 16def maxTimeMeetingRoomBooked(self, intervals: List[List[int]]) -> List[List[int]]:
 17  def overlaps(interval1, interval2):
 18    # since the calling ensures that interval1 has earlier start time
 19    # than interval2, we just need to check if interval1 ends after
 20    # interval2 begins.
 21    return interval1[1] >= interval2[0]
 22  
 23  def merge(interval1, interval2):
 24    # similar to above, the calling ensures that interval1 starts earlier
 25    # than interval2. therefore, for merged interval, we only need to check
 26    # the max of end times
 27    return [interval1[0], max(interval1[1], interval2[1])]
 28
 29  intervals.sort()
 30  stack = [intervals[0]]
 31
 32  for interval in intervals[1:]:
 33    if overlaps(stack[-1], interval):
 34      last = stack.pop()
 35      stack.append(merge(last, interval))
 36    else:
 37      stack.append(interval)
 38
 39  return stack
 40
 41def minMeetingsDropped(self, intervals: List[List[int]]) -> int:
 42  # removing overlapping intervals is a different prolem
 43  # than counting intervals.
 44  # why?
 45  # say, we have 3 intervals, i1, i2, i3 where i1 overlaps with i2
 46  # and i2 overlaps with i3. if we count overlaps, there are 2.
 47  # if we remove i1, overlap count reduces to 1
 48  # if we remove i3, overlap count reduces to 1
 49  # but if we remove i2, overlap count reduces to 0
 50  # so this requires a different strategy
 51  # greedy activity selection. basically the idea is to think of it
 52  # as meetings which are to be held in a single room. we are the doorman
 53  # and we want to maximize the room usage by assigning it to the most
 54  # number of meetings possible. this can be achieved by assigning it to
 55  # the meeting that ends first. since this choice doesn't hurt with the objective.
 56  intervals.sort(key=lambda x:x[1]) # sorted by end time
 57  n = len(intervals)
 58  meetings_count = 1
 59  last_meeting_finish_time = intervals[0][1]
 60  for start_time, finish_time in intervals[1:]:
 61    if last_meeting_finish_time <= start_time:
 62      meetings_count += 1
 63      last_meeting_finish_time = finish_time
 64  return n - meetings_count
 65
 66def minMeetingRooms(self, intervals: List[List[int]]) -> int:
 67  # it asks for minimum number of resources that is required
 68  # to allocate all meetings. this means, every time there
 69  # are overlapping meetings, we need to find a new room to 
 70  # accomodate for it. if intervals are represented as nodes in
 71  # a graph, and we use edges to connect those nodes whenever they
 72  # are overlapping, then this problem can be thought of as a
 73  # graph colouring problem on that interval graph.
 74  # the algorithm works by simulation of the situation.
 75  # we need to maintain two lists - one for occupied rooms and one
 76  # for free rooms that we've already decided to pay for.
 77  # every time a new meeting comes, we first check if there are occupied
 78  # rooms which can be marked as free. we add them to the free rooms list.
 79  # we allocate room from this free list as we've already paid for them.
 80  # if there really are no free rooms, we increase max room count, and decide
 81  # to pay for one more extra room
 82  intervals.sort()
 83  occupied = [] # needs to store finish time
 84  # instead of free list, can we manage using just the count?
 85  free_rooms_count = 1
 86  max_rooms_used = 1
 87
 88  for start_time, finish_time in intervals:
 89    while occupied and occupied[0] <= start_time:
 90      heapq.heappop(occupied)
 91      free_rooms_count += 1
 92    if free_rooms_count:
 93      heapq.heappush(occupied, finish_time)
 94      free_rooms_count -= 1
 95    else:
 96      max_rooms_used += 1
 97      heapq.heappush(occupied, finish_time)
 98  
 99  return max_rooms_used
100
101def mostBooked(self, n: int, meetings: List[List[int]]) -> int:
102  # here, the room id matters
103  # need to maintain not just free rooms, but the room ids as well
104  # lucky for us, our allocation rule indicates that we can pick
105  # rooms from the free list in order of their ids.
106  # so we maintain the same occupied and free lists. but we also
107  # store the room ids.
108  meetings.sort()
109  occupied = [] # stores finish time and room id
110  free = list(range(n)) # stores room id
111
112  # we also need to do bookkeeping for meetings counts
113  counts = defaultdict(int)
114
115  for start_time, end_time in meetings:
116    duration = end_time - start_time
117
118    while occupied and occupied[0][0] <= start_time:
119      _, room_id = heapq.heappop(occupied)
120      heapq.heappush(free, room_id)
121    
122    if free:
123      # can allocate a room right now
124      room_id = heapq.heappop(free)
125      heapq.heappush(occupied, (end_time, room_id))
126      counts[room_id] += 1
127    else:
128      # need to wait until another room becomes free
129      # need to adjust end time accordingly
130      finish_time, room_id = heapq.heappop(occupied)
131      heapq.heappush(occupied, (finish_time + duration, room_id))
132      counts[room_id] += 1
133
134  max_meetings = max(counts.values())
135  # we return the room that held max meetings and has the lowest index
136  # this can be achieved by traversing the key list in counts in sorted
137  # order. lucky for us, dict already has keys in sorted order
138  return [x for x in counts if counts[x] == max_meetings][0]
139
140def busiestServers(self, k: int, arrival: List[int], load: List[int]) -> List[int]:
141  # similar to meeting roms 3, here also we need to maintain
142  # two lists for free resources and occupied resources
143  # but unlike meeting rooms 3, where we were to wait until a room was free
144  # and our preference was to allocate rooms to the lowest id possible,
145  # here, we have a fixed allocation rule according to round robin.
146  # if there are no free rooms at the time of arrival, request is dropped.
147  # the round robin works on sorted room id sequences.
148  # example:
149  # at any given time, free rooms [0,1,5], and we have 8 resources [0-7]
150  # request 12 comes. 12 % 8 = 4
151  # if server 4 was available, we would have used it. but since it's not,
152  # the next best we can do is 5. note that this is the insert position of 4
153  # in the sorted free rooms list
154  # does this hold for everything?
155  # any incoming requests would be mapped to [0, 7] due to modulo division.
156  # say, request 16 comes. server index = 16 % 8 = 0
157  # since 0 is already there, we allocate it.
158
159  # arrivals are already sorted
160  occupied = [] # stores the finish time and room_id
161  free = SortedList(list(range(k))) # stores room ids in sorted order
162  # SortedList is a balanced BST
163  # we could have used SortedSet since rooms are unique
164  # but SortedList provides us with bisect_left which is what we need
165
166  # bookkeeping for counts
167  counts = defaultdict(int)
168
169  for i, start_time in enumerate(arrival):
170    end_time = start_time + load[i]
171
172    while occupied and occupied[0][0] <= start_time:
173      _, server_id = heapq.heappop(occupied)
174      free.add(server_id)
175    
176    if free:
177      # key point to observe here is that as long as we
178      # have free servers, we can ALWAYS allocate resource
179      # in a round robin fashion
180      server_id = i % k
181      index = free.bisect_left(server_id)
182      # there is a server which can serve the request
183      server_id = free[index] if index < len(free) else free[0]
184      free.remove(server_id)
185      heapq.heappush(occupied, (end_time, server_id))
186      counts[server_id] += 1
187  
188  max_count = max(counts.values())
189  # since keys in dict are in sorted order, this returns
190  # the list of server ids in sorted order
191  return [x for x in counts if counts[x] == max_count]