Сортировка и бинарный поиск
Сортировка и бинарный поиск
В 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)).
Сравнение алгоритмов
<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, не проверяется в рантайме.