# 팀원 구성

팀원은 cozyyg, kipa00, crescent_h입니다. kipa00과는 UCPC 5번째, 전체 PS 팀 대회로 따지면 8번째, 해시코드까지 합치면 12번째로 함께 나간 대회입니다. 사실 너무 많이 나가서 이 횟수가 맞는지도 잘 모르겠습니다... crescent_h와는 2019년 이후 2번째로 UCPC에 함께 나갔습니다.

팀명이 정해진 과정은 다음과 같습니다.

흔한 디자인 의뢰
이렇게 아무말을 하다 보면 팀명이 완성된다!
그러나 이 팀명이 스코어보드에 쓰이는 일은 없었다
그렇게 팀명을 정하면서 생겼던 고난과 역경을 모두 담은 팀명이 완성되었다

# 대회 준비

팀으로 준비한 건 딱히 없었습니다. 평소에 각자 풀고 싶은 문제를 자유롭게 풀어보면서 대비했습니다.

팀원들은 공통적으로 애드 혹 문제들을 잘 풉니다. 장점이긴 한데, 애드 혹 문제의 경우 실력이 충분하지 않다면 문제 하나에 모든 시간을 쏟아붓고도 풀지 못하는 대참사가 벌어질 수 있기 때문에 양날의 검이라고 생각합니다. 올해도 애드 혹 문제가 나왔는데, H번 문제는 어떻게 되었을까요?

더보기

cozyyg가 맛있게 먹었습니다.

# 대회 전략 수립

문제 분배를 제외하고는 크게 신경쓸 부분은 없었습니다.

cozyyg는 문제를 읽는 속도, 그리고 대략의 풀이를 생각해내는 속도가 빠릅니다. 그래서 거의 대부분의 UCPC에서는 A부터 시작하여 3개씩 뛰어넘으며(ADGJ) 읽었습니다. 어차피 어떤 문제를 누가 먼저 읽는지는 팀원들의 대회 경험이 많은 팀에서는 크게 상관이 없지만, 이 방법에는 한 가지 큰 문제가 있었습니다.

cozyyg가 UCPC A번 문제를 풀면 항상 한 번 틀린다.

그래도 다시 한 번 A를 풀어서, 이 징크스를 스스로 끊어낼까 하는 생각도 해봤지만, "아직은 그럴 준비가 안 되었다" 라고 생각했습니다. 그래서 올해는 G H I J K를 읽었습니다. 이 전략은 과연 잘 통했을까요?

더보기

실제로 저는 F H I를 풀었고, 팀은 A번을 틀리지 않고 맞았으니, 전략은 꽤나 성공적이었다고 할 수 있습니다.

# 대회 결과

8솔브, 18등으로 예선을 마무리했습니다! 대회 종료 후에 Open Contest가 열려서 스코어보드 공개가 늦어졌지만, 본선 진출에 큰 문제가 없으리라 짐작할 수 있었습니다. 그래서 가벼운 마음으로 링고에 가서 맛있는 치킨과 피자와 오렌지 주스를 즐겼습니다.

# 대회 타임라인

각자 집에서 디스코드로 현황을 공유하다 보면, "이거 제가 짭니다", "D AC"와 같이 타임라인을 짐작할 수 있는 로그가 많이 남습니다. 하지만 올해는 오프라인에서 모여서 대회를 치다 보니, 정확한 시각과 다른 팀원들의 현황은 잘 모릅니다. 그래서 이 글에서는 cozyyg의 타임라인에만 초점을 맞추려 합니다.

(0:00 ~ 0:08) 문제 읽기

문제를 G번부터 순서대로 읽었습니다. 문제를 읽으면서 했던 생각은 다음과 같습니다.

 

G: 일단 tree_set이나 priority_queue 같은 게 필요할 것 같은 문제고, 정말 어처구니없는 쉬운 풀이가 있는 게 아닌 이상, 빨리 풀 수 있는 문제는 절대 아니라고 생각했습니다. (그리고 그런 풀이가 있는지는 중간에 스코어보드를 확인하면 쉽게 판단이 가능하기 때문에, 꼭 빨리 잡을 필요는 없는 문제입니다.)

H: 맛 있 다 !

I: 이런 형태의 문제는 어려워봤자 priority_queue의 간단한 활용 정도니, 다른 쉬운 문제가 없다면 이것부터 풀기로 했습니다.

J: 총 다섯 개의 줄이 주어진다. 라는 문장 하나만으로도, 이 문제가 어려운 문제임을 예상할 수 있었습니다. 그래서 문제 내용만 이해하고 넘어갔습니다.

K: 문제 관상이 너무나도 parametric search였습니다. 그래도 I번보다는 구현에 시간이 걸릴 것 같았고, 다른 사람이 짜도록 하고 H를 짜야겠다 라고 생각했습니다.

 

이런 과정을 거친 결과, 제가 할 일은 I를 짠 다음, 다른 쉬운 문제가 남아있지 않다면, 즐겁게 H를 음미하기 였습니다. 이 때 AD는 다른 팀원들이 이미 풀었습니다.

