JavaScript: структуры данных и алгоритмы. Часть 6

от автора

Привет, друзья!

В этой серии статей мы разбираем структуры данных и алгоритмы, представленные в этом замечательном репозитории. Это шестая часть серии, в которой мы начинаем разбирать алгоритмы.

Сегодня мы поговорим об алгоритмах для работы с множествами.

Код, представленный в этой и других статьях серии, можно найти в этом репозитории.

С вашего позволения, я не буду рассказывать о математических алгоритмах, соответствующий код вместе с комментариями и тестами можно найти в директории math.

Интересно? Тогда прошу под кат.

Множества

Прямое произведение

Описание

В теории множеств прямое (декартово) произведение (Cartesian product) — это математическая операция, которая возвращает множество, элементами которого являются все возможные упорядоченные пары элементов исходных множеств. Таким образом, для множеств A и B прямое произведение — это множество упорядоченных пар (a, b), где a ∈ A и b ∈ B.

Прямое произведение A x B множеств A={ x, y, z } и B={ 1, 2, 3 }:

Реализация

// algorithms/sets/cartesian-product.js export default function cartesianProduct(a, b) {   if (!a?.length || !b?.length) return null    return a.map((x) => b.map((y) => [x, y])).reduce((a, b) => a.concat(b), []) }

Тестирование

// algorithms/sets/__tests__/cartesian-product.test.js import cartesianProduct from '../cartesian-product'  describe('cartesianProduct', () => {   it('должен вернуть `null` при отсутствии одного из множеств', () => {     const product1 = cartesianProduct([1], null)     const product2 = cartesianProduct([], null)      expect(product1).toBeNull()     expect(product2).toBeNull()   })    it('должен вычислить произведение двух множеств', () => {     const product1 = cartesianProduct([1], [1])     const product2 = cartesianProduct([1, 2], [3, 5])      expect(product1).toEqual([[1, 1]])     expect(product2).toEqual([       [1, 3],       [1, 5],       [2, 3],       [2, 5],     ])   }) })

Запускаем тесты:

npm run test ./algorithms/sets/__tests__/cartesian-product

Результат:

Тасование Фишера-Йетса

Описание

Тасование Фишера-Йетса — это алгоритм создания произвольных перестановок конечного множества, проще говоря, для произвольного тасования множества. Правильно реализованный алгоритм несмещенный, так что каждая перестановка генерируется с одинаковой вероятностью. Современная версия алгоритма очень эффективна и требует время, пропорциональное числу элементов множества, а также не требует дополнительной памяти.

Основная процедура тасования Фишера-Йетса аналогична случайному вытаскиванию записок с числами из шляпы или карт из колоды, один элемент за другим, пока элементы не кончатся. Алгоритм обеспечивает эффективный и строгий метод таких операций, гарантирующий несмещенный результат.

Реализация

// algorithms/sets/fisher-yates.js export default function fisherYates(arr) {   // Эффективно создаем глубокую копию массива   // https://developer.mozilla.org/en-US/docs/Web/API/structuredClone   const arrCopy = structuredClone(arr)    for (let i = arrCopy.length - 1; i > 0; i--) {     const j = Math.floor(Math.random() * (i + 1))     ;[arrCopy[i], arrCopy[j]] = [arrCopy[j], arrCopy[i]]   }    return arrCopy }

Тестирование

// algorithms/sets/__tests__/fisher-yates.test.js import fisherYates from '../fisher-yates'  const sortedArr = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]  describe('fisherYates', () => {   it('должен тасовать маленькие массивы', () => {     expect(fisherYates([])).toEqual([])     expect(fisherYates([1])).toEqual([1])   })    it('должен произвольно перетасовать элементы массива', () => {     const shuffledArray = fisherYates(sortedArr)      expect(shuffledArray.length).toBe(sortedArr.length)     expect(shuffledArray).not.toEqual(sortedArr)     expect(shuffledArray.sort()).toEqual(sortedArr)   }) })

Запускаем тесты:

npm run test ./algorithms/sets/__tests__/fisher-yates

Результат:

Множество всех подмножеств

Описание

Множество всех подмножеств (булеан, показательное множество) (power set) — множество, состоящее из всех подмножеств данного множества
S (включая пустое множество и само множество S); обозначается
P(S) или 2^S (так как оно соответствует множеству отображений из S в {0, 1}).

Например, множеством всех подмножеств множества { x, y, z } будет:

{   {}, // (также обозначается как ∅ или нулевое подмножество)   {x},   {y},   {z},   {x, y},   {x, z},   {y, z},   {x, y, z} }

Иллюстрация элементов этого множества, упорядоченных по порядку включения:

Количество подмножеств

Если S — это конечное множество, содержащее |S| = n элементов, то количество подмножеств S равняется |P(S)| = 2^n.

Алгоритмы

Двоичный подход

Биты каждого числа двоичного представления в диапазоне от 0 до 2^n показывают, следует ли включать соответствующий элемент множества в подмножество (1 — включать, 0 — не включать). Например, для множества { 1, 2, 3 } бинарное число 0b010 означает, что в подмножество включается только второй элемент множества, т.е. 2.

abc Подмножество
0 000 {}
1 001 {c}
2 010 {b}
3 011 {c, b}
4 100 {a}
5 101 {a, c}
6 110 {a, b}
7 111 {a, b, c}

Реализация

// algorithms/sets/power-set/bitwise.js export default function bitwise(set) {   const subsets = []    // Количество подмножеств - `2^n`, где `n` - количество элементов в `set`.   // Это обусловлено тем, что для каждого элемента `set` мы будем решать,   // включать его в подмножество или нет (2 варианта на каждый элемент)   const numberOfCombinations = 2 ** set.length    for (let i = 0; i < numberOfCombinations; i++) {     const subset = []      for (let j = 0; j < set.length; j++) {       // Решаем, включать текущий элемента в подмножество или нет       if (i & (1 << j)) {         subset.push(set[j])       }     }      subsets.push(subset)   }    return subsets }

Тестирование

// algorithms/sets/power-set/__tests__/bitwise.test.js import bitwise from '../bitwise'  describe('bitwise', () => {   it('должен вычислить множества всех подмножеств с помощью бинарного подхода', () => {     expect(bitwise([1])).toEqual([[], [1]])      expect(bitwise([1, 2, 3])).toEqual([       [],       [1],       [2],       [1, 2],       [3],       [1, 3],       [2, 3],       [1, 2, 3],     ])   }) })

Рекурсивный подход

При таком подходе мы рекурсивно пытаемся добавить следующий элемент множества в подмножество, запоминаем подмножество, затем удаляем текущий элемент и берем следующий.

Реализация

// algorithms/sets/power-set/backtracking.js export default function backtracking(   set,   allSubsets = [[]],   currentSubset = [],   start = 0, ) {   // Перебираем элементы множества, которые могут быть добавлены в подмножество   // без дублирования (это обеспечивается значением `start`)   for (let i = start; i < set.length; i++) {     // Добавляем текущий элемент в подмножество     currentSubset.push(set[i])      // Текущее подмножество является валидным, запоминаем его.     // `structuredClone()` создает копию `currentSubset`.     // Это необходимо, поскольку `currentSubset` будет модифицирован     // в дальнейших рекурсивных вызовах     allSubsets.push(structuredClone(currentSubset))      // Генерируем другие подмножества для текущего подмножества.     // В качестве значения `start` передаем `i + 1` во избежание дублирования     backtracking(set, allSubsets, currentSubset, i + 1)      // Удаляем последний элемент и берем следующий     currentSubset.pop()   }    return allSubsets }

Тестирование

// algorithms/sets/power-set/__tests__/backtracking.test.js import backtracking from '../backtracking'  describe('backtracking', () => {   it('должен вычислить множества всех подмножеств с помощью рекурсивного подхода', () => {     expect(backtracking([1])).toEqual([[], [1]])      expect(backtracking([1, 2, 3])).toEqual([       [],       [1],       [1, 2],       [1, 2, 3],       [1, 3],       [2],       [2, 3],       [3],     ])   }) })

Каскадный подход

Вероятно, это самый простой способ создания множества всех подмножеств.

Предположим, что у нас есть множество originalSet = [1, 2, 3].

Мы начинаем с пустого подмножества:

powerSets = [[]]

Добавляем первый элемент originalSet во все существующие подмножества:

[[]] ← 1 = [[], [1]]

Добавляем второй элемент:

[[], [1]] ← 2 = [[], [1], [2], [1, 2]]

Добавляем третий элемент:

[[], [1], [2], [1, 2]] ← 3 = [[], [1], [2], [1, 2], [3], [1, 3], [2, 3], [1, 2, 3]]

И так для всех элементов originalSet. На каждой итерации количество множеств удваивается, поэтому в итоге мы получаем 2^n множеств.

Реализация

// algorithms/sets/power-set/cascading.js export default function cascading(set) {   // Начинаем с пустого множества   const sets = [[]]    for (let i = 0; i < set.length; i++) {     // Без этого мы получим бесконечный цикл,     // поскольку длина `sets` будет увеличиваться на каждой итерации     const len = sets.length     for (let j = 0; j < len; j++) {       const _set = [...sets[j], set[i]]       sets.push(_set)     }   }    return sets }

Тестирование

// algorithms/sets/power-set/__tests__/cascading.test.js import cascading from '../cascading'  describe('cascading', () => {   it('должен вычислить множества всех подмножеств с помощью каскадного подхода', () => {     expect(cascading([1])).toEqual([[], [1]])      expect(cascading([1, 2])).toEqual([[], [1], [2], [1, 2]])      expect(cascading([1, 2, 3])).toEqual([       [],       [1],       [2],       [1, 2],       [3],       [1, 3],       [2, 3],       [1, 2, 3],     ])   }) })

Запускаем тесты:

npm run test ./algorithms/sets/power-set

Результат:

Перестановки и комбинации

Описание

Когда порядок элементов не важен, это комбинация (combination).

Когда порядок элементов важен, это перестановка (permutation).

Перестановки

Перестановки без повторений

Перестановка (permutation) — это перестановка элементов упорядоченного списка S во взаимно однозначное соответствие самому S.

Пример перестановок строки ABC:

ABC ACB BAC BCA CBA CAB

Еще один пример — первые три человека в гонке: вы не можете одновременно быть и первым, и вторым.

Количество вариантов:

n * (n-1) * (n -2) * ... * 1 = n! (факториал `n`)

Реализация

// algorithms/sets/permutations/without-repetitions.js export default function withoutRepetitions(set) {   if (set.length === 1) {     return [set]   }    const result = []    const subset = withoutRepetitions(set.slice(1))   const first = set[0]    for (let i = 0; i < subset.length; i++) {     const smaller = subset[i]     for (let j = 0; j < smaller.length + 1; j++) {       const permutation = [...smaller.slice(0, j), first, ...smaller.slice(j)]       result.push(permutation)     }   }    return result }

Тестирование

// algorithms/sets/permutations/__tests__/without-repetitions.test.js import withoutRepetitions from '../without-repetitions' import factorial from '../../../math/factorial'  describe('withoutRepetitions', () => {   it('должен переставлять элементы множеств без повторений', () => {     const permutations1 = withoutRepetitions(['A'])     expect(permutations1).toEqual([['A']])      const permutations2 = withoutRepetitions(['A', 'B'])     expect(permutations2.length).toBe(2)     expect(permutations2).toEqual([       ['A', 'B'],       ['B', 'A'],     ])      const permutations6 = withoutRepetitions(['A', 'A'])     expect(permutations6.length).toBe(2)     expect(permutations6).toEqual([       ['A', 'A'],       ['A', 'A'],     ])      const permutations3 = withoutRepetitions(['A', 'B', 'C'])     expect(permutations3.length).toBe(factorial(3))     expect(permutations3).toEqual([       ['A', 'B', 'C'],       ['B', 'A', 'C'],       ['B', 'C', 'A'],       ['A', 'C', 'B'],       ['C', 'A', 'B'],       ['C', 'B', 'A'],     ])      const permutations4 = withoutRepetitions(['A', 'B', 'C', 'D'])     expect(permutations4.length).toBe(factorial(4))     expect(permutations4).toEqual([       ['A', 'B', 'C', 'D'],       ['B', 'A', 'C', 'D'],       ['B', 'C', 'A', 'D'],       ['B', 'C', 'D', 'A'],       ['A', 'C', 'B', 'D'],       ['C', 'A', 'B', 'D'],       ['C', 'B', 'A', 'D'],       ['C', 'B', 'D', 'A'],       ['A', 'C', 'D', 'B'],       ['C', 'A', 'D', 'B'],       ['C', 'D', 'A', 'B'],       ['C', 'D', 'B', 'A'],       ['A', 'B', 'D', 'C'],       ['B', 'A', 'D', 'C'],       ['B', 'D', 'A', 'C'],       ['B', 'D', 'C', 'A'],       ['A', 'D', 'B', 'C'],       ['D', 'A', 'B', 'C'],       ['D', 'B', 'A', 'C'],       ['D', 'B', 'C', 'A'],       ['A', 'D', 'C', 'B'],       ['D', 'A', 'C', 'B'],       ['D', 'C', 'A', 'B'],       ['D', 'C', 'B', 'A'],     ])      const permutations5 = withoutRepetitions(['A', 'B', 'C', 'D', 'E', 'F'])     expect(permutations5.length).toBe(factorial(6))   }) })

Перестановки с повторениями

Когда разрешены дубликаты, мы говорим о перестановках с повторениями.

Примером такой перестановки может быть код замка 333.

Количество вариантов:

n * n * n ... (r раз) = n^r (экспонента `r`)

Реализация

// algorithms/sets/permutations/with-repetitions.js export default function withRepetitions(set, length = set.length) {   if (length === 1) {     return set.map((i) => [i])   }    const result = []    const subset = withRepetitions(set, length - 1)    for (const i of set) {     for (const j of subset) {       result.push([i, ...j])     }   }    return result }

Тестирование

// algorithms/sets/permutations/__tests__/with-repetitions.test.js import withRepetitions from '../with-repetitions'  describe('withRepetitions', () => {   it('должен переставлять элементы множеств с повторениями', () => {     const permutations1 = withRepetitions(['A'])     expect(permutations1).toEqual([['A']])      const permutations2 = withRepetitions(['A', 'B'])     expect(permutations2).toEqual([       ['A', 'A'],       ['A', 'B'],       ['B', 'A'],       ['B', 'B'],     ])      const permutations3 = withRepetitions(['A', 'B', 'C'])     expect(permutations3).toEqual([       ['A', 'A', 'A'],       ['A', 'A', 'B'],       ['A', 'A', 'C'],       ['A', 'B', 'A'],       ['A', 'B', 'B'],       ['A', 'B', 'C'],       ['A', 'C', 'A'],       ['A', 'C', 'B'],       ['A', 'C', 'C'],       ['B', 'A', 'A'],       ['B', 'A', 'B'],       ['B', 'A', 'C'],       ['B', 'B', 'A'],       ['B', 'B', 'B'],       ['B', 'B', 'C'],       ['B', 'C', 'A'],       ['B', 'C', 'B'],       ['B', 'C', 'C'],       ['C', 'A', 'A'],       ['C', 'A', 'B'],       ['C', 'A', 'C'],       ['C', 'B', 'A'],       ['C', 'B', 'B'],       ['C', 'B', 'C'],       ['C', 'C', 'A'],       ['C', 'C', 'B'],       ['C', 'C', 'C'],     ])      const permutations4 = withRepetitions(['A', 'B', 'C', 'D'])     expect(permutations4.length).toBe(4 * 4 * 4 * 4)   }) })

Запускаем тесты:

npm run test ./algorithms/sets/permutations

Результат:

Комбинации

Комбинации без повторений

Так работают лотереи. Числа пишутся одно за другим. Побеждает счастливая комбинация, независимо от порядка чисел.

Комбинация чисел без повторений:

[2, 14, 15, 27, 30, 33]

Количество вариантов:

Здесь n — количество вариантов для выбора, а r — выбранное нами количество, без повторений, порядок не важен.

Такая комбинация также называется биномиальным коэффициентом.

Реализация

// algorithms/sets/combinations/without-repetitions.js export default function withoutRepetitions(set, length) {   if (length === 1) {     return set.map((i) => [i])   }    const result = []    set.forEach((i, idx) => {     const subset = withoutRepetitions(set.slice(idx + 1), length - 1)      for (const j of subset) {       result.push([i, ...j])     }   })    return result }

Тестирование

// algorithms/sets/combinations/__tests__/without-repetitions.test.js import combineWithoutRepetitions from '../without-repetitions' import factorial from '../../../math/factorial'  describe('combineWithoutRepetitions', () => {   it('должен комбинировать элементы множеств без повторов', () => {     expect(combineWithoutRepetitions(['A', 'B'], 3)).toEqual([])      expect(combineWithoutRepetitions(['A', 'B'], 1)).toEqual([['A'], ['B']])      expect(combineWithoutRepetitions(['A'], 1)).toEqual([['A']])      expect(combineWithoutRepetitions(['A', 'B'], 2)).toEqual([['A', 'B']])      expect(combineWithoutRepetitions(['A', 'B', 'C'], 2)).toEqual([       ['A', 'B'],       ['A', 'C'],       ['B', 'C'],     ])      expect(combineWithoutRepetitions(['A', 'B', 'C'], 3)).toEqual([       ['A', 'B', 'C'],     ])      expect(combineWithoutRepetitions(['A', 'B', 'C', 'D'], 3)).toEqual([       ['A', 'B', 'C'],       ['A', 'B', 'D'],       ['A', 'C', 'D'],       ['B', 'C', 'D'],     ])      expect(combineWithoutRepetitions(['A', 'B', 'C', 'D', 'E'], 3)).toEqual([       ['A', 'B', 'C'],       ['A', 'B', 'D'],       ['A', 'B', 'E'],       ['A', 'C', 'D'],       ['A', 'C', 'E'],       ['A', 'D', 'E'],       ['B', 'C', 'D'],       ['B', 'C', 'E'],       ['B', 'D', 'E'],       ['C', 'D', 'E'],     ])      const combinationOptions = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']     const combinationSlotsNumber = 4     const combinations = combineWithoutRepetitions(       combinationOptions,       combinationSlotsNumber,     )     const n = combinationOptions.length     const r = combinationSlotsNumber     const expectedNumberOfCombinations =       factorial(n) / (factorial(r) * factorial(n - r))      expect(combinations.length).toBe(expectedNumberOfCombinations)   }) })

Комбинации с повторениями

Примером такой комбинации являются монеты в кармане: (5, 5, 5, 10, 10).

Еще один пример — пять вкусов мороженного: banana, chocolate, lemon, strawberry и vanilla.

Мы можем выбрать 3. Сколько у нас вариантов?

Используем символы для вкусов — { b, c, l, s, v }. Примеры вариантов:

  • { c, c, c }
  • { b, l, v }
  • { b, v, v }

Количество вариантов:

Здесь n — количество вариантов для выбора, дубликаты разрешены, порядок не важен.

Реализация

// algorithms/sets/combinations/with-repetitions.js export default function withRepetitions(set, length) {   if (length === 1) {     return set.map((i) => [i])   }    const result = []    set.forEach((i, idx) => {     const subset = withRepetitions(set.slice(idx), length - 1)      for (const j of subset) {       result.push([i, ...j])     }   })    return result }

Тестирование

// algorithms/sets/combinations/__tests__/with-repetitions.test.js import withRepetitions from '../with-repetitions' import factorial from '../../../math/factorial'  describe('withRepetitions', () => {   it('должен комбинировать элементы множеств с повторами', () => {     expect(withRepetitions(['A'], 1)).toEqual([['A']])      expect(withRepetitions(['A', 'B'], 1)).toEqual([['A'], ['B']])      expect(withRepetitions(['A', 'B'], 2)).toEqual([       ['A', 'A'],       ['A', 'B'],       ['B', 'B'],     ])      expect(withRepetitions(['A', 'B'], 3)).toEqual([       ['A', 'A', 'A'],       ['A', 'A', 'B'],       ['A', 'B', 'B'],       ['B', 'B', 'B'],     ])      expect(withRepetitions(['A', 'B', 'C'], 2)).toEqual([       ['A', 'A'],       ['A', 'B'],       ['A', 'C'],       ['B', 'B'],       ['B', 'C'],       ['C', 'C'],     ])      expect(withRepetitions(['A', 'B', 'C'], 3)).toEqual([       ['A', 'A', 'A'],       ['A', 'A', 'B'],       ['A', 'A', 'C'],       ['A', 'B', 'B'],       ['A', 'B', 'C'],       ['A', 'C', 'C'],       ['B', 'B', 'B'],       ['B', 'B', 'C'],       ['B', 'C', 'C'],       ['C', 'C', 'C'],     ])      const combinationOptions = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']     const combinationSlotsNumber = 4     const combinations = withRepetitions(       combinationOptions,       combinationSlotsNumber,     )     const n = combinationOptions.length     const r = combinationSlotsNumber     const expectedNumberOfCombinations =       factorial(r + n - 1) / (factorial(r) * factorial(n - 1))      expect(combinations.length).toBe(expectedNumberOfCombinations)   }) })

Запускаем тесты:

npm run test ./algorithms/sets/combinations

Результат:

Шпаргалки

Наибольшая общая подпоследовательность

Описание

Задача нахождения наибольшей общей подпоследовательности (НОП) (longest common subsequence, LCS) — задача поиска последовательности, которая является подпоследовательностью нескольких последовательностей (обычно двух). Это классическая задача информатики, которая имеет приложения, в частности, в задаче сравнения текстовых файлов (утилита diff), а также в биоинформатике. Она также широко используется системами контроля версий, такими как Git, для согласования изменений в коллекции файлов.

Подпоследовательность отличается от подстроки. Например, если есть исходная последовательность ABCDEF, то ACE будет подпоследовательностью, но не подстрокой, а ABC будет как подпоследовательностью, так и подстрокой.

Примеры

  • НОП для последовательностей ABCDGH и AEDFHRADH длиной 3
  • НОП для последовательностей AGGTAB и GXTXAYBGTAB длиной 4

Реализация

НОП можно реализовать, как минимум, двумя способами.

Рекурсивный подход

// algorithms/sets/longest-common-subsequence/recursive.js export default function lcsRecursive(a, b) {   const lcs = (a, b, memo = {}) => {     if (!a || !b) return ''      if (memo[`${a},${b}`]) {       return memo[`${a},${b}`]     }      if (a[0] === b[0]) {       return a[0] + lcs(a.slice(1), b.slice(1), memo)     }      const next1 = lcs(a.slice(1), b, memo)     const next2 = lcs(a, b.slice(1), memo)      const nextLongest = next1.length >= next2.length ? next1 : next2     memo[`${a},${b}`] = nextLongest      return nextLongest   }    return lcs(a, b) }

Тестирование

// algorithms/sets/longest-common-subsequence/__tests__/recursive.test.js import lcsRecursive from '../recursive'  describe('lcsRecursive', () => {   it('должен рекурсивно найти НОП двух строк', () => {     expect(lcsRecursive('', '')).toBe('')     expect(lcsRecursive('ABC', '')).toBe('')     expect(lcsRecursive('', 'ABC')).toBe('')     expect(lcsRecursive('ABABC', 'BABCA')).toBe('BABC')     expect(lcsRecursive('BABCA', 'ABCBA')).toBe('ABCA')     expect(lcsRecursive('sea', 'eat')).toBe('ea')     expect(lcsRecursive('algorithms', 'rithm')).toBe('rithm')     expect(       lcsRecursive(         'Algorithms and data structures implemented in JavaScript',         'Here you may find Algorithms and data structures that are implemented in JavaScript',       ),     ).toBe('Algorithms and data structures implemented in JavaScript')   }) })

Динамическое программирование

Этот подход предполагает использование матрицы (см. ролик на ютубе по ссылке в начале раздела).

// algorithms/sets/longest-common-subsequence/matrix.js export default function lcs(a, b) {   // Инициализируем матрицу LCS   const matrix = new Array(b.length + 1)     .fill(null)     .map(() => new Array(a.length + 1).fill(null))    // Заполняем `0` первую строку   for (let i = 0; i <= a.length; i++) {     matrix[0][i] = 0   }    // Заполняем `0` первую колонку   for (let i = 0; i <= b.length; i++) {     matrix[i][0] = 0   }    // Заполняем матрицу LCS   for (let i = 1; i <= b.length; i++) {     for (let j = 1; j <= a.length; j++) {       if (b[i - 1] === a[j - 1]) {         matrix[i][j] = matrix[i - 1][j - 1] + 1       } else {         matrix[i][j] = Math.max(matrix[i - 1][j], matrix[i][j - 1])       }     }   }    // Вычисляем LCS на основе матрицы   if (!matrix[b.length][a.length]) {     return ['']   }    const lcs = []   let i = a.length   let j = b.length    while (i > 0 && j > 0) {     if (a[i - 1] === b[j - 1]) {       // Двигаемся по диагонали "влево-верх"       lcs.unshift(a[i - 1])       i--       j--     } else if (matrix[j][i] === matrix[j][i - 1]) {       // Двигаемся влево       i--     } else {       // Двигаемся вверх       j--     }   }    return lcs }

Тестирование

// algorithms/sets/longest-common-subsequence/__tests__/matrix.test.js import lcs from '../matrix'  describe('lcs', () => {   it('должен найти НОП двух множеств', () => {     expect(lcs([''], [''])).toEqual([''])      expect(lcs([''], ['A', 'B', 'C'])).toEqual([''])      expect(lcs(['A', 'B', 'C'], [''])).toEqual([''])      expect(lcs(['A', 'B', 'C'], ['D', 'E', 'F', 'G'])).toEqual([''])      expect(       lcs(['A', 'B', 'C', 'D', 'G', 'H'], ['A', 'E', 'D', 'F', 'H', 'R']),     ).toEqual(['A', 'D', 'H'])      expect(       lcs(['A', 'G', 'G', 'T', 'A', 'B'], ['G', 'X', 'T', 'X', 'A', 'Y', 'B']),     ).toEqual(['G', 'T', 'A', 'B'])      expect(       lcs(['A', 'B', 'C', 'D', 'A', 'F'], ['A', 'C', 'B', 'C', 'F']),     ).toEqual(['A', 'B', 'C', 'F'])   }) })

Запускаем тесты:

npm run test ./algorithms/sets/longest-common-subsequence

Результат:

Кратчайшая общая суперпоследовательность

Описание

Кратчайшая общая суперпоследовательность (КОС) (shortest common supersequence, SCS) двух последовательностей X и Y — это самая короткая последовательность, содержащая X и Y.

Предположим, что у нас есть строки str1 и str2 и нам нужно найти кратчайшую строку, содержащую как str1, так и str2.

Эта задача тесно связана с задачей нахождения наибольшей общей подпоследовательности.

Примеры

  • КОС для строк geek и ekegeeke длиной 5
  • КОС для строк AGGTAB и GXTXAYBAGXGTXAYB длиной 9

Реализация

Для реализации алгоритма нахождения КОС можно использовать алгоритм нахождения НОП, разобранный нами в предыдущем разделе.

// algorithms/sets/shortest-common-supersequence.js import lcsFn from './longest-common-subsequence/matrix'  export default function scs(set1, set2) {   // Находим НОП двух множеств   const lcs = lcsFn(set1, set2)    // Если НОП пустая, то КОС будет просто   // объединением множеств   if (lcs.length === 1 && lcs[0] === '') {     return set1.concat(set2)   }    // Добавляем элементы множеств в порядке перед/внутрь/после НОП   let result = []    let idx1 = 0   let idx2 = 0   let idx = 0   let onHold1 = false   let onHold2 = false    while (idx < lcs.length) {     // Добавляем элементы `set1` в правильном порядке     if (idx1 < set1.length) {       if (!onHold1 && set1[idx1] !== lcs[idx]) {         result.push(set1[idx1])         idx1++       } else {         onHold1 = true       }     }      // Добавляем элементы `set2` в правильном порядке     if (idx2 < set2.length) {       if (!onHold2 && set2[idx2] !== lcs[idx]) {         result.push(set2[idx2])         idx2++       } else {         onHold2 = true       }     }      // Добавляем НОП в правильном порядке     if (onHold1 && onHold2) {       result.push(lcs[idx])       idx++       idx1++       idx2++       onHold1 = false       onHold2 = false     }   }    // Добавляем остатки `set1`   if (idx1 < set1.length) {     result = result.concat(set1.slice(idx1))   }    // Добавляем остатки `set2`   if (idx2 < set2.length) {     result = result.concat(set2.slice(idx2))   }    return result }

Тестирование

// algorithms/sets/__tests__/shortest-common-supersequence.test.js import shortestCommonSupersequence from '../shortest-common-supersequence'  describe('shortestCommonSupersequence', () => {   it('должен найти КОС двух множеств', () => {     // LCS (наибольшая общая последовательность) пустая     expect(       shortestCommonSupersequence(['A', 'B', 'C'], ['D', 'E', 'F']),     ).toEqual(['A', 'B', 'C', 'D', 'E', 'F'])      // LCS - "EE"     expect(       shortestCommonSupersequence(['G', 'E', 'E', 'K'], ['E', 'K', 'E']),     ).toEqual(['G', 'E', 'K', 'E', 'K'])      // LCS - "GTAB"     expect(       shortestCommonSupersequence(         ['A', 'G', 'G', 'T', 'A', 'B'],         ['G', 'X', 'T', 'X', 'A', 'Y', 'B'],       ),     ).toEqual(['A', 'G', 'G', 'X', 'T', 'X', 'A', 'Y', 'B'])      // LCS - "BCBA"     expect(       shortestCommonSupersequence(         ['A', 'B', 'C', 'B', 'D', 'A', 'B'],         ['B', 'D', 'C', 'A', 'B', 'A'],       ),     ).toEqual(['A', 'B', 'D', 'C', 'A', 'B', 'D', 'A', 'B'])      // LCS - "BDABA"     expect(       shortestCommonSupersequence(         ['B', 'D', 'C', 'A', 'B', 'A'],         ['A', 'B', 'C', 'B', 'D', 'A', 'B', 'A', 'C'],       ),     ).toEqual(['A', 'B', 'C', 'B', 'D', 'C', 'A', 'B', 'A', 'C'])   }) })

Запускаем тесты:

npm run test ./algorithms/sets/__tests__/shortest-common-supersequence

Результат:

Максимальный подмассив

Описание

Задача максимального подмассива (maximum subarray) — это задача поиска подмассива в одномерном массиве a[1..n], числа которого дают наибольшую сумму (числа должны следовать одно за другим).

Исходные массивы обычно содержат отрицательные и положительные числа, а также 0. Например, для массива [−2, 1, −3, 4, −1, 2, 1, −5, 4] максимальным подмассивом будет [4, -1, 2, 1], а его сумма — 6.

Реализация

Для решения задачи нахождения максимального подмассива можно применить, как минимум, 3 подхода.

Грубая сила

Суть этого метода состоит в двойном переборе элементов массива. Поэтому его временная сложность составляет O(n^2).

// algorithms/sets/maximum-subarray/brute-force.js export default function bruteForce(arr) {   let maxStartIdx = 0   let maxLen = 0   let maxSum = null    for (let i = 0; i < arr.length; i++) {     let sum = 0     for (let j = 1; j <= arr.length - i; j++) {       sum += arr[i + (j - 1)]       if (!maxSum || sum > maxSum) {         maxSum = sum         maxStartIdx = i         maxLen = j       }     }   }    return arr.slice(maxStartIdx, maxStartIdx + maxLen) }

Тестирование

// algorithms/sets/maximum-subarray/__tests__/brute-force.test.js import bruteForce from '../brute-force'  describe('bruteForce', () => {   it('должен найти максимальные подмассивы методом грубой силы', () => {     expect(bruteForce([])).toEqual([])     expect(bruteForce([0, 0])).toEqual([0])     expect(bruteForce([0, 0, 1])).toEqual([0, 0, 1])     expect(bruteForce([0, 0, 1, 2])).toEqual([0, 0, 1, 2])     expect(bruteForce([0, 0, -1, 2])).toEqual([2])     expect(bruteForce([-1, -2, -3, -4, -5])).toEqual([-1])     expect(bruteForce([1, 2, 3, 2, 3, 4, 5])).toEqual([1, 2, 3, 2, 3, 4, 5])     expect(bruteForce([-2, 1, -3, 4, -1, 2, 1, -5, 4])).toEqual([4, -1, 2, 1])     expect(bruteForce([-2, -3, 4, -1, -2, 1, 5, -3])).toEqual([4, -1, -2, 1, 5])     expect(bruteForce([1, -3, 2, -5, 7, 6, -1, 4, 11, -23])).toEqual([       7, 6, -1, 4, 11,     ])   }) })

Разделяй и властвуй

При использовании этого подхода нам также приходится перебирать массив дважды. Поэтому его временная сложность также составляет O(n^2).

// algorithms/sets/maximum-subarray/divide-conquer.js export default function divideConquer(arr) {   const dc = (idx, pick) => {     if (idx >= arr.length) {       return pick ? 0 : -Infinity     }      return Math.max(       // Вариант 1: берем текущий элемент и переходим к следующему       arr[idx] + dc(idx + 1, true),       // Вариант 2: не берем текущий элемент       pick ? 0 : dc(idx + 1, false),     )   }    return dc(0, false) }

Тестирование

// algorithms/sets/maximum-subarray/__tests__/divide-conquer.test.js import divideConquer from '../divide-conquer'  describe('dcMaximumSubarraySum', () => {   it("должен найти максимальные подмассивы методом 'Разделяй и властвуй'", () => {     expect(divideConquer([])).toEqual(-Infinity)     expect(divideConquer([0, 0])).toEqual(0)     expect(divideConquer([0, 0, 1])).toEqual(1)     expect(divideConquer([0, 0, 1, 2])).toEqual(3)     expect(divideConquer([0, 0, -1, 2])).toEqual(2)     expect(divideConquer([-1, -2, -3, -4, -5])).toEqual(-1)     expect(divideConquer([1, 2, 3, 2, 3, 4, 5])).toEqual(20)     expect(divideConquer([-2, 1, -3, 4, -1, 2, 1, -5, 4])).toEqual(6)     expect(divideConquer([-2, -3, 4, -1, -2, 1, 5, -3])).toEqual(7)     expect(divideConquer([1, -3, 2, -5, 7, 6, -1, 4, 11, -23])).toEqual(27)   }) })

Динамическое программирование

Это лучшее с точки зрения времени выполнения решение, поскольку позволяет ограничиться одним перебором массива (O(n)).

// algorithms/sets/maximum-subarray/dynamic-programming.js export default function dynamicProgramming(arr) {   let maxSum = -Infinity   let sum = 0    let maxStartIdx = 0   let maxEndIdx = arr.length - 1   let currentStartIdx = 0    arr.forEach((item, idx) => {     sum += item      if (maxSum < sum) {       maxSum = sum       maxStartIdx = currentStartIdx       maxEndIdx = idx     }      if (sum < 0) {       sum = 0       currentStartIdx = idx + 1     }   })    return arr.slice(maxStartIdx, maxEndIdx + 1) }

Тестирование

// algorithms/sets/maximum-subarray/__tests__/dynamic-programming.test.js import dynamicProgramming from '../dynamic-programming'  describe('dynamicProgramming', () => {   it('должен найти максимальные подмассивы методом динамического программирования', () => {     expect(dynamicProgramming([])).toEqual([])     expect(dynamicProgramming([0, 0])).toEqual([0])     expect(dynamicProgramming([0, 0, 1])).toEqual([0, 0, 1])     expect(dynamicProgramming([0, 0, 1, 2])).toEqual([0, 0, 1, 2])     expect(dynamicProgramming([0, 0, -1, 2])).toEqual([2])     expect(dynamicProgramming([-1, -2, -3, -4, -5])).toEqual([-1])     expect(dynamicProgramming([1, 2, 3, 2, 3, 4, 5])).toEqual([       1, 2, 3, 2, 3, 4, 5,     ])     expect(dynamicProgramming([-2, 1, -3, 4, -1, 2, 1, -5, 4])).toEqual([       4, -1, 2, 1,     ])     expect(dynamicProgramming([-2, -3, 4, -1, -2, 1, 5, -3])).toEqual([       4, -1, -2, 1, 5,     ])     expect(dynamicProgramming([1, -3, 2, -5, 7, 6, -1, 4, 11, -23])).toEqual([       7, 6, -1, 4, 11,     ])   }) })

Запускаем тесты:

npm run test ./algorithms/sets/maximum-subarray

Результат:

Комбинация сумм

Описание

Дан набор чисел (candidates) (без дубликатов) и целевое число (target). Необходимо найти все уникальные комбинации чисел candidates, сумма которых равняется target.

Дополнительные условия:

  • одно и тоже число candidates может использоваться многократно
  • все числа (включая target) являются положительными
  • решение не должно содержать повторений

Примеры

Дано: candidates = [2,3,6,7], target = 7,  Решение: [   [7],   [2,2,3] ]

Дано: candidates = [2,3,5], target = 8,  Решение: [   [2,2,2,2],   [2,3,3],   [3,5] ]

Объяснение

Поскольку задача состоит в получении всех возможных результатов, а не лучшего результата и не количества результатов, нам не требуется динамическое программирование. Для решения задачи достаточно рекурсивного подхода.

Пример дерева решений для candidates = [2, 3] и target = 6:

                0               /   \            +2      +3           /   \      \        +2       +3    +3       /  \     /  \     \     +2    ✘   ✘   ✘     ✓    /  \   ✓    ✘

Реализация

// algorithms/sets/combination-sum.js function combinationSumRecursive(   candidates,   remainingSum,   finalCombinations = [],   currentCombination = [],   startFrom = 0, ) {   if (remainingSum < 0) {     // Добавив кандидата, мы опустились ниже `0`.     // Это означает, что последний кандидат неприемлем     return finalCombinations   }    if (remainingSum === 0) {     // Если после добавления кандидата, мы получили `0`,     // нужно сохранить текущую комбинацию, поскольку     // это одно из искомых решений     finalCombinations.push(currentCombination.slice())      return finalCombinations   }    // Если мы пока не получили `0`, продолжаем добавлять оставшихся кандидатов   for (let i = startFrom; i < candidates.length; i++) {     const currentCandidate = candidates[i]      currentCombination.push(currentCandidate)      combinationSumRecursive(       candidates,       remainingSum - currentCandidate,       finalCombinations,       currentCombination,       i,     )      // Возвращаемся назад, исключаем текущего кандидата и пробуем другого     currentCombination.pop()   }    return finalCombinations }  export default function combinationSum(candidates, target) {   return combinationSumRecursive(candidates, target) }

Тестирование

// algorithms/sets/__tests__/combination-sum.test.js import combinationSum from '../combination-sum'  describe('combinationSum', () => {   it('должен найти все комбинации чисел для получения указанной суммы', () => {     expect(combinationSum([1], 4)).toEqual([[1, 1, 1, 1]])      expect(combinationSum([2, 3, 6, 7], 7)).toEqual([[2, 2, 3], [7]])      expect(combinationSum([2, 3, 5], 8)).toEqual([       [2, 2, 2, 2],       [2, 3, 3],       [3, 5],     ])      expect(combinationSum([2, 5], 3)).toEqual([])      expect(combinationSum([], 3)).toEqual([])   }) })

Запускаем тесты:

npm run test ./algorithms/sets/__tests__/combination-sum

Результат:

На сегодня это все, друзья. Увидимся в следующей части.



Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале


ссылка на оригинал статьи https://habr.com/ru/articles/845544/