$ sudo teach IT
Модуль 9 · Сортировка

Урок 9.4 — Сортировка слиянием (Merge Sort)

Изучим сортировку слиянием — гарантированно быстрый алгоритм с предсказуемым поведением.

Сортировка слиянием (Merge Sort) — это алгоритм, который гарантированно работает за O(n log n) в любом случае. В отличие от Quick Sort, ему всё равно, отсортирован массив или нет. За эту гарантию приходится платить памятью — алгоритму нужен O(n) дополнительного пространства. Merge Sort был разработан Джоном фон Нейманом в 1945 году.

🔱

Принцип «Разделяй и властвуй» в Merge Sort

Как и Quick Sort, Merge Sort использует стратегию «разделяй и властвуй». Но делает это по-другому.

Алгоритм состоит из двух фаз:

  1. Разделение (Divide) — рекурсивно делим массив пополам, пока не получим подмассивы из одного элемента. Один элемент — это уже отсортированный подмассив (trivially sorted).
  2. Слияние (Merge) — попарно сливаем отсортированные подмассивы в один отсортированный массив большего размера.

Ключевое отличие от Quick Sort: вся «магия» Merge Sort происходит на стадии слияния, а не разделения. Разделение — простое (делим пополам), а слияние — сложное (нужно объединить два отсортированных массива).

💡 Сравни: в Quick Sort мы сначала делаем сложную работу (partition), а потом рекурсивно обрабатываем части. В Merge Sort — сначала просто делим, а сложная работа (merge) происходит после рекурсии, при возврате из неё.

👣

Пошаговая визуализация

Рассмотрим массив [38, 27, 43, 3, 9, 82, 10]. Сначала делим его до отдельных элементов, потом сливаем обратно.

Фаза 1: Разделение (Divide)

[38, 27, 43, 3, 9, 82, 10]
             ↓
[38, 27, 43, 3]          [9, 82, 10]
     ↓                          ↓
[38, 27]    [43, 3]        [9, 82]    [10]
   ↓          ↓              ↓
[38] [27]   [43] [3]      [9] [82]    [10]

Фаза 2: Слияние (Merge) — обратный ход рекурсии

[38] + [27] → [27, 38]    (слияние двух элементов)
[43] + [3]  → [3, 43]

[27, 38] + [3, 43] → [3, 27, 38, 43]   (слияние двух отсортированных)

[9] + [82] → [9, 82]
[9, 82] + [10] → [9, 10, 82]

[3, 27, 38, 43] + [9, 10, 82] → [3, 9, 10, 27, 38, 43, 82]  ✓

Обрати внимание: на каждом шаге слияния мы объединяем два уже отсортированных массива. Это позволяет нам выполнить слияние за O(n) — просто идём двумя указателями по массивам и выбираем меньший элемент.

🔄

Функция слияния (Merge)

Слияние двух отсортированных массивов — это сердце Merge Sort. Представь, что у тебя две стопки отсортированных карт, и ты хочешь объединить их в одну отсортированную стопку.

Алгоритм слияния:

  1. Создаём временный массив достаточного размера.
  2. Ставим два указателя — на начало левого и правого подмассивов.
  3. Сравниваем элементы под указателями. Меньший копируем во временный массив и двигаем соответствующий указатель.
  4. Когда один из подмассивов закончился — копируем оставшиеся элементы из другого подмассива.
  5. Копируем временный массив обратно в исходный.
C# · Функция Merge
static void Merge(int[] arr, int left, int mid, int right)
{
    // Размеры двух подмассивов
    int n1 = mid - left + 1;
    int n2 = right - mid;

    // Создаём временные массивы
    int[] leftArr = new int[n1];
    int[] rightArr = new int[n2];

    // Копируем данные во временные массивы
    for (int i = 0; i < n1; i++)
        leftArr[i] = arr[left + i];
    for (int j = 0; j < n2; j++)
        rightArr[j] = arr[mid + 1 + j];

    // Слияние: три указателя
    int iLeft = 0;      // указатель для leftArr
    int iRight = 0;     // указатель для rightArr
    int k = left;       // указатель для основного массива

    while (iLeft < n1 && iRight < n2)
    {
        if (leftArr[iLeft] <= rightArr[iRight])
        {
            arr[k] = leftArr[iLeft];
            iLeft++;
        }
        else
        {
            arr[k] = rightArr[iRight];
            iRight++;
        }
        k++;
    }

    // Копируем оставшиеся элементы из leftArr
    while (iLeft < n1)
    {
        arr[k] = leftArr[iLeft];
        iLeft++;
        k++;
    }

    // Копируем оставшиеся элементы из rightArr
    while (iRight < n2)
    {
        arr[k] = rightArr[iRight];
        iRight++;
        k++;
    }
}

