Advent of Code 2023 in Oracle - Day 7: Camel Cards

Advent of Code 2023 in Oracle SQL and PL/SQL. Day 7: Camel Cards.

Advent of Code

Advent of Code is a great yearly programming challenge. Each day (1st to 25th December), a new challenge arises that challenges your problem-solving and programming skills. I learn a lot every year, and it is a lot of fun.

I will do the challenges in Oracle (SQL and, where necessary, PL/SQL) this year again. You can find my solutions in this repository and, if I find the time, on this blog.

I strongly recommend checking out Advent of Code yourself before reading on. The challenges are clearly and in a Christmas theme. There is always a small sample input to test your solution and a bigger input to get the real result. When you solve the first challenge, you get a star, and a second, more difficult challenge appears.

My setup

I am doing the challenges on a local 23c free edition database. I am eager to explore some of the new features of 23c, so some of the syntax might not work on older versions. Furthermore, I will also use the Oracle APEX API packages.

I import the input data into a table that stores each line of the input as a row.

create table aoc_input (
  day      integer             not null -- 1-25
, key      varchar2(255 char)  not null -- 'SAMPLE1', 'INPUT' etc.
, line_no  integer             not null -- 1, 2, 3, ...
, line_str varchar2(4000 char) not null -- The actual line
, constraint aoc_input_pk primary key (day, key, line_no)
);

I then use SQL Developer to import the input data into the table. I use a bind for the input and paste the data from the website:

begin
  insert into aoc_input
    (day, key, line_no, line_str)
  with x as (
    select :bind as data from dual
  )
  select 1
       , 'INPUT' -- or something like 'SAMPLE1'
       , rownum
       , column_value
    from x
       , apex_string.split(x.data, chr(10));

  commit;
end;
/

Day 7

You can read the challenge text here.

Star 1

I noticed pretty quickly that ranking the games would be too complicated to achieve in pure SQL for my taste. So I opted to create a PL/SQL function that returns a numeric rank for a given hand. It will be numeric so that we can just sort by it to get the best/worst hands.

To achieve such a rank, we need to consider the highest factor. In the poker case, this is the “type” like “two pairs” or “four of a kind”. Notice how most types require only one piece of information and some two: The count of the most common card and the count of the second most common card. I just created the following scale:

mc = most common card, smc = second most common card

7. Five of a kind: mc=5
6. Four of a kind: mc=4
5. Full House: mc=3, smc=2
4. Three of a kind: mc=3
3. Two pair: mc=2, smc=2
2. One Pair: mc=2
1. High card mc=1

To program this out, we first need to loop over each character to count the occurrences. We can use a map/associative array (table of number index by varchar2(1char)) to store the counts. I have dedicated a whole blog post to maps in PL/SQL if you want to find out more. Afterward, we loop over our map to store the highest and second-highest counts.

create or replace function day7_get_hand_rank (pi_hand in varchar2)
  return number
as
  type t_char_map is table of number index by varchar2(1char);
  l_char_map t_char_map;
  /* map would look like this as JSON for hand 'A73A7':
  {
    "A": 2,
    "7": 2,
    "3": 1
  }
  */

  l_char varchar2(1 char);
  l_key  varchar2(1 char);
  l_fir_highest number := 0;
  l_sec_highest number := 0;
begin
  -- loop over eacht character and store it in the map
  for i in 1..5
  loop
    l_char := substr(pi_hand, i, 1);
    if l_char_map.exists(l_char) then
      l_char_map(l_char) := l_char_map(l_char) + 1;
    else
      l_char_map(l_char) := 1;
    end if;
  end loop;

  -- loop over the map to find the highest and second highest count
  l_key := l_char_map.first;
  while l_key is not null
  loop
    if l_char_map(l_key) > l_fir_highest then
      l_sec_highest := l_fir_highest;
      l_fir_highest := l_char_map(l_key);
    elsif l_char_map(l_key) > l_sec_highest then
      l_sec_highest := l_char_map(l_key);
    end if;

    l_key := l_char_map.next(l_key);
  end loop;

  dbms_output.put_line(
    apex_string.format(
      'l_fir_highest: %s, l_sec_highest: %s',
      l_fir_highest,
      l_sec_highest
    )
  );

  -- ...

Next, we can give scores to each type. I used a regular case statement for this. For the cases where we need to consider the second-highest count, I added an if statement. In the end, we add the rank number (higher → better) to a string.

case l_fir_highest
  when 5 then
    l_res_str := '7';
  when 4 then
    l_res_str := '6';
  when 3 then
    if l_sec_highest = 2 then
      l_res_str := '5';
    else
      l_res_str := '4';
    end if;
  when 2 then
    if l_sec_highest = 2 then
      l_res_str := '3';
    else
      l_res_str := '2';
    end if;
  when 1 then
    l_res_str := '1';
  else
    raise no_data_found;
end case;

The next question is: What happens if two hands have the same type? In this case, we need to compare the hands, card by card. On the first occurrence they differ we use the higher card to determine the winner. We can also achieve that with numerical sorting.

Because there are more than 10 cards, we have to pad the cards with a leading zero. This way, we can sort them numerically. We can use a function for this:

create or replace function day7_get_hand_rank (pi_hand in varchar2)

  -- ...

  function get_card_value (pi_char in varchar2)
    return varchar2
  as
  begin
    case pi_char
      when 'A' then
        return '14';
      when 'K' then
        return '13';
      when 'Q' then
        return '12';
      when 'J' then
        return '11';
      when 'T' then
        return '10';
      else
        return '0' || pi_char;
    end case;
  end get_card_value;

  -- ...

Inside the loop where we iterate over each character, we can use this function to get each card’s value. We just append every value to a string, so the first card value will be at the start of the string:

  for i in 1..5
  loop
    l_char := substr(pi_hand, i, 1);
    if l_char_map.exists(l_char) then
      l_char_map(l_char) := l_char_map(l_char) + 1;
    else
      l_char_map(l_char) := 1;
    end if;

    -- build the card value string
    l_card_value_seq := l_card_value_seq || get_card_value(l_char);
  end loop;

In the end, we can append the type result number to the front of the card value string so that it has the most impact on the number size. Now we only need to return the string converted to a number.

  l_res_str := l_res_str || l_card_value_seq;

  dbms_output.put_line(
    apex_string.format(
      'l_res_str %s',
      l_res_str
    )
  );

  return to_number(l_res_str);
end day7_get_hand_rank;

You can check out the full code of the function here.

To see how it works, we can query our input table and call the function. At first, I separate the hand and the bet for each line:

with hand_bet as (
  select regexp_substr(line_str, '([0-9A-Z]+) [0-9]+', 1, 1, null, 1) as hand
       , to_number(
          regexp_substr(line_str, '[0-9A-Z]+ ([0-9]+)', 1, 1, null, 1)
         ) as bet
    from aoc_input
   where day = 7
     and key = 'SAMPLE'
)
select hand
     , bet
     , day7_get_hand_rank(hand) as score
  from hand_bet
 order by 3 desc
;
/*
HAND        BET          SCORE
________ ______ ______________
QQQJA       483    41212121114
T55J5       684    41005051105
KK677        28    31313060707
KTJJT       220    31310111110
32T3K       765    20302100313
*/

We can see that QQQJA and T55J5 both start with a 4 because both are three of a kind’s. The first has a higher card value. The first card is Q vs a T that’s why the second and third numbers are 12 vs 10. Great; our function seems to work.

To get the result for our data input, we need to get a rank for each score and then multiply that with the bet (5 hands: best hand has rank 5, 2nd best has rank 4…). We can use the row_number analytic function for this. At last, we need to multiply the rank by the bet and sum it up:

with hand_bet as (
  select regexp_substr(line_str, '([0-9A-Z]+) [0-9]+', 1, 1, null, 1) as hand
       , to_number(
          regexp_substr(line_str, '[0-9A-Z]+ ([0-9]+)', 1, 1, null, 1)
         ) as bet
    from aoc_input
   where day = 7
     and key = 'INPUT'
), scores as (
  select hand
       , bet
       , day7_get_hand_rank(hand) as score
       , row_number() over(order by day7_get_hand_rank(hand) asc) as rnk
    from hand_bet
   order by 4 desc
)
select sum(rnk * bet)
from scores
;

/*
   SUM(RNK*BET)
_______________
      255048101
*/

Star 2

For the second challenge, we must handle J differently, as this is actually a Joker card. This means that this card should be used as any other card with the best potential outcome.

I thought this would be complicated, but it was actually pretty easy. We don’t need to write intelligent code that tries out different combinations, as the solution is to always use the Joker as the most common card. It never makes sense to go for two pairs or a full house, as a triplet or a four of a kind is always better. So 77J22 should be treated as 77722 and JJJ77 as 77777.

To achieve this, I started to count the Joker cards in the loop where we fill the map. Note that it is important that we don’t add the Jokers to the map itself. Otherwise, if the most common card is a joker, we would get the wrong result (applying jokers to jokers).

for i in 1..5
loop
  l_char := substr(pi_hand, i, 1);
  if l_char_map.exists(l_char) then
    l_char_map(l_char) := l_char_map(l_char) + 1;
  elsif l_char = 'J' then
    -- dont put into map
    l_joker_count := l_joker_count + 1;
  else
    l_char_map(l_char) := 1;
  end if;

  l_card_value_seq := l_card_value_seq || get_card_value(l_char);
end loop;

Before the case statement that determines the type of the hand, we just add the number of jokers to the most common card count:

if l_joker_count > 0 then
  l_fir_highest := l_fir_highest + l_joker_count;
end if;

Additionally, the value of the Joker card has changed. Instead of 11 we now return 01:

case pi_char
  -- ...
  when 'J' then
    return '01';

And that’s basically it. We now just use all the Jokers as the most common card. If we use the same query with the new function, we get the result.

Conclusion

If you find different or better solutions, please let me know in the comments. I am always eager to learn new things.

You can check out the full code on GitHub.

Other Posts

Comments

Loading comments...
Homepage All Blogposts

AI disclaimer: I spend hours writing my blog posts by hand, adding my own thoughts and experiences to them. In my view, purely AI-generated content lacks that human depth and isn't worth publishing. I only use AI for research and editing assistance.