Mastering Heaps: 5 Essential Algorithms for Optimal Performance
Published:
Immerse yourself in the world of heaps with this informative post. Explore five essential algorithms that leverage heaps to optimize performance and efficiency. Unlock the potential of heaps in your data structure and algorithms toolkit with this indispensable guide.
In this blog post, we will examine numerous heap algorithms and how they are used in software engineering and computer science. We will go over the key ideas and methods that each software engineer should be familiar with, from heap data structure to heap sort algorithm. Prepare yourself to enter the realm of heaps! You can run this post in Google Colab using this link:
1. Heap Data Structure
A heap is a tree-based data structure that satisfies the heap property. The heap property states that for every node $i$ other than the root, the value of the node is at least as large as the value of its parent. This is called a max-heap. A min-heap is defined similarly, except that each node is at least as small as its children. The root of the heap is the largest element in a max-heap and the smallest element in a min-heap. The heap property is listed below:
A min-heap is a complete binary tree where each node is smaller than or equal to its children. This means that the root of the heap is the minimum element in the entire tree, and each subtree rooted at a node also satisfies the heap property.
A max-heap is a complete binary tree where each node is greater than or equal to its children. This means that the root of the heap is the maximum element in the entire tree, and each subtree rooted at a node also satisfies the heap property.
Note: In both cases, the heap property ensures that the minimum or maximum element can be efficiently retrieved from the heap in O(1) time. Additionally, inserting and deleting elements in a heap while maintaining the heap property can be done in O(log n) time, where n is the number of elements in the heap.
Recap: A complete binary tree is a special type of binary tree in which every level of the tree is completely filled, except possibly for the last level. In the last level, all nodes are aligned as far left as possible. This means that a complete binary tree can be represented as an array, where the root is at index
0, its children are at indices1and2, and so on. The parent of a node at indexiis at indexfloor((i-1)/2). The children of a node at indexiare at indices2i+1and2i+2.
Recap: Binary trees are trees in which each node has at most two children. A binary tree is a full binary tree if each node has either zero or two children. A binary tree is a perfect binary tree if all interior nodes have two children and all leaves have the same depth or same level. A binary tree is a complete binary tree if all levels except possibly the last are completely filled and all nodes are as far left as possible.
1.1. Min Heap class
Wrtie a class called minHeap that implements a min-heap.
class minHeap:
    def __init__(self):
        self.heap = []
        self.size = 0
    def parent(self, i):
        return (i-1)//2
    def left(self, i):
        return 2*i + 1
    def right(self, i):
        return 2*i + 2
    def insertKey(self, k):
        self.size += 1
        i = self.size - 1
        self.heap.append(k)
        while i != 0 and self.heap[self.parent(i)] > self.heap[i]:
            self.heap[i], self.heap[self.parent(i)] = self.heap[self.parent(i)], self.heap[i]
            i = self.parent(i)
    def decreaseKey(self, i, new_val):
        self.heap[i] = new_val
        while i != 0 and self.heap[self.parent(i)] > self.heap[i]:
            self.heap[i], self.heap[self.parent(i)] = self.heap[self.parent(i)], self.heap[i]
            i = self.parent(i)
    def extractMin(self):
        if self.size <= 0:
            return float('inf')
        if self.size == 1:
            self.size -= 1
            return self.heap.pop()
        root = self.heap[0]
        self.heap[0] = self.heap.pop()
        self.size -= 1
        self.minHeapify(0)
        return root
    def deleteKey(self, i):
        self.decreaseKey(i, float('-inf'))
        self.extractMin()
    def minHeapify(self, i):
        l = self.left(i)
        r = self.right(i)
        smallest = i
        if l < self.size and self.heap[l] < self.heap[i]:
            smallest = l
        if r < self.size and self.heap[r] < self.heap[smallest]:
            smallest = r
        if smallest != i:
            self.heap[i], self.heap[smallest] = self.heap[smallest], self.heap[i]
            self.minHeapify(smallest)
    def getMin(self):
        return self.heap[0]
    def printHeap(self):
        print(self.heap)
