Merge Sort Algorithm

Master the stable divide-and-conquer sorting algorithm with guaranteed O(n log n) performance

🔀 What is Merge Sort?

Merge Sort is like organizing two sorted piles of papers into one sorted pile. It divides the array into smaller pieces, sorts them, and then merges them back together. It's stable, predictable, and always performs well!


# Merge Sort in action!
def merge_sort(arr):
    if len(arr) <= 1:
        return arr
    
    mid = len(arr) // 2
    left = merge_sort(arr[:mid])
    right = merge_sort(arr[mid:])
    
    return merge(left, right)

def merge(left, right):
    result = []
    i = j = 0
    
    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
    
    result.extend(left[i:])
    result.extend(right[j:])
    return result

# Example
numbers = [64, 34, 25, 12, 22, 11, 90]
print("Sorted:", merge_sort(numbers))
                                    
O(n log n)
Always
O(n)
Space
Stable
Algorithm

Merge Sort Characteristics

🔄

Stable Sorting

Maintains relative order of equal elements

Order Preserved Reliable
📊

Predictable Performance

Always O(n log n) regardless of input

Consistent No Worst Case
🔀

Divide & Conquer

Breaks problem into smaller subproblems

Recursive Elegant
🌐

Parallelizable

Can be easily parallelized

Multi-core Scalable

Lesson 1: Understanding the Divide and Merge Process

Merge Sort works by recursively dividing the array into halves until each piece has one element, then merging them back in sorted order:

Merge Sort Tree: [38, 27, 43, 3, 9, 82, 10]

38
27
43
3
9
82
10
Divide → Sort → Merge
Step-by-Step Merge Sort
def merge_sort_detailed(arr, depth=0):
    """Merge Sort with detailed steps"""
    indent = "  " * depth
    print(f"{indent}Dividing: {arr}")
    
    # Base case: arrays with 0 or 1 element are already sorted
    if len(arr) <= 1:
        print(f"{indent}Base case: {arr}")
        return arr
    
    # Divide the array into two halves
    mid = len(arr) // 2
    left_half = arr[:mid]
    right_half = arr[mid:]
    
    print(f"{indent}Left half: {left_half}")
    print(f"{indent}Right half: {right_half}")
    
    # Recursively sort both halves
    print(f"{indent}Sorting left half...")
    sorted_left = merge_sort_detailed(left_half, depth + 1)
    
    print(f"{indent}Sorting right half...")
    sorted_right = merge_sort_detailed(right_half, depth + 1)
    
    # Merge the sorted halves
    print(f"{indent}Merging {sorted_left} and {sorted_right}")
    merged = merge_detailed(sorted_left, sorted_right, depth)
    
    print(f"{indent}Merged result: {merged}")
    return merged

def merge_detailed(left, right, depth=0):
    """Merge two sorted arrays with detailed steps"""
    indent = "  " * depth
    result = []
    i = j = 0
    
    print(f"{indent}  Merging process:")
    
    # Compare elements from both arrays and add smaller one to result
    while i < len(left) and j < len(right):
        if left[i] <= right[j]:
            result.append(left[i])
            print(f"{indent}    Added {left[i]} from left")
            i += 1
        else:
            result.append(right[j])
            print(f"{indent}    Added {right[j]} from right")
            j += 1
    
    # Add remaining elements from left array
    while i < len(left):
        result.append(left[i])
        print(f"{indent}    Added remaining {left[i]} from left")
        i += 1
    
    # Add remaining elements from right array
    while j < len(right):
        result.append(right[j])
        print(f"{indent}    Added remaining {right[j]} from right")
        j += 1
    
    return result

# Example usage
numbers = [38, 27, 43, 3, 9, 82, 10]
print("=== Merge Sort Step by Step ===")
sorted_numbers = merge_sort_detailed(numbers)

Lesson 2: Basic Implementation

Let's implement Merge Sort step by step:

Simple Merge Sort
def merge_sort(arr):
    """
    Simple Merge Sort implementation
    Time Complexity: O(n log n) always
    Space Complexity: O(n)
    """
    # Base case: arrays with 0 or 1 element are already sorted
    if len(arr) <= 1:
        return arr
    
    # Divide: find the middle point and divide array into two halves
    mid = len(arr) // 2
    left_half = arr[:mid]
    right_half = arr[mid:]
    
    # Conquer: recursively sort both halves
    sorted_left = merge_sort(left_half)
    sorted_right = merge_sort(right_half)
    
    # Combine: merge the sorted halves
    return merge(sorted_left, sorted_right)

