Jekyll2024-04-25T00:49:34+00:00https://justicehui.github.io/rss/JusticeHui가 PS하는 블로그Let's solve problem with JusticeHui!JusticeHui2024 ICPC Asia Pacific Championship 후기2024-04-09T00:00:00+00:002024-04-09T00:00:00+00:00https://justicehui.github.io/review/2024/04/09/icpc-apac

서론

지난 2월 29일부터 3월 3일까지 베트남 하노이에서 열린 2024 ICPC Asia Pacific Championship에 참가했습니다. 관광은 별로 관심이 없어서 따로 기록하진 않았고, 대회 이야기를 해 보려고 합니다. 2023 ICPC Asia Seoul Regional Contest 후기(링크)에서 이어지는 글입니다. 팀원 구성과 팀 특성 같은 내용은 서울 리저널 후기에서 확인할 수 있습니다.

대회 소개

ICPC는 한국이 속한 Asia Pacific을 포함해 Asia East, Asia West, South Pacific, Norhern Eurasia, Europe, North America, Latin America, Africa 등 여러 개의 슈퍼 리저널로 분할되어 있고, 각 슈퍼 리저널에서 독자적인 규칙을 통해 월드 파이널 진출 팀을 선정합니다. Asia Pacific 지역은 지난 2022-2023 cycle까지 각 리저널 대회 상위 2~3팀을 선발했고, 다른 모든 지역은 리저널 대회 상위 팀들을 모아서 지역 챔피언십을 개최해 월드 파이널 진출 팀을 선발했었습니다. 그리고 올해, 2023-2024 cycle부터 Asia Pacific 지역도 챔피언십 대회가 신설되었습니다.

ICPC Asia Pacific Championship(이하 플레이오프)은 2019년부터 이야기가 나왔었지만(링크), 코로나 19(아마도?)로 인해 계속 미뤄지다가 이번 2023-2024 cycle에 처음으로 개최되었습니다. 각 리저널 대회(Seoul, Yokohama, Jakarta, Taoyuan, Hue city)에서 우승한 5팀은 월드 파이널에 직행하고, 나머지 진출권은 이번 대회를 통해 결정하는 방식입니다. 리저널 우승팀이 소속된 대학은 우승팀만, 다른 대학에서는 최대 3팀이 플레이오프에 진출할 수 있습니다. 각각 한국 대회와 베트남 대회에서 우승한 카이스트 Penguins팀과 서울대학교 NewTrend팀 덕분에 한국에서는 매우 다양한 대학의 팀이 플레이오프에 진출하게 되었습니다. 인도네시아 대회에서 티켓을 뺏어온 서강대학교 Redshift팀과 전북대학교 2 3 5 8 14팀도 한국 진출 팀을 늘리는 데 큰 역할을 했습니다. 65팀 중 14팀이 한국 팀이었습니다.

한국에서 여러 팀이 진출한 만큼 재미있는 후기 글도 많이 올라왔습니다. 함께 보시면 좋을 것 같습니다.

  • 포스텍 AllSolvedin1557팀 leo020630님 후기 #1, #2, #3
  • 포스텍 AllSolvedin1557팀 kwoncycle님 후기 #
  • 연세대 SCC_SinChonCoders팀 ystaeyoon113님 후기 #
  • 연세대 cookie팀 plast님 후기 #

우리 연습 진짜 안 해?

서울 리저널 전까지는 시간 날 때마다 팀 연습을 했었습니다. 2023년 말 ~ 2024년 초는 저와 kyo20111가 Good Bye, BOJ 2023!, Hello, BOJ 2024! 개최 준비를 하고 있었던 상황이라 두 대회가 모두 끝나고 1월 중순부터 팀 연습을 다시 시작하려고 했지만… 저는 갑자기 네이버 인턴(기습 홍보)에 합격해 버려서 연습할 시간이 없었고, 다른 두 팀원도 회사와 해외여행 때문에 시간이 없어서 단 한 번도 연습을 진행하지 않았습니다. 팀 연습만 하지 않은 게 아니라 아예 3명 모두 PS에 시간을 투자하지 못해서 2월 말이 됐을 때는 살짝 맛이 가 있는 상태였습니다. 풀이도 바로바로 안 나오고 코딩 실수도 많이 하고…

230701(토) UCPC 2023 예선 (3시간)
230722(토) UCPC 2023 본선 (5시간)
231011(수) ECNA 2022 (3시간)
231012(목) TOPC 2023 (3시간)
231013(금) SWERC 2016 (5시간)
231016(월) Brazil 2023 (3시간)
231021(토) 2023 ICPC 서울 지역 대회 예선 (3시간)
231027(금) 2023 Latin (5시간)
231029(일) 2023 Macau (5시간)
231106(월) NCPC 2021 (5시간)
231120(월) 2023 CCPC Guilin (5시간)
231122(수) CERC 2020 (5시간)
231125(토) 2023 ICPC 서울 지역 대회 본선 (5시간)

(공백)

240302(토) 2024 ICPC Asia Pacific Championship (5시간)

팀노트 작업

ICPC 대회에 참가할 때마다 항상 더 이상 추가할 내용이 없을 것이라 생각하면서 팀노트 작업을 마감하고 있습니다. 하지만 주기적으로 이상한 알고리즘을 새로 배워오는 사람들 덕분에 매번 기존 코드의 길이를 줄이고 필요 없는 내용을 쳐내서 공간을 만든 다음에 새로 공부한 것을 넣는 행동을 반복하고 있습니다. 이게 진짜 힘든데… 아무도 안 알아줍니다.

지금까지 주로 준비했던 ICPC 서울 리저널 대회는 2n년 간의 기출 문제를 기반으로 정수론, 다항식, 생성 함수, 3차원 기하 등 꽤 많은 분야를 배제하고 준비할 수 있었지만, 이번에 참가하는 ICPC Asia Pacific Championship은 올해 처음으로 개최되는 대회이기 때문에 어떠한 것도 배제하면 안 된다고 생각했습니다. 특히 이번 대회의 scientific committee(명단)에 베트남 사람이 2명이나 있는 것이 많이 신경 쓰였습니다. 베트남은 지난 몇 년 동안 리저널 대회에 생성 함수 문제를 출제한 전적이 있으므로 이번에도 무조건 생성 함수 문제가 나올 것으로 생각했고, 팀노트의 꽤 많은 지면을 조합론 카운팅과 다항식 관련 라이브러리에 투자했습니다. 이 밖에도 대회 환경을 알 수 없으므로 스택 사이즈 제한을 우회하는 이상한 인라인 어셈블리 코드, 왠지 나만 빼고 다 알고 있는 것만 같은 각종 transform/convolution 코드도 추가했습니다. 3차원 기하는 팀에 다룰 줄 아는 사람이 없으므로 과감하게 포기했습니다. 수십 개의 팀노트를 모두 보면서 3차원 기하와 선형 대수를 제외한 내용은 모두 넣은 것으로 기억합니다. 작업 내용은 다음과 같습니다.

  • 수학
    • 카운팅, 생성 함수, Faulhaber 공식 관련 노트 추가
    • $O(n \log n)$ polynomial log 추가
    • $O(n \log n)$ polynomial exp 추가
    • $O(n \log n)$ polynomial pow 추가
    • $O(n \log n)$ polynomial sqrt 추가
    • $O(n \log n)$ polynomial sampling shift 추가
    • zeta/mobius transform, and/or/gcd/lcm convolution 추가
    • primitive root finder 추가
    • $O(n \log^2 n)$ multipoint evaluation 코드 길이 단축
    • $O(n \log^2 n)$ polynomial interpolation 코드 길이 단축
    • 행렬, 다항식 관련 알고리즘의 실제 실행 시간 표기
  • 기하
    • $O(n^2)$ 원 합집합 넓이 추가
  • 그래프
    • degree sequence 관련 노트 추가
    • lr flow, circulation, cost-scaling mcmf 추가
    • directed mst 코드 길이 단축
  • 문자열
    • all lcs 추가
  • 기타
    • stack hack 추가
    • 각종 bithack 추가

팀노트는 아마 9월에 개최되는 2024 ICPC 월드 파이널이 끝난 다음에 공개할 것 같습니다.

목표와 경쟁 상대

가장 중요한, 그리고 유일한 목표는 월드 파이널 진출입니다. edenooo는 지난 학기를 끝으로 졸업했기 때문에 이번이 마지막 기회였고, 숭실대에는 edenooo의 역할을 대신할 수 있는 사람은 없기 때문에 다른 2명(jhnah917, kyo20111)도 이번이 마지막이라 생각하고 대회에 임했습니다. 서울 리저널 대회를 치르던 11월의 실력으로는 WF 진출권을 따지 못하는 게 불가능하다고 생각했지만, 이번 대회는 팀원 3명의 상태가 모두 별로 좋지 않아서 걱정을 많이 했었습니다. 마침 코드포스에 Teams going to the 2024 ICPC Asia Pacific Championship(링크)라는 글이 올라왔길래 참가자들의 레이팅을 확인해 봤는데…

어…

일단 SpeedStar(TokyoU), absinthe(NUS), NewTrend(SNU), RngBased(NTU)와 시트에 없는 Penguins(KAIST)는 리저널 우승 팀이기 때문에 이미 WF 진출이 확정된 상태였습니다. 아시아 태평양 지역에는 보통 WF 티켓이 16장 정도 할당되기 때문에 WF 진출이 확정된 팀을 제외하고 대학 순위 기준 11등 안에 들어야 했습니다. 위에 있는 시트를 보면서 우리 팀이 질 수도 있다는 생각이 드는 팀(볼드체는 리저널 우승팀)은 다음과 같았습니다.

  • 한국(2팀): 서울대, 카이스트
  • 일본(4팀): 도쿄대, 교토대, 도쿄공대, 오사카대
  • 싱가포르(2팀): NUS, 난양공대
  • 대만(1팀): NTU
  • 인도네시아(1팀): 인도네시아대학
  • 베트남(2팀): VNU-HCM, VNU-UET

이 팀들에게 다 져도 16등 안에는 들 수 있기 때문에 말리지만 않는다면 WF 진출은 가능할 것으로 예상했습니다.

예비 소집

잘 치고 싶은 대회는 항상 긴장되고, 잘 치는 것이 당연한 대회도 잘 하지 못할까 봐 항상 떨립니다. 이번 대회는 그 둘 모두 해당하는 대회라서 인천 공항에서부터 정말 많이 긴장하고 있었고, 다른 팀들의 실력을 처음으로 확인할 수 있는 예비 소집 날은 말 그대로 다리가 덜덜 떨리고 있었습니다.

예비 소집 문제는 12개였지만, 문제 번호를 4로 나눈 나머지가 같은 문제는 같은 문제였기 때문에 실제로는 4문제였습니다. edenooo는 다른 일정이 있어서 예비 소집이 끝날 때쯤 대회장에 도착했었기 때문에 예비 소집은 저와 kyo20111 2인팀으로 참가했습니다. 문제 지문만 읽었을 때의 첫인상은 다음과 같았습니다.

  • A. rope 쓰면 바로 풀 수 있는 문제 → __gnu_cxx::crope 사용법 까먹어서 1번 틀린 다음에 AC
  • B. 애드혹?
  • C. 안 읽었는데 kyo20111이 쉽다면서 풀었음
  • D. 쉬운 문제 → 실수 오차 때문에 2번 틀린 다음에 AC

B는 머리를 써야 하는 문제 같아서 kyo20111에게 던져두고 저는 손가락만 움직이면 되는 A와 D를 풀었습니다. 제가 A D를 구현하는 동안 kyo20111이 50 이하의 소수가 15개밖에 없다는 것을 이용하면 될 것 같다는 관찰을 했고, 저는 15를 보자마자 Bit DP를 외치며 키보드를 잡았습니다. 전형적인 Bit DP라서 풀이 구체화는 빠르게 할 수 있었고, 사소한 부분을 하나 놓쳐서 1번 틀리고 AC를 받았습니다.

예비 소집은 6등으로 마무리되었습니다. 예상했던 것보다 훨씬 높은 등수를 받은 것을 보며 해외 팀들이 그렇게 잘하지 않는다는 것을 깨달았습니다. 대회 초반에 말리더라도 어떻게든 복구해서 월드 파이널에는 진출할 수 있을 것이라는 자신감이 생겼고, 실제로 예비 소집이 끝난 뒤에는 이전처럼 별로 긴장이 되지 않았습니다.

대회

드디어 결전의 날입니다. 이제야 이야기하는 건데, 사실 PS akgwi 팀은 팀 전략이 없습니다. 유일하게 지키는 원칙은 대회 시작하고 1시간 안에 모든 사람이 모든 문제의 내용을 알고 있기? 정도밖에 없습니다. 기하랑 문자열은 제가 잡고, 경우의 수 구해야 하면 edenooo한테 주고, 머리 써야 하는(내가 풀기 싫은) 문제나 든든한 국밥 같은 문제는 kyo20111 주고, 안 풀리면 서로 바꿔서 보고… 기본적으로 서로를 믿을 수 있을 정도로 실력이 좋고, 3명의 강점이 서로 다르기 때문에 한 명이 말려도 나머지 2명이 커버할 수 있는 조합이라서 가능한 방법인 것 같습니다. 2022년 서울 리저널에 참가했던 NLP 팀은 팀 전략이 확실히 있었기 때문에 후기(링크)에 대회 진행 상황을 열심히 적었는데, 이번 대회는 2023 서울 리저널과 마찬가지로 별로 기억나는 것이 없습니다. 그래서 그냥 제가 푼 문제(B, C, H) 풀이만 간단하게 적어보려고 합니다.

H. Pho Restaurant (BOJ 31600)

2가지의 메뉴만 판매하는 식당에 $N$개의 테이블이 있다. $i$번째 테이블에 $\vert S_i \vert$명의 사람이 있고, $i$번째 테이블의 $j$번째 사람은 $S_i[j]$번 메뉴를 주문하려고 한다. 식당 주인은 주문 처리의 편의를 위해, 사람을 몇 명의 사람을 다른 테이블로 옮겨서 같은 테이블에 있는 사람은 모두 같은 음식을 주문하도록 만들려고 한다. 이때 필요한 사람 이동 횟수의 최솟값을 구하는 문제
$N \leq 100\,000;$ $1 \leq \sum \vert S_i\vert \leq 200\,000$

대회에서 가장 먼저 풀린 문제입니다. 쉬운 문제였지만 스코어보드에 불기둥이 세워지고 있던 상태라 정신 똑바로 차리고 문제를 잡았습니다.

$S_i$에서 $0$의 개수를 $C(i, 0)$, $1$의 개수를 $C(i, 1)$이라고 합시다. 일반적으로는 $C(i, 0) < C(i, 1)$이면 $0$번 메뉴를 주문한 사람, 반대의 경우에는 $1$번 메뉴를 주문한 사람을 옮기는 것이 좋을 것 같지만, 이게 정답이었으면 스코어보드에 불기둥이 세워지진 않았을 것 같아서 코너 케이스를 고민하기 시작했습니다.

$N$개의 테이블에서 모두 $C(i, 0) < C(i, 1)$이라면 $S_i[j] = 0$인 사람만 옮기려고 할 텐데, 테이블을 새로 추가할 수는 없기 때문에 이 사람들이 갈 수 있는 곳이 없습니다. 따라서 이런 상황에서는 $C(i, 1) - C(i, 0)$이 가장 작은 테이블로 $S_i[j] = 0$인 사람을 모아야 하고, 이 케이스만 잘 처리하면 AC를 받을 수 있습니다.

C. Bit Counting Sequence (BOJ 31595)

음이 아닌 정수 $x$를 이진법으로 표현했을 때 등장하는 1의 개수를 $p(x)$라고 정의하자. 수열 $A_1, A_2, \cdots, A_N$이 주어지면 $p(x), p(x+1), \cdots, p(x+N-1)$이 $A$와 같은 가장 작은 $x$를 구하는 문제
$T \leq 1\,000;$ $N \leq 500\,000;$ $0 \leq A_i \leq 60$

문제를 처음 봤을 때의 관상은 “내가 못 푸는 문제”였습니다. 하늘에서 뚝 떨어진 뜬금없는 아이디어 하나 잡고 이리저리 고민해서 풀어야 할 것만 같은, 제가 별로 안 좋아하는 유형의 문제처럼 보였습니다.

사실 문제를 푼 방법도 이것과 크게 다르지 않습니다. $\min A_i = 1$이면 쉽게 풀 수 있다는 것만 관찰하고 풀이가 보이지 않아서 멍때리고 있었는데, 뜬금없이 rkm0959의 얼굴이 떠오르더니 rkm0959가 BOJ 13729번 문제(링크)에 난이도 투표에 남긴 “믿음을 가지고 lifting”이라는 문장이 생각났습니다.

뜬금없이 튀어나온 “lifting”이라는 단어는 하늘이 점지해 준 것이 분명해 보였습니다. 이 키워드를 갖고 열심히 고민한 결과, 다음과 같이 그럴듯한 가설을 만들어낼 수 있었습니다.

$A_1, A_2, \cdots, A_N$의 정답을 $x$라고 하면, $A_1+1, A_2+1, \cdots, A_N+1$의 정답은 $x + 2^{\lfloor log_2 (x+N-1) \rfloor + 1}$이다.

즉, $x+N-1$의 최상위 비트보다 한 칸 더 높은 비트를 켠 것이 $A_1+1, A_2+1, \cdots, A_N+1$의 정답이라는 것입니다. 이 가설이 참이라면 $A$의 모든 원소에 $\min A_i - 1$를 빼서 $\min A_i = 1$인 문제를 해결한 뒤, 적당히 “lifting” 시키는 것으로 전체 문제를 해결할 수 있습니다! 예제를 보니 대충 맞는 것 같아서 랜덤 스트레스 테스트를 돌려봤는데 반례가 나오지 않았습니다. 맞는 풀이일 것이라는 확신이 들어서 자신감을 갖고(믿음을 갖고) 구현을 시작했고, 한 번에 AC를 받았습니다.

B. Attraction Score (BOJ 31594)

2차원 평면 위에 $N$개의 정점이 주어지고, 서로 교차하지 않는 “선분 형태”의 가중치 있는 무향 간선이 $M$개 주어진다. 정점 부분 집합 $S \subset V$의 가중치가 다음과 같이 계산될 때, 가중치의 최댓값을 구하는 문제.
(1) 만약 $a, b \in S$를 연결하는 간선이 있으면 그 간선의 가중치를 더함
(2) 간선으로 연결되지 않은 $a, b \in S$ 쌍의 개수를 $f(S)$라고 할 때, $-10^6 \times f(S)^2$를 더함
$N \leq 100\,000;$ $M \leq 300\,000;$ $w \leq 10^6$

입력으로 주어지는 그래프는 평면 그래프입니다. 평면 그래프와 관련해서 다음과 같은 사실이 잘 알려져 있습니다.

  1. 평면 그래프는 4개의 색으로 칠할 수 있음 → 크기가 5 이상인 클리크가 존재하지 않음
  2. $ V \geq 3$인 단순 평면 그래프는 항상 $E \leq 3V-6$을 만족함 → $2E < 6V$이므로 항상 차수가 5 이하인 정점이 존재함
  3. perfect elimination ordering을 구하는 것과 비슷한 느낌으로, 차수가 작은 정점부터 차례대로 제거하면 $O(n)$개의 클리크를 모두 $O(n \log n)$ 시간에 구할 수 있음

일단, 간선이 하나 빠질 때마다 정점 집합 $S$의 가중치가 많이 감소합니다. 따라서 클리크에 가까운 형태만 보면 된다는 것은 쉽게 관찰할 수 있습니다. 실제로 간선이 2개 이상 빠지는 경우에는 가중치를 감소시키지 않으면서 차수가 $\vert S\vert -1$이 아닌 정점을 하나 지울 수 있습니다. 따라서 클리크와 간선이 하나 빠진 클리크만 고려해도 정답을 구할 수 있습니다. 평면 그래프는 $K_5$가 존재하지 않으므로 4개 이하의 정점으로 구성된 클리크, 5개 이하의 정점으로 구성된 간선 하나 빠진 클리크만 고려하면 됩니다.

클리크 찾아서 가중치 계산하는 건 쉬우니까 넘어가고, 간선이 하나 빠진 클리크를 잘 찾는 방법을 고민해야 합니다. $K_4$에서 간선을 하나 제거한 그래프는 삼각형($K_3$) 2개가 변 하나를 공유하고 있는 형태로만 등장할 수 있습니다. 따라서 각 간선마다 간선에 달려 있는 삼각형의 가중치를 모두 모은 다음, 가장 큰 2개를 보면 쉽게 해결할 수 있습니다.

이제 $K_5$에서 간선을 하나 제거한 형태만 찾으면 됩니다. 이 그래프는 간선이 항상 선분 형태라는 조건을 활용해야 합니다.

모든 간선이 선분 형태이기 때문에 $K_4$는 위 그림처럼 삼각형 안에 점이 하나 들어간 형태만 가능하고, 삼각형을 구성하는 점 3개가 고정되면 삼각형 내부에 클리크를 구성할 수 있는 점은 최대 1개만 존재합니다. 또한, $K_5$에서 간선이 하나 빠진 그래프는 위 그림처럼 $K_4$의 작은 삼각형 안에 $K_4$가 들어간 형태만 가능합니다. 따라서 $K_3$과 $K_4$를 다 구한 다음, 필요한 정보를 잘 뽑아서 정규화한 뒤, 전부 살펴보는 코드를 200줄 정도 작성하면 AC를 받을 수 있습니다.

저는 다음과 같은 구조체를 정의한 다음 열심히 구현했습니다. 아무튼 $O(n \log n)$에 되긴 합니다.

struct k3{
    array<int,3> v; // 정점 번호 오름차순 정렬
    array<int,3> e; // e[3-i-j] = v[i] v[j] 연결하는 간선 가중치
};

struct k4{
    k3 out; // 바깥쪽 삼각형
    array<k3,3> a; // 내부 점을 통해 만들어지는 삼각형
    array<int,3> in; // 안쪽 간선 3개 가중치
    array<int,3> out; // 바깥쪽 간선 3개 가중치
};

대회 결과

프리즈 전에는 4등이었지만 대회 마지막 1시간 동안 교토대학교가 D를 풀어서 5등으로 밀려났습니다. 처음에는 1n등 안에만 들어서 월드 파이널에 진출하는 것이 목표였지만, 막상 은메달 1등을 하고 나니 금메달을 못 받은 것이 아쉬웠습니다. 은탑 동탑은 그냥 한국의 전통인가…

한국에서는 리저널 대회 우승팀인 서울대학교 NewTrend, 카이스트 Penguins팀과 함께 이번 플레이오프에서 은메달을 받은 숭실대학교 PS akgwi, 포스텍 AllSolvedIn1557팀이 월드 파이널 진출을 확정 지었습니다. 유니스트 Potom팀과 연세대학교 SCC팀도 진출 가능성이 있어 보이는데 아직 아시아 태평양 지역에 티켓이 몇 장 떨어질지 몰라서… 빨리 발표되면 좋겠습니다.

SCCC의 미래

숭실대학교는 2020-2021 cycle부터 2023-2024 cycle까지 지난 4년 동안 ICPC에서 서울대학교와 카이스트, 그리고 2020년의 고려대학교를 제외하고는 적수가 없을 정도로 강력한 퍼포먼스를 보여주었습니다. 이런 실적은 같은 시기에 서로 분야가 다른 오렌지~레드급 실력자(18학번 aeren, 19학번 edenooo, 20학번 kyo20111, 21학번 jhnah917)가 학교에 3명 이상 있었고, 그 사람들이 모두 군대와 졸업을 미루면서까지 대회를 준비했었기에 가능했다고 생각합니다. 이번 대회를 끝으로 CF 오렌지 이상인 사람들은 모두 학교를 졸업하거나 월드 파이널 2회 진출자가 되어 더 이상 ICPC에 참가할 수 없게 되었습니다. 따라서 저를 포함한 많은 사람이 더 이상 숭실대에서 월드 파이널 진출 팀이 나오기는 어려울 것으로 예상하고 있었습니다.

하지만 이번 대회를 보면서 생각이 바뀌었습니다. 해외 팀들은 최상위권을 제외하면 생각했던 것만큼 강력하지 않았고, 팀원 조합을 잘 맞추고 연습을 열심히 한 2오렌지 1퍼플 팀이라면 월드 파이널 진출을 노릴 수 있을 것 같았습니다. 한국에서 3등 안에 드는 것은 어려운 일이지만 오렌지 2명 퍼플 1명이 나오는 건 가능해 보입니다. 저는 더 이상 ICPC에 나갈 수 없게 되었지만 아직 졸업까지 1년이라는 시간이 남아 있습니다. 1년 안에 민트~블루 정도 되는 사람들을 오렌지로 만드는 것은 힘들더라도, 함께 공부할 수 있는 환경을 만들기 위해 노력해 보려고 합니다. 제발 제가 숭실대의 마지막 월드 파이널 진출자가 아니길 바랍니다…

마무리

ICPC 온사이트 대회만 나가면 5등을 하는 사람이 되었습니다. 온라인으로 진행된 21 리저널을 제외하면 22 리저널과 23 리저널, 그리고 이번 24 플레이오프에서 모두 5등(은상)을 받았습니다. WF에서도 5등을 하면 참 좋을 텐데… 이건 진짜 어림도 없어 보입니다.

ICPC는 WF에 2번 진출해서 더 이상 참가하지 못하고, 1년 뒤면 대학교도 졸업하기 때문에 SCPC와 UCPC 같은 대학생 대회도 올해가 마지막 참가가 될 것 같습니다. 중고등학생 때 정보 올림피아드를 준비할 때부터 프로그래밍 대회는 항상 끝이 보이지 않는 레이스와 같은 느낌이었는데 드디어 끝이 보이기 시작했습니다. 반년만 더 열심히 공부해서 만족할 만한 성과를 거두고 아름답게 마무리하고 싶습니다.

다음 글은 (이집트 여행기에 더 가까운) 2023 ICPC WF 후기로 찾아오겠습니다. 왜 2023 ICPC WF가 아직도 안 열렸는지는 모르겠지만…

]]>
JusticeHui
네이버 Yorkie TF 인턴 생존기2024-03-30T00:00:00+00:002024-03-30T00:00:00+00:00https://justicehui.github.io/review/2024/03/30/naver-intern-reviewPS 원툴 대학생의 네이버 인턴 생존기… 인 줄 알았지만 Yorkie 프로젝트 소개가 되어버린 글

목차

  • 인턴 지원
  • Yorkie 프로젝트 소개
  • 기억력 문제
  • Yorkie 자료구조 소개
  • 고쳐야 할 문제
  • RHT 동시 편집 지원
  • Table Driven Test 도입
  • Garbage collector 수정
  • Tree 연산 소개
  • 트리 동시 편집 테스트 프레임워크
  • 이진 탐색 트리 개선 방안 제안
  • 후기

처음에 예상했던 것보다 글이 훨씬 많이 길어졌습니다. PC 화면으로 보시는 분들은 오른쪽 사이드 바를 이용해 원하는 문단으로 넘어갈 수 있습니다.

인턴 지원

평소와 같이 침대에 누워서 핸드폰을 보고 있었는데 우연히 네이버 Yorkie TF 에서 인턴을 모집한다는 소식(링크)을 듣고 3일 만에 자소서를 휘갈겨 써서 제출했습니다.

인터넷을 보면 PS만 한 사람은 취업을 못 한다는 이야기가 많이 보이고 보통 이런 글에는 자소서/이력서에 백준 등수를 적지 말라는 조언도 함께 붙곤 합니다. 하지만 저는 주변 지인들이 좋은 회사에 취업한 것을 많이 보았기 때문에 당당하게 자소서 문항 3개를 전부 PS로 도배하는 것으로 모자라서 아예 첫 번째 줄에 BOJ 등수를 적었습니다. 물론 문제 푸는 얘기만 3000글자씩 적은 건 아니고, PS 하면서 상 받은 이야기, PS 하면서 사람 가르친 이야기, PS 하면서 전공과목 날로 먹은 이야기 등등 다양하게 적긴 했습니다.

놀랍게도(?) 서류 합격해서 코딩 테스트 응시 기회를 얻었습니다. 3문제 출제되었고 다 푸는데 10~15분 정도 걸렸습니다. 제출 버튼 눌러도 채점 결과 안 알려주면서 더 이상 코드 수정을 못 하게 하는 게 인상적이었습니다. 코딩 테스트에서 떨어질 일은 없으니 코딩 테스트가 끝나자마자 면접 준비를 시작했습니다. 면접 준비를 위해 채용 공고(잡코리아)를 다시 읽어 보니…

자격 요건: 자료구조, 알고리즘, 프로그래밍언어, OS 등 전산 기초를 공부하신 분 / Go, Rust, C/C++, JS, Python 등 하나 이상의 프로그래밍 언어에 익숙한 사용이 가능하신 분
우대 사항: 논리 시계나 CRDT를 이용한 대규모 분산 시스템 개발에 관심이 있으신 분 / … (후략)

어… 제가 학교에서 A+ 받은 과목이 몇 개 없는데 딱 A+ 받은 과목들만 모여있었습니다. 동시 편집 기능을 개발하는 팀인 만큼 동시성 관련 내용을 물어볼 것 같아서 뮤텍스와 세마포어를 간단하게 복습하고, Yorkie에서 splay tree랑 rope 같은 자료구조를 사용하는 것 같아서 BBST(balanced binary search tree)도 다시 봤습니다. 우대 사항에 논리 시계나 대규모 분산 시스템이 언급되어 있어서 남는 시간에는 “가상 면접 사례로 배우는 대규모 시스템 설계 기초” 책을 조금씩 읽었습니다.

면접은 30분 정도 진행되었고, 자세하게 말할 수는 없지만 숭실대 특기자 전형 면접보다도 더 편안한 분위기에서 제가 좋아하는 PS와 ICPC 이야기만 잔뜩 하다가 나왔습니다. 상상 속에 있던 회사 면접과는 전혀 다르게, 이게 면접이 맞나 싶을 정도로 신기하게 진행되었습니다. 동아리나 기업에서 사람들 대상으로 강의했던 경험이 큰 도움이 되었던 것 같습니다. 떨어질 확률보다 붙을 확률이 조금 더 높다고 생각했고, 면접 일주일 정도 뒤에 합격 통보를 받았습니다. 참고로 면접 준비하면서 공부한 것은 아무 쓸모가 없었습니다.

면접 때 옷을 어떻게 입어야 하는지 몰라서 열심히 검색했었는데 쓸만한 결과가 나오지 않아서 많이 고민했었습니다. 저는 잠옷 입고 면접 봐서 붙었으니 이 글을 보는 분들은 고민하지 말고 편한 복장으로 면접에 들어가시길 바랍니다.

Yorkie 프로젝트 소개

저는 인턴십 기간 동안 실시간 협업을 위한 CRDT 기반 동시 편집 오픈소스 프레임워크 Yorkie(GitHub) 개발에 참여했습니다. Yorkie 프로젝트를 구경하다 보면 다양한 토픽을 볼 수 있는데, 저는 동시 편집 지원을 위한 CRDT 자료구조(RHT, RGA tree 등)와 이를 지원하기 위한 기초적인 여러 자료구조(Splay tree, Red-black tree, Hash table 등)에 가장 관심이 있었습니다. 이 밖에도 CRDT에서 사용하기 위한 논리 시계(Lamport timestamp, Vector clock, 관련 PR), 문서 데이터의 효율적인 저장을 위한 데이터베이스 샤딩(관련 이슈)같은 학교 강의에서 어렴풋이 들었던 개념들도 실제로 활용하고 있고, Yorkie의 동시 편집 기능에 GPT와 같은 LLM을 붙인 마크다운 문서 편집기 CodePair(GitHub)를 만들기도 합니다.

주변에서 “네이버에서 어떤 일을 했냐?” 라고 물어보면 주로 “구글 독스 같은 것을 만들었다” 라고 대답하고 있습니다. 예전부터 구글 독스를 많이 써와서 별생각 없이 당연하다는 듯이 사용하고 있었는데, 인턴을 하면서 구글 독스는 온갖 기술의 결정체라는 것을 깨닫게 되었습니다. 진짜 어떻게 만든 거지…

기억력 문제

사실 이 글을 작성하기 시작할 때는 1주차부터 8주차까지 어떤 생각을 갖고 어떤 작업을 했는지 적으려고 했지만, 인턴 기간이 끝난 지 한 달도 넘게 지나서 기억이 잘 안 나기도 하고 블로그에 어디까지 작성해도 되는지를 모르겠어서… 그냥 발표 준비하면서 만들었던 자료 중심으로 설명하려고 합니다. 다 쓰고 보니 인턴 후기보다는 CRDT와 Yorkie 프로젝트 소개에 가까운 것 같습니다.