Explanation:
The
Heapclass has a constructor that takes in an array of integers and stores them in an instance variable calledheap. The constructor also calls thebuild_heapmethod to build the heap from the array.The
build_heapmethod builds a min-heap from the array of integers stored in theheapinstance variable. It does this by calling theheapifymethod on each node in the tree, starting from the last node and working its way up to the root.The
heapifymethod takes in an indexiand recursively moves the node at indexidown the tree until the heap property is satisfied. This is done by comparing the node at indexiwith its children. If the node at indexiis greater than either of its children, then it is swapped with the smaller child. This process is repeated until the node at indexiis smaller than both of its children.The
insertmethod takes in an integerxand inserts it into the heap. It does this by appendingxto the end of the array and then calling theheapifymethod on the last node in the tree.The
extract_minmethod removes and returns the minimum element in the heap. It does this by swapping the root of the heap with the last node in the tree, removing the last node, and then calling theheapifymethod on the root of the tree.The
get_minmethod returns the minimum element in the heap. It does this by returning the root of the tree.The
sizemethod returns the number of elements in the heap. It does this by returning the length of the array.The
is_emptymethod returnsTrueif the heap is empty andFalseotherwise. It does this by checking if the length of the array is equal to zero.The
print_heapmethod prints the elements in the heap. It does this by iterating through the array and printing each element.The
heap_sortmethod sorts the elements in the heap. It does this by repeatedly calling theextract_minmethod and appending the result to a list.The
increase_keymethod takes in an indexiand an integerxand increases the value of the node at indexibyx. It does this by addingxto the node at indexiand then calling theheapifymethod on the node at indexi.The
decrease_keymethod takes in an indexiand an integerxand decreases the value of the node at indexibyx. It does this by subtractingxfrom the node at indexiand then calling theheapifymethod on the node at indexi.The
rightandleftmethods return the indices of the right and left children of a node at indexi, respectively. They do this by returning2*i+2and2*i+1, respectively.
Time and Space Complexity:
The
build_heapmethod runs in O(n) time, where n is the number of elements in the heap. This is because it calls theheapifymethod on each node in the tree, starting from the last node and working its way up to the root. Thebuild_heapmethod uses O(n) space, where n is the number of elements in the heap. This is because it creates an array of length n.The
heapifymethod runs in O(log n) time, where n is the number of elements in the heap. This is because it compares the node at indexiwith its children and swaps it with the smaller child if necessary. This process is repeated until the node at indexiis smaller than both of its children. Theheapifymethod uses O(1) space, where n is the number of elements in the heap. This is because it does not create any new variables.The
insertmethod runs in O(log n) time, where n is the number of elements in the heap. This is because it appendsxto the end of the array and then calls theheapifymethod on the last node in the tree. Theinsertmethod uses O(1) space, where n is the number of elements in the heap. This is because it does not create any new variables.The
extract_minmethod runs in O(log n) time, where n is the number of elements in the heap. This is because it swaps the root of the heap with the last node in the tree, removes the last node, and then calls theheapifymethod on the root of the tree. Theextract_minmethod uses O(1) space, where n is the number of elements in the heap. This is because it does not create any new variables.The
get_minmethod runs in O(1) time, where n is the number of elements in the heap. This is because it returns the root of the tree. Theget_minmethod uses O(1) space, where n is the number of elements in the heap. This is because it does not create any new variables.The
sizemethod runs in O(1) time, where n is the number of elements in the heap. This is because it returns the length of the array. Thesizemethod uses O(1) space, where n is the number of elements in the heap. This is because it does not create any new variables.The
is_emptymethod runs in O(1) time, where n is the number of elements in the heap. This is because it checks if the length of the array is equal to zero. Theis_emptymethod runs in O(1) time, where n is the number of elements in the heap. This is because it checks if the length of the array is equal to zero.The
print_heapmethod runs in O(n) time, where n is the number of elements in the heap. This is because it iterates through the array and prints each element. Theprint_heapmethod uses O(1) space, where n is the number of elements in the heap. This is because it does not create any new variables.The
heap_sortmethod runs in O(n log n) time, where n is the number of elements in the heap. This is because it repeatedly calls theextract_minmethod and appends the result to a list. Theheap_sortmethod uses O(n) space, where n is the number of elements in the heap. This is because it creates an array of length n.The
increase_keymethod runs in O(log n) time, where n is the number of elements in the heap. This is because it addsxto the node at indexiand then calls theheapifymethod on the node at indexi. Theincrease_keymethod uses O(1) space, where n is the number of elements in the heap. This is because it does not create any new variables.The
decrease_keymethod runs in O(log n) time, where n is the number of elements in the heap. This is because it subtractsxfrom the node at indexiand then calls theheapifymethod on the node at indexi. Thedecrease_keymethod uses O(1) space, where n is the number of elements in the heap. This is because it does not create any new variables.The
rightandleftmethods run in O(1) time, where n is the number of elements in the heap. This is because they return2*i+2and2*i+1, respectively. Therightandleftmethods use O(1) space, where n is the number of elements in the heap. This is because they do not create any new variables.
# example - insertKey, extractMin, decreaseKey, deleteKey
heap = minHeap()
print('heap = minHeap(): '), heap.printHeap()
print('-----------------------------------')
heap.insertKey(3)
print('heap.insertKey(3): '), heap.printHeap()
print('-----------------------------------')
heap.insertKey(2)
print('heap.insertKey(2): '), heap.printHeap()
print('-----------------------------------')
heap.deleteKey(1)
print('heap.deleteKey(1): '), heap.printHeap()
print('-----------------------------------')
heap.insertKey(15)
print('heap.insertKey(15): '), heap.printHeap()
print('-----------------------------------')
heap.insertKey(5)
print('heap.insertKey(5): '), heap.printHeap()
print('-----------------------------------')
heap.insertKey(4)
print('heap.insertKey(4): '), heap.printHeap()
print('-----------------------------------')
heap.insertKey(45)
print('Extract Minimum: ', heap.extractMin())
print('Heap after extract: '), heap.printHeap()
print('-----------------------------------')
print('New Minimum after extract min: ', heap.getMin())
heap.decreaseKey(2, 1)
print('heap.decreaseKey(2, 1): '), heap.printHeap()
print('New Minimum after decrease: ', heap.getMin())
print('-----------------------------------')
print('Final heap: '), heap.printHeap()
heap = minHeap():
[]
-----------------------------------
heap.insertKey(3):
[3]
-----------------------------------
heap.insertKey(2):
[2, 3]
-----------------------------------
heap.deleteKey(1):
[2]
-----------------------------------
heap.insertKey(15):
[2, 15]
-----------------------------------
heap.insertKey(5):
[2, 15, 5]
-----------------------------------
heap.insertKey(4):
[2, 4, 5, 15]
-----------------------------------
Extract Minimum:  2
Heap after extract:
[4, 15, 5, 45]
-----------------------------------
New Minimum after extract min:  4
heap.decreaseKey(2, 1):
[1, 15, 4, 45]
New Minimum after decrease:  1
-----------------------------------
Final heap:
[1, 15, 4, 45]
(None, None)
1.2. Max Heap class
class maxHeap:
    def __init__(self):
        self.heap = []
        self.size = 0
    def parent(self, i):
        return (i-1)//2
    def left(self, i):
        return 2*i + 1
    def right(self, i):
        return 2*i + 2
    def insertKey(self, k):
        self.size += 1
        i = self.size - 1
        self.heap.append(k)
        while i != 0 and self.heap[self.parent(i)] < self.heap[i]:
            self.heap[i], self.heap[self.parent(i)] = self.heap[self.parent(i)], self.heap[i]
            i = self.parent(i)
    def increaseKey(self, i, new_val):
        self.heap[i] = new_val
        while i != 0 and self.heap[self.parent(i)] < self.heap[i]:
            self.heap[i], self.heap[self.parent(i)] = self.heap[self.parent(i)], self.heap[i]
            i = self.parent(i)
    def extractMax(self):
        if self.size <= 0:
            return float('-inf')
        if self.size == 1:
            self.size -= 1
            return self.heap.pop()
        root = self.heap[0]
        self.heap[0] = self.heap.pop()
        self.size -= 1
        self.maxHeapify(0)
        return root
    def deleteKey(self, i):
        self.increaseKey(i, float('inf'))
        self.extractMax()
    def maxHeapify(self, i):
        l = self.left(i)
        r = self.right(i)
        largest = i
        if l < self.size and self.heap[l] > self.heap[i]:
            largest = l
        if r < self.size and self.heap[r] > self.heap[largest]:
            largest = r
        if largest != i:
            self.heap[i], self.heap[largest] = self.heap[largest], self.heap[i]
            self.maxHeapify(largest)
    def getMax(self):
        return self.heap[0]
    def printHeap(self):
        print(self.heap)
