Недавно мне довелось поработать над задачей T-SQL, в процессе решения которой требовалось определить подпоследовательность внутри последовательности. Задача показалась мне интересной, и я решил рассмотреть ее в статье, состоящей из двух частей. В первой статье речь пойдет об итеративных решениях, а в следующей — о решениях на основе наборов. Как всегда, я рекомендую, прочитав условие задачи, попробовать справиться с ней самостоятельно, прежде чем знакомиться с моим решением.

Задача

Нам необходимо найти местонахождение подпоследовательности внутри последовательности. Например, существует последовательность 1, 1, 7, 5, 9, 1, 7, 1, 7, 5, 9 и подпоследовательность 1, 7, 5, 9. Подпоследовательность находится в позициях со 2 по 5 и с 8 по 11 в исходной последовательности.

Пример практического применения — поиск определенной подпоследовательности в последовательности ДНК, представляющей возможную мутацию. В этом случае четыре нуклеотидных основания ДНК (G, A, T и C) составляют элементы последовательности вместо целых чисел в моем примере, но принцип остается неизменным. Используйте программный код в листинге 1, чтобы построить таблицы с именем T1 и заполнить ее малым набором тестовых данных, представляющих полную последовательность.

Предположим, нам дана входная последовательность в табличной переменной. Задача — реализовать программный код, который определяет начальную и конечную позиции входной последовательности в полной последовательности. Приведенный ниже программный код объявляет такую табличную переменную и заполняет ее тестовой входной последовательностью (здесь оставлено место, чтобы вставить исходный текст вашего решения).

— Входная табличная переменная @P

DECLARE @P AS TABLE
(
keycol INT NOT NULL
PRIMARY KEY,
val INT NOT NULL,
UNIQUE(val, keycol)
);

— тестовый ввод; попробуйте другие входные данные

INSERT INTO @P(keycol, val) VALUES
(1, 1),(2, 7),(3, 1),(4, 7);
--

Конечно, можно как угодно менять тестовый ввод, чтобы протестировать решения с разными подпоследовательностями. На рисунке 1 показан желаемый результат для малого набора тестовых данных для входной последовательности 1,7,1,7.

 

Желаемый результат для?малого набора тестовых данных и?входа 1, 7, 1, 7
Рисунок 1. Желаемый результат для?малого набора тестовых данных и?входа 1, 7, 1, 7

На рисунке 2 показан желаемый результат для подпоследовательности 1,7,5,9. Обратите внимание, что при наличии нескольких экземпляров подпоследовательности необходимо определить их все.

 

Желаемый результат для?малого набора тестовых данных и?входа 1, 7, 5, 9
Рисунок 2. Желаемый результат для?малого набора тестовых данных и?входа 1, 7, 5, 9

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

В исходном тексте сначала определяется вспомогательная функция с именем GetNums, а затем она используется для заполнения таблицы T1 десятью миллионами строк.

Решение 1. Использование рекурсивного запроса

Как уже отмечалось, в первой части представлены итеративные решения. В первом решении для выполнения задачи используется рекурсивный запрос. В листинге 3 содержится полный исходный текст решения без приведенного ранее кода для объявления и заполнения входной табличной переменной @P.

Первый запрос в тексте CTE представляет собой закрепленный элемент:

SELECT
T1.keycol AS minkey,
P.keycol AS P_keycol,
T1.keycol AS T1_keycol
FROM @P AS P
INNER JOIN dbo.T1
ON P.val = T1.val
WHERE P.keycol = 1

Запрос фильтрует только первый элемент из входной последовательности в табличной переменной @P, объединяя @P и T1, чтобы идентифицировать все вхождения первого значения подпоследовательности в последовательность. Ключ этого первого значения определяется как потенциальный минимальный ключ (с псевдонимом minkey). Ключи подпоследовательности и последовательности также возвращаются с псевдонимами P_keycol и T1_keycol, соответственно.

Второй запрос в тексте CTE представляет собой рекурсивный элемент:

SELECT
PRV.minkey,
P.keycol AS P_keycol,
T1.keycol AS T1_keycol
FROM C AS PRV
INNER JOIN @P AS P
ON P.keycol = PRV.P_keycol + 1
INNER JOIN dbo.T1
ON T1.keycol = PRV.T1_keycol + 1
AND P.val = T1.val

