Esc
Горячие клавиши
?Show this help ⌘KSearch tToggle dark/light theme nOpen notes j / kScroll down / up bBack to top /Focus search EscClose panels
Все курсы
Coding Interview Patterns
ENRUUZ
Заметки
Current chapter
0 chars
Highlight color
Глава 1

Два указателя

~27 мин чтения

Введение в метод двух указателей

Введение в метод двух указателей

Интуиция

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

The image represents a visual depiction of data insertion or modification within a data structure, likely an array or list.  A small, orange square containing the letter 'i' acts as an indicator or pointer, positioned above a sequence of numbers enclosed in square brackets: `[ ... 14 5 5 20 ... ]`.  A downward-pointing orange arrow originates from the 'i' indicator and points directly to the number '5', suggesting that the 'i' represents a position or index within the data structure, and the arrow indicates the insertion or modification is occurring at that specific index. The ellipses (...) indicate that there are additional elements before and after the visible portion of the sequence.  The overall diagram illustrates a simple data manipulation operation, focusing on the insertion or update of a value at a particular index within a larger dataset.

Введение второго указателя открывает новые возможности. Самое главное — теперь мы можем выполнять сравнения. Имея указатели в двух разных позициях, мы можем сравнивать элементы в этих позициях и принимать решения на основе результатов сравнения:

Image represents a visual depiction of a comparison step within a sorting algorithm, likely a comparison-based sort like bubble sort or insertion sort.  The diagram shows a numerical array `nums` represented as `[... 14 5 5 20 ...]`, indicating a partial view of a larger array.  Two index variables, `i` (in an orange box) and `j` (in a light-blue box), point to elements 14 and 5 respectively within the array.  Arrows descend from `i` and `j` to highlight these selected elements.  To the right, a dashed-line box describes a function call `compare(nums[i], nums[j])`, which implies a comparison operation between the values at indices `i` and `j` (14 and 5 in this instance).  A right-pointing arrow from this box leads to the text 'make decision,' indicating that the result of the comparison (whether `nums[i]` > `nums[j]`, `nums[i]` < `nums[j]`, or `nums[i]` == `nums[j]`) will determine the next step in the sorting algorithm.

Во многих случаях такие сравнения выполняются с помощью двух вложенных циклов for, что занимает O(n^2) времени, где n — длина структуры данных. В фрагменте кода ниже i и j — два указателя, используемых для сравнения каждых двух элементов массива:

python
for i in range(n):
    for j in range(i + 1, n):
        compare(nums[i], nums[j])

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

Image represents a diagram illustrating a coding pattern or prediction within a sorted array.  At the top, a small orange square containing the letter 'i' points downwards, indicating an index or iterator.  Below, a sorted integer array `[1 2 3 4]` is shown, with a grey line underneath labeled 'sorted array'.  A dashed-line box at the bottom contains a prediction statement written in a pseudo-code style: 'prediction: if nums[i] == 2, then nums[i + 1] ≥ 2'. This statement suggests that if the element at index 'i' in the `nums` array is equal to 2, then the element at the next index (i+1) must be greater than or equal to 2. The arrow from 'i' implies that 'i' is used to index into the array, likely as part of an algorithm or conditional check.

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

Стратегии двух указателей

Алгоритмы с двумя указателями обычно занимают O(n) времени, устраняя необходимость в вложенных циклах. Существует три основные стратегии использования двух указателей.

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

Image represents a visual depiction of a two-pointer approach in a coding pattern, likely for array traversal or similar operations.  The diagram shows a sequence of eight black dots enclosed within square brackets, representing elements in a data structure.  Above the dots, two rectangular boxes are labeled 'left' (in orange) and 'right' (in light blue).  Dashed orange lines connect the 'left' box to the first four dots, indicating a pointer traversing from left to right across this section. Similarly, dashed light blue lines connect the 'right' box to the last four dots, showing a pointer moving from right to left.  Both pointers converge on the central dot, suggesting a meeting point in the algorithm. The arrows from the 'left' and 'right' boxes point downwards, indicating the direction of pointer movement towards the center. The overall structure illustrates a common algorithm strategy where two pointers start from opposite ends of a data structure and move towards each other until they meet or a specific condition is met.

Указатели движутся к центру, корректируя свои позиции на основе сравнений, пока не выполнится определённое условие или они не встретятся/пересекутся. Это идеально для задач, где нужно сравнивать элементы с разных концов структуры данных.

Однонаправленный обход При этом подходе оба указателя начинают с одного конца структуры данных (обычно с начала) и движутся в одном направлении:

The image represents a visual depiction of a two-pointer approach to traversing a data structure, likely an array or list.  The structure is shown as a sequence of black dots enclosed within square brackets `[ ]`, representing individual elements.  An orange rectangle labeled 'left' points a downward arrow to the leftmost element within a subset of the dots.  Dashed orange lines with arrowheads connect this element to the next few elements to its right, indicating a traversal from left to right.  Similarly, a light-blue rectangle labeled 'right' points a downward arrow to the rightmost element within a different subset of the dots.  Dashed light-blue lines with arrowheads connect this element to the preceding few elements to its left, indicating a traversal from right to left.  The two pointers, indicated by the orange and light-blue arrows, are moving towards the center of the data structure, suggesting a merging or comparison process commonly used in algorithms like merge sort or two-pointer techniques for finding pairs with a specific sum.

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

Поэтапный обход При этом подходе мы обходим структуру одним указателем, и когда он попадает на элемент, соответствующий определённому условию, начинаем обход вторым указателем:

Image represents a diagram illustrating a coding pattern, likely related to data processing or algorithm execution.  The diagram shows two stages separated by a large right arrow. The first stage depicts a sequence of black dots enclosed in square brackets, representing data elements.  An orange rectangular box labeled 'first' points downwards with an arrow to the second element in the sequence, indicating a process or function ('first') acting upon this specific element.  The connection is shown with a dashed orange line forming a curved arc above the data sequence. The second stage, also enclosed in square brackets, shows the same data sequence but with a different transformation. A light-blue rectangular box labeled 'second' points downwards with an arrow to the second element in this new sequence, indicating a different process or function ('second') acting upon the same element. The connection is shown with a dashed light-blue line forming a curved arc above the data sequence. A third, identical orange box labeled 'first' is shown above the second stage, suggesting the 'first' process is applied again, possibly iteratively or in parallel with the 'second' process.  The overall transformation shows how the initial data sequence is modified by the sequential or parallel application of the 'first' and 'second' processes.

Аналогично однонаправленному обходу, оба указателя служат разным целям. Здесь первый указатель используется для поиска чего-либо, а после нахождения второй указатель находит дополнительную информацию, связанную со значением у первого указателя.

Все эти техники подробно рассматриваются в задачах данной главы.

Когда использовать два указателя?

Алгоритм с двумя указателями обычно требует линейной структуры данных, например массива или связного списка. Признак того, что задача решается с помощью этого алгоритма, — когда входные данные подчиняются предсказуемой логике, например представляют собой отсортированный массив.

Предсказуемые свойства могут проявляться по-разному. Возьмём, например, палиндромную строку. Её симметричная структура позволяет логично перемещать два указателя к центру. По мере решения задач в этой главе вы научитесь лучше распознавать такие предсказуемые свойства.

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

Пример из реальной жизни

Алгоритмы сборки мусора: при уплотнении памяти — ключевой части сборки мусора — цель состоит в освобождении непрерывного пространства памяти путём устранения пробелов, оставленных освобождёнными (мёртвыми) объектами. Техника двух указателей помогает добиться этого эффективно: указатель «scan» обходит кучу для обнаружения живых объектов, а указатель «free» отслеживает следующее доступное место, куда следует переместить живые объекты. По мере движения указателя «scan» он пропускает мёртвые объекты и перемещает живые на позицию, указанную указателем «free», уплотняя память путём группировки всех живых объектов и освобождения непрерывных блоков памяти.

Обзор главы

Image represents a hierarchical diagram illustrating different coding patterns categorized under the umbrella term 'Two Pointers'.  A rounded rectangle at the top labeled 'Two Pointers' acts as the root node, branching down via dashed lines to three subordinate rectangular boxes representing distinct traversal types.  The leftmost box, 'Inward Traversal,' lists four sub-problems: 'Pair Sum - Sorted,' 'Triplet Sum,' 'Largest Container,' and 'Is Palindrome Valid.' The rightmost box, 'Unidirectional Traversal,' contains a single sub-problem: 'Shift Zeros to the End.'  Finally, the bottom box, 'Staged Traversal,' lists one sub-problem: 'Next Lexicographical Sequence.'  The dashed lines indicate a hierarchical relationship, showing how each traversal type falls under the broader 'Two Pointers' category, and the listed items are specific problems solvable using that traversal technique.

Метод двух указателей очень универсален и, следовательно, достаточно широк. Поэтому более специализированные варианты этого алгоритма рассматриваются в отдельных главах, например быстрый и медленный указатели (Fast and Slow Pointers) и скользящее окно (Sliding Windows).

Сумма пары — отсортированный массив

Сумма пары — отсортированный массив

Дан массив целых чисел, отсортированных в порядке возрастания, и целевое значение. Верните индексы любой пары чисел в массиве, сумма которых равна целевому значению. Порядок индексов в результате не важен. Если пара не найдена, верните пустой массив.

Пример 1:

python
Input: nums = [-5, -2, 3, 4, 6], target = 7
Output: [2, 3]

Пояснение: nums[2] + nums[3] = 3 + 4 = 7

Пример 2:

python
Input: nums = [1, 1, 1], target = 2
Output: [0, 1]

Пояснение: другими допустимыми ответами могут быть [1, 0], [0, 2], [2, 0], [1, 2] или [2, 1].

Интуиция

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

python
from typing import List
  
def pair_sum_sorted_brute_force(nums: List[int], target: int) -> List[int]:
    n = len(nums)
    for i in range(n):
        for j in range(i + 1, n):
            if nums[i] + nums[j] == target:
                return [i, j]
    return []

Этот подход имеет временную сложность O(n^2), где n — длина массива. Данный подход не учитывает, что входной массив отсортирован. Можно ли использовать этот факт для разработки более эффективного решения?

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

Image represents a sample input for a coding problem likely related to finding pairs of numbers within an array that sum to a target value.  The image shows an integer array `[-5, -2, 3, 4, 6]` enclosed in square brackets, with the index of each element subtly displayed below it (0 through 4).  A comma separates the array from the statement 'target = 7,' which specifies the target sum to be found.  The arrangement implies that the algorithm should search within the provided array for two numbers that add up to 7.  The indices are not explicitly part of the input data but are visually provided to aid understanding of the array's structure.

Хорошей отправной точкой является рассмотрение наименьшего и наибольшего значений: первого и последнего элементов соответственно. Сумма этих двух значений равна 1.

Image represents a diagram illustrating a simple summation operation on an array.  Two orange rectangular boxes labeled 'left' and 'right' point downwards with arrows towards a numerical array [-5, -2, 3, 4, 6].  The 'left' arrow points to the first two elements of the array, [-5, -2], while the 'right' arrow points to the last three elements, [3, 4, 6].  The array elements are indexed below with numbers 0 through 4, indicating their positions.  A separate, light-grey, dashed-line rectangular box displays the result of the summation, showing 'sum = 1'. This implies that the sum of all elements in the array (-5 + -2 + 3 + 4 + 6) equals 1.

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

  • Левый указатель: левый указатель всегда будет указывать на значение, меньшее или равное значению у правого указателя, поскольку массив отсортирован. Его увеличение приведёт к сумме, большей или равной текущей сумме 1.
  • Правый указатель: уменьшение правого указателя приведёт к сумме, меньшей или равной 1.

Следовательно, нам следует увеличить левый указатель, чтобы найти большую сумму:

Image represents a visual depiction of a two-pointer algorithm, likely used to find a pair of numbers in a sorted array that sum to a target value (not explicitly shown but implied).  The top section shows an initial state with two pointers, labeled 'left' and 'right,' pointing to the beginning and end, respectively, of a sorted array [-5, -2, 3, 4, 6].  The indices (0, 1, 2, 3, 4) are shown below the array elements.  A dashed box shows the current sum (1, calculated as -5 + 6) being compared to a target value (7, also implied).  Since the sum (1) is less than 7, a solid arrow indicates that the 'left' pointer is incremented (left += 1). The bottom section illustrates the updated state after the pointer increment, where the 'left' pointer now points to -2.  The 'right' pointer remains unchanged. The dotted arrow emphasizes the movement of the 'left' pointer.  The overall diagram demonstrates a single iteration of the algorithm, highlighting the pointer movement based on the comparison of the current sum with the target value.

Снова сумма значений у этих двух указателей (4) слишком мала. Увеличим левый указатель:

Image represents a visual depiction of a coding pattern, likely illustrating a two-pointer approach or a similar algorithm operating on an array.  The top section shows an array `[-5, -2, 3, 4, 6]` with pointers labeled 'left' and 'right' initially pointing to the first and last elements respectively.  The indices of the array elements are shown below the array. A dashed box displays a condition: `sum = 4 < 7`, indicating that the sum of elements pointed to by 'left' (3) and 'right' (6) is 4, which is less than 7.  A thick arrow connects this condition to another dashed box showing the consequence: `left += 1`, signifying that the 'left' pointer is incremented. The bottom section shows the state after this operation: the 'left' pointer has moved one position to the right, now pointing to the element 3, while the 'right' pointer remains unchanged. The 'left' pointer in the bottom section is highlighted in orange to emphasize its movement.  The overall diagram illustrates a step-by-step execution of a code segment that iterates through an array, potentially searching for a specific condition or performing a calculation based on the values pointed to by the 'left' and 'right' pointers.

Теперь сумма (9) слишком велика. Нам следует уменьшить правый указатель, чтобы найти пару значений с меньшей суммой:

Image represents a visual depiction of a coding pattern, possibly illustrating a segment of an algorithm.  The top section shows an array `[-5, -2, 3, 4, 6]` with indices 0 through 4 labeled below.  Above the array, two rectangular boxes labeled 'left' and 'right' point downwards, indicating pointers or indices.  The 'left' pointer points to the element '3' (index 2), and the 'right' pointer points to the element '6' (index 4).  A dashed box to the right shows a calculation: 'sum = 9 > 7', implying the sum of the elements pointed to by 'left' and 'right' (3 + 6 = 9) is greater than 7.  An arrow then points to another box indicating 'right -= 1', suggesting the 'right' pointer's index is decremented. The bottom section shows the updated state after this operation. The array remains the same, but the 'right' pointer (now orange) points to the element '4' (index 3), illustrating the effect of the decrement operation.  A dotted line shows the movement of the 'right' pointer from index 4 to index 3. The 'left' pointer remains unchanged.

Наконец, мы нашли два числа, дающих сумму, равную целевому значению. Вернём их индексы:

Image represents a diagram illustrating a coding pattern, possibly related to array processing or a divide-and-conquer algorithm.  At the top, two orange rectangular boxes labeled 'left' and 'right' are shown.  Arrows descend from these boxes, pointing to the elements '3' and '4' respectively, within a horizontal array represented as `[-5, -2, 3, 4, 6]`.  The array indices (0, 1, 2, 3, 4) are displayed beneath the corresponding array elements. A light gray, dashed-bordered rectangle contains the expression 'sum = 7 == 7', indicating a comparison where the sum of some values (implied to be '3' and '4') equals 7.  A thick arrow points from this comparison to the text 'return [left, right]', suggesting that if the comparison is true, the function returns an array containing the values 'left' and 'right'.  The overall diagram visually depicts the process of selecting elements from an array based on some condition (here, implicitly the sum of selected elements), and returning a result based on those selections.

Выше мы продемонстрировали алгоритм с двумя указателями, используя встречный обход. Подведём итог этой логики. Для любой пары значений у left и right:

  • Если их сумма меньше целевого значения, увеличиваем left, стремясь приблизить сумму к целевому значению.
  • Если их сумма больше целевого значения, уменьшаем right, стремясь приблизить сумму к целевому значению.
  • Если их сумма равна целевому значению, возвращаем [left, right].

Мы можем прекратить перемещение указателей left и right, когда они встретятся, поскольку это указывает на отсутствие пары с суммой, равной целевому значению.

Реализация

python
from typing import List
       
def pair_sum_sorted(nums: List[int], target: int) -> List[int]:
    left, right = 0, len(nums) - 1
    while left < right:
        sum = nums[left] + nums[right]
        # If the sum is smaller, increment the left pointer, aiming to increase the
        # sum towards the target value.
        if sum < target:
            left += 1
        # If the sum is larger, decrement the right pointer, aiming to decrease the
        # sum towards the target value.
        elif sum > target:
            right -= 1
        # If the target pair is found, return its indexes.
        else:
            return [left, right]
    return []

Анализ сложности

Временная сложность: временная сложность pair_sum_sorted равна O(n), поскольку в худшем случае мы выполняем примерно n итераций с использованием техники двух указателей.

Пространственная сложность: мы выделяем лишь константное количество переменных, поэтому пространственная сложность равна O(1).

Тестовые случаи

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

InputExpected outputDescription
nums = [] target = 0[]Tests an empty array.
nums = [1] target = 1[]Tests an array with just one element.
nums = [2, 3] target = 5[0, 1]Tests a two-element array that contains a pair that sums to the target.
nums = [2, 4] target = 5[]Tests a two-element array that doesn’t contain a pair that sums to the target.
nums = [2, 2, 3] target = 5[0, 2] or [1, 2]Testing an array with duplicate values.
nums = [-1, 2, 3] target = 2[0, 2]Tests if the algorithm works with a negative number in the target pair.
nums = [-3, -2, -1] target = -5[0, 1]Tests if the algorithm works with both numbers of the target pair being negative.

Совет для собеседования

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

Сумма тройки

Сумма тройки

Дан массив целых чисел. Верните все тройки [a, b, c] такие, что a + b + c = 0. Решение не должно содержать повторяющихся троек (например, [1, 2, 3] и [2, 3, 1] считаются дубликатами). Если таких троек не найдено, верните пустой массив.

Каждая тройка может быть упорядочена произвольно, и результат может быть возвращён в произвольном порядке.

Пример:

python
Input: nums = [0, -1, 2, -3, 1]
Output: [[-3, 1, 2], [-1, 0, 1]]

Интуиция

Метод грубой силы предполагает проверку каждой возможной тройки в массиве на предмет суммирования до нуля. Это можно сделать с помощью трёх вложенных циклов, перебирая каждую комбинацию из трёх элементов.

Дублирования троек можно избежать путём сортировки каждой тройки, что обеспечивает единообразный порядок для идентичных троек с разным представлением (например, [1, 3, 2] и [3, 2, 1] → [1, 2, 3]). После сортировки тройки можно добавить в хеш-множество. Тогда при повторном обнаружении той же тройки хеш-множество сохранит лишь один экземпляр. Ниже представлен код для этого подхода:

python
from typing import List
   
def triplet_sum_brute_force(nums: List[int]) -> List[List[int]]:
    n = len(nums)
    # Use a hash set to ensure we don't add duplicate triplets.
    triplets = set()
    # Iterate through the indexes of all triplets.
    for i in range(n):
        for j in range(i + 1, n):
            for k in range(j + 1, n):
                if nums[i] + nums[j] + nums[k] == 0:
                    # Sort the triplet before including it in the hash set.
                    triplet = tuple(sorted([nums[i], nums[j], nums[k]]))
                    triplets.add(triplet)
    return [list(triplet) for triplet in triplets]

Это решение довольно неэффективно: его временная сложность равна O(n^3), где n — длина входного массива. Как можно сделать лучше?

Посмотрим, можем ли мы найти хотя бы одну тройку, дающую в сумме 0. Заметим, что если зафиксировать одно из чисел тройки, задача сводится к нахождению двух других. Это приводит к следующему наблюдению:

Для любой тройки [a, b, c], если зафиксировать 'a', можно сосредоточиться на поиске пары [b, c] с суммой '-a' (a + b + c = 0 → b + c = -a).

Звучит знакомо? Потому что задача поиска пары чисел с заданной суммой уже рассмотрена в задаче «Сумма пары — отсортированный массив». Однако этот алгоритм применим только к отсортированному массиву. Поэтому первым делом следует отсортировать входные данные. Рассмотрим следующий пример:

Image represents a visual depiction of a sorting operation.  The image shows an unsorted array `[-1, 2, -2, 1, -1, 2]` enclosed in square brackets on the left.  A rightward-pointing arrow labeled 'sort' connects this initial array to a second array `[-2, -1, -1, 1, 2, 2]` also enclosed in square brackets on the right. The arrow indicates the transformation of the input array (on the left) into the sorted output array (on the right) using a sorting algorithm (implied by the label 'sort').  The numbers within each array represent the elements of the array, and their order demonstrates the before-and-after effect of the sorting process.  The output array shows the elements of the input array arranged in ascending order.

Теперь, начиная с первого элемента -2 (то есть 'a'), применим метод pair_sum_sorted к оставшейся части массива, чтобы найти пару с суммой 2 (то есть '-a'):

Image represents a visual depiction of the `pair_sum_sorted` function's execution on a sorted array `[-2, -1, -1, 1, 2, 2]`, aiming to find triplets summing to a target of 2.  The top shows the input array `a` labeled with 'i' indicating an index. An arrow points downwards to the function call `pair_sum_sorted([-1, -1, 1, 2, 2], target = 2)`, excluding the initial -2. The function iteratively uses two pointers, `left` and `right`, initially pointing to the first and last elements of the sub-array, respectively.  Each step shows the current array section, the positions of `left` and `right` highlighted in orange boxes, the calculated `sum` of the elements at these positions, and the resulting adjustment to `left` or `right` based on whether the `sum` is less than, greater than, or equal to the target.  The process continues until `left` and `right` cross, at which point 'no pairs found' is indicated, implying no triplet starting with -2 sums to 2.  Finally, a concluding statement 'no triplet exists that starts with -2' summarizes the function's outcome.

Как видно, при вызове pair_sum_sorted мы не нашли пару с суммой 2. Это означает, что не существует троек, начинающихся с -2, которые в сумме дают 0.

Итак, увеличим основной указатель i и попробуем снова.

Image represents a flowchart illustrating the `pair_sum_sorted` algorithm.  The algorithm starts with a sorted input array `[-2, -1, -1, 1, 2, 2]` labeled 'a'.  The function `pair_sum_sorted([-1, 1, 2, 2], target = 1)` is then called, indicating a search for pairs summing to 1 within the sub-array `[-1, 1, 2, 2]`.  Two pointers, `left` and `right`, initially point to the first and last elements of this sub-array respectively. The algorithm iteratively checks the sum of the elements pointed to by `left` and `right`. If the sum equals the target (1), a pair is found ([-1, 2], labeled 'b' and 'c'), and `left` is incremented to find more pairs. If the sum is greater than the target, `right` is decremented. If the sum is less than the target, `left` is incremented (not explicitly shown but implied). This process continues until `left` and `right` cross, at which point the algorithm terminates. The final result, pairs found `[-1, 2]`, is shown, along with a note indicating how these pairs are added to a result array.  The flowchart visually depicts the flow of control and data manipulation within the algorithm, showing the changes in pointer positions and the conditional logic based on the sum of the elements.

На этот раз мы нашли одну пару, образующую допустимую тройку.

Если продолжить этот процесс для оставшейся части массива, окажется, что [-1, -1, 2] — единственная тройка, сумма которой равна 0.

Между реализацией pair_sum_sorted в задаче «Сумма пары — отсортированный массив» и этой задачей есть важное отличие: в данной задаче мы не останавливаемся при нахождении одной пары, а продолжаем поиск всех целевых пар.

Обработка дубликатов троек Ранее мы упустили из виду вопрос о том, как избежать добавления дубликатов троек. Существует два случая, когда это происходит. Рассмотрим пример ниже:

Image represents a one-dimensional array or list of integers enclosed within square brackets.  The array contains nine integer elements: -4, -4, -2, 0, 0, 1, 2, and 3. These elements are arranged sequentially from left to right, separated by spaces. No other components, connections, or information flow are depicted; the image solely presents the data structure itself.  There are no URLs, parameters, or other labels beyond the numerical values and the enclosing brackets.

Случай 1: повторяющиеся значения 'a'

Первый случай появления дубликатов — при поиске пар для троек, начинающихся с одного и того же значения 'a':

Image represents two identical examples of a data transformation process.  Each example shows a sequence of numbers arranged into three labeled groups: 'a', 'b', and 'c'. Group 'a' contains the numbers [-4, -4, -2, 0], group 'b' contains [0, 1, 2], and group 'c' contains [3].  A rightward arrow indicates a transformation where the first element from each group ('a', 'b', and 'c') – specifically -4, 0, and 3 – are extracted and combined to form a new data structure labeled 'triplet [-4 1 3]'.  The arrangement is replicated identically below the first example, emphasizing the consistent application of the transformation rule.  The labels 'a', 'b', and 'c' are consistently placed above their respective number groups in both examples.

Поскольку pair_sum_sorted будет искать пары с суммой '-a' в обоих случаях, мы неизбежно получим одинаковые пары и, следовательно, одинаковые тройки.

Чтобы избежать выбора одного и того же значения 'a', мы продолжаем увеличивать i (где num[i] представляет значение 'a'), пока оно не достигнет числа, отличного от предыдущего. Мы делаем это перед поиском пар методом pair_sum_sorted. Эта логика работает, поскольку массив отсортирован, а значит, одинаковые числа стоят рядом. Код для проверки дубликатов значений 'a' выглядит следующим образом:

python
# To prevent duplicate triplets, ensure 'a' is not a repeat of the previous element
# in the sorted array.
if i > 0 and nums[i] == nums[i - 1]:
    continue
... Find triplets ...

Случай 2: повторяющиеся значения 'b'

Что касается второго случая, рассмотрим, что происходит в pair_sum_sorted при аналогичной ситуации. Для фиксированного целевого значения ('-a') пары, начинающиеся с одного числа 'b', всегда будут одинаковыми:

Image represents two examples of extracting a triplet from a larger array.  Each example shows a numerical array enclosed in square brackets, containing the elements [-4, -4, -2, 0, 0, 1, 2, 3].  Above each element within the array, labels 'a', 'b', and 'c' are placed to identify sections. In both examples, a sub-array consisting of three elements is highlighted with a cyan-colored box.  The first example highlights the elements 0, 0, and 1 (labeled b, b, and c respectively), while the second example highlights the elements 0, 0, and 1 (labeled a, b, and c respectively). A rightward arrow connects each array to the resulting 'triplet,' which is represented as [-2, 0, 2] in both cases.  Note that the triplet's values are not directly taken from the highlighted sub-array but rather seem to be a result of some transformation or operation not explicitly shown in the diagram.

Решение то же, что и прежде: убедиться, что текущее значение 'b' не совпадает с предыдущим.

Важно отметить, что нам не нужно явно обрабатывать дубликаты значений 'c'. Корректировки для устранения дубликатов значений 'a' и 'b' гарантируют уникальность каждой пары [a, b]. Поскольку 'c' определяется уравнением c = -(a + b), каждая уникальная пара [a, b] даст уникальное значение 'c'. Следовательно, избегая дубликатов в 'a' и 'b', мы автоматически избегаем дубликатов в тройках [a, b, c].

Оптимизация Интересное наблюдение: тройки с суммой 0 нельзя составить только из положительных чисел. Поэтому можно прекратить поиск троек, как только мы достигнем положительного значения 'a', поскольку это означает, что 'b' и 'c' тоже будут положительными.

Реализация

Исходя из вышесказанного, нам нужно немного изменить функцию pair_sum_sorted для устранения дубликатов троек. Также необходимо передавать начальное значение, указывающее начало подмассива, к которому применяется алгоритм поиска пар. В остальном логика двух указателей практически идентична задаче «Сумма пары — отсортированный массив».

python
from typing import List
  
def triplet_sum(nums: List[int]) -> List[List[int]]:
    triplets = []
    nums.sort()
    for i in range(len(nums)):
        # Optimization: triplets consisting of only positive numbers will never sum
        # to 0.
        if nums[i] > 0:
            break
        # To avoid duplicate triplets, skip 'a' if it's the same as the previous
        # number.
        if i > 0 and nums[i] == nums[i - 1]:
            continue
        # Find all pairs that sum to a target of '-a' ('-nums[i]').
        pairs = pair_sum_sorted_all_pairs(nums, i + 1, -nums[i])
        for pair in pairs:
            triplets.append([nums[i]] + pair)
    return triplets
  
def pair_sum_sorted_all_pairs(nums: List[int], start: int, target: int) -> List[int]:
    pairs = []
    left, right = start, len(nums) - 1
    while left < right:
        sum = nums[left] + nums[right]
        if sum == target:
            pairs.append([nums[left], nums[right]])
            left += 1 # To avoid duplicate '[b, c]' pairs, skip 'b' if it’s the same as the # previous number.
            while left < right and nums[left] == nums[left - 1]:
                left += 1
        elif sum < target:
            left += 1 
        else:
            right -= 1
    return pairs

Анализ сложности

Временная сложность: временная сложность triplet_sum равна O(n^2). Вот почему:

  • Сначала мы сортируем массив, что занимает O(n log n) времени.
  • Затем для каждого из n элементов массива мы вызываем pair_sum_sorted_all_pairs не более одного раза, что выполняется за O(n) времени.

Таким образом, общая временная сложность равна O(log n) + O(n²) = O(n²).

Пространственная сложность: пространственная сложность равна O(n) из-за пространства, занимаемого алгоритмом сортировки Python. Важно отметить, что эта сложность не включает выходной массив triplets, поскольку нас интересует только дополнительное пространство, используемое алгоритмом, а не пространство для самого результата.

Если интервьюер спросит о пространственной сложности с учётом выходного массива, она будет равна O(n²). Это объясняется тем, что функция pair_sum_sorted_all_pairs в худшем случае может добавить примерно n пар в результат. Поскольку эта функция вызывается примерно n раз, общая пространственная сложность равна O(n²).

Тестовые случаи

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

InputExpected outputDescription
nums = [][]Tests an empty array.
nums = [0][]Tests a single-element array.
nums = [1, -1][]Tests a two-element array.
nums = [0, 0, 0][0, 0, 0]Tests an array where all three of its values are the same.
nums = [1, 0, 1][]Tests an array with no triplets that sum to 0.
nums = [0, 0, 1, -1, 1, -1][-1, 0, 1]Tests an array with duplicate triplets.

Проверка палиндрома

Проверка палиндрома

Палиндром — это последовательность символов, которая читается одинаково слева направо и справа налево.

Дана строка. Определите, является ли она палиндромом после удаления всех небуквенно-цифровых символов. Символ является буквенно-цифровым, если это буква или цифра.

Пример 1:

python
Input: s = 'a dog! a panic in a pagoda.'
Output: True

Пример 2:

python
Input: s = 'abc123'
Output: False

Ограничения:

  • Строка может содержать комбинацию строчных букв английского алфавита, цифр, пробелов и знаков препинания.

Интуиция

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

Image represents a simple data flow diagram illustrating a string reversal operation.  The diagram shows the input string 'racecar' enclosed in double quotes, positioned on the left.  A rightward-pointing arrow connects this input to the word 'reverse', indicating the operation being performed.  Another rightward-pointing arrow then connects 'reverse' to the output string 'racecar', also enclosed in double quotes, positioned on the right.  The arrangement visually depicts the transformation of the input string 'racecar' into its reversed form, 'racecar,' through the application of the 'reverse' function.  The entire diagram is linear, showing a clear, unidirectional flow of data from input to output via a clearly labeled process.

Важное наблюдение: если строка является палиндромом, первый символ совпадает с последним, второй — со вторым с конца и т.д.:

Image represents a visual depiction of a nested structure, likely illustrating a coding pattern such as nested loops or recursive function calls.  The image shows the letters 'b y t e e t y b' arranged horizontally.  Each letter is vertically connected to a horizontal line segment extending downwards. These line segments are nested, with the line under 'y t e e t y' being the longest, encompassing shorter lines beneath 't e e t'. The shortest lines are directly under 't' and 'e', and 'e' and 't'. The outermost line segment connects the 'b' at each end, creating a visual representation of a hierarchical or nested structure where the inner segments are contained within the outer ones.  The arrangement suggests a parent-child relationship, where the outer structure contains and encompasses the inner structures.

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

Image represents a visual depiction of a palindrome check using a nested loop approach.  The word 'racecar' is displayed at the top, with each letter positioned above a corresponding vertical bar.  The letter 'e' is highlighted in orange, representing the central character of the palindrome.  A series of nested rectangular boxes are drawn underneath the letters. The outermost box encloses all the letters, representing the entire string.  Inner boxes are nested around the 'a', 'c', and 'r' letters on both sides of the central 'e', visually demonstrating the comparison of characters from the beginning and end of the string moving inwards. The structure implies that the algorithm iteratively compares characters from the outer ends, working its way towards the center, to determine if the string is a palindrome.  The nested boxes visually represent the nested loop structure of the algorithm, where the outer loop iterates through the string from both ends, and the inner loop (implied) compares the characters at each iteration.

Палиндромы представляют идеальный сценарий для использования двух указателей (left и right). Установив указатели в начало и конец строки, мы можем сравнивать символы в этих позициях. Игнорируя на мгновение небуквенно-цифровые символы, логику можно обобщить следующим образом:

  • Если буквенно-цифровые символы у left и right совпадают, перемещаем оба указателя внутрь для обработки следующей пары символов.
  • Если нет — строка не является палиндромом: возвращаем false.

Если мы успешно сравним все пары символов, не вернув false, строка является палиндромом, и следует вернуть true.

Обработка небуквенно-цифровых символов Теперь рассмотрим, как искать палиндромы, содержащие небуквенно-цифровые символы.

Поскольку небуквенно-цифровые символы не влияют на то, является ли строка палиндромом, их следует пропускать. Этого можно достичь следующим подходом, который гарантирует, что указатели left и right корректируются для работы только с буквенно-цифровыми символами:

  • Увеличиваем left до тех пор, пока символ, на который он указывает, не станет буквенно-цифровым.
  • Уменьшаем right до тех пор, пока символ, на который он указывает, не станет буквенно-цифровым.

С учётом этого проверим, является ли строка ниже палиндромом, используя всю имеющуюся у нас информацию:

Image represents a diagram illustrating a coding pattern, possibly related to array or string manipulation.  Two orange rectangular boxes labeled 'left' and 'right' are positioned above a line of text 'a + 2 c ! 2 a'.  Arrows descend from each box, pointing to the respective ends of this text string. Below, a light-grey, dashed-bordered rectangle contains the conditional statement 's[left] == s[right]', which checks for equality between elements at indices 'left' and 'right' of an array or string 's'. A right-pointing arrow follows this condition, leading to the consequent action 'left += 1, right -= 1', indicating that the 'left' index is incremented and the 'right' index is decremented.  The overall diagram suggests an algorithm that iterates through a sequence from both ends, comparing elements until a mismatch is found or the indices cross.
Image represents a diagram illustrating a coding pattern.  At the top, two orange rectangular boxes labeled 'left' and 'right' are shown.  A downward-pointing arrow extends from each box. Below the boxes, a string 'a + 2 c ! 2 a' is displayed. The 'left' arrow points to the 'a + 2' portion of the string, and the 'right' arrow points to the 'c ! 2 a' portion.  A light gray, dashed-bordered rectangle contains the text 's[left] is not alphanumeric → left += 1,' indicating a conditional statement.  This statement checks if the character at the index specified by the variable `left` within string `s` is not alphanumeric. If the condition is true, the value of `left` is incremented by 1.  The arrow within the gray rectangle visually represents the flow of control based on the condition's outcome.
Image represents a diagram illustrating a coding pattern, possibly related to string manipulation or array processing.  At the top, two orange rectangular boxes labeled 'left' and 'right' are shown.  Arrows descend from each box pointing to a string 'a + 2 c ! 2 a'. This string appears to be the input data, with 'left' pointing to the 'a + 2' segment and 'right' pointing to the '2 a' segment. Below the input string, a light gray, dashed-bordered rectangle contains the core logic: 's[left] == s[right] → left += 1, right -= 1'. This expression signifies a comparison: if the element at index 'left' in string 's' is equal to the element at index 'right', then the 'left' index is incremented by 1, and the 'right' index is decremented by 1.  This suggests an algorithm that iterates from both ends of the string, comparing elements until a mismatch is found or the indices cross. The arrow ('→') indicates the conditional action taken upon successful comparison.
Image represents a diagram illustrating a coding pattern, specifically a conditional assignment.  At the top, two rectangular boxes labeled 'left' and 'right' (in orange) are shown.  Downward arrows from these boxes point to a string 'a + 2 c ! 2 a'. This string appears to represent an input, with 'left' pointing to 'a + 2 c' and 'right' pointing to '! 2 a'. Below this, a light gray, dashed-bordered rectangle contains the conditional statement: 's[right] is not alphanumeric → right -= 1'. This statement indicates that if the substring referenced by `s[right]` (which is '! 2 a' in this example) is not alphanumeric, then the value of the variable `right` is decremented by 1.  The arrow ('→') signifies the conditional flow, showing the consequence of the condition being met.  The diagram visually demonstrates how the 'right' variable's value might change based on the content of the input string.
Image represents a diagram illustrating a coding pattern.  Two orange rectangular boxes labeled 'left' and 'right' are positioned above a sequence of characters: 'a + 2 c ! 2 a'.  Arrows descend from each box, pointing to the characters 'a', '2', 'c', '!', '2', and 'a' respectively, suggesting a mapping or association between the labels and the character sequence.  To the right, a light gray, dashed-bordered rectangle contains the expression 'left == right → break'. This indicates a conditional statement: if the value of 'left' is equal to the value of 'right', then the program will 'break' (likely exiting a loop or conditional block). The arrow signifies the flow of control based on the comparison's outcome.  The overall diagram likely depicts a scenario where the equality of 'left' and 'right' (potentially variables or values) determines the execution path, influencing the processing of the character sequence below.

Как показано выше, когда указатели left и right встречаются, это сигнализирует об условии выхода. Когда эти указатели встречаются, мы достигли среднего символа палиндрома, и в этой точке можно выйти из цикла, поскольку средний символ не требует оценки. Однако следует иметь в виду, что выход при равенстве left и right не всегда будет достаточным условием выхода. Например, если количество буквенно-цифровых символов чётно, указатели не встретятся. Это можно наблюдать ниже:

Image represents a diagram illustrating a coding pattern, possibly related to string manipulation or palindrome checking.  At the top, two orange rectangular boxes labeled 'left' and 'right' are shown.  Arrows point downwards from these boxes to a sequence of characters 'a b b a' below.  This sequence appears to be a string being processed. A light gray, dashed-bordered rectangle contains the core logic: 's[left] == s[right] → left += 1, right -= 1'. This expression indicates a comparison:  the character at index `left` in string `s` is compared to the character at index `right`. If they are equal (indicated by '=='), then the `left` index is incremented by 1 ('left += 1') and the `right` index is decremented by 1 ('right -= 1'). This suggests an iterative approach, moving inwards from both ends of the string, likely to check for palindromic properties. The arrow ('→') signifies the conditional execution of the index updates based on the comparison result.
Image represents a simple diagram illustrating a coding pattern, likely related to data manipulation or sorting.  Two orange rectangular boxes, labeled 'right' and 'left' respectively, are positioned side-by-side.  A downward-pointing arrow extends from each box.  The 'right' box's arrow points to a vertically stacked 'a' and 'b', while the 'left' box's arrow points to a vertically stacked 'b' and 'a'. This arrangement visually depicts a swapping or inversion operation where the data elements 'a' and 'b' are exchanged depending on their position relative to the 'right' and 'left' labels, suggesting a potential algorithm involving comparison and rearrangement of data based on a defined criteria.

Следовательно, нам нужно обеспечить выход из цикла, когда left равно right или когда left проходит мимо right. Иными словами, алгоритм продолжает работу, пока left меньше right:

python
while left < right:

Реализация

В Python можно использовать встроенный метод isalnum для проверки того, является ли символ буквенно-цифровым.

python
def is_palindrome_valid(s: str) -> bool:
    left, right = 0, len(s) - 1
    while left < right:
        # Skip non-alphanumeric characters from the left.
        while left < right and not s[left].isalnum():
            left += 1
        # Skip non-alphanumeric characters from the right.
        while left < right and not s[right].isalnum():
            right -= 1
        # If the characters at the left and right pointers don’t match, the string is
        # not a palindrome.
        if s[left] != s[right]:
            return False
        left += 1
        right -= 1
    return True

Анализ сложности

Временная сложность: временная сложность is_palindrome_valid равна O(n), где n — длина строки. Это объясняется тем, что мы выполняем примерно n итераций с использованием техники двух указателей.

Пространственная сложность: мы выделяем лишь константное количество переменных, поэтому пространственная сложность равна O(1).

Тестовые случаи

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

InputExpected outputDescription
s = ""TrueTests an empty string.
s = "a"TrueTests a single-character string.
s = "aa"TrueTests a palindrome with two characters.
s = "ab"FalseTests a non-palindrome with two characters.
s = "!, (?)"TrueTests a string with no alphanumeric characters.
s = "12.02.2021"TrueTests a palindrome with punctuation and numbers.
s = "21.02.2021"FalseTests a non-palindrome with punctuation and numbers.
s = "hello, world!"FalseTests a non-palindrome with punctuation.

Советы для собеседования

Совет 1: уточните ограничения задачи. Обычно интервьюер не сообщает все детали задачи. Например, вас могут попросить просто «проверить, является ли строка палиндромом». Но прежде чем приступать к решению, важно уточнить у интервьюера детали: наличие небуквенно-цифровых символов, их обработку, роль цифр, чувствительность букв к регистру и другие важные аспекты.

Совет 2: подтвердите использование встроенных функций. Эту задачу упрощает использование встроенных функций, таких как .isalnum (или аналогичных). Прежде чем использовать встроенную функцию, упрощающую реализацию, спросите интервьюера, можно ли её использовать, или он предпочтёт, чтобы вы реализовали её самостоятельно.

Скорее всего, интервьюер разрешит использование встроенной функции или попросит реализовать её самостоятельно в качестве упражнения в ходе собеседования. Если вы используете встроенную функцию, убедитесь, что понимаете её временну́ю и пространственную сложность.

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

Наибольший контейнер

Наибольший контейнер

Дан массив чисел, каждое из которых представляет высоту вертикальной линии на графике. Контейнер можно сформировать из любой пары этих линий вместе с осью x графика. Верните объём воды, который может удержать наибольший контейнер.

Пример:

Image represents a bar chart or histogram with a horizontal x-axis ranging from 0 to 5 and a vertical y-axis ranging from 0 to 8.  The y-axis represents frequency or count, while the x-axis likely represents categories or data points.  The chart displays several vertical bars of varying heights. A bar at x=0 has a height of 2.  Bars at x=1, x=2, x=4, and x=5 all reach a height of 6, forming a plateau of light-blue filled area.  A shorter bar at x=3 reaches a height of 3.  The bars are represented by vertical black lines extending from the x-axis to their respective heights on the y-axis.  The area under the bars from y=0 to y=6 between x=1 and x=5 is filled with light blue, visually highlighting the consistent frequency across those data points.
python
Input: heights = [2, 7, 8, 3, 7, 6]
Output: 24

Интуиция

Если у нас есть две вертикальные линии heights[i] и heights[j], количество воды между ними равно min(heights[i], heights[j]) * (j - i), где j - i — ширина контейнера. Берём минимальную высоту, потому что заполнение выше неё привело бы к переполнению.

Image represents a graphical illustration of a calculation alongside its formula and result.  The left side shows a 2D Cartesian coordinate system with a light-blue rectangle representing a volume of water. The rectangle's base extends from x=0 to x=2 on the x-axis and its height is 3 units along the y-axis, reaching from y=0 to y=3.  A vertical line segment at x=1 is also present within the rectangle.  A curved grey arrow originates from the top-right corner of the rectangle and points to the right, indicating the direction of the calculation's result. To the right of the graph, the formula 'water = min(4, 3) ⋅ (2 - 0)' is displayed, calculating the volume of water.  'min(4, 3)' finds the minimum value between 4 and 3 (which is 3), and this is multiplied by the base length (2 - 0 = 2). The subsequent lines show the calculation steps: '3 ⋅ 2' and the final result '6', representing the total volume of water (likely in some arbitrary units).

Иными словами, площадь контейнера зависит от двух вещей:

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

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

python
from typing import List
    
def largest_container_brute_force(heights: List[int]) -> int:
   n = len(heights)
   max_water = 0
   # Find the maximum amount of water stored between all pairs of lines.
   for i in range(n):
       for j in range(i + 1, n):
           water = min(heights[i], heights[j]) * (j - i)
           max_water = max(max_water, water)
   return max_water

Перебор всех возможных пар значений занимает O(n^2) времени, где n — длина массива. Поищем более эффективное решение.

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

Не сразу очевидно, как найти контейнер с наибольшей высотой, поскольку высоты линий в массиве не следуют чёткой закономерности. Однако мы знаем контейнер с максимальной шириной: тот, что начинается с индекса 0 и заканчивается на индексе n - 1.

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

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

Рассмотрим следующий пример:

Image represents a bar chart visualizing the data contained within the Python list `heights = [2, 7, 8, 3, 7, 6]`.  The horizontal axis represents the index of each element in the list (0 through 5), while the vertical axis represents the value of each element, ranging from 0 to 8.  Each vertical bar corresponds to an element in the list; its height corresponds to the element's value. For example, the first bar at index 0 has a height of 2, the second bar at index 1 has a height of 7, and so on. The vertical axis is marked with numerical labels (0 through 8) indicating the height of the bars, and the horizontal axis is implicitly marked by the position of the bars, corresponding to the list indices.  The arrowheads on the axes indicate the direction of increasing values.

Наибольший по ширине контейнер может удержать воды на площадь 10. Поскольку это наибольший найденный нами контейнер, установим max_water = 10.

Image represents a diagram illustrating a calculation of maximum trapped water.  The left side shows a bar chart with a horizontal axis labeled from 0 to 5 and a vertical axis representing height from 0 to 8.  Vertical bars of varying heights (2, 7, 8, 3, 6) are plotted at each integer position on the horizontal axis. A light-blue area represents the trapped water, extending horizontally between the bars and reaching a height of 2 units.  Arrows point upwards from the labels 'left' and 'right' below the horizontal axis, indicating the direction of calculation. On the right, a light-grey box displays a calculation: 'water = min(2, 6) . (5 - 0) = 10' and 'max_water = 10', showing the minimum height between the leftmost (height 2) and rightmost (height 6) bars multiplied by the width (5-0) to determine the total trapped water, which is 10 units.

Как нам продолжать? Перемещение любого указателя внутрь даёт контейнер с меньшей шириной. Это оставляет высоту определяющим фактором. В данном случае левая линия короче правой, что означает: левая линия ограничивает высоту воды. Чтобы найти больший контейнер, переместим левый указатель внутрь:

Image represents a visual explanation of a coding pattern, likely related to algorithms operating on arrays or lists.  The left side shows a bar chart with a horizontal axis labeled from 0 to 5 and a vertical axis representing height from 0 to 8.  Several bars of varying heights are displayed; the heights are implicitly represented by the vertical extent of the bars. A light-blue rectangle fills the area below the height of the bars, representing a water level.  An orange box labeled 'left' points to the leftmost bar, and another orange box labeled 'right' points to the rightmost bar.  These labels 'left' and 'right' likely represent index pointers within an array. On the right side, a light-grey dashed-bordered box contains a conditional statement in code-like notation:  `heights[left] < heights[right] → left += 1`. This indicates that if the height at the index `left` is less than the height at the index `right`, then the `left` index is incremented by 1.  The arrow implies a flow of information or control; the condition's evaluation dictates the update of the `left` pointer. The overall diagram illustrates a step within an algorithm, possibly for finding the largest rectangular area in a histogram or a similar problem involving array traversal and comparison.

Текущий контейнер может удержать 24 единицы воды — это наибольшее значение на данный момент. Обновим max_water до 24. Здесь правая линия короче, ограничивая высоту воды. Для поиска большего контейнера переместим правый указатель внутрь:

Image represents a bar chart illustrating a coding pattern, likely related to finding the maximum area of water trapped between vertical bars.  The horizontal axis shows indices (0 to 5), and the vertical axis represents heights.  Vertical bars of varying heights are drawn at each index; their heights are 2, 6, 6, 3, 6, and 0 respectively. A light-blue area is filled between the bars at indices 1, 2, 3, and 4, representing the trapped water.  Arrows labeled 'left' and 'right' point towards the x-axis, indicating the direction of traversal in an algorithm. A separate box contains code snippets: `water = min(7, 6) * (5 - 1) = 24` calculates the water trapped between specific bars (likely using the minimum height as the limiting factor and the distance between them), `max_water = 24` stores the maximum water found so far, and `heights[left] > heights[right] → right -= 1` shows a conditional statement, suggesting an algorithm iterates from left and right, moving the right pointer inwards if the left bar is taller.  The overall diagram visually explains a two-pointer approach to solving a 'trapping rain water' problem.

После этого мы встречаем ситуацию, когда высоты левой и правой линий равны. В этой ситуации какой указатель следует переместить внутрь? Независимо от выбора, следующий контейнер гарантированно удержит меньше воды, чем текущий. Попробуем понять почему.

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

Следовательно, поскольку мы не можем увеличить высоту, перемещая только один указатель, можно просто переместить оба указателя внутрь:

Image represents a bar chart illustrating a 'two-pointer' approach to finding the maximum area that can be contained between vertical bars. The horizontal axis shows indices (0 to 5), and the vertical axis represents height.  Several vertical bars of varying heights are depicted; one bar at index 0 has a height of 2, a group of bars from index 1 to 3 have a height of 7, and a bar at index 3 has a height of 6. The area between the bars at indices 1 and 3 is shaded light blue, representing the calculated water trapped between them.  Two orange rectangular boxes labeled 'left' and 'right' point to the indices 1 and 4 respectively, indicating the pointers used in the algorithm. A separate box displays calculation details: `water = min(7, 7) * (4 - 1) = 21`, showing the calculation of the water trapped between the pointers; `max_water = 24` indicates the maximum water trapped found so far; and `heights[left] == heights[right] => left += 1 and right -= 1` describes the algorithm's logic: if the heights at the left and right pointers are equal, both pointers move inwards.

Теперь правая линия ограничивает высоту воды. Переместим правый указатель внутрь:

Image represents a diagram illustrating a coding pattern, likely related to finding the maximum trapped rainwater between vertical bars.  The left side shows a bar chart with vertical bars of varying heights (1, 7, 3, 7, 6) at positions 0, 1, 2, 3, 4, 5 respectively on the x-axis and heights on the y-axis. A light-blue rectangle is filled between bars of height 3 and 7 at positions 2 and 3, representing trapped water.  Below the chart, orange rectangular boxes labeled 'left' and 'right' indicate pointers or indices used to iterate through the bars.  An arrow points upwards from 'left' to the bar at position 2 and another from 'right' to the bar at position 3. The right side contains a light-grey box with code-like annotations.  These annotations show a calculation: `water = min(8, 3) * (3 - 2) = 3`, indicating the calculation of water trapped between two bars; `max_water = 24`, representing the maximum trapped water; and `heights[left] > heights[right] => right -= 1`, suggesting a conditional statement controlling the movement of the 'right' pointer based on the heights of the bars at the 'left' and 'right' indices.  The overall diagram visually explains an algorithm for calculating maximum trapped rainwater using two pointers.

Наконец, указатели left и right встречаются. Мы можем завершить поиск и вернуть max_water:

Image represents a diagram illustrating a coding pattern, likely within a search or sorting algorithm.  The left side shows a bar chart with the x-axis ranging from 0 to 5 and the y-axis representing a value, with bars of varying heights at different x-axis positions (0, 1, 2, 3, 4, and 5).  Below the chart, two rectangular boxes labeled 'left' and 'right' are shown, with arrows pointing upwards towards the x-axis, suggesting these labels represent index pointers or boundaries within a data structure. On the right side, a dashed-line rectangle contains the condition 'left == right' with a solid arrow pointing to the word 'break,' indicating that when the condition 'left' equals 'right' is met, the algorithm terminates or exits the loop using a 'break' statement.  The overall diagram visually depicts the process of a binary search or similar algorithm where two pointers converge until a condition is met, resulting in the termination of the process.

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

  1. Если левая линия меньше, перемещаем левый указатель внутрь.
  2. Если правая линия меньше, перемещаем правый указатель внутрь.
  3. Если обе линии имеют одинаковую высоту, перемещаем оба указателя внутрь.

Реализация

python
from typing import List
  
def largest_container(heights: List[int]) -> int:
    max_water = 0
    left, right = 0, len(heights) - 1
    while (left < right):
        # Calculate the water contained between the current pair of lines.
        water = min(heights[left], heights[right]) * (right - left)
        max_water = max(max_water, water)
        # Move the pointers inward, always moving the pointer at the shorter line. If
        # both lines have the same height, move both pointers inward.
        if (heights[left] < heights[right]):
            left += 1
        elif (heights[left] > heights[right]):
            right -= 1
        else:
            left += 1
            right -= 1
    return max_water

Анализ сложности

Временная сложность: временная сложность largest_container равна O(n), поскольку мы выполняем примерно n итераций с использованием техники двух указателей.

Пространственная сложность: мы выделяем лишь константное количество переменных, поэтому пространственная сложность равна O(1).

Тестовые случаи

В дополнение к примерам, рассмотренным в данном объяснении, ниже приведены другие примеры для тестирования вашего кода.

InputExpected outputDescription
heights = []0Tests an empty array.
heights = [1]0Tests an array with just one element.
heights = [0, 1, 0]0Tests an array with no containers that can contain water.
heights = [3, 3, 3, 3]9Tests an array where all heights are the same.
heights = [1, 2, 3]2Tests an array with strictly increasing heights.
heights = [3, 2, 1]2Tests an array with strictly decreasing heights.

Перенос нулей в конец

Перенос нулей в конец

Дан массив целых чисел. Измените массив на месте, перемещая все нули в конец и сохраняя относительный порядок ненулевых элементов.

Пример:

python
Input: nums = [0, 1, 0, 3, 2]
Output: [1, 3, 2, 0, 0]

Интуиция

У этой задачи три основных требования:

  1. Переместить все нули в конец массива.
  2. Сохранить относительный порядок ненулевых элементов.
  3. Выполнить изменение на месте.

Наивный подход к этой задаче — построить результат с помощью отдельного массива (temp). Можно добавить все ненулевые элементы из левой части nums в этот временный массив и оставить остаток нулями. Затем просто присвоить входному массиву значение temp.

Определяя и перемещая ненулевые элементы из левой части массива в первую очередь, мы гарантируем сохранение их порядка при добавлении в результат:

python
def shift_zeros_to_the_end_naive(nums: List[int]) -> None:
    temp = [0] * len(nums)
    i = 0
    # Add all non-zero elements to the left of 'temp'.
    for num in nums:
        if num != 0:
            temp[i] = num
            i += 1
    # Set 'nums' to 'temp'.
    for j in range(len(nums)):
        nums[j] = temp[j]

К сожалению, это решение нарушает третье требование — изменение входного массива на месте.

Тем не менее из этого подхода можно извлечь ценное понимание. В частности, заметим, что это решение сосредоточено на ненулевых элементах, а не на нулях. Это означает, что если изменить цель на перемещение всех ненулевых элементов в левую часть массива, нули окажутся справа. Следовательно, нужно сосредоточиться только на ненулевых элементах:

The image represents a data transformation process.  It shows an input array `[0 1 0 2 3]` enclosed in square brackets.  A light-blue rectangular box highlights the non-zero elements (1, 2, and 3) within this array. A solid black arrow points from the input array to an output array `[1 2 3 0 0]`.  Below the input array, the text 'non-zero numbers go here' in light-blue indicates that the transformation involves extracting only the non-zero numbers.  Light-blue horizontal lines are drawn under both the input and output arrays, visually connecting the non-zero elements in the input to their corresponding positions in the output, implying a direct mapping of non-zero values.  The zeros in the input array are preserved in the output array, maintaining the original array's length.

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

Два указателя Для этого можно использовать два указателя:

  • Левый указатель для итерации по левой части массива, куда должны быть помещены ненулевые элементы.
  • Правый указатель для поиска ненулевых элементов.

Рассмотрим пример ниже. Начнём с размещения указателей left и right в начале массива. Прежде чем перемещать ненулевые элементы влево, правый указатель должен указывать на ненулевой элемент. Поэтому игнорируем ноль у первого элемента и увеличиваем right:

Image represents a visualization of a coding pattern, likely illustrating pointer manipulation within an array.  The top section shows two gray boxes labeled 'left' and 'right' pointing downwards to an array `[0 1 0 2 3]`.  This represents the initial state of the array and the pointers. Below, a second similar arrangement shows the array after a step in the algorithm.  The 'left' pointer remains unchanged, but the 'right' pointer (shown in orange) has moved one position to the right.  A dashed gray box in the middle displays a conditional statement: `nums[right] == 0 → right += 1`, indicating that if the element at the index pointed to by 'right' is equal to 0, then the value of 'right' is incremented.  The dashed line connecting the 'right' pointer in the second array to the conditional statement visually connects the conditional check to the pointer's movement. The overall diagram illustrates a single iteration of an algorithm that likely involves traversing and manipulating an array using two pointers.

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

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

1. Поменяем местами элементы у left и right: сначала переместим элемент у правого указателя в левую часть массива. Для этого поменяем его местами с элементом у левого указателя.

Image represents a visual depiction of a sorting algorithm's step.  Two rectangular boxes labeled 'left' and 'right' are positioned above a horizontal array `[0 1 0 2 3]`. Arrows descend from 'left' and 'right' pointing to the array elements at indices 0 and 1 respectively. A dashed-line rectangle to the right contains the conditional statement 'nums[right] != 0 → swap(nums[left], nums[right])', indicating that if the element at the index pointed to by 'right' (which is 1 in this case) is not equal to 0, then a swap operation occurs between the elements at the indices pointed to by 'left' and 'right'.  The 'nums' refers to the array, and 'left' and 'right' act as index pointers. The arrow '→' signifies the conditional execution of the swap function.
Image represents a visual depiction of a swap operation within a sorting algorithm, likely a variation of quicksort or similar.  At the top, two rectangular boxes labeled 'left' and 'right' are shown, each with a downward-pointing arrow. These arrows point to a numerical array represented as `[1 0 2 3]`, where the numbers 1 and 0 are highlighted in light peach circles. A gray horizontal line connects the numbers 1 and 0, indicating their positions within the array.  A curved orange arrow, labeled 'swapped,' arcs underneath the line, connecting the circled 1 and 0, illustrating the exchange of these two elements.  The overall diagram demonstrates a single swap step in a sorting process, where the values pointed to by 'left' and 'right' pointers are interchanged.

2. Увеличим указатели:

  • После завершения обмена следует увеличить левый указатель, чтобы установить его на позицию, куда должен идти следующий ненулевой элемент.
  • Также увеличим правый указатель в поисках следующего ненулевого элемента.
The image represents a simple illustration of a coding pattern, possibly related to array manipulation or pointer movement.  Two rectangular boxes labeled 'left' and 'right' are positioned above a horizontal line segment.  Downward arrows connect each box to a number below the line.  The numbers below the line are arranged as an array-like structure: `[1 0 0 2 3]`.  A dashed-line rectangle to the right contains the text 'left += 1, right += 1', indicating an operation where the values associated with 'left' and 'right' are incremented by 1.  The arrows suggest that the 'left' and 'right' variables might represent indices or pointers into the array, and the operation in the dashed box describes how these indices are updated.  The overall diagram visually depicts a step in an algorithm that involves iterating or manipulating an array using two pointers.
Image represents a diagram illustrating a coding pattern, possibly related to array traversal or pointer manipulation.  Two rectangular boxes labeled 'left' (in orange) and 'right' (in gray) are positioned at the top.  A solid downward-pointing arrow extends from each box, indicating data flow.  These arrows point to the numbers 0 and 0 respectively, which are situated below on a horizontal gray line representing an array or sequence.  The number 1 is placed to the left of the 0 from the 'left' box, and the number 2 and 3 are placed to the right of the 0 from the 'right' box, all on the same horizontal line, enclosed within square brackets `[ ]` to denote an array structure. A dashed orange arrow connects the 'left' box's output (0) to the number 1, suggesting a possible indirect or conditional relationship or data flow.  The overall arrangement suggests a process where the 'left' and 'right' elements might represent pointers or indices that traverse the array, potentially converging or interacting at a central point (the 0s).

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

Image represents a diagram illustrating a coding pattern, possibly within a sorting or searching algorithm.  Two rectangular boxes labeled 'left' and 'right' are positioned above a horizontal array represented by `[1 0 0 2 3]`.  Arrows descend from 'left' and 'right' pointing to the array elements at indices 1 and 2 respectively (the values 0 and 0).  A separate, light-grey, dashed-bordered rectangle to the right contains a conditional statement: `nums[right] == 0 -> right += 1`. This indicates that if the element at the index `right` in the `nums` array is equal to 0, then the value of `right` is incremented by 1.  The diagram visually shows the variables `left` and `right` potentially used as pointers or indices within the array, and the conditional statement describes a step in an algorithm that modifies the `right` pointer based on the value of the array element it points to.
Image represents a simplified illustration of a data structure, possibly an array, being manipulated.  Two rectangular boxes, labeled 'left' (in gray) and 'right' (in orange), represent pointers or indices. A gray horizontal line depicts an array containing the elements [1, 0, 0, 2, 3]. A solid downward-pointing arrow from 'left' points to the element '0' at index 1, indicating that the 'left' pointer is currently at this position. An orange downward-pointing arrow from 'right' points to the element '2' at index 3, showing the 'right' pointer's position. A dashed orange curved arrow connects the 'right' pointer to the element '0' at index 2, suggesting a potential movement or operation involving this element.  The overall arrangement visualizes the pointers' positions within the array and hints at a possible algorithm or process involving these pointers, perhaps related to partitioning or searching within the data structure.
Image represents a visual depiction of a step in a sorting algorithm, likely a comparison-based sort.  Two rectangular boxes labeled 'left' and 'right' are positioned above a horizontal array `[1, 0, 0, 2, 3]`. Arrows descend from 'left' and 'right' pointing to the array elements at indices 1 and 4 respectively (0 and 2). A dashed-line rectangle to the right displays the condition `nums[right] != 0` followed by a right arrow and the action `swap(nums[left], nums[right])`. This indicates that if the element at the index pointed to by 'right' (which is 2) is not equal to 0, then the elements at the indices pointed to by 'left' and 'right' (0 and 2) will be swapped.  The diagram illustrates a single comparison and potential swap operation within a larger sorting process.
Image represents a visual depiction of a swapping operation within an array.  The diagram shows an array `[1, 2, 0, 3]` with two pointers, labeled 'left' and 'right,' initially pointing to the elements 2 and 0 respectively.  Arrows from rectangular boxes labeled 'left' and 'right' indicate the pointers' positions. A gray horizontal line connects the elements 1, 2, 0, and 3, representing the array.  A curved orange arrow labeled 'swapped' shows the exchange of the values at the 'left' and 'right' pointer positions.  To the right, a light gray, dashed-bordered rectangle contains the operation `left += 1, right += 1`, indicating that after the swap, both pointers will increment their positions by one in the next iteration.  The overall image illustrates a single step in a sorting algorithm, likely involving a swap and pointer movement.
Image represents a diagram illustrating a simple coding pattern, possibly related to array manipulation or pointer movement.  The diagram shows a numerical array `[1 2 0 0 3]` represented horizontally. Above the array, two rectangular boxes labeled 'left' and 'right' in orange are positioned.  A solid orange arrow descends from 'left' pointing to the element '0' in the second position from the left within the array.  Similarly, a solid orange arrow descends from 'right' pointing to the element '3' at the right end of the array. Dashed orange lines curve from 'left' to the element '2' and from 'right' to the element '0' in the third position, suggesting a potential search or traversal pattern. The overall arrangement suggests a process where two pointers, one starting from the left and the other from the right, might be moving towards the center of the array, potentially for comparison, searching, or merging operations.
Image represents a visual depiction of a step in a sorting algorithm, likely a part of a larger process.  Two rectangular boxes labeled 'left' and 'right' are positioned above a numerical array `[1, 2, 0, 0, 3]`.  Arrows descend from 'left' and 'right' pointing to the '0' elements within the array, indicating these are the current indices being considered. A dashed-line rectangle to the right shows a conditional statement: `nums[right] != 0 → swap(nums[left], nums[right])`. This indicates that if the element at the index 'right' is not equal to 0, then a `swap` function is called to exchange the values at the 'left' and 'right' indices within the `nums` array.  The arrow represents the flow of control based on the condition.
Image represents a visual depiction of a swapping operation within an array.  The diagram shows an array `[1, 2, 3, 0]` with two pointers, 'left' and 'right', initially pointing to the elements 3 and 0 respectively.  Rectangular boxes labeled 'left' and 'right' above indicate the pointers' origins.  A downward-pointing arrow from each box connects to its corresponding element in the array.  The elements 3 and 0 are highlighted in light peach. A gray horizontal line connects the elements 1 and 2, visually representing the array's structure. A curved orange arrow labeled 'swapped' connects the highlighted elements 3 and 0, indicating that these elements have been exchanged. To the right, a light gray, dashed-bordered rectangle displays the operation performed: `→ left += 1, right += 1`, signifying that after the swap, both pointers will increment their positions by one.
Image represents a diagram illustrating a coding pattern, possibly related to array manipulation or pointer movement.  The diagram shows a light-blue rectangular array `[1 2 3]` with a light-blue underline indicating its boundaries.  Two orange rectangular boxes labeled 'left' and 'right' are positioned above the array.  Dashed orange arrows point downwards from 'left' and 'right' to the array elements. The 'left' arrow points to the first element (1) and the 'right' arrow points to the last element (3).  The arrows suggest that the 'left' and 'right' variables might represent pointers or indices that can be manipulated to traverse the array.  The empty space `∅` between the array and the pointers suggests an initial state or a potential intermediate step in an algorithm.  The overall structure suggests a two-pointer approach commonly used in algorithms like merging or partitioning.

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

Реализация

Вы могли заметить, что мы всегда перемещаем правый указатель вперёд независимо от того, указывает ли он на ноль или ненулевой элемент. Это позволяет использовать цикл for для итерации правого указателя.

python
def shift_zeros_to_the_end(nums: List[int]) -> None:
    # The 'left' pointer is used to position non-zero elements.
    left = 0
    # Iterate through the array using a 'right' pointer to locate non-zero
    # elements.
    for right in range(len(nums)):
        if nums[right] != 0:
            if right != left:
                nums[left], nums[right] = nums[right], nums[left]
            # Increment 'left' since it now points to a position already occupied
            # by a non-zero element.
            left += 1

Анализ сложности

Временная сложность: временная сложность shift_zeros_to_the_end равна O(n), где n — длина массива, поскольку мы выполняем один проход по входному массиву.

Пространственная сложность: пространственная сложность равна O(1), поскольку перестановка выполняется на месте.

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

InputExpected outputDescription
nums = [][]Tests an empty array.
nums = [0][0]Tests an array with one 0.
nums = [1][1]Tests an array with one 1.
nums = [0, 0, 0][0, 0, 0]Tests an array with all 0s.
nums = [1, 3, 2][1, 3, 2]Tests an array with all non-zeros.
nums = [1, 1, 1, 0, 0][1, 1, 1, 0, 0]Tests an array with all zeros already at the end.
nums = [0, 0, 1, 1, 1][1, 1, 1, 0, 0]Tests an array with all zeros at the start.

Следующая лексикографическая перестановка

Следующая лексикографическая перестановка

Дана строка строчных букв английского алфавита. Переставьте символы, чтобы получить новую строку, представляющую следующую перестановку в лексикографическом (алфавитном) порядке. Если данная строка уже является последней в лексикографическом порядке среди всех возможных перестановок, верните перестановку, которая является первой в лексикографическом порядке.

Пример 1:

python
Input: s = 'abcd'
Output: 'abdc'

Пояснение: «abdc» — следующая перестановка в лексикографическом порядке после перестановки «abcd».

Пример 2:

python
Input: s = 'dcba'
Output: 'abcd'

Пояснение: поскольку «dcba» — последняя перестановка в лексикографическом порядке, возвращаем первую: «abcd».

Ограничения:

  • Строка содержит как минимум один символ.

Интуиция

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

Важная деталь: следующая лексикографическая последовательность строки лексикографически больше исходной строки. Рассмотрим строку «abc» и все её перестановки в лексикографически упорядоченной последовательности:

Image represents a table of three columns and six rows containing permutations of the letters 'a', 'b', and 'c'.  Each row presents a unique arrangement of these three letters. A vertical arrow separates the table from the text 'lexicographical order'. This arrow indicates that the rows in the table are ordered lexicographically; that is, they are arranged in ascending order as they would appear in a dictionary.  The ordering starts with 'a' as the smallest element, followed by 'b', and then 'c'.  The first row is 'abc', the second is 'acb', and so on, systematically exhausting all possible permutations of the three letters in lexicographical order.

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

Image represents a mapping of permutations of the letters 'a', 'b', and 'c' to their corresponding numerical representations.  Six rows display all possible orderings of these three letters.  Each row shows a permutation on the left side, followed by a grey arrow indicating a transformation.  On the right side, each permutation is mapped to a unique set of numbers (1, 2, and 3), representing the rank of each letter within its permutation.  For example, in the first row, 'a', 'b', and 'c' map to 1, 2, and 3 respectively, reflecting their increasing order.  The vertical orange arrow and the word 'increasing' highlight that the numerical mapping consistently assigns 1 to the smallest letter, 2 to the next smallest, and 3 to the largest within each row's permutation.  The arrangement visually demonstrates a one-to-one correspondence between letter permutations and their ranked numerical equivalents.

Из этого мы также замечаем, что следующая строка в последовательности после «abc» — это «acb», первая строка, большая исходной:

Image represents a table illustrating a mapping between character sequences and numerical sequences.  Six rows display different permutations of the characters 'a', 'b', and 'c' in the leftmost column.  Each character sequence is connected via a grey arrow to a corresponding numerical sequence (1, 2, 3) in the rightmost column.  The numerical sequences represent rankings or assignments based on the input character sequence.  For example, the sequence 'abc' maps to '1 2 3', while 'acb' maps to '1 3 2'.  An orange arrow points from the numerical sequence '1 3 2' to the text 'next largest after 'abc'', indicating that this numerical sequence represents the next largest ranking after the sequence 'abc' based on some implied ordering or ranking system.  The image demonstrates a pattern or algorithm that transforms character permutations into ranked numerical sequences.

Это даёт нам некоторое представление о том, что нужно найти. Следующая лексикографическая строка:

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

Определение символов для перестановки Поскольку наша цель — сделать наименьшее возможное увеличение, нам нужно каким-то образом переставить символы в правой части строки.

Чтобы понять почему, представьте попытку «увеличить» значение строки. Увеличение самой правой буквы приводит к тому, что результирующая строка ближе к исходной лексикографически, чем при увеличении самой левой буквы:

Image represents two rows of data, each showing a sequence of letters (a, b, c, d, e) with corresponding numerical values (1, 2, 3, 4, 5) beneath them.  The top row displays the sequence 'a b c e d d,' with values '1 2 3 5 4 4' respectively. The bottom row shows a slightly altered sequence: 'b a b c e d d a,' with values '2 1 2 3 5 4 4 1' respectively.  A diagonal strikethrough line connects 'a' (value 1) in the top row to 'b' (value 2) in the top row, and similarly connects 'a' (value 1) in the bottom row to 'b' (value 2) in the bottom row.  The top row is labeled '(small increase)' and the bottom row is labeled '(big increase),' indicating that the changes between the two rows represent different magnitudes of increase in the value associated with 'a'.  The numerical values under each letter represent the magnitude of each element in the sequence. The visual emphasis is on the change in the position and value of 'a' between the two rows, highlighting the concept of a small versus a big increase in a sequence.

Следовательно, по возможности следует сначала сосредоточиться на перестановке символов в правой части строки.

Ключевое наблюдение: последняя строка в лексикографической последовательности (то есть наибольшая перестановка) всегда следует в невозрастающем порядке. Это можно увидеть на примере строки «abcc», например, где наибольшая возможная перестановка — «ccba»:

Image represents a diagram illustrating a coding pattern transformation.  The diagram shows two sequences of characters, 'a', 'b', 'c', 'c' and 'c', 'c', 'b', 'a', arranged vertically with their corresponding numerical indices (1, 2, 3, 3 and 3, 3, 2, 1 respectively) displayed below. A black arrow connects the first sequence to the second, labeled 'last permutation'.  Above this arrow and connected to the second sequence by an orange arrow is the label 'non-increasing', indicating the transformation's goal. The transformation shows the initial sequence being reordered into a non-increasing sequence, where the elements are arranged in descending order, or if equal, maintain their original order.  The numerical indices also reflect this reordering, demonstrating the change in position of the elements after the transformation.

Как это нам помогает? Мы знаем, что нужно переставить символы в правой части строки, но не знаем, сколько именно. Далее будем называть крайние правые символы, которые следует переставить, «суффиксом».

Возьмём строку «abcedda» в качестве примера. Мы обходим её справа налево с целью найти кратчайший суффикс, который можно переставить для получения большей перестановки. Последние 4 символа образуют невозрастающий суффикс и не могут быть переставлены так, чтобы строка стала больше:

Image represents three rows of data illustrating a non-increasing subsequence pattern. Each row displays a sequence of characters (a, b, c, e, d, d, a) with corresponding numerical values (1, 2, 3, 5, 4, 4, 1) beneath them.  The arrangement shows the initial sequence followed by progressively identified non-increasing subsequences.  The non-increasing subsequences (d, d, a) are highlighted with a peach-colored background and the label 'non-increasing' is written in orange text above each subsequence. The numerical values remain consistent across all rows, demonstrating how the non-increasing subsequence is identified within the larger sequence.  The visual structure emphasizes the iterative identification of the non-increasing subsequence from the original sequence.

Однако следующий символ «c» нарушает невозрастающую последовательность:

Image represents a sequence of characters 'a', 'b', 'c', 'e', 'd', 'd', 'a' displayed horizontally, each character having a corresponding numerical value (1, 2, 3, 5, 4, 4, 1) shown below.  The characters 'c', 'e' are highlighted in a peach-colored background, indicating they form a non-increasing subsequence (3, 5). A curved arrow connects the numerical values 3 and 5, pointing downwards to '3 < 5', explicitly showing the non-increasing relationship.  A horizontal arrow connects the entire character sequence to the text 'can rearrange to incur an increase' in orange, indicating that because the sequence is not monotonically non-increasing, it can be rearranged to create an increasing sequence. The text 'not non-increasing!' in orange further emphasizes the initial sequence's non-monotonic nature.

Назовём этот символ опорным (pivot):

Image represents a visual depiction of a pivot operation, likely within a sorting algorithm.  The top shows the word 'pivot' in light blue, with a downward-pointing arrow indicating the pivot element. Below, a horizontal sequence of elements is displayed: 'a', 'b', 'c', 'e', 'd', 'd', 'a'. Each element is associated with a numerical value (1, 2, 3, 5, 4, 4, 1) written beneath it in orange.  The element 'c' (with value 3) is highlighted in a peach-colored background, indicating it's the chosen pivot. The arrangement shows the initial state before the pivot operation, with the pivot element visually separated. The numbers likely represent the values being sorted, and the letters are placeholders or keys associated with those values. The image illustrates a single step in a sorting algorithm where the pivot is selected and the data is prepared for partitioning or comparison based on the pivot's value.

Если опорный символ не найден, строка уже является последней лексикографической последовательностью. В этом случае, как указывает условие задачи, нужно получить её первую лексикографическую перестановку. Это можно сделать, перевернув строку:

Image represents a simple illustration of a data reversal operation.  On the left, a sequence of four lowercase letters, 'd', 'c', 'b', and 'a', are arranged from left to right. A thick, black arrow connects this sequence to another sequence on the right. The arrow is labeled 'reverse' indicating the transformation applied. The sequence on the right shows the same letters, but in reversed order: 'a', 'b', 'c', and 'd', arranged from left to right. The diagram visually demonstrates how the 'reverse' operation transforms the input sequence into its reversed counterpart.  No URLs or parameters are present.

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

Чтобы увеличить символ в позиции опорного, нужно поменять опорный местами с более крупным символом справа.

В нашем примере с каким символом должен поменяться местами опорный ('c')? Мы хотим поменять его с символом, большим 'c', но ненамного больше, поскольку увеличение должно быть минимальным. Разберёмся, как найти такой символ.

Поскольку подстрока после опорного символа лексикографически невозрастающая, мы можем найти ближайший символ, больший 'c', обходя этот суффикс справа налево и останавливаясь на первом символе, большем него. Иными словами, мы ищем правого преемника опорного символа — 'd':

Image represents a diagram illustrating a concept likely related to data structures or algorithms, specifically focusing on finding a pivot and its rightmost successor.  The diagram shows two sequences of characters ('a', 'b', 'c', 'e', 'd', 'a') with associated numerical indices (1, 2, 3, 5, 4, 1, 4, 1) beneath them.  The character 'c' is highlighted in cyan and labeled 'pivot,' indicating it's a selected element. A downward-pointing arrow connects 'pivot' to 'c', showing its selection.  Separately, the text 'rightmost_successor' is shown above a downward-pointing arrow pointing to the second instance of 'd' in the sequence. This suggests that 'd' is identified as the rightmost successor of the pivot 'c' within the context of the algorithm or data structure being depicted. The arrangement implies a process where a pivot is chosen, and then a specific successor element is identified based on some criteria (in this case, the rightmost occurrence).

Теперь меняем местами опорный символ и правого преемника:

Image represents a visual depiction of a data transformation or rearrangement process.  The left side shows a sequence of characters ('a', 'b', 'c', 'e', 'd', 'a') each with a numerical subscript (1, 2, 3, 5, 4, 1 respectively).  The characters 'c' and 'a' are highlighted in cyan and black respectively.  These characters are connected by a curved arrow forming a loop, indicating a cyclical or iterative relationship. The arrow points from 'a' to 'c' and then back to 'a'. A straight arrow points from this looped sequence to a second sequence on the right. The right-side sequence is a rearrangement of the left-side sequence ('a', 'b', 'd', 'e', 'd', 'c', 'a'), maintaining the same numerical subscripts (1, 2, 4, 5, 4, 3, 1) but with a changed order.  The character 'c' remains highlighted in cyan, and 'a' in black, indicating that these elements retain their identity despite the shift in position within the sequence. The overall diagram illustrates a transformation where the order of elements changes, but the elements themselves and their associated numerical values remain consistent.

Символ в позиции опорного увеличился, поэтому для получения следующей перестановки нужно сделать подстроку после опорного как можно меньше. После показанного обмена видим, что подстрока «edca» не находится в наименьшей перестановке. Поэтому нужно минимизировать перестановку этой подстроки.

Важное наблюдение: после предыдущего обмена подстрока после опорного символа по-прежнему лексикографически невозрастающая.

Image represents a sequence of elements ('a', 'b', 'd', 'e', 'd', 'c', 'a') each associated with a numerical value (1, 2, 4, 5, 4, 4, 1 respectively) displayed below.  A downward-pointing arrow labeled 'pivot' in orange text points to the element 'd' (with value 4), indicating this element as a pivot point.  A horizontal arrow in orange text connects the elements 'e', 'd', 'c', 'a' (with values 5, 4, 4, 1 respectively), which are highlighted in a peach-colored background.  The text 'still non-increasing' describes the relationship between these highlighted elements, indicating their values are not increasing.  Another orange text phrase, 'make this as small as possible,' suggests an optimization goal: to minimize the range or size of this highlighted subsequence.  The overall diagram illustrates a concept related to finding a pivot point within a sequence and optimizing a subsequence based on a non-increasing condition.

Это означает, что можно минимизировать перестановку этой подстроки, перевернув её:

Image represents a sequence transformation process.  The input is a sequence 'a b d e d c a' with corresponding numerical values '1 2 3 5 4 3 1' displayed below. This input sequence is highlighted in a peach-colored rectangle. An arrow labeled 'reverse' indicates the transformation applied to the input. The output sequence is 'a c d e' with values '1 3 4 5' shown below, highlighted in a light green rectangle. The transformation 'reverse' implies that the input sequence within the peach rectangle is reversed, resulting in the output sequence within the green rectangle.  The numerical values associated with each element remain consistent before and after the reversal, indicating that the transformation only affects the order of the elements, not their associated numerical values.

И вот так мы нашли следующую лексикографическую последовательность! Стратегия двух указателей в этой задаче — поэтапный обход: сначала находим опорный символ, а затем правого преемника относительно него.

Поиск следующей лексикографической последовательности включает несколько шагов. Вот краткое изложение:

  1. Найдите опорный символ (pivot).
  • Опорный символ — это первый символ, нарушающий невозрастающую последовательность справа строки.
  • Если опорный символ не найден, строка уже находится в последней лексикографической перестановке, и результат — просто перевёрнутая строка.
  1. Найдите правого преемника опорного символа.
  2. Поменяйте местами правого преемника и опорный символ, чтобы увеличить лексикографический порядок суффикса.
  3. Переверните суффикс после опорного символа, чтобы минимизировать его перестановку.

Реализация

python
def next_lexicographical_sequence(s: str) -> str:
    letters = list(s)
    # Locate the pivot, which is the first character from the right that breaks
    # non-increasing order. Start searching from the second-to-last position.
    pivot = len(letters) - 2
    while pivot >= 0 and letters[pivot] >= letters[pivot + 1]:
        pivot -= 1
    # If pivot is not found, the string is already in its largest permutation. In
    # this case, reverse the string to obtain the smallest permutation.
    if pivot == -1:
        return ''.join(reversed(letters))
    # Find the rightmost successor to the pivot.
    rightmost_successor = len(letters) - 1
    while letters[rightmost_successor] <= letters[pivot]:
        rightmost_successor -= 1
    # Swap the rightmost successor with the pivot to increase the lexicographical
    # order of the suffix.
    letters[pivot], letters[rightmost_successor] = (letters[rightmost_successor], letters[pivot])
    # Reverse the suffix after the pivot to minimize its permutation.
    letters[pivot + 1:] = reversed(letters[pivot + 1:])
    return ''.join(letters)

Анализ сложности

Временная сложность: временная сложность next_lexicographical_sequence равна O(n), где n — длина входной строки, поскольку мы выполняем максимум два прохода по строке: один для нахождения опорного символа и другой для нахождения крайнего правого символа суффикса, большего по значению, чем опорный. Мы также выполняем одно обращение, которое занимает O(n) времени.

Пространственная сложность: пространственная сложность равна O(n) из-за пространства, занимаемого списком letters. В Python эта дополнительная память используется потому, что строки неизменяемы — для их изменения входная строка сохраняется в виде списка.

Тестовые случаи

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

InputExpected outputDescription
s = 'a''a'Tests a string with a single character.
s = 'aaaa''aaaa'Tests a string with a repeated character.
s = 'ynitsed''ynsdeit'Tests a string with a random pivot character.

Совет для собеседования

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

Глава 2

Hash Maps и множества

~22 мин чтения

Введение в Hash Maps и множества

Введение в Hash Maps и множества

Интуиция

Представьте, что вы работаете в продуктовом магазине. Покупатель спрашивает цену фрукта. Если у вас есть только бумажный список всех фруктов, вам придётся просматривать его, чтобы найти нужный — это может занять немало времени. Однако, зная список наизусть, вы мгновенно назовёте цену любого фрукта. Именно так работают Hash Maps, обеспечивая быстрый доступ к информации.

Hash Maps Hash Map — также известная как хеш-таблица или словарь, в зависимости от языка программирования — это структура данных, сопоставляющая ключи со значениями. Проиллюстрируем это на примере фруктов и их цен:

Image represents a simple data mapping or lookup table illustrating a price list for fruits.  The table is implicitly structured with two columns: 'Fruit' on the left and 'Price' on the right.  Three rows represent individual fruit items.  The 'Fruit' column lists the items: 'apple', 'bananas', and 'carrot', each written in a monospace font.  The 'Price' column displays the corresponding prices: 5.00, 4.50, and 2.75 respectively, also in a monospace font.  A grey horizontal arrow connects each fruit item in the left column to its associated price in the right column, visually representing the mapping between fruit and its price.  The overall arrangement suggests a straightforward key-value relationship, where the fruit name acts as the key and the price as the value.

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

Image represents a simple hashmap data structure, visually depicted as a table.  The table is labeled 'hashmap' at the top.  It consists of two columns: a 'key' column and a 'value' column. The key column contains string values representing fruit and vegetable names: 'apple,' 'bananas,' and 'carrot.'  The value column contains corresponding floating-point numbers representing numerical values, likely prices: 5.00, 4.50, and 2.75 respectively.  Each key-value pair is implicitly linked; for example, 'apple' is associated with 5.00.  The arrangement shows a direct mapping between keys and values, illustrating the fundamental principle of a hashmap:  efficiently storing and retrieving data based on a unique key.  The labels 'key' and 'value' at the bottom clarify the role of each column.

Hash Maps чрезвычайно эффективны для поиска, вставки и удаления: как правило, все эти операции выполняются за константное время O(1). Это одна из наиболее универсальных и широко используемых структур данных в информатике — для подсчёта частоты элементов, кэширования данных и многого другого.

Свойства Hash Maps:

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

Анализ временной сложности Ниже n обозначает количество записей в Hash Map.

OperationAverage caseWorst caseDescription
InsertO(1)O(1)O(n)O(n)Add a key-value pair to the hash map.
AccessO(1)O(1)O(n)O(n)Find or retrieve an element.
DeleteO(1)O(1)O(n)O(n)Delete a key-value pair.

На собеседованиях принято считать операции с Hash Map быстрыми со средней временной сложностью O(1), а не наихудшей. Это основано на предположении, что эффективная хеш-функция минимизирует коллизии [HashCollision]. Однако в наихудшем случае — когда плохо оптимизированная хеш-функция вызывает частые коллизии — временная сложность может деградировать до O(n), требуя линейного поиска по всем записям.

Хеш-множества Хеш-множества — упрощённая форма Hash Maps. Вместо пар ключ-значение они хранят только ключи. По аналогии с магазином: хеш-множество похоже на мысленный список фруктов без цен. Оно полезно для быстрой проверки наличия или отсутствия элемента — например, есть ли конкретный фрукт в наличии.

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

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

В условии задачи обращайте внимание на ключевые слова: «частота», «уникальный», «отображение», «словарь» или «быстрый поиск» — они часто указывают на то, что Hash Maps или множества могут оказаться полезными.

Ключевые концепции для освоения Hash Maps и множеств В этой главе рассматриваются практические применения Hash Maps и множеств. Для более полного понимания их устройства и эффективности изучите темы, выходящие за рамки главы, например:

  • Хеш-функции: изучите тонкости отображения ключей на конкретные значения в хеш-таблице [1].
  • Коллизии и методы их разрешения: разберитесь с такими подходами, как цепочки или открытая адресация [2].
  • Коэффициент заполнения и рехеширование: помогают понять, как хеш-таблицы растут и меняют размер [3].

Пример из реальной жизни

Кеш веб-браузера: Hash Maps и множества широко применяются в реальных системах. Классический пример — кеширующие системы в веб-браузерах. Когда вы посещаете сайт, браузер сохраняет данные — изображения, HTML и CSS — в кеше, чтобы при следующих посещениях загрузка происходила значительно быстрее.

Структура главы

Image represents a hierarchical diagram illustrating the application of Hash Maps and Sets in coding.  A rounded rectangle at the top, labeled 'Hash Maps and Sets,' serves as the root node.  From this root, a dashed line extends downwards, branching into two separate rectangular boxes with dashed borders. The left box is titled 'Hash maps' and lists three example applications: 'Pair Sum,' 'Unsorted,' and 'Geometric Sequence Triplets.' The right box is titled 'Hash sets' and lists three different applications: 'Verify Sudoku Board,' 'Zero Striping,' and 'Longest Chain of Consecutive Numbers.'  The arrangement shows that 'Hash Maps and Sets' is a broader category encompassing the specific use cases detailed under 'Hash maps' and 'Hash sets,' indicating how these data structures are employed in solving various coding problems.

Глава включает задачи, охватывающие наиболее популярные случаи применения Hash Maps и множеств, и направлена на улучшение вашего понимания и эффективности их использования.

Сумма пары — несортированный массив

Сумма пары — несортированный массив

Дан массив целых чисел. Верните индексы любых двух чисел, сумма которых равна целевому значению. Порядок индексов в результате не важен. Если пара не найдена, верните пустой массив.

Пример:

python
Input: nums = [-1, 3, 4, 2], target = 3
Output: [0, 2]

Пояснение: nums[0] + nums[2] = -1 + 4 = 3

Ограничения:

  • Один и тот же индекс нельзя использовать дважды в результате.

Интуиция

Наивный подход — перебрать все возможные пары в массиве и проверить, равна ли их сумма целевому значению. Это то же самое, что перебор в задаче «Сумма пары — отсортированный массив», с временной сложностью O(n²), где n — длина массива. Можно также отсортировать массив и применить алгоритм двух указателей из той задачи — это займёт O(n·log(n)) из-за сортировки. Посмотрим, можно ли найти ещё более быстрое решение.

Дополнение Нам нужно найти пару (x, y) такую, что x + y == target. В этом уравнении два неизвестных: x и y. Важное наблюдение: зная одно из чисел, мы легко вычислим второе.

Для каждого числа x из nums нужно найти другое число y такое, что x + y = target, иными словами, y = target - x. Это число называется дополнением x.

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

Один из способов — перебрать массив, чтобы найти дополнение каждого числа и соответствующий индекс. Но это займёт O(n²), поскольку для каждого числа потребуется линейный поиск его дополнения. Лучше иметь эффективный способ определить индекс любого числа без поиска по массиву. Есть ли подходящая структура данных?

Hash Map Hash Map идеально подходит, так как позволяет хранить и искать значения за O(1). Каждое число и его индекс можно хранить в Hash Map как пару ключ-значение:

Image represents a transformation of a numerical array into a hashmap.  The input is an array `[-1, 3, 4, 2]` with indices `[0, 1, 2, 3]` displayed beneath each element. A rightward arrow indicates the transformation process. The output is a hashmap labeled 'hashmap' which is represented as a table with two columns: 'num' and 'index'. The 'num' column lists the numbers from the input array: -1, 3, 4, and 2. The corresponding 'index' column shows the original index of each number in the input array: 0, 1, 2, and 3 respectively.  The diagram illustrates how the original array's elements and their indices are reorganized into a key-value pair structure within the hashmap, where the number acts as the key and its original index acts as the value.

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

Наиболее интуитивный способ использования Hash Map:

  1. В первом проходе заполнить Hash Map каждым числом и соответствующим индексом.
  2. Во втором проходе сканировать массив и проверять, существует ли дополнение каждого числа в Hash Map. Если да — вернуть индексы этого числа и его дополнения.

Ниже приведён код для этого двухпроходного подхода:

python
from typing import List
        
def pair_sum_unsorted_two_pass(nums: List[int], target: int) -> List[int]:
    num_map = {}
    # First pass: Populate the hash map with each number and its index.
    for i, num in enumerate(nums):
        num_map[num] = i
    # Second pass: Check for each number's complement in the hash map.
    for i, num in enumerate(nums):
        complement = target - num
        if complement in num_map and num_map[complement] != i:
            return [i, num_map[complement]]
    return []

Этот алгоритм требует двух проходов. Можно ли обойтись одним? Однопроходное решение означает, что нужно одновременно заполнять Hash Map и искать дополнения. Возможно ли это? Рассмотрим пример:

Image represents a sample input for a search algorithm, likely a target-sum problem.  The input consists of a numerical array [-1, 3, 4, 2] enclosed in square brackets, with each element implicitly indexed from 0 to 3 shown in grey below the array.  This array is separated by a comma from the target value, which is explicitly stated as 'target = 3'. The arrangement suggests the algorithm's task is to find a subset of numbers within the array that sums up to the target value of 3.  There is no explicit flow of information depicted; the image simply presents the input data for the algorithm to process.

Начнём с индекса 0. Его дополнение: 3 - (-1) = 4. Есть ли 4 в Hash Map? Нет, он пуст. Добавим -1 и его индекс в Hash Map:

Image represents a data flow diagram illustrating a step in a coding pattern, likely involving hashmaps.  An input array `[-1, 3, 4, 2]` with indices [0, 1, 2, 3] is shown. An orange arrow labeled 'x' points to the first element, -1. This array flows into a rectangular box labeled 'hashmap' with internal labels 'num' and 'index' suggesting it stores key-value pairs.  To the right of the hashmap is a dashed-line box detailing the processing steps:  first, it checks if the hashmap contains -1 (it doesn't); second, it calculates the complement of 4 (presumably within the context of the algorithm); and third, it adds the key-value pair (-1, 0) to the hashmap.  The diagram visually depicts the input array, its processing through a hashmap, and the specific operations performed on the hashmap based on the input.

Далее — индекс 1. Дополнение (0) отсутствует в Hash Map. Добавим 3 и его индекс:

Image represents a step-by-step illustration of a coding pattern involving a hashmap.  An input array `[-1, 3, 4, 2]` is shown, with a small orange arrow pointing downwards to the element '3' at index 1, indicating the current element being processed. This array is then processed, resulting in a hashmap represented as a table with two columns labeled 'num' and 'index'.  Currently, the hashmap contains only one entry: -1 at index 0. To the right of the hashmap is a dashed-line box containing three bullet points describing the processing logic:  '- hashmap doesn't contain 3's', indicating that the current element (3) is not yet in the hashmap; 'complement (0)', suggesting a calculation of the complement (likely 0 in this context); and '- add (3, 1) to hashmap', indicating that the pair (3, 1) – the element and its index – will be added to the hashmap as the next step.  The arrow between the array and the hashmap shows the data flow from the input array to the hashmap during processing.

На индексе 2 обнаруживаем, что дополнение 4 (то есть -1) уже есть в Hash Map. Значит, пара найдена:

Image represents a data transformation process.  An input array `[-1, 3, 4, 2]` with indices [0, 1, 2, 3] is shown.  A downward-pointing orange arrow labeled 'x' highlights the element '4' at index 2. This array is then transformed into a hashmap labeled 'hashmap' which has two key-value pairs. The key 'num' stores the number -1, and its corresponding value under the 'index' key is 3.  The second key-value pair has 'num' as 0 and 'index' as 1. A separate dashed-line box describes the hashmap's content, stating that it 'contains 4's complement (-1)'.  The arrow indicates the input array is processed to generate the hashmap, implying a mapping or counting operation where the complement of a number (in this case, 4's complement -1) and its index are stored.

Теперь можно вернуть индексы двух значений. Берём индекс 4 из входного массива и индекс его дополнения из Hash Map:

Image represents a visual depiction of a coding pattern, likely for finding pairs in an array that sum to a target value (here, implicitly zero).  The input is an array `[-1, 3, 4, 2]` with indices `0, 1, 2, 3` respectively, shown above a downward-pointing orange arrow labeled 'x', suggesting a target value or operation.  This array is processed, with the element '4' (at index 2) highlighted in light green.  A rightward arrow indicates the transformation of this array into a hashmap. The hashmap is represented as a box divided into two columns labeled 'num' and 'index'.  The 'num' column contains the unique numbers from the input array (-1 and 3), while the 'index' column shows their corresponding indices (0 and 1 respectively).  A curved grey arrow connects the highlighted '4' in the input array to the hashmap, implying that the algorithm uses the hashmap to store and retrieve information.  Finally, another curved grey arrow originates from the hashmap's 'index' column and points to `[0, 2]` labeled 'return', indicating that the algorithm returns the indices 0 and 2 as the solution, likely because -1 + 4 = 3 (or a similar logic involving the target value).  The overall diagram illustrates the use of a hashmap to efficiently find pairs with a specific sum within an array.

Реализация

python
from typing import List
   
def pair_sum_unsorted(nums: List[int], target: int) -> List[int]:
    hashmap = {}
    for i, x in enumerate(nums):
        if target - x in hashmap:
            return [hashmap[target - x], i]
        hashmap[x] = i
    return []

Анализ сложности

Временная сложность: O(n), поскольку мы однократно проходим по каждому элементу массива nums и выполняем операции с Hash Map за константное время.

Пространственная сложность: O(n), так как Hash Map может вырасти до размера n.

Совет для собеседования

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

Проверка доски судоку

Проверка доски судоку

Дана частично заполненная доска судоку 9×9. Определите, соответствует ли текущее состояние доски правилам игры:

  • Каждая строка и столбец должны содержать уникальные числа от 1 до 9, либо быть пустыми (обозначаются как 0).
  • Каждый из девяти квадратов 3×3 должен содержать уникальные числа от 1 до 9, либо быть пустым.

Примечание: требуется определить, является ли текущее состояние доски допустимым, а не разрешимым.

Пример:

Image represents a partially filled Sudoku grid with a highlighted 3x3 subgrid in light pink.  The grid contains various numbers (1-9) arranged in rows and columns. Two curved arrows originate from within the pink subgrid, specifically from the numbers '2' and '8' located in the same row and column respectively. These arrows point to a light gray rectangular box containing the text 'two 2s in the same row' stacked above 'two 8s in the same column'.  A further arrow extends from this gray box to the right, pointing to the text 'return False', indicating that the presence of two '2's in the same row and two '8's in the same column violates Sudoku rules, resulting in a 'False' return value.  The overall diagram illustrates a pattern recognition process within a Sudoku solver, where the algorithm detects rule violations based on the presence of duplicate numbers within rows and columns.
python
Output: False

Ограничения:

  • Считается, что каждое целое число на доске находится в диапазоне [0, 9].

Интуиция

Основная цель — проверить каждую строку, столбец и каждый из девяти квадратов 3×3 на наличие дубликатов. Сначала разберём механизм поиска дубликатов, затем применим его к строкам, столбцам и подсеткам.

Проверка на дубликаты Начнём с проверки одной строки доски на дубликаты:

Image represents a visual depiction of data transformation.  A 9x9 grid, resembling a partially completed Sudoku puzzle, is shown on the left.  The grid contains various single-digit numbers (1-9) arranged within its cells, with some cells remaining empty.  A specific row of the grid, highlighted in a light peach color, contains the numbers 1, 2, 5, 3, and 2. A rightward-pointing arrow connects this grid to a single-row, rectangular array on the right. This array contains the same numbers (1, 2, 5, 3, 2) as the highlighted row from the grid, arranged sequentially in individual cells, mirroring the order in the highlighted row. The image illustrates the extraction of a specific row of data from a two-dimensional structure (the Sudoku grid) into a one-dimensional structure (the array), effectively demonstrating a data transformation or extraction process.

Наивный способ — взять каждое число в строке и проверить, встречается ли оно ещё раз. Линейный поиск для каждого числа даст O(n²) для проверки одной строки — весьма неэффективно.

Хеш-множество улучшает временную сложность. Перебирая строку, мы отслеживаем ранее встреченные числа. При встрече нового числа проверяем его наличие в множестве за O(1). Если оно уже там — это дубликат:

Image represents a visual depiction of data being processed to create a hashset.
Image represents a visual depiction of a HashSet operation.
Image represents a visual depiction of a HashSet data structure operation.
Image represents a visual depiction of adding an element to a hash set.
Image represents a visual depiction of duplicate detection using a hash set.

Если завести по одному хеш-множеству для каждой из 9 строк, можно отслеживать дубликаты в каждой строке отдельно. Аналогично — для столбцов и подсеток: по одному множеству на каждый столбец и каждую подсетку. Задача — понять, какие хеш-множества соответствуют строке, столбцу и подсетке конкретной ячейки:

Image represents a 9x9 grid, subdivided into nine 3x3 subgrids.

Разберём, как определить строку, столбец и подсетку ячейки.

Определение строк и столбцов Строки определяются просто — у каждой есть индекс. То же касается столбцов. Поэтому создадим массив из 9 хеш-множеств — по одному на строку — и обращаемся к нужному множеству по индексу строки. Аналогично для столбцов.

Image represents two Python variable assignments for row_sets and col_sets.

Определение подсеток Подсетки представляют собой интересную задачу: в отличие от строк и столбцов, принадлежность ячейки к подсетке не очевидна из её индекса.

Тем не менее, как и строк и столбцов, подсеток тоже 9. Визуализируя подсетки, видим их в виде сетки 3×3:

Image represents a 9x9 grid, visually divided into nine 3x3 subgrids.

Нам нужен способ индексировать подсетки как матрицу 3×3. Для этого необходимо преобразовать индексы от 0 до 8 в соответствующие скорректированные индексы от 0 до 2:

Image represents a 3x3 grid illustrating a data partitioning or mapping scheme.

Поскольку скорректированные индексы получаются сжатием сетки 9×9 до 3×3 — то есть делением числа строк и столбцов на 3 — новые индексы подсетки для строки и столбца получаются целочисленным делением на 3:

Image represents three separate but similar computational processes showing division by 3.

С этими скорректированными индексами девять хеш-множеств организуются в таблицу 3×3 — по одному на каждую подсетку. Доступ к множеству подсетки для любой ячейки осуществляется через скорректированные индексы: subgrid_sets[r // 3][c // 3] (оператор // выполняет целочисленное деление).

Однопроходная проверка судоку Теперь у нас есть всё необходимое для однопроходного решения. Инициализируем хеш-множества: 9 для строк, 9 для столбцов и 9 для подсеток в виде массива 3×3.

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

  • Если число уже есть в любом из этих множеств — вернуть false.
  • Иначе — добавить его в соответствующие множества строки, столбца и подсетки.

Это позволяет отслеживать числа в каждой строке, столбце и подсетке. Если удалось обойти всю доску без дубликатов — доска допустима, возвращаем true.

Реализация

python
from typing import List
  
def verify_sudoku_board(board: List[List[int]]) -> bool:
    # Create hash sets for each row, column, and subgrid to keep track of numbers
    # previously seen on any given row, column, or subgrid.
    row_sets = [set() for _ in range(9)]
    column_sets = [set() for _ in range(9)]
    subgrid_sets = [[set() for _ in range(3)] for _ in range(3)]
    for r in range(9):
        for c in range(9):
            num = board[r][c]
            if num == 0:
                continue
            # Check if 'num' has been seen in the current row, column, or subgrid.
            if num in row_sets[r]:
                return False
            if num in column_sets[c]:
                return False
            if num in subgrid_sets[r // 3][c // 3]:
                return False
            # If we passed the above checks, mark this value as seen by adding it to
            # its corresponding hash sets.
            row_sets[r].add(num)
            column_sets[c].add(num)
            subgrid_sets[r // 3][c // 3].add(num)
    return True

Анализ сложности

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

Временная сложность: O(n²), поскольку однократно проходим по каждой ячейке доски и выполняем операции с хеш-множествами за константное время.

Пространственная сложность: O(n²) из-за массивов row_sets, column_sets и subgrid_sets. Каждый содержит n хеш-множеств, каждое из которых может вырасти до размера n.

Зачёркивание нулей

Зачёркивание нулей

Для каждого нуля в матрице m×n обнулите всю его строку и столбец на месте.

Image represents a transformation of a 4x5 matrix.

Интуиция — хеш-множества

Грубый подход: сначала запомнить позиции всех нулей, затем для каждого из них пройти по его строке и столбцу, обнуляя их. Однако в наихудшем случае, при множестве нулей, это займёт O(m·n·(m+n)), где m·n — количество нулей, а (m+n) — общее число ячеек в строке и столбце. Подход неэффективен — ищем лучшее решение.

Рассмотрим любую ячейку матрицы. После преобразования она либо сохранит исходное значение, либо станет нулём. Можно ли заранее определить, станет ли ячейка нулём?

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

Можно проверять строку и столбец каждой ячейки на наличие нуля — каждая проверка займёт O(m+n). Но эффективнее делать это за константное время с помощью хеш-множеств. Создадим два множества: одно для строк с нулями, другое для столбцов — и будем проверять принадлежность за O(1).

Заполняем множества в ходе обхода матрицы: при встрече нулевой ячейки:

  • Добавляем индекс её строки в zero_rows.
  • Добавляем индекс её столбца в zero_cols.
Image represents a 4x5 matrix with zero_rows and zero_cols sets.

Затем находим ячейки, чьи индексы строки или столбца присутствуют в соответствующих множествах, и обнуляем их:

Image represents a step-by-step illustration of a coding pattern related to setting matrix elements to zero.
Image represents a transformation of a 4x5 matrix based on zero_cols.
Image represents a coding pattern illustration where a cell is not set to zero.

Общая стратегия:

  1. В одном проходе матрицы находим каждую нулевую ячейку и добавляем её строку и столбец в zero_rows и zero_cols соответственно.
  2. Во втором проходе обнуляем ячейку, если её строка есть в zero_rows или столбец в zero_cols.

Реализация — хеш-множества

python
from typing import List
    
def zero_striping_hash_sets(matrix: List[List[int]]) -> None:
    if not matrix or not matrix[0]:
        return
    m, n = len(matrix), len(matrix[0])
    zero_rows, zero_cols = set(), set()
    # Pass 1: Traverse through the matrix to identify the rows and columns
    # containing zeros and store their indexes in the appropriate hash sets.
    for r in range(m):
        for c in range(n):
            if matrix[r][c] == 0:
                zero_rows.add(r)
                zero_cols.add(c)
    # Pass 2: Set any cell in the matrix to zero if its row index is in 'zero_rows'
    # or its column index is in 'zero_cols’.
    for r in range(m):
        for c in range(n):
            if r in zero_rows or c in zero_cols:
                matrix[r][c] = 0

Анализ сложности

Временная сложность: O(m·n) — два прохода по матрице с константными операциями в каждом.

Пространственная сложность: O(m+n) из-за хеш-множеств: одно растёт пропорционально строкам, другое — столбцам. В наихудшем случае каждая строка и каждый столбец содержат ноль.

Интуиция — отслеживание нулей на месте

Предыдущее решение было эффективным по времени благодаря хеш-множествам. Но это потребовало дополнительной памяти. Можно ли отслеживать строки и столбцы с нулями иначе?

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

Стратегия: использовать первую строку и первый столбец (строку 0 и столбец 0) в качестве маркеров.

Для строк: если в строке есть ноль, устанавливаем соответствующую ячейку в первом столбце в ноль. Этот ноль служит маркером, указывающим, что всю строку нужно обнулить.

Image represents a visual explanation using the first column to mark rows that have zeros.

Аналогично маркируем столбцы с нулями с помощью первой строки:

Image represents a visual explanation using the first row to mark columns that have zeros.

Для простановки маркеров обходим подматрицу — всю матрицу, кроме первой строки и первого столбца. При обнаружении нуля устанавливаем соответствующую ячейку первой строки и первого столбца в ноль:

Image represents a 4x5 matrix showing marker placement in first row and column.

Теперь обнуляем ячейки подматрицы на основе маркеров. Для каждой ячейки проверяем:

  • Равен ли нулю соответствующий маркер в первом столбце.
  • Равен ли нулю соответствующий маркер в первой строке.

Если выполняется хотя бы одно условие — обнуляем ячейку:

Image represents a visual explanation of setting a cell to zero based on the marker.

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

Image represents a transformation of a 4x5 matrix where submatrix cells are zeroed.

Обработка нулей в первой строке и столбце После предыдущего шага остаётся один нюанс. Что если первая строка или столбец изначально содержали ноль?

Image represents a 4x5 matrix with a zero in the first row.

В этом случае нельзя различить: ноль в первой строке был там изначально или является маркером. Значит, мы не поймём, нужно ли обнулять первую строку:

Image represents ambiguity between marker zeros and original zeros in first row.

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

Image represents first_row_has_zero and first_col_has_zero boolean flags.

После простановки маркеров в первой строке и столбце (матрица X) и обнуления подматрицы (матрица Y), отдельно обрабатываем первую строку и первый столбец. Первая строка изначально содержала ноль — обнуляем её (матрица Z). Первый столбец нулей не содержал — оставляем как есть:

Image represents step-by-step transformation of matrices X, Y, Z.

Стратегия обнуления на месте Обобщим подход в следующие шаги:

  1. Флагом отметить, содержит ли первая строка хотя бы один ноль.
  2. Флагом отметить, содержит ли первый столбец хотя бы один ноль.
  3. Обойти подматрицу: при нахождении нуля выставить маркеры в первой строке и первом столбце.
  4. Применить нули по маркерам: пройти по подматрице, начиная со второй строки и второго столбца. Для каждой ячейки проверить маркер в первой строке или столбце. Если маркер равен нулю — обнулить ячейку.
  5. Если первая строка изначально содержала ноль — обнулить все элементы первой строки.
  6. Если первый столбец изначально содержал ноль — обнулить все элементы первого столбца.

Реализация — отслеживание нулей на месте

python
from typing import List
   
def zero_striping(matrix: List[List[int]]) -> None:
   if not matrix or not matrix[0]:
       return
   m, n = len(matrix), len(matrix[0])
   # Check if the first row initially contains a zero.
   first_row_has_zero = False
   for c in range(n):
       if matrix[0][c] == 0:
           first_row_has_zero = True
           break
   # Check if the first column initially contains a zero.
   first_col_has_zero = False
   for r in range(m):
       if matrix[r][0] == 0:
           first_col_has_zero = True
           break
   # Use the first row and column as markers. If an element in the submatrix is zero,
   # mark its corresponding row and column in the first row and column as 0.
   for r in range(1, m):
       for c in range(1, n):
           if matrix[r][c] == 0:
               matrix[0][c] = 0
               matrix[r][0] = 0
   # Update the submatrix using the markers in the first row and column.
   for r in range(1, m):
       for c in range(1, n):
           if matrix[0][c] == 0 or matrix[r][0] == 0:
               matrix[r][c] = 0
   # If the first row had a zero initially, set all elements in the first row to
   # zero.
   if first_row_has_zero:
       for c in range(n):
           matrix[0][c] = 0
   # If the first column had a zero initially, set all elements in the first column
   # to zero.
   if first_col_has_zero:
       for r in range(m):
           matrix[r][0] = 0

Анализ сложности

Временная сложность: O(m·n). Вот почему:

  • Проверка первой строки на нули — O(m), первого столбца — O(n).
  • Затем два прохода по всей матрице — O(m·n) каждый.
  • Наконец, однократный проход по первой строке O(m) и первому столбцу O(n).

Итоговая сложность: O(m)+O(n)+O(m·n) = O(m·n).

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

Наидлиннейшая цепочка последовательных чисел

Наидлиннейшая цепочка последовательных чисел

Найдите наидлиннейшую цепочку последовательных чисел в массиве. Два числа являются последовательными, если их разность равна 1.

Пример:

python
Input: nums = [1, 6, 2, 5, 8, 7, 10, 3]
Output: 4

Пояснение: наидлиннейшая цепочка последовательных чисел — 5, 6, 7, 8.

Интуиция

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

Image represents a visual depiction of a sorting algorithm's effect on an array of numbers.

Этот подход требует сортировки за O(n·log(n)), где n — длина массива. Попробуем сделать лучше.

Важно понимать: каждое число в массиве может быть началом некоторой последовательной цепочки. Один подход — считать каждое число началом цепочки и искать её продолжение в массиве.

Для этого воспользуемся тем, что следующее число в цепочке всегда равно num + 1. Код для такого подхода:

python
from typing import List
     
def longest_chain_of_consecutive_numbers_brute_force(nums: List[int]) -> int:
    if not nums:
        return 0
    longest_chain = 0
    # Look for chains of consecutive numbers that start from each number.
    for num in nums:
        current_num = num
        current_chain = 1
        # Continue to find the next consecutive numbers in the chain.
        while (current_num + 1) in nums:
            current_num += 1
            current_chain += 1
        longest_chain = max(longest_chain, current_chain)
    return longest_chain

Этот грубый подход занимает O(n³) из-за вложенных операций:

  • Внешний цикл for перебирает каждый элемент — O(n).
  • Для каждого элемента внутренний цикл while может выполниться до n итераций при длинной последовательности.
  • На каждой итерации while выполняется проверка наличия следующего числа — O(n).

Это медленнее подхода с сортировкой, но можно применить ряд оптимизаций.

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

Это снижает сложность с O(n³) до O(n²).

Оптимизация — определение начала цепочки В грубом подходе каждое число считается началом цепочки. Это дорого: для каждого числа выполняется линейный поиск:

Image represents a diagram illustrating a search for a consecutive chain of numbers.

Ключевое наблюдение: не нужно выполнять этот поиск для каждого числа в цепочке. Достаточно делать это только для наименьшего числа — оно определяет начало цепочки:

Image represents a diagram illustrating the concept of finding a consecutive chain within a sequence.

Определить, является ли число наименьшим в своей цепочке, можно, проверив отсутствие числа curr_num - 1 в массиве. Для этой проверки тоже используем хеш-множество.

Image represents a step-by-step illustration of checking whether a number is the start of its chain.

Это снижает сложность с O(n²) до O(n): теперь каждая цепочка обрабатывается только один раз.

Реализация

python
from typing import List
        
def longest_chain_of_consecutive_numbers(nums: List[int]) -> int:
    if not nums:
        return 0
    num_set = set(nums)
    longest_chain = 0
    for num in num_set:
        # If the current number is the smallest number in its chain, search for
        # the length of its chain.
        if num - 1 not in num_set:
            current_num = num
            current_chain = 1
            # Continue to find the next consecutive numbers in the chain.
            while current_num + 1 in num_set:
                current_num += 1
                current_chain += 1
            longest_chain = max(longest_chain, current_chain)
    return longest_chain

Анализ сложности

Временная сложность: O(n). Несмотря на два цикла, внутренний выполняется только при нахождении начала цепочки. Это гарантирует однократный обход каждой цепочки во внутреннем while. Суммарное число итераций обоих циклов — O(n): внешний for выполняется n раз, внутренний while в сумме по всем итерациям — n раз. Итого O(n+n) = O(n).

Пространственная сложность: O(n), так как хеш-множество хранит каждое уникальное число из массива.

Тройки геометрической прогрессии

Тройки геометрической прогрессии

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

Рассмотрим три тройки:

  • (1, 2, 4): геометрическая прогрессия со знаменателем 2 (то есть [1, 1·2 = 2, 2·2 = 4]).
  • (5, 15, 45): геометрическая прогрессия со знаменателем 3 (то есть [5, 5·3 = 15, 15·3 = 45]).
  • (2, 3, 4): не является геометрической прогрессией.

Дан массив целых чисел и знаменатель r. Найдите все тройки индексов (i, j, k), образующих геометрическую прогрессию при i < j < k. Дубликаты троек допустимы.

Пример:

Image represents five rows demonstrating a geometric sequence.
python
Input: nums = [2, 1, 2, 4, 8, 8], `r` = 2
Output: 5

Пояснение:

  • Тройка [2, 4, 8] встречается на индексах (0, 3, 4), (0, 3, 5), (2, 3, 4), (2, 3, 5).
  • Тройка [1, 2, 4] встречается на индексах (1, 2, 3).

Интуиция

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

  1. Три значения образуют геометрическую прогрессию со знаменателем r.
  2. Три значения тройки должны встречаться в массиве в том же порядке, что и в прогрессии. То есть для тройки (nums[i], nums[j], nums[k]) индексы должны удовлетворять i < j < k.

Как представить геометрическую прогрессию, удовлетворяющую правилу 1? Пусть первое число равно x. Второе — x·r, третье — x·r·r = x·r². Тройку можно записать как (x, x·r, x·r²).

Грубый подход — перебрать все возможные тройки и проверить каждую. Это потребует трёх вложенных циклов — O(n³), где n — длина массива. Можно ли сделать лучше?

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

Все три значения связаны через знаменатель r. Для любого числа x нужно найти x·r и x·r². Однако использование представления (x, x·r, x·r²) может вызвать проблемы: хотя x·r и x·r² должны стоять правее x, важен их порядок — нельзя случайно принять за тройку (x, x·r², x·r):

Image represents a diagram illustrating an ordering problem.

Эту проблему решает представление (x/r, x, x·r): порядок всегда соблюдается — ищем x/r слева от x и x·r справа:

Image represents a comparison of two search operations within a data structure.

Значения x/r и x·r можно искать линейным поиском в левом и правом подмассивах — O(n) для каждого числа, итого O(n²). Это лучше грубого подхода, но можно ещё быстрее.

Hash Maps Hash Map идеально подходит: позволяет искать конкретные значения за константное время в среднем.

Нам потребуются два Hash Map:

  • left_map — числа левее текущего x.
  • right_map — числа правее текущего x.
Image represents a diagram illustrating left_map and right_map data structures.

Hash Maps позволяют запрашивать x/r из left_map и x·r из right_map за константное время. Hash Map предпочтительнее хеш-множества, так как хранит частоту каждого значения — это важно при дубликатах в массиве.

Поиск всех троек (x/r, x, x·r) Цель — найти все тройки геометрической прогрессии, рассматривая каждое число в массиве как средний элемент (x).

Перед поиском x/r нужно проверить, делится ли x на r. Если нет — тройку из текущего x построить невозможно. Иначе ищем тройку.

Для любого x может быть несколько экземпляров x/r в left_map и x·r в right_map — значит, несколько троек с x в центре. Для подсчёта перемножаем частоты x·r и x/r:

Image represents a diagram illustrating multiplying frequencies of x/r and x*r.

Общая методология:

Image represents a flowchart illustrating a coding pattern for counting triplets.

Если x/r или x·r не найдены в соответствующих Hash Map — их частота по умолчанию равна 0.

Применим стратегию на примере:

Image represents the input array [2, 1, 2, 4, 8, 8] with r = 2.

Для корректности Hash Map нужна динамическая стратегия с обновлением по ходу обхода, поскольку содержимое обоих Hash Map меняется в зависимости от позиции x.

Поскольку обходим массив слева направо, изначально заполним right_map всеми значениями массива — до начала итерации каждый элемент является потенциальным x·r. left_map изначально пуст — предшествующих элементов ещё нет:

Image represents initial state of left_map and right_map.

Начинаем поиск троек. Рассматриваем первое значение как средний элемент (x).

Сначала обновляем right_map: удаляем текущее значение (2), поскольку оно не находится правее себя. В right_map два экземпляра 2 — уменьшаем частоту до 1:

Image represents updating right_map when processing x=2.

Проверяем, является ли x/r целым числом. Да — ищем количество троек с x в центре, умножая частоты x/r и x·r из соответствующих Hash Map. Поскольку left_map пуст, частота x/r равна 0:

Image represents the calculation for x=2 with empty left_map.

Перед переходом к следующему элементу добавляем текущее число в left_map — теперь оно становится потенциальным x/r для будущих троек:

Image represents state of left_map and right_map after processing first element.

Повторяя этот процесс для всего массива, находим все тройки геометрической прогрессии со знаменателем r. В следующих диаграммах Hash Map отображают своё состояние в текущей позиции x: left_map содержит значения левее x, right_map — правее.

Image represents processing x=1 where x%r != 0.
Image represents processing x=2 (second occurrence) finding 1 triplet.
Image represents processing x=4 finding 4 triplets.
Image represents processing x=8 (first occurrence).
Image represents processing x=8 (second occurrence).

Реализация

python
from typing import List
from collections import defaultdict
    
def geometric_sequence_triplets(nums: List[int], r: int) -> int:
    # Use 'defaultdict' to ensure the default value of 0 is returned when
    # accessing a key that doesn’t exist in the hash map. This effectively sets
    # the default frequency of all elements to 0.
    left_map = defaultdict(int)
    right_map = defaultdict(int)
    count = 0
    # Populate 'right_map' with the frequency of each element in the array.
    for x in nums:
        right_map[x] += 1
    # Search for geometric triplets that have x as the center.
    for x in nums:
        # Decrement the frequency of x in 'right_map' since x is now being
        # processed and is no longer to the right.
        right_map[x] -= 1
        if x % r == 0:
            count += left_map[x // r] * right_map[x * r]
        # Increment the frequency of x in 'left_map' since it'll be a part of the
        # left side of the array once we iterate to the next value of `x`.
        left_map[x] += 1
    return count

Анализ сложности

Временная сложность: O(n), поскольку однократно проходим по массиву nums и выполняем операции с Hash Map за константное время на каждой итерации.

Пространственная сложность: O(n), так как Hash Map могут вырасти до размера n.

Глава 3

Связные списки

~20 мин чтения

Введение в связные списки

Введение в связные списки

Интуиция

Связный список — структура данных, представляющая собой последовательность узлов, где каждый узел ссылается на следующий. Узел связного списка имеет два основных компонента: хранимые данные (val) и ссылку на следующий узел (next) в последовательности:

The image represents a single node in a linked list data structure.

Узел определяется с помощью класса ListNode:

python
class ListNode:
   def __init__(self, val: int, next: ListNode):
       self.val = val
       self.next = next

Односвязный список

Простейшая форма связного списка — односвязный список, где каждый узел указывает на следующий, а последний узел указывает на null, обозначая конец списка. Начало списка называется «головой» (head) — как правило, это единственный узел, к которому у нас изначально есть прямой доступ:

Image represents a singly linked list data structure.

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

Image represents a singly linked list data structure traversal.

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

Image represents a visual explanation of inserting a node into a linked list.

Эффективность этих операций достигается ценой невозможности произвольного доступа: узлы нельзя получить по индексу, как в массиве. Этот компромисс оправдан во многих сценариях, где преимущества динамического размера и эффективности вставки/удаления перевешивают выгоды произвольного доступа.

Двусвязный список Двусвязный список — расширенная версия, где каждый узел содержит две ссылки: на следующий узел (next) и на предыдущий (prev). В большинстве реализаций двусвязный список предоставляет прямой доступ как к головному, так и к хвостовому узлу.

Image represents a doubly linked list data structure.

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

Image represents a doubly linked list before and after the deletion of a node.

Работа с указателями

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

Image represents a visual depiction of inserting a new node into a linked list.

Пример из реальной жизни

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

Структура главы

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

Image represents a hierarchical diagram illustrating different coding patterns related to linked lists.

Разворот связного списка

Разворот связного списка

Разверните односвязный список.

Пример:

Image represents two directed acyclic graphs showing reversal.

Интуиция — итеративный подход

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

Рассмотрим задачу через призму манипуляций с указателями. Ключевое наблюдение: если «перевернуть» направление указателей, мы фактически разворачиваем список:

Image represents a visual illustration of a sequence reversal.

Теперь нужно понять, как выполнить эту манипуляцию с указателями. Рассмотрим пример:

Image represents a simple directed acyclic graph with three nodes.

Для разворота направления всех указателей перебираем узлы один за другим. В процессе нужен доступ к текущему узлу (curr_node) и предыдущему (prev_node), чтобы изменить указатель next текущего узла на предыдущий. Изначально prev_node указывает на null, так как у первого узла нет предшественника:

Image represents curr_node.next = prev_node operation.
Image represents reversing pointer from node 1 to null.

Для перемещения указателей curr_node и prev_node на один узел вперёд нужно сдвинуть prev_node на позицию curr_node. Однако переместить curr_node к узлу 2 не получится — мы потеряли ссылку на него:

Image represents lost reference to node 2 in linked list reversal.

Это означает, что нужно было сохранить ссылку на узел 2 до разворота curr_node. Создадим переменную next_node и присвоим ей curr_node.next. Теперь можно сдвинуть prev_node и curr_node вперёд на один узел:

Image represents prev_node = curr_node and curr_node = next_node operations.
Image represents updated pointers after first reversal step.

Обратите внимание: next_node не нужно сдвигать — он будет установлен через curr_node.next на следующей итерации.

Логику можно обобщить в три шага. На каждом узле связного списка:

  1. Сохранить ссылку на следующий узел (next_node = curr_node.next).
  2. Изменить указатель next текущего узла на предыдущий (curr_node.next = prev_node).
  3. Сдвинуть prev_node и curr_node вперёд на один узел (prev_node = curr_node, curr_node = next_node).

Повторяем эти шаги для остальных узлов:

Image represents steps 1 and 2 of reversal at node 2.
Image represents state after reversing pointer at node 2.
Image represents step 3: prev_node = curr_node; curr_node = next_node.
Image represents pointers at node 3.
Image represents steps 1 and 2 of reversal at node 3.
Image represents state after reversing pointer at node 3.
Image represents step 3 at node 3.
Image represents final state with curr_node at null.

Разворот останавливается, когда curr_node становится null — это означает, что больше нет узлов для обработки.

Последний шаг — вернуть голову развёрнутого списка, на которую указывает prev_node в момент, когда curr_node равен null.

Реализация — итеративный подход

python
from ds import ListNode
  
def linked_list_reversal(head: ListNode) -> ListNode:
    curr_node, prev_node = head, None
    # Reverse the direction of each node's pointer until 'curr_node' is null.
    while curr_node:
        next_node = curr_node.next
        curr_node.next = prev_node
        prev_node = curr_node
curr_node = next_node # 'prev_node' will be pointing at the head of the reversed linked list.
return prev_node

Анализ сложности

Временная сложность: O(n), где n — длина связного списка. Выполняются константные манипуляции с указателями в каждом узле.

Пространственная сложность: O(1).

Интуиция — рекурсивный подход

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

В рекурсивном подходе задача решается через меньшие экземпляры той же задачи. Для этого используем функцию linked_list_reversal в её собственной реализации. Рекурсивные вызовы продолжаются до достижения наименьшей версии задачи.

Наименьшая версия — разворот списка размером 0 или 1. Такие списки совпадают со своим разворотом. Это и есть базовые случаи.

Попробуем построить логику рекурсивной функции на примере:

Image represents a linked list with head pointing to nodes 1, 2, 3, 4.

Подумаем, какую подзадачу решать. Для разворота всего списка воспользуемся linked_list_reversal для разворота подсписка после текущего узла. Тогда нужно только развернуть указатель текущего узла.

Сначала рекурсивно вызовем linked_list_reversal для подсписка, начинающегося с head.next. Предположим, что вызов разворачивает подсписок и возвращает его голову.

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

Image represents recursive call on head.next.
Image represents new_head pointing to reversed sublist.

Далее нужно, чтобы хвост развёрнутого подсписка (узел 2) указывал на узел 1. Узел 2 доступен через head.next. Достаточно установить head.next.next = head:

Image represents head.next.next = head operation.
Image represents the linked list after head.next.next = head.

Список почти полностью развёрнут, но узел 1 всё ещё указывает на узел 2. Чтобы убрать эту ссылку, установим head.next = null. Затем вернём new_head — голову развёрнутого списка:

Image represents head.next = null operation.
Image represents return new_head of reversed list.

Реализация — рекурсивный подход

python
from ds import ListNode
   
def linked_list_reversal_recursive(head: ListNode) -> ListNode:
    # Base cases.
    if (not head) or (not head.next):
        return head
    # Recursively reverse the sublist starting at the next node.
    new_head = linked_list_reversal_recursive(head.next)
    # Connect the reversed sublist to the head node to fully reverse the entire linked list.
    head.next.next = head
    head.next = None
    return new_head

Анализ сложности

Временная сложность: O(n) — однократный рекурсивный обход списка с посещением каждого узла ровно один раз.

Пространственная сложность: O(n) из-за стека рекурсивных вызовов, который растёт до n уровней в глубину.

Совет для собеседования

Совет: визуализируйте манипуляции с указателями. При работе со связными списками зачастую сложно сразу понять, что нужно сделать. Рисование указателей в виде стрелок между узлами очень помогает. Наблюдая за тем, как нужно переориентировать стрелки для отражения изменений в структуре, можно вывести логику манипуляций с указателями. Этот подход также помогает определить, к каким узлам нужны ссылки при внесении изменений.

Удаление k-го узла с конца связного списка

Удаление k-го узла с конца связного списка

Верните голову односвязного списка после удаления k-го узла с конца.

Пример:

Image represents removing the kth last node from a linked list.

Ограничения:

  • Связный список содержит хотя бы один узел.

Интуиция

Разобьём задачу на две цели:

  1. Найти позицию k-го узла с конца.
  2. Удалить этот узел.

Сначала поймём, как работает удаление узла. Рассмотрим пример, где нужно удалить узел b. Для этого нужен доступ к предшествующему узлу (a), чтобы перенаправить его указатель next через узел b. Так узел b становится недостижимым при обходе списка:

Image represents prev.next = prev.next.next operation for node removal.
Image represents a state after node removal.

Значит, нужно найти узел, непосредственно предшествующий k-му узлу с конца, чтобы удалить его.

Наивное решение: сначала определить длину списка (n), обходя его. Затем по длине вычислить количество шагов до узла перед k-м с конца — это n - k - 1 шагов. Решение требует двух циклов — нет ли более элегантного подхода?

Сложность с обходом односвязного списка в одном цикле: не зная длины списка, трудно понять, как далеко мы от последнего узла. Единственный способ узнать — достичь последнего узла, у которого next равен null. Как воспользоваться этой информацией?

Рассмотрим использование двух указателей. Можно ли создать сценарий, где к моменту достижения одним указателем конца списка другой окажется перед k-м узлом с конца? Изучим логику на примере:

Image represents linked list [1,2,4,7,3] with k=2.

Назовём первый указатель leader, второй — trailer. Когда leader достигнет последнего узла, trailer должен быть у узла 4 (узел перед k-м с конца). Иными словами, leader должен быть на k узлов впереди trailer, когда достигнет последнего узла.

Image represents leader k=2 nodes ahead of trailer.

Для этого сначала продвинем leader на k шагов. Когда leader на k узлов впереди trailer, двигаем оба указателя одновременно до достижения leader последнего узла.

Важный граничный случай: а что если нужно удалить сам head? В таком случае перед ним нет узла для перенаправления указателя. Чтобы обойти это, создадим фиктивный узел (dummy) перед head и начнём обход с него.

Image represents dummy node before head with trailer and leader starting there.

Применим стратегию на примере.

Сначала продвинем leader на k (2) шагов:

Image represents leader advanced k=2 steps from dummy.

С leader на k узлов впереди двигаем оба указателя до достижения leader последнего узла:

Image represents both pointers moving together.
Image represents trailer at node 2, leader at node 7.
Image represents trailer at node 4 (ideal position), leader at node 3.

Указатель trailer находится в нужной позиции — удаляем узел 7:

Image represents trailer.next = trailer.next.next to remove kth node.
Image represents linked list after removing node 7.

После удаления возвращаем dummy.next — голову изменённого списка.

Реализация

python
from ds import ListNode
    
def remove_kth_last_node(head: ListNode, k: int) -> ListNode:
    # A dummy node to ensure there's a node before 'head' in case we need to remove
    # the head node.
    dummy = ListNode(-1)
    dummy.next = head
    trailer = leader = dummy
    # Advance 'leader' k steps ahead.
    for _ in range(k):
        leader = leader.next
        # If k is larger than the length of the linked list, no node needs to be
        # removed.
        if not leader:
            return head
    # Move 'leader' to the end of the linked list, keeping 'trailer' k nodes behind.
    while leader.next:
        leader = leader.next
        trailer = trailer.next
    # Remove the kth node from the end.
    trailer.next = trailer.next.next
    return dummy.next

Анализ сложности

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

Пространственная сложность: O(1).

Пересечение связных списков

Пересечение связных списков

Верните узел, в котором пересекаются два односвязных списка. Если пересечения нет — верните null.

Пример:

Image represents two linked lists A and B intersecting at node 8.
python
Output: Node 8

Интуиция

Сначала разберёмся, что такое пересечение двух связных списков.

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

Это пересечение не связано со значениями узлов.

Наивный подход — использовать хеш-множество. Обойти первый список, сохраняя каждый узел в множество. Затем обходить второй список до нахождения первого узла из множества — это точка пересечения. Подход работает за линейное время, но можно ли решить задачу с константной памятью?

Рассмотрим пример:

Image represents two linked lists merging at common tail nodes.

Рассматривать их как два отдельных списка удобнее так: визуализируем входные данные раздельно, помня, что хвостовые узлы по-прежнему общие:

Image represents two linked lists with same tail nodes.

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

Image represents two equal-length lists with pointers reaching intersection simultaneously.

Можно ли воспроизвести это поведение для списков разной длины? Ключевое наблюдение: хотя списки A и B могут быть разной длины, «список A → список B» имеет ту же длину, что и «список B → список A». При этом оба объединённых списка разделяют одинаковые хвостовые узлы:

Image represents A→B and B→A combined lists of equal length.

Теперь есть сценарий с двумя объединёнными списками одинаковой длины, разделяющими хвостовые узлы. Обходя их, мы одновременно достигнем узла пересечения (если он существует).

Для этого обходим оба объединённых списка двумя указателями и останавливаемся, когда узлы под обоими указателями совпадут — это и есть узел пересечения:

Image represents ptr_A and ptr_B traversing A→B and B→A to find intersection.

Если пересечения нет, оба указателя остановятся на null:

Image represents both pointers reaching null when no intersection.

Обход объединённых списков Важное наблюдение: для обхода «список A → список B» не нужно физически соединять их. Достаточно обойти список A и по достижении его конца продолжить обход списка B:

Image represents traversal strategy: traverse A then B for ptr_A.

Этот приём позволяет обходить оба списка последовательно, как если бы они были соединены.

Реализация

python
from ds import ListNode
   
def linked_list_intersection(head_A: ListNode, head_B: ListNode) -> ListNode:
    ptr_A, ptr_B = head_A, head_B
    # Traverse through list A with 'ptr_A' and list B with 'ptr_B' until they meet.
    while ptr_A != ptr_B:
        # Traverse list A -> list B by first traversing 'ptr_A' and then, upon
        # reaching the end of list A, continue the traversal from the head of list B.
        ptr_A = ptr_A.next if ptr_A else head_B
        # Simultaneously, traverse list B -> list A.
        ptr_B = ptr_B.next if ptr_B else head_A
    # At this point, 'ptr_A' and 'ptr_B' either point to the intersection node or both
    # are null if the lists do not intersect. Return either pointer.
    return ptr_A

Анализ сложности

Временная сложность: O(n+m), где n и m — длины списков A и B соответственно. Указатели линейно обходят оба списка последовательно.

Пространственная сложность: O(1).

LRU-кеш

LRU-кеш

Спроектируйте и реализуйте структуру данных для кеша по принципу вытеснения давно неиспользуемых элементов (LRU — Least Recently Used), поддерживающую следующие операции:

  • LRUCache(capacity: int): инициализировать LRU-кеш с заданной ёмкостью.
  • get(key: int) -> int: вернуть значение по ключу. Вернуть -1, если ключ не существует.
  • put(key: int, value: int) -> None: добавить ключ и его значение в кеш. Если добавление превысит ёмкость — вытеснить наиболее давно неиспользуемый элемент. Если ключ уже существует — обновить его значение.

Пример:

python
Input: [
  put(1, 100),
  put(2, 250),
  get(2),
  put(4, 300),
  put(3, 200),
  get(4),
  get(1),
],
  capacity = 3
Output: [250, 300, -1]

Пояснение:

python
put(1, 100)  # cache is[1: 100]
put(2, 250)  # cache is[1: 100, 2: 250]
get(2)       # return 250
put(4, 300)  # cache is[1: 100, 2: 250, 4: 300]t
put(3, 200)  # cache is[2: 250, 4: 300, 3: 200]
get(4)       # return 300
get(1)       # key 1 was evicted when adding key 3 due to the capacity
             # limit: return -1

Ограничения:

  • Все ключи и значения — положительные целые числа.
  • Ёмкость кеша положительна.

Интуиция

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

Рассмотрим LRU-кеш ниже: он содержит 3 элемента и заполнен до предела. В этом представлении пары ключ-значение упорядочены от наиболее давно использованных (слева) до наиболее недавно использованных (справа):

Image represents an LRU cache with capacity 3 holding pairs 1|100, 2|250, 4|300.

Попробуем добавить новую пару ключ-значение:

Image represents put(3|200) operation on full LRU cache.

Новая пара — самая свежая в кеше, поэтому она добавляется в конец (наиболее недавно использованный конец). Поскольку кеш заполнен, сначала вытесняем наиболее давно неиспользуемую пару:

Image represents evicting 1|100 and adding 3|200 to LRU cache.

На основе этого обзора обобщим операции для функции put:

  1. Удалить пару ключ-значение с наиболее давно неиспользуемого конца кеша.
  2. Добавить пару ключ-значение в наиболее недавно использованный конец кеша.

Теперь попробуем получить значение из кеша. При get(2) ожидаем 250. Обращение к паре делает её наиболее недавно использованной — переместим её в соответствующий конец:

Image represents get(2) moving 2|250 to most recently used end.
Image represents returning 250 from get(2).

Из этого примера выделим две ключевые операции для функции get:

  1. Переместить пару ключ-значение на наиболее недавно использованный конец кеша.
  2. Получить значение по ключу.

Итого четыре основные операции. Они помогут выбрать подходящие структуры данных.

Выбор структур данных Операции 1 и 2

Первые две операции связаны с добавлением и удалением пар ключ-значение: нужна возможность удалять с одного конца (наиболее давно неиспользуемый) и добавлять к другому.

Подходящая структура данных — связный список: добавление и удаление узла за O(1) при наличии ссылки на него. Но одно- или двусвязный?

Односвязный vs. двусвязный список

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

Для удобства работы с обоими концами определим:

  • Хвост списка — наиболее недавно использованный узел.
  • Голова списка — наиболее давно неиспользуемый узел.

Для доступа к концам создадим узлы head и tail:

Image represents doubly linked list with head and tail sentinel nodes.

Операции 3 и 4

Операция 3 требует перемещения узла на наиболее недавно использованный конец — и этот узел может находиться в середине списка. Обход для его поиска займёт O(n). Как получить доступ за O(1)? Поскольку узел связан с ключом, используем Hash Map для хранения пар ключ-узел — это даёт доступ за константное время. Ниже показано, как значения Hash Map ссылаются на узлы списка:

Image represents doubly linked list with hashmap mapping keys to nodes.

Hash Map также решает операцию 4 — эффективный доступ к значениям по ключу.

Определившись с двусвязным списком и Hash Map для представления LRU-кеша, рассмотрим реализацию функций put и get.

put(key: int, val: int) -> None: Схема добавления новой пары ключ-значение в кеш — корректное обновление списка и Hash Map с соблюдением ёмкости:

Image represents flowchart for put operation in LRU cache.

Пример добавления нового узла в заполненный двусвязный список:

Image represents removing head.next and adding to tail in LRU cache.

Понадобятся функции: удаление узла (remove_node) и добавление узла в хвост (add_to_tail).

get(key: int) -> int: Процесс получения значения из кеша:

Image represents flowchart for get operation in LRU cache.

Пример обновления двусвязного списка при вызове get:

Image represents get(2) operation: remove node 2 and add to tail, return 250.

Теперь рассмотрим детали реализации, включая вспомогательные методы remove_node и add_to_tail.

Реализация

Используем класс для узла двусвязного списка:

python
class DoublyLinkedListNode:
   def __init__(self, key: int, val: int):
       self.key = key
       self.val = val
       self.next = self.prev = None

Пример добавления узла в хвост. Назовём узел перед хвостом prev_node:

Image represents add_to_tail operation showing new node before tail.

Новый узел должен стоять после prev_node и перед хвостом. Установим его указатели prev и next:

Image represents setting new node's prev and next pointers.

Теперь соединим prev_node и хвост с новым узлом:

Image represents completed add_to_tail operation.

Реализация функции:

python
def add_to_tail(self, node: DoublyLinkedListNode) -> None:
    prev_node = self.tail.prev
    node.prev = prev_node
    node.next = self.tail
    prev_node.next = node
    self.tail.prev = node

Пример удаления узла из двусвязного списка:

Image represents remove_node operation on doubly linked list.

Для удаления узла делаем его два соседних узла ссылающимися друг на друга, исключая удаляемый узел из списка:

Image represents node removal by connecting adjacent nodes.

Реализация функции:

python
def remove_node(self, node: DoublyLinkedListNode) -> None:
    node.prev.next = node.next
    node.next.prev = node.prev

Полная реализация LRU-кеша:

python
class DoublyLinkedListNode:
    def __init__(self, key: int, val: int):
        self.key = key
        self.val = val
        self.next = self.prev = None
    
class LRUCache:
    def __init__(self, capacity: int):
        self.capacity = capacity
        # A hash map that maps keys to nodes.
        self.hashmap = {}
        # Initialize the head and tail dummy nodes and connect them to 
        # each other to establish a basic two-node doubly linked list.
        self.head = DoublyLinkedListNode(-1, -1)
        self.tail = DoublyLinkedListNode(-1, -1)
        self.head.next = self.tail
        self.tail.prev = self.head
    
    def get(self, key: int) -> int:
        if key not in self.hashmap:
            return -1
        # To make this key the most recently used, remove its node and
        # re-add it to the tail of the linked list.
        self.remove_node(self.hashmap[key])
        self.add_to_tail(self.hashmap[key])
        return self.hashmap[key].val
    
    def put(self, key: int, value: int) -> None:
        # If a node with this key already exists, remove it from the
        # linked list.
        if key in self.hashmap:
            self.remove_node(self.hashmap[key])
        node = DoublyLinkedListNode(key, value)
        self.hashmap[key] = node
        # Remove the least recently used node from the cache if adding
        # this new node will result in an overflow.
        if len(self.hashmap) > self.capacity:
            del self.hashmap[self.head.next.key]
            self.remove_node(self.head.next)
        self.add_to_tail(node)
    
    def add_to_tail(self, node: DoublyLinkedListNode) -> None:
        prev_node = self.tail.prev
        node.prev = prev_node
        node.next = self.tail
        prev_node.next = node
        self.tail.prev = node
    
    def remove_node(self, node: DoublyLinkedListNode) -> None:
        node.prev.next = node.next
        node.next.prev = node.prev

Анализ сложности

Временная сложность: вспомогательные функции remove_node и add_to_tail работают за O(1) — константные операции с двусвязным списком. Функции put и get используют их совместно с константными операциями Hash Map. Итоговая сложность — O(1).

Пространственная сложность: O(n), где n — ёмкость кеша. Двусвязный список и Hash Map занимают по O(n) памяти.

Совет для собеседования

Совет: изучите, как комбинирование структур данных помогает реализовать нужную функциональность. Возможны ситуации, когда ни одна отдельная структура данных не обеспечивает всю необходимую функциональность. В таких случаях попробуйте объединить несколько структур. Например, в этой задаче двусвязный список в паре с Hash Map обеспечивают всю функциональность LRU-кеша.

Палиндромный связный список

Палиндромный связный список

Дана голова односвязного списка. Определите, является ли он палиндромом.

Пример 1:

Image represents linked list 1->2->3->2->1.
python
Output: True

Пример 2:

Image represents linked list 1->2->1->2.
python
Output: False

Интуиция

Связный список является палиндромом, если его значения читаются одинаково в обоих направлениях. Наивный способ — сохранить все значения в массив и свободно обходить его в обоих направлениях. Однако это требует линейной памяти. Лучше найти способ обходить список в обратном порядке для проверки. Есть ли такой способ?

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

Image represents palindrome check: original and reversed list have same values.

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

Важное наблюдение: достаточно сравнить первую половину оригинального списка с разворотом второй половины (при нечётном числе элементов средний узел можно включить в обе половины):

Image represents comparing first half with reverse of second half.

Перед сравнением нужно:

  1. Найти середину списка — это голова второй половины.
  2. Развернуть вторую половину, начиная с этого среднего узла.

Обратите внимание: шаг 2 модифицирует входные данные. В данной задаче будем считать это допустимым. Всегда уточняйте у интервьюера, разрешено ли изменять входные данные.

Начинаем с нахождения среднего узла (mid) списка:

Image represents head and mid pointers in linked list.

Для нахождения середины связного списка смотрите объяснение задачи «Середина связного списка» в главе про быстрый и медленный указатели.

Затем разворачиваем вторую половину, начиная с mid. Последний узел оригинального списка становится головой второй половины (second_head) — он используется для обхода развёрнутой второй половины:

Image represents head and second_head after reversing second half.

Для разворота связного списка за O(n) смотрите объяснение задачи «Разворот связного списка» в этой главе.

Наконец, проверяем, совпадает ли первая половина с развёрнутой второй. Одновременно обходим обе половины узел за узлом, сравнивая значения. Если в какой-то момент значения не совпадают — список не является палиндромом.

Используем два указателя (ptr1 и ptr2) для обхода первой и развёрнутой второй половин:

Image represents ptr1 and ptr2 traversing both halves for comparison.

Реализация

python
from ds import ListNode
    
def palindromic_linked_list(head: ListNode) -> bool: 
    # Find the middle of the linked list and then reverse the second half of the # linked list starting at this midpoint.
    mid = find_middle(head)
    second_head = reverse_list(mid)
    # Compare the first half and the reversed second half of the list
    ptr1, ptr2 = head, second_head
    res = True
    while ptr2:
        if ptr1.val != ptr2.val:
            res = False
        ptr1, ptr2 = ptr1.next, ptr2.next
    return res
    
# From the 'Reverse Linked List' problem.
def reverse_list(head: ListNode) -> ListNode:
    prevNode, currNode = None, head
    while currNode:
        nextNode = currNode.next
        currNode.next = prevNode
        prevNode = currNode
        currNode = nextNode
    return prevNode
    
# From the 'Linked List Midpoint' problem.
def find_middle(head: ListNode) -> ListNode:
    slow = fast = head
    while fast and fast.next:
        slow = slow.next
        fast = fast.next.next
    return slow

Анализ сложности

Временная сложность: O(n), где n — длина списка. Три прохода: нахождение середины, разворот второй половины и сравнение двух половин.

Пространственная сложность: O(1).

Совет для собеседования

Совет: уточняйте, допустимо ли изменять связный список. В нашем решении мы развернули вторую половину, нарушив исходную структуру входных данных. Почему это важно? Входные структуры данных нередко нельзя изменять — особенно если они разделяются или используются параллельно. Поэтому важно уточнить у интервьюера, допустимо ли изменение, и кратко обозначить его последствия.

Сглаживание многоуровневого связного списка

Сглаживание многоуровневого связного списка

В многоуровневом связном списке каждый узел имеет указатель next и указатель child. Указатель next связывает с последующим узлом того же уровня, а указатель child указывает на голову нового связного списка под ним. Так создаются несколько уровней связных списков. Если у узла нет дочернего списка — его атрибут child равен null.

Сгладьте многоуровневый связный список в одноуровневый, связав конец каждого уровня с началом следующего.

Пример:

Image represents flattening a multi-level linked list into a single level.

Интуиция

Рассмотрим два условия для формирования сглаженного списка:

  1. Порядок узлов на каждом уровне должен быть сохранён.
  2. Все узлы одного уровня должны быть подключены прежде, чем добавляются узлы следующего уровня.

Сложность задачи — в обработке нижних уровней. Можно подумать об обходе в ширину (BFS), но BFS требует очереди — как минимум линейная память. Как слить уровни на месте?

Ключевое наблюдение: на любом уровне у нас есть прямой доступ ко всем узлам следующего уровня, поскольку дочерние узлы каждого узла уровня L обеспечивают доступ к уровню L+1:

Image represents level L accessing level L+1 through child pointers.

Как подключить узлы уровня L+1 к концу уровня L? Через дочерние указатели текущего уровня можно добавить каждый дочерний связный список к концу текущего уровня — это эффективно объединяет два уровня в один.

Image represents appending child lists to the end of current level.

После добавления узлов уровня L+1 к уровню L продолжаем: добавляем узлы уровня L+2 к L+1 и так далее.

Применим стратегию на примере:

Image represents multi-level linked list example with head:1.

Начинаем с добавления узлов уровня 2 к концу уровня 1. Нужна ссылка на хвостовой узел уровня 1 — создадим указатель tail и продвигаем его до последнего узла (где tail.next равен null):

Image represents tail pointer at end of level 1.

Добавим дочерние списки (5→6 и 7→8) к хвостовому узлу. Зафиксируем tail в конце списка, введём отдельный указатель curr для обхода. Когда curr встречает узел с ненулевым дочерним списком — мы нашли дочерний список для добавления к хвосту. В примере первый узел (1) имеет дочерний список:

Image represents curr at node 1, tail at node 4 with child list 5-6.

Для добавления дочернего списка к хвосту устанавливаем tail.next на голову дочернего списка:

Image represents tail.next = curr.child operation.
Image represents list after attaching child list 5-6.

Перед переходом curr к следующему узлу с дочерним списком, перемещаем tail на последний узел расширенного списка (узел 6). Снова итерируем tail до null:

Image represents tail advanced to node 6.

Теперь продолжаем процесс:

  • Находим следующий узел с дочерним списком через curr.
  • Добавляем дочерний список к хвосту.
  • Продвигаем tail к последнему узлу сглаженного списка.
Image represents curr at node 4, tail at node 6, child list 7-8 being added.
Image represents adding child list 9 from node 6.
Image represents adding child 10 from node 8.
Image represents fully flattened linked list 1 through 10.

После завершения процесса возвращаем head — голову сглаженного списка.

Важная деталь: после добавления любого дочернего списка к хвосту обнуляем атрибут child, чтобы список был полностью сглажен.

Реализация

Определение класса MultiLevelListNode:

python
class MultiLevelListNode:
    def __init__(self, val, next, child):
        self.val = val
        self.next = next
        self.child = child
python
from ds import MultiLevelListNode
   
def flatten_multi_level_list(head: MultiLevelListNode) -> MultiLevelListNode:
    if not head:
        return None
    tail = head
    # Find the tail of the linked list at the first level.
    while tail.next:
        tail = tail.next
    curr = head
    # Process each node at the current level. If a node has a child linked list,
    # append it to the tail and then update the tail to the end of the extended linked
    # list. Continue until all nodes at the current level are processed.
    while curr:
        if curr.child:
            tail.next = curr.child
            curr.child = None
            while tail.next:
                tail = tail.next
        curr = curr.next
    return head

Анализ сложности

Временная сложность: O(n), где n — количество узлов в многоуровневом связном списке. Каждый узел обрабатывается не более двух раз: один раз при итерации tail и один раз при итерации curr.

Пространственная сложность: O(1) — выделяем константное число переменных.

Глава 4

Быстрый и медленный указатели

~12 мин чтения

Введение в технику быстрого и медленного указателей

Введение в технику быстрого и медленного указателей

Интуиция

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

  • Обычно медленный указатель продвигается на один шаг за каждую итерацию.
  • Обычно быстрый указатель продвигается на два шага за каждую итерацию.

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

Image represents a visual depiction of the 'slow and fast pointer' algorithm often used in linked list traversal.  The diagram shows two parallel linked lists, each composed of several circular nodes connected by solid arrows representing the `next` pointer in each node.  The top list illustrates the initial state, with a 'slow' pointer (indicated by a light blue box labeled 'slow') and a 'fast' pointer (indicated by an orange box labeled 'fast') both initially pointing to the first node.  Arrows from the boxes point to their respective nodes.  The bottom list shows the pointers after one iteration. The 'slow' pointer has moved one node to the right, and the 'fast' pointer has moved two nodes to the right.  Both lists continue with ellipses (...) indicating that they extend beyond the visible portion.  Dashed arrows on the bottom list show the movement of the pointers.  To the right, a gray box with dashed borders contains pseudocode: `slow = slow.next` and `fast = fast.next.next`, illustrating how the pointers are updated in each iteration.  This code snippet explains the algorithm's core logic: the slow pointer advances one node at a time, while the fast pointer advances two nodes at a time.

Следует учитывать, что эти указатели не ограничены одним и двумя шагами. Главное условие — быстрый указатель продвигается на большее число шагов, чем медленный; при этом логика техники быстрого и медленного указателей сохраняется.

Пример из реальной жизни

Обнаружение циклов в символических ссылках: символические ссылки (symlinks) — это ярлыки, указывающие на файлы или каталоги в файловой системе. Реальный пример применения техники быстрого и медленного указателей — обнаружение циклов в символических ссылках.

В этом процессе медленный указатель следует по каждой символической ссылке шаг за шагом, тогда как быстрый указатель перемещается по два шага за раз. Если быстрый указатель догоняет медленный, это говорит о наличии цикла в символических ссылках, который может вызвать бесконечные циклы или ошибки при обращении к файлам. Чтобы понять, как быстрый и медленный указатели обнаруживают циклы таким образом, изучите задачу «Цикл в связном списке».

Содержание главы

Быстрый и медленный указатели особенно полезны в таких структурах данных, как связные списки, где индексный доступ недоступен. Они позволяют собирать важную информацию об элементах структуры данных через относительные позиции двух указателей, а не через индексирование. Это станет понятнее по мере изучения типичных сценариев применения в данной главе.

Image represents a hierarchical diagram illustrating the applications of 'Fast and Slow Pointers' coding pattern.  The top-level node, a rounded rectangle, is labeled 'Fast and Slow Pointers'.  From this node, three dashed lines descend to three lower-level nodes, each representing a specific application.  The leftmost node is a rounded rectangle labeled 'Cycle Detection - `Linked List Loop`', indicating its use in detecting cycles within linked lists. The rightmost node is a similarly styled rounded rectangle labeled 'Sequence Analysis - `Happy Number`', showing its application in analyzing number sequences, specifically the 'Happy Number' problem.  Finally, a central lower-level node, also a rounded rectangle, is labeled 'Fractional Point Identification - `Linked List Midpoint`', demonstrating its use in finding the midpoint of a linked list.  The connections between the top node and the three lower nodes represent the different ways the 'Fast and Slow Pointers' pattern can be utilized, with each lower node detailing a specific problem solved using this pattern.

Цикл в связном списке

Цикл в связном списке

Дан односвязный список. Определите, содержит ли он цикл. Цикл возникает, если указатель next некоторого узла ссылается на более ранний узел в связном списке, образуя петлю.

Пример:

Image represents a state diagram illustrating a coding pattern.  The diagram shows a linear sequence followed by a cyclical sequence. The linear sequence consists of three nodes labeled '0', '1', and '2', connected by unidirectional arrows indicating a flow from '0' to '1' and then from '1' to '2'.  The cyclical sequence, labeled 'cycle', is composed of four nodes numbered '3', '4', '5', and '2' (note that '2' is shared between the linear and cyclical sequences). These nodes form a closed loop with unidirectional arrows indicating a flow from '3' to '4', '4' to '5', '5' to '2', and '2' to '3'. The cyclical sequence is enclosed by a dashed rectangle to visually distinguish it from the linear sequence.  The overall diagram depicts a system where a linear process leads into a repeating cyclical process, potentially representing a common pattern in program execution or data flow.
python
Output: True

Интуиция

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

python
from ds import ListNode
    
def linked_list_loop_naive(head: ListNode) -> bool:
    visited = set()
    curr = head
    while curr:
        # Cycle detected if the current node has already been visited.
        if curr in visited:
            return True
        visited.add(curr)
        curr = curr.next
    return False

Это решение требует O(n)O(n)O(n) времени, где nnn — количество узлов в связном списке, поскольку каждый узел посещается один раз. Однако оно использует O(n)O(n)O(n) дополнительной памяти для хэш-множества. Можно ли достичь линейной временной сложности при константном использовании памяти?

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

Image represents a circular flowchart depicting a four-step process, numbered 1 through 4, with each step represented by a numbered circle.  Arrows indicate the sequential flow, starting from step 1, proceeding to step 2, then 3, then 4, and finally looping back to step 1, creating a continuous cycle.  A central circle labeled 'θ' (theta) is positioned outside the main cycle and connected to it with arrows indicating interaction or data flow between the central element and the cyclical process.  To the left of the central circle, two stick figures are shown; one orange labeled 'FF' and one light blue labeled 'S', suggesting two distinct entities or inputs interacting with the central element 'θ' and influencing the cyclical process. The arrangement suggests a feedback loop where the four steps continuously repeat, influenced by the interaction between 'FF', 'S', and the central component 'θ'.

Если оба бегуна движутся с одинаковой скоростью, они всегда будут находиться в одном узле. Однако рассмотрим, что происходит, когда один бегун (медленный) перемещается на один шаг за раз, а другой (быстрый) — на два шага. В этом случае быстрый бегун в какой-то момент обгонит медленного, поскольку трасса циклическая. Но как использовать эту информацию для обнаружения цикла?

В связном списке определить, обогнал ли быстрый бегун медленного, сложно из-за отсутствия позиционных индикаторов (как индексы в массиве). Лучший способ найти цикл — проверить, воссоединится ли быстрый бегун с медленным, то есть окажутся ли они в одном узле в какой-то момент. Это будет явным признаком того, что в связном списке есть цикл.

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

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

Image represents a visual depiction of a coding pattern, likely illustrating a cycle detection algorithm.  The diagram shows three stages. The first stage depicts a circular linked list with nodes numbered 0 through 4, where node 0 has incoming arrows labeled 'fast' (orange) and 'slow' (light blue), indicating two pointers moving at different speeds.  The second stage shows the same circular list, but now the 'fast' pointer has advanced further than the 'slow' pointer, with dashed orange and light blue arrows indicating their movement. The third stage shows the pointers converging at a point, with the 'fast' pointer now moving faster than the 'slow' pointer, indicated by dashed orange arrows from node 2 to node 3 and from node 3 to node 4. A solid black arrow points downwards from the third stage, suggesting the algorithm's completion or a subsequent step.  The overall structure demonstrates the concept of two pointers traversing a circular data structure at different speeds to detect cycles.
Image represents a state transition diagram illustrating a coding pattern, likely related to cycle detection or pointer manipulation.  The diagram shows three stages. The first stage depicts a circular linked list with nodes numbered 0 through 4. Node 0 is distinct, enclosed in a green box with incoming arrows labeled 'fast' (orange) and 'slow' (light blue), representing two pointers moving at different speeds.  Nodes 1 through 4 form a cycle with solid black arrows indicating the direction of traversal. The second stage shows the same structure but with dashed arrows (orange and light blue) indicating the movement of the 'fast' and 'slow' pointers, respectively, starting from node 0 and progressing through the cycle.  The third stage shows the final state where the 'fast' pointer has caught up to the 'slow' pointer, indicated by the convergence of the dashed orange and light blue arrows at node 1.  The 'fast' pointer is labeled with an orange rectangle, and the 'slow' pointer is labeled with a light blue rectangle in the final stage.  The arrows between stages indicate a progression of time or steps in the algorithm.

Отложенный цикл Что насчёт случая, когда цикл начинается не с первого узла связного списка? Рассмотрим симуляцию техники быстрого и медленного указателей на следующем примере:

Image represents a step-by-step visualization of a coding pattern, possibly related to linked lists or circular buffers, demonstrating the movement of data elements labeled with 'slow' and 'fast' pointers.  The diagram begins with a linear sequence of nodes (0, 1, 2) connected by solid arrows, followed by a circular linked list (3, 4, 5).  A 'fast' pointer (orange dashed arrows) moves twice as quickly as a 'slow' pointer (light blue dashed arrows). The subsequent stages show the pointers traversing the circular list.  The 'fast' pointer initially overtakes the 'slow' pointer, and then the process repeats in a smaller circular list.  Finally, the 'fast' and 'slow' pointers converge at node 4, which is highlighted in a green box, indicating a potential detection of a cycle or loop within the data structure.  Each node is numbered (0-5), and the speed of each pointer ('slow' or 'fast') is indicated by a colored box next to the pointer's arrow.  The arrows illustrate the direction and speed of pointer movement.

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

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

Быстрый указатель перемещается на 2 шага за итерацию, а медленный — на 1 шаг, поэтому быстрый сокращает расстояние до медленного на 1 узел при каждой итерации. Это можно наблюдать ниже: расстояние между быстрым и медленным указателями уменьшается на единицу с каждой итерацией, пока они неизбежно не встретятся.

Image represents a step-by-step visualization of a cyclical process, possibly illustrating a coding pattern like a pointer chase algorithm.  The core element is a circular arrangement of five nodes labeled 0 through 4.  Initially (leftmost diagram), node 0 is unfilled, while nodes 1 and 3 are shaded grey, indicating a state change.  A dashed line connects node 1 to node 3, labeled 'distance = 2'.  Solid arrows show the directional flow around the circle.  A rectangular box labeled 'fast' points to node 1, and a box labeled 'slow' points to node 3. The middle diagram shows the process evolving; node 4 is now shaded grey, the distance between the pointers (represented by the dashed line) is reduced to 'distance = 1', and the 'slow' pointer has moved to node 4.  The rightmost diagram depicts the final state where the distance between the pointers is 'distance = 0', both pointers are at node 0, and node 0 is now shaded grey.  The 'fast' and 'slow' pointers are now both directed towards node 0.  The arrows between the diagrams indicate the progression of the process.

Таким образом, максимальное количество шагов, необходимых быстрому указателю для того, чтобы догнать медленный, составляет k шагов (когда оба уже находятся в цикле), где k — длина цикла. В худшем случае цикл содержит все узлы связного списка, и указатели встретятся через n шагов.

Нет цикла Последний случай — когда цикла нет. В этом случае быстрый указатель в конечном счёте достигнет конца списка и выйдет из цикла while:

Image represents a state diagram or flowchart illustrating a process with four states, numbered 0, 1, 2, and 3, represented as circles.  State 0 is the starting point, receiving input from two sources labeled 'slow' (in light blue) and 'fast' (in orange), indicated by arrows pointing to state 0.  These labels likely represent different pathways or speeds of entry into the process.  The states are sequentially connected by unidirectional arrows, indicating a linear progression from state 0 to 1, then 1 to 2, and finally 2 to 3.  The arrows represent transitions between states, suggesting a step-by-step process where each state completes before moving to the next.  There are no loops or branches shown, implying a simple, linear workflow.
The image represents a data flow diagram illustrating two parallel processing paths.  Four numbered circles (0, 1, 2, and 3) represent processing stages.  A solid arrow from circle 0 to circle 1 indicates a sequential flow of data.  Another solid arrow connects circle 1 to circle 2, and a final solid arrow connects circle 2 to circle 3, showing a linear progression through these stages.  Above the diagram, two rectangular boxes are labeled 'slow' (in light blue) and 'fast' (in orange).  A downward-pointing blue arrow connects the 'slow' box to circle 1, indicating that data enters stage 1 via a slower path.  Similarly, an orange downward-pointing arrow connects the 'fast' box to circle 2, showing data entering stage 2 through a faster route.  Dashed orange arrows connect circle 0 to circle 1 and circle 1 to circle 2, representing a feedback loop or alternative data path within the 'fast' processing stream.  A dashed blue arrow connects circle 0 to circle 1, suggesting a secondary, slower feedback loop.  The overall diagram depicts a system where data can follow either a fast or slow path, with potential feedback mechanisms within each path.
Image represents a linked list with nodes numbered 0, 1, 2, and 3, sequentially connected by solid arrows.  A light-blue rectangular box labeled 'slow' points downwards to node 2, indicating a pointer.  Similarly, an orange rectangular box labeled 'fast' points downwards to node 3.  A dashed light-blue arrow connects node 1 to node 2, representing a slow pointer's movement. A dashed orange arrow connects node 3 back to node 2, representing a fast pointer's movement.  The sequence continues with a solid arrow from node 3 to 'null', signifying the end of the list.  To the right, a grey, dashed-line box displays the condition 'fast == null → no cycle', indicating that if the fast pointer reaches the end (null), there is no cycle detected in the linked list.

Реализация

Этот алгоритм формально известен как алгоритм обнаружения цикла Флойда [1].

python
from ds import ListNode
    
def linked_list_loop(head: ListNode) -> bool:
    slow = fast = head
    # Check both 'fast' and 'fast.next' to avoid null pointer exceptions when we
    # perform 'fast.next' and 'fast.next.next'.
    while fast and fast.next:
        slow = slow.next
        fast = fast.next.next
        if fast == slow:
            return True
    return False

Анализ сложности

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

Пространственная сложность: пространственная сложность равна O(1)O(1)O(1).

Середина связного списка

Середина связного списка

Дан односвязный список. Найдите и верните его средний узел. Если средних узлов два, верните второй из них.

Пример 1:

Image represents a directed graph illustrating a sequence of steps or a process flow.  The graph consists of five nodes, represented as circles, labeled sequentially with the numbers 1, 2, 4, 7, and 3.  Arrows indicate the direction of flow, showing a progression from node 1 to node 2, then to node 4, then to node 7, and finally to node 3. Node 4 is visually distinct, highlighted with a green fill and labeled 'mid' in green text above it, suggesting it represents a midpoint or a crucial step in the overall process. The arrangement of the nodes and arrows clearly depicts a linear sequence with a highlighted central element.
python
Output: Node 4

Пример 2:

Image represents a directed acyclic graph (DAG) illustrating a sequence of data flow.  The graph consists of four circular nodes, each containing a numerical value: 1, 2, 4, and 7.  These nodes are connected by directed edges (arrows) indicating the direction of data flow.  The data flows sequentially from node 1 to node 2, then from node 2 to node 4, and finally from node 4 to node 7. Node 4 is highlighted with a light green fill and the word 'mid' is written above it in green, suggesting that node 4 represents a midpoint or intermediate stage in the process.  The absence of cycles and the unidirectional nature of the edges confirm the acyclic nature of the graph.
python
Output: Node 4

Ограничения:

  • Связный список содержит хотя бы один узел.
  • Связный список содержит уникальные значения.

Интуиция

Наиболее интуитивный подход к решению этой задачи — пройти по связному списку для определения его длины (n), а затем пройти повторно, чтобы найти средний узел (n / 2):

Image represents two parallel sequences of nodes (circles) connected by directed edges (arrows).  The top sequence, labeled 'n', shows nodes containing the numbers 1, 2, 4, 7, and 3, sequentially connected with solid black arrows indicating the flow of information.  Additionally, grey dashed curved arrows connect the first node to the third, the second to the fourth, and the fourth to the fifth, suggesting a non-sequential, potentially parallel or skip-ahead processing step. The bottom sequence, labeled 'n/2', mirrors the node values and sequential connections of the top sequence using solid black arrows. However, it features orange dashed curved arrows connecting the first node to the third, indicating a different, potentially more efficient or reduced processing path compared to the top sequence.  The difference in the arrows' styles and colors highlights the distinction between the two processing methods, suggesting a comparison of computational complexity or efficiency.  The 'n' and 'n/2' labels likely represent the input size or number of operations involved in each processing path.

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

При итерировании по связному списку наша точная позиция неизвестна. Мы узнаём её лишь когда достигаем последнего узла. Можно ли создать такой сценарий, при котором, как только один указатель достигает конца списка, другой оказывается у среднего узла?

Если один указатель будет перемещаться вдвое медленнее другого, то к тому моменту, когда более быстрый указатель достигнет конца списка, более медленный окажется на середине связного списка.

Этого можно добиться с помощью техники быстрого и медленного указателей:

  • Медленный указатель продвигается на один узел за шаг.
  • Быстрый указатель продвигается на два узла за шаг.
Image represents a directed acyclic graph illustrating a workflow or process.  The graph consists of five circular nodes labeled sequentially with the numbers 1, 2, 4, 7, and 3.  Arrows indicate the direction of flow between these nodes, starting from node 1 and proceeding through nodes 2, 4, 7, and finally to node 3.  Above the first node (1), two rectangular boxes are positioned; one labeled 'slow' in light blue and the other labeled 'fast' in orange.  Arrows point from these boxes to node 1, indicating that the 'slow' path leads to node 1, and the 'fast' path also leads to node 1. This suggests two different input paths or processes that converge at node 1 before continuing through the rest of the sequence. The overall structure depicts a linear sequence of operations or steps, with two distinct initial inputs merging into a single workflow.
Image represents a directed graph illustrating a workflow or process.  The graph consists of five numbered nodes (1, 2, 4, 7, 3) connected by solid arrows indicating the sequential flow. Node 1 connects to node 2, node 2 connects to node 4, node 4 connects to node 7, and node 7 connects to node 3.  Additionally, there are two dashed arrows: a light-blue dashed arrow loops from node 1 back to node 2, labeled 'slow' in a light-blue box above it, indicating a feedback or iterative process; and an orange dashed arrow loops from node 1 to node 2 and then to node 4, labeled 'fast' in an orange box above the arrow pointing to node 4, suggesting a faster alternative path or shortcut.  The overall diagram depicts two distinct paths through the process: a slower, iterative path (indicated by the light-blue arrow) and a faster, more direct path (indicated by the orange arrow).
Image represents a diagram illustrating a linked list traversal with two pointers, 'slow' and 'fast.'  The linked list is depicted as a sequence of numbered circles (nodes) 1, 2, 4, 7, and 3, connected by solid arrows indicating the direction of traversal.  A light gray node (4) is visually distinguished. A light blue rectangle labeled 'slow' points down to node 4, and an orange rectangle labeled 'fast' points down to node 3.  Dashed blue arrows show a secondary connection from node 2 to node 4, representing the 'slow' pointer's movement. Dashed orange arrows show a secondary connection from node 7 to node 3, representing the 'fast' pointer's movement. A gray rounded rectangle to the right displays the condition 'fast.next == null → break,' indicating that the traversal stops when the 'fast' pointer reaches the end of the list (null).  The overall diagram visualizes a process where two pointers move through a linked list at different speeds, with the condition checking for the end of the list to terminate the process.

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

А что если длина связного списка чётная? Рассмотрим пример ниже:

Image represents a directed acyclic graph illustrating a coding pattern, possibly related to task scheduling or pipeline processing.  The graph consists of four circular nodes labeled sequentially as 1, 2, 4, and 7, connected by unidirectional arrows indicating the flow of information or execution order.  Node 1 acts as the starting point, receiving input from two rectangular boxes positioned above it. The top box is light blue and labeled 'slow,' while the other is orange and labeled 'fast,' suggesting two different input streams or processing speeds.  Arrows from these boxes point to node 1, indicating that both 'slow' and 'fast' inputs feed into the initial processing step (node 1).  Subsequently, node 1's output flows to node 2, then to node 4, and finally to node 7, representing a linear progression of tasks or stages. The overall structure suggests a sequential process where the initial step (node 1) combines inputs from different sources before proceeding through subsequent stages.
Image represents a directed acyclic graph illustrating a workflow with two distinct paths.  The graph consists of five nodes (circles) numbered 1, 2, 4, and 7, representing sequential steps in a process.  Nodes 1, 2, 4, and 7 are connected by solid arrows indicating the primary flow. Node 1 connects to node 2, node 2 connects to node 4, and node 4 connects to node 7.  Above the graph, two rectangular boxes labeled 'slow' (in light blue) and 'fast' (in orange) indicate alternative paths. A light blue dashed arrow connects node 1 to node 2, representing a 'slow' path, while an orange dashed arrow connects node 1 to node 2 and another connects node 4 to node 2, representing a 'fast' path. The 'slow' path implies a direct progression from node 1 to node 2, while the 'fast' path suggests a faster route potentially skipping some steps or utilizing parallel processing, indicated by the feedback loop from node 4 to node 2.  The overall diagram visualizes a process where data or tasks flow through different stages, with the possibility of choosing between a slower, linear path and a faster, potentially more complex path.
Image represents a diagram illustrating a coding pattern, likely related to linked lists or similar data structures.  The diagram shows a sequence of numbered nodes (1, 2, 4, 7) connected by solid arrows representing a forward traversal. Node 4 is shaded gray, suggesting a different state or significance. A light-blue box labeled 'slow' points to node 4 with a downward-pointing arrow, indicating a pointer or iterator moving slowly through the sequence. An orange box labeled 'fast' points to the word 'null' with a downward-pointing arrow, representing a faster pointer. Dashed arrows, blue and orange respectively, show the movement of the slow and fast pointers. The orange dashed arrow loops from node 7 back to node 4, and the blue dashed arrow loops from node 2 back to node 4. A dashed-line box contains the code snippet 'fast == null → break', indicating a conditional statement where the algorithm terminates when the fast pointer reaches a null value.  The overall structure depicts a process where two pointers traverse a data structure at different speeds, with the termination condition based on the fast pointer's position.

Как видно, чтобы медленный указатель указывал на второй средний узел, нужно остановить быстрый указатель, когда он достигает null-узла.

Реализация

python
from ds import ListNode
    
def linked_list_midpoint(head: ListNode) -> ListNode:
    slow = fast = head
    # When the fast pointer reaches the end of the list, the slow pointer will be at
    # the midpoint of the linked list.
    while fast and fast.next:
        slow = slow.next
        fast = fast.next.next
    return slow

Анализ сложности

Временная сложность: временная сложность linked_list_midpoint составляет O(n)O(n)O(n), поскольку мы линейно обходим связный список с помощью двух указателей.

Пространственная сложность: пространственная сложность равна O(1)O(1)O(1).

Остановитесь и подумайте

Что если вас попросят изменить алгоритм так, чтобы он возвращал первый средний узел при чётной длине связного списка?

Ответ: следуя тому же методу, нужно остановить продвижение быстрого указателя, когда fast.next.next равно null. Тогда медленный окажется на первом среднем узле:

Image represents a diagram illustrating a linked list traversal using two pointers, 'slow' and 'fast'.  Four nodes, numbered 1, 2, 4, and 7, are depicted as circles, connected by solid arrows indicating the direction of traversal. Node 2 is shaded gray. A light-blue rectangular box labeled 'slow' points to node 2 with a light-blue arrow, and an orange rectangular box labeled 'fast' points to node 4 with an orange arrow.  Dashed orange and light-blue curved arrows connect node 1 to node 2, suggesting the movement of the 'slow' and 'fast' pointers.  A separate, light-gray, dashed-bordered rectangle contains the code snippet 'fast.next.next == null → break', indicating a conditional check within the algorithm: if the node two steps ahead of the 'fast' pointer is null (end of the list), the loop breaks.

Совет для собеседования

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

Счастливое число

Счастливое число

В теории чисел счастливое число определяется как число, которое при многократном применении процесса возведения его цифр в квадрат и суммирования этих квадратов в конечном итоге приводит к 1. Несчастливое число никогда не достигнет 1 в этом процессе и попадёт в бесконечный цикл [1].

Дано целое число. Определите, является ли оно счастливым.

Пример:

python
Input: n = 23
Output: True

Пояснение: 222^222 + 323^232 = 13 ⇒ 121^212 + 323^232 = 10 ⇒ 121^212 + 020^202 = 1

Интуиция

Мы можем симулировать процесс определения счастливого числа, многократно суммируя квадраты каждой цифры числа, а затем применяя тот же процесс к полученной сумме.

Согласно условию задачи, этот процесс может завершиться одним из двух способов:

  • Случай 1: процесс продолжается до тех пор, пока итоговое число не равно 1.
  • Случай 2: процесс попадает в бесконечный цикл.

Если изобразить оба сценария графически, можно заметить кое-что интересное:

Image represents two distinct cases illustrating data flow, possibly representing different coding patterns or algorithms.  Case 1 shows a linear sequence of four circular nodes labeled 23, 13, 10, and 1, respectively.  Each node is connected to the next by a unidirectional arrow, indicating a sequential flow of information from 23 to 1. Case 2 depicts a circular flow of data. Eight circular nodes, labeled 116, 38, 73, 58, 37, 16, 4, and 20, are arranged in a circle.  Unidirectional arrows connect each node to the next in a clockwise direction, forming a closed loop.  Additionally, a separate circular sequence of three nodes (89, 145, and 42) is connected to the main circular sequence via arrows from node 58 to 89 and from node 42 to node 20.  The numbers within each node likely represent data values or states, and the arrows signify the direction of data processing or transformation.

Это очень похоже на задачу со связным списком. В частности, на задачу определения наличия цикла в связном списке (случай 2) или его отсутствия (случай 1).

Мы можем свести эту задачу к той же задаче обнаружения цикла, что и в задаче «Цикл в связном списке». Применив технику быстрого и медленного указателей (алгоритм обнаружения цикла Флойда), мы можем эффективно определить наличие цикла.

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

К счастью, мы уже знаем, каким будет «следующее» число в последовательности для любого числа x. Как описано в условии задачи, следующее число вычисляется суммированием квадрата каждой цифры числа x. Таким образом, если каждое число будет узлом связного списка, мы можем получить «следующий» узел, вычислив следующее число в последовательности.

Получение следующего числа в последовательности Чтобы вычислить следующее число для x, нам нужно обращаться к каждой его цифре. Это можно сделать в два шага:

  1. Операция взятия остатка от деления (x % 10) используется для извлечения последней цифры числа x.
  2. Делим x на 10 (x = x / 10), чтобы отбросить последнюю цифру и сделать следующую цифру новой последней.

Ниже показан полный процесс для x = 123:

Image represents a flowchart illustrating the `get_next_num(x)` function, which takes an integer `x` (initially 123) as input and calculates a new number (`next_num`). The process involves iteratively extracting digits from `x` using the modulo operator (`%`) and integer division (`/`).  First, the rightmost digit (3) is obtained via `x % 10`, then `x` is updated to `x / 10` (12). This process repeats, extracting the next digit (2) and then (1). Each extracted digit is circled in orange.  These digits are then used to calculate `next_num` by summing their squares (3² + 2² + 1² = 14). The iteration stops when `x` becomes 0.  Arrows indicate the flow of data, showing how the extracted digits (`digit`) are derived and subsequently used in the final calculation of `next_num`. The function's output, `next_num`, is explicitly calculated as 14 at the bottom.

Теперь, имея способ обходить последовательность, мы можем реализовать алгоритм обнаружения цикла Флойда. Для начала установим быстрый и медленный указатели в начало последовательности. Затем продвигаем указатели следующим образом:

  • Медленный указатель продвигается на одно число за шаг (slow = get_next_num(slow)).
  • Быстрый указатель продвигается на два числа за шаг (fast = get_next_num(get_next_num(fast))).

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

Реализация

python
def happy_number(n: int) -> bool:
    slow = fast = n
    while True:
        slow = get_next_num(slow)
        fast = get_next_num(get_next_num(fast))
        if fast == 1:
            return True
        # If the fast and slow pointers meet, a cycle is detected. Hence, 'n' is not
        # a happy number.
        elif fast == slow:
            return False
    
def get_next_num(x: int) -> int:
    next_num = 0
    while x > 0:
        # Extract the last digit of 'x'.
        digit = x % 10
        # Truncate (remove) the last digit from 'x' using floor division.
        x //= 10
        # Add the square of the extracted digit to the sum.
        next_num += digit ** 2
    return next_num

Анализ сложности

Временная сложность: временная сложность happy_number составляет O(log⁡(n))O(\log(n))O(log(n)). Полный анализ этой временной сложности довольно сложен и выходит за рамки собеседований. Для заинтересованных читателей подробный анализ приведён ниже.

Пространственная сложность: пространственная сложность равна O(1)O(1)O(1).

Совет для собеседования

Совет: визуализируйте задачу. На первый взгляд эта задача кажется требующей математического обоснования для решения. Однако, когда мы визуализировали её, нам удалось сформулировать решение с помощью уже известного алгоритма (обнаружения цикла Флойда). Визуализация задачи может помочь выявить скрытые паттерны или структуры данных, ведущие к решению.

Анализ временной сложности задачи «Счастливое число»

Следующий анализ временной сложности устанавливает верхнюю границу числа шагов, необходимых для определения счастливого числа. В этом анализе «следующим числом» числа nnn называется результат суммирования квадратов цифр nnn.

1) Верхняя граница следующего числа Для любого числа nnn с фиксированным количеством цифр максимальное значение его преемника достигается, когда все цифры равны 9. Например, максимальное следующее число для трёхзначного числа достигается при числе 999.

2) Размер следующего числа относительно количества цифр

  • Если число nnn имеет 1 или 2 цифры, следующее число может быть больше nnn (например, следующее число для 99 равно 162, что длиннее на одну цифру).
  • Если число nnn имеет 3 или более цифр, следующее число всегда меньше исходного значения nnn. Таблица ниже показывает, что наибольшее число с 3 или более цифрами имеет меньшее следующее число.
DigitsLargest NumberNext Number
1981
299162
3999243
49999324
599999405
6999999486
.........

3) Следствия для циклов Поскольку следующее число для чисел с тремя и более цифрами всегда меньше, цикл в процессе определения счастливого числа может возникнуть только после того, как число опускается ниже 243 (следующее число от 999). Это потому, что любое nnn больше 243 будет иметь следующее число, меньшее nnn. Однако как только число опустится ниже 243, следующее число может стать больше, потенциально возвращаясь к предыдущему числу.

4) Временная сложность для чисел меньше 243 Как только число опускается ниже 243, алгоритм потребует менее 243 шагов, чтобы либо сойтись к 1, либо обнаружить цикл с предыдущим числом. Следовательно, поскольку длина цикла или число шагов до достижения 1 ограничено 243, временная сложность алгоритма обнаружения цикла Флойда для чисел меньше 243 составляет O(1)O(1)O(1).

5) Временная сложность для чисел больше 243 Количество цифр в числе приблизительно равно log⁡(n)\log(n)log(n) (по основанию 10). Поэтому вычисление следующего числа для nnn займёт приблизительно log⁡(n)\log(n)log(n) шагов (то есть get_next_num выполняется за log⁡(n)\log(n)log(n) шагов).

Назовём следующее число nnn как n2n^2n2. Следующее число после n2n^2n2 (n3n^3n3) потребует приблизительно log⁡(n2)\log(n^2)log(n2) шагов для вычисления. Следующее число после n3n^3n3 (n4n^4n4) потребует приблизительно log⁡(n3)\log(n^3)log(n3) шагов и так далее. Из этого следует, что временная сложность процесса составляет O(log⁡(n)+log⁡(n2)+log⁡(n3)+…)O(\log(n)+\log(n^2)+\log(n^3)+\ldots)O(log(n)+log(n2)+log(n3)+…). Поскольку мы установили, что n>n2>…>nkn>n^2>\ldots>n^kn>n2>…>nk, где nkn^knk — последнее число, большее 243, доминирующим компонентом этой временной сложности является O(log⁡(n))O(\log(n))O(log(n)). Таким образом, временная сложность для чисел больше 243 составляет O(log⁡(n))O(\log(n))O(log(n)).

Заключение Когда nnn меньше 243, временная сложность равна O(1), а когда nnn больше 243 — O(log⁡(n))O(\log(n))O(log(n)). Следовательно, общая временная сложность алгоритма составляет O(log⁡(n))O(\log(n))O(log(n)).

Совет для собеседования

Совет: не тратьте время на сложные доказательства, если это не является важной частью собеседования.

На собеседовании обычно не ожидается точное определение временной сложности алгоритма, подобного использованному в этой задаче. В таких ситуациях можно сделать обоснованное предположение о том, как будет расти время выполнения алгоритма при увеличении входных данных, исходя из поведения алгоритма. Упоминайте любые допущения, которые вы делаете при обсуждении оценок. Также может быть полезно указать, какие аспекты задачи или решения затрудняют анализ временной сложности. В данной задаче изначально неясно, сколько шагов займёт процесс определения счастливого числа до достижения 1 или обнаружения цикла.

Глава 5

Скользящее окно

~15 мин чтения

Введение в технику скользящего окна

Введение в технику скользящего окна

Интуиция

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

The image represents a sliding window concept often used in algorithms.  It shows a sequence of elements represented by dots enclosed in square brackets `[ . . . . . . ]`. A light-blue rectangular area highlights a subset of these elements, labeled 'window'.  Two rectangular boxes, labeled 'left' and 'right,' are positioned above the sequence.  A downward-pointing arrow connects 'left' to the leftmost dot within the window, and a similar arrow connects 'right' to the rightmost dot within the window.  A light-blue upward-pointing arrow labeled 'window' indicates that this window can move along the sequence.  The arrangement visually demonstrates how the window's position is controlled by the 'left' and 'right' pointers, allowing it to slide across the data sequence, processing a fixed-size subset at a time.

Скользящие окна особенно ценны в сценариях, где алгоритмы иначе использовали бы два вложенных цикла для перебора всех возможных подкомпонентов, что давало бы временную сложность O(n2)O(n^2)O(n2) или хуже. При использовании скользящего окна нужный подкомпонент (или подкомпоненты) обычно находится за O(n)O(n)O(n) времени. Прежде чем разбираться, как это работает, установим необходимую терминологию:

  • Расширить окно — значит продвинуть правый указатель:
  • Сузить окно — значит продвинуть левый указатель:
  • Сдвинуть окно — значит продвинуть оба указателя одновременно: Заметим, что сдвиг эквивалентен одновременному расширению и сужению окна.

Теперь рассмотрим два основных типа алгоритмов скользящего окна:

  1. Фиксированное скользящее окно.
  2. Динамическое скользящее окно.

Фиксированное скользящее окно

Фиксированное скользящее окно сохраняет постоянную длину при скольжении по структуре данных.

Image represents a visual depiction of coding patterns related to window manipulation.  The image shows three sequential stages.  Each stage features a light-blue rectangular 'window' containing several black dots representing data elements, enclosed in square brackets.  Above each window, rectangular boxes labeled 'left' (in gray) and 'right' (in orange) indicate directional operations.  In the first stage, a gray 'left' box points down with a solid arrow to the leftmost dot, while an orange 'right' box points down with a dashed orange arrow to a dot near the right edge.  The text below the first window reads 'expand window to desired size,' indicating that the window expands to accommodate new data.  The second and third stages depict a 'slide' operation.  Here, the 'left' and 'right' boxes point to the leftmost and rightmost dots respectively, using dashed orange arrows.  The text 'slide' appears below the second and third windows.  Solid black arrows connect the stages, showing the transition from expanding the window to sliding it.  The overall diagram illustrates how data is added to (expand) and then moved within (slide) a window, showcasing different data manipulation techniques.

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

Если фиксированное окно длиной k пройдёт по структуре данных от начала до конца, оно гарантированно увидит каждый подкомпонент длиной k в этой структуре данных.

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

python
left = right = 0
while right < n:
    # If the window has reached the expected fixed length, we slide the window (move
    # both left and right).
    if right - left + 1 == fixed_window_size:
        # Process the current window.
        result = process_current_window()
        left += 1
    right += 1

Динамическое скользящее окно

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

Image represents a sequence of three diagrams illustrating a data structure manipulation.  The first diagram shows a light-blue rounded rectangle representing a data structure containing several black dots, enclosed in square brackets.  A grey box labeled 'left' points down with an arrow to the leftmost dot, and an orange box labeled 'right' points down with an arrow to a dot near the right edge.  Orange dashed lines connect the dots, indicating an expansion of the structure. The label 'expand' is below the rectangle.  A solid arrow points to the second diagram, which shows the same structure but with fewer dots, labeled 'shrink,' indicating a reduction in size.  The 'left' and 'right' boxes similarly point to the leftmost and rightmost dots, respectively.  Finally, a solid arrow leads to the third diagram, which is identical in structure to the first, again labeled 'expand,' showing the data structure expanding again.  The 'left' and 'right' boxes maintain their positions and directional arrows.  The overall sequence demonstrates a dynamic data structure that can expand and shrink, with the 'left' and 'right' labels possibly indicating pointers or access points to the structure's boundaries.

Как правило, динамические скользящие окна применяются к задачам, требующим найти наидлиннейший или наикратчайший подкомпонент, удовлетворяющий условию (например, условие может состоять в том, что числа в окне должны быть больше 10).

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

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

Ниже приведён общий шаблон, демонстрирующий принцип работы:

python
left = right = 0
while right < n:
    # While the condition is violated, the window is invalid, so shrink the window by
    # advancing the left pointer.
    while condition is violated:
        left += 1
    # Once the window is valid, process it and then expand the window by advancing the
    # right pointer.
    result = process_current_window()
    right += 1

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

Пример из реальной жизни

Буферизация при потоковой передаче видео: при потоковой передаче видео динамическое скользящее окно может использоваться для управления буферизацией и обеспечения плавного воспроизведения.

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

Содержание главы

Image represents a hierarchical diagram illustrating the concept of 'Sliding Windows' in coding patterns.  At the top, a rounded rectangle labeled 'Sliding Windows' acts as the parent node.  From this node, two dashed lines descend, each pointing to a child node represented by a rounded rectangle with a dashed border. The left child node is labeled 'Fixed Sliding Window' and contains a single bullet point: 'Substring Anagrams'. The right child node is labeled 'Dynamic Sliding Window' and contains two bullet points: 'Longest Substring With Unique Characters' and 'Longest Uniform Substring After Replacements'.  The arrangement shows that 'Sliding Windows' is a broader category encompassing both 'Fixed Sliding Window' and 'Dynamic Sliding Window' approaches, with specific problem examples listed under each subtype.  No URLs or parameters are present.

Анаграммы в подстроке

Анаграммы в подстроке

Даны две строки s и t, состоящие из строчных букв латинского алфавита. Верните количество подстрок строки s, являющихся анаграммами строки t.

Анаграмма — это слово или фраза, образованная перестановкой букв другого слова или фразы с использованием всех исходных букв ровно по одному разу.

Пример:

python
Input: s = 'caabab', t = 'aba'
Output: 2

Пояснение: есть анаграмма t, начинающаяся с индекса 1 ("caabab"), и ещё одна, начинающаяся с индекса 2 ("caabab").

Интуиция

Можно переформулировать понятие анаграммы, изменив приведённое определение. Подстрока строки s является анаграммой t, если она содержит ровно те же символы, что и t, в любом порядке.

Чтобы подстрока s была анаграммой t, она должна иметь ту же длину, что и t (обозначим её len_t). Это означает, что достаточно рассматривать только подстроки s длиной len_t, что избавляет от необходимости проверять каждую возможную подстроку.

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

Image represents a visual depiction of a sliding window algorithm, likely for string manipulation or pattern matching, where `len_t = 3` defines the window size.  The diagram shows four stages. Each stage features two orange rectangular boxes labeled 'left' and 'right,' representing pointers or indices at the beginning and end of a sliding window, respectively.  Arrows descend from these boxes to a light-blue rectangular box containing a sequence of characters ('c', 'a', 'a', 'b', 'a', 'b').  The sequence represents the input string.  In each stage, the window, indicated by the light-blue box, slides one position to the right. The first stage shows the window encompassing 'c', 'a', 'a'. The second stage shows the window shifted to 'a', 'a', 'b'. The third stage shows 'a', 'b', 'a'. The fourth stage shows 'b', 'a', 'b'.  Gray arrows separate the stages, indicating the progression of the sliding window across the input string.  The algorithm appears to be processing the string in a left-to-right manner, with the window size remaining constant at 3.

Теперь нам нужен способ проверить, является ли окно анаграммой t. Вспомним, что в анаграмме порядок букв не важен — важна только частота каждой буквы. Сравнивая частоту каждого символа в окне с частотами символов строки t, можно определить, является ли данное окно анаграммой t.

Рассмотрим это рассуждение на примере. Перед запуском алгоритма скользящего окна нужен способ хранить частоты символов строки t. Можно использовать хэш-карту или массив expected_freqs для хранения частот каждого символа строки t.

expected_freqs — это целочисленный массив размером 26, где каждый индекс соответствует одной из строчных букв латинского алфавита (0 для 'a', 1 для 'b' и так далее вплоть до 25 для 'z').

Image represents a diagram illustrating the concept of frequency counting in a string.  The diagram shows a string `s = 'caababb'` and a target string `t = 'aba'`.  Curved arrows connect the characters of `t` to a frequency array labeled `expected_freqs`. This array represents the expected frequencies of characters in `t`.  Specifically, the array `expected_freqs` shows that 'a' appears twice (2), 'b' appears once (1), 'c' appears zero times (0), and all other characters from 'd' to 'z' also appear zero times (represented by `... 0`). The arrangement visually demonstrates how the characters in the target string `t` are mapped to their corresponding counts within the `expected_freqs` array, which is used to determine the frequency of each character in the target string.

Для настройки алгоритма скользящего окна определим следующие компоненты:

  • Левый и правый указатели: инициализируются в начале строки для задания границ окна.
  • window_freqs: массив размером 26 для отслеживания частот символов внутри окна.
  • count: переменная для подсчёта обнаруженных анаграмм.

Перед скольжением окна сначала нужно расширить его до фиксированной длины len_t. Это делается продвижением правого указателя до тех пор, пока длина окна не станет равной len_t. При расширении необходимо обновлять window_freqs для отражения частот символов в окне:

Image represents a diagram illustrating a data structure or algorithm related to character frequency counting within a sliding window.  Two rectangular boxes labeled 'left' and 'right' are positioned above a light-blue square labeled 'C'. Arrows descend from 'left' and 'right' pointing to 'C', suggesting that 'left' and 'right' might represent boundaries of a sliding window.  To the right of the 'C' square, a sequence of characters 'caabab' is shown.  Adjacent to this character sequence, a variable named 'window_freqs' is defined and assigned a list or array. This array appears to represent the frequency of each character within the window, indicated by the numbers 0, 0, 1, followed by an ellipsis (...) and ending with 0.  Subscripts beneath the numbers in the array 'window_freqs' show that the values correspond to characters 'a', 'b', 'c', ..., 'z', implying that the array tracks the frequency of each character from 'a' to 'z' within the defined window.  The diagram likely depicts a step in a process where the window moves across a larger sequence of characters, updating the 'window_freqs' array at each step.
Image represents a sliding window mechanism demonstrating character frequency counting.  Two rectangular boxes labeled 'left' and 'right' (in gray and orange respectively) point downwards with arrows towards a light-blue box containing the character 'c' and followed by the character sequence 'aabab'.  The arrows indicate the movement of a sliding window of size one character. The 'left' box indicates the leftmost position of the window, and the 'right' box indicates the rightmost position.  To the right of the character sequence, a variable named `window_freqs` is defined and assigned a list representing character frequencies within the window.  The list shows '1' for 'a', '0' for 'b', '1' for 'c', and ellipses (...) indicating continuation, ending with '0' for 'z'.  The numbers in the list represent the count of each character within the current window, with the characters 'a' to 'z' listed below the corresponding frequency values.  The diagram illustrates how the window moves across the sequence, updating the `window_freqs` list to reflect the character frequencies in each position.
Image represents a sliding window over a sequence of characters ('c', 'a', 'a', 'b', 'a', 'b').  A rectangular box highlights the current window, labeled 'len_t' indicating its length.  Above the window, two rectangular boxes labeled 'left' and 'right' show the direction of window movement.  An arrow points from 'left' to the leftmost character of the window, and a dashed arrow points from 'right' to the second 'a' in the window, suggesting the window's movement. To the right, a variable named `window_freqs` is defined as an array; this array represents the frequency of each character within the current window.  The array shows '2' for 'a', '0' for 'b', '1' for 'c', and ellipses (...) indicating other characters, finally showing '0' for 'z'.  The characters 'a', 'b', 'c', and 'z' are subscripted under their corresponding frequencies in the array, clarifying which character each frequency represents.

После того как окно достигло фиксированной длины len_t, можно проверить, является ли оно анаграммой t, сравнив массивы expected_freqs и window_freqs. Это делается за константное время, поскольку требует всего 26 сравнений — по одному для каждой строчной буквы латинского алфавита.

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

Image represents a Python-like array assignment statement.  The statement `expected_freqs = [...]` assigns a numerical array to the variable `expected_freqs`. The array is enclosed in square brackets `[]` and contains numerical values representing frequencies.  Specifically, the first element is 2, associated with the character 'a'; the second is 1, associated with 'b'; the third is 0, associated with 'c'; and the remaining elements (indicated by an ellipsis '...') are all 0, extending up to and including the element associated with the character 'z'.  The characters 'a' through 'z' are shown below the corresponding frequency values, indicating that the array likely represents the expected frequencies of each letter in the alphabet.  There are no URLs or parameters present.
Image represents a data flow diagram illustrating a comparison process.  On the left, two labeled boxes, 'left' and 'right,' point downwards with arrows to a light-blue rectangular box containing the character sequence 'c a a b a b'. This sequence appears to be a data window. To the right, the variable `window_freqs` is defined and assigned an array-like value `[2 0 1 ... 0]`, where the subscripts 'a', 'b', 'c', and 'z' indicate that the array represents the frequencies of characters 'a', 'b', 'c', and so on, up to 'z' within the window.  The ellipsis (...) denotes that the array continues with frequencies for characters not explicitly shown.  Below, a dashed-line box shows a comparison: `window_freqs != expected_freqs`, indicating that the calculated frequencies (`window_freqs`) are being checked against some pre-defined `expected_freqs`. An arrow labeled 'continue' emerges from this box, suggesting that the process continues if the comparison is true (i.e., the frequencies do not match the expected values).
Image represents a sliding window algorithm visualization.  Two orange rectangular boxes labeled 'left' and 'right' point downwards with arrows to a light-blue rectangular box containing a sequence of characters 'c a a b a b'.  Dashed lines extend from the arrows to indicate the window's boundaries. The 'left' pointer is positioned at the 'a' and the 'right' pointer at the 'b', defining the current window. Above, a list `window_freqs = [2 1 0 ... 0]` shows the frequency count of characters within the window (2 'a's, 1 'b', 0 'c', etc., up to 'z').  To the right, a light-grey box depicts a conditional check: `window_freqs == expected_freqs`, implying a comparison with a pre-defined frequency array (`expected_freqs`). If the condition is true, a counter `count` is incremented (`count += 1`).  The diagram illustrates how the window moves across the character sequence, updating `window_freqs` and checking for matches against `expected_freqs` at each step.
Image represents a sliding window algorithm segment.  Two orange rectangular boxes labeled 'left' and 'right' point downwards with arrows to a light-blue rectangular box containing the character sequence 'a b a'.  Dashed arrows indicate the window's movement.  The character 'c' precedes the window, and 'b' follows it. To the right,  'window_freqs = [2a 1b 0c ... 0z]' shows a frequency array;  '2' indicates two occurrences of 'a' within the window, '1' shows one 'b', and '0' represents zero occurrences of 'c' and all other characters (represented by '...'). A light-grey dashed-line box displays the condition 'window_freqs == expected_freqs' which, if true, increments a counter: 'count += 1'.  The diagram illustrates how the algorithm checks character frequencies within a sliding window against an expected frequency distribution ('expected_freqs'), updating a counter when a match is found.
Image represents a sliding window algorithm visualization.  Two orange rectangular boxes labeled 'left' and 'right' point downwards via orange arrows to a light-blue rectangular box containing the character sequence 'c a a b'.  Dotted orange lines indicate the window's movement. Above, 'window_freqs = [1 2 0 ... 0]' shows an array representing the frequency of characters ('a', 'b', 'c', ..., 'z') within the current window, with 'a' having frequency 1, 'b' having frequency 2, and others having frequency 0.  A light-grey dashed-line box displays the condition 'window_freqs != expected_freqs' and an arrow pointing right labeled 'continue,' indicating that if the window's character frequencies do not match the 'expected_freqs' (not shown), the algorithm continues processing.  The overall diagram illustrates a step in a character frequency-based algorithm where a sliding window is used to compare observed frequencies against expected frequencies.

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

Небольшая оптимизация: возвращать 0, если длина t превышает длину s, поскольку сформировать анаграмму t из подстрок s невозможно, если t длиннее.

Реализация

В Python можно использовать ord(character) - ord('a') для нахождения индекса строчной буквы латинского алфавита в массиве размером 26. Функция ord принимает символ и возвращает его ASCII-код. Эта формула вычисляет расстояние данного символа от 'a', давая индекс в диапазоне от 0 до 25.

python
def substring_anagrams(s: str, t: str) -> int:
    len_s, len_t = len(s), len(t)
    if len_t > len_s:
        return 0
    count = 0
    expected_freqs, window_freqs = [0] * 26, [0] * 26
    # Populate 'expected_freqs' with the characters in string 't'.
    for c in t:
        expected_freqs[ord(c) - ord('a')] += 1
    left = right = 0
    while right < len_s:
        # Add the character at the right pointer to 'window_freqs' before sliding the
        # window.
        window_freqs[ord(s[right]) - ord('a')] += 1
        # If the window has reached the expected fixed length, we advance the left
        # pointer as well as the right pointer to slide the window.
        if right - left + 1 == len_t:
            if window_freqs == expected_freqs:
                count += 1
            # Remove the character at the left pointer from 'window_freqs' before
            # advancing the left pointer.
            window_freqs[ord(s[left]) - ord('a')] -= 1
            left += 1
        right += 1
    return count

Анализ сложности

Временная сложность: временная сложность substring_anagrams составляет O(n)O(n)O(n), где nnn — длина s. Вот почему:

  • Заполнение массива expected_freqs занимает O(m)O(m)O(m) времени, где m — длина t. Поскольку mmm гарантированно не превышает nnn на этом этапе, это не является доминирующим членом временной сложности.
  • Затем мы линейно обходим строку s двумя указателями, что занимает O(n)O(n)O(n) времени.
  • Заметим, что на каждой итерации сравнение двух массивов частот (expected_freqs и window_freqs) выполняется за O(1)O(1)O(1), поскольку каждый массив содержит лишь 26 элементов.

Пространственная сложность: пространственная сложность равна O(1)O(1)O(1), поскольку каждый массив частот содержит лишь 26 элементов.

Наидлиннейшая подстрока из уникальных символов

Наидлиннейшая подстрока из уникальных символов

Дана строка. Определите длину её наидлиннейшей подстроки, состоящей только из уникальных символов.

Пример:

python
Input: s = 'abcba'
Output: 3

Пояснение: подстрока "abc" — наидлиннейшая подстрока длиной 3, содержащая уникальные символы ("cba" также подходит).

Интуиция

Подход «в лоб» предполагает перебор всех возможных подстрок и проверку, состоят ли они исключительно из уникальных символов. Разберём этот подход:

  • Проверка подстроки на уникальность выполняется за O(n)O(n)O(n) времени путём сканирования подстроки с использованием хэш-множества для отслеживания каждого символа, где n — длина s. Если встречается символ, уже находящийся в хэш-множестве, значит, это дубликат.
  • Перебор всех возможных подстрок занимает O(n2)O(n^2)O(n2) времени.

Это означает, что подход «в лоб» в целом потребует O(n3)O(n^3)O(n3) времени. Это довольно медленно, во многом потому что мы просматриваем каждую подстроку. Можно ли сократить количество просматриваемых подстрок?

Скользящее окно Техники скользящего окна весьма полезны для задач, связанных с подстроками. В частности, поскольку мы ищем наидлиннейшую подстроку, удовлетворяющую конкретному условию (содержит уникальные символы), алгоритм с динамическим скользящим окном может быть правильным выбором, как обсуждалось во введении.

Любое окно можно охарактеризовать одним из двух способов. Окно либо:

  1. Состоит только из уникальных символов (окно без дублирующихся символов).
Image represents a visual depiction of a data structure or algorithm concept related to character uniqueness.  A light-blue rectangular box contains the characters 'a', 'b', and 'c' arranged horizontally.  A gray line underlines this box, with a downward-pointing arrow extending from the center of the line. Below the arrow, the text 'no duplicate characters' is displayed, indicating a constraint or condition.  The characters 'b' and 'a' appear to the right of the box, suggesting a potential input stream or sequence. The overall arrangement implies a process where the characters within the box are checked against subsequent characters (like 'b' and 'a') to ensure no duplicates exist, possibly as part of a validation or filtering step within a larger algorithm.
  1. Содержит хотя бы один символ с частотой больше 1.

Если верно условие A, следует расширить окно, продвинув правый указатель, чтобы найти более длинное окно, также не содержащее дубликатов.

Если верно условие B из-за наличия дублирующегося символа в окне, следует сузить окно, продвигая левый указатель, пока оно не перестанет содержать дубликат.

Применим эту стратегию к следующему примеру. Инициализируем хэш-множество для отслеживания символов в окне.

Image represents a sequence of characters 'a', 'b', 'c', 'b', 'a' displayed horizontally, each character having a corresponding numerical index (0, 1, 2, 3, 4) shown below in a lighter gray font.  This sequence is positioned to the left of a rectangular box labeled 'hash set'.  There are no explicit connections drawn between the character sequence and the hash set, implying a conceptual relationship rather than a direct data flow. The image likely illustrates the concept of adding elements from the sequence into a hash set, where the hash set would only contain unique elements ('a', 'b', 'c' in this case), eliminating duplicates.  The indices suggest an ordered input, while the hash set represents an unordered collection of unique elements.

Для реализации техники скользящего окна определим следующее:

  • Левый и правый указатели: инициализируются в начале строки для задания границ окна.
  • hash_set: хэш-множество для хранения уникальных символов в окне, обновляемое по мере его расширения. Заметим, что хэш-множество на диаграмме отражает своё состояние до добавления в него символа у правого указателя.

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

Image represents a diagram illustrating a sliding window algorithm.  Two rectangular boxes labeled 'left' and 'right' point downwards to a sequence of characters 'a b c b a' indexed 0 through 4.  A light-blue box highlights the first character 'a' at index 0.  To the right, an empty rectangular box labeled 'hash_set' is shown. A dashed-line rectangle contains the text ''a' not in hash_set → expand window,' indicating that if the character 'a' is not found within the hash_set, the window expands.  The arrows and text describe the conditional logic of the algorithm: the 'left' and 'right' pointers control the window's boundaries, and the 'hash_set' is used to track characters within the current window; the window expands if a character is not present in the hash_set.
Image represents a sliding window algorithm visualization.  Two rectangular boxes labeled 'left' (gray) and 'right' (orange) point downwards with arrows to a light-blue rectangular box containing a sequence of characters: 'a', 'b', 'c', 'b', 'a', indexed from 0 to 4.  A dashed orange arrow curves from index 0 to index 1, indicating a window's movement.  Separately, a rectangular box labeled 'hash_set' contains the character 'a'. A light-gray, dashed-bordered rectangle displays the condition ' 'b' not in hash_set → expand window', illustrating that if the character 'b' is not found within the hash_set, the window expands.  The overall diagram shows the interaction between a sliding window (represented by the light-blue box and the arrows), a hash set (containing elements), and a conditional statement that governs the window's expansion based on the presence or absence of elements in the hash set.
Image represents a sliding window algorithm visualization.  A light-blue rectangular box labeled 'a b c b a' with indices 0 through 4 underneath represents the input array.  A gray box labeled 'left' points down to the beginning of the array, and an orange box labeled 'right' points to the element 'c' at index 2. A dashed arrow from the 'b' at index 1 to the 'c' at index 2 indicates the movement of the right pointer.  Separately, a box labeled 'hash_set' contains the elements 'a' and 'b'. A light-gray, dashed-bordered rectangle displays the condition ' 'c' not in hash_set → expand window,' illustrating that because 'c' is not present in the hash set, the window expands (the right pointer moves).  The overall diagram shows the interaction between a sliding window, a hash set used for tracking elements within the window, and the condition that triggers window expansion.
Image represents a sliding window algorithm visualization for finding the longest substring without repeating characters.  A sequence 'abcba' is shown, with a sliding window indicated by a light red background.  A gray box labeled 'left' points down to the leftmost element ('a') of the window, and an orange box labeled 'right' points to the current rightmost element ('b').  The window's indices (0-4) are displayed below the sequence.  A dashed arrow from the second 'b' loops back to itself, highlighting the duplicate.  Separately, a box represents a 'hash_set' containing elements 'a', 'b', and 'c', with 'b' shaded to indicate its presence.  A light gray dashed box to the right explains the algorithm's logic: if 'b' is found in the 'hash_set' (indicating a duplicate), the window shrinks.  The red text 'window contains duplicate' clarifies the current state.

Выше видно, что символ 'b' с индексом 3 является дублирующимся в окне, поскольку 'b' уже находится в хэш-множестве.

Обнаружив дубликат, следует сужать окно, продвигая левый указатель, пока окно не перестанет содержать дублирующийся 'b'. Как только окно снова становится корректным, продолжаем расширение:

Image represents a sliding window algorithm for finding duplicates within a sequence.  Two rectangular boxes labeled 'left' (orange) and 'right' (gray) point downwards to a sequence 'a b c b a' with indices 0-4 displayed below.  The 'left' pointer indicates the start of the window, currently at index 0. The 'right' pointer indicates the end of the window, currently at index 3.  A dashed orange line connects the 'left' box to the 'b' at index 1, highlighting the window's movement. The sequence portion 'b c b' (indices 1-3) is highlighted in light red, indicating the current window.  Separately, a rectangular box labeled 'hash_set' contains 'b' (highlighted in gray) and 'c', representing a set storing elements within the current window.  A light gray, dashed-bordered rectangle displays the text ''b' still in hash_set → shrink window', indicating that because 'b' is already present in the hash_set, the window needs to shrink from the left to remove the duplicate.  The text 'window contains duplicate' in red below the sequence emphasizes the presence of a duplicate within the current window.
Image represents a sliding window algorithm visualization.  At the top, two rectangular boxes labeled 'left' (in orange) and 'right' (in gray) represent pointers indicating the window's boundaries.  Arrows from these boxes point downwards to a sequence of characters 'a b c b a' indexed 0 through 4, indicating the data stream.  A light-blue rectangle highlights the current window encompassing 'c b'. A dashed arrow from 'b' at index 1 points to the 'c' at index 2, suggesting the window's movement. To the right, a rectangular box labeled 'hash_set' contains the character 'c', representing a set storing characters within the current window. Finally, a light-gray, dashed-bordered rectangle describes the algorithm's logic: ' 'b' not in hash_set → expand window', indicating that if a character ('b' in this case) is not found in the hash_set, the window expands.
Image represents a sliding window algorithm visualization.  A sequence of characters 'a', 'b', 'c', 'b', 'a' is shown, indexed from 0 to 4.  Two rectangular boxes labeled 'left' (gray) and 'right' (orange) point downwards with arrows to the sequence, indicating the boundaries of a sliding window.  The window initially encompasses 'c', 'b', and 'a' (indices 2, 3, and 4). A separate box labeled 'hash_set' contains 'c' and 'b', representing a set of characters encountered within the window. A dashed light-blue rectangle highlights the current window. A light-gray, dashed-bordered box describes the algorithm's logic: if the character 'a' is not found in the `hash_set`, the window expands.  The arrows visually connect the 'right' pointer, the window's expansion, and the update of the `hash_set`.  The indices below the character sequence provide positional context.

Дальнейшее расширение окна приведёт к выходу правого указателя за границу строки, на этом поиск заканчивается. Наидлиннейшая найденная подстрока без дубликатов имеет длину 3. Можно использовать переменную max_len для отслеживания этой длины в процессе поиска.

Реализация

python
def longest_substring_with_unique_chars(s: str) -> int:
    max_len = 0
    hash_set = set()
    left = right = 0
    while right < len(s):
        # If we encounter a duplicate character in the window, shrink the window until
        # it's no longer a duplicate.
        while s[right] in hash_set:
            hash_set.remove(s[left])
            left += 1
        # Once there are no more duplicates in the window, update 'max_len' if the
        # current window is larger.
        max_len = max(max_len, right - left + 1)
        hash_set.add(s[right])
        # Expand the window.
        right += 1
    return max_len

Анализ сложности

Временная сложность: временная сложность longest_substring_with_unique_chars составляет O(n)O(n)O(n), поскольку мы линейно обходим строку двумя указателями.

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

Оптимизация

Описанный подход решает задачу, но его можно оптимизировать. Оптимизация касается способа сужения окна при обнаружении дублирующегося символа. Рассмотрим следующий пример, где правый указатель встречает дублирующийся символ 'c':

Image represents a diagram illustrating a coding pattern, possibly related to array manipulation or string processing.  A light pink rectangle displays a sequence of characters: 'c a b c_ d e c a'.  Above this rectangle, two rectangular boxes labeled 'left' and 'right' are positioned, each with a downward-pointing arrow indicating data flow. The 'left' arrow points to the beginning of the sequence, and the 'right' arrow points to the last 'c' within the sequence.  Below the rectangle, a curved arrow points upward from the last 'c' to the first 'c', labeled 'duplicate c', suggesting a duplication or mirroring operation. The underscore ('_') under the third 'c' might indicate a placeholder or a position where an operation is performed. The overall diagram visually depicts a process where a character ('c') is duplicated or inserted at a specific location within a sequence, potentially based on pointers or indices represented by 'left' and 'right'.

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

Image represents a diagram illustrating a merging operation, possibly within a sorting algorithm or data structure manipulation.  Two labeled boxes, 'left' (in orange) and 'right' (in gray), represent input sequences.  The 'left' box points downwards with an arrow to a light-blue rectangular box containing the elements 'd' and 'e'.  The 'right' box similarly points to the same light-blue box, adding the element 'c' to its right.  Before the light-blue box, three elements 'c', 'a', and 'b' are shown, with dashed orange arrows indicating they are part of the 'left' input sequence, and are to be merged into the light-blue box.  Another element 'c' is shown to the right of the light-blue box, and an element 'a' is shown further to the right, indicating these are part of the 'right' input sequence. The overall arrangement suggests that elements from the 'left' and 'right' sequences are being combined into a single sorted or ordered sequence within the light-blue box, with the final result potentially being 'c', 'a', 'b', 'c', 'd', 'e', 'c', 'a'.

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

Image represents a diagram illustrating a data manipulation or merging process.  A light-blue rectangular box contains the characters 'd', 'e', 'c', and 'a'.  Above this box, two rectangular boxes are labeled 'left' (in orange) and 'right' (in gray). A solid orange arrow descends from 'left' pointing to the 'd' character in the light-blue box, indicating data insertion or modification at that point. A solid gray arrow descends from 'right', pointing to the 'c' character in the light-blue box, suggesting data insertion or modification at that location.  To the left of the light-blue box, the characters 'c', 'a', 'b', and 'c' (with an underscore) are shown. A dashed orange curved line connects the 'c' and 'b' characters to the 'd' character in the light-blue box, suggesting a potential relationship or data flow from the left side, possibly indicating data being moved or combined. The overall diagram likely depicts a coding pattern involving the merging or manipulation of data streams or arrays, with 'left' and 'right' representing input sources and the light-blue box representing the resulting output.

Это даёт нам новую стратегию продвижения левого указателя: если правый указатель встречает символ, предыдущий индекс которого (предыдущее вхождение) находится в окне, необходимо переместить левый указатель на один индекс дальше этого предыдущего индекса.

Для хранения предыдущего индекса каждого символа строки можно использовать хэш-карту (prev_indexes).

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

  • Если этот индекс находится после левого указателя, он внутри окна.
  • Если он находится до левого указателя, он вне окна.

Ниже показано, как проверить, находится ли символ внутри окна:

Image represents a comparison of how a sliding window algorithm handles duplicate elements within and outside its current view.  The diagram is split into two sections, each illustrating a scenario.  The left section, titled 'Previous index inside the window:', shows a sequence 'cabcdeca' where a sliding window is highlighted, encompassing 'abcde'.  Above this sequence, 'left' and 'right' boxes indicate the window's boundaries. Arrows point from these boxes to the 'a' at the left and 'c' at the right edge of the highlighted section. Below, a box displays the code `prev_indexes['c'] = 3 ≥ left`, indicating that the previous index of 'c' (3) is greater than or equal to the left boundary of the window (implied as 0), signifying that 'c' is already present within the window.  Two arrows then point to the conclusions: 'inside window' and 'duplicate in the window'. The right section, titled 'Previous index outside the window:', shows a similar setup with the sequence 'cabcdeca', but the highlighted window is 'abc'.  Again, 'left' and 'right' boxes point to the 'd' and 'a' respectively.  The code `prev_indexes['a'] = 1 < left` shows that the previous index of 'a' (1) is less than the left boundary, meaning 'a' is outside the current window.  Consequently, two arrows point to 'outside window' and 'not a duplicate in the window'.  Both sections use color-coding: the highlighted window sections are shaded differently (pink/light blue) to distinguish the current window's contents, and the element being checked for duplication ('c' and 'a') is highlighted in a distinct color (peach/orange).

Реализация — оптимизированный подход

python
def longest_substring_with_unique_chars_optimized(s: str) -> int:
    max_len = 0
    prev_indexes = {}
    left = right = 0
    while right < len(s):
        # If a previous index of the current character is present in the current
        # window, it's a duplicate character in the window.
        if s[right] in prev_indexes and prev_indexes[s[right]] >= left:
            # Shrink the window to exclude the previous occurrence of this character.
            left = prev_indexes[s[right]] + 1
        # Update 'max_len' if the current window is larger.
        max_len = max(max_len, right - left + 1)
        prev_indexes[s[right]] = right
        # Expand the window.
        right += 1
    return max_len

Анализ сложности

Временная сложность: временная сложность оптимизированной реализации составляет O(n)O(n)O(n), поскольку мы линейно обходим строку двумя указателями.

Пространственная сложность: пространственная сложность равна O(m)O(m)O(m), поскольку для хранения уникальных символов используется хэш-карта, где mmm — общее количество уникальных символов в строке.

Наидлиннейшая однородная подстрока после замен

Наидлиннейшая однородная подстрока после замен

Однородная подстрока — это подстрока, в которой все символы одинаковы. Дана строка. Определите длину наидлиннейшей однородной подстроки, которую можно получить, заменив не более k символов.

Пример:

The image represents a visual illustration of finding the longest uniform substring within a given string.  The input string 'a b c d c c c a' is shown on the left.  Two 'c' characters within this string are highlighted with orange diagonal lines, indicating they are part of a uniform substring. A rightward-pointing arrow separates the input string from the output. The output, displayed in a light-blue rectangular box, shows the identified longest uniform substring 'c c c c c'. Above the output box, the text 'Longest uniform substring' is written in light-blue, clearly labeling the purpose of the diagram. The diagram visually demonstrates the process of identifying and extracting the longest consecutive sequence of identical characters from a given input string.
python
Input: s = 'aabcdcca', k = 2
Output: 5

Пояснение: если можно заменить только 2 символа, наидлиннейшая однородная подстрока, которую можно получить — "ccccc", заменив 'b' и 'd' на 'c'.

Интуиция

Определение однородности подстроки Прежде чем искать наидлиннейшую однородную подстроку, сначала выясним наиболее эффективный способ сделать строку однородной с минимальным количеством замен символов. Рассмотрим пример ниже:

Image represents a sequence of lowercase letters arranged horizontally.  The sequence is 'a b a a b a c', where each letter is individually spaced. There are no connections or arrows between the letters; they are simply presented as a linear string of characters. No URLs, parameters, or other information beyond the letters themselves are present in the image.  The letters appear to be a simple example data set, possibly illustrating a concept within the context of coding patterns, such as sequence analysis or pattern recognition.

Какие символы следует заменить, чтобы минимизировать количество замен при приведении строки к однородному виду? Есть три основных варианта: сделать строку всей из 'a', всей из 'b' или всей из 'c'. Наиболее эффективный выбор, требующий наименьшего числа замен, — сделать все символы 'a', что потребует лишь трёх замен:

Image represents a transformation process illustrated using sequences of characters.  On the left, we see three subsequences: 'ab', 'aa', and 'ac'.  Each subsequence is individually marked with an orange arrow pointing diagonally upwards and labeled with 'a', indicating that the 'a' element is being processed or removed. A thick black arrow points from the left-hand sequences to the right-hand sequence.  The right-hand side shows a sequence of eight 'a's. The diagram visually depicts a transformation where the input consists of three subsequences containing 'a's and other characters ('b' and 'c'). The transformation removes elements other than 'a' from each subsequence, resulting in an output sequence containing only 'a's, effectively filtering or extracting 'a's from the input.

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

Это означает, что зная максимальную частоту какого-либо символа в подстроке, можно определить, достаточно ли значения k для приведения этой подстроки к однородному виду. Количество символов, которые нужно заменить (num_chars_to_replace), находится вычитанием этой максимальной частоты из общего количества символов в подстроке:

Image represents a calculation to determine the number of characters to replace in a substring.  At the top, a sequence of characters 'a b a a b a c' is shown, with the character 'b' and 'c' crossed out in orange, indicating they are to be replaced. Below this, a grey box contains a calculation: `num_chars_to_replace = len(substring) - highest_freq`. This equation calculates the number of characters to replace by subtracting the highest frequency of any single character (`highest_freq`) from the total length of the substring (`len(substring)`).  The calculation is then shown step-by-step:  `len(substring)` is explicitly given as 7, `highest_freq` as 4, resulting in a final answer of 3 characters to replace (`num_chars_to_replace = 3`).  The variables `num_chars_to_replace`, `len(substring)`, and `highest_freq` are written in orange, black, and blue respectively, highlighting their roles in the calculation.

Вычислив num_chars_to_replace для данной подстроки, можно оценить, можно ли привести её к однородному виду:

  • Если num_chars_to_replace ≤ k, подстроку можно сделать однородной.
  • Если num_chars_to_replace > k, подстроку нельзя сделать однородной.

Для вычисления num_chars_to_replace нужно знать значение highest_freq. Для этого требуется отслеживать частоту каждого символа, что можно эффективно делать с помощью хэш-карты (freqs). Она позволяет обновлять highest_freq при обнаружении символа с большей частотой. Ниже показано, как обновляется freqs:

Image represents a data flow diagram illustrating the process of finding the highest frequency of a character in a sequence.  The input, shown as 'a b c' with a downward arrow indicating data flow, represents a sequence of characters. This input feeds into a table labeled 'freqs', which is a two-column table with headers 'char' and 'freq'.  The table shows the character 'a' and its frequency '1'.  This table acts as an intermediate data structure storing character frequencies.  A separate box contains a code snippet: `highest_freq = max(highest_freq, freqs['a'])`, which calculates the highest frequency encountered so far.  The variable `highest_freq` is updated using the `max` function, comparing its current value with the frequency of character 'a' (obtained from the `freqs` table using the key 'a'). The result of this calculation, '1', is displayed below the equation, indicating that the highest frequency found so far is 1.  The overall diagram shows how the input sequence is processed to determine the highest character frequency.
Image represents a data flow diagram illustrating a frequency counting algorithm.  The input consists of a sequence of characters 'a', 'b', 'b', 'c', depicted by a downward-pointing arrow above the sequence. This input feeds into a table labeled 'freqs,' which is a two-column table with headers 'char' and 'freq.'  The table stores the frequency of each character; 'a' has a frequency of 1, and 'b' has a frequency of 1.  A separate, light-grey, dashed-bordered box contains a Python code snippet: `highest_freq = max(highest_freq, freqs['b'])`, which calculates the highest frequency encountered so far.  The result of this calculation, `= 1`, is shown below the code snippet.  The code implies an iterative process where `highest_freq` is updated by comparing it with the frequency of each character in the `freqs` table.  The 'b' is used as an example in the code snippet, suggesting the algorithm iterates through each character in the input sequence.
Image represents a data flow diagram illustrating the process of finding the highest frequency of characters in a sequence.  The input is a sequence of characters 'a b b c', depicted as a horizontal list. A downward arrow indicates this sequence is processed to generate a frequency table labeled 'freqs'. This table is a 2-column structure with the left column labeled 'char' representing characters and the right column labeled 'freq' representing their frequencies. The table shows 'a' with frequency 1 and 'b' with frequency 2.  To the right, a separate box shows a Python-like code snippet calculating the highest frequency. The code `highest_freq = max(highest_freq, freqs['b'])` calculates the maximum frequency by comparing a current `highest_freq` (implicitly initialized to 0 or a lower value not shown) with the frequency of character 'b' (which is 2 from the table). The result, `= 2`, is displayed below, indicating that 2 is the highest frequency found in the input sequence.
Image represents a data flow diagram illustrating the process of finding the highest frequency of characters in a sequence.  The input is a sequence of characters 'a b b c' shown with a downward arrow indicating data flow into a frequency table labeled 'freqs'. This table is a two-column structure with 'char' representing the character and 'freq' representing its frequency. The table shows 'a' with frequency 1, 'b' with frequency 2, and 'c' with frequency 1.  To the right, a calculation is shown: `highest_freq = max(highest_freq, freqs['c']) = 2`. This equation indicates that the `highest_freq` variable is updated by taking the maximum value between its current value (implicitly assumed to be initialized to a value less than or equal to 2) and the frequency of character 'c' (which is 1 from the table), resulting in a final `highest_freq` value of 2.  The diagram visually demonstrates how the frequency table is generated from the input sequence and how a maximum frequency is calculated using the table's data.

Теперь, имея инструменты для определения того, можно ли сделать подстроку однородной, следующий шаг — выяснить, как найти наидлиннейшую однородную подстроку. Рассмотрим технику, позволяющую это сделать.

Динамическое скользящее окно Мы знаем, что скользящие окна полезны для решения задач с подстроками. Данная задача требует найти наидлиннейшую подстроку, удовлетворяющую конкретному условию:

num_chars_to_replace <= k

Поэтому динамическое скользящее окно может быть уместным, как обсуждалось во введении к главе.

Это условие можно использовать для определения того, расширять или сужать окно:

  • Если условие выполнено (окно корректно), расширяем его в поисках более длинного окна, также удовлетворяющего условию.
  • Если условие нарушено (окно некорректно), сужаем его до тех пор, пока условие не будет выполнено снова.

Посмотрим, как это работает на примере ниже:

Image represents a sequence of characters 'a a b c d c c a' followed by a comma and then an equation 'k = 2'.  The sequence of characters appears to be a data set or input, possibly representing a string or an array of elements.  The characters 'a', 'b', 'c', and 'd' are individual elements within this sequence, arranged linearly from left to right.  There's no explicit connection shown between the character sequence and the equation 'k = 2', but the proximity suggests a relationship, possibly indicating that 'k' is a parameter or variable related to the processing or analysis of the character sequence.  The value '2' assigned to 'k' might represent a length, a count, or some other relevant property of the sequence.  The comma acts as a separator between the character sequence and the equation.

Начинаем с определения левой и правой границ окна на индексе 0. Продолжаем расширять окно, пока оно удовлетворяет нашему условию (nums_char_to_replace <= k):

Image represents a visual explanation of a sliding window algorithm, possibly for character replacement or frequency analysis.  The left side shows a sequence of characters 'a a b c d c c a' with a light-blue highlighted 'a' at the beginning. Above this sequence, two boxes labeled 'left' and 'right' point downwards, indicating pointers or indices into the character sequence.  A value 'k = 2' is displayed, likely representing a parameter limiting the number of replacements or operations. The right side shows a calculation box. Inside, the variable `highest_freq` is set to 1.  A formula `num_chars_to_replace = window_length - highest_freq` is presented, followed by its evaluation as `1 - 1 = 0`. Finally, the condition `0 ≤ k` is shown, resulting in the action 'expand,' suggesting the window's size will increase. The overall diagram illustrates a step in an algorithm where the frequency of characters within a window is analyzed, and a decision is made based on the comparison of the number of characters to replace with the parameter 'k' to determine whether to expand the window.
Image represents a sliding window algorithm visualization.  Two labeled boxes, 'left' (in gray) and 'right' (in orange), point downwards with arrows towards a light-blue rectangular box containing the character sequence 'aa'. This sequence is part of a larger sequence 'aabcdcca' shown to the right.  A dashed arrow connects the 'right' box to the second 'a' in the light-blue box, indicating the window's current position. A separate gray box on the right displays calculations:  `highest_freq = 2`, representing the highest frequency of a character within the current window (which is 'a').  The next line calculates `num_chars_to_replace = window_length - highest_freq`, which evaluates to `2 - 2 = 0`. The final line states `0 ≤ k --> expand`, indicating that because the number of characters to replace is 0 or less, the window should expand.  The overall diagram illustrates a step in a sliding window algorithm where the window's size is determined by character frequencies.
Image represents a diagram illustrating a sliding window algorithm.  A sequence of characters 'a a b c d c c a' is shown.  Two rectangular boxes labeled 'left' and 'right' in gray and orange respectively, point downwards with arrows indicating the left and right boundaries of a sliding window currently encompassing 'a a b'.  A dashed orange arrow shows the window's right boundary moving one position to the right.  An upward red arrow labeled 'chars to replace' points to the character 'b' within the window, suggesting this character is a candidate for replacement. A separate gray box contains calculations:  `highest_freq` is set to 2, representing the highest frequency of a character within the window.  The formula `num_chars_to_replace = window_length - highest_freq` is shown, with a substitution using `window_length = 3` resulting in `num_chars_to_replace = 1`. Finally, the condition `1 ≤ k → expand` indicates that if the number of characters to replace (k) is greater than or equal to 1, the window should expand.
Image represents a diagram illustrating a sliding window algorithm.  A light-blue rectangular box displays a sequence of characters: 'a a b_c_ d c c a'.  A gray rectangular box to the right shows calculations: `highest_freq = 2`, `num_chars_to_replace = window_length - highest_freq = 4 - 2 = 2`, and `2 ≤ k → expand`.  Above the character sequence, a rectangular box labeled 'left' points down with an arrow to the beginning of the sequence, and another rectangular box labeled 'right' (in orange) points down with a dashed arrow to the characters 'b_c_'. Red upward arrows under 'b_c_' are labeled 'chars to replace', indicating these characters are targeted for replacement. The calculation in the gray box determines the number of characters to replace (`num_chars_to_replace`) based on the window length (4) and the highest frequency of any character within the window (2). The result (2) is compared to a variable 'k', and if 2 is less than or equal to k, the window expands.

Как только окно расширится до пятого символа ('d'), оно будет содержать 3 символа, которые нужно заменить для его приведения к однородному виду. Поскольку можно заменить не более k = 2 символов, окно становится некорректным. Следовательно, сужаем окно:

Image represents a sliding window algorithm visualization.  A sequence of characters 'a a b c_d c c a' is shown, with a light-red background highlighting a sub-sequence 'a a b c_d'.  Arrows pointing upwards from beneath this sub-sequence are labeled in red as 'chars to replace'.  A grey box to the right displays calculations: 'highest_freq = 2', indicating the highest frequency of any character within the window.  The next line calculates 'num_chars_to_replace = window_length - highest_freq', substituting values to get '5 - 2 = 3'. Finally, '3 > k → shrink' suggests that if the number of characters to replace (3) exceeds a threshold 'k' (not explicitly defined), the window should shrink.  Rectangles labeled 'left' and 'right' above the character sequence indicate the direction of window movement, with a dashed arrow suggesting the right pointer's movement within the window.
The image represents a data structure manipulation process.  A sequence of characters 'a b c d' is shown centrally, highlighted in a light pink rectangle.  Above this sequence, two rectangular boxes labeled 'left' (in orange) and 'right' (in gray) indicate the direction of data input.  A downward-pointing arrow from 'left' connects to the first 'a' in the sequence, with a dashed arrow indicating an additional 'a' to the left of the main sequence. A downward-pointing arrow from 'right' connects to the last 'd' in the sequence. Below the central sequence, red upward-pointing arrows connect to each character (b, c, and d) with the text 'chars to replace' indicating these characters are targets for modification. To the right, a dashed-line rectangle displays the text 'highest_freq = 2 X', suggesting that the character with the highest frequency (presumably 'a' or 'c' based on the visible data) has a frequency of 2, and the 'X' likely represents a placeholder or an operation to be performed based on this frequency. The overall diagram illustrates a process where data is input from left and right, and characters within a central sequence are identified and potentially replaced based on frequency analysis.

Заметим, что после сужения окна значение highest_freq по-прежнему равно 2, что уже неверно. Напомним, что наш текущий метод обновления highest_freq лишь увеличивает его при обнаружении символа с большей частотой — это означает, что highest_freq может только сохраняться или расти, но никогда не уменьшаться.

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

Это означает, что при обнаружении некорректного окна можно просто сдвигать его, а не сужать, фактически сохраняя длину текущего окна.

Учитывая это наблюдение, скорректируем предыдущую логику:

  • Если окно удовлетворяет условию: расширяем.
  • Если окно не удовлетворяет условию: сдвигаем.

Исправим действие, предпринятое для первого некорректного окна выше: вместо сужения сдвигаем. Затем продолжаем обработку оставшейся строки.

Image represents a sliding window algorithm visualization.  Two rectangular boxes labeled 'left' and 'right' point downwards to a sequence of characters 'a a b c d c c a' displayed horizontally. A light red background highlights the characters 'b c d' within the sequence. Red upward-pointing arrows beneath these highlighted characters are labeled 'chars to replace'.  To the right, a grey box shows a calculation:  `highest_freq` is set to 2; `num_chars_to_replace` is calculated as `window_length` (5) minus `highest_freq` (2), resulting in 3.  The final line indicates that if this result (3) is greater than some value `k` (not explicitly defined), a 'slide' operation should occur, implying the window moves along the character sequence.  The diagram illustrates how a sliding window identifies characters to be replaced based on frequency within the window, and the calculation determines the window's movement.
Image represents a sliding window algorithm visualization.  The left side shows a sequence of characters 'aabcdcca' with a highlighted sub-sequence 'a_b_c_d_c' in a light pink rectangle.  Orange boxes labeled 'left' and 'right' indicate pointers to the beginning and end of this window, respectively. Dashed orange arrows show the pointers moving across the sequence. Red upward arrows under the highlighted subsequence point to the text 'chars to replace,' indicating these characters within the window are candidates for replacement. The right side displays a calculation:  `highest_freq` is set to 2, representing the highest frequency of a character within the window.  The number of characters to replace is calculated as `window_length` (5) minus `highest_freq` (2), resulting in 3.  The final line states that if this result (3) is greater than a variable `k` (not defined in the image), then the window should slide.  The entire diagram illustrates how a sliding window algorithm determines which characters to replace based on frequency within a given window size.
Image represents a visual explanation of a sliding window algorithm, likely for character replacement or frequency analysis.  The left side shows a sequence of characters 'a a b c d c c a' with a light-blue highlighted window encompassing 'b c d c c'.  Orange rectangular boxes labeled 'left' and 'right' indicate pointers to the window's boundaries. Dashed arrows show the window's movement. Red upward arrows under the window point to the characters 'b', 'd', indicating these are characters to be replaced. The right side displays a calculation: `highest_freq` is set to 3, representing the highest frequency of a character within the window.  The number of characters to replace is calculated as `window_length - highest_freq` (5 - 3 = 2). The result (2) is compared to a variable 'k' (2 ≤ k), implying a condition for window expansion based on this comparison.  If the condition is met, the window expands; otherwise, it might contract or remain the same.
Image represents a sliding window algorithm visualization.  A sequence of characters 'a a b c d c c a' is shown.  A light-red rectangular box highlights a sub-sequence 'b c d c c a', representing a sliding window of length 6.  A grey box to the right displays calculations: `highest_freq = 3` (indicating the highest frequency of any character within the window), and `num_chars_to_replace = window_length - highest_freq = 6 - 3 = 3`.  This calculation determines the number of characters to replace within the window.  Red upward arrows under the window point to the characters 'b', 'd', and 'c' labeled 'chars to replace'.  Rectangular boxes labeled 'left' and 'right' with arrows indicate the direction of the sliding window movement.  A dashed arrow shows the rightward movement of the window. The final equation `3 < k → slide` suggests that if the number of characters to replace (3) is less than some variable 'k', the window slides to the right.

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

Реализация

python
def longest_uniform_substring_after_replacements(s: str, k: int) -> int:
    freqs = {}
    highest_freq = max_len = 0
    left = right = 0
    while right < len(s):
        # Update the frequency of the character at the right pointer and the highest
        # frequency for the current window.
        freqs[s[right]] = freqs.get(s[right], 0) + 1
        highest_freq = max(highest_freq, freqs[s[right]])
        # Calculate replacements needed for the current window.
        num_chars_to_replace = (right - left + 1) - highest_freq
        # Slide the window if the number of replacements needed exceeds 'k'.
        # The right pointer always gets advanced, so we just need to advance 'left'.
        if num_chars_to_replace > k:
            # Remove the character at the left pointer from the hash map before
            # advancing the left pointer.
            freqs[s[left]] -= 1
            left += 1
        # Since the length of the current window increases or stays the same, assign
        # the length of the current window to 'max_len'.
        max_len = right - left + 1
        # Expand the window.
        right += 1
    return max_len

Анализ сложности

Временная сложность: временная сложность longest_uniform_substring_after_replacements составляет O(n)O(n)O(n), где nnn — длина входной строки. Это обусловлено линейным обходом строки двумя указателями.

Пространственная сложность: пространственная сложность равна O(m)O(m)O(m), где mmm — количество уникальных символов строки, хранящихся в хэш-карте freqs.

Глава 8

Кучи

~6 мин чтения

Введение в кучи

Введение в кучи

Интуиция

Куча — структура данных, организующая элементы по приоритету и гарантирующая, что элемент с наивысшим приоритетом всегда доступен немедленно.

  • Мин-куча: приоритет отдаётся наименьшему элементу — он находится на вершине.
  • Макс-куча: приоритет отдаётся наибольшему элементу — он находится на вершине.
Image represents a comparison of a min-heap and a max-heap data structures.

Эффективная приоритизация возможна благодаря структуре кучи. Куча — это двоичное дерево, в котором каждый родительский узел имеет более высокий приоритет, чем его дочерние узлы.

Image represents a min-heap data structure as a binary tree.

Анализ временной сложности распространённых операций с кучей:

...heap operations table...

В этой главе рассматриваются практические применения куч. Для более глубокого понимания работы кучи обратитесь к разделу «Основные понятия» в конце.

Очередь с приоритетом: очередь с приоритетом — специальный тип кучи, следующий структуре мин-кучи или макс-кучи. Она позволяет добавлять элементы с произвольным приоритетом и извлекать элемент с наивысшим приоритетом.

Пример из реальной жизни

Управление задачами в операционных системах: операционные системы часто используют очередь с приоритетом для управления выполнением процессов.

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

Обзор главы

Hierarchical diagram illustrating the applications of Heaps data structure.

K наиболее частых строк

K наиболее частых строк

Найдите k наиболее часто встречающихся строк в массиве и верните их, отсортированных по убыванию частоты. При одинаковой частоте строки сортируются лексикографически.

Пример:

python
Input: strs = ['go', 'coding', 'byte', 'byte', 'go', 'interview', 'go'], k = 2
Output: ['go', 'byte']

Пояснение: строки "go" и "byte" встречаются наиболее часто — 3 и 2 раза соответственно.

Ограничения:

  • k ≤ n, где n — длина массива.

Интуиция — Макс-куча

Две основные задачи:

  1. Определить k наиболее частых строк.
  2. Отсортировать эти строки сначала по частоте, затем лексикографически.

Сначала нужно отслеживать частоты каждой строки. Для этого используется хеш-таблица.

Data transformation: list of strings converted into a frequency table.

Наиболее очевидный подход — получить массив строк из хеш-таблицы, отсортировать его и вернуть первые k элементов.

Data processing flow demonstrating the selection of the k most frequent elements.

Главная неэффективность — сортировка всех n строк, хотя нам нужны только k.

Полезное наблюдение: после удаления наиболее частой строки новой наиболее частой становится следующая по частоте.

Для реализации этой идеи нужна структура данных, обеспечивающая эффективный доступ к наиболее частой строке. Здесь идеально подходит макс-куча.

Max-heap populated with all string-frequency pairs.

Один из способов наполнить кучу — поместить в неё все n строк по одной, что займёт O(n log n) времени. В качестве альтернативы можно построить кучу за O(n) с использованием heapify.

Для получения k наиболее частых строк извлекайте верхний элемент из кучи k раз и сохраняйте результаты.

Step-by-step visualization of a top-k frequent element algorithm with k=2.

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

Реализация — Макс-куча

Создаётся класс Pair для пар строка-частота, позволяющий задать приоритет через пользовательский компаратор.

python
from typing import List
from collections import Counter
import heapq
    
class Pair:
   def __init__(self, str, freq):
       self.str = str
       self.freq = freq
    
   def __lt__(self, other):
       if self.freq == other.freq:
           return self.str < other.str
       return self.freq > other.freq
    
def k_most_frequent_strings_max_heap(strs: List[str], k: int) -> List[str]:
   freqs = Counter(strs)
   max_heap = [Pair(str, freq) for str, freq in freqs.items()]
   heapq.heapify(max_heap)
   return [heapq.heappop(max_heap).str for _ in range(k)]

Анализ сложности

Временная сложность: O(n + k log n). Пространственная сложность: O(n).

Интуиция — Мин-куча

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

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

Однако эту стратегию нельзя реализовать с макс-кучей, поскольку доступа к наименее частой строке нет. Вместо этого используется мин-куча: при её размере более k извлекается элемент с наименьшей частотой.

Min-heap approach: push each pair, pop when heap size exceeds k.
python
from typing import List
from collections import Counter
import heapq
    
class Pair:
    def __init__(self, str, freq):
        self.str = str
        self.freq = freq
    def __lt__(self, other):
        if self.freq == other.freq:
            return self.str > other.str
        return self.freq < other.freq
    
def k_most_frequent_strings_min_heap(strs: List[str], k: int) -> List[str]:
    freqs = Counter(strs)
    min_heap = []
    for str, freq in freqs.items():
        heapq.heappush(min_heap, Pair(str, freq))
        if len(min_heap) > k:
            heapq.heappop(min_heap)
    return [heapq.heappop(min_heap).str for _ in range(k)][::-1]

Анализ сложности

Временная сложность: O(n log k). Пространственная сложность: O(n) для хеш-таблицы, O(k) для кучи.

Объединение отсортированных связных списков

Объединение отсортированных связных списков

Даны k отсортированных по возрастанию связных списков. Объедините их в один отсортированный связный список и верните его голову.

Интуиция

Хорошее начало — понять, как объединить два отсортированных связных списка. Использование двух указателей позволяет сравнивать головы обоих списков и добавлять меньший узел в выходной список за O(n+m) времени.

Но что если связных списков больше двух? Объединение двух списков предполагает сравнение двух узлов одновременно. При k списках это k сравнений для каждого из n узлов, что в сумме даёт O(n·k).

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

Для начала заполним кучу головными узлами всех связных списков.

Heap populated with head nodes of all k linked lists.

Затем реализуем стратегию добавления узла с наименьшим значением в выходной список с помощью кучи.

Когда куча пуста, возвращаем dummy.next — голову объединённого списка.

Реализация

python
from typing import List
from ds import ListNode
import heapq
    
def combine_sorted_linked_lists(lists: List[ListNode]) -> ListNode:
    ListNode.__lt__ = lambda self, other: self.val < other.val
    heap = []
    for head in lists:
        if head:
            heapq.heappush(heap, head)
    dummy = ListNode(-1)
    curr = dummy
    while heap:
        smallest_node = heapq.heappop(heap)
        curr.next = smallest_node
        curr = curr.next
        if smallest_node.next:
            heapq.heappush(heap, smallest_node.next)
    return dummy.next

Анализ сложности

Временная сложность: O(n log k), где n — общее число узлов. Пространственная сложность: O(k).

Медиана потока целых чисел

Медиана потока целых чисел

Разработайте структуру данных, поддерживающую добавление целых чисел из потока данных и получение медианы всех добавленных чисел.

  • add(num: int) -> None: добавляет целое число в структуру данных.
  • get_median() -> float: возвращает медиану всех добавленных чисел.

Пример:

python
Input: [add(3), add(6), get_median(), add(1), get_median()]
Output: [4.5, 3.0]

Интуиция

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

Используется комбинация мин-кучи и макс-кучи:

  • Макс-куча управляет левой половиной; её верхнее значение — первое значение медианы.
  • Мин-куча управляет правой половиной; её верхнее значение — второе значение медианы.

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

Два правила управления кучами:

  1. Максимальное значение макс-кучи (левая половина) должно быть меньше или равно минимальному значению мин-кучи (правая половина).
  2. Кучи должны быть равного размера, но макс-куча может содержать на один элемент больше.

Реализация

python
import heapq
    
class MedianOfAnIntegerStream:
    def __init__(self):
        self.left_half = []   # max-heap (negated)
        self.right_half = []  # min-heap
        
    def add(self, num: int) -> None:
        if not self.left_half or num <= -self.left_half[0]:
            heapq.heappush(self.left_half, -num)
            if len(self.left_half) > len(self.right_half) + 1:
                heapq.heappush(self.right_half, -heapq.heappop(self.left_half))
        else:
            heapq.heappush(self.right_half, num)
            if len(self.right_half) > len(self.left_half):
                heapq.heappush(self.left_half, -heapq.heappop(self.right_half))
    
    def get_median(self) -> float:
        if len(self.left_half) == len(self.right_half):
            return (-self.left_half[0] + self.right_half[0]) / 2
        return float(-self.left_half[0])

Анализ сложности

Временная сложность add: O(log n). Временная сложность get_median: O(1). Пространственная сложность: O(n).

Сортировка k-отсортированного массива

Сортировка k-отсортированного массива

Дан целочисленный массив, в котором каждый элемент находится не более чем на k позиций от своей позиции в отсортированном массиве. Отсортируйте массив за O(n log k) времени.

Пример:

python
Input: nums = [5, 1, 9, 4, 7, 10], k = 2
Output: [1, 4, 5, 7, 9, 10]

Интуиция

В k-отсортированном массиве каждый элемент находится не более чем на k индексов от своего положения в полностью отсортированном массиве.

Тривиальное решение — отсортировать массив стандартным алгоритмом. Однако, используя свойство k-отсортированности, можно достичь O(n log k).

Для любого индекса i элемент, принадлежащий позиции i в отсортированном массиве, находится в диапазоне [i-k, i+k]. Следовательно, минимальный элемент из первых k+1 элементов является наименьшим во всём массиве.

Для улучшения подхода нужна мин-куча для эффективного доступа к минимальному значению на каждом диапазоне [i, i+k].

Min-heap approach for sorting k-sorted array.

Реализация

python
from typing import List
import heapq
        
def sort_a_k_sorted_array(nums: List[int], k: int) -> List[int]:
    min_heap = nums[:k+1]
    heapq.heapify(min_heap)
    insert_index = 0
    for i in range(k + 1, len(nums)):
        nums[insert_index] = heapq.heappop(min_heap)
        insert_index += 1
        heapq.heappush(min_heap, nums[i])
    while min_heap:
        nums[insert_index] = heapq.heappop(min_heap)
        insert_index += 1
    return nums

Анализ сложности

Временная сложность: O(n log k). Пространственная сложность: O(k).

Глава 9

Intervals

~8 мин чтения

Введение в Intervals

Введение в Intervals

Интуиция

Interval состоит из двух значений: start point и end point. Он представляет непрерывный отрезок на числовой оси, включающий все значения между этими двумя точками. Intervals часто используются для обозначения отрезка, временного периода или непрерывного диапазона значений.

  • Start point interval'а указывает, где interval начинается.
  • End point interval'а указывает, где interval заканчивается.
Image represents a simple horizontal timeline diagram illustrating a process or sequence.

Intervals бывают закрытыми, открытыми или полуоткрытыми — в зависимости от того, включены ли их start или end point в interval.

  • Closed intervals: и start, и end point включены в interval.
Image represents a line segment with thick, black endpoints labeled 3 and 8.
  • Open intervals: start и end point не включены в interval.
Image represents a simple line graph illustrating relationship between two data points 3 and 8.
  • Half-open intervals: либо start, либо end point включён, а другой — нет.
Image represents two identical line segments showing range from 3 to 8.

При решении задачи на intervals на собеседовании важно уточнить, являются ли intervals открытыми, закрытыми или полуоткрытыми — это может изменить характер overlap'а между ними.

Overlapping intervals Два interval'а overlap'ятся, если у них есть хотя бы одно общее значение.

Image represents a visual depiction of an overlap between two intervals on a number line.

Главная сложность большинства задач на intervals — эффективная работа с overlapping intervals. Независимо от того, нужно ли их выявлять или merge'ить, важно понимать, как overlap между intervals влияет на желаемый результат. Задачи этой главы посвящены обработке overlapping intervals в различных ситуациях.

Sorting intervals В большинстве задач на intervals предварительная сортировка intervals очень полезна, поскольку позволяет обрабатывать их в определённом порядке.

Обычно intervals сортируют по start point, чтобы обходить их в хронологическом порядке. Когда у двух или более intervals одинаковый start point, при сортировке также может потребоваться учитывать end point каждого interval'а.

Разделение start и end points В некоторых сценариях бывает полезно обрабатывать start и end points intervals по отдельности. Обычно это подразумевает создание двух отсортированных массивов: одного — со всеми start points, другого — со всеми end points. Например, это необходимо в алгоритме sweeping line, который рассматривается в задаче Largest Overlap of Intervals.

Image represents a data structure illustrating the concept of intervals with start and end points.

Определение класса Interval В задачах этой главы intervals представлены с помощью приведённого ниже класса.

python
class Interval:
   def __init__(self, start, end):
       self.start = start
       self.end = end

Пример из реальной жизни

Системы планирования: intervals широко применяются в системах расписания. Например, в системе бронирования переговорных комнат каждое бронирование представлено как interval. Это представление используется, если система должна поддерживать функциональность — например, определять максимальное число overlapping бронирований для обеспечения достаточного количества свободных комнат. Анализируя эти intervals, система может эффективно распределять ресурсы и предотвращать двойные бронирования.

Содержание главы

Image represents a hierarchical diagram illustrating two common coding patterns related to interval management.

Merge Overlapping Intervals

Merge Overlapping Intervals

Merge массив intervals так, чтобы среди них не осталось overlapping intervals, и верните результирующие merged intervals.

Пример:

Image represents a Gantt chart illustrating task scheduling.
python
Input: intervals = [[3, 4], [7, 8], [2, 5], [6, 7], [1, 4]]
Output: [[1, 5], [6, 8]]

Ограничения:

  • Входные данные содержат хотя бы один interval.
  • Для каждого индекса i в массиве: intervals[i].start ≤ intervals[i].end.

Интуиция

В данной задаче есть две основные сложности:

  1. Определить, какие intervals overlap'ятся друг с другом.
  2. Merge'ить эти intervals.

Начнём с первой сложности. Рассмотрим два interval'а, A и B, где interval A начинается раньше B. На рисунке ниже показан случай, когда эти два interval'а не overlap'ятся:

Image represents a visualization of non-overlapping intervals on a number line.

Пунктирная линия показывает, что interval A заканчивается раньше, чем начинается interval B, что исключает возможность overlap'а B с A. Это означает, что intervals A и B никогда не будут overlap'иться, если A.end < B.start.

Теперь рассмотрим несколько случаев, когда эти два interval'а всё же overlap'ятся:

Image represents a diagram illustrating overlapping intervals (case 1).
Image represents a diagram illustrating overlapping intervals (case 2).

В этих случаях видно, что B начинается до (или в момент) окончания A (A.end ≥ B.start). Иными словами, часть B overlap'ится с A, так как interval A не успел закончиться до начала interval'а B. Следовательно, intervals A и B overlap'ятся при A.end ≥ B.start.

Мы определили два случая, охватывающих все сценарии overlap'а и его отсутствия для двух intervals при условии, что interval A начинается раньше B:

  • Если A.end < B.start, intervals не overlap'ятся.
  • Если A.end ≥ B.start, intervals overlap'ятся.

Чтобы применить эти условия к любым двум intervals из входных данных, удобно иметь способ определить, какой interval начинается первым. Один из вариантов — отсортировать intervals по их start value, что явно покажет, какой из каждой пары соседних intervals начинается первым.

Merging intervals С учётом приведённой логики разберём пример. Рассмотрим следующие intervals:

Image represents a sequence of five distinct arrays.

Первый шаг — отсортировать эти intervals по start value:

Image represents a diagram illustrating a sorting operation on a list of sub-lists.

Для наглядности представим intervals визуально:

Image represents a visualization of intervals on a number line.

Будем добавлять/merge'ить каждый interval в новый массив merged, начиная с первого — его можно сразу добавить в массив merged, так как он первый:

Image represents a visual illustration of merging overlapping intervals - step 1.

После добавления первого interval'а начинается процесс merging. Определим A как последний interval в массиве merged, а B — как текущий interval во входных данных.

Замечаем, что B начинается до окончания A, что указывает на overlap. Выполним merge:

Image represents a visual explanation of interval merging - overlap condition.

При merge A и B берём наименьший start value и наибольший end value из двух.

Image represents a visual explanation of merging overlapping intervals - merge formula.

Применим ту же логику к следующему interval'у:

Image represents overlap check for next interval.
Image represents merge result after processing interval.

Когда мы доходим до четвёртого interval'а, видим, что B начинается после окончания A — overlap'а нет.

Image represents no overlap case - add B to merged list.

Поэтому просто добавляем его как новый interval в массив merged:

Image represents adding new interval to merged array.

Следующий interval B overlap'ится с последним interval'ом в массиве merged — A, так как B начинается в момент окончания A (A.end == B.start).

Image represents overlap when A.end == B.start.

Выполним merge A с B:

Image represents final merge result.

Обработав последний interval, мы успешно merge'или все intervals.

Реализация

python
from typing import List
from ds import Interval
    
def merge_overlapping_intervals(intervals: List[Interval]) -> List[Interval]:
    intervals.sort(key=lambda x: x.start)
    merged = [intervals[0]]
    for B in intervals[1:]:
        A = merged[-1]
        if A.end < B.start:
            merged.append(B)
        else:
            merged[-1] = Interval(A.start, max(A.end, B.end))
    return merged

Анализ сложности

Временная сложность: O(nlog(n)) из-за сортировки. Сам merge занимает O(n).

Пространственная сложность: O(n) для Tim sort в Python.

Совет для собеседования

Совет: визуализируйте intervals, чтобы раскрыть логику и граничные случаи. Рисование примеров помогает интервьюеру следить за вашими рассуждениями.

Identify All Interval Overlaps

Identify All Interval Overlaps

Верните массив всех overlap'ов между двумя массивами intervals: intervals1 и intervals2. Каждый отдельный массив intervals отсортирован по start value и не содержит overlapping intervals внутри себя.

Пример:

Image represents a visualization of interval intersection.
python
Input: intervals1 = [[1, 4], [5, 6], [9, 10]],
       intervals2 = [[2, 7], [8, 9]]
Output: [[2, 4], [5, 6], [9, 9]]

Ограничения:

  • Для каждого индекса i в intervals1: intervals1[i].start < intervals1[i].end.
  • Для каждого индекса j в intervals2: intervals2[j].start < intervals2[j].end.

Интуиция

Нам даны два массива intervals, каждый из которых содержит non-overlapping intervals. Это означает, что overlap может возникнуть только между interval'ом из первого массива и interval'ом из второго массива.

Определение overlap'а двух overlapping intervals Из задачи Merge Overlapping Intervals мы знаем, что два interval'а — A и B — overlap'ятся при A.end >= B.start, при условии что A начинается раньше B.

Image represents two Gantt chart-like diagrams illustrating the temporal relationship between two processes A and B.

Чтобы извлечь overlap этих двух overlapping intervals, нужно определить, где он начинается и где заканчивается.

  • Overlap начинается в наибольшей start point, которой всегда является B.start.
  • Overlap заканчивается в наименьшей end point (min(A.end, B.end)).
Image represents two diagrams illustrating overlap calculation.

Таким образом, при overlap двух intervals их пересечение определяется диапазоном [B.start, min(A.end, B.end)].

Нахождение всех overlap'ов Вернёмся к двум массивам intervals.

Image represents a visualization of two sets of intervals on a numerical scale.

Начнём с рассмотрения первого interval'а из каждого массива:

Image represents a visualization of two sets of intervals with pointers i and j.

Чтобы проверить, overlap'ятся ли эти intervals, определим, какой из них начинается первым — назначим его как A, а другой — как B:

python
if intervals1[i].start <= intervals2[j].start:
    A, B = intervals1[i], intervals2[j]
else:
    A, B = intervals2[j], intervals1[i]
Image represents intervals1[i] starting first, labelled A.

A и B overlap'ятся при A.end >= B.start — здесь это условие выполняется. Запишем overlap: [B.start, min(A.end, B.end)]:

Image represents a visualization of interval intersection result.

После определения overlap'а продвинем указатель того interval'а, который заканчивается раньше.

Image represents advancing pointer i after intervals1[i] ends before intervals2[j].

Мы сформулировали процесс, позволяющий выявлять и записывать все overlap'ы при обходе массивов intervals:

  1. Назначить A как interval, который начинается первым, а B — как другой interval.
  2. Проверить, выполняется ли A.end >= B.start, чтобы определить overlap. Если да — записать overlap как [B.start, min(A.end, B.end)].
  3. Interval, который заканчивается первым, — продвинуть соответствующий указатель на следующий interval.

Продолжать, пока i или j не выйдут за пределы своего массива.

Реализация

python
from typing import List
from ds import Interval
   
def identify_all_interval_overlaps(intervals1: List[Interval], intervals2: List[Interval]) -> List[Interval]:
   overlaps = []
   i = j = 0
   while i < len(intervals1) and j < len(intervals2):
       if intervals1[i].start <= intervals2[j].start:
           A, B = intervals1[i], intervals2[j]
       else:
           A, B = intervals2[j], intervals1[i]
       if A.end >= B.start:
           overlaps.append(Interval(B.start, min(A.end, B.end)))
       if intervals1[i].end < intervals2[j].end:
           i += 1
       else:
           j += 1
   return overlaps

Анализ сложности

Временная сложность: O(n+m), где n и m — длины intervals1 и intervals2.

Пространственная сложность: O(1).

Largest Overlap of Intervals

Largest Overlap of Intervals

Дан массив intervals. Определите максимальное количество intervals, которые overlap'ятся в какой-либо точке. Каждый interval является half-open: он включает start point, но исключает end point.

Пример:

Image represents a horizontal number line illustrating overlapping intervals.
python
Input: intervals = [[1, 3], [5, 7], [2, 6], [4, 8]]
Output: 3

Ограничения:

  • Входные данные содержат хотя бы один interval.
  • Для каждого индекса i в списке: intervals[i].start < intervals[i].end.

Интуиция

Подумайте, что означает overlap x intervals в определённый момент времени. Это значит, что в данный момент существует x «активных» intervals, где interval считается активным, если он начался, но ещё не закончился.

В примере ниже видно три активных interval'а в момент времени 5:

Image represents a horizontal timeline showing three active intervals at time 5.

Чтобы определить количество активных intervals в любой момент времени, нужно отслеживать, когда interval начинается и когда заканчивается. Start point сигнализирует о начале нового активного interval'а, тогда как end point — о завершении активного interval'а.

Обработка start points и end points Пройдём по каждой точке в хронологическом порядке и определим количество активных intervals в каждой из них.

Первая точка — start point, увеличиваем счётчик:

Image represents sweeping line algorithm step 1.

Следующая точка — ещё один start point, увеличиваем счётчик до 2:

Image represents sweeping line algorithm step 2 - active_intervals = 2.

Следующая точка — end point, уменьшаем счётчик до 1:

Image represents sweeping line algorithm step 3 - active_intervals = 1.

Мы определили поведение для каждого типа точек:

  • При встрече start point: увеличить active_intervals.
  • При встрече end point: уменьшить active_intervals.

Финальный ответ — наибольшее значение active_intervals, зафиксированное в процессе обхода.

Граничный случай: обработка одновременных start и end points Когда start point и end point совпадают, следует обрабатывать end points раньше start points — иначе interval будет засчитан как активный в момент своего завершения.

Image represents wrong order - processing start before end at same time.

Если обрабатывать end point первым, эта проблема не возникает:

Image represents correct order - processing end before start at same time.

Таким образом, для start и end points, совпадающих по значению, end points обрабатываются раньше start points.

Итерация по точкам intervals в порядке Объедините все start и end points в один массив и отсортируйте его, обеспечив приоритет end points перед start points при одинаковых значениях.

Image represents transformation of intervals into sorted points list with S/E labels.

Алгоритм, используемый для решения этой задачи, называется «sweeping line algorithm». Он работает путём обработки start и end points intervals по порядку, как если бы вертикальная линия «проходила» сквозь них.

Реализация

python
from typing import List
from ds import Interval
    
def largest_overlap_of_intervals(intervals: List[Interval]) -> int:
   points = []
   for interval in intervals:
       points.append((interval.start, 'S'))
       points.append((interval.end, 'E'))
   points.sort(key=lambda x: (x[0], x[1]))
   active_intervals = 0
   max_overlaps = 0
   for time, point_type in points:
       if point_type == 'S':
           active_intervals += 1
       else:
           active_intervals -= 1
       max_overlaps = max(max_overlaps, active_intervals)
   return max_overlaps

Анализ сложности

Временная сложность: O(n*log(n)) из-за сортировки массива points размером 2n.

Пространственная сложность: O(n) из-за массива points.

Глава 10

Префиксные суммы

~4 мин чтения

Введение в префиксные суммы

Введение в префиксные суммы

Интуиция

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

Image represents spendings list = [10, 15, 20, 10, 5] aligned with days Mon-Fri.

Массив префиксных сумм хранит накопленную сумму значений до каждого индекса массива. Общая сумма расходов до среды включительно составляет $45 ($10+$15+$20).

Image represents spendings [10,15,20,10,5] and prefix_sums [10,25,45,55,60] arrays aligned with Mon-Fri.

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

Image represents step-by-step calculation of prefix sums for array [10,15,20,10,5].

В коде этот процесс выглядит следующим образом:

python
def compute_prefix_sums(nums):
    prefix_sum = [nums[0]]
    for i in range(1, len(nums)):
        prefix_sum.append(prefix_sum[-1] + nums[i])

Построение массива префиксных сумм занимает O(n) времени и O(n) памяти, где n — длина массива.

Применение префиксных сумм Помимо доступа к накопленным суммам за константное время, префиксные суммы широко применяются для эффективного вычисления суммы подмассивов. Интересной разновидностью являются префиксные произведения, которые заполняют массив накопленным произведением вместо суммы.

Пример из реального мира

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

Обзор главы

Image represents a hierarchical diagram showing Prefix Sums branching into Subarray Sums (Sum Between Range, K-Sum Subarrays) and Product Sums (Product Array Without Current Element).

Сумма в диапазоне

Сумма в диапазоне

Дан целочисленный массив. Напишите функцию, которая возвращает сумму значений между двумя индексами.

Пример:

Image represents three examples of sum_range function on [3, -7, 6, 0, -2, 5].
python
Input: nums = [3, -7, 6, 0, -2, 5],
       [sum_range(0, 3), sum_range(2, 4), sum_range(2, 2)]
Output: [2, 4, 6]

Ограничения:

  • nums содержит хотя бы один элемент.
  • Каждый вызов sum_range будет запрашивать корректный диапазон входного массива.

Интуиция

Нам нужно реализовать функцию sum_range(i, j). Наивное решение итерационно суммирует элементы от индекса i до j за линейное время на каждый вызов. Использование префиксных сумм позволяет повысить эффективность.

Рассмотрим целочисленный массив и его префиксные суммы:

Image represents nums [3,-7,6,0,-2,5] and prefix_sum [3,-4,2,2,0,5] arrays.

Префиксная сумма до любого индекса j даёт ответ на sum_range(0, j). Например, сумма диапазона [0, 3] равна prefix_sum[3].

Image represents prefix sum at index j = sum_range(0, j).

Следовательно, при i == 0:

sum_range(0, j) = prefix_sum[j]

Для диапазона [2, 4] сумма равна prefix_sum[4] - prefix_sum[1]:

Image represents subarray [6, 0, -2] within [3,-7,6,0,-2,5].
Image represents sum[0:4] highlighted in the array.

Сумма диапазона [2, 4] = prefix_sum[4] - prefix_sum[1]: вычитаем сумму [0, 1] из суммы [0, 4].

Image represents sum[2:4] = sum[0:4] - sum[0:1] visualization.

Следовательно, при i > 0:

sum_range(i, j) = prefix_sum[j] - prefix_sum[i - 1]

Реализация

python
from typing import List
    
class SumBetweenRange:
    def __init__(self, nums: List[int]):
        self.prefix_sum = [nums[0]]
        for i in range(1, len(nums)):
            self.prefix_sum.append(self.prefix_sum[-1] + nums[i])
    
    def sum_range(self, i: int, j: int) -> int:
        if i == 0:
            return self.prefix_sum[j]
        return self.prefix_sum[j] - self.prefix_sum[i - 1]

Анализ сложности

Временная сложность: конструктор — O(n). sum_range — O(1).

Пространственная сложность: O(n) для массива prefix_sum.

Подмассивы с суммой K

Подмассивы с суммой K

Найдите количество подмассивов целочисленного массива, сумма которых равна k.

Пример:

Image represents three subarrays of [1,2,-1,1,2] that sum to 3.
python
Input: nums = [1, 2, -1, 1, 2], k = 3
Output: 3

Интуиция

Метод грубой силы перебирает все возможные подмассивы за O(n^3). Использование префиксных сумм позволяет сократить сложность.

Сумма подмассива [i,j] = prefix_sum[j] - prefix_sum[i-1] (при i>0), или просто prefix_sum[j] при i=0.

Image represents prefix sum formula for subarray sum.
Image represents prefix_sum[j] = sum[0:j].

Объединим оба случая, добавив 0 в начало массива префиксных сумм — тогда prefix_sum[i-1]=0 при i=0 будет корректным.

Image represents prefix_sums [0,1,3,2,3,5] with leading 0 prepended.
python
from typing import List
    
def k_sum_subarrays(nums: List[int], k: int) -> int:
    n = len(nums)
    count = 0
    prefix_sum = [0]
    for i in range(0, n):
        prefix_sum.append(prefix_sum[-1] + nums[i])
    for j in range(1, n + 1):
        for i in range(1, j + 1):
            if prefix_sum[j] - prefix_sum[i - 1] == k:
                count += 1
    return count

Это решение O(n^2) можно оптимизировать с помощью хеш-таблицы. Для каждой текущей префиксной суммы (curr_prefix_sum) находим, сколько раз curr_prefix_sum - k встречалась ранее.

Image represents initialization of prefix_sum_map = {0: 1} for k=3.

Для каждого элемента проверяем, есть ли curr_prefix_sum - k в хеш-таблице. Если да — добавляем её частоту к счётчику.

Image represents hash map lookup: curr_prefix_sum=1, k=3, complement=-2 not found.
Image represents curr_prefix_sum=3, complement=0 found, count+=1.

Стратегия: 1) Обновить curr_prefix_sum. 2) Если curr_prefix_sum-k есть в таблице — добавить частоту к count. 3) Добавить curr_prefix_sum в таблицу.

Image represents step curr_prefix_sum=2, complement=-1 not found.
Image represents step curr_prefix_sum=3, complement=0 found count+=1.
Image represents step curr_prefix_sum=5, complement=2 found count+=1.

Реализация

python
from typing import List
    
def k_sum_subarrays_optimized(nums: List[int], k: int) -> int:
    count = 0
    prefix_sum_map = {0: 1}
    curr_prefix_sum = 0
    for num in nums:
        curr_prefix_sum += num
        if curr_prefix_sum - k in prefix_sum_map:
            count += prefix_sum_map[curr_prefix_sum - k]
        prefix_sum_map[curr_prefix_sum] = prefix_sum_map.get(curr_prefix_sum, 0) + 1
    return count

Анализ сложности

Временная сложность: O(n) — один проход по nums.

Пространственная сложность: O(n) для хеш-таблицы.

Произведение массива без текущего элемента

Произведение массива без текущего элемента

Дан массив целых чисел. Верните массив res, где res[i] равно произведению всех элементов, кроме nums[i].

Пример:

python
Input: nums = [2, 3, 1, 4, 5]
Output: [60, 40, 120, 30, 24]

Пояснение: значение на индексе 0 — произведение всех чисел, кроме nums[0] (3*1*4*5 = 60).

Интуиция

Простое решение делит общее произведение на каждый элемент (total=120). Но что если деление запрещено?

Image represents total product 120 divided by each element to produce res=[60,40,120,30,24].

Без деления результат для любого индекса = произведение всех чисел слева * произведение всех чисел справа.

Image represents res[i] = left_product * right_product visualization.

Нам нужны два массива:

  • left_products: left_products[i] — произведение всех значений левее i.
  • right_products: right_products[i] — произведение всех значений правее i.
Image represents left_products [1,2,6,6,24] and right_products [60,20,20,5,1] calculated from nums.

Префиксные произведения Префиксные произведения используют накопленное умножение (инициализируются значением 1 вместо 0).

  1. Вместо накопленного сложения используем накопленное умножение.
  2. Инициализируем массив префиксных произведений значением 1 вместо 0.
Image represents left_products initialized with [1].
Image represents left_products = [1,2] after first multiplication.
Image represents left_products = [1,2,6] after second multiplication.
Image represents left_products = [1,2,6,6,1] partially filled.
Image represents left_products = [1,2,6,6,24] complete.

right_products строится аналогично справа налево. Когда оба массива готовы, res[i] = left_products[i] * right_products[i].

Image represents right_products initialization.
Image represents right_products first step [5,1].
Image represents right_products = [20,5,1] partially filled.
Image represents right_products = [20,20,5,1] partially filled.
Image represents right_products = [60,20,20,5,1] complete.

Оптимизация по памяти: заполним res левыми произведениями, затем умножим на текущее правое произведение на месте.

Image represents res populated with left products [1,2,6,6,24].
Image represents res multiplied by right_product from right to left.
Image represents res[i] = 6 * 20 = 120 at index 2.
Image represents res[i] = 2 * 20 = 40 at index 1.
Image represents final res = [60,40,120,30,24].

Реализация

python
from typing import List
    
def product_array_without_current_element(nums: List[int]) -> List[int]:
    n = len(nums)
    res = [1] * n
    for i in range(1, n):
        res[i] = res[i - 1] * nums[i - 1]
    right_product = 1
    for i in range(n - 1, -1, -1):
        res[i] *= right_product
        right_product *= nums[i]
    return res

Анализ сложности

Временная сложность: O(n) — два прохода по nums.

Пространственная сложность: O(1). Массив res не учитывается в анализе пространственной сложности.

Глава 11

Деревья

~8 мин чтения

Введение в деревья

Введение в деревья

Интуиция

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

Image represents a simple binary tree data structure, showing a single parent node labeled 'node' containing a value 'val,' and two child nodes, also labeled 'val,' connected to the parent.  The parent node is connected to its left child node, labeled 'node.left,' and its right child node, labeled 'node.right,' via downward-pointing arrows indicating a hierarchical relationship.  Each node contains a value, represented by 'val,' suggesting that the tree stores data within its nodes. The structure visually demonstrates the fundamental concept of a binary tree where each node can have at most two children, a left child and a right child.

Ниже приведена реализация класса TreeNode:

python
class TreeNode:
    def __init__(self, val):
        self.val = val
        self.left = None
        self.right = None

Терминология:

  • Родительский узел (Parent): узел, имеющий одного или нескольких дочерних.
  • Дочерний узел (Child): узел, у которого есть родительский.
  • Поддерево (Subtree): дерево, образованное узлом и его потомками.
  • Путь (Path): непрерывная последовательность узлов, соединённых рёбрами.
  • Глубина (Depth): количество рёбер от корня до данного узла.

Атрибуты дерева:

  • Корень (Root): верхний узел дерева, единственный без родителя.
  • Промежуточный узел: узел, имеющий родителя и хотя бы одного потомка.
  • Лист (Leaf): узел без дочерних узлов.
  • Ребро (Edge): связь между двумя узлами. Рёбра деревьев обычно направленные — от родителя к дочернему узлу.
  • Высота (Height): длина наидлиннейшего пути от корня до листа.1
Image represents a diagram illustrating tree data structures and their properties.

В дальнейшем рассмотрении основное внимание уделяется бинарным деревьям.

Обходы дерева

Поиск в глубину (DFS) DFS — метод обхода всех узлов дерева, при котором начинают с корня и продвигаются как можно глубже по ветке, прежде чем возвращаться для обхода других ветвей.

Image represents a diagram illustrating Depth-First Search (DFS) traversal of a binary tree.

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

python
def dfs(node: TreeNode):
    if node is None:
        return
    process(node)       # Process the current node.
    dfs(node.left)      # Traverse the left subtree.
    dfs(node.right)     # Traverse the right subtree.

Рекурсивная реализация выше следует порядку прямого обхода (preorder). Два других распространённых метода DFS — симметричный (inorder) и обратный (postorder). Различия между ними:

Прямой обход (Preorder)Симметричный обход (Inorder)Обратный обход (Postorder)
process(node)dfs(node.left)dfs(node.left)
dfs(node.left)process(node)dfs(node.right)
dfs(node.right)dfs(node.right)process(node)

DFS имеет множество применений и является наиболее распространённым выбором для обхода дерева.

  • Прямой обход — наиболее часто используемый тип DFS. Он применяется, когда нужно обработать корневой узел каждого поддерева до его потомков.
  • Симметричный обход применяется, когда нужно обрабатывать узлы дерева слева направо.
  • Обратный обход используется реже всего, но важен в случаях, когда поддеревья каждого узла должны быть обработаны до их корня.

Задачи этой главы подробно раскрывают варианты применения DFS, приводя практические примеры того, когда и как применять эти методы обхода.

Поиск в ширину (BFS) BFS обходит узлы дерева уровень за уровнем. Сначала обрабатываются узлы текущего уровня, затем переходят к следующему.

Image represents a Breadth-First Search (BFS) traversal of a tree-like graph.
python
def bfs(root: TreeNode):
    if root is None:
        return
    queue = deque([root])
    while queue:
        node = queue.popleft()
        process(node)  # Process the current node.
        if node.left:
            queue.append(node.left)  # Add the left child to the queue.
        if node.right:
            queue.append(node.right)  # Add the right child to the queue.

BFS широко применяется для поиска кратчайшего пути до заданного узла или для обработки дерева уровень за уровнем. Когда важно знать конкретный уровень каждого узла в процессе обхода, используют разновидность BFS — обход по уровням (level-order traversal), которая подробно рассматривается в этой главе.

Анализ сложности Ниже n обозначает количество узлов в дереве, h — высоту дерева.

[BLOCKED: Cookie/query string data]

Пример из реального мира

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

Обзор главы

Image represents a hierarchical diagram illustrating coding patterns related to trees.

Обратите внимание, что некоторые задачи, отнесённые к разделу DFS, также можно решить с помощью BFS или других алгоритмов обхода, и наоборот.

Примечания

  1. Некоторые источники могут определять высоту дерева иначе. В этой книге используется определение, делающее проектирование рекурсивных алгоритмов более интуитивным. Здесь высота дерева с единственным узлом равна 1. ↩

Инвертирование бинарного дерева

Инвертирование бинарного дерева

Инвертируйте бинарное дерево и верните его корень. При инвертировании дерево становится своим зеркальным отражением.

Пример:

Image represents a transformation of a binary tree.

Интуиция — рекурсивный подход

Инвертировать бинарное дерево — значит «отразить» его по вертикальной оси.

python
from ds import TreeNode
    
def invert_binary_tree_recursive(root: TreeNode) -> TreeNode:
    if not root:
        return None
    root.left, root.right = root.right, root.left
    invert_binary_tree_recursive(root.left)
    invert_binary_tree_recursive(root.right)
    return root

Реализация — итеративный подход

python
from ds import TreeNode
    
def invert_binary_tree_iterative(root: TreeNode) -> TreeNode:
    if not root:
        return None
    stack = [root]
    while stack:
        node = stack.pop()
        node.left, node.right = node.right, node.left
        if node.left:
            stack.append(node.left)
        if node.right:
            stack.append(node.right)
    return root

Проверка сбалансированности бинарного дерева

Проверка сбалансированности бинарного дерева

Определите, является ли бинарное дерево сбалансированным по высоте, то есть у каждого узла разница высот левого и правого поддеревьев не превышает 1.

python
from ds import TreeNode
    
def balanced_binary_tree_validation(root: TreeNode) -> bool:
    return get_height_imbalance(root) != -1
    
def get_height_imbalance(node: TreeNode) -> int:
    if not node:
        return 0
    left_height = get_height_imbalance(node.left)
    right_height = get_height_imbalance(node.right)
    if left_height == -1 or right_height == -1:
        return -1
    if abs(left_height - right_height) > 1:
        return -1
    return 1 + max(left_height, right_height)

Крайние правые узлы бинарного дерева

Крайние правые узлы бинарного дерева

Верните массив со значениями крайних правых узлов на каждом уровне бинарного дерева.

python
from ds import TreeNode
    
def rightmost_nodes_of_a_binary_tree(root: TreeNode) -> List[int]:
    if not root:
        return []
    res = []
    queue = deque([root])
    while queue:
        level_size = len(queue)
        for i in range(level_size):
            node = queue.popleft()
            if node.left:
                queue.append(node.left)
            if node.right:
                queue.append(node.right)
            if i == level_size - 1:
                res.append(node.val)
    return res

Наиболее широкий уровень бинарного дерева

Наиболее широкий уровень бинарного дерева

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

python
from ds import TreeNode
    
def widest_binary_tree_level(root: TreeNode) -> int:
    if not root:
        return 0
    max_width = 0
    queue = deque([(root, 0)])
    while queue:
        level_size = len(queue)
        leftmost_index = queue[0][1]
        rightmost_index = leftmost_index
        for _ in range(level_size):
            node, i = queue.popleft()
            if node.left:
                queue.append((node.left, 2*i + 1))
            if node.right:
                queue.append((node.right, 2*i + 2))
            rightmost_index = i
        max_width = max(max_width, rightmost_index - leftmost_index + 1)
    return max_width

Проверка корректности двоичного дерева поиска

Проверка корректности двоичного дерева поиска

Проверьте, является ли бинарное дерево корректным двоичным деревом поиска (BST).

python
from ds import TreeNode
    
def binary_search_tree_validation(root: TreeNode) -> bool:
    return is_within_bounds(root, float('-inf'), float('inf'))
    
def is_within_bounds(node: TreeNode, lower_bound: int, upper_bound: int) -> bool:
    if not node:
        return True
    if not lower_bound < node.val < upper_bound:
        return False
    if not is_within_bounds(node.left, lower_bound, node.val):
        return False
    return is_within_bounds(node.right, node.val, upper_bound)

Наименьший общий предок

Наименьший общий предок

Верните наименьшего общего предка (LCA) двух узлов p и q в бинарном дереве.

python
from ds import TreeNode
    
def lowest_common_ancestor(root: TreeNode, p: TreeNode, q: TreeNode) -> TreeNode:
    dfs(root, p, q)
    return lca
    
def dfs(node: TreeNode, p: TreeNode, q: TreeNode) -> bool:
    global lca
    if not node:
        return False
    node_is_p_or_q = node == p or node == q
    left_contains_p_or_q = dfs(node.left, p, q)
    right_contains_p_or_q = dfs(node.right, p, q)
    if node_is_p_or_q + left_contains_p_or_q + right_contains_p_or_q == 2:
        lca = node
    return node_is_p_or_q or left_contains_p_or_q or right_contains_p_or_q

Построение бинарного дерева по прямому и симметричному обходам

Построение бинарного дерева по прямому и симметричному обходам

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

python
from ds import TreeNode
from typing import List
    
preorder_index = 0
inorder_indexes_map = {}
    
def build_binary_tree(preorder: List[int], inorder: List[int]) -> TreeNode:
    global inorder_indexes_map
    for i, val in enumerate(inorder):
        inorder_indexes_map[val] = i
    return build_subtree(0, len(inorder) - 1, preorder, inorder)
    
def build_subtree(left, right, preorder, inorder):
    global preorder_index, inorder_indexes_map
    if left > right:
        return None
    val = preorder[preorder_index]
    inorder_index = inorder_indexes_map[val]
    node = TreeNode(val)
    preorder_index += 1
    node.left = build_subtree(left, inorder_index - 1, preorder, inorder)
    node.right = build_subtree(inorder_index + 1, right, preorder, inorder)
    return node

Максимальная сумма пути в бинарном дереве

Максимальная сумма пути в бинарном дереве

Верните максимальную сумму непрерывного пути в бинарном дереве.

python
from ds import TreeNode
    
max_sum = float('-inf')
    
def max_path_sum(root: TreeNode) -> int:
   global max_sum
   max_path_sum_helper(root)
   return max_sum
    
def max_path_sum_helper(node: TreeNode) -> int:
   global max_sum
   if not node:
       return 0
   left_sum = max(max_path_sum_helper(node.left), 0)
   right_sum = max(max_path_sum_helper(node.right), 0)
   max_sum = max(max_sum, node.val + left_sum + right_sum)
   return node.val + max(left_sum, right_sum)

Симметрия бинарного дерева

Симметрия бинарного дерева

Определите, является ли бинарное дерево симметричным по вертикали.

python
from ds import TreeNode
    
def binary_tree_symmetry(root: TreeNode) -> bool:
    if not root:
        return True
    return compare_trees(root.left, root.right)
    
def compare_trees(node1: TreeNode, node2: TreeNode) -> bool:
    if not node1 and not node2:
        return True
    if not node1 or not node2:
        return False
    if node1.val != node2.val:
        return False
    if not compare_trees(node1.left, node2.right):
        return False
    return compare_trees(node1.right, node2.left)

Столбцы бинарного дерева

Столбцы бинарного дерева

Дан корень бинарного дерева. Верните список массивов, где каждый массив представляет вертикальный столбец дерева.

python
from ds import TreeNode
from typing import List
from collections import defaultdict, deque
    
def binary_tree_columns(root: TreeNode) -> List[List[int]]:
    if not root:
        return []
    column_map = defaultdict(list)
    leftmost_column = rightmost_column = 0
    queue = deque([(root, 0)])
    while queue:
        node, column = queue.popleft()
        if node:
            column_map[column].append(node.val)
            leftmost_column = min(leftmost_column, column)
            rightmost_column = max(rightmost_column, column)
            queue.append((node.left, column - 1))
            queue.append((node.right, column + 1))
    return [column_map[i] for i in range(leftmost_column, rightmost_column + 1)]

K-е наименьшее число в двоичном дереве поиска

K-е наименьшее число в двоичном дереве поиска

Дан корень двоичного дерева поиска (BST) и целое число k. Найдите k-е наименьшее значение узла.

python
def kth_smallest_number_in_BST_iterative(root: TreeNode, k: int) -> int:
    stack = []
    node = root
    while stack or node:
        while node:
            stack.append(node)
            node = node.left
        node = stack.pop()
        k -= 1
        if k == 0:
            return node.val
        node = node.right

Сериализация и десериализация бинарного дерева

Сериализация и десериализация бинарного дерева

Напишите функцию для сериализации бинарного дерева в строку и другую функцию для десериализации строки обратно в исходную структуру дерева.

python
from ds import TreeNode
    
def serialize(root: TreeNode) -> str:
    serialized_list = []
    preorder_serialize(root, serialized_list)
    return ','.join(serialized_list)
    
def preorder_serialize(node, serialized_list) -> None:
    if node is None:
        serialized_list.append('#')
        return
    serialized_list.append(str(node.val))
    preorder_serialize(node.left, serialized_list)
    preorder_serialize(node.right, serialized_list)
    
def deserialize(data: str) -> TreeNode:
    node_values = iter(data.split(','))
    return build_tree(node_values)
    
def build_tree(values):
    val = next(values)
    if val == '#':
        return None
    node = TreeNode(int(val))
    node.left = build_tree(values)
    node.right = build_tree(values)
    return node
Глава 12

Префиксные деревья (Tries)

~3 мин чтения

Введение в префиксные деревья

Введение в префиксные деревья

Интуиция

Префиксные деревья (Tries), также известные как деревья префиксов, — специализированные древовидные структуры данных, которые эффективно хранят строки, используя общие префиксы.

Directed graph illustrating a sequence 'internet'

TrieNode Каждый узел TrieNode должен иметь два атрибута.

1. Потомки (Children): у каждого TrieNode есть структура данных для хранения ссылок на дочерние узлы. Обычно для этого используется хеш-таблица, где ключом является символ, а значением — соответствующий дочерний узел.

2. Индикатор конца слова: каждый TrieNode должен иметь атрибут, указывающий, является ли он концом слова.

  • Булев атрибут (is_word) — подтверждает, является ли узел концом слова.
  • Строковая переменная (word) — хранит само слово в узле.
Trie data structure with words: internet, interface, byte, bytes, dog, dig
[BLOCKED: Cookie/query string data]

Когда использовать Trie Основное применение trie — эффективный поиск по префиксу.

Пример из реального мира

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

Обзор главы

Разработка Trie

Разработка Trie

Спроектируйте и реализуйте структуру данных Trie, поддерживающую следующие операции:

  • insert(word: str) -> None: вставляет слово в trie.
  • search(word: str) -> bool: возвращает true, если слово существует в trie, иначе false.
  • has_prefix(prefix: str) -> bool: возвращает true, если trie содержит слово с заданным префиксом, иначе false.

Реализация

python
class TrieNode:
    def __init__(self):
        self.children = {}
        self.is_word = False
python
class Trie:
   def __init__(self):
       self.root = TrieNode()
    
   def insert(self, word: str) -> None:
       node = self.root
       for c in word:
           if c not in node.children:
               node.children[c] = TrieNode()
           node = node.children[c]
       node.is_word = True
    
   def search(self, word: str) -> bool:
       node = self.root
       for c in word:
           if c not in node.children:
               return False
           node = node.children[c]
       return node.is_word
    
   def has_prefix(self, prefix: str) -> bool:
       node = self.root
       for c in prefix:
           if c not in node.children:
               return False
           node = node.children[c]
       return True

Вставка и поиск слов с подстановочными символами

Вставка и поиск слов с подстановочными символами

Спроектируйте и реализуйте структуру данных, поддерживающую операции вставки и поиска с использованием подстановочных символов.

  • insert(word: str) -> None: вставляет слово в структуру данных.
  • search(word: str) -> bool: возвращает true, если слово существует в структуре, иначе false. Слово может содержать подстановочные символы ('.'), представляющие любую букву.

Реализация

python
class InsertAndSearchWordsWithWildcards:
   def __init__(self):
       self.root = TrieNode()
    
   def insert(self, word: str) -> None:
       node = self.root
       for c in word:
           if c not in node.children:
               node.children[c] = TrieNode()
           node = node.children[c]
       node.is_word = True
    
   def search(self, word: str) -> bool:
       return self.search_helper(0, word, self.root)
    
   def search_helper(self, word_index: int, word: str, node: TrieNode) -> bool:
       for i in range(word_index, len(word)):
           c = word[i]
           if c == '.':
               for child in node.children.values():
                   if self.search_helper(i + 1, word, child):
                       return True
               return False
           else:
               if c not in node.children:
                   return False
               node = node.children[c]
       return node.is_word

Поиск всех слов на доске

Поиск всех слов на доске

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

python
Input: board = [['b', 'y', 's'], ['r', 't', 'e'], ['a', 'i', 'n']],
       words = ['byte', 'bytes', 'rat', 'rain', 'trait', 'train']
Output: ['byte', 'bytes', 'rain', 'train']

Реализация

python
class TrieNode:
    def __init__(self):
        self.children = {}
        self.word = None
python
from typing import List
    
def find_all_words_on_a_board(board: List[List[str]], words: List[str]) -> List[str]:
    root = TrieNode()
    for word in words:
        node = root
        for char in word:
            if char not in node.children:
                node.children[char] = TrieNode()
            node = node.children[char]
        node.word = word
    res = []
    for r in range(len(board)):
        for c in range(len(board[0])):
            if board[r][c] in root.children:
                dfs(board, r, c, root.children[board[r][c]], res)
    return res
    
def dfs(board, r, c, node, res):
    if node.word:
        res.append(node.word)
        node.word = None
    temp = board[r][c]
    board[r][c] = '#'
    directions = [(0,1),(0,-1),(1,0),(-1,0)]
    for dr, dc in directions:
        nr, nc = r + dr, c + dc
        if 0 <= nr < len(board) and 0 <= nc < len(board[0]) and board[nr][nc] in node.children:
            dfs(board, nr, nc, node.children[board[nr][nc]], res)
    board[r][c] = temp
Глава 13

Графы

~10 мин чтения

Введение в графы

Введение в графы

Интуиция

Граф — структура данных, состоящая из узлов (вершин), соединённых рёбрами. Графы используются для моделирования отношений, где рёбра определяют связи между объектами.

Ниже приведена реализация класса GraphNode:

python
class GraphNode:
    def __init__(self, val):
        self.val = val
        self.neighbors = []

Терминология:

  • Смежный узел/сосед: два узла являются смежными, если между ними есть ребро.
  • Степень (Degree): количество рёбер, соединённых с узлом.
  • Путь (Path): последовательность узлов, соединённых рёбрами.
graph terminology diagram

Атрибуты:

  • Ориентированный/неориентированный: в ориентированном графе рёбра имеют направление.
  • Взвешенный/невзвешенный: во взвешенном графе рёбра имеют вес, например расстояние или стоимость.
  • Циклический/ациклический: циклический граф содержит хотя бы один цикл — путь, начинающийся и заканчивающийся в одном узле.
graph attributes diagram

Представления В некоторых задачах граф не задан напрямую. В таких случаях обычно необходимо создавать собственное представление. Два наиболее распространённых варианта — список смежности и матрица смежности.

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

В матрице смежности граф представлен двумерной матрицей, где matrix[i][j] указывает на наличие ребра между узлами i и j.

adjacency list vs matrix diagram

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

Матрицы смежности предпочтительны для плотных графов и когда часто требуется проверить наличие конкретного ребра.

Обходы Основные методы обхода графа — DFS и BFS.

python
def dfs(node: GraphNode, visited: Set[GraphNode]):
    visited.add(node)
    process(node)
    for neighbor in node.neighbors:
        if neighbor not in visited:
            dfs(neighbor, visited)
python
def bfs(node: GraphNode):
    visited = set()
    queue = deque([node])
    while queue:
        node = queue.popleft()
        if node not in visited:
            visited.add(node)
            process(node)
            for neighbor in node.neighbors:
                queue.append(neighbor)

DFS и BFS имеют временную сложность O(n+e), где n — количество узлов, e — количество рёбер.

Пример из реального мира

Социальные сети: пользователи представлены узлами, а связи между ними — рёбрами.

Обзор главы

chapter outline diagram

Глубокое копирование графа

Глубокое копирование графа

Дана ссылка на узел внутри неориентированного графа. Создайте глубокую копию (клон) графа. Скопированный граф должен быть полностью независим от оригинала.

Пример:

original vs cloned graph

Ограничения:

  • Значение каждого узла уникально.
  • Каждый узел графа достижим из заданного узла.

Интуиция

Стратегия: обходим исходный граф и создаём глубокую копию в процессе обхода с помощью DFS.

python
dfs(node):
    cloned_node = new GraphNode(node)
    for neighbor in node.neighbors:
        cloned_neighbor = dfs(neighbor)
        cloned_node.neighbors.add(cloned_neighbor)
    return cloned_node

Используем хеш-таблицу для предотвращения повторного клонирования уже скопированных узлов.

Реализация

python
from ds import GraphNode
    
def graph_deep_copy(node: GraphNode) -> GraphNode:
    if not node:
        return None
    return dfs(node)
    
def dfs(node: GraphNode, clone_map = {}) -> GraphNode:
    if node in clone_map:
        return clone_map[node]
    cloned_node = GraphNode(node.val)
    clone_map[node] = cloned_node
    for neighbor in node.neighbors:
        cloned_neighbor = dfs(neighbor, clone_map)
        cloned_node.neighbors.append(cloned_neighbor)
    return cloned_node

Анализ сложности

Временная сложность: O(n+e). Пространственная сложность: O(n).

Подсчёт островов

Подсчёт островов

Дана бинарная матрица, где 1 — суша, 0 — вода. Верните количество островов.

Остров образуется соединёнными по 4 направлениям (вверх, вниз, влево, вправо) клетками суши.

Пример:

islands example
python
Output: 2

Интуиция

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

Реализация

python
from typing import List
    
def count_islands(matrix: List[List[int]]) -> int:
    if not matrix:
        return 0
    count = 0
    for r in range(len(matrix)):
        for c in range(len(matrix[0])):
            if matrix[r][c] == 1:
                dfs(r, c, matrix)
                count += 1
    return count
    
def dfs(r: int, c: int, matrix: List[List[int]]) -> None:
    matrix[r][c] = -1
    dirs = [(-1, 0), (1, 0), (0, -1), (0, 1)]
    for d in dirs:
        next_r, next_c = r + d[0], c + d[1]
        if is_within_bounds(next_r, next_c, matrix) and matrix[next_r][next_c] == 1:
            dfs(next_r, next_c, matrix)

Анализ сложности

Временная сложность: O(m*n). Пространственная сложность: O(m*n).

Заражение матрицы

Заражение матрицы

Дана матрица, где каждая клетка имеет значение 0 (пустая), 1 (незаражённая) или 2 (заражённая). Каждую секунду каждая заражённая клетка заражает своих незаражённых соседей по 4 направлениям. Определите, через сколько секунд все незаражённые клетки станут заражёнными. Если это невозможно — верните -1.

Пример:

matrix infection example
python
Input: matrix = [[1, 1, 1, 0], [0, 0, 2, 1], [0, 1, 1, 0]]
Output: 3

Интуиция

Используем многоисточниковый BFS (обход по уровням). Добавляем все изначально заражённые клетки в очередь. Каждый обработанный уровень = 1 секунда. Считаем незаражённые клетки и уменьшаем счётчик по мере заражения; возвращаем -1, если остались незаражённые.

Реализация

python
from typing import List
from collections import deque
    
def matrix_infection(matrix: List[List[int]]) -> int:
   dirs = [(-1, 0), (1, 0), (0, -1), (0, 1)]
   queue = deque()
   ones = seconds = 0
   for r in range(len(matrix)):
       for c in range(len(matrix[0])):
           if matrix[r][c] == 1:
               ones += 1
           elif matrix[r][c] == 2:
               queue.append((r, c))
   while queue and ones > 0:
       seconds += 1
       for _ in range(len(queue)):
           r, c = queue.popleft()
           for d in dirs:
               next_r, next_c = r + d[0], c + d[1]
               if (0 <= next_r < len(matrix) and 0 <= next_c < len(matrix[0])
                       and matrix[next_r][next_c] == 1):
                   matrix[next_r][next_c] = 2
                   ones -= 1
                   queue.append((next_r, next_c))
   return -1 if ones > 0 else seconds

Анализ сложности

Временная сложность: O(m*n). Пространственная сложность: O(m*n).

Проверка двудольности графа

Проверка двудольности графа

Дан неориентированный граф. Определите, является ли он двудольным. Граф двудольный, если его узлы можно раскрасить в два цвета так, чтобы никакие два смежных узла не имели одинакового цвета.

Пример:

bipartite graph example
python
Input: graph = [[1, 4], [0, 2], [1], [4], [0, 3]]
Output: True

Интуиция

Используем раскраску графа с помощью DFS: окрашиваем смежные узлы поочерёдно в цвета 1 и -1. Если два смежных узла имеют одинаковый цвет — граф не двудольный. Обрабатываем несвязные компоненты, запуская DFS из каждого нераскрашенного узла.

Реализация

python
def bipartite_graph_validation(graph: List[List[int]]) -> bool:
    colors = [0] * len(graph)
    for i in range(len(graph)):
        if colors[i] == 0 and not dfs(i, 1, graph, colors):
           return False
    return True
    
def dfs(node: int, color: int, graph: List[List[int]], colors: List[int]) -> bool:
    colors[node] = color
    for neighbor in graph[node]:
        if colors[neighbor] == color:
            return False
        if colors[neighbor] == 0 and not dfs(neighbor, -color, graph, colors):
            return False
    return True

Анализ сложности

Временная сложность: O(n+e). Пространственная сложность: O(n).

Наидлиннейший возрастающий путь

Наидлиннейший возрастающий путь

Найдите длину наидлиннейшего строго возрастающего пути в матрице положительных целых чисел. Путь — последовательность клеток, в которой каждая следующая клетка соседствует с предыдущей по 4 направлениям.

Пример:

longest increasing path example
python
Output: 5

Интуиция

Моделируем как ОАГ (направленный ациклический граф). DFS из каждой клетки для нахождения наидлиннейшего пути. Используем мемоизацию для избежания повторных вызовов DFS.

Реализация

python
from typing import List
    
def longest_increasing_path(matrix: List[List[int]]) -> int:
    if not matrix:
        return 0
    res = 0
    m, n = len(matrix), len(matrix[0])
    memo = [[0] * n for _ in range(m)]
    for r in range(m):
        for c in range(n):
            res = max(res, dfs(r, c, matrix, memo))
    return res
    
def dfs(r: int, c: int, matrix: List[List[int]], memo: List[List[int]]):
    if memo[r][c] != 0:
        return memo[r][c]
    max_path = 1
    dirs = [(-1, 0), (1, 0), (0, -1), (0, 1)]
    for d in dirs:
        next_r, next_c = r + d[0], c + d[1]
        if (0 <= next_r < len(matrix) and 0 <= next_c < len(matrix[0])
                and matrix[next_r][next_c] > matrix[r][c]):
            max_path = max(max_path, 1 + dfs(next_r, next_c, matrix, memo))
    memo[r][c] = max_path
    return max_path

Анализ сложности

Временная сложность: O(m*n). Пространственная сложность: O(m*n).

Кратчайшая последовательность преобразований

Кратчайшая последовательность преобразований

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

Пример:

python
Input: start = 'red', end = 'hit',
       dictionary = ['red', 'bed', 'hat', 'rod', 'rad', 'rat', 'hit', 'bad', 'bat']
Output: 5

Интуиция

Моделируем как граф: слова — узлы, рёбра существуют между словами, отличающимися на одну букву. Используем BFS от start для нахождения кратчайшего пути до end. Соседей генерируем, перебирая все замены символов алфавита.

Реализация

python
def shortest_transformation_sequence(start: str, end: str, dictionary: List[str]) -> int:
    dictionary_set = set(dictionary)
    if start not in dictionary_set or end not in dictionary_set:
        return 0
    if start == end:
        return 1
    lower_case_alphabet = 'abcdefghijklmnopqrstuvwxyz'
    queue = deque([start])
    visited = set([start])
    dist = 0
    while queue:
        for _ in range(len(queue)):
            curr_word = queue.popleft()
            if curr_word == end:
                return dist + 1
            for i in range(len(curr_word)):
                for c in lower_case_alphabet:
                    next_word = curr_word[:i] + c + curr_word[i+1:]
                    if next_word in dictionary_set and next_word not in visited:
                        visited.add(next_word)
                        queue.append(next_word)
        dist += 1
    return 0

Анализ сложности

Временная сложность: O(n*L^2). Пространственная сложность: O(n*L).

Объединение сообществ

Объединение сообществ

Есть n человек с номерами от 0 до n-1, каждый изначально в отдельном сообществе. Когда два человека соединяются, их сообщества объединяются. Реализуйте connect(x, y) и get_community_size(x).

Интуиция

Используем структуру данных Union-Find (Система непересекающихся множеств). Операция union объединяет два сообщества. Операция find возвращает представителя сообщества. Оптимизируем объединением по размеру и сжатием пути.

Реализация

python
class UnionFind:
    def __init__(self, size: int):
        self.parent = [i for i in range(size)]
        self.size = [1] * size
   
    def union(self, x: int, y: int) -> None:
        rep_x, rep_y = self.find(x), self.find(y)
        if rep_x != rep_y:
            if self.size[rep_x] > self.size[rep_y]:
                self.parent[rep_y] = rep_x
                self.size[rep_x] += self.size[rep_y]
            else:
                self.parent[rep_x] = rep_y
                self.size[rep_y] += self.size[rep_x]
   
    def find(self, x: int) -> int:
        if x == self.parent[x]:
            return x
        self.parent[x] = self.find(self.parent[x])
        return self.parent[x]
   
    def get_size(self, x: int) -> int:
        return self.size[self.find(x)]

class MergingCommunities:
    def __init__(self, n: int):
        self.uf = UnionFind(n)
   
    def connect(self, x: int, y: int) -> None:
        self.uf.union(x, y)
   
    def get_community_size(self, x: int) -> int:
        return self.uf.get_size(x)

Анализ сложности

connect и get_community_size: амортизированное O(1). Конструктор: O(n). Пространственная сложность: O(n).

Пререквизиты

Пререквизиты

Даны n курсов с метками от 0 до n-1 и пары пререквизитов. Определите, возможно ли записаться на все курсы.

Пример:

python
Input: n = 3, prerequisites = [[0, 1], [1, 2], [2, 1]]
Output: False

Интуиция

Используем алгоритм Кана (топологическая сортировка). Наличие цикла означает невозможность записи. Сначала обрабатываем узлы с нулевой входящей степенью; если обработаны все n узлов — запись возможна.

Реализация

python
from typing import List
from collections import defaultdict, deque
    
def prerequisites(n: int, prerequisites: List[List[int]]) -> bool:
    graph = defaultdict(list)
    in_degrees = [0] * n
    for prerequisite, course in prerequisites:
        graph[prerequisite].append(course)
        in_degrees[course] += 1
    queue = deque()
    for i in range(n):
        if in_degrees[i] == 0:
            queue.append(i)
    enrolled_courses = 0
    while queue:
        node = queue.popleft()
        enrolled_courses += 1
        for neighbor in graph[node]:
            in_degrees[neighbor] -= 1
            if in_degrees[neighbor] == 0:
                queue.append(neighbor)
    return enrolled_courses == n

Анализ сложности

Временная сложность: O(n+e). Пространственная сложность: O(n+e).

Кратчайший путь

Кратчайший путь

Даны n узлов, рёбра с неотрицательными весами и начальный узел. Верните массив длин кратчайших путей от start до каждого узла. Недостижимые узлы получают значение -1.

Пример:

shortest path example
python
Input: n = 6, edges = [[0,1,5],[0,2,3],[1,2,1],[1,3,4],[2,3,4],[2,4,5]], start = 0
Output: [0, 4, 3, 7, 8, -1]

Интуиция

Используем алгоритм Дейкстры (жадный алгоритм). Всегда обрабатываем непосещённый узел с наименьшим известным расстоянием с помощью минимальной кучи. Работает для неотрицательных весов.

Реализация

python
from typing import List
from collections import defaultdict
import heapq
    
def shortest_path(n: int, edges: List[int], start: int) -> List[int]:
    graph = defaultdict(list)
    distances = [float('inf')] * n
    distances[start] = 0
    for u, v, w in edges:
        graph[u].append((v, w))
        graph[v].append((u, w))
    min_heap = [(0, start)]
    while min_heap:
        curr_dist, curr_node = heapq.heappop(min_heap)
        if curr_dist > distances[curr_node]:
            continue
        for neighbor, weight in graph[curr_node]:
            neighbor_dist = curr_dist + weight
            if neighbor_dist < distances[neighbor]:
                distances[neighbor] = neighbor_dist
                heapq.heappush(min_heap, (neighbor_dist, neighbor))
    return [-1 if d == float('inf') else d for d in distances]

Анализ сложности

Временная сложность: O((n+e)log(n)). Пространственная сложность: O(n+e).

Соединить точки

Соединить точки

Дано множество точек на плоскости. Определите минимальную стоимость соединения всех точек. Стоимость = манхэттенское расстояние между двумя точками.

Пример:

connect the dots example
python
Input: points = [[1, 1], [2, 6], [3, 2], [4, 3], [7, 1]]
Output: 15

Интуиция

Задача о минимальном остовном дереве (MST). Используем алгоритм Крускала: сортируем все рёбра по стоимости, применяем Union-Find для добавления рёбер с наименьшей стоимостью, не создающих циклов. Останавливаемся после добавления n-1 рёбер.

Реализация

python
from typing import List
    
def connect_the_dots(points: List[List[int]]) -> int:
    n = len(points)
    edges = []
    for i in range(n):
        for j in range(i + 1, n):
            cost = (abs(points[i][0] - points[j][0]) + abs(points[i][1] - points[j][1]))
            edges.append((cost, i, j))
    edges.sort()
    uf = UnionFind(n)
    total_cost = edges_added = 0
    for cost, p1, p2 in edges:
        if uf.union(p1, p2):
            total_cost += cost
            edges_added += 1
            if edges_added == n - 1:
                break
    return total_cost

class UnionFind:
    def __init__(self, size):
        self.parent = [i for i in range(size)]
        self.size = [1] * size

    def union(self, x, y) -> bool:
        rep_x, rep_y = self.find(x), self.find(y)
        if rep_x != rep_y:
            if self.size[rep_x] > self.size[rep_y]:
                self.parent[rep_y] = rep_x
                self.size[rep_x] += self.size[rep_y]
            else:
                self.parent[rep_x] = rep_y
                self.size[rep_y] += self.size[rep_x]
            return True
        return False

    def find(self, x) -> int:
        if x == self.parent[x]:
            return x
        self.parent[x] = self.find(self.parent[x])
        return self.parent[x]

Анализ сложности

Временная сложность: O(n^2 log n). Пространственная сложность: O(n^2).

Глава 14

Метод с возвратом (Backtracking)

~12 мин чтения

Введение в метод с возвратом

Введение в метод с возвратом

Интуиция

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

Flowchart illustrating backtracking decision paths

Однако вы не знаете, какой путь выбрать. Чтобы найти выход, вы решаете пробовать каждый вариант по очереди, начиная с пути A. Пройдя по коридору A, вы обнаруживаете новую развилку с двумя путями: D и E:

Backtracking flowchart showing intersection 2

Вы пробуете путь D, но он ведёт в тупик. Возвращаетесь на вторую развилку:

Backtracking step diagram

Затем пробуете путь E, но он тоже ведёт в тупик — возвращаетесь на вторую развилку. Убедившись, что ни D, ни E не работают, возвращаетесь на первую развилку:

Backtracking to first intersection

Убедившись, что путь A не ведёт к выходу, вы переходите к пути B и обнаруживаете, что он ведёт к выходу:

Path B leads to exit

Этот метод перебора всех возможных путей с возвратом при неудаче называется «методом с возвратом» (backtracking).

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

Например, вот как выглядит дерево состояний для сценария с лабиринтом:

State space tree for maze scenario

Краткое описание дерева состояний:

  • Рёбра: каждое ребро представляет возможное решение, ход или действие.
  • Корневой узел: представляет начальное состояние до принятия каких-либо решений.
  • Промежуточные узлы: узлы, представляющие частично завершённые состояния.
  • Листовые узлы: представляют полные или недействительные решения.
  • Путь: путь от корня до любого листа представляет последовательность решений, ведущую к полному или недействительному решению.

Построение дерева состояний помогает визуализировать всё пространство решений и все возможные варианты. Кроме того, это отличный способ понять, как работает алгоритм. Разобравшись, как обходить это дерево, мы фактически создаём алгоритм с возвратом.

Алгоритм с возвратом Обход дерева состояний обычно выполняется с помощью рекурсивного DFS. Рассмотрим высокоуровневую реализацию.

Условие завершения: определите условие, указывающее, когда путь должен завершиться. Это условие определяет, когда найдено корректное и/или некорректное решение.

Перебор решений: перебираем все возможные решения в текущем узле с текущим состоянием задачи. Для каждого решения:

  1. Принимаем это решение и обновляем текущее состояние.
  2. Рекурсивно исследуем все пути, ветвящиеся из обновлённого состояния.
  3. Возврат: отменяем принятое решение и восстанавливаем состояние.

Ниже приведён общий шаблон для метода с возвратом:

python
def dfs(state):
    # Termination condition.
    if meets_termination_condition(state):
        process_solution(state)
        return
    # Explore each possible decision that can be made at the current state.
    for decision in possible_decisions(state):
        make_decision(state, decision)
        dfs(state)
        undo_decision(state, decision)  # Backtrack.

Анализ временной сложности

Анализ временной сложности алгоритмов с возвратом включает понимание коэффициента ветвления и глубины дерева состояний:

  • Коэффициент ветвления: количество дочерних узлов у каждого узла. Обычно представляет максимальное количество решений для данного состояния.
  • Глубина: длина наидлиннейшего пути в дереве состояний. Соответствует количеству решений или шагов для достижения полного решения.

Временная сложность часто оценивается как O(b^d), где b — коэффициент ветвления, d — глубина.

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

Пример из реального мира

ИИ-алгоритмы для игр: метод с возвратом используется в алгоритмах искусственного интеллекта для игр, таких как шахматы и го, для анализа возможных ходов и стратегий. Программы рассматривают каждый потенциальный ход, симулируют развитие партии и оценивают результат. Если ход ведёт к невыгодной позиции, программа возвращается к предыдущему ходу и пробует альтернативы, систематически исследуя дерево игры в поисках оптимальной стратегии.

Обзор главы

Chapter outline diagram for Backtracking

Найти все перестановки

Найти все перестановки

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

Пример:

python
Input: nums = [4, 5, 6]
Output: [[4, 5, 6], [4, 6, 5], [5, 4, 6], [5, 6, 4], [6, 4, 5], [6, 5, 4]]

Интуиция

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

Дерево состояний Разберёмся, как построить одну перестановку. Рассмотрим массив [4, 5, 6]. Начнём с выбора одного числа для первой позиции.

State space tree for permutations step 1

Найдя одну перестановку, выполним возврат для поиска других. Удаляем последнее добавленное число, 6, возвращаясь к [4, 5]:

Backtracking permutations step 2

Есть ли другие числа, которые можно добавить к [4, 5]? Только 6, которую мы уже рассмотрели. Делаем ещё один возврат, удаляя 5 и возвращаясь к [4]:

Backtracking permutations step 3

Есть ли числа кроме 5, которые можно добавить к [4]? Да, можно использовать 6, поэтому добавляем её:

Backtracking permutations step 4

Единственное доступное число — 5, добавляем её к [4, 6], получая ещё одну перестановку:

Backtracking permutations step 5

Продолжая этот процесс с возвратом до исследования всех ветвей, получаем все перестановки:

Full state space tree for permutations

Каждый раз, когда строимая перестановка достигает размера n (длины входного массива), добавляем её в результат.

Обход дерева состояний Генерация всех перестановок достигается обходом дерева состояний.

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

Начиная с корня, обходим дерево методом с возвратом:

  1. Выбираем неиспользованное число и добавляем его к текущему кандидату. Отмечаем это число как использованное, добавляя его в хеш-множество used.
  2. Делаем рекурсивный вызов с обновлённым кандидатом для исследования его ветвей.
  3. Возврат: удаляем последнее добавленное число из кандидата и из хеш-множества.

Когда кандидат достигает длины n — добавляем его в результат.

Реализация

python
from typing import List, Set
    
def find_all_permutations(nums: List[int]) -> List[List[int]]:
    res = []
    backtrack(nums, [], set(), res)
    return res
    
def backtrack(nums: List[int], candidate: List[int], used: Set[int], res: List[List[int]]) -> None:
    # If the current candidate is a complete permutation, add it to the result.
    if len(candidate) == len(nums):
        res.append(candidate[:])
        return
    for num in nums:
        if num not in used:
            # Add 'num' to the current permutation and mark it as used.
            candidate.append(num)
            used.add(num)
            # Recursively explore all branches using the updated permutation
            # candidate.
            backtrack(nums, candidate, used, res)
            # Backtrack by reversing the changes made.
            candidate.pop()
            used.remove(num)

Анализ сложности

Временная сложность: O(n * n!). Начиная с корня, рекурсивно исследуем n кандидатов. Для каждого из n кандидатов исследуем ещё n-1, затем n-2 и т.д. Для каждой из n! перестановок создаём копию и добавляем в результат — это занимает O(n).

Пространственная сложность: O(n), так как максимальная глубина дерева рекурсии равна n. Алгоритм также поддерживает структуры candidate и used, каждая из которых требует O(n) памяти.

Найти все подмножества

Найти все подмножества

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

Пример:

python
Input: nums = [4, 5, 6]
Output: [[], [4], [4, 5], [4, 5, 6], [4, 6], [5], [5, 6], [6]]

Интуиция

Ключевая идея: каждое подмножество формируется конкретным решением для каждого числа входного массива — включить или исключить его. Например, из массива [4, 5, 6] подмножество [4, 6] создаётся включением 4, исключением 5 и включением 6.

Дерево состояний Рассмотрим входной массив [4, 5, 6]. Начнём с корневого узла — пустого подмножества:

Empty root node for subsets tree

Рассматриваем решение о включении или исключении первого элемента 4:

Include/exclude decision for element 4

Для каждого из этих подмножеств повторяем процесс для второго элемента:

Include/exclude decision for element 5

Наконец, для третьего элемента продолжаем ветвление:

Complete subsets tree with all 8 subsets

Используем индекс i, чтобы отслеживать, для какого элемента входного массива принимается решение:

Subsets tree with index i

Финальный уровень дерева (i == n) содержит все подмножества входного массива. Для их получения необходимо обойти дерево — для этого отлично подходит метод с возвратом.

Реализация

python
from typing import List
    
def find_all_subsets(nums: List[int]) -> List[List[int]]:
    res = []
    backtrack(0, [], nums, res)
    return res
    
def backtrack(i: int, curr_subset: List[int], nums: List[int], res: List[List[int]]) -> None:
    # Base case: if all elements have been considered, add the current subset to the
    # output.
    if i == len(nums):
        res.append(curr_subset[:])
        return
    # Include the current element and recursively explore all paths that branch from
    # this subset.
    curr_subset.append(nums[i])
    backtrack(i + 1, curr_subset, nums, res)
    # Exclude the current element and recursively explore all paths that branch from
    # this subset.
    curr_subset.pop()
    backtrack(i + 1, curr_subset, nums, res)

Анализ сложности

Временная сложность: O(2^n * n). Дерево состояний имеет глубину n и коэффициент ветвления 2. Для каждого из 2^n подмножеств создаём копию и добавляем в результат — это занимает O(n).

Пространственная сложность: O(n), так как максимальная глубина дерева рекурсии равна n.

Задача N ферзей

Задача N ферзей

Дана шахматная доска размером n x n. Расставьте n ферзей так, чтобы ни один из них не атаковал другого. Верните количество различных конфигураций, при которых это возможно.

Пример:

N-Queens example with 4x4 board showing 2 solutions
python
Input: n = 4
Output: 2

Интуиция

Ферзи ходят по вертикали, горизонтали и диагонали:

Queen movement directions

Таким образом, поставить ферзя на клетку можно только если:

  • На той же строке нет другого ферзя.
  • На том же столбце нет другого ферзя.
  • На обеих диагоналях данной клетки нет другого ферзя.

Расстановка ферзей — метод с возвратом Простая стратегия: ставить по одному ферзю за раз, следя за тем, чтобы каждый новый ферзь располагался на безопасной клетке. Если безопасную клетку найти невозможно, значит, нужно переставить одного из ранее расставленных ферзей.

Для повышения эффективности размещаем каждого ферзя в новой строке. Тогда не нужно проверять конфликты по строкам — достаточно проверять столбец и диагонали.

Backtracking illustration for N-Queens placement

Частичное дерево состояний для n = 4:

Partial state space tree for N=4 queens

Обнаружение атак Для эффективной проверки атак используем хеш-множества. Для столбцов: при размещении ферзя на (r, c) добавляем c в множество столбцов. Для диагоналей: диагональ определяется по идентификатору r - c, антидиагональ — по r + c.

Diagonal and anti-diagonal identification matrix

Ключевое наблюдение: для любой клетки (r, c) диагональ определяется как r - c, а антидиагональ — как r + c.

Размещение и удаление ферзя «Поставить» ферзя означает добавить его столбец, диагональ и антидиагональ в соответствующие хеш-множества. «Убрать» ферзя — удалить эти же идентификаторы из множеств.

Реализация

python
from typing import Set
    
res = 0
    
def n_queens(n: int) -> int:
   dfs(0, set(), set(), set(), n)
   return res
    
def dfs(r: int, diagonals_set: Set[int], anti_diagonals_set: Set[int], cols_set: Set[int], n: int) -> None:
    global res
    # Termination condition: If we have reached the end of the rows, we've placed all
    # 'n' queens.
    if r == n:
        res += 1
        return
    for c in range(n):
        curr_diagonal = r - c
        curr_anti_diagonal = r + c
        # If there are queens on the current column, diagonal or anti-diagonal, skip
        # this square.
        if (c in cols_set or curr_diagonal in diagonals_set or curr_anti_diagonal in anti_diagonals_set):
            continue
        # Place the queen by marking the current column, diagonal, and anti-diagonal
        # as occupied.
        cols_set.add(c)
        diagonals_set.add(curr_diagonal)
        anti_diagonals_set.add(curr_anti_diagonal)
        # Recursively move to the next row to continue placing queens.
        dfs(r + 1, diagonals_set, anti_diagonals_set, cols_set, n)
        # Backtrack by removing the queen's marks.
        cols_set.remove(c)
        diagonals_set.remove(curr_diagonal)
        anti_diagonals_set.remove(curr_anti_diagonal)

Анализ сложности

Временная сложность: O(n!). Для первого ферзя n вариантов. Для второго — n-a, где a — количество клеток второй строки, атакуемых первым ферзём. Это приближённо даёт факториальный рост пространства поиска.

Пространственная сложность: O(n), так как максимальная глубина дерева рекурсии равна n. Хеш-множества также вносят вклад — каждое хранит до n значений.

Комбинации с заданной суммой

Комбинации с заданной суммой

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

Пример:

python
Input: nums = [1, 2, 3], target = 4
Output: [[1, 1, 1, 1], [1, 1, 2], [1, 3], [2, 2]]

Ограничения:

  • Все целые числа в nums положительны и уникальны.
  • Целевое значение положительно.
  • Результат не должен содержать дублирующихся комбинаций. Например, [1, 1, 2] и [1, 2, 1] считаются одной комбинацией.

Интуиция

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

Важное наблюдение: все значения в массиве положительные, поэтому по мере добавления элементов сумма растёт. Следовательно, нужно прекращать построение комбинации, как только её сумма достигает или превышает целевое значение.

Для предотвращения дубликатов используем start_index, который обеспечивает единообразное представление комбинаций в порядке элементов исходного массива.

Дерево состояний Рассмотрим массив [1, 2, 3] и target = 4.

Empty root for combinations tree
First level decisions: choose 1, 2, or 3
Combinations tree with sum checks

Проблема: подход привёл к дублирующимся комбинациям. Для их устранения используем start_index:

Duplicate combinations highlighted
Combinations tree with start_index

Изначально для корневой комбинации start_index = 0. По мере рекурсивного построения start_index обновляется до индекса текущего добавляемого элемента.

Реализация

Перерабатываем целевое значение: при добавлении числа уменьшаем target. При target = 0 нашли корректную комбинацию. Если target < 0 — прерываем текущую ветвь.

Decision tree showing target reduction
Full combinations tree with target tracking
python
from typing import List
    
def combinations_of_sum_k(nums: List[int], target: int) -> List[List[int]]:
    res = []
    dfs([], 0, nums, target, res)
    return res
    
def dfs(combination: List[int], start_index: int, nums: List[int], target: int,
        res: List[List[int]]) -> None:
    # Termination condition: If the target is equal to 0, we found a combination
    # that sums to 'k'.
    if target == 0:
        res.append(combination[:])
        return
    # Termination condition: If the target is less than 0, no more valid
    # combinations can be created.
    if target < 0:
        return
    # Starting from start_index, explore all combinations after adding nums[i].
    for i in range(start_index, len(nums)):
        # Add the current number to create a new combination.
        combination.append(nums[i])
        # Recursively explore all paths that branch from this new combination.
        dfs(combination, i, nums, target - nums[i], res)
        # Backtrack.
        combination.pop()

Анализ сложности

Временная сложность: O(n^(target/m)), где n — длина массива, m — наименьший кандидат. Дерево рекурсии ветвится до тех пор, пока сумма наименьших кандидатов не достигнет или не превысит target, что даёт глубину target/m.

Пространственная сложность: O(target/m) с учётом стека рекурсии и списка комбинации.

Комбинации клавиш телефона

Комбинации клавиш телефона

Дана строка с цифрами от 2 до 9 включительно. Каждой цифре соответствует набор букв, как на традиционной телефонной клавиатуре:

12 abc3 def
4 ghi5 jkl6 mno
7 pqrs8 tuv9 wxyz

Верните все возможные буквенные комбинации, которые могут представлять введённые цифры.

Пример:

python
Input: digits = '69'
Output: ['mw', 'mx', 'my', 'mz', 'nw', 'nx', 'ny', 'nz', 'ow', 'ox', 'oy', 'oz']

Интуиция

На каждой цифре строки нужно принять решение: какую букву будет представлять эта цифра?

Дерево состояний Рассмотрим строку "69". Начнём с корневого узла — пустой строки:

Empty root node for keypad combinations

На первой цифре 6 мы выбираем начало: 'm', 'n' или 'o':

First digit decision tree for digit 6

Для каждой из созданных комбинаций принимаем новое решение: какую букву из цифры 9 ('w', 'x', 'y', 'z') выбрать?

Full decision tree for digits 69

Используем индекс i для отслеживания, для какой цифры принимается решение в каждом узле:

Decision tree with index i for digit tracking

Сопоставление цифр с буквами Для определения соответствия цифр буквам используем хеш-таблицу.

keypad_map hash table

Реализация

python
def phone_keypad_combinations(digits: str) -> List[str]:
    keypad_map = {
        '2': 'abc', '3': 'def', '4': 'ghi', '5': 'jkl',
        '6': 'mno', '7': 'pqrs', '8': 'tuv', '9': 'wxyz'
    }
    res = []
    backtrack(0, [], digits, keypad_map, res)
    return res

def backtrack(i: int, curr_combination: List[str], digits: str,
             keypad_map: Dict[str, str], res: List[str]) -> None:
    # Termination condition: if all digits have been considered, add the
    # current combination to the output list.
    if len(curr_combination) == len(digits):
        res.append("".join(curr_combination))
        return
    for letter in keypad_map[digits[i]]:
       # Add the current letter.
        curr_combination.append(letter)
        # Recursively explore all paths that branch from this combination.
        backtrack(i + 1, curr_combination, digits, keypad_map, res)
        # Backtrack by removing the letter we just added.
        curr_combination.pop()

Анализ сложности

Временная сложность: O(n * 4^n). Дерево состояний ветвится до тех пор, пока не будет принято решение для всех n элементов, с коэффициентом ветвления до 4. Для каждой из 4^n комбинаций преобразуем её в строку — O(n).

Пространственная сложность: O(n) за счёт стека рекурсии глубиной до n.

Совет для интервью

Совет: проверьте, можно ли пропустить тривиальные реализации.

На собеседовании важно эффективно распоряжаться временем. Если вы столкнулись с тривиальной и трудоёмкой задачей — например, созданием keypad_map в этой задаче — интервьюер может разрешить пропустить её или реализовать позже, если останется время.

Глава 15

Dynamic Programming

~13 мин чтения

Введение в Dynamic Programming

Введение в Dynamic Programming

Dynamic programming (DP) поначалу может показаться сложным, но мы разберём её на понятные концепции и приёмы. Для начала разберёмся, что именно решает DP, взглянув на задачу в целом.

Некоторые задачи можно разбить на subproblem'ы, где каждый subproblem — это уменьшенная версия основной задачи. Эти subproblem'ы, в свою очередь, тоже могут дробиться на ещё более мелкие subproblem'ы. Для решения подобных задач часто применяется рекурсия, при которой мы делаем рекурсивные вызовы для каждого subproblem'а.

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

Recursive subproblem tree diagram

Именно здесь на помощь приходит DP. Это техника, которая сохраняет решения каждого subproblem'а, чтобы использовать их повторно при необходимости. Иными словами, это эффективный инструмент, гарантирующий, что каждый subproblem решается не более одного раза.

  • Оптимальная подструктура (optimal substructure): оптимальное решение задачи строится из оптимальных решений её subproblem'ов.
  • Перекрывающиеся subproblem'ы (overlapping subproblems): одни и те же subproblem'ы встречаются многократно в ходе решения задачи.
  • Рекуррентное соотношение (recurrence relation): формула, выражающая решение задачи через решения её subproblem'ов.
  • Базовые случаи (base cases): простейшие экземпляры задачи, решение которых уже известно и не требует дальнейшего разбиения на subproblem'ы.

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

Пример из реального мира

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

Структура главы

Chapter outline diagram showing 1D-DP and 2D-DP categories

Каждая DP-задача в этой главе по-своему уникальна, но для удобства мы разбили их на две категории: одномерный DP (1D-DP) и двумерный DP (2D-DP).

Подъём по лестнице

Подъём по лестнице

Определите количество различных способов подняться на лестницу из n ступенек, делая за один раз 1 или 2 ступеньки.

Пример:

Staircase climbing example diagram
python
Input: n = 4
Output: 5

Интуиция — подход сверху вниз (Top-Down)

Метод грубой силы перебирает все возможные комбинации шагов по 1 или 2 ступеньки. Чтобы достичь ступеньки i, нужно прийти либо со ступеньки i-1, либо со ступеньки i-2.

Staircase with step i
Reaching step i from i-1 and i-2

climbing_stairs(n) = climbing_stairs(n - 1) + climbing_stairs(n - 2)

Base cases: если n равно 1, вернуть 1. Если n равно 2, вернуть 2.

Recursion tree for climbing_stairs(6)

Данное решение является top-down решением: оно начинается с основной задачи и рекурсивно разбивает её на всё меньшие subproblem'ы.

Обратите внимание, что в дереве рекурсии некоторые subproblem'ы вычисляются повторно (например, climbing_stairs(4) вызывается дважды). Это наглядно демонстрирует наличие overlapping subproblems. Именно здесь применяется memoization.

Memoized recursion tree

Для memoization используется хэш-таблица, в которой хранятся результаты subproblem'ов — это обеспечивает доступ к ним за константное время.

Реализация — Top-Down

python
memo = {}
        
def climbing_stairs_top_down(n: int) -> int:
    if n <= 2:
        return n
    if n in memo:
        return memo[n]
    memo[n] = climbing_stairs_top_down(n - 1) + climbing_stairs_top_down(n - 2)
    return memo[n]

Анализ сложности

Временная сложность: без memoization O(2^n), с memoization O(n). Пространственная сложность: O(n) — за счёт стека рекурсивных вызовов и массива memoization.

Интуиция — подход снизу вверх (Bottom-Up)

Как правило, любую задачу, решаемую с помощью top-down memoization, можно также решить с помощью bottom-up DP, преобразовав массив memoization в массив DP.

dp[n] = dp[n - 1] + dp[n - 2]

Base cases: dp[1] = 1, dp[2] = 2. Вернуть dp[n].

Реализация — Bottom-Up

python
def climbing_stairs_bottom_up(n: int) -> int:
    if n <= 2:
        return n
    dp = [0] * (n + 1)
    dp[1], dp[2] = 1, 2
    for i in range(3, n + 1):
        dp[i] = dp[i - 1] + dp[i - 2]
    return dp[n]

Оптимизация — Bottom-Up

Нам требуются только два предыдущих значения DP-массива. Используем две переменные: one_step_before и two_steps_before.

python
def climbing_stairs_bottom_up_optimized(n: int) -> int:
    if n <= 2:
        return n
    one_step_before, two_steps_before = 2, 1
    for i in range(3, n + 1):
        current = one_step_before + two_steps_before
        two_steps_before = one_step_before
        one_step_before = current
    return one_step_before

Совет для собеседования

Если вам сложно придумать bottom-up решение, начните с top-down. Разработать top-down решение зачастую проще: сначала определяем recurrence relation, а затем применяем memoization для оптимизации.

Минимальная комбинация монет

Минимальная комбинация монет

Дан массив номиналов монет и целевая сумма. Верните минимальное количество монет, необходимых для получения целевой суммы. Если это невозможно, верните -1. Можно считать, что каждой монеты неограниченное количество.

Пример 1:

python
Input: coins = [1, 2, 3], target = 5
Output: 2

Пояснение: используем одну монету номиналом 2 и одну монету номиналом 3, чтобы получить 5.

Пример 2:

python
Input: coins = [2, 4], target = 5
Output: -1

Интуиция — Top-Down

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

Coin combination example
Target reduced from 5 to 2 after using 3-dollar coin
Three subproblems from target=5
Full decision tree

min_coin_combination(target) = 1 + min(min_coin_combination(target - coin_i) | coin_i in coins)

Base case: когда target равен 0, вернуть 0. Memoization сохраняет решения subproblem'ов, чтобы избежать повторных вычислений.

Overlapping subproblems example
Memoized tree

Реализация — Top-Down

Анализ сложности

Временная сложность: без memoization O(n^(target/m)), с memoization O(target * n). Пространственная сложность: O(target).

Интуиция — Bottom-Up

Используя тот же приём, что и в задаче «Подъём по лестнице», преобразуем top-down решение в bottom-up.

python
for t in range(1, target + 1):
    for coin in coins:
        if coin <= t:
            dp[t] = min(dp[t], 1 + dp[t - coin])

Реализация — Bottom-Up

python
def min_coin_combination_bottom_up(coins, target):
    dp = [float('inf')] * (target + 1)
    dp[0] = 0
    for t in range(1, target + 1):
        for coin in coins:
            if coin <= t:
                dp[t] = min(dp[t], 1 + dp[t - coin])
    return dp[target] if dp[target] != float('inf') else -1

Анализ сложности

Временная сложность: O(target * n). Пространственная сложность: O(target).

Совет для собеседования

Если задача просит найти минимум или максимум чего-либо, вероятно, это DP-задача. Ключевые слова «минимальный», «максимальный», «наидлиннейший» или «наикратчайший» указывают на применимость DP.

Пути в матрице

Пути в матрице

Вы находитесь в левом верхнем углу матрицы m x n и можете двигаться только вниз или вправо. Определите количество уникальных маршрутов, ведущих в правый нижний угол.

Пример:

6 unique paths in 3x3 grid
python
Input: m = 3, n = 3
Output: 6

Интуиция

Количество путей до любой клетки равно сумме путей из клетки выше и клетки слева.

Bottom-right cell reached from above or left
Recurrence relation diagram

dp[r][c] = dp[r - 1][c] + dp[r][c - 1]

Base cases: все клетки в строке 0 и столбце 0 имеют ровно один путь (устанавливаются в 1).

Row 0 and column 0 base cases
Fully populated DP table

Реализация

python
def matrix_pathways(m, n):
    dp = [[1] * n for _ in range(m)]
    for r in range(1, m):
        for c in range(1, n):
            dp[r][c] = dp[r - 1][c] + dp[r][c - 1]
    return dp[m - 1][n - 1]

Анализ сложности

Временная сложность: O(m*n). Пространственная сложность: O(m*n).

Оптимизация

Достаточно хранить только две строки: prev_row и curr_row. Пространственная сложность снижается до O(n).

Two-row optimization diagram
prev_row and curr_row example
python
def matrix_pathways_optimized(m, n):
    prev_row = [1] * n
    for r in range(1, m):
        curr_row = [1] * n
        for c in range(1, n):
            curr_row[c] = prev_row[c] + curr_row[c - 1]
        prev_row = curr_row
    return prev_row[n - 1]

Ограбление по соседству

Ограбление по соседству

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

Пример:

Houses diagram
python
Input: houses = [200, 300, 200, 50]
Output: 400

Пояснение: ограбление домов с индексами 0 и 2 даёт 200 + 200 = 400.

Интуиция

Жадный алгоритм здесь не подходит, так как не учитывает долгосрочные последствия. Для дома i есть два варианта: пропустить его (прибыль = dp[i-1]) или ограбить (прибыль = houses[i] + dp[i-2]).

Greedy option 1
Optimal option 2

dp[i] = max(dp[i - 1], houses[i] + dp[i - 2])

Base cases: dp[0] = houses[0], dp[1] = max(houses[0], houses[1]).

Реализация

python
def neighborhood_burglary(houses):
    if not houses: return 0
    if len(houses) == 1: return houses[0]
    dp = [0] * len(houses)
    dp[0] = houses[0]
    dp[1] = max(houses[0], houses[1])
    for i in range(2, len(houses)):
        dp[i] = max(dp[i - 1], houses[i] + dp[i - 2])
    return dp[len(houses) - 1]

Анализ сложности

Временная сложность: O(n). Пространственная сложность: O(n).

Оптимизация

Нужны только два предыдущих значения. Используем переменные prev_max_profit и prev_prev_max_profit. Пространственная сложность снижается до O(1).

python
def neighborhood_burglary_optimized(houses):
    if not houses: return 0
    if len(houses) == 1: return houses[0]
    prev_max_profit = max(houses[0], houses[1])
    prev_prev_max_profit = houses[0]
    for i in range(2, len(houses)):
        curr_max_profit = max(prev_max_profit, houses[i] + prev_prev_max_profit)
        prev_prev_max_profit = prev_max_profit
        prev_max_profit = curr_max_profit
    return prev_max_profit

Наибольшая общая подпоследовательность

Наибольшая общая подпоследовательность

Даны две строки. Найдите длину их наибольшей общей подпоследовательности (LCS). Подпоследовательность — это последовательность символов, полученная из строки удалением нуля или более элементов без изменения порядка оставшихся.

Пример:

python
Input: s1 = 'acabac', s2 = 'aebab'
Output: 3

Интуиция

Два случая: (1) символы совпадают: включаем их, LCS(i,j) = 1 + LCS(i+1, j+1). (2) символы различаются: берём max(LCS без s1[i], LCS без s2[j]).

if s1[i] == s2[j]: LCS(i, j) = 1 + LCS(i + 1, j + 1)

if s1[i] != s2[j]: LCS(i, j) = max(LCS(i + 1, j), LCS(i, j + 1))

DP-формула: dp[i][j]. Base cases: dp[len(s1)][j] = 0 и dp[i][len(s2)] = 0. Вернуть dp[0][0].

DP table with base cases
Fully populated DP table

Реализация

python
def longest_common_subsequence(s1, s2):
    dp = [[0] * (len(s2) + 1) for _ in range(len(s1) + 1)]
    for i in range(len(s1) - 1, -1, -1):
        for j in range(len(s2) - 1, -1, -1):
            if s1[i] == s2[j]:
                dp[i][j] = 1 + dp[i + 1][j + 1]
            else:
                dp[i][j] = max(dp[i + 1][j], dp[i][j + 1])
    return dp[0][0]

Анализ сложности

Временная сложность: O(m*n). Пространственная сложность: O(m*n).

Оптимизация

Достаточно двух строк: curr_row и prev_row. Пространственная сложность снижается до O(n).

python
def longest_common_subsequence_optimized(s1, s2):
    prev_row = [0] * (len(s2) + 1)
    for i in range(len(s1) - 1, -1, -1):
        curr_row = [0] * (len(s2) + 1)
        for j in range(len(s2) - 1, -1, -1):
            if s1[i] == s2[j]:
                curr_row[j] = 1 + prev_row[j + 1]
            else:
                curr_row[j] = max(prev_row[j], curr_row[j + 1])
        prev_row = curr_row
    return prev_row[0]

Наибольший палиндром в строке

Наибольший палиндром в строке

Верните наибольшую палиндромную подстроку данной строки.

Пример:

python
Input: s = 'abccbaba'
Output: 'abccba'

Интуиция

Подстрока s[i:j] является палиндромом, если: (1) s[i] == s[j] и (2) s[i+1:j-1] тоже является палиндромом.

Palindrome contains smaller palindromes
Palindrome check with i and j pointers

dp[i][j] = True if s[i] == s[j] and dp[i + 1][j - 1]

Base cases: все подстроки длины 1 являются палиндромами (dp[i][i] = True). Подстроки длины 2 являются палиндромами, если оба символа одинаковы.

Отслеживаем наибольший палиндром с помощью start_index и max_len. Возвращаем s[start_index : start_index + max_len].

Реализация

python
def longest_palindrome_in_a_string(s):
    n = len(s)
    if n == 0: return ''
    dp = [[False] * n for _ in range(n)]
    max_len = 1
    start_index = 0
    for i in range(n):
        dp[i][i] = True
    for i in range(n - 1):
        if s[i] == s[i + 1]:
            dp[i][i + 1] = True
            max_len = 2
            start_index = i
    for substring_len in range(3, n + 1):
        for i in range(n - substring_len + 1):
            j = i + substring_len - 1
            if s[i] == s[j] and dp[i + 1][j - 1]:
                dp[i][j] = True
                if substring_len > max_len:
                    max_len = substring_len
                    start_index = i
    return s[start_index : start_index + max_len]

Анализ сложности

Временная сложность: O(n^2). Пространственная сложность: O(n^2).

Оптимизированный подход

Расширяемся от центра каждого базового случая. Два типа центров: односимвольный (нечётный) и двухсимвольный (чётный).

Expanding from single-char center
Expanding from two-char center
python
def longest_palindrome_in_a_string_expanding(s):
    n = len(s)
    start, max_len = 0, 0
    for center in range(n):
        odd_start, odd_length = expand_palindrome(center, center, s)
        if odd_length > max_len:
            start = odd_start
            max_len = odd_length
        if center < n - 1 and s[center] == s[center + 1]:
            even_start, even_length = expand_palindrome(center, center + 1, s)
            if even_length > max_len:
                start = even_start
                max_len = even_length
    return s[start : start + max_len]

Анализ сложности

Временная сложность: O(n^2). Пространственная сложность: O(1).

Алгоритм Манакера

Алгоритм Манакера работает за O(n), однако является специализированным алгоритмом и редко встречается на собеседованиях. Большинство интервьюеров ожидают решений, демонстрирующих фундаментальные навыки решения задач.

Максимальная сумма подмассива

Максимальная сумма подмассива

Дан массив целых чисел. Верните сумму подмассива с наибольшей суммой.

Пример:

python
Input: nums = [3, 1, -6, 2, -1, 4, -9]
Output: 5

Пояснение: подмассив [2, -1, 4] имеет наибольшую сумму равную 5.

Интуиция

Для каждого числа выбираем max(curr_sum + num, num): продолжаем текущий подмассив или начинаем новый с текущего элемента.

Input array

Отслеживаем max_sum — наибольшее встреченное значение curr_sum. Это алгоритм Кадане.

Реализация

python
def maximum_subarray_sum(nums):
    if not nums: return 0
    max_sum = current_sum = float('-inf')
    for num in nums:
        current_sum = max(current_sum + num, num)
        max_sum = max(max_sum, current_sum)
    return max_sum

Анализ сложности

Временная сложность: O(n). Пространственная сложность: O(1).

Интуиция — DP

Каждый подмассив заканчивается на определённом индексе. dp[i] = максимальная сумма подмассива, заканчивающегося на индексе i.

max_subarray(i) = max(max_subarray(i - 1) + nums[i], nums[i])

dp[i] = max(dp[i - 1] + nums[i], nums[i])

Base case: dp[0] = nums[0]. Вернуть максимум всех значений dp.

Реализация — DP

python
def maximum_subarray_sum_dp(nums):
    n = len(nums)
    if n == 0: return 0
    dp = [0] * n
    dp[0] = nums[0]
    max_sum = dp[0]
    for i in range(1, n):
        dp[i] = max(dp[i - 1] + nums[i], nums[i])
        max_sum = max(max_sum, dp[i])
    return max_sum

Оптимизация

Используем одну переменную вместо полного DP-массива. Пространственная сложность снижается до O(1).

python
def maximum_subarray_sum_dp_optimized(nums):
    n = len(nums)
    if n == 0: return 0
    current_sum = nums[0]
    max_sum = nums[0]
    for i in range(1, n):
        current_sum = max(nums[i], current_sum + nums[i])
        max_sum = max(max_sum, current_sum)
    return max_sum

Задача о рюкзаке 0/1

Задача о рюкзаке 0/1

Вы — вор, планирующий ограбление магазина. Вы можете нести рюкзак с максимальной вместимостью cap единиц. Каждый предмет (i) имеет вес (weights[i]) и стоимость (values[i]). Найдите максимальную суммарную стоимость, которую можно унести.

Пример:

Knapsack problem diagram
python
Input: cap = 7, weights = [5, 3, 4, 1], values = [70, 50, 40, 10]
Output: 90

Пояснение: предметы 1 и 2 имеют суммарную стоимость 50 + 40 = 90 и суммарный вес 3 + 4 = 7.

Интуиция

Для каждого предмета принимается бинарное решение: взять или не взять (отсюда название «0/1 Knapsack»). Рекуррентное соотношение:

knapsack(i, c) = max(values[i] + knapsack(i + 1, c - weights[i]), knapsack(i + 1, c))

Если предмет не помещается: knapsack(i, c) = knapsack(i + 1, c).

python
if weights[i] <= c:
    dp[i][c] = max(values[i] + dp[i + 1][c - weights[i]], dp[i + 1][c])
else:
    dp[i][c] = dp[i + 1][c]

Base cases: dp[n][c] = 0 для всех c (нет предметов), dp[i][0] = 0 для всех i (нет вместимости). Вернуть dp[0][cap].

DP table with base cases
DP table population order

Реализация

python
def knapsack(cap, weights, values):
    n = len(values)
    dp = [[0 for x in range(cap + 1)] for x in range(n + 1)]
    for i in range(n - 1, -1, -1):
        for c in range(1, cap + 1):
            if weights[i] <= c:
                dp[i][c] = max(values[i] + dp[i + 1][c - weights[i]], dp[i + 1][c])
            else:
                dp[i][c] = dp[i + 1][c]
    return dp[0][cap]

Анализ сложности

Временная сложность: O(n*cap). Пространственная сложность: O(n*cap).

Оптимизация

Достаточно двух строк: curr_row и prev_row. Пространственная сложность снижается до O(cap).

python
def knapsack_optimized(cap, weights, values):
    n = len(values)
    prev_row = [0] * (cap + 1)
    for i in range(n - 1, -1, -1):
        curr_row = [0] * (cap + 1)
        for c in range(1, cap + 1):
            if weights[i] <= c:
                curr_row[c] = max(values[i] + prev_row[c - weights[i]], prev_row[c])
            else:
                curr_row[c] = prev_row[c]
        prev_row = curr_row
    return prev_row[cap]

Наибольший квадрат в матрице

Наибольший квадрат в матрице

Определите площадь наибольшего квадрата из единиц в бинарной матрице.

Пример:

5x5 matrix with 3x3 square highlighted
python
Output: 9

Интуиция

Квадраты содержат внутри себя меньшие квадраты. Длина стороны квадрата, заканчивающегося в точке (i,j), зависит от квадратов, заканчивающихся слева (i,j-1), сверху (i-1,j) и по диагонали сверху-слева (i-1,j-1).

6x6 matrix with 4x4 square of 1s
Three 3x3 squares around cell (4,4)

if matrix[i][j] == 1: dp[i][j] = 1 + min(dp[i - 1][j], dp[i - 1][j - 1], dp[i][j - 1])

Base cases: клетки строки 0 и столбца 0 получают значение DP, равное значению матрицы (0 или 1).

DP table with base cases

Отслеживаем max_len. Возвращаем max_len^2 в качестве площади.

Реализация

python
def largest_square_in_a_matrix(matrix):
    if not matrix: return 0
    m, n = len(matrix), len(matrix[0])
    dp = [[0] * n for _ in range(m)]
    max_len = 0
    for j in range(n):
        if matrix[0][j] == 1:
            dp[0][j] = 1
            max_len = 1
    for i in range(m):
        if matrix[i][0] == 1:
            dp[i][0] = 1
            max_len = 1
    for i in range(1, m):
        for j in range(1, n):
            if matrix[i][j] == 1:
                dp[i][j] = 1 + min(dp[i-1][j], dp[i-1][j-1], dp[i][j-1])
                max_len = max(max_len, dp[i][j])
    return max_len ** 2

Анализ сложности

Временная сложность: O(m*n). Пространственная сложность: O(m*n).

Оптимизация

Достаточно двух строк: prev_row и curr_row. Пространственная сложность снижается до O(m).

python
def largest_square_in_a_matrix_optimized(matrix):
    if not matrix: return 0
    m, n = len(matrix), len(matrix[0])
    prev_row = [0] * n
    max_len = 0
    for i in range(m):
        curr_row = [0] * n
        for j in range(n):
            if i == 0 or j == 0:
                curr_row[j] = matrix[i][j]
            else:
                if matrix[i][j] == 1:
                    curr_row[j] = 1 + min(curr_row[j-1], prev_row[j-1], prev_row[j])
            max_len = max(max_len, curr_row[j])
        prev_row, curr_row = curr_row, [0] * n
    return max_len ** 2
Глава 16

Жадные алгоритмы

~4 мин чтения

Введение в жадные алгоритмы

Введение в жадные алгоритмы

Интуиция

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

Представьте, что вы планируете автопробег из города A в город E и хотите посетить города B, C и D по дороге.

Weighted graph showing road trip routes

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

Shortest path highlighted in graph

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

Как работает жадный алгоритм? Формально жадный алгоритм следует принципу жадного выбора, согласно которому глобально оптимальное решение может быть получено путём локально оптимальных выборов.

Каждое решение принимается исходя только из текущего контекста и не учитывает его влияние на будущие шаги. Этот процесс продолжается до тех пор, пока задача не будет решена.

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

Пример из реальной жизни

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

Обзор главы

Chapter outline: Reaching a Destination (Jump to End, Gas Stations) and Resource Allocation (Candies)

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

Прыжок к концу

Прыжок к концу

Вам дан целочисленный массив, в котором вы изначально находитесь в индексе 0. Каждое число представляет максимальную длину прыжка вперёд из данной позиции. Верните true, если можно достичь последнего индекса, и false в противном случае.

Пример 1:

Jump path in [3,2,0,2,5]
python
Input: nums = [3, 2, 0, 2, 5]
Output: True

Пример 2:

Dead end in [2,1,0,3]
python
Input: nums = [2, 1, 0, 3]
Output: False

Интуиция

Из индекса i самый дальний достижимый индекс равен i + nums[i]. Проблема возникает с нулями (тупиками). Думайте в обратном направлении: установите destination = последний индекс. Если можно достичь destination из i, обновите destination = i.

если i + nums[i] >= destination, из индекса i можно добраться до destination.

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

Реализация

python
def jump_to_the_end(nums):
    destination = len(nums) - 1
    for i in range(len(nums) - 1, -1, -1):
        if i + nums[i] >= destination:
            destination = i
    return destination == 0

Анализ сложности

Время: O(n). Пространство: O(1).

Заправочные станции

Заправочные станции

На круговом маршруте расположены заправочные станции. На каждой станции можно залить gas[i] единиц бензина, а движение до следующей станции стоит cost[i] единиц. Верните начальную станцию, с которой можно проехать весь круговой маршрут, или -1, если это невозможно.

Пример:

python
Input: gas = [2, 5, 1, 3], cost = [3, 2, 1, 4]
Output: 1

Интуиция

Случай 1: sum(gas) < sum(cost) — невозможно, вернуть -1. Случай 2: sum(gas) >= sum(cost) — допустимая начальная точка существует.

Используйте чистый газ (gas[i] - cost[i]) на каждой станции. Двигайтесь вперёд, накапливая значение в переменной tank. Если tank < 0 на станции i, мы не можем начать движение ни с одной из предыдущих станций; устанавливаем следующую станцию как стартовую и сбрасываем tank.

Если не удаётся добраться от станции a до станции b, нельзя начать движение ни с одной из станций между a и b, не исчерпав бензин.

Cannot start between a and b diagram
Circular circuit segments diagram

Доказательство: если sum(gas) >= sum(cost), начальная точка, найденная этим жадным процессом, гарантированно завершит маршрут.

Реализация

python
def gas_stations(gas, cost):
    if sum(gas) < sum(cost):
        return -1
    start = tank = 0
    for i in range(len(gas)):
        tank += gas[i] - cost[i]
        if tank < 0:
            start, tank = i + 1, 0
    return start

Анализ сложности

Время: O(n). Пространство: O(1).

Совет для собеседования

Совет: демонстрируйте жадное решение на примерах, если формальное доказательство слишком сложно. Доказательство корректности с помощью наглядных примеров часто более чем достаточно на практическом собеседовании.

Конфеты

Конфеты

Вы преподаёте классу детей, сидящих в ряд, каждый из которых имеет рейтинг успеваемости. Правила: (1) каждый ребёнок должен получить хотя бы одну конфету; (2) ребёнок с более высоким рейтингом, чем непосредственный сосед, должен получить больше конфет. Верните минимальное количество конфет для раздачи.

Пример 1:

python
Input: ratings = [4, 3, 2, 4, 5, 1]
Output: 12

Пояснение: раздайте конфеты как [3, 2, 1, 2, 3, 1].

Пример 2:

python
Input: ratings = [1, 3, 3]
Output: 4

Интуиция

Двухпроходный жадный алгоритм: первый проход (слева направо): если ratings[i] > ratings[i-1], устанавливаем candies[i] = candies[i-1] + 1. Второй проход (справа налево): если ratings[i] > ratings[i+1], устанавливаем candies[i] = max(candies[i], candies[i+1] + 1).

First pass increasing ratings
Second pass decreasing ratings

Это жадное решение: локально оптимальный выбор для каждого ребёнка с учётом только непосредственных соседей. Два прохода обеспечивают соблюдение обоих ограничений.

Реализация

python
def candies(ratings):
    n = len(ratings)
    candies = [1] * n
    for i in range(1, n):
        if ratings[i] > ratings[i - 1]:
            candies[i] = candies[i - 1] + 1
    for i in range(n - 2, -1, -1):
        if ratings[i] > ratings[i + 1]:
            candies[i] = max(candies[i], candies[i + 1] + 1)
    return sum(candies)

Анализ сложности

Время: O(n). Пространство: O(n).

Глава 17

Sort and Search

~7 мин чтения

Сортировка и поиск — две наиболее фундаментальные операции в информатике. В этой главе мы рассмотрим паттерны и техники для эффективной сортировки данных и поиска по ним.

Сортировка

Сортировка — это упорядочивание элементов в определённом порядке (обычно по возрастанию или убыванию). Распространённые алгоритмы сортировки:

  • Merge Sort: время O(n log n), память O(n) — стабильный, разделяй и властвуй
  • Quick Sort: среднее время O(n log n), память O(log n) — сортировка на месте, разделяй и властвуй
  • Counting Sort: время O(n + k), память O(k) — для целых чисел в известном диапазоне

Поиск

Поиск — это нахождение конкретного элемента или значения в структуре данных. Binary Search — наиболее эффективный алгоритм поиска в отсортированных данных:

  • Binary Search: время O(log n) — работает только с отсортированными массивами
  • Linear Search: время O(n) — работает с неотсортированными массивами

Задачи

В этой главе мы решим следующие задачи:

  • Sort Linked List
  • Sort Array
  • Kth Largest Integer
  • Dutch National Flag

Sort Linked List

Sort Linked List

Дан head односвязного списка. Отсортируйте его по возрастанию и верните head отсортированного списка.

Пример:

python
Input: head = [4, 2, 1, 3]
Output: [1, 2, 3, 4]

Идея

Для сортировки связного списка можно использовать Merge Sort, который хорошо подходит для связных списков, поскольку не требует произвольного доступа (в отличие от Quick Sort). Алгоритм работает следующим образом:

  1. Разделить список на две половины с помощью техники медленного и быстрого указателей.
  2. Рекурсивно отсортировать каждую половину.
  3. Слить две отсортированные половины.

Реализация

python
class ListNode:
    def __init__(self, val=0, next=None):
        self.val = val
        self.next = next

def sort_linked_list(head: ListNode) -> ListNode:
    if not head or not head.next:
        return head
    
    # Split the list into two halves
    left, right = split_list(head)
    
    # Recursively sort each half
    left = sort_linked_list(left)
    right = sort_linked_list(right)
    
    # Merge the two sorted halves
    return merge(left, right)

def split_list(head: ListNode):
    slow, fast = head, head.next
    while fast and fast.next:
        slow = slow.next
        fast = fast.next.next
    right = slow.next
    slow.next = None
    return head, right

def merge(left: ListNode, right: ListNode) -> ListNode:
    dummy = ListNode(0)
    curr = dummy
    while left and right:
        if left.val <= right.val:
            curr.next = left
            left = left.next
        else:
            curr.next = right
            right = right.next
        curr = curr.next
    curr.next = left if left else right
    return dummy.next

Анализ сложности

Временная сложность: O(n log n) — каждое разбиение занимает O(n), а уровней рекурсии O(log n).

Пространственная сложность: O(log n) — под стек рекурсивных вызовов.

Sort Array

Sort Array

Дан массив целых чисел nums. Отсортируйте его по возрастанию и верните результат. Задачу необходимо решить без использования встроенных функций сортировки за время O(nlog(n)) с минимально возможной пространственной сложностью.

Пример:

python
Input: nums = [5, 2, 3, 1]
Output: [1, 2, 3, 5]

Идея

Можно использовать Quick Sort со средней временной сложностью O(n log n) и пространственной сложностью O(log n). Quick Sort работает следующим образом:

  1. Выбрать pivot-элемент из массива.
  2. Разбить остальные элементы на две группы: элементы меньше pivot и элементы больше pivot.
  3. Рекурсивно применить ту же логику к обеим группам.

Альтернативно можно использовать Counting Sort, когда диапазон элементов ограничен — временная сложность O(n + k), где k — диапазон входных значений.

Реализация

python
from typing import List
import random

def sort_array(nums: List[int]) -> List[int]:
    quicksort(nums, 0, len(nums) - 1)
    return nums

def quicksort(nums: List[int], low: int, high: int) -> None:
    if low < high:
        pivot_idx = partition(nums, low, high)
        quicksort(nums, low, pivot_idx - 1)
        quicksort(nums, pivot_idx + 1, high)

def partition(nums: List[int], low: int, high: int) -> int:
    # Randomly select a pivot to avoid worst case
    rand_idx = random.randint(low, high)
    nums[rand_idx], nums[high] = nums[high], nums[rand_idx]
    pivot = nums[high]
    i = low - 1
    for j in range(low, high):
        if nums[j] <= pivot:
            i += 1
            nums[i], nums[j] = nums[j], nums[i]
    nums[i + 1], nums[high] = nums[high], nums[i + 1]
    return i + 1

Анализ сложности

Временная сложность: O(n log n) в среднем — худший случай O(n²), но редкий при случайном выборе pivot.

Пространственная сложность: O(log n) — под стек рекурсивных вызовов.

Kth Largest Integer

Kth Largest Integer

Дан неотсортированный массив целых чисел nums и целое число k. Верните k-й наибольший элемент массива.

Пример:

python
Input: nums = [3, 2, 1, 5, 6, 4], k = 2
Output: 5

Идея

Существует два эффективных подхода к решению этой задачи:

Подход 1: Min-Heap

Можно поддерживать min-heap размера k. Для каждого элемента: если он больше минимума кучи, заменяем минимум этим элементом. После обработки всех элементов минимум кучи является k-м наибольшим элементом.

Временная сложность подхода — O(n log k), пространственная — O(k).

Подход 2: Quickselect

Quickselect — алгоритм выбора, который находит k-й наименьший (или наибольший) элемент в неотсортированном списке. Он основан на шаге разбиения из Quick Sort.

Средняя временная сложность подхода — O(n), пространственная — O(log n).

Реализация

python
import heapq
from typing import List

def kth_largest(nums: List[int], k: int) -> int:
    min_heap = []
    for num in nums:
        heapq.heappush(min_heap, num)
        if len(min_heap) > k:
            heapq.heappop(min_heap)
    return min_heap[0]
python
import random
from typing import List

def kth_largest_quickselect(nums: List[int], k: int) -> int:
    target = len(nums) - k  # kth largest = (n-k)th smallest
    
    def quickselect(low: int, high: int) -> int:
        pivot_idx = random.randint(low, high)
        nums[pivot_idx], nums[high] = nums[high], nums[pivot_idx]
        pivot = nums[high]
        i = low
        for j in range(low, high):
            if nums[j] <= pivot:
                nums[i], nums[j] = nums[j], nums[i]
                i += 1
        nums[i], nums[high] = nums[high], nums[i]
        if i == target:
            return nums[i]
        elif i < target:
            return quickselect(i + 1, high)
        else:
            return quickselect(low, i - 1)
    
    return quickselect(0, len(nums) - 1)

Анализ сложности

Min-Heap: временная сложность O(n log k), пространственная сложность O(k).

Quickselect: средняя временная сложность O(n), худший случай O(n²), пространственная сложность O(log n).

Dutch National Flag

Dutch National Flag

Дан массив из 0, 1 и 2, представляющих красный, белый и синий цвета соответственно. Отсортируйте массив на месте так, чтобы он воспроизводил флаг Нидерландов: сначала все красные (0), затем белые (1) и наконец синие (2).

Пример:

python
Input: nums = [0, 1, 2, 0, 1, 2, 0]
Output: [0, 0, 0, 1, 1, 2, 2]

Идея

По сути задача сводится к сортировке трёх чисел по возрастанию. Простое решение — воспользоваться встроенной функцией сортировки. Однако это подход O(nlog(n)), где n — длина массива, и он не использует важное ограничение задачи: в массиве присутствуют только три типа элементов.

Чтобы упорядочить эти числа, нужно разместить все 0 слева, все 2 справа, а 1 — в середине. Ключевое наблюдение: если поставить 0 и 2 на правильные позиции, 1 окажутся на своих местах автоматически:

Визуальное пояснение алгоритма сортировки: показано, как элементы естественным образом группируются в процессе выполнения.

Это позволяет сосредоточиться лишь на размещении двух чисел.

Одна из стратегий — проходить по массиву и перемещать встречающиеся 0 влево, а встречающиеся 2 вправо.

Заведём левый указатель для перемещения 0 влево и правый указатель для перемещения 2 вправо. Для итерации по массиву используем отдельный указатель i:

  • При встрече 0 по индексу i меняем его местами с nums[left].
  • При встрече 2 по индексу i меняем его местами с nums[right].

Разберём на примере, как следует перемещать указатели после каждой перестановки:

Одномерный массив из семи целых чисел: 2, 0, 1, 2, 0, 0, 1.

Первый элемент равен 2, поэтому меняем его местами с nums[right]. Затем сдвигаем правый указатель внутрь, чтобы он указывал на позицию следующей 2:

После перестановки стрелка 'swapped' соединяет первый и последний элементы.

Обратите внимание: после этой перестановки по индексу i появился новый элемент. Поэтому i пока не продвигаем — нужно определить, требует ли этот элемент перемещения.

Указатель i теперь указывает на 1. Единицы обрабатывать не нужно, поэтому просто продвигаем указатель i:

Диаграмма: условие nums[i] == 1 ведёт к действию i++.

Теперь указатель i указывает на 0, поэтому меняем его местами с nums[left]:

Диаграмма: условие nums[i] == 0 ведёт к операции перестановки.

После этой перестановки по индексу i появился новый элемент. Поскольку i расположен правее левого указателя, этот элемент может быть только 1 по следующим причинам:

  • До перестановки все 0, находившиеся левее i, уже были перемещены левее индекса left.
  • До перестановки все 2, находившиеся левее i, уже были перемещены правее индекса right.

Следовательно, можно одновременно продвигать и указатель i, и левый указатель.

Теперь мы знаем, как действовать при встрече 0, 1 или 2. Продолжаем применять эту логику до тех пор, пока указатель i не превысит правый указатель — это означает, что все элементы расставлены правильно:

Диаграмма: условие остановки i > right.

Обратите внимание: процесс не останавливается при i == right, так как указатель i может по-прежнему указывать на 0, которую необходимо переставить.

Реализация

python
from typing import List
    
def dutch_national_flag(nums: List[int]) -> None:
    i, left, right = 0, 0, len(nums) - 1
    while i <= right:
        # Swap 0s with the element at the left pointer.
        if nums[i] == 0:
            nums[i], nums[left] = nums[left], nums[i]
            left += 1
            i += 1
        # Swap 2s with the element at the right pointer.
        elif nums[i] == 2:
            nums[i], nums[right] = nums[right], nums[i]
            right -= 1
        else:
            i += 1

Анализ сложности

Временная сложность: O(n) — каждый элемент массива nums обрабатывается ровно один раз.

Пространственная сложность: O(1).

Глава 18

Bit Manipulation

~9 мин чтения

Введение в bit manipulation

Введение в bit manipulation

Интуиция

Bit manipulation — техника программирования, позволяющая выполнять операции на уровне отдельных битов. Это нередко приводит к более эффективным и быстрым алгоритмам.

Когда полезен bit manipulation? Bit manipulation позволяет работать непосредственно с двоичным представлением чисел, делая ряд операций более эффективными. Такие типовые задачи, как установка, сброс, инвертирование и проверка битов, быстро выполняются с помощью bitwise-операторов.

Например, одна из наиболее распространённых техник оптимизации памяти — использование беззнакового 32-битного целого числа для хранения набора булевых значений, где каждый bit соответствует отдельному булевому значению. Это позволяет хранить и управлять до 32 состояниями без использования массива булевых значений или хэш-множества.

Image represents a visual depiction of a binary counter's behavior.

Bitwise-операторы

Существует несколько фундаментальных bitwise-операций, каждая из которых служит определённой цели. Они показаны ниже вместе с таблицей истинности для каждой операции:

Image represents a visual explanation of Boolean logic gates, specifically the NOT and AND gates.
Image represents a comparison of two logical operations, OR and XOR, using truth tables and examples.

Полезные свойства оператора XOR:

  • a ^ 0 = a
  • a ^ a = 0

Помимо этого, важно понимать фундаментальные операторы shift:

  • Left shift (<< n): сдвигает биты числа влево на n позиций, добавляя нули справа. Это эквивалентно умножению числа на 2n.
  • Right shift (>> n): сдвигает биты числа вправо на n позиций, отбрасывая биты справа. Это эквивалентно делению числа на 2n (целочисленное деление).

Используя эти операторы, полезно знать следующие техники bit manipulation:

  • Установить i-й bit числа x в 1: x |= (1 << i)
  • Сбросить i-й bit числа x в 0: x &= ~(1 << i)
  • Инвертировать i-й bit числа x (из 0 в 1 или из 1 в 0): x ^= (1 << i)
  • Проверить, установлен ли i-й bit числа x: (x >> i) & 1
  • Убрать младший установленный bit числа x: x & (x - 1)
  • Извлечь младший установленный bit числа x: x & (-x)

Пример из реальной жизни

Передача данных в сетях: во многих сетевых протоколах bit manipulation применяется для эффективного кодирования, сжатия и передачи данных. Например, IP-адреса и маски подсети используют bitwise AND для определения, находятся ли два устройства в одной сети. Аналогично, в алгоритмах обнаружения и исправления ошибок — таких как контрольные суммы или биты чётности — bit manipulation обеспечивает целостность данных при передаче, позволяя выявлять и исправлять ошибки в двоичных данных.

Структура главы

Image represents a hierarchical diagram illustrating different coding patterns within the broader category of 'Bit Manipulation'.

Для наилучшего усвоения основ bit manipulation данная глава исследует разнообразные задачи, в которых применяется широкий спектр сложных техник bit manipulation, а также рассматривает, как выбрать подходящий bitwise-оператор исходя из конкретных требований.

Веса Хэмминга целых чисел

Веса Хэмминга целых чисел

Вес Хэмминга числа — это количество установленных битов (единичных битов) в его двоичном представлении. Дано положительное целое число n; верните массив, где i-й элемент равен весу Хэмминга целого числа i для всех целых чисел от 0 до n.

Пример:

python
Input: n = 7
Output: [0, 1, 1, 2, 1, 2, 2, 3]

Пояснение:

NumberBinary representationNumber of set bits
000
111
2101
3112
41001
51012
61102
71113

Интуиция — подсчёт битов для каждого числа

Наиболее прямолинейная стратегия — поочерёдно подсчитывать количество битов для каждого числа от 0 до n.

Рассмотрим число x = 25 и его двоичное представление:

x = 25 (base 10) = 11001 (base 2)

Чтобы подсчитать количество установленных битов (единиц) в числе, можно проверять каждый bit и увеличивать счётчик при обнаружении установленного бита.

Для проверки LSB (least significant bit) числа x можно применить оператор AND с 1: x & 1. Если результат равен 1, LSB установлен.

Bitwise AND operation: 11001 & 00001 = 00001

Как проверить следующий bit? При выполнении bitwise right-shift над x все биты числа x сдвигаются на одну позицию вправо. Это делает следующий bit новым LSB:

Right shift operation on binary number

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

  1. Проверить LSB числа x с помощью x & 1, увеличив счётчик, если он равен 1.
  2. Выполнить right shift числа x.

Продолжать эти два шага до тех пор, пока x не станет равным 0 — это означает, что больше нет установленных битов для подсчёта. Выполнение этого для каждого числа от 0 до n даёт ответ.

Реализация — подсчёт битов для каждого числа

python
from typing import List

def hamming_weights_of_integers(n: int) -> List[int]:
    result = []
    for i in range(n + 1):
        count = 0
        x = i
        while x:
            count += x & 1
            x >>= 1
        result.append(count)
    return result

Анализ сложности

Временная сложность: временная сложность hamming_weights_of_integers составляет O(n log(n)), поскольку для каждого целого числа x от 0 до n подсчёт установленных битов занимает логарифмическое время — в числе x приблизительно log2(x) битов. Если предположить, что все целые числа занимают 32 бита, временная сложность упрощается до O(n).

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

Интуиция — динамическое программирование

В предыдущем подходе важно заметить, что к моменту обработки целого числа x мы уже вычислили результаты для всех целых чисел от 0 до x - 1. Если найти способ использовать эти предыдущие результаты, можно повысить эффективность построения выходного массива.

Было бы разумно найти способ воспользоваться оптимальной подструктурой, рассматривая результаты для чисел от 0 до x - 1 как потенциальные подзадачи для x. Это является отправной точкой для решения с помощью динамического программирования. Пусть dp[x] обозначает количество установленных битов в целом числе x.

Предсказуемый способ обратиться к подзадаче dp[x] — выполнить right shift числа x на 1, тем самым фактически убрав его LSB. Это подзадача dp[x >> 1], и единственное отличие между ней и dp[x] — это только что удалённый LSB.

Как упоминалось ранее, LSB числа x можно найти с помощью x & 1. Следовательно:

  • Если LSB числа x равен 0, количество битов у x и x >> 1 одинаково:
  • Если LSB числа x равен 1, разница в количестве битов между x и x >> 1 составляет 1:

Таким образом, dp[x] можно получить, используя результат dp[x >> 1] и добавив к нему LSB:

dp[x] = dp[x >> 1] + (x & 1)

Теперь нужно определить базовый случай.

Базовый случай Простейший вариант задачи — когда n равно 0. В этом случае установленных битов нет, поэтому их количество равно 0. Базовый случай задаётся установкой dp[0] = 0.

После определения базового случая заполняем остальную часть массива DP, применяя формулу от dp[1] до dp[n]. Ответом на задачу служат значения в массиве DP, содержащем количество установленных битов для каждого числа от 0 до n.

Реализация — динамическое программирование

python
from typing import List

def hamming_weights_of_integers_dp(n: int) -> List[int]:
    dp = [0] * (n + 1)
    for i in range(1, n + 1):
        dp[i] = dp[i >> 1] + (i & 1)
    return dp

Анализ сложности

Временная сложность: временная сложность hamming_weights_of_integers_dp составляет O(n), так как каждый элемент массива DP заполняется ровно один раз.

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

Одинокое целое число

Одинокое целое число

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

Пример:

python
Input: nums = [1, 3, 3, 2, 1]
Output: 2

Ограничения:

  • nums содержит хотя бы один элемент.

Интуиция

Решение с хэш-таблицей Прямолинейный способ решения — использование хэш-таблицы. Идея состоит в подсчёте вхождений каждого элемента массива. Для этого перебираем массив и увеличиваем счётчик вхождений каждого встреченного элемента в хэш-таблице.

Hash map approach: nums=[1,3,3,2,1], hashmap shows {1:2, 2:1, 3:2}, lonely integer is 2.

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

Решение с сортировкой Другой подход — сначала отсортировать массив, затем найти одинокое целое число, перебирая массив и сравнивая каждый элемент с соседями. Одинокое целое число — то, рядом с которым нет дубликата.

Sorting solution: [1,3,3,2,1] sorted to [1,1,2,3,3], 2 has no duplicates next to it.

Этот метод требует O(n log(n)) времени из-за сортировки, но не нуждается в дополнительных структурах данных (не считая тех, что используются при сортировке). Можно ли достичь линейной временной сложности при постоянном использовании памяти?

Bit manipulation Способ избежать использования дополнительной памяти — применить bit manipulation. Операция XOR особенно полезна при работе с дублирующимися целыми числами. Напомним два свойства оператора XOR:

  • a ^ a == 0
  • a ^ 0 == a

Как видно, XOR двух одинаковых чисел даёт 0. Поскольку каждое число, кроме одинокого, встречается в массиве дважды, применение XOR ко всем числам отменит все пары одинаковых чисел до 0. Это изолирует одинокое целое число: когда все дубликаты сводятся к 0, XOR нуля с одиноким числом даёт само это число.

Это работает независимо от расположения чисел в массиве, поскольку XOR обладает коммутативным и ассоциативным свойствами:

  • Коммутативность: a ^ b == b ^ a
  • Ассоциативность: (a ^ b) ^ c == a ^ (b ^ c)

Таким образом, пока в массиве есть два одинаковых числа, они будут взаимно уничтожаться при применении XOR ко всем элементам. Пример показан ниже:

XOR all elements: 1^3^3^2^1 = (1^1)^(3^3)^2 = 0^0^2 = 2

Это позволяет найти одинокое целое число за линейное время без использования дополнительной памяти.

Реализация

python
from typing import List
    
def lonely_integer(nums: List[int]) -> int:
    res = 0
    # XOR each element of the array so that duplicate values will cancel each other
    # out (x ^ x == 0).
    for num in nums:
        res ^= num
    # 'res' will store the lonely integer because it would not have been canceled out
    # by any duplicate.
    return res

Анализ сложности

Временная сложность: временная сложность lonely_integer составляет O(n), поскольку над каждым элементом nums выполняется операция XOR за константное время.

Пространственная сложность: пространственная сложность составляет O(1).

Перестановка чётных и нечётных битов

Перестановка чётных и нечётных битов

Дано беззнаковое 32-битное целое число n; верните целое число, в котором все чётные биты числа n переставлены с соседними нечётными битами.

Пример 1:

Binary 101001 (41) swapped to 010110 (22)
python
Input: n = 41
Output: 22

Пример 2:

Binary 010111 (23) swapped to 101011 (43)
python
Input: n = 23
Output: 43

Интуиция

Перестановка чётных и нечётных битов означает, что каждый bit на чётной позиции меняется местами с битом на следующей нечётной позиции, и наоборот. Обратите внимание, что нумерация позиций начинается с 0 — позиции least significant bit.

Ключевое наблюдение: для выполнения перестановки необходимо:

  • Сдвинуть все биты на чётных позициях на одну позицию влево.
  • Сдвинуть все биты на нечётных позициях на одну позицию вправо.
Visual depiction showing even bits shift left, odd bits shift right.

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

Flowchart: extract even bits (shift left) and odd bits (shift right), then merge with OR.

Разберёмся, как получить чётные и нечётные биты числа n.

Получение всех чётных битов Для извлечения всех чётных битов числа n можно использовать mask, в котором все чётные позиции установлены в 1:

even_mask = 0x55555555 (alternating 01 pattern)

Применение bitwise AND этого mask к числу n даёт целое число, в котором все биты на нечётных позициях сброшены в 0 — таким образом сохраняются только биты n на чётных позициях:

n AND even_mask = even_bits

Получение всех нечётных битов Аналогично, для извлечения всех нечётных битов числа n используется mask, в котором все нечётные позиции установлены в 1:

odd_mask = 0xAAAAAAAA (alternating 10 pattern)

Применение bitwise AND этого mask к числу n даёт целое число, в котором все биты на чётных позициях сброшены в 0 — таким образом сохраняются только биты n на нечётных позициях:

n AND odd_mask = odd_bits

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

Сдвиг и объединение битов чётных и нечётных позиций Применим оператор shift для сдвига битов на чётных позициях влево на один, а битов на нечётных позициях — вправо на один:

even_bits shifted left by 1, odd_bits shifted right by 1

Затем для объединения применим bitwise OR, поскольку он сливает два набора битов в конечный результат.

shifted even_bits OR shifted odd_bits = result

Теперь биты с нечётных позиций находятся на чётных, и наоборот.

Реализация

python
def swap_odd_and_even_bits(n: int) -> int:
    # Mask to extract even bits (positions 0, 2, 4, ...): 0x55555555 = 01010101...
    even_mask = 0x55555555
    # Mask to extract odd bits (positions 1, 3, 5, ...): 0xAAAAAAAA = 10101010...
    odd_mask = 0xAAAAAAAA
    # Extract even bits and shift left, extract odd bits and shift right
    even_bits = (n & even_mask) << 1
    odd_bits = (n & odd_mask) >> 1
    # Merge the two
    return even_bits | odd_bits

Анализ сложности

Временная сложность: временная сложность swap_odd_and_even_bits составляет O(1).

Пространственная сложность: пространственная сложность составляет O(1).

Глава 19

Math and Geometry

~19 мин чтения

Введение в Math and Geometry

Введение в Math and Geometry

Интуиция

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

Мы изучаем такие темы, как:

  • Наибольший общий делитель (GCD).
  • Модульная арифметика.
  • Работа с точностью чисел с плавающей запятой.
  • Обработка переполнения и антипереполнения целых чисел.
  • Распознавание паттернов.

Пример из реального мира

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

Содержание главы

Hierarchical diagram of Math and Geometry chapter: Math (Reverse 32-bit Integer, The Josephus Problem, Triangle Numbers) and Geometry (Spiral Traversal, Maximum Collinear Points).

Спиральный обход

Спиральный обход

Верните элементы matrix в порядке обхода по спирали по часовой стрелке.

Пример:

5x4 grid showing spiral traversal path with numbers 0-19.
python
Output: [0, 1, 2, 3, 4, 9, 14, 19, 18, 17, 16, 15, 10, 5, 6, 7, 8, 13, 12, 11]

Интуиция

Чтобы получить ожидаемый результат, попробуем смоделировать именно то, что описывает задача: обойти matrix по спирали, добавляя каждое значение в результат по мере продвижения. Как это сделать?

Спиральный обход предполагает движение по matrix в одном направлении до упора, затем смену направления и продолжение движения. Конкретно последовательность направлений такова: вправо, вниз, влево и вверх — и так повторяется до тех пор, пока не будут пройдены все элементы. Для этого нужно определить точные условия смены направления.

На первый взгляд подход кажется простым: начинаем движение вправо, пока не достигнем крайнего правого столбца matrix, после чего меняем направление. Можно совершить три таких перехода без каких-либо проблем:

Three 4x4 matrices showing rightward, downward, and leftward traversals.

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

4x5 grid showing revisited cell at (0,0) when moving up.

Одно из возможных решений — отслеживать все посещённые ячейки с помощью хеш-множества. Это позволяет остановить движение в текущем направлении при обнаружении уже посещённой ячейки. Подход эффективен, однако требует O(m*n) памяти. Можно ли избежать повторного посещения ячеек без использования дополнительной структуры данных?

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

Что если динамически корректировать эти границы по мере обхода matrix, чтобы избежать повторного посещения ячеек?

Корректировка границ Инициализируем четыре границы (top, bottom, left, right) их начальными значениями:

  • top = 0
  • bottom = m - 1
  • left = 0
  • right = n - 1
4x5 matrix with left, right, top, bottom boundary labels.

Начинаем обход, двигаясь вправо по первой строке от левой границы до правой. Поскольку все ячейки первой строки посещены, необходимо заблокировать к ней доступ. Для этого сдвигаем верхнюю границу вниз на 1 (top += 1), тем самым исключая верхнюю строку из дальнейшего обхода:

Before and after: top boundary moves down after traversing first row.

Затем двигаемся вниз от верхней границы до нижней. Чтобы исключить этот столбец из повторного посещения, обновляем правую границу (right -= 1):

Before and after: right boundary moves left after traversing rightmost column.

Затем двигаемся влево от правой границы до левой. Чтобы исключить эту строку из повторного посещения, обновляем нижнюю границу (bottom -= 1):

Before and after: bottom boundary moves up after traversing bottom row.

Затем двигаемся вверх от нижней границы до верхней. Чтобы исключить этот столбец из повторного посещения, обновляем левую границу (left += 1):

Before and after: left boundary moves right after traversing leftmost column.

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

Подводя итог, обход matrix по спирали осуществляется путём повторения следующей последовательности:

  1. Движение слева направо вдоль верхней границы, затем обновление верхней границы (top += 1)
  2. Движение сверху вниз вдоль правой границы, затем обновление правой границы (right -= 1)
  3. Движение справа налево вдоль нижней границы, затем обновление нижней границы (bottom -= 1)
  4. Движение снизу вверх вдоль левой границы, затем обновление левой границы (left += 1)

Это продолжается при условии top <= bottom и left <= right.

Важный момент: после обновления верхней границы она может превысить нижнюю (top > bottom). Поэтому перед обходом по нижней границе необходимо проверить условие top <= bottom. Аналогично, перед обходом по левой границе нужно проверить left <= right, чтобы убедиться, что границы не пересеклись.

По мере движения по matrix каждое встречаемое значение добавляется в выходной массив. Таким образом, значения matrix записываются в спиральном порядке.

Реализация

python
from typing import List
    
def spiral_matrix(matrix: List[List[int]]) -> List[int]:
    if not matrix:
        return []
    result = []
    # Initialize the matrix boundaries.
    top, bottom = 0, len(matrix) - 1
    left, right = 0, len(matrix[0]) - 1
    # Traverse the matrix in spiral order.
    while top <= bottom and left <= right:
        # Move from left to right along the top boundary.
        for i in range(left, right + 1):
            result.append(matrix[top][i])
        top += 1
        # Move from top to bottom along the right boundary.
        for i in range(top, bottom + 1):
            result.append(matrix[i][right])
        right -= 1
        # Check that the bottom boundary hasn't passed the top boundary before
        # moving from right to left along the bottom boundary.
        if top <= bottom:
            for i in range(right, left - 1, -1):
                result.append(matrix[bottom][i])
            bottom -= 1
        # Check that the left boundary hasn't passed the right boundary before
        # moving from bottom to top along the left boundary.
        if left <= right:
            for i in range(bottom, top - 1, -1):
                result.append(matrix[i][left])
            left += 1
    return result

Анализ сложности

Временная сложность: временная сложность spiral_matrix составляет O(m*n), поскольку каждая ячейка matrix обходится ровно один раз.

Пространственная сложность: пространственная сложность равна O(1). Массив res не учитывается при оценке пространственной сложности.

Разворот 32-битного целого числа

Разворот 32-битного целого числа

Разверните цифры знакового 32-битного целого числа. Если развёрнутое число выходит за пределы диапазона [-2^31, 2^31 - 1], верните 0. Предполагается, что среда исполнения позволяет хранить только целые числа в диапазоне знакового 32-битного целого.

Пример 1:

python
Input: n = 420
Output: 24

Пример 2:

python
Input: n = -15
Output: -51

Интуиция

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

Разворот положительных чисел Рассмотрим n = 123. Попробуем строить развёрнутое целое число по одной цифре за раз. Первое, что нужно выяснить, — как перебирать цифры числа n для построения развёрнутого числа (изначально равного 0):

n = 123, reversed_n = 0

Один из способов — начать с последней цифры числа n и последовательно добавлять каждую цифру к reversed_n:

Step-by-step: m=123->m=12->m=1, reversed_n=3->reversed_n=32->reversed_n=321

Разберём, как это реализовать. Для извлечения последней цифры можно использовать оператор остатка от деления: n % 10. Эта операция находит остаток от деления n на 10:

n = 123, digit = n % 10 = 3

После извлечения последней цифры можно удалить её, разделив n на 10. Это сдвигает предпоследнюю цифру на последнюю позицию, подготавливая её к следующей итерации:

n = n // 10 = 12

После этого добавим извлечённую последнюю цифру к нашему развёрнутому числу:

reversed_n += digit (digit = 3)

Для обработки следующей цифры извлечём её из n с помощью операции остатка, затем удалим её делением n на 10:

digit = n % 10 = 2, n = n // 10 = 1

Чтобы добавить эту цифру к reversed_n, умножим reversed_n на 10 — это сдвинет его цифры влево, освобождая место для новой цифры. Затем добавим новую цифру как прежде:

reversed_n = 10 * reversed_n + digit = 30 + 2 = 32

Описанный процесс повторяется до тех пор, пока все цифры n не будут добавлены в reversed_n (то есть пока n не станет равным 0). Вот разбивка этого алгоритма:

  1. Извлечь последнюю цифру: digit = n % 10.
  2. Удалить последнюю цифру: n = n // 10.
  3. Добавить цифру: reversed_n = reversed_n * 10 + digit.

Разворот отрицательных чисел Прежде чем рассматривать отдельную стратегию для отрицательных чисел, проверим, работает ли описанный выше алгоритм для них. Применим эти шаги к n = -15:

n=-15: digit = n%10 = -5, n = n//10 = -1, reversed_n = -5
n=-1: digit = -1, n = 0, reversed_n = -51

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

Обнаружение переполнения Если разворот положительного числа даёт результат, превышающий 2^31 - 1, произойдёт переполнение и следует вернуть 0. Назовём это максимальное значение INT_MAX.

INT_MAX = 2^31 - 1 = 2147483647

На первый взгляд может показаться достаточным полностью развернуть число, проверить, не превышает ли оно 2^31 - 1, и вернуть 0 если превышает. Однако в среде, где нельзя хранить числа больше 2^31 - 1, попытка развернуть такое целое число уже вызовет переполнение:

2199999999 reversed = 9999999912 > INT_MAX -> overflow

Нужен другой способ обнаружения переполнения.

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

Если reversed_n равно 214748364 (то есть INT_MAX // 10), то последняя добавляемая цифра должна быть не больше 7, иначе произойдёт переполнение (поскольку 2147483647 == INT_MAX):

reversed_n=214748364, digit=7: result=2147483647=INT_MAX, no overflow
reversed_n=214748364, digit=8: result=2147483648 > INT_MAX -> overflow

Важно учесть: когда reversed_n == INT_MAX // 10, к нему можно добавить ещё только одну цифру. Ключевое наблюдение состоит в том, что эта цифра может быть только 1, поскольку большая последняя цифра была бы невозможна:

reversed_n=2147483641 implies n=1463847412 (valid); reversed_n=2147483642 would imply impossible input

Это означает, что когда reversed_n == INT_MAX // 10, последняя добавляемая к нему цифра не вызовет переполнения, и проверять её в этом случае не нужно.

Если reversed_n уже больше INT_MAX // 10, добавление любой цифры вызовет переполнение. Этот случай обрабатывается следующим условием:

if reversed_n > INT_MAX // 10: return 0

Обнаружение антипереполнения Аналогичную логику можно применить для обработки антипереполнения. Здесь достаточно проверить, что reversed_n не опускается ниже INT_MIN // 10:

if reversed_n < INT_MIN // 10: return 0

Реализация

В Python оператор остатка (%) при работе с отрицательными числами возвращает положительный результат. Чтобы избежать этого, можно использовать math.fmod и привести результат к целому числу для получения отрицательного остатка.

При делении оператор // в Python выполняет деление с округлением вниз, что может давать нежелательный результат при работе с отрицательными числами (например, -15 // 10 равно -2 вместо желаемого -1). Чтобы получить нужное поведение, используйте / для деления и приводите результат к целому числу.

python
import math
    
def reverse_32_bit_integer(n: int) -> int:
    INT_MAX = 2**31 - 1
    INT_MIN = -2**31
    reversed_n = 0
    # Keep looping until we've added all digits of 'n' to 'reversed_n' in reverse
    # order.
    while n != 0:
        # digit = n % 10
        digit = int(math.fmod(n, 10))
        # n = n // 10
        n = int(n / 10)
        # Check for integer overflow or underflow.
        if reversed_n > int(INT_MAX / 10) or reversed_n < int(INT_MIN / 10):
            return 0
        # Add the current digit to 'reversed_n'.
        reversed_n = reversed_n * 10 + digit
    return reversed_n

Анализ сложности

Временная сложность: временная сложность reverse_32_bit_integer составляет O(log(n)), поскольку мы перебираем каждую цифру числа n, которых приблизительно log(n) штук. Так как данная среда поддерживает только 32-битные целые числа, временную сложность также можно считать O(1).

Пространственная сложность: пространственная сложность равна O(1).

Максимальное количество коллинеарных точек

Максимальное количество коллинеарных точек

Дано множество точек на двумерной плоскости. Найдите максимальное количество точек, лежащих на одной прямой.

Пример:

Coordinate system with points (1,1),(1,3),(2,2),(3,1),(3,3),(4,4); line through (1,1),(2,2),(3,3),(4,4) highlighted.
python
Input: points = [[1, 1], [1, 3], [2, 2], [3, 1], [3, 3], [4, 4]]
Output: 4

Ограничения:

  • Входные данные не содержат дублирующихся точек.

Интуиция

Две или более точек являются коллинеарными, если они лежат на одной прямой. Иными словами, наклон между любой парой таких точек одинаков:

Coordinate system showing 4 collinear points with slope=1.

Вспомним, как вычисляется наклон прямой по двум точкам (xa, ya) и (xb, yb):

slope = rise / run = (yb - ya) / (xb - xa)

По этой формуле можно вычислить наклон для всех пар точек из входных данных и найти наибольшее количество пар с одинаковым значением наклона. Однако этот подход ошибочен: две пары точек с одинаковым значением наклона могут не быть коллинеарными:

Two separate line segments both with slope=1 but not collinear.

Рассмотрим другой подход. Что если попробовать найти максимальное количество точек, коллинеарных с конкретной точкой? Назовём такую точку фокусной.

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

Focal point (2,2), slope_map: {1: 3, -1: 2}

Это позволяет найти максимальное количество точек, коллинеарных с данной фокусной точкой:

Max frequency in slope_map is 3, so max collinear points = 3+1 = 4.

Здесь наклон с наибольшей частотой встречается 3 раза, что означает: количество точек на прямой, определяемой этим наклоном, равно 3 + 1 = 4, где +1 учитывает саму фокусную точку.

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

Граничный случай: две точки на одной вертикальной прямой Когда две коллинеарные точки имеют одинаковое значение координаты x, знаменатель формулы наклона равен нулю. Это проблематично, поскольку деление на ноль не определено:

Vertical line through (2,1) and (2,4): slope = 3/0 = undefined.

Для решения этой проблемы можно проверять, равно ли значение run нулю. Если да, используем бесконечность (float('inf')) для представления значения такого наклона.

Избежание ошибок точности Важный момент — точность представления наклонов при хранении их в виде чисел с плавающей запятой. Рассмотрим следующие наклоны:

499999999/500000000 and 499999998/499999999 both round to 0.999999998 in float but are not equal.

Как видим, несмотря на то что дроби представляют разные наклоны, представление их в виде числа с плавающей запятой не обеспечивает достаточной точности для их различения. Это может привести к ошибочному отождествлению различных наклонов.

Чтобы избежать этого, можно представлять наклон в виде пары целых чисел, хранимой как кортеж: (rise, run). Например, наклон с rise равным 1 и run равным 2 можно представить как (1, 2) вместо 1 / 2 = 0.5.

Обеспечение согласованного представления наклонов Осталась ещё одна задача: обеспечить согласованность представления наклона для всех эквивалентных дробей:

Fractions 1/2, 2/4, 13/26 all represent the same value.

Если сводить дроби к несократимому виду, можно единообразно представлять равные дроби, имеющие разные исходные записи.

1/2, 2/4, 13/26 all reduce to 1/2.

Как это сделать? Можно сократить дроби, разделив числитель и знаменатель на их наибольший общий делитель (GCD).

Наибольший общий делитель GCD двух чисел — это наибольшее число, на которое оба числа делятся без остатка. Разделив rise и run на их GCD, мы приводим наклон к несократимому виду:

(rise, run) = (13, 26) / gcd(13,26)=13 -> (1, 2)

Это гарантирует единообразное представление всех равных дробей.

Реализация

python
from typing import List, Tuple
from collections import defaultdict
    
def maximum_collinear_points(points: List[List[int]]) -> int:
    res = 0
    # Treat each point as a focal point, and determine the maximum number of points
    # that are collinear with each focal point. The largest of these maximums is the
    # answer.
    for i in range(len(points)):
        res = max(res, max_points_from_focal_point(i, points))
    return res
    
def max_points_from_focal_point(focal_point_index: int, points: List[List[int]]) -> int:
    slopes_map = defaultdict(int)
    max_points = 0
    # For the current focal point, calculate the slope between it and every other
    # point. This allows us to group points that share the same slope.
    for j in range(len(points)):
        if j != focal_point_index:
            curr_slope = get_slope(points[focal_point_index], points[j])
            slopes_map[curr_slope] += 1
            # Update the maximum count of collinear points for the current focal
            # point.
            max_points = max(max_points, slopes_map[curr_slope])
    # Add 1 to max_points to account for the focal point itself.
    return max_points + 1
    
def get_slope(point_a: List[int], point_b: List[int]) -> Tuple[int, int]:
    rise = point_b[1] - point_a[1]
    run = point_b[0] - point_a[0]
    # Handle vertical lines.
    if run == 0:
        return (1, 0)
    # Reduce the slope fraction to its simplest form.
    common_divisor = gcd(abs(rise), abs(run))
    # Normalize the sign of the slope.
    sign = -1 if (rise < 0) != (run < 0) else 1
    return (sign * abs(rise) // common_divisor, abs(run) // common_divisor)
    
def gcd(a: int, b: int) -> int:
    while b != 0:
        a, b = b, a % b
    return a

Хотя некоторые языки программирования, в том числе Python, имеют встроенную реализацию функции GCD, ниже приведена её реализация для справки. Этот алгоритм широко известен как алгоритм Евклида:

python
# The Euclidean algorithm.
def gcd(a, b):
    while b != 0:
        a, b = b, a % b
    return a

Анализ сложности

Временная сложность: временная сложность maximum_collinear_points составляет O(n^2 * log(m)), где n — количество точек, m — наибольшее значение среди координат.

  • Временная сложность функции gcd(rise, run) составляет O(log(min(rise, run))), что приблизительно равно O(log(m)) в худшем случае.
  • Вспомогательная функция max_points_from_focal_point вызывает функцию gcd в общей сложности n-1 раз: по одному разу для каждой точки, кроме фокусной, что даёт временную сложность O(n log(m)).
  • Функция max_points_from_focal_point вызывается в общей сложности n раз, что даёт итоговую временную сложность O(n^2 log(m)).

Пространственная сложность: пространственная сложность составляет O(n) из-за хеш-таблицы, которая в худшем случае хранит n-1 пар «ключ — значение»: по одной для каждой точки, кроме фокусной.

Задача Иосифа

Задача Иосифа

В круге стоят n человек, пронумерованных от 0 до n - 1. Начиная с человека 0, считайте k человек по часовой стрелке и удалите k-го из них. После удаления начинайте отсчёт заново со следующего оставшегося в круге человека. Повторяйте этот процесс до тех пор, пока не останется один человек, и верните его номер.

Пример:

Step-by-step: 5 people in circle, k=2, eliminating every 2nd person until only person 2 remains.
python
Input: n = 5, k = 2
Output: 2

Ограничения:

  • В круге будет как минимум один человек
  • k будет не менее 1.

Интуиция

Наивный подход к решению этой задачи — пошаговая симуляция удаления людей. Можно создать кольцевой связный список из n узлов. Начиная с узла 0, перебираем список, удаляя каждого k-го человека. Последний оставшийся узел после всех удалений представляет последнего выжившего.

Такой подход имеет временную сложность O(n*k), поскольку для удаления узла необходимо пройти k узлов в связном списке. Следовательно, для каждого из n узлов выполняется k итераций. Посмотрим, можно ли найти более быстрое решение.

Рассмотрим пример с n = 12 и k = 4. При первом удалении начинаем отсчёт k узлов от узла 0 и удаляем человека, на котором остановились после подсчёта.

12 people in circle, start at 0, k=4, remove person 3.
11 people remaining, new start at person 4.

Как видим, после первого удаления в круге остался на одного человека меньше. Кроме того, после удаления новая стартовая позиция находится на k-й позиции (человек 4).

Теперь фактически нужно найти последнего оставшегося в круге из n - 1 человек, начиная отсчёт с человека k. Это говорит о том, что решение подзадачи josephus(n - 1, k) поможет получить ответ на задачу josephus(n, k). Обратите внимание, что ответ на подзадачу josephus(i, k) — это номер последнего выжившего в круге из i человек при начале отсчёта с человека 0.

Чтобы учесть сдвинутую стартовую позицию, нужно прибавить k к ответу, возвращённому josephus(n - 1, k). Это необходимо, поскольку в данной подзадаче начало отсчёта не с позиции 4, а с позиции 0 по умолчанию. Таким образом, прибавление k компенсирует разницу в начальной позиции.

Это можно выразить следующим рекуррентным соотношением:

josephus(n, k) = josephus(n - 1, k) + k

Последнее соображение — убедиться, что значение josephus(n - 1, k) + k не превышает n - 1, поскольку это представляет позицию последнего человека. Это достигается применением оператора остатка (% n) к выражению josephus(n - 1, k) + k. В результате получается следующее обновлённое рекуррентное соотношение:

josephus(n, k) = (josephus(n - 1, k) + k) % n

Теперь осталось только определить базовый случай.

Базовый случай Простейший вариант задачи — когда в круге остался только один человек: n = 1. В этом случае последний выживший — человек 0, поэтому для базового случая просто возвращаем 0.

Реализация

python
def josephus(n: int, k: int) -> int:
    # Base case: If there's only one person, the last person is person 0.
    if n == 1:
        return 0
    # Calculate the position of the last person remaining in the reduced problem
    # with 'n - 1' people. We use modulo 'n' to ensure the answer doesn't exceed
    # 'n - 1'.
    return (josephus(n - 1, k) + k) % n

Анализ сложности

Временная сложность: временная сложность josephus составляет O(n), поскольку выполняется в общей сложности n рекурсивных вызовов функции до достижения базового случая.

Пространственная сложность: пространственная сложность составляет O(n) из-за стека рекурсивных вызовов, глубина которого достигает n.

Оптимизация

Рекурсивное решение «сверху вниз», описанное выше, можно реализовать итеративным подходом «снизу вверх». Пусть res — массив, хранящий решение каждой подзадачи, где res[i] содержит решение подзадачи для круга из i человек. Используя этот массив, формула принимает вид:

res[i] = (res[i - 1] + k) % i

Ключевое наблюдение: для вычисления результата текущей подзадачи (с индексом i) нам нужен только предыдущий элемент массива res (с индексом i - 1). Это означает, что хранить весь массив не нужно.

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

res = (res + k) % i

Обратите внимание, что значение res в правой части уравнения представляет результат предыдущей подзадачи.

Реализация

python
def josephus_optimized(n: int, k: int) -> int:
    res = 0
    for i in range(2, n + 1):
        # res[i] = (res[i - 1] + k) % i.
        res = (res + k) % i
    return res

Анализ сложности

Временная сложность: временная сложность josephus_optimized составляет O(n), поскольку выполняется перебор n подзадач.

Пространственная сложность: пространственная сложность равна O(1).

Треугольные числа

Треугольные числа

Рассмотрим треугольник, составленный из чисел, вершина которого равна 1. Каждое последующее число в треугольнике равно сумме трёх чисел над ним: числа сверху слева, числа сверху и числа сверху справа. Если какое-либо из этих трёх чисел отсутствует, считается, что оно равно 0.

Дано число, соответствующее строке треугольника. Верните позицию первого чётного числа в этой строке. Считается, что первое число в каждой строке находится на позиции 1.

Пример:

Triangle with 4 rows: row 4 = [1,3,6,7,6,3,1]; first even (6) is at position 3.
python
Input: n = 4
Output: 3

Ограничения:

  • n будет не менее 3.

Интуиция

Наивное решение — сгенерировать весь треугольник со всеми значениями до строки n включительно. Затем перебирать элементы строки n до нахождения первого чётного числа. Однако этот подход неэффективен: построение всего треугольника требует избыточных затрат времени и памяти. Для поиска более оптимального решения рассмотрим, как упростить представление нашего треугольника.

Упрощение треугольника Первое ключевое наблюдение: треугольник симметричен. Это означает, что правую половину треугольника можно не рассматривать: если чётное число существует в правой половине, оно обязательно существует и в левой:

Symmetric triangle with vertical lines showing left half is sufficient.

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

5x5 matrix: rows 1-5 aligned by position showing values.

Следующее ключевое наблюдение: нас не интересуют сами значения — нас интересует только чётность каждого числа (то есть является ли оно чётным или нечётным). Исходя из этого, можно ещё больше упростить треугольник, представив его как бинарный, где 0 — чётное число, а 1 — нечётное:

5x5 binary matrix: 1=odd, 0=even (peach circles).

Теперь, когда треугольник упрощён, проще выявить паттерны в позициях первого чётного числа в каждой строке.

Выявление паттернов Пропустим строки 1 и 2, поскольку чётные числа начинают появляться только с 3-й строки. Хорошим отправным пунктом для поиска паттерна является выделение первого чётного числа в каждой строке и наблюдение за их позициями:

Rows 3-5 showing first even at positions: row3=2, row4=3, row5=2.

Анализируя строки с 3 по 5, можно заметить, что в нечётных строках первое чётное число находится на позиции 2. Можно также предположить, что в чётных строках первое чётное число находится на позиции 3.

Чтобы проверить это наблюдение, рассмотрим ещё несколько строк:

7x7 matrix: odd rows have first even at pos 2, even rows vary.

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

Добавим ещё несколько строк, чтобы разобраться в этом паттерне:

10x10 matrix: rows 3-6 pattern repeats for rows 7-10.

Теперь видно, что первые четыре бинарных значения строк с 3 по 6 повторяются в строках с 7 по 10. Если продолжить, обнаружится, что этот паттерн продолжается.

По существу, начиная со строки 3, последовательно повторяется следующий паттерн:

4x4 repeating unit (rows 3-6): first-even positions are 2, 3, 2, 4.

Чтобы понять, почему этот паттерн повторяется, важно осознать, что первые четыре значения строки вычисляются исключительно на основе четырёх значений предыдущей строки. Это можно наглядно увидеть ниже, используя исходное представление треугольника для большей ясности:

Four arrays showing how 1,2,1 pattern generates predictable next row.

Таким образом, когда в начале строки встречается определённая последовательность из четырёх чисел, она порождает предсказуемую последовательность из четырёх чисел в следующей строке. Распространяя это наблюдение на весь паттерн, можно заключить, что поскольку паттерн уже повторился один раз (со строк 3–6 к строкам 7–10), он будет повторяться бесконечно.

Это даёт следующее циклическое правило:

  • Если n нечётное (n % 2 != 0), вернуть 2.
  • Если n кратно 4 (n % 4 == 0), вернуть 3.
  • В остальных случаях вернуть 4.
Three matrices: odd rows->pos2, multiples of 4->pos3, other rows->pos4.

Эта задача демонстрирует, как распознавание паттернов и упрощение задачи может превратить затратное по времени решение в быстрое решение за константное время.

Реализация

python
def triangle_numbers(n: int) -> int:
    # If n is an odd-numbered row, the first even number always starts at position
    # 2.
    if n % 2 != 0:
        return 2
    # If n is a multiple of 4, the first even number always starts at position 3.
    elif n % 4 == 0:
        return 3
    # For all other rows, the first even number always starts at position 4.
    return 4

Анализ сложности

Временная сложность: временная сложность triangle_numbers составляет O(1).

Пространственная сложность: пространственная сложность равна O(1).