Description:
The
MaxHeapclass is a class that implements a max heap. It has the following methods:build_heap: This method takes in an array and builds a max heap from the array. It does this by calling theheapifymethod on each node in the tree, starting from the last node and working its way up to the root.heapify: This method takes in an indexiand compares the node at indexiwith its children. If the node at indexiis smaller than either of its children, it is swapped with the larger child. This process is repeated until the node at indexiis larger than both of its children.insert: This method takes in an integerxand inserts it into the heap. It does this by appendingxto the end of the array and then calling theheapifymethod on the last node in the tree.extract_max: This method removes and returns the maximum element in the heap. It does this by swapping the root of the heap with the last node in the tree, removing the last node, and then calling theheapifymethod on the root of the tree.get_max: This method returns the maximum element in the heap. It does this by returning the root of the tree.size: This method returns the number of elements in the heap. It does this by returning the length of the array.is_empty: This method returnsTrueif the heap is empty andFalseotherwise. It does this by checking if the length of the array is equal to zero.print_heap: This method prints the elements in the heap. It does this by iterating through the array and printing each element.heap_sort: This method sorts the elements in the heap. It does this by repeatedly calling theextract_maxmethod and appending the result to a list.increase_key: This method takes in an indexiand an integerxand increases the value of the node at indexibyx. It does this by addingxto the node at indexiand then calling theheapifymethod on the node at indexi.decrease_key: This method takes in an indexiand an integerxand decreases the value of the node at indexibyx. It does this by subtractingxfrom the node at indexiand then calling theheapifymethod on the node at indexi.right: This method takes in an indexiand returns the index of the right child of the node at indexi. It does this by returning2*i+2.left: This method takes in an indexiand returns the index of the left child of the node at indexi. It does this by returning2*i+1.
Time Complexity and space Complexity:
The
build_heapmethod runs in O(n) time, where n is the number of elements in the heap. This is because it calls theheapifymethod on each node in the tree, starting from the last node and working its way up to the root. Thebuild_heapmethod uses O(1) space, where n is the number of elements in the heap. This is because it does not create any new variables.The
heapifymethod runs in O(log n) time, where n is the number of elements in the heap. This is because it compares the node at indexiwith its children and swaps it with the larger child if it is smaller than either of its children. This process is repeated until the node at indexiis larger than both of its children. Theheapifymethod uses O(1) space, where n is the number of elements in the heap. This is because it does not create any new variables.The
insertmethod runs in O(log n) time, where n is the number of elements in the heap. This is because it appendsxto the end of the array and then calls theheapifymethod on the last node in the tree. Theinsertmethod uses O(1) space, where n is the number of elements in the heap. This is because it does not create any new variables.The
extract_maxmethod runs in O(log n) time, where n is the number of elements in the heap. This is because it swaps the root of the heap with the last node in the tree, removes the last node, and then calls theheapifymethod on the root of the tree. Theextract_maxmethod uses O(1) space, where n is the number of elements in the heap. This is because it does not create any new variables.The
get_maxmethod runs in O(1) time, where n is the number of elements in the heap. This is because it returns the root of the tree. Theget_maxmethod uses O(1) space, where n is the number of elements in the heap. This is because it does not create any new variables.The
sizemethod runs in O(1) time, where n is the number of elements in the heap. This is because it returns the length of the array. Thesizemethod uses O(1) space, where n is the number of elements in the heap. This is because it does not create any new variables.The
is_emptymethod runs in O(1) time, where n is the number of elements in the heap. This is because it checks if the length of the array is equal to zero. Theis_emptymethod uses O(1) space, where n is the number of elements in the heap. This is because it does not create any new variables.The
print_heapmethod runs in O(n) time, where n is the number of elements in the heap. This is because it iterates through the array and prints each element. Theprint_heapmethod uses O(1) space, where n is the number of elements in the heap. This is because it does not create any new variables.The
heap_sortmethod runs in O(n log n) time, where n is the number of elements in the heap. This is because it repeatedly calls theextract_maxmethod and appends the result to a list. Theheap_sortmethod uses O(n) space, where n is the number of elements in the heap. This is because it creates a new list to store the sorted elements.The
increase_keymethod runs in O(log n) time, where n is the number of elements in the heap. This is because it addsxto the node at indexiand then calls theheapifymethod on the node at indexi. Theincrease_keymethod uses O(1) space, where n is the number of elements in the heap. This is because it does not create any new variables.The
decrease_keymethod runs in O(log n) time, where n is the number of elements in the heap. This is because it subtractsxfrom the node at indexiand then calls theheapifymethod on the node at indexi. Thedecrease_keymethod uses O(1) space, where n is the number of elements in the heap. This is because it does not create any new variables.The
rightmethod runs in O(1) time, where n is the number of elements in the heap. This is because it returns2*i+2. Therightmethod uses O(1) space, where n is the number of elements in the heap. This is because it does not create any new variables.The
leftmethod runs in O(1) time, where n is the number of elements in the heap. This is because it returns2*i+1. Theleftmethod uses O(1) space, where n is the number of elements in the heap. This is because it does not create any new variables.
# example - insertKey, extractMax, increaseKey, deleteKey
heap = maxHeap()
print('heap = maxHeap(): '), heap.printHeap()
print('-----------------------------------')
heap.insertKey(3)
print('heap.insertKey(3): '), heap.printHeap()
print('-----------------------------------')
heap.insertKey(2)
print('heap.insertKey(2): '), heap.printHeap()
print('-----------------------------------')
heap.deleteKey(1)
print('heap.deleteKey(1): '), heap.printHeap()
print('-----------------------------------')
heap.insertKey(15)
print('heap.insertKey(15): '), heap.printHeap()
print('-----------------------------------')
heap.insertKey(5)
print('heap.insertKey(5): '), heap.printHeap()
print('-----------------------------------')
heap.insertKey(4)
print('heap.insertKey(4): '), heap.printHeap()
print('-----------------------------------')
heap.insertKey(45)
print('Extract Maximum: ', heap.extractMax())
print('Heap after extract: '), heap.printHeap()
print('-----------------------------------')
print('New Maximum after extract max: ', heap.getMax())
heap.increaseKey(2, 1)
print('heap.increaseKey(2, 1): '), heap.printHeap()
print('New Maximum after increase: ', heap.getMax())
print('-----------------------------------')
print('Final heap: '), heap.printHeap()
heap = maxHeap():
[]
-----------------------------------
heap.insertKey(3):
[3]
-----------------------------------
heap.insertKey(2):
[3, 2]
-----------------------------------
heap.deleteKey(1):
[3]
-----------------------------------
heap.insertKey(15):
[15, 3]
-----------------------------------
heap.insertKey(5):
[15, 3, 5]
-----------------------------------
heap.insertKey(4):
[15, 4, 5, 3]
-----------------------------------
Extract Maximum:  45
Heap after extract:
[15, 4, 5, 3]
-----------------------------------
New Maximum after extract max:  15
heap.increaseKey(2, 1):
[15, 4, 1, 3]
New Maximum after increase:  15
-----------------------------------
Final heap:
[15, 4, 1, 3]
(None, None)
2. Python build-in heapq module
In Python, heapq is a built-in module that provides an implementation of the heap queue algorithm, also known as the priority queue algorithm. A heap is a special type of binary tree that satisfies the heap property, which specifies that the parent node is always greater than or equal to its children nodes (in a max heap) or less than or equal to its children nodes (in a min heap). The heap queue algorithm is useful for maintaining a collection of items with a priority, where the item with the highest priority is always at the front of the queue.
Here is an example of how to use heapq to implement a simple priority queue:
import heapq
# create an empty heap
heap = []
# add items to the heap with a priority
heapq.heappush(heap, (2, 'task 1'))
heapq.heappush(heap, (1, 'task 2'))
heapq.heappush(heap, (3, 'task 3'))
# get the item with the highest priority
highest_priority = heapq.heappop(heap)
print(highest_priority)  # output: (1, 'task 2')
(1, 'task 2')
In this example, we first create an empty heap. We then use the heappush() function to add items to the heap with a priority. Each item is a tuple containing the priority and the task description. The heappush() function automatically maintains the heap property by rearranging the items in the heap as necessary.
Finally, we use the heappop() function to get the item with the highest priority. The heappop() function removes the item from the heap and returns it as a tuple. In this case, the item with the highest priority is (‘task 2’, 1), which we print to the console.
Now let’s dive on some example of heap queue algorithm.
3. Continuous Median
Write a class to calculate continuous median that supports:
- The continuous insertation of numbers with the 
insertmethod - the instant (O(1) time) retriival of the median of the numbers that have been inserted thus far with the 
getMedianmethod 
Example:
insert(5)
insert(10)
getMedian() -> 7.5
import heapq
class ContinuousMedian:
    def __init__(self):
        self.max_heap = [] # to store the smaller half of numbers
        self.min_heap = [] # to store the larger half of numbers
    def insert(self, num):
        # If both heaps are empty, add to max heap
        if not self.max_heap and not self.min_heap:
            heapq.heappush(self.max_heap, -num)
            return
        # If num is smaller than max heap root, add to max heap
        if num <= -self.max_heap[0]:
            heapq.heappush(self.max_heap, -num)
        # If num is larger than min heap root, add to min heap
        else:
            heapq.heappush(self.min_heap, num)
        # Balance the two heaps to ensure that the difference in size is at most 1
        if len(self.max_heap) - len(self.min_heap) > 1:
            num_to_move = -heapq.heappop(self.max_heap)
            heapq.heappush(self.min_heap, num_to_move)
        elif len(self.min_heap) - len(self.max_heap) > 1:
            num_to_move = heapq.heappop(self.min_heap)
            heapq.heappush(self.max_heap, -num_to_move)
    def getMedian(self):
        # If both heaps have the same size, take the average of the two roots
        if len(self.max_heap) == len(self.min_heap):
            return (-self.max_heap[0] + self.min_heap[0]) / 2.0
        # Otherwise, return the root of the heap with the larger size
        elif len(self.max_heap) > len(self.min_heap):
            return -self.max_heap[0]
        else:
            return self.min_heap[0]
