Недавно я наткнулся на этот очень интересный вопрос о переполнении стека. Его название:
В этой статье мы сравним императивный подход пользователя с чрезвычайно элегантным (Oracle) подходом SQL. Мы будем использовать любую комбинацию этих замечательных функций SQL:
- Оконные функции
- ПЕРВЫЕ и ПОСЛЕДНИЕ функции с использованием специфичного для Oracle синтаксиса
LATERAL JOIN
(илиAPPLY
- Рекурсивный SQL
Эта проблема
Пользователь alhashmiya, который задал этот вопрос, искал решение проблемы поиска «ближайшей» суммы элементов в подмножестве чисел A для набора «ожидаемых» сумм B. Более конкретно, alhasmiya имела следующие два столы:
ID ASSIGN_AMT
--------------
1 25150
2 19800
3 27511
И…
ID WORK_AMT
------------
1 7120
2 8150
3 8255
4 9051
5 1220
6 12515
7 13555
8 5221
9 812
10 6562
ASSIGN_AMT
Значение является «ожидается» сумма. Алхашмия искала сумму подмножества WORK_AMT
значений A, так что эта сумма максимально приближена к любой из «ожидаемых» сумм. Есть два способа понять эту проблему:
- Возможные «ближайшие» суммы ограничиваются суммами, полученными в строго определенном порядке, например, в порядке
ID
. Применение этого понимания заключается в том, чтобы выяснить точный момент, когда четко определенный, упорядоченный промежуточный итог (например, остаток на банковском счете) превысил определенный порог - Возможные «ближайшие» суммы не ограничены. Любое неупорядоченное подмножество может рассчитать такую сумму. Применение этого понимания заключается в том, чтобы найти комбинацию дискретных значений, чтобы максимально приблизиться к целевому значению, например, для оптимизации сделки.
Второе понимание называется «проблемой суммы подмножеств» , для которой существуют только экспоненциальные алгоритмы, если вы ищете точное решение. Важно отметить, что этот алгоритм НЕ будет хорошо масштабироваться, независимо от метода решения!
Но давайте сначала посмотрим на более простое понимание:
1. Расчет суммы упорядоченного подмножества значений
Под строго упорядоченным мы подразумеваем (в смысле вопроса), что мы хотим упорядочить все WORK_AMT
значения, например, по ID
и разрешить только те суммы, которые могут появляться в этом конкретном порядке. То есть:
ID WORK_AMT SUBSET_SUM
------------------------
1 7120 7120 (= WORK_AMT)
2 8150 15270 (= previous SUBSET_SUM + WORK_AMT)
3 8255 23525 (= previous SUBSET_SUM + WORK_AMT)
4 9051 32576 (= ...)
5 1220 33796
6 12515 46311
7 13555 59866
8 5221 65087
9 812 65899
10 6562 72461
Вышеуказанное SUBSET_SUM
значение является суммой всех WORK_AMT
значений с ID <= "current" ID
. Мы видели это раньше в этом блоге, это называется промежуточной суммой , и ее лучше всего вычислять с помощью оконных функций следующим образом:
WITH
VALS (ID, WORK_AMT) AS (
SELECT 1 , 7120 FROM DUAL
UNION ALL SELECT 2 , 8150 FROM DUAL
UNION ALL SELECT 3 , 8255 FROM DUAL
UNION ALL SELECT 4 , 9051 FROM DUAL
UNION ALL SELECT 5 , 1220 FROM DUAL
UNION ALL SELECT 6 , 12515 FROM DUAL
UNION ALL SELECT 7 , 13555 FROM DUAL
UNION ALL SELECT 8 , 5221 FROM DUAL
UNION ALL SELECT 9 , 812 FROM DUAL
UNION ALL SELECT 10, 6562 FROM DUAL
)
SELECT
ID,
WORK_AMT,
SUM (WORK_AMT) OVER (ORDER BY ID) AS SUBSET_SUM
FROM
VALS
ORDER BY
ID
Описанная выше оконная функция вычисляет сумму всех WORK_AMT
значений, которые находятся в «окне» значений, где значение ID
меньше или равно текущему ID
.
Нахождение «ближайшего» из этих сумм с помощью количественных предикатов сравнения
Теперь задача состоит в том , чтобы найти для каждого значения ASSIGN_AMT
в 25150
, 19800
, и 27511
ближайшее значение SUBSET_SUM
. В некотором смысле, мы пытаемся минимизировать выражение ABS(SUBSET_SUM - ASSIGN_AMT)
.
Однако MIN()
агрегатная функция нам здесь не поможет, потому что она просто вернет минимальное значение этой разницы. Мы хотим, чтобы ценность SUBSET_SUM
этого порождала эту разницу.
Одним из решений было бы использование количественного предиката сравнения , редко используемого и недостаточно известного оператора сравнения, который работает во всех базах данных SQL:
-- The common table expressions are the same as
-- in the previous examples
WITH
ASSIGN(ID, ASSIGN_AMT) AS (
SELECT 1, 25150 FROM DUAL
UNION ALL SELECT 2, 19800 FROM DUAL
UNION ALL SELECT 3, 27511 FROM DUAL
),
VALS (ID, WORK_AMT) AS (
SELECT 1 , 7120 FROM DUAL
UNION ALL SELECT 2 , 8150 FROM DUAL
UNION ALL SELECT 3 , 8255 FROM DUAL
UNION ALL SELECT 4 , 9051 FROM DUAL
UNION ALL SELECT 5 , 1220 FROM DUAL
UNION ALL SELECT 6 , 12515 FROM DUAL
UNION ALL SELECT 7 , 13555 FROM DUAL
UNION ALL SELECT 8 , 5221 FROM DUAL
UNION ALL SELECT 9 , 812 FROM DUAL
UNION ALL SELECT 10, 6562 FROM DUAL
),
SUMS (ID, WORK_AMT, SUBSET_SUM) AS (
SELECT
VALS.*,
SUM (WORK_AMT) OVER (ORDER BY ID)
FROM
VALS
)
-- This is now the interesting part, where we
-- calculate the closest sum
SELECT
ASSIGN.ID,
ASSIGN.ASSIGN_AMT,
SUBSET_SUM
FROM
ASSIGN
JOIN
SUMS
ON
ABS (ASSIGN_AMT - SUBSET_SUM) <= ALL (
SELECT
ABS (ASSIGN_AMT - SUBSET_SUM)
FROM
SUMS
)
Количественное сравнение предикат читает интуитивно. Мы ищем тот конкретный,SUBSET_SUM
чья разница с «ожидаемым» ASSIGN_AMT
меньше или равна всем другим возможным различиям.
Вышеприведенный запрос дает:
ID ASSIGN_AMT SUBSET_SUM
--------------------------
1 25150 23525
2 19800 23525
3 27511 23525
В этом случае это всегда то же самое.
Вы, возможно, заметили, что решение не совсем корректно в случае, когда ASSIGN_AMT
разрешено быть равным нулю (давайте проигнорируем возможность отрицательных значений) — в случае чего мы будем создавать повторяющиеся значения в JOIN
. Это может быть достигнуто при замене:
UNION ALL SELECT 4 , 0 FROM DUAL
Теперь результат:
ID ASSIGN_AMT SUBSET_SUM
--------------------------
1 25150 23525
2 19800 23525
2 19800 23525
3 27511 23525
Одним из решений является удаление этих дубликатов с помощью DISTINCT
( что является анти-паттерном. См. № 6 в этом списке ). Лучшее решение состоит в том, чтобы сделать JOIN
предикат однозначным путем сравнения ID
значений, то есть:
SELECT
ASSIGN.ID,
ASSIGN.ASSIGN_AMT,
SUBSET_SUM
FROM
ASSIGN
JOIN
SUMS
ON
(ABS (ASSIGN_AMT - SUBSET_SUM), SUMS.ID) <= ALL (
SELECT
ABS (ASSIGN_AMT - SUBSET_SUM),
ID
FROM
SUMS
)
Вышеописанное, к сожалению, не работает в Oracle (пока), который сообщит об ошибке:
ORA-01796: this operator cannot be used with lists
Oracle поддерживает сравнение выражений кортежей / значений строк только с равными компараторами , а не с компараторами меньше / больше, что является позором. Тот же запрос выполняется в PostgreSQL без сбоев.
Нахождение «ближайшего» из этих сумм с помощью функции Oracle FIRST
В Oracle есть очень интересная функция, позволяющая хранить первые (или последние) значения в наборе агрегатных значений группы при любом конкретном упорядочении и рассчитывать агрегатную функцию только для этих значений в группе. Следующий оператор SQL проиллюстрирует это:
SELECT
ASSIGN.ID,
ASSIGN.ASSIGN_AMT,
MIN (SUBSET_SUM) KEEP (
DENSE_RANK FIRST
ORDER BY ABS (ASSIGN_AMT - SUBSET_SUM)
) AS CLOSEST_SUM
FROM
ASSIGN
CROSS JOIN
SUMS
GROUP BY
ASSIGN.ID, ASSIGN.ASSIGN_AMT
По сути, мы группируем все значения из SUMS
таблицы для каждого ASSIGN_AMT
. Для каждой из этих групп мы будем искать "FIRST"
строку, которая появляется при упорядочении строк в группе по нашим критериям ABS(ASSIGN_AMT - SUBSET_SUM)
. Мы "KEEP"
только те строки в группе, и сохраняем из этих строк минимум SUBSET_SUM
.
Этот запрос снова даст:
ID ASSIGN_AMT SUBSET_SUM
--------------------------
1 25150 23525
2 19800 23525
3 27511 23525
Это чрезвычайно приятный функционал, который может пригодиться время от времени.
Помните, что мы недавно видели похожую функцию в этом блоге, когда искали FIRST_VALUE
(или LAST_VALUE
) внутри PARTITION
окна . В стандартном SQL аналогичная вещь может быть достигнута с помощью оконных функций как таковых:
SELECT DISTINCT
ASSIGN.ID,
ASSIGN.ASSIGN_AMT,
FIRST_VALUE (SUBSET_SUM) OVER (
PARTITION BY ASSIGN.ID
ORDER BY ABS (ASSIGN_AMT - SUBSET_SUM)
) AS CLOSEST_SUM
FROM
ASSIGN
CROSS JOIN
SUMS
К сожалению, все эти решения создают дубликаты, которые мы должны удалить либо через GROUP BY
( KEEP
решение), либо через DISTINCT
( FIRST_VALUE
решение).
Нахождение «ближайшего» из этих сумм с LATERAL JOIN
Более чистое решение, которое не зависит от удаления дубликатов, использует новое FETCH FIRST
предложение Oracle 12c вместе с CROSS JOIN LATERAL
(или CROSS APPLY
, что то же самое)
SELECT
ASSIGN.ID,
ASSIGN.ASSIGN_AMT,
CLOSEST_SUM
FROM
ASSIGN
CROSS JOIN LATERAL (
SELECT
SUBSET_SUM AS CLOSEST_SUM
FROM
SUMS
ORDER BY
ABS (ASSIGN.ASSIGN_AMT - SUBSET_SUM)
FETCH FIRST 1 ROW ONLY
) SUMS
Что это значит? По сути, мы объединяем для каждого значения ASSIGN
только FIRST 1 ROW
in SUMS
, упорядоченные по обычным критериям. Нам нужно LATERAL
(или APPLY
), потому что это позволяет нам ссылаться на столбцы с левой стороны от JOIN
выражения также в правой части , которая не была бы возможно в противном случае.
Та же функциональность поддерживается в SQL Server (только при использовании CROSS APPLY
) или в PostgreSQL (только при использовании CROSS JOIN LATERAL
).
Боковой может быть очень полезен, когда правая сторона a JOIN
зависит от левой стороны. В отличие от обычных объединений, это означает, что JOIN
порядок будет установлен слева направо, а оптимизатор имеет сокращенный набор параметров алгоритма объединения. Это полезно в подобных примерах (с помощью ORDER BY
и FETCH FIRST
) или при присоединении к необъявленным табличным функциям, о которых мы расскажем в следующем сообщении в блоге.
2. Расчет суммы любого подмножества значений
До сих пор мы работали над упрощенной версией проблемы. Вероятно, это не то, что имел в виду alhashmiya в вопросе переполнения стека. Вероятно, они хотели решить проблему суммы подмножеств , найдя «ближайшую» сумму любого подмножества WORK_AMT
значений.
Мы будем использовать рекурсивный SQL для вычисления всех возможных сумм. Прежде всего, давайте вспомним, как работает рекурсивный SQL:
Слайд взят из jOOQ Advanced SQL Training . Свяжитесь с нами, чтобы узнать больше .
В рекурсивном SQL нам нужен UNION ALL
запрос в общем табличном выражении ( WITH
предложение в Oracle или WITH RECURSIVE
предложение в PostgreSQL). Первый подзапрос UNION ALL
генерирует «начальную строку (и)» рекурсии, а второй подзапрос UNION ALL
генерирует рекурсию на основе a, SELECT
который выбирает из объявленной таблицы рекурсивно.
Итак, наивное решение этой проблемы сумм подмножеств можно увидеть здесь:
-- Repetition of the previous data
WITH
ASSIGN (ID, ASSIGN_AMT) AS (
SELECT 1, 25150 FROM DUAL
UNION ALL SELECT 2, 19800 FROM DUAL
UNION ALL SELECT 3, 27511 FROM DUAL
),
WORK (ID, WORK_AMT) AS (
SELECT 1 , 7120 FROM DUAL
UNION ALL SELECT 2 , 8150 FROM DUAL
UNION ALL SELECT 3 , 8255 FROM DUAL
UNION ALL SELECT 4 , 9051 FROM DUAL
UNION ALL SELECT 5 , 1220 FROM DUAL
UNION ALL SELECT 6 , 12515 FROM DUAL
UNION ALL SELECT 7 , 13555 FROM DUAL
UNION ALL SELECT 8 , 5221 FROM DUAL
UNION ALL SELECT 9 , 812 FROM DUAL
UNION ALL SELECT 10, 6562 FROM DUAL
),
-- A new way of calculating all possible sums by
-- Recursively adding up all the sums
SUMS (SUBSET_SUM, MAX_ID) AS (
SELECT
WORK_AMT,
ID
FROM
WORK
UNION ALL
SELECT
WORK_AMT + SUBSET_SUM,
GREATEST (MAX_ID, WORK.ID)
FROM
SUMS
JOIN
WORK
ON
SUMS.MAX_ID < WORK.ID
)
-- The same technique to match the "closest" sum
-- As before
SELECT
ASSIGN.ID,
ASSIGN.ASSIGN_AMT,
MIN (SUBSET_SUM) KEEP (
DENSE_RANK FIRST
ORDER BY ABS (ASSIGN_AMT - SUBSET_SUM)
) AS CLOSEST_SUM
FROM
SUMS
CROSS JOIN
ASSIGN
GROUP BY
ASSIGN.ID, ASSIGN.ASSIGN_AMT
Рекурсия проста. В первом подзапросе рекурсии («начальный ряд») мы выбираем каждую отдельную строку в WORK
:
SELECT
WORK_AMT,
ID
FROM
WORK
Во втором подзапросе рекурсии («строки отказов») мы объединяем значение предыдущего шага рекурсии ( SUMS
) со всеми оставшимися значениями ( WORK
), то есть со всеми значениями, которые имеют более высокое значение ID
:
SELECT
WORK_AMT + SUBSET_SUM,
GREATEST (MAX_ID, WORK.ID)
FROM
SUMS
JOIN
WORK
ON
SUMS.MAX_ID < WORK.ID
В этой комбинации мы вычисляем промежуточную сумму ( которая, между прочим, также является промежуточной суммой ) и вычисляем самый высокий суммарный идентификатор на данный момент, чтобы уменьшить количество комбинаций. Последнее мы можем сделать, потому что суммирование коммутативно .
Основное отличие этого решения по сравнению с предыдущими подходами заключается в том, что в настоящее время мы генерируем намного (очень много) больше различных значений в SUMS
таблице.
После все еще приемлемых 0,112 с для 10 различных WORK_AMT
значений база данных рассчитала:
ID ASSIGN_AMT CLOSEST_SUM
---------------------------
1 25150 25133
2 19800 19768
3 27511 27488
Но будьте осторожны, как только вы начнете добавлять значения в VALS
таблицу, этот алгоритм начинает взрываться во времени и пространстве. Выполнение того же запроса со следующей, только чуть большей WORK
таблицей уже требует 16,3 секунды для получения результата:
WORK(ID, WORK_AMT) AS (
SELECT 1 , 7120 FROM DUAL
UNION ALL SELECT 2 , 8150 FROM DUAL
UNION ALL SELECT 3 , 8255 FROM DUAL
UNION ALL SELECT 4 , 9051 FROM DUAL
UNION ALL SELECT 5 , 1220 FROM DUAL
UNION ALL SELECT 6 , 12515 FROM DUAL
UNION ALL SELECT 7 , 13555 FROM DUAL
UNION ALL SELECT 8 , 5221 FROM DUAL
UNION ALL SELECT 9 , 812 FROM DUAL
UNION ALL SELECT 10, 6562 FROM DUAL
UNION ALL SELECT 11, 1234 FROM DUAL
UNION ALL SELECT 12, 61 FROM DUAL
UNION ALL SELECT 13, 616 FROM DUAL
UNION ALL SELECT 14, 2456 FROM DUAL
UNION ALL SELECT 15, 5161 FROM DUAL
UNION ALL SELECT 16, 414 FROM DUAL
UNION ALL SELECT 17, 516 FROM DUAL
UNION ALL SELECT 18, 617 FROM DUAL
UNION ALL SELECT 19, 146 FROM DUAL
),
И результат будет:
ID ASSIGN_AMT CLOSEST_SUM
---------------------------
1 25150 25150
2 19800 19800
3 27511 27511
Хотите доказательства о фактической сумме? Это также легко с рекурсивным SQL:
-- Repetition of the previous data
WITH
ASSIGN (ID, ASSIGN_AMT) AS (
SELECT 1, 25150 FROM DUAL
UNION ALL SELECT 2, 19800 FROM DUAL
UNION ALL SELECT 3, 27511 FROM DUAL
),
WORK (ID, WORK_AMT) AS (
SELECT 1 , 7120 FROM DUAL
UNION ALL SELECT 2 , 8150 FROM DUAL
UNION ALL SELECT 3 , 8255 FROM DUAL
UNION ALL SELECT 4 , 9051 FROM DUAL
UNION ALL SELECT 5 , 1220 FROM DUAL
UNION ALL SELECT 6 , 12515 FROM DUAL
UNION ALL SELECT 7 , 13555 FROM DUAL
UNION ALL SELECT 8 , 5221 FROM DUAL
UNION ALL SELECT 9 , 812 FROM DUAL
UNION ALL SELECT 10, 6562 FROM DUAL
),
-- A new way of calculating all possible sums by
-- Recursively adding up all the sums
SUMS (SUBSET_SUM, MAX_ID, CALC) AS (
SELECT
WORK_AMT,
ID,
TO_CHAR(WORK_AMT)
FROM WORK
UNION ALL
SELECT
WORK_AMT + SUBSET_SUM,
GREATEST (MAX_ID, WORK.ID),
CALC || '+' || WORK_AMT
FROM
SUMS
JOIN
WORK
ON
SUMS.MAX_ID < WORK.ID
)
-- The same technique to match the "closest" sum
-- As before
SELECT
ASSIGN.ID,
ASSIGN.ASSIGN_AMT,
MIN (SUBSET_SUM) KEEP (
DENSE_RANK FIRST
ORDER BY ABS (ASSIGN_AMT - SUBSET_SUM)
) AS CLOSEST_SUM,
MIN (CALC) KEEP (
DENSE_RANK FIRST
ORDER BY ABS (ASSIGN_AMT - SUBSET_SUM)
) AS CALCULATION
FROM
SUMS
CROSS JOIN
ASSIGN
GROUP BY
ASSIGN.ID, ASSIGN.ASSIGN_AMT
Вышесказанное теперь дает:
ID ASSIGN_AMT CLOSEST_SUM CALCULATION
------------------------------------------------------------
1 25150 25133 7120 + 8150 + 9051 + 812
2 19800 19768 1220 + 12515 + 5221 + 812
3 27511 27488 8150 + 8255 + 9051 + 1220 + 812
Вывод
SQL мощный Чрезвычайно мощный. В этой статье мы использовали различные методы для вычисления проблемы суммы подмножеств или ее упрощения . Мы показали, как решить эту проблему в Oracle или PostgreSQL, используя комбинацию этих замечательных функций SQL:
- Оконные функции
KEEP FIRST
(только в Oracle)LATERAL JOIN
(илиAPPLY
- Рекурсивный SQL
Тебе понравилась эта статья? Хотите узнать больше о продвинутом SQL? Свяжитесь с нами, чтобы узнать о наших продвинутых учебных курсах по SQL , где мы поможем вам понять простоту ориентированного на множество мышления и расчетов с помощью SQL.
Мы хотели бы отметить, что все эти решения могут быть написаны на Java с использованием jOOQ также безопасным способом.