def merge(left, right):
    """
    Merge two sorted arrays into one sorted array
    """
    result = []
    i = j = 0  # Pointers for left and right arrays
    
    # Compare elements and add smaller one to result
    while i < len(left) and j < len(right):
        if left[i] <= right[j]:  # <= ensures stability
            result.append(left[i])
            i += 1
        else:
            result.append(right[j])
            j += 1
    
    # Add remaining elements from left array (if any)
    result.extend(left[i:])
    
    # Add remaining elements from right array (if any)
    result.extend(right[j:])
    
    return result

# Test the function
test_array = [64, 34, 25, 12, 22, 11, 90]
print("Original array:", test_array)
sorted_array = merge_sort(test_array)
print("Sorted array:", sorted_array)

Lesson 3: In-Place Merge Sort

A more memory-efficient version that modifies the original array:

In-Place Merge Sort
def merge_sort_inplace(arr, left=0, right=None):
    """
    In-place Merge Sort implementation
    Time Complexity: O(n log n)
    Space Complexity: O(log n) for recursion stack
    """
    if right is None:
        right = len(arr) - 1
    
    if left < right:
        # Find the middle point
        mid = left + (right - left) // 2
        
        # Recursively sort first and second halves
        merge_sort_inplace(arr, left, mid)
        merge_sort_inplace(arr, mid + 1, right)
        
        # Merge the sorted halves
        merge_inplace(arr, left, mid, right)
    
    return arr

def merge_inplace(arr, left, mid, right):
    """
    Merge two sorted subarrays arr[left:mid+1] and arr[mid+1:right+1]
    """
    # Create temporary arrays for the two subarrays
    left_arr = arr[left:mid + 1]
    right_arr = arr[mid + 1:right + 1]
    
    # Initial indexes for left_arr, right_arr, and merged array
    i = j = 0
    k = left
    
    # Merge the temporary arrays back into arr[left:right+1]
    while i < len(left_arr) and j < len(right_arr):
        if left_arr[i] <= right_arr[j]:
            arr[k] = left_arr[i]
            i += 1
        else:
            arr[k] = right_arr[j]
            j += 1
        k += 1
    
    # Copy remaining elements of left_arr (if any)
    while i < len(left_arr):
        arr[k] = left_arr[i]
        i += 1
        k += 1
    
    # Copy remaining elements of right_arr (if any)
    while j < len(right_arr):
        arr[k] = right_arr[j]
        j += 1
        k += 1

# Test the in-place version
test_array = [64, 34, 25, 12, 22, 11, 90]
print("Original array:", test_array)
merge_sort_inplace(test_array)
print("Sorted array:", test_array)

Lesson 4: Merge Sort Optimizations

Several techniques to make Merge Sort even more efficient:

Optimized Merge Sort

🔹Hybrid Merge Sort (with Insertion Sort for small arrays)

def insertion_sort_range(arr, left, right):
    """Insertion sort for a range of array"""
    for i in range(left + 1, right + 1):
        key = arr[i]
        j = i - 1
        while j >= left and arr[j] > key:
            arr[j + 1] = arr[j]
            j -= 1
        arr[j + 1] = key

def hybrid_merge_sort(arr, left=0, right=None, threshold=10):
    """
    Hybrid Merge Sort that switches to Insertion Sort for small subarrays
    """
    if right is None:
        right = len(arr) - 1
    
    if left < right:
        # Use insertion sort for small subarrays
        if right - left + 1 <= threshold:
            insertion_sort_range(arr, left, right)
        else:
            mid = left + (right - left) // 2
            hybrid_merge_sort(arr, left, mid, threshold)
            hybrid_merge_sort(arr, mid + 1, right, threshold)
            merge_inplace(arr, left, mid, right)
    
    return arr

🔹Bottom-Up Merge Sort (Iterative)

