Сегодняшнее испытание «Пришествие кода» действительно интересное. Как-то легко, но с парочкой интересных дискуссионных моментов.

День 3: Реорганизация рюкзака помогает эльфам организовать и расставить приоритеты в своих припасах.

Я импортировал входные данные, как обычно, скопировав их с веб-сайта в мой запрос Azure Data Studio, а затем используя STRING_SPLIT для импорта каждой отдельной строки, представляющей содержимое рюкзака, в отдельной строке:

declare @input nvarchar(max) = 'QLFdFCdlLcVqdvFLnFLSSShZwptfHHhfZZZpSwfmHp
rTJRjjbJTgzDJjdsRsfwtfNwtfmZpZNhmmzt
...
jGrGqjJfqccrfqGcGplrJpFvzggqmCtMzmsMnvMvvCgm';

drop table if exists dbo.ch03_input;
create table ch03_input 
(
    id int identity not null primary key,
    items varchar(100) collate Latin1_General_BIN2
);

insert into 
    dbo.ch03_input (items)
select 
    trim(replace([value], char(13), ''))
from
    string_split(@input, char(10))
go

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

Часть 1

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

Первый шаг — разбить список на два списка одинакового размера:

select 
    *,
    len(items) as itemcount,
    left(items, len(items)/2) as comp1,
    right(items, len(items)/2) as comp2
into    
    #step1
from 
    ch03_input;

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

select top(100) row_number() over (order by a.object_id) as n into #n from sys.columns a cross join sys.columns b

select 
    *,
    substring(items, n, 1) as item 
into
    #step2 
from 
    #step1 s  
cross join
    #n n
where 
    n.n <= s.itemcount

Разбить строку на буквы легко, если у вас есть таблица с числами, что я и создаю в первую очередь в приведенном выше запросе. Затем я использую эту таблицу чисел для создания строки для каждой буквы в строке через CROSS JOIN, и для каждой строки генерируется извлечение Nth буквы строки. Предложение WHERE использует itemcount, чтобы убедиться, что я генерирую ровно одну строку для каждой буквы в каждой строке, и не более того.

Затем мне нужно найти, какой предмет находится в обоих отсеках. Это означает проверку наличия буквы в строке, что можно сделать с помощью CHARINDEX:

select distinct
    id, items, comp1, comp2, item,
    charindex(item, comp1, 1) as p1, 
    charindex(item, comp2, 1) as p2
into 
    #step3
from 
    #step2 
where 
    charindex(item, comp1, 1) != 0 
and 
    charindex(item, comp2, 1) != 0
order by id

Запросу требуется DISTINCT, так как в строке может быть более одного элемента одного типа, мне нужен только один элемент для каждого типа. Тот факт, что мне нужно использовать DISTINCT, звонит в колокола (или приносит какой-то запах): я почти уверен, что смогу реорганизовать свой код, чтобы выполнить эту операцию раньше и избежать проверки буквы, которая уже была найдена. Я сделаю это позже, если у меня будет достаточно свободного времени. А пока я хочу посмотреть, работает ли мое решение; после этого я могу оптимизировать его.

Теперь, когда предметы, присутствующие в обоих отсеках, найдены, я должен присвоить каждому типу предметов значение приоритета, как описано в задаче. Значения приоритетов основаны на алфавитном порядке, поэтому я могу использовать ASCII, чтобы получить буквенное значение и преобразовать его в соответствующее значение приоритета. Значения приоритета упорядочены иначе, чем порядок ASCII, поэтому для применения правильных преобразований требуется оператор CASE:

select
    item,
    case 
        when item like '[a-z]' then ASCII(item) - ASCII('a') + 1
        when item like '[A-Z]' then ASCII(item) - ASCII('A') + 27
    end as priority,
    id,
    comp1,
    comp2
into
    #priorities
from
    #step3
order by
    item

А теперь просто суммируя все приоритеты, мы получим ответ:

select sum(priority) from #priorities

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

Часть 2

Эльфы собираются группами по три человека, и цель состоит в том, чтобы выяснить, какой тип предметов несут все в группе.

Первый шаг — создать способ легко сгруппировать эльфов вместе. Используя существующие столбцы id и оператор по модулю, я могу определить, когда начинается новая группа. Когда результат операции по модулю равен единице:

Мне просто нужно знать, когда начинается группа, поэтому я могу установить все, что не равно 1, на 0:

Теперь я могу использовать простой и быстрый промежуточный итог для создания group_id, который позволит быстро идентифицировать все элементы в одной группе. Удивительно, не так ли?

Забавно, что мой друг SQL Guru Itzik упомянул эту технику с промежуточным итогом, когда мы встретились вчера вечером. Забавно, что он мне понадобился бы прямо на следующий день. Спасибо, Ицик!

Окончательный запрос следующий:

select 
    *,
    len(items) as itemcount,
    sum(case when (id % 3) != 1 then 0 else 1 end) over (order by id) as group_id
into    
    #step1
from 
    ch03_input;

легко, быстро и элегантно!

Как только появится способ идентифицировать каждую группу, задача почти решена. Это просто вопрос разделения строк на их буквы, как я сделал и в первой части.

select 
    *,
    substring(items, n, 1) as item 
into
    #step2 
from 
    #step1 s  
cross join
    #n n
where 
    n.n <= s.itemcount

Теперь у меня есть все необходимое, чтобы увидеть, какая буква присутствует во всех трех рюкзаках. Как простая GROUP BY фильтрация только тех букв, которые встречаются ровно три раза с использованием предложения HAVING, даст ответ:

with cte as
(    
    select distinct id, group_id, item from #step2 
)
select
    group_id,
    item
into
    #step3
from
    cte
group by 
    group_id, item
having
    count(*) = 3
order by
    group_id

Сложность здесь заключается в операторе DISTINCT в Common Table Expression. Этот DISTINCT гарантирует, что я смогу отличить предмет, появляющийся трижды в одном и том же рюкзаке, от предмета, появляющегося по одному во всех трех рюкзаках. Нас интересует только последнее, а не первое.

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

Попробуй сам

Полное решение доступно здесь: yorek/aoc-2022

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

Альтернативное решение части 2

Альтернативным решением могло бы быть простое СОЕДИНЕНИЕ между тремя рюкзаками в одной группе:

select distinct
    a.id as group_id,
    a.item as item
into
    #step3
from 
    #step2 a 
inner join
    #step2 b on a.id + 1 = b.id and a.item = b.item
inner join
    #step2 c on b.id + 1 = c.id and b.item = c.item
where
    a.id % 3 = 1
order by a.id
go

Это бы сработало, но только для группы ровно из трех эльфов. Идеально отвечая требованиям, я считаю, что выбранное решение обеспечивает большую гибкость, является более элегантным и ориентированным на будущее. Или agile, если хотите. Это требует некоторого нестандартного мышления, что всегда является хорошей возможностью для тренировки, так здорово, что есть возможность ее использовать. Учитывая, что результирующий запрос касается таблицы только один раз, я также подозреваю, что он также будет быстрее. Стоит покопаться в этом немного больше, если у вас есть время.

Веселиться!