코드로 문제해결 연습 > 프로그래머스 > 공원 산책

주싱·2023년 4월 3일
0

문제

링크 : 프로그래머스 > Level 1 > 공원 산책

해결 코드

public int[] solution(String[] park, String[] routes) {
    // 시작 지점
    Point startPoint = startPoint(park);

    // 움직일 지점
    AtomicReference<Point> movingPoint = new AtomicReference<>(startPoint);
    Arrays.stream(routes)
            // 타입 전환
            .map(route -> Route.of(route))
            // 각 라우팅 명령에 대해
            .forEach(route -> {
                // 범위 이내 & 열린 경로
                if (!canRoute(park, movingPoint.get(), route)) {
                    return;
                }
                // 이동
                Point nextPoint = Point.move(movingPoint.get(), route);
                // 이동 결과 저장
                movingPoint.set(nextPoint);
            });
    return movingPoint.get().toArray();
}

// park에서 "S" 문자의 위치를 찾는다.
private Point startPoint(String[] park) {
    int startY = IntStream.range(0,  park.length)
            .filter(i -> park[i].contains("S"))
            .findFirst()
            .orElse(-1);
    int startX = park[startY].indexOf('S');
    return Point.of(startX, startY);
}

// 현재 위치에서 라우팅 경로가 열려있고, 공원을 넘어가지 않습니다.
private boolean canRoute(String[] park, Point current, Route route) {
    int height = park.length;
    int width = park[0].length();
    if (!inLimit(current, route, width, height)) {
        return false;
    }
    return opened(park, current, route);
}

// 현재 위치에서 라우팅 시 공원을 넘어가지 않습니다.
private boolean inLimit(Point current, Route route, int width, int height) {
    return switch (route.direct) {
        // 동
        case "E" -> current.x + route.distance < width;
        // 서
        case "W" -> current.x - route.distance >= 0;
        // 남
        case "S" -> current.y + route.distance < height;
        // 북
        case "N" -> current.y - route.distance >= 0;
        // 예외
        default -> throw new IllegalArgumentException();
    };
}

// 현재 위치에서 라우팅을 위한 경로가 열려있습니다.
private boolean opened(String[] park, Point current, Route route) {
    return switch (route.direct) {
        // 동
        case "E" -> openPath(current.x + 1, current.x + route.distance, i -> park[current.y].charAt(i));
        // 서
        case "W" -> openPath(current.x - route.distance, current.x - 1, i -> park[current.y].charAt(i));
        // 남
        case "S" -> openPath(current.y + 1, current.y + route.distance, i -> park[i].charAt(current.x));
        // 북
        case "N" -> openPath(current.y - route.distance, current.y - 1, i -> park[i].charAt(current.x));
        // 예외
        default -> throw new IllegalArgumentException();
    };
}

private boolean openPath(int startInclusive, int endInclusive, Function<Integer, Character> eachPointStatus) {
    return IntStream.rangeClosed(startInclusive, endInclusive)
            .filter(i -> eachPointStatus.apply(i) == 'X')
            .findFirst()
            .isEmpty();
}

static final class Point {
    int x;
    int y;

    static Point of(int x, int y){
        Point point = new Point();
        point.x = x;
        point.y = y;
        return point;
    }

    static Point move(Point prev, Route route) {
        return switch (route.direct) {
            case "E" -> of(prev.x + route.distance, prev.y);
            case "W" -> of(prev.x - route.distance, prev.y);
            case "S" -> of(prev.x, prev.y + route.distance);
            case "N" -> of(prev.x, prev.y - route.distance);
            default -> throw new IllegalArgumentException();
        };
    }

    int[] toArray() {
        return new int[]{y, x};
    }
}

static class Route {
    String direct;
    int distance;

    static Route of(String routeStr) {
        Route route = new Route();
        String[] tokens = routeStr.split(" ");
        route.direct = tokens[0]; // 방향
        route.distance = Integer.parseInt(tokens[1]); // 거리
        return route;
    }
}