Yorkie 자료구조 소개

Yorkie는 다양한 자료구조가 맞물려 돌아가고 있습니다. (data structure design docs 참고)

Yorkie는 자료구조를 (1) 실제로 사용자가 다루게 될 문서 레벨의 JSON 형태의 자료구조, (2) 문서의 동시 편집 연산을 지원하는 CRDT 자료구조, (3) CRDT 자료구조의 밑에서 돌아가는 기본 자료구조까지 총 3가지로 구분하고 있습니다. 예를 들어 사용자가 Text 형태인 객체의 특정 구간에 bold 속성을 적용한다고 하면 Text객체의 내용물을 저장하고 있는 RGATreeSplit은 속성을 적용할 노드들의 구간을 구해야 하는데, 이때 BBST인 SplayTreeLLRBTree를 이용해 효율적으로 구간을 모으는 방식으로 연산이 진행됩니다. SplayTreeLLRBTree와 같은 기본 자료구조는 동시 편집을 고려하지 않은 채로 만들어져 있으며, 연산의 우선순위나 오프셋 등을 조정해서 동시 편집 지원을 보장하는 부분은 CRDT 자료구조에서 담당하고, 유저가 사용할 인터페이스는 JSON-Like 자료구조에서 제공하는 방식입니다.

ElementRHTRHT에서 보이는 RHT는 Replicated Hash Table의 줄임말로, 동시 편집을 지원하는 해시 테이블입니다. ElementRHT는 JSON-Like Object의 멤버를 관리하는 자료구조로 문자열 → 객체 매핑을 수행하고, RHT는 노드의 attribute를 관리하는 자료구조로 문자열 → 문자열 매핑을 수행합니다.

RGATreeListRGATreeSplit은 Replicated Growable Array(RGA)를 기반으로 하는 리스트 자료구조입니다. RGA는 기본적으로 각 노드의 크기가 1인 연결 리스트이며, RGATreeList는 random access를 지원하기 위해 os-select 연산을 수행하는 BBST 위에 리스트를 올린 자료구조입니다. RGATreeSplitRGATreeList의 성능을 개선하기 위해 각 노드의 크기가 1을 초과할 수 있도록 허용한 자료구조입니다. 노드의 split 연산을 지원하며, split할 인덱스가 주어졌을 때 실제로 분할될 노드를 구해야 하므로 lower bound 연산을 지원하는 BBST가 필요합니다.

SplayTree는 가장 마지막에 접근한 정점을 맨 위로 올리는 방식(splay)의 이진 탐색 트리로, 주로 같은 위치에서 연산을 여러 번 수행하는 문자열을 관리하기에 적합한 자료구조입니다. 연산 한 번에 최대 $O(n)$ 시간이 걸릴 수 있지만, $m$번 연산을 수행할 때 항상 $O((n+m) \log n)$ 시간, 즉 단일 연산의 시간 복잡도가 amortized $O(\log n)$이기 때문에 평균적으로는 빠르게 동작합니다. 또한, 후술하겠지만 Dynamic optimality conjecture 등 splay tree에 대한 여러 정리와 추측을 통해 일반적으로 꽤 빠른 자료구조라는 것이 알려져 있습니다.

LLRBTree는 일반적으로 다른 BBST에 비해 우수한 성능을 보여주는 red-black tree를 조금 변형한 자료구조로, rb tree에 디해 상대적으로 구현이 쉽다는 장점이 있습니다. LLRBTreeSplayTree와 다르게 모든 연산을 매번 최대 $O(\log n)$ 시간에 수행한다는 장점이 있습니다.

IndexTree는 xml 형태로 표현된 트리 구조 문서의 인덱스가 주어지면 이를 트리에서의 위치로 변환하는 역할을 수행합니다. 예를 들어 아래 그림에서 6번 인덱스를 넘겨주면 문서의 두 번째 p 태그와 세 번째 p 태그 사이를 “잘 표현하는 방법”을 구성해서 반환합니다.

고쳐야 할 문제

저는 8주 간의 인턴십 기간 동안 Yorkie 자료구조의 문제점을 발견하고 이를 수정하는 작업을 주로 수행했습니다. 제가 발견한 문제점은 다음과 같습니다.

  • JSON-Like data structure - Tree: 동시 편집 테스트 부족함
  • CRDT data structure - RHT: 동시 편집 지원 안 됨
  • CRDT data structure - ElementRHT: 관리하는 자료의 타입을 제외하면 RHT와 완전히 동일한 자료구조이므로 코드 중복
  • Common data structure - SplayTree: 연산 한 번의 레이턴시가 클 수 있음
  • Common data structure - LLRBTree: 더 좋은 방법이 있지 않을까?
  • (js-sdk) CRDT data structure - CRDTTree: garbage collector가 지수 시간 - $O(2^n)$ 시간에 동작함

RHT 동시 편집 지원

두 명의 유저가 동시에 같은 키에 대해 한 명은 삽입 연산, 다른 한 명은 삭제할 때 두 유저에게 보이는 결과가 달라지는 문제가 발생했습니다. 구체적으로 다음과 같은 시나리오에서 문제가 발생했습니다. 아래 설명에서 나오는 1@A, 2@B와 같은 문자열은 lamport timestamp에 ActorID를 추가한 것으로, 같은 원소를 수정하는 연산이 동시에 여러 개 주어지면 이벤트 발생 시간(lamport timestamp)가 큰 것을 수행하되, 그 시간이 같으면 ActorID가 큰 연산을 수행하도록 연산들 사이에 total order를 주기 위한 logical clock입니다.

해시 테이블의 초기 상태는 {X: 1, Y: 2} 일 때, 첫 번째 유저가 X=3 1@A, X=4 2@A 라는 연산을, 두 번째 유저가 Y=0 1@B, Delete X 2@B라는 연산을 수행한 상태를 생각해 봅시다. 아직까지는 서로 동기화 작업을 수행하지 않은 상태입니다.

두 번째 유저가 첫 번째 유저의 연산을 pull 하면 아래 그림과 같이 됩니다. 두 번째 유저는 X에 대한 정보를 갖고 있지 않고, 첫 번째 유저는 2@A 시간에 X=4 연산을 수행했기 때문에 X의 값이 4로 변경됩니다.

이후 첫 번째 유저가 두 번째 유저의 연산을 pull 하면 아래 그림과 같이 됩니다. 첫 번째 유저의 입장에서 Y의 마지막 갱신 시간은 0@0이었는데, 두 번째 유저가 1@B 시간에 Y=0 연산을 수행했으므로 Y의 값이 0으로 변경됩니다. 또한, 첫 번째 유저의 입장에서 X의 마지막 갱신 시간은 2@A였는데, 두 번째 유저가 그보다 더 이후인 2@B 시간에 X를 삭제했으므로 첫 번째 유저의 RHT에서 X가 삭제됩니다.

이렇게 두 클라이언트의 결과가 다르다는 문제점이 발생했고, 제거된 노드의 정보를 소멸시킨 것이 원인이었습니다. 따라서 노드를 제거할 때 정보를 완전히 삭제하는 대신, “removed” 태그만 달아놓고 실제로 삭제하지 않는 방법으로 문제를 해결할 수 있습니다. 실제로 노드를 삭제하는 것은 모든 클라이언트에 삭제 연산이 전파되었을 때 garbage collection을 수행해서 tombstone node를 제거하면 됩니다. 이 해결책을 적용하면 앞에서 본 시나리오에서 두 클라이언트의 결과가같아진다는 것을 알 수 있습니다.

두 번째 유저가 Delete X 2@B를 수행하면 실제로 X 노드를 삭제하는 대신 2@B 시점에 노드가 삭제되었다는 정보를 기록합니다.

두 번째 유저가 첫 번째 유저의 연산을 pull 하면 아래 그림과 같이 됩니다. 첫 번째 유저가 X를 수정한 시점(1@A, 2@A)이 모두 2@B 이전이므로 X의 정보를 변경하지 않습니다.

첫 번째 유저가 두 번째 유저의 연산을 pull 하면 아래 그림과 같이 두 클라이언트의 해시 테이블이 같아지는 것을 알 수 있습니다.

Table Driven Test 도입

기존 RHT 테스트 코드는 아래와 같이 테스트 시나리오의 각 스텝을 모두 하드코딩 하는 방식이었습니다. 이런 식으로 코드를 작성하다 보니 중복된 코드가 많아지고 읽기 어려워져서, 읽기 쉽고 일반화된 코드 스타일의 필요성을 느꼈습니다.

table driven test는 테스트 케이스를 테이블 형태로 정리해서 작성하는 방식입니다. 아래의 두 코드는 모두 Add(a, b)를 테스트하는 코드로, 왼쪽에 있는 코드는 모든 테스트 케이스에서의 동작을 하드코딩하는 방식, 오른쪽에 있는 코드는 테스트 케이스 설명과 기대하는 결과, 그리고 a b의 값을 테이블 형태로 작성하는 방식입니다. 테스트 케이스가 많아질수록 오른쪽의 방식이 더 읽기 쉽고 코드의 중복도 감소한다는 것을 알 수 있습니다.

아래 코드처럼 외부 변수를 사용하면 context를 유지하면서 여러 시나리오를 차례대로 수행할 수도 있습니다. 아래 코드는 sum의 값을 0으로 초기화한 뒤, 차례대로 값을 더해가면서 값의 변화를 확인하는 방식의 테스트 코드입니다.

rht_test.go에서 table driven test 형식으로 작성한 RHT의 테스트 코드를 확인할 수 있습니다.

Garbage collector 수정

Yorkie는 위에서 언급한 tombstone node를 제거하기 위한 garbage collector(design docs)를 갖고 있습니다. garbage collection의 대략적인 과정은 다음과 같습니다.

  1. registerRemovedElement(element)를 이용해 tombstone node 등록(removedElementSetByCreatedAt에 삽입)
  2. 적절한 시기에 호출된 garbageCollect(timestamp) 에서 timestamp 시점 이전에 등록된 모든 tombstone node에 대해 deregisterElement(element)호출
  3. deregisterElement(element)는 언어에서 제공하는 GC가 element를 제거할 수 있는 형태로 전환

Yorkie의 garbage collector는 두 가지 문제가 있었는데, 첫 번째 문제는 등록된 garbage의 개수를 구하는 getGarbageLen() 함수가 올바르지 않은 값을 반환하는 것(관련 이슈, PR), 두 번째 문제는 deregisterElement에서 같은 원소를 여러 번(무려 최대 $O(2^n)$번!, 관련 이슈, PR) 제거하는 것이었습니다.

getGarbageLen() 이슈는 nested object에서 조상-자손 관계인 두 정점 $p, c$가 모두 tombstone node로 등록된 경우에 $c$와 $c$의 자식들을 중복해서 카운팅하는 것이 원인이었고, 다른 분께서 set을 이용해 중복을 제거하는 방법을 이용해 해결하셨습니다(commit).

하지만 이 솔루션은 skew tree와 같은 형태에서 모든 노드가 tombstone node로 등록되어 있을 때 $O(n^2)$ 시간이 걸립니다. 따라서 저는 tombstone node를 정렬해서 위에 있는 노드부터 삭제하는 방식의 $O(n \log n)$ 방법을 만들어 내고, 성능 개선 확인을 위한 테스트 코드를 작성했습니다. skew tree에서 테스트를 돌렸는데 성능 변화가 없어서 무슨 일인지 확인을 했더니… 10개의 정점으로 구성된 트리를 삭제할 때 garbage collector에서 522개의 정점을 deregister하고 있었습니다. 정점이 9개인 트리를 삭제할 때는 265개의 정점을, 정점이 11개인 트리를 삭제할 때는 1035개의 정점을 deregister하고 있었습니다. 테스트 코드에서는 garbage의 개수와 실제 garbage collecting 과정에서 deregister된 원소의 개수를 함께 세고 있었는데, deregisterElement가 터무니없이 많이 호출되고 있어서 성능 변화가 눈에 보이지 않고 있었던 것이었습니다.

$f(9) = 265, f(10) = 522, f(11) = 1035, \ldots$ 수가 너무 익숙합니다. 특히 $522$라는 수는 보자마자 $522 = 512 + 10$이 생각났고, 조금 더 고민해 보니 $f(n) = 2^{n-1} + n$이라는 것을 알게 되었습니다. 알 수 없는 이유로 deregisterElement() 안에서 원소가 지수 번 삭제되고 있었고, 저는 getGarbageLen()의 $O(n \log n)$ 구현 대신 이 문제를 해결하기로 했습니다.

기존에 deregisterElement()는 아래와 같이 구현되어 있었습니다.

function deregisterElement(element){
    let count = 0;

    const deregisterElementInternal = (elem) => {
        count++; // deregister
        if(elem instanceof Container){
            elem.descendantsTraversal((e) => {
                deregisterElementInternal(e);
            });
        }
    };

    deregisterElementInternal(element);
    return count;
}

위 코드에서 6-10번째 줄만 잘라내서 deregisterElementInternal(element); 바로 아래로 옮기면?

function deregisterElement(element){
    let count = 0;

    const deregisterElementInternal = (elem) => {
        count++; // deregister
    };

    deregisterElementInternal(element);
    if(elem instanceof Container){
        elem.descendantsTraversal((e) => {
            deregisterElementInternal(e);
        });
    }

    return count;
}

문제가 해결되었습니다! skew tree에서 맨 위에 있는 정점을 deregister 할 때 정점들이 총 $f(n) = 1 + \sum_{i=1}^{n-1} f(i) = 2^{n-1}$번 삭제되고 있었고, 나머지 $+n$은 그냥 테스트 코드를 잘못 짜서(…) 붙었던 것이었습니다. 아무튼 코드 한 줄 작성하지 않고 시간 복잡도를 $O(2^n)$에서 $O(n)$으로 줄이는 재미있는 경험을 했습니다.

여담으로, 결국 getGarbageLen()의 $O(n \log n)$ 구현은 Yorkie에 반영되지 않았습니다. 글 작성하면서 $265, 522, 1035$를 oeis(온라인 정수열 사전, https://oeis.org)에 넣었더니 $a(n) = 2^n + n + 1$이라는 수열(A005126)이 나왔습니다. 이럴 줄 알았으면 열심히 고민해서 식 때려 맞추지 말고 oeis에 넣어볼걸…

Tree 연산 소개

Yorkie에서 트리 구조의 문서를 담당하는 Tree는 다양한 연산을 제공합니다. Tree의 연산은 크게 basic edit, advanced edit, style 로 구분할 수 있습니다.

  • Basic tree edit: Insert, Delete, Replace
  • Advanced tree edit: Split, Merge
  • Style: Style, RemoveStyle

Insert는 원하는 위치에 새로운 노드를 삽입하는 연산, Delete는 삭제하고 싶은 노드의 양쪽 끝 인덱스를 넘겨서 노드를 삭제하는 연산, Replace는 Delete와 Insert를 조합해서 노드를 교체하는 연산입니다.



Merge는 두 노드를 병합하는 연산입니다. 주로 두 번째 그림과 같이 양 끝점이 서로 다른 block element에 속한 구간을 선택해서 삭제할 때 발생합니다.


Tree.Edit의 4번째 인자는 splitLevel로, split 연산을 수행할 때 몇 번째 조상 정점에서 분할할 지를 결정하는 인자입니다. splitLevel = 1 일 때는 분할하려는 인덱스가 포함된 정점의 부모 정점을 분할하고, splitLevel = 2 일 때는 2칸 위 조상 정점을 분할합니다.


Split은 주로 어떤 노드 안에 <hr>과 같은 새로운 block element를 삽입할 때 발생합니다.

Style과 RemoveStyle은 주어진 구간에 속한 노드에 attribute를 추가/제거하는 연산입니다.

트리 동시 편집 테스트 프레임워크

기존에도 트리의 동시 편집 기능을 하는 테스트 코드가 있었습니다. 아래 그림과 같이 테스트 내용을 3개의 차원으로 분할한 뒤, 각 차원마다 3개씩 총 27가지의 대분류로 나눠서 테스트를 작성했습니다.

이런 방식의 문제점은 크게 두 가지가 있는데, advanced edit이나 style 연산에 대한 테스트 작성 가이드가 없다는 것, 그리고 아래 이미지와 같이 27가지 대분류 안에서 세부 내용을 일일이 직접 구현하고 있다는 것입니다.

이런 식의 테스트는 테스트 커버리지를 확인하기 어려울 뿐만 아니라, 트리의 기능을 수정하거나 새로운 기능을 추가할 때마다 여러 개의 테스트 코드를 다시 수정해야 한다는 문제점이 있습니다. 더 나아가, 현재 테스트 코드가 발생할 수 있는 모든 동시 편집 상황을 표현한다는 보장이 없으므로, 문제를 발견할 때마다 땜질하는 느낌으로 수정하고 테스트 코드를 추가하는 것을 반복하고 있어서 언제 트리 구현이 완료될지 모르는 상황이었습니다.

따라서 새로운 동시 편집 테스트 프레임워크를 설계하기로 결정했습니다. 새로운 프레임워크의 목표는 두 가지로 설정했습니다.

  1. 연산의 교환 법칙 성립 여부 확인 → 연산 2개의 순서를 바꿔가며 테스트
  2. 실패하는 테스트를 한 눈에 볼 수 있도록 제작 + 실패하는 테스트는 Skip 처리해서 CI는 항상 통과되도록 조정

각 테스트마다 2개의 연산만 고려하면 되기 때문에 테스트는 (1) 두 유저가 구간을 선택하는 방법, (2) 첫 번째 유저의 연산, (3) 두 번째 유저의 연산까지 총 3개의 차원으로 나눠서 구분하기로 결정했습니다. 두 유저가 선택하는 구간은 기본적으로 아래 그림과 같이 4가지 정도로 나눌 수 있지만, 유저들이 사용하는 연산의 종류에 따라 더 많이 고려해야 할 수도 있습니다.

예를 들어 첫 번째 유저는 Edit 계열의 연산, 두 번째 유저는 Style 계열의 연산을 사용한다고 가정하면 A contains BB contains A를 모두 확인해야 합니다. 또한, Style 계열의 연산은 트리의 노드에만 적용하는 연산이므로 위 그림과 같이 구간을 설정하면 충분하지만, Edit 계열의 연산은 text node를 수정하는 것, element node를 수정하는 것, 둘 모두를 수정하는 것을 모두 확인해야 합니다.

이런 식으로 테스트 상황에 따라 각 차원에 들어가는 요소들이 천차만별이기 때문에, 테스트 타입마다 각 차원에 들어갈 요소를 간편하게 지정할 수 있는 구조를 만들고자 했습니다.

새로 작성한 테스트 프레임워크에서 테스트는 RunTestTreeConcurrency 함수를 이용해 수행할 수 있습니다. 이 함수는 테스트 전체에서 공통으로 사용될 트리의 초기 상태(initialState, initialXML)와 각 차원에 들어갈 정보(rangesArr, opArr1, opArr2)를 인자로 받은 뒤, 함수 내에서는 단순히 3중 반복문으로 모든 조합을 생성해 결과를 받아오는 방식으로 동작합니다. 오른쪽 코드와 같이 초기 상태와 각 차원에 들어갈 요소들의 배열을 선언하면 테스트를 간편하게 수행할 수 있습니다. 기존 테스트를 보강하고 싶을 때는 단순히 배열 선언 부분에 한 줄만 추가하면 됩니다.

실패하는 테스트는 Skip 처리를 했기 때문에 CI는 항상 통과됩니다. 또한, IDE의 기능을 이용하면 아래 사진과 같이 skip 된 테스트를 모아서 볼 수 있어서, 현재 프로젝트에서 커버하지 못하는 케이스 수에 대한 정량적인 분석이 가능해졌습니다.

또한, 각 차원에 들어갈 요소에 이름을 붙이면 자동으로 테스트에도 이름이 붙는다는 장점도 있습니다. 이를 통해 “concurrently-edit-style-test-equal(insertFront,set-bold-aa) 에서 실패해요!”, “이 방식은 EditStyleTest 에서 실패하는 15가지 중 10가지를 커버할 수 있어요!”와 같이 실패하는 케이스를 정확하게 집을 수 있어서 개발자들 간의 소통이 조금 더 원활해졌다는 소소한 이득이 있었습니다.

새로 작성한 테스트 프레임워크는 tree_concurrency_test.go에서 확인할 수 있습니다.

이진 탐색 트리 개선 방안 제안

위에서도 언급했듯이 스플레이 트리는 연산 한 번에 최대 $O(n)$ 만큼의 시간이 필요할 수 있지만, 연산을 $m$번 수행할 때의 전체 시간 복잡도는 항상 $O((n+m) \log n)$이 보장되는 자료구조입니다. 스플레이 트리는 다음과 같은 다양한 성질이 알려져 있고, 이는 전체 실행 시간 측면에서는 스플레이 트리가 좋은 자료구조라는 것을 의미합니다.

  • $n$개의 원소가 있는 트리에 연산을 $m$번 수행할 때 전체 시간 복잡도는 $O((n+m) \log n)$
  • Static optimality theorem: 원소가 삽입/삭제되지 않는 상황에서 모든 원소를 1번 이상 접근하는 경우, 다른 어떠한 BST보다 상수 배 이상 느리지 않음
  • Scanning theorem: 모든 원소를 오름차순으로 한 번씩 splay 할 때 전체 시간 복잡도는 $O(n)$
  • Queue theorem: $n$개의 원소가 있는 트리에 $m$번의 push_back, pop_front 연산을 수행할 때 전체 시간 복잡도는 $O(n+m)$
  • Deque conjecture: $n$개의 원소가 있는 트리에 $m$번의 push_front, push_back, pop_front, pop_back 연산을 수행할 때 전체 시간 복잡도는 $O(n+m)$일 것이라는 추측
  • Dynamic optimality conjecture: 트리에 여러 번 조작을 가했을 때 필요한 전체 연산의 횟수가 다른 어떠한 BST보다 상수 배 이상 많지 않을 것이라는 추측

하지만 여전히 연산 한 번의 시간 복잡도가 $O(n)$이 될 수 있다는 것은 서비스를 제공하는 입장에서 큰 문제입니다. 키보드를 한 번 눌렀더니 1초 정도의 딜레이가 발생하는 것이 긍정적인 경험을 주진 않을 것입니다. 스플레이 트리의 장점을 해치지 않으면서, 동시에 트리의 최대 높이를 적당한 수준으로 줄일 방법을 찾아야 합니다.

그 해결책은 예전에 알고리즘 문제를 풀던 경험에서 얻을 수 있었습니다. 연산의 시간 복잡도가 매번 $O(h^2)$인 BST 문제를 풀었던 적이 있는데, 이때 연산을 $X$번 수행할 때마다 정점을 임의로 $Y$개 선택해서 splay 하는 방식으로 나름의 rebalancing을 했던 기억이 있습니다. 마침 유저의 실제 사용 데이터(editing-trace.json, 삽입 182315회, 삭제 77463회)가 있어서, 이 데이터를 이용해 $X$와 $Y$를 바꿔가며 성능 변화를 관찰했습니다. 아래 표는 $X = \infty, Y=0$일 때의 최대 rotate 횟수와 전체 rotate 횟수를 각각 100% 라고 했을 때, $X, Y$ 값에 따른 rotate 횟수의 상대적인 차이를 나타내는 표입니다.

X Y 최대 rotate 횟수 전체 rotate 횟수
100 1 30% 130%
100 5 15% 223%
100 10 11% 333%
500 1 34% 107%
500 5 36% 127%
500 10 26% 150%
1000 1 67% 104%
1000 5 35% 115%
1000 19 32% 126%

$X = 500, Y = 1$일 때 전체 rotate 연산 수행 횟수는 7% 밖에 상승하지 않으면서 최대 rotate 횟수는 66%를 감소시킬 수 있었습니다. 테스트 코드와 결과는 (여기)에서 확인할 수 있습니다.

현재 LLRBTreeRGATreeSplit에서 분할할 지점의 인덱스가 주어지면 실제로 쪼개질 노드를 구할 때 사용하고 있으며, 모든 키가 정수라는 특징을 갖고 있습니다. SplayTree의 개선 방안을 모색할 때 키가 정수인 상황에서 사용할 수 있는 여러 자료구조를 공부했어서 이를 정리해 보고자 합니다. 아래 표에서 $n$은 원소 개수, $M$은 원소 범위 $[0, M=2^k)$를 의미합니다.

  rb tree splay tree y-fast trie van Emde Boas tree
search $O(\log n)$ amortized $O(\log n)$ $O(\log \log M)$ $O(\log \log M)$
insert $O(\log n)$ amortized $O(\log n)$ amortized $O(\log \log M)$ $O(\log \log M)$
delete $O(\log n)$ amortized $O(\log n)$ amortized $O(\log \log M)$ $O(\log \log M)$
lower bound $O(\log n)$ amortized $O(\log n)$ amortized $O(\log \log M)$ $O(\log \log M)$
space complexity $O(n)$ $O(n)$ $O(n)$ $O(M)$ or $O(n)$ with hashing

복잡도 상으로는 y-fast trie나 van Emde Boas tree가 좋아 보이지만, 실제 수행 시간에서도 개선이 있을지는 확신이 서지 않아서 조사와 실험이 더 필요해 보입니다.

후기

이 밖에도 RHT의 여러 오류를 수정하기 위해 대부분의 기능을 처음부터 다시 구현하고 Tree.RemoveStyle 연산을 추가하는 작업(issue, PR1, PR2)도 했습니다. 이 작업을 하면서 살면서 처음으로 Go언어와 protobuf를 사용해 봤습니다.

프로그래밍 공부를 시작한 지 벌써 10년이 넘게 지났지만 아직 Git을 제대로 사용해 본 경험이 없고, 프로그래밍을 공부한 10년 중 7년이 넘는 시간을 알고리즘 공부에 투자했기 때문에 “개발”이라는 것을 많이 경험해 보지 못한 상태로 인턴십을 시작하게 되었습니다. 이번 인턴십을 통해 이런 문제를 해결해 보고 싶었고, 구체적으로 다음과 같은 3가지 목표를 세웠습니다.

  1. 이슈 생성 → 구조 설계 → 코드 작성 → PR 생성 → 코드 리뷰와 같은 개발 업무의 프로세스 익히기
  2. 지금까지 공부한 자료구조와 알고리즘을 실제 제품에 적용하기
  3. Git 사용과 문서 작업 등을 통해 다른 사람들과의 협업 경험해 보기

운 좋게도 저에게 맞는 프로젝트와 좋은 팀원들을 만나서 세 가지 목표 모두 잘 이룰 수 있었습니다.

PS를 했던 경험은 시간 복잡도를 계산하거나 자료구조를 직접적으로 다룰 때뿐만 아니라 회사에서 일하는 내내 은은하게 도움 되었습니다. 저는 제가 평소에 하는 일을 소개할 때 “문제를 풀고, 문제 푸는 방법을 가르치고, 문제를 만드는 사람”이라고 소개합니다. 세 가지 경험 모두 큰 도움이 되었습니다. 특히 코드를 작성하는 것과 동시에 예외 상황이나 반례를 떠올리는 것, 그리고 코드를 눈으로 디버깅하는 것은 PS를 하지 않았다면 거의 불가능했을 것 같습니다. 비슷한 맥락으로 사람들을 가르치고 코드를 디버깅해 준 것도 많은 도움이 되었습니다. 제가 생각했던 것보다도 남의 코드를 읽고 디버깅할 일이 매우 많았습니다. 문제를 출제하고 데이터를 만든 경험 덕분인지 “문제를 잘 정의하는 것과 테스트를 꼼꼼하게 작성하는 것에 큰 강점이 있다”라는 피드백을 듣기도 했습니다.

원래의 계획은 트리 동시 편집 테스트를 새로 작성한 다음 실제로 동시 편집 결과가 수렴하지 않는 케이스를 몇 개 해결하는 것이었습니다. 테스트 작성과 RHT의 동시 편집 지원까지는 잘 완성했지만, 트리 구조 문서의 동시 편집 문제를 하나도 해결하지 못한 채로 인턴 기간이 종료된 것이 많이 아쉬웠습니다. 이진 탐색 트리 부분도 더 개선해 보고 싶었는데, 충분히 고민하거나 실험할 시간이 없어서 이 부분도 거의 건들지 못했습니다. Yorkie는 오픈소스 프로젝트인 만큼 시간이 날 때마다 조금씩 더 기여해 보려고 합니다. 지금은 바빠서 추가로 작업을 하지 못하고 있지만 언젠가는 잘 끝낼 수 있을 때가 오길 바라며…

끝!

]]>
JusticeHui
다양한 경우의 수 세기2024-02-10T00:00:00+00:002024-02-10T00:00:00+00:00https://justicehui.github.io/medium-algorithm/2024/02/10/combinatorics0. 목차
  1. 기본 지식
  2. 카탈란 수
  3. 집합의 분할
  4. 자연수의 분할
  5. 교란 순열
  6. 조합론의 12정도

1. 기본 지식

본론으로 들어가기 전에 중학교와 고등학교에서 배운 내용을 아주 빠르게 복습하고 넘어갑시다.

1-1. 순열, 중복 순열, 조합, 중복 조합

$n$개의 원소 중 중복 없이 $k$개의 원소를 선택해서 나열하는 방법의 수는 $n \times (n-1) \times \cdots \times (n-k+1) = \frac{n!}{(n-k)!}$가지입니다. 각 상자에 최대 1개의 공이 들어가도록 $k$개의 공을 $n$개의 상자에 분배하는 방법의 수라고 생각할 수도 있습니다. 흔히 고등학교에서는 이것을 순열이라고 부르고, $_nP_k$와 같은 기호로 나타냈습니다.

중복 순열은 위 문제에서 중복이 없어야 한다는 조건을 제거한 것인데, 중복을 허용해서 $k$개의 원소를 선택하고 나열하는 방법의 수는 $n^k$으로 계산할 수 있습니다. $k$개의 공을 $n$개의 상자에 자유롭게 분배하는 방법의 수라고 생각할 수도 있습니다.

조합은 $n$개의 원소 중 중복 없이 $k$개의 원소를 선택하는 방법의 수를 의미하고, $\frac{n\times(n-1)\times\cdots\times(n-k+1)}{k\times(k-1)\times\cdots\times1} = \frac{n!}{(n-k)!k!}$으로 계산할 수 있습니다. 고등학교에서는 주로 $_nC_k$라는 기호를 사용하고, 대학교에서는 주로 $n \choose k$라는 표기를 사용합니다. 이 글에서는 $n \choose k$라는 기호를 사용하겠습니다.

$n$개의 원소 중 중복 없이 $k$개의 원소를 선택하는 것은 (1) $n$번 원소를 선택한 다음 $1\sim n-1$번 원소 중 $k-1$개를 선택하는 것과 (2) $n$번 원소를 선택하지 않고 $1\sim n-1$번 원소 중 $k$개를 선택하는 것으로 나눠서 생각할 수도 있습니다. 따라서 ${n \choose k} = {n-1 \choose k-1} + {n-1 \choose k}$라는 점화식을 이용해 계산할 수도 있습니다. 또한, $n$개의 원소 중 $k$개를 선택하는 것은 선택하지 않을 $n-k$개를 선택하는 것이라고 생각할 수 있으므로 ${n \choose k} = {n \choose n-k}$가 성립한다는 것을 알 수 있습니다.

조합을 사용하는 대표적인 문제로 격자 상의 최단 경로 개수를 세는 문제가 있습니다. 격자에서 $(0, 0)$에서 출발해 $(n, m)$으로 이동하는 최단 경로의 개수는 $\frac{(n+m)!}{n!m!} = {n+m \choose n}$으로 계산할 수 있고, 이는 $n+m$번의 이동 중 세로 방향으로 이동할 $n$번의 위치를 선택하는 것과 동일합니다. 이 문제는 뒤에서 카탈란 수를 설명할 때 다시 등장하니 꼭 알고 있어야 합니다.

중복 조합은 위 문제에서 중복이 없어야 한다는 조건을 제거한 것입니다. 중복 조합 문제는 $k$개의 원소가 들어갈 자리를 만든 다음 $n-1$개의 칸막이를 원소와 원소 사이에 배치해서, 첫 번째 칸막이 바로 전까지는 1번 원소, 첫 번째 칸막이와 두 번째 칸막이 사이에는 2번 원소, $n-1$번째 칸막이 이후에는 $n$번 원소를 배치하는 것으로 생각할 수 있습니다. 이는 $k+(n-1)$개의 원소 중 $n-1$개의 원소를 골라 칸막이로 교체하는 것이라고 생각할 수 있고, 따라서 $n+k-1 \choose n-1$로 계산할 수 있습니다. 고등학교에서는 $_nH_k$라는 기호를 사용했습니다.

