Непересекающиеся окна

О чем урок

Основная цель урока — познакомить тебя с техникой «непересекающиеся окна», которая позволяет решать множество задач одним и тем же способом. Это поможет тебе не ошибаться на собеседованиях и писать решения с первого раза! А также ты:

  • Решишь задачу с собеседования в крупную технологическую компанию (BigTech).
  • Научишься решать задачи на тему «непересекающиеся окна» с первого раза.
  • Узнаешь, как мыслит интервьюер на собеседовании.
Пример задачи с собеседования

Представь, что ты на собеседовании в компании своей мечты. Ты заходишь на видеозвонок и знакомишься с интервьюером. Он сообщает, что тебе нужно решить две алгоритмические задачи за 60 минут, и озвучивает условие первой задачи…

Условие

Дан отсортированный по возрастанию массив уникальных чисел nums. Нужно объединить подряд идущие числа по следующим правилам:

  • "x->y", если есть непрерывная последовательность, увеличивающаяся на 1, от x до y
  • "x", если соседние элементы отличаются более чем на 1
Пример
Ввод: nums = [1,2,3,4,5,8,10,15,16,20]
Вывод: ["1->5","8","10","15->16","20"]
В чём сложность задачи

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

using System;
using System.Collections.Generic;

public class Solution
{
    public static List<string> SummaryRanges(List<int> nums)
    {
        List<string> result = new List<string>();
        int start = 0;

        while (start < nums.Count)
        {
            int end = start;

            // Найти диапазон последовательных чисел
            while (end + 1 < nums.Count && nums[end] + 1 == nums[end + 1])
            {
                end++;
            }

            // Добавить результат в список
            if (nums[start] == nums[end])
            {
                result.Add(nums[start].ToString());
            }
            else
            {
                result.Add($"{nums[start]}->{nums[end]}");
            }

            // Переместить старт на следующий элемент после текущего диапазона
            start = end + 1;
        }

        return result;
    }
}

На собеседовании оценивается не только правильность решения, но и чистота кода, а также умение объяснить своё решение. Приятно, когда твои решения выглядят аккуратно и понятно.

Как решать задачу

Заметим, что последовательности чисел можно представить как «окна», и когда одно окно заканчивается, начинается другое. Это ключевое свойство мы и будем использовать.

Алгоритм решения:

  1. Заводим два указателя
  2. Двигаем правый указатель
  3. Когда движение правого указателя невозможно, добавляем в результат:
    1. "{nums[l]}->{nums[r]}", если l != r
    2. "{nums[l]}", если l == r
  4. Сдвигаем указатели:
  5. Повторяем шаги 2–4, пока l не выйдет за границы массива
using System;
using System.Collections.Generic;

public class Solution
{
    public static List<string> SummaryRanges(List<int> nums)
    {
        int l = 0;
        int r = 0;
        List<string> result = new List<string>();

        while (l < nums.Count)
        {
            while (r + 1 < nums.Count && nums[r] + 1 == nums[r + 1])
            {
                r++;
            }

            if (l != r)
            {
                result.Add($"{nums[l]}->{nums[r]}");
            }
            else
            {
                result.Add($"{nums[l]}");
            }

            l = r + 1;
            r = r + 1;
        }
        return result;
    }
}
Паттерн «Непересекающиеся окна»

Мы использовали паттерн, который применим к целому классу задач. Теперь вместо запоминания решений для каждой задачи, ты можешь использовать этот универсальный подход. А вот и общий шаблон с использованием этого паттерна:

using System;
using System.Collections.Generic;

public class Solution
{
/**
 * @param nums - вектор целых чисел
 * @return ReturnType - замените ReturnType на фактический тип возвращаемого значения
 */
    public static List<ReturnType> SomeFunction(int[] nums)
    {
        int l = 0;
        int r = 0;
        // Замените ReturnType на фактический тип элементов
        List<ReturnType> result = new List<int>();

        while (l < nums.Length)
        {
            while (r + 1 < nums.Length && условие_продолжения_окна)
            {
                r += 1;
            }

            // Обработка текущего окна и обновление результата
            // ...

            // Сдвиг указателей для следующего окна
            l = r + 1;
            r = r + 1;
        }

        return result;
    }
}

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

