«Напиши функцию» — 5 задач

Подборка из базы собесов · Сбер, Райффайзенбанк · сложность по возрастанию

У каждой задачи есть блок «Пример использования» — конкретные вызовы и то, что функция должна вернуть, включая граничные случаи. Все выводы в примерах получены прогоном в node, не на глаз. Код копируется кнопкой.
1

Уникальные значения массива без Set

🟢 juniorСбер27.09.2024📄 листинг
алгоритмыjavascript
Написать функцию, возвращающую только уникальные значения массива, без нативного Set.
Пример использования
// Что нужно написать: функцию uniqueValues(array) -> новый массив
// без повторов, порядок = порядок первого появления. Set использовать НЕЛЬЗЯ.

uniqueValues([1, 1, 2, 3, 5, 2, 8, 9, 1, 0]);
// -> [1, 2, 3, 5, 8, 9, 0]

uniqueValues([1, '1', 1, '1']);
// -> [1, '1']        число и строка — РАЗНЫЕ значения, не схлопывать

uniqueValues([0, 0, false, '']);
// -> [0, false, '']  falsy-значения не теряются

uniqueValues(['b', 'a', 'b', 'c', 'a']);
// -> ['b', 'a', 'c'] порядок первого появления

uniqueValues([NaN, NaN, undefined, null, undefined]);
// -> [NaN, undefined, null]

uniqueValues([]);
// -> []
Все выводы получены прогоном в node. Главный кейс — второй: если строить ключ без типа, 1 и '1' склеятся в один и вернётся [1]. Последний кейс с NaN — то, чем добивают: сравнение через indexOf его не ловит.
✅ Проверено прогоном: [1,2,3,5,8,9,0] — сошлось. Плюс [1,'1'] → [1,'1'] (типы не склеились), [0,0,1] → [0,1].
✅ Ответ и разбор
Почему так

Задача не про алгоритм, а про то, чем является ключ объекта. Ключи объекта — только строки и символы, любое другое значение приводится к строке. Отсюда весь набор грабель. Сложность O(n) — за один проход, потому что поиск по ключу объекта константный; вложенный indexOf дал бы O(n²).

Разбор по шагам
  1. Идём по массиву один раз, слева направо.
  2. Для каждого значения строим ключ, однозначно кодирующий тип + значение.
  3. Если такого ключа ещё не было — значение уникально, кладём в результат и помечаем ключ.
  4. Порядок результата = порядок первого появления.
А если… (чем добивают)
  • «А теперь без объекта и без Set» → filter((value, index, array) => array.indexOf(value) === index). Красиво, но O(n²) — на это и смотрят.
  • «А если в массиве объекты?» → строковый ключ ломается: {a:1} и {a:2} оба дадут [object Object]. Нужен Map/WeakSet со сравнением по ссылке.
  • «А NaN?» → String(NaN) даёт 'NaN', ключи совпадут, дубль поймается. А вот indexOf провалится: [NaN].indexOf(NaN) === -1. Отличная добивка.
Частые ошибки
  • if (!seen[key]) вместо in — концептуально неверно (полагается на truthy флага).
  • Забыть префикс типа → [1, '1'] вернёт один элемент. Прогнал: наивная версия даёт [1].
  • Обычный {} вместо Object.create(null) — редко, но стреляет на ключах из прототипа.
  • Сказать «сложность O(n)» для варианта с indexOf внутри filter.
Как объяснить вслух
«Беру словарь встреченных значений и прохожу массив один раз — это O(n). Главная тонкость: ключи объекта всегда строки, поэтому число 1 и строка '1' схлопнулись бы в один ключ. Чтобы этого не было, кладу в ключ ещё и тип.»
Решение
// Без Set: объект-словарь уже встреченных значений.
// Object.create(null) — чистый словарь без прототипа: иначе ключи
// вроде "constructor" или "toString" уже «существуют» и сломают проверку.
function uniqueValues(sourceArray) {
  const alreadySeen = Object.create(null);
  const result = [];

  for (const value of sourceArray) {
    // Ключ с префиксом типа: ключи объекта — всегда строки,
    // поэтому число 1 и строка '1' дали бы один и тот же ключ "1".
    const key = typeof value + ':' + String(value);

    // Проверяем через `in`, а не через if (alreadySeen[key]):
    // значение-флаг может быть falsy, а нам важен сам факт наличия ключа.
    if (!(key in alreadySeen)) {
      alreadySeen[key] = true;
      result.push(value);   // порядок первого появления сохраняется
    }
  }

  return result;
}
2