def bottom_up_merge_sort(arr):
    """
    Iterative bottom-up merge sort
    Avoids recursion overhead
    """
    n = len(arr)
    
    # Start with subarrays of size 1, then 2, 4, 8, ...
    size = 1
    while size < n:
        # Pick starting point of left sub array
        left = 0
        while left < n - 1:
            # Calculate mid and right points
            mid = min(left + size - 1, n - 1)
            right = min(left + 2 * size - 1, n - 1)
            
            # Merge subarrays if mid is smaller than right
            if mid < right:
                merge_inplace(arr, left, mid, right)
            
            left = left + 2 * size
        
        size = size * 2
    
    return arr

🔹Natural Merge Sort (takes advantage of existing runs)

def natural_merge_sort(arr):
    """
    Natural merge sort that takes advantage of existing sorted runs
    """
    n = len(arr)
    if n <= 1:
        return arr
    
    while True:
        runs = find_runs(arr)
        if len(runs) <= 1:
            break
        
        # Merge adjacent runs
        new_runs = []
        i = 0
        while i < len(runs):
            if i + 1 < len(runs):
                # Merge runs[i] and runs[i+1]
                start1, end1 = runs[i]
                start2, end2 = runs[i + 1]
                merge_inplace(arr, start1, end1, end2)
                new_runs.append((start1, end2))
                i += 2
            else:
                new_runs.append(runs[i])
                i += 1
        
        runs = new_runs
    
    return arr

def find_runs(arr):
    """Find naturally occurring sorted runs in the array"""
    runs = []
    n = len(arr)
    i = 0
    
    while i < n:
        start = i
        
        # Find ascending run
        while i + 1 < n and arr[i] <= arr[i + 1]:
            i += 1
        
        # If no ascending run found, find descending run and reverse it
        if i == start:
            while i + 1 < n and arr[i] > arr[i + 1]:
                i += 1
            # Reverse the descending run
            arr[start:i + 1] = arr[start:i + 1][::-1]
        
        runs.append((start, i))
        i += 1
    
    return runs

🔹Parallel Merge Sort

import threading
from concurrent.futures import ThreadPoolExecutor

def parallel_merge_sort(arr, max_workers=4):
    """
    Parallel merge sort using multiple threads
    """
    if len(arr) <= 1:
        return arr
    
    def merge_sort_parallel(arr, depth=0, max_depth=2):
        if len(arr) <= 1:
            return arr
        
        mid = len(arr) // 2
        left_half = arr[:mid]
        right_half = arr[mid:]
        
        # Use parallel processing for top levels only
        if depth < max_depth:
            with ThreadPoolExecutor(max_workers=2) as executor:
                left_future = executor.submit(merge_sort_parallel, left_half, depth + 1, max_depth)
                right_future = executor.submit(merge_sort_parallel, right_half, depth + 1, max_depth)
                
                sorted_left = left_future.result()
                sorted_right = right_future.result()
        else:
            # Use sequential merge sort for deeper levels
            sorted_left = merge_sort(left_half)
            sorted_right = merge_sort(right_half)
        
        return merge(sorted_left, sorted_right)
    
    return merge_sort_parallel(arr)

Lesson 5: Performance Analysis

Understanding Merge Sort's consistent performance:

Performance Analysis
import time
import random
import math

def analyze_merge_sort():
    """Analyze Merge Sort performance characteristics"""
    
    def count_operations_merge_sort(arr):
        """Count comparisons and array accesses in merge sort"""
        comparisons = 0
        array_accesses = 0
        
        def merge_sort_count(arr):
            nonlocal comparisons, array_accesses
            
            if len(arr) <= 1:
                return arr
            
            mid = len(arr) // 2
            left = merge_sort_count(arr[:mid])
            right = merge_sort_count(arr[mid:])
            
            return merge_count(left, right)
        
        def merge_count(left, right):
            nonlocal comparisons, array_accesses
            result = []
            i = j = 0
            
            while i < len(left) and j < len(right):
                comparisons += 1
                array_accesses += 2  # Access left[i] and right[j]
                
                if left[i] <= right[j]:
                    result.append(left[i])
                    i += 1
                else:
                    result.append(right[j])
                    j += 1
                array_accesses += 1  # Append operation
            
            # Add remaining elements
            while i < len(left):
                result.append(left[i])
                array_accesses += 2
                i += 1
            
            while j < len(right):
                result.append(right[j])
                array_accesses += 2
                j += 1
            
            return result
        
        merge_sort_count(arr)
        return comparisons, array_accesses
    
    # Test different input sizes
    sizes = [10, 50, 100, 500, 1000]
    
    print("=== Merge Sort Performance Analysis ===")
    print(f"{'Size':>6} | {'Time (ms)':>10} | {'Comparisons':>12} | {'Theoretical':>12} | {'Ratio':>8}")
    print("-" * 70)
    
    for size in sizes:
        # Create random test data
        test_data = [random.randint(1, 1000) for _ in range(size)]
        
        # Measure time
        start_time = time.time()
        merge_sort(test_data.copy())
        end_time = time.time()
        
        # Count operations
        comparisons, _ = count_operations_merge_sort(test_data.copy())
        
        # Theoretical comparison count: approximately n * log2(n)
        theoretical = size * math.log2(size) if size > 1 else 0
        ratio = comparisons / theoretical if theoretical > 0 else 0
        
        print(f"{size:6d} | {(end_time - start_time)*1000:8.2f} | {comparisons:10d} | {theoretical:10.1f} | {ratio:6.2f}")

