알고리즘/문제풀이

[백준] Q1987 알파벳 Gold IV

케팔스 2022. 1. 19. 00:25

https://www.acmicpc.net/problem/1987

 

1987번: 알파벳

세로 R칸, 가로 C칸으로 된 표 모양의 보드가 있다. 보드의 각 칸에는 대문자 알파벳이 하나씩 적혀 있고, 좌측 상단 칸 (1행 1열) 에는 말이 놓여 있다. 말은 상하좌우로 인접한 네 칸 중의 한 칸으

www.acmicpc.net

1. 문제의 유형 및 이해

backtracking, 그래프 탐색(DFS)

 

세로 R, 가로 C칸의 보드 각 칸에 알파벳이 적혀있고 좌측 상단 칸에 말이 놓여 있다.

말이 상하좌우 인접한 네칸 중 한칸으로 이동할 때 조건이 있다.

 - 지금까지 지나온 모든 칸에 적혀있는 알파벳과 달라야 한다.

 - 같은 알파벳이 적힌 칸을 두 번 지날 수 없다.

최대 몇칸까지 지날 수 있는가?

 

어떠한 칸 (x,y)에 있을 때 항상 같은가? 라고 생각해보면 같지 않습니다.

A -> B -> C (x,y)와 D-> A -> C(x,y)는 다르니까요. 하지만, A->B->C와 B->A->C의 경로로 도착한 (x,y)는 같은 것으로 취급할 수 있습니다.

'어떠한 알파벳을 포함해 왔는지' 라는 상태를 파악해야 합니다. 

그래프를 탐색하는데 있어 BFS, DFS 방식이 있을 때 상태 또는 분기점, 확산 방식에 따라 구분을 하게 되는데, 

BFS를 사용했을 때 퍼져나가는 각각의 상태를 따로따로 저장해두기에는 불편하다고 생각하고 , 갈 수 있는 길을 모두 가보아야한다는 점에서 DFS가 맞다고 생각하였습니다.

또한 퍼져나가면서 어떠한 특정한 조건에 부딪히면 다시 돌아가서 확인해야하기 때문에 backtracking이라고 생각했습니다.

 

2. 문제 접근

DFS라는 탐색방법으로 Backtracking 알고리즘을 수행하자고 생각하고 보았을 때 정해야할 것이 몇개 있습니다.1. 현재 상태는?   각각의 상태를 따로따로 저장해두기 불편하다는 점에서 BFS가 아닌 DFS로 정하였습니다.   알파벳을 저장해두면서 갔다가 아니라면 그 부분을 빼주면 되니까요.  1.1 boolean 배열 활용.     알파벳 대문자만 들어온다고 하였으므로 boolean[] alphabet = new boolean[26]을 두고 확인하면서     true/false로 활용합니다. 이때 dfs()를 호출하기 이전에 해당 알파벳을 true로 해두고,      이후 false로 다시 돌려놓아야 합니다.  1.2 bit 연산활용.     요즘 bit 활용이 재밌어서 int형 안에 처리가 가능하다면 (int형 2^ 32) 방문 여부를 bit로 처리합니다.     쉬프트 연산과 or연산 and연산등을 활용하여 2진수로 바꾸었을 때 위치를 자신의 위치로 기억합니다.      예를 들어 'C'라면 알파벳에서 3번째 위치가 되고, 1<<(3-1) 을  상태에 or연산으로 masking해두면       status = ~~~ 100(2) 가 되고, 이후에 어떠한 칸 C를 마주쳤을 때        status =     ~~~~~~~~~~~~~~~~~~~~~100       masking = 00000000000000000000000100을 &연산하였을 때 3이 나온다면(또는 0보다 크다면) 그전 상태에서 C를 지났다는 뜻이 됩니다.

 

2. Backtracking이라면 Pruning(가지치기)지!  어떠한 조건에서 다시 돌아 갈 것이냐 하는 것입니다.  backtracking에서 promising function은 속도 향상에 큰 영향을 줍니다. 이 문제의 경우 조건으로 '지나오지 않은 알파벳일때'라는 조건이 있어서 이를 활용하여도 좋습니다. 기존에 많이 사용하던 true/false 문제도 해결 가능하기 때문입니다.하지만 단순 boolean visited배열은 사용하거나 사용하지 않거나 어차피 '지나오지 않은 알파벳일 때'라는 조건에 걸리기 때문에 속도향상에 큰 영향을 주지 않습니다.

 

하지만 다음과 같은 경우는 조금 다릅니다.

  A B C

  B F C 라는 맵이 있을 때 

  F에 도달하는 ㄱ 자 모양 (ABF)와 ㄴ자 모양(ABF)를 보았을 때 도착한 경로는 다르지만 결국, 같은 상태, 같은 위치가 됩니다.

이러한 연산들을 반복적으로 하게되었을 때 시간이 많이 늘어날 수 있습니다. 이러한 것을 해결하기 위해 만약,

 ㄱ자 모양으로 F에 도달하였을 때 F에 A,B로 해서 온적이 있음! 이라고 저장을 해둔 다음 모든 경우를 다녀옵니다.

