|

시뮬레이션 문제 풀이법: 상태 정의와 구현 순서

시뮬레이션 문제 풀이법과 상태 정의 흐름을 설명하는 대표 이미지
시뮬레이션 문제를 상태 정의와 한 턴 처리 순서로 나누는 흐름

시뮬레이션 문제 풀이법에서 중요한 것은 구현을 빨리 시작하는 일이 아니라, 무엇을 상태로 둘지와 한 턴을 어떤 순서로 처리할지 먼저 정하는 것입니다. 이번 글에서는 손으로 따라가는 예시와 Java·Python·C 코드를 함께 보면서 시뮬레이션 문제를 덜 꼬이게 푸는 흐름을 정리합니다.


시뮬레이션 문제 풀이법에서 왜 코드가 자꾸 꼬일까

시뮬레이션 문제는 겉으로 보면 복잡한 알고리즘이 없어 보여서 바로 구현부터 시작하기 쉽습니다. 하지만 실제로는 코드 안에서 문제 구조를 동시에 발명하게 되면서 현재 상태와 다음 상태를 섞거나, 예외 처리를 중간에 끼워 넣다가 흐름이 무너지는 경우가 많습니다.

즉, 시뮬레이션 문제는 구현 문제처럼 보이지만 실제로는 상태와 순서를 설계하는 문제에 가깝습니다.


시뮬레이션 문제를 읽으면 가장 먼저 무엇을 적어야 할까

  1. 무엇이 매 턴 바뀌는가
  2. 무엇이 다음 턴에도 영향을 주는가
  3. 무엇은 저장해야 하고 무엇은 그때 계산하면 되는가
  4. 한 턴 안에서 어떤 순서로 처리해야 하는가
  5. 동시에 일어나는 변화는 즉시 반영하면 안 되는가

이 다섯 질문만 먼저 적어도 코드가 훨씬 덜 꼬입니다. 아래 다이어그램은 이번 글에서 설명할 전체 설계 흐름입니다.

시뮬레이션 문제 설계 흐름 다이어그램
문제 읽기 → 상태 정의 → 한 턴 흐름 정리 → 손검증 → 의사코드 → 구현 순서로 정리하는 흐름

상태 정의란 무엇인가

상태는 문제가 진행되는 동안 바뀌고, 다음 턴에도 영향을 주는 값입니다. 예를 들어 격자 시뮬레이션 문제에서는 현재 위치, 방향, 방문 정보, 점수 같은 값이 상태가 됩니다.

반대로 이번 턴에서만 잠깐 계산하는 nextRow, nextCol 같은 값은 상태가 아니라 임시 계산값입니다. 이 구분을 잘해야 변수 의미가 겹치지 않고 코드가 덜 꼬입니다.


예시 문제: 격자 위 로봇 이동 시뮬레이션

이번 글에서는 상태와 순서 설명에 집중하기 좋은 예시로 격자 위 로봇 이동 시뮬레이션을 사용하겠습니다. 로봇은 (0, 0)에서 시작하고, 명령 L, R, F를 따라 움직입니다. 격자 밖 이동은 무시하고, 이동에 성공한 칸만 방문 처리합니다.

이 예시는 위치 상태, 방향 상태, 명령 처리 순서, 경계 검사, 방문 처리까지 모두 보여주기 때문에 시뮬레이션 문제 풀이법을 설명하기에 적절합니다.


작은 입력으로 먼저 손으로 따라가 보자

격자 크기를 3 x 3, 명령을 F, R, F, F, L, F라고 해보겠습니다. 초기 상태는 위치 (0, 0), 방향은 오른쪽, 방문 수는 1입니다.

  1. 1턴 F: (0, 1)로 이동, 방문 수 2
  2. 2턴 R: 방향만 아래로 변경
  3. 3턴 F: (1, 1)로 이동, 방문 수 3
  4. 4턴 F: (2, 1)로 이동, 방문 수 4
  5. 5턴 L: 방향만 오른쪽으로 변경
  6. 6턴 F: (2, 2)로 이동, 방문 수 5