def compare_merge_implementations():
    """Compare different merge sort implementations"""
    
    implementations = {
        "Standard Merge Sort": merge_sort,
        "In-place Merge Sort": lambda arr: merge_sort_inplace(arr.copy()),
        "Hybrid Merge Sort": lambda arr: hybrid_merge_sort(arr.copy()),
        "Bottom-up Merge Sort": lambda arr: bottom_up_merge_sort(arr.copy()),
        "Natural Merge Sort": lambda arr: natural_merge_sort(arr.copy())
    }
    
    test_cases = {
        "Random": [random.randint(1, 1000) for _ in range(1000)],
        "Sorted": list(range(1000)),
        "Reverse Sorted": list(range(1000, 0, -1)),
        "Nearly Sorted": list(range(1000)),
        "Many Duplicates": [random.randint(1, 10) for _ in range(1000)]
    }
    
    # Make nearly sorted array
    nearly_sorted = test_cases["Nearly Sorted"]
    for _ in range(50):  # Swap 5% of elements
        i, j = random.randint(0, 999), random.randint(0, 999)
        nearly_sorted[i], nearly_sorted[j] = nearly_sorted[j], nearly_sorted[i]
    
    print("\n=== Implementation Comparison ===")
    
    for case_name, test_data in test_cases.items():
        print(f"\n{case_name} Data (1000 elements):")
        print("-" * 50)
        
        for impl_name, impl_func in implementations.items():
            try:
                start_time = time.time()
                result = impl_func(test_data.copy())
                end_time = time.time()
                
                # Verify correctness
                is_sorted = all(result[i] <= result[i+1] for i in range(len(result)-1))
                status = "✓" if is_sorted else "✗"
                
                print(f"{impl_name:20} | {(end_time - start_time)*1000:6.2f} ms | {status}")
            except Exception as e:
                print(f"{impl_name:20} | ERROR: {str(e)[:20]}...")

def memory_usage_analysis():
    """Analyze memory usage of different implementations"""
    import sys
    
    def get_size(obj):
        """Get approximate memory size of object"""
        return sys.getsizeof(obj)
    
    sizes = [100, 500, 1000]
    
    print("\n=== Memory Usage Analysis ===")
    print(f"{'Size':>6} | {'Standard':>10} | {'In-place':>10} | {'Difference':>12}")
    print("-" * 50)
    
    for size in sizes:
        test_data = [random.randint(1, 1000) for _ in range(size)]
        
        # Standard merge sort creates new arrays
        original_size = get_size(test_data)
        
        # Estimate memory usage (this is approximate)
        standard_extra = original_size  # Creates copies during merge
        inplace_extra = 0  # Modifies original array
        
        difference = standard_extra - inplace_extra
        
        print(f"{size:6d} | {standard_extra:8d}B | {inplace_extra:8d}B | {difference:10d}B")

# Run all analyses
analyze_merge_sort()
compare_merge_implementations()
memory_usage_analysis()

Practical Applications

Best Use Cases

  • Large datasets requiring stability
  • External sorting (disk-based)
  • Parallel processing systems
  • Guaranteed O(n log n) performance

Real-World Examples

  • Database sorting operations
  • File system organization
  • Scientific data processing
  • Multi-threaded applications

Advantages

  • Stable sorting algorithm
  • Predictable O(n log n) performance
  • Works well with large datasets
  • Easily parallelizable