Explanation:
The __init__ method initializes two empty lists: self.max_heap and self.min_heap. These will be used to keep track of the numbers that have been inserted so far.
The insert method takes a number and adds it to the appropriate heap. First, it checks if the number is less than or equal to the maximum value in the max heap. If it is, it adds the negative of the number to the max heap (so that the largest value is at the top). If the number is greater than the maximum value in the max heap, it adds it to the min heap.
The getMedian method returns the median of the numbers that have been inserted so far. If the number of elements in the max heap is greater than the number of elements in the min heap, it returns the maximum value in the max heap. If the number of elements in the min heap is greater than the number of elements in the max heap, it returns the minimum value in the min heap. If the number of elements in the max heap is equal to the number of elements in the min heap, it returns the average of the maximum value in the max heap and the minimum value in the min heap.
The ContinuousMedian class allows for the continuous insertion of numbers and the instant retrieval of the median. The code uses Python’s built-in heapq module to efficiently maintain the max heap and min heap, and takes advantage of the properties of heaps to make calculations faster.
Time and Space Complexity:
The time complexity of the insert method is O(log n) since inserting an element into a heap takes O(log n) time in the worst case, where n is the number of elements in the heap.
The time complexity of the getMedian method is O(1), since we are simply returning the median of the two heaps, which takes constant time.
The space complexity of the class is O(n), where n is the number of elements inserted into the heaps. This is because we are storing all the elements in the two heaps.
Testing:
# Create a new ContinuousMedian object
cm = ContinuousMedian()
# Insert some numbers
cm.insert(1)
cm.insert(2)
cm.insert(3)
cm.insert(4)
# Get the current median
print(cm.getMedian()) # Output: 2.5
# Insert some more numbers
cm.insert(5)
cm.insert(6)
# Get the updated median
print(cm.getMedian()) # Output: 3.5
2.5
3.5
4. Sort k-sorted array
Write a function that takes in a non-negetive integer k and a k-sorted array of integers and returns the sorted version of the array. Your function can either sort the array in place or create an entirely new array.
A k-sorted array is a partially sorted array in which all elements are at most k positions away from their sorted position. For example, the array [3, 1, 2, 2] is k-sorted with k = 2, because each element in the array is at most 2 positions away from its sorted position.
def sort_k_sorted_array(arr, k):
    n = len(arr)
    if n <= 1:
        return arr
    # Create a min-heap of size k+1
    heap = arr[:k+1]
    heapq.heapify(heap)
    # Add the smallest element from the heap to the sorted array,
    # and add the next element from the array to the heap
    sorted_arr = []
    for i in range(k+1, n):
        min_elem = heapq.heappop(heap)
        sorted_arr.append(min_elem)
        heapq.heappush(heap, arr[i])
    # Add the remaining elements in the heap to the sorted array
    while heap:
        min_elem = heapq.heappop(heap)
        sorted_arr.append(min_elem)
    return sorted_arr
