Откуда берутся дубликаты и почему это проблема?
Представьте: вы собираете данные с API, парсите CSV-файл или обрабатываете пользовательский ввод. В 90% случаев вы сталкиваетесь с массивами, в которых один и тот же элемент встречается несколько раз. Это не просто эстетическая проблема — дубликаты могут исказить статистику, сломать рендеринг списков или привести к бесконечным циклам в алгоритмах.
Вопрос "Remove duplicate values from a JavaScript array" на Stack Overflow собрал тысячи просмотров не просто так. Это базовая, но критически важная операция, которую должен знать каждый разработчик. Сегодня мы заглянем под капот и разберём, как работают разные подходы — от наивных до самых производительных.
Метод №1: Set — самый элегантный и быстрый
Если вы спросите современного JavaScript-разработчика: "Как удалить дубликаты?", первое, что он скажет — Set. И это правильно. Но как это работает?
const numbers = [1, 2, 2, 3, 4, 4, 5];
const uniqueNumbers = [...new Set(numbers)];
console.log(uniqueNumbers); // [1, 2, 3, 4, 5]
Что происходит под капотом Set?
Объект Set хранит уникальные значения любого типа. Когда вы передаёте массив в конструктор new Set(numbers), движок JavaScript (V8, SpiderMonkey и т.д.) выполняет следующие шаги:
- Итерация по массиву: движок проходит по каждому элементу исходного массива.
- Проверка на уникальность: для каждого элемента вызывается внутренняя хеш-функция. Если элемент уже есть в "корзине" (bucket), он игнорируется. Если нет — добавляется.
- Сборка результата: оператор
[...set](spread) разворачивает итерируемый объект Set обратно в массив.
Важный нюанс: Set использует алгоритм сравнения SameValueZero. Это значит, что NaN считается равным NaN (в отличие от ===), а +0 и -0 считаются разными значениями. На практике это редко имеет значение, но знать стоит.
Производительность Set
Сложность операции — O(n). Каждый элемент проверяется ровно один раз. Это лучший показатель среди всех методов. Для массивов с 10 000 элементов разница между Set и другими методами может составлять десятки миллисекунд — критично для высоконагруженных приложений.
Метод №2: filter + indexOf — классика жанра
До появления ES6 (2015) разработчики использовали связку filter и indexOf. Этот метод до сих пор встречается в легаси-коде:
const numbers = [1, 2, 2, 3, 4, 4, 5];
const uniqueNumbers = numbers.filter((value, index, self) => {
return self.indexOf(value) === index;
});
console.log(uniqueNumbers); // [1, 2, 3, 4, 5]
Как это работает?
filterпроходит по каждому элементу массива.- Для каждого элемента вызывается
indexOf(value), который возвращает первый индекс, где встречается это значение. - Если текущий индекс элемента совпадает с первым найденным — значит, это первое вхождение, и элемент остаётся. Если не совпадает — это дубликат, и он отбрасывается.
Подводные камни
Главная проблема — производительность. indexOf внутри использует линейный поиск (O(n) для каждого вызова). В итоге для массива из N элементов сложность становится O(n²). При 1000 элементах это ещё терпимо, но при 100 000 — тормоза становятся заметными.
Ещё один минус: indexOf не работает корректно с NaN. Если в массиве есть NaN, indexOf(NaN) всегда вернёт -1, и элемент будет добавлен повторно. Для проверки на NaN нужно использовать Number.isNaN().
Метод №3: reduce + includes — гибкий, но медленный
Метод reduce даёт больше контроля, особенно если нужно обрабатывать объекты:
const numbers = [1, 2, 2, 3, 4, 4, 5];
const uniqueNumbers = numbers.reduce((acc, current) => {
if (!acc.includes(current)) {
acc.push(current);
}
return acc;
}, []);
console.log(uniqueNumbers); // [1, 2, 3, 4, 5]
Как это работает?
- Создаётся пустой аккумулятор (
acc). - Для каждого элемента проверяется, есть ли он уже в аккумуляторе с помощью
includes(). - Если нет — элемент добавляется.
Сложность снова O(n²), так как includes — это тот же линейный поиск. Этот метод полезен, если нужно сохранить порядок элементов и применить дополнительную логику (например, фильтрацию по свойству объекта).
Метод №4: forEach + объект — хак для старых браузеров
До появления Set и даже Array.includes разработчики использовали объект как хеш-таблицу:
const numbers = [1, 2, 2, 3, 4, 4, 5];
const seen = {};
const uniqueNumbers = [];
numbers.forEach(value => {
if (!seen[value]) {
seen[value] = true;
uniqueNumbers.push(value);
}
});
console.log(uniqueNumbers); // [1, 2, 3, 4, 5]
Как это работает?
Объект seen используется как "словарь". Ключ — это значение элемента, значение — true (маркер того, что элемент уже встречался). Проверка !seen[value] работает за O(1), поэтому общая сложность — O(n).
Но есть нюанс: ключи объекта всегда преобразуются в строки. Если вы работаете с числами, это не проблема. Но если в массиве есть объекты или массивы, они будут преобразованы в строку "[object Object]" — и все объекты станут одинаковыми. Для примитивов (строки, числа, булевы) метод работает отлично.
Сравнение производительности: тест-драйв
Давайте проведём мысленный эксперимент. Возьмём массив из 10 000 случайных чисел (с дубликатами) и замерим время выполнения (в условных единицах):
| Метод | <
|---|