(0:08 ~ 0:14) I 구현

I번 문제의 코딩에 바로 착수했습니다. 생각을 좀 하다 보니 \(a_i - K \cdot i\)\(a_i + K \cdot i\)가 중요해 보였고, 이걸 priority queue로 관리하면 되겠다고 생각했습니다. 그런데 어차피 최소만 중요하고 이 값이 \(i\)마다 고정되니까, 최솟값만 들고 있어도 충분하면 된다는 결론이 나왔습니다. 그래서 빠르게 코드를 짰고, 14분 2초째에 낸 코드가 AC를 받았습니다.

(0:14 ~ 0:16) 스코어보드 확인

다른 쉬운 문제가 있지는 않은지 확인해보기로 했습니다. A D I만 많이 풀렸고, 간간히 BK를 푼 팀이 있는 정도였습니다. K가 생각했던 대로 전형적인 문제일 것이라는 확신을 가질 수 있었고, 저는 저의 할 일을 하러 가기로 했습니다. 이 때 다른 팀원들은 풀이가 나온 C를 짜고, 일찍 풀린 문제들인 BK를 보고 있었던 것 같습니다.

(0:16 ~ 0:57) H 맛있게 먹기 구현

풀이는, 문제 읽기 과정에서 이미 나왔습니다. 작년에 출제했던 돌무더기 게임 1 문제와 밀접한 관련이 있었기 때문에, 문제를 읽자마자 불가능한 경우를 바로 알아낼 수 있었습니다. 그리고 변환이 가역적이라는 점에 착안하여 두 팔찌를 작은 base case로 변환하면 되겠다고 생각했고, 몇몇 경우들을 메모장에 써보고 나니 구현할 준비가 완료되었습니다.

더보기

\(RG \rightarrow B\)

\(RRG \rightarrow RB \rightarrow G\)

\(RRR \rightarrow RBGR \rightarrow GGR \rightarrow GB \rightarrow R\)

\(GG \rightarrow RBG \rightarrow RR\)

이런 문제는 Python으로 짜고 싶을 때가 많습니다. 구현을 하다 보면 비슷한 과정을 반복하게 될 때가 많아서, 함수로 잘 분리해줘야 합니다. C++보다 Python이 나을 거라 생각해서 Python으로 구현을 시작했습니다. 여러 함수를 선언해가며 구현을 해나갔고, 한두 번의 디버깅 끝에 예제에 대해 정확한 출력이 나오는 것을 눈으로 확인했습니다. 57분째에 코드를 제출했더니 바로 맞았습니다. 제 코드가 어떻게 만들어졌는지, 자세한 구현 과정을 아래에 써둡니다.

더보기

1. 일단 base case를 정합니다. 저는 R, G, B, RR로 정했습니다.

2. 함수를 하나 만듭니다. 이 함수는, 문자열을 입력으로 받아서, 어떤 base case로 환원되는지, 그리고 환원하는 과정을 순서대로 return 하는 함수입니다. 일단 이런 함수를 만들 것이다 라는 사실만 알아두고, 이 함수의 구현은 나중에 진행합니다.

3. 출력 부분을 먼저 만들어줍니다. 환원되는 base case가 다르면 불가능하니까 -1을 출력하고 끝냅니다. 아직 a, b라는 리스트에 뭘 넣을지는 모르겠지만, 일단 출력 형태를 그대로 넣는다고 생각하고 함수를 짭니다. b에 있는 원소는 순서도 뒤집고, 각 쿼리도 뒤집어야 하니까, 쿼리를 뒤집는 함수도 하나 선언해줍니다. 여기까지를 구현하면 다음과 같습니다.

# 1 1 2
# 2 2 R G

def iv(x):
    pass

def f(s):
    pass

_,s=input().split()
_,t=input().split()
q, a = f(s)
r, b = f(t)
if q != r:
    print("-1\n")
    exit(0)
print(str(len(a)+len(b))+"\n")
for x in a:
    print(' '.join(map(str, x)))
for x in b[::-1]:
    print(' '.join(map(str, iv(x))))

4. 이렇게 구현해놓고 iv 함수를 짜려는데, 짤 수가 없습니다. 2 쿼리를 1 쿼리로 바꿀 수는 있는데, 1 쿼리를 2 쿼리로 바꾸려고 보니 색깔을 모릅니다. 그래서 a, b 리스트에 들어갈 형태를 수정해줍니다. 두 쿼리 모두, [쿼리 번호, 왼쪽 구슬의 인덱스, 왼쪽 구슬의 색깔, 오른쪽 구슬의 인덱스, 오른쪽 구슬의 색깔]을 저장하도록 합니다. 이때 원소를 출력하는 것이 조금 복잡해졌기 때문에, show라는 함수를 선언하여 수정하고, 주석도 수정해줍니다. 이제 iv 함수를 구현하려는데, 놀랍게도 쿼리 번호만 바꾸면 됩니다! 이럴 거면 왜 함수로 뺐지 라는 생각이 들 수도 있지만, 한 줄짜리 함수도 큰 의미가 있는 경우가 많습니다. 그대로 둡니다. 참고로 저는 다른 문제를 풀던 중 x, y를 입력으로 받아서 x*y를 return하는 함수도 그대로 둔 적 있습니다. 그 결과 코드 부분마다 실제 곱셈과 해당 프로세스 중 어떤 것을 시행한 건지 구분할 수 있어서 도움이 되었습니다.

