Удаление дубликатов из массива в JavaScript: разбор под капотом

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Откуда берутся дубликаты и почему это проблема?

Представьте: вы собираете данные с 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 случайных чисел (с дубликатами) и замерим время выполнения (в условных единицах):

<

Блоги

Рекомендаций по книгам

Метод