Disadvantages

  • O(n) extra space required
  • Slower than Quick Sort on average
  • Not adaptive to input
  • More complex than simple sorts

Practice Project: Music Library Organizer

Let's create a program that organizes a music library using Merge Sort:

Music Library Organizer
from datetime import datetime
import random

class Song:
    def __init__(self, title, artist, album, year, duration, genre, rating=0):
        self.title = title
        self.artist = artist
        self.album = album
        self.year = year
        self.duration = duration  # in seconds
        self.genre = genre
        self.rating = rating  # 0-5 stars
    
    def format_duration(self):
        """Convert seconds to MM:SS format"""
        minutes = self.duration // 60
        seconds = self.duration % 60
        return f"{minutes:02d}:{seconds:02d}"
    
    def __str__(self):
        return f"{self.title:25} | {self.artist:20} | {self.album:20} | {self.year} | {self.format_duration()} | {self.genre:12} | {'★' * self.rating}"
    
    def __repr__(self):
        return self.__str__()

class MusicLibraryOrganizer:
    def __init__(self):
        self.songs = []
    
    def add_song(self, song):
        """Add a song to the library"""
        self.songs.append(song)
    
    def merge_sort_by_title(self, songs=None):
        """Sort songs by title using merge sort"""
        if songs is None:
            songs = self.songs.copy()
        
        if len(songs) <= 1:
            return songs
        
        mid = len(songs) // 2
        left = self.merge_sort_by_title(songs[:mid])
        right = self.merge_sort_by_title(songs[mid:])
        
        return self._merge_by_title(left, right)
    
    def _merge_by_title(self, left, right):
        """Merge two sorted lists by title"""
        result = []
        i = j = 0
        
        while i < len(left) and j < len(right):
            if left[i].title.lower() <= right[j].title.lower():
                result.append(left[i])
                i += 1
            else:
                result.append(right[j])
                j += 1
        
        result.extend(left[i:])
        result.extend(right[j:])
        return result
    
    def merge_sort_by_artist(self, songs=None):
        """Sort songs by artist using merge sort"""
        if songs is None:
            songs = self.songs.copy()
        
        if len(songs) <= 1:
            return songs
        
        mid = len(songs) // 2
        left = self.merge_sort_by_artist(songs[:mid])
        right = self.merge_sort_by_artist(songs[mid:])
        
        return self._merge_by_artist(left, right)
    
    def _merge_by_artist(self, left, right):
        """Merge two sorted lists by artist, then by album, then by title"""
        result = []
        i = j = 0
        
        while i < len(left) and j < len(right):
            # Primary sort: artist
            if left[i].artist.lower() < right[j].artist.lower():
                result.append(left[i])
                i += 1
            elif left[i].artist.lower() > right[j].artist.lower():
                result.append(right[j])
                j += 1
            else:
                # Secondary sort: album
                if left[i].album.lower() < right[j].album.lower():
                    result.append(left[i])
                    i += 1
                elif left[i].album.lower() > right[j].album.lower():
                    result.append(right[j])
                    j += 1
                else:
                    # Tertiary sort: title
                    if left[i].title.lower() <= right[j].title.lower():
                        result.append(left[i])
                        i += 1
                    else:
                        result.append(right[j])
                        j += 1
        
        result.extend(left[i:])
        result.extend(right[j:])
        return result
    
    def merge_sort_by_year(self, songs=None, ascending=True):
        """Sort songs by year using merge sort"""
        if songs is None:
            songs = self.songs.copy()
        
        if len(songs) <= 1:
            return songs
        
        mid = len(songs) // 2
        left = self.merge_sort_by_year(songs[:mid], ascending)
        right = self.merge_sort_by_year(songs[mid:], ascending)
        
        return self._merge_by_year(left, right, ascending)
    
    def _merge_by_year(self, left, right, ascending):
        """Merge two sorted lists by year"""
        result = []
        i = j = 0
        
        while i < len(left) and j < len(right):
            if ascending:
                condition = left[i].year <= right[j].year
            else:
                condition = left[i].year >= right[j].year
            
            if condition:
                result.append(left[i])
                i += 1
            else:
                result.append(right[j])
                j += 1
        
        result.extend(left[i:])
        result.extend(right[j:])
        return result
    
    def merge_sort_by_rating(self, songs=None):
        """Sort songs by rating (highest first), then by title"""
        if songs is None:
            songs = self.songs.copy()
        
        if len(songs) <= 1:
            return songs
        
        mid = len(songs) // 2
        left = self.merge_sort_by_rating(songs[:mid])
        right = self.merge_sort_by_rating(songs[mid:])
        
        return self._merge_by_rating(left, right)
    
    def _merge_by_rating(self, left, right):
        """Merge two sorted lists by rating (descending), then by title"""
        result = []
        i = j = 0
        
        while i < len(left) and j < len(right):
            if left[i].rating > right[j].rating:
                result.append(left[i])
                i += 1
            elif left[i].rating < right[j].rating:
                result.append(right[j])
                j += 1
            else:
                # Same rating, sort by title
                if left[i].title.lower() <= right[j].title.lower():
                    result.append(left[i])
                    i += 1
                else:
                    result.append(right[j])
                    j += 1
        
        result.extend(left[i:])
        result.extend(right[j:])
        return result
    
    def create_playlist(self, criteria):
        """Create a playlist based on criteria"""
        filtered_songs = []
        
        for song in self.songs:
            match = True
            
            if 'genre' in criteria and song.genre.lower() != criteria['genre'].lower():
                match = False
            if 'min_year' in criteria and song.year < criteria['min_year']:
                match = False
            if 'max_year' in criteria and song.year > criteria['max_year']:
                match = False
            if 'min_rating' in criteria and song.rating < criteria['min_rating']:
                match = False
            if 'artist' in criteria and criteria['artist'].lower() not in song.artist.lower():
                match = False
            
            if match:
                filtered_songs.append(song)
        
        return filtered_songs
    
    def display_songs(self, songs=None, title="Music Library", limit=None):
        """Display songs in a formatted table"""
        if songs is None:
            songs = self.songs
        
        if limit:
            songs = songs[:limit]
        
        print(f"\n🎵 {title}")
        print("=" * 120)
        print(f"{'Title':25} | {'Artist':20} | {'Album':20} | {'Year'} | {'Duration'} | {'Genre':12} | {'Rating'}")
        print("-" * 120)
        
        for song in songs:
            print(song)
        
        print(f"\nShowing {len(songs)} songs")