Разберём работу Merge на примере: левая часть [27, 38], правая часть [3, 43].

Пошагово · Merge
leftArr = [27, 38]   rightArr = [3, 43]   result = [ _ , _ , _ , _ ]
                                                     ↑ k

Шаг 1: iLeft=0 (27), iRight=0 (3). 3 <= 27 → берём 3
  result = [3, _ , _ , _ ], iRight=1, k=1

Шаг 2: iLeft=0 (27), iRight=1 (43). 27 <= 43 → берём 27
  result = [3, 27, _ , _ ], iLeft=1, k=2

Шаг 3: iLeft=1 (38), iRight=1 (43). 38 <= 43 → берём 38
  result = [3, 27, 38, _ ], iLeft=2, k=3

Шаг 4: left закончился (iLeft=2 > n1-1=1).
  Копируем остаток из rightArr: [43]
  result = [3, 27, 38, 43]  ✓
💡 Ключевое наблюдение: слияние двух отсортированных массивов работает за O(n), где n — общее количество элементов. Каждый элемент мы просматриваем ровно один раз.
💻

Рекурсивная реализация на C#

Теперь соединим рекурсивное разделение и функцию Merge в полную реализацию:

C# · Merge Sort (полная реализация)
using System;

class Program
{
    static void Main()
    {
        int[] arr = { 38, 27, 43, 3, 9, 82, 10 };

        Console.WriteLine("Исходный массив: " + string.Join(", ", arr));

        MergeSort(arr, 0, arr.Length - 1);

        Console.WriteLine("Отсортированный: " + string.Join(", ", arr));
    }

    static void MergeSort(int[] arr, int left, int right)
    {
        if (left < right)
        {
            // Находим середину
            int mid = left + (right - left) / 2;

            // Рекурсивно сортируем левую половину
            MergeSort(arr, left, mid);

            // Рекурсивно сортируем правую половину
            MergeSort(arr, mid + 1, right);

            // Сливаем две отсортированные половины
            Merge(arr, left, mid, right);

            Console.WriteLine($"Слияние [{left}..{mid}] + [{mid+1}..{right}] → " +
                              string.Join(", ", arr));
        }
    }

    static void Merge(int[] arr, int left, int mid, int right)
    {
        int n1 = mid - left + 1;
        int n2 = right - mid;

        int[] leftArr = new int[n1];
        int[] rightArr = new int[n2];

        for (int i = 0; i < n1; i++)
            leftArr[i] = arr[left + i];
        for (int j = 0; j < n2; j++)
            rightArr[j] = arr[mid + 1 + j];

        int iLeft = 0, iRight = 0, k = left;

        while (iLeft < n1 && iRight < n2)
        {
            if (leftArr[iLeft] <= rightArr[iRight])
                arr[k++] = leftArr[iLeft++];
            else
                arr[k++] = rightArr[iRight++];
        }

        while (iLeft < n1)
            arr[k++] = leftArr[iLeft++];

        while (iRight < n2)
            arr[k++] = rightArr[iRight++];
    }
}

// Вывод:
// Исходный массив: 38, 27, 43, 3, 9, 82, 10
// Слияние [0..0] + [1..1] → [27, 38, 43, 3, 9, 82, 10]
// Слияние [2..2] + [3..3] → [27, 38, 3, 43, 9, 82, 10]
// Слияние [0..1] + [2..3] → [3, 27, 38, 43, 9, 82, 10]
// Слияние [5..5] + [6..6] → [3, 27, 38, 43, 9, 10, 82]
// Слияние [4..4] + [5..6] → [3, 27, 38, 43, 9, 10, 82]
// Слияние [0..3] + [4..6] → [3, 9, 10, 27, 38, 43, 82]
// Отсортированный: 3, 9, 10, 27, 38, 43, 82

Обрати внимание на порядок слияния:

  1. Сначала сливаются самые маленькие подмассивы (размером 1).
  2. Постепенно размер сливаемых частей растёт: 2, 4, 8...
  3. Последнее слияние — двух половинок всего массива.

💡 Формула mid = left + (right - left) / 2 предпочтительнее, чем (left + right) / 2, потому что избегает целочисленного переполнения при очень больших массивах (когда left + right > int.MaxValue).

