두 살 아들에게 배운 제약 충족 문제(Constraint Solving)
해커뉴스에 올라온 흥미로운 글로, 아이의 브리오(Brio) 나무 기차 놀이를 통해 제약 충족 문제(Constraint Solving)와 백트래킹, SAT 솔버 등의 컴퓨터 과학 알고리즘을 어떻게 적용할 수 있는지 설명합니다. 단순한 장난감 놀이에 불과해 보이는 과정을 수학적 구조와 탐색 알고리즘으로 분해하여 소프트웨어 엔지니어링 및 문제 해결 방식을 유쾌하게 보여줍니다.
이메일로 읽고 계신가요? 브라우저에서 보면 시각화 자료가 더 잘 보입니다. 제 아들은 두 살인데, 이는 그가 권력을 향한 아폴론적인 의지를 가지고 있으며 각종 탈것과 토목 기계를 좋아한다는 뜻입니다. 그가 특히 좋아하는 것은 브리오(Brio) 나무 기차 세트로 '칙칙폭폭' 놀이를 하는 것입니다. 아들은 제가 함께 놀아주길 원하지만, 기차를 만지는 것은 단호하게 금지되어 있습니다. 그래서 저는 흥미로운 선로 모양을 만드는 것으로 심심함을 달웁니다.
오랜 시간이 지난 후, 저는 브리오에 대해 더 체계적으로 생각하기 시작했습니다. 이 조각들은 분명 특정 모양으로 조립되도록 설계되었습니다. 그렇다면 이 설계에 내재된 구조는 무엇일까요? 우리가 가진 조각들을 고려했을 때, 내가 만들 수 있는 가장 정교한 레이아웃은 무엇일까요? 저는 정식 수학 배경이 없지만, 이것이 내 눈앞에 놓인 매우 흥미로운 알고리즘 문제라는 것을 알 수 있었습니다. 이를 탐구하는 동안, 제 아들은 이전에 알지 못했던 제약 충족(constraint solving) 분야의 전문성을 보여주었습니다. 이 글의 나머지 부분은 제가 아들에게 '들은 바'를 약간 윤문한 이야기입니다.
브리오 시스템 브리오는 아이들을 위한 나무 기차 장난감이지만, 관심이 많은 성인들에 의해 매우 깊이 있게 문서화되어 있습니다. 저는 먼저 모든 조각에 문자 코드와 측정치를 부여하는 '비공식 브리오 선로 가이드'를 참고했습니다. 'A'는 144mm 중간 직선입니다. 'A1'과 'A2'는 각각 108mm, 54mm 변형입니다. 'E'는 원의 8분의 1인 표준 곡선으로, 안쪽 가장자리는 182mm 이상, 바깥쪽은 222mm입니다. 따라서 8개의 45도 곡선을 이으면 지름이 약 40cm인 원이 만들어집니다. 대부분의 조각은 뒤집을 수 있으므로 배치 방법에 따라 곡선이 왼쪽이나 오른쪽으로 휘어질 수 있습니다. 경사로와 다리 조각도 있지만, 단순화를 위해 이것들은 무시하고 이 시스템을 2차원으로 취급하겠습니다. 다리가 상당히 불안정해서 아들이 계속 무너뜨리기 때문에 저는 그 조각들을 숨겨두므로, 이 방식은 저에게 잘 맞습니다.
첫 번째, 선로 닫기 어느 날 아침, 저는 8개의 곡선 조각을 이어 원을 만들었습니다. "동그라미!" 아들이 환호했습니다. 좋아요! 《기관차 토마스와 친구들》을 수백 번 읽어준 보람이 있었습니다. 하지만 이것은 만들 수 있는 가장 단순한 형태의 닫힌 브리오 루프입니다. 이제 여기서 어디로 가야 할까요? 저에게 흥미롭게 들리는 것은 선로 조각 세트를 가져와서 모든 조각을 닫힌 레이아웃(즉, 모든 연결부가 서로 짝지어진 상태)으로 배열할 수 있는지 확인하는 것입니다.
이 글의 시각화 자료는 점점 더 정교해지는 세 가지 솔버(Solver)를 통해 구동됩니다. 1~4번 그림은 백트래킹 탐색(backtracking search)을 사용하고, 5번 그림은 제약 충족 기법(constraint solving)을, 6번 그림은 SAT 솔버를 사용합니다. 백트래킹 탐색은 유아가 선로를 만드는 방식과 같습니다. 즉, 조각을 하나 놓고, 열려있는 연결부를 살펴보고, 다른 조각을 시도해보고, 맞지 않으면 다시 뒤로 물러나는 것입니다. 우리가 루프를 만드는 방식은 다음과 같습니다.
사실 선로가 맞지 않으면 제 아들은 화를 내며 기차를 던져버리지만, 솔버는 대신 '재귀적 백트래킹(recursive backtracking)'을 수행합니다. 이는 자제력이 있는 사람들이 탐색 공간을 탐색하는 일반적인 방법입니다. 열려 있는 연결부들은 작업 목록을 형성합니다. 솔버는 한 번에 하나의 연결부만 작업하며, 거기에 맞는 모든 조각과 방향을 시도하고 각각에 대해 재귀적으로 들어갑니다. 어떤 분기가 막다른 길(즉, 들어맞는 조각이 없거나 연결부가 남아있는데 모든 조각을 다 써버린 경우)에 도달하면, 선택지가 남아있는 마지막 연결부로 백업합니다. 남은 연결부가 없고 세트의 모든 조각이 배치된 경우에만 선로가 닫혀 있다고 간주됩니다. 조각이 남아있는 상태로 너무 일찍 닫히는 것도 백업해야 할 또 다른 막다른 길로 취급됩니다. 파이썬 스타일의 의사코드(pseudocode)로 나타내면 대략 다음과 같을 것입니다.
def search(open_connectors, unused_pieces, layout): if not open_connectors: return layout if not unused_pieces else None
connector = open_connectors[0]
for piece in unused_pieces:
for port in piece.ports:
placement = mate(piece, port, connector)
if collides(placement):
continue
found = search(update(open_connectors, placement), unused_pieces - piece, layout + placement)
if found is not None:
return found
return None
8개의 E 곡선 조각을 사용할 때, 이 문제는...