1-2. 포함 배제의 원리

여러 개의 집합이 주어졌을 때 합집합의 크기를 구하는 방법에 대해 알아봅시다. 집합 $A, B$가 주어졌을 때 $\vert A \cup B \vert$는 $\vert A \vert + \vert B \vert - \vert A \cap B \vert$로 계산할 수 있고, 집합이 3개 주어진다면 $\vert A\cup B\cup C \vert$는 $\vert A \vert + \vert B \vert + \vert C \vert - \vert A\cap B\vert - \vert B\cap C\vert - \vert C \cap A \vert + \vert A \cap B \cap C \vert$를 이용해 계산할 수 있습니다. 이를 일반화하면 홀수 개의 집합을 교집합한 것의 크기는 더하고, 짝수 개를 교집합한 것의 크기는 빼는 방식으로 계산할 수 있다는 것을 알 수 있습니다. 수식으로는 $\vert \bigcup_{i=1}^{n} A_i \vert = \sum_{I \subset [n]} (-1)^{\vert I\vert-1} \vert \bigcap_{i\in I} A_i \vert$와 같이 나타낼 수 있습니다.

1-3. 이항 계수의 빠른 계산

${n \choose k} = {n-1 \choose k-1} + {n-1 \choose k}$를 이용하면 $n, k \leq N$일 때의 이항 계수를 $O(N^2)$ 시간에 계산할 수 있습니다. 하지만 이항 계수를 적당한 정수 $M$으로 나눈 나머지를 구하는 것은 $O(N^2)$보다 빠르게 계산할 수 있고, 온라인 저지에서 문제를 풀 때는 주로 이런 방법을 요구합니다.

  • $N < M$, $M$은 소수: 전처리 $O(N + \log M)$, 쿼리 $O(1)$
  • $M$은 소수: 전처리 $O(M + \log M)$, 쿼리 $O(\log N)$
  • $M$은 소수의 거듭제곱($M=p^e$): 전처리 $O(p^e)$, 쿼리 $O(\log n + \log p)$
  • 조건 없음: 소인수분해 후 $M=p^e$인 문제를 해결한 뒤, 중국인의 나머지 정리를 이용해서 합치면 됨

위 네 가지 방법은 (여기)에 있는 슬라이드에서 자세한 설명을 확인할 수 있습니다. 첫 번째 방법만 알아도 연습 문제를 푸는 데 큰 지장은 없습니다.

2. 카탈란 수

2-1. Dyck Path

Dyck path 문제란, 격자에서 $(0, 0)$에서 $(n, n)$으로 이동할 때, 주대각선($y=x$ 직선)을 넘어가지 않는 경로의 개수를 구하는 문제입니다. 편의상 $(0, 0)$을 왼쪽 아래, $(n, n)$을 오른쪽 위에 있는 점이라고 정의합시다. 이러한 경로의 개수는 전체 경로의 개수에서 $y=x$를 뚫고 지나가는 경로의 개수를 빼서 구할 수 있습니다. 즉, $2n \choose n$에서 $y=x$를 뚫고 지나가는 경로의 개수를 뺀 값을 계산하면 됩니다. $y=x$ 직선을 뚫고 가는 직선의 개수를 구하는 방법을 알아봅시다.

$y=x$를 뚫고 올라가는 경로는 항상 $y=x+1$ 직선 위의 점을 한 번 이상 지납니다. 이런 경로마다 처음으로 $y=x+1$과 만나는 점 이후의 모든 이동을 반전시키면, $y=x$를 뚫고 가는 모든 경로는 $(n-1, n+1)$에 도착하게 됩니다.

$y=x$를 뚫고 가는 모든 경로는 $(0, 0)$에서 $(n-1, n+1)$까지 가는 경로와 일대일 대응시킬 수 있고, 따라서 Dyck path 의 개수는 ${2n \choose n} - {2n \choose n+1}$로 계산할 수 있습니다. 이때 ${2n \choose n+1} = \frac{n}{n+1}{2n \choose n}$이므로 $\frac{1}{n+1}{2n \choose n}$으로도 계산할 수 있습니다.

원점을 제외하고 처음으로 $y=x$ 직선과 만나는 점을 기준으로 나눠서 생각하면 점화식도 유도할 수 있습니다. $(0, 0)$에서 $(n, n)$으로 이동하는 Dyck path의 개수를 $C_n$이라고 정의합시다. 경로가 원점을 제외하고 $y=x$와 처음 만나는 점이 $(k, k)$라고 하면, $(0, 0)$에서 $(k, k)$까지 가는 경로의 개수는 $C_{k-1}$, $(k, k)$에서 $(n, n)$으로 가는 경로의 개수는 $C_{n-k}$로 계산할 수 있습니다. 아래 그림은 $n = 7$, $k = 3$인 상황입니다.

따라서 $C_n = \sum_{k=1}^n C_{k-1}C_{n-k} = \sum_{k=0}^{n-1} C_kC_{n-k-1}$이라는 점화식을 얻을 수 있습니다. 이렇게 정의되는 수열 $C_n$을 카탈란 수라고 부릅니다.

생성 함수를 이용하면 $\sum_{n=0}^\infty C_nx^n=\frac{1-\sqrt{1-4x}}{2x}$를 이용해 $C_0, C_1, \cdots, C_n$를 $O(n \log n)$에 계산할 수 있지만… 글의 범위를 벗어나므로 설명을 생략하겠습니다.

2-2. 카탈란 수로 해결할 수 있는 문제들

여는 괄호 $n$개와 닫는 괄호 $n$개로 구성된 올바른 괄호 문자열는 총 $C_n$가지가 있습니다. 여는 괄호와 닫는 괄호를 각각 오른쪽 이동과 위쪽 이동으로 생각하면 dych path 문제로 바꿀 수 있습니다. 또한, 리프 정점이 $n+1$개이고 모든 정점의 자식이 0개 또는 2개인 full binary tree의 개수는 $C_n$과 동일합니다. 수식 트리를 $n+1$개의 항이 있는 수식에 $n$쌍의 괄호를 씌워 이항 연산자를 적용하는 경우의 수와 동일하기 때문입니다. 다르게 이야기하면 내부 정점(internal node)가 $n$개인 full binary tree의 개수가 $C_n$이라고 생각할 수도 있습니다.

볼록 $n+2$각형에 $n-1$개의 대각선을 그려서 $n$개의 삼각형으로 분할하는 방법의 수도 $C_n$입니다. 다각형의 한 변을 고정한 뒤, 그 변을 밑변으로 하는 삼각형을 만들기 위해 대각이 될 꼭짓점을 선택하는 상황을 생각해 봅시다. $n+2$각형에서 한 변을 고정했을 때 대각이 될 수 있는 꼭짓점의 후보는 $n$개이며, 그중 $k(1 \leq k \leq n)$번째 점을 선택하면 도형은 삼각형 하나, 후보가 $k-1$개인 볼록 $k+1$각형, 후보가 $n-k$개인 볼록 $n-k+2$각형으로 분할됩니다. 따라서 $C_n = \sum_{k=1}^{n} C_{k-1}C_{n-k} = \sum_{k=0}^{n-1}C_kC_{n-k-1}$이라는 점화식을 얻을 수 있고, 이는 카탈란 수의 점화식과 동일합니다.

이밖에도 카탈란 수를 이용해 풀 수 있는 문제가 많이 있는데, 아래에 있는 연습 문제를 풀어보면서 카탈란 수에 어떻게 대응시킬 수 있을지 고민해 보는 것을 추천합니다.

2-3. 카탈란 삼각형

카탈란 삼각형 $C(n, k)$는 여는 괄호 $n$개와 닫는 괄호 $k(\leq n)$개로 구성된 문자열 중, 모든 닫는 괄호가 여는 괄호와 매칭된 문자열의 개수를 나타냅니다. $k < n$이면 여는 괄호와 매칭되는 닫는 괄호가 존재할 수도 있지만, 모든 닫는 괄호는 여는 괄호와 매칭되어야 합니다. Dyck path 문제와 비슷한 방법을 이용해 $C(n, k) = {n+k \choose k} - {n+k \choose k-1}$로 계산할 수 있으며, $C(n, k) = \frac{n-k+1}{n+1}{n+k \choose k}$도 성립합니다.

Wikipedia에 따르면 $C(n, k) = \sum_{i=0}^{k} C(n-1, i) = \sum_{i=k}^{n} C(i, k-1)$이 성립하고, 따라서 $C(n, k) = C(n, k-1) + C(n-1, k)$도 성립한다고 합니다.

3. 집합의 분할

3-1. 제2종 스털링 수

제2종 스털링 수는 $n$개의 원소를 $k$개의 부분 집합으로 분할하는 경우의 수를 의미하며, 주로 ${n \brace k}$ 또는 $S(n, k)$로 나타냅니다. 이때, 각 부분 집합은 공집합이 아니고, 부분 집합 간의 교집합이 존재하면 안 됩니다. 예를 들어 ${1, 2, 3}$을 2개의 부분 집합으로 분할하는 방법은 ${1, 2} \cup {3}$, ${2, 3}\cup {1}$, ${3, 1} \cup {2}$로 3가지이므로 ${3 \brace 2} = 3$입니다. $n \brace k$를 계산하는 것은 $n$번 원소가 들어가는 집합을 기준으로 생각해 보면 아래 두 가지 경우로 나눠서 계산할 수 있다는 것을 알 수 있습니다.

  1. $n-1$개의 원소를 $k-1$개의 부분 집합으로 분할한 뒤, 새로운 집합을 만들어서 $n$번 원소를 삽입
  2. $n-1$개의 원소를 $k$개의 부분 집합으로 분할한 뒤, $n$번 원소를 $k$개의 부분 집합 중 한 곳에 삽입

따라서 ${n \brace k} = {n-1 \brace k-1} + {n-1 \brace k} \times k$ 를 이용해 계산할 수 있습니다.

3-2. 벨 수

벨 수는 $n$개의 원소를 분할하는 경우의 수를 의미하며, 주로 $B_n$으로 나타냅니다. 정의에서 알 수 있듯이 벨 수는 $B_n = \sum_{k=0}^n {n \brace k}$로 계산할 수 있습니다. 이때, ${0 \brace 0} = B_0 = 1$로 맞추기 위해서 $k$를 $0$부터 더합니다.

점화식을 이용해 벨 수를 계산할 수도 있습니다. $n$번 원소를 포함하는 집합의 크기에 주목합시다. $n$번 원소를 포함하는 집합의 크기가 $k$가 되도록 분할하기 위해서는 $n$번 원소를 제외한 나머지 $n-1$개의 원소 중 $k-1$개를 선택한 다음, 이 집합에 포함되지 않은 $n-k$개의 원소를 적절히 분할하면 됩니다. 따라서 $B_n = \sum_{k=1}^{n} {n-1 \choose k-1}B_{n-k}$ $= \sum_{k=0}^{n-1} {n-1 \choose k}B_{n-k-1}$ $=\sum_{k=0}^{n-1} {n-1 \choose n-k-1}B_{n-k-1}$ $= \sum_{k=0}^{n-1} {n-1 \choose k}B_k$ 라는 점화식을 얻을 수 있고, $B_0, B_1, \cdots, B_n$을 $O(n^2)$ 시간에 전처리할 수 있습니다. 생성 함수를 이용하면 $\sum_{n=0}^\infty \frac{B_n}{n!}x^n = e^{e^x-1}$를 이용해 $O(n \log n)$에 계산할 수 있지만 글의 범위를 벗어나므로 설명을 생략하겠습니다.

3-3. 제2종 스털링 수의 일반항

포함 배제의 원리를 이용하면 제2종 스털링 수의 일반항을 구할 수 있습니다. 처음에는 $n$명의 사람을 $k$개의 방에 자유롭게 집어넣는 것에서 시작합니다. 즉, 공집합을 허용하고, $k$개의 부분 집합을 서로 구분하는 상황에서 시작합니다. 이런 배정은 총 $k^n$가지가 있습니다. 여기에서 빈방이 1개 이상 생기는 모든 배정의 개수를 빼면 되고, 이는 ($r$개의 빈방을 고정하는 경우의 수) * ($k-r$개의 방에 $n$명의 사람을 집어넣는 경우의 수) $= {k\choose r}(k-r)^n$을 이용해 포함 배제를 하면 계산할 수 있습니다.

식으로 나타내면 $k!{n \brace k} = k^n + \sum_{r=1}^{k} (-1)^r{k \choose r}(k-r)^n = \sum_{r=0}^{k} (-1)^r{k \choose r}(k-r)^n$ 이 되고, ${k \choose r} = {k \choose k-r}$이라는 것을 이용하면 $k!{n \brace k} = \sum_{r=0}^{k} (-1)^r{k \choose k-r}(k-r)^n = \sum_{r=0}^{k} (-1)^{k-r}{k \choose r}r^n$으로 나타낼 수 있습니다. 제2종 스털링 수는 집합을 구분하지 않기 때문에 $n \brace k$는 앞에서 구한 식을 $k!$으로 나눈 값인 $\frac{1}{k!}\sum_{r=0}^{k}(-1)^{k-r}{k \choose r}r^n = \frac{(-1)^k}{k!}\sum_{r=0}^{k} (-1)^{r}{k \choose r}r^n$입니다.

3-4. 제1종 스털링 수

부호 없는 제1종 스털링 수는 $n$개의 원소를 $k$개의 방향 있는 사이클로 분할하는 경우의 수를 의미하며, 주로 $\begin{bmatrix} n \ k \end{bmatrix}$로 나타냅니다. 순열의 사이클 분할을 생각하면, $\sum_{k=1}^{n} \begin{bmatrix} n \ k \end{bmatrix} = n!$ 임을 알 수 있습니다. 제1종 스털링 수도 제2종 스털링 수와 같이 점화식을 이용해 계산할 수 있는데, $n$번 원소가 들어갈 위치를 기준으로 생각해 보면 아래 두 가지 경우로 나눌 수 있다는 것을 알 수 있습니다.

  1. $n-1$개의 원소로 $k-1$개의 사이클을 만든 뒤, $n$번 원소 혼자 있는 사이클을 생성
  2. $n-1$개의 원소로 $k$개의 사이클을 만든 뒤, 만들어진 $n-1$개의 간선 중 하나를 골라서 사이에 $n$번 원소 삽입

따라서 $\begin{bmatrix} n \ k \end{bmatrix} = \begin{bmatrix} n-1 \ k-1 \end{bmatrix} + \begin{bmatrix} n-1 \ k \end{bmatrix} \times (n-1)$이라는 점화식을 얻을 수 있습니다.

4. 자연수의 분할

4-1. 영 다이어그램과 자연수 분할

$\lambda = (\lambda_1, \lambda_2, \cdots, \lambda_k)$가 아래 조건을 만족할 때 $\lambda$를 $N$의 자연수 분할이라고 부릅니다.

  1. $\lambda_i$는 양의 정수
  2. $\lambda_1 \geq \lambda_2 \geq \cdots \geq \lambda_n > 0$
  3. $\sum_{i=1}^k \lambda_i = N$

$N$의 자연수 분할 $\lambda = (\lambda_1, \lambda_2, \cdots, \lambda_k)$가 주어지면 $\lambda$를 이용해 $k$개의 행으로 구성되어 있고 $i$번째 행에 $\lambda_i$개의 칸이 있는, 총 $n$칸짜리 영 다이어그램을 만들 수 있습니다. 예를 들어 9의 자연수 분할 $(4, 3, 1, 1)$의 영 다이어그램은 다음과 같습니다.

조합론에서 영 다이어그램은 많은 의미를 갖고 있지만, 이 글의 범위를 벗어나고 아직 저도 잘 모르기 때문에 생략합니다.

4-2. 분할 수의 계산

$P_k(n)$ 또는 $P(n, k)$는 자연수 $n$을 순서를 고려하지 않고 $r$개의 자연수의 합으로 나타내는 경우의 수를 의미합니다. 행이 $k$개인 $n$칸짜리 영 다이어그램의 가짓수라고 생각해도 무방합니다. $P(n, k)$의 점화식은 영 다이어그램의 마지막 행의 길이를 기준으로 생각하는 것이 편합니다. 행이 $k$개인 $n$칸짜리 영 다이어그램을 만드는 것은 아래 두 가지 경우로 나눠서 생각할 수 있습니다.

  1. (마지막 행의 길이가 1인 경우) 행이 $k-1$개인 $n-1$칸짜리 영 다이어그램을 만든 뒤, 밑에 길이가 1인 행 하나 추가
  2. (마지막 행의 길이가 2 이상인 경우) 행이 $k$개인 $n-k$칸짜리 영 다이어그램을 만든 뒤, 각 행의 길이를 1씩 증가

따라서 $P(n, k) = P(n-1, k-1) + P(n-k, k)$라는 점화식을 얻을 수 있습니다. 특이하게도 자연수 $n$을 분할하는 방법의 수 $P(n) = \sum_{k=1}^{n} P(n, k)$를 $O(N \sqrt N)$ 시간에 계산할 수 있는 점화식이 두 가지 있는데, 궁금하신 분들은 글 하단에 있는 링크를 참고하시길 바랍니다.

5. 교란 순열

5-1. 교란 순열의 점화식

교란 순열 또는 완전 순열이란 모든 $1 \leq i \leq n$에 대해 $\pi(i) \neq i$를 만족하는 길이 $n$짜리 순열 $\pi$를 의미합니다. 주로 $D_n$이나 $!n$ 으로 표기합니다. $\pi(n)$의 값을 기준으로 생각하면 어렵지 않게 $D_n$의 점화식을 유도할 수 있습니다. $n$번째 위치에 $n-1$번 원소가 들어간 상황, 즉 $\pi(n) = n-1$인 경우를 생각해 봅시다.

$n$번 원소는 $\pi(1), \pi(2), \cdots, \pi(n-1)$ 중 원하는 자리를 골라 들어갈 수 있습니다. 만약 $n$번 원소가 $n-1$번째 자리에 들어갔다면($\pi(n-1)=n$) 1번 원소부터 $n-2$번 원소까지를 적절히 배치하는 것으로 교란 순열을 만들 수 있습니다. 따라서 이 경우에는 $D_n$에 $D_{n-2}$ 만큼 기여합니다.

반대로 $n$번 원소가 $n-1$번째 자리가 아닌 다른 자리에 들어간 상황, 즉 $\pi(i)=n, i < n-1$ 인 경우에는 $n$번 원소에게 $n-1$이라는 가면을 임시로 씌워준 다음, $1, 2, \cdots, n-2, n$번 원소를 이용해 교란 순열을 만드는 것으로 $\pi(i) = n, i < n-1$은 모든 경우를 확인할 수 있습니다. 따라서 이 경우에는 $D_n$에 $D_{n-1}$ 만큼 기여합니다.

$\pi(n)$에 $n-1$이 아닌 $1, 2, \cdots, n-2$가 들어가는 경우도 동일한 방법으로 계산할 수 있고, 각각은 모두 $D_n$에 $D_{n-2} + D_{n-1}$ 만큼 기여합니다. 따라서 $D_n = (n-1)(D_{n-1} + D_{n-2})$ 라는 점화식을 얻을 수 있습니다.

5-2. 교란 순열의 일반항

점화식을 적절히 정리하면 $D_n$의 일반항도 얻을 수 있습니다. $D_n = (n-1)(D_{n-1} + D_{n-2}) = (n-1)D_{n-1} + (n-1)D_{n-2}$에서 시작합시다.
$nD_{n-1}$을 왼쪽으로 넘기면 $D_n - nD_{n-1} = -{ D_{n-1} - (n-1)D_{n-2} }$가 되고, $A_n = D_n - nD_{n-1}$로 정의하면 $A_n = -A_{n-1}$이 되어 $A$는 공비가 $-1$인 등비수열임을 알 수 있습니다. 이때, $A_1 = D_1 - D_0 = 0 - 1 = -1$이므로 $A_n = (-1)^n$입니다.

따라서 $A_n = D_n - nD_{n-1} = -A_{n-1} = (-1)^n$이고, 양변을 $n!$으로 나누면 $\frac{D_n}{n!} - \frac{D_{n-1}}{(n-1)!} = \frac{(-1)^n}{n!}$이 됩니다. $B_n = \frac{D_n}{n!}$으로 정의하면 $B_n - B_{n-1} = \frac{(-1^n)}{n!}$이라는 점화식을 얻을 수 있고, 이를 일반항으로 나타내면 $B_n = B_1 + \sum_{k=2}^{n} \frac{(-1)^k}{k!}$이고 $B_1 = \frac{D_1}{1!} = 0$이므로 $\frac{D_n}{n!} = \sum_{k=2}^{n} \frac{(-1)^k}{k!}$가 됩니다.

$\frac{(-1)^0}{0!} + \frac{(-1)^1}{1!} = 1 + (-1) = 0$이므로 $\frac{D_n}{n!} = \sum_{k=0}^n \frac{(-1)^k}{k!}$으로 써도 무방합니다. 따라서 $D_n = n! \sum \frac{(-1)^k}{k!}$이라는 일반항을 얻을 수 있습니다. 이때 $e^x$의 테일러 전개를 생각해 보면, $\frac{D_n}{n!}$의 일반항은 $e^x = \sum_{k=0}^\infty \frac{x^k}{k!}$에서 $x = -1$을 대입한 꼴이기 때문에, $\lim_{n\rightarrow \infty} \frac{D_n}{n!} = \frac{1}{e}$라는 것도 알 수 있습니다.

포함 배제의 원리를 이용해도 동일한 일반항을 유도할 수 있습니다. $n$개의 원소를 나열하는 방법 $n!$가지 중 $\pi(i)=i$인 $i$의 개수가 $k$ 이상인 순열이 $\frac{n!}{k!}$이라는 사실을 이용하면 $D_n = \sum_{k=0}^{n} (-1)^k \times \frac{n!}{k!} = n! \sum_{k=0}^n \frac{(-1)^k}{k!}$을 얻을 수 있습니다.

6. 조합론의 12정도

12정도는 $n$개의 원소를 $k$개의 집합으로 분할하는($n$개의 공을 $k$개의 상자에 넣는) 방법을 상황에 따라 분류한 표입니다. $[\text{cond}]$는 아이버슨 괄호로, 대괄호 안에 있는 조건이 참이면 $1$, 거짓이면 $0$을 나타냅니다.

동치 관계 \ 함수 조건 조건 없음 단사 함수(상자에 공 최대 1개) 전사 함수(상자에 공 최소 1개)
함수 일치 $k^n$ $_kP_n = k!/(k-n)!$ $k! \times {n \brace k}$
정의역의 순열 무시(공 구별 X) $_kH_n = {n+k-1 \choose n}$ $k \choose n$ $n-1 \choose n-k$, 단, $n=k=0$이면 $1$
공역의 순열 무시(상자 구별 X) $\sum_{r=1}^{k} {n \brace r}$ $[n \leq k]$ $n \brace k$
둘 다 무시(모두 구별 X) $P(n+k, k)$ $[n \leq k]$ $P(n, k)$

더 공부할 거리

연습 문제

]]>
JusticeHui
Union Find 200% 활용하기2024-02-04T00:00:00+00:002024-02-04T00:00:00+00:00https://justicehui.github.io/medium-algorithm/2024/02/04/union-find-application0. 목차
  1. Union Find 복습
  2. 오프라인 쿼리
  3. Small to Large
  4. 이분 그래프 표현
  5. std::set 대체
  6. 두 원소의 차이 관리

1. Union Find 복습

Union Find은 다음과 같은 연산을 지원하는 자료구조입니다. 구현 방법에 따라 union, find를 연산당 $O(n)$, $O(\log n)$, amortized $O(\log n)$, amortized $O(\log^\ast n)$, 또는 amortized $O(\alpha(n))$ 시간에 수행할 수 있습니다. 이때 $\alpha(n)$은 Ackermann function의 역함수($\alpha(n) = A(k,3) \geq n$을 만족하는 가장 작은 $k$)로, 매우 느리게 증가하는 함수입니다.

  • init(n): 0~n-1번 원소를 각각 자기 자신만 있는 집합에 속하도록 초기화
  • find(x): x번 원소가 속한 집합의 대푯값을 반환
  • union(u, v) 또는 merge(u, v): u번 원소가 속한 집합과 v번 원소가 속한 집합을 병합

이후 설명에서 사용하는 코드의 이해를 돕기 위해, 제가 사용하는 Union Find의 구현을 아주 간단하게 소개합니다.

1-1. Union Find의 구현

Union Find의 가장 기본적인 구현 방법은 다음과 같습니다. std::iota(first, last, value)[first, last) 구간을 value, value+1, value+2, ...로 채우는 함수입니다. find 연산의 시간 복잡도는 트리의 높이에 비례합니다. 따라서 최악의 경우 $O(n)$이며, find를 2번 수행하는 merge 연산의 시간 복잡도 또한 $O(n)$이라는 것을 쉽게 알 수 있습니다.

struct union_find{
    vector<int> p;
    union_find(int n) : p(n) {
        iota(p.begin(), p.end(), 0);
    }
    int find(int v){
        if(v == p[v]) return v;
        else return find(p[v]);
    }
    bool merge(int u, int v){
        u = find(u); v = find(v);
        if(u == v) return false;
        p[u] = v; return true;
    }
};

1-2. Union by Rank

rank[x]를 x가 루트인 트리의 높이라고 정의합시다. 높이가 큰 트리 밑에 작은 트리를 붙이는 방식으로 merge 연산을 구현하면 트리의 높이가 항상 $O(\log n)$이하가 된다는 것을 증명할 수 있습니다. 정점이 $2^h-1$개 이하인 트리의 높이는 항상 $h$ 이하임을 보이면 됩니다. 구현은 다음과 같습니다.

struct union_find{
    vector<int> p, r;
    union_find(int n) : p(n), r(n) {
        iota(p.begin(), p.end(), 0);
        fill(r.begin(), r.end(), 1);
    }
    int find(int v){
        if(v == p[v]) return v;
        else return find(p[v]);
    }
    bool merge(int u, int v){
        u = find(u); v = find(v);
        if(u == v) return false;
        if(r[u] > r[v]) swap(u, v);
        if(r[u] == r[v]) r[v]++;
        p[u] = v; return true;
    }
};

1-3. Union by Size

size[x]를 x가 루트인 트리에 포함된 정점의 개수라고 정의합시다. 크기가 큰 트리 밑에 크기가 작은 트리를 붙이는 방식으로 merge 연산을 구현하면 트리의 높이가 항상 $O(\log n)$ 이하가 된다는 것을 알 수 있습니다. 어떤 정점이 속한 트리의 크기가 증가하는 경우, 즉 어떤 정점의 깊이가 증가하는 경우에는 항상 2배 이상 증가하게 되고, 트리의 최대 크기는 $n$이므로 각 정점의 깊이가 최대 $O(\log n)$번만 증가할 수 있기 때문입니다. 구현은 다음과 같습니다.

struct union_find{
    vector<int> p, s;
    union_find(int n) : p(n), s(n) {
        iota(p.begin(), p.end(), 0);
        fill(s.begin(), s.end(), 1);
    }
    int find(int v){
        if(v == p[v]) return v;
        else return find(p[v]);
    }
    bool merge(int u, int v){
        u = find(u); v = find(v);
        if(u == v) return false;
        if(s[u] > s[v]) swap(u, v);
        p[u] = v; s[v] += s[u];
        return true;
    }
};

Union by size의 시간 복잡도 증명 아이디어는 small to large, heavy light decomposition 등 다양한 알고리즘의 시간 복잡도 증명에 사용되기 때문에 잘 이해하고 넘어가야 합니다.

1-4. Path Compression

아래 코드와 같이 경로 압축을 수행하면 find 연산의 시간 복잡도가 amortized $O(\log n)$이 됨을 증명할 수 있습니다. 증명은 (링크)에서 확인할 수 있습니다. 분할 상환 분석을 통해 얻은 시간 복잡도이기 때문에, 연산 한 번에는 worst $O(n)$이 될 수 있음에 유의해야 합니다.

struct union_find{
    vector<int> p;
    union_find(int n) : p(n) {
        iota(p.begin(), p.end(), 0);
    }
    int find(int v){
        if(v == p[v]) return v;
        else return p[v] = find(p[v]);
    }
    bool merge(int u, int v){
        u = find(u); v = find(v);
        if(u == v) return false;
        p[u] = v; return true;
    }
};

삼항 연산자와 논리 연산자를 활용하면 더 짧게 구현할 수도 있습니다.

struct union_find{
    vector<int> p;
    union_find(int n) : p(n) { iota(p.begin(), p.end(), 0); }
    int find(int v){ return v == p[v] ? v : p[v] = find(p[v]); }
    bool merge(int u, int v){ return find(p[u]) != find(p[v]) && (p[p[u]]=p[v], true); }
};

path compression과 union by rank를 함께 사용하면 amortized $O(\log^\ast n)$, 더 나아가 amortized $O(\alpha(n))$이 됨을 증명할 수 있지만, 이 글의 범위를 벗어나므로 생략합니다(링크1, 링크2). union by rank를 함께 사용하면 연산을 한 번 수행할 때도 worst $O(\log n)$이 됩니다.

2. 오프라인 쿼리

BOJ 13306 트리 문제를 봅시다. 아래 두 가지 쿼리를 처리해야 하는 문제입니다.

  • 0 x : x와 x의 부모 정점을 연결하는 간선 제거
  • 1 c d : c번 정점과 d번 정점이 같은 컴포넌트에 있는지 확인

일반적으로 Union Find는 그래프에 간선이 추가되는 상황에서 정점들의 연결 여부를 관리(incremental dynamic connectivity)할 때 사용하는 자료구조입니다. 하지만 이 문제는 간선이 추가되는 상황이 추가되는 상황이 아닌, 간선이 제거되는 상황에서 정점들의 연결 여부를 관리(decremental dynamic connectivity)해야 합니다. 다행히도 온라인 저지의 채점 방식 특성상 쿼리가 주어질 때마다 바로바로 정답을 구하지 않아도 되기 때문에, 쿼리를 모두 입력받은 다음 뒤에서부터 쿼리를 처리하면 incremental 상황으로 바꿀 수 있습니다.

0번 쿼리가 정확히 N-1번 주어지기 때문에, 쿼리를 뒤에서부터 처리하면 정점들이 모두 흩어져 있는 상황에서 시작합니다. 원래 간선을 제거하는 쿼리였던 0번 쿼리는 간선을 추가하는 쿼리로 바뀌게 됩니다. 따라서 아래 코드처럼 Union Find를 이용해 문제를 해결할 수 있습니다.

#include <bits/stdc++.h>
using namespace std;

int P[202020];
int Find(int v){ return v == P[v] ? v : P[v] = Find(P[v]); }
void Merge(int u, int v){ if(Find(u) != Find(v)) P[Find(u)] = Find(v); }

int N, Q, G[202020];
int A[404040], B[404040], C[404040];
vector<int> R;

int main(){
    ios_base::sync_with_stdio(false); cin.tie(nullptr);
    cin >> N >> Q; Q += N-1;
    for(int i=2; i<=N; i++) cin >> G[i];
    for(int i=1; i<=Q; i++){
        cin >> A[i];
        if(A[i] == 0) cin >> B[i];
        else cin >> B[i] >> C[i];
    }

    iota(P+1, P+N+1, 1);
    for(int i=Q; i>=1; i--){
        if(A[i] == 0) Merge(B[i], G[B[i]]);
        else R.push_back(Find(B[i]) == Find(C[i]));
    }

    reverse(R.begin(), R.end());
    for(auto i : R) cout << (i ? "YES" : "NO") << "\n";
}

이런 식으로 쿼리를 모두 입력받은 다음, 쿼리의 순서를 원하는 대로 수정해서 처리하는 것을 오프라인 쿼리라고 부릅니다. 출제자가 오프라인 풀이를 막으려고 할 때는 BOJ 13309 트리, BOJ 22306 트리의 색깔과 쿼리 2, BOJ 17465 동적 연결성과 쿼리처럼 이전 쿼리의 결과를 이용해 다음 쿼리의 파라미터를 생성하게 하는데, 이런 문제는 반드시 쿼리를 주어지는 순서대로 처리해야 합니다.

3. Small to Large

설명 바로가기

4. 이분 그래프 표현

이 단락에서는 Union Find에 몇 가지 추가 정보를 저장해서 간선이 추가되는 상황에서 각 컴포넌트가 이분 그래프인지 판별하는 문제를 푸는 방법을 알아볼 것입니다. 이분 그래프란, 그래프 $G = (V, E)$를 $E \subset {(u, v);\ u \in L, v \in R}$이 성립하도록 정점 집합을 $V = L \cup R$로 분할할 수 있는 그래프를 의미합니다. 쉽게 말해, 정점 집합을 두 개의 그룹 $L, R$으로 분할하려고 할 때, 같은 그룹에 속한 두 정점을 연결하는 간선이 없도록 분할 가능한 그래프가 이분 그래프입니다.

