1.4. Демонстрация - Примеры алгоритмически переусложнённого кода
Шаг 1
Такую простую с виду задачу на самом деле можно решить десятком способов, при этом на ум сначала приходят совершенно неоптимальные решения. Перед тем, как переходить к следующим шагам, попробуйте прийти к самому оптимальному из них.
import {test} from "./tester";
/*
Задача: найти, сколько раз встречается самый частый элемент в объединении двух отсортированных по возрастанию массивов. Элементы могут повторяться.
Примеры:
countMostFrequent([1, 2, 2, 3], [0, 2, 4, 4]) => 3
countMostFrequent([], [0, 0]) => 2
*/
function countMostFrequent(firstArray, secondArray) {
return 0;
}
test(countMostFrequent([1, 2, 2, 3], [0, 2, 4, 4]), 3);
test(countMostFrequent([], [0, 0]), 2)
Шаг 2
Самое наивное решение — просто объединить два массива в один и посчитать частотность каждого элемента. А потом найти из получившегося объекта максимальное значение. Такое решение на самом деле не самое плохое: если оценивать его сложность, то мы получим O(n + m). Не пугайтесь новых переменных в оценке. Раз уж у нас два набора входных данных и оба они добавляют времени выполнения нашему решению, то и в оценке должны стоять они оба. Проблема этого решения одна: мы используем дополнительную память (для объединения массивов и объекта-счётчика. Оценивать дополнительную память тоже можно с помощью О-нотации. Здесь сложность по памяти равна сложности по времени — тоже O(n + m).
import {test} from "./tester";
/*
Задача: найти, сколько раз встречается самый частый элемент в объединении двух отсортированных по возрастанию массивов. Элементы могут повторяться.
Примеры:
countMostFrequent([1, 2, 2, 3], [0, 2, 4, 4]) => 3
countMostFrequent([], [0, 0]) => 2
*/
function countMostFrequent(firstArray, secondArray) {
const union = [...firstArray, ...secondArray];
const counter = {};
for (const number of union) {
counter[number] = counter[number] ? counter[number] + 1 : 1;
}
let maxFrequency = 0;
for (const frequency of Object.values(counter)) {
if (frequency > maxFrequency) {
maxFrequency = frequency;
}
}
return maxFrequency;
}
test(countMostFrequent([1, 2, 2, 3], [0, 2, 4, 4]), 3);
test(countMostFrequent([], [0, 0]), 2)
Шаг 3
А вот отличный пример того, как в современном фронтенде любят решать большинство задач, включающих в себя работу с массивами — слишком активно используя встроенные методы filter/map/reduce. Код действительно стал меньше и для кого-то читабельнее. Если оценивать его с точки зрения алгоритмической сложности, то мы имеем дело с reduce, который последовательно будет проходиться по каждому из элементов массива, из них вызывая filter. Reduce сделает n итераций, а в каждой из них filter также сделает n операций (для фильтрации массива нужно пробежаться по всем его элементам и сравнить с тем, что мы фильтруем). В итоге имеем сложность O((n + m)²), которая значительно хуже предыдущего решения. Подобные решения очень часто можно встретить в повседневных задачах, и именно подобный легкочитаемый, но медленный код приводит к постоянному подтормаживанию в сложных кастомных списках.
import {test} from "./tester";
/*
Задача: найти, сколько раз встречается самый частый элемент в объединении двух отсортированных по возрастанию массивов. Элементы могут повторяться.
Примеры:
countMostFrequent([1, 2, 2, 3], [0, 2, 4, 4]) => 3
countMostFrequent([], [0, 0]) => 2
*/
function countMostFrequent(firstArray, secondArray) {
const union = [...firstArray, ...secondArray];
return union.reduce((maxOccurences, current, _, array) => {
const currentOccurences = array.filter(num => num === current).length;
return Math.max(currentOccurences, maxOccurences)
}, 0)
}
test(countMostFrequent([1, 2, 2, 3], [0, 2, 4, 4]), 3);
test(countMostFrequent([], [0, 0]), 2)
Шаг 4
Этот же пример работает за O(n + m) по времени с константной дополнительной памятью. Мы обходим оба массива без их объединения в один, используя два указателя, и проверяем их на наличие последовательности одинаковых элементов. Этот код хоть и работает быстрее и использует меньше памяти, но считывается значительно сложнее предыдущих двух решений, что подводит к одному из самых важных моментов: при написании кода важно выдерживать баланс между его производительностью и читабельностью.
import {test} from "./tester";
/*
Задача: найти, сколько раз встречается самый частый элемент в объединении двух отсортированных по возрастанию массивов. Элементы могут повторяться.
Примеры:
countMostFrequent([1, 2, 2, 3], [0, 2, 4, 4]) => 3
countMostFrequent([], [0, 0]) => 2
*/
function countDuplicates(array, startPosition) {
// сначала предположим, что число встречается всего один раз
let lastPosition = startPosition;
// последнее проверенное число — то же самое, что и в начале повторений. И мы не прошли массив полностью...
while (array[startPosition] === array[lastPosition] && lastPosition < array.length) {
// ...подвигаем указатель на последнее одинаковое число
lastPosition++;
}
// а как только числа перестали совпадать, вернём длину отрезка с дубликатами
return lastPosition - startPosition;
};
function countMostFrequent(firstArray, secondArray) {
let result = 0;
// храним указатели на текущие элементы в массиве
let firstPointer = 0;
let secondPointer = 0;
// пока не закончился один из наших массивов
while (firstPointer < firstArray.length && secondPointer < secondArray.length) {
// если в первом массиве текущее число меньше, чем во втором, то нужно сначала посчитать количество этих чисел
if (firstArray[firstPointer] < secondArray[secondPointer]) {
// посчитаем дубликаты, начиная с текущего указателя
const dup1 = countDuplicates(firstArray, firstPointer);
// если получилось больше дубликатов, чем уже было, то запомним это число как текущий результат
result = Math.max(result, dup1);
// и подвигаем указатель внутри первого массива
firstPointer += dup1;
// если в массивах числа совпадают, то ...
} else if (firstArray[firstPointer] === secondArray[secondPointer]) {
// ...посчитаем их количество в каждом массиве
const dup1 = countDuplicates(firstArray, firstPointer);
const dup2 = countDuplicates(secondArray, secondPointer);
// если нужно, обновим результат и подвигаем каждый из указателей
result = Math.max(result, dup1 + dup2);
firstPointer += dup1;
secondPointer += dup2;
// если же во втором массиве число больше, чем в первом, то сделаем над ним те же операции, что над первым
} else {
const dup2 = countDuplicates(secondArray, secondPointer);
result = Math.max(result, dup2);
secondPointer += dup2;
}
}
// если один из массивов закончился, значит нужно досчитать дубликаты в оставшемся массиве подобным образом, пока не закончится и второй
while (firstPointer < firstArray.length) {
const dup1 = countDuplicates(firstArray, firstPointer);
result = Math.max(result, dup1);
firstPointer += dup1;
}
while (secondPointer < secondArray.length) {
const dup2 = countDuplicates(secondArray, secondPointer);
result = Math.max(result, dup2);
secondPointer += dup2;
}
return result;
}
test(countMostFrequent([1, 2, 2, 3], [0, 2, 4, 4]), 3);
test(countMostFrequent([], [0, 0]), 2)
Шаг 5
Посмотрим на следующую задачу. Как и в прошлый раз, попробуйте сами решить её максимально оптимально, а затем переходите к следующему шагу.
import {test} from "./tester";
/*
Задача: написать функцию для прохождения типового задания с числами в тесте iq — из списка чисел найти то, которое отличается по чётности от остальных, и вернуть его позицию.
Примеры:
iqTest("2 4 7 8 10") => 3
iqTest("1 2 1 1") => 2
*/
function iqTest(numbers) {
return 0;
}
test(iqTest("2 4 7 8 10"), 3);
test(iqTest("1 2 1 1"), 2);
Шаг 6
Это самое популярное решение задачи. Выглядит очень простым для понимания: сначала мы приводим строчку с числами в массив, а затем разбиваем его на два массива — чётных и нечётных чисел. Если чётных чисел больше, чем нечётных — возвращаем индекс первого нечётного, и наоборот. Это решение отрабатывает за O(n), потому как парсинг строчки, фильтрация и поиск индекса занимают по одному проходу массива, однако можно прийти и к более лаконичному решению.
import {test} from "./tester";
/*
Задача: написать функцию для прохождения типового задания с числами в тесте iq: из списка чисел найти то, которое отличается по четности от остальных и вернуть его позицию.
Примеры:
iqTest("2 4 7 8 10") => 3
iqTest("1 2 1 1") => 2
*/
function iqTest(numbers) {
const parsedNumbers = numbers.split(" ").map(el => parseInt(el));
const odd = parsedNumbers.filter(el => el % 2 === 1);
const even = parsedNumbers.filter(el => el % 2 === 0);
return odd.length < even.length ? (parsedNumbers.indexOf(odd[0]) + 1) : (parsedNumbers.indexOf(even[0]) + 1);
}
test(iqTest("2 4 7 8 10"), 3);
test(iqTest("1 2 1 1"), 2);
Шаг 7
Если попробовать написать более приближенное к тексту задачи решение, то может получиться что-то подобное. Здесь мы сразу перегоняем числа в остатки от деления на два, чтобы определить их чётность, и работаем уже с этим массивом. Если сумма остатков на 0 больше единицы, очевидно, что нечётных чисел больше одного и надо возвращать чётное, и наоборот. Это решение также работает за O(n), но оно лучше предыдущего не за счёт асимптотики, а за счёт чистоты решения: мы отбросили всю лишнюю информацию о входных данных и работали только с тем, что реально важно. В более сложных прикладных задачах тоже следует тренировать насмотренность на подобные ситуации и вовремя переходить на нужный уровень абстракции. Это поможет экономить память на хранение ненужной информации и делать код более поддерживаемым за счёт сосредоточения на том, что действительно нужно для решения задачи. Также стоит тренировать насмотренность на математические хаки! В работе фронтенд-разработчика, особенно при работе со стилизацией из JavaScript, время от времени находят применение простые математические свойства наподобие остатков от деления, что мы уже рассмотрели.
import {test} from "./tester";
/*
Задача: написать функцию для прохождения типового задания с числами в тесте iq — из списка чисел найти то, которое отличается по чётности от остальных, и вернуть его позицию.
Примеры:
iqTest("2 4 7 8 10") => 3
iqTest("1 2 1 1") => 2
*/
function iqTest(numbers) {
const remainders = numbers.split(" ").map(x => x % 2);
const sum = remainders.reduce((a, b) => a + b);
const target = sum > 1 ? 0 : 1;
return remainders.indexOf(target) + 1;
}
test(iqTest("2 4 7 8 10"), 3);
test(iqTest("1 2 1 1"), 2);
File tester.js
const results = document.getElementById('results')
export function test(left, right) {
const result = document.createElement('li');
if (left === right) {
result.innerHTML = "Тест пройден!";
result.style.color = "green";
} else {
result.innerHTML = `Тест не пройден! ${left} не равно ${right}`;
result.style.color = "red";
}
results.appendChild(result);
}