Задачи на алгоритмы и структуры данных в JavaScript: от простых к сложным
#Веб-разработка #Основы JavaScript #АлгоритмыДля кого эта статья:
- Разработчики и программисты, желающие улучшить свои навыки программирования и алгоритмизации в JavaScript
- Студенты и профессионалы, готовящиеся к техническим собеседованиям в IT-компаниях
- Люди, интересующиеся углубленным пониманием структур данных и алгоритмов
Собеседования в крупных IT-компаниях часто напоминают проход через полосу алгоритмических препятствий, где отказ ждёт тех, кто не способен решить хотя бы среднюю задачу на эффективность алгоритма. Недавно коллега проиграл шестизначное годовое предложение, потому что не смог оптимизировать решение задачи с O(n²) до O(n log n). JavaScript, будучи языком с высоким порогом входа, но низким порогом мастерства, особенно коварен: его гибкость создаёт иллюзию, что глубокое понимание алгоритмов необязательно. Это опасное заблуждение. Давайте шаг за шагом разберём путь от простых задач, которые решит даже начинающий, до алгоритмических головоломок, справиться с которыми могут только разработчики с глубоким пониманием структур данных. 🚀
Основы работы с алгоритмами в JavaScript для новичков
Алгоритм — это последовательность шагов для решения конкретной задачи. В JavaScript, как и в любом другом языке, понимание алгоритмов начинается с осознания фундаментальных концепций: переменных, условий, циклов и функций. Прежде чем браться за сложные структуры данных, необходимо освоить базовые техники работы с массивами и объектами.
Давайте рассмотрим простую задачу: найти сумму всех чисел в массиве. Это элементарная задача, которая демонстрирует применение циклов и накопления результата:
function sumArray(arr) {
let sum = 0;
for(let i = 0; i < arr.length; i++) {
sum += arr[i];
}
return sum;
}
// Альтернативное решение с использованием методов массива
function sumArrayReduce(arr) {
return arr.reduce((sum, current) => sum + current, 0);
}
Обе функции имеют временную сложность O(n), где n — количество элементов в массиве. Это означает, что время выполнения линейно возрастает с размером входных данных.
Ещё одна фундаментальная задача — определение четности числа:
function isEven(num) {
return num % 2 === 0;
}
Эта функция имеет сложность O(1) — константное время выполнения, независимо от величины числа.
Для новичков особенно важно понять принципы оценки сложности алгоритмов, поскольку это фундаментальный навык при разработке эффективных решений. Вот краткая шпаргалка:
| Обозначение | Название | Пример алгоритма |
|---|---|---|
| O(1) | Константная сложность | Доступ к элементу массива по индексу |
| O(log n) | Логарифмическая сложность | Бинарный поиск |
| O(n) | Линейная сложность | Поиск максимального элемента |
| O(n log n) | Квазилинейная сложность | Эффективные алгоритмы сортировки |
| O(n²) | Квадратичная сложность | Сортировка пузырьком |
Антон Седов, технический директор
Я помню свою первую серьезную задачу в JavaScript — нужно было вычислить число Фибоначчи для позиции n. Наивно реализовал рекурсивное решение:
JSСкопировать кодfunction fibonacci(n) { if (n <= 1) return n; return fibonacci(n-1) + fibonacci(n-2); }Функция прекрасно работала для малых чисел, но когда я попытался вызвать
fibonacci(50), скрипт завис. Я не понимал, что происходит, пока мой ментор не объяснил, что временная сложность этого алгоритма — O(2^n), экспоненциальная! С каждым вызовом функция порождает два новых вызова, создавая "дерево" рекурсивных вызовов, которое удваивается с каждым уровнем.Решение? Динамическое программирование и мемоизация:
JSСкопировать кодfunction fibonacciMemo(n, memo = {}) { if (n in memo) return memo[n]; if (n <= 1) return n; memo[n] = fibonacciMemo(n-1, memo) + fibonacciMemo(n-2, memo); return memo[n]; }Эта версия сохраняет уже вычисленные значения и снижает сложность до O(n). Для
fibonacci(50)разница между несколькими миллисекундами и "зависшим навсегда" скриптом.Этот случай научил меня, что понимание алгоритмической сложности — не теоретический изыск, а практическая необходимость.
Освоение работы с функциями высшего порядка, такими как map, filter и reduce, существенно упрощает решение многих алгоритмических задач в JavaScript и делает код более читаемым:
// Найти все четные числа в массиве
function findEvenNumbers(arr) {
return arr.filter(num => num % 2 === 0);
}
// Удвоить каждое число в массиве
function doubleNumbers(arr) {
return arr.map(num => num * 2);
}
Эти базовые примеры демонстрируют элегантность функционального подхода в JavaScript и являются фундаментом для работы с более сложными алгоритмами. 🔍

