제가 백준 14939번 "불 끄기"를 무슨 역행렬을 쓰는 이상한 방법으로는 풀 수 있는데 브루트포스로 푸는 방법은 최근까지 전혀 몰랐다고 하면 믿어지시나요? 저도 못 믿겠습니다.
아무튼 이 글은 Honeyhouse와 한별포스 이후 벌써 3번째로 작성하는 선형대수학에 관한 글입니다. 분량은 그렇게까지 길어지지는 않을 것 같네요.
문제 옮겨적기
문제
전구 100개가 10×10 정사각형 모양으로 늘어서 있다. 전구에 달린 스위치를 누르면 그 전구와 위, 아래, 왼쪽, 오른쪽에 있는 전구의 상태도 바뀐다. 전구 100개의 상태가 주어지면 모든 전구를 끄기 위해 최소한으로 눌러야 하는 스위치의 개수를 출력하라
입력
10줄에 10글자씩 입력이 주어진다. #은 꺼진 전구고 O(대문자 알파벳 o)는 켜진 전구다. #과 O외에는 입력으로 주어지지 않는다.
출력
모든 전구를 끄기 위해 최소한으로 눌러야 하는 스위치의 개수를 출력하라. 불가능하면 -1를 출력하라.
예제 입력 1
#O######## OOO####### #O######## ####OO#### ###O##O### ####OO#### ########## ########O# #######OOO ########O#
예제 출력 1
4
작은 버전의 문제
때마침 설명에 쓰기 좋은 게임이 있네요. 프로토 게임잼1 2022년 4월 참여작인 도바's 캔디드롭을 소개합니다.
플레이어가 원형의 아레나 위를 돌아다니고, 가운데에 보스가 있습니다(오른쪽 위의 원 모양 그림이 위에서 본 미니맵입니다). 보스의 공격을 적당히 피하면서 아레나에 설치된 버튼 8개를 모두 눌러진(노란색) 상태로 바꾸어야 하는데, 버튼을 누르면 양옆 버튼의 상태가 같이 바뀝니다. 원본 문제와는 달리 양옆이 연결되지만 일단은 신경쓰지 말아 봅시다.
이 게임이 올라왔을 당시에 스피드런 이벤트가 같이 열리고 있었는데, 가끔씩 처음 보는 헷갈리는 버튼 조합이 나와서 시간을 다 잡아먹길래 머릿속에서 계산할 수 있는 공략법이 있는지 자연스럽게 생각해보게 되었습니다. 지금도 디스코드에 이 흔적이 남아 있습니다.
반응 행렬 만들기
버튼에 1번부터 8번까지 번호를 붙이고 각 버튼이 어떻게 상호작용하는지 반응 행렬(급조한 이름입니다)로 나타내 봅시다. 이 행렬의 원소 Rij는 i번째 버튼을 눌렀을 때 j번째 버튼이 반응하면 1, 그렇지 않으면 0입니다.
R=(1100000111100000011100000011100000011100000011100000011110000011)이 행렬의 오른쪽에 누르려는 버튼을 열벡터로 모아서 곱하면 버튼을 누른 결과를 나타내는 열벡터를 얻을 수 있습니다.
R(01001001)=(01111111)그런데 제가 원하는 건 원하는 결과가 주어졌을 때 올바른 버튼의 조합을 구하는 것입니다. 완전히 반대 방향이네요. 역행렬을 구해 봅시다!
어... 잠시 혼선이 있었습니다. 대신 직접 구한 결과는 이렇습니다.
R−1=(1011011001011011101011011101011001101011101101011101101001101101)검산 겸 위에서 R−1에 2번, 5번, 8번 버튼을 누른 결과를 다시 곱해봅시다.
R−1(01111111)=(01001001)눌러야 되는 버튼 조합을 복구했네요! 위의 R−1을 뜷어져라 쳐다보면, 누르고 싶은 버튼이 있으면 그 버튼을 포함해서 좌우 2칸, 3칸씩 떨어진 버튼(총 5개입니다)을 전부 누르면 된다는 공략을 얻을 수 있습니다. 머릿속으로 계산하기 어려운 공략이긴 하네요.
실수 행렬과 0-1 행렬
위에서 Wolfram|Alpha로 계산한 결과를 다시 보면 처음에 예상했던 0과 1의 조합이 아니라 −13과 23의 범벅이었습니다. 뭐가 문제였을까요?
사실 "처음에 예상했던 0과 1의 조합"에 제가 말하지 않고 넘어갔던 가정이 숨어 있습니다. 행렬 R은 임의의 실수가 아니라 0과 1만으로 이루어진 0-1 행렬입니다. 게다가 우리는 누른 버튼을 다시 누르면 원래대로 돌아온다는, 즉 1+1=0이라는 가정을 추가로 하고 있었습니다. Wolfram|Alpha는 대신 R이 일반적인 실수 행렬이라고 가정하고 풀었기 때문에 "잘못된"2 결과가 나온 것입니다.
그러고 보니까 창명님, 혹시 아까 R−1은 어떻게 구하셨나요?
그냥 가우스 소거법 썼는데요?
엥? 일반적인 실수 행렬도 아닌데 그래도 되나요?
추상 벡터 공간
그럴 줄 알고 수학자들이 이미 벡터와 행렬을 일반화해서 벡터 공간이라는 이름을 붙여 놓았습니다. 다음과 같은 몇 가지 공리만 만족시키면 "벡터 공간"이라고 하고, 선형대수학의 모든 정리와 도구를 그냥 가져와서 쓸 수 있습니다.
설명의 편의상 스칼라는 기울임꼴 소문자, 벡터는 굵은 소문자, 행렬은 굵은 대문자로 적겠습니다.
- 스칼라가 체를 이룬다.
- 덧셈의 결합법칙: a+(b+c)=(a+b)+c
- 덧셈의 교환법칙: a+b=b+a
- 덧셈의 항등원의 존재: a+0=a
- 덧셈의 역원의 존재: a+(−a)=0
- 곱셈의 결합법칙: a⋅(b⋅c)=(a⋅b)⋅c
- 곱셈의 교환법칙: a⋅b=b⋅a
- 곱셈의 항등원의 존재: a⋅1=a
- 곱셈의 역원의 존재: a⋅a−1=1 (단, a≠0)
- 덧셈에 대한 곱셈의 분배법칙: a⋅(b+c)=a⋅b+a⋅c
- 이건 공리라기보다는 전제 조건에 가까운 느낌으로 들어가 있습니다.
- 벡터 공간의 8대 공리
- 벡터합의 결합법칙: u+(v+w)=(u+v)+w
- 벡터합의 교환법칙: u+v=v+u
- 벡터합의 항등원의 존재: v+0=v
- 벡터합의 역원의 존재: v+(−v)=0
- 곱셈과 스칼라곱의 호환성: a⋅(b⋅v)=(a⋅b)⋅v
- 스칼라곱의 항등원의 존재: 1⋅v=v
- 여기서의 1은 위에서 언급한 곱셈의 항등원과 같습니다.
- 벡터합에 대한 스칼라곱의 분배법칙: a⋅(u+v)=a⋅u+a⋅v
- 덧셈에 대한 스칼라곱의 분배법칙: (a⋅b)⋅v=a⋅v+b⋅v
뭔가 내용이 엄청나게 많아 보이는데 제가 전부 풀어서 써서 그렇지 공리 하나하나는 생각보다 어렵지 않습니다. 여기서 중요한 점은, 관례상 덧셈과 벡터합을 +, 곱셈과 스칼라곱을 ⋅, 덧셈의 항등원을 0, 곱셈의 항등원을 1로 쓰긴 했지만 이것들이 진짜로 덧셈, 곱셈, 0, 1이 아니어도 된다는 것입니다. 애초에 이 문제에서는 1 더하기 1이 0입니다.
제가 이 이상 설명하려고 하면 더 이해하기 어려워질 것 같으니 나머지는 3Blue1Brown님의 이 유튜브 동영상으로 넘기겠습니다. 원래 이 글에서 0-1 벡터가 왜 벡터 공간을 이루는지도 전부 유도해보려고 했는데, 쓰기도 재미없고 읽기도 재미없을 것 같으니 연습문제로 남기겠습니다. 결론만 정리하자면 다음과 같습니다.
- 스칼라는 0과 1로만 이루어져 있음
- 덧셈은 모듈로 2 덧셈
- 덧셈의 항등원은 0
- 곱셈은 일반 곱셈
- 곱셈의 항등원은 1
아니면 이렇게도 해석할 수 있고, 어느 관점을 취하든 상관은 없습니다.
- 스칼라는 참과 거짓으로만 이루어져 있음
- 덧셈은 배타적 논리합 (XOR)
- 덧셈의 항등원은 거짓
- 곱셈은 논리곱 (AND)
- 곱셈의 항등원은 참
실전 문제 풀기
이제 문제를 푸는 방법도 알았고 그 방법이 올바르다는 것도 알았으니 실제로 문제를 풀어볼 시간입니다.
반응 행렬 만들기
도바's 캔디드롭에서는 버튼이 8개밖에 없었지만, 이 문제에서는 스위치가 무려 100개이고 반응 행렬도 100×100짜리가 됩니다. 이 정도는 컴퓨터로 비교적 빨리 풀 수 있으니 상관 없긴 합니다. 편의상 스위치 번호는 줄글을 읽듯이 붙입시다.
반응 행렬은 적당한 언어에서 100×100짜리 이차원 배열을 만들고 각 줄마다 자신과 상하좌우에 해당하는 원소를 찾아 1로 만들면 됩니다. 저는 C++로 짰습니다.
역행렬 구하기
...그런데 여기서 잠깐, 반응 행렬이 가역이라고 확신할 수 있나요?
RREF 구하기
아까 가우스 소거법을 썼다고 잠깐 언급했는데, 가우스 소거법으로 역행렬 R−1을 구하는 방법은 이렇습니다.
우선 R과 I로 첨가행렬을 만듭니다.
(RI)이 행렬의 RREF를 구해서 왼쪽 절반이 I가 된다면 나머지 오른쪽이 R의 역행렬입니다.
이게 왜 성립하나요?
RREF나 가우스 소거법이 정확히 뭐고 어떻게 하는 건지는 이 글에서는 생략하지만, 연립방정식의 모양을 바꾸면서 해는 바꾸지 않는 연산을 여러 번 해서 예쁘게 만드는 것이라고만 하겠습니다.
저번 한별포스 글에서 "행렬로 연립 일차방정식도 풀 수 있다"고 했었는데, 보통 행렬로 연립방정식을 풀 때는 식을 이런 꼴로 세우고...
Av=b연립방정식의 관점에서는 이렇게 볼 수 있습니다.
- A11v1+A12v2+A13v3+⋯=b1
- A21v1+A22v2+A23v3+⋯=b2
- A31v1+A32v2+A33v3+⋯=b3
- ...
또 이걸 풀 때는 아까처럼 A와 b로 첨가행렬을 만들어서 RREF를 구합니다.
(Ab)그런데 첨가행렬을 열벡터 하나 대신 행렬을 통째로 넣어서 만들면 우변에도 문자가 여러 개 생기는 효과를 가집니다. u는 모양을 맞추기 위해 새로 만든 열벡터입니다.
Av=Bu- A11v1+A12v2+A13v3+⋯=B11u1+B12u2+B13u3+⋯
- A21v1+A22v2+A23v3+⋯=B21u1+B22u2+B23u3+⋯
- A31v1+A32v2+A33v3+⋯=B31u1+B32u2+B33u3+⋯
- ...
그런데 B의 자리에 대신 I를 넣으면...
Av=Iu=u- A11v1+A12v2+A13v3+⋯=u1
- A21v1+A22v2+A23v3+⋯=u2
- A31v1+A32v2+A33v3+⋯=u3
- ...
원점으로 돌아왔다고요? 그런데 잘 생각해보면, 우변이 상수 bi 대신 미지수 ui로 달라졌습니다. 다르게 말하면, 이 첨가행렬을 RREF하면 A(좌변)를 고정하고 모든 u(우변)에 대해서 연립방정식을 푸는 것과 같습니다. 와!
역행렬이 없다면?
그런데 만약에 왼쪽 절반이 I가 아니라면 어떻게 될까요? 이럴 때는 "역행렬이 존재하지 않는다"고 하고, 연립방정식 관점에서는 각 줄마다 무슨 모양인지 살펴봐야 하는데, 보통 이 두 가지 패턴을 조심하면 됩니다.
- 좌변에 항이 2개 이상 있음 (v1+v2=u1+u3+⋯)
- 우변의 여러 항을 좌변에 아무렇게나 나눠서 넣으면 되는데, 가장 첫 항을 잡아서 전부 몰아넣고 나머지는 0으로 놓는 게 제일 편합니다.
- 좌변이 0임 (0=u2+u5+⋯)
- 이 식이 성립하지 않으면 연립방정식의 해가 존재하지 않습니다.
역으로 역행렬이 존재한다면 연립방정식의 해가 반드시 하나 존재한다고 말할 수 있습니다.
이 점을 염두에 두면서 위에서 구한 반응 행렬에 RREF를 하고 결과를 확인해 봅시다. 역행렬이 있는지 없는지를 여기서 밝히면 문제를 푸는 재미가 없으니 비밀로 하겠습니다. 저는 결과를 구해서 Base64로 인코딩한 뒤 제출하는 코드에 하드코딩으로 넣었습니다(🤣).
정답 구하기
위에서 하드코딩한 행렬을 디코딩하고 입력으로 받은 전구 상태를 방정식 100개에 각각 넣어서 계산하고 나면 위에서 잠깐 보았듯이 세 가지 경우로 나눌 수 있습니다.
- 좌변에 항이 정확히 1개 있음 (v1=1)
- 우변을 스위치 개수에 그대로 반영하면 됩니다.
- 좌변에 항이 2개 이상 있음 (v3+v5=0)
- 어차피 하나 빼고 나머지는 0으로 처리하면 되니 똑같이 반영하면 됩니다.
- 좌변이 0임
- 0=0이면 그냥 무시하면 됩니다.
- 0=1이면 모든 전구를 끌 수 있는 방법이 없으므로
-1
을 출력하고 종료합니다.
역행렬이 존재한다면 R−1만 하드코딩하고 첫 번째 경우만 고려하면 되니 풀기 훨씬 쉬워집니다.
첫 문단에서 얘기했듯이 이거는 제가 이상한 방법을 생각해서 어거지로 푼 것에 조금 더 가까우니 이 방법 대신 "제대로 된" 풀이법으로 푸는 것을 권장드립니다. 저도 글을 올리고 나서 제대로 풀어볼 예정입니다.
글의 주제와 전혀 무관한 여담
저는 스피드런 이벤트에서 개발자 제외 2등을 해서 상품을 받았습니다. 제 기록이 궁금하신가요? (스포일러 주의)