Сортировка и бинарный поиск

Сортировка и бинарный поиск

В Go не нужно писать сортировку с нуля. Но нужно понимать, когда какой подход работает, чем Slice отличается от SliceStable, и почему один и тот же массив сортируется за разное время на разных версиях Go.

sort.Slice - быстрая сортировка

nums := []int{5, 3, 1, 4, 2}
sort.Slice(nums, func(i, j int) bool {
    return nums[i] < nums[j]
})
// [1, 2, 3, 4, 5]

// Сортировка структур
type User struct {
    Name string
    Age  int
}

users := []User{
    {"Bob", 30}, {"Alice", 25}, {"Carol", 35},
}

sort.Slice(users, func(i, j int) bool {
    return users[i].Age < users[j].Age
})

sort.Slice использует reflection один раз для получения swap-функции, поэтому немного медленнее sort.Sort с sort.Interface. На горячем пути это может быть заметно.

<?php
declare(strict_types=1);

$nums = [5, 3, 1, 4, 2];
sort($nums); // [1, 2, 3, 4, 5] - in-place, переиндексирует

// Сортировка структур через usort + spaceship operator <=>
final readonly class User
{
    public function __construct(public string $name, public int $age) {}
}

$users = [
    new User('Bob', 30),
    new User('Alice', 25),
    new User('Carol', 35),
];

usort($users, static fn (User $a, User $b): int => $a->age <=> $b->age);
// [Alice 25, Bob 30, Carol 35]

В PHP - sort() (числа/строки in-place), usort() (с компаратором), asort() (сохраняя ключи), ksort() (по ключам).

Оператор <=> (spaceship) возвращает -1/0/1 - именно то, что ждёт usort. Это аналог cmp.Compare из Go 1.21.

sort.Interface - для повторного использования

type ByName []User

func (a ByName) Len() int           { return len(a) }
func (a ByName) Less(i, j int) bool { return a[i].Name < a[j].Name }
func (a ByName) Swap(i, j int)      { a[i], a[j] = a[j], a[i] }

sort.Sort(ByName(users))

Когда один и тот же тип сортируется в разных местах - выноси sort.Interface в reusable тип.

<?php
declare(strict_types=1);

final class UserComparator
{
    public static function byName(User $a, User $b): int
    {
        return $a->name <=> $b->name;
    }

    public static function byAge(User $a, User $b): int
    {
        return $a->age <=> $b->age;
    }

    /** Multi-key: сначала по департаменту, потом по имени */
    public static function byDeptThenName(User $a, User $b): int
    {
        return $a->department <=> $b->department
            ?: $a->name <=> $b->name;
    }
}

usort($users, UserComparator::byName(...)); // PHP 8.1+ first-class callable syntax

В PHP компараторы вытаскивают в final class со статическими методами или в invokable-объекты - удобно тестировать.

UserComparator::byName(...) - PHP 8.1+ синтаксис «first-class callable». До этого писали [UserComparator::class, 'byName'] или замыкание.

Бинарный поиск

// Через sort.Search (возвращает индекс первого элемента, где f(i) == true)
nums := []int{1, 3, 5, 7, 9, 11}
target := 7

idx := sort.Search(len(nums), func(i int) bool {
    return nums[i] >= target
})

if idx < len(nums) && nums[idx] == target {
    fmt.Printf("Найден на позиции %d\n", idx)
}

// С Go 1.21: slices.BinarySearch
import "slices"

idx, found := slices.BinarySearch(nums, target)

slices.BinarySearch короче и не требует замыкания. Работает только с упорядоченными типами (cmp.Ordered).

<?php
declare(strict_types=1);

/**
 * Бинарный поиск на отсортированном массиве.
 * @return int Индекс или -1, если не найдено.
 */
function binarySearch(array $nums, int $target): int
{
    $lo = 0;
    $hi = count($nums) - 1;

    while ($lo <= $hi) {
        $mid = intdiv($lo + $hi, 2);
        if ($nums[$mid] === $target) {
            return $mid;
        }
        if ($nums[$mid] < $target) {
            $lo = $mid + 1;
        } else {
            $hi = $mid - 1;
        }
    }

    return -1;
}

$nums = [1, 3, 5, 7, 9, 11];
$idx = binarySearch($nums, 7); // 3

В PHP native бинарного поиска нет - array_search() это O(n) линейный обход. Если данные отсортированы и поиск горячий - реализуй вручную.

array_search($needle, $arr) всегда O(n), не используй его как замену бинарного поиска. Для частых поисков на отсортированном наборе - либо ручная реализация, либо переход на hash-структуру (isset($index[$key]), O(1)).

Quicksort пошагово: pivot, partition, рекурсия по половинам

