Shell Sort (Сортировка Шелла)
Краткое описание
Обобщение Insertion Sort, которое сравнивает и обменивает элементы на больших расстояниях (gap), постепенно уменьшая это расстояние до 1. Позволяет элементам быстро перемещаться к их финальной позиции, преодолевая главный недостаток Insertion Sort — медленное перемещение элементов на большие расстояния.
Когда использовать
- Средние массивы (сотни-тысячи элементов) — эффективнее простых O(n²) алгоритмов, но проще чем Quick/Merge Sort
- Ограниченная память — использует O(1) дополнительной памяти (in-place)
- Нет рекурсии — подходит для систем с ограниченным стеком
- Простота реализации важна — проще чем Quick Sort или Merge Sort, но эффективнее базовых алгоритмов
- Встроенные системы — хорошая производительность без сложного кода
- Почти отсортированные данные — работает эффективно благодаря основе на Insertion Sort
Когда не стоит использовать:
- Очень большие массивы — Quick Sort, Merge Sort или IntroSort будут эффективнее
- Нужна стабильность — Shell Sort не стабильный алгоритм
- Нужна гарантированная производительность — сложность зависит от последовательности gap (может быть от O(n log² n) до O(n²))
- Критична предсказуемость — производительность сильно зависит от выбора последовательности gap
С объяснением логики работы
/**
* Shell Sort — улучшенная версия Insertion Sort
*
* Идея:
* 1. Insertion Sort медленно перемещает элементы (по одной позиции за раз)
* 2. Shell Sort сначала сравнивает далёкие элементы (gap > 1)
* 3. Постепенно уменьшаем gap до 1
* 4. Последний проход с gap=1 — это обычный Insertion Sort, но массив уже почти отсортирован
*/
/**
* Shell Sort с последовательностью gap Knuth (1, 4, 13, 40, 121, ...)
* Формула: gap = (3^k - 1) / 2
* Это одна из лучших последовательностей для Shell Sort
*/
function shellSort(arr) {
const n = arr.length;
// ШАГ 1: Вычисляем начальный gap по формуле Кнута
// Начинаем с максимального gap < n
// Последовательность Knuth: 1, 4, 13, 40, 121, 364, 1093...
let gap = 1;
while (gap < Math.floor(n / 3)) {
gap = gap * 3 + 1; // Следующий gap в последовательности
}
// ШАГ 2: Проходы с уменьшающимся gap
while (gap >= 1) {
// ШАГ 3: Для текущего gap выполняем "gapped insertion sort"
// Это как Insertion Sort, но сравниваем элементы на расстоянии gap
// Проходим по всем элементам начиная с gap-й позиции
for (let i = gap; i < n; i++) {
// Сохраняем текущий элемент для вставки
const temp = arr[i];
// Это будет индекс для сравнения с элементами на расстоянии gap
let j = i;
// КЛЮЧЕВАЯ ИДЕЯ: сравниваем arr[j] с arr[j-gap], а не с arr[j-1]
// Сдвигаем элементы с шагом gap, пока не найдём правильную позицию
while (j >= gap && arr[j - gap] > temp) {
// Сдвигаем элемент вправо на gap позиций
arr[j] = arr[j - gap];
// Переходим к следующему элементу на расстоянии gap
j -= gap;
}
// Вставляем temp на найденную позицию
arr[j] = temp;
}
// ШАГ 4: Уменьшаем gap для следующего прохода
// По формуле Кнута: gap = (gap - 1) / 3
gap = Math.floor(gap / 3);
}
// После последнего прохода с gap=1 массив полностью отсортирован
return arr;
}
// Пример использования
const numbers = [64, 34, 25, 12, 22, 11, 90, 88];
console.log("Исходный массив:", numbers);
shellSort(numbers);
console.log("Отсортированный массив:", numbers);
/**
* ВИЗУАЛИЗАЦИЯ РАБОТЫ на примере [64, 34, 25, 12, 22, 11, 90, 88]:
*
* Начальный gap = 4:
* Сравниваем: [64, _, _, _, 22] [34, _, _, _, 11] [25, _, _, _, 90] [12, _, _, _, 88]
* После gap=4: [22, 11, 25, 12, 64, 34, 90, 88]
*
* gap = 1:
* Выполняем обычный Insertion Sort, но массив уже почти отсортирован!
* После gap=1: [11, 12, 22, 25, 34, 64, 88, 90]
*/
/**
* ПОЧЕМУ SHELL SORT БЫСТРЕЕ INSERTION SORT:
*
* Insertion Sort:
* - Чтобы переместить 90 из начала в конец массива из 1000 элементов
* - Нужно ~1000 сдвигов (по одной позиции)
*
* Shell Sort:
* - Первый проход с gap=364: переместит 90 на 364 позиции (1 сдвиг)
* - Второй проход с gap=121: ещё 121 позиция (1 сдвиг)
* - Третий проход с gap=40: ещё 40 позиций (1 сдвиг)
* - И так далее...
* - Итого: ~5-7 сдвигов вместо 1000!
*/
// Альтернативная реализация с последовательностью gap Shell (n/2, n/4, n/8, ...)
function shellSortOriginal(arr) {
const n = arr.length;
// Начинаем с gap = n/2 и делим пополам на каждом проходе
for (let gap = Math.floor(n / 2); gap > 0; gap = Math.floor(gap / 2)) {
for (let i = gap; i < n; i++) {
const temp = arr[i];
let j = i;
while (j >= gap && arr[j - gap] > temp) {
arr[j] = arr[j - gap];
j -= gap;
}
arr[j] = temp;
}
}
return arr;
}
// ПРИМЕЧАНИЕ: последовательность Knuth обычно даёт лучшие результаты,
// чем оригинальная последовательность Shell (n/2, n/4, ...)С анализом сложностей
/**
* Shell Sort с анализом сложности
*
* ВРЕМЕННАЯ СЛОЖНОСТЬ (зависит от последовательности gap):
* - Оригинальная (n/2, n/4, ...): O(n²) в худшем случае
* - Knuth (3^k-1)/2: O(n^(3/2)) ≈ O(n^1.5) в худшем
* - Sedgewick: O(n^(4/3)) в худшем
* - Лучший случай: O(n log n) для хороших последовательностей
*
* ПРОСТРАНСТВЕННАЯ СЛОЖНОСТЬ:
* - O(1) — только переменные gap, i, j, temp
* - In-place сортировка
*
* ОСОБЕННОСТИ:
* - НЕ стабильный (элементы могут перепрыгивать через равные)
* - Адаптивный (быстрее на почти отсортированных)
* - Без рекурсии
*/
/**
* Shell Sort с последовательностью Knuth
* Сложность: O(n^(3/2)) в худшем случае
*/
function shellSortKnuth(arr) {
const n = arr.length;
// ВЫЧИСЛЕНИЕ НАЧАЛЬНОГО GAP: O(log n)
// Последовательность Knuth: gap_k = (3^k - 1) / 2
// Примеры: 1, 4, 13, 40, 121, 364, 1093, 3280, 9841...
let gap = 1;
while (gap < Math.floor(n / 3)) {
gap = gap * 3 + 1;
}
// КОЛИЧЕСТВО ПРОХОДОВ: O(log n)
// Каждый проход уменьшает gap в ~3 раза
// Для n=1000: gap = 364 → 121 → 40 → 13 → 4 → 1 (6 проходов)
while (gap >= 1) {
// ПРОХОД С ТЕКУЩИМ GAP
// Сложность одного прохода зависит от gap:
// - Для большого gap: меньше сравнений и сдвигов
// - Для gap=1: O(n) на почти отсортированном массиве
for (let i = gap; i < n; i++) {
// Внешний цикл: O(n)
const temp = arr[i];
let j = i;
// Внутренний цикл: количество итераций зависит от gap и данных
//
// АНАЛИЗ СЛОЖНОСТИ:
// - Для gap близкого к n: мало элементов для сравнения
// - Для gap=1 на почти отсортированном: O(1) итераций в среднем
// - Суммарная работа всех проходов: O(n^(3/2)) для Knuth
while (j >= gap && arr[j - gap] > temp) {
arr[j] = arr[j - gap];
j -= gap;
}
arr[j] = temp;
}
// Уменьшаем gap: O(1)
gap = Math.floor(gap / 3);
}
return arr;
}
/**
* Shell Sort с оригинальной последовательностью Shell
* Сложность: O(n²) в худшем случае (хуже чем Knuth!)
*/
function shellSortOriginal(arr) {
const n = arr.length;
// КОЛИЧЕСТВО ПРОХОДОВ: O(log n)
// Последовательность: n/2, n/4, n/8, ..., 1
for (let gap = Math.floor(n / 2); gap > 0; gap = Math.floor(gap / 2)) {
for (let i = gap; i < n; i++) {
const temp = arr[i];
let j = i;
while (j >= gap && arr[j - gap] > temp) {
arr[j] = arr[j - gap];
j -= gap;
}
arr[j] = temp;
}
}
return arr;
}
/**
* Shell Sort с последовательностью Sedgewick
* Одна из лучших последовательностей: O(n^(4/3)) в худшем случае
* Последовательность (1986): 1, 5, 19, 41, 109, 209, 505, 929...
* (генерируется рекуррентой в коде ниже: 9·4^k − 9·2^k + 1 для чётных k,
* 8·2^k − 6·2^((k+1)/2) + 1 для нечётных k)
*/
function shellSortSedgewick(arr) {
const n = arr.length;
// Генерируем последовательность Sedgewick
const gaps = [];
let k = 0;
while (true) {
let gap;
if (k % 2 === 0) {
// Для чётных k: 9·4^k - 9·2^k + 1
const pow4 = Math.pow(4, k / 2);
const pow2 = Math.pow(2, k / 2);
gap = 9 * pow4 - 9 * pow2 + 1;
} else {
// Для нечётных k: 8·2^k - 6·2^((k+1)/2) + 1
const pow2k = Math.pow(2, k);
const pow2half = Math.pow(2, (k + 1) / 2);
gap = 8 * pow2k - 6 * pow2half + 1;
}
if (gap >= n) break;
gaps.push(gap);
k++;
}
// Проходы с убывающими gaps
for (let g = gaps.length - 1; g >= 0; g--) {
const gap = gaps[g];
for (let i = gap; i < n; i++) {
const temp = arr[i];
let j = i;
while (j >= gap && arr[j - gap] > temp) {
arr[j] = arr[j - gap];
j -= gap;
}
arr[j] = temp;
}
}
return arr;
}
// СРАВНЕНИЕ ПОСЛЕДОВАТЕЛЬНОСТЕЙ GAP:
// 1. ОРИГИНАЛЬНАЯ SHELL (n/2, n/4, ...):
// ✗ Худший случай: O(n²)
// ✓ Простая в реализации
// Не рекомендуется для production
// 2. KNUTH (1, 4, 13, 40, ...):
// ✓ Худший случай: O(n^1.5)
// ✓ Простая формула
// ✓ Хорошая на практике
// Рекомендуется как стандарт
// 3. SEDGEWICK:
// ✓ Худший случай: O(n^(4/3))
// ✓ Лучшая теоретическая сложность
// ✗ Сложнее в реализации
// Рекомендуется для критичных приложений
// ПРИМЕРЫ ПРОИЗВОДИТЕЛЬНОСТИ:
// Для n = 1000:
// - Insertion Sort: ~500,000 операций (O(n²))
// - Shell Sort (Knuth): ~15,000 операций (O(n^1.5))
// - Quick Sort: ~10,000 операций (O(n log n))
// Для n = 10,000:
// - Insertion Sort: ~50,000,000 операций
// - Shell Sort (Knuth): ~500,000 операций
// - Quick Sort: ~130,000 операций
/**
* КОГДА SHELL SORT ЭФФЕКТИВЕН:
*
* 1. РАЗМЕР ДАННЫХ: сотни-тысячи элементов
* - Слишком большой для O(n²) алгоритмов
* - Не требует сложности Quick/Merge Sort
*
* 2. ОГРАНИЧЕНИЯ ПАМЯТИ
* - O(1) как у Insertion Sort
* - Нет рекурсии как у Quick Sort
*
* 3. ПРОСТОТА КОДА
* - Проще Quick Sort и Merge Sort
* - Эффективнее простых алгоритмов
*
* 4. ВСТРОЕННЫЕ СИСТЕМЫ
* - Предсказуемое использование памяти
* - Без рекурсии
*/
// Тестирование производительности
function benchmark() {
const sizes = [100, 1000, 10000];
sizes.forEach(n => {
const arr = Array.from({length: n}, () => Math.floor(Math.random() * n));
console.time(`Shell Sort (Knuth) n=${n}`);
shellSortKnuth([...arr]);
console.timeEnd(`Shell Sort (Knuth) n=${n}`);
console.time(`Shell Sort (Original) n=${n}`);
shellSortOriginal([...arr]);
console.timeEnd(`Shell Sort (Original) n=${n}`);
console.time(`Shell Sort (Sedgewick) n=${n}`);
shellSortSedgewick([...arr]);
console.timeEnd(`Shell Sort (Sedgewick) n=${n}`);
});
}
// benchmark();