Quick Sort. Часть 3: реализация.
1. Напишем главную функцию:
4. Наконец, напишем swap:
1. Напишем главную функцию:
const quickSort = (arr) => {2. Теперь напишем функцию quickSortHelper:
if(arr.length < 2) { // если длина массива меньше 2, запуск алогоритма не имеет смысла - вернем сам массив.
return arr;
}
quickSortHelper(arr, 0, arr.length - 1); // изначальные аргументы всегда 0 и arr.length-1. Они означают первый и последний индексы в массиве.
return arr; // массив сортируется на месте и возвращается после всех рекурсивных вызовов.
}
const quickSortHelper = (arr, left, right) => {3. Теперь - функция partition:
if(left >= right) { // условие для выхода из рекурсии.
return arr;
}
const partitionIndex = partition(arr, left, right); // вызывается для каждого массива, начиная с изначального и заканчивая единичными.
quickSortHelper(arr, left, partitionIndex-1); // вызов для левой части с числами меньше опорного элемента.
quickSortHelper(arr, partitionIndex, right); // вызов для правой части с числами больше опорного элемента.
}
const partition = (arr, left, right) => {
const pivot = arr[Math.floor((left + right) / 2)]; // опорный элемент определяем как средний. Опорный элемент - значит, тот, с которым сравниваются все остальные элементы при сортировке.
while (left <= right) { // двигаем указатели, пока они не сойдутся.
// cравниваем с опорным элементом и передвигаем указатель до тех пор, пока не выполнится условие.
while(arr[left] < pivot) {
left++;
}
// то же, что в предыдущем случае, но после левого указателя.
while(arr[right] > pivot) { // то же, что в предыдущем случае.
right--;
}
// если элементы равны, просто передвигаем указатели, не вызывая swap. Это немного оптимизирует алгоритм.
if(arr[left] === arr[right]) {
left++;
right--;
continue; // важно, чтобы не выполнился swap.
}
swap(arr, left, right)
}
return left;
}
4. Наконец, напишем swap:
const swap = (arr, left, right) => {
[arr[left], arr[right]] = [arr[right], arr[left]];
}
Quick Sort. Часть 4: оптимизации и тест-кейсы.
Версия выше использует несколько оптимизаций:
- сортировка на месте с перестановками: позволяет не создавать новые массивы на каждом рекурсивном вызове, экономя память.
- выбор среднего элемента как опорного: помогает избегать худших случаев, ускоряя алгоритм для уже отсортированных массивов.
- пропуск перестановок для одинаковых элементов: ускоряет сортировку для данных с большим количеством одинаковых данных.
Что еще можно использовать:
- выбор случайного опорного элемента.
- выбор опорной медианы из первого, последнего и среднего элементов.
Теперь соберите алгоритм сами и попробуйте на тест-кейсах :)
В качестве бонуса - алгоритм без оптимизаций:
Версия выше использует несколько оптимизаций:
- сортировка на месте с перестановками: позволяет не создавать новые массивы на каждом рекурсивном вызове, экономя память.
- выбор среднего элемента как опорного: помогает избегать худших случаев, ускоряя алгоритм для уже отсортированных массивов.
- пропуск перестановок для одинаковых элементов: ускоряет сортировку для данных с большим количеством одинаковых данных.
Что еще можно использовать:
- выбор случайного опорного элемента.
- выбор опорной медианы из первого, последнего и среднего элементов.
Теперь соберите алгоритм сами и попробуйте на тест-кейсах :)
export const cases = [
[],
[1],
[2, 1],
[1,2,3],
[3,1,2,4],
[5,4,3,2,1],
[116, 90, 89, 70, 65, 30, 25, 16, 9, 1, 4, 0, -1, -9],
[1, 27, 5, 16, 3, 11, 8],
[1, 27, 116, 9, 5, 16, 3, 2, 11, 8],
[9,8,5,2,1],
['string', { object: true }],
[undefined, undefined]
]
В качестве бонуса - алгоритм без оптимизаций:
const quickSort = (array) => {
if(array.length <= 1) {
return array;
}
const left = [];
const right = [];
const middleIndex = Math.floor(array.length / 2);
const pivot = array[middleIndex];
for(let i = 0; i < array.length; i++) {
if(i === middleIndex) {
continue;
}
if(typeof array[i] !== "number") return null;
if(array[i] > pivot) {
right.push(array[i]);
} else {
left.push(array[i]);
}
}
return [...quickSort(left), pivot, ...quickSort(right)]
}
В чём разница между arr.length и arr.length-1?
Всё просто:
- arr.length возвращает общее количество элементов в массиве. Например, length для [1,2,3] будет 3.
Но можем ли мы использовать это значение как индекс последнего элемента массива? Нет! Ведь массивы индексируются с нуля.
Поэтому любой код, где нужен последний элемент массива, пользуется записью arr.length - 1.
Запомни это, чтобы избежать распространенной ошибки :)
Всё просто:
- arr.length возвращает общее количество элементов в массиве. Например, length для [1,2,3] будет 3.
Но можем ли мы использовать это значение как индекс последнего элемента массива? Нет! Ведь массивы индексируются с нуля.
Поэтому любой код, где нужен последний элемент массива, пользуется записью arr.length - 1.
Запомни это, чтобы избежать распространенной ошибки :)
Бинарный поиск. В чем суть?
Представьте линейку школьников, где дети выстроены по росту - от самых низких до самых высоких. Физрук ищет ученика определенного роста.
Первая мысль - измерить всех. Это метод brute force. Физруку придется поработать над каждым из учеников - даже над теми, кто точно выше или ниже нужного.
Вместо этого физрук прибегает к методу бинарного поиска:
1. Переходит в центр колонны.
2. Измеряет ученика. Если ученик подходит - заканчивает подбор. Если нет - смотрит, выше ученик или ниже.
3. Отбрасывает половину с теми, кто ниже или выше.
4. Переходит в середину оставшихся.
5. Повторяет цикл, пока не найдет нужного.
6. Если не находит - идет пить пиво :)
Это и есть алгоритм бинарного поиска. Всё просто!
Вот реализация на JS. Проследите, как в коде описаны шаги электронного физрука :)
Представьте линейку школьников, где дети выстроены по росту - от самых низких до самых высоких. Физрук ищет ученика определенного роста.
Первая мысль - измерить всех. Это метод brute force. Физруку придется поработать над каждым из учеников - даже над теми, кто точно выше или ниже нужного.
Вместо этого физрук прибегает к методу бинарного поиска:
1. Переходит в центр колонны.
2. Измеряет ученика. Если ученик подходит - заканчивает подбор. Если нет - смотрит, выше ученик или ниже.
3. Отбрасывает половину с теми, кто ниже или выше.
4. Переходит в середину оставшихся.
5. Повторяет цикл, пока не найдет нужного.
6. Если не находит - идет пить пиво :)
Это и есть алгоритм бинарного поиска. Всё просто!
Вот реализация на JS. Проследите, как в коде описаны шаги электронного физрука :)
const binarySearch = (array, target) => {
let left = 0;
let right = array.length-1;
while(left <= right) {
const pivotIndex = Math.floor((left + right) / 2);
let pivot = array[pivotIndex];
if(pivot === target) {
return pivotIndex;
}
if(pivot < target) {
left = pivotIndex+1;
}
if(pivot > target) {
right = pivotIndex-1;
}
}
return -1;
}
Задача структур данных.
Структуры данных придуманы не только для того, чтобы бросать вызов кандидатам на интервью в бигтехи. :) У них, как явления, есть конкретная задача.
О любой структуре данных полезно думать с позиции всего четырёх операций:
- добавление элемента.
- удаление элемента.
- поиск элемента.
- хранение элементов.
Array - одна из первых структур данных - появился, чтобы удобно (непрерывно) хранить данные одного типа, быстро получать к ним доступ по индексам и выполнять над ними вычисления.
Singly Linked List (однонаправленный связный список), появившийся следом, отличался тем, что более эффективно использовал память, а также вставлял элементы в начало и конец за O(1). Массивы изначально были статическими: размер задавался заранее и не мог быть изменен. Чтобы добавить новые элементы, приходилось создавать новый массив - уже с новым элементов.
Любопытная черта динамических массивов в том, что, когда добавляется новый последний элемент, структура выделяет новый блок памяти - что выполняется за O(n) - больший, чем необходимо для добавления одного элемента. Выделенные заранее ячейки памяти заполняются уже за O(1).
Думайте о структурах данных в этом ключе. Так проще поймете и их, и алгоритмы.
А в следующем посте я покажу простой вариант Singly Linked List на JS. Внутри будет любопытный пример работы со стеком. 😉
Структуры данных придуманы не только для того, чтобы бросать вызов кандидатам на интервью в бигтехи. :) У них, как явления, есть конкретная задача.
О любой структуре данных полезно думать с позиции всего четырёх операций:
- добавление элемента.
- удаление элемента.
- поиск элемента.
- хранение элементов.
Array - одна из первых структур данных - появился, чтобы удобно (непрерывно) хранить данные одного типа, быстро получать к ним доступ по индексам и выполнять над ними вычисления.
Singly Linked List (однонаправленный связный список), появившийся следом, отличался тем, что более эффективно использовал память, а также вставлял элементы в начало и конец за O(1). Массивы изначально были статическими: размер задавался заранее и не мог быть изменен. Чтобы добавить новые элементы, приходилось создавать новый массив - уже с новым элементов.
Любопытная черта динамических массивов в том, что, когда добавляется новый последний элемент, структура выделяет новый блок памяти - что выполняется за O(n) - больший, чем необходимо для добавления одного элемента. Выделенные заранее ячейки памяти заполняются уже за O(1).
Думайте о структурах данных в этом ключе. Так проще поймете и их, и алгоритмы.
А в следующем посте я покажу простой вариант Singly Linked List на JS. Внутри будет любопытный пример работы со стеком. 😉
Singly Linked List. Реализация.
Далеко ходить не буду - вот код:
https://github.com/Gretten/SinglyLinkedList/blob/main/SingleLinkedList.mjs
Как видим, связанный список - просто набор объектов со значением и ссылкой на следующий элемент. Есть небольшая оптимизация с хранением tail.
Далеко ходить не буду - вот код:
https://github.com/Gretten/SinglyLinkedList/blob/main/SingleLinkedList.mjs
Как видим, связанный список - просто набор объектов со значением и ссылкой на следующий элемент. Есть небольшая оптимизация с хранением tail.
GitHub
SinglyLinkedList/SingleLinkedList.mjs at main · Gretten/SinglyLinkedList
Just a LL. Contribute to Gretten/SinglyLinkedList development by creating an account on GitHub.
Хеш-таблица. В чём её крутизна? 😎
TL;DR: Вставкой, удалением и поиском со скоростью O(1). Это очень эффективно! Но затратно по памяти.
Развеем магию над этой структурой данных. Для начала, пройдемся по начинке.
Хеш-таблица - это просто массив с парочкой хитроумных методов оптимизации операций. Там могут быть:
- бакет (bucket): элемент массива со структурой данных, используемой как контейнер. Это может быть связанный список, двоичное дерево и другие. Бакеты используются только при чейнинге.
- хеш-функция: чёрный ящик, который принимает ключ и возвращает индекс массива.
- функция перераспределения (rehashing): увеличивает массив и распределяет элементы таблицы, когда их становится слишком много.
- алгоритм открытой адресации, если используется этот метод борьбы с коллизиями (о них позже).
В следующем посте остановимся подробнее на хеш-функциях :)
TL;DR: Вставкой, удалением и поиском со скоростью O(1). Это очень эффективно! Но затратно по памяти.
Развеем магию над этой структурой данных. Для начала, пройдемся по начинке.
Хеш-таблица - это просто массив с парочкой хитроумных методов оптимизации операций. Там могут быть:
- бакет (bucket): элемент массива со структурой данных, используемой как контейнер. Это может быть связанный список, двоичное дерево и другие. Бакеты используются только при чейнинге.
- хеш-функция: чёрный ящик, который принимает ключ и возвращает индекс массива.
- функция перераспределения (rehashing): увеличивает массив и распределяет элементы таблицы, когда их становится слишком много.
- алгоритм открытой адресации, если используется этот метод борьбы с коллизиями (о них позже).
В следующем посте остановимся подробнее на хеш-функциях :)
Please open Telegram to view this post
VIEW IN TELEGRAM
Хеш-таблица. Что такое хеш-функция?
Как я уже сказал, можно думать о хеш-функции о черном ящике, который:
1. всегда принимает ключ (и текущую длину массива).
2. всегда возвращает индекс массива.
Не так важно, как он устроен. Важно, что это чистая функция с чётко определенной задачей.
Усвоив это, углубимся в реализацию.
Хеш-функции сравнивают по разным критериям, главными из которых я назвал бы:
- качество: то, как эффективно хеш-функция распределяет элементы по таблице. Если большинство элементов попадает в несколько бакетов - функция плохо пахнет.
- скорость: как быстро работает сама функция.
- простота: как просто и понятно функция написана. Код пишут люди для людей. В особых случаях, конечно, простота первой идет под нож.
Наименее качественная, но наиболее простая и наглядная реализация - арифметическая:
Что здесь происходит?
Примем за условие, что ключи передаются строками или приравниваются к ним. Тогда строка:
- разбивается на символы
- возвращает свой индекс из ASCII-таблицы
- складывает индексы
- возвращает остаток от деления, который становится индексом массива.
Среди других реализаций есть криптографические, универсальные, с псевдослучайными числами и многие другие.
В следующем посте обсудим rehashing.
Как я уже сказал, можно думать о хеш-функции о черном ящике, который:
1. всегда принимает ключ (и текущую длину массива).
2. всегда возвращает индекс массива.
Не так важно, как он устроен. Важно, что это чистая функция с чётко определенной задачей.
Усвоив это, углубимся в реализацию.
Хеш-функции сравнивают по разным критериям, главными из которых я назвал бы:
- качество: то, как эффективно хеш-функция распределяет элементы по таблице. Если большинство элементов попадает в несколько бакетов - функция плохо пахнет.
- скорость: как быстро работает сама функция.
- простота: как просто и понятно функция написана. Код пишут люди для людей. В особых случаях, конечно, простота первой идет под нож.
Наименее качественная, но наиболее простая и наглядная реализация - арифметическая:
#h(key, tableLength) {
let ASCIICharKeysSum = 0;
for(let i = 0; i < key.length; i++) {
ASCIICharKeysSum += key[i].charCodeAt();
}
return ASCIICharKeysSum % tableLength;
}
Что здесь происходит?
Примем за условие, что ключи передаются строками или приравниваются к ним. Тогда строка:
- разбивается на символы
- возвращает свой индекс из ASCII-таблицы
- складывает индексы
- возвращает остаток от деления, который становится индексом массива.
Среди других реализаций есть криптографические, универсальные, с псевдослучайными числами и многие другие.
В следующем посте обсудим rehashing.
Хеш-таблица. Функция перераспределения, или rehashing.
Прежде чем разбираться, что такое рехешинг, ответим, зачем он нужен.
В первых реализациях хеш-таблицы были фиксированного размера. Массив выделялся под небольшое количество ключей, и это лишало таблицы универсальности.
Rehashing решил эту проблему: в таблицах может храниться сколько угодно элементов - ценой сложности O(n).
Что, в сущности, такое рехешинг? Это просто создание нового массива, пересоздание хешей и перераспределение данных. Та же технология, что и в динамических массивах.
Но постойте, что значит сложность O(n)?! Где хваленый (O1)?
Не спешите ужасаться. Рехешинг выполняется редко. Например, если массив состоит из 32 элементов, а рехешинг увеличивает его каждый раз вдвое, выполнится он только 5 раз.
Вот реализация в моей хеш-таблице:
А в следующем посте поговорим про коллизии :)
Прежде чем разбираться, что такое рехешинг, ответим, зачем он нужен.
В первых реализациях хеш-таблицы были фиксированного размера. Массив выделялся под небольшое количество ключей, и это лишало таблицы универсальности.
Rehashing решил эту проблему: в таблицах может храниться сколько угодно элементов - ценой сложности O(n).
Что, в сущности, такое рехешинг? Это просто создание нового массива, пересоздание хешей и перераспределение данных. Та же технология, что и в динамических массивах.
Но постойте, что значит сложность O(n)?! Где хваленый (O1)?
Не спешите ужасаться. Рехешинг выполняется редко. Например, если массив состоит из 32 элементов, а рехешинг увеличивает его каждый раз вдвое, выполнится он только 5 раз.
Вот реализация в моей хеш-таблице:
#allocateSpace() {
const newTable = new Array(this.table.length * 2) // таблица при каждом вызове увеличивается вдвое
.fill(null)
.map(_ => new LinkedList()); // каждый бакет использует связанный список
const bucketNodes = [];
for(const bucket of this.table) {
bucket.traverse((node) => { // метод traverse обходит связанный список, используемый в бакете
if(node) bucketNodes.push(node.value);
})
}
for(const node of bucketNodes) {
const newHash = this.#h(node.key, newTable.length);
newTable[newHash].append({
key: node.key,
value: node.value,
});
}
this.table = newTable;
}
А в следующем посте поговорим про коллизии :)
Хеш-таблица. Коллизии.
Коллизия - это когда для разных ключей вычисляется одинаковый хеш. Таблица попытается поместить элементы в один бакет.
Мы можем либо разрешить, либо запретить это.
- Стратегия "разрешить" называется "chaining".
- Стратегия "запретить" называется "открытая адресация".
Chaining: элементы массива хранят структуры данных, в которые при коллизии добавляются новые значения.
Достоинства чейнинга - в относительной простоте и избегании проблем, которые приносит стратегия "запретить".
Недостаток - в том, что, в худшем случае (если для всех ключей будет вычислен одинаковый хеш), сложность поиска и удаления станет O(n).
Для оптимизации поиска вместо простого связанного списке могут использоваться более сложные структуры данных, включая двоичные деревья. В связке с ними используют рехешинг.
Открытая адресация: один элемент массива хранит непосредственно одно значение.
Достоинства - в том, что такая реализация не требует дополнительных структур данных.
Недостаток вытекает из достоинства. Коллизии всё равно происходят, но просто добавить элементы в одну ячейку - нельзя.
Это решают поиском следующего свободного элемента.
Отсюда возникает следующая проблема: кластеры.
Что это такое? Представьте, что для двух десятков ключей хеш-функция вычислила только четыре хеша. Тогда в массиве возникнет четыре последовательных набора элементов, а остальные места в массиве остаются пустыми.
Борьба с кластерами - отдельная интересная тема. Все методы сводятся к тому, чтобы повысить вероятность равномерного заполнения таблицы. Например, используют две хеш-функции: одну для вычисления индекса, другую - для вычисления уникального множителя под этот индекс.
Исходя из этого, заключим, что операции вставки, удаления и поиска в хеш-таблице имеют амортизированную константную сложность. Это значит, что, несмотря на присутствие операций со сложностью O(n) или выше, обычно операции выполняются за O(1) или близкое к тому время.
Коллизия - это когда для разных ключей вычисляется одинаковый хеш. Таблица попытается поместить элементы в один бакет.
Мы можем либо разрешить, либо запретить это.
- Стратегия "разрешить" называется "chaining".
- Стратегия "запретить" называется "открытая адресация".
Chaining: элементы массива хранят структуры данных, в которые при коллизии добавляются новые значения.
Достоинства чейнинга - в относительной простоте и избегании проблем, которые приносит стратегия "запретить".
Недостаток - в том, что, в худшем случае (если для всех ключей будет вычислен одинаковый хеш), сложность поиска и удаления станет O(n).
Для оптимизации поиска вместо простого связанного списке могут использоваться более сложные структуры данных, включая двоичные деревья. В связке с ними используют рехешинг.
Открытая адресация: один элемент массива хранит непосредственно одно значение.
Достоинства - в том, что такая реализация не требует дополнительных структур данных.
Недостаток вытекает из достоинства. Коллизии всё равно происходят, но просто добавить элементы в одну ячейку - нельзя.
Это решают поиском следующего свободного элемента.
Отсюда возникает следующая проблема: кластеры.
Что это такое? Представьте, что для двух десятков ключей хеш-функция вычислила только четыре хеша. Тогда в массиве возникнет четыре последовательных набора элементов, а остальные места в массиве остаются пустыми.
Борьба с кластерами - отдельная интересная тема. Все методы сводятся к тому, чтобы повысить вероятность равномерного заполнения таблицы. Например, используют две хеш-функции: одну для вычисления индекса, другую - для вычисления уникального множителя под этот индекс.
Исходя из этого, заключим, что операции вставки, удаления и поиска в хеш-таблице имеют амортизированную константную сложность. Это значит, что, несмотря на присутствие операций со сложностью O(n) или выше, обычно операции выполняются за O(1) или близкое к тому время.
Задачка: что выведет undefined ^ null ^ 2 ^ true ^ 2?
Anonymous Poll
38%
Выбросит ошибку
0%
0
38%
1
13%
2
13%
null
Дэн Щербаков ⚛️
Задачка: что выведет undefined ^ null ^ 2 ^ true ^ 2?
Правильный ответ - 1.
Почему?
В коде используется оператор XOR - исключающее "ИЛИ". Как оно работает:
Этот оператор преобразует числа в 32-битное представление. Например, 0 - к 00000000 00000000 00000000 00000000.
В свою очередь, типы отсутствия, такие как null и undefined, приводятся к 0. Положительный булев тип приводится к единице.
Что же мы имеем?
1. undefined ^ null ^ 2 ^ true ^ 2
2. 0 ^ 0 ^ 2 ^ 1 ^ 2
3. Слева-направо:
1.
2.
3.
4.
Приведем результат последнего вычисления к десятичному формату - и получим 1.
Эврика!
Этот принцип лежит за решением задачи Leetcode по поиску единственного числа без дублей в массиве, или single number:
https://leetcode.com/problems/single-number/
Почему?
В коде используется оператор XOR - исключающее "ИЛИ". Как оно работает:
1 | 1 = 0
1 | 0 = 1
0 | 1 = 1
0 | 0 = 0
Этот оператор преобразует числа в 32-битное представление. Например, 0 - к 00000000 00000000 00000000 00000000.
В свою очередь, типы отсутствия, такие как null и undefined, приводятся к 0. Положительный булев тип приводится к единице.
Что же мы имеем?
1. undefined ^ null ^ 2 ^ true ^ 2
2. 0 ^ 0 ^ 2 ^ 1 ^ 2
3. Слева-направо:
1.
00000000 00000000 00000000 00000000
^
00000000 00000000 00000000 00000000
=
00000000 00000000 00000000 00000000
2.
00000000 00000000 00000000 00000000
^
00000000 00000000 00000000 00000010
=
00000000 00000000 00000000 00000010
3.
00000000 00000000 00000000 00000010
^
00000000 00000000 00000000 00000001
=
00000000 00000000 00000000 00000011
4.
00000000 00000000 00000000 00000011
^
00000000 00000000 00000000 00000010
=
00000000 00000000 00000000 00000001
Приведем результат последнего вычисления к десятичному формату - и получим 1.
Эврика!
Этот принцип лежит за решением задачи Leetcode по поиску единственного числа без дублей в массиве, или single number:
https://leetcode.com/problems/single-number/
LeetCode
Single Number - LeetCode
Can you solve this real interview question? Single Number - Given a non-empty array of integers nums, every element appears twice except for one. Find that single one.
You must implement a solution with a linear runtime complexity and use only constant extra…
You must implement a solution with a linear runtime complexity and use only constant extra…
Побитовые операции. База
- Все данные находятся в памяти в виде двоичного кода.
- Двоичный код - это совокупность битов.
- Биты измеряют в разных величинах: 8 бит - байт, 16 бит - машинное слово, 32 или 64 бита - слова для соответствующих процессоров, и так далее.
- Правые биты называются младшими. Левые - старшими.
- Самый старший бит указывает знак числа.
- Числа в JS представлены в виде 64 бит.
- Побитовые операции приводят числа к 32-битному формату.
- Чтение, переключение и установка битов - то, ради чего нужны побитовые операции.
- Самый частый паттерн работы с битами - маски. Маска - это второй операнд для побитовой операции.
Пример работы с маской. В битах программист может зашифровать булевы состояния. Например, три состояния (или 9 возможных комбинаций) в трёх младших битах:
"00000000000000000000000000000100"
"00000000000000000000000000000010"
"00000000000000000000000000000001"
Как прочитать текущее состояние? Через маску и оператор & (И).
"00000000000000000000000000000100" - текущее состояние.
"00000000000000000000000000000100" - маска для проверки третьего бита.
Применим &...
"00000000000000000000000000000100", так как 1 & 1 = 1.
Как установить состояние бита? Через маску и оператор | (ИЛИ).
"00000000000000000000000000000000" - текущее состояние.
"00000000000000000000000000000100" - маска для установки третьего бита.
Применим |...
"00000000000000000000000000000100", так как 0 | 1 = 1. Если бит в первом операнде будет 1, то результат все равно будет 1.
Как переключить состояние бита? Через маску и оператор ^ (Исключающее ИЛИ).
"00000000000000000000000000000000" - текущее состояние.
"00000000000000000000000000000100" - маска для переключения третьего бита.
Применим ^...
"00000000000000000000000000000100", так как 0 ^ 1 = 1. Если бит в первом операнде будет 1, то результат будет 0.
И наглядный экземпляр кода.
1. Функция проверки бита по динамической маске:
Что здесь происходит?
1. Первый аргумент - это первый операнд в формате десятичного числа.
2. Второй аргумент - количество, на которое нужно сдвинуть бит в маске (изначально равной 1).
3. Вычисляется выражение в скобках. Происходит сдвиг по указанному индексу - например, бит передвигается на 9 позиций влево.
4. Операнды сравниваются.
5. Результат может быть 0 или 1.
- Все данные находятся в памяти в виде двоичного кода.
- Двоичный код - это совокупность битов.
- Биты измеряют в разных величинах: 8 бит - байт, 16 бит - машинное слово, 32 или 64 бита - слова для соответствующих процессоров, и так далее.
- Правые биты называются младшими. Левые - старшими.
- Самый старший бит указывает знак числа.
- Числа в JS представлены в виде 64 бит.
- Побитовые операции приводят числа к 32-битному формату.
- Чтение, переключение и установка битов - то, ради чего нужны побитовые операции.
- Самый частый паттерн работы с битами - маски. Маска - это второй операнд для побитовой операции.
Пример работы с маской. В битах программист может зашифровать булевы состояния. Например, три состояния (или 9 возможных комбинаций) в трёх младших битах:
"00000000000000000000000000000100"
"00000000000000000000000000000010"
"00000000000000000000000000000001"
Как прочитать текущее состояние? Через маску и оператор & (И).
"00000000000000000000000000000100" - текущее состояние.
"00000000000000000000000000000100" - маска для проверки третьего бита.
Применим &...
"00000000000000000000000000000100", так как 1 & 1 = 1.
Как установить состояние бита? Через маску и оператор | (ИЛИ).
"00000000000000000000000000000000" - текущее состояние.
"00000000000000000000000000000100" - маска для установки третьего бита.
Применим |...
"00000000000000000000000000000100", так как 0 | 1 = 1. Если бит в первом операнде будет 1, то результат все равно будет 1.
Как переключить состояние бита? Через маску и оператор ^ (Исключающее ИЛИ).
"00000000000000000000000000000000" - текущее состояние.
"00000000000000000000000000000100" - маска для переключения третьего бита.
Применим ^...
"00000000000000000000000000000100", так как 0 ^ 1 = 1. Если бит в первом операнде будет 1, то результат будет 0.
И наглядный экземпляр кода.
1. Функция проверки бита по динамической маске:
const readBit = (number, index) => {
return number & (1 << index);
}
Что здесь происходит?
1. Первый аргумент - это первый операнд в формате десятичного числа.
2. Второй аргумент - количество, на которое нужно сдвинуть бит в маске (изначально равной 1).
3. Вычисляется выражение в скобках. Происходит сдвиг по указанному индексу - например, бит передвигается на 9 позиций влево.
4. Операнды сравниваются.
5. Результат может быть 0 или 1.
Задачки на побитовые операции.
Попробуйте сами!
1. Напишите программу, вычисляющую заданную степень числа 2, используя битовые операции.
Условие: n < 31.
Как решать: вспомнить, как умножать побитовыми операциями, и что такое дополнительный код знака числа.
const foo = (n) => (2 << (n-1)) >>> 0;
2. Напишите программу, которая в заданном числе устанавливает определенный бит в 1 (биты при этом нумеруются с нуля, начиная от младших).
Как решать: вспомнить, какая операция нужна для установки бита в 1, и создать маску для такой операции.
const foo = (A, i) => {
const mask = 1 << i;
return A | mask;
}
3. Напишите программу, определяющую значение i-го бита числа.
Как решать: выполнить сдвиг А на значение i, чтобы искомый бит оказался на последнем месте, и вспомнить, какая операция нужна, чтобы прочесть состояние бита.
const foo = (A, i) => (A >> i) & 1
Попробуйте сами!
1. Напишите программу, вычисляющую заданную степень числа 2, используя битовые операции.
Условие: n < 31.
Как решать: вспомнить, как умножать побитовыми операциями, и что такое дополнительный код знака числа.
2. Напишите программу, которая в заданном числе устанавливает определенный бит в 1 (биты при этом нумеруются с нуля, начиная от младших).
Как решать: вспомнить, какая операция нужна для установки бита в 1, и создать маску для такой операции.
const mask = 1 << i;
return A | mask;
}
3. Напишите программу, определяющую значение i-го бита числа.
Как решать: выполнить сдвиг А на значение i, чтобы искомый бит оказался на последнем месте, и вспомнить, какая операция нужна, чтобы прочесть состояние бита.
Leetcode. First Occurrence
Пока готовлю большой материал по бинарным деревьям, занимаюсь задачами с Leetcode. Работаю с методом двух указателей.
Сегодня решил задачу поиска первого совпадения в строке: https://leetcode.com/problems/find-the-index-of-the-first-occurrence-in-a-string/
Однако в первом решении обошелся одним указателем:
Решить помогла другая задача:
https://leetcode.com/problems/remove-duplicates-from-sorted-array
Самое популярное решение создает "плавающее окно" между переменной i цикла for и i-1. Это окно движется по массиву, перемещая элементы. Тот же принцип я использовал и здесь. :)
В следующем посте опишу вариант честнее - без встроенных методов, более академический.
Пока готовлю большой материал по бинарным деревьям, занимаюсь задачами с Leetcode. Работаю с методом двух указателей.
Сегодня решил задачу поиска первого совпадения в строке: https://leetcode.com/problems/find-the-index-of-the-first-occurrence-in-a-string/
Однако в первом решении обошелся одним указателем:
const strStr = (haystack, needle) => {
for(let i = needle.length-1; i <= haystack.length; i++) {
const currentSlice = haystack.slice(i-needle.length, i);
if(currentSlice === needle) {
return i-needle.length;
}
}
return -1;
};
Решить помогла другая задача:
https://leetcode.com/problems/remove-duplicates-from-sorted-array
Самое популярное решение создает "плавающее окно" между переменной i цикла for и i-1. Это окно движется по массиву, перемещая элементы. Тот же принцип я использовал и здесь. :)
В следующем посте опишу вариант честнее - без встроенных методов, более академический.
LeetCode
Find the Index of the First Occurrence in a String - LeetCode
Can you solve this real interview question? Find the Index of the First Occurrence in a String - Given two strings needle and haystack, return the index of the first occurrence of needle in haystack, or -1 if needle is not part of haystack.
Example 1:…
Example 1:…
Дэн Щербаков ⚛️
Leetcode. First Occurrence Пока готовлю большой материал по бинарным деревьям, занимаюсь задачами с Leetcode. Работаю с методом двух указателей. Сегодня решил задачу поиска первого совпадения в строке: https://leetcode.com/problems/find-the-index-of-the…
Leetcode. First Occurrence: Алгоритм Рабина-Карпа. Скользящая хеш-функция.
На собеседованиях цель не решить задачу хотя бы как-то, а:
1. Оптимально,
2. Осмысленно.
В случае поиска подстрок компании ожидают увидеть решение за О(n). С этой скоростью задачу решает алгоритм Рабина-Карпа. Это базовый алгоритм, на котором можно строить решения других подобных задач.
Этот алгоритм использует в работе хеш-функцию. Но не какую угодно, а скользящую! Одна из популярных и простых скользящих хеш-функций - полиномиальная.
Но что значит "скользящая" хеш-функция? В такой функции хэши не вычисляются на каждой итерации заново для каждой подстроки, а, используя хэш предыдущей подстроки, удаляется влияние предыдущего и добавляется следующий.
В наивном решении сложность возрастает до О(n * m), где n - длина строки, а m - длина искомой подстроки. Наивный алгоритм перебирает каждую подстроку, пока не найдет решение.
Алгоритм Рабина-Карпа не перебирает подстроки на каждой итерации, а сравнивает хэш искомой подстроки с хэшем новой, полученным на основе предыдущей. Только в случае совпадения алгоритм сравнивает строки перебором, чтобы удостовериться в результате. Таким образом добиваемся скорости O(n+m), или O(n).
Давайте напишем этот алгоритм...
На собеседованиях цель не решить задачу хотя бы как-то, а:
1. Оптимально,
2. Осмысленно.
В случае поиска подстрок компании ожидают увидеть решение за О(n). С этой скоростью задачу решает алгоритм Рабина-Карпа. Это базовый алгоритм, на котором можно строить решения других подобных задач.
Этот алгоритм использует в работе хеш-функцию. Но не какую угодно, а скользящую! Одна из популярных и простых скользящих хеш-функций - полиномиальная.
Но что значит "скользящая" хеш-функция? В такой функции хэши не вычисляются на каждой итерации заново для каждой подстроки, а, используя хэш предыдущей подстроки, удаляется влияние предыдущего и добавляется следующий.
В наивном решении сложность возрастает до О(n * m), где n - длина строки, а m - длина искомой подстроки. Наивный алгоритм перебирает каждую подстроку, пока не найдет решение.
Алгоритм Рабина-Карпа не перебирает подстроки на каждой итерации, а сравнивает хэш искомой подстроки с хэшем новой, полученным на основе предыдущей. Только в случае совпадения алгоритм сравнивает строки перебором, чтобы удостовериться в результате. Таким образом добиваемся скорости O(n+m), или O(n).
Давайте напишем этот алгоритм...
Алгоритм Рабина-Карпа. Часть 1: полиномиальный хэш.
Для начала научимся писать полиномиальную хэш-функцию.
Из каких шагов состоит алгоритм?
1. Вычислить полином для строки.
2. Вернуть результат деления полинома по модулю.
Полином вычисляется по формуле:
P(x) = base * s[i]**1 + base * s[i]**2 + base * s[i]**3 (...)
Комбинация пунктов может выглядеть так:
В следующем посте напишем функцию рехешинга.
Для начала научимся писать полиномиальную хэш-функцию.
Из каких шагов состоит алгоритм?
1. Вычислить полином для строки.
2. Вернуть результат деления полинома по модулю.
Полином вычисляется по формуле:
P(x) = base * s[i]**1 + base * s[i]**2 + base * s[i]**3 (...)
Комбинация пунктов может выглядеть так:
/**
* Вычисляет полиномальный хеш
* @param str { string } - строка, для которой хеш вычисляется
* @param base { number } - основание для создания частей полинома
* @param mod { number } - модуль, остаток деления на который - искомый хеш
* @returns { number } полиномальный хеш
*/
const polynomialHash = (str, base = 256, mod = 101) => {
let result = 0;
for(let i = 0; i < str.length; i++) {
const charPosition = str.codePointAt(i);
let currentPart = charPosition * (base ** i);
result += currentPart;
}
return result % mod;
}
В следующем посте напишем функцию рехешинга.
Алгоритм Кнута-Морриса-Пратта.
Рабин-Карп потребовал больше понимания, чем я предполагал - ведь я стремлюсь досконально понимать алгоритмы, а не зубрить. За две недели, даже с учебником линейной алгебры, не смог воспроизвести Рабина-Карпа. И понял: пора остановиться.
В поиске альтернативы узнал об алгоритме Кнута-Морриса-Пратта. И это было открытие! Алгоритм очень прост, логичен и популярен - ведь его сложность равна О(n + m), где n - длина текста, а m - длина искомой подстроки.
Как и Рабин-Карп, алгоритм можно применять для поиска первого вхождения подстроки в текст. В нём так же предвычисляется вспомогательная структура - но не хеш, а таблица префиксов.
Как он работает? Давайте разбираться...
Рабин-Карп потребовал больше понимания, чем я предполагал - ведь я стремлюсь досконально понимать алгоритмы, а не зубрить. За две недели, даже с учебником линейной алгебры, не смог воспроизвести Рабина-Карпа. И понял: пора остановиться.
В поиске альтернативы узнал об алгоритме Кнута-Морриса-Пратта. И это было открытие! Алгоритм очень прост, логичен и популярен - ведь его сложность равна О(n + m), где n - длина текста, а m - длина искомой подстроки.
Как и Рабин-Карп, алгоритм можно применять для поиска первого вхождения подстроки в текст. В нём так же предвычисляется вспомогательная структура - но не хеш, а таблица префиксов.
Как он работает? Давайте разбираться...
Алгоритм Кнута-Морриса-Пратта: префикс-функция
Заранее оговорюсь, что за полноценной теорией - не сюда, а в учебник. Здесь только практика и мои замечания.
В чем задача префикс-функции? Она подсчитывает префиксы строки. Точнее, части строки, которые являются её же суффиксами.
Например, в строке "abab" префиксо-суффиксы - это "ab".
Функция всегда принимает строку и возвращает массив (вектор, итератор...) чисел - тех самых префиксов. Этот массив используется в КМП для подсчета отступа, если найденное частичное вхождение строки прерывается.
И префикс-функцию, и использующий ее алгоритм КМП можно сделать через два указателя.
Заранее оговорюсь, что за полноценной теорией - не сюда, а в учебник. Здесь только практика и мои замечания.
В чем задача префикс-функции? Она подсчитывает префиксы строки. Точнее, части строки, которые являются её же суффиксами.
Например, в строке "abab" префиксо-суффиксы - это "ab".
Функция всегда принимает строку и возвращает массив (вектор, итератор...) чисел - тех самых префиксов. Этот массив используется в КМП для подсчета отступа, если найденное частичное вхождение строки прерывается.
И префикс-функцию, и использующий ее алгоритм КМП можно сделать через два указателя.
const prefix = (str) => {
if(typeof str !== 'string') {
throw new Error('В префикс-функцию переданы данные не строкового типа')
}
const prefixTable = Array(str.length).fill(0);
let i = 1,
j = 0;
while(i < str.length) {
while(j > 0 && str[i] !== str[j]) {
j = prefixTable[j-1];
}
if(str[i] === str[j]) {
j++;
}
prefixTable[i] = j;
i++;
}
return prefixTable;
}
Алгоритм Кнута-Морриса-Пратта: тело алгоритма
Для меня удовольствие избегать циклов for. While подобен опасной бритве: ошибка в условии выхода из цикла - и программа сходит с ума в дурной бесконечности. Программист чувствует себя живым, как на штурме дворца Амина.
Но вернемся к алгоритму.
Как видите, во многом он похож на префикс-функцию. Тот же while для отката индекса j на каждом цикле, та же проверка на совпадение. Только индекс i указывает на символы текста, а j - на подстроку.
Всё гениальное - просто.
Для меня удовольствие избегать циклов for. While подобен опасной бритве: ошибка в условии выхода из цикла - и программа сходит с ума в дурной бесконечности. Программист чувствует себя живым, как на штурме дворца Амина.
Но вернемся к алгоритму.
Как видите, во многом он похож на префикс-функцию. Тот же while для отката индекса j на каждом цикле, та же проверка на совпадение. Только индекс i указывает на символы текста, а j - на подстроку.
Всё гениальное - просто.
const KMP = (text, substr) => {
const pi = prefix(substr);
if(!pi) {
return null;
}
let j = 0;
let i = 0;
while(i < text.length) {
while(text[i] !== substr[j] && j > 0) {
j = pi[j - 1];
}
if(text[i] === substr[j]) {
j++;
}
if(j === substr.length) {
return i - j + 1;
}
i++;
}
return -1;
}
Про рекурсию
Много воды утекло с тех пор, как я переключился с деревьев на алгоритмы поиска подстрок. Многое забыл. Со вчерашнего вечера разбираю AVL-деревья: как увеличивать параметр высоты нод при добавлении новых и выполнять балансировку.
Вспомнил, что для вставки, удаления и поиска в сбалансированных деревьях идеально подходит рекурсия. Почему? Две причины: сложность основных операций - O(logN), а сущность операций - обход одинаковых нод. Балансировка и обработка высот выполняется уже после добавления ноды. В цикле это сложно сделать, а вот рекурсия каким-то образом "восходит" назад к корню.
И вот ночью мне снится сон про рекурсию. В котором увидел, что...
Рекурсивная функция может обрабатывать данные в обратном порядке!
Нужно только вызывать блок обработки данных после рекурсивного вызова.
Дело в том, что рекурсивные вызовы создают стек вызовов - до тех пор, пока не выполнится условия выхода из функции. И только программисту решать, на каком этапе создания этой стопки тарелок обрабатывать данные - последовательно или в обратном направлении, когда стек очищается.
Вот пример рекурсивного обхода массива с конца:
В итоге я понял, почему рекурсия идеально подходит для деревьев - и как её особенности помогают в балансировке.
Много воды утекло с тех пор, как я переключился с деревьев на алгоритмы поиска подстрок. Многое забыл. Со вчерашнего вечера разбираю AVL-деревья: как увеличивать параметр высоты нод при добавлении новых и выполнять балансировку.
Вспомнил, что для вставки, удаления и поиска в сбалансированных деревьях идеально подходит рекурсия. Почему? Две причины: сложность основных операций - O(logN), а сущность операций - обход одинаковых нод. Балансировка и обработка высот выполняется уже после добавления ноды. В цикле это сложно сделать, а вот рекурсия каким-то образом "восходит" назад к корню.
И вот ночью мне снится сон про рекурсию. В котором увидел, что...
Рекурсивная функция может обрабатывать данные в обратном порядке!
Нужно только вызывать блок обработки данных после рекурсивного вызова.
Дело в том, что рекурсивные вызовы создают стек вызовов - до тех пор, пока не выполнится условия выхода из функции. И только программисту решать, на каком этапе создания этой стопки тарелок обрабатывать данные - последовательно или в обратном направлении, когда стек очищается.
Вот пример рекурсивного обхода массива с конца:
const rec = [0, 4, 8, 11, 14, 15, 17, 18, 20, 22]
const foo = (arr, index) => {
if(index === arr.length) {
return;
}
foo(arr, index+1);
console.log(arr[index])
}
foo(rec, 0)
В итоге я понял, почему рекурсия идеально подходит для деревьев - и как её особенности помогают в балансировке.