4-1. BOJ 1765 닭싸움 팀 정하기

BOJ 1765 닭싸움 팀 정하기 문제를 봅시다. 다음과 같은 조건이 주어질 때 가능한 친구 그룹 개수의 최댓값을 구하는 문제입니다.

  • F p q : p와 q는 친구이다.
  • E p q : p와 q는 원수이다.
  • 단, 친구의 친구는 친구이며, 원수의 원수도 친구이다.

사람을 정점으로, 친구 그룹을 정점 그룹으로 생각하면 이 문제를 이분 그래프의 컴포넌트 개수(와 비슷한 것)을 관리하는 문제로 바꿀 수 있습니다.

  • F p q : p와 q는 반드시 같은 그룹에 속해야 한다.
  • E p q : p와 q는 반드시 다른 그룹에 속해야 한다.

이런 문제는 무조건 같은 그룹에 포함되어야 하는 정점들을 Union Find를 이용해 묶으면서, x번 정점과 무조건 다른 그룹에 포함되어야 하는 정점 하나(enemy[x])를 관리하는 방식으로 해결할 수 있습니다. “p와 q는 친구이다” 라는 정보가 주어지면 단순히 union하면 되고, “p와 q는 원수이다” 라는 정보가 주어지면 enemy[p]와 q, 그리고 enemy[q]와 p를 union 하면 됩니다. 문제에서 요구하지는 않지만, 각 컴포넌트가 이분 그래프인지 판별하는 것은 x와 enemy[x]가 서로 다른 집합에 있는지 확인하면 됩니다.

struct friendship_bipartite_union_find{
    vector<int> p, e; // parent, enemy
    bipartite_union_find(int n) : p(n), e(n, -1) {
        iota(p.begin(), p.end(), 0);
    }
    int find(int v){ return v == p[v] ? v : p[v] = find(p[v]); }
    bool merge(int u, int v){ return find(u) != find(v) && (p[p[u]]=p[v], true); }
    int set_friend(int u, int v){ return merge(u, v); }
    int set_enemy(int u, int v){
        int res = 0;
        if(e[u] == -1) e[u] = v;
        else res += set_friend(e[u], v);
        if(e[v] == -1) e[v] = u;
        else res += set_friend(e[v], u);
        return res;
    }
};

4-2. BOJ 28121 산책과 쿼리

BOJ 28121 산책과 쿼리 문제를 봅시다. 간선이 추가되는 상황에서 산책의 자유도가 높은 자취방의 개수를 구하는 문제입니다. 어떤 자취방 $v$의 자유도가 높다는 것은, $10^6$ 이상의 모든 정수 $t$에 대해, $v$에서 출발해 $v$로 돌아오는 길이가 $t$인 사이클이 존재함을 의미합니다. $v$와 연결된 정점이 하나라도 있으면 그 정점을 왕복하는 것으로 항상 짝수 길이 사이클을 만들 수 있으니 홀수 길이 사이클에만 집중하면 됩니다. 여기에서 어떤 그래프가 이분 그래프인 것과 그래프에 홀수 사이클이 없는 것은 동치이다 라는 사실을 이용하면, 결국 이 문제는 이분 그래프가 아닌 컴포넌트의 크기의 합을 구하는 문제라는 것을 알 수 있습니다.

사실 이 문제는 이분 그래프보다는 홀수 사이클에 초점을 맞추는 것이 더 편합니다. 각 정점 $v$를 두 개의 정점 $v_0, v_1$로 분할합시다. $v_0$은 짝수 시간에 $v$에 위치하는 상태, $v_1$은 홀수 시간에 $v$에 위치하는 상태를 의미합니다. 만약 $v_0$에서 $v_1$로 이동할 수 있으면 $v$를 포함하는 홀수 사이클이 존재합니다. 따라서 간선 $(a, b)$가 추가될 때마다 $a_0$과 $b_1$, 그리고 $a_1$과 $b_0$을 union한 다음, $v_0$과 $v_1$이 같은 집합에 속하는 $v$의 개수를 구하면 문제를 해결할 수 있습니다.

struct bipartite_union_find{
    int n, sum; vector<int> p, s;
    bipartite_union_find(int n) : n(n), sum(0), p(n+n), s(n+n) {
        iota(p.begin(), p.end(), 0);
        fill(s.begin(), s.begin()+n, 1);
    }
    int neg(int v){ return v < n ? v + n : v - n; } // v_0 <=> v_1
    int find(int v){ return v == p[v] ? v : p[v] = find(p[v]); }
    void merge(int u, int v){
        u = find(u); v = find(v);
        if(u == v) return;
        if(find(neg(u)) == u) sum -= s[u];
        if(find(neg(v)) == v) sum -= s[v];
        p[u] = v; s[v] += s[u];
        if(find(neg(v)) == v) sum += s[v];
    }
    void add_edge(int u, int v){
        merge(u, neg(v));
        merge(v, neg(u));
    }
};

5. std::set 대체

std::set(또는 balanced binary search tree)은 삽입(insert), 삭제(erase), x 이상인 최소 원소(lower bound), x 초과인 최대 원소(upper bound) 등의 연산을 $O(\log n)$에 수행하는 멋진 자료구조지만, 시간 복잡도에 붙는 상수 계수가 상당히 크다는 단점이 있습니다. 이 단락에서는 아래 조건을 만족하는 상황에서 Union Find를 이용해 lower bound와 upper bound 연산을 구현하는 방법에 대해 다룹니다.

  1. 집합에 1부터 n까지의 원소가 정확히 하나씩 들어있는 상황에서 시작
  2. 원소를 제거할 수만 있고, 삽입할 수 없음

즉, 원소가 추가되지 않을 때 erase, lower bound, upper bound 연산을 $O(\log N)$, 또는 amortized $O(\alpha(n))$ 정도에 구현하는 것이 목표입니다.

핵심 아이디어는 연속한 위치에 있는 삭제된 원소들을 구간을 관리하는 것입니다. 만약 2~4가 삭제된 상황이라면 2~4를 union한 뒤, 이 구간의 왼쪽 끝점과 오른쪽 끝점이 각각 2와 4라고 기록하면 됩니다. union 할 때마다 구간의 최솟값과 최댓값을 관리하면 시간 복잡도의 변화 없이 이런 정보를 관리할 수 있습니다. x 이상인 최소 원소를 찾는 연산(lower bound)은 x가 삭제되었는지 확인한 다음 삭제되었다면 (x가 포함된 구간의 최댓값) + 1을 반환하면 되고, upper bound도 비슷하게 구현할 수 있습니다. 자세한 구현은 아래 코드에서 확인할 수 있습니다.

struct union_find{
    vector<int> p, l, r;
    union_find(int n) : p(n), l(n), r(n) {
        iota(p.begin(), p.end(), 0);
        iota(l.begin(), l.end(), 0);
        iota(r.begin(), r.end(), 0);
    }
    int find(int v){ return v == p[v] ? v : p[v] = find(p[v]); }
    void merge(int u, int v){
        u = find(u); v = find(v);
        if(u == v) return; p[u] = v;
        l[v] = min(l[v], l[u]);
        r[v] = max(r[v], r[u]);
    }
    int prev(int x){ return find(l[find(x)]-1); }
    int next(int x){ return find(r[find(x)]+1); }
};

struct uf_set{ // 1-based
    union_find uf;
    vector<int> chk;
    uf_set(int n) : uf(n+2), chk(n+2) {}
    void erase(int x){
        chk[x] = 1;
        if(chk[x-1]) uf.merge(x-1, x);
        if(chk[x+1]) uf.merge(x, x+1);
    }
    int prev(int x){
        do x = uf.prev(x); while(chk[x]);
        return x;
    }
    int next(int x){
        do x = uf.next(x); while(chk[x]);
        return x;
    }
    int front(){ return next(0); }
    int back(){ return prev(chk.size()-1); }
};

BOJ 10775 공항 문제를 봅시다. 이 문제는 $g_i$가 주어질 때마다, 아직 도킹 되지 않은 게이트 중 번호가 $g_i$ 이하인 가장 큰 게이트에 도킹시키는 그리디 풀이가 성립함을 어렵지 않게 알 수 있습니다. 따라서 std::set을 이용하면 다음과 같이 문제를 해결할 수 있습니다.

#include <bits/stdc++.h>
using namespace std;

int main(){
    ios_base::sync_with_stdio(false); cin.tie(nullptr);
    int N, Q; cin >> N >> Q;
    set<int> S;
    for(int i=1; i<=N; i++) S.insert(i);
    for(int i=1; i<=Q; i++){
        int t; cin >> t;
        auto it = S.upper_bound(t);
        if(it == S.begin()){ cout << i - 1; return 0; }
        else S.erase(--it);
    }
    cout << Q;
}

위에서 구현한 uf_set을 이용하면 다음과 같이 구현할 수도 있습니다. std::set을 사용한 프로그램의 실행 시간은 60ms, uf_set을 사용한 프로그램의 실행 시간은 16ms 입니다.

int main(){
    ios_base::sync_with_stdio(false); cin.tie(nullptr);
    int N, Q; cin >> N >> Q;
    uf_set S(N);
    for(int i=1; i<=Q; i++){
        int t; cin >> t;
        int pos = S.prev(t+1);
        if(pos == 0){ cout << i - 1; return 0; }
        else S.erase(pos);
    }
    cout << Q;
}

이 문제와 같이 “x 이하인 가장 큰 원소”를 구하는 연산만 주어질 때는 아래 코드처럼 더 간단하게 구현할 수도 있습니다. 항상 번호가 작은 정점 아래에 번호가 큰 정점을 붙이는 방식으로 union을 진행하며, x를 삭제할 때마다 x-1과 x를 union해서 find(x)가 항상 x 이하인 가장 큰 원소를 가리키게 하는 방법입니다.

#include <bits/stdc++.h>
using namespace std;

int N, Q, P[101010];
int Find(int v){ return v == P[v] ? v : P[v] = Find(P[v]); }

int main(){
    ios_base::sync_with_stdio(false); cin.tie(nullptr);
    cin >> N >> Q;
    iota(P, P+101010, 0);
    for(int i=1; i<=Q; i++){
        int t; cin >> t;
        int pos = Find(t);
        if(pos == 0){ cout << i - 1; return 0; }
        else P[pos] = Find(pos-1);
    }
    cout << Q;
}

6. 두 원소의 차이 관리

Union Find를 이용하면 다음과 같은 쿼리가 주어지는 문제(BOJ 3830 교수님은 기다리지 않는다, 2021 SCPC 1차 예선 5번)도 해결할 수 있습니다.

  • 1 u v w : $X_v - X_u = w$ 조건 추가
  • 2 u v : $X_v - X_u$ 출력, 만약 두 변수의 차를 계산할 수 없으면 NC, 두 변수의 차를 계산하는 데 모순이 발생하면 CF 출력

기본적인 아이디어는 Union Find에서 루트 정점의 값은 항상 0으로 고정하고, 각 정점마다 부모 정점과의 차이를 저장하는 것입니다. $f(v) = X_v - X_{find(v)}$의 값을 구할 때는 단순히 $v$에서 루트 정점으로 갈 때 거치는 간선의 가중치를 모두 더하면 되고, 따라서 find 연산에서 경로 압축도 어렵지 않게 구현할 수 있습니다. $X_v - X_u$는 $f(v) - f(u)$를 구하면 되므로 2번의 find 연산으로 답을 구할 수 있습니다. 단, $u$와 $v$가 서로 다른 집합에 포함되어 있는 경우를 따로 처리해야 합니다.

merge 연산은 피연산자로 $u, v$가 주어지지만 실제로 연산을 수행할 때는 $find(u), find(v)$를 다뤄야 한다는 점에서 다른 연산에 비해 조금 복잡합니다. 만약 $u, v$가 이미 같은 집합에 속해있을 때는 $f(v) - f(u) = w$를 만족해야 합니다. 따라서 $f(v) - f(u) - w \ne 0$이면 해당 집합에 모순이 발생했다고 표시해야 합니다. 모순이 발생하지 않으면 $find(u)$의 부모 정점을 $find(v)$로 지정하면서 간선의 가중치 $e$를 결정해야 합니다. $w = X_v - X_u = f(v) - f(u) - e$를 만족해야 하므로 $e = f(v) - f(u) - w$로 설정하면 됩니다. 구현은 다음과 같이 할 수 있습니다.

template<typename cost_t>
struct weighted_union_find{
    vector<int> p, die;
    vector<cost_t> d;
    weighted_union_find(int n) : p(n), die(n,0), d(n,0) { iota(p.begin(), p.end(), 0); }
    pair<int,cost_t> find(int v){
        if(v == p[v]) return {v, 0};
        auto [root,diff] = find(p[v]);
        p[v] = root; d[v] += diff;
        return {p[v], d[v]};
    }
    void merge(int u, int v, cost_t w){
        auto [pu,wu] = find(u);
        auto [pv,wv] = find(v);
        if(pu == pv){ die[pv] |= wv - wu - w != 0; return; }
        p[pu] = pv; die[pv] |= die[pu];
        d[pu] = wv - wu - w;
    }
    pair<bool,cost_t> get_difference(int u, int v){ // return W[v] - W[u]
        auto [pu, du] = find(u);
        auto [pv, dv] = find(v);
        if(pu == pv && !die[pv]) return {true, dv - du};
        else if(pu == pv && die[pv]) return {false, cost_t(-1)};
        else return {false, cost_t(0)};
    }
};

연습 문제 목록

]]>
JusticeHui
분할 상환 분석과 동적 배열의 구현2024-01-28T00:00:00+00:002024-01-28T00:00:00+00:00https://justicehui.github.io/medium-algorithm/2024/01/28/amortized-analysis0. 목차
  1. 분할 상환 분석
  2. 동적 배열의 구현
  3. 포텐셜 메소드
  4. 동적 배열의 시간 복잡도 분석

1. 분할 상환 분석

1-1. 대출 상환 방식의 종류

대출을 갚는 방법은 여러 가지가 있지만, 일단 이 글에서는 만기 일시 상환원리금 균등 분할 상환에 주목해 봅시다. 만기 일시 상환은 대출받은 뒤 만기일 전까지는 이자만 갚다가 만기 때 남은 이자와 대출 원금을 갚는 방식입니다. 평소에는 조금씩 돈을 갚다가 마지막에 한 번 큰 금액을 갚는다고 생각하면 됩니다. 원리금 균등 분할 상환은 (대출 원금 + 만기일까지 지급해야 할 이자)를 만기일까지 균등하게 상환하는 방식입니다. 같은 금액을 갚는 것이지만(사실은 조금 다릅니다) 조금씩 갚다가 딱 한 번 많이 갚는 것과, 매번 균등하게 갚는 차이가 있습니다.

알고리즘 세미나에서 왜 뜬금없이 대출 이야기를 하는지 궁금한 분들이 있을 텐데, 그 이유는 뒷 내용을 들으면 자연스럽게 이해가 될 것입니다.

1-2. worst time complexity의 함정 (1)

BOJ 31218 자료 구조의 왕 문제를 봅시다. 이 문제에서 요구하는 것을 아래 코드와 같이 구현하면 각 쿼리를 $O(\max(N,M))$ 시간에 처리할 수 있습니다.

#include <bits/stdc++.h>
using namespace std;

int N, M, Q, A[1010][1010], C;
bool Bound(int i, int j){ return 1 <= i && i <= N && 1 <= j && j <= M; }

int main(){
    ios_base::sync_with_stdio(false); cin.tie(nullptr);
    cin >> N >> M >> Q;
    for(int q=1; q<=Q; q++){
        int op, di, dj, i, j; cin >> op;
        if(op == 1){
            cin >> di >> dj >> i >> j;
            while(Bound(i, j) && !A[i][j]) A[i][j] = 1, C++, i += di, j += dj;
        }
        if(op == 2) cin >> i >> j, cout << A[i][j] << "\n";
        if(op == 3) cout << N * M - C << "\n";
    }
}

각 쿼리를 처리하는데 $O(\max(N,M))$ 시간이 걸리므로 전체 시간 복잡도는 $O(Q \max(N, M))$입니다. 시간 복잡도만 보면 약 2억 번의 연산을 수행해서 시간제한 안에 간당간당하게 들어와야 할 것 같지만, 실제로 위 코드를 제출해 보면 64ms 정도로 매우 빠르게 동작하는 것을 확인할 수 있습니다. $O(Q\max(N,M))$이라는 수식이 이 프로그램의 연산량을 정확하게 나타내고 있지 않다는 것을 유추할 수 있습니다.

코드의 14번째 줄에 있는 while문이 실제로 얼마나 반복하게 될지 생각해 봅시다. while문 내부의 코드는 $(i, j)$가 잔디밭 내부에 있으면서 아직 그 위치에 잔디가 있을 때만 동작하고, 만약 $(i, j)$가 잔디밭 밖으로 나가거나 그 위치에 잔디가 없으면 반복을 중단합니다. 따라서 while문은 마지막 비교를 제외하면 잔디밭의 넓이보다 더 많이 반복할 수 없습니다. 그러므로 위 프로그램의 시간 복잡도는 $O(Q\max(N,M))$ 대신 $O(NM+Q)$으로도 표현할 수 있습니다.

1-3. worst time complexity의 함정 (2)

BOJ 17298 오큰수 문제를 봅시다. 이 문제는 스택을 이용해 아래 코드와 같이 해결할 수 있음이 잘 알려져 있습니다.

#include <bits/stdc++.h>
using namespace std;

int N, A[1010101], B[1010101];

void pop_leq_k(stack<int> &stk, int k){
    while(!stk.empty() && stk.top() <= k) stk.pop();
}

int main(){
    ios_base::sync_with_stdio(false); cin.tie(nullptr);
    cin >> N;
    for(int i=1; i<=N; i++) cin >> A[i];

    stack<int> S;
    for(int i=N; i>=1; i--){
        pop_leq_k(S, A[i]);
        B[i] = S.empty() ? -1 : S.top();
        S.push(A[i]);
    }
    for(int i=1; i<=N; i++) cout << B[i] << " ";
}

이 코드는 스택에서 $k$보다 작거나 같은 원소를 모두 제거하는 pop_leq_k 연산을 $N$번 수행합니다. pop_leq_k 연산의 시간 복잡도는 얼마나 될까요? 스택 $S$에 최대 $N$개의 원소가 있을 수 있기 때문에 한 번의 pop_leq_k 연산에서 최대 $N$개의 원소를 제거할 수 있습니다. 따라서 pop_leq_k의 시간 복잡도는 $O(N)$이고, 위 프로그램은 이러한 연산을 $N$번 수행하므로 전체 시간 복잡도는 $O(N^2)$입니다. 이상한 점이 보이나요?

시간 복잡도를 다시 계산해 봅시다. 위 프로그램에서 스택을 빠져나온 원소가 다시 스택에 들어가는 일은 발생하지 않습니다. 즉, 각 원소를 정확히 한 번 스택에 들어간 뒤, 최대 한 번 스택을 빠져나옵니다. 따라서 전체 시간 복잡도는 $O(N)$입니다.

$N$개의 답을 구하는 동안 수행하는 전체 연산 횟수가 $O(N)$인 점을 생각하면, pop_leq_k 연산의 시간 복잡도를 $O(N)$이라고 하는 것은 별로 좋은 표현이 아닌 것처럼 느껴집니다. 그렇다고 $O(1)$이라고 하는 건 올바른 표현이 아닙니다. pop_leq_k 연산의 시간 복잡도를 어떻게 표현하는 것이 좋을까요?

1-4. 분할 상환 분석

분할 상환 분석은 이런 질문에 대한 멋진 답을 알려줄 수 있습니다. 다시 대출 이야기로 돌아가 봅시다. 오큰수 문제의 pop_leq_k 연산은 $k$가 스택에 있는 원소들에 비해 상대적으로 작을 때 적은 금액을 갚고, $k$가 클 때는 많은 금액을 갚는 것으로 생각할 수 있습니다. 우리는 $N$번의 연산을 수행하는데 총 $O(N)$ 시간이 걸린다는 것을 알고 있기 때문에, 대출을 균등 분할 상환 방식으로 갚으면 매번 $O(1)$ 만큼만 갚아도 된다는 것을 알 수 있습니다. 그러므로 우리는 앞으로 오큰수 문제에서 pop_leq_k 연산의 시간 복잡도를 amortized $O(1)$로 나타낼 것입니다.

이는 평균 시간 복잡도 분석과는 분명히 다릅니다. 평균 시간 복잡도 분석은 확률을 이용해 분석한 것이기 때문에 Big-O notation으로 나타낸 것보다 더 많은 연산이 발생할 수 있지만, 분할 상환 분석은 확률이 포함되지 않았기 때문에 최악의 경우에도 각 연산의 평균 수행 시간을 보장합니다. 퀵 정렬의 평균 시간 복잡도는 $O(N \log N)$이고 $O(N^2)$에 동작하도록 하는 입력을 쉽게 만들 수 있지만, $N$번의 pop_leq_k 연산이 $O(N)$보다 더 많은 연산을 수행하도록 하는 입력은 절대 만들 수 없습니다.

분할 상환 분석을 하는 방법은 여러가지가 있는데, 이 문단에서 총계 분석(aggregate method), 결산 분석(accounting method)을, 뒤에 3장에서 잠재 비용 분석(potential method)에 대해 설명합니다.

총계 분석은 $n$번의 연산을 수행하는데 $T(n)$ 시간이 걸렸다면 연산 한 번의 시간 복잡도를 amortized $T(n) / n$으로 표현하는 방법으로, 위에서 오큰수 문제의 시간 복잡도를 분석할 때 사용한 방법입니다. 자료 구조의 왕 문제는 $Q$개의 쿼리를 처리하는 데 $O(NM+Q)$ 만큼의 시간이 소요되므로 쿼리 한 번의 시간 복잡도는 amortized $O(NM/Q+1)$ 이라고 나타낼 수 있습니다.

결산 분석은 각 연산의 분할 상환 비용 $\hat{c}_i$를 미리 정한 다음, 연산의 실제 비용 $c_i$가 $\hat{c}_i$보다 작은 경우 $\hat{c}_i - c_i$를 저장하는 방식으로 분석을 진행합니다. 즉, 분할 상환 비용보다 더 적은 비용을 사용했다면, 그 차액을 저축한 다음 나중에 더 오래걸리는 연산을 수행하는 데 사용하는 것입니다. 이 방법은 총계 분석과 다르게 연산종류마다 분할 상환 비용을 다르게 설정할 수 있습니다.

몇 가지 제약 사항이 있는데, $N$번의 연산을 수행할 때의 분할 상환 비용은 실제 비용보다 크거나 같아야 합니다. 즉, $\sum_{i=1}^N \hat{c}_i \geq \sum_{i=1}^{N} c_i$를 충족해서 실제 비용의 상한을 나타낼 수 있어야 합니다. 또한, 매 순간에도 $\sum_{i=1}^{k} (\hat{c}_i - c_i) \geq 0$을 만족해야 합니다. 만약 연산 수행 도중에 값이 0보다 작아지게 된다면, 그 시점까지 투입된 비용의 상한을 나타낼 수 없기 때문입니다.

오큰수 문제를 다시 살펴봅시다. S.push(x)를 수행할 때의 분할 상환 비용을 2, pop_leq_k를 수행할 때의 분할 상환 비용을 0으로 설정하겠습니다. 프로그램은 S.push(x)를 수행할 때마다 1 만큼의 비용을 저축하고, pop_leq_k에서 pop된 원소의 개수만큼 저축한 비용을 사용합니다. 즉, 저축된 금액은 스택의 크기와 동일합니다. 따라서 위에서 언급한 두 조건을 모두 만족하는 것을 알 수 있습니다. 따라서 S.push(x)를 $N$번, pop_leq_k를 $M$번 수행했을 때의 시간 복잡도는 $2N \in O(N)$으로 나타낼 수 있습니다.

2. 동적 배열의 구현

2-1. 문제 소개

동적 배열은 C++의 std::vector처럼 들어있는 원소의 개수에 따라 크기가 유동적으로 변하는 배열을 의미합니다. 이 글에서는 push_back 연산과 pop_back 연산을 지원하는 동적 배열을 구현하면서, 잠재 비용 분석(potential method) 방법을 이용해 시간 복잡도를 분석하는 방법에 대해 다룹니다.

동적 배열은 미리 공간을 여유 있게 할당한 다음, 배열에 데이터를 저장할 때마다 미리 할당받은 공간에 하나씩 저장하는 방식으로 동작합니다. 만약 할당받아 놓은 공간을 모두 사용했다면, 조금 더 큰 공간을 할당받은 다음, 기존에 저장되어 있던 데이터를 모두 새로운 공간으로 옮긴 이후에 데이터를 추가합니다. 따라서 동적 배열은 현재 저장되어 있는 데이터의 개수를 나타내는 변수인 size와 할당받은 공간의 크기를 나타내는 변수인 capacity를 관리해야 합니다.

미리 할당받은 공간에 데이터를 저장하는 것은 단순히 arr[size++] = data;와 같이 상수 시간에 처리할 수 있고, pop_back 연산도 size--;를 이용해 상수 시간에 처리할 수 있습니다. 따라서 동적 배열의 성능은 할당받은 공간을 모두 사용했을 때 추가 공간을 할당하는 정책에 따라 결정된다고 볼 수 있습니다.

2-2. 재할당 정책에 따른 성능

가장 먼저 생각나는 방법은 아마 적당한 상수 $B$을 잡은 뒤, 공간이 가득 찼을 때마다 $B$ 만큼의 공간을 추가로 할당하는 방법일 것입니다. $n$번의 삽입에서 필요한 총 연산 횟수를 계산해 봅시다. $n = kB+1$번째 삽입에서는 기존에 저장되어 있던 데이터를 복사하는 데 $O(n)$ 만큼의 연산을 수행해야 하고, $n$번째 데이터를 저장하는 데 $O(1)$ 만큼의 연산을 추가로 수행합니다. $n \not\equiv 1 \pmod B$일 때는 $O(1)$ 만큼의 연산만 수행하면 됩니다.

$n = kB+1$ 꼴이면 원소를 $n$번 삽입할 때 $B(1+2+\cdots + k) + n \in O(Bk^2 + n)$ 만큼의 연산을 수행해야 합니다. $Bk \in O(n)$이므로 $O(nk)$으로 나타낼 수 있으며, $B$는 상수이므로 $k = n/B \in \Theta(n)$이 되어서 전체 시간 복잡도는 $O(n^2)$이 됩니다. $B$의 값을 크게 잡을수록 재할당 횟수가 적어지고 $k = n/B$의 크기도 줄어들어서 조금의 성능 개선이 있을 수 있겠지만, $n$이 충분히 크다면 $B$의 값에 관계없이 $O(n^2)$이 돼서 비효율적인 것은 여전합니다.

조금 더 공부를 해본 사람이라면 $B = \sqrt n$으로 정해서 전체 시간 복잡도를 $O(n \sqrt n)$으로 만드는 방법을 생각할 수도 있을 것입니다. 하지만 총 연산 횟수는 프로그램을 실행하는 시점에 알 수 없기 때문에 $n$을 이용해 $B$를 정하는 것은 불가능합니다. 더 좋은 방법은 없는 걸까요?

2-3. array doubling

매번 공간을 2배씩 확장하면 원소를 $n$번 삽입하는 것을 총 $3n$번의 연산만으로 처리할 수 있습니다. $n=2^k+1$번의 삽입에서 필요한 연산 횟수를 계산해 봅시다. 데이터를 저장하는 연산을 $n$번 수행해야 하고, 추가로 $2^0+1, 2^1+1, \cdots, 2^{k-1}+1, 2^k+1$번째 삽입에서 각각 $2^0, 2^1, \cdots, 2^{k-1}, 2^k$번의 복사를 수행해야 합니다. $2^0 + 2^1 + \cdots + 2^k = 2^{k+1}-1$이므로 $n = 2^k+1$번의 삽입에서 $3n-3$번의 연산을 수행해야 합니다. 따라서 매번 공간을 2배씩 확장할 때 push_back 연산의 시간 복잡도는 amortized $O(1)$이라는 것을 알 수 있습니다.

이 방법은 한 가지 문제점이 있습니다. pop_back을 여러 번 수행해서 더 이상 사용하지 않는 공간이 많이 남아있을 때에도 할당받은 공간을 해제하지 않기 때문에 공간을 낭비하게 됩니다. 할당받은 공간을 모두 사용했을 때 공간을 2배로 늘렸으니 pop_back으로 인해 capacity의 절반 이하를 사용할 때 공간의 크기를 절반으로 줄이는 방법은 어떨까요? 이 방법은 sizecapacity가 같은 상황에서 push_backpop_back을 번갈아 가며 호출하면 매번 $O(n)$의 연산을 수행하게 돼서 비효율적입니다.

할당받은 공간의 1/4 이하만 사용할 때 공간의 크기를 절반으로 줄이는 정책을 사용하면 삽입과 삭제를 $n$번 수행했을 때 최대 $O(n)$번의 연산만 한다는 것을 증명할 수 있습니다. 하지만 실제로 총계 분석이나 결산 분석 방법을 이용해 직접 증명하려고 하면 쉽지 않습니다. 이를 증명하는 건 뒤에서 포텐셜 메소드를 배운 뒤에 하도록 하고, 일단 지금은 amortized $O(1)$에 동작한다고 믿고 넘어갑시다.

2-4. 구현

(링크)

3. 포텐셜 메소드

3-1. 개요

포텐셜 메소드는 결산 분석과 비슷하게 미리 지불한 비용을 향후 연산에 사용하는 방식입니다. 하지만 결산 분석은 각 연산마다 선불 비용을 계산했다면, 포텐셜 메소드는 자료구조 전체 상태에 선불 비용을 달아놓는다는 점에서 차이가 있습니다.

자료구조의 초기 상태를 $D_0$, $D_{i-1}$에 $i$번째 연산을 적용한 자료구조의 상태를 $D_i$라고 합시다. 포텐셜 함수 $\Phi(D_i)$는 자료구조의 상태 $D_i$를 음이 아닌 실수로 보내는 함수로, 자료구조가 얼마나 많이 망가져 있는지를 나타냅니다. 이때 $i$번째 연산의 분할 상환 비용은 $\hat{c_i} = c_i + \Phi(D_i) - \Phi(D_{i-1})$, 즉 $i$번째 연산의 실제 비용과 $i$번째 연산으로 인해 자료구조가 지저분해진 정도를 더한 값으로 정의합니다. 다시 말해, $\Phi(D_i) - \Phi(D_{i-1}) > 0$이면 자료구조를 더럽히는 대신 미래를 위해 약간의 비용을 저축하는 것이고, $\Phi(D_i) - \Phi(D_{i-1}) < 0$이면 지금까지 저축한 것을 사용해 자료구조를 이상적인 형태로 보내는 것을 의미합니다.

$n$번의 연산을 모두 수행했을 때 분할 상환 비용의 합은 $\sum_{i=1}^{n} \hat{c}_i = \sum_{i=1}^{n} (c_i + \Phi(D_i) - \Phi(D_{i-1}))$이고, 식을 정리하면 $\sum_{i=1}^{n} \hat{c}_i = \sum_{i=1}^{n} c_i + \Phi(D_n) - \Phi(D_0)$이 됩니다. 결산 분석에서와 같이, $\Phi(D_0) = 0$으로 지정한 다음, 모든 $i$에 대해 $\Phi(D_i) \geq \Phi(D_0)$을 만족하도록 함수를 정의하면 분할 상환 비용의 합은 실제 비용의 상한이 됩니다.

3-2. BOJ 17298 오큰수

$\Phi(D)$를 스택에 있는 원소의 개수로 정의합시다. 처음에 $\Phi(D_0) = 0$이고, 원소의 개수가 음수가 될 수 없으므로 항상 $\Phi(D_i) \geq \Phi(D_0)$을 만족함을 알 수 있습니다. 스택에 $s$개의 원소가 있는 상황에서 S.push(x) 연산의 분할 상환 비용은 $\hat{c}_i = c_i + \Phi(D_i) - \Phi(D_{i-1}) = 1 + (s + 1) - s = 2$입니다. $s$개의 원소가 있는 상황에서 pop_leq_k 연산을 통해 $x$개의 원소를 삭제한 상황을 생각해 봅시다. 이때의 분할 상환 비용은 $\hat{c}_i = c_i + \Phi(D_i) - \Phi(D_{i-1}) = x + (s - x) - s = 0$입니다. 따라서 pop_leq_k 연산의 분할 상환 비용은 삭제하는 원소의 개수에 관계없이 항상 0입니다.