def create_sample_music_library():
    """Create sample music data"""
    songs_data = [
        ("Bohemian Rhapsody", "Queen", "A Night at the Opera", 1975, 355, "Rock", 5),
        ("Hotel California", "Eagles", "Hotel California", 1976, 391, "Rock", 5),
        ("Stairway to Heaven", "Led Zeppelin", "Led Zeppelin IV", 1971, 482, "Rock", 5),
        ("Imagine", "John Lennon", "Imagine", 1971, 183, "Pop", 4),
        ("Like a Rolling Stone", "Bob Dylan", "Highway 61 Revisited", 1965, 369, "Folk Rock", 4),
        ("Billie Jean", "Michael Jackson", "Thriller", 1982, 294, "Pop", 5),
        ("Hey Jude", "The Beatles", "Hey Jude", 1968, 431, "Pop", 4),
        ("Smells Like Teen Spirit", "Nirvana", "Nevermind", 1991, 301, "Grunge", 4),
        ("What's Going On", "Marvin Gaye", "What's Going On", 1971, 232, "Soul", 4),
        ("Respect", "Aretha Franklin", "I Never Loved a Man", 1967, 147, "Soul", 4),
        ("Good Vibrations", "The Beach Boys", "Smiley Smile", 1966, 219, "Pop", 3),
        ("Johnny B. Goode", "Chuck Berry", "Chuck Berry Is on Top", 1958, 161, "Rock and Roll", 4),
        ("Purple Haze", "Jimi Hendrix", "Are You Experienced", 1967, 171, "Rock", 4),
        ("Born to Run", "Bruce Springsteen", "Born to Run", 1975, 270, "Rock", 4),
        ("Bridge Over Troubled Water", "Simon and Garfunkel", "Bridge Over Troubled Water", 1970, 292, "Folk Rock", 3),
        ("The Sound of Silence", "Simon and Garfunkel", "Sounds of Silence", 1965, 200, "Folk Rock", 3),
        ("Yesterday", "The Beatles", "Help!", 1965, 125, "Pop", 3),
        ("Let It Be", "The Beatles", "Let It Be", 1970, 243, "Pop", 4),
        ("Come As You Are", "Nirvana", "Nevermind", 1991, 219, "Grunge", 3),
        ("Sweet Child O' Mine", "Guns N' Roses", "Appetite for Destruction", 1987, 356, "Hard Rock", 4)
    ]
    
    songs = []
    for title, artist, album, year, duration, genre, rating in songs_data:
        songs.append(Song(title, artist, album, year, duration, genre, rating))
    
    return songs

