class MinHeap:    """    A Min-Heap implementation using a list.    The heap stores elements in a way that the smallest element is always at the root.    """    def __init__(self):        self.heap = []    def _parent(self, i):        """Get the index of the parent of node at index i."""        return (i - 1) // 2    def _left_child(self, i):        """Get the index of the left child of node at index i."""        return 2 * i + 1    def _right_child(self, i):        """Get the index of the right child of node at index i."""        return 2 * i + 2    def insert(self, key):        """Insert a new key into the heap."""        self.heap.append(key)        self._heapify_up(len(self.heap) - 1)    def _heapify_up(self, i):        """Move a node up in the tree to maintain the heap property."""        while i > 0 and self.heap[self._parent(i)] > self.heap[i]:            # Swap with parent            self.heap[i], self.heap[self._parent(i)] = self.heap[self._parent(i)], self.heap[i]            i = self._parent(i)    def extract_min(self):        """        Remove and return the smallest element from the heap.        Raises an IndexError if the heap is empty.        """        if not self.heap:            raise IndexError("extract_min from an empty heap")        if len(self.heap) == 1:            return self.heap.pop()                root = self.heap[0]        # Move the last element to the root        self.heap[0] = self.heap.pop()        self._heapify_down(0)        return root    def _heapify_down(self, i):        """Move a node down in the tree to maintain the heap property."""        smallest = i        left = self._left_child(i)        right = self._right_child(i)        n = len(self.heap)        # Check if left child is smaller than current node        if left < n and self.heap[left] < self.heap[smallest]:            smallest = left        # Check if right child is smaller than the smallest so far        if right < n and self.heap[right] < self.heap[smallest]:            smallest = right        # If the smallest is not the current node, swap and continue heapifying down        if smallest != i:            self.heap[i], self.heap[smallest] = self.heap[smallest], self.heap[i]            self._heapify_down(smallest)    def peek(self):        """        Return the smallest element from the heap without removing it.        Raises an IndexError if the heap is empty.        """        if not self.heap:            raise IndexError("peek from an empty heap")        return self.heap[0]    def __len__(self):        """Return the number of elements in the heap."""        return len(self.heap)    def __str__(self):        """Return a string representation of the heap (the internal list)."""        return str(self.heap)# Example Usage    min_heap = MinHeap()    print(f"Initial Heap: {min_heap}")    # Insert elements    elements = [3, 2, 1, 15, 5, 4, 45]    print(f"Inserting elements: {elements}")    for el in elements:        min_heap.insert(el)        print(f"  Inserted {el}, Heap is now: {min_heap}")    print(f"\nFinal Heap structure: {min_heap}")    print(f"Size of heap: {len(min_heap)}")    # Peek at the minimum element    print(f"\nMinimum element (peek): {min_heap.peek()}")    # Extract minimum elements    print("\nExtracting elements:")    while len(min_heap) > 0:        min_val = min_heap.extract_min()        print(f"  Extracted {min_val}, Heap is now: {min_heap}")    # Try to extract from an empty heap    try:        min_heap.extract_min()    except IndexError as e:        print(f"\nError when extracting from empty heap: {e}")