이 손풀이에서 중요한 것은 결과 숫자보다 한 턴이 항상 같은 구조로 반복된다는 점입니다. 즉, 시뮬레이션 문제는 전체를 한 번에 구현하는 문제가 아니라 한 턴의 규칙을 정확하게 정의하는 문제입니다.


이 예시의 상태를 정확히 적어보자

  1. 현재 위치: row, col
  2. 현재 방향: dir
  3. 방문 정보: visited[row][col]
  4. 방문한 칸 수: visitedCount

정리하면 이 예시 문제의 핵심 상태는 현재 위치, 현재 방향, 방문 정보와 방문 수입니다. 이 단계까지 오면 코드 없이도 문제 구조가 훨씬 선명해집니다.


한 턴 처리 순서를 먼저 적어보자

회전 명령일 때는 방향만 바꾸고, 전진 명령일 때는 다음 좌표를 계산한 뒤 격자 안인지 검사하고 유효하면 위치를 반영한 뒤 방문 여부를 갱신합니다. 이 흐름을 먼저 적어두면 구현이 훨씬 덜 꼬입니다.

시뮬레이션 문제 한 턴 처리 흐름 다이어그램
명령 읽기 → 회전/이동 분기 → 다음 좌표 계산 → 범위 검사 → 상태 반영 → 방문 수 갱신

먼저 공통 의사코드로 정리해보자

  1. 명령을 하나 읽는다
  2. 명령이 L이면 direction만 왼쪽으로 회전한다
  3. 명령이 R이면 direction만 오른쪽으로 회전한다
  4. 명령이 F이면 다음 좌표 nextRow, nextCol을 계산한다
  5. 다음 좌표가 격자 안이면 현재 위치를 갱신한다
  6. 처음 방문한 칸이면 visited를 갱신하고 visitedCount를 증가시킨다
  7. 격자 밖이면 현재 상태를 그대로 유지한다

이 의사코드의 핵심은 세 가지입니다. 현재 상태를 바로 덮어쓰지 않고, 먼저 다음 상태 후보를 계산하고, 유효성 검사를 통과하면 반영한다는 점입니다.


Java 구현: 이번 글의 메인 해설 코드

public class RobotSimulation {

    private static final int[] DR = {-1, 0, 1, 0};
    private static final int[] DC = {0, 1, 0, -1};

    static class Result {
        int row;
        int col;
        int visitedCount;

        Result(int row, int col, int visitedCount) {
            this.row = row;
            this.col = col;
            this.visitedCount = visitedCount;
        }
    }

    public static Result simulate(String commands, int n, int m) {
        int row = 0;
        int col = 0;
        int dir = 1; // 오른쪽

        boolean[][] visited = new boolean[n][m];
        visited[row][col] = true;
        int visitedCount = 1;

        for (char command : commands.toCharArray()) {
            if (command == 'L') {
                dir = turnLeft(dir);
            } else if (command == 'R') {
                dir = turnRight(dir);
            } else if (command == 'F') {
                int nextRow = row + DR[dir];
                int nextCol = col + DC[dir];

                if (isInRange(nextRow, nextCol, n, m)) {
                    row = nextRow;
                    col = nextCol;

                    if (!visited[row][col]) {
                        visited[row][col] = true;
                        visitedCount++;
                    }
                }
            }
        }

        return new Result(row, col, visitedCount);
    }

    private static int turnLeft(int dir) {
        return (dir + 3) % 4;
    }

    private static int turnRight(int dir) {
        return (dir + 1) % 4;
    }

    private static boolean isInRange(int row, int col, int n, int m) {
        return row >= 0 && row < n && col >= 0 && col < m;
    }

    public static void main(String[] args) {
        Result result = simulate("FRFFLF", 3, 3);
        System.out.println("final row = " + result.row);
        System.out.println("final col = " + result.col);
        System.out.println("visited count = " + result.visitedCount);
    }
}