Сравнение алгоритмов

<ComparisonTable data={{ headers: ["Алгоритм", "Среднее", "Худшее", "Память", "Стабильный"], rows: [ ["Bubble Sort", "O(n²)", "O(n²)", "O(1)", "Да"], ["Insertion Sort", "O(n²)", "O(n²)", "O(1)", "Да"], ["Merge Sort", "O(n log n)", "O(n log n)", "O(n)", "Да"], ["Quick Sort", "O(n log n)", "O(n²)", "O(log n)", "Нет"], ["sort.Slice (Go)", "O(n log n)", "O(n log n)", "O(log n)", "Нет"] ] }} />

С Go 1.19 sort.Slice использует pdqsort (pattern-defeating quicksort) - гибрид рекурсивных quicksort и heapsort:

  • быстрый quicksort на типичных данных;
  • переключается в heapsort при подозрении на adversarial input → гарантирует O(n log n) в худшем случае;
  • использует insertion sort на маленьких подмассивах (< ~24 элементов), где он быстрее из-за cache locality;
  • детектирует уже отсортированные/обратно отсортированные участки и обрабатывает их за O(n).

На реальных данных это даёт 2-3× ускорение против старого sort.Sort (introsort).

В PHP с версии 8.0 sort/usort использует Timsort - гибрид merge-sort и insertion-sort. До 8.0 был quicksort. Главное практическое следствие: usort стал stable начиная с PHP 8.0. На отсортированных/частично-отсортированных данных Timsort работает за O(n).

Грубые ориентиры по скорости

На современном x86 ядре slices.Sort для []int:

РазмерВремя
1 000~30 мкс
100 000~5 мс
1 000 000~60 мс

Цифры зависят от распределения данных, типа элементов и компаратора. Всегда замеряй через go test -bench на своём профиле - не доверяй ориентирам.

Стабильная сортировка

Стабильная сортировка сохраняет относительный порядок равных элементов. Важно при multi-key sort: сначала сортируешь по фамилии, потом по департаменту - фамилии внутри департамента остаются упорядоченными только при стабильной сортировке.

// sort.Slice - НЕ стабильная (равные элементы могут поменять порядок)
// sort.SliceStable - стабильная (на ~10-20% медленнее)
sort.SliceStable(users, func(i, j int) bool {
    return users[i].Age < users[j].Age
})

Не платишь за стабильность, когда она не нужна - Slice быстрее.

В PHP 8.0+ usort стабилен по умолчанию (Timsort) - отдельного «stable»-варианта не нужно. На PHP 7.x для стабильной сортировки добавляли индекс в компаратор как tiebreaker. Если код должен работать на legacy-версиях - см. ниже.

<?php
declare(strict_types=1);

// PHP 8.0+ - просто usort, оно уже stable
usort($users, static fn (User $a, User $b): int => $a->age <=> $b->age);

// Legacy-trick для PHP < 8.0: добавить индекс как tiebreaker
$indexed = array_map(
    static fn (int $i, User $u): array => ['idx' => $i, 'user' => $u],
    array_keys($users),
    $users,
);
usort($indexed, static fn (array $a, array $b): int =>
    $a['user']->age <=> $b['user']->age
        ?: $a['idx'] <=> $b['idx']
);
$users = array_column($indexed, 'user');

Counting / Radix sort: когда обходят O(n log n)

Граница O(n log n) фундаментальна только для сортировок сравнением. Если знаешь диапазон значений - есть линейные алгоритмы.

Counting sort

Для целых в небольшом диапазоне (например, возраст 0-150):

func countingSort(nums []int, maxVal int) []int {
    count := make([]int, maxVal+1)
    for _, v := range nums {
        count[v]++
    }
    out := make([]int, 0, len(nums))
    for v, c := range count {
        for ; c > 0; c-- {
            out = append(out, v)
        }
    }
    return out
}
<?php
declare(strict_types=1);

/**
 * @param array<int> $nums
 * @return array<int>
 */
function countingSort(array $nums, int $maxVal): array
{
    $count = array_fill(0, $maxVal + 1, 0);
    foreach ($nums as $v) {
        $count[$v]++;
    }

    $out = [];
    foreach ($count as $value => $cnt) {
        for ($i = 0; $i < $cnt; $i++) {
            $out[] = $value;
        }
    }
    return $out;
}

Время - O(n + k), где k - диапазон значений. На сортировке миллиона возрастов работает в разы быстрее quicksort.

Radix sort

Разбивает ключ на разряды и применяет counting sort по каждому. Используется в специализированных библиотеках (например, golang.org/x/exp/slices экспериментирует с radix). В стандартной библиотеке нет - слишком ситуативно.

Пакет slices (Go 1.21+)