회고

  • 문제 자체와 제약사항을 처음부터 제대로 이해하기 위해 차분히 문제를 읽어나갔다. 문제를 잘못 이해한 실수는 이번에 없었다. 잘한 부분이다.
  • 해결책을 설계할 때 선언적으로 큰 그림을 먼저 그려나갔는데 굉장히 좋았다. 다만 아쉬운 점은 첫 번째 설계에 문제가 있었는데 전체 코드를 작성하고 실행해 본 뒤에 알게 되었다. 선언적으로 작성한 큰 그림만 보고 미리 문제를 발견했다면 좋았겠다. 아쉬운 부분이다. 실수한 부분은 다음과 같다. 처음에는 전체 명령들(routes)을 스트림으로 만들고 각 명령어에 대해 이동 가능한 명령인지 체크하는 filter 구문을 적용했는데 이 부분이 잘못되었다. filter 조건에서 최초 시작 위치를 기준으로 모든 명령이 적용가능한지 체크하는 실수를 했다. 각 명령은 동적으로 계산된 다음 위치가 다시 시작 위치가 되어야 했다. 그래서 forEach() 문으로 변경하고 AtomicReference 타입의 위치 객체를 동적으로 계속 갱신해주도록 했다.
  • 선언적으로 작성된 큰 그림으로부터 분리된 세부 문제들을 코드로 풀기 시작했는데 한 단계씩 처음에 프린트 문으로 확인하며 넘어간 것을 잘한 것 같다. 하나씩 확신이 생겨서 좋았다. 그러나 조금 복잡한 핵심로직에 대한 테스트가 막막해서 단계 별 테스트 없이 전체를 한 번에 테스트했다. 한 번에 답을 맞추긴 했지만 오답이 났다면 어떻게 접근해야할지 분명 막막했을 것이다. 각 단계에 대해 차분히 동등분할과 경계값 테스트 케이스를 적용해 보아야 겠다.
@Test
void testInLimit() {
    assertTrue(inLimit(Point.of(1, 1), Route.of("E 1"), 3, 2)); // 동쪽 범위 이내
    assertFalse(inLimit(Point.of(1, 1), Route.of("E 2"), 3, 2)); // 동쪽 범위 초과

    assertTrue(inLimit(Point.of(1, 1), Route.of("W 1"), 3, 2)); // 서쪽 범위 이내
    assertFalse(inLimit(Point.of(1, 1), Route.of("W 2"), 3, 2)); // 서쪽 범위 초과

    assertTrue(inLimit(Point.of(1, 1), Route.of("S 2"), 3, 4)); // 남쪽 범위 이내
    assertFalse(inLimit(Point.of(1, 1), Route.of("S 3"), 3, 4)); // 남쪽 범위 초과

    assertTrue(inLimit(Point.of(1, 2), Route.of("N 2"), 3, 4)); // 북쪽 범위 이내
    assertFalse(inLimit(Point.of(1, 2), Route.of("N 3"), 3, 4)); // 북쪽 범위 초과
}

@Test
void testOpened() {
    String[] park;

    park = Stream.of("XXX", "XSO", "XXX").toArray(String[]::new);
    assertTrue(opened(park, Point.of(1,1), Route.of("E 1"))); // 동쪽 방향 열림
    park = Stream.of("XXX", "XSX", "XXX").toArray(String[]::new);
    assertFalse(opened(park, Point.of(1,1), Route.of("E 1"))); // 동쪽 방향 닫힘

    park = Stream.of("XXX", "XXX", "OSX").toArray(String[]::new);
    assertTrue(opened(park, Point.of(1,2), Route.of("W 1"))); // 서쪽 방향 열림
    park = Stream.of("XXX", "XXX", "XSX").toArray(String[]::new);
    assertFalse(opened(park, Point.of(1,2), Route.of("W 1"))); // 서쪽 방향 닫힘

    park = Stream.of("XXX", "XSX", "XOX").toArray(String[]::new);
    assertTrue(opened(park, Point.of(1,1), Route.of("S 1"))); // 남쪽 방향 열림
    park = Stream.of("XXX", "XSX", "XXX").toArray(String[]::new);
    assertFalse(opened(park, Point.of(1,1), Route.of("S 1"))); // 남쪽 방향 닫힘

    park = Stream.of("XXX", "OXX", "SXX").toArray(String[]::new);
    assertTrue(opened(park, Point.of(0,2), Route.of("N 1"))); // 북쪽 방향 열림
    park = Stream.of("XXX", "XXX", "SXX").toArray(String[]::new);
    assertFalse(opened(park, Point.of(0,2), Route.of("N 1"))); // 북쪽 방향 닫힘
}
  • 2차원 격자를 표현한 문자열 배열에서 시작문자(”S”)의 좌표를 찾는 간단한 코드가 단숨에 생각나지 않았다. 이런 패턴에 조금 더 익숙해 질 필요가 있겠다.
  • 문제를 해결하는 한 루틴을 단위 테스트 완료와 주석 작성과 코드 정리까지를 하나의 나눌 수 없는 루틴으로 만들어야 한다.
  • 부정문 보다 긍정문을 써야 헷갈리지 않는다.