def main():
    # Create music library organizer
    library = MusicLibraryOrganizer()
    
    # Add sample songs
    sample_songs = create_sample_music_library()
    for song in sample_songs:
        library.add_song(song)
    
    # Display original library
    library.display_songs(title="Original Music Library")
    
    # Sort by title
    print("\n" + "="*120)
    print("SORTING BY TITLE")
    print("="*120)
    title_sorted = library.merge_sort_by_title()
    library.display_songs(title_sorted, "Songs Sorted by Title")
    
    # Sort by artist
    print("\n" + "="*120)
    print("SORTING BY ARTIST")
    print("="*120)
    artist_sorted = library.merge_sort_by_artist()
    library.display_songs(artist_sorted, "Songs Sorted by Artist → Album → Title")
    
    # Sort by year
    print("\n" + "="*120)
    print("SORTING BY YEAR")
    print("="*120)
    year_sorted = library.merge_sort_by_year(ascending=False)
    library.display_songs(year_sorted, "Songs Sorted by Year (Newest First)")
    
    # Sort by rating
    print("\n" + "="*120)
    print("SORTING BY RATING")
    print("="*120)
    rating_sorted = library.merge_sort_by_rating()
    library.display_songs(rating_sorted, "Songs Sorted by Rating (Highest First)")
    
    # Create playlists
    print("\n" + "="*120)
    print("CREATING PLAYLISTS")
    print("="*120)
    
    # Rock playlist
    rock_playlist = library.create_playlist({'genre': 'Rock'})
    rock_sorted = library.merge_sort_by_year(rock_playlist, ascending=False)
    library.display_songs(rock_sorted, "Rock Playlist (by Year)")
    
    # 5-star songs
    top_rated = library.create_playlist({'min_rating': 5})
    top_rated_sorted = library.merge_sort_by_title(top_rated)
    library.display_songs(top_rated_sorted, "5-Star Songs")
    
    # 1970s music
    seventies = library.create_playlist({'min_year': 1970, 'max_year': 1979})
    seventies_sorted = library.merge_sort_by_artist(seventies)
    library.display_songs(seventies_sorted, "1970s Music")
    
    # Beatles songs
    beatles = library.create_playlist({'artist': 'Beatles'})
    beatles_sorted = library.merge_sort_by_year(beatles)
    library.display_songs(beatles_sorted, "The Beatles Songs")
    
    # Statistics
    print("\n" + "="*120)
    print("LIBRARY STATISTICS")
    print("="*120)
    
    total_duration = sum(song.duration for song in library.songs)
    avg_duration = total_duration / len(library.songs)
    
    genres = {}
    years = {}
    ratings = {i: 0 for i in range(6)}
    
    for song in library.songs:
        genres[song.genre] = genres.get(song.genre, 0) + 1
        decade = (song.year // 10) * 10
        years[decade] = years.get(decade, 0) + 1
        ratings[song.rating] += 1
    
    print(f"Total songs: {len(library.songs)}")
    print(f"Total duration: {total_duration // 3600}h {(total_duration % 3600) // 60}m {total_duration % 60}s")
    print(f"Average duration: {avg_duration // 60:.0f}m {avg_duration % 60:.0f}s")
    
    print(f"\nGenres: {', '.join(f'{genre} ({count})' for genre, count in sorted(genres.items()))}")
    print(f"Decades: {', '.join(f'{decade}s ({count})' for decade, count in sorted(years.items()))}")
    print(f"Ratings: {', '.join(f'{rating}★ ({count})' for rating, count in ratings.items() if count > 0)}")

# Run the music library organizer
main()

🧠 Test Your Knowledge

What is the time complexity of Merge Sort in all cases?

Merge Sort is a _____ sorting algorithm.

What is the main disadvantage of Merge Sort?