Data Structures and Algorithms: A Simple Guide — Dynamic Programming

Sumedha J
8 min readApr 28, 2024

--

Dynamic programming is one of those techniques which optimises the time complexity of your recursive solution. And it’s very simple to understand and implement. In recursion, you tend to solve the same sub problem again and again. You can observe the below recursive tree for Fibonacci numbers. What dynamic programming does is, it stores the solutions to these repeating subproblems. Suppose the same subproblem arises again, instead of computing again, it fetches the corresponding stored result. One of the popular methods of storing these intermittent results is called ‘memoisation’ (And no, it’s not memeorisation :) ).

Recursion tree for finding the 5th Fibonacci number
Recursion tree with memoisation (ie., dynamic programming)

Now that we understood the basics, let’s solve a few problems!

  1. Fibonacci series
# Recursion approach O(2^n) time complexity
def fib(n):
if n == 0:
return 0 # Base case
if n == 1:
return 1 # Base case
return fib(n-1) + fib(n-2) # Recursive case

# Recursion + Memoization approach O(2n) => O(n) time complexity
def _fib(n, memo):
if memo[n] != None:
return memo[n]
memo[n] = _fib(n-1, memo) + _fib(n-2, memo)
return memo[n]

def fib(n):
if n == 0:
return 0
if n == 1:
return 1
memo = [None for i in range(n+1)] # Or use a dictionary
memo[0] = 0
memo[1] = 1
return _fib(n, memo)

The dictionary approach would look like this. This has the same O(n) time complexity, differs only in the implementation of memo.

# Recursion + Memoization approach O(2n) => O(n) time complexity
def _fib(n, memo):
if memo.get(n) != None: # Or use the 'in' operator
return memo[n]
memo[n] = _fib(n-1, memo) + _fib(n-2, memo)
return memo[n]

def fib(n):
memo = {}
memo[0] = 0
memo[1] = 1
return _fib(n, memo)

2. Tribonacci series

To solve this problem, we can go through the below recursive tree.

Recursion + memoisation for tribonacci series
def _fib(n, memo):
if memo[n] != None:
return memo[n]
memo[n] = _fib(n-1, memo) + _fib(n-2, memo) + _fib(n-3, memo)
return memo[n]

def tribonacci(n):
if n == 0:
return 0
if n == 1:
return 0
if n == 2:
return 1
memo = [None for i in range(n+1)] # Or use a dictionary for memo
memo[0] = 0
memo[1] = 0
memo[2] = 1
return _fib(n, memo) # O(n) time complexity

3. Make a function called sum_possible. It needs to check if it’s possible to get a certain total by adding up numbers from a list. The function should return either true or false. You can use numbers from the list more than once if needed. Just remember, the total you’re trying to reach can’t be negative.

Example: sum_possible(8, [5, 12, 4]) -> True, 4 + 4

Recursion tree
def sum_possible(amount, numbers):
memo = {}
return _sum_possible(amount, numbers, memo)

def _sum_possible(amount, numbers, memo):
if amount in memo:
return memo[amount]

if amount == 0:
return True

if amount < 0:
return False

for num in numbers:
if _sum_possible(amount - num, numbers, memo) == True:
memo[amount] = True
return memo[amount]

memo[amount] = False
return memo[amount]
  • a = amount
  • n = length of numbers
  • Time: O(a*n) — A hack to remember this is, just count the number of nodes in the recursion tree. That would be the number of times, the function would be invoked. In simple recursion, it would have been O(a^n)
  • Space: O(a)

A variation of the above program could be this.

Create a function called min_change that accepts two arguments: an integer representing an amount and a list of integers representing different coin denominations. The function should determine the minimum number of coins required to make up the given amount using the coins provided in the list. Each coin can be used multiple times if necessary. If it’s impossible to make up the amount using the provided coins, the function should return -1.

import math

def _min_change(amount, coins, memo, min_coins):
if amount in memo:
return memo[amount]

if amount == 0:
return 0

if amount < 0:
return math.inf

for coin in coins:
res = 1 + _min_change(amount-coin, coins, memo, min_coins)
min_coins = min(res, min_coins)

memo[amount] = min_coins
return min_coins

def min_change(amount, coins):
res = _min_change(amount, coins, {}, math.inf)
if res == math.inf:
return -1
return res
  • a = amount
  • c = # coins
  • Time: O(a*c)
  • Space: O(a)

4. Write a function, count_paths, that takes in a grid as an argument. In the grid, ‘X’ represents walls and ‘O’ represents open spaces. You may only move down or to the right and cannot pass through walls. The function should return the number of ways possible to travel from the top-left corner of the grid to the bottom-right corner.

grid = [
["O", "O", "X"],
["O", "O", "O"],
["O", "O", "O"],
]
Recursion tree for the example grid
def _count_paths(i, j, grid, memo):
if (i, j) in memo:
return memo[(i, j)]

if i == len(grid) or j == len(grid[0]) or grid[i][j] == 'X':
return 0

if (i, j) == (len(grid) - 1, len(grid[0]) - 1):
return 1

down_count = _count_paths(i+1, j, grid, memo)
right_count = _count_paths(i, j+1, grid, memo)

memo[(i, j)] = down_count + right_count
return memo[(i, j)]

def count_paths(grid):
memo = {}
return _count_paths(0, 0, grid, memo)
  • r = # rows
  • c = # columns
  • Time: O(r*c)
  • Space: O(r*c)

