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);
}