두 연산의 분할 상환 비용은 $O(1)$이기 때문에 연산을 $n$번 수행했을 때의 분할 상환 비용의 총합은 $O(n)$이고, $\Phi(D_i) \geq \Phi(D_0)$이기 때문에 분할 상환 비용의 총합은 실제 비용의 상한을 나타냅니다. 따라서 실제 연산 횟수는 $O(n)$을 넘지 않음을 알 수 있습니다.

3-3. push_back만 수행하는 동적 배열

push_backpop_back을 모두 지원하는 동적 배열의 시간 복잡도를 분석하기 전에, 조금 더 간단한 케이스인 push_back만 지원하는 동적 배열의 시간 복잡도를 분석해 보겠습니다.

포텐셜 함수는 $\Phi(D_i) = 2 \times s(D_i) - c(D_i)$으로 정의하겠습니다. $s$와 $c$는 각각 sizecapacity를 의미합니다. 즉, 재할당이 일어난 직후가 가장 이상적인 형태이며, 재할당한 지 오래 지났을수록 자료구조가 망가진 상태로 정의한 것입니다. 동적 배열의 알고리즘을 생각해 보면 항상 $sz \geq cap/2$를 만족하기 때문에 $\Phi(D_i) \geq 0$임을 알 수 있습니다. 따라서 분할 상환 비용의 합은 실제 비용의 합을 나타냅니다.

push_back 연산은 공간의 크기가 확장되지 않는 경우와 확장되는 경우로 나눌 수 있습니다. 두 가지 경우에 대해 각각 분할 상환 비용을 계산해 봅시다.

크기가 확장되지 않는 경우에는 $c(D_i) = c(D_{i-1})$이고, $s(D_i) = s(D_{i-1}) + 1$입니다. 따라서 $\hat{c_i} = c_i + \Phi(D_i) - \Phi(D_{i-1})$ $= 1 + (2s(D_{i-1}) + 2 - c(D_{i-1})) - (2s(D_{i-1}) - c(D_{i-1})) = 3$입니다.

크기가 확장되는 경우에는 $c(D_i) = 2c(D_{i-1})$이고, $s(D_i) = s(D_{i-1}) + 1$입니다. 따라서 $\hat{c}_i = c_i + \Phi(D_i) - \Phi(D_{i-1})$ $= (1+c(D_{i-1})) + (2s(D_{i-1})+2-2c(D_{i-1})) - (2s(D_{i-1}) - c(D_{i-1})) = 3$입니다. $c_i = 1 + c(D_{i-1})$인 것을 주의해야 합니다.

두 경우 모두 분할 상환 비용이 $O(1)$이므로 연산을 $n$번 수행했을 때의 분할 상환 비용의 총합은 $O(n)$입니다.

4. 동적 배열의 시간 복잡도 분석

4-1. 포텐셜 함수 정의

포텐셜 함수를 다음과 같이 정의합시다. 마찬가지로 재할당이 발생한 직후인 $s(D_i) = c(D_i) / 2$ 일 때가 가장 이상적인 형태이며, 재할당한 지 오래 지났을수록 자료구조가 망가진 상태로 정의했습니다.

\[\Phi(D_i) = \begin{cases} 2s(D_i) - c(D_i) & \text{if } 2s(D_i) \geq c(D_i) & \cdots \enclose{circle}{1} \\ c(D_i)/2 - s(D_i) & \text{if } 2s(D_i) < c(D_i) & \cdots \enclose{circle}{2} \end{cases}\]

빈 배열은 $s(D_i) = c(D_i) = 0$이기 때문에 $\Phi(D_0) = 0$이고, 항상 $\Phi(D_i) \geq 0$을 만족합니다.

4-2. push_back의 분할 상환 비용

push_back 연산으로 인해 ①에 해당하던 자료구조가 ②로 바뀌진 않습니다. 따라서 ① → ①, ② → ①, ② → ② 세 가지만 생각하면 됩니다.

① → ① 은 재할당 여부와 관계없이 분할 상환 비용이 3이라는 것을 위에서 증명했습니다. ② → ②는 재할당이 발생하지 않으므로 간단하게 계산할 수 있습니다. $\hat{c}_i = c_i + \Phi(D_i) - \Phi(D_{i-1})$ $= 1 + (c(D_{i-1})/2 - s(D_{i-1})-1) - (c(D_{i-1})/2 - s(D_{i-1})) = 0$입니다. ② → ① 일 때도 재할당은 발생하지 않습니다. 분할 상환 비용의 계산은 다음과 같습니다.

\[\hat{c}_i = c_i + \Phi(D_i) - \Phi(D\_{i-1}) \\= 1 + (2s(D\_{i-1})+2-c(D\_{i-1})) - (c(D\_{i-1})/2 - s(D\_{i-1})) \\= 3 + 3s(D\_{i-1}) - 3c(D\_{i-1})/2\]

이때 $2s(D_{i-1}) < c(D_{i-1})$이므로 $3s(D_{i-1}) - 3c(D_{i-1})/2 < 0$이 돼서 $\hat{c}_i < 3$입니다. 따라서 push_back 연산의 분할 상환 비용은 항상 3 이하입니다.

4-3. pop_back의 분할 상환 비용

pop_back 연산은 ①인 상태에서 재할당이 발생하지 않으며, 이 연산으로 인해 ②에 해당하던 자료구조가 ①로 바뀌진 않습니다. 따라서 ① → ①, ① → ②, ② → ② 세 가지만 생각하면 됩니다.

① → ① 의 분할 상환 비용은 $\hat{c}_i = c_i + \Phi(D_i) - \Phi(D_{i-1})$ $= 1 + (2s(D_{i-1})-2 - c(D_{i-1})) - (2s(D_{i-1}) - c(D_{i-1})) = -1$입니다. ① → ② 의 분할 상환 비용은 다음과 같습니다.

\[\hat{c}_i = c_i + \Phi(D_i) - \Phi(D\_{i-1}) \\ = 1 + (c(D\_{i-1})/2 - s(D\_{i-1}) + 1) - (2s(D\_{i-1}) - c(D\_{i-1})) \\ = 2 + 3c(D\_{i-1})/2 - 3s(D\_{i-1})\]

이때 $2s(D_{i-1}) \geq c(D_{i-1})$ 이므로 $3c(D_{i-1})/2 - 3s(D_{i-1}) \leq 0$이 돼서 $\hat{c}_i \leq 2$입니다.

② → ② 는 재할당이 발생하지 않는 경우와 발생하는 경우로 나눠서 계산해야 합니다. 재할당이 발생하지 않으면 $\hat{c}_i = c_i + \Phi(D_i) - \Phi(D_{i-1})$ $= 1 + (c(D_{i-1})/2-s(D_{i-1})+1) - (c(D_{i-1})/2-s(D_{i-1})) = 2$입니다. 재할당이 발생하면 $\hat{c}_i = c_i + \Phi(D_i) - \Phi(D_{i-1})$ $= (1+c(D_{i-1})/4) + (c(D_{i-1})/4 - s(D_{i-1}) + 1) - (c(D_{i-1})/2 - s(D_{i-1}))$ $= 2$입니다.

따라서 pop_back 연산의 분할 상환 비용은 2 이하입니다.

push_back 연산의 분할 상환 비용과 pop_back 연산의 분할 상환 비용이 모두 $O(1)$이므로 연산을 $n$번했을 때의 분할 상환 비용의 총합은 $O(n)$입니다. 포텐션 함수는 항상 $\Phi(D_i) \geq 0$을 만족하기 때문에 분할 상환 비용의 합은 실제 비용의 합의 상한을 나타냅니다. 따라서 동적 배열의 push_back 연산과 pop_back 연산 모두 amortized $O(1)$ 시간에 동작함을 알 수 있습니다.

더 공부할 거리

과제

]]>
JusticeHui
Good Bye 2023!2023-12-29T00:00:00+00:002023-12-29T00:00:00+00:00https://justicehui.github.io/review/2023/12/29/good-bye-2023서론

컴퓨터를 공부하고 있는 평범한 대학교 3학년 학생이 살아가는 이야기입니다. 흘러가는 대로 살다 보니 벌써 대학교에 입학한 지 3년이나 지났습니다. 고등학생 때나 지금이나 별반 다르지 않은데… 고등학교 졸업보다 대학교 졸업이 더 가깝다는 게 아직은 실감이 나지 않습니다.
올해도 지난 몇 년과 비슷하게 PS 이야기가 대부분을 차지합니다. PS밖에 안 한 걸 보면 발전이 없는 사람처럼 보이지만, 작년에 비해 훨씬 더 다양한 활동을 했으니 조금은 성장했다고 생각합니다.

목차

PC 화면으로 보시는 분들은 오른쪽 사이드 바를 이용해 원하는 문단으로 넘어갈 수 있습니다.

  • 올해 목표 돌아보기
  • 인턴 지원
    • 구글 코리아, SWE 인턴 (23 6~9월)
    • 네이버, ETECH Yorkie 체험형 인턴 (24 1~2월)
    • Jane Street, SWE 인턴 (24 여름)
  • 학교생활 - 강의
    • 1학기 과목
    • 2학기 과목
  • 학교생활 - SCCC
    • 1~5월 - 2023 SCON 개최
    • 3~6월 - 1학기 신입 부원 스터디
    • 6~8월 - 여름방학 기초 알고리즘 강의
    • 9~11월 - 동아리 내전
    • 9~11월 - 2학기 스터디
    • 3~12월 - 동아리 방 활성화
  • Problem Solving
    • 대회 운영
    • 대회 참가
    • 알고리즘 강의
  • 마무리

올해 목표 돌아보기

  • BOJ 10000문제 (성공)
    • 하루에 5문제씩 1년 동안 풀면 8500 → 10000 정도는 쉽게 달성할 수 있습니다.
  • Codeforces 2400 (실패)
    • 코드포스를 안 하는데 레이팅이 오를 리가 없지…
  • SCPC 수상 (실패)
    • 내년에는 받을 수 있을까요? 라는 말을 3년째 하고 있습니다.
    • ICPC, UCPC, 모비스는 상 받았는데 왜 이건 매년 못 받는지 모르겠습니다.
  • ICPC 대학 2등 (실패)
    • ICPC 예선에서 패널티 3분 차이로 KAIST를 이기지 못했습니다. 아쉽다…
  • 인턴 (성공)
    • 2024년 1월 2일부터 2월 23일까지 네이버에서 인턴을 하게 되었습니다!

매년 다양한 목표를 세우지만, 항상 백준 랭작 말고는 달성하는 게 없는 것 같습니다. ‘BOJ 랭작 멈추기’ 같은 목표를 세우면 어떻게 될까요?

인턴 지원

인터넷을 보면 PS만 한 사람은 취업을 못 한다는 이야기가 많이 보입니다. 이런 글을 볼 때마다 조금씩 불안한 마음이 들어서 슬슬 PS와 거리를 두고 먹고 살길을 찾아야 한다고 생각하고 있었습니다.

저는 초등학생 때부터 10년 동안 코드를 짰으며 특성화고 소프트웨어과를 졸업하고 대학교에서도 컴퓨터를 전공하고 있지만, 부끄럽게도 무언가를 만들어내는 “개발”이라는 것을 거의 해본 적이 없고 내세울 만한 프로젝트도 없습니다. 이런 고민 탓에 작년부터 주변 친구들과 선배들에게 조언을 여러 번 구했었습니다.

  • 연합 동아리, 부트캠프, 소프트웨어 마에스트로보다는 인턴 하면서 배우는 게 낫다.
  • 너는 프로젝트가 없어도 실력을 보여줄 수 있는 것이 많으니 괜찮다.
  • 기본적인 것들만 잘 갖춰져 있으면 상관없다.

와 같은 조언을 들었고, 마침 작년 말에 ICPC에서도 큰 성과를 거둔 터라 자신감을 갖고 인턴에 지원했습니다. 사실 자신감이라기보다는 그냥 ICPC 하나만 믿고 도박을 한 것에 가깝습니다. 마치 KOI와 블로그만 믿고 카이스트부터 국민대 숭실대까지 다양한 대학에 수시 원서 8개 넣은 고3 때처럼…

여기에 다 쓰자니 내용이 너무 길어질 것 같아서 간단하게만 적고, 자세한 내용은 결과가 다 나온 이후에 별도의 글을 작성한 다음 이 문단은 지울 계획입니다.

구글 코리아, SWE 인턴 (23 6~9월)

작년에 친구가 올린 글(링크)를 보고 저도 구글 가고 싶어서 지원했습니다. 사실 작년에 2023 목표를 세울 때 인턴을 넣은 것은 구글 인턴을 염두에 두고 작성한 것이었습니다. 원래도 구글은 프로세스가 천천히 진행된다는 것은 알고 있었지만, 제가 직접 겪는 것은 또 다른 문제였습니다. “까먹을 때쯤 연락이 온다”는 표현이 가장 적절한 것 같습니다. 지원한 지 1년 가까이 돼서 자세한 건 기억이 나지 않습니다. 내년에 또 지원할 때 참고하기 위해 날짜 위주로 기록합니다.

1월 12~13일에 채용 공고가 올라왔습니다. 한 가지 문제라면 공고가 올라온 다음 날 구글 12000명 layoff 발표가 나와서… 1월 20일에 공고가 내려갔다가 2월 7일에 다시 공개되는 등 굉장히 정신없었던 것으로 기억합니다.
채용 공고가 공개되자마자 하루 만에 다시 resume와 cover letter등을 작성해서 제출했고, 2월 28일에 코딩 테스트를 보라는 연락을 받았습니다. 코딩 테스트는 2문제가 나왔고, 두 문제를 모두 해결하는데 10분 정도 걸렸습니다. 이후 1달 정도 기다리니 3월 27일에 선호하는 언어와 프로그래밍 언어, 그리고 면접 가능한 시간을 알려달라는 연락이 왔습니다. 서류는 어떻게 합격했으니 이제 면접 준비를 열심히 하면 되겠다고 생각했지만…
5월 18일에 이후 프로세스를 진행하지 않겠다는 연락을 받았습니다. 올해 인턴을 한 사람이 있다는 이야기가 있는 것을 보면 인턴을 아예 모집하지 않은 건 아닌 것 같던데, 그냥 제가 우선순위가 낮았던 모양입니다. 서류 합격했다고 말해야 할지 떨어졌다고 말해야 할지 잘 모르겠습니다. 아무튼 구글 인턴은 탈락했지만, 제 경력(?)이 매력이 없진 않다는 것을 깨닫고 자신감을 얻었습니다.

네이버, ETECH Yorkie 체험형 인턴 (24 1~2월)

네이버는 채용을 안 한다는 소문이 돌길래 카카오 겨울 인턴을 알아봤는데, 카카오는 채용 연계형으로 4학년만 뽑는다고 해서 포기하고 겨울방학에 집에서 뭐 하고 놀지 고민하고 있었습니다. 아무 생각 없이 집에서 누워서 핸드폰을 보는데 우연히 네이버에서 인턴을 모집한다는 소식을 듣고 3일 만에 자소서를 휘갈겨서 제출했습니다.

앞에서도 언급했지만, 인터넷을 보면 PS만 한 사람은 취업을 못 한다는 이야기가 많이 보이고, 보통 이런 글에는 자소서/이력서에 백준 등수 적지 말라는 이야기도 함께 붙곤 합니다. 하지만 저는 당당하게 자소서 문항 3개를 전부 PS로 도배하는 것으로 모자라서, 아예 첫 번째 줄에 BOJ 등수를 적었는데 서류를 붙었습니다. 인터넷에 있는 글은 진지하게 받아들일 필요가 없다는 것을 깨달았습니다. 당연히 이 글도 인터넷에 있는 글이니 진지하게 받아들이진 마세요. 물론 문제 푸는 얘기만 3000글자씩 적은 건 아니고, PS 하면서 상 받은 이야기, PS 하면서 사람 가르친 이야기, PS 하면서 전공과목 날로 먹은 이야기 등등 다양하게 적긴 했습니다.

아무튼 서류는 붙었고, 이제 코딩 테스트를 볼 차례입니다. 3문제 출제되었고 다 푸는데 10~15분 정도 걸렸습니다. 제출 버튼 누르면 채점 결과도 알려주지 않는데 더 이상 코드 수정을 못 하는 게 인상적이었습니다. 코딩 테스트에서 떨어질 일은 없으니 코딩 테스트가 끝나자마자 면접 준비를 시작했습니다. 채용 공고를 보면 자격 요건이 “자료구조, 알고리즘, 프로그래밍언어, OS를 공부한 사람”으로 되어 있습니다. 앞에 있는 2개는 걱정이 없었고 프로그래밍언어론도 크게 걱정되진 않았지만, OS는 조금 걱정이 됐습니다. 동시 작업 편집기를 구현하는 팀인 만큼 동시성 관련된 내용을 물어볼 것 같아서 뮤텍스와 세마포어를 복습했습니다. 또한, Yorkie 팀에서 뭐 하는지 찾아봤더니 splay tree랑 rope 같은 거 사용하는 것 같길래 BBST를 복습하고, 남는 시간에는 “가상 면접 사례로 배우는 대규모 시스템 설계 기초” 책을 조금씩 읽었습니다.

면접은 30분 정도 진행됐는데, 면접 준비하면서 공부한 것은 아무 쓸모가 없었습니다. 자세하게 말할 수는 없지만, 숭실대 특기자 면접보다도 더 편안한 분위기에서 제가 좋아하는 PS와 ICPC 얘기만 잔뜩 하다가 나왔습니다. 상상 속에 있던 회사 면접과는 전혀 다르게, 이게 면접이 맞나 싶을 정도로 신기하게 진행되었습니다. 동아리나 기업에서 사람들 대상으로 강의했던 경험이 큰 도움이 되었습니다. 떨어질 확률보다 붙을 확률이 조금 더 높다고 생각했고, 면접 일주일 정도 뒤에 합격 통보를 받았습니다.

Jane Street, SWE 인턴 (24 여름)

10월에 man_of_learning님의 소개로 Jane Street ETC에 참가했었는데, 대회가 종료된 후에 여름 인턴십을 모집한다는 메일을 받아서 지원하게 되었습니다. 트레이더로 지원하는 건 어림도 없을 것 같고, 그나마 가능성이 있어 보이는 SWE로 지원했습니다. resume는 구글에 냈던 것에 올해 수상 실적을 추가해서 냈고, 3일 뒤에 zoom으로 간단하게 10분 정도 통화하자는 연락을 받았습니다.

영어로 대화하기도 어렵고 낯선 사람과 대화하기도 어려운데 낯선 사람과 영어로 대화하는 것은 얼마나 어려울까요? 일단 저는 많이 어려웠습니다. 학교와 졸업 예정 시기, 그리고 지원 계기와 알게 된 경로 등 간단한 정보를 물어보고 이후 프로세스 일정을 알려주는 간단한 통화였음에도 불구하고, 낯선 사람과 영어로 대화하는 것은 처음이었기에 많이 어려웠습니다. 영어 공부의 필요성을 22년 만에 처음으로 느꼈습니다.

전화가 끝나자마자 첫 번째 인터뷰 일정을 잡자는 메일을 받았고, 바로 1주 뒤에 첫 번째 인터뷰를 진행했습니다. 인터뷰는 1시간 동안 면접관과 1:1로 진행되었습니다. 모든 인터뷰는 한 문제를 여러 단계를 거쳐서 점점 발전시켜 나가는 방식으로 진행됐습니다. 1차 인터뷰는 2개의 단계가 있었는데, 첫 번째 단계는 완벽하게 풀었고 두 번째 단계는 정답에 근접한 답을 냈지만 면접관이 원하는 답과 정확히 일치하지는 않았습니다. 초반에 영어로 대화하는 것이 서툴러서 시간이 지연된 것이 많이 아쉬웠습니다. 붙을 확률보다 떨어질 확률이 조금 더 높다고 생각하고 zoom 회의실에서 나왔습니다.

1주 뒤에 연락이 왔는데, 제 예상과 다르게 긍정적인 평가를 받아서 파이널 인터뷰 기회를 얻게 되었습니다. 파이널 인터뷰는 하루 동안 1시간 정도의 인터뷰를 2~3번 진행하며, 각 라운드를 면접관 2명과 함께 진행합니다. 문제 형식은 동일합니다.

첫 번째 라운드는 오전 11시부터 12시 5분까지 진행됐습니다. 3단계로 구성되어 있었고, 3단계 모두 뇌 비우고 열심히 구현하는 문제라서 어렵지 않게 했습니다. 그게 인터뷰어 마음에 들었을지는 모르겠지만… 마지막에 시간이 조금 남아서 면접관과 잡담할 시간이 있었는데, 하고 싶은 말도 영어로 내뱉지 못하고, 어떻게 잘 영작해서 말하더라도 돌아오는 답변을 다시 해석하는 것이 어려웠습니다. 그래도 영어 공부를 하지 않은 중고등학생 시절의 제가 원망스럽지는 않습니다. 그때 영어 공부했으면 지금 인터뷰를 못 하고 있었을 것이기에…
두 번째 라운드는 12시 10분부터 13시 10분까지 진행됐습니다. 3단계로 구성되어 있었고, 2번째 단계까지는 열심히 구현하는 문제, 마지막 단계는 머리를 조금 써야 하는 문제였습니다. 구현은 잘했고 마지막 단계에서 올바른 아이디어도 냈는데, 인터뷰어의 도움 없이 한 번에 정답에 도달하지 못한 것이 조금 마음에 걸렸습니다. 두 번째 라운드도 잡담할 시간이 조금 있었고, 1시간 만에 영어 실력이 늘진 않기 때문에 이때도 그냥 슬퍼하고 있었습니다.
40분 정도의 점심시간을 가진 후에 13시 50분에 세 번째 라운드를 진행하려고 다시 zoom에 들어갔는데, 인터뷰어가 아닌 리크루터가 들어와서 논의 결과 세 번째 라운드를 진행하지 않는 것으로 결정했다는 이야기를 했습니다. 그리고 파이널 인터뷰도 이렇게 끝이 났습니다.

두 라운드 모두 첫 번째 인터뷰보다는 괜찮았다고 생각하는데, 세 번째 라운드가 취소된 것이 어떤 의미인지 모르겠어서 어떻게 될지 감이 잡히지 않습니다. 문제를 못 풀어서 떨어지진 않을 것 같고, 만약 떨어진다면 영어 때문일 것 같습니다. 이제는 진짜 영어 공부를 해야 할 때가 온 것 같습니다. 붙으면 반년 뒤에 출국해야 하므로 영어 공부를 해야 하고, 떨어지면 영어 때문에 떨어진 것이니 영어 공부를 해야 합니다. 붙든 떨어지든 영어 공부는 해야 합니다. 1차 인터뷰와는 평가 기준이 다를 것 같기 때문에 떨어질 확률이 붙을 확률의 2배 정도 된다고 생각하고 있습니다.

밖에서 노트북으로 문제 풀 때 배터리를 아끼기 위해서 IDE 대신 메모장(notepad.exe)에서 코드를 작성하는데, 이 경험은 자동 완성 안 되는 색깔 있는 메모장에서 코드 실행 없이 눈으로만 검사하는 인터뷰에서 이 경험이 아주 큰 도움이 되었습니다.

결과는 다음 주 정도에 나온다고 하는데 과연…

학교생활 - 강의

전공과목들은 작년(2학년)에 비해 전체적으로 재미없었습니다. 형식언어와 오토마타, 문제해결 같은 과목 듣다가 파일처리랑 웹프로그래밍을 들으면 누구나 그렇게 생각하지 않을까요? 일단 저는 그랬습니다. 강의 듣는 게 너무 재미없어서 강의 시간에는 BOJ 랭작을 열심히 했었고, 대신 강의 자료 다운로드받아서 집에서 혼자 공부했습니다. 혼자 공부하면 재미있던데 왜 강의실에만 앉아있으면 흥미가 뚝 떨어지는지 모르겠습니다. 중요한 과목에서는 나름 좋은 성적을 받았으니 괜찮은 것 같습니다.

1학기 과목

전공과목은 프로그래밍언어, 네트워크프로그래밍, 파일처리, 오픈소스기반고급설계를 수강했고, 수학과 전공과목인 조합론도 함께 들었습니다.

조합론은 PS 공부하려고 신청한 과목이었습니다. 정수론과 조합론은 PS 하면서 영재고 → 서울대/카이스트 루트를 탄 학생들에 비해 가장 크게 뒤쳐진다고 느낀 분야였습니다. 작년 2학기에 정수론, 올해 1학기에 조합론을 수강한 것은 모두 그 격차를 조금이라도 줄여보기 위함이었습니다. xMO에 나올 법한 신박한 카운팅이나 AtCoder에 자주 등장하는 생성함수 같은 것을 배우고 싶어서 조합론을 신청했지만…
교수님께서 수학과 4학년이면 취업 생각을 해야 한다면서 갑자기 중간고사 이후부터 머신러닝을 가르쳐주셨습니다. 기대했던 내용을 배우지는 못했지만, 중간고사는 중학생 때 KOI 지역 예선 공부하면서 봤던 내용, 기말고사는 중학생 때 혼자 머신러닝 공부하던 내용으로 모두 커버돼서 성적은 잘 나온 과목입니다.
프로그래밍언어는 1학기에 배운 과목 중 가장 재미있는 과목이었습니다. 지난 학기에 오토마타 과목에서 배운 Context Free Grammar를 이용해 C언어 문법을 표현하는 방법에 대해 한 학기 동안 배웠고, 그 과정에서 교수님의 지도 덕분에 재귀적으로 정의된 개념을 엄밀하면서도 쉽게 설명하는 방법에 대해 배웠습니다. 학생들은 별로 안 좋아하는 것 같았지만, 사람들을 가르치는 일을 하는 저에게는 큰 도움이 되었습니다. 시험공부는 별로 안 했지만, 강의를 다른 과목에 비해 재미있게 들은 덕분인지 꽤 좋은 성적을 받았습니다.
네트워크프로그래밍은 별로 기대하지 않고 신청했는데 생각했던 것보다 재미있었고 과제도 유익했습니다. 강의 들을 때는 대충 당연한 이야기를 하는 것 같아서 잘 듣지 않았었는데, 네이버 인턴 면접 준비하면서 열심히 듣지 않은 것을 많이 후회했습니다. 이 글을 읽고 있는 숭실대학교 컴퓨터학부 1~2학년 학생이 있다면 3학년 때 꼭 열심히 들으시길 바랍니다.

다른 두 과목은 별로 할 이야기가 없습니다.

2학기 과목

전공과목은 운영체제, 컴퓨터비전, 인공지능, 컴파일러, 웹프로그래밍설계및실습을 수강했고, 수학과 전공과목인 집합론도 함께 들었습니다.

운영체제는 졸업 해야 돼서 들었습니다. 과제 4개 중에 2개 초과로 fail 하면 F 준다고 해서 과제 열심히 했더니 A+을 받았습니다(?). 알고 보니 과제 4개를 온전히 구현한 학생이 저 포함해서 2명밖에 없었습니다. 교과서는 OSTEP(링크), 과제는 xv6(링크)를 사용했습니다. 과목에서 배운 내용 중에 인상 깊었던 건 별로 없었는데, 대신 교과서를 읽으면서 많이 감탄했었습니다. 운영체제의 대가가 집필한 교과서답게 책이 정말 잘 쓰여 있어서, 강의 시간 내내 BOJ 랭작만 한 사람도 한 번 읽으면 모두 이해가 되도록 작성되어 있었습니다. 또한, 개념과 개념 사이의 연결도 매끄럽게 작성되어 있었습니다. 덕분에 시험 이틀 전에 시험공부는 안 하고, 대신 알고리즘 책을 작성하고 싶다는 오만한 꿈을 꾸던 저를 되돌아보고 있었습니다.
컴퓨터비전은 2학기에 들은 과목 중 가장 재미있는 과목이었습니다. 대부분의 개념이 PS, 특히 계산 기하를 공부하면서 한 번 이상 봤던 컨셉/개념이었고, 심지어 이 과목에서 배운 것들만 사용해서 풀 수 있는 문제들로 문제집(링크)도 만들 수 있을 정도였습니다. 같이 들은 학생 중에 동의하는 사람이 얼마나 있을지는 모르겠지만… 아무튼 재미있었고 성적도 잘 나왔습니다.
인공지능은 컴퓨터학부 전공과목으로 3-2 인공지능, 4-1 머신러닝, 4-2 딥러닝이 있길래 맨 앞에 있는 인공지능은 날먹 과목일 것 같아서 수강했습니다. 중학생 때 혼자서 공부한 내용, 1학기에 조합론 강의에서 배운 내용, 2학기에 컴퓨터비전 강의에서 배운 내용으로 모두 커버할 수 있어서 편했습니다.
컴파일러는 매우 유익했지만 제가 많이 부족한 과목이었습니다. 과제는 총 6개 나왔고 그중 4~6번째 과제가 각각 syntax analyzer, semantic analyzer, C compiler 제작이었는데, 4번째 과제 마감과 ICPC Seoul Regional Contest가 겹치는 바람에 4번째 과제와 그 이후 과제를 하나도 하지 못했습니다. 시간 날 때 다시 공부할 계획입니다.

다른 두 과목은 별로 할 이야기가 없습니다.

학교생활 - SCCC

올해 1월부터 회장 인수인계를 시작해서 1학기 시작할 때쯤 chansol(링크)에게 회장을 넘기고 저는 부회장과 총무를 겸하게 되었습니다. 하지만 2학기 때 모종의 사유로 제가 다시 회장을 넘겨받게 되었고, 총무를 넘겨받을 후임자도 찾지 못해서 회장 업무와 총무 업무를 모두 혼자서 처리하는 중노동에 시달리게 되었습니다.

학교가 대면으로 완전히 전환됨에 따라 SCCC도 작년에 비해 많은 행사를 했습니다. 1학기는 SCON 개최, 여름방학은 기초 알고리즘 강의, 2학기는 동아리 내전에 가장 많은 시간과 노력을 투자했습니다. 이 밖에도 스터디를 대면으로 전환하고 새로운 형태의 스터디를 진행한 것, 동아리 방을 활성화한 것도 큰 성과입니다. 작년에는 저 혼자 모든 스터디를 진행했는데 올해는 의도적으로라도 다른 분들에게 강의를 나눠준 것도 개인적으로는 큰 변화라고 생각합니다.

개인적으로 갖고 있는 성공적인 스터디의 기준은 “한 시즌의 끝까지 따라오는 학생이 1명 이상 존재하는 것”입니다. 올해는 총 3번의 스터디를 진행했는데, 매번 끝까지 따라오는 것을 넘어서, 혼자서 더 많이 공부하고 질문하는 등 아주 열심히 하는 학생을 1명 이상 발견해서 올해 스터디는 매우 성공적이라고 생각합니다.

1~5월 - 2023 SCON 개최

3시간의 대회를 위해 4달을 준비했습니다. 대회 개최 후기는 (여기)에 이미 잘 적어놓았으므로 더 이야기하진 않겠습니다. 제가 올해 블로그에 올린 글 중에서 가장 많은 공을 들인 글이니 한 번씩 읽어주시면 감사하겠습니다.
이제 슬슬 내년 대회도 준비해야 하는데… 일단 Good Bye BOJ 2023! & Hello BOJ 2024! 부터 잘 마무리 짓고 생각하겠습니다.

3~6월 - 1학기 신입 부원 스터디

작년 1학기 때 신입 부원을 선발하고 기초 알고리즘 강의를 진행했을 때, 강의를 제대로 들은 부원이 3명 정도밖에 되지 않았었습니다. 앞에서 설명만 하고 끝내는 방식의 강의는 너무 비효율적인 것 같아서, 1학기 신입 부원 스터디는 강사 1명이 학생 3~5명을 담당하는 소규모 그룹 스터디로 진행했습니다. 구체적으로, 1학년 신입생들을 가르치는 C언어 문법 그룹 4개, 2학년 이상 학생들이 solved.ac CLASS 3~5를 딸 수 있도록 도와주는 클래스 그룹 4개를 만들어서 진행했습니다.

부원들의 참여도나 개인적으로 오는 질문의 빈도를 봤을 때, 작년에 비해 훨씬 성공적이었다고 생각합니다. C언어 그룹 참가 학생 중에 반년 넘게 solvedac 스트릭을 잇는 학생도 있고, 클래스 그룹 참가 학생 중에 꾸준히 질문하면서 어려운 알고리즘도 공부하는 학생도 몇 명 있는 것을 보면 그룹 스터디를 진행하길 참 잘한 것 같습니다.

