Sliding Window Maximum

Дан массив чисел nums и ширина окна k, т.е. в каждый момент времени захватываются ровно k чисел. Окно движется слева направо с шагом в одно число. Найти максимумы в скользящем окне.

Решение #

Решение «в лоб» — перебирать все числа в окне, находя максимум на каждом шаге снова.

/**
 * @param {number[]} nums
 * @param {number} k
 * @return {number[]}
 */
var maxSlidingWindow = function(nums, k) {
  const result = [];

  for (let i = k; i <= nums.length; i++) {
    // «вырезаем» окно и ищем максимум на каждом шаге
    result.push(Math.max(...nums.slice(i - k, i)));
  }
  return result;
};

Сложность — O(n * k). Если сдать это решение, то получим TLE.

Не зря эта задачка помечена как «hard» – брутфорс уже не прокатит.

Как всегда, на этом моменте нужно задать себе вопрос: «где совершается лишняя работа?». В примере выше ширина окна k равна 50000. С каждым шагом в окне меняется всего не более 2 чисел из 50000, а перечитываем мы все каждый раз! Давайте подумаем как можно быстее.

Окно даже внешне похоже на очередь — двусвязный список, в котором мы имеем возможность за O(1) писать и читать с обеих сторон: как с головы, так и с хвоста.

По-моему, есть два ключевых наблюдения, которые помогают прийти к решению с очередью:

Первое почти очевидно, а вот до второго нужно догадаться — понятнее становится если нарисовать.

В начале очереди всегда должен быть максимум в окне. Если нам приходят числа, которые меньше текущего максимума — мы спокойно кладём их в очередь, потому что они могут стать максимумами в будущем. Если нам приходят числа, которые больше текущего максимума — мы удаляем всё что меньше, пока очередь не опустеет и новый максимум не займёт своё место в начале.

Каждое число мы трогаем не более 2 раз (положить в очередь, удалить из очереди), поэтому итоговая сложность O(n). То есть вообще не важен размер окна!

/**
 * @param {number[]} nums
 * @param {number} k
 * @return {number[]}
 */
var maxSlidingWindow = function(nums, k) {
  const q = [];
  const result = [];

  for (let i = 0; i < nums.length; i++) {
    // удаляем невалидные индекс слева каждый раз,
    // когда окно двигается вправо
    if (q.length > 0 && q[0] < i - k + 1) {
      q.shift();
    }
    // удаляем невалидные индекс справа —
    // все, которые точно уже не станут максимумами,
    // по мере продвижения окна направо
    while (q.length > 0 && nums[q[q.length - 1]] < nums[i]) {
      q.pop();
    }
    // добавляем новый индекс в конец очереди
    q.push(i);
    // голова всегда показывает максимум
    if (i >= k - 1) {
      result.push(nums[q[0]]);
    }
  }
  return result;
};

В JavaScript нет очереди или связного списка в стандартной библиотеке, поэтому используем обычные массивы. Можно слегка ускориться если всё же не использовать массив, а написать свой, очень простой, связный список.

Если нам не нужно ничего добавлять в начало, а только снимать, то есть очень простая реализация (правда, не оптимальная с точки зрения потребления памяти), которую я приведу ниже.

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

Сперва обновим код самого решения.

/**
 * @param {number[]} nums
 * @param {number} k
 * @return {number[]}
 */
var maxSlidingWindow = function(nums, k) {
- const q = [];
+ const q = new LinkedList();
  const result = [];

  for (let i = 0; i < nums.length; i++) {
    // удаляем невалидные индекс слева каждый раз
    // когда окно двигается вправо
-   if (q.length > 0 && q[0] < i - k + 1) {
+   if (q.length > 0 && q.front() < i - k + 1) {
      q.shift();
    }
    // удаляем невалидные индекс справа,
    // все которые точно уже не станут максимумами
    // по мере продвижения окна направо
-   while (q.length > 0 && nums[q[q.length - 1]] < nums[i]) {
+   while (q.length > 0 && nums[q.back()] < nums[i]) {
      q.pop();
    }
    // добавляем новый индекс в конец очереди
    q.push(i);
    // голова всегда показывает максимум
    if (i >= k - 1) {
-     result.push(nums[q[0]]);
+     result.push(nums[q.front());
    }
  }
  return result;
};

Как видно, интерфейс почти не меняется: вместо обращения по индексами появляются методы для чтения с головы и хвоста, остальное всё так же как у массива — дифф получается аккуратный. Наконец, сама реализация.

class LinkedList {
  constructor() {
    this.q = [];
    this.head = 0;
  }
  push(v) {
    this.q.push(v);
  }
  pop() {
    return this.q.pop();
  }
  shift() {
    this.q[this.head++];
  }
  front() {
    return this.q[this.head];
  }
  back() {
    return this.q[this.q.length - 1];
  }
  get length() {
    return this.q.length - this.head;
  }
}

Вся соль в том, что мы никогда не удаляем элементы с начала, а просто двигаем указатель на голову дальше.

PS. Обсудить можно в телеграм-чате любознательных программистов. Welcome! 🤗

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