import "slices"

nums := []int{3, 1, 4, 1, 5}
slices.Sort(nums)                  // сортировка
slices.SortStableFunc(nums, cmp.Compare) // стабильная с компаратором
slices.SortFunc(nums, cmp.Compare) // нестабильная с компаратором
slices.Contains(nums, 4)           // O(n) поиск
slices.Index(nums, 4)              // индекс или -1
slices.Min(nums)                   // минимум
slices.Max(nums)                   // максимум
slices.BinarySearch(nums, 4)       // O(log n) на отсортированном

Новый API быстрее старого sort за счёт generics - нет reflection. Используй slices в новом коде, sort - только для совместимости с sort.Interface библиотек.

В PHP набор аналогичных функций давно встроен в ядро:

<?php
declare(strict_types=1);

$nums = [3, 1, 4, 1, 5];

sort($nums);                                  // [1, 1, 3, 4, 5] - in-place
$copy = $nums; sort($copy);                   // сортировка копии

usort($nums, fn($a, $b) => $a <=> $b);        // с компаратором (stable с PHP 8.0)

in_array(4, $nums, strict: true);             // O(n) поиск
$idx = array_search(4, $nums, strict: true);  // O(n), индекс или false
min($nums);                                    // минимум
max($nums);                                    // максимум

// asort/ksort/uasort/uksort - вариации с сохранением ключей
asort($users);   // сортирует значения, сохраняет ключи
ksort($users);   // сортирует по ключам

В отличие от Go, PHP всегда работает с reference-семантикой массивов через copy-on-write - reflection не используется. На крупных датасетах разница с Go измеримая, но для типичных HTTP-запросов это не bottleneck.

Когда какой выбор

СценарийAPI
[]int, []string в новом кодеslices.Sort
Структура, простой компараторslices.SortFunc
Нужна стабильностьslices.SortStableFunc
Один и тот же тип в 5+ местахsort.Sort с sort.Interface
Числа в малом диапазоне, миллион элементовcounting sort вручную
Найти k-й элемент без полной сортировкиcontainer/heap или quickselect
Поиск в отсортированномslices.BinarySearch

Типичные ошибки

1. Неправильное направление Less

// БАГ: возвращает по убыванию, хотя задумано по возрастанию
sort.Slice(nums, func(i, j int) bool {
    return nums[i] > nums[j] // > вместо <
})
<?php
declare(strict_types=1);

// PHP-аналог: путаница в знаке spaceship-оператора
// БАГ: $b - $a сортирует по убыванию вместо возрастания
usort($nums, static fn (int $a, int $b): int => $b <=> $a); // DESC, не ASC

// Правильно - $a <=> $b для возрастания:
usort($nums, static fn (int $a, int $b): int => $a <=> $b);

Less должен вернуть true, когда элемент i должен идти раньше j.

2. Сортировка float с NaN

nums := []float64{1.0, math.NaN(), 2.0}
sort.Float64s(nums) // NaN портит порядок
<?php
declare(strict_types=1);

// PHP - то же поведение: NAN не сравнивается ни с чем.
$nums = [1.0, NAN, 2.0];
sort($nums); // порядок NAN не определён, нарушает сортировку

// Фильтрация NAN до сортировки:
$nums = array_values(array_filter($nums, static fn (float $v): bool => !is_nan($v)));
sort($nums);

// Или кастомный компаратор - NAN в конец:
usort($nums, static function (float $a, float $b): int {
    if (is_nan($a)) return 1;
    if (is_nan($b)) return -1;
    return $a <=> $b;
});

NaN не сравнивается ни с чем (NaN < x всегда false). Фильтруй NaN до сортировки или используй кастомный компаратор с math.IsNaN.

3. Бинарный поиск в неотсортированном слайсе

idx, found := slices.BinarySearch([]int{3, 1, 4, 1, 5}, 4)
// found может быть false, даже если 4 есть в слайсе
<?php
declare(strict_types=1);

// PHP-эквивалент: ручной binarySearch без проверки сортировки в рантайме
$nums = [3, 1, 4, 1, 5]; // НЕ отсортирован
$idx = binarySearch($nums, 4); // вернёт -1 или неверный индекс

// Защитный паттерн: assertion в DEBUG, sort в production:
assert($nums === array_values(array_unique($nums)) && $nums === [...$nums]);

// Или forsorted-обёртка, гарантирующая контракт:
final class SortedIntArray
{
    public function __construct(private readonly array $items)
    {
        $copy = $items;
        sort($copy);
        if ($items !== $copy) {
            throw new \InvalidArgumentException('Input must be sorted');
        }
    }
}

BinarySearch ожидает отсортированный вход - это контракт API, не проверяется в рантайме.

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.