Dynamic Programming, Stack, Merge Intervals

1. Dynamic Programming

1.1. Maximum Earnings From Taxi

from bisect import bisect_right

class Solution:
    def maxTaxiEarnings(self, n: int, rides: List[List[int]]) -> int:
        # Sort by end time
        rides.sort(key=lambda x:x[1])
        m = len(rides)

        # Create a dp to track maximum until the i
        end_times = [ride[1] for ride in rides]
        dp = [0] * (m + 1)

        # Go backward
        for i in range(1, m + 1):
            start, end, tip = rides[i - 1]
            earning = end - start + tip
            # only find in array
            idx = bisect_right(end_times, start, lo = 0, hi = i - 1)
            dp[i] = max(dp[i - 1], dp[idx] + earning)

        return dp[m]

1.2. Partition Equal Subset Sum

  • Notes:

For example: arr = [1,2,5]

dp[0] = True -> If we choose [] dp[1] = True -> If we choose [1]

class Solution:
    def canPartition(self, nums: List[int]) -> bool:
        # Check we can contribute the target // 2 in the current array => If an array can sum to target // 2 in the index i => the rest still target // 2 too
        if sum(nums) % 2 != 0:
            return False

        target = sum(nums) // 2

        # dp[i] means "Can we form a subset of numbers from the list that sums up to exactly i?"
        dp = [False] * (target + 1)
        dp[0] = True # No choice subset is 0, we choose []
        # For example, dp[1] = True if we choose [1]

        # For each num, we try to add it to existing possible sums.
        for num in nums:
            for i in range(target, num - 1, -1):
                dp[i] = dp[i] or dp[i - num]

        return dp[target]

1.3. Subset Sum Problem

arr = [7, 3, 2, 5, 8] target = 14

=> Output [7, 2, 5], True

def isSubsetSum(arr, target_sum):
    # dp[i][targetSum] to track whether from i element we can form target
    n = len(arr)
    dp = [[False] * (target_sum + 1) for _ in range(n + 1)]

    # We always can get sum 0 by not choosing anything []
    for i in range(n + 1):
        dp[i][0] = True

    # Backward
    for i in range(n + 1):
        for j in range(1, target_sum + 1):
            if arr[i - 1] > j:
                # Can not choose arr[i - 1]
                dp[i][j] = dp[i - 1][j]
            else:
                # Can choose or not
                dp[i][j] = dp[i - 1][j] or dp[i - 1][j - arr[i - 1]]

    # Unil n -> find target_sum
    return dp[n][target_sum]
def printBackward(dp, arr, n, target_sum):
    subset = []
    i, j = n, target_sum
    while i > 0 and j > 0:
        if dp[i][j] == dp[i - 1][j]:
            i -= 1
        else:
            subset.append(arr[i - 1])
            j -= arr[i - 1]
            i -= 1

    print(subset[::-1])
def isSubsetSum(arr, target_sum):
    # dp[i][targetSum] to track whether from i element we can form target
    n = len(arr)
    dp = [[False] * (target_sum + 1) for _ in range(n + 1)]

    # We always can get sum 0 by not choosing anything []
    for i in range(n + 1):
        dp[i][0] = True

    # Backward
    for i in range(n + 1):
        for j in range(1, target_sum + 1):
            if arr[i - 1] > j:
                # Can not choose arr[i - 1]
                dp[i][j] = dp[i - 1][j]
            else:
                # Can choose or not
                dp[i][j] = dp[i - 1][j] or dp[i - 1][j - arr[i - 1]]

    # Unil n -> find target_sum
    result = dp[n][target_sum]

    printBackward(dp, arr, n, target_sum)

    return result

def printBackward(dp, arr, n, target_sum):
    subset = []
    i, j = n, target_sum
    while i > 0 and j > 0:
        if dp[i][j] == dp[i - 1][j]:
            i -= 1
        else:
            subset.append(arr[i - 1])
            j -= arr[i - 1]
            i -= 1

    print(subset[::-1])

print(isSubsetSum([7, 3, 2, 5, 8], 14))

1.4. Knapstack Problem

W = 4, profit[] = [1, 2, 3], weight[] = [4, 5, 1]

Using dp[i][weight] -> Get weight until ith element

def knapsack(weights, values, capacity):
    # Create 2-D dp array to carry i item with capacity
    n = len(values)
    dp = [[0] * (capacity + 1) for _ in range(n + 1)]

    # Loop and update array
    for i in range(1, n + 1):
        for j in range(capacity + 1):
            if weights[i - 1] > j:
                dp[i][j] = dp[i - 1][j]
            else:
                # Lay hoac khong lay item i
                dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weights[i - 1]] + values[i - 1])

    return dp[n][capacity]

weights = [2, 3, 4, 5]
values = [3, 4, 5, 6]
capacity = 5

print(knapsack(weights, values, capacity))

1.5. Target Sum (Positive and Negative)

  • Split to array Negative and Positive, select from this collection to get the sum

  • Trước sau gì cũng duyệt hết nên 1-D được rồi

  • Call from [target_sum, num] thôi.

from typing import List

class Solution:
    def findTargetSumWays(self, nums: List[int], target: int) -> int:
        if (sum(nums) + target) % 2 != 0 or abs(target) > sum(nums):
            return 0

        # Find target_sum is postive, and other is negative
        target_sum = (sum(nums) + target) // 2

        # Trước sau gì cũng duyệt hết nên 1-D được rồi
        # Count the way to choose num -> get the sum
        dp = [0] * (target_sum + 1)
        dp[0] = 1  # One way to make sum 0 (pick nothing)

        for num in nums:
            for j in range(target_sum, num - 1, -1):
                dp[j] = dp[j] + dp[j - num]

        return dp[target_sum]


1.6. Unbounded Knapsack

weights = [2, 3, 4] values = [40, 50, 100] capacity = 8

print(unbounded_knapsack(weights, values, capacity)) # Output: 200

  • Cái kia tính tới phần tử i chứ unbounded thì chỉ cần tính theo weight

  • Cái nào cũng 2 vòng lặp, khác nào trong ngoài thôi.

  • Can get ith item, dp[w - weights[i]] là bỏ cùng 1 cái khối lượng đó (bản chất là bỏ cái cũ) + lấy thêm cái đó.

  • Loop tới n thôi, do cái này không có backward.

def unbounded_knapsack(weights, values, capacity):
    n = len(weights)

    # Max value you can gain after catch i weights
    # Cái kia tính tới phần tử i chứ unbounded thì chỉ cần tính theo weight
    dp = [0] * (capacity + 1)
    dp[0] = 0

    # Go forward
    for w in range(1, capacity + 1):
        for i in range(n):
            # Can get ith item, dp[w - weights[i]] là bỏ cùng 1 cái khối lượng đó (bản chất là bỏ cái cũ) + lấy thêm cái đó
            if weights[i] <= w:
                dp[w] = max(dp[w], dp[w - weights[i]] + values[i])

    return dp[capacity]


weights = [2, 3, 4]
values = [40, 50, 100]
capacity = 8

print(unbounded_knapsack(weights, values, capacity))  # Output: 200

1.7. Minimum Cost to Cut a Stick (Min-max trong đoạn -> Cut cut này auto dynamic programming)

from typing import List

class Solution:
    def minCost(self, n: int, cuts: List[int]) -> int:
        # Step 1: Add 0 and n to the list of cuts and sort it
        cuts = [0] + sorted(cuts) + [n]
        m = len(cuts)

        # Step 2: Initialize a 2D DP array
        dp = [[0] * m for _ in range(m)]

        # Step 3: Bottom-up DP - compute min cost for all intervals of increasing length
        for length in range(2, m):  # interval length
            for i in range(m - length):  # start of interval
                j = i + length  # end of interval
                dp[i][j] = float('inf')
                for k in range(i + 1, j):  # possible cut points between i and j
                    cost = cuts[j] - cuts[i] + dp[i][k] + dp[k][j]
                    dp[i][j] = min(dp[i][j], cost)

        # Step 4: Return the minimum cost to cut the entire stick
        return dp[0][m - 1]

1.8. Coin Change

class Solution:
    def coinChange(self, coins: List[int], amount: int) -> int:
        n = len(coins)

        # Number of way to have amount i
        dp = [float('inf')] * (amount + 1)
        dp[0] = 0

        for w in range(1, amount + 1):
            for i in range(n):
                if coins[i] <= w:
                    dp[w] = min(dp[w], dp[w - coins[i]] + 1)

        return dp[amount] if dp[amount] != float('inf') else -1

1.8. Coin Change 2 (Dynamic Programming Count Sum)

  • Bài count sum thì chạy 2 vòng for. For num bên ngoài, weight bên trong.
class Solution:
    def change(self, amount: int, coins: List[int]) -> int:
        # Init n of the amount
        n = len(coins)

        # Count way so init 0
        dp = [0] * (amount + 1)
        dp[0] = 1 # Do not get any coins

        for coin in coins:
            for w in range(coin, amount + 1):
                dp[w] += dp[w - coin]

        return dp[amount]

Fibonacci Pattern

1.9. Jump Game II

from typing import List

class Solution:
    def jump(self, nums: List[int]) -> int:
        n = len(nums)

        # Minimum number to jump to ith
        dp = [float('inf')] * n
        dp[0] = 0

        for i in range(1, n):
            for j in range(i):
                if j + nums[j] >= i:  # Can we jump from j to i?
                    dp[i] = min(dp[i], dp[j] + 1)

        return dp[n - 1]

1.10. Min Cost Climbing Stairs

  • dp[i] represents min cost to reach step i
from typing import List

class Solution:
    def minCostClimbingStairs(self, cost: List[int]) -> int:
        n = len(cost)
        dp = [0] * (n + 1)  # dp[i] represents min cost to reach step i

        for i in range(2, n + 1):
            dp[i] = min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2])

        return dp[n]

1.11. House Robber

  • Backward: n + 1.

  • Hiện tại thì n thôi

class Solution:
    def rob(self, nums: List[int]) -> int:
        n = len(nums)
        # Find max -> init 0
        # dp is max value the thief can get until the home i
        if n == 0:
            return 0
        if n == 1:
            return nums[0]

        dp = [0] * n
        dp[0] = nums[0]
        dp[1] = max(nums[0], nums[1])

        for i in range(2, n):
            # dp[i - 1] là skip nhà ith
            # dp[i - 2] + nums[i] là lấy nhà i - 2 và i
            dp[i] = max(dp[i - 1], dp[i - 2] + nums[i])

        return dp[n - 1]

Notes:

  • Cứ capacity thì capacity + 1.

  • Loop trong array thì n.


Notes:

  • Cái chuỗi liên tục (continuous) thì Sliding Window or Longest substring with K character -> You can pop to shrink the string -> Sliding Window (left to right), cái nào based trên quá khứ được dùng Sliding Window.

  • Bớt element trong chuỗi liên tục -> Palindrome -> Dynamic Programming => Cái gì cần tương lai thì dùng pattern + Dynamic Programming.

1.12. Longest Palindromic Substring

  • s[i..j] is a palindrome if:

    • s[i] == s[j] and

    • dp[i+1][j-1] == True (i.e., the inner substring is a palindrome)

class Solution:
    def longestPalindrome(self, s: str) -> str:
        n = len(s)
        if n < 2:
            return s

        # Create a 2D table to store palindrome truth values
        dp = [[False] * n for _ in range(n)]

        start = 0       # start index of longest palindrome
        max_len = 1     # length of longest palindrome

        # All substrings of length 1 are palindromes
        for i in range(n):
            dp[i][i] = True

        # Check all substring lengths from 2 to n
        for length in range(2, n + 1):           # substring length
            for i in range(n - length + 1):      # starting index
                j = i + length - 1               # ending index

                if s[i] == s[j]:
                    if length == 2 or dp[i + 1][j - 1]:
                        dp[i][j] = True
                        if length > max_len:
                            start = i
                            max_len = length

        return s[start:start + max_len]

  • Still O(N^2), but Brute Force need O(N) to check palindrome -> But dynamic programming reuse the previous calculation.

1.13. Longest Substring with K Character

class Solution:
    def longest_substring_with_k_distinct(self, str, k):
        # Given a string, find the length of the longest substring in it with no more than K distinct characters.
        windowStart = 0
        maxLength = 0
        charFrequency = {} # store the frequence count of character

        # in the following loop we'll try to extend the range [windowStart, windowEnd]
        for windowEnd in range(0, len(str)):
            endChar = str[windowEnd]
            charFrequency[endChar] = charFrequency.get(endChar, 0) + 1

            # shrink the window until we are left with k distinct characters
            # in the charFrequency dictionary
            while len(charFrequency) > k:
                startChar = str[windowStart]
                charFrequency[startChar] -= 1

                if charFrequency[startChar] == 0:
                    del charFrequency[startChar]
                windowStart += 1

            maxLength = max(maxLength, windowEnd - windowStart + 1)

        return maxLength
def longest_substring_k_distinct_dp(s: str, k: int) -> int:
    n = len(s)
    dp = [[0]*n for _ in range(n)]
    max_len = 0

    for i in range(n):
        freq = {}
        for j in range(i, n):
            freq[s[j]] = freq.get(s[j], 0) + 1
            if len(freq) <= k:
                dp[i][j] = j - i + 1
                max_len = max(max_len, dp[i][j])
    return max_len

1.14. Longest Palindromic Subsequence (Subsequence có thể bỏ được)

  • dp[i][j] => mean that is the maximum size palindrome until [i:j].

  • if s[i] == s[j]: dp[i][j] = 2 + dp[i + 1][j - 1], cộng thêm 2 giá trị đầu cuối vào chuỗi hiện có => vẫn là chuỗi palindrome.

  • else: dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]) => bỏ đầu hoặc cuối

class Solution:
    def longestPalindromeSubseq(self, s: str) -> int:
        n = len(s)
        # Create a 2D DP array initialized to 0
        dp = [[0] * n for _ in range(n)]

        # All substrings of length 1 are palindromes of length 1
        for i in range(n):
            dp[i][i] = 1

        # Build the DP table
        for length in range(2, n + 1):  # Substring lengths from 2 to n
            for i in range(n - length + 1):
                j = i + length - 1
                if s[i] == s[j]:
                    dp[i][j] = 2 + dp[i + 1][j - 1]
                else:
                    dp[i][j] = max(dp[i + 1][j], dp[i][j - 1])

        return dp[0][n - 1]

1.15. Longest Common Substring (Không based quá khứ được, 2 chuỗi khác nhau)

  • Không based quá khứ được, 2 chuỗi khác nhau -> Dynamic Programming
def longest_common_substring(s1: str, s2: str) -> int:
    n1, n2 = len(s1), len(s2)
    dp = [[0] * (n2 + 1) for _ in range(n1 + 1)]
    max_len = 0

    for i in range(1, n1 + 1):
        for j in range(1, n2 + 1):
            if s1[i - 1] == s2[j - 1]:
                dp[i][j] = 1 + dp[i - 1][j - 1]
                max_len = max(max_len, dp[i][j])

    return max_len

s1 = "geeksforgeeks"
s2 = "practicewritegeekscourses"
print(longest_common_substring(s1, s2))

1.16. Longest Common Subsequence (có thể bỏ i, hoặc j)

class Solution:
    def longestCommonSubsequence(self, s1: str, s2: str) -> int:
        m, n = len(s1), len(s2)
        dp = [[0] * (n + 1) for _ in range(m + 1)]

        for i in range(1, m + 1):
            for j in range(1, n + 1):
                if s1[i - 1] == s2[j - 1]:
                    dp[i][j] = dp[i - 1][j - 1] + 1
                else:
                    dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])

        return dp[m][n]

2. Stack

Use stack when:

  • Nested structures

  • Undo/rollback

  • Balancing / matching elements (like parentheses)

  • Monotonic order tracking (next greater/smaller)

2.1. Basic Calculator

Example: “-3+2”

- First char is '-', so sign = '+' won’t apply right away.
- It sees '-' as the first actual operator, then reads '3', and processes sign == '-', so it appends -3.
class Solution:
    def calculate(self, s: str) -> int:
        def helper(chars):
            num = 0
            sign = '+'
            stack = []
            while chars:
                ch = chars.pop(0)

                if ch.isdigit():
                    num = num * 10 + int(ch)

                if ch == '(':
                    num = helper(chars)

                # not digit -> sign or not chars
                if ch in '+-*/)' or not chars:
                    if sign == '+':
                        stack.append(num)
                    elif sign == '-':
                        stack.append(-num)
                    elif sign == '*':
                        stack.append(stack.pop() * num)
                    elif sign == '/':
                        prev = stack.pop()
                        stack.append(int(prev / num))  # Truncate toward zero

                    sign = ch
                    num = 0

                    if ch == ')':
                        break

            return sum(stack)

        return helper(list(s))

2.2 Design LRU

class Node:
    def __init__(self, key: int, value: int):
        self.key = key
        self.value = value
        self.prev = None
        self.next = None

class LRUCache:

    def __init__(self, capacity: int):
        self.capacity = capacity
        self.cache = {}  # key -> Node
        self.head = None  # LRU
        self.tail = None  # MRU

    def _remove(self, node: Node):
        """Remove a node from the linked list."""
        if node.prev:
            node.prev.next = node.next
        else:
            self.head = node.next  # node was head

        if node.next:
            node.next.prev = node.prev
        else:
            self.tail = node.prev  # node was tail

    def _add_to_tail(self, node: Node):
        """Add node to the end (MRU)."""
        node.prev = self.tail
        node.next = None
        if self.tail:
            self.tail.next = node
        self.tail = node

        if not self.head:
            self.head = node

    def get(self, key: int) -> int:
        if key not in self.cache:
            return -1

        node = self.cache[key]
        self._remove(node)
        self._add_to_tail(node)
        return node.value

    def put(self, key: int, value: int) -> None:
        if key in self.cache:
            node = self.cache[key]
            node.value = value
            self._remove(node)
            self._add_to_tail(node)
        else:
            if len(self.cache) >= self.capacity:
                # Remove LRU node
                lru = self.head
                self._remove(lru)
                del self.cache[lru.key]

            new_node = Node(key, value)
            self.cache[key] = new_node
            self._add_to_tail(new_node)

2.3. Queue using Stacks

class MyQueue:

    def __init__(self):
        self.in_stack = []
        self.out_stack = []

    def push(self, x: int) -> None:
        self.in_stack.append(x)

    def pop(self) -> int:
        self.peek()  # Ensure out_stack has the current front element
        return self.out_stack.pop()

    def peek(self) -> int:
        if not self.out_stack:
            while self.in_stack:
                self.out_stack.append(self.in_stack.pop())
        return self.out_stack[-1]

    def empty(self) -> bool:
        return not self.in_stack and not self.out_stack

1.20. Stack using Queues

from collections import deque

class MyStack:

    def __init__(self):
        self.queue = deque()

    def push(self, x: int) -> None:
        self.queue.append(x)
        # Rotate the queue so the last element becomes the front
        for _ in range(len(self.queue) - 1):
            self.queue.append(self.queue.popleft())

    def pop(self) -> int:
        return self.queue.popleft()

    def top(self) -> int:
        return self.queue[0]

    def empty(self) -> bool:
        return not self.queue

2.4. Evaluation of Postfix Expression

def evaluate_postfix(expression):
    stack = []
    tokens = expression.split()

    for token in tokens:
        if token.isdigit():
            stack.append(int(token))
        else:
            b = stack.pop()
            a = stack.pop()
            if token == '+':
                stack.append(a + b)
            elif token == '-':
                stack.append(a - b)
            elif token == '*':
                stack.append(a * b)
            elif token == '/':
                stack.append(int(a / b))  # Integer division

    return stack[0]

# Example usage
expr = "5 1 2 + 4 * + 3 -"
print(evaluate_postfix(expr))  # Output: 14

2.5. Evaluation of Prefix Expression