Этот запрос объединяет предыдущий результат (с псевдонимом PRV) с табличной переменной @P (с псевдонимом P) и таблицей T1. Цель этих объединений — определить следующее значение как в подпоследовательности, так и в последовательности и сообщить о совпадении, только если следующее значение в обоих случаях одинаковое (P.val = T1.val). Этот рекурсивный запрос выполняется повторно до тех пор, пока не будут найдены дополнительные совпадения.

Наконец, внешний запрос фильтрует только строки из CTE, представляющие последние оставшиеся элементы подпоследовательности (P_keycol равен максимальному ключу в @P). Это означает, что найдена вся подпоследовательность:

SELECT minkey, T1_keycol AS maxkey
FROM C
WHERE P_keycol = (SELECT MAX(keycol) FROM @P);

Это довольно простое решение, но, к сожалению, его эффективность невелика. Дело в том, что запрос выполняет много операций ввода-вывода с внутренней рабочей таблицы (буфером), формируемой для хранения промежуточных наборов результатов. У вас нет возможности повлиять на этот процесс, индексацию рабочей таблицы или любые другие аспекты. На моем компьютере выполнение данного решения заняло 11 секунд; при этом было совершено 13 миллионов операций логического считывания.

Решение 2. Использование цикла

Во втором решении реализован тот же подход, что и в первом, но вместо рекурсивного CTE используется явный цикл. В листинге 4 приведен полный исходный текст решения.

Исходный текст начинается с определения временной таблицы с именем #T для хранения промежуточных результатов. Затем объявляются и инициализируются две переменные: @i — счетчик итераций с начальным значением 1, которое увеличивается на 1 при каждом повторном выполнении цикла; @cnt — содержит число элементов в подпоследовательности.

Затем инструкция INSERT выполняет задачу, которая в предыдущем решении возлагалась на закрепленный элемент: фильтрует первый элемент в подпоследовательности и присоединяет к T1, чтобы обнаружить все значения в последовательности, представляющей возможное начало подпоследовательности. Результат сохраняется в #T.

Затем выполняется цикл, который не прекращается до тех пор, пока предыдущая инструкция INSERT возвращает строки. В каждой итерации INSERT выполняет задачу, которая предыдущем решении выполнялась рекурсивным членом, а именно, сопоставляет элементы предыдущего прохода со следующим элементом в последовательности, если это ожидаемый следующий элемент подпоследовательности.

Наконец, последний запрос возвращает ключи начала и конца для всех экземпляров подпоследовательности в последовательности (где P_keycol = @cnt).

При выполнении программного кода в листинге 4 в том виде, как он приведен (без параметра запроса RECOMPILE), я получил тот же план для всех исполнений INSERT в теле запроса. На рисунке 3 показан результирующий план.

 

План для запросов в листинге 4 без RECOMPILE
Рисунок 3. План для запросов в листинге 4 без RECOMPILE

Первый план был сформирован для примерно 1 миллиона совпадений; затем он использовался во всех последующих итерациях. Проблема в том, что все последующие итерации возвращают гораздо меньше совпадений, и данный план — не самый эффективный. На моем компьютере выполнение этого решения заняло 10 секунд; при этом было совершено 3,5 миллионов операций логического считывания.

Производительность решения можно повысить с помощью параметра запроса RECOMPILE в инструкции INSERT в теле цикла. Это заставляет SQL Server подготовить новый план для каждой итерации инструкции, более подходящий на основе новой оценки. Я снял знаки комментария параметра запроса RECOMPILE в листинге 4 и повторно выполнил программный код.

Я получил различные планы для первой итерации инструкции INSERT (подходит для большого числа совпадений) и для всех оставшихся итераций (подходит для малого числа совпадений), как показано на рисунке 4.

 

План для запросов в листинге 4 c RECOMPILE
Рисунок 4. План для запросов в листинге 4 c RECOMPILE

На этот раз запрос был выполнен за 7 секунд, а число логических считываний немного увеличилось (3,85 миллиона), но в целом производительность повысилась.

Решение 3. Принцип «Halloween — разделяй и властвуй»