Есть ли дубли среди аргументов

🟢 juniorСбер30.09.2024📄 листинг
алгоритмыjavascript
Функция возвращает true, если среди аргументов есть дубли. Что выведет каждый вызов?
function duplicatesSberPro(...args) {
  const uniqueValues = new Set(args);
  return uniqueValues.size !== args.length;
}

console.log(duplicatesSberPro(1, 2, 3));     // ?
console.log(duplicatesSberPro(1, 2, 2));     // ?
console.log(duplicatesSberPro('a','b','a')); // ?
console.log(duplicatesSberPro('1', 1));      // ?
Пример использования
// Функция УЖЕ написана. Задача: сказать, что выведет каждый вызов, и объяснить почему.

hasDuplicates(1, 2, 3);        // -> false
hasDuplicates(1, 2, 2);        // -> true
hasDuplicates('a', 'b', 'a');  // -> true
hasDuplicates('1', 1);         // -> false   строка и число не равны
hasDuplicates(NaN, NaN);       // -> true    ХОТЯ NaN === NaN это false!
hasDuplicates(0, -0);          // -> true    ХОТЯ Object.is(0,-0) это false
hasDuplicates();               // -> false   пустой вызов
hasDuplicates({}, {});         // -> false   разные объекты = разные ссылки

const sameObject = {};
hasDuplicates(sameObject, sameObject); // -> true   одна и та же ссылка
Два вывода ломают интуицию: NaN, NaN → true и 0, -0 → true. Внутри Set работает SameValueZero — он не равен ни ===, ни Object.is. Это и есть суть задачи.
✅ Проверено прогоном: false → true → true → false. Сверх условия прогнал: (NaN,NaN) → true, (0,-0) → true, ({},{}) → false.
✅ Ответ и разбор
Почему так

Вся задача — про алгоритм сравнения внутри Set. Он называется SameValueZero и отличается от === ровно в одном месте: NaN равен самому себе. И отличается от Object.is тоже ровно в одном: 0 и -0 считаются равными.

Разбор по шагам
  1. new Set(args) выкидывает повторы по SameValueZero.
  2. Если размер Set меньше числа аргументов — значит что-то схлопнулось, то есть дубль был.
  3. ('1', 1) → строка и число остаются разными элементами, size = 2 = длина → false.
А если… (чем добивают)
  • «Хочу, чтобы '1' и 1 считались дублями» → нормализовать перед Set: args.map(String). Но тогда true и 'true' тоже станут дублями — это уже продуктовое решение, стоит сказать вслух.
  • «А если аргументы — объекты с одинаковыми полями?» → Set бессилен, нужно сравнение по содержимому: ключ через JSON.stringify (с поправкой на порядок ключей) или ручной deep-equal.
  • «Сложность?» → O(n), один проход + константная вставка в хеш. Наивное двойное for — O(n²).
Частые ошибки
  • Ответить true на ('1', 1) — самая частая ошибка, кажется, что Set приводит типы.
  • Уверенно сказать, что Set сравнивает через ===: тогда NaN, NaN «должны» дать false, а реально true.
  • Сказать, что Object.is — то же самое, что внутри Set. Разница в -0.
  • Ждать, что ({},{}) даст true.
Как объяснить вслух
«Set убирает повторы, поэтому сравниваю его размер с числом аргументов. Важно, что Set не приводит типы — строка '1' и число 1 остаются разными, поэтому последний вызов false. И ещё Set сравнивает не через строгое равенство, а через SameValueZero: NaN там равен самому себе, в отличие от ===.»
Решение
// Решение в условии уже верное — ценность задачи в разборе поведения Set.
// Проверка фактов прогоном:

new Set([1, 2, 3]).size;      // 3 === 3 аргумента -> дублей нет -> false
new Set([1, 2, 2]).size;      // 2 !== 3 -> true
new Set(['a','b','a']).size;  // 2 !== 3 -> true
new Set(['1', 1]).size;       // 2 === 2 -> false: Set НЕ приводит типы

// Set сравнивает по SameValueZero — это НЕ ===:
NaN === NaN;                  // false
new Set([NaN, NaN]).size;     // 1  <- дубль пойман, хотя === говорит "разные"
new Set([0, -0]).size;        // 1  <- считает одинаковыми
Object.is(0, -0);             // false <- а Object.is различает

// Объекты сравниваются по ссылке, а не по содержимому:
new Set([{}, {}]).size;       // 2 -> дубль НЕ найден
const shared = {};
new Set([shared, shared]).size; // 1
3

Вычесть N рабочих дней из даты

🟡 middleСбер23.12.2024📄 листинг
алгоритмыдаты
Написать функцию, которая вычитает N рабочих дней из даты, пропуская субботы и воскресенья.
Пример использования
// Что нужно написать: subtractBusinessDays(date, n) -> НОВЫЙ Date,
// отступив назад на n РАБОЧИХ дней (сб и вс не считаются).
// Входную дату мутировать нельзя.

const wednesday = new Date(2026, 8, 30);   // ср 30.09.2026

subtractBusinessDays(wednesday, 3);
// -> пт 25.09.2026   (вт 29, пн 28, пт 25 — выходные пропущены)

subtractBusinessDays(new Date(2026, 8, 28), 1);  // пн 28.09
// -> пт 25.09.2026   перепрыгнули через выходные

subtractBusinessDays(new Date(2026, 9, 3), 1);   // сб 03.10
// -> пт 02.10.2026   старт в выходной

subtractBusinessDays(wednesday, 0);
// -> ср 30.09.2026   ноль = та же дата

subtractBusinessDays(wednesday, 10);
// -> ср 16.09.2026   ровно две недели назад

console.log(wednesday);  // ср 30.09.2026 — исходная дата НЕ изменилась
Проверено прогоном, включая немутабельность: Date изменяемый, и setDate на аргументе испортил бы дату у вызывающего кода. Кейс «10 рабочих дней = ровно 2 недели» удобен для самопроверки на созвоне.
✅ Проверено прогоном: ср 30.09 − 3 → пт 25.09 (сошлось с условием). Плюс: старт в сб/вс, n=0, немутабельность входа.
✅ Ответ и разбор
Почему так

Ключевая мысль: нельзя вычесть сразу n дней и потом «поправить» на выходные — поправка сама может попасть на выходной. Нужно шагать по одному дню назад и уменьшать счётчик только когда попали на будний день. Второй важный момент — Date мутабельный, поэтому работаем с копией.

Разбор по шагам
  1. Копируем дату: new Date(startDate.getTime()).
  2. Пока остаток рабочих дней больше нуля — сдвигаем дату на день назад.
  3. Смотрим getDay(): 0 — вс, 6 — сб. Если будний — уменьшаем остаток.
  4. Возвращаем копию. Выходные «проскакиваются» бесплатно.
А если… (чем добивают)
  • «Добавь праздники» → передавать Set строк 'YYYY-MM-DD' и проверять вместе с выходными.
  • «А если n огромное, 10000?» → цикл станет медленным. Оптимизация: целых недель Math.floor(n / 5) = столько же недель назад, остаток дошагать вручную. Хороший ответ на «ускорь».
  • «А время?» → setDate сохраняет часы/минуты. Если нужна чистая дата — обнулить через setHours(0,0,0,0).
  • «Переход на летнее время?» → setDate безопаснее, чем вычитание 24*60*60*1000 мс: второе ломается на переводе часов.
Частые ошибки
  • Мутировать входной Date — самая частая. Функция «работает», но портит дату у вызывающего кода.
  • Вычесть n календарных дней и один раз откатиться с выходных — неверно при n больше 5.
  • Вычитать миллисекунды date - n*86400000 — ломается на переводе часов.
  • Перепутать индексы дней: 0 это воскресенье, а не понедельник.
  • Уменьшать счётчик до проверки на выходной.