def evaluate_prefix(expression):
    stack = []
    tokens = expression.split()[::-1]  # reverse for right-to-left

    for token in tokens:
        if token.isdigit():
            stack.append(int(token))
        else:
            a = stack.pop()
            b = stack.pop()
            if token == '+':
                stack.append(a + b)
            elif token == '-':
                stack.append(a - b)
            elif token == '*':
                stack.append(a * b)
            elif token == '/':
                stack.append(int(a / b))  # integer division

    return stack[0]

# Example usage
expr = "+ 9 * 2 3"
print(evaluate_prefix(expr))  # Output: 15

2.6. Min Stack

class MinStack:

    def __init__(self):
        self.stack = []       # Main stack
        self.min_stack = []   # Stack to track current min at each level

    def push(self, val: int) -> None:
        self.stack.append(val)
        # Push the new min (either val or current min)
        if not self.min_stack or val <= self.min_stack[-1]:
            self.min_stack.append(val)
        else:
            self.min_stack.append(self.min_stack[-1])

    def pop(self) -> None:
        self.stack.pop()
        self.min_stack.pop()

    def top(self) -> int:
        return self.stack[-1]

    def getMin(self) -> int:
        return self.min_stack[-1]

2.7. Max Stack

class MaxStack:

    def __init__(self):
        self.stack = []
        self.max_stack = []

    def push(self, val: int) -> None:
        self.stack.append(val)
        if not self.max_stack or val >= self.max_stack[-1]:
            self.max_stack.append(val)
        else:
            self.max_stack.append(self.max_stack[-1])

    def pop(self) -> int:
        self.max_stack.pop()
        return self.stack.pop()

    def top(self) -> int:
        return self.stack[-1]

    def getMax(self) -> int:
        return self.max_stack[-1]

    def empty(self) -> bool:
        return not self.stack

2.8. Decode String

Notes: curr_str = prev_str + curr_str * prev_num

 elif char == "]":
    prev_num = stack.pop()
    prev_str = stack.pop()
    # Note
    curr_str = prev_str + curr_str * prev_num
class Solution:
    def decodeString(self, s: str) -> str:
        stack = []
        curr_num = 0
        curr_str = ""

        for char in s:
            if char.isdigit():
                curr_num = curr_num * 10 + (int(char))
            elif char == "[":
                stack.append(curr_str)
                stack.append(curr_num)
                curr_num = 0
                curr_str = ""
            elif char == "]":
                prev_num = stack.pop()
                prev_str = stack.pop()
                # Note
                curr_str = prev_str + curr_str * prev_num
            # Case char is character
            else:
                curr_str += char

        return curr_str

2.9. Car Fleet

class Solution:
    def carFleet(self, target: int, position: List[int], speed: List[int]) -> int:
        # Zip and sort by position
        cars = sorted(zip(position, speed), reverse=True)

        # The car closest to target is process first, and other cars will pass it
        times = [(target - pos) / spd for pos, spd in cars]

        # Count fleets
        fleets = 0
        curr_time = 0

        for time in times:
            if time > curr_time:
                fleets += 1
                curr_time = max(curr_time, time)

        return fleets

