메뉴
HN
Hacker News 18일 전

두 살 아들에게 배운 제약 충족 문제(Constraint Solving)

IMP
7/10
핵심 요약

해커뉴스에 올라온 흥미로운 글로, 아이의 브리오(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 곡선 조각을 사용할 때, 이 문제는...

원문 보기
원문 보기 (영어)
Reading on email? The visualisations will work better in browser. My son is two years old, which means he has an Apollonian will to power and loves all kinds of mechanised transportation and earthworks machinery. His particular joy is “playing choo-choo” with a Brio wooden train set. Since he likes me to be involved, but I am expressly NOT permitted to touch the trains, I amuse myself by building interesting track layouts. After a long while, I began to think more systematically about Brio. The pieces are clearly designed to fit together into shapes, so what’s the underlying structure in the design? Given out set of pieces, what is the most elaborate layout I can build? I don’t have a formal maths background, but I could see that this was an interesting algorithms problem lying on the floor in front of me. As I explored this, my son demonstrated a hitherto undetected expertise in constraint solving. The rest of this post is a lightly editorialised account of what he told me. The Brio system Brio is a wooden train toy for kids but has been documented in depth by particularly interested adults. I first turned to the unofficial Brio track guide , which gives every piece a letter code and a measurement. A is the 144 mm medium straight. A1 and A2 are 108 mm and 54 mm variants. E is the standard curve, an eighth of a circle, measuring just over 182 mm on the inner edge and 222 mm on the outer. Eight of those 45-degree curves therefore enclose a circle about 40 cm across. Most pieces can be flipped over, so a curve can bend left or right depending on how you arrange it. You get ramps and bridges too, but let’s ignore them and treat the system as two dimensional for simplicity. This suits me because the bridges are quite rickety and my son keeps knocking them over, so I try to hide those pieces. First, make the track close One morning I begin by putting eight curves together to form a circle. “Circle!” my son cheers. Good! Reading Shapes with Thomas the Tank Engine and his Friends for hundreds of times has paid off. But this is the simplest possible closed Brio loop. Where do we go from here? What sounds fun to me is to take a set of track pieces and find whether every one of them can be arranged in a closed layout (i.e. with every connector paired up). This post features visualisations powered by three solvers of increasing sophistication. Figures one to four run a backtracking search, figure five uses constraint solving, and figure six uses a SAT solver. The backtracking search builds the track the way a toddler would: put down a piece, look at the open connectors, try another piece, back up when something doesn’t fit. Here’s how we’d make a loop: Actually, my son throws the choo-choo in a rage when the track doesn’t fit, but the solver performs recursive backtracking instead, a common way to explore a search space for those with self-control. The open connectors form a task list. The solver works on just one of them at a time, trying every piece and orientation that fits there and recursing into each. When a branch hits a dead end — no piece that can fit, or every piece used up while connectors are still open — it backs up to the last connector that still had options. The track only counts as closed once no connectors remain open and every piece in the set has been placed. Closing early with pieces still spare is treated as another dead end to back out of. In Python-style pseudocode, it would look something like: 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 With eight E curves, the problem is pretty trivial as long as you lay every curve facing the same direction. The solver doesn’t know that, though. If you step through the figure above and watch the “states explored” count, it will jump up very quickly. That’s because the search first tries a curve bending the wrong way, following that dead end until it runs out of pieces without ever closing, then backing out and laying the curve that actually works. The “states” count tracks that whole wasted subtree. “More, Dada!” he says. Making the track bigger So I make the track bigger by splitting the circle and adding some parallel straight sections on opposite sides. “Oval!” I say, as if I’ve discovered geometry. “No, Dada, oblong ,” he says, pointing at the straight sections. I stare. He’s right. His nursery worker had said he seemed quick. Perhaps the fees were worth it after all. Trying to quickly recover authority, I consider the algorithmic impacts. Adding two straight pieces dramatically increases the number of possible states. The obvious approach uses a greedy algorithm: take the first piece that fits, keep going, and never look back. But a piece can fit in one place and still make it impossible to ever close the track. The straight pieces in particular only work in a few positions. A greedy run would use one of them in the wrong place, get stuck, and have nothing left to do about it. So we want the ability to backtrack from dead ends, unwinding the placements we’ve made and trying a new piece. I explain this gently. “Exponential, Dada,” he nods. Indeed. Every open connector can be continued by any piece that fits it, so the number of partial layouts grows exponentially with the number of pieces, very roughly O(b^n) for a branching factor b and n pieces. This figure has only two more pieces than the previous circle, but it tries 1,930 states against the circle’s 254. That’s about eight times as many states for two extra pieces. So if it’s an algorithm with exponential running time, how is it running reasonably quickly in your browser? At this size, it doesn’t need to be clever: a couple of thousand states is nothing for a laptop to chew through, so plain exhaustive backtracking — trying every piece and port in a fixed order, no shortcuts, no cleverness — finishes in milliseconds. That won’t stay true forever. The worst case is still exponential, and we’ll hit the wall soon. Anyway, circles and oblongs or ellipses or ovals or whatever you call them are boring. It’s not hard to make a bigger one but the trains just go round the same. We need crossings and branches to make things interesting. Crossings add branching points My son picks up a crossing piece (H3). It’s made from two overlapping circles so a train rolling through can stay on its groove or switch to the other line. Now we have branching points! I show my son. “Look, we’ve got a crossing piece here.” “Graph with two cycles!” he beams. I melt with pride. My own little computer scientist! A future terror of Hacker News comment sections! As we saw in the graphs section of The Computer Science Book , a graph is the mathematical term for points joined by lines (the points are vertices, the lines are edges), and a cycle is any route through the graph that comes back to where it started. Every track piece up to now had two connectors, so the track was like a single thread, going from one end and round to the other. One cycle, if it formed a closed loop. The crossing has four connectors, so placing it opens three connectors at once and the search itself starts to branch. The data model had to change from this: piece = geometric move to this: piece = connectors + geometry + internal grooves The search algorithm didn’t change but it is now searching over a more complex space. The goal, remember, is to check whether every piece in t