이후 ㄴ자 모양으로 F에 도달하였을 때 이미 A,B로 해서 왔다간적이 있음 이라는 것을 보고 더이상 가지 않는다면, 이후의 위치는 어차피 다녀온 길이니 가지 않아도 되겠죠.  큰 그래프에서 이러한 경우가 초반이나 몇번 걸리기라도 한다면 크게 단축 시킬 수 있을 것입니다. 

물론, 

A B C

 C F D 와 같은 위치에서 F는 큰 역할을 하지 않을 지도 모르지만, 문제를 푸는데 영향을 주지 않으면서 한번이라도 잡을 수 있다면 시간을 줄일 수 있다는 점에서 의미가 있습니다.

 

/*
2022.01.11
문제번호 : Q1987
이름 및 난이도 : 알파벳 Gold IV
문제이해 
-----------------
세로 R칸, 가로 C칸 모양 보드. 
각 칸에 대문자 알파벳이 하나씩. 좌측(1행 1열) 말이 있다.
말은 상하좌우 인접한 네 칸 중 한칸 이동.
(새로 디오한 칸에는 지나온 모든 칸에 적혀있는 알파벳과 달라야한다.)(같은 알파벳 두번 X)
좌측 상단에서 말이 최대한 몇 칸을 지날 수 있는가?

제한 조건 : 
    R, C <= 20
접근 방법 :
    2^26의 상태. 2^26 = 67_108_864  >> 상태의 확인 bit
    back tracking 으로 가본다. with dp
    back tracking으로 가는 건 너무 많은 경우의 수와
    중복의 경우의 수가 있음.
     →  ←  ↑ ↓
     → →  로 도착한 경우와
     ↑ → → ↓ 로 도착한 칸 의 위치는 같지만 상태는 다르다.
     dp로 해결하는데 어려움이 있다.
     
     backtracking..다시 돌아가는 건 안됨.(중복 X)'
      1. bfs
        >> bfs로 하기에 상태공간의 정의가 [R][C][2^26] 이 되어야함.
     2. dfs
      >> dfs로 할 시에 중복의 관리는 bit계산을 통한 현재 상태를 통해 진행한다.
      >> depth가 깊어봐야 26이라고 생각함.
      >> 채택
       
       ++ bit가 아니라 boolean[26]으로도 가능하다.
       역시 backtracking promising 에 따라 달라진다 속도가.
    3. dp (X)
      dp는 작은 문제를 풀었을 때 큰 문제를 풀어야하나.
      → →  로 도착한 경우와
     ↑ → → ↓ 로 도착한 칸 의 위치는 같지만 상태는 다르다.
     dp로 해결하는데 어려움이 있다.
     
*/

import java.io.*;
import java.util.*;

public class Main {
    static int cnt = 1;
    static int R, C;
    static char[][] map;
    static int[][] visit;

    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        StringTokenizer st = new StringTokenizer(br.readLine());
        R = Integer.parseInt(st.nextToken());
        C = Integer.parseInt(st.nextToken());
        map = new char[R][C];
        for (int i = 0; i < R; i++) {
            map[i] = br.readLine().toCharArray();
        }
        int status = 1 << map[0][0] - 'a';
        visit = new int[R][C];
        dfs(0, 0, status, 1);
        System.out.println(cnt);
    }

    static int[][] dir = new int[][] { { -1, 0 }, { 0, 1 }, { 1, 0 }, { 0, -1 } };

    public static void dfs(int r, int c, int status, int depth) {
        if (visit[r][c] == status)
            return;
        visit[r][c] = status;
        boolean isEnd = true;
        for (int i = 0; i < 4; i++) {
            int nx = dir[i][0] + r;
            int ny = dir[i][1] + c;
            if (nx < 0 || ny < 0 || nx > R - 1 || ny > C - 1)
                continue;
            if ((status & 1 << map[nx][ny] - 'a') > 0)
                continue;
            isEnd = false;
            dfs(nx, ny, status | 1 << map[nx][ny] - 'a', depth + 1);
        }
        if (isEnd) {
            cnt = Math.max(cnt, depth);
        }
    }
}

3. 배운점

사실 방문 여부 check를 하지 않아도 풀 수 있는 문제이다. 실제로 방문 여부를 체크하지 않고 풀었을 때

시간이 꽤 많이 걸렸었고, 다른 사람들과 시간 차이가 많이 나서 확인해 보았을 때의 차이는 방문 여부 체크였다.

사실 visit배열을 통해 단순 true/false로 비교하는 것은 bit연산이나 boolean 배열에서 해당 값이 있는지 없는지 확인하는 정도로 비교하면 되서 크게 차이가 나지 않는다.

하지만, visit배열에 지금까지 들어온 status 값을 기록해둔다면 위에서 설명한 것 처럼 하나라도 걸리면 크게 시간을 단축시킬 수 있다는 점에서 좋다.

backtracking에서 pruning연산의 퀄리티에 따라 속도가 차이나는 것을 알고 있었지만, 체감한 것은 처음이라서 조금 더 신경써야되고 고려해줄 수 있다면 고려해주는 것이 좋겠다 라는 것을 알았다.