Explanation:
The function takes in a k-sorted array and the value of k. The length of the array is stored in n. If the length of the array is less than or equal to 1, it means the array is already sorted and the function returns the array as is.
A new heap is created using the heapq module from Python’s standard library. The first k+1 elements of the array are used to create the heap, since the maximum displacement of an element from its final position in a k-sorted array is k. The heapify function is then used to turn the array into a heap.
A new list sorted_arr is created to store the sorted array. The loop iterates over the elements of the array starting from index k+1. In each iteration, the smallest element in the heap is popped using heappop, and appended to the sorted_arr. The next element from the array is then pushed onto the heap using heappush. This process continues until all elements of the array have been processed.
Finally, any remaining elements in the heap are popped using heappop and appended to the sorted_arr. The sorted_arr is returned as the sorted k-sorted array.
Time and Space Complexity:
The heapify operation takes O(k) time, and the push and pop operations take O(log(k)) time. The loop iterates over n-k elements, so it takes O((n-k)log(k)) time. The remaining elements in the heap are popped in O(klog(k)) time. The overall time complexity of the function is O(nlog(k)).
A heap of size k+1 is created, so the space complexity of the function is O(k). The additional space used for the sorted array is O(n-k), resulting in a total space complexity of O(n).
Testing:
# Test case 1
arr = [3, 1, 2, 2]
k = 2
expected_output = [1, 2, 2, 3]
assert sort_k_sorted_array(arr, k) == expected_output
# Test case 2
arr = [10, 9, 8, 7, 4, 70, 60, 50]
k = 4
expected_output = [4, 7, 8, 9, 10, 50, 60, 70]
assert sort_k_sorted_array(arr, k) == expected_output
# Test case 3
arr = [1, 2, 3, 4, 5]
k = 3
expected_output = [1, 2, 3, 4, 5]
assert sort_k_sorted_array(arr, k) == expected_output
# Test case 4
arr = [5, 4, 3, 2, 1]
k = 5
expected_output = [1, 2, 3, 4, 5]
assert sort_k_sorted_array(arr, k) == expected_output
# Test case 5
arr = [1]
k = 0
expected_output = [1]
assert sort_k_sorted_array(arr, k) == expected_output
# Test case 6
arr = []
k = 0
expected_output = []
assert sort_k_sorted_array(arr, k) == expected_output
5. Laptop rental
We are giving a list of time intervals during which students at a school need a laptop. These time intervals are represented by pairs of integers [start, end]. No two student can use a laptop at the same time, but immediatlety after studetns is done using laptop, another student can use that same laptop.
Wrtie a function that returns the minimum number of laptops that the school needs to rent such that all students will always have access to a laptop when they need one.
def min_laptops(intervals):
    times = []
    for start, end in intervals:
        times.append((start, 1))
        times.append((end, -1))
    times.sort()
    laptops_in_use = 0
    max_laptops = 0
    for time, delta in times:
        laptops_in_use += delta
        max_laptops = max(max_laptops, laptops_in_use)
    return max_laptops
