Last Stone Weight II

Medium
5 days ago

You are given an array of integers stones where stones[i] is the weight of the i<sup>th</sup> stone. We are playing a game with the stones. On each turn, we choose any two stones and smash them together. Suppose the stones have weights x and y with x <= y. The result of this smash is:

  • If x == y, both stones are destroyed, and
  • If x != y, the stone of weight x is destroyed, and the stone of weight y has new weight y - x.

At the end of the game, there is at most one stone left.

Return the smallest possible weight of the left stone. If there are no stones left, return 0.

Example 1:

Input: stones = [2,7,4,1,8,1]
Output: 1
Explanation:
We can combine 2 and 4 to get 2, so the array converts to [2,7,1,8,1] then,
we can combine 7 and 8 to get 1, so the array converts to [2,1,1,1] then,
we can combine 2 and 1 to get 1, so the array converts to [1,1,1] then,
we can combine 1 and 1 to get 0, so the array converts to [1], then that's the optimal value.

Example 2:

Input: stones = [31,26,33,21,40]
Output: 5

Constraints:

  • 1 <= stones.length <= 30
  • 1 <= stones[i] <= 100
Sample Answer

Here's how to solve the stone smashing problem using dynamic programming.

1. Naive Approach (Recursion)

A straightforward, but inefficient, way to solve this problem is to use recursion. We try all possible pairs of stones, simulate the smash, and recursively call the function with the updated stones array. We keep track of the minimum possible weight at the end.

def lastStoneWeightRecursive(stones):
    if not stones:
        return 0
    if len(stones) == 1:
        return stones[0]

    min_weight = float('inf')
    for i in range(len(stones)):
        for j in range(i + 1, len(stones)):
            x = stones[i]
            y = stones[j]
            new_stones = stones[:i] + stones[i+1:j] + stones[j+1:]
            if x == y:
                min_weight = min(min_weight, lastStoneWeightRecursive(new_stones))
            else:
                new_stones.append(abs(x - y))
                min_weight = min(min_weight, lastStoneWeightRecursive(new_stones))

    return min_weight

This approach has exponential time complexity because of the overlapping subproblems. It will result in TLE (Time Limit Exceeded) for larger input sizes.

2. Optimal Approach (Dynamic Programming)

We can optimize the solution using dynamic programming. The key idea is to realize that we are essentially trying to divide the stones into two groups such that the absolute difference of their sums is minimized. This can be framed as a 0/1 knapsack problem.

Here's the algorithm:

  1. Calculate the total sum of all stone weights (total_sum).
  2. Create a DP table dp of size (total_sum // 2) + 1. dp[i] will be True if a subset of stones can sum up to i, and False otherwise.
  3. Initialize dp[0] to True (an empty set can sum up to 0).
  4. Iterate through the stones array. For each stone stone, iterate through the dp table from total_sum // 2 down to stone. If dp[j - stone] is True, then dp[j] becomes True (meaning we can achieve sum j by including stone).
  5. Find the largest i such that dp[i] is True. This represents the sum of one group of stones.
  6. The smallest possible weight is total_sum - 2 * i (the difference between the two groups).
def lastStoneWeightDP(stones):
    total_sum = sum(stones)
    target = total_sum // 2

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

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

    for i in range(target, -1, -1):
        if dp[i]:
            return total_sum - 2 * i

3. Code Implementation (Python)

def lastStoneWeight(stones):
    total_sum = sum(stones)
    n = len(stones)
    target = total_sum // 2

    dp = [[False] * (target + 1) for _ in range(n + 1)]
    for i in range(n + 1):
        dp[i][0] = True

    for i in range(1, n + 1):
        for j in range(1, target + 1):
            if stones[i-1] <= j:
                dp[i][j] = dp[i-1][j] or dp[i-1][j - stones[i-1]]
            else:
                dp[i][j] = dp[i-1][j]

    ans = 0
    for j in range(target, -1, -1):
        if dp[n][j]:
            ans = total_sum - 2 * j
            break
    return ans

4. Big O Analysis

  • Time Complexity: O(n * sum), where n is the number of stones and sum is the total sum of the stone weights. This is due to the nested loops in the dynamic programming approach.
  • Space Complexity: O(n * sum), where n is the number of stones and sum is the total sum of the stone weights, due to the size of the DP table.

5. Edge Cases

  • Empty input: If the input stones array is empty, the function should return 0.
  • Single stone: If there's only one stone, the function should return its weight.
  • Stones with the same weight: The algorithm correctly handles cases where multiple stones have the same weight.
  • Large stone weights: The time and space complexity depends on the total sum of the stone weights. If the weights are very large, the DP table could become very large, potentially leading to memory issues. In such cases, alternative approaches might be considered.

Example Usage

stones1 = [2, 7, 4, 1, 8, 1]
print(lastStoneWeight(stones1))  # Output: 1

stones2 = [31, 26, 33, 21, 40]
print(lastStoneWeight(stones2))  # Output: 5