Розв’язуємо задачі на LeetCode. Припущення та оптимізація рішень
Усім привіт, мене звати Олексій Михняк, я Software Developer. У цій статті хочу розглянути та разом з читачами розібрати, як можна розв’язувати типові задачі на LeetCode. Беремось за задачу Intersection of Multiple Arrays:
Given a 2D integer array nums where nums[i] is a non-empty array of distinct positive integers, return the list of integers that are present in each array of nums sorted in ascending order.
Example 1:
Input: nums = [[3,1,2,4,5],[1,2,3,4],[3,4,5,6]] Output: [3,4] Explanation: The only integers present in each of nums[0] = [3,1,2,4,5], nums[1] = [1,2,3,4], and nums[2] = [3,4,5,6] are 3 and 4, so we return [3,4].
Example 2:
Input: nums = [[1,2,3],[4,5,6]] Output: [] Explanation: There does not exist any integer present both in nums[0] and nums[1], so we return an empty list [].
Constraints:
— 1 <= nums.length <= 1000
— 1 <= sum(nums[i].length) <= 1000
— 1 <= nums[i][j] <= 1000
— All the values of nums[i] are unique.
1. Перші припущення
Якщо взагалі не знайомі з підходами до розв’язання задач, то можна спробувати використати Brute Force алгоритм, тобто повний перебір всіх можливих варіантів. Це може бути дуже просто, але він не завжди є оптимальним (таким чином можна розв’язати більшість простих та частину середніх задач).
public class Solution {
public IList<int> Intersection(int[][] nums) {
var result = new List<int>();
// Вибираємо за еталонний перший масив, але можна було б вибрати будь-який інший.
// Можна спробувати вибрати масив з найменшою кількістю елементів,
// але в найгіршому випадку всі масиви будуть мати рівну кількість елементів.
var etalon = nums[0];
// Перебираємо всі елементи еталонного масиву
// Якщо в нас існує багато вкладених циклів, то краще винести їх в окремі методи, оскільки не можемо ефективно використовувати break/continue/return statements
foreach(var n in etalon)
{
// Перевіряємо чи елемент існує в кожному масиві
if(ContainsInAll(nums, n))
{
result.Add(n);
}
}
// Сортуємо та повертаємо результат
result.Sort();
return result;
}
// Цей метод повертає true, якщо масив масивів array[][] містить елемент n
private bool ContainsInAll(int[][] array, int n)
{
// Перебираємо всі масиви
foreach(var a in array)
{
// Якщо ми не знайшли елемент n в поточному масиві, то повертаємо false
if(!a.Contains(n))
{
return false;
}
}
// Якщо ми перебрали всі масиви і в кожному з них знайшли елемент n, то повертаємо true
return true;
}
}
Результат за посиланням на LeetCode.

Аналіз
- Часова складність:
O(M×N) + O(K×log(K)), деM— кількість елементів в масиві з мінімальною довжиною,N— загальна кількість всіх елементів в усіх масивах,K— кількість елементів, які містяться в кожному масиві. Оскільки час сортуванняO(K×log(K))менший за час перебору елементівO(M×N), то в кінцевому результаті ми отримуємоO(M×N). - Пам’яткова складність:
O(K), якщо ми не враховуємо вхідні дані, аK— кількість елементів, які містяться в кожному масиві.
2. Оптимізація
Вже відчули задоволення від того, що задачу прийняли і починаємо думати, як можна поліпшити наше рішення. Один з підходів, коли потрібно перевірити на унікальність чи кількість елементів в масиві — це використовувати Counter, тобто лічильник. Оскільки маємо умову що всі елементи унікальні
All the values of nums[i] are unique
то можемо порахувати кількість кожного елементу в кожному масиві і використовувати цю інформацію для вирішення задачі.
public class Solution {
public IList<int> Intersection(int[][] nums) {
var result = new List<int>();
// в C# є Dictionary, який реалізовує структуру даних Map (хеш-таблиця)
var counter = new Dictionary<int, int>();
// проходимось мо всім значенням в усіх масивах
foreach(var array in nums)
{
foreach(var n in array)
{
// C# повертає KeyNotFoundException помилку, якщо намагаємось отримати ключ, якого немає в хеш-таблиці
// в інших мовах програмування цей запис може виглядати як:
// counter[n] = counter[n] + 1; or counter[n] += 1; or counter[n]++;
counter[n] = counter.GetValueOrDefault(n, 0) + 1;
}
}
foreach(var pair in counter)
{
if(pair.Value == nums.Length)
{
result.Add(pair.Key);
}
}
// Сортуємо, оскільки хеш-таблиця не гарантує результат у відсортованому виді та повертаємо результат
result.Sort();
return result;
}
}
Результат за посиланням на LeetCode.