2학기에도 비슷하게 진행하고 싶었지만, 강사를 8명씩 구하는 것이 너무 어려워서 2학기에는 다시 단체 강의로 돌아갔습니다. 2학기는 ICPC가 있어서 강사들에게 시간을 내어달라고 부탁하기가 어려운 것도 영향이 있었습니다. 아마 내년 1학기에는 다시 그룹 방식으로 돌아갈 것 같습니다.

6~8월 - 여름방학 기초 알고리즘 강의

여름방학에 6주 동안 매주 2번씩 오후 2시부터 6시까지, 총 48시간 동안 기초 알고리즘 강의(링크)를 진행했습니다. 코딩테스트에 나오는 모든 범위, 그리고 코드포스 오렌지를 찍는 데 필요한 모든 개념을 전부 커버하는 것이 목표였습니다. 강의를 준비할 때 제가 중점적으로 고려한 것은 아래 2가지입니다. 시중에 돌아다니는 알고리즘 강의 자료가 모두 마음에 들지 않아서 제가 원하는 구성으로 처음부터 끝까지 직접 강의 자료를 제작했습니다.

  • 개념의 직관적인 이해, 증명, 문제 풀이를 모두 다룸
    • 학부 알고리즘 강의는 문제 풀이를 거의 다루지 않음
    • 대부분의 코딩 테스트/알고리즘 대회 강의는 문제 풀이를 제외한 부분을 제대로 다루지 않음
  • 비트 연산, C++ 표준 라이브러리의 내부 구현, 상수 커팅 등 경험하지 않으면 알기 어려운 내용도 다룸
    • 너네는 제발 이런 걸로 삽질하지 말아라…
    • 이런 거 안 가르쳐 주면 BOJ 랭커한테 배우는 이유가 있나?

한 번에 4시간씩 강의를 했지만 실제로 설명하는 시간은 1.5 ~ 2시간 정도였고, 남은 시간은 강의실을 돌아다니면서 코드에 훈수 두고 질문을 받았습니다. 단순히 앞에서 개념과 문제 풀이만 설명하고 끝내는 강의가 별로 좋지 않다는 것은 작년에 깨달았기 때문에, 강의실에 앉아있는 시간만이라도 제발 문제를 본인의 힘으로 직접 풀었으면 해서 이렇게 진행했습니다. BOJ 그룹 연습 스코어보드가 이쁘게 나온 것을 보면 나름대로 효과가 있었던 것 같습니다.

하지만 너무 많은 연습 문제에 부담을 느껴서 중도 하차를 하거나, 한 번 결석한 이후로 진도를 따라가지 못한 학생이 여러 명 있었습니다. 다음에 또 이런 형태의 강의를 진행하게 된다면 필수 문제와 심화 문제로 나누고, 강의 시작할 때마다 지난 시간 내용을 리뷰하는 등의 변화를 주는 게 좋을 것 같습니다.

6~8월 - 문제 출제 강의

2학기에 동아리 내전을 열기 위해 야심 차게 준비한 스터디(링크)였지만… 제가 계절학교와 대회 운영 등으로 스터디에 시간을 쓰지 못해서 실제 문제 출제까지는 이어지지 못했습니다. 지금까지 제가 출제했던 문제들을 활용해서 문제 지문, generator, validator, checker를 올바르게 작성하는 방법에 대해 가르치는 스터디였습니다. 참가자들의 열정을 제가 따라가지 못해서 죄송할 따름입니다.

9~11월 - 동아리 내전

1학기에 가장 많은 시간과 노력을 투자한 행사가 SCON이었다면, 2학기는 동아리 내전이었습니다. 직접 문제를 만들어서 내전을 개최한다는 원대한 꿈은 이루지 못했지만… 대신 여름방학 동안 kyo20111와 함께 러시아 문제를 풀면서 모은 1~2명만 해결한 좋은 문제들을 이용해 내전을 개최했습니다. 많은 사람이 참가해서 알고리즘 대회의 즐거움을 알아갔으면 하는 마음으로 상품을 푸짐하게 준비했지만, 3번의 내전 모두 참가자 수가 상품의 개수를 넘지 못하는 참사가 발생했습니다. 그래도 참가했던 분들은 다들 재미있게 문제를 푼 것 같아서 다행입니다. 문제는 (여기)에서 확인할 수 있습니다.

ICPC 예선 직전에는 3인 1팀 형태의 내전도 진행했습니다. 최대한 대회와 유사한 환경을 제공하기 위해서 DomJudge 서버를 구축하고, 해외 리저널 예선 문제(링크)를 가져와서 쉬운 문제 몇 개 번역(링크)해서 제공하고, 외부 공간을 대여해서 진행했습니다. 문제 셋을 잘못 선정해서 조금 구데기 같았던 것을 빼면 나름 괜찮게 굴러갔던 것 같습니다.

개인 대회와 팀 대회 모두 내년에도 진행하고 싶지만, 대회를 열 수 정도로 문제를 모으지 못할 것 같아서 걱정입니다. 방학 동안 열심히 모아야지…

9~11월 - 2학기 스터디

2학기에는 기본적인 알고리즘 개념을 배울 수 있는 기초 알고리즘 강의, 기초 알고리즘 강의에서 배운 내용으로 다양한 문제를 해결하는 기초 알고리즘 연습, 기초보다 조금 더 어려운 solved.ac 기준 P5~P3 정도의 개념을 배울 수 있는 중급 알고리즘 강의까지, 총 3개의 오프라인 강의를 진행했습니다. 기초 강의와 기초 연습은 여름방학에 사용했던 자료를 기반으로 진행했으며, 기초 연습은 강사가 아닌 수강생들이 돌아가면서 각자의 풀이 방법을 주고받는 형태로 진행했습니다. 중급 강의는 작년 겨울에 진행했던 중급 강의 자료에 계산 기하 내용을 추가해서 진행했습니다.

기초 연습은 끝까지 따라온 부원과 그렇지 않은 부원의 차이가 매우 컸는데, 끝까지 따라온 분들은 매우 만족해하시는 것 같았습니다. 다만 서로 풀이를 토론하는 방식은 참가자들이 시간을 더 많이 투자할 수 있는 방학에 진행하면 더 좋을 것 같다는 피드백을 받았습니다.

중급 강의는 강의를 1번 진행할 때마다 그룹 연습 스코어보드에 뜨는 핸들이 대략 절반씩 없어져서 슬펐습니다. 다들 과제에 치여서 사느라 그런 것 같습니다. PS보다는 학교 강의가 중요하긴 하지… 그래도 끝까지 따라온 사람이 3명이나 있는 것을 보면, 제가 SCCC를 떠나더라도 소모임이 잘 유지될 수 있을 것 같다는 생각이 듭니다.

3~12월 - 동아리 방 활성화

사실 동아리 방이라고 부르기도 뭐한 공간이긴 하지만… 1년 동안 많은 변화가 있었습니다.

작년까지는 비좁은 공간에 책상 6개, 의자 7개, 고장 난 컴퓨터 5대, 윈도우 7이 깔린 컴퓨터 1대, 한 10년은 됐을 것 같은 모니터 1n개가 들어가 있는 삭막한 공간이었습니다. 사람들이 알고리즘에 재미를 붙이는 것보다는 동아리 방에서 사람들과 친해져서 자연스럽게 문제를 풀도록 하는 게 더 빠를 것 같아서, 사람들이 많이 오도록 유도하기 위해 많이 갖다 버리고 새로운 물건을 새로 사서 채워 넣었습니다.

일단 모든 컴퓨터 본체와 모니터를 갖다 버리는 것부터 시작했습니다. 의자도 2개 버리고 책상 배치를 변경한 다음, 전선 몰딩을 사서 직접 시공도 했습니다. 65w 충전기와 C to C 케이블, 라이트닝 케이블, 모니터 거치대, 담요 등은 현대모비스의 후원을 받아서 구매했습니다. 이렇게 해서 1학기에는 책상과 충전기가 있는, 공강 시간 때우기에 적합한 공간으로 만들었습니다. 덕분에 작년에는 1비트로 표현할 수 있었던 동아리 방 사용 인원은 무려 2비트로도 표현할 수 없을 정도로 커졌습니다.

2학기에는 현대 모비스의 후원을 받아 27인치 모니터와 모니터 암을 구매했습니다. 이제는 평일 오후 10시나 주말에 가더라도 3명 이상의 사람이 있으며, 실제 사용 인원은 5비트로 표현해야 할 정도로 많아졌습니다. 이렇게 사람이 모이다 보니 서로 solved.ac 레이팅이나 스트릭으로 경쟁하는 등 제가 원래 기대하던 효과도 나타나기 시작했습니다.

Problem Solving

이 블로그의 메인 콘텐츠입니다. 작년보다 더 다양한 경험을 했습니다.

대회 운영

올해는 작년이나 재작년만큼 대회를 많이 운영하진 않았지만, 임팩트가 큰 대회를 여러 개 운영했습니다. 기억할 만한 대회는 이미 장문의 후기를 남겼기 때문에, 지금은 그냥 간단하게 코멘트만 남겨보려고 합니다.

한국 정보 올림피아드(KOI) 운영진이 되었습니다. 고등학생 때 KOI 1차 대회에서 비버 챌린지 문제를 풀면서 이런 기능은 도대체 누가 작업하는 건지 궁금했었는데, 4년 만에 그 궁금증을 해결했습니다. 제가 만들고 있더라고요. 비버 챌린지도 작업하고 실기 문제도 세팅하고 검수도 하고… 많이 힘들었지만 그만큼 재미있었습니다. 자세한 이야기는 쓰기 귀찮으니까 생략.
넥슨 청소년 프로그래밍 챌린지(NYPC) 운영진이 되었습니다. KOI와 비슷하게 많이 힘들고 많이 재미있었고 자세한 이야기는 쓰기 귀찮습니다.
숭실대학교 프로그래밍 대회(SCON)의 개최 후기는 (여기)에서 볼 수 있습니다.
선린인터넷고등학교 교내 대회도 3년째 개최하고 있습니다. 이제 슬슬 손을 놓을 때가 된 것 같아서 올해는 의도적으로 대부분의 작업을 후배들에게 넘겼습니다. 내년에는 검수진 구하는 것을 제외하면 대회 운영에 관여하지 않을 예정이고, 그 이후에는 아예 손을 떼려고 합니다. 그래도 후배들에게 넘겨주기 전에 인건비는 올려주고 떠나고 싶어서 학교와 많은 대화 끝에 출제/검수 예산을 증가시켰습니다. 3년 했으면 많이 했다…
고려대학교, 성균관대학교, 서울대학교 교내 대회 출제/검수에도 참여했는데 잘 기억나지 않습니다.

KOI와 NYPC 출제는 중고등학생 때부터 어렴풋이 꿈꿔왔던 일이지만 저에게는 기회가 오지 않을 것 같았는데, 주변 사람들의 도움과 엄청난 행운 덕분에 기회를 잡을 수 있었습니다. PS 열심히 하길 정말 잘한 것 같습니다. 코로나 걸린 상태로 KOI 1차 대회와 SCON을 동시에 준비할 때 쓴 일기를 보면 힘들어 죽을 것 같다는 내용으로 도배되어 있던데, 지금은 즐거웠던 기억만 남아있는 걸 보니 사람은 추억을 먹고 산다는 어른들의 말씀이 이제야 이해가 됩니다.

대회 참가

대회 참가도 간단한 코멘트만 남깁니다.

ICPC World Finals
에 참가할 예정이었지만 이스라엘-하마스 전쟁 때문에 대회가 연기되었습니다. 내 공짜 이집트 여행을 돌려줘…

현대 모비스 알고리즘 경진대회
에 참가하고 싶었지만, 상 2번 받았다고 밴 당해서 못 나갔습니다. 내 아이패드를 돌려줘…

SCPC
“내년에는 상 받겠지?” 를 3년째 반복하고 있습니다. 본선에서 상 못 받은 건 그냥 실력 부족이라고 생각합니다. 1차 예선 풀이와 후기는 (링크)에서, 2차 예선 풀이와 후기는 (링크)에서 확인할 수 있습니다.

UCPC/ICPC
UCPC는 3년 만에 특별상이 아닌 등수에 따른 상을 받았습니다. ICPC는 금상을 받고 싶다는 욕심이 있었지만… 아쉽게도 작년과 동일하게 5등 해서 은상을 받았습니다. 후기는 (링크)에서 볼 수 있습니다.

한양대학교 ERICA x 코드트리 프로그래밍 경진대회
특별상이 많이 걸려있다고 해서 특별상 노리고 첫 1시간 동안만 문제 풀었는데… 대학생부에서 1등 해서 아이패드 + 에어팟 + 스타벅스 기프티콘을 받게 되었습니다. 제가 아무도 모르는 대회 조용히 나가서 1등 하는 건 좀 잘하긴 합니다(참고 링크).
일반부 1등과 고등부 1등이 저보다 등수가 높았는데, 한 학기만 일찍 열렸으면 일반부 1등이 대학생이라서 졌을 것이고, 한 학기만 늦게 열렸으면 고등부 1등이 대학생이라서 졌을 것입니다. 인생은 타이밍입니다.

알고리즘 강의

국제정보올림피아드 계절학교에서 코치로 일했습니다. 분명 고등학생 때 봤던 계절학교 코치님들은 엄청 어른인 것 같고 무게감(그 무게 아님ㅎ) 있는 실력자분들이었는데, 왜 올해 코치장까지 한 저는 그냥 한없이 가벼운(저체중 맞음) 잼민이 같은 건지… 5년 전에 처음반 겨울학교도 떨어질 정도의 실력을 갖고 있었는데 이 자리까지 올라온 것을 보면 참 신기합니다. 제가 열심히 공부한 것도 있지만, 그것보다는 타이밍과 운, 그리고 주변 사람들의 도움 덕분이라고 생각합니다. 고등학생 때 꿈꾸던 KOI 출제, NYPC 출제, 계절학교 코치까지 모두 이뤘으니 이제 새로운 목표를 정할 시기가 다가온 것 같습니다.
계절학교 코치에 대해서 조금 이야기하자면… 고등학생 때 실습 시간에 풀지 못했던 문제를 3~4년 만에 다시 보고 푸는 것이 가장 즐거웠고, 저보다 똑똑하고 열심히 공부하는 학생들을 가르치는 것도 매우 재미있었습니다. 여름학교에서는 처음반 오전 강의 하나를 교수님 대신 제가 진행했으며, 그때 사용한 강의 자료는 (여기)에서 볼 수 있습니다. 선린 정올반이나 SCCC에서는 가르칠 수 없는 내용까지 마음껏 가르칠 수 있어서 좋았습니다.

선린인터넷고등학교 정보올림피아드반 강의는 3년째 하고 있습니다. 대부분의 수업에 학생이 1명만 나와서 사실상 과외 형식으로 진행되었습니다. 그 학생은 배우고 싶은 걸 배울 수 있고, 저는 기초 내용 전부 생략하고 재미있는 거 가르칠 수 있으니 윈윈이라고 생각합니다. 아마도? 다만 고등학생 때 알고리즘을 열심히 공부했던 학생으로서, 후배들이 더 이상 PS에 관심을 갖지 않는 것은 매우 아쉬운 일이라고 생각합니다. 입시 트랜드가 바뀌었으니 어쩔 수 없는 일이긴 합니다…
매년 12월에 열리는 선린 알고리즘 컨퍼런스에는 삼고초려 끝에 어렵게 모신 rkm0959, jwvg0425, ryute님들과 함께 영업했는데 효과가 있을지는 모르겠습니다. PS 영업과는 별개로 연사로 오신 분들께서 해주신 이야기는 어디서도 들을 수 없는 귀한 경험담이라고 생각해서, 기회가 된다면 내년에 SCCC에서도 비슷한 형태의 행사를 추진해 보려고 합니다.

숭실대학교 문제해결 소모임 SCCC에서도 강의했습니다. 대부분의 이야기는 위에서 했으니 그냥 넘어갑니다. 강의 자료는 (여기)에서 볼 수 있습니다.

마무리

어차피 지키지도 않을 것 같지만 일단 또 목표를 세워봅시다.

  • ICPC World Finals 한 번 더 진출
  • SCPC 수상(…)
  • Codeforces 2400(…)
  • ICPC Seoul Regional Contest 출제

사실 가장 크고 급한 목표는 1월 2일부터 8주 동안 진행되는 네이버 인턴을 성공적으로 마치는 것입니다. 첫 직장 생활이라 두려우면서도 설렙니다. 제 인생이 늘 그랬듯이 흘러가는 대로 살면 어떻게든 될 거라는 생각으로 매 순간 최선… 까지는 아니고, 그냥 적당히 열심히 하려고 합니다.

2/29 ~ 3/3에는 2024 ICPC World Finals의 티켓을 두고 아시아 태평양 지역의 대학이 경쟁을 펼치는 2024 ICPC Asia Pacific Championship(링크)이 개최됩니다. 서울 리저널에서 1순위로 진출한 만큼 꼭 진출 티켓을 따고 싶습니다. 2년 동안 함께 ICPC에 참가했던 edenooo가 이제 졸업하는데, edenooo 없이는 WF 진출이 어려울 것 같아서 이번에 WF 진출 2번 채우고 기분 좋게 ICPC에서 은퇴하는 것이 목표입니다.

ICPC 출제는 하고 싶다고 할 수 있는 게 아니긴 한데 일단 넣어봤습니다. ICPC 은퇴에 실패하면 한 번 더 ICPC 참가는 할 생각이라서, 일단 플레이오프를 잘 치고 다시 생각하는 걸로… SCPC랑 코드포스 레드는 몇 년째 목표에 있는지 모르겠습니다.

내년에는 또 어떤 일이 벌어질지 궁금하고 기대되면서도, 이제 슬슬 진로를 정해야 할 시기가 온 것 같아서 참 고민이 많습니다. 어떻게든 흘러가지 않을까요?

끝!

]]>
JusticeHui
2023 ICPC Asia Seoul Regional Contest 후기2023-12-07T00:00:00+00:002023-12-07T00:00:00+00:00https://justicehui.github.io/review/2023/12/07/icpc

올해도 5등해서 은상 받았습니다.

팀 구성

작년에 휴학했던 kyo20111이 복학하면서 자연스럽게 edenooo + jhnah917 + kyo20111 이 함께 뭉쳤습니다. 3명 모두 한국 PS판에서 나름 굵직한 성과를 낸 사람들이기에 이번 대회에 큰 욕심이 있었습니다. 더 나아가서, 이미 3명 모두 월드 파이널 티켓을 한 번씩 딴 상태였고 edenooo는 이번 학기를 마지막으로 졸업하기 때문에, 정말 마지막이라는 생각으로 한 번 더 월드 파이널 진출 티켓을 따기 위해 열심히 준비했습니다.

미적분학이나 정수론, 그리고 고등학교 수학을 제외하면 PS 대회에서 나오는 대부분의 분야에서 약점이 없는 팀이라고 생각합니다. 3명이 모두 강점이 서로 다른데, 저는 기하 문제와 다양한 사전지식을 활용하는 문제, edenooo는 앳코더 스타일의 조합론 문제와 네트워크 플로우 관련 문제를 잘 해결하고, kyo20111은 수학을 제외한 모든 분야에서 일정하게 강한 모습을 보여줍니다. 기본적으로 세 명 모두 푼 문제 수가 정말 많고, 팀원 개개인이 약한 부분이 있지만 팀원 조합으로 대부분의 약점을 커버할 수 있는 게 가장 큰 장점입니다. 팀원 3명의 코드포스 레이팅만 보면 레드 + 레드 + 오렌지로 별로 강해 보이지 않지만, 각자 못 푸는 문제를 팀원이 풀어준다면 충분히 3레드 팀 이상의 퍼포먼스를 뽑을 수 있는 팀이라고 생각합니다.

한 가지 문제점이라면 월드 파이널(11/12~11/17)과 서울 리저널 대회(11/25) 사이의 간격이 너무 짧고, 두 대회를 서로 다른 팀으로 출전하기 때문에 팀 연습을 할 시간이 많이 없다는 점이었습니다. 3명이 함께 대회에 참가한 적은 없지만, 그래도 2020~21년의 LongestPathToWF(aeren + edenooo + kyo20111)팀과 2022년의 NLP(chansol + edenooo + jhnah917)팀에서 2명이 함께 연습한 경험이 많았고, 지난 몇 년 동안 함께 공부하면서 서로를 잘 알고 있는 팀이라고 생각해서 팀 연습 횟수가 적더라도 별로 걱정되진 않았습니다. 굳이 팀 연습을 하지 않더라도 개인 연습은 그 누구보다 열심히 하는 사람들이기도 하고요.

팀명

팀 대회를 준비할 때 가장 어려운 두 가지는 팀 이름을 정하는 것과 팀노트를 만드는 것이라고 생각합니다. 팀노트는 한번 잘 만들어두면 매번 조금씩 수정해서 사용하면 되지만 팀 이름은 매번 새로 정해야 해서 매년 고통을 받고 있습니다.

UCPC는 별생각 없이 “BOJ 20000 Solve“로 정했습니다. UCPC 본선까지 한 달 정도 남았을 때 팀원 3명이 푼 문제 수의 합이 19600문제 정도밖에 안 된다는 사소한 문제점이 있었는데…

한 달 동안 매일 한 명이 15문제씩 푸는 방법으로 극복해 냈습니다. 사다리 타기, 돌림판 등의 랜덤 추첨을 사용하기도 하고, 제가 매일 하루에 5문제 이상 푼다는 것을 이용해서 하루 동안 문제를 제일 적게 푼 사람이 다음 날 15문제를 푸는 등의 방법도 사용했습니다.

ICPC도 BOJ 20000 Solve라는 이름으로 나가는 것을 생각해 봤지만, 한 번 더 사용하기에는 너무 재미없다는 의견이 나와서 다시 팀명을 고민해야 하는 시간이 돌아왔습니다. ICPC 팀명을 이야기하기 전에 간단한 퀴즈를 하나 내보려고 합니다. 아래 세 가지 중 가장 PS 악귀 같은 행동은 무엇일까요?

  1. 매일 5문제씩 2년 동안 풀기
  2. 매주 한 문제 이상 5년 4개월 동안 풀기
  3. 앳코더 ARC 모든 라운드 버추얼 돌기

어떤 게 가장 PS 악귀 같은 행동인지는 모르겠지만, 저렇게 살고 있는 3명이 한 팀으로 뭉쳤다는 것은 잘 알고 있는 사실입니다. 따라서 PS 악귀 라는 이름으로 ICPC에 참가하게 되었습니다. 영문 이름을 정할 때 ghoul, maniac, devil, restligeist, akgwi, AC-GUI, addict, enslaved 같은 다양한 후보가 있었지만, 다른 것들은 모두 다양한 이유로 기각당하면서 PS akgwi로 최종 결정되었습니다. 기각당한 이유로는 (1) 대문자보다는 소문자가 귀엽게 생겼다, (2) 단어가 너무 어렵다, (3) 악귀에 정확히 대응되는 단어가 없다 같은 것들이 있었습니다.

팀노트

저는 3년째 ICPC 팀에서 팀노트 제작을 담당하고 있습니다. 항상 팀원 3명 중 사전지식을 가장 많이 아는 사람이었고, 저 말고는 팀노트를 보는 사람도 별로 없기 때문입니다. 작년 ICPC 이후로 새로 배운 많은 내용들을 모두 팀노트에 넣고 싶었지만, 이미 지난 몇 년 동안 팀노트에 정말 많은 내용을 넣었기 때문에 이미 25페이지가 모두 채워진 상황이었습니다. 그렇다고 기존에 있던 내용을 뺄 수는 없기 때문에 2열로 되어 있는 문서를 3열로 바꾸고 종이 여백을 모두 없애서 7장 정도의 공간을 만들어냈습니다. 가독성을 희생하면서 얻은 소중한 공간이기 때문에 열심히 코드를 꾹꾹 눌러 담았습니다. 상하좌우 여백은 프린터가 알아서 넣어줄 것이라고 믿었습니다.

ICPC 서울 대회와 월드 파이널은 문제 스타일이 다르므로 팀노트를 서로 다르게 가져가려고 했지만, 따로 작업할 시간이 없어서 결국 한 번에 제작하는 것으로 결정했습니다. 일단 월드 파이널의 이상한 기하 문제를 풀기 위한 2차원 기하 라이브러리를 많이 준비했고, 팀 연습을 진행하면서 본 모르는 알고리즘이나 2번 이상 등장한 테크닉을 모두 팀노트에 넣었습니다. 이 밖에도 이미 공개되어 있는 다른 팀들의 팀노트를 10개 이상 보면서 수학과 3차원 기하, 그리고 절대 안 나올 것 같은 어려운 내용을 제외한 대부분의 내용을 넣었습니다. 참고한 팀노트 목록과 올해 추가한 내용은 다음과 같습니다.

[펼치기 / 접기]
  • 참고 자료
  • 자료구조
    • Color Processor(amortized $O(\log N)$ 구간 색칠 쿼리)
    • Kinetic Segment Tree
    • Lazy LiChao Tree
  • 계산 기하
    • 선분 $n$개의 교차점 존재 여부 확인 $O(n \log n)$ - Shamos-Hoey algorithm
    • $O(nm)$ 다각형 클리핑 - Sutherland-Hodgman algorithm
    • $O(n \log n)$ 보로노이 다이어그램 - Fortune's algorithm
    • 단순 $n$각형과 반직선의 교점 이벤트 $O(n)$
    • 단순 $n$각형 선분 내부 판별 $O(n)$
    • 직선/선분 시리즈
      • 두 선분 사이의 거리
      • 두 선분의 교차점
      • 점 - 직선 대칭 이동
    • 교점 시리즈
      • 두 원의 교점 0~2개
      • 원과 직선의 교점 0~2개
      • 단순 $n$각형과 원의 교집합 넓이 $O(n)$
      • 볼록 $n$각형과 직선의 교점 0~2개 $O(\log n)$
    • 접선 시리즈
      • 두 원의 공통 접선 4개
      • 외부의 점에서 원으로 그은 접선 2개
      • 외부의 점에서 볼록 $n$각형으로 그은 접선 2개 $O(\log n)$
      • 기울기에 대한 볼록 $n$각형의 접점 2개 $O(\log n)$
    • 다각형 시리즈
      • 단순 $n$각형 무게 중심 $O(n)$
      • $n_1, n_2, \cdots, n_k$각형 합집합 넓이 $O\left((\sum n_i)^2\right)$
  • 그래프 이론
    • 이분 매칭, 쾨닉 정리, 딜워스 정리
    • 선형 시간 Horn SAT 솔버
    • 선형 시간 2-QBF 솔버
    • $C_3, C_4$ 서브그래프 개수
    • $O(E \log E)$ 여그래프 스패닝 포레스트
    • $O(VE)$ Shortest mean cycle
    • $O(V^3)$ Stoer-Wagner algorithm
    • $O(3^{V/3})$ Maximal Clique
  • 수학
    • $n$차 방정식 $O(n^3 \log 1/\epsilon)$
    • Stern-Brocot Tree
    • Xudyh Sieve
    • $O(n \log n)$ Taylor Shift
    • FFT 실수 오차 수정
    • Berlekamp-Massey algorithm 코드 길이 단축
  • 문자열
    • Aho-Corasick
    • Suffix automaton
  • 기타
    • Java 입출력, Python Decimal 사용법
    • Aliens Trick 역추적
    • Hook length formula
    • SMAWK
    • Min-plus convolution (convex, arbitrary)
    • DLAS (Diversified Late Acceptance Search)
    • $O(N \times \max W_i)$ Knapsack
    • Kahan's algorithm
    • BOJ 13925 수열과 쿼리 13 (덧셈, 곱셈 레이지)
    • BOJ 14636 Money for Nothing (직사각형 넓이 최대화)
    • BOJ 18596 Monster Hunter (트리에서의 exchange argument)
    • BOJ 3611 팀의 난이도 (Maximum density subgraph)


팀노트는 여기에서 확인할 수 있습니다.

UCPC 예선

너무 오래돼서 잘 기억이 안 납니다. kyo20111이 일본 여행 가서 그냥 edenooo와 함께 2인 팀으로 참가하려고 했지만, 다행히 kyo20111이 컴퓨터를 사용할 수 있는 장소를 찾아서 3명이 함께 문제를 풀었습니다. 하지만 컴퓨터를 사용할 수 있는 장소를 구했다고 하더라도 평소 작업 환경과 달라서 그런지 구현하는 것이 편해 보이진 않아서, kyo20111이 풀이를 찾은 문제 중 구현이 복잡한 문제는 제가 풀이를 받아서 구현하는 방식으로 진행했습니다. 올해는 작년에 비해 본선 진출 팀 수가 많이 줄어서 2명으로 참가했으면 본선 진출이 위태롭지 않았을까… 라는 생각도 듭니다.

UCPC 예선은 10~11문제로 구성되어 있고, 가장 쉬운 문제가 항상 A번에 배치되어 있습니다. 따라서 3명 중 구현이 가장 빠른 제가 첫 4문제를 잡고, edenooo가 가운데 1/3, kyo20111이 마지막 1/3을 잡고 시작했습니다.

저는 ABD를 풀고, kyo20111이 예전에 푼 disk convex hull 코드를 받아서 복붙한 다음 코드 10줄 정도 추가해서 C를 풀고, kyo20111이 불러주는 풀이를 그대로 코드로 받아 적어서 H를 풀었습니다. 제가 머리를 비우고 9000바이트 정도 작성하는 동안 다른 팀원들이 머리를 써야 하는 FGIK를 풀어줘서 9솔브 12등으로 마무리했습니다.

대회가 시작하자마자 A를 잡고 빠르게 코딩했지만 몇 초 차이로 first solve를 뺏긴 것이 아쉬웠습니다. H에 4000바이트를 짜고 제출해서 한 번에 맞은 게 기억에 남는데, 코드도 잘 짠 것 같아서 여기에 자랑하려고 합니다. (코드)

UCPC 본선

이것도 너무 오래 돼서 잘 기억이 나지 않을 줄 알았지만… 고작 4달이라는 시간으로는 기억에서 지울 수 없는 대회였나 봅니다.

지금까지는 항상 초반에 빠른 속도로 문제를 풀어서 패널티에서 우위를 점하고 어려운 문제를 고민할 시간을 확보한 다음 중후반에 어떻게든 어려운 문제를 하나 풀어내서 상위권을 확정했었는데, 이번 대회는 대회 첫 1시간 동안 한 문제도 풀지 못했습니다. 3명이 모두 한 문제씩 잡고 구현했지만 3명 모두 틀려서 디버깅하던 게 문제였고, 다행히 60~90분 시점에 막혀 있던 3문제를 모두 풀어서 그 이후로는 잘 진행이 됐습니다.

저는 FHL을 풀고, 다른 팀원들이 5문제를 더 풀어서 8문제 10등으로 마무리했고 4등상을 받았습니다. 초반에 말리지 않았으면 2문제 더 풀 수 있었을 것 같은데… 지금까지 참가했던 팀 대회를 전부 통틀어서 초반에 이렇게 말린 적은 처음인데, 이걸 복구해서 수상까지 간 경험은 이후에 팀 연습이나 대회에서 초반에 조금 주춤하더라도 침착함을 유지할 수 있게 도와준 좋은 도구가 되었습니다. 배운 점이 많은 대회였습니다.

팀 연습 (1)

작년과 재작년에 ICPC에 참가했을 때는 ICPC 예선 전에 단 한 번도 연습을 하지 않았습니다. 첫 번째 팀 연습은 UCPC 예선, 두 번째 팀 연습은 UCPC 본선, 세 번째 팀 연습은 ICPC 예선이라는 말이 괜히 있는 게 아닙니다. 하지만 앞에서도 언급했지만, 올해는 월드 파이널(11/12~11/17)과 서울 리저널 대회(11/25)에 모두 참가해야 했습니다. 두 대회 사이의 간격이 너무 짧아서 귀국하고 시차 적응하자마자 서울 리저널 치러 가야 하는 상황이었고, 월드 파이널 직전에는 월드 파이널 팀으로 연습해야 했기 때문에 리저널 팀으로 연습할 시간이 많지 않았습니다. 따라서 ICPC 예선 전에도 연습을 몇 번 진행했습니다.

예선 전에 진행한 연습 기록은 여기에서 확인할 수 있습니다. 연습을 몇 번 진행하면서 제가 생각했던 것보다 더 강한 팀이라는 것을 깨달았습니다.

ICPC 예선

대회

늘 그랬듯이 한글 문제 3개를 모두 푼 다음에 남은 문제를 적절히 분배하기로 했습니다.