В некоторых задачах шаблон может казаться избыточным и не во всех задачах мы получим самое красивое решение, но тут цель в другом: получить решение, которое работает с первого раза и не запоминать каждый раз новую задачу, а иметь универсальный ключ!

Всегда ли решение паттерном оптимальное?

Рассмотрим задачу: дан массив nums из нулей и единиц, нужно найти длину самой длинной последовательности из подряд идущих единиц. Например, для nums = [1,1,0,0,1,1,1,0] ответ — 3.

Можно использовать тот же паттерн «непересекающихся окон», но иногда есть более простые решения. Например:

using System;
using System.Collections.Generic;

public class Solution
{
    public static int LongestConsecutiveOnes(int[] nums)
    {
        int maxCount = 0; // Максимальное количество подряд идущих единиц
        int count = 0; // Текущее количество подряд идущих единиц

        foreach (int num in nums)
        {
            if (num == 1)
            {
                count++;
                // Обновляем максимальное значение при необходимости
                maxCount = Math.Max(maxCount, count);
            }
            else
            {
                // Сбрасываем счетчик, если встречается 0
                count = 0;
            }
        }

        return maxCount;
    }
}

Решение выше выглядит более простым, чем то, которое мы получим, следуя паттерну:

using System;
using System.Collections.Generic;

public class Solution
{
    public static int LongestConsecutiveOnes(List<int> nums)
    {
        int l = 0;
        int r = 0;
        int result = 0;

        while (l < nums.Count)
        {
            while (r + 1 < nums.Count && nums[r] == nums[r + 1])
            {
                r += 1;
            }

            if (nums[r] == 1)
            {
                result = Math.Max(result, r - l + 1);
            }

            l = r + 1;
            r = r + 1;
        }

        return result;
    }
}

В паттерне так же есть сложность додуматься до идеи: не нужно пропускать как-то специально нули, а можно тоже объединить их в окно. Главное, при обновлении ответа учесть, что нужно обновлять окна только из подряд идущих единиц, а нули игнорировать

Важно отметить, что оба решения оптимальны и оба интервьюер примет на собеседовании! Не стоит путать красоту кода и оптимальность решения. Если код красивый, но не оптимальный, то к следующей задачке тебя не допустят, а вот если наоборот, то все ОК, ведь главное – рабочее решение, а красота уже потом.

Итог

Моя цель — дать тебе инструмент для решения множества задач, а не только одной. Хотя важно рассматривать разные варианты решений, универсальные паттерны помогут тебе быстрее находить ответы. Тренируйся применять их, но всегда выбирай решение, которое считаешь лучшим для себя!

Бонус: мир глазами интервьюера

Знаешь ли ты, что интервьюеры часто имеют перед собой эталонное решение? Особенно если они дают эту задачу впервые.

Чтобы интервьюер был уверен в твоём решении и не тратил время на его проверку, важно чётко объяснять свои мысли. Например:

"Я использую технику плавающего окна. Завожу два указателя l и r, изначально указывающие на первый элемент. Пока следующий элемент последовательный (nums[r] + 1 == nums[r + 1]), сдвигаю r. Затем добавляю диапазон в результат. Сдвигаю l и r на r + 1 и повторяю, пока l не выйдет за границы массива. Моё решение имеет временную сложность O(n), так как каждый элемент обрабатывается один раз. Дополнительная память — O(1) для хранения результата."

Объяснять ход решения задачи и оценивать решение по времени и памяти является хорошей практикой перед написанием кода, поэтому важно уметь кратко формулировать идею решения задачи!

Что дальше?

А теперь, время закрепить все полученные знания на задачах!