아직도 고등학생 시절과 똑같은 삶을 살고 있는데 어느새 대학교 졸업이 코앞으로 다가와 버린 만 22세 대학교 4학년 직장인의 이야기입니다. 지난 몇 년 동안 연말정산 글 분량의 대부분을 PS로 채웠는데, 올해라고 크게 다르진 않습니다. 4학년인 만큼 대학교 생활의 전반적인 리뷰와 취업 관련 내용이 조금 추가되었습니다.
2020년 11월에 대학교 합격을 확정지은 뒤, 대학교에 다니는 동안 시기 별로 이루고 싶은 목표와 나름의 계획을 세웠었습니다.
간단히 요약하면, 2학년 때까지 PS로 얻을 수 있는 이득을 모두 얻고 취업의 하한선을 끌어올린 뒤, 남은 시간 동안 제가 미처 경험해 보지 않은 분야를 살펴보면서 진로를 정하고 싶다는 계획을 세웠었습니다. 하지만 현실은…
연구와 맞지 않는다는 것을 1학년 1학기에 깨달은 것은 계획보다 빨랐지만 오히려 좋은 흐름이라고 생각하고, SCPC에서 상을 받지 못한 것은 앞으로도 기회가 많이 남았으므로 별로 문제가 되지 않습니다. 하지만 ICPC World Finals에 진출한 것이 2학년 때 PS를 접겠다는 계획에 제동을 걸었습니다.
사실 저 말고도 이 대회 때문에 인생의 계획이 꼬인 사람은 많습니다. 지난 몇 년 동안 COVID-19 때문에 WF 개최가 1년 정도 밀려있었고, ICPC HQ는 2022 WF와 2023 WF를 2023년 11월에 이집트에서 동시 개최하겠다는 결단을 내렸습니다. 그리고 그 대회는 이스라엘-하마스 전쟁 때문에 2024년 4월로 연기되었고, 이에 따라 2021년과 2022년 ICPC 리저널 대회를 치른 사람들은 2024년 4월까지 PS도 못 접고 군대도 못 가고 대회 개최만 기다리게 되었습니다.
아무튼 PS를 못 접게 된 저는 “이왕 이렇게 된 거 WF 한 번 더 가자”는 마인드로 휴학 계획을 철회하고 2023년에도, 2024년에도 PS를 하게 됩니다.
2023년에는 2022년보다 훨씬 강한 팀으로 ICPC에 참가해 순조롭게 WF 티켓을 따냈습니다. 하지만 졸업을 1년 정도밖에 남겨두지 않은 시점에서 SCPC 수상에 한 번 더 실패하면서 처음으로 졸업 전에 취업할 수 있을지 진지하게 고민하게 되었습니다. PS로 취업을 날로 먹겠다는 생각을 버리고 진로를 고민하려고 했으나 할 줄 아는 게 없어서 고민하고 있던 와중에, 다행히 네이버에서 자료구조를 잘하는 인턴을 구한다는 공고를 보고 지원해 합격하게 되어 취업 걱정을 어느 정도 덜게 되었습니다.
2024년에는 애증의 대회인 ICPC World Finals에 2번 참가했고, 대회 참가 4번째 만에 드디어 SCPC에서도 상을 받았습니다. 이미 다른 회사에 합격한 상태여서 수상의 기쁨은 과거의 제 간절함에 비해 덜했지만, 그래도 8년 동안의 알고리즘 대회 여정에서 유종의 미를 거두었다는 점에서 참 기뻤습니다.
이 밖에도 대학교에 다니면서 꼭 이루고 싶은 목표로 “선린인터넷고등학교 나정휘보다 숭실대학교 나정휘를 유명하게 만들기”가 있었습니다. 고등학생 때부터 백준 랭커에 블로그도 유명하고 UCPC 출제도 하는 등 PS 판에서는 어느 정도 인지도가 있었습니다. 하지만 과거에만 머물러 있지 않고 더욱 발전하는 사람이 되고 싶었고, 그때보다 더 좋은 성과를 거두고 싶었기에 저런 목표를 세웠습니다. 그리고 그 목표는 다행히 성공적으로 이룬 것 같습니다.
졸업 전에 어떻게든 취업하는 것을 제외하면 계획대로 된 것이 하나도 없지만 목표했던 바를 졸업 전에 모두 이루었다는 점에서, 그리고 인턴 때와 지금 모두 회사에서 모두 분산 시스템을 다루는 점에서, 제 대학 생활은 eventually consistency 정도의 단어로 요약할 수 있을 것 같습니다.
너무 길어서 다른 글로 분리했습니다. (링크)
올해도 여러 대회의 문제 출제/검수에 관여했습니다.
제 손을 거친 많고 많은 문제 중, 저에게 가장 뜻깊은 문제는 2024 KOI 2차 중3/고2 XOR 최대 입니다. 고등학생 때 KOI 고등부 2번을 안정적으로 풀 수 있는 실력을 갖추기 위해 정말 많은 노력을 들였지만 실패했는데, 그런 고등부 2번에 아이디어부터 세팅까지 모두 제 손으로 작업했다는 것이 감격스러웠습니다. 그전까지는 다른 사람들이 만든 문제를 세팅하기만 해서 KOI 홈페이지에 이름이 올라가진 않았는데, 드디어 홈페이지(링크)에 문제를 올릴 수 있게 되었습니다.
제가 만든 문제에 대한 몇 가지 코멘트를 덧붙이자면…
2024 KOI 2차 중3/고2 XOR 최대 문제는 2년 전에 한 코드포스 문제(링크)를 잘못 읽은 채로 풀어서 나온 문제입니다. 처음에는 랜덤 데이터에서 $O(N)$에 해결하는 풀이만 있었지만, 문제 아이디어 발전 과정에서 suffix array를 이용한 $O(N \log N)$ 풀이가 나왔고, 이후에 별다른 알고리즘을 사용하지 않는 $O(N)$ 풀이가 나와서 대회에 나올 수 있게 되었습니다.
2024 LGCPC 예선 A. 고장난 계산기 문제는 이것을 보고 만든 문제입니다. 이를 이용하면 파이썬 코드 2~3줄 정도(링크)로 간단하게 풀 수 있지만, 직접 수식 파싱을 하는 분들이 많아서 안타까웠습니다. 정답 코드와는 다르게, 체커는 약 190줄(링크)입니다. recursive descent parser를 이용해 수식이 올바른지 확인한 뒤, 나올 수 있는 값은 DP를 이용해 구하는 방식입니다.
Hello, BOJ 2024! B. 2024는 무엇이 특별할까? 문제는 원래 숭실대학교 교내 대회에 내려고 했지만, Hello BOJ에 문제가 부족해서, 그리고 교내 대회에 내기에는 너무 좋은 문제라서 Hello BOJ에 냈습니다. 처음에는 $K = 1$인 문제의 풀이를 만들었는데, 조금 더 고민해 보니 어렵지 않게 $K > 1$로 확장할 수 있어서 $K \le 10^{18}$ 버전으로 출제했습니다.
2024 숭실대 교내 대회 B. 팀명 정하기 2 문제는 2020-2023 숭실대학교 ICPC 수상 팀 이름을 이용해 만든 문제, 2024 숭실대 교내 대회 C. 온데간데없을뿐더러 문제는 제목과 난이도를 먼저 정하고 만든 문제입니다. 2024 숭실대 교내 대회 F. 피보나치 기념품 문제는 NP-Complete 문제인 subset sum 문제에 조건을 추가해서 다항 시간에 풀리도록 만든 문제입니다. 예상했던 것보다 많이 풀려서 놀랐습니다.
이 밖에도 용돈을 벌기 위해 문제를 몇 개 출제하기도 했습니다.
10년 동안 안 하던 해외여행을 ICPC 덕분에 몰아서 했습니다. 2월에 베트남 하노이(2024 ICPC Asia Pacific Championship), 3월에 이집트 룩소르(2023 ICPC World Finals), 9월에 카자흐스탄 아스타나(2024 ICPC World Finals)까지… 참 힘들었습니다.
2024 Asia Pacific Championship 후기는 (링크)에서 볼 수 있습니다.
2023 ICPC World Finals는 팀원 2명이 모두 PS를 접은 사람이라서 (포스텍 팀에게는 미안하지만) 아무런 기대를 하지 않고 참가했고, 이집트 유적지만 열심히 구경하다가 왔습니다. 대회가 룩소르에서 열려서 피라미드는 보지 못했지만, 여러 신전과 왕가의 계곡(valley of the kings)에 있는 여러 파라오(람세스 6세, 투탕카멘, 세티 1세)의 무덤 등을 봤습니다. 분명 이집트 갔다가 돌아온 직후에는 싫었던 기억(특히 날씨)만 가득했었던 것 같은데, 반년 넘게 지난 지금 다시 돌아보면 안 좋았던 것들은 전혀 기억나지 않고, 관광지 구경하면서 신기해했던 것만 생각납니다.
2024 ICPC World Finals는 운이 아주 좋으면 동메달 끝자락을 노려볼 수도 있다는 약간의 희망을 품고 출발했지만, 메달의 벽은 너무 높았습니다. 한 문제 정도는 더 풀 수 있었을 것 같은데, 그보다 더 위는 아무리 해도 못 갈 것 같습니다. 제가 마지막에 한 문제를 못 푼 채로 끝나서 완전히 만족할 만한 결과라고 생각하진 않지만, 그래도 마지막 ICPC를 기분 좋게 끝내서 다행이라고 생각합니다.
월드 파이널 2개 후기 언제 올리지…
이 밖에도 글 초반에 언급했듯이 SCPC에서 4번의 도전 끝에 5등상을 받았고, UCPC는 2018년 계절학교에서 만난 친구들과 함께 팀을 만들어서 재미있게 쳤습니다. UCPC는 제가 조금만 더 침착했으면 상 받을 수 있었을 것 같은데, 팀을 꾸릴 당시에는 수상 생각 없이 즐겜용으로 만든 팀이었어서 크게 아쉽지는 않습니다.
올해는 계절학교 코치 대신 교수님 12명과 함께 유일한 학부생 신분의 강사로 참여했습니다. 여름학교에서 동적 계획법과 오토마타를 주제로 강의했고, 강의 자료는 (여기)에서 볼 수 있습니다. 국가대표 합숙 교육에 코치로 참가해서 약 2주 동안 카이스트 기숙사에서 생활하기도 했습니다.
solved.ac 에서는 프로 플랜을 구독하면 그날 푼 가장 높은 난이도의 문제로 스트릭 색을 칠할 수 있습니다. 작년 9월부터 약 15개월 동안 모든 다이아몬드(파란색)를 하나의 connected component로 관리하고 있습니다. 학교에 다닐 때는 금요일과 토요일이 가장 시간이 많아서 그때 다이아몬드 문제를 풀었는데, 회사에 들어간 이후로는 회사에서 주말 역할을 하는 일요일과 월요일에 풀고 있습니다. 입사 3주 전부터 아래에 있던 파란색 덩어리를 위로 끌어올리느라 고생을 좀 했던 게 기억납니다.
지금 와서 돌아보면 2022년 서울 리저널 이후로는 팀 연습을 제외하면 Competitive Programming을 위한 공부를 아예 하지 않고 있습니다. 제가 주로 공부하는 방식으로는 더 실력을 올리지 못할 것 같다는 느낌을 받은 것이 가장 큰 이유인 것 같습니다. 공부 방식을 바꾼다면 더 올릴 수 있겠지만, ICPC에서 제가 맡은 역할은 충분히 잘하고 있어서 굳이 바꿀 필요를 느끼지 못했습니다. 개인 대회, 특히 SCPC에서 성적이 안 좋은 것도 실력보다는 대회 전략의 문제라고 생각해서, SCPC 4~5등상 받는 것을 목표로 한다면 이대로 괜찮을 것 같았습니다. 실제로 3년 동안 SCPC에서 상을 받지 못하다가 올해 상 받은 것도 전략을 잘 세우고 그 전략대로 침착하게 행동해서 받은 거지, 실력이 올라서 받은 건 아니라고 생각합니다. 문제 푸는 실력만 본다면 오히려 2022년 SCPC 본선 당시가 더 잘했던 것 같습니다. 2024 ICPC World Finals가 끝나고 난 뒤에는 잠시 후회를 하긴 했지만…
1학기에는 졸업을 위해 필요한 20학점을 편하게 채우기 위해 온라인 과목 위주로 들었고, 2학기에는 수강 신청에서 무려 0과목을 신청해서 쓸 내용이 없습니다.
아는 사람들은 알겠지만, 저는 2021년 11월 초부터 머리를 길러서 2024년에 와서는 꽤 긴 머리카락을 달고 다녔습니다(영상1, 영상2, 영상3). 회사 입사를 2주 앞둔 9월 말에, 35개월 만에 머리를 잘랐습니다. 원래는 2023년 11월과 2024년 9월에 각각 ICPC World Finals가 있으니 긴 머리와 짧은 머리로 한 번씩 가서 사진을 남기면 좋겠다고 생각해서 2024년 봄 정도에 자를 생각이었습니다. 하지만 2023년 11월에 예정되어 있던 대회는 2024년 4월로 연기되었고, 이집트에 갔다 온 뒤로는 학교 시험, 취업 준비, 대회 운영 등으로 9월까지 정말 정신없는 순간의 연속이어서 머리 자를 생각을 못 하고 있었습니다.
알고리즘 대회도 다 끝나서 숨을 돌릴 틈이 생겼고, 마침 회사도 들어가니까 딱 적절한 시기라고 생각해서 9월 말에 자르게 되었습니다. 회사 때문에 머리 잘랐냐고 물어보는 분들이 꽤 있는데 그런 건 아닙니다. 9월 말에 머리 자른 이후로 지금까지 또 안 자르고 버티고 있습니다.
잘라낸 머리는 40cm 이상이었고, 대한민국사회공헌재단(링크)을 통해 기부했습니다. 어린 암 환자들에게 많은 도움이 되기를 바랍니다.
4학년이라 학교에서 시간을 많이 보내지 않아서 쓸 내용이 없고, 인턴십이나 취업 관련 내용은 다른 글로 분리해서 그런지 평소보다 양이 많이 적습니다.
내년 목표를 세울 차례입니다.
지난 4년 동안 작성한 팀노트 코드는 한정된 공간 안에 최대한 많은 내용을 담기 위해서 과도하게 압축해 놓은 상태이고, 전역 변수를 활용한 코드도 있어서 객체를 여러 개 생성하지 못하거나 함수를 여러 번 호출하면 결과가 달라질 수도 있습니다. 이제는 팀노트를 볼 일이 없으니, 지금보다 더 편하게 사용할 수 있는 라이브러리를 다시 만들어 보고 싶습니다.
고등학생 때 제가 가장 시간을 많이 들였던 일은 KOI와 NYPC 같은 대회에 참가하고, 국제정보올림피아드 계절학교에서 만난 친구들과 함께 공부하는 것이었습니다. 고등학교를 졸업한 뒤에도 이것들을 놓고 싶지 않아서 제가 직접 KOI, NYPC, 계절학교에 기여하고 싶다는 생각을 항상 했었고, 어떻게 인연이 닿아서 셋 모두에 많이 관여하는 사람이 되었습니다. 비슷하게, 저는 대학생으로 지내는 동안 ICPC 준비에 가장 많은 시간을 쏟았고, 아직은 ICPC 커뮤니티를 떠나고 싶지 않습니다. 따라서 서울 리저널 대회나 아시아 태평양 지역 대회, 아니면 BAPC와 같은 유럽 지역의 대회 등 어떤 방식으로라도 ICPC에 기여하고 싶어서 방법을 알아보려고 합니다.
위에서 PS 이야기만 한참 늘어놓았지만, 이제 제 신분은 직장인입니다. 아직은 일을 효율적으로 하지 못한다고 생각해서, 내년에는 생활 패턴을 교정하고 주변 환경을 정리해서 일을 효율적으로, 그리고 잘 집중해서 할 수 있는 환경을 만들려고 합니다.
어느새 졸업이 코앞으로 다가왔습니다. 지난 몇 년 동안 항상 명확한 목표가 있어서 그 목표를 향해 달렸었는데, 이제는 새로운 목표를 찾아야 할 때가 되었습니다. 앞으로 어떤 일이 펼쳐질지 기대가 되면서 두렵기도 합니다. 직장인의 삶은 어떤지 잘 체험해 보고 2025년 정산으로 돌아오겠습니다.
끝!
]]>인터넷을 보면 PS만 한 사람은 취업을 못 한다는 이야기가 많이 보입니다. 이 글은 지난 8년 동안 PS만 했던 한 사람의 취준 후기입니다.
대학교 2학년 때부터였나, PS만 한 사람은 취업을 못 한다는 글을 볼 때마다 조금씩 불안한 마음이 들어서, 슬슬 PS와 거리를 두고 먹고 살길을 찾아야 한다고 생각하고 있었습니다.
저는 초등학교 5학년 때부터 지금까지 11년 동안 코드를 짰으며 특성화고 소프트웨어과를 졸업하고 대학교에서도 컴퓨터를 전공하고 있지만, 부끄럽게도 무언가를 만들어내는 “개발”이라는 것을 거의 해본 적이 없고 내세울 만한 프로젝트도 없습니다. 이런 고민 탓에 재작년부터 주변 친구들과 선배들에게 조언을 여러 번 구했었습니다.
와 같은 조언을 들었고, 마침 2022년 말에 ICPC에서도 큰 성과를 거두었던 터라 자신감을 갖고 인턴에 지원했습니다. 사실 자신감이라기보다는 그냥 ICPC 하나만 믿고 도박을 한 것에 가깝습니다. 마치 KOI와 블로그만 믿고 카이스트부터 숭실대, 국민대까지 다양한 대학에 수시 원서 8개 넣은 고등학교 3학년 때처럼…
2022년에 친구가 올린 글(링크)을 보고 저도 구글 가고 싶어서 지원했습니다. 원래도 구글은 프로세스가 천천히 진행된다는 것은 알고 있었지만, 제가 직접 겪는 것은 또 다른 문제였습니다. “까먹을 때쯤 연락이 온다”는 표현이 가장 적절한 것 같습니다. 이 글을 쓰는 시점으로부터 약 2년 전에 지원했던 터라 자세한 내용은 잘 기억나지 않습니다. 혹시 다음에 지원하시는 분들을 위해 예전에 기록해 둔 날짜 위주로 적습니다.
1월 12~13일에 채용 공고가 올라왔습니다. 한 가지 문제라면 공고가 올라온 다음 날 구글 12000명 layoff 발표가 나와서… 1월 20일에 공고가 내려갔다가 2월 7일에 다시 공개되는 등 굉장히 정신없었던 것으로 기억합니다.
채용 공고가 다시 올라오자마자 하루 만에 resume와 cover letter등을 완성해서 제출했고, 2월 28일에 코딩 테스트를 보라는 연락을 받았습니다. 코딩 테스트는 2문제가 나왔고, 두 문제를 모두 해결하는 데 10분 정도 걸렸습니다. 이후 1달 정도 기다리니 3월 27일에 선호하는 언어와 프로그래밍 언어, 그리고 면접 가능한 시간을 알려달라는 연락이 왔습니다. 서류는 어떻게 합격했으니 이제 면접 준비를 열심히 하면 되겠다고 생각했지만…
5월 18일에 이후 프로세스를 진행하지 않겠다는 연락을 받았습니다. 2023년 여름에 인턴을 한 사람이 있다는 이야기가 있는 것을 보면 인턴을 아예 모집하지 않은 건 아닌 것 같고, 그냥 제가 우선순위가 낮았던 모양입니다. 서류 합격했다고 말해야 할지 떨어졌다고 말해야 할지 잘 모르겠습니다. 아무튼 구글 인턴은 탈락했지만, 제 resume에 적혀 있는 경력(?)이 아주 매력이 없진 않다는 것을 깨닫고 자신감을 얻었습니다.
지원 과정과 프로젝트 소개, 업무 내용 모두 (링크 - 네이버 Yorkie TF 인턴 생존기)에서 볼 수 있습니다.
작년 10월에 man_of_learning님의 소개로 Jane Street ETC에 참가했었는데, 대회가 종료된 후에 여름 인턴십을 모집한다는 메일을 받아서 11월 말에 지원했습니다. 트레이더로 지원하는 건 어림도 없어 보여서, 그나마 가능성이 있을 것 같은 SWE로 지원했습니다. resume는 구글에 냈던 것에 2023년 수상 실적 몇 개를 추가해서 냈고, 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배 정도 된다고 생각하고 있습니다.
그리고 4일 뒤에 불합격 메일이 왔습니다. 살면서 처음으로 영어 공부의 필요성을 느꼈다는 점에서 얻어가는 게 많은 면접이었다고 생각합니다. 여담으로, 집이 아닌 곳에서 노트북으로 문제 풀 때 배터리를 아끼기 위해서 IDE 대신 메모장(notepad.exe)에서 코드를 작성하는데, 자동 완성 안 되는 색깔 있는 메모장에서 코드 실행 없이 눈으로만 검사하는 인터뷰에서 이 경험이 아주 큰 도움이 되었습니다.
운 좋게 네이버에서 인턴십을 수행할 기회를 얻은 덕분에 조금 더 매력적인 이력서를 갖게 되었습니다. 좋은 회사에서 성공적으로(!!) 인턴 생활을 마친 것은 앞으로 저에게 큰 무기가 되어줄 것이라 믿었기 때문에, 2023년과 다르게 회사에 가지 못할 것 같다는 불안감은 들지 않았습니다.
한 번 더 지원했습니다. 2월 14일에 채용 공고가 올라왔고, 올라오자마자 작년에 작성한 resume와 cover letter를 조금 수정한 뒤 2월 15일에 바로 제출했습니다. 2월 26일에 정보(졸업 시기, 인턴 근무 가능 기간 등)를 조금 더 제공해 달라는 메일이 와서 이번에는 작년과 조금 다르다고 생각했고, 이틀 뒤인 28일에 면접 일정을 잡자는 연락을 받게 되었습니다.
면접은 3월 8일에 한국어 면접 1번 + 영어 면접 1번으로 진행되었습니다. 면접 문제는 어렵지 않아서 잘 풀었고, 3월 12일에 면접을 통과했다는 연락을 받았습니다. first batch of candidates 라면서 다음 프로세스(팀 매칭)가 시작되기 전까지 잠시 기다리라는 연락을 받았지만, 결국 5월 13일에 팀 매칭이 안 되었다는 통보를 받아 인턴은 하지 못하게 되었습니다.
2월 말에 ICPC Asia Pacific Championship 참가하러 베트남에 갔을 때, 몰로코에 다니고 있는 지인이 인턴 모집 열렸다고 알려줘서 지원했습니다. 몰로코는 구글과 다르게 팀 매칭 이후에 면접을 보기 때문에 채용 과정은 서류 전형 > 팀 매칭 > 면접 > 합격 순으로 이루어집니다. 저는 팀 매칭에 탈락해서 면접을 1번 보는지 2번 보는지는 모릅니다. “n달 뒤에 지원했으면 붙었을 확률이 높다” 같은 이야기가 아직까지 들려오는 것을 보면 타이밍이 안 좋지 않았나… 싶습니다.
링크드인을 통해 리크루터에게 연락을 받아서 지원하게 되었습니다. 처음 보는 이름이라서 조금 검색해 봤는데, 베트남, 싱가포르, 중국 등에 오피스가 있고 리모트 근무도 가능한 퀀트 회사라고 합니다. Codeforces에도 홍보 글(링크)을 올리는 것을 보면, 역시 퀀트 회사답게 PS에 관심이 많은 것 같습니다. 채용 과정은 서류 전형 > 코딩 테스트 > HR Call > 1차 면접 > 2차 면접 > 인턴십 수행 > 정규직 전환 이고, 프로세스가 굉장히 빨랐던 것이 기억에 남습니다.
코딩 테스트는 SQL을 빈칸으로 제출한 것(…)만 제외하면 쉬웠습니다. HR Call은 그래도 몇 달 전에 Jane Street에서 영어로 면접도 봤다고 꽤 괜찮게(?) 했던 것 같습니다.
1차 면접은 C, C++ 문법, Modern C++에서 추가된 것, 운영체제, C/C++의 컴파일 과정 등 학교에서 C/C++ 관련해서 배우는 전반적인 지식에 대한 질문과 알고리즘 문제로 구성되어 있었습니다. 알고리즘 문제로는 solved.ac 기준 D4인 문제가 그대로 나왔었는데, 그것보다는 덜 효율적인 풀이를 의도했을 것이라 믿고 쉬운 풀이로 풀었습니다.
결과는 이틀 만에 나왔고, 2차 면접도 보게 되었습니다. 2차 면접도 비슷하게 Modern C++ 문법과 운영체제 위주로 물어봤고, 컴퓨터 구조나 객체지향 이론에 대한 질문도 있었습니다. 그리고 알고리즘 문제 대신 구현 위주의 문제가 나와서 꽤 많은 양의 구현을 했던 것이 기억에 남습니다.
2차 면접 결과도 짧은 시일 내에 나올 줄 알았지만, 인턴십 기간으로 예정되어 있던 10월까지도 연락이 없었던 걸 보면 탈락이거나 저를 잊은 게 아닌가 싶습니다.
지원 과정과 공채 프로세스는 (링크 - 2024 팀네이버 신입 공채 Tech 직군 합격 후기)에서 볼 수 있습니다.
최종 결과는 6월 14일에 나왔으며, 2025년 2월 졸업 예정이라서 2024년 7월 초 입사와 2025년 1월 초 입사 중 하나를 선택할 수 있었습니다. 1학기를 끝으로 “8학기 재학”이라는 조건을 제외하면 모든 졸업 요건을 채워둔 상황이라 7월에 입사해도 문제는 없었지만, 반년 정도 놀다가 회사에 가고 싶어서 2025년 1월 입사를 선택했습니다.
2025년 1월 초에 입사하기로 하고 반년 동안 아무 생각 없이 놀려고 했지만, 8월쯤부터 다른 회사도 지원해 보고 싶다는 생각이 들기 시작했습니다. 8년 동안 문제만 풀던 사람이라 그런지 면접에서 문제 푸는 것이 굉장히 즐거웠고, 지금 다른 회사에 지원해 보지 않으면 미래에 후회할 것이라 생각했으며, 일단 선택지를 늘려두면 손해 볼 것은 없다고 생각했기에 바로 실천으로 옮겼습니다. 고등학교 3학년 때 대학 입시에서 숭실대학교에 합격하고 나서 다른 학교 면접에 모두 불참했던 것 때문에 후배들이 입시 준비할 때 많이 도움을 주지 못했는데, 이번에는 최대한 많은 경험을 해서 후배들에게 많은 도움을 주고 싶다는 마음도 컸습니다.
Presto Labs는 여러 알고리즘 대회에 후원하고 얻은 홍보 세션에서 “신입 트레이더 초봉 (아주 큰 수)원!”을 외치는 것으로 유명한 HFT 회사입니다. 저는 아는 분의 추천을 받아 Presto Labs에서 운영하는 가상화폐 거래소인 Flipster의 Junior Software Engineer (Backend) 포지션에 지원했습니다. 재택근무도 되고 오피스도 집에서 매우 가깝다는 점이 가장 매력적이었습니다.
채용 절차는 서류 전형 > 코딩테스트 > 1차 면접 * 2 > 2차 면접 * 2 > 합격 순으로 진행되었습니다. 1차 면접 2번은 모두 자료구조/알고리즘 질문만 나와서 예정된 시간보다 일찍 끝냈습니다. 2차 면접은 (난이도가 너프된) 시스템 디자인 면접 같은 것 한 번과 네이버 공채의 1처 면접(기술 역량 인터뷰)과 비슷한 것 한 번으로 구성되어 있었습니다. 작년 말에 네이버 인턴 면접 준비하면서 이 책을 읽었던 것이 큰 도움이 되어 다행히 합격할 수 있었습니다.
Quora는 IOI/ICPC 출신이 여러 명 있고, 2021년과 2022년에 프로그래밍 대회도 대최했어서 예전부터 알고 있었던 회사입니다. 알고리즘 대회를 함께 운영하면서 알게 된 분을 통해 Core Infrastructure 팀의 Software Engineer 포지션으로 지원했습니다. 채용 절차는 리크루터 콜 > 1차 면접 > 최종 면접 > 합격 순으로 진행되었습니다. 미국에 본사를 두고 있는 회사인 만큼, 대부분의 일정이 새벽에 진행되어서 힘들었습니다.
1차 면접은 Coding/Algo interview 였습니다. 알고리즘 문제를 풀고 설명하는 것은 제가 가장 자신있어하는 일이지만, 반대로 외국인과 영어로 대화하는 것은 제가 매우 어려워하는 일 중 하나입니다. 이번 면접은 이 두 가지를 동시에 해야 하는 면접으로, 당연히 잘해야 한다는 마음과 어떻게 해야 잘할 수 있을지 모르겠다는 마음이 교차하고 있었습니다. 실제 문제는 쉽게 나와서 풀이를 찾는 것과 구현하는 것 모두 막히는 부분 없이 잘했습니다.
최종 면접은 Coding/Algo interview, Language practical interview, Hiring manager interview로 구성되어 있습니다. Language practical interview는 처음 보는 형식의 면접이었습니다. C++, Java, Python 중 하나를 선택하면 해당 언어로 작성된 천 줄 단위의 프로젝트가 주어지고, 기능을 추가하거나 버그를 고치는 형식으로 진행됩니다. 문제를 풀 때 C++을 주로 사용했었지만 모던 C++ 문법에 익숙하지 않기 때문에, 이미 회사에 다니고 있는 분들의 조언을 받아 Python을 선택했습니다. 주어진 시간 동안 태스크 1개 빼고 전부 구현했고, 이 정도면 괜찮을 것 같다는 느낌을 받았습니다.
Hiring manager interview는 네이버의 2차 면접(종합 역량 인터뷰)과 비슷한 느낌으로, 회사의 인재상(Eng Value, 링크)에 대해 심도 있게 물어보는 질문이 많았습니다. 인터뷰어가 한국인이라서 한국어로 진행될 줄 알았는데, Zoom 들어오자마자 Hello~ 라고 하셔서 많이 당황했던 기억이 납니다. 앞에서 Coding/Algo나 Practical은 기술 관련 주제로만 이야기하면 돼서 나름 편하게 말할 수 있었는데, 이 면접은 제 가치관과 일상생활 속에서의 사례를 영어로 말해야 돼서 많이 힘들었습니다. 영어 단어를 몰라서 인터뷰어의 양해를 구하고 영어 사전을 찾아가면서 면접을 이어나갔습니다.
만약 떨어진다면 영어를 못해서 떨어질 것이라고 예상했지만, 면접을 본 지 일주일도 안 돼서 오퍼를 받았습니다. 네이버, 플립스터, 그리고 Quora 사이에서 많은 고민 끝에 Quora에 입사하는 것으로 결정했습니다.
여담으로, 6일 동안 플립스터 1차 면접(15:00 - 17:00), Quora 최종 면접(01:00 - 04:30), 플립스터 2차 면접(16:00 - 18:00)를 연달아서 보느라 굉장히 힘들었습니다. 그냥 학교 다니면서 수면 시간을 자유분방하게 가져가는 것은 쉬웠지만, 강제로 자유분방한 수면 패턴을 만드는 것은 굉장히 어려운 일이라는 것을 깨달았습니다.
ICPC Asia Pacific Championship와 SCPC에서 수상한 덕분에 각각 화웨이와 삼성전자에서 지원 권유를 받기도 했습니다. 하지만 화웨이는 홍콩 월세가 너무 비싸서, 그리고 삼성전자는 Quora에 합격한 이후에 연락을 받아서 지원하진 않았습니다.
2023년부터 조금씩 쓰던 글을 열심히 짜맞췄더니 시제도 꼬이고 문단마다 스타일도 조금씩 다른 것 같습니다. 길다면 길고 짧다면 짧은, 글 솜씨 없는 사람이 쓴 영양가도 없는 글인데도 불구하고 끝까지 읽어주셔서 감사합니다.
PS하면서 용돈 벌겠다고 알고리즘 강의했던 경험과 문제 만들던 경험이 큰 도움이 되었습니다. 알고리즘 강의를 안 했다면 면접에서 말을 제대로 못해서 고생했을 것이고, 문제를 안 만들었다면 주변에 저에게 채용 공고가 올라온 것을 알려주고 추천해 주신 분들을 만나지 못했을 것입니다. 이 밖에도 친구들 코드 디버깅해 주거나, 노트북 배터리 아깝다고 notepad.exe에서 문제를 풀던 것과 같이 별 생각 없이 했던 사소한 행동들 또한 면접 볼 때 정말 유용했습니다.
고등학생 때 담임 선생님께서 저에게 하셨던 말씀이 아직도 기억에 남습니다. 백준이 밥 먹여주냐, 알고리즘 문제 푸는 게 취업에 도움이 되긴 하냐, 이런 말씀을 저에게 하셨고, 사실 저도 스스로 의심이 될 때가 있었습니다. 하지만 지나고 보니 둘 다 맞는 말이었고, 모든 경험은 다 어떻게든 도움이 되는 것 같았습니다.
이제는 대학교도 졸업하고 알고리즘 대회도 그만 나가게 될 텐데, 앞으로 어떤 인생이 펼쳐질지 기대가 됩니다.
]]>문제 지문과 공식 풀이는 NYPC 아카이브에서 확인할 수 있습니다. 채점은 BIKO에서 받을 수 있습니다.
제가 생각하는 난이도(solved.ac 기준)는 다음과 같습니다. Round 1 게임, Round 2-B 점 짝짓기는 사람마다 편차가 클 것 같습니다.
PC 화면으로 보는 분들은 우측 사이드바를 이용해 원하는 문제로 빠르게 이동할 수 있습니다.
일반성을 잃지 않고 $A < B$라고 생각합시다. 만약 $3A < B$이면 매번 $A$를 1개, $B$를 3개씩 가져가더라도 모든 초밥을 옮길 수 없기 때문에 $-1$을 출력해야 합니다. 그렇지 않다면 항상 $3\min(A,B) \geq \max(A,B)$이 성립하도록 $\min(4,A+B)$개의 초밥을 옮길 수 있으므로 $\lceil \frac{A+B}{4} \rceil$번의 이동으로 모든 초밥을 옮길 수 있습니다.
#include <bits/stdc++.h>
using namespace std;
int main(){
ios_base::sync_with_stdio(false); cin.tie(nullptr);
int TC; cin >> TC;
for(int tc=1; tc<=TC; tc++){
int a, b; cin >> a >> b;
if(a > b) swap(a, b);
if(b > 3 * a) cout << -1 << "\n";
else cout << (a + b + 3) / 4 << "\n";
}
}
이차원 평면에 여러 개의 점이 주어졌을 때, 두 점을 이어서 만들 수 있는 x축 또는 y축에 평행한 서로 다른 직선의 개수를 구하는 문제입니다. 만약 x좌표가 같은 점이 2개 있으면 y축에 평행한 직선을 만들 수 있고, y좌표가 같은 점이 2개 있으면 x축에 평행한 직선을 만들 수 있습니다.
따라서 x좌표가 같은 점 또는 y좌표가 같은 점이 2개 이상 있다면 정답이 1 증가합니다. x좌표와 y좌표를 각각 모아서 정렬한 다음 투 포인터를 사용하거나, std::map
과 같은 연관 배열을 사용해서 $O(N \log N)$ 또는 (해시를 사용하면) $O(N)$ 시간에 문제를 해결할 수 있습니다.
#include <bits/stdc++.h>
using namespace std;
int N, R;
vector<int> X, Y;
int main(){
ios_base::sync_with_stdio(false); cin.tie(nullptr);
cin >> N; X.resize(N); Y.resize(N);
for(int i=0; i<N; i++) cin >> X[i] >> Y[i];
sort(X.begin(), X.end());
sort(Y.begin(), Y.end());
for(int i=0, j=0; i<N; i=j){
while(j < N && X[i] == X[j]) j++;
R += j - i > 1;
}
for(int i=0, j=0; i<N; i=j){
while(j < N && Y[i] == Y[j]) j++;
R += j - i > 1;
}
cout << R;
}
간단한 동적 계획법 문제입니다. $1, 2, \cdots, i$번 고객을 서비스하 데 걸리는 최소 시간을 $D(i)$로 정의하면, $D(i) = \min(D(i-1) + A_i, D(i-2) + \max(A_{i-1}, A_i))$와 같이 점화식을 세울 수 있습니다.
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
ll N, A[505050], D[505050];
int main(){
ios_base::sync_with_stdio(false); cin.tie(nullptr);
cin >> N;
for(int i=1; i<=N; i++) cin >> A[i];
D[1] = A[1];
for(int i=2; i<=N; i++) D[i] = min(D[i-1] + A[i], D[i-2] + max(A[i-1], A[i]));
cout << D[N];
}
구간의 시작점 $i$에 대해, $T[i\cdots j]$가 문제의 조건을 만족하는 가장 큰 구간의 끝점을 $f(i)$라고 정의하면, 문제의 정답은 구간 $[i, f(i)]$의 길이의 최댓값, 즉 $f(i) - i + 1$의 최댓값입니다.
모든 $1 \le i \le N-2$에 대해 $f(i) \le f(i+2)$가 항상 성립하기 때문에, 투 포인터를 이용해 $f(1), f(2), \cdots, f(N)$을 $O(N)$ 시간에 모두 구할 수 있습니다.
#include <bits/stdc++.h>
using namespace std;
int N, A[202020], R;
int Inc[202020], Dec[202020];
bool Check(int i, int j){
int len = j - i + 1;
if(len <= 2) return true;
if(len % 2 == 0) return Inc[i] == Inc[j-1] && Dec[i+1] == Dec[j];
else return Inc[i] == Inc[j] && Dec[i+1] == Dec[j-1];
}
int main(){
ios_base::sync_with_stdio(false); cin.tie(nullptr);
cin >> N;
for(int i=1; i<=N; i++) cin >> A[i];
for(int i=3; i<=N; i++) Inc[i] = Inc[i-2] + (A[i-2] > A[i]);
for(int i=3; i<=N; i++) Dec[i] = Dec[i-2] + (A[i-2] < A[i]);
for(int i=1, j=1; i<=N; i+=2){
while(j <= N && Check(i, j)) j++;
R = max(R, j - i);
}
for(int i=2, j=2; i<=N; i+=2){
while(j <= N && Check(i, j)) j++;
R = max(R, j - i);
}
cout << R;
}
선공은 수를 최대한 작게 만들고, 후공은 수를 최대한 크게 만들어야 합니다. 선공의 입장에서 생각해 보면, 수를 최대한 작게 만들어야 하므로 앞에 있는 1을 제거해야 하고, 1이 없다면 앞에 있는 0을 제거하는 것은 의미가 없으므로 뒤에 있는 0을 제거해야 합니다. 반대로 후공은 수를 최대한 크게 만들어야 하므로 앞에 있는 0을 제거하는 것이 좋고, 0이 없다면 뒤에 있는 1을 제거하면 됩니다.
deque나 linked list 등을 이용하면 편리하게 구현할 수 있습니다.
#include <bits/stdc++.h>
using namespace std;
int main(){
ios_base::sync_with_stdio(false); cin.tie(nullptr);
int N, K; string S;
cin >> N >> K >> S;
deque<int> P[2];
for(int i=0; i<N; i++) P[S[i]-'0'].push_back(i);
for(int i=0; i<K; i++){
if(!P[1].empty()) P[1].pop_front();
else P[0].pop_back();
if(!P[0].empty()) P[0].pop_front();
else P[1].pop_back();
}
int i = 0, j = 0;
while(i < P[0].size() && j < P[1].size()){
if(P[0][i] < P[1][j]) cout << 0, i++;
else cout << 1, j++;
}
while(i < P[0].size()) cout << 0, i++;
while(j < P[1].size()) cout << 1, j++;
}
먼저 물풍선을 x축에 평행한 선분과 y축에 평행한 선분으로 분할한 뒤, 평행하면서 겹치는 선분들을 합치고 시작합시다. $N^2$에서 이 선분들이 덮는 칸의 개수를 뺀 값이 정답입니다.
선분들이 덮는 칸의 개수는 각 선분의 길이를 모두 더한 뒤, 선분이 교차하는 횟수를 빼면 됩니다. 평행하면서 겹치는 선분을 모두 합쳤기 때문에, 각 지점은 최대 한 개의 수직 선분과 한 개의 수평 선분만 지나므로 단순히 교차점의 개수만 구해도 됩니다. 교차점은 세그먼트 트리 또는 펜윅 트리를 이용해 스위핑을 하면 $O(K \log K)$에 문제를 해결할 수 있습니다.
스위핑은 기본적으로 y축에 평행한 선분 $(y_1, y_2, x)$를 좌표 평면에 깔아놓은 다음, x축에 평행한 선분 $(x_1, x_2, y)$을 보면서 해당 y좌표에서 $[x_1, x_2]$ 구간에 있는 선분의 개수를 세는 방식으로 진행합니다. $[x_1, x_2]$ 구간에 있는 선분의 개수는 $[1, x_2]$ 구간에 있는 선분의 개수에서 $[1, x_1-1]$ 구간에 있는 선분의 개수를 빼면 됩니다.
따라서, 아래와 같이 이벤트를 만든 다음 x좌표가 증가하는 순서대로, x좌표가 같다면 이벤트 번호가 증가하는 순서대로 처리하면 됩니다.
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
constexpr int SZ = 1 << 20;
template<typename T>
void Compress(vector<T> &v){
sort(v.begin(), v.end());
v.erase(unique(v.begin(), v.end()), v.end());
}
template<typename T>
int Index(const vector<T> &v, T x){
return lower_bound(v.begin(), v.end(), x) - v.begin();
}
vector<pair<int,int>> Merge(vector<pair<int,int>> v){
vector<pair<int,int>> res;
sort(v.begin(), v.end());
int st = 0, ed = -1;
for(auto [s,e] : v){
if(ed + 1 < s) res.emplace_back(st, ed), st = s, ed = e;
ed = max(ed, e);
}
res.emplace_back(st, ed);
return res;
}
int N, K; ll Color;
vector<tuple<int,int,int,int>> V;
vector<pair<int,int>> X[303030], Y[303030];
vector<int> Xc, Yc, C;
int T[SZ];
void Add(int x, int v){ for(x+=3; x<SZ; x+=x&-x) T[x] += v; }
void Add(int l, int r, int v){ Add(l, v); Add(r+1, -v); }
int Get(int x){ int r = 0; for(x+=3; x; x-=x&-x) r += T[x]; return r; }
int main(){
ios_base::sync_with_stdio(false); cin.tie(nullptr);
cin >> N >> K; V.reserve(K*2); Xc.reserve(K); Yc.reserve(K);
for(int i=1; i<=K; i++){
int x, y, r; cin >> x >> y >> r;
V.emplace_back(0, x, max(1,y-r), min(N,y+r));
V.emplace_back(1, y, max(1,x-r), min(N,x+r));
Xc.push_back(x); Yc.push_back(y);
}
Compress(Xc); Compress(Yc);
for(auto [flag,p,l,r] : V){
if(flag == 0) X[Index(Xc, p)].emplace_back(l, r);
if(flag == 1) Y[Index(Yc, p)].emplace_back(l, r);
}
for(int i=0; i<Xc.size(); i++) X[i] = Merge(X[i]);
for(int i=0; i<Yc.size(); i++) Y[i] = Merge(Y[i]);
for(int i=0; i<Xc.size(); i++) for(auto [l,r] : X[i]) Color += r - l + 1;
for(int i=0; i<Yc.size(); i++) for(auto [l,r] : Y[i]) Color += r - l + 1;
C.reserve(K*3);
for(int i=0; i<Xc.size(); i++) for(auto [l,r] : X[i]) C.push_back(l-1), C.push_back(l), C.push_back(r);
Compress(C);
vector<tuple<int,int,int>> Upd;
vector<tuple<int,int,int>> Qry;
for(int i=0; i<Xc.size(); i++) for(auto [l,r] : X[i]) Upd.emplace_back(Xc[i], Index(C, l), Index(C, r));
for(int i=0; i<Yc.size(); i++) for(auto [l,r] : Y[i])
Qry.emplace_back(Index(C, Yc[i]), l-1, -1),
Qry.emplace_back(Index(C, Yc[i]), r, +1);
sort(Upd.begin(), Upd.end(), [](const auto &a, const auto &b){ return get<0>(a) < get<0>(b); });
sort(Qry.begin(), Qry.end(), [](const auto &a, const auto &b){ return get<1>(a) < get<1>(b); });
int i = 0, j = 0;
while(i < Upd.size() && j < Qry.size()){
if(get<0>(Upd[i]) <= get<1>(Qry[j])){
auto [x,l,r] = Upd[i++]; Add(l, r, 1);
}
else{
auto [y,_,f] = Qry[j++]; Color -= f * Get(y);
}
}
while(j < Qry.size()){
auto [y,_,f] = Qry[j++]; Color -= f * Get(y);
}
cout << 1LL * N * N - Color;
}
모스 알고리즘을 이용해 해결할 수 있습니다. 세그먼트 트리나 std::set
등을 이용해서 후보들 중에서 가장 작은 수를 선택하면 $O(Q \sqrt N \log N)$이라서 100점을 받을 수 없습니다. 하지만 후보의 변화는 $O(Q \sqrt N)$번 일어나는 반면에 최솟값을 구하는 연산은 $O(Q)$번밖에 일어나지 않는다는 점을 이용하면 $O(1)$ 갱신, $O(\sqrt N)$ 쿼리를 지원하는 자료구조를 사용해 $O(Q \sqrt N)$에 문제를 해결할 수 있습니다.
여담으로, $O(1)$ 갱신, $O(\sqrt N)$ 쿼리를 지원하는 자료구조 대신 std::bitset
의 _Find_first()
을 이용해도 $O(Q \sqrt N + QN / 64)$ 시간에 해결하여 100점을 받을 수 있습니다.
#include <bits/stdc++.h>
using namespace std;
constexpr int SZ = 512;
struct query{
int s, e, i;
query() = default;
query(int s, int e, int i) : s(s), e(e), i(i) {}
bool operator < (const query &q) const {
if(s / SZ != q.s / SZ) return s < q.s;
else return s / SZ % 2 ? e < q.e : e > q.e;
}
};
struct container{
vector<int> chk, buc;
void set(int x){
if(!chk[x]) chk[x] = 1, buc[x/SZ]++;
}
void reset(int x){
if(chk[x]) chk[x] = 0, buc[x/SZ]--;
}
container() : chk(303030), buc(666) {}
int get() const {
for(int i=0; i<buc.size(); i++){
if(!buc[i]) continue;
int l = i * SZ, r = (i + 1) * SZ;
for(int j=l; j<r; j++) if(chk[j]) return j;
}
return 0;
}
};
int N, Q, A[303030], R[303030], C[303030];
vector<query> V;
container T;
void Ins(int x){
C[A[x]]++;
if(C[A[x]] == 1) T.set(A[x]);
if(C[A[x]] != 1) T.reset(A[x]);
}
void Del(int x){
C[A[x]]--;
if(C[A[x]] == 1) T.set(A[x]);
if(C[A[x]] != 1) T.reset(A[x]);
}
int main(){
ios_base::sync_with_stdio(false); cin.tie(nullptr);
cin >> N;
for(int i=1; i<=N; i++) cin >> A[i];
cin >> Q; V.resize(Q); Q = 0;
for(auto &[s,e,i] : V) cin >> s >> e, i = ++Q;
sort(V.begin(), V.end());
int s = V[0].s, e = V[0].e;
for(int i=s; i<=e; i++) Ins(i);
R[V[0].i] = T.get();
for(int i=1; i<Q; i++){
while(V[i].s < s) Ins(--s);
while(e < V[i].e) Ins(++e);
while(s < V[i].s) Del(s++);
while(V[i].e < e) Del(e--);
R[V[i].i] = T.get();
}
for(int i=1; i<=Q; i++) cout << R[i] << "\n";
}
카트가 정지한 상태를 정점으로 나타낸 방향 그래프를 생각해 보면, 한 SCC 안에 있는 모든 정점을 어떻게든 방문할 수 있다는 사실을 알 수 있습니다. 따라서 SCC를 압축한 DAG를 만든 다음 DP를 이용해 최대 개수를 구할 수 있습니다. 실제 이동 방법을 구하는 건 한 SCC 안에 있는 모든 정점을 어떻게든 방문하는 것을 $O(N^2)$ 정도에 잘 구현하면 됩니다.
2017 KAIST 가을 대회 D. Dev, Please Add This!(풀이), 2020 1차 선발고사 4번. 칠하기(풀이)를 풀면 도움이 될 수도 있습니다.
미션 7까지는 손으로 해결할 수 있고, 8~10은 백트래킹을 이용해서 꽤 높은 점수를 받을 수 있습니다.
일단 모든 장비를 다운그레이드해서 돈을 최대한 많이 모은 다음, 앞에 있는 장비부터 최대한 많이 업그레이드하는 그리디 전략으로 정답을 구할 수 있습니다.
또는, $i, i+1, \cdots, N$번 장비를 다운그레이드해서 얻을 수 있는 돈을 $S[i]$라고 정의하면, 이 배열을 이용해 매번 $O(1)$ 시간에 장비를 업그레이드할지 다운그레이드할지 결정할 수 있습니다.
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
ll N, A[505050], B[505050], C;
char R[505050];
int main(){
ios_base::sync_with_stdio(false); cin.tie(nullptr);
cin >> N;
for(int i=1; i<=N; i++) cin >> A[i];
for(int i=1; i<=N; i++) cin >> B[i];
for(int i=1; i<=N; i++){
if(B[i] != -1) C += B[i], R[i] = '-';
else R[i] = '0';
}
for(int i=1; i<=N; i++){
if(R[i] == '-' && B[i] <= C) C -= B[i], R[i] = '0';
if(R[i] == '0' && A[i] != -1 && A[i] <= C) C -= A[i], R[i] = '+';
}
cout << R+1;
}
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
ll N, A[505050], B[505050], S[505050], C;
char R[505050];
int main(){
ios_base::sync_with_stdio(false); cin.tie(nullptr);
cin >> N;
for(int i=1; i<=N; i++) cin >> A[i];
for(int i=1; i<=N; i++) cin >> B[i];
for(int i=1; i<=N; i++) S[i] = max(0LL, B[i]);
for(int i=N; i>=1; i--) S[i] += S[i+1];
for(int i=1; i<=N; i++) R[i] = '0';
for(int i=1; i<=N; i++){
if(A[i] != -1 && C + A[i] <= S[i+1]) C += A[i], R[i] = '+';
else if(B[i] != -1 && C > S[i+1]) C -= B[i], R[i] = '-';
}
cout << R+1;
}
만약 x축과 평행한 직선만 주어진다면 주어진 두 점의 y좌표 $y_1, y_2$ 사이에 직선이 있는지 판별하면 되고, 이분 탐색을 이용하면 매번 $O(\log M)$에 확인할 수 있습니다. 마찬가지로 y축에 평행한 직선이 주어지더라도 $O(\log M)$ 시간에 확인할 수 있다.
기울기가 45도 또는 135도인 직선이 주어지면 일차 함수의 y절편으로 이분 탐색을 해도 되지만, 개인적으로 저는 $(x, y)$를 $(x-y, x+y)$로 바꾸는 등의 방법으로 좌표계를 45도 회전시킨 뒤 x축 또는 y축에 평행한 직선처럼 처리하는 것을 선호합니다.
참고로, $(x, y)$를 $(x+y, x-y)$로 바꾸는 것은 아래와 같이 회전 변환을 적용한 뒤 상수 배 한 것을 의미합니다.
\[\begin{pmatrix}\cos 45\degree & -\sin 45\degree \\ \sin 45\degree & \cos 45\degree \end{pmatrix}\begin{pmatrix}x \\ y\end{pmatrix} = \frac{\sqrt 2}{2} \begin{pmatrix} x - y \\ x + y \end{pmatrix}\]#include <bits/stdc++.h>
#define x first
#define y second
using namespace std;
using ll = long long;
using Point = pair<ll, ll>;
istream& operator >> (istream &in, Point &p){ return in >> p.x >> p.y; }
Point Rotate(Point p){ return {p.x + p.y, p.x - p.y}; }
vector<int> Solve(vector<Point> st, vector<Point> ed, vector<Point> qs, vector<Point> qe){
int m = st.size(), q = qs.size();
vector<int> res(q, 1), xv, yv;
for(int i=0; i<m; i++){
if(st[i].x == ed[i].x) xv.push_back(st[i].x);
if(st[i].y == ed[i].y) yv.push_back(st[i].y);
}
sort(xv.begin(), xv.end());
sort(yv.begin(), yv.end());
auto on_laser = [&](Point p) -> bool {
return binary_search(xv.begin(), xv.end(), p.x) || binary_search(yv.begin(), yv.end(), p.y);
};
auto inside = [](const vector<int> &v, int l, int r) -> bool {
return upper_bound(v.begin(), v.end(), r-1) - lower_bound(v.begin(), v.end(), l+1) > 0;
};
for(int i=0; i<q; i++){
if(on_laser(qs[i]) || on_laser(qe[i])) res[i] = 0;
if(qs[i].x != qe[i].x){
int x1 = qs[i].x, x2 = qe[i].x;
if(x1 > x2) swap(x1, x2);
if(inside(xv, x1, x2)) res[i] = 0;
}
if(qs[i].y != qe[i].y){
int y1 = qs[i].y, y2 = qe[i].y;
if(y1 > y2) swap(y1, y2);
if(inside(yv, y1, y2)) res[i] = 0;
}
}
return res;
}
int N, M, Q;
vector<Point> S, E, Q1, Q2;
int main(){
ios_base::sync_with_stdio(false); cin.tie(nullptr);
cin >> N >> M >> Q;
S.resize(M); E.resize(M); Q1.resize(Q); Q2.resize(Q);
for(int i=0; i<M; i++) cin >> S[i] >> E[i];
for(int i=0; i<Q; i++) cin >> Q1[i] >> Q2[i];
auto R1 = Solve(S, E, Q1, Q2);
for(auto &p : S) p = Rotate(p);
for(auto &p : E) p = Rotate(p);
for(auto &p : Q1) p = Rotate(p);
for(auto &p : Q2) p = Rotate(p);
auto R2 = Solve(S, E, Q1, Q2);
for(int i=0; i<Q; i++) cout << (R1[i] && R2[i]) << "\n";
}
일반적으로 최단 경로 알고리즘은 한 시작점에서 다른 모든 정점으로 가는 최단 경로(SSSP)를 구하지만, 이 문제는 도착점이 고정된 상태에서 시작점이 쿼리로 주어집니다. 따라서 그래프의 방향을 뒤집어서 처리하는 것이 편할 것이라는 생각을 자연스럽게 할 수 있습니다.
시점 $t$에 $v$번 정점에 있는 상태를 순서쌍 $(v, t)$로 표현하면, 기차 $(s, t_s, d, t_d)$는 $(d, t_d)$에서 $(s, t_s)$로 상태를 전이하는 것이라고 생각할 수 있습니다. 따라서 각 순서쌍을 정점으로 하고, 기차를 통한 상태 전이를 간선으로 표현한 그래프를 만듭시다. 유의미한 정점만 만들면 정점이 $2M + Q$개, 간선이 $3M+Q$개인 그래프가 만들어집니다.
쿼리의 답을 구하는 것은 $(K, t)$를 $t$가 증가하는 순서대로 보면서, 방문하는 정점에 $t$를 기록하는 방식으로 각 정점에 도달하는 가장 빠른 시간을 구할 수 있습니다. 이는 DFS, BFS 등으로 방문하지 않은 정점만 방문하는 방식으로 처리할 수 있습니다.
좌표 압축이 들어가기 때문에 시간 복잡도는 $O((M+Q) \log (M+Q))$입니다.
#include <bits/stdc++.h>
using namespace std;
int N, M, K, Q, R[707070];
vector<tuple<int,int,int,int>> E;
vector<pair<int,int>> T;
vector<pair<int,int>> C;
vector<int> G[707070];
void DFS(int v, int c){
R[v] = c;
for(auto i : G[v]) if(R[i] == -1) DFS(i, c);
}
int main(){
ios_base::sync_with_stdio(false); cin.tie(nullptr);
cin >> N >> M >> K >> Q; E.resize(M); T.resize(Q);
for(auto &[a,b,c,d] : E) cin >> a >> b >> c >> d, C.emplace_back(a, b), C.emplace_back(c, d);
for(auto &[a,b] : T) cin >> a >> b, C.emplace_back(a, b);
sort(C.begin(), C.end());
C.erase(unique(C.begin(), C.end()), C.end());
for(auto [a,b,c,d] : E){
int s = lower_bound(C.begin(), C.end(), make_pair(c,d)) - C.begin();
int e = lower_bound(C.begin(), C.end(), make_pair(a,b)) - C.begin();
assert(C[s] == make_pair(c,d));
assert(C[e] == make_pair(a,b));
G[s].push_back(e);
}
for(int i=1; i<C.size(); i++) if(C[i-1].first == C[i].first) G[i].push_back(i-1);
memset(R, -1, sizeof R);
for(int i=0; i<C.size(); i++) if(C[i].first == K && R[i] == -1) DFS(i, C[i].second);
for(int i=0; i<Q; i++) cout << R[lower_bound(C.begin(), C.end(), T[i]) - C.begin()] << "\n";
}
HLD를 이용해 경로를 $O(\log N)$개의 부분 문자열로 표현한 뒤, 해싱이나 접미사 배열 + LCP 배열 + RMQ를 이용해 부분 문자열의 사전 순 비교를 할 수 있습니다.
해싱을 이용해서 구현할 때 $O(\log N)$개의 부분 문자열을 모두 사전 순 비교를 하면 쿼리당 $O(\log^2 N)$이지만, 완전히 같은 두 부분 문자열을 $O(1)$ 시간에 판별하고 넘어가면 실제로 이분 탐색을 수행해야 하는 부분 문자열을 한 쌍밖에 없으므로 $O(\log N)$에 정답을 구할 수 있습니다.
참고로, 이 문제처럼 swap 연산에 비해 비교 연산이 많이 느린 경우, std::sort
대신 std::stable_sort
를 사용하면 실행 시간을 많이 줄일 수 있습니다.
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
constexpr ll P1 = 917, M1 = 998244353;
constexpr ll P2 = 179, M2 = 993244853;
template<ll P, ll M>
struct hasher{
vector<ll> h, p;
void build(const string &s){
int n = s.size();
h.resize(n+1); for(int i=1; i<=n; i++) h[i] = (h[i-1] * P + s[i-1]) % M;
p.resize(n+1); p[0] = 1; for(int i=1; i<=n; i++) p[i] = p[i-1] * P % M;
}
ll get(int l, int r) const {
ll res = (h[r] - h[l-1] * p[r-l+1]) % M;
return res >= 0 ? res : res + M;
}
};
struct hashing{
hasher<P1, M1> h1;
hasher<P2, M2> h2;
void build(const string &s){ h1.build(s); h2.build(s); }
pair<ll,ll> get(int l, int r) const { l++; r++; return {h1.get(l, r), h2.get(l, r)}; }
};
int N, M, A[151515];
vector<int> Inp[151515], G[151515];
int Top[151515], Sz[151515], Dep[151515], Par[151515], In[151515], Rev[151515];
string S; hashing H;
int LCP(int s, int e, int l, int r){
if(e-s == r-l && H.get(s, e) == H.get(l, r)) return e-s+1;
int lo = 0, hi = min(e-s+1, r-l+1) - 1;
while(lo < hi){
int m = (lo + hi + 1) / 2;
if(H.get(s, s+m-1) == H.get(l, l+m-1)) lo = m;
else hi = m - 1;
}
return lo;
}
void DFS0(int v, int b=-1){
for(auto i : Inp[v]) if(i != b) Dep[i] = Dep[v] + 1, Par[i] = v, G[v].push_back(i), DFS0(i, v);
}
void DFS1(int v){
Sz[v] = 1;
for(auto &i : G[v]){
DFS1(i); Sz[v] += Sz[i];
if(Sz[i] > Sz[G[v][0]]) swap(i, G[v][0]);
}
}
void DFS2(int v){
static int pv = 0; In[v] = ++pv; Rev[pv] = v;
for(auto i : G[v]) Top[i] = i == G[v][0] ? Top[v] : i, DFS2(i);
}
pair<int,int> Block(int s, int e){
if(In[s] <= In[e]) return { In[s] - 1, In[e] - In[s] + 1 };
else return { N + N - In[s] + 1, In[s] - In[e] + 1 };
}
vector<pair<int,int>> GetPath(int u, int v){
vector<pair<int,int>> l, r;
while(Top[u] != Top[v]){
if(Dep[Top[u]] > Dep[Top[v]]) l.push_back(Block(u, Top[u])), u = Par[Top[u]];
else r.push_back(Block(Top[v], v)), v = Par[Top[v]];
}
l.push_back(Block(u, v));
l.insert(l.end(), r.rbegin(), r.rend());
return l;
}
bool Compare(const vector<pair<int,int>> &u, const vector<pair<int,int>> &v){
int l = 0, r = 0;
for(auto [st,len] : u) l += len;
for(auto [st,len] : v) r += len;
if(l != r) return l < r;
for(int i=0, j=0, a=0, b=0; i<u.size() && j<v.size(); ){
int st1 = u[i].first + a, len1 = u[i].second - a;
int st2 = v[j].first + b, len2 = v[j].second - b;
int len = min(len1, len2);
int lcp = LCP(st1, st1+len-1, st2, st2+len-1);
if(lcp < len) return S[st1+lcp] < S[st2+lcp];
if(len1 == len) i++, a = 0; else a += len2;
if(len2 == len) j++, b = 0; else b += len1;
}
return l < r;
}
vector<pair<int,int>> P[151515];
int main(){
ios_base::sync_with_stdio(false); cin.tie(nullptr);
cin >> N >> M;
for(int i=1; i<=N; i++) cin >> A[i];
for(int i=1,u,v; i<N; i++) cin >> u >> v, Inp[u].push_back(v), Inp[v].push_back(u);
DFS0(1); DFS1(1); DFS2(Top[1]=1);
for(int i=1; i<=N; i++) S += char(A[Rev[i]]+'0');
S += "#";
for(int i=N; i>=1; i--) S += char(A[Rev[i]]+'0');
H.build(S);
for(int i=1,u,v; i<=M; i++) cin >> u >> v, P[i] = GetPath(u, v);
vector<int> O(M);
iota(O.begin(), O.end(), 1);
stable_sort(O.begin(), O.end(), [](int a, int b){ return Compare(P[a], P[b]); });
for(auto i : O) cout << i << "\n";
}
접미사 배열을 이용해 구현하는 경우, 위 코드의 hasher
, hashing
클래스와 LCP
함수를 제거한 뒤 아래 코드를 적절히 추가하면 됩니다. 구간 최솟값 쿼리(range minimum query)를 수행할 때 sprase table과 같은 $O(1)$ 쿼리를 지원하는 자료구조가 아닌 $O(\log N)$ 시간에 동작하는 세그먼트 트리 같은 것을 이용하면 쿼리당 $O(\log^2 N)$이 되어 100점을 받을 수 없습니다.
pair<vector<int>, vector<int>> SuffixArray(const string &s){
int n = s.size(), m = max(n, 256);
vector<int> sa(n), lcp(n), pos(n), tmp(n), cnt(m);
auto counting_sort = [&](){
fill(cnt.begin(), cnt.end(), 0);
for(int i=0; i<n; i++) cnt[pos[i]]++;
partial_sum(cnt.begin(), cnt.end(), cnt.begin());
for(int i=n-1; i>=0; i--) sa[--cnt[pos[tmp[i]]]] = tmp[i];
};
for(int i=0; i<n; i++) sa[i] = i, pos[i] = s[i], tmp[i] = i;
counting_sort();
for(int k=1; ; k<<=1){
int p = 0;
for(int i=n-k; i<n; i++) tmp[p++] = i;
for(int i=0; i<n; i++) if(sa[i] >= k) tmp[p++] = sa[i] - k;
counting_sort();
tmp[sa[0]] = 0;
for(int i=1; i<n; i++){
tmp[sa[i]] = tmp[sa[i-1]];
if(sa[i-1]+k < n && sa[i]+k < n && pos[sa[i-1]] == pos[sa[i]] && pos[sa[i-1]+k] == pos[sa[i]+k]) continue;
tmp[sa[i]] += 1;
}
swap(pos, tmp);
if(pos[sa.back()] + 1 == n) break;
}
for(int i=0, j=0; i<n; i++, j=max(j-1,0)){
if(pos[i] == 0) continue;
while(sa[pos[i]-1]+j < n && sa[pos[i]]+j < n && s[sa[pos[i]-1]+j] == s[sa[pos[i]]+j]) j++;
lcp[pos[i]] = j;
}
return {sa, lcp};
}
struct RMQ{
vector<vector<int>> st;
vector<int> lg;
RMQ() = default;
RMQ(const vector<int> &a){
int n = a.size();
st = vector<vector<int>>(__lg(n)+1, vector<int>(n));
for(int i=0; i<n; i++) st[0][i] = a[i];
for(int i=1; i<st.size(); i++) for(int j=0; j<n; j++) if(j + (1<<i) - 1 < n) st[i][j] = min(st[i-1][j], st[i-1][j+(1<<(i-1))]);
lg.resize(n);
for(int i=0; i<st.size(); i++) lg[1<<i] = i;
for(int i=1; i<n; i++) if(!lg[i]) lg[i] = lg[i-1];
}
int query(int l, int r) const {
if(l > r) return 0x3f3f3f3f;
int k = lg[r-l+1];
return min(st[k][l], st[k][r-(1<<k)+1]);
}
};
vector<int> SA, Lcp, Pos;
RMQ Q;
int LCP(int s, int e, int l, int r){
if(s == l) return min(e-s+1, r-l+1);
int len = min(e-s+1, r-l+1);
int u = Pos[s], v = Pos[l];
if(u > v) swap(u, v);
return min(Q.query(u+1, v), len);
}
int main(){
// ...
tie(SA,Lcp) = SuffixArray(S); Pos.resize(S.size());
for(int i=0; i<S.size(); i++) Pos[SA[i]] = i;
Q = RMQ(Lcp);
// ...
}
1 이상 $N$ 이하의 수 중 여러 번 등장하는 수가 있다면 가장 뒤에 있는 수를 제외한 모든 수를 변경하는 것이 최적입니다. 따라서 1 이상 $N$ 이하의 수가 등장하는 마지막 인덱스를 구한 뒤, 그렇지 않은 모든 인덱스의 합을 구하면 됩니다.
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
ll N, A[101010], B[101010], P[101010], R;
int main(){
ios_base::sync_with_stdio(false); cin.tie(nullptr);
cin >> N;
for(int i=1; i<=N; i++) cin >> A[i];
for(int i=1; i<=N; i++) if(A[i] <= N) P[A[i]] = i;
for(int i=1; i<=N; i++) B[P[i]] = i;
for(int i=1; i<=N; i++) if(!B[i]) R += i;
cout << R;
}
$i$번째 수를 마지막으로 하는 공차가 $d$인 등차 수열의 개수를 $D(i, d)$라고 정의합시다. $i$의 정의역의 크기는 $N$, $d$의 정의역의 크기는 $10^9$이지만, 실제로 의미있는 상태는 $O(N^2)$개밖에 없습니다.
상태 전이는 $D(j, A_i - A_j) \rightarrow D(i, A_i - A_j)$와 같이 설계할 수 있고, std::map
과 같은 연관 배열을 사용하거나 실제로 의미있는 $d$만 좌표 압축해서 관리하는 방식으로 $O(N^2 \log N)$ 시간에 문제를 해결할 수 있습니다.
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
constexpr int MOD = 1e9+7;
inline void Add(int &a, const int b){ if((a += b) >= MOD) a -= MOD; }
void Compress(vector<int> &v){
sort(v.begin(), v.end());
v.erase(unique(v.begin(), v.end()), v.end());
}
int Index(const vector<int> &v, int x){
int pos = lower_bound(v.begin(), v.end(), x) - v.begin();
return pos < v.size() && v[pos] == x ? pos : -1;
}
int N, A[2020], D[2020][2020], R;
vector<int> C[2020];
int main(){
ios_base::sync_with_stdio(false); cin.tie(nullptr);
cin >> N;
for(int i=1; i<=N; i++) cin >> A[i];
for(int i=1; i<=N; i++) for(int j=i-1; j>=1; j--) C[i].push_back(A[i]-A[j]);
for(int i=1; i<=N; i++) Compress(C[i]);
for(int i=1; i<=N; i++){
for(int j=i+1; j<=N; j++){
int pos = Index(C[j], A[j]-A[i]); Add(D[j][pos], 1);
if(int prv=Index(C[i], A[j]-A[i]); prv != -1) Add(D[j][pos], D[i][prv]);
}
}
for(int i=1; i<=N; i++) for(int j=0; j<C[i].size(); j++) Add(R, D[i][j]);
cout << R;
}
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
constexpr int MOD = 1e9+7;
inline void Add(int &a, const int b){ if((a += b) >= MOD) a -= MOD; }
int N, A[2020], R;
map<int, int> D[2020];
int main(){
ios_base::sync_with_stdio(false); cin.tie(nullptr);
cin >> N;
for(int i=1; i<=N; i++) cin >> A[i];
for(int i=1; i<=N; i++){
for(int j=i+1; j<=N; j++){
auto it = D[j].find(A[j]-A[i]);
if(it == D[j].end()) it = D[j].emplace(A[j]-A[i], 0).first;
Add(it->second, 1);
auto prv = D[i].find(A[j]-A[i]);
if(prv != D[i].end()) Add(it->second, prv->second);
}
}
for(int i=1; i<=N; i++) for(auto [a,b] : D[i]) Add(R, b);
cout << R;
}
일차원에서의 문제를 먼저 해결해 봅시다. $X_1, X_2, \cdots, X_N$이 오름차순으로 주어지면, $X_N - X_1$, $X_{N-1} - X_2$, $X_{N-2}, X_3$, $\cdots$ 와 같이 매칭하는 것이 최적이라는 것은 어렵지 않게 알 수 있습니다. 그리고 이는 $X_{N/2+1} - X_1$, $X_{N/2+2} - X_2$, $X_{N/2+3} - X_3$, $\cdots$ 와 같이 매칭하더라도 값이 바뀌지 않습니다.
이차원에서의 답은 x좌표와 y좌표를 나눠서 각각 해결한 것의 합 이하이며, 이 값을 달성하는 방법이 존재합니다.
주어진 x좌표들을 오름차순으로 정렬한 것을 $X_1, X_2, \cdots, X_N$, y좌표들을 오름차순으로 정렬한 것을 $Y_1, Y_2, \cdots, Y_N$이라고 합시다. $(X_{N/2}, Y_{N/2})$가 원점이 되도록 조정하면, (1사분면의 개수) = (3사분면의 개수), (2사분면의 개수) = (4사분면의 개수)가 됩니다. 따라서 1사분면의 점과 3사분면의 점을 매칭하고, 2사분면의 점과 4사분면의 점을 매칭하면 문제를 해결할 수 있습니다.
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
ll N, S, X[202020], Y[202020];
struct Point{ ll x, y, v, i; } A[202020];
vector<int> V[4];
vector<pair<int,int>> R;
int main(){
ios_base::sync_with_stdio(false); cin.tie(nullptr);
cin >> N;
for(int i=1; i<=N; i++) cin >> X[i] >> Y[i];
for(int i=1; i<=N; i++) A[i] = { X[i], Y[i], 0, i };
nth_element(A+1, A+N/2, A+N+1, [](auto a, auto b){ return a.x < b.x; });
for(int i=1; i<=N/2; i++) A[i].v |= 1;
nth_element(A+1, A+N/2, A+N+1, [](auto a, auto b){ return a.y < b.y; });
for(int i=1; i<=N/2; i++) A[i].v |= 2;
for(int i=1; i<=N; i++) V[A[i].v].push_back(A[i].i);
for(int x : {0, 1}){
int y = 3 ^ x;
while(!V[x].empty() && !V[y].empty()){
int i = V[x].back(), j = V[y].back();
V[x].pop_back(); V[y].pop_back();
R.emplace_back(i, j);
S += abs(X[i] - X[j]) + abs(Y[i] - Y[j]);
}
}
cout << S << "\n";
for(auto [x,y] : R) cout << x << " " << y << "\n";
}
h가 등장하지 않는 구간에서 문제의 정답은 (e의 개수)와 (g의 개수)의 최솟값입니다. 따라서 h를 기준으로 쪼개서 답을 구하면 되며, 갱신과 구간 쿼리가 주어지는 상황에서는 흔히 “금광 세그먼트 트리”라고 부르는 자료구조를 이용해 해결할 수 있습니다. 세그먼트 트리의 각 정점에서 다음과 같은 값을 관리하면 됩니다.
$O(N + Q \log N)$ 시간에 문제를 해결할 수 있습니다.
#include <bits/stdc++.h>
using namespace std;
constexpr int SZ = 1 << 20;
const string S = "heg";
struct node{
int lx, ly, rx, ry, md, sz, full;
node() : node(0) {}
node(int v) : node(v==1, v==2, v==1, v==2, 0, 1, v!=0) {}
node(int lx, int ly, int rx, int ry, int md, int sz, int full)
: lx(lx), ly(ly), rx(rx), ry(ry), md(md), sz(sz), full(full) {}
};
node operator + (const node &a, const node &b){
return {
a.lx + (a.full ? b.lx : 0),
a.ly + (a.full ? b.ly : 0),
(b.full ? a.rx : 0) + b.rx,
(b.full ? a.ry : 0) + b.ry,
a.md + b.md + (!a.full && !b.full ? min(a.rx + b.lx, a.ry + b.ly) : 0),
a.sz + b.sz, a.full && b.full
};
}
int N, Q, A[1010101]; node T[SZ<<1];
void Set(int x, int v){ for(T[x|=SZ]=v; x>>=1; ) T[x] = T[x<<1] + T[x<<1|1]; }
int Get(int l, int r){
node lv, rv;
for(l|=SZ, r|=SZ; l<=r; l>>=1, r>>=1){
if(l & 1) lv = lv + T[l++];
if(~r & 1) rv = T[r--] + rv;
}
lv = lv + rv;
return lv.md + min(lv.lx, lv.ly) + (lv.full ? 0 : min(lv.rx, lv.ry));
}
int main(){
ios_base::sync_with_stdio(false); cin.tie(nullptr);
cin >> N >> Q;
for(int i=1; i<=N; i++){ char c; cin >> c; A[i] = S.find(c); }
for(int i=1; i<=N; i++) T[i|SZ] = A[i];
for(int i=SZ-1; i; i--) T[i] = T[i<<1] + T[i<<1|1];
for(int q=1; q<=Q; q++){
int x, s, e; char v; cin >> x >> v >> s >> e;
Set(x, S.find(v));
cout << Get(s, e) << "\n";
}
}
접수 | 서류 전형 | 코딩 테스트 | 서류 결과 | 1차 면접 | 1차 결과 | 2차 면접 | 최종 결과 |
---|---|---|---|---|---|---|---|
3/5-18(화-월) | 3/19-4/4(화-목) | 3/23(토) 10-12시 | 4/9(화) 10시 | 4/16-26(화-금) | 5/3(금) 17시 | 5/16-29(목-수) | 6/14(금) 17시 |
일부 선발 부문은 마지막 단계가 6/7 금요일까지 진행되었다고 합니다. 2023(참고), 2024년 일정을 보면 기술 역량 인터뷰 결과는 마지막 일정으로부터 7일, 종합 역량 인터뷰는 마지막 일정으로부터 14일 뒤 정도에 나오는 것 같습니다.
2023년 회고 글(링크)의 ‘인턴 지원’ 단락과 네이버 Yorkie TF 인턴 생존기 글(링크)의 ‘인턴 지원’ 단락을 함께 보면 좋습니다.
저는 개발자의 길을 걸어오는 데 있어 남들과는 다른 조금 독특한 경험을 했습니다. 학교 사람들 소식이나 개발 관련 블로그를 구경하다 보면 연합 동아리에 들어가서 활동하거나 팀을 꾸려서 프로젝트를 진행하는 사람들이 많이 보입니다. 저는 11년 동안 프로그래밍을 했지만 고등학교/대학교 과제 수준을 넘어서는 프로젝트 경험이 없으며, 연합 동아리 활동이나 부트캠프 수료, 공모전 참가, 논문 작성도 하지 않았습니다. 대신 알고리즘을 오랜 시간 공부하고 대회에서 좋은 성과를 거둔 것은 저의 큰 강점이라고 생각했습니다.
흔치 않은 길을 걸어왔고 그 분야에서 좋은 성과를 거둔 것은 많은 지원자들 사이에서 눈에 띌 수 있다는 장점이 될 수 있지만, 지원 전략을 세우거나 자기소개서를 작성할 때 참고할 수 있는 정보가 없어서 지원서를 작성할 때 조금 부담이 되었습니다.
가장 걱정되었던 건 다른 경험 없이 알고리즘 공부만 한 사람을 서비스 기업에서 안 뽑을 것 같다는 점이었습니다. 사실 지난 기억을 되돌아보면 저와 비슷한 상태의 지인들은 꽤 있었고, 그분들이 구글, 몰로코, 프레스토랩스, 삼성전자 등 다양한 회사에 들어간 것도 보았었습니다. 하지만 과거의 저는 ‘이 사람은 나보다 훨씬 똑똑한 사람이니까’, ‘이 사람은 영재학교를 나와서 서울대/카이스트에 갔으니까’와 같은 이유로 제가 참고할 만한 표본이 아니라고 생각했던 것 같습니다. 그래도 저는 특성화고를 졸업해 숭실대학교에 다니고 있는 사람이니, 제가 작성한 글이 저와 비슷한 상황에 있는 분들에게 조금이라도 도움과 마음에 위안이 되길 바랍니다. 그렇지 않은 분들이더라도 그냥 ‘이런 방법으로도 취업을 할 수 있구나~’ 정도의 생각으로 읽어주시면 좋을 것 같습니다. 취업에는 정답이 없고 저조차도 제가 거쳐 온 과정이 저에게 가장 좋은 방법이었을지는 확신하지 못하니까요.
[필수] 1. 다음 중 본인의 가장 자신 있고 희망하는 분야를 한 가지 선택해 주세요.
■ Front-End ■ Back-End ■ Android ■ iOS ■ Data ■ 공통
선택한 분야에 관심을 갖게 된 계기와 자신 있는 이유(그동안의 노력, 경험, 강점 포함) 등에 대해 구체적으로 설명해 주세요.
※ 작성 예시 : ■ Front-End / 관심 계기 및 자신 있는 이유 :
※ 구체적인 희망 직무 분야가 미정이신 경우 '공통'으로 선택하시고 상세 사유를 작성해 주세요.
[필수] 2. 가장 열정을 가지고 임했던 프로젝트(목표/과제 등)를 소개해 주시고, 해당 프로젝트의 수행 과정 및 결과에 대해 기재해 주세요.
* 지원 부문과 관련된, 어려웠거나 인상 깊었던 문제를 해결한 경험을 중심으로 작성해 주세요. (학교수업, 경진대회, 대외활동 등)
* 맞닥뜨린 문제를 ‘구체적’으로 기술하고, 본인의 접근 방법과 해결 과정, 그리고 실제 결과를 ‘상세히’ 기술해 주세요.
* 문제를 잘 해결했다면 그 경험에서 아쉬웠던 점 혹은 더 나은 방법은 없었을지에 대한 고민 과정을 함께 작성해 주세요.
* 해결하지 못한 경험이더라도 해결을 위해 얼마나 깊이 있게 고민을 했는지 그 과정에 대해 이야기 해 주세요.
※ 코드로 설명해 주셔도 좋습니다.
[선택] 3. 본인의 대표적인 개발 경험이나 희망 분야 관련 과제 성과, 활동 등을 가장 잘 보여줄 수 있는 Github, 블로그 등의 URL을 작성하시거나 자료를 첨부하시고, 간단한 소개나 설명을 해 주세요. 공동 프로젝트였다면 본인의 역할을 명확히 써 주세요.
* 오픈소스 컨트리뷰션, 프로젝트, 본인이 작성한 소스코드 등 (임시저장 후 복수 첨부 가능 / 최대 200MB)
* 수강하셨거나 별도로 공부하신 컴퓨터공학 관련 학습/과제/프로젝트 활동을 보여 주셔도 좋습니다.
제가 다뤄 본 분야가 많았다면 소재를 선택하는 데 많이 고민했을 테지만, 다행히 제 경력과 실적에서 알고리즘을 제외하면 두드러지는 부분이 없었기에 소재를 선택하는 것은 편했습니다.
가장 쓰기 쉬운 3번 문항부터 시작했고, 알고리즘 공부 기록과 대회 수상 내역, 인턴십 기간 동안 작업한 것들을 적었습니다. 2번 문항은 원래 ICPC 준비 방법을 적으려고 했지만, ICPC로는 도저히 1000글자를 뽑을 수가 없어서 인턴 기간 동안 작업했던 ‘Yorkie.Tree 동시 편집 테스트 프레임워크 설계’로 주제를 바꿨습니다. 다행히 인턴 최종 발표 슬라이드에 상세하게 잘 적어놓았기에 소재를 정한 뒤로는 어렵지 않게 작성했습니다.
1번 문항은 희망 분야를 선택하고 그 이유를 작성해야 합니다. 저는 프론트엔드, 백엔드, 안드로이드, iOS, 데이터 분석 모두 잘 모르는 사람이라서 단 1초도 고민하지 않고 공통을 선택했습니다. 관심을 갖게 된 계기와 자신 있는 이유는 지난겨울에 인턴 지원할 때 썼던 것을 그대로 붙여 넣었습니다. 초등학생 때부터 어떤 점이 즐거워서 프로그래밍을 했는지, 살면서 네이버에게 어떤 도움을 받았었는지, 그리고 알고리즘을 공부한 경험이 다른 분야를 공부할 때 어떻게 도움 되었는지를 위주로 작성했습니다.
인턴과는 다르게 공채에서는 첨부파일도 추가할 수 있었습니다. 3번 문항에 작성한 수상 내역의 증빙 서류와 인턴 최종 발표 자료, 알고리즘 강의 슬라이드를 넣었습니다.
코딩 테스트는 3문제가 출제되었고 다 푸는 데 3~40분 정도 걸렸던 것으로 기억합니다. 네이버에서 인턴도 했었고 코딩 테스트도 다 풀었으니 떨어지진 않을 것 같아서 바로 면접 준비하려고 했지만… 4/12-21 ICPC World Finals, 4/22-26 중간고사라는 정신없는 일정 덕분에 아무런 준비 없이 면접에 들어갔습니다.
그래도 면접에서 어떤 것을 물어보는지는 알아야 할 것 같아서 면접 전날에 조금 찾아봤는데, 알고리즘 / 창의력 수학 문제 / 손코딩 / CS 지식 정도를 물어본다는 것 같았습니다. 앞에 있는 3개는 이미 오랜 시간 연습했기 때문에 별로 걱정이 없었지만 CS 지식은 걱정이 많았습니다. 면접 전날부터 https://github.com/gyoogle/tech-interview-for-developer 에서 자료구조와 알고리즘 파트를 제외한 모든 문서를 읽었지만 기대했던 것에 비해 별로 좋진 않았습니다. 학부 전공 강의 요약본 같은 것을 기대했었지만, 제가 기대했던 것과는 거리가 멀고 깊이가 많이 얕았습니다.
면접 내용을 자세하게 이야기할 수는 없지만… 제가 받은 질문 중 절반 정도를 대답하지 못했습니다. 저는 아직 다른 분야를 깊게 공부한 경험이 없어서 희망 분야를 공통으로 선택했지만, 면접관님은 모든 분야를 잘 알고 있어서 공통을 선택했다고 생각하시는 건지 정말 다양한 분야의 지식을 물어보셨습니다. “CS 지식 잘 몰라도 알고리즘이랑 창의력 수학 문제 잘 풀면 합격하겠지?” 같은 생각을 하고 있었지만, 면접 초반에 “알고리즘을 잘하시는 것 같으니 안 물어볼게요~” 라고 하셔서 당황스러웠던 것이 기억에 남습니다.
1차 면접을 겪으면서 저는 제가 첫 시작을 열어야 하는 질문을 어려워한다는 것을 깨달았습니다. 그 이후에 따라오는 질문이나 아예 방향을 정해놓고 물어보는 질문이라면 적당히 눈치껏 대답하면 되지만, 제가 방향을 정해야 하는 질문은 미리 생각해 둔 게 없으면 답변하기 어려웠습니다. 그래서 몇 가지 질문을 만들어 놓고 답변을 생각하는 식의 연습을 조금 했었습니다. 예를 들면 이런 질문들…
이 밖에도 네이버 종합 역량 인터뷰을 위한 준비도 하려고 했지만, 검색해 보니 ‘사람마다 물어보는 게 달라서 뭘 물어볼지 알 수 없다’라는 내용밖에 안 보여서 어쩔 수 없이 준비를 안(못) 했습니다.
면접은 1차 면접보다 훨씬 제가 답변하기 쉬운 질문으로 구성되어 있어서 편했습니다. 기술 관련 질문도 조금 있었지만, 대부분의 질문은 삶 / 일 / 공부를 대하는 태도를 물어보는 느낌의 질문이었습니다. 삶에 대한 질문은 운이 좋게도 최근에 “어떻게 해야 행복하게 살 수 있을까?”, “스트레스를 효과적으로 관리/대처하는 방법은 무엇일까?”와 같은 고민을 했어서 어렵지 않게 대답했습니다. 공부와 일에 대한 질문은 지금까지 공부하면서 쌓인 생각을 기반으로 편하게 대답했습니다. 마지막에는 알고리즘 말고 다른 것도 공부해 보라는 조언과 함께 면접이 마무리되었습니다.
“물어보는 것의 절반을 대답 못 한 1차 면접도 붙었는데 모든 질문에 대답한 2차 면접은 당연히 붙지 않을까?”와 같은 행복 회로를 돌리며 3주를 기다렸고…
붙었습니다.
왠지 오후 5시에 결과가 나올 것 같아서 5시가 되자마자 이메일 새로고침했으나 아무것도 온 게 없어서 실망하고 있었지만… 바로 1n초 뒤에 문자와 함께 합격 메일을 받았습니다.
붙으면 엄청 기쁘고 신날 줄 알았는데 기쁨보다는 허무함이 더 크게 느껴집니다. 되돌아보니 PS를 시작한 중학교 2학년 여름(2016년 여름)부터 지금까지 8년 동안은 항상 목표가 있었고, 쉬지 않고 달려왔던 것 같습니다. 중고등학생 때는 정보올림피아드, 고등학교 3학년 때는 입시, 대학생 때는 ICPC, 그리고 최근 몇 달은 취업까지… ICPC는 이미 월드 파이널에 2번 진출해서 이번 WF가 마지막 참가인데, 지난 대회에서 벽을 느끼고 돌아와서 열정이 예전 같지 않은 상태입니다. 이런 상황에서 취준까지 너무 빠르게 끝나버려서 8년 만에 처음으로 목표가 없어졌습니다. 가족들과 주변 친구들이 합격을 축하해 주면서 이제 쉬면서 마음껏 놀라고 했지만, 쉬어본 적도 없고 놀아본 적도 없어서 입사일까지 어떻게 보내야 할지 잘 모르겠습니다.
2025년 2월 졸업이라 2학기가 끝나고 입사하게 될 것 같습니다. 그전까지 어떻게 지낼지, 그리고 반년 뒤에 다가올 새로운 시작에 어떻게 대처해야 할지 고민할 차례인 것 같습니다. 지금까지 제가 공부하는 것을 도와주고 응원해 주신 분들, 그리고 축하해 주신 모든 분들께 감사드립니다.
]]>지난 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팀이 한국 팀이었습니다.
한국에서 여러 팀이 진출한 만큼 재미있는 후기 글도 많이 올라왔습니다. 함께 보시면 좋을 것 같습니다.
서울 리저널 전까지는 시간 날 때마다 팀 연습을 했었습니다. 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차원 기하와 선형 대수를 제외한 내용은 모두 넣은 것으로 기억합니다. 작업 내용은 다음과 같습니다.
팀노트는 아마 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등 안에 들어야 했습니다. 위에 있는 시트를 보면서 우리 팀이 질 수도 있다는 생각이 드는 팀(볼드체는 리저널 우승팀)은 다음과 같았습니다.
이 팀들에게 다 져도 16등 안에는 들 수 있기 때문에 말리지만 않는다면 WF 진출은 가능할 것으로 예상했습니다.
잘 치고 싶은 대회는 항상 긴장되고, 잘 치는 것이 당연한 대회도 잘 하지 못할까 봐 항상 떨립니다. 이번 대회는 그 둘 모두 해당하는 대회라서 인천 공항에서부터 정말 많이 긴장하고 있었고, 다른 팀들의 실력을 처음으로 확인할 수 있는 예비 소집 날은 말 그대로 다리가 덜덜 떨리고 있었습니다.
예비 소집 문제는 12개였지만, 문제 번호를 4로 나눈 나머지가 같은 문제는 같은 문제였기 때문에 실제로는 4문제였습니다. edenooo는 다른 일정이 있어서 예비 소집이 끝날 때쯤 대회장에 도착했었기 때문에 예비 소집은 저와 kyo20111 2인팀으로 참가했습니다. 문제 지문만 읽었을 때의 첫인상은 다음과 같았습니다.
__gnu_cxx::crope
사용법 까먹어서 1번 틀린 다음에 ACB는 머리를 써야 하는 문제 같아서 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) 풀이만 간단하게 적어보려고 합니다.
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를 받을 수 있습니다.
음이 아닌 정수 $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를 받았습니다.
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$
입력으로 주어지는 그래프는 평면 그래프입니다. 평면 그래프와 관련해서 다음과 같은 사실이 잘 알려져 있습니다.
일단, 간선이 하나 빠질 때마다 정점 집합 $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팀도 진출 가능성이 있어 보이는데 아직 아시아 태평양 지역에 티켓이 몇 장 떨어질지 몰라서… 빨리 발표되면 좋겠습니다.
숭실대학교는 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가 아직도 안 열렸는지는 모르겠지만…
]]>처음에 예상했던 것보다 글이 훨씬 많이 길어졌습니다. 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 이야기만 잔뜩 하다가 나왔습니다. 상상 속에 있던 회사 면접과는 전혀 다르게, 이게 면접이 맞나 싶을 정도로 신기하게 진행되었습니다. 동아리나 기업에서 사람들 대상으로 강의했던 경험이 큰 도움이 되었던 것 같습니다. 떨어질 확률보다 붙을 확률이 조금 더 높다고 생각했고, 면접 일주일 정도 뒤에 합격 통보를 받았습니다. 참고로 면접 준비하면서 공부한 것은 아무 쓸모가 없었습니다.
면접 때 옷을 어떻게 입어야 하는지 몰라서 열심히 검색했었는데 쓸만한 결과가 나오지 않아서 많이 고민했었습니다. 저는 잠옷 입고 면접 봐서 붙었으니 이 글을 보는 분들은 고민하지 말고 편한 복장으로 면접에 들어가시길 바랍니다.
저는 인턴십 기간 동안 실시간 협업을 위한 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는 다양한 자료구조가 맞물려 돌아가고 있습니다. (data structure design docs 참고)
Yorkie는 자료구조를 (1) 실제로 사용자가 다루게 될 문서 레벨의 JSON 형태의 자료구조, (2) 문서의 동시 편집 연산을 지원하는 CRDT 자료구조, (3) CRDT 자료구조의 밑에서 돌아가는 기본 자료구조까지 총 3가지로 구분하고 있습니다. 예를 들어 사용자가 Text
형태인 객체의 특정 구간에 bold
속성을 적용한다고 하면 Text
객체의 내용물을 저장하고 있는 RGATreeSplit
은 속성을 적용할 노드들의 구간을 구해야 하는데, 이때 BBST인 SplayTree
나 LLRBTree
를 이용해 효율적으로 구간을 모으는 방식으로 연산이 진행됩니다. SplayTree
와 LLRBTree
와 같은 기본 자료구조는 동시 편집을 고려하지 않은 채로 만들어져 있으며, 연산의 우선순위나 오프셋 등을 조정해서 동시 편집 지원을 보장하는 부분은 CRDT 자료구조에서 담당하고, 유저가 사용할 인터페이스는 JSON-Like 자료구조에서 제공하는 방식입니다.
ElementRHT와 RHT에서 보이는 RHT
는 Replicated Hash Table의 줄임말로, 동시 편집을 지원하는 해시 테이블입니다. ElementRHT
는 JSON-Like Object의 멤버를 관리하는 자료구조로 문자열 → 객체 매핑을 수행하고, RHT
는 노드의 attribute를 관리하는 자료구조로 문자열 → 문자열 매핑을 수행합니다.
RGATreeList와 RGATreeSplit은 Replicated Growable Array(RGA)를 기반으로 하는 리스트 자료구조입니다. RGA
는 기본적으로 각 노드의 크기가 1인 연결 리스트이며, RGATreeList
는 random access를 지원하기 위해 os-select 연산을 수행하는 BBST 위에 리스트를 올린 자료구조입니다. RGATreeSplit
은 RGATreeList
의 성능을 개선하기 위해 각 노드의 크기가 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에 디해 상대적으로 구현이 쉽다는 장점이 있습니다. LLRBTree
는 SplayTree
와 다르게 모든 연산을 매번 최대 $O(\log n)$ 시간에 수행한다는 장점이 있습니다.
IndexTree는 xml 형태로 표현된 트리 구조 문서의 인덱스가 주어지면 이를 트리에서의 위치로 변환하는 역할을 수행합니다. 예를 들어 아래 그림에서 6번 인덱스를 넘겨주면 문서의 두 번째 p 태그와 세 번째 p 태그 사이를 “잘 표현하는 방법”을 구성해서 반환합니다.
저는 8주 간의 인턴십 기간 동안 Yorkie 자료구조의 문제점을 발견하고 이를 수정하는 작업을 주로 수행했습니다. 제가 발견한 문제점은 다음과 같습니다.
두 명의 유저가 동시에 같은 키에 대해 한 명은 삽입 연산, 다른 한 명은 삭제할 때 두 유저에게 보이는 결과가 달라지는 문제가 발생했습니다. 구체적으로 다음과 같은 시나리오에서 문제가 발생했습니다. 아래 설명에서 나오는 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 하면 아래 그림과 같이 두 클라이언트의 해시 테이블이 같아지는 것을 알 수 있습니다.
기존 RHT 테스트 코드는 아래와 같이 테스트 시나리오의 각 스텝을 모두 하드코딩 하는 방식이었습니다. 이런 식으로 코드를 작성하다 보니 중복된 코드가 많아지고 읽기 어려워져서, 읽기 쉽고 일반화된 코드 스타일의 필요성을 느꼈습니다.
table driven test는 테스트 케이스를 테이블 형태로 정리해서 작성하는 방식입니다. 아래의 두 코드는 모두 Add(a, b)
를 테스트하는 코드로, 왼쪽에 있는 코드는 모든 테스트 케이스에서의 동작을 하드코딩하는 방식, 오른쪽에 있는 코드는 테스트 케이스 설명과 기대하는 결과, 그리고 a b의 값을 테이블 형태로 작성하는 방식입니다. 테스트 케이스가 많아질수록 오른쪽의 방식이 더 읽기 쉽고 코드의 중복도 감소한다는 것을 알 수 있습니다.
아래 코드처럼 외부 변수를 사용하면 context를 유지하면서 여러 시나리오를 차례대로 수행할 수도 있습니다. 아래 코드는 sum
의 값을 0으로 초기화한 뒤, 차례대로 값을 더해가면서 값의 변화를 확인하는 방식의 테스트 코드입니다.
rht_test.go에서 table driven test 형식으로 작성한 RHT의 테스트 코드를 확인할 수 있습니다.
Yorkie는 위에서 언급한 tombstone node를 제거하기 위한 garbage collector(design docs)를 갖고 있습니다. garbage collection의 대략적인 과정은 다음과 같습니다.
registerRemovedElement(element)
를 이용해 tombstone node 등록(removedElementSetByCreatedAt
에 삽입)garbageCollect(timestamp)
에서 timestamp
시점 이전에 등록된 모든 tombstone node에 대해 deregisterElement(element)
호출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에 넣어볼걸…
Yorkie에서 트리 구조의 문서를 담당하는 Tree
는 다양한 연산을 제공합니다. Tree
의 연산은 크게 basic edit, advanced edit, style 로 구분할 수 있습니다.
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가지 대분류 안에서 세부 내용을 일일이 직접 구현하고 있다는 것입니다.
이런 식의 테스트는 테스트 커버리지를 확인하기 어려울 뿐만 아니라, 트리의 기능을 수정하거나 새로운 기능을 추가할 때마다 여러 개의 테스트 코드를 다시 수정해야 한다는 문제점이 있습니다. 더 나아가, 현재 테스트 코드가 발생할 수 있는 모든 동시 편집 상황을 표현한다는 보장이 없으므로, 문제를 발견할 때마다 땜질하는 느낌으로 수정하고 테스트 코드를 추가하는 것을 반복하고 있어서 언제 트리 구현이 완료될지 모르는 상황이었습니다.
따라서 새로운 동시 편집 테스트 프레임워크를 설계하기로 결정했습니다. 새로운 프레임워크의 목표는 두 가지로 설정했습니다.
각 테스트마다 2개의 연산만 고려하면 되기 때문에 테스트는 (1) 두 유저가 구간을 선택하는 방법, (2) 첫 번째 유저의 연산, (3) 두 번째 유저의 연산까지 총 3개의 차원으로 나눠서 구분하기로 결정했습니다. 두 유저가 선택하는 구간은 기본적으로 아래 그림과 같이 4가지 정도로 나눌 수 있지만, 유저들이 사용하는 연산의 종류에 따라 더 많이 고려해야 할 수도 있습니다.
예를 들어 첫 번째 유저는 Edit 계열의 연산, 두 번째 유저는 Style 계열의 연산을 사용한다고 가정하면 A contains B
와 B 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)$이 보장되는 자료구조입니다. 스플레이 트리는 다음과 같은 다양한 성질이 알려져 있고, 이는 전체 실행 시간 측면에서는 스플레이 트리가 좋은 자료구조라는 것을 의미합니다.
push_back
, pop_front
연산을 수행할 때 전체 시간 복잡도는 $O(n+m)$push_front
, push_back
, pop_front
, pop_back
연산을 수행할 때 전체 시간 복잡도는 $O(n+m)$일 것이라는 추측하지만 여전히 연산 한 번의 시간 복잡도가 $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%를 감소시킬 수 있었습니다. 테스트 코드와 결과는 (여기)에서 확인할 수 있습니다.
현재 LLRBTree
는 RGATreeSplit
에서 분할할 지점의 인덱스가 주어지면 실제로 쪼개질 노드를 구할 때 사용하고 있으며, 모든 키가 정수라는 특징을 갖고 있습니다. 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가지 목표를 세웠습니다.
운 좋게도 저에게 맞는 프로젝트와 좋은 팀원들을 만나서 세 가지 목표 모두 잘 이룰 수 있었습니다.
PS를 했던 경험은 시간 복잡도를 계산하거나 자료구조를 직접적으로 다룰 때뿐만 아니라 회사에서 일하는 내내 은은하게 도움 되었습니다. 저는 제가 평소에 하는 일을 소개할 때 “문제를 풀고, 문제 푸는 방법을 가르치고, 문제를 만드는 사람”이라고 소개합니다. 세 가지 경험 모두 큰 도움이 되었습니다. 특히 코드를 작성하는 것과 동시에 예외 상황이나 반례를 떠올리는 것, 그리고 코드를 눈으로 디버깅하는 것은 PS를 하지 않았다면 거의 불가능했을 것 같습니다. 비슷한 맥락으로 사람들을 가르치고 코드를 디버깅해 준 것도 많은 도움이 되었습니다. 제가 생각했던 것보다도 남의 코드를 읽고 디버깅할 일이 매우 많았습니다. 문제를 출제하고 데이터를 만든 경험 덕분인지 “문제를 잘 정의하는 것과 테스트를 꼼꼼하게 작성하는 것에 큰 강점이 있다”라는 피드백을 듣기도 했습니다.
원래의 계획은 트리 동시 편집 테스트를 새로 작성한 다음 실제로 동시 편집 결과가 수렴하지 않는 케이스를 몇 개 해결하는 것이었습니다. 테스트 작성과 RHT의 동시 편집 지원까지는 잘 완성했지만, 트리 구조 문서의 동시 편집 문제를 하나도 해결하지 못한 채로 인턴 기간이 종료된 것이 많이 아쉬웠습니다. 이진 탐색 트리 부분도 더 개선해 보고 싶었는데, 충분히 고민하거나 실험할 시간이 없어서 이 부분도 거의 건들지 못했습니다. Yorkie는 오픈소스 프로젝트인 만큼 시간이 날 때마다 조금씩 더 기여해 보려고 합니다. 지금은 바빠서 추가로 작업을 하지 못하고 있지만 언젠가는 잘 끝낼 수 있을 때가 오길 바라며…
끝!
]]>본론으로 들어가기 전에 중학교와 고등학교에서 배운 내용을 아주 빠르게 복습하고 넘어갑시다.
$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$라는 기호를 사용했습니다.
여러 개의 집합이 주어졌을 때 합집합의 크기를 구하는 방법에 대해 알아봅시다. 집합 $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$와 같이 나타낼 수 있습니다.
${n \choose k} = {n-1 \choose k-1} + {n-1 \choose k}$를 이용하면 $n, k \leq N$일 때의 이항 계수를 $O(N^2)$ 시간에 계산할 수 있습니다. 하지만 이항 계수를 적당한 정수 $M$으로 나눈 나머지를 구하는 것은 $O(N^2)$보다 빠르게 계산할 수 있고, 온라인 저지에서 문제를 풀 때는 주로 이런 방법을 요구합니다.
위 네 가지 방법은 (여기)에 있는 슬라이드에서 자세한 설명을 확인할 수 있습니다. 첫 번째 방법만 알아도 연습 문제를 푸는 데 큰 지장은 없습니다.
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)$에 계산할 수 있지만… 글의 범위를 벗어나므로 설명을 생략하겠습니다.
여는 괄호 $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}$이라는 점화식을 얻을 수 있고, 이는 카탈란 수의 점화식과 동일합니다.
이밖에도 카탈란 수를 이용해 풀 수 있는 문제가 많이 있는데, 아래에 있는 연습 문제를 풀어보면서 카탈란 수에 어떻게 대응시킬 수 있을지 고민해 보는 것을 추천합니다.
카탈란 삼각형 $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)$도 성립한다고 합니다.
제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$번 원소가 들어가는 집합을 기준으로 생각해 보면 아래 두 가지 경우로 나눠서 계산할 수 있다는 것을 알 수 있습니다.
따라서 ${n \brace k} = {n-1 \brace k-1} + {n-1 \brace k} \times k$ 를 이용해 계산할 수 있습니다.
벨 수는 $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)$에 계산할 수 있지만 글의 범위를 벗어나므로 설명을 생략하겠습니다.
포함 배제의 원리를 이용하면 제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$입니다.
부호 없는 제1종 스털링 수는 $n$개의 원소를 $k$개의 방향 있는 사이클로 분할하는 경우의 수를 의미하며, 주로 $\begin{bmatrix} n \ k \end{bmatrix}$로 나타냅니다. 순열의 사이클 분할을 생각하면, $\sum_{k=1}^{n} \begin{bmatrix} n \ k \end{bmatrix} = n!$ 임을 알 수 있습니다. 제1종 스털링 수도 제2종 스털링 수와 같이 점화식을 이용해 계산할 수 있는데, $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)$이라는 점화식을 얻을 수 있습니다.
$\lambda = (\lambda_1, \lambda_2, \cdots, \lambda_k)$가 아래 조건을 만족할 때 $\lambda$를 $N$의 자연수 분할이라고 부릅니다.
$N$의 자연수 분할 $\lambda = (\lambda_1, \lambda_2, \cdots, \lambda_k)$가 주어지면 $\lambda$를 이용해 $k$개의 행으로 구성되어 있고 $i$번째 행에 $\lambda_i$개의 칸이 있는, 총 $n$칸짜리 영 다이어그램을 만들 수 있습니다. 예를 들어 9의 자연수 분할 $(4, 3, 1, 1)$의 영 다이어그램은 다음과 같습니다.
조합론에서 영 다이어그램은 많은 의미를 갖고 있지만, 이 글의 범위를 벗어나고 아직 저도 잘 모르기 때문에 생략합니다.
$P_k(n)$ 또는 $P(n, k)$는 자연수 $n$을 순서를 고려하지 않고 $r$개의 자연수의 합으로 나타내는 경우의 수를 의미합니다. 행이 $k$개인 $n$칸짜리 영 다이어그램의 가짓수라고 생각해도 무방합니다. $P(n, k)$의 점화식은 영 다이어그램의 마지막 행의 길이를 기준으로 생각하는 것이 편합니다. 행이 $k$개인 $n$칸짜리 영 다이어그램을 만드는 것은 아래 두 가지 경우로 나눠서 생각할 수 있습니다.
따라서 $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)$ 시간에 계산할 수 있는 점화식이 두 가지 있는데, 궁금하신 분들은 글 하단에 있는 링크를 참고하시길 바랍니다.
교란 순열 또는 완전 순열이란 모든 $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})$ 라는 점화식을 얻을 수 있습니다.
점화식을 적절히 정리하면 $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!}$을 얻을 수 있습니다.
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)$ |
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$)로, 매우 느리게 증가하는 함수입니다.
이후 설명에서 사용하는 코드의 이해를 돕기 위해, 제가 사용하는 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;
}
};
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;
}
};
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 등 다양한 알고리즘의 시간 복잡도 증명에 사용되기 때문에 잘 이해하고 넘어가야 합니다.
아래 코드와 같이 경로 압축을 수행하면 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)$이 됩니다.
BOJ 13306 트리 문제를 봅시다. 아래 두 가지 쿼리를 처리해야 하는 문제입니다.
일반적으로 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 동적 연결성과 쿼리처럼 이전 쿼리의 결과를 이용해 다음 쿼리의 파라미터를 생성하게 하는데, 이런 문제는 반드시 쿼리를 주어지는 순서대로 처리해야 합니다.
이 단락에서는 Union Find에 몇 가지 추가 정보를 저장해서 간선이 추가되는 상황에서 각 컴포넌트가 이분 그래프인지 판별하는 문제를 푸는 방법을 알아볼 것입니다. 이분 그래프란, 그래프 $G = (V, E)$를 $E \subset {(u, v);\ u \in L, v \in R}$이 성립하도록 정점 집합을 $V = L \cup R$로 분할할 수 있는 그래프를 의미합니다. 쉽게 말해, 정점 집합을 두 개의 그룹 $L, R$으로 분할하려고 할 때, 같은 그룹에 속한 두 정점을 연결하는 간선이 없도록 분할 가능한 그래프가 이분 그래프입니다.
BOJ 1765 닭싸움 팀 정하기 문제를 봅시다. 다음과 같은 조건이 주어질 때 가능한 친구 그룹 개수의 최댓값을 구하는 문제입니다.
사람을 정점으로, 친구 그룹을 정점 그룹으로 생각하면 이 문제를 이분 그래프의 컴포넌트 개수(와 비슷한 것)을 관리하는 문제로 바꿀 수 있습니다.
이런 문제는 무조건 같은 그룹에 포함되어야 하는 정점들을 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;
}
};
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));
}
};
std::set(또는 balanced binary search tree)은 삽입(insert), 삭제(erase), x 이상인 최소 원소(lower bound), x 초과인 최대 원소(upper bound) 등의 연산을 $O(\log n)$에 수행하는 멋진 자료구조지만, 시간 복잡도에 붙는 상수 계수가 상당히 크다는 단점이 있습니다. 이 단락에서는 아래 조건을 만족하는 상황에서 Union Find를 이용해 lower bound와 upper bound 연산을 구현하는 방법에 대해 다룹니다.
즉, 원소가 추가되지 않을 때 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;
}
Union Find를 이용하면 다음과 같은 쿼리가 주어지는 문제(BOJ 3830 교수님은 기다리지 않는다, 2021 SCPC 1차 예선 5번)도 해결할 수 있습니다.
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)};
}
};
대출을 갚는 방법은 여러 가지가 있지만, 일단 이 글에서는 만기 일시 상환과 원리금 균등 분할 상환에 주목해 봅시다. 만기 일시 상환은 대출받은 뒤 만기일 전까지는 이자만 갚다가 만기 때 남은 이자와 대출 원금을 갚는 방식입니다. 평소에는 조금씩 돈을 갚다가 마지막에 한 번 큰 금액을 갚는다고 생각하면 됩니다. 원리금 균등 분할 상환은 (대출 원금 + 만기일까지 지급해야 할 이자)를 만기일까지 균등하게 상환하는 방식입니다. 같은 금액을 갚는 것이지만(사실은 조금 다릅니다) 조금씩 갚다가 딱 한 번 많이 갚는 것과, 매번 균등하게 갚는 차이가 있습니다.
알고리즘 세미나에서 왜 뜬금없이 대출 이야기를 하는지 궁금한 분들이 있을 텐데, 그 이유는 뒷 내용을 들으면 자연스럽게 이해가 될 것입니다.
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)$으로도 표현할 수 있습니다.
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
연산의 시간 복잡도를 어떻게 표현하는 것이 좋을까요?
분할 상환 분석은 이런 질문에 대한 멋진 답을 알려줄 수 있습니다. 다시 대출 이야기로 돌아가 봅시다. 오큰수 문제의 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)$으로 나타낼 수 있습니다.
동적 배열은 C++의 std::vector
처럼 들어있는 원소의 개수에 따라 크기가 유동적으로 변하는 배열을 의미합니다. 이 글에서는 push_back
연산과 pop_back
연산을 지원하는 동적 배열을 구현하면서, 잠재 비용 분석(potential method) 방법을 이용해 시간 복잡도를 분석하는 방법에 대해 다룹니다.
동적 배열은 미리 공간을 여유 있게 할당한 다음, 배열에 데이터를 저장할 때마다 미리 할당받은 공간에 하나씩 저장하는 방식으로 동작합니다. 만약 할당받아 놓은 공간을 모두 사용했다면, 조금 더 큰 공간을 할당받은 다음, 기존에 저장되어 있던 데이터를 모두 새로운 공간으로 옮긴 이후에 데이터를 추가합니다. 따라서 동적 배열은 현재 저장되어 있는 데이터의 개수를 나타내는 변수인 size
와 할당받은 공간의 크기를 나타내는 변수인 capacity
를 관리해야 합니다.
미리 할당받은 공간에 데이터를 저장하는 것은 단순히 arr[size++] = data;
와 같이 상수 시간에 처리할 수 있고, pop_back
연산도 size--;
를 이용해 상수 시간에 처리할 수 있습니다. 따라서 동적 배열의 성능은 할당받은 공간을 모두 사용했을 때 추가 공간을 할당하는 정책에 따라 결정된다고 볼 수 있습니다.
가장 먼저 생각나는 방법은 아마 적당한 상수 $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배씩 확장하면 원소를 $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
의 절반 이하를 사용할 때 공간의 크기를 절반으로 줄이는 방법은 어떨까요? 이 방법은 size
와 capacity
가 같은 상황에서 push_back
과 pop_back
을 번갈아 가며 호출하면 매번 $O(n)$의 연산을 수행하게 돼서 비효율적입니다.
할당받은 공간의 1/4 이하만 사용할 때 공간의 크기를 절반으로 줄이는 정책을 사용하면 삽입과 삭제를 $n$번 수행했을 때 최대 $O(n)$번의 연산만 한다는 것을 증명할 수 있습니다. 하지만 실제로 총계 분석이나 결산 분석 방법을 이용해 직접 증명하려고 하면 쉽지 않습니다. 이를 증명하는 건 뒤에서 포텐셜 메소드를 배운 뒤에 하도록 하고, 일단 지금은 amortized $O(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)$을 만족하도록 함수를 정의하면 분할 상환 비용의 합은 실제 비용의 상한이 됩니다.
$\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)$을 넘지 않음을 알 수 있습니다.
push_back
과 pop_back
을 모두 지원하는 동적 배열의 시간 복잡도를 분석하기 전에, 조금 더 간단한 케이스인 push_back
만 지원하는 동적 배열의 시간 복잡도를 분석해 보겠습니다.
포텐셜 함수는 $\Phi(D_i) = 2 \times s(D_i) - c(D_i)$으로 정의하겠습니다. $s$와 $c$는 각각 size
와 capacity
를 의미합니다. 즉, 재할당이 일어난 직후가 가장 이상적인 형태이며, 재할당한 지 오래 지났을수록 자료구조가 망가진 상태로 정의한 것입니다. 동적 배열의 알고리즘을 생각해 보면 항상 $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)$입니다.
포텐셜 함수를 다음과 같이 정의합시다. 마찬가지로 재할당이 발생한 직후인 $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$을 만족합니다.
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 이하입니다.
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)$ 시간에 동작함을 알 수 있습니다.
컴퓨터를 공부하고 있는 평범한 대학교 3학년 학생이 살아가는 이야기입니다. 흘러가는 대로 살다 보니 벌써 대학교에 입학한 지 3년이나 지났습니다. 고등학생 때나 지금이나 별반 다르지 않은데… 고등학교 졸업보다 대학교 졸업이 더 가깝다는 게 아직은 실감이 나지 않습니다.
올해도 지난 몇 년과 비슷하게 PS 이야기가 대부분을 차지합니다. PS밖에 안 한 걸 보면 발전이 없는 사람처럼 보이지만, 작년에 비해 훨씬 더 다양한 활동을 했으니 조금은 성장했다고 생각합니다.
PC 화면으로 보시는 분들은 오른쪽 사이드 바를 이용해 원하는 문단으로 넘어갈 수 있습니다.
매년 다양한 목표를 세우지만, 항상 백준 랭작 말고는 달성하는 게 없는 것 같습니다. ‘BOJ 랭작 멈추기’ 같은 목표를 세우면 어떻게 될까요?
이 문단은 2023-2024 취준 후기 글로 이동했습니다. (링크)
전공과목들은 작년(2학년)에 비해 전체적으로 재미없었습니다. 형식언어와 오토마타, 문제해결 같은 과목 듣다가 파일처리랑 웹프로그래밍을 들으면 누구나 그렇게 생각하지 않을까요? 일단 저는 그랬습니다. 강의 듣는 게 너무 재미없어서 강의 시간에는 BOJ 랭작을 열심히 했었고, 대신 강의 자료 다운로드받아서 집에서 혼자 공부했습니다. 혼자 공부하면 재미있던데 왜 강의실에만 앉아있으면 흥미가 뚝 떨어지는지 모르겠습니다. 중요한 과목에서는 나름 좋은 성적을 받았으니 괜찮은 것 같습니다.
전공과목은 프로그래밍언어, 네트워크프로그래밍, 파일처리, 오픈소스기반고급설계를 수강했고, 수학과 전공과목인 조합론도 함께 들었습니다.
조합론은 PS 공부하려고 신청한 과목이었습니다. 정수론과 조합론은 PS 하면서 영재고 → 서울대/카이스트 루트를 탄 학생들에 비해 가장 크게 뒤쳐진다고 느낀 분야였습니다. 작년 2학기에 정수론, 올해 1학기에 조합론을 수강한 것은 모두 그 격차를 조금이라도 줄여보기 위함이었습니다. xMO에 나올 법한 신박한 카운팅이나 AtCoder에 자주 등장하는 생성함수 같은 것을 배우고 싶어서 조합론을 신청했지만…
교수님께서 수학과 4학년이면 취업 생각을 해야 한다면서 갑자기 중간고사 이후부터 머신러닝을 가르쳐주셨습니다. 기대했던 내용을 배우지는 못했지만, 중간고사는 중학생 때 KOI 지역 예선 공부하면서 봤던 내용, 기말고사는 중학생 때 혼자 머신러닝 공부하던 내용으로 모두 커버돼서 성적은 잘 나온 과목입니다.
프로그래밍언어는 1학기에 배운 과목 중 가장 재미있는 과목이었습니다. 지난 학기에 오토마타 과목에서 배운 Context Free Grammar를 이용해 C언어 문법을 표현하는 방법에 대해 한 학기 동안 배웠고, 그 과정에서 교수님의 지도 덕분에 재귀적으로 정의된 개념을 엄밀하면서도 쉽게 설명하는 방법에 대해 배웠습니다. 학생들은 별로 안 좋아하는 것 같았지만, 사람들을 가르치는 일을 하는 저에게는 큰 도움이 되었습니다. 시험공부는 별로 안 했지만, 강의를 다른 과목에 비해 재미있게 들은 덕분인지 꽤 좋은 성적을 받았습니다.
네트워크프로그래밍은 별로 기대하지 않고 신청했는데 생각했던 것보다 재미있었고 과제도 유익했습니다. 강의 들을 때는 대충 당연한 이야기를 하는 것 같아서 잘 듣지 않았었는데, 네이버 인턴 면접 준비하면서 열심히 듣지 않은 것을 많이 후회했습니다. 이 글을 읽고 있는 숭실대학교 컴퓨터학부 1~2학년 학생이 있다면 3학년 때 꼭 열심히 들으시길 바랍니다.
다른 두 과목은 별로 할 이야기가 없습니다.
전공과목은 운영체제, 컴퓨터비전, 인공지능, 컴파일러, 웹프로그래밍설계및실습을 수강했고, 수학과 전공과목인 집합론도 함께 들었습니다.
운영체제는 졸업 해야 돼서 들었습니다. 과제 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번째 과제와 그 이후 과제를 하나도 하지 못했습니다. 시간 날 때 다시 공부할 계획입니다.
다른 두 과목은 별로 할 이야기가 없습니다.
올해 1월부터 회장 인수인계를 시작해서 1학기 시작할 때쯤 chansol(링크)에게 회장을 넘기고 저는 부회장과 총무를 겸하게 되었습니다. 하지만 2학기 때 모종의 사유로 제가 다시 회장을 넘겨받게 되었고, 총무를 넘겨받을 후임자도 찾지 못해서 회장 업무와 총무 업무를 모두 혼자서 처리하는 중노동에 시달리게 되었습니다.
학교가 대면으로 완전히 전환됨에 따라 SCCC도 작년에 비해 많은 행사를 했습니다. 1학기는 SCON 개최, 여름방학은 기초 알고리즘 강의, 2학기는 동아리 내전에 가장 많은 시간과 노력을 투자했습니다. 이 밖에도 스터디를 대면으로 전환하고 새로운 형태의 스터디를 진행한 것, 동아리 방을 활성화한 것도 큰 성과입니다. 작년에는 저 혼자 모든 스터디를 진행했는데 올해는 의도적으로라도 다른 분들에게 강의를 나눠준 것도 개인적으로는 큰 변화라고 생각합니다.
개인적으로 갖고 있는 성공적인 스터디의 기준은 “한 시즌의 끝까지 따라오는 학생이 1명 이상 존재하는 것”입니다. 올해는 총 3번의 스터디를 진행했는데, 매번 끝까지 따라오는 것을 넘어서, 혼자서 더 많이 공부하고 질문하는 등 아주 열심히 하는 학생을 1명 이상 발견해서 올해 스터디는 매우 성공적이라고 생각합니다.
3시간의 대회를 위해 4달을 준비했습니다. 대회 개최 후기는 (여기)에 이미 잘 적어놓았으므로 더 이야기하진 않겠습니다. 제가 올해 블로그에 올린 글 중에서 가장 많은 공을 들인 글이니 한 번씩 읽어주시면 감사하겠습니다.
이제 슬슬 내년 대회도 준비해야 하는데… 일단 Good Bye BOJ 2023! & Hello BOJ 2024! 부터 잘 마무리 짓고 생각하겠습니다.
작년 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주 동안 매주 2번씩 오후 2시부터 6시까지, 총 48시간 동안 기초 알고리즘 강의(링크)를 진행했습니다. 코딩테스트에 나오는 모든 범위, 그리고 코드포스 오렌지를 찍는 데 필요한 모든 개념을 전부 커버하는 것이 목표였습니다. 강의를 준비할 때 제가 중점적으로 고려한 것은 아래 2가지입니다. 시중에 돌아다니는 알고리즘 강의 자료가 모두 마음에 들지 않아서 제가 원하는 구성으로 처음부터 끝까지 직접 강의 자료를 제작했습니다.
한 번에 4시간씩 강의를 했지만 실제로 설명하는 시간은 1.5 ~ 2시간 정도였고, 남은 시간은 강의실을 돌아다니면서 코드에 훈수 두고 질문을 받았습니다. 단순히 앞에서 개념과 문제 풀이만 설명하고 끝내는 강의가 별로 좋지 않다는 것은 작년에 깨달았기 때문에, 강의실에 앉아있는 시간만이라도 제발 문제를 본인의 힘으로 직접 풀었으면 해서 이렇게 진행했습니다. BOJ 그룹 연습 스코어보드가 이쁘게 나온 것을 보면 나름대로 효과가 있었던 것 같습니다.
하지만 너무 많은 연습 문제에 부담을 느껴서 중도 하차를 하거나, 한 번 결석한 이후로 진도를 따라가지 못한 학생이 여러 명 있었습니다. 다음에 또 이런 형태의 강의를 진행하게 된다면 필수 문제와 심화 문제로 나누고, 강의 시작할 때마다 지난 시간 내용을 리뷰하는 등의 변화를 주는 게 좋을 것 같습니다.
2학기에 동아리 내전을 열기 위해 야심 차게 준비한 스터디(링크)였지만… 제가 계절학교와 대회 운영 등으로 스터디에 시간을 쓰지 못해서 실제 문제 출제까지는 이어지지 못했습니다. 지금까지 제가 출제했던 문제들을 활용해서 문제 지문, generator, validator, checker를 올바르게 작성하는 방법에 대해 가르치는 스터디였습니다. 참가자들의 열정을 제가 따라가지 못해서 죄송할 따름입니다.
1학기에 가장 많은 시간과 노력을 투자한 행사가 SCON이었다면, 2학기는 동아리 내전이었습니다. 직접 문제를 만들어서 내전을 개최한다는 원대한 꿈은 이루지 못했지만… 대신 여름방학 동안 kyo20111와 함께 러시아 문제를 풀면서 모은 1~2명만 해결한 좋은 문제들을 이용해 내전을 개최했습니다. 많은 사람이 참가해서 알고리즘 대회의 즐거움을 알아갔으면 하는 마음으로 상품을 푸짐하게 준비했지만, 3번의 내전 모두 참가자 수가 상품의 개수를 넘지 못하는 참사가 발생했습니다. 그래도 참가했던 분들은 다들 재미있게 문제를 푼 것 같아서 다행입니다. 문제는 (여기)에서 확인할 수 있습니다.
ICPC 예선 직전에는 3인 1팀 형태의 내전도 진행했습니다. 최대한 대회와 유사한 환경을 제공하기 위해서 DomJudge 서버를 구축하고, 해외 리저널 예선 문제(링크)를 가져와서 쉬운 문제 몇 개 번역(링크)해서 제공하고, 외부 공간을 대여해서 진행했습니다. 문제 셋을 잘못 선정해서 조금 구데기 같았던 것을 빼면 나름 괜찮게 굴러갔던 것 같습니다.
개인 대회와 팀 대회 모두 내년에도 진행하고 싶지만, 대회를 열 수 정도로 문제를 모으지 못할 것 같아서 걱정입니다. 방학 동안 열심히 모아야지…
2학기에는 기본적인 알고리즘 개념을 배울 수 있는 기초 알고리즘 강의, 기초 알고리즘 강의에서 배운 내용으로 다양한 문제를 해결하는 기초 알고리즘 연습, 기초보다 조금 더 어려운 solved.ac 기준 P5~P3 정도의 개념을 배울 수 있는 중급 알고리즘 강의까지, 총 3개의 오프라인 강의를 진행했습니다. 기초 강의와 기초 연습은 여름방학에 사용했던 자료를 기반으로 진행했으며, 기초 연습은 강사가 아닌 수강생들이 돌아가면서 각자의 풀이 방법을 주고받는 형태로 진행했습니다. 중급 강의는 작년 겨울에 진행했던 중급 강의 자료에 계산 기하 내용을 추가해서 진행했습니다.
기초 연습은 끝까지 따라온 부원과 그렇지 않은 부원의 차이가 매우 컸는데, 끝까지 따라온 분들은 매우 만족해하시는 것 같았습니다. 다만 서로 풀이를 토론하는 방식은 참가자들이 시간을 더 많이 투자할 수 있는 방학에 진행하면 더 좋을 것 같다는 피드백을 받았습니다.
중급 강의는 강의를 1번 진행할 때마다 그룹 연습 스코어보드에 뜨는 핸들이 대략 절반씩 없어져서 슬펐습니다. 다들 과제에 치여서 사느라 그런 것 같습니다. PS보다는 학교 강의가 중요하긴 하지… 그래도 끝까지 따라온 사람이 3명이나 있는 것을 보면, 제가 SCCC를 떠나더라도 소모임이 잘 유지될 수 있을 것 같다는 생각이 듭니다.
사실 동아리 방이라고 부르기도 뭐한 공간이긴 하지만… 1년 동안 많은 변화가 있었습니다.
작년까지는 비좁은 공간에 책상 6개, 의자 7개, 고장 난 컴퓨터 5대, 윈도우 7이 깔린 컴퓨터 1대, 한 10년은 됐을 것 같은 모니터 1n개가 들어가 있는 삭막한 공간이었습니다. 사람들이 알고리즘에 재미를 붙이는 것보다는 동아리 방에서 사람들과 친해져서 자연스럽게 문제를 풀도록 하는 게 더 빠를 것 같아서, 사람들이 많이 오도록 유도하기 위해 많이 갖다 버리고 새로운 물건을 새로 사서 채워 넣었습니다.
일단 모든 컴퓨터 본체와 모니터를 갖다 버리는 것부터 시작했습니다. 의자도 2개 버리고 책상 배치를 변경한 다음, 전선 몰딩을 사서 직접 시공도 했습니다. 65w 충전기와 C to C 케이블, 라이트닝 케이블, 모니터 거치대, 담요 등은 현대모비스의 후원을 받아서 구매했습니다. 이렇게 해서 1학기에는 책상과 충전기가 있는, 공강 시간 때우기에 적합한 공간으로 만들었습니다. 덕분에 작년에는 1비트로 표현할 수 있었던 동아리 방 사용 인원은 무려 2비트로도 표현할 수 없을 정도로 커졌습니다.
2학기에는 현대 모비스의 후원을 받아 27인치 모니터와 모니터 암을 구매했습니다. 이제는 평일 오후 10시나 주말에 가더라도 3명 이상의 사람이 있으며, 실제 사용 인원은 5비트로 표현해야 할 정도로 많아졌습니다. 이렇게 사람이 모이다 보니 서로 solved.ac 레이팅이나 스트릭으로 경쟁하는 등 제가 원래 기대하던 효과도 나타나기 시작했습니다.
이 블로그의 메인 콘텐츠입니다. 작년보다 더 다양한 경험을 했습니다.
올해는 작년이나 재작년만큼 대회를 많이 운영하진 않았지만, 임팩트가 큰 대회를 여러 개 운영했습니다. 기억할 만한 대회는 이미 장문의 후기를 남겼기 때문에, 지금은 그냥 간단하게 코멘트만 남겨보려고 합니다.
한국 정보 올림피아드(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에서도 강의했습니다. 대부분의 이야기는 위에서 했으니 그냥 넘어갑니다. 강의 자료는 (여기)에서 볼 수 있습니다.
어차피 지키지도 않을 것 같지만 일단 또 목표를 세워봅시다.
사실 가장 크고 급한 목표는 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랑 코드포스 레드는 몇 년째 목표에 있는지 모르겠습니다.
내년에는 또 어떤 일이 벌어질지 궁금하고 기대되면서도, 이제 슬슬 진로를 정해야 할 시기가 온 것 같아서 참 고민이 많습니다. 어떻게든 흘러가지 않을까요?
끝!
]]>