2.10. Generate Parentheses

  • Add “(“ to the string as much as possible, and after add “(“, we add “)”
class Solution:
    def generateParenthesis(self, n: int) -> List[str]:
        res = []

        def backtrack(curr_str, open_count, close_count):
            if len(curr_str) == 2 * n:
                res.append(curr_str)
                return

            if open_count < n:
                backtrack(curr_str + "(", open_count + 1, close_count)

            if close_count < open_count:
                backtrack(curr_str + ")", open_count, close_count + 1)

        backtrack("", 0, 0)
        return res

2.11. Longest Valid Parentheses

  • Store last “(“ of the character.
class Solution:
    def longestValidParentheses(self, s: str) -> int:
        max_len = 0
        # Magic here to trick case ()
        # Store last index of '('
        stack = [-1]

        for i, char in enumerate(s):
            if char == '(':
                stack.append(i)
            else:
                stack.pop()
                if stack:
                   max_len = max(max_len, i - stack[-1])
                else:
                    stack.append(i)

        return max_len


2.12. Next Greater Element

Notes: Loop from left to right, if nums[i] > the last stack => Update the index of greater than last stack is nums[i]

def nextGreaterElement(nums):
  n = len(nums)
  result = [-1] * n
  stack = []

  for i in range(n):
    while stack and nums[i] > nums[stack[-1]]:
      idx = stack.pop()
      result[idx] = nums[i]
    stack.append(i)

  return result

2.12. Next Smaller Element

Notes: Loop from left to right, if nums[i] < the last stack => Update the index of greater than last stack is nums[i]

def nextSmallerElement(nums):
  n = len(nums)
  result = [-1] * n
  stack = []

  for i in range(n):
    while stack and nums[i] < nums[stack[-1]]:
      idx = stack.pop()
      result[idx] = nums[i]
    stack.append(i)

  return result

2.13. Daily Temperatures

class Solution:
    def dailyTemperatures(self, temperatures: List[int]) -> List[int]:
        n = len(temperatures)

        stack = []
        result = [0] * n

        for i in range(n):
            while stack and temperatures[i] > temperatures[stack[-1]]:
                top = stack.pop()
                result[top] = i - top

            stack.append(i)

        return result

2.14. Largest Rectangle in Histogram

  • Find the first element smaller than the current row => Area.

  • Find the max area from the left and the right too.

  • Compare in both left and right max

Specical case: [2,4,6]

class Solution:
    def largestRectangleArea(self, heights: List[int]) -> int:
        stack = []
        max_area = 0
        i = 0
        n = len(heights)
        while i < n:
            if not stack or heights[i] >= heights[stack[-1]]:
                stack.append(i)
                i += 1
            else:
                top = stack.pop()
                right = i - 1
                left = stack[-1] if stack else -1
                area = heights[top] * (right - left)
                max_area = max(max_area, area)

        # Second pass: clean up remaining elements in stack
        # Make sure the stack is increment (2,4,6)
        while stack:
            top = stack.pop()
            right = n - 1
            left = stack[-1] if stack else -1
            area = heights[top] * (right - left)
            max_area = max(max_area, area)

        return max_area

3. Binary Search

3.1. Koko Eating Bananas

import math

class Solution:
    def minEatingSpeed(self, piles: List[int], h: int) -> int:
        # Greedy in range [1, max(piles)]
        left, right = 1, max(piles)

        def canEat(speed):
            count = 0
            for banana in piles:
                count += math.ceil(banana / speed)

            return count <= h

        while left <= right:
            mid = (left + right) // 2
            if canEat(mid):
                right = mid - 1
            else:
                left = mid + 1

        return left

3.2. Search in Rotated Sorted Array

  • Search the target in array using O(logN)

  • Idea: Using Binary Search of Binary Search

Input: nums = [4,5,6,7,0,1,2], target = 0
Output: 4
Input: nums = [4,5,6,7,0,1,2], target = 3
Output: -1
class Solution:
    def search(self, nums: List[int], target: int) -> int:
        if not nums:
            return -1

        left, right = 0, len(nums) - 1

        while left <= right:
            mid = (left + right) // 2
            if nums[mid] == target:
                return mid

            if nums[left] <= nums[mid]:
                if nums[left] <= target < nums[mid]:
                    right = mid - 1
                else:
                    left = mid + 1

            else:
                if nums[mid] < target <= nums[right]:
                    left = mid + 1
                else:
                    right = mid - 1

        return -1

4. Merge Interval (Sort theo start time)

4.1. Check Overlap Interval (Can Attend Meeting)

class Solution:
    def canAttendMeetings(self, intervals: List[List[int]]):
        # Sort by start time
        intervals.sort(key=lambda x:x[0])

        n = len(intervals)

        # Check validate
        for i in range(1, n):
            # Overlap
            if intervals[i][0] < intervals[i - 1][1]:
                return False

        return True

4.2. Merge Interval

  • Sort by start time:
    • If the start < last merge (overlap) => merge to the last.
    • Else: append to the list.
def mergeIntervals(intervals):
  sortedIntervals = sorted(intervals, key=lambda x: x[0])
  merged = []

  for interval in sortedIntervals:
    if not merged or interval[0] > merged[-1][1]:
      merged.append(interval)
    else:
      merged[-1][1] = max(interval[1], merged[-1][1])

  return merged

4.3. Non-Overlapping

  • Sort by end => check the overlap with end
def nonOverlappingIntervals(intervals):
  if not intervals:
    return 0

  intervals.sort(key=lambda x: x[1])
  end = intervals[0][1]
  count = 1

  for i in range(1, len(intervals)):
    # Non-overlapping interval found
    if intervals[i][0] >= end:
      end = intervals[i][1]
      count += 1

  return len(intervals) - count

4.4. Insert Intervals

class Solution:
    def insertIntervals(self, intervals: List[List[int]], newInterval: List[int]):
        intervals.append(newInterval)
        intervals.sort(key=lambda x:x[0])
        n = len(intervals)
        merged = []

        for i in range(0, n):
            # Do not overlap
            if not merged or intervals[i][0] > merged[-1][1]:
                merged.append(intervals[i])
            else:
                merged[-1][1] = max(merged[-1][1], intervals[i][1])

        return merged

4.5. Non-overlap Intervals

  • intervals = [[1,3], [2,4], [3,5]]

=> Can not compare intervals[i][0] >= intervals[i - 1][1] => because if we skip the interval[i - 1], it will miss to select the interval[i].

class Solution:
    def nonOverlappingIntervals(self, intervals: List[List[int]]):
        if not intervals:
            return 0

        # Sort by end time
        intervals.sort(key=lambda x:x[1])

        # len of intervals
        n = len(intervals)
        count = 1
        end = intervals[0][1]

        # Check the non-overlapping range
        for i in range(1, n):
            # The overlap interval
            if intervals[i][0] >= end:
                end = intervals[i][1]
                count += 1

        return n - count

4.6. Employee Free Time

def employeeFreeTime(intervals):
    intervals.sort(key=lambda x:x[0])
    n = len(intervals)
    merged = []

    for i in range(n):
        # Do not overlap
        if not merged or intervals[i][0] > merged[-1][1]:
            merged.append(intervals[i])
        else:
            merged[-1][1] = max(merged[-1][1], intervals[i][1])

    result = []
    for i in range(len(merged) - 1):
        result.append([merged[i][1], merged[i + 1][0]])

    return result


schedule = [[2,4],[7,10],[1,5],[6,9]]
print(employeeFreeTime(schedule))

5. Recursion

  • Rule 1: Luôn có basecase

  • Rule 2: Ví dụ với input nhỏ trước (Lấy ví dụ cái nhỏ nhất trước)

  • Rule 3: Không dùng external varibles, chỉ dùng parameter truyền vào recursive function => làm sao cái parameter đó nó càng gần base case hơn.

  • Rule 4: luôn assump là cái bước trước đó đúng => dùng cái đó chứng minh bước sau

  • Phân biệt Recursion and Tail Recursion:

    • Recursion: lưu vào stack call => go the basecase => compute back.
    • Tail Recursion: lưu trực tiếp trong hàm recursion => tới bước tail chỉ cần return không cần compute nữa.

6. Backtracking

6.1. Core Idea

  • Backtrack is still a tree => depth-first tree searching
function solvable(n):
    if n is a leaf node:
        if n is a goal node, return true
        else return false
    else:
        for each child c of n:
            if solvable(c): return true
        return false

How does this work?

- If any child of n is solvable, then n is solvable.
- If no child of n is solvable, then n is not solvable.
boolean solve(Node n):
    put node n on the stack
    while the stack is not empty:
        topnode = the node at the top of the stack
        if topnode is a leaf:
            If it is a goal node, return true
            else pop it off the stack
        else:
            if topnode has untried children:
                push the next untried child onto the stack
            else pop the node off the stack
    return false

6.2. Prunning

Bản chất check isValid là 1 cách prunning

boolean explore1 (int country, Color color) {
    if (country >= map.length())
        return goodColoring();

    mapColors[country] = color;
    for (Color c: Color.values()) {
        if (explore1(country + 1, c)) {
            return true;
        }
    }
    mapColors[country] = Color.NONE;
    return false;
}
  • Prunning
boolean explore1 (int country, Color color) {
    if (country >= map.length())
        return goodColoring();

    if (okToColor(country, color)) {
        mapColors[country] = color;
        for (Color c: Color.values()) {
            if (explore1(country + 1, c)) {
                return true;
            }
        }
        mapColors[country] = Color.NONE;
    }
    return false;
}
  • Benchmark
Method 1: 2355638070 ns.
Method 2: 20516 ns.
function solvable(binaryTree):
    1. if node is null/None, return false
    2. if node is a goal node return true
    3. if solvable(node.leftChild), return true
    4. if solvable(node.rightChild), return true
    5. return false
class Node:
    def __init__(self, key):
        self.key = key
        self.left = None
        self.right = None

class BST:
    def __init__(self):
        self.root = None

    # Insert key into BST
    def insert(self, key):
        self.root = self._insert(self.root, key)

    def _insert(self, node, key):
        if node is None:
            return Node(key)
        if key < node.key:
            node.left = self._insert(node.left, key)
        elif key > node.key:
            node.right = self._insert(node.right, key)
        return node

    # Search for a key
    def search(self, key):
        return self._search(self.root, key)

    def _search(self, node, key):
        if node is None or node.key == key:
            return node
        if key < node.key:
            return self._search(node.left, key)
        return self._search(node.right, key)

    # In-order traversal (sorted order)
    def inorder(self):
        result = []
        self._inorder(self.root, result)
        return result

    def _inorder(self, node, result):
        if node:
            self._inorder(node.left, result)
            result.append(node.key)
            self._inorder(node.right, result)

6.3. Binary Search II

  • How to get the solution
def solve(node):
    """Find goal node and report path."""
    if node == None:
        return None

    if node.is_goal_node:
        return [node.name]

    temp = solve(node.left_child)

    if temp != None:
        return [node.name] + temp

    temp = solve(node.right_child)
    if temp != None:
        return [node.name] + temp
        return None

6.4. Concepts

  • Concepts for tree (k-tree)
To search from a node:
    if the node is a goal node,
        return success.
    for each child of the node:
        if searching from that child succeeds,
        return success.
    return failure.
  • Concepts for graph
To search from a node:

    if the node is a goal node,
        return success.

    if we've been at this node before,
        return failure.

    for each neighbor of the node:
        if searching from that neighbor succeeds,
            return success.
    return failure

6.5. Debug:

  • Print the order of function call.

  • Order of sample call

Entering solvable(Root)
| Entering solvable(A)
| | Entering solvable(C)
| | | Entering solvable(null)
| | | solvable(null) returns false
| | | Entering solvable(null)
| | | solvable(null) returns false
| | solvable(C) returns false
| | Entering solvable(D)
| | | Entering solvable(null)
| | | solvable(null) returns false
| | | Entering solvable(null)
| | | solvable(null) returns false
| | solvable(D) returns false
| solvable(A) returns false
| Entering solvable(B)
| | Entering solvable(E)
| | solvable(E) returns true
| solvable(B) returns true
solvable(Root) returns true

- Scan all the path:

class TreeNode:
    def __init__(self, val=0, left=None, right=None):
        self.val = val
        self.left = left
        self.right = right

# Build the tree
root = TreeNode(5)
root.left = TreeNode(4)
root.right = TreeNode(8)
root.left.left = TreeNode(11)
root.left.left.left = TreeNode(7)
root.left.left.right = TreeNode(2)
root.right.left = TreeNode(13)
root.right.right = TreeNode(4)

#        5
#       / \
#      4   8
#     /   / \
#   11  13  4
#   /  \
#  7    2

def findAllPaths(root, target):
    res = []

    def backtrack(node, path, total):
        if not node:
            return

        path.append(node.val)
        total += node.val
        print(f"Visited Node: {node.val}, Path: {path}, Sum: {total}")

        if not node.left and not node.right and total == target:
            print(f"✅ Found valid path: {path}")
            res.append(list(path))

        backtrack(node.left, path, total)
        backtrack(node.right, path, total)

        print(f"Backtracking from: {node.val}, Path before pop: {path}")
        path.pop()

    backtrack(root, [], 0)
    return res

paths = findAllPaths(root, 22)
print("All paths that sum to 22:", paths)

Output:

Visited Node: 5, Path: [5], Sum: 5
Visited Node: 4, Path: [5, 4], Sum: 9
Visited Node: 11, Path: [5, 4, 11], Sum: 20
Visited Node: 7, Path: [5, 4, 11, 7], Sum: 27
Backtracking from: 7, Path before pop: [5, 4, 11, 7]
Visited Node: 2, Path: [5, 4, 11, 2], Sum: 22
 Found valid path: [5, 4, 11, 2]
Backtracking from: 2, Path before pop: [5, 4, 11, 2]
Backtracking from: 11, Path before pop: [5, 4, 11]
Backtracking from: 4, Path before pop: [5, 4]
Visited Node: 8, Path: [5, 8], Sum: 13
Visited Node: 13, Path: [5, 8, 13], Sum: 26
Backtracking from: 13, Path before pop: [5, 8, 13]
Visited Node: 4, Path: [5, 8, 4], Sum: 17
Backtracking from: 4, Path before pop: [5, 8, 4]
Backtracking from: 8, Path before pop: [5, 8]
Backtracking from: 5, Path before pop: [5]
All paths that sum to 22: [[5, 4, 11, 2]]
  • Scan to find 1 path:
class TreeNode:
    def __init__(self, val=0, left=None, right=None):
        self.val = val
        self.left = left
        self.right = right

# Build the tree
root = TreeNode(5)
root.left = TreeNode(4)
root.right = TreeNode(8)
root.left.left = TreeNode(11)
root.left.left.left = TreeNode(7)
root.left.left.right = TreeNode(2)
root.right.left = TreeNode(13)
root.right.right = TreeNode(4)

def findOnePath(root, target):
    result = []

    def backtrack(node, path, total):
        if not node:
            return False

        path.append(node.val)
        total += node.val
        print(f"Visited Node: {node.val}, Path: {path}, Sum: {total}")

        if not node.left and not node.right and total == target:
            print(f"✅ Found valid path: {path}")
            result.extend(path)
            return True

        if backtrack(node.left, path, total) or backtrack(node.right, path, total):
            return True

        print(f"Backtracking from: {node.val}, Path before pop: {path}")
        path.pop()
        return False

    found = backtrack(root, [], 0)
    return result if found else None

# ✅ ACTUALLY CALL THE FUNCTION HERE
result = findOnePath(root, 22)
print("Final result:", result)

Output:

Visited Node: 5, Path: [5], Sum: 5
Visited Node: 4, Path: [5, 4], Sum: 9
Visited Node: 11, Path: [5, 4, 11], Sum: 20
Visited Node: 7, Path: [5, 4, 11, 7], Sum: 27
Backtracking from: 7, Path before pop: [5, 4, 11, 7]
Visited Node: 2, Path: [5, 4, 11, 2], Sum: 22
 Found valid path: [5, 4, 11, 2]
Final result: [5, 4, 11, 2]
  • Step 1: Base case

  • Step 2: Prunning

  • Step 3: Node

  • Step 4: Neighbor

Backtracking = DFS + Backtrack

class Solution:
    def exist(self, board: List[List[str]], word: str) -> bool:
        row, col = len(board), len(board[0])
        visited = set()

        directions = [(-1, 0), (1, 0), (0, -1), (0, 1)]

        def backtrack(r, c, index):
            # Base case
            if index == len(word):
                return True

            # Prunning
            if r < 0 or r >= row or c < 0 or c >= col:
                return False

            if (r, c) in visited:
                return False

            if board[r][c] != word[index]:
                return False

            # Node
            visited.add((r, c))
            index += 1

            # Neighbor
            for dr, dc in directions:
                nr = r + dr
                nc = c + dc
                if backtrack(nr, nc, index):
                    return True

            visited.remove((r, c))
            index -= 1
            return False

        for i in range(row):
            for j in range(col):
                if board[i][j] == word[0]:
                    visited.clear() # Reset after change 'B'
                    if backtrack(i, j, 0):
                        return True

        return False

6.7. Letter Combinations of a Phone (Đủ ký tự => Chỉ thêm path tại base case)

  • Subsets = Tree + DFS + Backtrack
class Solution:
    def letterCombinations(self, digits: str) -> List[str]:
        # Subsets = Tree + DFS + Backtrack

        phone = {
            "2": "abc",
            "3": "def",
            "4": "ghi",
            "5": "jkl",
            "6": "mno",
            "7": "pqrs",
            "8": "tuv",
            "9": "wxyz"
        }

        result = []

        def backtrack(index, path):
            # Base case
            if index == len(digits):
                if path:
                    result.append(''.join(path[:]))
                return

            # Prunning
            for char in phone[digits[index]]:
                # Node
                path.append(char)

                # Neighbor
                backtrack(index + 1, path)
                path.pop()

        backtrack(0, [])
        return result

6.8. Subsets (Không đủ ký tự => tăng index nhưng không thêm path)

  • Vẫn index + 1 => nhưng không include in path.
class Solution:
    def subsets(self, nums: List[int]):
        result = []

        def backtrack(index, path):
            # Base case
            if index == len(nums):
                result.append(path[:])
                return

            # Prunning

            # Node
            path.append(nums[index])

            # Neighbor
            backtrack(index + 1, path)

            # Backtrack
            path.pop()

            # Magic here
            backtrack(index + 1, path)


        backtrack(0, [])
        return result

6.9. Generate Parentheses

  • Step 1: Backtrack

  • Step 2: Prunning

  • Step 3: Node

  • Step 4: Neighbors

  • Step 5: Backtrack

class Solution:
    def generateParenthesis(self, n: int):
        result = []

        def backtrack(path, open_bracket, close_bracket):
            # Base case
            if open_bracket == n and close_bracket == n:
                result.append(''.join(path[:]))
                return

            # Prunning

            # Node
            if open_bracket < n:
                path.append('(')
                backtrack(path, open_bracket + 1, close_bracket)
                path.pop()

            # Neighbors
            if close_bracket < open_bracket:
                path.append(')')
                backtrack(path, open_bracket, close_bracket + 1)
                path.pop()

        backtrack([], 0, 0)
        return result

6.10. Combination Sum

  • For duplicate same num => For selection in a list => combination in loop, not outside.
class Solution:
    def combinationSum(self, candidates: List[int], target: int) -> List[List[int]]:
        result = []

        def backtrack(index, path, currSum):
            # Base case
            if currSum == 0:
                result.append(path[:])
                return

            # Prunning
            if index == len(candidates) or currSum < 0:
                return

            # Neighbor
            for i in range(index, len(candidates)):
                # Node
                path.append(candidates[i])
                currSum -= candidates[i]
                backtrack(i, path, currSum)

                # Backtrack
                path.pop()
                currSum += candidates[i]

        backtrack(0, [], target)
        return result

7. Tree

7.1. Path Sum:

  • Find only 1 path
class Solution:
    def pathSum(self, root: TreeNode, target: int) -> bool:
        def dfs(node, curr_sum):
            if not node:
                return False

            curr_sum += node.val

            # If it's a leaf node, check if the sum matches the target
            if not node.left and not node.right:
                # Find a function and return
                return curr_sum == target

            # Continue to left or right subtree
            return dfs(node.left, curr_sum) or dfs(node.right, curr_sum)

        return dfs(root, 0)

7.2. Count Good Nodes in Binary Tree:

  • Cover all
# Definition for a binary tree node.
# class TreeNode:
#     def __init__(self, val=0, left=None, right=None):
#         self.val = val
#         self.left = left
#         self.right = right
class Solution:
    def goodNodes(self, root: TreeNode) -> int:
        # Duyet het
        count = 0

        def dfs(node, prev_max_value):
            nonlocal count
            if not node:
                return

            prev_max_value = max(prev_max_value, node.val)
            if node.val == prev_max_value:
                count += 1

            dfs(node.left, prev_max_value)
            dfs(node.right, prev_max_value)

        dfs(root, root.val)
        return count

7.3. Count Path Sum

# Definition for a binary tree node.
# class TreeNode:
#     def __init__(self, val=0, left=None, right=None):
#         self.val = val
#         self.left = left
#         self.right = right
class Solution:
    def hasPathSum(self, root: Optional[TreeNode], targetSum: int) -> bool:
        if not root:
            return False

        def dfs(node, total):
            if not node:
                # In case [1,2] => When traversal right 1 -> null, targetSum = 1 => Count true, we do not allow it.
                return False

            # Calculate sum until val
            total += node.val

            # Only check when find in a leaf node
            if not node.left and not node.right:
                return total == targetSum

            left = dfs(node.left, total)
            right = dfs(node.right, total)
            # Return when found the result
            return left or right

        return dfs(root, 0)

7.4. Validate BST:

class Solution:
    def validateBST(self, root):
        def dfs(node, min_val, max_val):
            if not node:
                return True

            if not (min_val < node.val < max_val):
                return False

            return dfs(node.left, min_val, node.val) and dfs(node.right, node.val, max_val)

        return dfs(root, float('-inf'), float('inf'))

7.5. Calculate Tilt

  • Condition 1: Góc nhìn của node.

  • Condition 2: Góc nhìn đối với parent của node.

class Solution:
    def calculateTilt(self, root):
        # Scan all the tree
        tilt = 0
        def dfs(node):
            nonlocal tilt

            if not node:
                return 0

            # If I a node of the tree, I would return
            # Top-down
            left = dfs(node.left)
            right = dfs(node.right)
            tilt += abs(left - right)

            # return the sum of the current subtree
            # Bottom up
            return left + right + node.val

        dfs(root)
        return tilt

7.6. Path Sum

class Solution:
    def pathSum(self, nodes: TreeNode, target: int):
        def dfs(node, total):
            if not node:
                return total == target

            # Calculate node
            total += node.val

            # Prunning here
            if not node.left and not node.right and total == target:
                return True

            left = dfs(node.left, total)
            right = dfs(node.right, total)

            # Left Right trả gì cho root
            return left or right

        return dfs(nodes, 0)

7.7. Count Good Nodes in Binary Tree

# Definition for a binary tree node.
# class TreeNode:
#     def __init__(self, val=0, left=None, right=None):
#         self.val = val
#         self.left = left
#         self.right = right
class Solution:
    def goodNodes(self, root: TreeNode) -> int:
        count = 0
        def dfs(node, prev_max):
            nonlocal count
            if not node:
                return False

            # What node do
            if node.val >= prev_max:
                prev_max = node.val
                count += 1

            # What left right do for root
            left = dfs(node.left, prev_max)
            right = dfs(node.right, prev_max)
            return left and right

        dfs(root, root.val)
        return count

7.8. Validate Binary Search Tree

# Definition for a binary tree node.
# class TreeNode:
#     def __init__(self, val=0, left=None, right=None):
#         self.val = val
#         self.left = left
#         self.right = right
class Solution:
    def isValidBST(self, root: Optional[TreeNode]) -> bool:
        def dfs(node):
            if not node:
                return True

            # Step 1: Prunning
            if node.val < node.left or node.val > node.right:
                return False

            # Step 2: What node do
            # Continue to traversal to left and right

            # Step 3: What left and right do for node
            left = dfs(node.left)
            right = dfs(node.right)
            return left and right

        return dfs(root)

7.9. Binary Tree Tilt

  • Step 1: Basecase
  • Step 2: Prunning
  • Step 3: What node.val do
  • Step 4: What left right do for node
# Definition for a binary tree node.
# class TreeNode:
#     def __init__(self, val=0, left=None, right=None):
#         self.val = val
#         self.left = left
#         self.right = right
class Solution:
    def findTilt(self, root: Optional[TreeNode]) -> int:
        titl = 0

        def dfs(node):
            nonlocal titl

            # Step 1: Basecase
            if not node:
                return 0

            # Step 2: Prunning

            # Step 3: What node.val do
            # Calculate the left and right abs
            left = dfs(node.left)
            right = dfs(node.right)
            titl += abs(left - right)

            # Step 4: What left right do for node
            return left + right + node.val

        dfs(root)
        return titl

7.10. Diameter of a Binary Tree

  • Height is call from the leaf to the node.
class Solution:
    def maxDiameter(self, nodes: TreeNode):
        diameterLen = 0

        # Height from the leaf to the node
        def dfs(node):
            nonlocal diameterLen
            # Step 1: Base case
            if not node:
                return 0

            # Step 2: Prunning
            # No prunning

            # Step 3: What node val do
            # Find the max distance from its left and right
            left = dfs(node.left)
            right = dfs(node.right)
            # (Root -> right) + (Root -> left)
            diameterLen = max(diameterLen, left + right)

            # Step 4: Do left and right
            # Return the height of current subtree
            return 1 + max(left, right)

        dfs(nodes)
        return diameterLen

7.11. Path Sum II

  • Backtrack.

  • Basecase > Prunning > What Node do > What left and right do.

class Solution:
    def pathSum(self, root, target):
        res = []

        def dfs(node, path, curr_sum):
            nonlocal res
            # Base case
            if not node:
                return False

            # What node do
            path.append(node.val)
            curr_sum += node.val

            if not node.left and not node.right and curr_sum == target:
                res.append(path[:])

            # Prunning
            if curr_sum > target:
                return False

            # What left and right do
            left = dfs(node.left, path, curr_sum)
            right = dfs(node.right, path, curr_sum)

            # backtrack
            path.pop()
            return left and right

        dfs(root, [], 0)
        return res

7.12. Longest Univalue Path (Hay)

  • Step 1: Basecase
  • Step 2: Prunning
  • Step 3: What node.val do
  • Step 4: What left right do for node

  • Assump we already have the largest of the same value in left right
    subLeft = dfs(node.left)
    subRight = dfs(node.right)

    currLeft, currRight = 0, 0
    if node.left and node.left.val == node.val:
        currLeft = subLeft + 1
    if node.right and node.right.val == node.val:
        currRight = subRight + 1
    max_height = max(max_height, currLeft + currRight)

    # What left and right do
    return max(currLeft, currRight)
class Solution:
    def longestUnivaluePath(self, root: TreeNode):
        max_height = 0

        # Height from the leaf to curr_node
        def dfs(node):
            nonlocal max_height
            # Base case
            if not node:
                return 0

            # Prunning
            # No prunning

            # What node do
            # The max of same value from left and from right
            subLeft = dfs(node.left)
            subRight = dfs(node.right)

            currLeft, currRight = 0, 0
            if node.left and node.left.val == node.val:
                currLeft = subLeft + 1
            if node.right and node.right.val == node.val:
                currRight = subRight + 1
            max_height = max(max_height, currLeft + currRight)

            # What left and right do
            return max(currLeft, currRight)

        dfs(root)
        return max_height

8. Graph

8.1. Adjacency List

  • Step 1: Basecase

  • Step 2: Visit node

  • Step 3: Visit neighbors

adjList = {
    "1": ["2", "4"],
    "2": ["1", "3"],
    "3": ["2", "4"],
    "4": ["1", "3", "5"],
    "5": ["4"]
}
def dfs(adjList):
    visited = set()
    def dfs_helper(node):
        if node in visited:
            return

        # Visit node
        print("Visit node: ", node)
        visited.add(node)
        for neighbor in adjList[node]:
            dfs_helper(neighbor)

    # Ensure the unconnected graph is still cover
    for node in adjList:
        if node not in visited:
            dfs_helper(node)

adjList = {
    "1": ["2", "4"],
    "2": ["1", "3"],
    "3": ["2", "4"],
    "4": ["1", "3", "5"],
    "5": ["4"]
}
dfs(adjList)

8.2. Copy Graph

from typing import Dict, List

class IntGraphNode:
    def __init__(self, value = 0, neighbors = None):
        self.value = value
        self.neighbors = neighbors if neighbors is not None else []

class Solution:
    def copy_graph(self, node: IntGraphNode) -> Dict[int, List[int]]:
        def dfs(root):
            visited = set()
            result = {}

            def dfs_helper(node):
                if node.value in visited:
                    return

                visited.add(node.value)
                result[node.value] = [neighbor.value for neighbor in node.neighbors]

                for neighbor in node.neighbors:
                    dfs_helper(neighbor)

            if root:
                dfs_helper(root)
            return result

        return dfs(node)

8.3. Graph Valid Tree

  • Graph connected.

  • But no cycle.

    • Idea: Visit DFS and check we have visited the parent again.

    • Visited đại từ 1 node nào cũng được => nếu tất cả đều connected component thì có thể visited được hết.

  • Using defaultdict for init.

  • Allow to visit a dup again, as long as it is not a parent.

from collections import defaultdict

class Solution:
    def graph_valid_tree(self, n, edges):
        graph = defaultdict(list)
        for start, end in edges:
            graph[start].append(end)
            graph[end].append(start)

        visited = set()
        def isCycle(node, parent):
            visited.add(node)
            for neighbor in graph[node]:
                # [0,1] and [1,0] is ok
                if neighbor == parent:
                    continue
                # Prunning => True xong không Prunning xuống nữa
                if neighbor in visited:
                    return True
                if isCycle(neighbor, node):
                    return True
            return False

        # Check not cycle & connected
        return not isCycle(0, 0) and len(visited) == n

8.4. Matrix DFS

  • Step 1: Basecase

  • Step 2: Prunning

  • Step 3: Node

  • Step 4: Neighbor

def dfs(matrix):
    visited = set()

    # Cứ tưởng tượng toạ độ trên trục Oxy (0, 0)
    directions = [(-1, 0), (1, 0), (0, -1), (0, 1)]

    def dfs_helper(r, c):
        # Basecase
        if (r, c) in visited:
            return

        # Prunning
        if r < 0 or r >= len(matrix) or c < 0 or c >= len(matrix[0]):
            return

        # Neighbor
        visited.add((r, c))
        print("Visit:", (r, c))
        for dr, dc in directions:
            dfs_helper(r + dr, c + dc)

    dfs_helper(0, 0)

matrix = [
    [0, 1, 0],
    [1, 0, 1],
    [0, 1, 0]
]
dfs(matrix)

8.5. Flood Fill

  • Step 1: Basecase

  • Step 2: Prunning

  • Step 3: Node

  • Step 4: Neighbor

class Solution:
    def flood_fill(self, image, sr, sc, color):
        m, n = len(image), len(image[0])
        # Visited
        visited = set()

        # Directions
        directions = [(-1, 0), (1, 0), (0, -1), (0, 1)]

        def dfs(r, c, prev_color, color):
            # Base case
            if (r, c) in visited:
                return

            # Prunning
            if r < 0 or r >= m or c < 0 or c >= n:
                return

            if image[r][c] != prev_color or image[r][c] == color:
                return

            # Node
            image[r][c] = color

            # Neighbor
            for dr, dc in directions:
                dfs(r + dr, c + dc, prev_color, color)

        dfs(sr, sc, image[sr][sc], color)
        return image

8.6. Number of Islands

  • Step 1: Basecase

  • Step 2: Prunning

  • Step 3: Node

  • Step 4: Neighbor

  • Notes: Logic check (r, c) in visited only match when the dfs call to the (r, c) => So we still need to check the visited outside too.

class Solution:
    def number_of_islands(self, grid):
        row, col = len(grid), len(grid[0])
        visited = set()

        # directions
        directions = [(-1, 0), (1, 0), (0, -1), (0, 1)]

        def dfs(r, c):
            # Base case
            # (0, 1) is already in visited if it was reached by DFS from another cell.
            if (r, c) in visited:
                return

            # Prunning
            if r < 0 or r >= row or c < 0 or c >= col:
                return

            if grid[r][c] == 0:
                return

            # Node
            visited.add((r, c))

            # Neighbor
            for dr, dc in directions:
                dfs(r + dr, c + dc)

        count = 0
        for i in range(row):
            for j in range(col):
                if grid[i][j] == 1 and (i, j) not in visited:
                    dfs(i, j)
                    count += 1

        return count

8.7. Boundaries in the Matrix (Surrounded Regions)

  • DFS border ‘O’ to make is ‘S’.

  • Change another ‘O’ to ‘X’.

class Solution:
    def surrounded_regions(self, grid: List[List[str]]):
        if not grid or not grid[0]:
            return []
        row, col = len(grid), len(grid[0])
        visited = set()

        # Directions
        directions = [(-1, 0), (1, 0), (0, -1), (0, 1)]

        def dfs(r, c):
            # Base case
            if (r, c) in visited:
                return

            # Prunning
            if r < 0 or r >= row or c < 0 or c >= col:
                return

            if grid[r][c] != 'O':
                return

            # Node
            visited.add((r, c))
            grid[r][c] = 'S'

            # Check neighbor
            for dr, dc in directions:
                dfs(r + dr, c + dc)

        # Step 1: DFS in col 0 and col - 1
        for i in range(row):
            if grid[i][0] == 'O':
                dfs(i, 0)
            if grid[i][col - 1] == 'O':
                dfs(i, col - 1)

        # Step 2: DFS in row 0 and row - 1
        for j in range(col):
            if grid[0][j] == 'O':
                dfs(0, j)
            if grid[row - 1][j] == 'O':
                dfs(row - 1, j)

        # Step 3: Change another X to O
        for i in range(row):
            for j in range(col):
                if grid[i][j] == 'O':
                    grid[i][j] = 'X'
                elif grid[i][j] == 'S':
                    grid[i][j] = 'O'

        return grid

8.8. Pacific Atlantic Water Flow

  • Backtracking from border to check it contains both ocean.
class Solution:
    def pacific_atlantic_flow(self, grid: List[List[int]]):
        # Water from high to low
        # Top - left: Pacific
        # Bottom - Right: Atlantic

        if not grid or not grid[0]:
            return []

        # Get row, col
        row, col = len(grid), len(grid[0])

        # Visited
        pacific = set()
        atlantic = set()

        # directions
        directions = [(-1, 0), (1, 0), (0, -1), (0, 1)]

        def dfs(r, c, visited, prev_height):
            # Base case
            if (r, c) in visited:
                return

            # Prunning
            if r < 0 or r >= row or c < 0 or c >= col:
                return

            # If the height is larger than the curr_height => this water flow to the ocean
            if grid[r][c] < prev_height:
                return

            # Node
            visited.add((r, c))

            # Neighbor
            for dr, dc in directions:
                dfs(r + dr, c + dc, visited, grid[r][c])

        # Check in top and bottom row
        for j in range(col):
            dfs(0, j, pacific, grid[0][j])
            dfs(row - 1, j, atlantic, grid[row - 1][j])

        # Check in left and right col
        for i in range(row):
            dfs(i, 0, pacific, grid[i][0])
            dfs(i, col - 1, atlantic, grid[i][col - 1])

        # Count both in pacific and atlantic
        result = []
        for i in range(row):
            for j in range(col):
                if (i, j) in pacific and (i, j) in atlantic:
                    result.append([i, j])

        return result

9. BFS

9.1. Implement BFS:

Ideas:

- Append to queue.

- Popleft
  • Step 1: Base case

  • Step 2: Root

  • Step 3: Neighbors

  • Step 4: Pop start of the queue.

from collections import deque

class TreeNode:
    def __init__(self, val=0, left=None, right=None):
        self.val = val
        self.left = left
        self.right = right

def bfs(root):
    # Base case
    if not root:
        return []

    # Root
    result = []
    queue = deque([root])

    # Neighbors
    while queue:
        # Add the node in start of the queue
        start = queue.popleft()
        result.append(start.val)

        # Add the left and right of start to queue
        if start.left:
            queue.append(start.left)

        if start.right:
            queue.append(start.right)

    return result

root = TreeNode(1)
root.left = TreeNode(2, TreeNode(4), TreeNode(5))
root.right = TreeNode(3, TreeNode(6))
print(bfs(root))

9.2. Level Order Sum

  • Step 1: Base case

  • Step 2: Root.

  • Step 3: Neighbors.

  • Step 4: Level of the queue.

  • Step 5: Pop start

from collections import deque

class Solution:
    def level_order_sum(self, root: TreeNode):
        # Base case
        if not root:
            return []

        # Root
        queue = deque([root])
        result = []

        # Neighbors
        while queue:
            # Level Queue
            level_size = len(queue)

            curr_sum = 0
            for _ in range(level_size):
                # Pop Start
                start = queue.popleft()
                curr_sum += start.val

                if start.left:
                    queue.append(start.left)

                if start.right:
                    queue.append(start.right)

            result.append(curr_sum)

        return result

9.3. Rightmost Node

  • Step 1: Base case

  • Step 2: Root

  • Step 3: Neighbors

  • Step 4: Level

  • Step 5: Popleft.

from collections import deque

class Solution:
    def rightmostNode(self, root: TreeNode):
        # Base case
        if not root:
            return []

        # Root
        queue = deque([root])
        result = []

        # Neighbors
        while queue:
            # Level size
            level_size = len(queue)

            for i in range(level_size):
                # Pop left
                start = queue.popleft()

                if i == level_size - 1:
                    result.append(start.val)

                if start.left:
                    queue.append(start.left)

                if start.right:
                    queue.append(start.right)

        return result

9.4. Zigzag Level Order

from collections import deque

class Solution:
    def zig_zag(self, root: TreeNode):
        # Base case
        if not root:
            return []

        # Root
        queue = deque([root])
        result = []
        odd = True

        # Neighbors
        while queue:
            # Level
            level_size = len(queue)
            curr_list = []
            for _ in range(level_size):
                # Pop left
                start = queue.popleft()
                curr_list.append(start.val)
                if start.left:
                    queue.append(start.left)
                if start.right:
                    queue.append(start.right)

            if odd:
                result.append(curr_list[:])
            else:
                result.append(curr_list[::-1])

            odd = not odd

        return result

9.5. Maximum Width of Binary Tree

  • Add the index of the node.
from collections import deque

class Solution:
    def maxWidth(self, root: TreeNode):
        # Base case
        if not root:
            return 0

        # Node
        queue = deque([(root, 0)])
        max_width = 0

        # Neighbor
        while queue:
            # Level
            level_size = len(queue)
            _, first_index = queue[0]
            last_index = -1
            for i in range(level_size):
                # Pop left
                start, index = queue.popleft()

                if i == level_size - 1:
                    last_index = index

                if start.left:
                    queue.append((start.left, 2 * index))
                if start.right:
                    queue.append((start.right, 2 * index + 1))

            max_width = max(max_width, last_index - first_index + 1)

        return max_width

9.6. BFS in Adjacency List

  • Step 1: Base case

  • Step 2: Visit node

  • Step 3: Visit neighbors

  • Step 4: Level

  • Step 5: Pop left

from collections import deque

def bfs(adjList, root):
    # Base case

    # Root
    visited = set()
    queue = deque([root])

    # Neighbors
    while queue:
        # Popleft
        start = queue.popleft()

        print("Visited:", start)
        visited.add(start)

        # Neighbors
        for neighbor in adjList[start]:
            if neighbor not in visited:
                queue.append(neighbor)
                visited.add(neighbor)

adjList = {
    "1": ["2", "4"],
    "2": ["1", "3"],
    "3": ["2", "4"],
    "4": ["1", "3", "5"],
    "5": ["4"]
}

bfs(adjList, "1")

9.7. BFS in Matrix

from collections import deque

def bfs(grid, r, c):
    # Base case

    # Node
    visited = set()
    queue = deque([(r, c)])

    directions = [(-1, 0), (1, 0), (0, -1), (0, 1)]

    # Neighbors
    while queue:
        # Pop left
        start_row, start_col = queue.popleft()
        visited.add((start_row, start_col))

        print("Visited:", (start_row, start_col))

        # Neighbors
        for dr, dc in directions:
            n_row = start_row + dr
            n_col = start_col + dc

            # Prunning
            if n_row < 0 or n_row >= len(grid) or n_col < 0 or n_col >= len(grid[0]):
                continue

            if (n_row, n_col) in visited:
                continue

            queue.append((n_row, n_col))
            visited.add((n_row, n_col))


matrix = [
    [0, 0, 0],
    [0, 1, 1],
    [0, 1, 0]
]

bfs(matrix, 0, 0)

9.8. Adjacency List Level-By-Level

from collections import deque

def bfs(adjList, root):
    # Base case

    # Root
    visited = set()
    queue = deque(root)
    result = []

    # Neighbors
    while queue:
        # Level
        level_size = len(queue)
        temp = []

        for _ in range(level_size):
            # Pop left
            start = queue.popleft()
            visited.add(start)
            temp.append(start)

            # Neighbors
            for neighbor in adjList[start]:
                if neighbor not in visited:
                    queue.append(neighbor)
                    visited.add(neighbor)

        result.append(temp)

    return result

adjList = {
    "1": ["2", "4"],
    "2": ["1", "3"],
    "3": ["2", "4"],
    "4": ["1", "3", "5"],
    "5": ["4"]
}

print(bfs(adjList, "1"))

9.9. Matrix Level-By-Level

  • queue = deque([(r, c)]): The way to init a tuple
from collections import deque

def bfs(grid, r, c):
    # Base case

    # Node
    visited = set()
    # The way to init a tuple
    queue = deque([(r, c)])

    directions = [(-1, 0), (1, 0), (0, -1), (0, 1)]
    result = []

    # Neighbors
    while queue:
        level_size = len(queue)
        temp = []

        for _ in range(level_size):
            # Pop left
            start_row, start_col = queue.popleft()
            visited.add((start_row, start_col))
            temp.append((start_row, start_col))

            # Visited the neighbors
            for dr, dc in directions:
                n_row = start_row + dr
                n_col = start_col + dc

                # Prunning
                if n_row < 0 or n_row >= len(grid) or n_col < 0 or n_col >= len(grid[0]):
                    continue

                if (n_row, n_col) in visited:
                    continue

                queue.append((n_row, n_col))
                visited.add((n_row, n_col))

        result.append(temp)

    return result

matrix = [
    [0, 0, 0],
    [0, 1, 1],
    [0, 1, 0]
]
print(bfs(matrix, 0, 0))

9.10. Minimum Knight Moves

  • To count the level of the node in BFS, add the length to a node too.

  • level_size is total of of a level, find shortest path is the count of the level.

from collections import deque

class Solution:
    def minimum_knight_moves(self, x: int, y: int):
        def bfs(start_x, start_y):
            # Base case

            # Root
            visited = set()
            queue = deque([(start_x, start_y)])

            # Start with (0, 0) in Oxy axis
            directions = [(-1, 2), (-2, 1), (-2, -1), (-1, -2), (1, 2), (2, 1), (2, -1), (1, -2)]

            steps = 0

            # Neighbors
            while queue:
                level_size = len(queue)
                for _ in range(level_size):
                    # Pop left
                    x_idx, y_idx = queue.popleft()
                    visited.add((x_idx, y_idx))
                    if (x_idx, y_idx) == (x, y):
                        return steps

                    # Neighbors
                    for dr, dc in directions:
                        nx_idx = x_idx + dr
                        ny_idx = y_idx + dc

                        # Prunning
                        if (nx_idx, ny_idx) in visited:
                            continue

                        queue.append((nx_idx, ny_idx))
                        visited.add((nx_idx, ny_idx))

                steps += 1

            return -1

        return bfs(0, 0)

9.11. Rotting Oranges

  • BFS and change the value Fresh oranges to rottens oranges.

  • If the value is rottens continue to BFS.

  • Find the intitial rottens index first, only change fresh to rotten oranges.

from collections import deque

class Solution:
    def rotting_oranges(self, grid: List[List[str]]):
        if not grid or not grid[0]:
            return -1

        row, col = len(grid), len(grid[0])
        fresh_oranges = 0

        # Node
        visited = set()
        queue = deque()
        times = -1

        for i in range(row):
            for j in range(col):
                if grid[i][j] == 'R':
                    queue.append((i, j))
                    visited.add((i, j))
                elif grid[i][j] == 'F':
                    fresh_oranges += 1

        directions = [(-1, 0), (1, 0), (0, -1), (0, 1)]

        # Neighbors
        while queue:
            # Level (Count)
            level_size = len(queue)
            for _ in range(level_size):
                # Pop left
                start_row, start_col = queue.popleft()

                # Neighbors
                for dr, dc in directions:
                    n_row = start_row + dr
                    n_col = start_col + dc

                    # Prunning
                    if n_row < 0 or n_row >= row or n_col < 0 or n_col >= col:
                        continue

                    if (n_row, n_col) in visited:
                        continue

                    if grid[n_row][n_col] != 'F':
                        continue

                    grid[n_row][n_col] = 'R'
                    queue.append((n_row, n_col))
                    visited.add((n_row, n_col))
                    fresh_oranges -= 1
            times += 1

        return times if fresh_oranges == 0 else -1

9.12. Bus Routes

  • BFS

  • Count the level to find the shortest path

  • Step 1: Node

  • Step 2: Neighbor

  • Step 3: Level

  • Step 4: Popleft

from collections import defaultdict
from collections import deque

class Solution:
    def bus_routes(self, routes: List[List[int]], source: int, target: int):
        # Init
        graph = defaultdict(list)

        for route in routes:
            n = len(route)
            for i in range(n):
                for j in range(n):
                    if route[i] != route[j] and route[j] not in graph[route[i]]:
                        graph[route[i]].append(route[j])

        # Node
        visited = set()
        queue = deque([source])
        count = 0

        # Neighbor
        while queue:
            # Level
            level_size = len(queue)
            for _ in range(level_size):
                # Pop left
                start = queue.popleft()
                if start == target:
                    return count

                for neighbor in graph[start]:
                    # Prunning
                    if neighbor not in visited:
                        queue.append(neighbor)
                        visited.add(neighbor)
            count += 1

        return -1

9.13. 01-Matrix

from collections import deque
from typing import List

class Solution:
    def updateMatrix(self, mat: List[List[int]]) -> List[List[int]]:
        m, n = len(mat), len(mat[0])
        dist = [[-1 for _ in range(n)] for _ in range(m)]
        q = deque()

        # Step 1: Initialize the queue with all 0s
        for i in range(m):
            for j in range(n):
                if mat[i][j] == 0:
                    dist[i][j] = 0
                    q.append((i, j))

        # Step 2: BFS from all 0s
        directions = [(-1, 0), (1, 0), (0, -1), (0, 1)]
        count = 1

        while q:
            level_size = len(q)
            for _ in range(level_size):
                x, y = q.popleft()
                for dx, dy in directions:
                    nx, ny = x + dx, y + dy
                    if 0 <= nx < m and 0 <= ny < n and dist[nx][ny] == -1:
                        dist[nx][ny] = count
                        q.append((nx, ny))
            count += 1

        return dist

10. Backtracking

11. Divide and Conquer (quick sort, merge sort, pow x n)

11.1. Merge k Sorted Lists

  • Step 1: Base case

  • Step 2: Split Mid

  • Step 3: Merge 2 list

# Definition for singly-linked list.
# class ListNode:
#     def __init__(self, val=0, next=None):
#         self.val = val
#         self.next = next
class Solution:
    def mergeKLists(self, lists: List[Optional[ListNode]]) -> Optional[ListNode]:
        # Assump we have 50 lists => change it multiple [list1, list2], [list3, list4]
        # Merge range
        # Merge 2 lists
        if not lists:
            return None
        return self.merge_range(lists, 0, len(lists) - 1)


    def merge_range(self, lists, left, right):
        # Base case
        # Mid
        # Merge 2 list

        if left == right:
            return lists[left]

        mid = (left + right) // 2

        l1 = self.merge_range(lists, left, mid)
        l2 = self.merge_range(lists, mid + 1, right)
        return self.merge_two_lists(l1, l2)


    def merge_two_lists(self, l1, l2):
        dummy = ListNode()
        current = dummy
        while l1 and l2:
            if l1.val < l2.val:
                current.next = l1
                l1 = l1.next
            else:
                current.next = l2
                l2 = l2.next

            current = current.next

        current.next = l1 if l1 else l2
        return dummy.next

11.2. Merge Sort

def merge_sort(arr):
    # Base case: array of 0 or 1 element is already sorted
    if len(arr) <= 1:
        return arr

    # Split the array into two halves
    mid = len(arr) // 2
    left = merge_sort(arr[:mid])
    right = merge_sort(arr[mid:])

    # Merge the sorted halves
    return merge(left, right)

def merge(left, right):
    result = []
    i = j = 0

    # Merge two sorted arrays
    while i < len(left) and j < len(right):
        if left[i] <= right[j]:
            result.append(left[i])
            i += 1
        else:
            result.append(right[j])
            j += 1

    # Append any remaining elements
    result.extend(left[i:])
    result.extend(right[j:])
    return result

11.3. Max Subarray (Sliding Window - Hay):

from typing import List

class Solution:
    def maxSubArray(self, nums: List[int]) -> int:
        # Initialize current max and global max to the first element
        current_sum = max_sum = nums[0]

        # Iterate through the rest of the array
        for num in nums[1:]:
            # Either start new subarray at current num or extend previous one
            current_sum = max(num, current_sum + num)
            max_sum = max(max_sum, current_sum)

        return max_sum

11.4. Quick Sort

  • Using pivot to sort small < pivot < large.

  • Continue to subarray.

  • j đi sau i đi trước

  • Step 1: Find left - right.

  • Step 2: Find partition in subarray.

  • Step 3: Sort Partition.

def quick_sort(arr):
    def partition(low, high):
        pivot = arr[high]
        i = low - 1

        for j in range(low, high):
            if arr[j] <= pivot:
                i += 1
                arr[i], arr[j] = arr[j], arr[i]

        arr[i + 1], arr[high] = arr[high], arr[i + 1]
        return i + 1

    def quicksort_recursive(low, high):
        if low < high:
            pi = partition(low, high)
            quicksort_recursive(low, pi - 1)
            quicksort_recursive(pi + 1, high)

    quicksort_recursive(0, len(arr) - 1)
    return arr

11.5. Implement Quick Sort

  • Step 1: Find pivot.

  • Step 2: Recursive sort for each pivot of subarray (pivot1 of arr1, pivot2 of arr2)

def partition(arr, left, right):
    pivot = arr[right]
    i = left - 1

    for j in range(left, right):
        # Swap to after pivot
        # j đi sau i đi trước
        if arr[j] <= pivot:
            i += 1
            arr[i], arr[j] = arr[j], arr[i]

    # Swap pivot
    arr[i + 1], arr[right] = arr[right], arr[i + 1]
    return i + 1



def quick_sort_recursion(arr, left, right):
    if left < right:
        pivot_index = partition(arr, left, right)
        quick_sort_recursion(arr, left, pivot_index - 1)
        quick_sort_recursion(arr,  pivot_index + 1, right)


def quick_sort(arr):
    quick_sort_recursion(arr, 0, len(arr) - 1)

arr = [1, 7, 4, 1, 10, 9, 2]
quick_sort(arr)
print(arr)

11.6. Implement Merge Sort

def merge_two_lists(l1, l2):
    result = []
    i, j = 0, 0
    while i < len(l1) and j < len(l2):
        if l1[i] < l2[j]:
            result.append(l1[i])
            i += 1
        else:
            result.append(l2[j])
            j += 1

    result.extend(l1[i:])
    result.extend(l2[j:])
    return result

def merge_sort_recursive(arr, left, right):
    if left == right:
        return [arr[left]]

    mid = (left + right) // 2

    l1 = merge_sort_recursive(arr, left, mid)
    l2 = merge_sort_recursive(arr, mid + 1, right)

    return merge_two_lists(l1, l2)


def merge_sort(arr):
    if not arr:
        return []
    return merge_sort_recursive(arr, 0, len(arr) - 1)


arr = [1, 7, 4, 1, 10, 9, 2]
print(merge_sort(arr))

11.7. Pow(x, n):

  • Step 1: Base case

  • Step 2: Divide

  • Step 3: Conquer

class Solution:
    def myPow(self, x: float, n: int) -> float:
        def exponent(base, pow):
            # Base case
            if pow == 0:
                return 1.0

            # Divide
            half = exponent(base, pow // 2)

            # Conquer
            if pow % 2 == 0:
                return half * half
            else:
                return half * half * base

        if n < 0:
            x = 1 / x
            n = -n

        return exponent(x, n)

12. Dynamic Programming

12.1. Min Cost Climbing Stairs

  • To reach the ith => we need cost[i] + come from (i - 1) or (i - 2).
class Solution:
    def minCostClimbingStairs(self, cost: List[int]) -> int:
        n = len(cost)
        if n == 0:
            return cost[0]
        if n == 1:
            return cost[1]

        dp = [0] * (n + 1)
        dp[0] = cost[0]
        dp[1] = cost[1]

        for i in range(2, n + 1):
            dp[i] = (0 if i == n else cost[i]) + min(dp[i - 1], dp[i - 2])

        return dp[n]

12.2. Minimum Path Sum

  • Max value in (i, j) => dp[i][j] = grid[i][j] + min(dp[i - 1][j], dp[i][j - 1])

  • Remember to fill the first row and left column.

class Solution:
    def minPathSum(self, grid: List[List[int]]) -> int:
        row, col = len(grid), len(grid[0])

        dp = [[0] * col for _ in range(row)]

        # Init dp
        dp[0][0] = grid[0][0]

        # We can do it because we start from (0, 0)
        # Start first row
        for j in range(1, col):
            dp[0][j] = grid[0][j] + dp[0][j - 1]

        # Start left column
        for i in range(1, row):
            dp[i][0] = grid[i][0] + dp[i - 1][0]


        # Fill in another value
        for i in range(1, row):
            for j in range(1, col):
                dp[i][j] = grid[i][j] + min(dp[i - 1][j], dp[i][j - 1])

        return dp[row - 1][col - 1]

12.3. Coin Change.

  • Can choose between dp[x] and dp[x - coin] + 1 => I mean dp[x - coin] + 1 to reach the target coin.

  • If the coin that can not be make => it still forever (‘inf’ + 1 = ‘inf’)

  • Notes: amount + 1 => Because to reach amount

  • Step 1: Loop coin

  • Step 2: Loop target

class Solution:
    def coinChange(self, coins: List[int], amount: int) -> int:
        # Step 1: Loop coin
        # Step 2: Loop target
        dp = [float('inf')] * (amount + 1)
        dp[0] = 0 # Base case


        # amount + 1 => Because to reach amount
        for coin in coins:
            for i in range(coin, amount + 1):
                dp[i] = min(dp[i], dp[i - coin] + 1)

        return dp[amount] if dp[amount] != float('inf') else -1

12.4. Minimum Falling Path Sum (Hay)

  • Start from the row n - 2 to upward

  • Choose the min count until the last row.

  • Choose the min in the start of the matrix

from typing import List

class Solution:
    def minFallingPathSum(self, matrix: List[List[int]]) -> int:
        n = len(matrix)

        # Start from the second-last row and go upward
        for row in range(n - 2, -1, -1):
            for col in range(n):
                # Get values from the next row: directly below, left-diagonal, right-diagonal
                down = matrix[row + 1][col]
                left = matrix[row + 1][col - 1] if col > 0 else float('inf')
                right = matrix[row + 1][col + 1] if col < n - 1 else float('inf')

                # Update current cell with min falling path sum
                matrix[row][col] += min(down, left, right)

        # The answer is the min in the first row
        return min(matrix[0])

12.5. Minimum Cost For Tickets (Hay)

from typing import List

class Solution:
    def mincostTickets(self, days: List[int], costs: List[int]) -> int:
        day_set = set(days)
        last_day = days[-1]
        dp = [0] * (last_day + 1)

        for day in range(1, last_day + 1):
            if day not in day_set:
                dp[day] = dp[day - 1]  # no travel, cost stays the same
            else:
                dp[day] = min(
                    dp[max(0, day - 1)] + costs[0],  # 1-day ticket
                    dp[max(0, day - 7)] + costs[1],  # 7-day ticket
                    dp[max(0, day - 30)] + costs[2]  # 30-day ticket
                )
        return dp[last_day]

12.6. 2 Keys Keyboard (Hay nhưng khó)

class Solution:
    def minSteps(self, n: int) -> int:
        dp = [0] * (n + 1)
        # dp[i] = min number of steps to get i 'A's

        for i in range(2, n + 1):
            dp[i] = i  # worst case: all Paste after one Copy All at 1
            for j in range(i // 2, 1, -1):
                if i % j == 0:
                    dp[i] = dp[j] + (i // j)
                    break  # we want the largest factor to minimize steps

        return dp[n]

12.7. Perfect Squares (Giống bài chia coin)

import math

class Solution:
    def numSquares(self, n: int) -> int:
        # dp[i] will be the least number of perfect square numbers that sum to i
        dp = [float('inf')] * (n + 1)
        dp[0] = 0  # base case: 0 is made up of 0 numbers

        # Precompute all perfect squares less than or equal to n
        squares = [i * i for i in range(1, int(math.sqrt(n)) + 1)]

        for square in squares:
            for i in range(square, n + 1):
                dp[i] = min(dp[i], dp[i - square] + 1)

        return dp[n]

12.8. Last Stone Weight II (Each Stone use once)

  • Step 1: Loop by coin.

  • Step 2: Loop back from target

class Solution:
    def lastStoneWeightII(self, stones: List[int]) -> int:
        total = sum(stones)
        target = total // 2
        # dp[j] means the maximum sum we can get which is <= j
        dp = [0] * (target + 1)

        for stone in stones:
            for j in range(target, stone - 1, -1):
                dp[j] = max(dp[j], dp[j - stone] + stone)

        return total - 2 * dp[target]

12.9. Triangle (Giống Minimum Falling Path Sum)

class Solution:
    def minimumTotal(self, triangle: List[List[int]]) -> int:
        if not triangle:
            return 0

        # Start from the bottom row
        dp = triangle[-1][:]  # Copy the last row

        # Iterate from the second-to-last row upward
        for row in range(len(triangle) - 2, -1, -1):
            for col in range(len(triangle[row])):
                # Update dp[col] to be the min path sum from this cell
                dp[col] = triangle[row][col] + min(dp[col], dp[col + 1])

        return dp[0]  # Top element will contain the minimum path sum

Notes:

  • Unbounded Knapstack: Go forward because weight = 0 => dp[cap] = max(dp[cap], dp[cap - weight] + value) => if weight = 0 we can reuse

  • Knapstack 0/1: Go backward => dp[cap] = max(dp[cap], dp[cap - weight] + value) => cap - weight != cap => Due to weight start from end will different than 0.

  • Trùng thì forward, Khác thì backward.

  • Step 1: Loop coin.

  • Step 2: Loop target.

12.10. Ones and Zeroes

12.11. Maximal Square

12.12. Coin Change

  • Step 1: Init DP
  • Step 2: Loop coin
  • Step 3: Loop target

Here’s what happens:

dp[1] = min(dp[1], dp[0] + 1) → 1 coin

dp[2] = min(dp[2], dp[1] + 1) → 2 coins

dp[5] = min(dp[5], dp[4] + 1) → 5 coins

Each new dp[i] is built on top of the result from dp[i - coin], which may already include the same coin.

class Solution:
    def coinChange(self, coins: List[int], amount: int) -> int:
        # Step 1: Init DP
        dp = [float('inf')] * (amount + 1)
        dp[0] = 0

        # Step 2: Loop coin
        for coin in coins:
            # Step 3: Loop target
            for i in range(coin, amount + 1):
                dp[i] = min(dp[i], dp[i - coin] + 1)

        return dp[amount] if dp[amount] != float('inf') else -1

12.13. Maximal Square (Hay nhưng chưa hiểu)

class Solution:
    def maximalSquare(self, matrix: List[List[str]]) -> int:
        if not matrix:
            return 0

        rows, cols = len(matrix), len(matrix[0])
        # dp[i][j] will represent the size of the largest square ending at (i, j)
        dp = [[0] * (cols + 1) for _ in range(rows + 1)]
        max_side = 0

        for i in range(1, rows + 1):
            for j in range(1, cols + 1):
                if matrix[i - 1][j - 1] == '1':
                    dp[i][j] = min(
                        dp[i - 1][j],      # top
                        dp[i][j - 1],      # left
                        dp[i - 1][j - 1]   # top-left
                    ) + 1
                    max_side = max(max_side, dp[i][j])

        return max_side * max_side  # return area

12.14. Ones and Zeroes (Hay)

  • Step 1: DP

  • Step 2: Coin

  • Step 3: Target

  • Forward: coin -> n + 1

  • Backward: n -> coin - 1

class Solution:
    def findMaxForm(self, strs: List[str], m: int, n: int) -> int:
        # Step 1: DP
        dp = [[0] * (n + 1) for _ in range(m + 1)]

        # Step 2: Coin
        for s in strs:
            zeros = s.count('0')
            ones = s.count('1')
            # Step 3: Target
            for i in range(m, zeros - 1, -1):
                for j in range(n, ones - 1, -1):
                    dp[i][j] = max(dp[i][j], dp[i - zeros][j - ones] + 1)

        return dp[m][n]

Distinct Ways:

12.15. Climbing Stairs

  • To go to the step i => go 1 step from step i - 1 or go 2 steps from i - 2
class Solution:
    def climbStairs(self, n: int) -> int:
        # To go to the step i => go 1 step from step i - 1 or go 2 steps from i - 2
        dp = [0] * (n + 1)
        dp[0] = 1
        dp[1] = 1

        for i in range(2, n + 1):
            dp[i] = dp[i - 1] + dp[i - 2]

        return dp[n]

12.16. Unique Paths (Hay)

  • Find shortest path using bfs.

  • Count number of way using DP.

class Solution:
    def uniquePaths(self, m: int, n: int) -> int:
        dp = [[1] * n for _ in range(m)]

        for i in range(1, m):
            for j in range(1, n):
                dp[i][j] = dp[i - 1][j] + dp[i][j - 1]

        return dp[m - 1][n - 1]

12.17. Number of Dice Rolls With Target Sum (Hay - 2D DP - Note)

  • Step 1: Roll n times.

  • Step 2: Roll with value.

  • Step 3: Dynamic coin

  • Notes: Thường mấy này hơn nhau -1 lần thôi.

class Solution:
    def numRollsToTarget(self, n: int, k: int, target: int) -> int:
        dp = [[0] * (target + 1) for _ in range(n + 1)]
        dp[0][0] = 1

        MOD = 10**9 + 7

        for i in range(1, n + 1):
            for j in range(1, target + 1):
                for dice in range(1, k + 1):
                    if j - dice >= 0:
                        dp[i][j] = (dp[i][j] + dp[i - 1][j - dice]) % MOD

        return dp[n][target]

12.18. Knight Probability in Chessboard

  • if moves_left == 0 => return 1. Because knight is still on the board after k move

  • for m in range(1, k + 1): # 1️⃣ For each move from 1 to k for r in range(n): # 2️⃣ For each row on the board for c in range(n): # 3️⃣ For each column on the board for dr, dc in directions: # 4️⃣ For each possible knight move

class Solution:
    def knightProbability(self, n: int, k: int, row: int, column: int) -> float:
        directions = [
            (2, 1), (1, 2), (-1, 2), (-2, 1),
            (-2, -1), (-1, -2), (1, -2), (2, -1)
        ]

        # dp[m][r][c] = probability of being on cell (r, c) after m moves
        dp = [[[0] * n for _ in range(n)] for _ in range(k + 1)]
        dp[0][row][column] = 1  # Start at (row, column)

        for m in range(1, k + 1):         # 1️⃣ For each move from 1 to k
            for r in range(n):           # 2️⃣ For each row on the board
                for c in range(n):       # 3️⃣ For each column on the board
                    for dr, dc in directions:  # 4️⃣ For each possible knight move
                            prev_r, prev_c = r - dr, c - dc
                            if 0 <= prev_r < n and 0 <= prev_c < n:
                                dp[m][r][c] += dp[m - 1][prev_r][prev_c] / 8

        # Sum up all probabilities of being on the board after k moves
        total_prob = sum(dp[k][r][c] for r in range(n) for c in range(n))
        return total_prob

12.19. Target Sum (Note)

from typing import List

class Solution:
    from typing import List

class Solution:
    def findTargetSumWays(self, nums: List[int], target: int) -> int:
        total = sum(nums)
        if (total - target) % 2 != 0 or total < target:
            return 0
        neg = (total - target) // 2

        # Init DP
        dp = [0] * (neg + 1)
        dp[0] = 1

        # Loop coin
        for num in nums:
            # Select only 1 => backward
            for i in range(neg, num - 1, -1):
                if i >= num:
                    dp[i] = dp[i] + dp[i - num]

        return dp[neg]

12.20. Combination Sum IV (Note)

  • Number of ways is always plus

  • We change the order of loops based on what the problem considers unique.

    • Coin change: Coin is unique.

    • Combination: Target is unique.

  • Notes: Cái gì unique để ngoài

class Solution:
    def combinationSum4(self, nums: List[int], target: int) -> int:
        dp = [0] * (target + 1)
        dp[0] = 1

        # Order matters → outer loop over target
        for i in range(1, target + 1):
            for num in nums:
                if i >= num:
                    dp[i] += dp[i - num]

        return dp[target]

12.21. Knight Dialer (DP in graph - Hay)

class Solution:
    def knightDialer(self, n: int) -> int:
        MOD = 10**9 + 7

        # Knight moves from each digit
        moves = {
            0: [4, 6],
            1: [6, 8],
            2: [7, 9],
            3: [4, 8],
            4: [0, 3, 9],
            5: [],         # 5 is unreachable by knight
            6: [0, 1, 7],
            7: [2, 6],
            8: [1, 3],
            9: [2, 4]
        }

        # dp[i][j]: number of ways to reach digit j at step i
        dp = [ [0] * 10 for _ in range(n) ]

        # Base case: 1 way to be at any digit at step 0
        for digit in range(10):
            dp[0][digit] = 1

        # Fill dp table
        for i in range(1, n):
            for digit in range(10):
                for nei in moves[digit]:
                    dp[i][digit] = (dp[i][digit] + dp[i - 1][nei]) % MOD

        return sum(dp[n - 1]) % MOD

12.22. Partition Equal Subset Sum

  • Step 1: Loop num

  • Step 2: Loop backward

class Solution:
    def canPartition(self, nums: List[int]) -> bool:
        # Find dp sum with nums / 2
        total = sum(nums)
        if total % 2 != 0:
            return False

        target = total // 2

        dp = [0] * (target + 1)
        dp[0] = 1

        for num in nums:
            for i in range(target, num - 1, -1):
                dp[i] += dp[i - num]

        return dp[target] != 0


2D Matrix:

  • Step 1: Loop row.

  • Step 2: Loop col.

  • Step 3: Loop coin.

12.23. Soup Servings (Hay, giống bài mua vé ticket)

  • 2D array

  • Step 1: Loop row.

  • Step 2: Loop col.

  • Step 3: Loop coin.

class Solution:
    def soupServings(self, n: int) -> float:
        if n >= 5000:
            return 1.0

        N = (n + 24) // 25
        dp = [[0.0 for _ in range(N + 1)] for _ in range(N + 1)]

        # Base cases
        for i in range(N + 1):
            dp[0][i] = 1.0          # A empty first
            dp[i][0] = 0.0          # B empty first
        dp[0][0] = 0.5              # Both empty at same time

        for a in range(1, N + 1):
            for b in range(1, N + 1):
                dp[a][b] = 0.25 * (
                    dp[max(0, a - 4)][b] +
                    dp[max(0, a - 3)][max(0, b - 1)] +
                    dp[max(0, a - 2)][max(0, b - 2)] +
                    dp[max(0, a - 1)][max(0, b - 3)]
                )

        return dp[N][N]

12.24. Domino and Tromino Tiling

  • Step 1: Loop row.

  • Step 2: Loop col.

  • Step 3: Loop coin.

  • Find a rule

    • dp[i - 1]: Vertical
    • dp[i - 1] + dp[i - 3]: Horizontal + L-shape
class Solution:
    def numTilings(self, n: int) -> int:
        MOD = 10**9 + 7
        if n == 0:
            return 1
        if n == 1:
            return 1
        if n == 2:
            return 2

        dp = [0] * (n + 1)
        dp[0] = 1
        dp[1] = 1
        dp[2] = 2

        for i in range(3, n + 1):
            # dp[i - 1]: Vertical
            # dp[i - 1] + dp[i - 3]: Horizontal + L-shape
            dp[i] = (2 * dp[i - 1] + dp[i - 3]) % MOD

        return dp[n]

12.25. Unique Paths II (Hay)

class Solution:
    def uniquePathsWithObstacles(self, obstacleGrid: List[List[int]]) -> int:
        m, n = len(obstacleGrid), len(obstacleGrid[0])

        if obstacleGrid[0][0] == 1 or obstacleGrid[m - 1][n - 1] == 1:
            return 0

        dp = [[0] * n for _ in range(m)]
        dp[0][0] = 1

        # DP in first row
        for i in range(1, n):
            if obstacleGrid[0][i] == 0:
                dp[0][i] = dp[0][i - 1]


        # DP in left column
        for i in range(1, m):
            if obstacleGrid[i][0] == 0:
                dp[i][0] = dp[i - 1][0]

        # DP in other grid
        for i in range(1, m):
            for j in range(1, n):
                if obstacleGrid[i][j] == 0:
                    dp[i][j] = dp[i - 1][j] + dp[i][j - 1]

        return dp[m - 1][n - 1]

12.26. Number of Longest Increasing Subsequence

  • Using length[i] to keep the largest length.

  • Using count[i] to count the current largest length.

class Solution:
    def findNumberOfLIS(self, nums: List[int]) -> int:
        n = len(nums)
        length = [1] * (n + 1)
        count = [1] * (n + 1)

        for i in range(n):
            for j in range(i):
                if nums[j] < nums[i]:
                    if length[j] + 1 > length[i]:
                        length[i] = length[j] + 1
                        count[i] = count[j]
                    elif length[j] + 1 == length[i]:
                        count[i] += count[j]

        max_len = max(length)
        return sum(count[i] for i in range(n) if length[i] == max_len)

12.17. Out of Boundary Paths

  • Thứ tự loop tuỳ depend of row.
class Solution:
    def findPaths(self, m: int, n: int, maxMove: int, startRow: int, startColumn: int) -> int:
        MOD = 10**9 + 7

        dp = [[[0] * (n + 1) for _ in range(m + 1)] for _ in range(maxMove + 1)]

        directions = [(-1, 0), (1, 0), (0, -1), (0, 1)]

        for move in range(1, maxMove + 1):
            for i in range(m):
                for j in range(n):
                    for dr, dc in directions:
                        ni, nj = i + dr, j + dc
                        if ni < 0 or ni >= m or nj < 0 or nj >= n:
                            # State
                            dp[move][i][j] = (dp[move][i][j] + 1) % MOD
                        else:
                            dp[move][i][j] = (dp[move][i][j] + dp[move - 1][ni][nj]) % MOD

        return dp[maxMove][startRow][startColumn]


  • DP in Mid of Merge Intervals

12.18. Minimum Cost Tree From Leaf Values

class Solution:
    def mctFromLeafValues(self, arr: List[int]) -> int:
        n = len(arr)
        dp = [[0] * n for _ in range(n)]
        max_leaf = [[0] * n for _ in range(n)]

        # Precompute the max_leaf[i][j]
        for i in range(n):
            max_leaf[i][i] = arr[i]
            for j in range(i + 1, n):
                max_leaf[i][j] = max(max_leaf[i][j - 1], arr[j])

        # Interval DP
        for length in range(2, n + 1):  # length of subarray
            for i in range(n - length + 1):
                j = i + length - 1
                dp[i][j] = float('inf')
                for k in range(i, j):
                    cost = (dp[i][k] + dp[k + 1][j] +
                            max_leaf[i][k] * max_leaf[k + 1][j])
                    dp[i][j] = min(dp[i][j], cost)

        return dp[0][n - 1]

12.19. Unique Binary Search Trees

  • For n nodes, choose each number 1 to n as root.

    The number of unique BSTs with root i is: dp[i-1] (left subtree) × dp[n-i] (right subtree)

  • Step 1: Node trước.

  • Step 2: Value sau

class Solution:
    def numTrees(self, n: int) -> int:
        dp = [0] * (n + 1)
        dp[0] = 1  # Empty tree
        dp[1] = 1  # One node tree

        for nodes in range(2, n + 1):
            for root in range(1, nodes + 1):
                left = root - 1
                right = nodes - root
                dp[nodes] += dp[left] * dp[right]

        return dp[n]

12.20. Minimum Score Triangulation of Polygon

  • Step 1: Find i.

  • Step 2: Find sub range j => (i, j).

  • Step 3: Find maximum in range k

class Solution:
    def minScoreTriangulation(self, values: List[int]) -> int:
        # Find 2D n x n
        n = len(values)
        dp = [[0] * n for _ in range(n)]

        for length in range(3, n + 1):
            for i in range(n - length + 1):
                # Sub range (i, j)
                j = i + length - 1
                dp[i][j] = float('inf')
                for k in range(i, j):
                    dp[i][j] = min(dp[i][j], dp[i][k] + dp[k][j] + values[i] * values[k] * values[j])

        return dp[0][n - 1]

12.21. Guess Number Higher or Lower II

Pattern for merge interval DP.

  • Step 1: Length

  • Step 2: Start

  • Step 3: End

class Solution:
    def getMoneyAmount(self, n: int) -> int:
        dp = [[0] * (n + 1) for _ in range(n + 1)]

        for length in range(2, n + 1):  # length of interval
            for start in range(1, n - length + 2):
                end = start + length - 1
                dp[start][end] = float('inf')
                for x in range(start, end):
                    cost = x + max(dp[start][x - 1], dp[x + 1][end])
                    dp[start][end] = min(dp[start][end], cost)

        return dp[1][n]


  • DP on Strings

12.22. Longest Common Subsequence

  • dp[i][j] represents the length of LCS of s1[:i] and s2[:j]

  • If match, update dp[i][j].

  • Else: skip i or j

class Solution:
    def longestCommonSubsequence(self, s1: str, s2: str) -> int:
        m, n = len(s1), len(s2)

        # dp[i][j] represents the length of LCS of s1[:i] and s2[:j]
        dp = [[0] * (n + 1) for _ in range(m + 1)]

        for i in range(1, m + 1):
            for j in range(1, n + 1):
                if s1[i - 1] == s2[j - 1]:
                    dp[i][j] = dp[i - 1][j - 1] + 1     # Match
                else:
                    dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])  # Skip one character

        return dp[m][n]

12.23. Palindromic Substrings

class Solution:
    def countSubstrings(self, s: str) -> int:
        n = len(s)
        count = 0
        dp = [[False] * n for _ in range(n)]

        for end in range(n):
            for start in range(end + 1):
                if s[start] == s[end]:
                    # If the substring length is <= 3, it's a palindrome
                    if end - start <= 2:
                        dp[start][end] = True
                    else:
                        # For longer substrings, check the inner substring
                        dp[start][end] = dp[start + 1][end - 1]

                    # Count if it's a palindrome
                    if dp[start][end]:
                        count += 1

        return count

12.24. Longest Palindromic Subsequence

class Solution:
    def longestPalindromeSubseq(self, s: str) -> int:
        n = len(s)
        dp = [[0] * n for _ in range(n)]

        # Base case: every single letter is a palindrome of length 1
        for i in range(n):
            dp[i][i] = 1

        # Fill in order of increasing substring length
        for end in range(n):
            for start in range(end - 1, -1, -1):  # go backward to ensure dp[start + 1][end - 1] is ready
                if s[start] == s[end]:
                    if end - start == 1:
                        dp[start][end] = 2
                    else:
                        dp[start][end] = 2 + dp[start + 1][end - 1]
                else:
                    dp[start][end] = max(dp[start + 1][end], dp[start][end - 1])

        return dp[0][n - 1]

  • Notes: Substring khác với Subsequence:
    • Substring là từ i quay ngược về trước được (1 cái là loop forward)
    • Subsequence là cần backward về sau => có thể bỏ bớt ký tự được (1 cái là loop backward)

12.25. Shortest Common Supersequence (Hay)

class Solution:
    def shortestCommonSupersequence(self, str1: str, str2: str) -> str:
        m, n = len(str1), len(str2)

        # Step 1: Find the LCS
        dp = [[""] * (n + 1) for _ in range(m + 1)]

        for i in range(m):
            for j in range(n):
                if str1[i] == str2[j]:
                    dp[i + 1][j + 1] = dp[i][j] + str1[i]
                else:
                    dp[i + 1][j + 1] = max(dp[i + 1][j], dp[i][j + 1], key=len)

        lcs = dp[m][n]

        # Step 2: Build the SCS using LCS
        res = []
        i = j = 0
        for c in lcs:
            while str1[i] != c:
                res.append(str1[i])
                i += 1
            while str2[j] != c:
                res.append(str2[j])
                j += 1
            res.append(c)
            i += 1
            j += 1

        # Add remaining parts
        res.append(str1[i:])
        res.append(str2[j:])

        return ''.join(res)

12.26. Edit Distance (Hay mà khó)

  • How many operations to convert word1[0..i-1] to an empty string?

  • How many operations to convert an empty string to word2[0..j-1]?

  1. dp[i - 1][j] → Delete word1[i - 1]

  2. dp[i][j - 1] → Insert word2[j - 1]

  3. dp[i - 1][j - 1] → Replace word1[i - 1] with word2[j - 1]

class Solution:
    def minDistance(self, word1: str, word2: str) -> int:
        m, n = len(word1), len(word2)

        # dp[i][j] = min operations to convert word1[0..i-1] to word2[0..j-1]
        dp = [[0] * (n + 1) for _ in range(m + 1)]

        # Initialize base cases
        for i in range(m + 1):
            dp[i][0] = i  # delete all characters
        for j in range(n + 1):
            dp[0][j] = j  # insert all characters

        # Fill DP table
        for i in range(1, m + 1):
            for j in range(1, n + 1):
                if word1[i - 1] == word2[j - 1]:
                    dp[i][j] = dp[i - 1][j - 1]  # no operation needed
                else:
                    dp[i][j] = 1 + min(
                        dp[i - 1][j],     # delete
                        dp[i][j - 1],     # insert
                        dp[i - 1][j - 1]  # replace
                    )

        return dp[m][n]

12.27. Distinct Subsequences (Idea giống Edit Distance)

class Solution:
    def numDistinct(self, s: str, t: str) -> int:
        m, n = len(s), len(t)

        # dp[i][j] = number of distinct subsequences of s[0..i-1] that match t[0..j-1]
        dp = [[0] * (n + 1) for _ in range(m + 1)]

        # An empty t can always be matched by deleting all characters in s
        for i in range(m + 1):
            dp[i][0] = 1

        # Fill the DP table
        for i in range(1, m + 1):
            for j in range(1, n + 1):
                if s[i - 1] == t[j - 1]:
                    # Match or skip s[i-1]
                    dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j]
                else:
                    # Skip s[i-1]
                    dp[i][j] = dp[i - 1][j]

        return dp[m][n]

12.28. Minimum ASCII Delete Sum for Two Strings (Giống Longest Common Subsequence)

  • Tìm total - 2 * LCS
class Solution:
    def minimumDeleteSum(self, s1: str, s2: str) -> int:
        m, n = len(s1), len(s2)

        # dp[i][j] = max ASCII sum of common subsequence between s1[0..i-1] and s2[0..j-1]
        dp = [[0] * (n + 1) for _ in range(m + 1)]

        for i in range(m):
            for j in range(n):
                if s1[i] == s2[j]:
                    dp[i + 1][j + 1] = dp[i][j] + ord(s1[i])
                else:
                    dp[i + 1][j + 1] = max(dp[i][j + 1], dp[i + 1][j])

        total = sum(ord(c) for c in s1) + sum(ord(c) for c in s2)
        common = dp[m][n]

        return total - 2 * common

12.29. Longest Palindromic Substring (Hay)

class Solution:
    def longestPalindrome(self, s: str) -> str:
        n = len(s)
        if n == 0:
            return ""

        dp = [[False] * n for _ in range(n)]
        start = 0
        max_len = 1

        for end in range(n):
            for i in range(end + 1):
                if s[i] == s[end]:
                    if end - i <= 2:
                        dp[i][end] = True
                    else:
                        dp[i][end] = dp[i + 1][end - 1]

                    if dp[i][end] and end - i + 1 > max_len:
                        start = i
                        max_len = end - i + 1

        return s[start:start + max_len]


Decision Making

12.30. House Robber

class Solution:
    def rob(self, nums: List[int]) -> int:
        if not nums:
            return 0
        if len(nums) == 1:
            return nums[0]

        n = len(nums)
        dp = [0] * n
        dp[0] = nums[0]
        dp[1] = max(nums[0], nums[1])

        for i in range(2, n):
            # Skip the house[i]
            # Or stole the house[i]
            dp[i] = max(dp[i - 1], dp[i - 2] + nums[i])

        return dp[-1]

12.31. Best Time to Buy and Sell Stock (Buy 1 time)

from typing import List

class Solution:
    def maxProfit(self, prices: List[int]) -> int:
        min_price = float('inf')  # Initialize to a very high value
        max_profit = 0

        for price in prices:
            if price < min_price:
                min_price = price  # Update minimum price
            else:
                max_profit = max(max_profit, price - min_price)  # Potential profit

        return max_profit

from typing import List

class Solution:
    def maxProfit(self, prices: List[int]) -> int:
        n = len(prices)
        if n == 0:
            return 0

        dp0 = 0             # Max profit without stock
        dp1 = -prices[0]    # Max profit with one stock bought

        for i in range(1, n):
            dp0 = max(dp0, dp1 + prices[i])  # Sell today or do nothing
            dp1 = max(dp1, -prices[i])       # Buy today or do nothing

        return dp0

12.32. Best Time to Buy and Sell Stock with Transaction Fee

from typing import List

class Solution:
    def maxProfit(self, prices: List[int], fee: int) -> int:
        if not prices:
            return 0

        cash = 0
        hold = -prices[0]  # Buy on day 0

        for price in prices[1:]:
            cash = max(cash, hold + price - fee)  # Sell
            hold = max(hold, cash - price)        # Buy

        return cash

12.33. Best Time to Buy and Sell Stock with Cooldown

💡 DP State Definitions:

We track 3 states:

  • hold: Max profit on day i if holding a stock.

  • sold: Max profit on day i if just sold a stock (cooldown applies next day).

  • rest: Max profit on day i if in cooldown or doing nothing.

from typing import List

class Solution:
    def maxProfit(self, prices: List[int]) -> int:
        if not prices:
            return 0

        n = len(prices)
        hold = -prices[0]     # Buy on day 0
        sold = 0              # Nothing sold yet
        rest = 0              # Initial rest state

        for price in prices[1:]:
            prev_hold = hold
            prev_sold = sold
            prev_rest = rest

            hold = max(prev_hold, prev_rest - price) # Buy
            sold = prev_hold + price                # Sell
            rest = max(prev_rest, prev_sold)        # Stay resting or go into cooldown

        return max(sold, rest)  # Final profit must not be holding

12.34. Best Time to Buy and Sell Stock III

from typing import List

class Solution:
    def maxProfit(self, prices: List[int]) -> int:
        if not prices:
            return 0

        buy1 = float('-inf')
        sell1 = 0
        buy2 = float('-inf')
        sell2 = 0

        for price in prices:
            buy1 = max(buy1, -price)
            sell1 = max(sell1, buy1 + price)
            buy2 = max(buy2, sell1 - price)
            sell2 = max(sell2, buy2 + price)

        return sell2

12.35. Best Time to Buy and Sell Stock IV

dp[day][transaction][holding]

  • day = current day

  • transaction = number of completed transactions

  • holding = 0 (no stock), 1 (holding stock)

from typing import List

class Solution:
    def maxProfit(self, k: int, prices: List[int]) -> int:
        n = len(prices)
        if n == 0:
            return 0

        # Optimization for large k
        if k >= n // 2:
            return sum(
                max(prices[i] - prices[i - 1], 0) for i in range(1, n)
            )

        # dp[i][t][0 or 1]
        dp = [[[0] * 2 for _ in range(k + 1)] for _ in range(n)]

        for t in range(k + 1):
            dp[0][t][0] = 0
            dp[0][t][1] = -prices[0]  # if we buy on day 0

        for i in range(1, n):
            for t in range(1, k + 1):
                # Not holding
                dp[i][t][0] = max(dp[i-1][t][0], dp[i-1][t][1] + prices[i])
                # Holding
                dp[i][t][1] = max(dp[i-1][t][1], dp[i-1][t-1][0] - prices[i])

        return max(dp[n-1][t][0] for t in range(k + 1))  # max profit without holding

13. Scheduling

13.1. Indegree

edges = [(0, 1), (1, 2), (1, 3), (3, 2), (3, 4)]
n = 5

def in_degrees(edges, n):
    in_degrees = [0] * n

    for u, v in edges:
        in_degrees[v] += 1

    return in_degrees

print(in_degrees(edges, n))

13.2. Topo Sort (Kahn’s Algorithm)

Notes: Không phải visited mà indegree[i] = 0

  • Indegrees + BFS

  • Step 1: Build Indegree

  • Step 2: Add indegree = 0 to queue

  • Step 3: Node

  • Step 4: Neighbor - add indegree = 0 to queue

  • Step 5: Popleft

from collections import deque

def topo_sort(adjList, n):
    # Build indegrees
    in_degrees = [0] * n

    for u in adjList:
        for v in adjList[u]:
            in_degrees[v] += 1

    # Add indegrees = 0 to queue
    # Node
    queue = deque([u for u in range(n) if in_degrees[u] == 0])
    # Can visit multiple time => make sure indegrees[i] = 0
    # visited = set()

    result = []

    # Neighbor
    while queue:
        # Popleft
        start = queue.popleft()
        result.append(start)
        for neighbor in adjList[start]:
            in_degrees[neighbor] -= 1
            if in_degrees[neighbor] == 0:
                queue.append(neighbor)

    return result

adjList = {
    0: [1, 3],
    1: [2],
    2: [],
    3: [1, 4, 5],
    4: [5],
    5: []
}
n = 5

print(topo_sort(adjList, n + 1))

13.3. Course Schedule

from collections import defaultdict, deque

class Solution:
    def canFinish(self, numCourses: int, prerequisites: List[List[int]]):
        # Indegree
        in_degrees = [0] * numCourses

        # Adjancy List
        adj_list = defaultdict(list)

        for u, v in prerequisites:
            in_degrees[v] += 1
            adj_list[u].append(v)

        # Node
        queue = deque([u for u in range(numCourses) if in_degrees[u] == 0])
        result = []

        # Neighbor
        while queue:
            # Popleft
            start = queue.popleft()
            result.append(start)

            for neighbor in adj_list[start]:
                in_degrees[neighbor] -= 1
                if in_degrees[neighbor] == 0:
                    queue.append(neighbor)

        return len(result) == numCourses

13.4. Course Schedule II

from collections import defaultdict, deque

class Solution:
    def findOrder(self, numCourses: int, prerequisites: List[List[int]]):
        # Indegree
        in_degrees = [0] * numCourses

        # Adjancy List
        adj_list = defaultdict(list)

        for u, v in prerequisites:
            in_degrees[u] += 1
            adj_list[v].append(u)

        # Node
        queue = deque([u for u in range(numCourses) if in_degrees[u] == 0])
        result = []

        # Neighbor
        while queue:
            # Popleft
            start = queue.popleft()
            result.append(start)

            for neighbor in adj_list[start]:
                in_degrees[neighbor] -= 1
                if in_degrees[neighbor] == 0:
                    queue.append(neighbor)

        return result if len(result) == numCourses else []

13.5. Dijkstra

  • Dijkstra = BFS + thay deque to heapq + dist[i] to store start -> i

  • dist[neighbor] = dist[start] + weight

  • Step 1: Init heap

  • Step 2: Node

  • Step 3: Neighbor

  • Step 4: Dist

  • Find min distance to start -> i -> j -> end, push when dist[neighbor] = dist[start] + weight

import heapq
from collections import defaultdict

def dijkstra(graph, start):
    # Init heap
    dist = defaultdict(lambda: float('inf'))

    # Node
    pq = [(0, start)]
    dist[start] = 0

    # Neighbor
    while pq:
        curr_dist, start = heapq.heappop(pq)

        # Prunning
        if curr_dist > dist[start]:
            continue

        # Dist
        for neighbor, weight in graph[start]:
            if dist[neighbor] > dist[start] + weight:
                dist[neighbor] = dist[start] + weight
                heapq.heappush(pq, (dist[neighbor], neighbor))

    return dist


graph = {
    'A': [('B', 5), ('C', 1)],
    'B': [('A', 5), ('C', 2), ('D', 1)],
    'C': [('A', 1), ('B', 2), ('D', 4), ('E', 8)],
    'D': [('B', 1), ('C', 4), ('E', 3), ('F', 6)],
    'E': [('C', 8), ('D', 3)],
    'F': [('D', 6)]
}

start_node = 'A'
distances = dijkstra(graph, start_node)

for node in sorted(distances):
    print(f"Distance from {start_node} to {node}: {distances[node]}")

13.6. Single-thread CPU

  • Step 1: Start time at task[0]

  • Step 2: Add to queue.

  • Step 3: Process

import heapq

class Solution:
    def getOrder(self, tasks: List[List[int]]) -> List[int]:
        # Sort tasks
        tasks = [(task[0], task[1], idx) for idx, task in enumerate(tasks)]
        tasks.sort(key=lambda x:(x[0]))

        running_tasks = []

        # Need a curr_time
        curr_time = tasks[0][0]

        # Index
        i = 0
        n = len(tasks)

        # Result
        result = []

        while i < n or running_tasks:
            # Add to queue
            while i < n and tasks[i][0] <= curr_time:
                process_time, idx = tasks[i][1], tasks[i][2]
                heapq.heappush(running_tasks, (process_time, idx))
                i += 1

            # Process
            if running_tasks:
                process_time, idx = heapq.heappop(running_tasks)
                curr_time += process_time
                result.append(idx)
            else:
                curr_time = tasks[i][0]

        return result

13.7. Max CPU Load

  • Step 1: Sort job.

  • Step 2: Auto load job

  • Step 3: Process job and update curr_load

import heapq

def find_max_cpu_load(jobs):
    # Sort jobs
    jobs.sort(key=lambda x:x[0])
    process_tasks = []

    # CPU load
    max_cpu_load = 0
    curr_cpu_load = 0

    for job in jobs:
        start, end, load = job

        while process_tasks and process_tasks[0][0] <= start:
            processed_job = heapq.heappop(process_tasks)
            curr_cpu_load -= processed_job[1]

        # Append to jobs to queue by start_time
        heapq.heappush(process_tasks, (end, load))

        # Update max_cpu_load
        curr_cpu_load += load
        max_cpu_load = max(max_cpu_load, curr_cpu_load)

    return max_cpu_load

jobs = [(1, 4, 3), (2, 5, 4), (7, 9, 6)]
print(find_max_cpu_load(jobs))

13.8. Multi-thread CPU

import heapq
from typing import List

class Solution:
    def getOrder(self, tasks: List[List[int]], k: int) -> List[int]:
        tasks = [(task[0], task[1], idx) for idx, task in enumerate(tasks)]
        tasks.sort()

        task_heap = []        # (-processing_time, idx, enqueue_time)
        running_tasks = []    # (end_time, idx)
        res = []
        time = 0
        i = 0
        max_end_time = 0

        # Create a priority queue of time events (enqueue times and end times)
        event_queue = [(task[0], i) for i, task in enumerate(tasks)]
        heapq.heapify(event_queue)

        while event_queue or task_heap or running_tasks:
            # Get the next event time
            if event_queue:
                next_time, idx = event_queue[0]
                if not running_tasks or (running_tasks[0][0] >= next_time):
                    time = next_time
                    # Process all enqueue events at this time
                    while event_queue and event_queue[0][0] <= time:
                        _ , task_idx = heapq.heappop(event_queue)
                        enqueue_time, processing_time, idx = tasks[task_idx]
                        heapq.heappush(task_heap, (-processing_time, idx, enqueue_time))
                        i += 1
                else:
                    # Fast-forward to next task end time
                    time = running_tasks[0][0]
            elif running_tasks:
                time = running_tasks[0][0]
            else:
                break

            # Remove finished tasks
            while running_tasks and running_tasks[0][0] <= time:
                heapq.heappop(running_tasks)

            # Assign available CPUs
            while task_heap and len(running_tasks) < k:
                neg_processing_time, idx, enqueue_time = heapq.heappop(task_heap)
                processing_time = -neg_processing_time
                # Start task at max(current time, enqueue time)
                start_time = max(time, enqueue_time)
                end_time = start_time + processing_time
                heapq.heappush(running_tasks, (end_time, idx))
                res.append(idx)
                max_end_time = max(max_end_time, end_time)

        print("Time", max_end_time)
        return res

# Test
tasks = [[1, 2], [2, 4], [3, 2], [4, 1]]
k = 2
sol = Solution()
print(sol.getOrder(tasks, k))

13.9. Task Scheduler

  • Add tasks to cooldown queue => Only execute the task when come to time.
import heapq
from collections import Counter, deque
from typing import List

class Solution:
    def leastInterval(self, tasks: List[str], n: int) -> int:
        # Count the frequency of tasks
        freq = Counter(tasks)

        # Python's heapq is a min-heap, so we store negative frequencies for max-heap behavior
        ready = [-cnt for cnt in freq.values()]
        heapq.heapify(ready)

        # Queue to manage cooldowns: (ready_time, -task_count)
        cooldown = deque()

        time = 0
        while ready or cooldown:
            time += 1

            # Release from cooldown if task is ready
            if cooldown and cooldown[0][0] == time:
                heapq.heappush(ready, cooldown.popleft()[1])

            if ready:
                cnt = heapq.heappop(ready)
                if cnt + 1 < 0:
                    # Add to cooldown queue with ready time = now + n + 1
                    cooldown.append((time + n + 1, cnt + 1))

        return time

13.10. Task Scheduler II

from typing import List

class Solution:
    def taskSchedulerII(self, tasks: List[int], space: int) -> int:
        last_day = {}  # task -> last executed day
        day = 0

        for task in tasks:
            day += 1
            if task in last_day and day - last_day[task] <= space:
                # If task was executed too recently, jump forward
                day = last_day[task] + space + 1
            last_day[task] = day

        return day

13.11. Process Tasks Using Servers

from typing import List
import heapq

class Solution:
    def assignTasks(self, servers: List[int], tasks: List[int]) -> List[int]:
        n = len(servers)
        time = 0
        result = []

        # Min-heap for available servers: (weight, index)
        available = [(weight, i) for i, weight in enumerate(servers)]
        heapq.heapify(available)

        # Min-heap for busy servers: (free_time, weight, index)
        busy = []

        for i, task_time in enumerate(tasks):
            time = max(time, i)

            # Release servers that have finished by current time
            while busy and busy[0][0] <= time:
                free_time, weight, index = heapq.heappop(busy)
                heapq.heappush(available, (weight, index))

            if available:
                weight, index = heapq.heappop(available)
                heapq.heappush(busy, (time + task_time, weight, index))
                result.append(index)
            else:
                # No server available now, advance time to earliest free server
                free_time, weight, index = heapq.heappop(busy)
                time = free_time
                heapq.heappush(busy, (time + task_time, weight, index))
                result.append(index)

        return result

13.12. FCFS - First Come First Serve

  • Cons: High waiting time, such as P2, P3 go after but take shorter time to complete => but need to wait.

  • Step 1: Sort by start time.

  • Step 2: Update time.

  • Step 3: Update schedule.

  • Step 4: Using for.


def fcfs(tasks):
    # Task
    tasks.sort(key=lambda x:x['arrival_time'])

    # Time
    time = 0
    schedule = []

    for task in tasks:
        if time < task['arrival_time']:
            time = task['arrival_time']

        start_time = time
        end_time = time + task['burst_time']
        schedule.append((task['pid'], start_time, end_time))
        time = end_time

    return schedule


tasks = [
    {'pid': 'P1', 'arrival_time': 0, 'burst_time': 5},
    {'pid': 'P2', 'arrival_time': 1, 'burst_time': 3},
    {'pid': 'P3', 'arrival_time': 2, 'burst_time': 1},
]
print(fcfs(tasks))

13.13. SJF - Shortest Job First

  • Prior the shortest job first => Narrow the performance of wait time.

  • Cons: Long starving process

import heapq

def sjf(tasks):
    tasks.sort(key=lambda x:x['arrival_time'])

    # Base
    time = 0
    schedule = []

    # Add them
    i = 0
    ready = []
    n = len(tasks)

    while i < n or ready:
        while i < n and tasks[i]['arrival_time'] <= time:
            heapq.heappush(ready, (tasks[i]['burst_time'], tasks[i]['arrival_time'], tasks[i]['pid'], tasks[i]))
            i += 1

        if ready:
            _, _, _, task = heapq.heappop(ready)
            start_time = time
            end_time = start_time + task['burst_time']
            schedule.append((task['pid'], start_time, end_time))
            time = end_time
        else:
            time = tasks[i]['arrival_time']

    return schedule

# Sample test case
tasks = [
    {'pid': 'P1', 'arrival_time': 0, 'burst_time': 8},
    {'pid': 'P2', 'arrival_time': 1, 'burst_time': 4},
    {'pid': 'P3', 'arrival_time': 2, 'burst_time': 9},
    {'pid': 'P4', 'arrival_time': 3, 'burst_time': 5}
]

result = sjf(tasks)
for pid, start, end in result:
    print(f"Task {pid}: Start at {start}, End at {end}")


13.14. SRTF - Shortest Remaining Time First

import heapq

def srtf(tasks):
    # Preprocess: add 'remaining' field and sort by arrival
    tasks = sorted([{**t, 'remaining': t['burst_time']} for t in tasks], key=lambda x: x['arrival_time'])

    time = 0
    i = 0
    n = len(tasks)
    ready = []  # (remaining_time, arrival_time, pid, task)
    schedule = []

    completed = 0
    last_pid = None
    start_time = None

    while completed < n:
        # Push all tasks that have arrived into the ready
        while i < n and tasks[i]['arrival_time'] <= time:
            task = tasks[i]
            heapq.heappush(ready, (task['remaining'], task['arrival_time'], task['pid'], task))
            i += 1

        if ready:
            _, _, pid, task = heapq.heappop(ready)

            if pid != last_pid:
                # Job cu chua xong
                if last_pid is not None:
                    schedule.append((last_pid, start_time, time))
                start_time = time
                last_pid = pid

            # Execute task for 1 time unit
            task['remaining'] -= 1
            time += 1

            if task['remaining'] > 0:
                heapq.heappush(ready, (task['remaining'], task['arrival_time'], task['pid'], task))
            else:
                completed += 1
                schedule.append((pid, start_time, time))
                last_pid = None
        else:
            time += 1

    return schedule

tasks = [
    {'pid': 'P1', 'arrival_time': 0, 'burst_time': 8},
    {'pid': 'P2', 'arrival_time': 1, 'burst_time': 4},
    {'pid': 'P3', 'arrival_time': 2, 'burst_time': 9},
    {'pid': 'P4', 'arrival_time': 3, 'burst_time': 5}
]

for pid, start, end in srtf(tasks):
    print(f"Task {pid}: Start at {start}, End at {end}")

13.15. Round Robin

from collections import deque

def round_robin(tasks, quantum):
    # Add remaining time to each task
    tasks = sorted([{**t, 'remaining': t['burst_time']} for t in tasks], key=lambda x: x['arrival_time'])

    time = 0
    schedule = []


    i = 0  # Index for incoming tasks
    n = len(tasks)

    completed = 0
    ready = deque()

    while completed < n or ready:
        # Enready tasks that have arrived
        while i < n and tasks[i]['arrival_time'] <= time:
            ready.append(tasks[i])
            i += 1

        if ready:
            task = ready.popleft()

            start_time = time
            duration = min(quantum, task['remaining'])
            time += duration
            task['remaining'] -= duration
            schedule.append((task['pid'], start_time, time))

            # But while that task was executing, new tasks might have arrived
            while i < n and tasks[i]['arrival_time'] <= time:
                ready.append(tasks[i])
                i += 1

            # If not finished, push back to ready
            if task['remaining'] > 0:
                ready.append(task)
            else:
                completed += 1
        else:
            # No ready task, jump to the next arrival time
            if i < n:
                time = tasks[i]['arrival_time']

    return schedule

tasks = [
    {'pid': 'P1', 'arrival_time': 0, 'burst_time': 5},
    {'pid': 'P2', 'arrival_time': 1, 'burst_time': 3},
    {'pid': 'P3', 'arrival_time': 2, 'burst_time': 8},
    {'pid': 'P4', 'arrival_time': 3, 'burst_time': 6}
]

quantum = 3

result = round_robin(tasks, quantum)
for pid, start, end in result:
    print(f"Task {pid}: Start at {start}, End at {end}")

13.16. Priority Scheduling

import heapq

def priority_scheduling(tasks):
    # Sort by arrival time first
    tasks.sort(key=lambda x: x['arrival_time'])

    time = 0
    i = 0
    n = len(tasks)
    schedule = []
    ready = []

    while i < n or ready:
        # Push all tasks that have arrived into the ready queue
        while i < n and tasks[i]['arrival_time'] <= time:
            task = tasks[i]
            heapq.heappush(ready, (task['priority'], task['arrival_time'], task['pid'], task))
            i += 1

        if ready:
            _, _, _, task = heapq.heappop(ready)
            start = time
            end = start + task['burst_time']
            schedule.append((task['pid'], start, end))
            time = end
        else:
            # If no task is ready, jump to the next arrival
            time = tasks[i]['arrival_time']

    return schedule

tasks = [
    {'pid': 'P1', 'arrival_time': 0, 'burst_time': 10, 'priority': 3},
    {'pid': 'P2', 'arrival_time': 2, 'burst_time': 5,  'priority': 1},
    {'pid': 'P3', 'arrival_time': 1, 'burst_time': 8,  'priority': 2},
    {'pid': 'P4', 'arrival_time': 3, 'burst_time': 6,  'priority': 4}
]

result = priority_scheduling(tasks)

for pid, start, end in result:
    print(f"Task {pid}: Start at {start}, End at {end}")

13.17. HRRN - Highest Response Ratio Next

import heapq
from typing import List, Dict, Tuple

def hrrn(tasks: List[Dict]) -> List[Tuple[str, int, int]]:
    tasks.sort(key=lambda x: x['arrival_time'])  # Sort by arrival time once
    time = 0
    i = 0
    ready = []
    schedule = []

    while i < len(tasks) or ready:
        # Load all available tasks into ready queue
        while i < len(tasks) and tasks[i]['arrival_time'] <= time:
            ready.append(tasks[i])
            i += 1

        if ready:
            heap = []
            for task in ready:
                wait = time - task['arrival_time']
                response_ratio = (wait + task['burst_time']) / task['burst_time']
                # Push with negative response ratio to simulate max-heap
                heapq.heappush(heap, (-response_ratio, task['arrival_time'], task['pid'], task))

            _, _, _, task = heapq.heappop(heap)
            ready.remove(task)  # Remove the selected task from ready queue

            start = time
            end = start + task['burst_time']
            schedule.append((task['pid'], start, end))
            time = end
        else:
            time = tasks[i]['arrival_time']

    return schedule

13.18. Multiple Queue Scheduling

def multiple_queue(tasks, quantum=2):
    # First queue: high priority (RR), Second: low (FCFS)
    high = [t for t in tasks if t['priority'] == 1]
    low = [t for t in tasks if t['priority'] != 1]

    high_schedule = round_robin(high, quantum)
    low_schedule = fcfs(low)
    return high_schedule + low_schedule

13.19. Multilevel Feedback Queue Scheduling

def mlfq(tasks, queues=3, quantum=2):
    from collections import deque
    levels = [deque() for _ in range(queues)]
    tasks = sorted(tasks, key=lambda x: x['arrival_time'])
    i = 0
    time = 0
    remaining = {t['pid']: t['burst_time'] for t in tasks}
    level_map = {t['pid']: 0 for t in tasks}
    schedule = []

    while i < len(tasks) or any(levels):
        while i < len(tasks) and tasks[i]['arrival_time'] <= time:
            levels[0].append(tasks[i])
            i += 1

        for q in range(queues):
            if levels[q]:
                task = levels[q].popleft()
                pid = task['pid']
                start = time
                run = min(quantum * (2 ** q), remaining[pid])
                time += run
                remaining[pid] -= run
                schedule.append((pid, start, time))
                while i < len(tasks) and tasks[i]['arrival_time'] <= time:
                    levels[0].append(tasks[i])
                    i += 1
                if remaining[pid] > 0:
                    level_map[pid] = min(q + 1, queues - 1)
                    levels[level_map[pid]].append(task)
                break
        else:
            time = tasks[i]['arrival_time']

    return schedule

13.20. SJF - Shortest Job First (Multiple CPU)

import heapq
from typing import List, Dict, Tuple

def sjf_multi_cpu(tasks: List[Dict], k: int) -> List[Tuple[str, int, int, int]]:
    tasks.sort(key=lambda x: x['arrival_time'])  # sort by arrival_time
    n = len(tasks)
    i = 0
    time = 0
    ready = []  # (burst_time, arrival_time, pid, task)
    cpu_heap = []    # (available_time, cpu_id)
    schedule = []

    # Initialize all CPUs as available at time 0
    for cpu_id in range(k):
        heapq.heappush(cpu_heap, (0, cpu_id))

    while i < n or ready or any(cpu[0] > time for cpu in cpu_heap):
        # Add tasks that have arrived by current time
        while i < n and tasks[i]['arrival_time'] <= time:
            task = tasks[i]
            heapq.heappush(ready, (task['burst_time'], task['arrival_time'], task['pid'], task))
            i += 1

        # Assign tasks to available CPUs
        assigned = False
        while ready and cpu_heap and cpu_heap[0][0] <= time:
            burst_time, arrival_time, pid, task = heapq.heappop(ready)
            cpu_available_time, cpu_id = heapq.heappop(cpu_heap)

            start_time = max(time, cpu_available_time)
            end_time = start_time + burst_time
            schedule.append((pid, start_time, end_time, cpu_id))

            heapq.heappush(cpu_heap, (end_time, cpu_id))
            assigned = True

        if not assigned:
            next_arrival = tasks[i]['arrival_time'] if i < n else float('inf')
            next_cpu_free = cpu_heap[0][0] if cpu_heap else float('inf')
            # Move to the next event (task arrival or CPU becomes free)
            time = max(time + 1, min(next_arrival, next_cpu_free))

    print("Time", time)

    return schedule

tasks = [
    {'pid': 'P1', 'arrival_time': 0, 'burst_time': 4},
    {'pid': 'P2', 'arrival_time': 1, 'burst_time': 3},
    {'pid': 'P3', 'arrival_time': 2, 'burst_time': 1},
    {'pid': 'P4', 'arrival_time': 3, 'burst_time': 2},
    {'pid': 'P5', 'arrival_time': 4, 'burst_time': 5},
]

schedule = sjf_multi_cpu(tasks, k=3)

for pid, start, end, cpu_id in schedule:
    print(f"CPU{cpu_id} runs {pid} from {start} to {end}")

13.21. FCFS Multiple CPU

import heapq

def fcfs_multi_cpu(tasks, cpu_count):
    # Sort tasks by arrival time
    tasks = sorted(tasks, key=lambda x: x['arrival_time'])

    # Min-heap of (available_time, cpu_id)
    cpu_heap = [(0, cpu_id) for cpu_id in range(cpu_count)]
    heapq.heapify(cpu_heap)

    schedule = []

    for task in tasks:
        arrival = task['arrival_time']
        burst = task['burst_time']
        pid = task['pid']

        # Get the earliest available CPU
        available_time, cpu_id = heapq.heappop(cpu_heap)

        # The task starts at the max of CPU's available time or its own arrival
        start_time = max(available_time, arrival)
        end_time = start_time + burst

        schedule.append({
            'pid': pid,
            'cpu': cpu_id,
            'start_time': start_time,
            'end_time': end_time
        })

        # Update the CPU's available time
        heapq.heappush(cpu_heap, (end_time, cpu_id))

    return schedule


# Example
tasks = [
    {'pid': 'P1', 'arrival_time': 0, 'burst_time': 5},
    {'pid': 'P2', 'arrival_time': 1, 'burst_time': 3},
    {'pid': 'P3', 'arrival_time': 2, 'burst_time': 1},
    {'pid': 'P4', 'arrival_time': 3, 'burst_time': 2},
]
result = fcfs_multi_cpu(tasks, cpu_count=2)
for entry in result:
    print(entry)

13.22. Priority Scheduling Multiple CPUs

import heapq

def priority_scheduling_multi_cpu(tasks, cpu_count):
    tasks = sorted(tasks, key=lambda x: x['arrival_time'])
    i = 0
    time = 0
    n = len(tasks)

    cpu_heap = [(0, cpu_id) for cpu_id in range(cpu_count)]
    heapq.heapify(cpu_heap)

    ready_queue = []
    schedule = []

    while i < n or ready_queue:
        # Add all tasks that have arrived by current time
        while i < n and tasks[i]['arrival_time'] <= time:
            task = tasks[i]
            heapq.heappush(ready_queue, (task['priority'], task['arrival_time'], task['pid'], task))
            i += 1

        # Assign ready tasks to available CPUs
        assigned = False
        while ready_queue and cpu_heap and cpu_heap[0][0] <= time:
            cpu_available_time, cpu_id = heapq.heappop(cpu_heap)
            _, arrival, pid, task = heapq.heappop(ready_queue)

            start_time = max(cpu_available_time, arrival, time)
            end_time = start_time + task['burst_time']

            schedule.append({
                'pid': pid,
                'cpu': cpu_id,
                'start_time': start_time,
                'end_time': end_time
            })

            heapq.heappush(cpu_heap, (end_time, cpu_id))
            assigned = True

        # If no task is assigned and no CPU is available yet, move time forward
        if not assigned:
            next_arrival = tasks[i]['arrival_time'] if i < n else float('inf')
            next_cpu_free = cpu_heap[0][0] if cpu_heap else float('inf')
            # Move to the next event (task arrival or CPU becomes free)
            time = max(time + 1, min(next_arrival, next_cpu_free))

    return schedule


tasks = [
    {'pid': 'P1', 'arrival_time': 0, 'burst_time': 10, 'priority': 3},
    {'pid': 'P2', 'arrival_time': 2, 'burst_time': 5,  'priority': 1},
    {'pid': 'P3', 'arrival_time': 1, 'burst_time': 8,  'priority': 2},
    {'pid': 'P4', 'arrival_time': 3, 'burst_time': 6,  'priority': 4}
]

result = priority_scheduling_multi_cpu(tasks, cpu_count=2)
for r in result:
    print(r)

13.23. Top K Smaller Number

  • Heap: The value of root is smaller, larger than other child node => left > right, or right > left, no matters.

  • Step 1: Init heap

  • Step 2: Loop for number

  • Step 3: If len < k => Heap push.

  • Step 4: If len > 5 => Heap pop push.

import heapq

class Solution:
    def kthLargest(self, nums: List[int], k: int):
        heap = []
        for num in nums:
            if len(heap) < k:
                heapq.heappush(heap, num)
            elif num > heap[0]:
                heapq.heappushpop(heap, num)

        return heap[0]

13.24. K Closest Points to Origin

  • Using max-heap => we need to remove the farthest element.

  • Smallest Element is almost right => We need to fight the farthest element in the heap and remove it.

  • Step 1: Init heap

  • Step 2: Loop for number

  • Step 3: If len < k => Heap push.

  • Step 4: If len > 5 => Heap pop push.

import heapq

class Solution:
    def kClosest(self, points: List[List[int]], k: int):
        heap = []

        for point in points:
            x, y = point
            distance = x * x + y * y

            if len(heap) < k:
                heapq.heappush(heap, (-distance, point))
            elif distance < -heap[0][0]:
                heapq.heappushpop(heap, (-distance, point))

        return [item[1] for item in heap]

Notes:

  • Min -> Max Heap

  • Max -> Min Heap

13.24. Find K Closest Element

import heapq

class Solution:
    def kClosest(self, nums: List[int], k: int, target: int):
        # Max Heap
        heap = []
        for num in nums:
            distance = abs(target - num)
            if len(heap) < k:
                heapq.heappush(heap, (-distance, num))
            elif distance < -heap[0][0]:
                heapq.heappushpop(heap, (-distance, num))

        result = [item[1] for item in heap]
        result.sort()
        return result

13.25. Merge K Sort Lists

from typing import List
import heapq

class Solution:
    def mergeKLists(self, lists: List[List[int]]) -> List[int]:
        heap = []

        for l in lists:
            for val in l:
                heapq.heappush(heap, val)

        result = []
        while heap:
            result.append(heapq.heappop(heap))

        return result

14. Sliding Window

14.1. Fruit into market

  • Step 1: Loop from end

  • Step 2: Counting

  • Step 3: Prunning when reach the length

  • Step 4: Increase start

class Solution:
    def totalFruit(self, fruits: List[int]) -> int:
        k = 2

        # Init start
        start = 0
        n = len(fruits)
        state = {}
        max_len = 0

        # Counting
        for end in range(n):
            state[fruits[end]] = state.get(fruits[end], 0) + 1

            # Prunning
            while len(state) > k:
                # Update start
                state[fruits[start]] -= 1
                if state[fruits[start]] == 0:
                    del state[fruits[start]]
                start += 1

            max_len = max(max_len, end - start + 1)

        return max_len

14.2. Longest Substring Without Repeating Characters

  • Cái trên là 3 loại khác nhau => này bao nhiêu loại cũng được miễn là k dup

  • Bài toán số lượng

class Solution:
    def longestSubstringWithoutRepeat(self, s: str):
        k = 1

        # Init start
        start = 0
        state = {}
        n = len(s)
        max_len = 0

        # Loop end
        for end in range(n):
            # Counting
            state[s[end]] = state.get(s[end], 0) + 1
            while state[s[end]] > k:
                state[s[start]] -= 1
                if state[s[start]] == 0:
                    del state[s[start]]
                start += 1

            max_len = max(max_len, end - start + 1)

        return max_len

14.3. Longest Repeating Character Replacement

  • Step 1: Loop from end

  • Step 2: Counting

  • Step 3: Prunning when reach the length

  • Step 4: Increase start

  • Step 5: Find max_len outside.

class Solution:
    def characterReplacement(self, s: str, k: int):
        state = {}
        start = 0
        max_len = 0
        max_freq = 0

        # Loop end
        for end in range(len(s)):
            state[s[end]] = state.get(s[end], 0) + 1
            max_freq = max(max_freq, state[s[end]])
            while (end - start + 1 - max_freq) > k:
                state[s[start]] -= 1
                if state[s[start]] == 0:
                    del(state[s[start]])
                start += 1

            max_len = max(max_len, end - start + 1)

        return max_len

14.4. Fixed-Length Sliding Window

  • Step 1: Loop end

  • Step 2: Prunning but end - start + 1 = k

  • Step 3: Increase start.

  • Step 4: Find max_len in prunning.

def max_subarray_sum(nums, k):
    start = 0
    state = 0
    max_sum = 0

    # Loop end
    for end in range(len(nums)):
        state += nums[end]

        # Prunning
        if end - start + 1 == k:
            # Update max_sum
            max_sum = max(max_sum, state)
            state -= nums[start]
            # Increase start
            start += 1

    return max_sum

nums = [2, 1, 5, 1, 3, 2]
k = 3
print(max_subarray_sum(nums, k))

14.5. Maximum Sum of Subarrays of Size K

  • Step 1: Loop end.

  • Step 2: Prunning when end - start + 1 = k.

  • Step 3: Update max_len trong loop.

class Solution:
    def maxSum(self, nums: List[int], k: int):
        state = 0
        start = 0
        max_sum = 0

        for end in range(len(nums)):
            state += nums[end]

            # Prunning
            if end - start + 1 == k:
                # Update max here
                max_sum = max(max_sum, state)
                state -= nums[start]
                start +=1

        return max_sum

14.6. Max Points You Can Obtain From Cards

class Solution:
    def maxScore(self, cards, k):
        total = sum(cards)
        if k >= len(cards):
            return total

        state = 0
        max_points = 0
        start = 0

        for end in range(len(cards)):
            state += cards[end]

            if end - start + 1 == len(cards) - k:
                max_points = max(total - state, max_points)
                state -= cards[start]
                start += 1

        return max_points

14.7. Max Sum of Distinct Subarrays Length k

  • Step 1: Subarray length k => Find max event in loop when end - start + 1 == k.

  • Step 2: Subarray length k => Handle state count in when end - start + 1 == k.

class Solution:
    def maxSum(self, nums: List[int], k: int):
        state = {}
        curr_sum = 0

        start = 0
        max_sum = 0

        for end in range(len(nums)):
            state[nums[end]] = state.get(nums[end], 0) + 1
            curr_sum += nums[end]

            # Prunning
            if end - start + 1 == k:
                if len(state) == k:
                    # Update max here
                    max_sum = max(max_sum, curr_sum)

                curr_sum -= nums[start]
                state[nums[start]] -= 1
                if state[nums[start]] == 0:
                    del(state[nums[start]])

                start += 1

        return max_sum

15. Linked List

15.1. Traversing a Linked List

  • Step 1: Using only current
def findLength(head):
  length = 0
  current = head
  while current:
    length += 1
    current = current.next
  return length

15.2. Deleting a Node With a Given Target

  • Step 1: Using current and prev

  • Step 2: Read from prev to curr:

    • prev = curr
    • curr = curr -> next
def deleteNode(head, target):
  if head.val == target:
    return head.next

  prev = None
  curr = head

  while curr:
    if curr.val == target:
      prev.next = curr.next
      break
    prev = curr
    curr = curr.next

  return head

15.3. Fast & Slow Pointer

  • Step 1: Điều kiện fast & fast.next.next

def fastAndSlow(head):
  fast = head
  slow = head
  while fast && fast.next:
    fast = fast.next.next
    slow = slow.next

  return slow

15.4. Detect Cycle

  • Step 1: Điều kiện fast & fast.next.next
class Solution:
    def hasCycle(self, head: Optional[ListNode]) -> bool:
        slow = head
        fast = head

        while fast and fast.next:
            fast = fast.next.next
            slow = slow.next
            if slow == fast:
                return True

        return False

15.5. Reversing a Linked List

  • Step 1: Using prev and current.

  • Step 2: Chỗ này tư duy kiểu gán theo thứ tự => curr->next = prev, thứ tự gán prev = current, current = next.

def reverse(head):
  prev = None
  current = head
  while current:
    next_ = current.next
    current.next = prev
    prev = current
    current = next_

  return prev

15.3. Merge Two Linked List

  • Step 1: Init head.

  • Step 2: Assign head to head of l1 or head of l2.

  • Step 3: Using tail = head, using tail to transfer the 2 linked list.

def merge_lists(l1, l2):
  if not l1: return l2
  if not l2: return l1

  if l1.val < l2.val:
    head = l1
    l1 = l1.next
  else:
    head = l2
    l2 = l2.next

  tail = head
  while l1 and l2:
    if l1.val < l2.val:
      tail.next = l1
      l1 = l1.next
    else:
      tail.next = l2
      l2 = l2.next
    tail = tail.next

  tail.next = l1 or l2
  return head

15.4. Palindrome Linked List

Idea:

  • Step 1: Reverse second half.

  • Step 2: Compare 2 lists.

def is_palindrome(head):
  # find middle node of the list
  slow = fast = head
  while fast and fast.next:
    fast = fast.next.next
    slow = slow.next

  # reverse second half of the list
  curr, prev = slow, None
  while curr:
    next_ = curr.next # save next node
    curr.next = prev # reverse pointer
    prev = curr # move pointers
    curr = next_

  # Check palindrome
  left, right = head, prev
  while right:
    if left.val != right.val:
      return False
    left = left.next
    right = right.next
  return True

15.5. Remove Nth Node From End of List (Hay)

  • Step 1: Cho con fast đi trước n steps.

  • Step 2: Chạy con fast vs slow cùng tới end.

  • Step 3: Trỏ con slow tới slow.next.next.

def removeNthFromEnd(head, n):
  dummy = ListNode(0)
  dummy.next = head

  fast, slow = dummy, dummy
  for _ in range(n):
    fast = fast.next

  while fast.next:
    fast = fast.next
    slow = slow.next

  # remove nth node from end
  slow.next = slow.next.next
  return dummy.next

15.6. Reorder List

  • Step 1: Reverse second half of list

  • Step 2: Merge first half with reversed second half

def reorderList(head):
  if not head or not head.next:
    return head

  # find middle node
  slow = fast = head
  while fast and fast.next:
    fast = fast.next.next
    slow = slow.next

  # reverse second half of list
  prev, curr = None, slow
  while curr:
    next_ = curr.next
    curr.next = prev
    prev, curr = curr, next_

  # merge first and reversed second half of list
  first, second = head, prev
  while second.next:
    first.next, first = second, first.next
    second.next, second = first, second.next

  return head

15.7. Swap Nodes in Pairs

  • Idea: Reverse pair + next

  • Using con dummyNode, prev, current.

  • Step 1: Dùng prev, current xong thêm vô list mới cũng được.

def swapPairs(head):
  dummy = ListNode(0)
  dummy.next = head
  tail, first = dummy, head

  while first and first.next:
    second = first.next

    # swap nodes
    tail.next = second
    first.next = second.next
    second.next = first

    tail = first
    first = first.next

  return dummy.next

16. Two Pointer

16.1. Two Pointer (2 end)

  • Step 1: Same with binary search => but do not use mid = (left + right) / 2
def twoSum(nums, target):
  left, right = 0, len(nums) - 1

  while left < right:
    current_sum = nums[left] + nums[right]
    if current_sum == target:
        return True

    if current_sum < target:
        left += 1
    else:
        right -= 1

  return False

16.2. Container With Most Water

  • Step 1: Same with binary search => but do not use mid = (left + right) / 2
class Solution:
    def max_area(self, heights):
        # Left and right but not mid
        left, right = 0, len(heights) - 1
        max_area = 0

        while left < right:
            width = right - left
            height = min(heights[left], heights[right])
            curr_area = width * height

            max_area = max(max_area, curr_area)

            if heights[left] < heights[right]:
                left += 1
            else:
                right -= 1

        return max_area

16.3. Three Sum

  • Step 1: Giữ 1 số dầu => xong binary search trong đoạn còn lại => N * O(N) => O(N^2).

16.4. Triangle Numbers

  • Step 1: Sort and keep the largest number => Keep it => Find 2 sums.
class Solution:
    def triangleNumber(self, nums: List[int]):
        nums.sort()
        n = len(nums)

        count = 0

        for end in range(n - 1, -1, -1):
            target = nums[end]
            left, right = 0, end - 1

            while left < right:
                curr_sum = nums[left] + nums[right]

                if curr_sum > target:
                    count += right - left # Node here (Keep right)
                    right -= 1
                else:
                    left += 1

        return count

16.5. Move Zeroes

  • Step 1: Init the nextNonZeros => Increase it by condition.

  • Step 2: Go from left to right => If meet 0 => Swap to the back.

class Solution:
    def moveZeroes(self, nums: List[int]):
        n = len(nums)
        nextNonZeros = 0

        for i in range(n):
            if nums[i] != 0:
                nums[i], nums[nextNonZeros] = nums[nextNonZeros], nums[i]
                nextNonZeros += 1

        return nums

16.6. Remove duplicates

  • Step 1: Init the nextNonDup => Increase it by condition.

  • Step 2: If nums[i] != nums[nextNonDup - 1] => Increase nextNonDup

class Solution:
    def removeDuplicates(self, nums: List[int]) -> int:
        nextNonDup = 0
        n = len(nums)

        for i in range(n):
            if i == 0 or nums[i] != nums[nextNonDup - 1]:
                nums[nextNonDup] = nums[i]
                nextNonDup += 1

        return nextNonDup

16.7. Sort Flag (Merge 2 cái idea lại)

  • Step 1: nums[i] = 0 -> swap to left

  • Step 2: nums[i] = 1 -> Keep.

  • Step 3: nums[i] = 2 -> Swap right

Chỉ swap left thì i += 1 thôi.

  • Notes: Merge 2 cái idea lại => Bản chất duyệt đầu cuối, khác cái Move Zeros do bài đó chỉ có move cuối, còn cái này move 2 đầu nên phải có duyệt đầu cuối.
class Solution:
    def sortColors(self, nums: List[int]):
        left, right = 0, len(nums) - 1
        i = 0

        while i <= right:
            if nums[i] == 0:
                nums[i], nums[left] = nums[left], nums[i]
                left += 1
                i += 1
            elif nums[i] == 1:
                i += 1
            else:
                nums[i], nums[right] = nums[right], nums[i]
                right -= 1

        return nums


16.8. Trapping Rain Water

Idea:

class Solution:
    def trappingWater(self, heights: List[int]):
        if not heights:
            return 0
        left, right = 0, len(heights) - 1
        leftMax, rightMax = heights[left], heights[right]
        count = 0

        while left < right:
            if rightMax > leftMax:
                left += 1
                if heights[left] > leftMax:
                    leftMax = heights[left]
                else:
                    count += leftMax - heights[left]
            else:
                right -= 1
                if heights[right] > rightMax:
                    rightMax = heights[right]
                else:
                    count += rightMax - heights[right]

        return count

17. Dymamic Programming

17.1. Top-down

Top Down = DFS + memo table

  • Step 1: Basecase.

  • Step 2: Prunning.

  • Step 3: Memoization.

  • Step 4: Node.

  • Step 5: Neighbors.

=> Bản chất vẫn đi tìm dp[n].

def climbStairs(n: int) -> int:
    memo = {}

    def climb_helper(i: int) -> int:
        # Base case
        if i <= 1:
            return 1

        # Memoization
        if i in memo:
            return memo[i]

        # Node
        memo[i] = climb_helper(i - 1) + climb_helper(i - 2)
        return memo[i]

    return climb_helper(n)

17.2. Bottom-up

  • Build a table from dp[0] -> dp[n]

  • Step 1: Base case => what is needed for first build.

  • Step 2: Node.

  • Step 3: Condition.

  • Step 4: Neighbors

def stairs(n):
    if n <= 1:
        return 1
    dp = [0] * (n + 1)

    dp[0] = 1
    dp[1] = 1
    for i in range(2, n + 1):
        dp[i] = dp[i - 1] + dp[i - 2]
    return dp[n]

17.3. Dynamic Programming Problems

  • It has optimal substructure (it can be solved using recursion)
  • It has overlapping subproblems (the same recursive call is made multiple times)

Idea:

  1. Top-down first:
  • Step 1: Write base case.

    • If we have 0 houses, we can’t collect any treasure, so dp(0) = 0.
    • If we have 1 house, we can only collect the treasure from the first house, so dp(1) = treasure[0].
  • Step 2: Write a rob_helper(i).

  • Step 3: Add memoization.

def rob(treasure):
    if not treasure:
        return 0

    def rob_helper(i):
        if i == 0:
            return 0
        if i == 1:
            return treasure[0]

        skip = rob_helper(i - 1)
        take = rob_helper(i - 2) + treasure[i - 1]
        return max(skip, take)

    return rob_helper(len(treasure))
  1. Bottom-up DP:
  • Covert memoization table to bottom-up DP
def rob(treasure):
    if not treasure:
        return 0

    # initialize dp array
    dp = [0] * (len(treasure) + 1)

    # fill in base cases (dp[0] = 0 already)
    dp[1] = treasure[0]

    # iterate to fill in the rest of the array
    for i in range(2, len(treasure) + 1):
        # fill in dp[i] using the recurrence relation
        take = dp[i - 2] + treasure[i - 1]
        skip = dp[i - 1]
        dp[i] = max(take, skip)

    return dp[len(treasure)]

17.4. Rob Treasure Problem

  • Step 1: Init DFS Helper.

  • Step 2: Base case

  • Step 2: Memoization.

  • Step 3: Node

  • Step 4: Result

Because we have N houses => We gain treasure at the (i - 1)th house.

  1. Top-down:
def rob(treasure):
    if not treasure:
        return 0

    def rob_helper(i):
        # Base case
        if i == 0:
            return 0
        if i == 1:
            return treasure[0]

        # Node
        take = rob_helper(i - 2) + treasure[i - 1]
        skip = rob_helper(i - 1)

        # Result
        return max(take, skip)

    n = len(treasure)
    return rob_helper(n)

treasure = [2, 7, 9, 3, 1]
print(rob(treasure))  # Output: 12
  1. Memoization:
  • Add memo to store the current result => memo = {} => if i in memo => return memo[i] => memo[i] = max(take, skip)
def rob(treasure):
    if not treasure:
        return 0

    memo = {}

    def rob_helper(i):
        # Base case
        if i == 0:
            return 0
        if i == 1:
            return treasure[0]

        # Memoization
        if i in memo:
            return memo[i]

        # Node
        take = rob_helper(i - 2) + treasure[i - 1]
        skip = rob_helper(i - 1)
        memo[i] = max(take, skip)

        # Result
        return memo[i]

    n = len(treasure)
    return rob_helper(n)

treasure = [2, 7, 9, 3, 1]
print(rob(treasure))  # Output: 12
  1. Bottom-up:
  • Covert memoization table in top-down to bottom-up.

  • Store data with (n + 1) elements

Step 1: Init dp tables.

Step 2: Base case for start calculate.

Step 3: Calculate.

def rob(treasure):
    if not treasure:
        return 0

    # Init dp table
    n = len(treasure)
    dp = [0] * (n + 1)

    # Base case
    dp[0] = 0
    dp[1] = treasure[0]

    for i in range(2, n + 1):
        # Calculate
        dp[i] = max(dp[i - 1], dp[i - 2] + treasure[i - 1])

    return dp[n]

treasure = [2, 7, 9, 3, 1]
print(rob(treasure))  # Output: 12

17.5. Counting Bits

class Solution:
    def count_bits(self, n: int):
        dp = [0] * (n + 1)

        for i in range(1, n + 1):
            dp[i] = dp[i // 2] + (i % 2)
        return dp

17.6. Decode Ways

  • Previous 1-bit: d[i] += d[i - 1]

  • Previous 2-bit: d[i] += d[i - 2]

class Solution:
    def num_decodings(self, s: str):
        if not s or s[0] == '0':
            return 0

        n = len(s)
        dp = [0] * (n + 1)
        dp[0], dp[1] = 1, 1

        for i in range(2, n + 1):
            digit = s[i - 1]
            if int(digit) != 0:
                dp[i] += dp[i - 1]

            digit = s[i - 2:i]
            if 10 <= int(digit) <= 26:
                dp[i] += dp[i - 2]

        return dp[n]

17.7. Maximal Square

class Solution:
    def maximal_square(self, matrix: List[List[int]]):
        if not matrix:
            return 0

        r = len(matrix)
        c = len(matrix[0])
        dp = [[0] * (c + 1) for _ in range(r + 1)]
        max_side = 0

        for i in range(1, r + 1):
            for j in range(1, c + 1):
                if matrix[i - 1][j - 1] == 1:
                    top = dp[i - 1][j]
                    left = dp[i][j - 1]
                    diag = dp[i - 1][j - 1]
                    dp[i][j] = min(top, left, diag) + 1
                    max_side = max(max_side, dp[i][j])

        return max_side * max_side

17.8. Robot Unique Paths

  • Idea: Loop in top and left border
class Solution:
    def unique_paths(self, m: int, n: int) -> int:
        # Initialize a 2D array with dimensions m x n
        dp = [[0] * n for _ in range(m)]

        # base case: there is only one way to reach any cell in the first row (moving only right)
        for i in range(n):
            dp[0][i] = 1

        # Set base case: there is only one way to reach any cell in the first column (moving only down)
        for j in range(m):
            dp[j][0] = 1

        # Fill the rest of the dp array
        for i in range(1, m):
            for j in range(1, n):
                dp[i][j] = dp[i - 1][j] + dp[i][j - 1]

        return dp[m - 1][n - 1]

17.9. Longest Increasing Subsequence

  • Idea: dp[i] = max(dp[i], dp[j] + 1)
class Solution:
    def longest_increasing_subsequence(self, nums: List[int]):
        if not nums:
            return 0

        n = len(nums)
        dp = [1] * n

        for i in range(1, n):
            for j in range(i):
                if nums[i] > nums[j]:
                    dp[i] = max(dp[i], dp[j] + 1)

        return max(dp)

17.20. Word Break

class Solution:
    def word_break(self, s: str, wordDict: List[str]):
        dp = [False] * (len(s) + 1)
        dp[0] = True

        for i in range(1, len(s) + 1):
            for word in wordDict:
                if i >= len(word) and dp[i - len(word)]:
                    sub = s[i - len(word):i]
                    if sub == word:
                        dp[i] = True
                        break

        return dp[len(s)]

17.21. Job Scheduling

from bisect import bisect_right
from typing import List

class Solution:
    def job_scheduling(self, startTime: List[int], endTime: List[int], profit: List[int]) -> int:
        # Sort jobs by end time
        jobs = sorted(zip(startTime, endTime, profit), key=lambda x: x[1])

        # Extract end times for binary search
        ends = [job[1] for job in jobs]

        dp = [0] * (len(jobs) + 1)

        for i in range(1, len(jobs) + 1):
            start, end, p = jobs[i - 1]
            idx = bisect_right(ends, start)
            dp[i] = max(dp[i - 1], dp[idx] + p)

        return dp[-1]

18. Greedy

18.1. Greeds and Cookies

  • Using i and j in 2 array, only increase i when pass the condition.
def findContentChildren(greeds, cookies):
    greeds.sort()
    cookies.sort()

    count = 0
    i, j = 0, 0
    while i < len(greeds) and j < len(cookies):
        # current cookie can satisfy current child
        if cookies[j] >= greeds[i]:
            count += 1
            i += 1
        j += 1

    return count

18.2. Best Time to Buy and Sell Stock


def maxProfit(prices):
  if not prices:
    return 0

  min_price = prices[0]
  max_profit = 0

  for price in prices:
    min_price = min(min_price, price)
    max_profit = max(max_profit, price - min_price)

  return max_profit

18.3. Gas Station

def canCompleteCircuit(gas, cost):
  if sum(gas) < sum(cost):
    return -1

  start, fuel = 0, 0
  for i in range(len(gas)):
    if fuel + gas[i] - cost[i] < 0:
      # can't reach next station:
      # try starting from next station
      start, fuel = i + 1, 0
    else:
      # can reach next station:
      # update remaining fuel
      fuel += gas[i] - cost[i]

  return start

18.4. Jump Game

class Solution:
    def canJump(self, nums: List[int]):
        max_reach = 0

        for i in range(len(nums)):
            if i > max_reach:
                return False
            max_reach = max(max_reach, i + nums[i])

        return True

Last Updated On June 28, 2025