Базовые структуры данных и простые задачи на JavaScript
Понимание базовых структур данных — ключевой навык для решения алгоритмических задач. В JavaScript встроены несколько таких структур: массивы (Arrays), объекты (Objects), множества (Sets) и карты (Maps). Каждая имеет свои особенности и оптимальные сценарии использования.
Массивы — наиболее часто используемая структура данных, обеспечивающая упорядоченное хранение элементов. Рассмотрим задачу нахождения двух чисел в массиве, сумма которых равна заданному целевому числу:
function findTwoSum(nums, target) {
const numMap = {};
for(let i = 0; i < nums.length; i++) {
const complement = target – nums[i];
if(complement in numMap) {
return [numMap[complement], i];
}
numMap[nums[i]] = i;
}
return null;
}
// Пример использования
console.log(findTwoSum([2, 7, 11, 15], 9)); // [0, 1]
В данном решении мы используем объект как хеш-таблицу для хранения уже просмотренных элементов и их индексов. Это позволяет достичь временной сложности O(n) вместо O(n²), которую дал бы наивный подход с двумя вложенными циклами.
Работа со стеком и очередью — еще один важный аспект. В JavaScript стек можно реализовать с помощью массива и методов push/pop, а очередь — с помощью push/shift:
// Реализация стека
class Stack {
constructor() {
this.items = [];
}
push(element) {
this.items.push(element);
}
pop() {
if(this.items.length === 0) return "Underflow";
return this.items.pop();
}
peek() {
return this.items[this.items.length – 1];
}
isEmpty() {
return this.items.length === 0;
}
}
// Реализация очереди
class Queue {
constructor() {
this.items = [];
}
enqueue(element) {
this.items.push(element);
}
dequeue() {
if(this.isEmpty()) return "Underflow";
return this.items.shift();
}
front() {
if(this.isEmpty()) return "Queue is empty";
return this.items[0];
}
isEmpty() {
return this.items.length === 0;
}
}
С использованием стека легко решить задачу проверки сбалансированности скобок:
function isBalanced(str) {
const stack = [];
const brackets = {
'(': ')',
'{': '}',
'[': ']'
};
for(let char of str) {
if(char in brackets) {
stack.push(char);
} else if(Object.values(brackets).includes(char)) {
if(stack.length === 0 || brackets[stack.pop()] !== char) {
return false;
}
}
}
return stack.length === 0;
}
console.log(isBalanced("({[]})")); // true
console.log(isBalanced("({[})")); // false
Каждая структура данных имеет свои преимущества и недостатки в различных сценариях использования:
| Структура данных | Преимущества | Недостатки | Оптимальное применение |
|---|---|---|---|
| Массив (Array) | Быстрый доступ по индексу | Медленные вставка/удаление в начале и середине | Когда важен порядок элементов и частый доступ по индексу |
| Объект (Object) | Быстрый доступ, вставка и удаление по ключу | Нет гарантированного порядка свойств | Хранение данных типа "ключ-значение" |
| Множество (Set) | Уникальные значения, быстрый поиск | Нет доступа по индексу | Когда важна уникальность элементов |
| Карта (Map) | Любые типы ключей, сохранение порядка вставки | Сложнее в использовании для новичков | Когда требуются сложные ключи и важен порядок |
Понимание этих структур и умение выбирать подходящую для конкретной задачи — необходимые навыки для эффективного решения алгоритмических задач. 🧩
Алгоритмы сортировки и поиска: реализация в JS
Алгоритмы сортировки и поиска — фундаментальные инструменты в арсенале каждого разработчика. В JavaScript встроенный метод sort() работает на основе сортировки сравнением, обычно реализуя алгоритм Timsort (гибрид сортировки слиянием и вставками).
Однако понимание различных алгоритмов сортировки и их реализация "с нуля" критически важны для глубокого осмысления концепций и прохождения технических собеседований.
Рассмотрим один из простейших алгоритмов сортировки — сортировку пузырьком (Bubble Sort):
function bubbleSort(arr) {
const n = arr.length;
let swapped;
do {
swapped = false;
for(let i = 0; i < n – 1; i++) {
if(arr[i] > arr[i + 1]) {
// Обмен элементов
[arr[i], arr[i + 1]] = [arr[i + 1], arr[i]];
swapped = true;
}
}
} while(swapped);
return arr;
}
console.log(bubbleSort([64, 34, 25, 12, 22, 11, 90]));
Сортировка пузырьком проста для понимания, но неэффективна для больших массивов с временной сложностью O(n²). Для более эффективной сортировки рассмотрим быструю сортировку (Quick Sort):
function quickSort(arr) {
if(arr.length <= 1) return arr;
const pivot = arr[Math.floor(arr.length / 2)];
const left = arr.filter(x => x < pivot);
const middle = arr.filter(x => x === pivot);
const right = arr.filter(x => x > pivot);
return [...quickSort(left), ...middle, ...quickSort(right)];
}
console.log(quickSort([64, 34, 25, 12, 22, 11, 90]));
Быстрая сортировка имеет среднюю временную сложность O(n log n), что значительно эффективнее для больших массивов. Однако в худшем случае (например, когда массив уже отсортирован) сложность может достигать O(n²).
Алгоритмы поиска не менее важны. Линейный поиск имеет сложность O(n) и применим к несортированным массивам:
function linearSearch(arr, target) {
for(let i = 0; i < arr.length; i++) {
if(arr[i] === target) return i;
}
return -1;
}
Для сортированных массивов более эффективным является бинарный поиск с временной сложностью O(log n):
function binarySearch(arr, target) {
let left = 0;
let right = arr.length – 1;
while(left <= right) {
const mid = Math.floor((left + right) / 2);
if(arr[mid] === target) return mid;
if(arr[mid] < target) left = mid + 1;
else right = mid – 1;
}
return -1;
}
console.log(binarySearch([11, 12, 22, 25, 34, 64, 90], 25)); // 3
Елена Ковалёва, ведущий разработчик
В моём первом серьёзном проекте я столкнулась с задачей сортировки большого объёма данных клиентской аналитики — около 500,000 записей. Сначала я наивно использовала сортировку пузырьком, которую помнила ещё с университета:
JSСкопировать кодfunction bubbleSort(data) { for(let i = 0; i < data.length; i++) { for(let j = 0; j < data.length – i – 1; j++) { if(data[j].value > data[j+1].value) { [data[j], data[j+1]] = [data[j+1], data[j]]; } } } return data; }Браузер пользователя буквально зависал на 30 секунд при каждой сортировке! После профилирования кода я поняла, что O(n²) — это катастрофа для таких объёмов данных.
Переписав сортировку на быструю (QuickSort) с временной сложностью O(n log n):
JSСкопировать кодfunction quickSort(data) { if(data.length <= 1) return data; let pivot = data[Math.floor(data.length / 2)].value; let left = data.filter(item => item.value < pivot); let equal = data.filter(item => item.value === pivot); let right = data.filter(item => item.value > pivot); return [...quickSort(left), ...equal, ...quickSort(right)]; }Время сортировки снизилось до 800 мс! Но затем я узнала, что метод
Array.prototype.sort()в JavaScript уже оптимизирован и использует смесь алгоритмов с временной сложностью O(n log n). Переход на встроенный метод снизил время до 350 мс:JSСкопировать кодdata.sort((a, b) => a.value – b.value);Этот случай научил меня не изобретать велосипед и всегда оценивать сложность алгоритмов перед их использованием на больших наборах данных.
Помимо классических алгоритмов, важно знать более специализированные подходы, например, алгоритм поиска подстроки KMP (Кнута-Морриса-Пратта) с временной сложностью O(n+m):
function computeLPSArray(pattern) {
const lps = [0];
let len = 0;
let i = 1;
while (i < pattern.length) {
if (pattern[i] === pattern[len]) {
len++;
lps[i] = len;
i++;
} else {
if (len !== 0) {
len = lps[len – 1];
} else {
lps[i] = 0;
i++;
}
}
}
return lps;
}
function KMPSearch(text, pattern) {
if (pattern.length === 0) return 0;
const lps = computeLPSArray(pattern);
const matches = [];
let i = 0; // индекс для text
let j = 0; // индекс для pattern
while (i < text.length) {
if (pattern[j] === text[i]) {
i++;
j++;
}
if (j === pattern.length) {
matches.push(i – j);
j = lps[j – 1];
} else if (i < text.length && pattern[j] !== text[i]) {
if (j !== 0) {
j = lps[j – 1];
} else {
i++;
}
}
}
return matches;
}
console.log(KMPSearch("ABABDABACDABABCABAB", "ABABCABAB")); // [10]
Понимание и умение реализовать различные алгоритмы сортировки и поиска существенно расширяют инструментарий разработчика и позволяют выбирать оптимальное решение для конкретной задачи. 🔄
Продвинутые структуры данных и решение задач в JavaScript
Для решения более сложных алгоритмических задач необходимо владение продвинутыми структурами данных. В JavaScript они часто реализуются самостоятельно, поскольку стандартная библиотека не предоставляет их готовых реализаций.
Одной из таких структур является связный список (Linked List). В отличие от массива, он не требует непрерывного блока памяти и обеспечивает эффективную вставку и удаление элементов.
class ListNode {
constructor(val) {
this.val = val;
this.next = null;
}
}
class LinkedList {
constructor() {
this.head = null;
this.size = 0;
}
// Добавление элемента в конец списка
append(val) {
const newNode = new ListNode(val);
if (!this.head) {
this.head = newNode;
this.size++;
return;
}
let current = this.head;
while (current.next) {
current = current.next;
}
current.next = newNode;
this.size++;
}
// Добавление элемента в начало списка
prepend(val) {
const newNode = new ListNode(val);
newNode.next = this.head;
this.head = newNode;
this.size++;
}
// Удаление элемента по значению
delete(val) {
if (!this.head) return;
if (this.head.val === val) {
this.head = this.head.next;
this.size--;
return;
}
let current = this.head;
while (current.next && current.next.val !== val) {
current = current.next;
}
if (current.next) {
current.next = current.next.next;
this.size--;
}
}
// Поиск элемента по значению
find(val) {
let current = this.head;
while (current) {
if (current.val === val) return current;
current = current.next;
}
return null;
}
}
С использованием связного списка можно эффективно решать различные задачи, например, обнаружение цикла в списке (задача, которая часто встречается на собеседованиях):
function hasCycle(head) {
if (!head || !head.next) return false;
let slow = head;
let fast = head;
while (fast && fast.next) {
slow = slow.next;
fast = fast.next.next;
if (slow === fast) return true;
}
return false;
}
Еще одна важная структура — бинарное дерево поиска (Binary Search Tree), которое обеспечивает логарифмическое время для операций поиска, вставки и удаления:
class TreeNode {
constructor(val) {
this.val = val;
this.left = null;
this.right = null;
}
}
class BinarySearchTree {
constructor() {
this.root = null;
}
// Вставка нового узла
insert(val) {
const newNode = new TreeNode(val);
if (!this.root) {
this.root = newNode;
return;
}
function insertNode(node, newNode) {
if (newNode.val < node.val) {
if (!node.left) {
node.left = newNode;
} else {
insertNode(node.left, newNode);
}
} else {
if (!node.right) {
node.right = newNode;
} else {
insertNode(node.right, newNode);
}
}
}
insertNode(this.root, newNode);
}
// Поиск узла
search(val) {
return this._search(this.root, val);
}
_search(node, val) {
if (!node) return null;
if (val === node.val) return node;
if (val < node.val) {
return this._search(node.left, val);
} else {
return this._search(node.right, val);
}
}
// Обход в глубину (in-order traversal)
inOrderTraversal(callback) {
this._inOrderTraversal(this.root, callback);
}
_inOrderTraversal(node, callback) {
if (node) {
this._inOrderTraversal(node.left, callback);
callback(node.val);
this._inOrderTraversal(node.right, callback);
}
}
}
С использованием дерева можно эффективно решать задачи, такие как проверка сбалансированности дерева:
function isBalanced(root) {
function getHeight(node) {
if (!node) return 0;
const leftHeight = getHeight(node.left);
if (leftHeight === -1) return -1;
const rightHeight = getHeight(node.right);
if (rightHeight === -1) return -1;
if (Math.abs(leftHeight – rightHeight) > 1) return -1;
return Math.max(leftHeight, rightHeight) + 1;
}
return getHeight(root) !== -1;
}
Граф — еще одна важная структура данных, которая применяется для решения множества задач, от построения маршрутов до анализа социальных сетей:
class Graph {
constructor() {
this.adjacencyList = {};
}
// Добавление вершины
addVertex(vertex) {
if (!this.adjacencyList[vertex]) {
this.adjacencyList[vertex] = [];
}
}
// Добавление ребра
addEdge(vertex1, vertex2) {
this.adjacencyList[vertex1].push(vertex2);
this.adjacencyList[vertex2].push(vertex1); // для неориентированного графа
}
// Удаление ребра
removeEdge(vertex1, vertex2) {
this.adjacencyList[vertex1] = this.adjacencyList[vertex1].filter(v => v !== vertex2);
this.adjacencyList[vertex2] = this.adjacencyList[vertex2].filter(v => v !== vertex1);
}
// Удаление вершины
removeVertex(vertex) {
while(this.adjacencyList[vertex].length) {
const adjacentVertex = this.adjacencyList[vertex].pop();
this.removeEdge(vertex, adjacentVertex);
}
delete this.adjacencyList[vertex];
}
// Поиск в глубину (DFS)
depthFirstSearch(start) {
const result = [];
const visited = {};
const adjacencyList = this.adjacencyList;
(function dfs(vertex) {
if (!vertex) return null;
visited[vertex] = true;
result.push(vertex);
adjacencyList[vertex].forEach(neighbor => {
if (!visited[neighbor]) {
return dfs(neighbor);
}
});
})(start);
return result;
}
// Поиск в ширину (BFS)
breadthFirstSearch(start) {
const queue = [start];
const result = [];
const visited = {};
visited[start] = true;
while(queue.length) {
const currentVertex = queue.shift();
result.push(currentVertex);
this.adjacencyList[currentVertex].forEach(neighbor => {
if (!visited[neighbor]) {
visited[neighbor] = true;
queue.push(neighbor);
}
});
}
return result;
}
}
Навык работы с этими структурами данных позволяет решать сложные алгоритмические задачи эффективно и элегантно. Изучение и практика с продвинутыми структурами данных — необходимый шаг для перехода на новый уровень мастерства в программировании. 🌳
Сложные алгоритмические задачи для подготовки к собеседованию
Для успешного прохождения технических собеседований в крупных технологических компаниях необходимо освоить решение сложных алгоритмических задач. Эти задачи часто требуют комбинирования различных алгоритмических подходов и глубокого понимания оптимизации.
Рассмотрим несколько типичных задач высокого уровня сложности:
1. Задача о максимальном потоке в графе
Эта задача применяется в сетевых алгоритмах для определения максимальной пропускной способности между источником и стоком.
// Реализация алгоритма Форда-Фалкерсона
function fordFulkerson(graph, source, sink) {
// Создаем остаточный граф
const residualGraph = Array(graph.length).fill().map(() => Array(graph[0].length).fill(0));
// Инициализируем остаточный граф
for(let u = 0; u < graph.length; u++) {
for(let v = 0; v < graph.length; v++) {
residualGraph[u][v] = graph[u][v];
}
}
// Массив для хранения родительских вершин в BFS
const parent = Array(graph.length).fill(-1);
let maxFlow = 0;
// Поиск расширяющего пути методом BFS
function bfs() {
parent.fill(-1);
const visited = Array(graph.length).fill(false);
const queue = [];
visited[source] = true;
queue.push(source);
while(queue.length) {
const u = queue.shift();
for(let v = 0; v < graph.length; v++) {
if(!visited[v] && residualGraph[u][v] > 0) {
queue.push(v);
parent[v] = u;
visited[v] = true;
}
}
}
// Если мы достигли стока в BFS, значит путь существует
return visited[sink];
}
// Пока существует путь от источника к стоку
while(bfs()) {
// Находим минимальную пропускную способность на пути
let pathFlow = Infinity;
for(let v = sink; v !== source; v = parent[v]) {
const u = parent[v];
pathFlow = Math.min(pathFlow, residualGraph[u][v]);
}
// Обновляем остаточные ёмкости рёбер и обратных рёбер
for(let v = sink; v !== source; v = parent[v]) {
const u = parent[v];
residualGraph[u][v] -= pathFlow;
residualGraph[v][u] += pathFlow;
}
// Увеличиваем максимальный поток
maxFlow += pathFlow;
}
return maxFlow;
}
2. Задача о нахождении кратчайшего пути в графе с отрицательными весами
Алгоритм Дейкстры не работает с отрицательными весами, поэтому для таких графов применяют алгоритм Беллмана-Форда.
function bellmanFord(graph, source) {
const V = graph.length;
const dist = Array(V).fill(Infinity);
dist[source] = 0;
// Релаксация всех рёбер V-1 раз
for(let i = 1; i < V; i++) {
for(let u = 0; u < V; u++) {
for(let v = 0; v < V; v++) {
if(graph[u][v] !== 0 && dist[u] !== Infinity && dist[u] + graph[u][v] < dist[v]) {
dist[v] = dist[u] + graph[u][v];
}
}
}
}
// Проверка наличия отрицательных циклов
for(let u = 0; u < V; u++) {
for(let v = 0; v < V; v++) {
if(graph[u][v] !== 0 && dist[u] !== Infinity && dist[u] + graph[u][v] < dist[v]) {
console.log("Граф содержит отрицательный цикл");
return null;
}
}
}
return dist;
}
3. Задача о наибольшей возрастающей подпоследовательности
Эта задача требует применения динамического программирования.
function longestIncreasingSubsequence(nums) {
if(nums.length === 0) return 0;
const n = nums.length;
const dp = Array(n).fill(1);
for(let i = 1; i < n; i++) {
for(let j = 0; j < i; j++) {
if(nums[i] > nums[j]) {
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
}
return Math.max(...dp);
}
4. Задача о разбиении множества на подмножества с равной суммой
Это NP-полная задача, которая также решается с помощью динамического программирования.
function canPartition(nums) {
const sum = nums.reduce((a, b) => a + b, 0);
// Если сумма нечётная, невозможно разделить на две части с равной суммой
if(sum % 2 !== 0) return false;
const target = sum / 2;
const dp = Array(target + 1).fill(false);
dp[0] = true;
for(const num of nums) {
for(let i = target; i >= num; i--) {
dp[i] = dp[i] || dp[i – num];
}
}
return dp[target];
}
При подготовке к собеседованию важно не только знать алгоритмы, но и уметь анализировать их сложность и обосновывать выбор конкретного подхода.
Вот сравнительная сложность некоторых алгоритмов для сложных задач:
| Алгоритм | Временная сложность | Пространственная сложность | Применимость |
|---|---|---|---|
| Форда-Фалкерсона | O(max_flow * E) | O(V²) | Нахождение максимального потока |
| Беллман-Форда | O(V * E) | O(V) | Кратчайший путь с отрицательными весами |
| Динамическое программирование для LIS | O(n²) | O(n) | Наибольшая возрастающая подпоследовательность |
| Бинарный поиск для LIS | O(n log n) | O(n) | Оптимизированная наибольшая возрастающая подпоследовательность |
| Разбиение множества | O(n * sum) | O(sum) | Разбиение на подмножества с равной суммой |
Решение сложных алгоритмических задач требует практики и глубокого понимания различных техник. Регулярное решение задач на платформах вроде LeetCode, HackerRank и CodeWars значительно повышает шансы на успешное прохождение технических собеседований. 🏆
Практика решения алгоритмических задач — это не просто подготовка к собеседованию, но фундаментальное развитие мышления разработчика. Начинайте с простых задач, чтобы закрепить базовые концепции, и постепенно переходите к более сложным, используя изученные структуры данных и алгоритмические подходы. Помните, что сила JavaScript не только в его гибкости и вездесущности, но и в возможности применять фундаментальные компьютерные науки для создания оптимальных решений. Постоянно анализируйте временную и пространственную сложность своих решений — это отличает обычного кодера от настоящего инженера-программиста.
Читайте также
Станислав Плотников
фронтенд-разработчик