Similar logic can be applied to a program like below.

Write a function, max_path_sum, that takes in a grid as an argument. The function should return the maximum sum possible by traveling a path from the top-left corner to the bottom-right corner. You may only travel through the grid by moving down or right. You can assume that all numbers are non-negative.

Approach of the solution
def _count_paths(i, j, grid, memo):
if (i, j) in memo:
return memo[(i, j)]

if i == len(grid) or j == len(grid[0]):
return float('-inf')

if (i, j) == (len(grid) - 1, len(grid[0]) - 1):
return grid[i][j]

down_count = _count_paths(i+1, j, grid, memo)
right_count = _count_paths(i, j+1, grid, memo)

memo[(i, j)] = max(down_count, right_count) + grid[i][j]
return memo[(i, j)]

def max_path_sum(grid):
memo = {}
return _count_paths(0, 0, grid, memo)

5. Write a function, non_adjacent_sum, that takes in a list of numbers as an argument. The function should return the maximum sum of non-adjacent items in the list. There is no limit on how many items can be taken into the sum as long as they are not adjacent.

Recursion tree and approach
def _non_adjacent_sum(nums, i, memo):
if i in memo:
return memo[i] # list cannot be in dictionary keys, so use index; also, same list 'nums' is sent in every call. So i is the only differentiating factor

if i >= len(nums):
return 0

include = nums[i] + _non_adjacent_sum(nums, i + 2, memo)
exclude = _non_adjacent_sum(nums, i + 1, memo)

memo[i] = max(include, exclude)
return memo[i]

def non_adjacent_sum(nums):
return _non_adjacent_sum(nums, 0, {})

This question’s solution approach is similar to below.

Write a function, summing_squares, that takes a target number as an argument. The function should return the minimum number of perfect squares that sum to the target. A perfect square is a number of the form (i*i) where i >= 1. For example: 1, 4, 9, 16 are perfect squares, but 8 is not perfect square.

Dynamic programming with memoisation
import math

def _summing_squares(n, min_edges, memo):
if n in memo:
return memo[n]

if n == 0:
return 0

for i in range(1, math.floor(math.sqrt(n) + 1)):
square = i * i
num_squares = 1 + _summing_squares(n - square, min_edges, memo)
min_edges = min(min_edges, num_squares)

memo[n] = min_edges
return min_edges

def summing_squares(n):
if n == 1:
return 1
return _summing_squares(n, math.inf, {})

Time complexity => O(n * number of perfect squares below n)

Space complexity => O(n)

6. Counting change: Write a function, counting_change, that takes in an amount and a list of coins. The function should return the number of different ways it is possible to make change for the given amount using the coins. You may reuse a coin as many times as necessary.

For example,

counting_change(4, [1,2,3]) -> 4

There are four different ways to make an amount of 4:

1. 1 + 1 + 1 + 1
2. 1 + 1 + 2
3. 1 + 3
4. 2 + 2
Approach
def counting_change(amount, coins):
return _counting_change(amount, coins, 0, {})

def _counting_change(amount, coins, i, memo):
key = (amount, i)
if key in memo:
return memo[key]

if amount == 0:
return 1

if i == len(coins):
return 0

coin = coins[i]

total_count = 0
for qty in range(0, (amount // coin) + 1):
remainder = amount - (qty * coin)
total_count += _counting_change(remainder, coins, i + 1, memo)

memo[key] = total_count
return total_count
  • a = amount
  • c = # coins
  • Time: O(a*c)
  • Space: O(a*c)

7. Array stepper: Write a function, array_stepper, that takes in a list of numbers as an argument. You start at the first position of the list. The function should return a boolean indicating whether or not it is possible to reach the last position of the list. When situated at some position of the list, you may take a maximum number of steps based on the number at that position.

For example, given:

idx = 0 1 2 3 4 5
numbers = [2, 4, 2, 0, 0, 1]

The answer is True.
We start at idx 0, we could take 1 step or 2 steps forward.
The correct choice is to take 1 step to idx 1.
Then take 4 steps forward to the end at idx 5.
Approach
def array_stepper(steps, index, memo):
if index in memo:
return memo[index]

if index == len(steps) - 1:
return True

if steps[index] == 0 and index < (len(steps) - 1):
return False

if index > len(steps) - 1:
return False

for step in range(1, steps[index] + 1):
if (array_stepper(steps, index + step, memo) == True):
memo[index] = True
return True

memo[index] = False
return False

Time complexity => O(m * n)

Space complexity => O(n)

8. Max Palin subsequence

Write a function, max_palin_subsequence, that takes in a string as an argument. The function should return the length of the longest subsequence of the string that is also a palindrome. A subsequence of a string can be created by deleting any characters of the string, while maintaining the relative order of characters.

Approach for Max Palin
def max_palin_subsequence(str, memo):
if(str in memo):
return memo[str]

if(len(str) == 0):
return 0

if(len(str) == 1):
return 1

if(str[0] == str[len(str) - 1]):
return 2 + max_palin_subsequence(str[1:-1], memo)
else:
return max(max_palin_subsequence(str[1:], memo), max_palin_subsequence(str[:-1], memo))

# You can use i and j as extra parameters to get the sections of string, instead of creating new ones every time

I hope this gave you a good idea about dynamic programming:)

Cheers!!

--

--