boolean overLimit(...) -> inLimit()
boolean blocked(...) -> opened()
  • 문제에서 좌표를 y,x로 쓴다면 코드 전반적인 파라미터 순서도 이에 맞추는 것이 좋겠다. 내부에서 x,y 순서를 섞어서 쓰니 실수할 확률이 높아지는 것 같다.
  • 복잡한 로직은 중간 중간 확인하면서 코드를 작성해 나가는 것도 좋겠다. 전체가 완성된 후 테스트를 하려니 많은 경우의 수가 생겨 몇 개만 해보고는 확신이 잘 안 선다. 단계 단계 테스트 해 나가면 전체 경우의 수를 확인하지 않고 확신을 가질 수 있다.
  • 로봇의 현재 좌표를 구하는 코드도 선언적으로 작성할 수 있었겠다.
// Before
int startY = IntStream.range(0,  park.length)
                .filter(i -> park[i].contains("S"))
                .findFirst()
                .orElse(-1);
int startX = park[startY].indexOf('S');

// After
Point startPoint = startPoint(park);
... 
private Point startPoint(String[] park) {
    int startY = IntStream.range(0,  park.length)
            .filter(i -> park[i].contains("S"))
            .findFirst()
            .orElse(-1);
    int startX = park[startY].indexOf('S');
    return Point.of(startX, startY);
}

개선할 점

  • 선언적으로 작성한 뼈대가 되는 코드의 흐름이 정상인지 확신할 수 있는 수준으로 검증해야 한다.
  • 다소 복잡한 코드는 부분적으로 단위 테스트를 수행하는 것이 좋겠다. 테스트 코드 역시 한 번에 작성하는 것이 아니라 조금씩 확인해 나가면 전체의 경우의 수를 다루는 부담을 줄이고 점진적으로 확신해 나갈 수 있겠다.
  • 일정한 패턴의 코드들은 단숨에 생각하고 작성할 수 있도록 하자.
  • 문제 해결 완료는 단위 테스트와 코드 정리 그리고 독자를 고려한 주석 작성까지 포함하자.
  • 함수 이름은 부정문보다 긍정문을 사용해 혼돈의 여지를 줄이자.
  • 같은 타입 두 변수를 인자로 받는다면 순서를 일관되게 가져가자. 이랬다 저랬다 하면 실수할 여지가 많아 진다. 그리고 가능하면 파라미터로 순서에 의존해서 같은 타입의 두 변수를 받는 일은 피하자.

잘한 점

  • 문제와 제약사항을 한 번에 읽고 잘 이해할 수 있었다.
  • 선언적으로 전체 해결 구조에 접근하고 세부 문제를 풀려고 한 점 잘 했어.
  • 선언적으로 작성한 단계 단계를 프린트 코드로 검증해 나간 점도 잘 했어.

학습한 것

  • 새로운 스타일의 switch 문장의 입력으로 문자열이나 정수를 사용하면 아래와 같은 컴파일 오류를 낸다. 이런 오류는 default 문장을 추가해서 예외를 던져주게 하면 해결할 수 있다. enum의 경우에는 컴파일러에 의해 지정된 값만 사용할 수 있음으로 default 문이 필요가 없다.

java: the switch expression does not cover all possible input values

boolean overLimit = switch (route.direct) {
    // 동
    case "E" -> current.x + route.distance >= width;
    // 서
    case "W" -> current.x - route.distance < 0;
    // 남
    case "S" -> current.y + route.distance >= height;
    // 북
    case "N" -> current.y - route.distance < 0;
    // 예외
    default -> throw new IllegalArgumentException();
};
profile
소프트웨어 엔지니어, 일상

0개의 댓글