Как объяснить вслух
«Иду по одному дню назад от копии исходной даты. Счётчик уменьшаю только если день будний — так выходные проскакиваются сами, и не нужно ничего досчитывать отдельно. Копию делаю потому, что Date мутабельный и setDate изменил бы дату вызывающего кода.»
Решение
// Возвращаем НОВЫЙ Date: исходный мутировать нельзя,
// иначе функция незаметно испортит дату у вызывающего кода.
function subtractBusinessDays(startDate, businessDaysToSubtract) {
  const result = new Date(startDate.getTime());
  let remainingBusinessDays = businessDaysToSubtract;

  // Шагаем ровно по одному дню назад.
  // Нельзя вычесть сразу n дней и «поправиться» на выходные:
  // поправка сама может попасть на субботу или воскресенье.
  while (remainingBusinessDays > 0) {
    result.setDate(result.getDate() - 1);

    // getDay(): 0 — воскресенье, 6 — суббота.
    const dayOfWeek = result.getDay();
    const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;

    // Счётчик уменьшаем ТОЛЬКО за будни —
    // так выходные проскакиваются бесплатно.
    if (!isWeekend) {
      remainingBusinessDays--;
    }
  }

  return result;
}
4

PaginationHelper — класс постраничного вывода

🟡 middleРайффайзенбанк17.08.2025📄 листинг
алгоритмыооп
Реализовать класс для постраничного вывода коллекции. Индексация страниц и элементов — с нуля; если запрошенной страницы или элемента не существует, метод возвращает -1.
Пример использования
// Что нужно написать: класс с 4 методами. Индексация с НУЛЯ.
// Нет такой страницы / элемента -> вернуть -1 (не 0, не undefined, не бросать).

const helper = new PaginationHelper(Array.from({ length: 24 }, (_, i) => i), 10);

helper.itemCount();       // -> 24   всего элементов
helper.pageCount();       // -> 3    страниц: 10 + 10 + 4
helper.pageItemCount(0);  // -> 10   полная страница
helper.pageItemCount(2);  // -> 4    последняя, неполная
helper.pageItemCount(3);  // -> -1   такой страницы нет
helper.pageIndex(23);     // -> 2    последний элемент на 3-й странице
helper.pageIndex(0);      // -> 0
helper.pageIndex(24);     // -> -1   элемента нет
helper.pageIndex(-1);     // -> -1

// ГРАНИЧНЫЙ СЛУЧАЙ, на котором валится большинство:
const full = new PaginationHelper(Array.from({ length: 20 }, (_, i) => i), 10);
full.pageCount();         // -> 2
full.pageItemCount(1);    // -> 10, а НЕ 0 (остаток от деления = 0, но страница полная)

// Пустая коллекция:
const empty = new PaginationHelper([], 10);
empty.pageCount();        // -> 0
empty.pageItemCount(0);   // -> -1
Главная ловушка — 20 элементов по 10. Наивное itemCount % itemsPerPage для последней страницы даёт 0, хотя страница полная. Этот кейс в условии не написан, но спрашивают его почти всегда.
✅ Проверено прогоном: все эталоны из условия сошлись. Сверх того: 20 по 10 → pageItemCount(1) = 10, пустая коллекция → pageCount 0, pageItemCount(0) = -1.
✅ Ответ и разбор
Почему так

Задача не про алгоритм, а про аккуратность с границами. Вся суть в трёх формулах: количество страниц — Math.ceil, страница элемента — Math.floor, размер последней страницы — остаток от деления с обработкой нуля. Плюс единый контракт «нет данных → -1».

Разбор по шагам
  1. pageCount = Math.ceil(длина / размерСтраницы) — неполный хвост тоже занимает страницу.
  2. pageIndex(i) = Math.floor(i / размерСтраницы), но сначала проверка, что элемент существует.
  3. pageItemCount(p): если страница не последняя — полный размер; если последняя — остаток, а при остатке 0 — полный размер.
  4. Любой выход за границы — -1, единообразно.
