Last Stone Weight (Leetcode #1046)

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 the heaviest two stones and smash them together. Suppose the heaviest two 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 weight of the last remaining stone. If there are no stones left, return 0.

Example 1:

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

Example 2:

Input: stones = [1]
Output: 1

Constraints:

  • 1 <= stones.length <= 30

  • 1 <= stones[i] <= 1000

Heap

This is our first introduction in to a data structure called Heap

A min-heap is a type of binary heap data structure where the parent node is always smaller than or equal to its child nodes. This ensures that the smallest element is always at the root of the heap.

Key Characteristics of a Min-Heap

  1. Heap Property: In a min-heap, for any given node i, the value of i is less than or equal to the values of its children. This makes the smallest element always reside at the root of the tree.

  2. Complete Binary Tree: A min-heap is a complete binary tree, meaning all levels of the tree are fully filled except possibly the last level, which is filled from left to right.

Operations on a Min-Heap

  • Insertion (heappush): When a new element is added, it is placed at the end of the tree (maintaining the complete tree property) and then "bubbled up" to its correct position to maintain the heap property. This operation takes (O(log n)) time.

  • Find Minimum (heap[0]): The minimum element can be accessed directly at the root (index 0 in an array representation). This operation takes (O(1)) time.

  • Removal of Minimum (heappop): To remove the smallest element (root), the last element is moved to the root position, and then "bubbled down" to restore the heap property. This operation also takes (O(log n)) time.

Example of a Min-Heap

Consider the following min-heap:

       3
      / \
     5   8
    / \   /
   10  15 20
  • Array Representation: [3, 5, 8, 10, 15, 20]

  • The smallest element, 3, is at the root.

  • Each parent node is smaller than its children, maintaining the min-heap property.

Summary

A min-heap is a binary tree data structure that allows efficient retrieval of the smallest element. It is used in various algorithms, such as priority queues, where elements need to be processed based on their priority (smallest first). The reason we would use this over a sorted list is when we have to insert and remove a lot of item to a list while keeping the sort order.

Answer

The question requires us to repeatedly find the two largest numbers from a list, compare them, and add the difference back to the list. This process is repeated until there is only one or no rock left.

Using a heap to solve this problem poses a challenge because we need to frequently access the largest numbers, which requires a max heap. However, Python’s heapq module provides a min-heap by default. To simulate a max heap, we can multiply all the numbers by -1. This way, the largest number becomes the smallest in terms of negative values, and we can efficiently use the min-heap as a max heap.

We then repeatedly extract the two largest elements (smallest in negative form), compare them, and if they are not equal, we compute their difference and add it back to the heap.

This process continues until there is one or no rock left, and we return the remaining value and if they are not equal we can take the difference and add it back to the heap.

We repeat this until we have one on no rock left and return the remaining values

class Solution(object):
    def lastStoneWeight(self, stones):
        """
        :type stones: List[int]
        :rtype: int
        """
        if not stones:
            return 0
        negative = [-s for s in stones]
        heapq.heapify(negative)
        while len(negative) > 1:
            y = heapq.heappop(negative)
            x = heapq.heappop(negative)
            if x != y:
                heapq.heappush(negative,(y - x))
        return -negative[0] if negative else 0

Time Complexity

  1. Creating a Negative List:
    Converting all elements to negative values takes (O(n)) time because we visit each element in the list exactly once.

  2. Heapify:
    Transforming the list into a heap takes (O(n)) time. This is because heapify can be done in linear time for a list of (n) elements.

  3. Processing the Heap:
    In the worst case, we perform up to (n - 1) operations where we remove the two largest elements and push the result back into the heap. Each heappop operation and heappush operation on a heap takes (O(log n)) time. Therefore, the time complexity for this loop is (O(n log n)).

Combining all these steps:

  • Creating the negative list: (O(n))

  • Heapifying the list: (O(n))

  • Processing the heap (loop): (O(n log n))

The overall time complexity is (O(n log n)). This is because the (O(n log n)) term dominates the linear terms (O(n)).

Space Complexity

The space complexity is O(N) since at any given point we are storing a heap with maximum length of n.