import sys
input=lambda:sys.stdin.readline().strip()
print=sys.stdout.write

# 1 1 R 2 G
# 2 2 R 3 G

def show(p):
    if p[0] == 1:
        return f"1 {p[1]+1} {p[3]+1}\n"
    else:
        return f"2 {p[1]+1} {p[2]} {p[4]}\n"

def iv(x):
    return [3-x[0]] + x[1:]

def f(s):
    pass

_,s=input().split()
_,t=input().split()
q, a = f(s)
r, b = f(t)
if q != r:
    print("-1\n")
    exit(0)
print(str(len(a)+len(b))+"\n")
for p in a:
    print(show(p))
for p in b[::-1]:
    print(show(iv(p))

놀랍게도 여기서 f 함수만 구현하면 정답 코드가 완성됩니다. 문제 풀이의 핵심인 이 함수는 여러분이 직접 구현해보세요! 팁을 드리자면, 끝의 2개 또는 3개를 1개로 바꾸는 과정을 반복하면 됩니다. pop과 append는 \(O(1)\)에 되니까 시간 초과를 걱정할 필요가 없습니다.

그 사이 다른 팀원들은 C를 풀었고, K도 조금 뒤에 풀렸습니다.

(0:57 ~ 1:38) F 구현

B가 많이 풀리고 있길래, Bkipa00이 봤고, 풀이가 생각나긴 했는데 이렇게 어려운 게 어떻게 많이 풀리는 것인지 모르겠다고 했습니다. 그리고 F가 많이 풀리고 있었기 때문에, 귀찮은 자료구조 구현 문제 같다kipa00의 의견을 듣고 구현을 시작했습니다.

고민을 좀 하다가 홀수 열 홀수 행에 있는 원소들이 어떻게 이동할지를 생각해보니, 다 함께 움직인다는 사실을 파악했습니다. swap 쿼리가 고민이었는데 kipa00이 던져준 조언을 받아들여서 그대로 짰더니 예제가 거의 나왔습니다. 예제 2에서 출력되는 수의 순서가 살짝 이상하길래, 아무 생각 없이 코드에 있던 몇 개의 -를 +로 바꿨더니 예제가 잘 나왔습니다. 98분째에 제출한 다음 맞기를 기도한 결과 한 번에 맞았습니다. 한 번 틀리기 시작하면 말리기 좋은 문제라서 틀렸다면 정신적 고통이 엄청났을 것 같은데, 운이 좋았던 것 같습니다.

(1:38 ~ 2:08) 러버덕 + 기도

F를 풀고 나서도 B는 계속 kipa00이 구현하고 있었습니다. 쉬운 풀이가 있지 않을까 생각하고 던진 풀이는 안타깝게도 small to large를 생각해내지 못한 저희 팀에게는 \(O(NK)\) 풀이였고, 그 결과 \(\sqrt{N}\)을 기준으로 나눠, 무려 LCA까지 써가며 문제를 해결하는 \(O(N \sqrt{N \log N})\) 풀이를 구현하는 kipa00을 응원할 수밖에 없었습니다. 몇 번의 시행착오가 있었는데, 옆에서 함수를 하나하나 같이 보면서 문제점을 찾아 고쳤습니다. \(\sqrt{N}\)과 같이 산술-기하 평균 부등식으로 결정되는 기준을 잡을 때 저는 가장 큰 값에 대해 최적인 값을 고정값 기준으로 정합니다. 이러한 팁을 전달해주었고, 거기에 연산 속도에 대한 저의 감을 더해 200을 기준으로 잡자는 결론을 냈습니다. 그 결과 128분째에 AC를 받았습니다.

(2:08 ~ 3:00) 남은 문제 감상 및 휴식

남은 문제는 E G J였고, 하나씩 읽어봤습니다. E는 어려운 수학이거나 FFT일 거라 생각했고, 그걸 풀 기운이 남아있는 팀원은 없었습니다. GJ는 제 처음 느낌 그대로 풀 만한 문제가 아니었고, 풀 수 있다고 해도 본선 진출이 거의 확정된 시점에서 딱히 풀고 싶지는 않았습니다. 그래서 스코어보드를 보면서 놀았습니다. 그리고 kipa00에게 H번을 던져주고 풀이를 생각해보도록 했습니다. 처음에 H번이 생각보다 너무 안 풀려서 살짝 놀랐는데, kipa00이 푸는 데 걸린 시간을 보니, 제가 비정상적으로 빨리 풀이를 생각한 것이었습니다.

# 짧은 후기

문제를 잘 풀어낸 것 같아서 기쁩니다. B번 문제의 경우 최근 커뮤니티 대회의 트렌드였다고 하는데, 요즘 들어 커뮤니티 대회에 별로 참여를 못 해서 잘 몰랐던 것 같습니다. 트렌드를 따라가는 일은 정말 어려운 것 같습니다. H번 문제를 너무 재밌게 풀었고, 지금도 계속 문제를 풀어냈던 과정이 떠오를 정도로 인상적이었습니다. 다른 까다로운 문제들을 잘 풀어주고, 문제를 안 풀고 있을 때도 모니터로 제가 풀던 문제의 입력 형식과 예제를 띄워주고 러버덕이 되어준 팀원 kipa00, crescent_h에게 정말 고맙다는 말을 해주고 싶습니다. 그리고 앞으로도 애드 혹 강자로서의 입지를 더더욱 굳혀나가고 싶습니다. 본선도 화이팅!

'PS' 카테고리의 다른 글

SCPC 2021 Round 2 5번 문제 접근 과정과 풀이  (0) 2021.08.08
Google Hash Code 2020 대회 (간단한) 후기!  (0) 2020.02.21

첫 세 문제를 풀어내는 데 좀 많은 시간을 써서, 대회 시간 내에 풀지는 못했지만, 만약 저에게 2시간만 더 있었다면 이 문제를 풀어낼 수 있었을 겁니다...

1. 생각의 시작

일단 간단한 재귀를 생각해보는 것으로 출발했습니다. 대략 두 칸 넘어갈 때마다 횟수가 3배 정도 되는 방법들이 나왔는데, 가장 마지막에 생각했던 간단한 재귀는 다음과 같습니다.

1234567 - - → 127 - 3456 → 1 27 3456 → 13456 27 - → 1 234567 - → - 234567 1 → ...

한편 여기서 중요한 성질들을 조금 잡아냈습니다.

1. 반드시 "1 234567 -"이 되어야만 "- 234567 1"로 넘어가면서 진행할 수 있다.

2. 모든 이동은 가역적이기 때문에, "- - 1234567 → - 234567 1"의 과정은 앞에서 만든 것을 활용할 수 있으므로, 이 과정을 정확히 반대로 수행하면 "- 234567 1 → - - 1234567"을 만들 수 있다.

특히 2번 성질은 구현량을 효과적으로 줄여주기 때문에 매우 중요했습니다.

2. 좌절

위에 나와 있는 재귀의 식을 구해보면

f(n) = 2(g(n) + 2 + h(n)) + 1

g(n) = g(n-2) + 2 + h(n-2)

h(n) = h(n-2) + 1 + f(n-5) + 1 + h(n-2) + 1 + h(n-2)

정도의 식이 나왔습니다. 만점의 희망을 갖고 dp를 계산한 결과는..!

N=23 → 941745, N=24 → 1743913. 140점 정도짜리 풀이가 나왔습니다. 하지만 저는 부분 점수 따위에 만족하고 싶지 않았습니다. 그리고, 사실 "간단한 재귀"라고 했지만 구현이 생각보다 귀찮기 때문에, 완전한 풀이를 만들자고 결심했습니다. 뭐 결과적으로는 이게 실수였던 것 같습니다만... 저는 괜찮습니다. 이미 본선을 네 번이나 나갔기에 미련 없습니다.

3. 규칙 찾기

N=26일 때 만점을 받을 수 있는 길이와의 차이가 6배 이상 되었기 때문에, 방향을 선회해서 N이 작을 때의 최적해를 구해보고, 규칙을 찾아서 답을 만들어낼 수 있는 알고리즘을 구현해보기로 했습니다. BFS 코드에서 checker 코드를 만드는 것도 약간의 수정이면 가능해서, 만드는 김에 checker까지 만들었습니다.

from queue import Queue
N = 11

sd = [(0,1),(0,2),(1,0),(1,2),(2,0),(2,1)]
inv_sd = [2,4,0,5,1,3]
chkd = {}
def f(state, x):
    towers = [[],[],[]]
    for i in range(N):
        towers[state%3].append(i)
        state //= 3
    src, des = sd[x]
    nn, mm = len(towers[src]), len(towers[des])
    if nn == 0:
        return -1
    ppap = towers[src][nn//2]
    if (mm < 1 or towers[des][(mm-1)//2] < ppap) and (mm < 2 or ppap < towers[des][(mm-1)//2+1]):
        towers[src].remove(ppap)
        towers[des].insert((mm+1)//2, ppap)
    else:
        return -1
    nstate = 0
    for i in range(3):
        for j in towers[i]:
            nstate += i*(3**j)
    return nstate

def ppp(state):
    towers = [[],[],[]]
    for i in range(N):
        towers[state%3].append(i+1)
        state //= 3
    print(towers)

if __name__ == '__main__':
    start = 0
    end = (3**N-1)
    bfsq = Queue()
    bfsq.put(start)
    chkd[start] = (0, -1)
    while not bfsq.empty():
        tmp = bfsq.get()
        # print(tmp, chkd[tmp])
        if tmp == end:
            break
        prv = chkd[tmp][0]
        for i in range(6):
            ttmp = f(tmp, i)
            if ttmp != -1 and ttmp not in chkd:
                chkd[ttmp] = (prv+1, i)
                bfsq.put(ttmp)
    y = end
    ans = []
    aans = []
    while y!=start:
        tmp = chkd[y]
        ans.append(tmp[1])
        aans.append(y)
        y = f(y, inv_sd[tmp[1]])
    print(ans[::-1])
    print(''.join([chr(ord('A')+i) for i in ans[::-1]]))
    print(len(ans))
    idx = 0
    for z in aans[::-1]:
        idx += 1
        print(idx, end=' ')
        ppp(z)

# checker
# if __name__ == '__main__':
#     cur = 0
#     N = int(input())
#     with open("output.txt", "r") as ff:
#         ans = ff.readline().strip()
#     # ans = input()
#     cnt = 0
#     for c in ans:
#         xx = ord(c) - ord('A')
#         cur = f(cur, xx)
#         cnt += 1
#         # print(cnt, end=' ')
#         # ppp(cur)
#         if cur == -1:
#             print("boom")
#             exit(0)
#     print(cnt, end=' ')
#     ppp(cur)

이렇게 답을 뽑아놓고, 100개 정도 되는 타워들의 패턴을 보면서, 큰 덩어리가 움직이는 형태가 나오는지를 관찰했습니다. 특히, 예를 들면 N=7인 경우 1과 7이 항상 타워에 들어 있는 상황이라면, 1과 7이 없더라도 똑같은 이동 결과가 나오게 됩니다. 1234567 - - → 127 - 3456를 23456 - - → 2 - 3456과 똑같이 생각할 수 있다는 것이죠. 이것이 "간단한 재귀"에서 재귀 함수를 호출하게 되는 부분입니다. 심지어 이동도 똑같기 때문에 정답 string도 어느 기둥에서 어느 기둥으로 옮기는지만 잘 바꿔주면 됩니다.

1 [[1, 2, 3, 5, 6, 7], [4], []]
2 [[1, 2, 3, 6, 7], [4, 5], []]
3 [[1, 2, 6, 7], [4, 5], [3]]
4 [[1, 2, 7], [4, 5], [3, 6]]
5 [[1, 2, 5, 7], [4], [3, 6]]
6 [[1, 2, 5, 7], [], [3, 4, 6]]
7 [[1, 2, 7], [], [3, 4, 5, 6]]

8 [[1, 7], [2], [3, 4, 5, 6]]
9 [[1], [2, 7], [3, 4, 5, 6]]

10 [[1, 5], [2, 7], [3, 4, 6]]
11 [[1, 5], [2, 4, 7], [3, 6]]
12 [[1], [2, 4, 5, 7], [3, 6]]
13 [[1, 6], [2, 4, 5, 7], [3]]
14 [[1, 3, 6], [2, 4, 5, 7], []]
15 [[1, 3, 5, 6], [2, 4, 7], []]
16 [[1, 3, 5, 6], [2, 7], [4]]
17 [[1, 3, 6], [2, 7], [4, 5]]
18 [[1, 6], [2, 3, 7], [4, 5]]
19 [[1], [2, 3, 6, 7], [4, 5]]
20 [[1, 5], [2, 3, 6, 7], [4]]
21 [[1, 5], [2, 3, 4, 6, 7], []]
22 [[1], [2, 3, 4, 5, 6, 7], []]

23 [[], [2, 3, 4, 5, 6, 7], [1]]

24 [[], [2, 3, 4, 6, 7], [1, 5]]
25 [[4], [2, 3, 6, 7], [1, 5]]
26 [[4, 6], [2, 3, 7], [1, 5]]
27 [[4, 6], [2, 7], [1, 3, 5]]
28 [[4], [2, 6, 7], [1, 3, 5]]
29 [[], [2, 6, 7], [1, 3, 4, 5]]
30 [[6], [2, 7], [1, 3, 4, 5]]
31 [[6], [2, 4, 7], [1, 3, 5]]
32 [[], [2, 4, 6, 7], [1, 3, 5]]
33 [[3], [2, 4, 6, 7], [1, 5]]
34 [[3, 6], [2, 4, 7], [1, 5]]
35 [[3, 4, 6], [2, 7], [1, 5]]
36 [[3, 4, 5, 6], [2, 7], [1]]
37 [[3, 4, 5, 6], [2], [1, 7]]
38 [[3, 4, 5, 6], [], [1, 2, 7]]
39 [[3, 4, 6], [], [1, 2, 5, 7]]
40 [[3, 6], [4], [1, 2, 5, 7]]
41 [[3, 6], [4, 5], [1, 2, 7]]
42 [[3], [4, 5], [1, 2, 6, 7]]
43 [[], [4, 5], [1, 2, 3, 6, 7]]
44 [[], [4], [1, 2, 3, 5, 6, 7]]
45 [[], [], [1, 2, 3, 4, 5, 6, 7]]

N=7일 때를 간단히 보자면, 1234567 - - → 127 - 3456을 23456 - - → 2 - 3456로 환원할 수 있습니다. 또한, 이후 1 27 3456 → 1 234567 - 역시 1 - 3456 → 1 3456 -로 환원할 수 있습니다. 이렇게 하면 1234567 - - → 1 234567 -가 끝났고, 위에서 언급했듯이 전체 답도 만들어낼 수 있습니다.

많은 시행착오 끝에 다음과 같은 5가지 점화식으로 모든 것을 나타낼 수 있었습니다.

f(9,^,0,0) = f(7,^,0,0) + 2 + f(6,v,^,0) + 1 + f(6,^,v,0)
f(9,0,0,^) = f(8,v,^,0) + 1 + f(8,^,v,0)
f(9,^,v,0) = f(7,^,v,0) + 1 + f(6,0,0,^) + 2 + f(7,^,^,0)
f(9,^,^,0) = f(6,0,0,^) + 2 + f(5,^,^,0) + 1 + f(6,^^,0,^) + f(6,0,^^,^) + f(5,^,^,0) + 2 + f(6,0,0,^)
f(9,^^,0,^) = f(7,0,0,^) + 2 + f(6,^,^,0) + 1 + f(5,0,0,^) + 2 + f(4,^,^,0) + 1 + f(5,^^,0,^)

f(9,^,0,0): 123456789A - - => 1 23456789A -
f(9,0,0,^): 23456789A - 1 => - 23456789A 1
f(9,^,v,0): 123456789A B - => 1 23456789AB -
f(9,^,^,0): 13456789AB 2 - => 1 23456789AB -
f(9,^^,0,^): 13456789ABC - 2 => 13 456789ABC 2

실제 구현과는 조금 다른데, 세 번째 케이스가 바닥이 목표 기둥이 아닌 곳에 있어야 더 빠를 때가 있어서 해당 부분을 flag로 넣어 최적화를 했습니다.

저 점화식을 쓸 때 기둥 위치까지 같이 썼다면 좀 더 구현이 빨랐을 수도 있겠네요.

4. 구현, 그리고 대회 종료

구현을 하다가 결국 완성된 코드를 만들지 못하고 대회가 종료되었습니다. 이후 열심히 코딩을 했지만 2시간이 더 걸렸습니다. 아이디어 완성 이후부터만 따지더라도 전체 코딩 시간이 4시간은 넘는 것 같습니다.

처음에 N이 작은 경우에 대한 하드코딩을 좀 덜 했더니 1007835라는 조금 아쉬운 길이가 나왔습니다. 작은 경우에 대한 예외처리를 열심히 추가했더니 976371짜리 만점 답안이 나왔습니다. 출제자가 확인한 972051보다는 큰데, 더 줄이기 위해서는 아마 세 번째 케이스에 대한 완벽한 최적화가 필요할 것 같습니다.

아래는 답을 만드는 코드입니다.

#include <bits/stdc++.h>
using namespace std;
pair<int,int> i2p[6] = {{0,1},{0,2},{1,0},{1,2},{2,0},{2,1}};
map<pair<int,int>, int> p2i;
int ggr[6] = {2,4,0,5,1,3};
void init(){
	for(int i=0;i<6;i++) p2i[i2p[i]] = i;
}
string cv(string s, int a, int b, int c){
	string res = "";
	int k = s.size();
	int xxx[3] = {a, b, c};
	for(int i=0;i<k;i++){
		pair<int,int> tmp = i2p[s[i] - 'A'];
		res += (char)('A'+p2i[{xxx[tmp.first], xxx[tmp.second]}]);
	}
	return res;
}
string geoguro(string s){
	string res = "";
	int k = s.size();
	for(int i=k-1;i>=0;i--) res += (char)('A'+ggr[s[i]-'A']);
	return res;
}
string f1(int n, int a, int b, int c);
string f2(int n, int a, int b, int c);
string f3(int n, int a, int b, int c, bool flag);
string f4(int n, int a, int b, int c);
string f5(int n, int a, int b, int c);
int main(){
	// freopen("output.txt", "w", stdout);
	init();
	int N = 26;
	cout << "Case #1\n";
	cout << N << "\n";
	string ans;
	ans += f1(N-1, 0, 1, 2);
	ans += "B";
	ans += geoguro(f1(N-1, 2, 1, 0));
	cout << ans << "\n";
	cout << ans.size();
}
string f1(int n, int a, int b, int c){
	if(n==0) return "";
	if(n==1) return cv("A", a, b, c);
	if(n==2) return cv("AA", a, b, c);
	if(n==3) return cv("BAAF", a, b, c);
	if(n==4) return cv("BBAAEFA", a, b, c);
	string res = "";
	res += f1(n-2, a, c, b);
	res += (char)('A'+p2i[{a,b}]);
	res += (char)('A'+p2i[{a,b}]);
	res += geoguro(f3(n-3, a, c, b, true));
	res += f3(n-3, a, b, c, false);
	// cout << "f1 " << n << " " << a << " " << b << " " << c << ": " << res << "\n";
	return res;
}
string f2(int n, int a, int b, int c){
	if(n==0) return "";
	if(n==1) return cv("A", a, b, c);
	if(n==2) return cv("BAF", a, b, c);
	if(n==3) return cv("ABBCFFA", a, b, c);
	string res = "";
	res += geoguro(f3(n-1, c, a, b, false));
	res += f3(n-1, c, b, a, true);
	return res;
}
string f3(int n, int a, int b, int c, bool flag){
	if(n==0) return flag?"":cv("F", a, b, c);
	if(n==1) return flag?cv("DAF", a, b, c):cv("AF", a, b, c);
	if(n==2) return flag?cv("DAFA", a, b, c):cv("AFA", a, b, c);
	string res = "";
	if(flag){
		res += f3(n-2, a, c, b, false);
	}
	else{
		res += f3(n-2, a, c, b, true);
	}
	res += (char)('A'+p2i[{a,b}]);
	res += f2(n-3, c, a, b);
	res += (char)('A'+p2i[{c,b}]);
	res += (char)('A'+p2i[{c,b}]);
	res += f4(n-2, a, b, c);
	return res;
}
string f4(int n, int a, int b, int c){
	if(n==0) return "";
	if(n==1) return cv("A", a, b, c);
	if(n==2) return cv("BAF", a, b, c);
	if(n==3) return cv("ABBCFFA", a, b, c);
	if(n==4) return cv("BAABCDAECFAAF", a, b, c);
	if(n==5) return cv("ABDAAFBDCDAECEFBAAEFA", a, b, c);
	string res = "";
	res += f2(n-3, a, c, b);
	res += (char)('A'+p2i[{a,b}]);
	res += (char)('A'+p2i[{a,b}]);
	res += f4(n-4, c, b, a);
	res += (char)('A'+p2i[{a,c}]);
	res += f5(n-3, b, c, a);
	res += (char)('A'+p2i[{b,a}]);
	res += geoguro(f5(n-3, a, c, b));
	res += (char)('A'+p2i[{c,b}]);
	res += f4(n-4, a, c, b);
	res += (char)('A'+p2i[{a,b}]);
	res += (char)('A'+p2i[{a,b}]);
	res += f2(n-3, c, b, a);
	return res;
}
string f5(int n, int a, int b, int c){
	if(n==0) return "";
	if(n==1) return cv("BAE", a, b, c);
	if(n==2) return cv("ABAE", a, b, c);
	if(n==3) return cv("ABBAEF", a, b, c);
	if(n==4) return cv("BAFBBDAFEF", a, b, c);
	string res = "";
	res += f2(n-2, a, b, c);
	res += (char)('A'+p2i[{a,c}]);
	res += (char)('A'+p2i[{a,c}]);
	res += f4(n-3, b, c, a);
	res += (char)('A'+p2i[{a,b}]);
	res += f2(n-4, c, b, a);
	res += (char)('A'+p2i[{c,a}]);
	res += (char)('A'+p2i[{c,a}]);
	res += f4(n-5, b, a, c);
	res += (char)('A'+p2i[{c,b}]);
	res += f5(n-4, a, b, c);
	return res;
}

끝으로 마지막 디버깅 단계에서 썼던, 실제 이동 과정이 들어있는 메모를 첨부합니다.

# f5
13456...WXYZ - 2
13YZ 456..WX 2
1Z 456..WX 23Y
1Z 4 2356..WXY
1 4Z 2356..WXY
1 456..WZ 23XY
13X 456..WZ 2Y
136..WX 45Z 2Y
136..WX 45YZ 2
13456..WXYZ - 2

# f1
123..XYZ - -
12Z - 3..XY
1 2Z 3..XY
13..X 2Z Y
13..X 2YZ -
1 23..XYZ -

# f3
1234..XY Z -
1234..XY - Z
12Y - 34..XZ
1Y 2 34...XZ
14..XY 2 3Z
14..XY 23Z -
1 234..XYZ -

# f2
2..YZ - 1
Z - 12..Y
- Z 12..Y
- 2..YZ 1

# f4
1345..XYZ 2 -
13YZ 2 45..X
1Z 23Y 45..X
1Z 235..XY 4
1 235..XY 4Z
1 23 45..XYZ
13 2 45..XYZ
135..XY 2 4Z
135..XY 2Z 4
13Y 2Z 45..X
1 23YZ 45..X

작년의 파이널 라운드 진출팀이었지만, 올해는 예선 121등, 한국 4등으로 대회를 마무리하게 되었습니다. ㅠㅠ

새벽이라 좀 정신 없긴 하지만, 데이터별 공략을 간단히 적어보도록 할게요!

(혹시 나중에 더 풀어보실 생각이 있으시다면 미리 spoiler alert!)

 

A. example

더보기

pdf 파일에 있는 데이터를 그대로 출력하면 16점이 나오지만, 4번 책이 포함되도록 순서를 잘 조정하면 이 데이터에서 얻을 수 있는 최대 점수인 21점을 받을 수 있습니다. 

 

B. read on

더보기

데이터가 매우 단순합니다! 겹치는 책도 없고, 도서관마다 책 수도 똑같고, 다른 것은 signup time 뿐입니다. 그러므로 signup time이 작은 순서대로 출력하면 가능한 최고점인 5,822,900점을 받을 수 있습니다.

 

C. incunabula

더보기

이 데이터는 shipping size가 터무니없이 커서, signup만 하면 모든 책을 챙길 수 있습니다. 적절한 기준에 따라 Greedy하게 library를 고르는 것만으로도 5,689,502점을 받을 수 있었습니다. 대부분의 최상위권 팀들도 이 근처의 점수를 받았습니다. 저희 팀이 쓴 기준은 (추가되는 점수)/(signup time)이었고, 다른 library를 추가할 때마다 추가되는 점수를 실질적으로 반영하여 다시 산정하였습니다. 이러한 update의 관리는 c++의 priority_queue 등으로 깔끔하게 처리할 수 있습니다.

 

D. tough choices

더보기

데이터 이름이 암시하듯, 사실 이 데이터는 두 library 중 하나를 고르는 선택의 연속입니다. 데이터를 잘 관찰해보면, 2k번 library와 2k+1번 library가 책을 하나씩 공유합니다. 그리고 이 책들은 그 둘에서만 등장하죠. 나머지 책들은 모두 세 번씩 등장합니다. 30000개의 library, 각각의 signup time 2, 그리고 deadline 30001. 이 수들이 갖는 의미는, library를 15000개 고를 수 있다는 것이고, 만약 만점을 받을 수 있다면, 그 데이터는 2k번 library와 2k+1번 library 중 정확히 하나만을 포함할 것입니다. 그래서 2k번 library와 2k+1번 library 중 책이 더 많은 쪽을 일단 골라봤습니다. 그 자체로는 만족할 만한 점수가 나오지 않았지만, 상태를 flip하며(2k번이 골라졌었다면 그걸 해제하고 2k+1번을, 반대쪽도 비슷하게) 더 좋은 쪽으로 선택하도록 했습니다. 그 결과 5,043,025점을 받았습니다. 참고로 이 문제의 이론상 만점은 65*78600=5,109,000점입니다. 최상위권 팀들 중에는 510만점을 넘긴 팀들이 몇 있었고, 이 팀들은 데이터의 특징으로부터 추가적인 관찰을 했습니다. 둘 중 하나를 고른다는 것을, p나 ~p 중 하나를 고른다는 걸로 생각하는 거죠. 그러면 이 문제는 78600-15000=63600개의 clause와 15000개의 variable로 이루어진 3-SAT 문제입니다. 3-SAT solver를 이용하면 510만점 이상의, 거의 만점에 가까운 점수를 얻을 수 있다고 합니다.

 

E. so many books

더보기

이 데이터가 최상위권과 상위권을 가르는 데이터가 되지 않았나 싶습니다. 정말 별 방법이 다 있었고, MCMF부터 이상한 hyper-parameter 조작까지(<-이게 저희 팀이었습니다) 다양한 방법과 다양한 점수가 있었습니다. 최상위권의 점수는 520만점 정도였지만 저희 팀은 이 데이터에서 5,095,422점을 내는 데 그쳤습니다. 이 부분이 가장 아쉽네요.

 

F. libraries of the world

더보기

이 데이터도 E만큼은 아니지만 팀별로 점수 차이가 조금 났었습니다. 넣을 수 있는 library의 수가 그렇게 많지 않다는 사실로부터 접근하면 여러 경우들을 잘 시도해보는 것으로 다수의 팀들이 5,348,248점을 받았습니다. 저희 팀은 5,317,660점을 받았네요.

 

이렇게 저희 팀은 26,968,350점을 받았습니다. 한국에서는 78-9 팀이 27,070,220점으로 가장 높은 점수를 받았고, 전체 순위도 38등이라 사실상 월드 파이널 진출이신 것 같습니다! 아직은 모르긴 하지만 미리 축하드리고, 4월 25일(한국 시간으로는 하루 뒤일 수도 있습니다만)에 있을 월드 파이널에도 많은 관심 가져주시면 좋겠습니다! 또, 앞으로도 한국 팀들이 더 선전하여 내년에는 더 많은 한국 팀을 월드 파이널에서 볼 수 있었으면 좋겠네요!

+ Recent posts