def min_laptops_heap(intervals):
    # Sort the intervals based on their start times
    intervals.sort(key=lambda x: x[0])
    # Create a min-heap to keep track of the laptops that are currently available
    laptops = []
    # Iterate through the intervals and add or remove laptops from the heap as needed
    for interval in intervals:
        if laptops and laptops[0] <= interval[0]:
            # There is a laptop available for this interval, so remove it from the heap
            heapq.heappop(laptops)
        # Add a new laptop to the heap
        heapq.heappush(laptops, interval[1])
    # The size of the heap at the end will be the minimum number of laptops needed
    return len(laptops)
Explanation of code:
First method:
- We first create a list times that contains the start and end times of each interval. We also include a “delta” value of 1 for each start time and -1 for each end time. This will allow us to keep track of how many laptops are in use at any given time.
 - We sort the times list in increasing order.
 - We initialize two variables: laptops_in_use (the number of laptops currently in use) and max_laptops (the maximum number of laptops in use at any given time).
 - We iterate through the times list. For each time interval, we update laptops_in_use by adding or subtracting the delta value. We also update max_laptops to be the maximum of the current value of max_laptops and laptops_in_use.
 - Finally, we return max_laptops.
 
The time complexity of this method would be O(n log n) due to the sorting operation, where n is the number of time intervals. The space complexity would be O(n) to store the sorted intervals and the current laptop usage.
Heap method:
The algorithm first sorts the intervals based on their start times using the built-in sort method of Python lists, and then initializes an empty min-heap called laptops to keep track of the laptops that are currently available.
The algorithm then iterates through the intervals in the sorted order, and for each interval it checks if there is a laptop available for use. If there is a laptop available (i.e., if the end time of the laptop currently at the top of the heap is less than or equal to the start time of the current interval), the algorithm removes the laptop from the heap using the heappop method of the heapq module. It then adds the end time of the current interval to the heap using the heappush method.
At the end of the iteration, the size of the laptops heap represents the minimum number of laptops needed to schedule all the intervals, because the size of the heap corresponds to the maximum number of laptops that are needed simultaneously. This value is returned by the function.
The heap-based solution has a time complexity of O(n log k), where k is the number of available laptops. The space complexity would be O(k) to store the laptops in the heap. Therefore, the heap-based solution is more efficient for cases where the number of laptops is much smaller than the number of time intervals.
intervals1 = [(0, 30), (10, 20), (15, 25)]
print('First method: ', min_laptops(intervals1))  # Output: 3
print('Heap-based method: ', min_laptops_heap(intervals1))  # Output: 3
assert min_laptops_heap(intervals1) == 3
assert min_laptops(intervals1) == 3
intervals2 = [(0, 5), (2, 7), (4, 9), (6, 11)]
print('First method: ', min_laptops(intervals2))  # Output: 3
print('Heap-based method: ', min_laptops_heap(intervals2))  # Output: 3
assert min_laptops_heap(intervals2) == 3
assert min_laptops(intervals2) == 3
intervals3 = [(0, 1), (1, 2), (2, 3), (3, 4), (4, 5)]
print('First method: ', min_laptops(intervals3))  # Output: 1
print('Heap-based method: ', min_laptops_heap(intervals3))  # Output: 1
assert min_laptops_heap(intervals3) == 1
assert min_laptops(intervals3) == 1
First method:  3
Heap-based method:  3
First method:  3
Heap-based method:  3
First method:  1
Heap-based method:  1