Java 코드는 상태와 메서드 분리를 설명하기에 좋습니다. 방향 이동을 배열로 표현하고, 회전과 전진을 분리하고, 현재 상태와 다음 상태 후보를 나눠서 처리한 점을 눈여겨볼 만합니다.


Java 코드에서 꼭 봐야 할 포인트

  1. 방향 이동을 배열로 표현해 if문을 길게 늘리지 않았다
  2. 회전과 전진이 건드리는 상태를 명확히 분리했다
  3. nextRow, nextCol로 다음 상태 후보를 먼저 계산했다
  4. 범위 검사를 별도 함수로 분리해 논리 단위를 분명히 했다
  5. 방문 수 갱신도 조건부로 처리해 중복 카운트를 막았다

이 다섯 포인트는 Java 문법 자체보다 시뮬레이션 문제를 어떻게 덜 꼬이게 구현할 것인가와 직접 연결됩니다.


Python 구현: 같은 구조를 더 짧게 옮기면

def simulate(commands, n, m):
    dr = [-1, 0, 1, 0]
    dc = [0, 1, 0, -1]

    row, col = 0, 0
    direction = 1  # 오른쪽

    visited = [[False] * m for _ in range(n)]
    visited[row][col] = True
    visited_count = 1

    for command in commands:
        if command == 'L':
            direction = (direction + 3) % 4
        elif command == 'R':
            direction = (direction + 1) % 4
        elif command == 'F':
            next_row = row + dr[direction]
            next_col = col + dc[direction]

            if 0 <= next_row < n and 0 <= next_col < m:
                row, col = next_row, next_col

                if not visited[row][col]:
                    visited[row][col] = True
                    visited_count += 1

    return row, col, visited_count


final_row, final_col, visited_count = simulate("FRFFLF", 3, 3)
print(final_row, final_col, visited_count)

Python 코드의 장점은 짧다는 점입니다. 하지만 더 중요한 것은 문법이 아니라 구조가 Java와 같다는 점입니다. 방향 배열, 회전/전진 분리, 다음 상태 후보 계산, 유효성 검사 후 반영, 조건부 방문 갱신이라는 핵심 구조는 그대로 유지됩니다.


C 구현: 더 저수준으로 내려가도 구조는 같다

#include <stdio.h>
#include <stdbool.h>

int dr[4] = {-1, 0, 1, 0};
int dc[4] = {0, 1, 0, -1};

int turn_left(int dir) {
    return (dir + 3) % 4;
}

int turn_right(int dir) {
    return (dir + 1) % 4;
}

bool is_in_range(int row, int col, int n, int m) {
    return row >= 0 && row < n && col >= 0 && col < m;
}

void simulate(const char *commands, int n, int m) {
    int row = 0;
    int col = 0;
    int dir = 1; // 오른쪽

    bool visited[100][100] = {false};
    visited[row][col] = true;
    int visited_count = 1;

    for (int i = 0; commands[i] != '\0'; i++) {
        char command = commands[i];

        if (command == 'L') {
            dir = turn_left(dir);
        } else if (command == 'R') {
            dir = turn_right(dir);
        } else if (command == 'F') {
            int next_row = row + dr[dir];
            int next_col = col + dc[dir];

            if (is_in_range(next_row, next_col, n, m)) {
                row = next_row;
                col = next_col;

                if (!visited[row][col]) {
                    visited[row][col] = true;
                    visited_count++;
                }
            }
        }
    }

    printf("final row = %d\n", row);
    printf("final col = %d\n", col);
    printf("visited count = %d\n", visited_count);
}

int main() {
    simulate("FRFFLF", 3, 3);
    return 0;
}

C에서는 배열 크기 관리나 자료구조 표현이 조금 더 직접적입니다. 하지만 설계 흐름은 여전히 같습니다. 즉, 언어별 문법은 달라도 시뮬레이션 설계는 동일하다는 점을 꼭 기억하면 좋습니다.