⚡

Итеративная версия (Bottom-up Merge Sort)

Merge Sort можно реализовать и без рекурсии — итеративно. Алгоритм «снизу вверх» (bottom-up) начинает с подмассивов размера 1 и последовательно удваивает размер:

C# · Merge Sort (итеративный)
static void MergeSortIterative(int[] arr)
{
    int n = arr.Length;

    // size — размер подмассива, начинаем с 1, удваиваем
    for (int size = 1; size < n; size *= 2)
    {
        // left — начало левого подмассива
        for (int left = 0; left < n - size; left += 2 * size)
        {
            int mid = left + size - 1;
            int right = Math.Min(left + 2 * size - 1, n - 1);

            Merge(arr, left, mid, right);
        }

        Console.WriteLine($"size={size}: {string.Join(", ", arr)}");
    }
}

// Вывод:
// size=1: [27, 38, 3, 43, 9, 82, 10]
// size=2: [3, 27, 38, 43, 9, 10, 82]
// size=4: [3, 9, 10, 27, 38, 43, 82]

Итеративная версия:

  • Не использует рекурсию — нет риска StackOverflow
  • Работает точно так же за O(n log n)
  • Сложнее для понимания, но полезен в средах с ограниченным стеком
  • Часто используется в системах реального времени
📊

Сложность алгоритма

Временная сложность

  • Любой случай O(n log n) — гарантированно. Merge Sort не зависит от входных данных. Всегда делит массив пополам (log n уровней), на каждом уровне делает O(n) операций слияния.
  • Нет «худшего случая» — алгоритм всегда предсказуем.

Пространственная сложность

O(n) — это главный недостаток. На каждом уровне рекурсии создаются временные массивы (суммарно O(n)). Итеративная версия тоже требует O(n) дополнительной памяти.

Для массива из 10 млн int (40 МБ) Merge Sort потребует ещё 40 МБ временной памяти. Quick Sort — O(log n) = практически 0.

⚖️

Сравнение Merge Sort и Quick Sort

Какой алгоритм выбрать? Давай сравним их по ключевым параметрам:

Критерий Quick Sort Merge Sort
Среднее время O(n log n) — быстрее на практике O(n log n) — чуть медленнее
Худшее время O(n²) (редко со случайным pivot) O(n log n) — гарантированно
Память O(log n) — отлично O(n) — много
Стабильность Нестабильный Стабильный
Связные списки Плохо (требует доступа по индексу) Отлично (не требует произвольного доступа)
Предсказуемость Низкая (зависит от данных) Высокая (всегда одинаково)

Когда выбирать Merge Sort:

  • Нужна гарантированная скорость O(n log n) (реальное время, критичные системы)
  • Доступа много памяти
  • Нужна стабильная сортировка
  • Сортируются связные списки (linked list)
  • Данные находятся на диске (внешняя сортировка)

Когда выбирать Quick Sort:

  • Память ограничена (встроенные системы, микроконтроллеры)
  • Нужна максимальная скорость на случайных данных
  • Можно позволить себе риск O(n²) (со случайным pivot — практически никогда)
  • Массив помещается в кэш процессора (хорошая локальность)

💡 В C# Introsort (Array.Sort()) использует Quick Sort и переключается на Heap Sort при риске деградации. Если нужна гарантированная стабильность — LINQ OrderBy() использует Merge Sort. Так что в C# у тебя есть оба варианта «из коробки».

📝

Плюсы и минусы Merge Sort

✅ Плюсы

  • Гарантированно O(n log n) в любом случае
  • Стабильная сортировка
  • Предсказуемое поведение
  • Отлично работает со связными списками
  • Хорошо параллелится (разные части можно сливать параллельно)
  • Нет «худшего случая» — никаких сюрпризов
  • Работает с последовательным доступом (ленты, файлы)

❌ Минусы

  • Требует O(n) дополнительной памяти
  • На 10-30% медленнее Quick Sort в среднем
  • Дополнительные накладные расходы на копирование во временные массивы
  • Плохая локальность кэша (работает с разными участками памяти)
  • Для малых массивов проигрывает Insertion Sort
  • Не in-place
🔧

Оптимизация Merge Sort (In-place Merge)

Можно ли сделать Merge Sort без O(n) памяти? Технически да, но это сильно замедлит алгоритм. Одна из техник — In-place Merge с использованием бинарного поиска и ротации массива.