대회가 시작하자마자 문제지를 넘기면서 한글 문제를 찾았고, kyo20111이 C, edenooo가 D, 그리고 제가 G를 잡았습니다. edenooo가 작성한 코드를 윈도우 디펜더가 바이러스로 오인해서 실행 파일을 지워버리는 일이 있었던 것을 제외하면 C와 D는 큰 문제 없이 해결했습니다. 사실 G도 풀이는 빨리 나왔지만 $N \leq 5000$에 TL 0.5초인 게 마음에 걸려서 다른 팀원에게 키보드를 넘기고 잠시 기다렸습니다. 아니나 다를까 다른 팀들이 G에 빨간 기둥을 세우는 것을 보고 $O(N^2 \log N)$은 안 된다고 판단해서 $O(N^2 \log \log N)$ 풀이로 수정해서 작성하고 한 번에 AC를 받았습니다. BOJ에서 10000문제를 풀면서 생긴 똥문제 감지 능력이 빛을 발하는 순간이었습니다. 이렇게 별 탈 없이 첫 30분 동안 한글 문제 3개와 그다음으로 쉬운 K번까지 총 4문제를 풀어서 잠시 스코어보드 맨 위에 이름을 올렸었습니다. 같은 실습실에 있던 다른 팀들이 감탄하던 것이 기억에 남아 있습니다.

그 이후로 저는 반드시 제가 잡아야 할 것처럼 생긴 E를 잡았고, 다른 두 팀원은 AIJ를 적당히 잡고 있었습니다. 사실 E는 대회 전날 팀노트에 추가한 Polygon Union을 그대로 따라서 치면 풀 수 있는 문제였지만, 팀노트 배끼다가 오타를 2번 내서 1시간 정도 날리고, 시계 방향과 반시계 방향을 헷갈려서 시간을 또 버리고, 실수 오차 때문에 WA를 받자마자 키보드를 압수당하고, CLion 경고 무시했다가 클래스 멤버 변수가 아닌 생성자 인자를 수정해 버리는 등의 수난을 겪어서 결국 2시간 24분 정도에 AC를 받았습니다.

using ll = long long;
struct frac{
    ll p, q;
    frac(ll p, ll q) : p(p), q(q) {
        if(q < 0) p = -p, q = -q;
        ll g = __gcd(abs(p), q);
        p /= g; q /= g;
    }
};

문제의 그 코드입니다. 집에서 문제 풀 때는 분자가 양수인 기약 분수로 바꾸는 부분을 void norm() 등의 함수로 빼서 사용하기 때문에 몰랐는데, 이렇게 작성하면 구조체 멤버가 아닌 생성자 인자로 들어온 값을 수정하기 때문에 분모에 음수가 올 수 있습니다. 이것 때문에 분수 대소 비교가 제대로 동작하지 않아서 30분 정도 낭비했습니다.

제가 E에서 고생하는 동안 다행히 팀원들이 A와 I를 풀었고, J도 두 concave function의 max plus convolution을 구하는 파트를 제외한 모든 부분이 완성되어 있어서 제가 10줄 정도 짜고 AC를 받았습니다. 그리고 남은 20분 동안 kyo20111이 F를 열심히 구현했지만 대회가 끝날 때까지 완성하지 못해서 8문제로 마무리했습니다. F는 딱 봐도 스위핑 문제인데 “푼 문제 DB”에 없고 10분 정도 고민해도 풀이가 안 나오길래 어려운 문제인 줄 알고 안 건들였는데, 사실은 어렵지 않은 NYPC 기출 문제였습니다. NYPC 문제들도 풀고 DB에 넣어야 한다는 사실을 깨달았습니다.

3분 차이로 3등했습니다. E에서 한 서로 다른 5가지 실수 중 한 가지만 안 했더라도 이길 수 있었을 텐데… 대회 끝난 이후에도 3일 정도 계속 생각났습니다.

뒷풀이

ICPC 예선 후 회식은 2022~2023년 숭실대학교 컴퓨터학부 문제해결 소모임 SCCC의 유일한(…) 오프라인 행사입니다. 작년에는 회식에 4팀 정도 참가했던 걸로 기억하는데, 올해는 약 50명 정도가 참석해서 무한리필 고깃집 자리 절반 이상을 점거했습니다. 2년 동안 열심히 부원들 가르치고 SCON이랑 소모임 내전으로 PS 영업했던 게 효과가 있는 모양입니다. SCCC에 대한 이야기는 나중에 정리해 보려고 합니다.

팀 연습 (2), 그리고 WF 연기

이스라엘 하마스 전쟁으로 인해 11월에 예정되어 있던 월드 파이널이 연기되었습니다. 덕분에 우리 팀은 그냥 예선 전부터 열심히 연습하고 팀노트를 준비한 팀이 되었습니다.

뭐 아무튼 월드 파이널 팀으로 연습을 안 해도 되니 마음 놓고 리저널 팀으로 연습을 진행했습니다. 5번의 연습을 진행했고, 아직 연습 기록을 정리하진 않았습니다. 날짜와 셋 목록만 적어두고 나중에 수정하겠습니다.

  • (10/27) 2023-2024 ACM-ICPC Latin American Regional Programming Contest (링크)
  • (10/29) The 2021 ICPC Asia Macau Regional Contest (링크)
  • (11/06) 2021-2022 ACM-ICPC Nordic Collegiate Programming Contest (NCPC 2021) (링크)
  • (11/20) 2023 China Collegiate Programming Contest (CCPC) Guilin Onsite (The 2nd Universal Cup. Stage 8: Guilin) (링크)
  • (11/22) 2020-2021 ICPC Central Europe Regional Contest (CERC 20) (링크)

시간을 다 안 채우고 중간에 그만둔 Macau Regional Contest를 제외하면 모두 만족할만 한 결과가 나왔습니다.

ICPC 본선

대회 전

작년 목표는 14등 안에 들어서 수상하는 것과 고려대/한양대를 이기고 대학 3등을 차지해서 월드 파이널 티켓을 따는 것이었습니다. 하지만 올해부터는 리저널 챔피언이 아니면 아시아 태평양 지역 플레이오프를 통해 월드 파이널에 진출하는 것으로 규정이 바뀌었습니다. 따라서 리저널 대회가 작년만큼 중요하지는 않았지만, 그래도 고려대와 한양대에게 지면 기분이 안 좋으므로 작년과 같이 한국 대학 중에 3등하는 것을 목표로 잡았습니다.

대학 3등이 아닌 한국 대학 3등이 목표인 이유는, 팀 연습 때 단 한 번도 이기지 못했던 누텔라 + 레드 + 레드 조합의 National Taiwan University std_abs 팀(링크) 때문에 그렇습니다. 해외 팀이 한 팀 있다는 소식을 듣고 어떤 팀인지 궁금해서 확인했는데 std_abs 인 것을 보고 절망했었습니다… 한국 대학 2등은 많이 어려울 것이라고 예상했는데, 서울대의 NewTrend, Cafe Garage, Minors와 KAIST의 Penguins, MunSongSong Eggdrop, Final Round, Kim and Jang, IOS를 전부 이기는 것은 불가능하다고 생각했기 때문입니다.

대회

작년 본선은 아직도 매 순간순간이 기억나는데 올해는 그렇지 않습니다. 사실 5시간이 다 지나서 대회가 끝났을 때도 그냥 팀 연습이 끝난 느낌이었지, 작년처럼 긴장이 탁 풀리는 느낌이 들거나 감격스럽지 않았습니다. 그냥 “어? 끝났네” 정도의 생각만 들었습니다. 이유는 잘 모르겠습니다. 올해부터는 리저널 챔피언이 아니면 월드 파이널 직행이 아니라서 그런 건지, 아니면 서울대 카이스트를 제외하면 무조건 이길 수 있다는 자신감이 있어서 긴장이 안 됐던 건지… 그래서 작년에 비해 쓸 내용이 많이 없습니다.

작년에는 대회장 초기 세팅 그대로 모니터를 책상 가운데에 두고 쓰는 팀이 많았는데, 올해는 오른쪽으로 옮겨서 쓰는 팀이 많았던 것이 가장 인상 깊었습니다. 작년에 쓴 후기(링크)와 올해 5월에 작성한 ICPC 준비 가이드(링크)가 많이 퍼진 덕분일까요? 비슷한 이유로 추정되는데, 올해는 대회장 앞에 배치되어 있는 풍선의 개수가 작년에 비해 많이 균등해 보였습니다. 괜히 떠들고 다녔나…

자리 배치가 참 인상적이었는데, 대회장 맨 뒤에 예선 1등(Cafe Garage), 2등(Final Round), 3등(PS akgwi)가 일렬로 붙어있었습니다. 위에 적은 서울대/카이스트 팀들 중 문송송계란탁을 제외하면 전부 가까운 위치에 있어서 대회장 분위기를 관찰하기 편했습니다.

저는 가장 쉬운 문제 중 하나인 D를 풀고, kyo20111이 짜고 틀린 J를 고쳐서 AC를 받고, 그 이후로는 쭉 E를 잡고 있었지만 결국 문제를 풀지 못한 채로 대회를 마무리했습니다. C와 K를 읽자마자 어려울 것이라고 유추하고 뒤로 미뤘는데, C는 팀원들에게 미리 보여주는 게 좋았나… 라는 생각도 듭니다.

결과 발표

대회가 종료됐을 때 프리즈된 스코어보드 상에서는 4등이었고, 프리즈 이후에 문제를 더 풀어서 우리 팀 위로 올라올 가능성이 있는 팀은 Cafe Garage, Final Round, MunSongSong 정도였습니다. 대회가 끝나자마자 양옆에 있는 팀들의 화면을 슬쩍 훑어본 결과 오른쪽 자리에 있던 Cafe Garage와 왼쪽 자리에 있던 Final Round는 우리 팀보다 등수가 낮았습니다. MunSongSong은 자리가 멀어서 염탐을 못하고 후원사 세션 시작 전에 물어봤는데, 프리즈 이후에 문제를 풀어서 총 9문제로 우리 팀보다 등수가 높다는 것을 알아냈습니다.

스코어보드 오픈 전부터 이미 5등이라는 것을 알고 있었기 때문에 작년과 마찬가지로 긴장감 없이 구경했습니다. 대신 다른 팀들의 등수가 공개되는 것을 재미있게 봤는데, 개인적으로 좋은 성적을 거둘 것이라 기대하고 있던 대학이 몇 개 있었는데 그중 몇몇 대학은 수상하지 못해 아쉬웠습니다. 그래도 유학생으로 구성된 유니스트, 팀원 2명이 교체되었지만 여전히 강한 포스텍, 그리고 고등학교 후배 2명이 있으면서 UCPC와 ICPC 예선에서 멋진 퍼포먼스를 보여주었던 한양대가 15등 안에 들어서 수상했습니다. 모두 축하합니다!

마무리

아직 아시아 태평양 플레이오프가 남아있어서 그런지 작년과 다르게 대회가 끝났다는 생각이 들지 않습니다. 내년 3월까지 또 열심히 공부해서 월드 파이널 진출권 2번 따고 명예롭게 ICPC에서 은퇴하고 싶습니다. 월드 파이널 메달은 어… 잘 모르겠습니다.

응원해 주신 분들, 그리고 축하해 주신 분들 모두 감사드립니다. 내년에 플레이오프 후기와 월드 파이널 후기로 돌아오겠습니다.

참고 링크

]]>
JusticeHui
2023 ICPC 예선 대비 연습2023-10-16T00:00:00+00:002023-10-16T00:00:00+00:00https://justicehui.github.io/ps/2023/10/16/2023-icpc-pre-practice팀 연습

굳이 예선 준비를 해야 하나 싶지만… ICPC WF와 서울 리저널 사이의 간격이 너무 짧아서 리저널 연습을 미리 한다는 느낌으로 연습했습니다. 연습은 각자 집에서 진행했으며, 음성 채팅을 하면서 두 명 이상의 사람이 동시에 키보드를 잡지 않도록 제한했습니다.
까먹지 않기 위해서 제가 푼 문제만 간단하게 정리합니다. 스포일러 주의!

(10/11) ECNA 2022 (3시간)

링크(Codeforces): 2022-2023 ICPC East Central North America Regional Contest (ECNA 2022)
링크(BOJ): ecna2022

총평: solved.ac 기준 골드 이하의 쉬운 문제가 너무 많아서 우리 팀에게는 별로 맞지 않는 셋이었다. 최근에 외국인들 팀노트를 구경하면서 포커 구현 코드가 있는 것을 보고 신기했던 기억이 있는데, 이 대회를 치고 나니 왜 그런 코드를 넣는지 이해가 된다. 한국인들은 윷놀이 구현체를 넣어야 하나?

A. A-Mazing Puzzle: 4차원 격자 그래프에서 최단 경로를 구한다는 생각은 쉽게 할 수 있다. 한숨 한 번 내쉬고 열심히 구현하면 된다.
C. Cribbage On Steroids: 패를 만드는 카드의 숫자들을 고정한 뒤, 그러한 숫자 조합을 만드는 경우의 수를 세면 된다. 15를 세는 게 어렵다고 생각할 수 있는데 P(15)가 별로 크지 않아서 그냥 브루트포스를 해도 된다.
D. Determining Nucleotide Assortments: Do you know prefix sum?
J. Simple Solitaire: 단순 구현
L. Which Warehouse?: Do you know assignment problem? 대회 중에는 팀원이 풀었다.

반성할 점: 문제 그림 확인하고 연습 셋 정하자.

(10/12) TOPC 2023 (3시간)

링크(Codeforces): 2023 ICPC Asia Taiwan Online Programming Contest
링크(BOJ): topc2023

총평: 서울 지역 예선 연습용으로 좋은 셋이다.

A. Advance to Taoyuan Regional: 지문 위에 있는 Category 관련 내용은 문제와 아무런 관련이 없는데 열심히 읽느라 시간을 날렸다. 날짜 계산하는 전형적인 브론즈 문제
C. Cutting into Monotone Increasing Sequence: 골드 3~4에 있을 것 같은 전형적인 DP 문제인데 왜 많이 안 풀렸는지 모르겠다. C++로 풀기 위해서는 int128을 사용하거나 귀찮은 예외 처리가 필요하다.
E. Exponentiation: 수식을 열심히 정리하다 보면, $D(0) = 2, D(1) = a, D(n)=a\times D(n-1) - D(n-2)$가 성립함을 알 수 있다. 따라서 $\begin{pmatrix}a&-1\1&0\end{pmatrix}^{\beta-1}\begin{pmatrix}a\2\end{pmatrix}$를 계산하면 $O(\log \beta)$ 시간에 문제를 해결할 수 있다.
F. Finding Bridges: 쿼리를 뒤에서부터 처리하면 간선이 추가될 때마다 단절선의 개수를 관리하는 문제가 된다. 추가하는 간선을 $e=(u,v)$라고 할 때, $u$와 $v$가 연결되어 있지 않으면 $e$는 단절선이 되고, 그렇지 않으면 $u$와 $v$를 잇는 경로 위에 있는 모든 간선이 더 이상 단절선이 아니게 된다. 따라서 HLD로 경로 업데이트를 하면 쿼리당 $O(\log^2 N)$에 해결할 수 있다.

반성할 점: 없다.

(10/13) SWERC 2016 (5시간)

링크(Codeforces): 2016-2017 ACM-ICPC Southwestern European Regional Programming Contest (SWERC 2016)
링크(BOJ): swerc2016

총평: 서울 지역 예선 연습용으로 좋은 셋이다.

B. Bribing Eve: $ax+by$를 최대화하는 것은 좌표 평면 위에 점 $(x,y)$를 찍어놓고 기울기가 $-b/a$인 집선을 긋는 것과 같다. 따라서 이 문제는 원점을 지나는 기울기가 음수인 모든 직선을 보면서, 오른쪽 위에 있는 점의 최대/최소 개수를 구하는 문제라고 생각할 수 있다. 기울기가 같은 점 처리하는 게 조금 귀찮다. 같은 점이 여러 개 주어질 수 있음에 주의하자. 여기저기에서 많이 보이는 아이디어다.
C. Candle Box: 쉬운 문제
E. Passwords: 이미 BOJ에서 5번 정도 풀어봤던 것 같은 아호코라식 DP 문제다. $D(i, s) := $ 길이가 $i$이고 $s$번 state에서 끝나는 문자열의 개수를 계산하면 된다. solvedac에 #aho_corasick #dp *p1..d5를 검색(link)하면 비슷한 문제를 여러 개 풀어볼 수 있다.
G. Cairo Corridor: 그림만 봐도 즐겁다. corridor를 따는 건 쉬운데 minimal corridor인지 $O((NM)^2)$보다 빠르게 판별하는 것은 쉽지 않다. 처음에는 minimal corridor가 존재하면 그 그래프가 꽤 작을 것이라고 추측해서 완전 탐색 풀이를 제출했는데 TLE를 받았고, 중요해 보이는 정점 몇 개만 확인하는 방식으로 고쳐서 AC를 받았다. 일반적으로 degree가 2인 정점은 확인할 필요가 없지만, degree가 3인 정점과 인접한 정점은 확인해야 한다.
A. Within Arm’s Reach: 팀원이 의문의 런타임 에러로 고통받고 있길래 2줄 수정해서 AC 받아줬다. 실수 오차는 만악의 근원이다.

반성할 점: 문제 조건을 잘 읽자(B). 대회장에서 기하 구현할 때는 페어 코딩하는 게 좋을 듯(A). J는 다시 풀어봐야지…

(10/15) TOPC 2021 (개인, 3시간)

링크(Codeforces): 2021 ICPC Asia Taiwan Online Programming Contest
링크(BOJ): topc2021

총평: TOPC 2023 대회가 괜찮았어서 동아리 내부 ICPC 모의 대회 셋으로 2021년 대회를 골랐는데 이건 별로였다. 다이아 이상의 어려운 문제는 사전지식이 있으면 쉽게 풀 수 있고, 쉬운 문제는 나름의 함정이 있어서… 나쁜 셋은 아니지만 좋은 셋도 아니라고 생각한다.

풀이는 여기에서 확인할 수 있다.

A. Olympic Ranking: 쉬운 문제
B. Aliquot Sum: 쉬운 문제
C. A Sorting Problem: $A[B[i]] = i$인 배열 $B$를 만들면 inversion counting 문제가 된다.
D. Drunk Passenger: solvedac에서는 골드5라고 하는데 그것보다는 어려운 것 같다. DP로 풀거나 열심히 식 정리해서 풀거나…
E. Eatcoin: 쉬운데 BigInteger가 필요해서 별로인 문제
F. Flip: 전형적인 금광 세그 연습 문제
G. Garden Park: 가중치가 작은 간선부터 추가하면서 DP를 해도 되고, 아니면 그냥 뇌 비우고 센트로이드 분할을 해도 된다.
H. A Hard Problem: $(u,i) \neq (v,j)$ 조건만 없으면 전형적인 민컷 문제인데 저 조건이 있어서 NP-Hard가 되었다. $Q \leq 8$이니까 $(u,i)$를 소스에 붙이는 경우와 싱크에 붙이는 경우를 모두 확인해도 $2^8$가지밖에 안 돼서 시간 안에 문제를 해결할 수 있다.
I. ICPC Kingdom: graphic matroid와 colorful matroid의 최대 가중치 공통 독립 집합
J. JavaScript: 쉬운 문제

반성할 점: 없다.

(10/16) 2023 Brazil Subregional Contest (3시간)

링크(Codeforces): 2023-2024 ICPC Brazil Subregional Programming Contest
링크(BOJ): latinp2023

총평: 잘 모르겠다. 내가 5문제를 풀긴 했는데 브론즈 1개 + 예전에 푼 문제 4개(골드, 플래티넘, 다이아, 루비 하나씩)라서 평가를 할 수가 없다. 5시간 연습하려고 했는데 3시간 만에 다 풀었다.

A. Amusement Park Adventure: 쉬운 문제
B. Best Fair Shuffles: BOJ 11232 Shuffles과 똑같은 문제
D. Detour: BOJ 1848 동굴 탐험과 똑같은 문제
G. Great Treaty of Byteland: 풀었던 기억은 있는데 어떤 문제인지 모르겠다. 평각을 허용하는 볼록 껍질의 꼭짓점 개수를 세면 된다.
J. Jumping to Victory: BOJ 1288 전쟁 - 국지전과 거의 똑같은데 $N \leq 10^5$인 문제. 보로노이 다이어그램을 $O(N \log N)$에 구할 수 있으면 그다음은 기하 구현을 열심히 해서 풀 수 있다.

반성할 점: D에서 동굴 탐험을 너무 늦게 떠올렸고, 전체적으로 구현 실수가 너무 많았다. Graham scan 구현에서 실수한 건 거의 2년 만인 것 같은데…

정리

팀 연습 도중에 발견한 문제가 몇 가지 있다. 가장 크리티컬한 문제는 팀노트 Convolution.cpp에 있는 multiply_mod 함수가 틀렸다는 것이고, 이밖에도 기하 관련 라이브러리가 부족하다는 문제점이 있다. 다음 달에 월드 파이널도 가야 해서 기하는 빨리 추가해야 한다.

팀노트에 들어간 거의 모든 코드를 내가 작성한 거라서 다른 팀원들이 사용법을 잘 모른다는 소소한 문제도 발견했다. 대회 때는 연습과 다르게 내가 옆에 앉아있을 거라서 큰 문제가 되진 않을 것 같지만, 그래도 매뉴얼을 만들어 놓으면 도움이 될 것 같다.

]]>
JusticeHui
2023 SCPC 2차예선 풀이2023-08-19T00:00:00+00:002023-08-19T00:00:00+00:00https://justicehui.github.io/review/2023/08/19/scpc-qual-2총평

1, 2, 3, 4번 모두 작년/재작년보다 훨씬 어려워졌습니다. 많은 구현을 요구하는 1번을 풀고 나면 전형적인 문제일지 애드혹일지 감이 잡히지 않아 오만 가지 생각이 다 드는 2번과 3번 문제가 머리를 때리고, 뒤쪽 문제를 읽어보겠다고 4~5번으로 넘어가면 만점자가 한 자리 수인 문제들이 반겨줍니다. 4번과 5번은 만점자가 한 자리 수이고 풀 태스크를 제외한 모든 서브태스크를 긁는 건 쉽기 때문에 별로 문제가 안 되었지만, 3번이 정말… 힘들었습니다. 1차가 쉬웠던 이유가 2차에 어려운 문제를 몰아넣어서 그런 건가? 올해는 오랜만에 3번을 안 풀고도 본선에 진출하는 사람이 나올 것 같기도 합니다.

문제 점수 제출 횟수
1. 타이젠 윷놀이 100/100 1 (100)
2. 괄호 문자열 200/200 2 (0 → 200)
3. 루머 300/300 3 (90 → 180 → 300)
4. 막대기 연결 180/400 1 (180)
5. 스파트 아파트 건설 60/400 1 (60)
총점 840/1400 8

타이젠 윷놀이

말 하나를 이용해 윷놀이를 한다. 윷을 $N$번 던진 결과가 차례대로 주어지고, 이 $N$번의 행동을 $K$번 반복해서 총 $N\times K$번 말을 움직일 때, 말이 몇 바퀴 도는지 구하는 문제
$T \leq 87;$ $N,K \times 10^5;$ 백도는 주어지지 않음

다음과 같이 윷놀이 판의 각 칸에 번호를 붙입시다. 29는 한 바퀴를 온전히 돌았다는 것을 표시하기 위한 칸입니다.

15 - 14 - 13 - 12 - 11 - (10)
|  \                   /  |
16   24             25    9
|       \         /       |
17        23   26         8
|          (22)           |
18        27   21         7
|       /         \       |
19   28             20    6
|  /                   \  |
(0) - 1 -  2 -  3 -  4 - (5)
ㄴ 29

5번, 10번, 22번 칸은 그냥 지나가는 것과 멈추는 것을 다르게 취급해야 하므로 두 개의 점으로 분리해서 생각해야 합니다. 또한, 0번 칸은 시작점의 역할과 도착점의 한 칸 전의 역할을 모두 수행하기 때문에 0번 칸도 두 개의 점으로 분리해서 생각해야 합니다. 따라서 총 34개의 정점을 이용해 윷놀이 판을 관리합니다.

$v$번 정점의 다음 칸을 $\text{next}(v)$는 정점 개수에 비례하는 시간에 전처리할 수 있고, 이를 이용하면 $v$에서 $k (1 \leq k \leq 5)$칸 이동한 결과인 $\text{move}(v, k)$도 $5 \times$ (정점 개수) 정도에 전처리할 수 있습니다.

실제 문제의 정답을 구하는 것은, 각 칸에서 $N$번 이동했을 때 도착점에 가는 횟수와 종료 상태를 구하면 됩니다. 유효한 정점은 34개로 상수 개이므로 $O(N)$ 시간에 모두 전처리할 수 있고, $K$번 반복해서 이동한 결과는 전처리한 정보를 이용해 $O(K)$ 시간에 구할 수 있습니다.

int ID(int i, int j){ return i * 2 + j; }
bool IsStart(int v){ return v != ID(5,0) && v != ID(10,0) && v != ID(22,0) && Next[v] != -1; }

void Init(){
    memset(Next, -1, sizeof Next);
    S = ID(0,0); T = ID(29,0);

    for(int i=0; i<19; i++) Next[ID(i,0)] = ID(i+1,0);
    Next[ID(19,0)] = ID(0,1);

    Next[ID(5,1)] = ID(20,0); Next[ID(24,0)] = ID(15,0);
    for(int i=20; i<24; i++) Next[ID(i,0)] = ID(i+1,0);

    Next[ID(10,1)] = ID(25,0); Next[ID(25,0)] = ID(26,0);
    Next[ID(26,0)] = ID(22,1); Next[ID(22,1)] = ID(27,0);
    Next[ID(27,0)] = ID(28,0); Next[ID(28,0)] = ID(0,1);

    Next[ID(0,1)] = ID(29,0); Next[ID(29,0)] = ID(29,0);

    for(int i=0; i<61; i++){
        for(int j=0; j<5; j++){
            int v = i;
            for(int k=0; k<=j; k++) v = Next[v];
            Go[i][j] = v ^ !IsStart(v);
        }
    }
}

void Solve(){
    cin >> N >> K;
    for(int i=1; i<=N; i++) cin >> A[i];
    int move[61], get[61];
    for(int i=0; i<61; i++){
        if(!IsStart(i)) continue;
        int now = i, cnt = 0;
        for(int j=1; j<=N; j++){
            now = Go[now][A[j]-1];
            if(now == T) cnt++, now = S;
        }
        move[i] = now; get[i] = cnt;
    }
    long long now = S, res = 0;
    for(int i=0; i<K; i++) res += get[now], now = move[now];
    cout << res << "\n";
}

괄호 문자열

소괄호와 중괄호로 구성된 괄호 문자열이 주어지면, 올바른 괄호 문자열인 부분 문자열의 개수를 구하는 문제
$N \leq 10^6;$ $\sum N \leq 42 \times 10^6$

처음 문제를 보면 별의별 생각이 다 듭니다. 처음에는 끝점을 고정한 다음, 문자열의 prefix를 쪼갤 수 없는 괄호 문자열들로 분할해서 가능한 시작점의 개수를 세는 방식으로 접근했지만, 별다른 소득을 얻지 못했습니다. 구체적으로, 문자열의 분할을 incremental하게 관리할 방법도 잘 안 보였고, 관리할 수 있다고 하더라도 선형 시간에 하기는 어려울 것 같아서 포기했습니다.

문자열을 maximal한 올바른 괄호 문자열들로 분할하면, 각 그룹을 독립적으로 생각해도 된다는 것을 알 수 있습니다. 서로 다른 두 그룹에 모두 걸쳐 있는 올바른 괄호 문자열이 있다면 maximal한 문자열들로 분할한 것이 아니기 때문입니다. 따라서 스택을 이용해 maximal한 올바른 괄호 문자열로 분리한 다음, 각각의 올바른 괄호 문자열에서의 답을 선형 시간에 구하면 문제를 해결할 수 있습니다. 분할된 각 문자열은 올바른 괄호 문자열이기 때문에 소괄호와 중괄호의 구분을 신경 쓰지 않아도 되므로 구현이 쉬워집니다.

bool Match(char a, char b){ return a == '(' && b == ')' || a == '{' && b == '}'; }
bool Open(char c){ return c == '(' || c == '{'; }

ll Go(const string &s){
    ll res = 0;
    vector<ll> val{0};
    for(auto c : s){
        if(Open(c)) val.push_back(0);
        else{
            res += val.back() * (val.back() + 1) / 2;
            val.pop_back(); val.back() += 1;
        }
    }
    res += val.back() * (val.back() + 1) / 2;
    return res;
}

vector<string> Tokenizer(const string &s){
    int n = s.size();
    vector<string> res;
    vector<int> idx(n, -1), stk;
    for(int i=0; i<n; i++){
        if(Open(s[i])) stk.push_back(i);
        else if(!stk.empty() && Match(s[stk.back()], s[i])){
            idx[stk.back()] = i; idx[i] = stk.back(); stk.pop_back();
        }
        else stk.clear();
    }
    for(int i=0, j=0; i<n; i=j){
        if(idx[i] == -1){ j++; continue; }
        while(j < n && idx[j] != -1) j++;
        res.push_back(s.substr(i, j-i));
    }
    return res;
}

ll Solve(const string &s){
    ll res = 0;
    for(const auto &i : Tokenizer(s)) res += Go(i);
    return res;
}

루머

$N$명의 사람이 일렬로 서있다. 시간 $t$에 $i$번째 사람과 인접한 사람 중 $A_i$명 이상이 루머를 믿고 있으면, $t+1$ 이후부터 $i$번째 사람도 루머를 믿는다. $t = 0$인 시점부터 루머를 믿는 사람을 최대 $M$명 선택할 수 있을 때, $T$ 시간이 지난 후 루머를 믿는 사람의 최댓값을 구하는 문제
$M,T \leq N \leq 5\,000;$ $1 \leq A_i \leq 2$

이 문제도 다양한 시도를 했습니다. 처음에는 그래프로 접근해서 위상 정렬 같은 느낌으로 풀려고 했지만 실패했습니다. 그다음에는 $C(i, j) :=$ $[i,j]$ 구간을 활성화하는 데 필요한 최소 선택 횟수로 정의한 테이블을 어떻게든 계산한 다음, $D(i, k) := $ $[1, i]$ 구간을 $k$명으로 시작해서 활성화할 수 있는 최대 칸 개수 같은 것을 계산하려고 했고, 이 방법으로도 아무 소득도 얻지 못했습니다.

그다음 접근은 효과가 있었는데, $D(i, j) :=$ $i$명을 선택했고, 그중 마지막으로 선택한 사람이 $j$일 때 활성화되는 칸 개수의 최댓값으로 정의해서 계산하는 것이었습니다. 편의상 $L_i,R_i$를 $i$의 왼쪽/오른쪽에 있는 가장 가까운 2번 칸의 인덱스, $S_i = \max(i-t,L_i+1)$, $E_i=\min(i+t, R_i-1)$으로 정의합시다. $D(i, j)$는 항상 정확히 $E_i$까지 활성화하기 때문에, 이후에 선택하는 위치에 따라 2번 칸의 활성화 여부를 어렵지 않게 추적할 수 있습니다. 구체적으로, $D(i+1,k) \leftarrow D(i,j)$ 형태의 상태 전이는 다음과 같이 8가지로 나눠서 계산할 수 있습니다.

  1. $j$와 $k$의 담당 구간이 겹치지 않는 경우 ($j+2t < k \leq N$)
    • $D(i+1,k) \leftarrow D(i,j) + E_k - S_k + 1$
  2. $j$와 $k$의 구간이 겹치지만 서로의 위치를 포함하진 않는 경우 ($j+t < k \leq j+2t$)
    1. $j$의 오른쪽, $k$의 왼쪽에 2번 칸이 없는 경우 ($R_j = \infty \text{ and } L_k = -\infty$)
      • $D(i+1,k) \leftarrow D(i,j) + E_k - E_j$
    2. 2번 칸이 존재하지만, 두 구간의 교집합이 아닌 위치에 하나 이상 존재하는 경우 ($R_j < k-t \text{ or } j+t < L_k$)
      • $D(i+1,k) \leftarrow D(i,j) + E_k - \max(S_k, E_j+1) + 1$
    3. 1개의 2번 칸이 두 구간의 교집합에 위치하는 경우 ($R_j = L_k$)
      • $D(i+1,k) \leftarrow D(i,j) + E_k - E_j$
    4. 2개 이상의 2번 칸이 두 구간의 교집합에 위치하는 경우
      • $D(i+1,k) \leftarrow D(i,j) + E_k - S_k + 1$
  3. $j$와 $k$의 구간이 서로의 위치를 포함하는 경우 ($j < k \leq j+t$)
    1. $j$와 $k$ 사이에 2번 칸이 없는 경우 ($k \leq R_j \text{ or } L_k \leq j$)
      • $D(i+1,k) \leftarrow D(i,j) + E_k - E_j$
    2. $j$와 $k$ 사이에 2번 칸이 정확히 1개 있는 경우 ($R_j = L_k$)
      • $D(i+1,k) \leftarrow D(i,j) + E_k - E_j$
    3. $j$와 $k$ 사이에 2번 칸이 2개 이상 있는 경우
      • $D(i+1,k) \leftarrow D(i,j) + E_k - S_k + 1$