언어별 구현을 비교해서 보면 무엇이 보일까

  1. 세 언어 모두 현재 위치, 방향, 방문 여부, 방문 수를 상태로 둔다
  2. 세 언어 모두 nextRow, nextCol 같은 다음 상태 후보를 먼저 계산한다
  3. 세 언어 모두 유효성 검사 후 반영한다
  4. 세 언어 모두 부가 상태 갱신을 조건부로 처리한다

즉, 언어를 여러 개 본다고 해서 같은 내용을 세 번 읽는 것이 아니라, 같은 설계를 서로 다른 문법으로 옮긴 모습을 보는 것이 중요합니다.


동시에 일어나는 변화가 있는 문제는 어떻게 더 조심해야 할까

실제 시뮬레이션 문제에서는 여러 객체가 동시에 움직이거나, 충돌 후 제거 규칙이 있거나, 이전 상태를 기준으로 모두 계산한 뒤 한꺼번에 반영해야 하는 경우가 많습니다. 이때 가장 위험한 실수는 즉시 반영해버리는 것입니다.

그래서 복잡한 시뮬레이션 문제에서는 한 턴을 더 세분화해서 적어야 합니다. 현재 상태를 읽고, 모든 객체의 다음 상태 후보를 계산하고, 충돌이나 우선순위를 처리한 뒤, 최종 상태를 한꺼번에 반영하고, 마지막으로 점수나 출력 정보를 갱신하는 식입니다.


손으로 2~3턴 먼저 써보는 것이 왜 그렇게 중요할까

코드보다 먼저 작은 입력으로 2~3턴을 직접 써보면 어떤 값이 매 턴 바뀌는지, 무엇을 먼저 계산해야 하는지, 예외 처리가 어디서 끼는지, 동시에 처리해야 하는 규칙이 있는지가 한 번에 드러납니다. 그래서 손풀이 2~3턴은 느린 우회가 아니라 설계 도구에 가깝습니다.


시뮬레이션 문제에서 자주 틀리는 포인트

  1. 현재 상태를 바로 덮어써서 검사와 예외 처리가 꼬이는 경우
  2. 동시에 일어나는 변화를 순차 처리해버리는 경우
  3. 초기 상태를 방문 처리하지 않는 경우
  4. 중간 상태 갱신과 마지막 계산을 혼동하는 경우
  5. 회전과 이동이 건드리는 상태를 섞어 쓰는 경우

실전 체크리스트

  1. 무엇이 상태인지 적었는가
  2. 무엇이 임시 계산값인지 구분했는가
  3. 한 턴을 단계별로 쪼갰는가
  4. 동시에 처리인지 즉시 반영인지 정했는가
  5. 작은 입력으로 2~3턴 손으로 검증했는가
  6. 의사코드가 먼저 나왔는가
  7. 언어별 문법보다 구조를 먼저 설명할 수 있는가

이 체크리스트만 지켜도 시뮬레이션 문제에서 맞왜틀이 크게 줄어듭니다.


이번 글에서 꼭 기억하면 좋은 한 문장

시뮬레이션 문제 풀이법의 핵심은 코드를 빨리 쓰는 것이 아니라, 상태와 순서를 먼저 설계하는 것이다. 이 문장을 기억하면 문제를 보자마자 구현에 뛰어들기보다 먼저 구조를 적는 습관이 생깁니다.


마무리

시뮬레이션 문제는 겉으로 보면 구현만 하면 되는 문제처럼 보입니다. 하지만 실제로는 무엇을 상태로 둘 것인지, 한 턴을 어떤 순서로 처리할 것인지, 무엇을 즉시 반영하고 무엇을 나중에 반영할 것인지에서 대부분 갈립니다.

즉, 시뮬레이션 문제는 손이 빠른 사람이 아니라 구조를 먼저 정리하는 사람이 더 강합니다. 이전 글인 애드혹 알고리즘 접근법: 그리디와 완전탐색 판단 기준애드혹 알고리즘 반례 찾는 법을 함께 읽으면 이번 글의 흐름이 더 자연스럽게 연결됩니다.

함께보면 좋은 글