А если… (чем добивают)
  • «20 элементов по 10 — сколько на последней?» → главный добивающий вопрос. Ответ 10, а наивная формула даёт 0.
  • «Пустая коллекция?» → pageCount = 0, любой pageItemCount = -1.
  • «itemsPerPage = 0 или отрицательный?» → деление на ноль даст Infinity/NaN. Стоит проговорить валидацию в конструкторе.
  • «Дробный индекс, pageIndex(1.5)?» → Math.floor проглотит; если важно — проверять Number.isInteger.
Частые ошибки
  • Вернуть 0 вместо 10 для полной последней страницы — ошибка номер один.
  • Забыть проверку границ и вернуть undefined/NaN вместо -1.
  • Пропустить отрицательные индексы: pageIndex(-1) без проверки даст -1 «случайно», а pageIndex(-15) уже -2.
  • Использовать Math.round вместо ceil/floor.
  • Считать pageCount заново в каждом методе, когда коллекция может меняться, — или наоборот, закешировать и разойтись с реальностью.
Как объяснить вслух
«Страниц — потолок от деления, потому что неполный хвост тоже страница. Для размера страницы проверяю, последняя ли она: если нет, это полный размер; если да — остаток от деления, но с оговоркой, что остаток ноль означает полную страницу, а не пустую. Все выходы за границы возвращают минус один, чтобы контракт был единым.»
Решение
class PaginationHelper {
  constructor(collection, itemsPerPage) {
    this.collection = collection;
    this.itemsPerPage = itemsPerPage;
  }

  itemCount() {
    return this.collection.length;
  }

  pageCount() {
    // Именно ceil: 24 элемента по 10 — это 3 страницы,
    // последняя неполная, но она всё равно существует.
    return Math.ceil(this.collection.length / this.itemsPerPage);
  }

  pageItemCount(pageIndex) {
    // Единый контракт: любой выход за границы — -1.
    if (pageIndex < 0 || pageIndex >= this.pageCount()) {
      return -1;
    }

    const isLastPage = pageIndex === this.pageCount() - 1;
    if (!isLastPage) {
      return this.itemsPerPage;   // все страницы кроме последней всегда полные
    }

    // САМОЕ ВАЖНОЕ МЕСТО.
    // Остаток 0 означает, что последняя страница ПОЛНАЯ (20 по 10 -> 10),
    // а не пустая. Без этой проверки метод вернёт 0 — классический баг.
    const remainder = this.itemCount() % this.itemsPerPage;
    return remainder === 0 ? this.itemsPerPage : remainder;
  }

  pageIndex(itemIndex) {
    // Проверяем существование элемента ДО вычислений:
    // иначе floor молча вернёт правдоподобное число для несуществующего индекса.
    if (itemIndex < 0 || itemIndex >= this.itemCount()) {
      return -1;
    }

    return Math.floor(itemIndex / this.itemsPerPage);
  }
}
5

fetchDataWithRetry — запрос с повторами

🟡 middleСбер14.05.2025📄 листинг
промисыasyncfetch
Написать функцию, которая асинхронно ходит GET-запросом по url и возвращает JSON. Если произошла ошибка — повторить до 5 раз. Если так и не удалось — вернуть ошибку «Заданный url недоступен».
Пример использования
// Что нужно написать: fetchDataWithRetry(url, maxRetries = 5)
// -> Promise с распарсенным JSON.
// Ошибка (сеть ИЛИ статус не 2xx) -> повторить, всего до 5 попыток.
// Все 5 неудачны -> бросить Error('Заданный url недоступен').

const data = await fetchDataWithRetry('https://api.example.com/data');
// успех с первого раза      -> {...}, всего запросов: 1
// два раза 500, потом 200   -> {...}, всего запросов: 3
// 404, потом 200            -> {...}, всего запросов: 2  (404 — ТОЖЕ повод для retry)
// сеть упала, потом 200     -> {...}, всего запросов: 2