이 상태 전이를 naive하게 구현하면 $O(N^3)$ 시간에 문제를 풀어 90점을 받을 수 있습니다.

inline int GetL(int x){ return max(L[x]+1, x-T); }
inline int GetR(int x){ return min(R[x]-1, x+T); }
inline int Only(int x){ return GetR(x) - GetL(x) + 1; }

void Init(){
    fill(L+1, L+N+1, 0);
    fill(R+1, R+N+1, N+1);
    for(int i=1; i<=N; i++) for(int j=i-1; j>=max(1,i-T); j--) if(A[j] == 2) { L[i] = j; break; }
    for(int i=1; i<=N; i++) for(int j=i+1; j<=max(N,i+T); j++) if(A[j] == 2) { R[i] = j; break; }
}

void Solve(){
    cin >> N;
    for(int i=1; i<=N; i++) cin >> A[i];
    cin >> M >> T;
    Init();
    for(int i=1; i<=N; i++) for(int j=1; j<=N; j++) D[i][j] = nINF;
    for(int i=1; i<=N; i++) D[1][i] = Only(i);
    for(int i=1; i<M; i++){
        for(int j=1; j<=N; j++){
            for(int k=j+1; k<=N; k++){
                // case 1. no intersect (j+t < k-t)
                if(j + T < k - T) D[i+1][k] = max(D[i+1][k], D[i][j] + Only(k));
                // case 2. intersect, but not contain
                else if(j + T < k){
                    // case 2-1. no two
                    if(R[j] == N+1 && L[k] == 0) D[i+1][k] = max(D[i+1][k], D[i][j] + GetR(k) - GetR(j));
                    // case 2-2. exist two, but outside
                    else if(R[j] == N+1 || L[k] == 0 || R[j] < k-T || j+T < L[k]) D[i+1][k] = max(D[i+1][k], D[i][j] + GetR(k) - max(GetL(k), GetR(j)+1) + 1);
                    // case 2-3. exist only one two, inside
                    else if(R[j] == L[k]) D[i+1][k] = max(D[i+1][k], D[i][j] + GetR(k) - GetR(j));
                    // case 2-4. exist at least two twos
                    else D[i+1][k] = max(D[i+1][k], D[i][j] + Only(k));
                }
                // case 3. intersect, and contain
                else{
                    // case 3-1. not between
                    if(k <= R[j] || L[k] <= j) D[i+1][k] = max(D[i+1][k], D[i][j] + GetR(k) - GetR(j));
                    // case 3-2. exist only one two, inside
                    else if(R[j] == L[k]) D[i+1][k] = max(D[i+1][k], D[i][j] + GetR(k) - GetR(j));
                    // case 3-3. exist at least two twos
                    else D[i+1][k] = max(D[i+1][k], D[i][j] + Only(k));
                }
            }
        }
    }
    cout << *max_element(D[M]+1, D[M]+N+1) << "\n";
}

이제 이 풀이를 $O(N^2 \log N)$, 더 나아가 $O(N^2)$으로 최적화시켜 봅시다. $O(N^3)$보다 빠르게 풀 때는 $D(i,j)$의 값을 $D(i+1,k)$로 뿌려주는 방식이 아닌, $D(i, k)$의 값을 계산할 때 $D(i-1, j)$의 값을 가져오는 방식으로 구현하는 것이 편합니다.

각 $k$마다 위에서 다룬 8가지 경우를 이루는 $j$들이 구간을 이룬다는 것은 직관적으로 유추할 수 있습니다. 사실 증명은 못 했고, $N \leq 16$인 모든 데이터에서 assert를 이용해 구간을 이룬다는 것을 확인했습니다. 앞으로 언급하는 구간에 대한 모든 내용은 이런 방식으로 증명(?)했음을 미리 밝힙니다. 아무튼 각 경우마다 고려해야 하는 $j$들이 구간을 이룬다고 믿고, 세그먼트 트리를 이용해 점화식을 계산하면 $O(N^2 \log N)$ 시간에 문제를 해결해 180점을 받을 수 있습니다.

하지만 8가지 경우를 모두 고려하는 것은 귀찮고 세그먼트 트리를 8개씩 들고 다니는 것도 힘들기 때문에 저는 다음과 같이 케이스를 5가지로 줄였고, 이렇게 해도 각 경우에서 고려하는 $j$가 구간을 이룬다는 것을 확인했습니다.

  1. $j+2t < k \leq N$. 즉, $1 \leq j < k-2t$
    • $D(i,k) \leftarrow D(i-1,j) + E_k - S_k + 1$
  2. $j+t < k \leq 2+2t$. 즉, $k-2t \leq j < k-t$
    1. 2번 칸이 교집합에 정확히 1개 존재하는 경우. 즉, $R_j = L_k$
      • $D(i, k) \leftarrow D(i-1, j) + E_k - E_j$
    2. 그렇지 않은 경우. 즉, $R_j \neq L_k$
      • $D(i,k) \leftarrow D(i-1,j) + E(k) - \max(S_k, E_j+1) + 1$
  3. $j < k \leq j+t$. 즉, $k-t \leq j < k$
    1. $j$와 $k$ 사이에 2개 이상의 2번 칸이 존재하는 경우. 즉, $R_j < L_k$
      • $D(i,k) \leftarrow D(i-1,j) + E_k - S_k + 1$
    2. 그렇지 않은 경우. 즉, $R_j \geq L_k$
      • $D(i,k) \leftarrow D(i-1,j) + E_k - E_j$

여기까지 오면 연산의 종류가 2가지밖에 없다는 사실을 알 수 있습니다. 세그먼트 트리를 사용하는 $O(N^2 \log N)$ 풀이를 $O(N^2)$으로 줄이기 위해서는 세그먼트 트리 대신 슬라이딩 윈도우로 RMQ를 처리하거나, 아니면 기상천외한 아이디어를 이용해 새로운 풀이를 만들어 내는 방법밖에 없습니다. 후자였다면 30명 이상 풀었을 리가 없기 때문에 제 감을 믿고 전자의 방식으로 접근하기 시작했습니다.

위에서 나눈 5가지 경우를 이용해 $k$마다 각 $j$가 두 가지 연산 방식 중 어떤 것으로 $D(i, k)$에 반영되는지 표시했더니 놀랍게도 두 연산을 수행하는 구간이 나뉘어 있다는 것을 확인했고, 심지어 구간의 시작점과 끝점에 단조성이 있음을 함께 확인했습니다. 따라서 세그먼트 트리 대신 덱을 이용해서 RMQ를 처리할 수 있고, 시간 복잡도는 $O(N^2)$이 되어서 300점을 받을 수 있습니다.

int N, M, T, A[5050], L[5050], R[5050], D[5050][5050];

inline int GetL(int x){ return max(L[x]+1, x-T); }
inline int GetR(int x){ return min(R[x]-1, x+T); }
inline int Only(int x){ return GetR(x) - GetL(x) + 1; }

int Type[5050][5050];
vector<tuple<int,int,int>> Interval[5050];

void Init(){
    fill(L+1, L+N+1, 0);
    fill(R+1, R+N+1, N+1);
    for(int i=1; i<=N; i++) for(int j=i-1; j>=max(1,i-T); j--) if(A[j] == 2) { L[i] = j; break; }
    for(int i=1; i<=N; i++) for(int j=i+1; j<=min(N,i+T); j++) if(A[j] == 2) { R[i] = j; break; }

    for(int k=1; k<=N; k++){
        for(int j=1; j<k; j++){
            if(1 <= j && j < k - 2 * T) Type[k][j] = 1;
            else if(k - 2 * T <= j && j < k - T){
                if(R[j] == L[k]) Type[k][j] = 2;
                else if(GetL(k) > GetR(j) + 1) Type[k][j] = 1;
                else Type[k][j] = 2;
            }
            else{
                if(R[j] < L[k]) Type[k][j] = 1;
                else Type[k][j] = 2;
            }
        }
        Interval[k].clear();
        for(int i=1, j=1; i<k; i=j){
            while(j < k && Type[k][i] == Type[k][j]) j += 1;
            Interval[k].emplace_back(Type[k][i] - 1, i, j-1);
        }
    }
}

void Solve(){
    cin >> N;
    for(int i=1; i<=N; i++) cin >> A[i];
    cin >> M >> T;
    Init();

    for(int i=1; i<=N; i++) for(int j=1; j<=N; j++) D[i][j] = 0xc0c0c0c0;
    for(int i=1; i<=N; i++) D[1][i] = Only(i);
    for(int i=2; i<=M; i++){
        // type 0: D[i-1][j] + GetR(k) - GetL(k) + 1
        // type 1: D[i-1][j] - GetR(j) + GetR(k)
        deque<pair<int,int>> Q[2];
        auto f = [&](int type, int v){ return D[i-1][v] - (type ? GetR(v) : 0); };
        auto g = [&](int type, int k){ return Q[type].front().first + (type ? GetR(k) : Only(k)); };
        int lst[2] = {1, 1};
        for(int k=1; k<=N; k++){
            for(auto [type,l,r] : Interval[k]){
                while(!Q[type].empty() && Q[type].front().second < l) Q[type].pop_front();
                for(; lst[type]<=r; lst[type]++){
                    int v = lst[type];
                    while(!Q[type].empty() && Q[type].back().first <= f(type, v)) Q[type].pop_back();
                    Q[type].emplace_back(f(type, v), v);
                }
                D[i][k] = max(D[i][k], g(type, k));
            }
        }
    }
    cout << *max_element(D[M]+1, D[M]+N+1) << "\n";
}

막대기 연결

2차원 좌표 평면에 $N$개의 점 $(X_i,Y_i)$가 주어진다. 쿼리로 $l,r$이 주어지면 $l \leq a < b \leq r$를 만족하는 $(X_b-X_a) \times (Y_a+Y_b)$의 최솟값을 구하는 문제
$N,Q \leq 10^5;$ $1 \leq X_i,Y_i \leq 10^9;$ $X_{i-1} < X_i$

전혀 모르겠어서 풀 태스크 바로 아래 단계인 $N,Q \leq 3\,000$인 부분 문제를 풀었습니다. DP와 비슷한 느낌으로 $O(N^2)$ 시간에 모든 쿼리에 대한 답을 전처리하면, 각 쿼리에 대한 답을 상수 시간에 구할 수 있습니다. 자세한 방법은 글로 설명하는 것보다는 코드를 보는 것이 좋을 것 같습니다.

void Solve(){
    cin >> N;
    for(int i=1; i<=N; i++) cin >> X[i] >> H[i];
    cin >> Q;
    for(int i=1; i<=Q; i++) cin >> L[i] >> R[i];
    for(int i=1; i<=N; i++) for(int j=i+1; j<=N; j++) D[i][j] = (X[j] - X[i]) * (H[i] + H[j]);
    for(int d=2; d<=N; d++){
        for(int i=1, j=d+1; j<=N; i++, j++){
            D[i][j] = min({D[i][j], D[i+1][j], D[i][j-1]});
        }
    }
    for(int i=1; i<=Q; i++) cout << D[L[i]][R[i]] << "\n";
}

스마트 아파트 건설

간선으로 연결된 두 정점의 가중치 합이 $K$ 이하가 되도록 정점에 $1$부터 $N$까지의 가중치를 정확히 한 번씩 배정하는 문제
$T \leq 610;$ $N \leq 20$

이 문제도 전혀 모르겠어서 풀 태스크 바로 아래 단계인 $N \leq 8$인 부분 문제를 풀었습니다. $O(N^2 \times N!)$ 시간에 브루트포스를 하면 됩니다.

void Solve(){
    cin >> N >> K >> M; R = 0;
    vector<pair<int,int>> E(M);
    for(auto &[u,v] : E) cin >> u >> v, u--, v--;
    vector<int> O(N); iota(O.begin(), O.end(), 1);
    do{
        bool flag = true;
        for(auto [u,v] : E) if(O[u] + O[v] > K){ flag = false; break; }
        R += flag;
    }while(next_permutation(O.begin(), O.end()));
    cout << R << "\n";
}
]]>
JusticeHui
0804-0815 PS2023-08-15T00:00:00+00:002023-08-15T00:00:00+00:00https://justicehui.github.io/ps/2023/08/15/0804-0815문제 목록

12일 동안 푼 60문제 중 12문제를 골라서 풀이를 작성합니다.

    BOJ 16243 Teoreticar

    문제 링크

    이분 그래프 $G=(L\cup R,E)$가 주어진다. 최소 간선 채색 수를 $OPT$라고 할 때, $2^{\lceil \log_2 OPT\rceil}$개 이하의 색을 이용해 간선을 색칠하는 문제
    $L,R \leq 100\,000;$ $M\leq500\,000$

    그래프의 간선 채색 수는 차수의 최댓값보다 작을 수 없고, 이분 그래프에서는 홀의 결혼 정리를 이용하면 항상 차수의 최댓값 만큼의 색으로 간선을 칠할 수 있음을 보일 수 있습니다. 즉, $OPT = \max \text{deg}(v)$입니다.

    문제 제한에서 $\log_2 OPT$ 같은 게 보이니 분할 정복을 생각해 봅시다. 구체적으로, 모든 간선의 색을 구하기 위해 깊이가 최대 $\lceil \log_2 OPT \rceil$인 분할 정복을 하면서, 매번 간선 집합을 크기가 절반 두 개의 집합으로 분할한 다음 두 집합을 서로 다른 색으로 칠할 것입니다. 깊이가 $d$인 분할 정복 과정에서 한쪽 집합은 $2^d$를 나타내는 비트를 끄고 다른 집합은 $2^d$를 나타내는 비트를 켜는 것이라고 생각해도 됩니다.

    같은 정점을 공유하는 간선이 없도록 간선 집합을 분할해야 하는데, 이건 그래프의 오일러 투어를 구한 다음 홀수 번째 간선과 짝수 번째 간선으로 나누면 쉽게 처리할 수 있습니다. 오일러 투어는 간선 개수에 비례하는 시간에 구할 수 있으므로 시간 복잡도는 $T(M)=2T(M/2)+O(M)=O(M \log M)$이 됩니다.

    BOJ 16242 Strah

    문제 링크

    .#으로 구성된 $N\times M$ 크기의 격자가 주어진다. 격자의 각 칸마다, 그 점을 포함하면서 .으로만 구성된 직사각형의 개수를 모두 더한 값을 구하는 문제
    $N,M \leq 2\,000$

    각 점을 포함하는 직사각형의 개수를 세는 것은 결국 만들 수 있는 모든 직사각형의 넓이를 더하는 것과 같습니다. 직사각형의 넓이로 보는 편이 히스토그램과 같은 전형적인 테크닉을 적용하기 편하므로 넓이의 합을 구하는 문제라고 생각합시다. 이 문제는 스택을 사용해서 해결할 수도 있지만, 정리해야 할 게 많은 것 같아서 시간 복잡도에 $\log M$이 붙는 대신 식 정리가 조금 더 간단해 보이는 분할 정복을 사용했습니다.

    문제를 본격적으로 풀기에 앞서, $A(i,j)$에서 출발해 #을 만나지 않고 올라갈 수 있는 최대 길이를 $H(i, j)$라고 정의합시다. 간단한 점화식을 이용해 $O(NM)$ 시간에 모두 계산할 수 있습니다.

    이제 직사각형의 밑변이 될 행을 고정한 다음, 각 행에 대해 $O(M \log M)$에 해결할 것입니다. $m$번째 막대를 조금이라도 포함하는 모든 직사각형의 넓이를 구하는 방식으로 진행합니다. $l$번째 막대부터 $r$번째 막대만 고려했을 때, $m = \lfloor (l+r)/2 \rfloor$번째 막대를 지나는 모든 직사각형의 넓이를 $O(r-l)$ 시간에 구할 수 있다면 각 행을 $O(M \log M)$에 처리할 수 있습니다.

    직사각형의 왼쪽 변의 위치 $s$와 오른쪽 변의 위치 $e$ ($l \leq s \leq m \leq e \leq r$)를 선택해서 만들 수 있는 직사각형의 최대 높이 $f(s,e)$를 생각해 봅시다. $f(s, e) = \min\left{H(i,s),H(i,s+1),\cdots,H(e)\right}$이므로 $[l,r]$ 구간에서 나올 수 있는 서로 다른 $f(s,e)$의 값은 최대 $O(r-l)$가지입니다. 그 높이를 각각 $h_1<h_2<\cdots$ 이라고 하고, $f(s,e) = h_k$를 만족하는 가장 큰 구간을 $[s_i,e_i]$라고 합시다. $s_i \leq s_{i+1} \leq e_{i+1} \leq e_i$를 만족한다는 것은 쉽게 알 수 있습니다. $s_i$와 $e_i$는 투 포인터를 이용해 $O(r-l)$ 시간에 구할 수 있습니다.

    이제, 높이가 작은 것부터 차례대로 처리합시다. 즉, $m$번째 막대를 지나면서 높이가 $(h_{i-1}, h_i]$인 직사각형의 넓이의 합을 구할 것이고, 다음과 같은 식을 계산하면 됩니다.

    \[\displaystyle \sum_{h=h_{i-1}}^{h_i}\sum_{x=s_i}^{m}\sum_{y=m}^{e_i} h(y-x+1)\]

    이 식은 열심히 전개하면 $S(h_{i-1},h_i) \times \left{ L(s_i,m)S(m,e_i) + L(m,e_i)S(s_i,m) + L(s_i,m)L(m,e_i) \right}$가 되고, 이는 상수 시간에 계산할 수 있습니다. (단, $L(a,b)=b-a+1,S(a,b)=(a+b)(b-a+1)/2$)
    따라서 $m$을 지나는 모든 직사각형의 넓이의 합을 $O(r-l)$ 시간에 해결할 수 있고, 각 행을 $O(M \log M)$ 시간에 처리할 수 있으므로 전체 문제를 $O(NM \log M)$ 시간에 해결할 수 있습니다.

    BOJ 24276 Circle

    문제 링크

    원의 둘레를 따라 $N$개의 정점이 있고, 정점들은 시계 방향으로 $1$부터 $N$까지의 번호가 매겨져 있다. $M$개의 간선이 주어지는데, 간선은 정점을 제외한 곳에서 교차하지 않는다. 최소 개수의 색으로 그래프의 정점을 칠하는 문제
    $2 \leq N \leq 5\times 10^5;$ $1 \leq M \leq 5\times 10^5$

    색깔은 얼마나 많이 필요할까요? 일단 간선이 서로 교차하지 않는 평면 그래프이므로 4개 이하의 색으로 칠할 수 있음은 쉽게 알 수 있습니다. 조금 더 생각해 보면, 문제의 조건을 충족한 채로 간선을 더이상 추가하지 못하는 상황인 그래프는 볼록 다각형의 삼각분할 형태일 것이고, 바깥에 있는 삼각형부터 하나씩 떼어낸다고 생각하면 3개의 색으로 칠할 수 있다는 것을 알 수 있습니다.

    실제로 3개의 색으로 칠하는 방법도 이러한 관점에서 생각하는 것이 편합니다. 삼각형을 하나씩 떼어낸다는 것은 차수가 2인 점을 녹여서 두 간선을 병합하는 것이라고 생각할 수 있습니다. 따라서 위상 정렬과 비슷하게 degree가 2인 정점을 하나씩 제거한 다음, 제거된 순서의 역순으로 색칠할 수 있는 가장 작은 번호의 색으로 색칠하면 됩니다.

    2개의 색으로 칠할 수 있는 이분 그래프만 예외처리하면 어렵지 않게 문제를 해결할 수 있습니다.

    사실 문제에서 주어지는 그래프는 outer planar graph이므로 treewidth가 2 이하이기 때문에 항상 3개 이하의 색으로 칠할 수 있음을 바로 보일 수도 있습니다. 삼각형을 떼어내는 것은 chordal graph의 perfect elimination ordering을 구하는 것, degree가 2인 정점을 녹이는 것은 treewidth가 2인 그래프의 tree decomposition을 구하는 것과 비슷한 느낌이라서 그래프 관련해서 다양한 개념을 공부했다면 자연스럽게 떠올릴 수 있는데… 푼 사람이 많은 걸 보면 몰라도 전혀 상관 없는 것 같습니다.

    BOJ 11776 NEKAMELEONI

    문제 링크

    $1\ldots K$ 범위의 원소로 구성된 배열 $A[1\ldots N]$이 주어진다. 배열의 값을 변경하는 쿼리가 주어질 때마다 $1\ldots K$를 모두 포함하는 가장 작은 구간의 크기를 구하는 문제
    $N,Q\leq 100\,000;$ $K \leq 50$

    $K$가 50 이하로 매우 작다는 것에 주목합시다. 구간 합의 최댓값을 관리하는 세그먼트 트리처럼 구간에 대한 정보를 적당히 저장해서 문제를 해결할 수 있습니다.

    세그먼트 트리의 각 정점에서는 (1) 정점이 담당하는 구간에서의 최소 길이, (2) 구간의 앞에서부터 원소를 누적했을 때 집합의 크기가 증가하는 위치와 그 집합, (3) 뒤에서부터 누적했을 때 집합의 크기가 증가하는 위치와 그 집합, 이렇게 세 가지 정보를 저장합니다. 원소의 종류는 $K$가지밖에 없으므로 2와 3의 크기는 공집합을 포함해서 최대 $K+1$입니다.

    두 정점의 값을 합치는 것은 대충 구현하면 $O(K^2)$에 구현할 수 있고, 투 포인터를 이용하면 $O(K)$에 구현할 수 있습니다. 따라서 전체 문제를 $O(NK+KQ\log N)$에 해결할 수 있습니다.

    BOJ 16904 집합과 쿼리

    문제 링크

    집합에 양의 정수를 추가하는 쿼리와 제거하는 쿼리가 주어진다. 쿼리를 수행할 때마다 모든 원소를 XOR한 값이 최대인 부분 집합을 찾는 문제
    $N \leq 500\,000;$ $1 \leq x \leq 2\times 10^9;$ 오프라인 가능

    오프라인 동적 연결성과 같은 방식으로 분할 정복을 이용해 해결할 수 있습니다. basis의 크기는 30 이하이므로 굳이 롤백 연산을 구현할 필요 없이, 단순히 재귀 호출할 때 basis 배열을 call by value 방식으로 넘겨도 충분합니다. 따라서 원소가 삭제되는 상황에서 xor basis를 관리할 필요는 없고, 원소가 추가되는 상황만 고려해도 문제를 해결할 수 있습니다.

    코드: http://boj.kr/5f07eaa12e89438e9d2fdfd45d62da58

    BOJ 13946 Bipartite Blanket

    문제 링크

    정점에 가중치가 있는 이분 그래프 $G=(L\cup R,E)$가 주어진다. 가중치의 합이 $T$ 이상이면서 완전 매칭이 존재하는 정점 집합 $V\subseteq L\cup R$의 개수를 구하는 문제
    $L,R\leq 20;$ $T \leq 4\times 10^8$

    두 정점 집합 $A\subset L,B\subset R$가 주어졌을 때, $A$의 모든 정점을 포함하는 매칭이 존재할 필요 충분 조건은 모든 $a\subset A$에 대해 $\vert a\vert \leq \vert N(a)\vert$입니다(홀의 결혼 정리). 이 정리를 이용하면, 완전 매칭이 존재할 정점 집합 $V$는 홀의 정리에서 제시한 조건을 만족하는 $A\subset L$과 $B\subset R$의 합집합입니다. 조건을 만족하는 $A, B$는 $O(n2^n+m2^m)$ 시간에 구할 수 있습니다. 가중치의 합이 $T$ 이상인 쌍은 정렬한 다음 투 포인터를 이용해 쉽게 찾을 수 있습니다.

    BOJ 8177 Ice Skates

    문제 링크

    발 사이즈가 $r$인 사람은 크기가 $r$ 이상 $r+D$ 이하인 스케이트화를 신을 수 있다. 크기가 $1, 2, \cdots, N$인 스케이트화가 각각 $K$개씩 있을 때, 사람이 추가/제거되는 쿼리가 $Q$번 주어질 때마다모든 사람이 스케이트화를 신을 수 있는지 판별하는 문제
    $D < N \leq 200\,000;$ $M \leq 500\,000;$ $K\leq 10^9$

    홀의 정리를 사용하면 될 것처럼 생겼습니다. 발 사이즈가 $i$인 사람의 수를 $C_i$라고 하면, 모든 $1 \leq l \leq r \leq N-D$에 대해 $A_l+A_{l+1}+\cdots A+r \leq K(r-l+1+D)$를 만족하는지 확인하면 됩니다. 식을 변형하면 $\sum_{i=l}^r A_i-K \leq KD$가 되고, 이는 $A_i-K$의 부분 합의 최댓값이 $K\times D$보다 작은지 확인하는 것과 같습니다. 따라서 부분 합의 최댓값을 관리하는 세그먼트 트리, 흔히 금광 세그라고 부르는 자료구조를 이용하면 $O(N+Q\log N)$ 시간에 문제를 해결할 수 있습니다.

    BOJ 4223 Mummy Madness

    문제 링크

    당신과 $N$명의 미라들은 격자 위에서 턴을 주고 받으며 술래잡기를 한다. 당신이 먼저 턴을 갖는데, 인접한 8개의 칸 중 하나로 이동하거나 가만히 있을 수 있다. 그 다음에 $N$명의 미라가 모두 이동하는데, 미라는 단순히 당신과 유클리드 거리가 최소가 되는 인접한 사각형으로 이동한다. 최선을 다해 미라를 피해다닌다고 할 때, 몇 턴이 지난 후에 미라에게 잡히게 되는지 구하는 문제
    $N \leq 10^5;$ $-10^6 \leq x_i,y_i \leq 10^6$

    각 개체가 $k$턴 동안 이동할 수 있는 범위는 한 변의 길이가 $2k+1$인 정사각형이라고 생각할 수 있습니다. 로봇은 항상 가까워지는 방향으로 움직이기 때문에 최선을 다한다고 생각할 수 있고, 따라서 플레이어가 미라의 이동 가능 범위로 들어가면 무조건 $k$턴 안에 잡히게 됩니다.

    따라서 $k$턴 안에 잡히는지 확인하는 결정 문제를 해결하는 파라메트릭 서치를 생각할 수 있습니다. 결정 문제의 파라미터로 $k$가 주어지면, 각 미라를 중심으로 하는 한 변의 길이가 $2k+1$인 정사각형들의 합집합이 플레이어를 중심으로 하는 정사각형을 모두 커버하는지 확인하면 됩니다. 이러한 결정 문제는 미라의 이동 범위와 플레이어의 이동 범위의 교집합인 직사각형의 합집합의 넓이가 $(2k+1)^2$인지 확인하는 것으로 해결할 수 있고, 직사각형 합집합의 넓이를 구하는 것은 $O(N \log N)$에 할 수 있음이 잘 알려져 있습니다.

    결정 문제를 $O(N \log N)$ 시간에 해결할 수 있으므로 전체 문제를 $O(N \log N \log X)$에 해결할 수 있습니다.

    BOJ 17512 Gosu 2

    문제 링크

    서로 다른 두 정점 사이에 정확히 한 개의 간선이 존재하는 방향 그래프(토너먼트 그래프)가 주어진다. 모든 $i < j$에 대해, $S_i$에서 $S_j$로 가는 간선이 있는 정점들의 나열 $S_1, S_2, \cdots, S_{1 + \lfloor \log_2 N \rfloor}$를 구하는 문제
    $N \leq 512$

    편의상 $K = 1 + \lfloor \log_2 N \rfloor$라고 합시다. $S_1$은 체인에 속한 모든 정점 $S_2, S_3, \cdots, S_K$로 가는 간선이 있어야 합니다. 마찬가지로 $S_2$는 $S_1$을 제외한 모든 정점 $S_3,\cdots, S_K$로 가는 간선이 있어야 하고, $S_3$은 $S_1,S_2$를 제외한 모든 정점으로 가는 간선이 있어야 합니다. 앞에 있는 정점부터 차례대로 하나씩 구해 봅시다.

    out-degree가 가장 큰 정점을 생각해 보면, 이 정점의 out-degree는 항상 $(N-1)/2$ 이상이라는 것을 알 수 있습니다. 그러한 정점을 $S_1$으로 둔 다음, $S_1$에서 바로 갈 수 없는 정점을 제거합시다. 그래프에는 $N/2$ 이상의 정점이 남아있는 상태입니다.

    이제 다시 out-degree가 가장 큰 정점 $S_2$를 선택합시다. 이 정점의 out-degree는 $N/4$ 이상이고, $S_1$에서 갈 수 없는 정점을 모두 제거했기 때문에 $S_2$에서 한 번에 갈 수 있는 정점은 $S_1$에서도 한 번에 갈 수 있습니다. 따라서 그 정점을 $S_2$로 둘 수 있습니다.

    이런 식으로 체인의 앞에서부터 정점을 하나씩 확정짓고 필요없는 정점을 제거하더라도, 절반 이상의 정점은 살아남기 때문에 $1+\lfloor \log_2 N\rfloor$개의 정점을 찾을 수 있습니다.

    BOJ 20608 Dynamic Convex Hull

    문제 링크

    사차 함수 $f_i(x)=(x-a_i)^4+b$를 추가하는 쿼리, 함수 $f_i$를 삭제하는 쿼리, $x$가 주어지면 $\max f_i(x)$를 구하는 쿼리까지, 총 3가지 쿼리를 오프라인으로 처리하는 문제
    $Q \leq 2\times 10^5;$ $1 \leq a,x \leq 5\times 10^4;$ $1 \leq b \leq 10^{18}$

    주어지는 모든 함수는 $y=x^4$를 평행이동시킨 것이므로 서로 다른 두 함수 간의 교점은 최대 1개만 존재합니다. 따라서 리차오 트리에서 가장 마지막에 삽입된 함수를 제거하는 기능을 구현한 다음, 오프라인 동적 연결성 문제처럼 분할정복을 하면 $O(Q \log Q \log X)$ 정도에 문제를 해결할 수 있습니다.

    BOJ 4815 Wealthy Family

    문제 링크

    정점에 가중치가 있는 rooted tree가 주어진다. 조상-자손 관계인 정점들이 선택되지 않도록 정확히 $K$개의 정점을 선택할 때, 가능한 가중치 합의 최댓값을 구하는 문제
    $N \leq 150\,000;$ $K \leq 300;$ $A_i \leq 1\,000$

    간단한 트리 DP 문제입니다. $D(v, k) := $ $v$를 루트로 하는 서브 트리에서 정점을 $k$개 선택했을 때의 가중치 최댓값이라고 정의하면, 크기가 $S_a,S_b$인 두 트리의 DP 값을 합치는데 $O(\min\left{S_a,K\right} \times \min\left{S_b,K\right})$ 만큼의 시간이 걸려서 전체 시간 복잡도는 $O(NK^2)$이 되어서 시간 초과를 받을 것 같지만… 사실은 $O(NK)$라서 문제를 풀 수 있습니다. KOI 19 고등부 3번 검은 돌 문제와 비슷한 원리로 시간 복잡도가 보장됩니다.

    BOJ 12456 모닝커피 (Large)

    문제 링크

    수행하는데 1초가 걸리는 작업이 $N$가지 있다. $i$번째 작업은 최대 $c_i$번 할 수 있으며 한 번 수행할 때마다 $s_i$ 만큼의 돈을 얻을 수 있고, $t_i$초 이후에는 수행할 수 없다. $K$초 동안 작업을 수행하려고 할 때, 얻을 수 있는 최대 이득을 구하는 문제
    $N \leq 100;$ $1 \leq c_i,t_i \leq K \leq 10^{12};$ $1 \leq s_i \leq 1000$

    마감 시간이 있는 단위 시간 작업 스케줄링 문제는 매트로이드 구조이므로 가중치가 큰 작업부터 최대한 많이 수행하는 방식의 그리디를 이용해 문제를 해결할 수 있습니다. 같은 작업이 최대 $10^{12}$개 존재하므로 각 작업을 수행할 구간을 관리해야 하며, 작업의 종류는 최대 100가지로 많지 않기 때문에 효율적으로 구현할 필요는 없습니다.

    ]]>
    JusticeHui