Алгоритмы с нуля
Подсчёт частоты
У тебя есть строка “mississippi” и ты хочешь знать: сколько раз встречается каждая буква? Один подход: для каждой буквы отсканируй всю строку и подсчитай. Это O(n²) — n букв, каждая требует сканирования. Но вот ключевая идея: отсканируй строку один раз, и по дороге заполняй подсчёт каждой буквы в хеш-карте. Когда достигнешь конца, твоя хеш-карта содержит полный подсчёт частоты. Один проход, O(n) времени. Это приём подсчёта частоты, и это самый частый способ использовать хеш-карты.
После этого урока ты можешь построить карту частоты за один проход O(n), использовать карту частоты чтобы решить «это анаграмма?», найти самый частый элемент в массиве, обнаружить неповторяющиеся элементы, понять, почему фиксированный алфавит позволяет таблицу частоты на базе массива, и узнать, что подсчёт частоты заменяет подход «сканируй и подсчитай для каждого элемента» за O(n²).
Проблема, которую мы решаем:
Тебя просят: подсчитай, сколько раз встречается каждая буква в “hello”. Один способ:
function countNaive(str) {
// Для каждой буквы отсканируй строку и подсчитай
for (let i = 0; i < str.length; i++) {
let count = 0;
for (let j = 0; j < str.length; j++) {
if (str[i] === str[j]) count++;
}
console.log(str[i] + ": " + count); // h: 1, e: 1, l: 2, l: 2, o: 1
}
}Это O(n²): для каждой из n букв ты сканируешь всю строку. Для больших входов это расточительно.
Идея подсчёта частоты:
Вместо этого отсканируй строку один раз. Для каждой буквы, которую ты встречаешь, обновляй её подсчёт в хеш-карте:
function countFreq(str) {
const freq = new Map(); // карта частоты: буква → подсчёт
for (let char of str) {
// Инкрементируй подсчёт для этого символа
freq.set(char, (freq.get(char) ?? 0) + 1);
}
// freq теперь содержит: h→1, e→1, l→2, o→1
return freq;
}Один цикл, O(n) времени. Одна хеш-карта, O(k) памяти, где k — количество уникальных букв.
Почему это работает:
- Первый проход: посети каждый элемент один раз. Добавь в карту если новый, инкрементируй если видел раньше.
- O(1) обновления:
freq.set()иfreq.get()это O(1) в среднем. - Результат: после одного прохода, карта полная.
Хеш-карта против массива для маленьких алфавитов:
Если ключи это маленький фиксированный диапазон (например, строчные буквы a–z), ты можешь использовать обычный массив вместо хеш-карты:
function countFreqArray(str) {
const freq = new Array(26).fill(0); // 26 ячеек для a-z
for (let char of str) {
const index = char.charCodeAt(0) - 'a'.charCodeAt(0); // 'a'→0, 'b'→1, и т.д.
freq[index]++;
}
// freq[0] это подсчёт 'a', freq[1] это подсчёт 'b', и т.д.
return freq;
}Доступ к массиву это O(1) и немного быстрее чем операции хеш-карты (нет издержек хеширования). Оба работают; массив оптимизирован когда ключи маленькие и предсказуемые.
Общие применения карт частоты:
- Анаграммы: две строки это анаграммы если они имеют одинаковые частоты символов.
- Дубликаты и неповторяющиеся: найди элементы, что встречаются ровно один раз или больше чем один.
- Самый/наименее частый: отслеживай элемент с самым высоким или низким подсчётом.
- Группировка по частоте: организуй элементы по частоте.
1. Построение карты частоты:
function frequency(arr) {
const freq = new Map();
for (let item of arr) {
freq.set(item, (freq.get(item) ?? 0) + 1);
}
return freq;
}
const counts = frequency([5, 2, 5, 9, 5, 3, 2]);
// counts: 5→3, 2→2, 9→1, 3→1
console.log(counts.get(5)); // 32. Проверка, являются ли две строки анаграммами:
Две строки это анаграммы если они содержат одинаковые символы с одинаковыми частотами.
function isAnagram(s, t) {
if (s.length !== t.length) return false;
const freq = new Map();
// Подсчитай символы в s
for (let char of s) {
freq.set(char, (freq.get(char) ?? 0) + 1);
}
// Декрементируй для символов в t
for (let char of t) {
if (!freq.has(char)) return false;
freq.set(char, freq.get(char) - 1);
if (freq.get(char) < 0) return false;
}
// Все подсчёты должны быть 0
for (let count of freq.values()) {
if (count !== 0) return false;
}
return true;
}
console.log(isAnagram("listen", "silent")); // true
console.log(isAnagram("hello", "world")); // false3. Поиск первого неповторяющегося символа:
function firstNonRepeating(str) {
const freq = new Map();
// Подсчитай все символы
for (let char of str) {
freq.set(char, (freq.get(char) ?? 0) + 1);
}
// Найди первый символ с подсчётом 1
for (let char of str) {
if (freq.get(char) === 1) {
return char;
}
}
return null; // Нет неповторяющегося символа
}
console.log(firstNonRepeating("abacabad")); // 'c'
console.log(firstNonRepeating("aabbcc")); // nullПопробуй примеры:
Давайте проследим построение карты частоты на строке “aabab”:
1
function frequency(str) {
2
const freq = new Map();
3
for (let char of str) {
4
freq.set(char, (freq.get(char) ?? 0) + 1);
5
}
6
return freq;
7
}
Заметь: мы посетили каждый символ ровно один раз. Каждый set и get это O(1). Полное время: O(n).
Подсчёт частоты:
| Операция | Время | Память |
|---|---|---|
| Построить карту частоты | O(n) | O(k) |
| Проверить анаграмму | O(n) | O(k) |
| Найти первый неповторяющийся | O(n) | O(k) |
| Найти самый частый элемент | O(n) | O(k) |
Где n это размер входа (длина строки или размер массива) и k это количество уникальных значений.
Сравнение: наивный O(n²) против подсчёта частоты O(n):
- Наивный подход: для каждого элемента отсканируй весь вход чтобы подсчитать его. n × n = O(n²).
- Подсчёт частоты: один проход чтобы построить карту, потом используй карту. O(n) + O(n) = O(n).
Для n = 1000, наивный это 1,000,000 операций. Подсчёт частоты это 2,000. Разница растёт быстро.
Память для варианта на базе массива:
Если алфавит фиксирован (например, строчные буквы, 26 значений), память это O(26) = O(1) константа. Время подсчёта частоты это всё ещё O(n), но память это O(1) вместо O(k).
Почему подсчёт частоты подсчитывает вхождения каждого элемента за O(n) время вместо O(n²)?
Две строки это анаграммы если они содержат одинаковые символы с одинаковыми частотами. Какой подход эффективнее?
В строке длины 100 с 8 уникальными символами, сколько памяти использует карта частоты?
При подсчёте частоты символов в строке из строчных букв только (a–z), какая структура данных самая быстрая?
Чтобы найти первый неповторяющийся символ в строке, нужно ли построить карту частоты полностью сначала, или можешь проверять во время построения?
lesson.inset.misconception
«Подсчёт частоты это только для строк.» Неправда. Подсчёт частоты работает на любом массиве сравнимых значений: целые числа, объекты (если hashable), пары, и т.д. Приём одинаков: один проход, подсчитай в хеш-карте, используй результат.
Граничные случаи
Пустой или однобуквенный вход: Пустая строка не имеет символов, поэтому карта частоты пуста. Строка с одним символом имеет одну запись: символ с подсчётом 1. Всегда тестируй граничные случаи.
Почему приём подсчёта частоты это O(n) время, даже если кажется что «я должен подсчитать каждый элемент» звучит как O(n²)?
Подсчёт частоты это практика подсчёта того, сколько раз встречается каждое значение в наборе данных, используя хеш-карту.
Ключевые факты:
- Алгоритм за один проход: итерируй вход один раз. Для каждого элемента инкрементируй его подсчёт в карте используя
map.set(key, (map.get(key) ?? 0) + 1). - Сложность времени: O(n), где n это размер входа.
- Сложность памяти: O(k), где k это количество уникальных значений.
- Общие применения:
- Проверь, являются ли две строки анаграммами (одинаковые частоты символов).
- Найди первый неповторяющийся элемент.
- Обнаруживай элементы, что встречаются ровно один раз или больше чем один.
- Найди самый или наименее частый элемент.
- Оптимизация на базе массива: если ключи это маленький фиксированный диапазон (например, строчные буквы a–z), используй массив вместо хеш-карты для O(1) памяти и немного более быстрых операций.
- Заменяет O(n²): наивный подход сканирования для каждого элемента это O(n²). Подсчёт частоты решает ту же задачу за O(n).
У тебя теперь есть самый частый приём хеш-карты. Дальше, ты изучишь разрешение коллизий и узнаешь как хеш-функции распределяют ключи.