try {
  await fetchDataWithRetry('https://api.example.com/down');
} catch (error) {
  console.log(error.message);  // -> 'Заданный url недоступен'
  // всего запросов: ровно 5, не 6
}
Ключевой момент, который валит решение целиком: fetch не реджектится на 404 и 500 — он спокойно резолвится с ok: false. Без ручной проверки response.ok retry не сработает ни разу. Счётчики запросов сняты прогоном с замоканным fetch.
✅ Проверено прогоном (fetch замокан, считались вызовы): 2 падения → 3 запроса и успех · вечное падение → ровно 5 запросов и «Заданный url недоступен» · 404 → retry сработал.
✅ Ответ и разбор
Почему так

Главное знание задачи: fetch реджектится только при сетевой ошибке — нет соединения, DNS, CORS. HTTP-статусы 404 и 500 для него — нормально завершённый запрос, промис резолвится с ok: false. Поэтому try/catch вокруг fetch без проверки response.ok не поймает серверные ошибки, и retry будет мёртвым кодом.

Разбор по шагам
  1. Цикл попыток от 1 до maxRetries.
  2. await fetch(url) — ловим сетевые падения через catch.
  3. Проверяем response.ok вручную и бросаем ошибку сами, если статус не 2xx.
  4. Успех — сразу возвращаем await response.json() и выходим из цикла.
  5. Если это была последняя попытка — бросаем ошибку с нужным текстом.
А если… (чем добивают)
  • «Добавь задержку между попытками» → экспоненциальный backoff: await new Promise(r => setTimeout(r, 2 ** attempt * 100)). Ждут именно этого слова.
  • «А 404 стоит повторять?» → по-хорошему нет: это не временная ошибка, повтор бесполезен. Ретраить 5xx и сетевые, а 4xx отдавать сразу. Скажешь это — большой плюс.
  • «Как отменить запрос?» → AbortController + таймаут на каждую попытку.
  • «А если тело не JSON?» → response.json() бросит, и это уйдёт в тот же catch — то есть будет ретрай на битом ответе. Стоит проговорить, нужно ли так.
Частые ошибки
  • Не проверить response.ok — фатально: на 500 функция вернёт мусор вместо повтора.
  • Сделать 6 запросов вместо 5 (ошибка на границе цикла).
  • Рекурсия без базового случая или с потерей счётчика.
  • Проглотить исходную ошибку и не отдать ничего — вызывающий код не поймёт, что случилось.
  • Ретраить мгновенно, без паузы — на упавшем сервере это DDoS самому себе.
Как объяснить вслух
«Оборачиваю fetch в цикл попыток. Важная тонкость: fetch не реджектится на 404 и 500 — промис успешно резолвится с ok: false. Поэтому я явно проверяю response.ok и бросаю ошибку сам, иначе повторы просто не сработают. После последней неудачной попытки бросаю ошибку с текстом из условия. На проде добавил бы экспоненциальную задержку и не ретраил 4xx.»
Решение
async function fetchDataWithRetry(url, maxRetries = 5) {
  // Цикл, а не рекурсия: проще контролировать точное число попыток.
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      const response = await fetch(url);

      // КЛЮЧЕВАЯ СТРОКА.
      // fetch реджектится ТОЛЬКО на сетевых ошибках.
      // На 404 и 500 он спокойно резолвится с ok: false,
      // поэтому статус проверяем руками и бросаем ошибку сами —
      // иначе повторы никогда не запустятся.
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`);
      }

      // Успех — сразу выходим, оставшиеся попытки не тратим.
      return await response.json();
    } catch (error) {
      // Ошибка на ПОСЛЕДНЕЙ попытке — дальше пробовать нечего.
      if (attempt === maxRetries) {
        throw new Error('Заданный url недоступен');
      }

      // На проде здесь была бы пауза с экспоненциальным ростом,
      // чтобы не добивать уже упавший сервер:
      // await new Promise(resolve => setTimeout(resolve, 2 ** attempt * 100));
    }
  }
}
Порядок на созвоне — как пронумеровано: 1 и 2 разогрев, 3 и 4 основная часть, 5 финал с разговором про надёжность.
1. Ключ объекта — всегда строка
2. SameValueZero внутри Set ≠ ===
3. Шаг по календарю и мутабельность Date
4. Границы, деление с остатком, контракт -1
5. fetch не реджектится на 404/500