Несмотря на добавление параметра RECOMPILE в предыдущее решение, остается еще простор для совершенствования. Обратите внимание на операторы Table Spool (Eager Spool) на рисунках 3 и 4. Оптимизатор добавил эти операторы в планы для целей Halloween Protection, чтобы не обрабатывать одну и ту же строку более одного раза. Дополнительные сведения о Halloween Protection можно найти в превосходной серии статей Поля Уайта (http://sqlblog.com/blogs/paul_white/archive/2013/02/21/halloween-protection-the-complete-series.aspx).

В статье «Divide and Conquer Halloween» (http://sqlmag.com/t-sql/divide-and-conquer-halloween) рассмотрена общая форма итеративного алгоритма, используемого в данной статье – «разделяй и властвуй». Я объяснил необходимость в Halloween Protection, когда источник и место назначения итеративной инструкции INSERT совпадают. Было предложено решение, реализующее тот же алгоритм, но без необходимости в Halloween Protection. Просто использовались две временные таблицы вместо одной с переключением между ними. Таким образом на каждом проходе источник и место назначения различаются. Я назвал этот подход «Halloween — разделяй и властвуй». В листинге 5 приведен исходный текст решения, реализующего этот подход.

На рисунке 5 показаны планы для различных исполнений инструкций INSERT в теле цикла, без добавления специального кода для Halloween Protection.

 

План для запросов в листинге 5
Рисунок 5. План для запросов в листинге 5

На моем компьютере выполнение этого решения заняло 3,5 секунды; при этом было совершено всего 370 000 операций логического считывания.

В следующей статье

Благодаря подходу «Halloween — разделяй и властвуй» производительность итеративного решения увеличилась весьма существенно. Показатели были самыми высокими из тех, что мне удалось добиться при использовании итеративного решения в T-SQL. Следующая задача — найти наиболее эффективное решение на основе набора. В течение месяца, пока я не опубликую несколько возможных решений, вы можете попробовать найти собственное. Удачи!

Листинг 1. Исходный текст для создания и заполнения таблицы T1

SET NOCOUNT ON;
USE tempdb;
GO
IF OBJECT_ID(N'dbo.T1', N'U') IS NOT NULL DROP TABLE dbo.T1;
GO
CREATE TABLE dbo.T1
(
keycol INT NOT NULL
CONSTRAINT PK_T1 PRIMARY KEY,
val INT NOT NULL,
CONSTRAINT UNQ_T1_val_keycol UNIQUE(val, keycol)
);
— Малый набор тестовых данных для проверки корректности
INSERT INTO dbo.T1(keycol, val) VALUES
(1, 1),(2, 1),(3, 7),(4, 5),(5, 9),(6, 1),(7, 7),(8, 1),(9, 7),(10, 5),(11, 9);

Листинг 2. Вспомогательная функция GetNums и большой набор тестовых данных

IF OBJECT_ID(N'dbo.GetNums', N'IF') IS NOT NULL DROP FUNCTION dbo.GetNums;
GO
CREATE FUNCTION dbo.GetNums(@low AS BIGINT, @high AS BIGINT) RETURNS TABLE
AS
RETURN
WITH
L0 AS (SELECT c FROM (SELECT 1 UNION ALL SELECT 1) AS D(c)),
L1 AS (SELECT 1 AS c FROM L0 AS A CROSS JOIN L0 AS B),
L2 AS (SELECT 1 AS c FROM L1 AS A CROSS JOIN L1 AS B),
L3 AS (SELECT 1 AS c FROM L2 AS A CROSS JOIN L2 AS B),
L4 AS (SELECT 1 AS c FROM L3 AS A CROSS JOIN L3 AS B),
L5 AS (SELECT 1 AS c FROM L4 AS A CROSS JOIN L4 AS B),
Nums AS (SELECT ROW_NUMBER() OVER(ORDER BY (SELECT NULL)) AS rownum
FROM L5)
SELECT TOP(@high — @low + 1) @low + rownum — 1 AS n
FROM Nums
ORDER BY rownum;
GO
— Большой набор тестовых данных для проверки производительности
TRUNCATE TABLE dbo.T1;
INSERT INTO dbo.T1(keycol, val)
SELECT n AS keycol, ABS(CHECKSUM(NEWID())) % 10 + 1 AS val
FROM TSQL2012.dbo.GetNums(1, 10000000) AS Nums;

Листинг 3. Решение 1: использование рекурсивного запроса

WITH C AS
(
SELECT
T1.keycol AS minkey,
P.keycol AS P_keycol,
T1.keycol AS T1_keycol
FROM @P AS P
INNER JOIN dbo.T1
ON P.val = T1.val
WHERE P.keycol = 1
UNION ALL
SELECT
PRV.minkey,
P.keycol AS P_keycol,
T1.keycol AS T1_keycol
FROM C AS PRV
INNER JOIN @P AS P
ON P.keycol = PRV.P_keycol + 1
INNER JOIN dbo.T1
ON T1.keycol = PRV.T1_keycol + 1
AND P.val = T1.val
)
SELECT minkey, T1_keycol AS maxkey
FROM C
WHERE P_keycol = (SELECT MAX(keycol) FROM @P);

Листинг 4. Решение 2: использование цикла

CREATE TABLE #T
(
minkey INT NOT NULL,
P_keycol INT NOT NULL,
T1_keycol INT NOT NULL,
PRIMARY KEY(P_keycol, T1_keycol)
);
DECLARE
@i AS INT = 1,
@cnt AS INT = (SELECT MAX(keycol) FROM @P);
INSERT INTO #T(minkey, P_keycol, T1_keycol)
SELECT
T1.keycol AS minkey,
P.keycol AS P_keycol,
T1.keycol AS T1_keycol
FROM @P AS P
INNER JOIN dbo.T1
ON P.val = T1.val
WHERE P.keycol = 1;
WHILE @@rowcount > 0
BEGIN
SET @i += 1;
INSERT INTO #T(minkey, P_keycol, T1_keycol)
SELECT
PRV.minkey,
P.keycol AS P_keycol,
T1.keycol AS T1_keycol
FROM #T AS PRV
INNER JOIN @P AS P
ON P.keycol = PRV.P_keycol + 1
INNER JOIN dbo.T1
ON T1.keycol = PRV.T1_keycol + 1
AND P.val = T1.val
WHERE PRV.P_keycol = @i — 1
/* OPTION(RECOMPILE) */;
END;
SELECT minkey, T1_keycol AS maxkey
FROM #T
WHERE P_keycol = @cnt;
DROP TABLE #T;

Листинг 5. Решение 3: использование подхода «Halloween — разделяй и властвуй»

CREATE TABLE #T1
(
minkey INT NOT NULL,
P_keycol INT NOT NULL,
T1_keycol INT NOT NULL,
PRIMARY KEY(P_keycol, T1_keycol)
);
CREATE TABLE #T2
(
minkey INT NOT NULL,
P_keycol INT NOT NULL,
T1_keycol INT NOT NULL,
PRIMARY KEY(P_keycol, T1_keycol)
);
DECLARE
@i AS INT = 1,
@cnt AS INT = (SELECT MAX(keycol) FROM @P);
INSERT INTO #T1(minkey, P_keycol, T1_keycol)
SELECT
T1.keycol AS minkey,
P.keycol AS P_keycol,
T1.keycol AS T1_keycol
FROM @P AS P
INNER JOIN dbo.T1
ON P.val = T1.val
WHERE P.keycol = 1;
WHILE @@rowcount > 0
BEGIN
SET @i += 1;
IF @i % 2 = 0
BEGIN
TRUNCATE TABLE #T2;
INSERT INTO #T2(minkey, P_keycol, T1_keycol)
SELECT
PRV.minkey,
P.keycol AS P_keycol,
T1.keycol AS T1_keycol
FROM #T1 AS PRV
INNER JOIN @P AS P
ON P.keycol = PRV.P_keycol + 1
INNER JOIN dbo.T1
ON T1.keycol = PRV.T1_keycol + 1
AND P.val = T1.val
OPTION(RECOMPILE);
END;
ELSE
BEGIN
TRUNCATE TABLE #T1;
INSERT INTO #T1(minkey, P_keycol, T1_keycol)
SELECT
PRV.minkey,
P.keycol AS P_keycol,
T1.keycol AS T1_keycol
FROM #T2 AS PRV
INNER JOIN @P AS P
ON P.keycol = PRV.P_keycol + 1
INNER JOIN dbo.T1
ON T1.keycol = PRV.T1_keycol + 1
AND P.val = T1.val
OPTION(RECOMPILE);
END;
END;
IF @i % 2 = 0
SELECT minkey, T1_keycol AS maxkey
FROM #T1
WHERE P_keycol = @cnt;
ELSE
SELECT minkey, T1_keycol AS maxkey
FROM #T2
WHERE P_keycol = @cnt;
DROP TABLE #T1, #T2;