Более практичная оптимизация — использовать один большой временный массив и не создавать новые на каждом уровне:

C# · Merge Sort (один временный массив)
static void MergeSortOptimized(int[] arr)
{
    int[] temp = new int[arr.Length];
    MergeSortRec(arr, temp, 0, arr.Length - 1);
}

static void MergeSortRec(int[] arr, int[] temp, int left, int right)
{
    if (left < right)
    {
        int mid = left + (right - left) / 2;
        MergeSortRec(arr, temp, left, mid);
        MergeSortRec(arr, temp, mid + 1, right);
        MergeOptimized(arr, temp, left, mid, right);
    }
}

static void MergeOptimized(int[] arr, int[] temp, int left, int mid, int right)
{
    // Копируем в temp только один раз
    for (int k = left; k <= right; k++)
        temp[k] = arr[k];

    int i = left;      // указатель на temp (левая часть)
    int j = mid + 1;   // указатель на temp (правая часть)
    int k = left;      // указатель на arr

    while (i <= mid && j <= right)
    {
        if (temp[i] <= temp[j])
            arr[k++] = temp[i++];
        else
            arr[k++] = temp[j++];
    }

    // Копируем остаток левой части
    while (i <= mid)
        arr[k++] = temp[i++];

    // Остаток правой части уже на месте (если был)
}

Преимущества такого подхода:

  • Меньше накладных расходов на выделение памяти (один раз создаём массив)
  • Лучшая производительность за счёт меньшего количества операций копирования
  • Общая сложность по памяти всё равно O(n), но практическая скорость выше
🧪

Мини-тест

Проверь понимание Merge Sort.

Вопрос 1

Какая временная сложность Merge Sort в худшем случае?

A) O(n)   B) O(n log n)   C) O(n²)

Вопрос 2

Какая пространственная сложность Merge Sort?

A) O(1)   B) O(log n)   C) O(n)

Вопрос 3

Какой принцип лежит в основе Merge Sort?

A) Разделяй, потом работай (работа после рекурсии)   B) Работай, потом разделяй (работа до рекурсии)   C) Жадный алгоритм

Вопрос 4

Сколько временных массивов нужно для Merge Sort размера n (в базовой реализации)?

A) n массивов (на каждом уровне)   B) Один массив размера n   C) Ни одного (in-place)

Вопрос 5

Является ли Merge Sort стабильным?

A) Да   B) Нет   C) Только в итеративной версии

💡 Ответы: 1 — B, 2 — C, 3 — A, 4 — B (на каждом уровне создаются массивы суммарно O(n)), 5 — A (да, стабильный, так как при равенстве берём из левого массива)
🎮

Задача

Задание: Merge Sort для массива double

Реализуй Merge Sort для сортировки массива чисел с плавающей точкой (double[]). Требования:

  1. Создай массив из 10-15 дробных чисел (например, цены товаров)
  2. Реализуй MergeSort для double[]
  3. Используй оптимизированную версию с одним временным массивом
  4. Выводи массив после каждого слияния (чтобы видеть процесс)
  5. Посчитай количество сравнений и копирований

Пример данных: { 99.99, 25.50, 150.00, 49.95, 299.99, 19.99, 75.00, 89.50 }

Дополнительно: реализуй Generic-версию MergeSort<T>(T[] arr) где T : IComparable<T>.

Подсказка: для сравнения используй leftArr[iLeft].CompareTo(rightArr[iRight]) <= 0.

💡 Generic-версия Merge Sort — отличное упражнение, которое закрепляет понимание дженериков (урок 8.7). Обрати внимание, что для T нужно ограничение IComparable<T>.
📌

Что важно запомнить

1️⃣
Merge Sort = деление пополам + слияние. Сначала рекурсивно делим массив до одного элемента, потом сливаем обратно.
2️⃣
Гарантированно O(n log n) в любом случае. В отличие от Quick Sort, нет худшего случая. Всегда одинаково предсказуем.
3️⃣
O(n) дополнительной памяти — главный недостаток. Для больших массивов это может быть проблемой.
4️⃣
Стабильная сортировка. Порядок равных элементов сохраняется. Важно при сортировке по нескольким полям.
5️⃣
Quick Sort vs Merge Sort: Quick быстрее на практике и экономнее по памяти. Merge — предсказуемее и стабильнее. Выбирай под задачу.

Тест: 9.4: Сортировка слиянием (Merge Sort)

3 вопроса

Сортировка объектов

Premium