Аналіз
- Часова складність:
O(N) + O(K×log(K)) -> O(N×N) + O(K×log(K)), деN— кількість елементів у всіх масивах,K— кількість елементів, які містяться в кожному масиві. Час вставки елемента в хеш-таблицюO(1), але в найгіршому випадку може бутиO(N), тому час за який ми проходимо всі елементи може коливатися відO(N)доO(N×N). - Пам’яткова складність:
O(L) + O(K), деL— кількість унікальних елементів у всіх масивах,K— кількість елементів, які містяться в кожному масиві. Якщо взяти найгірший випадок, коли всі елементи унікальні, тоL = N, та в нас всього один масив,K = N, тоді пам’яткова складність будеO(N) + O(N) = 2×O(N) = O(N). деN— кількість елементів у всіх масивах.
2. Оптимізуємо далі
Ми вже змогли оптимізувати часову складність (для найкращого результату) з квадратної залежності в лінійну, але збільшили пам’яткову складність. Завжди потрібно уважно передивлятися початкові умови задачі, вони можуть підказати, як можна змінити алгоритм:
—1 <= nums.length <= 1000
—1 <= sum(nums[i].length) <= 1000
—1 <= nums[i][j] <= 1000
Оскільки ми маємо обмеження на кількість елементів (1000 — це дуже мале значення, яке можна зберігати в пам’яті), та обмеження на значення самих елементів, особливо зверніть увагу що всі значення > 0, зазвичай це свідчить, що їх можна використовувати як індекси масиву. Тому ми можемо використовувати масив замість хеш-таблиці для зберігання кількості кожного елемента, а потім пройтися по ньому і знайти елементи, які містяться у всіх масивах.
public class Solution {
public IList<int> Intersection(int[][] nums) {
var result = new List<int>();
// в C# масиви заповнюються нулями, тому нам не потрібно про це турбуватися
var counter = new int[1000]; // 1 <= nums[i][j] <= 1000
for (int i = 0; i < nums.Length; i++)
{
for (int j = 0; j < nums[i].Length; j++)
{
var value = nums[i][j];
var index = value - 1; // щоб не зберігати нульовий пустий елемент, перетворюємо значення в індекс
counter[index] = counter[index] + 1;
}
}
for (int i = 0; i < counter.Length; i++)
{
if (counter[i] == nums.Length)
{
result.Add(i + 1); // перетворюємо індекс назад в значення
// оскільки ми проходимо всі значення від 0 до 1000 то вони будуть додані у відсортованому порядку
}
}
return result;
}
}
Результат за посиланням на LeetCode.

Аналіз
- Часова складність:
O(N) + O(1000) = O(N) + O(1) = O(N), деN— кількість елементів у всіх масивах. - Пам’яткова складність:
O(1000) = O(1), оскільки ми завжди будемо мати масив з фіксованою кількістю елементів, оскільки ми залежимо не від довжини масивів, а від обмеження на значення елементів.
Це типове рішення з використанням лічильника, зазвичай використовуються в задачах на пошук унікального елементу або символів в рядку. Лайфхак: якщо використовуються тільки латинські літери, то як лічильник можна використовувати Int32 або ж Int64 за необхідності та працювати з бітовими масками — це дозволить максимально оптимізувати пам’яткову складність.
Що робити якщо в кожному масиві можуть бути дублікати
Знаючи існуюче рішення, можна умову підігнати під нього, тобто типовою є ситуація, коли перед вирішенням задачі відбувається «очистка» даних за O(N). Знаходимо дублікати за допомогою хеш-таблиці або ж окремого лічильника, і якщо хочемо економити пам’ять, то змінюємо вхідні дані, наприклад, замінюючи дублікати на 0, і потім не враховуємо 0 в рішенні.
Якщо ж значення елементів не будуть обмежені, то нічого не залишається як використати хеш-таблицю як лічильник.
Кому було цікаво, пишіть в коментарі, та якщо буде попит на подібні тексти, то можу розібрати ще якісь задачки з LeetCode. Кому щось не зрозуміло — теж пишіть в коментарі, буду радий допомогти.
Ще пишу технічні новини у своєму Telegram-каналі, кому цікаво — підписуйтесь. Дякую за увагу!

16 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів