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

 

1261번: 알고스팟

첫째 줄에 미로의 크기를 나타내는 가로 크기 M, 세로 크기 N (1 ≤ N, M ≤ 100)이 주어진다. 다음 N개의 줄에는 미로의 상태를 나타내는 숫자 0과 1이 주어진다. 0은 빈 방을 의미하고, 1은 벽을 의미

www.acmicpc.net

안녕하세요, 이륙사입니다.

 

풀이1

이번 문제는 BFS를 활용하는 그래프 탐색 문제였습니다.

 

기본적인 BFS와는 문제와는 다른 점이 있는데요. 특정 위치에 최단거리로 가는 것이 아닌, 최소로 벽을 부수면서 간다는 것입니다. BFS의 특징은 가까운 순서대로 한번만 방문하면서 모든 위치를 방문할 수 있다는 것입니다. 하지만 이 문제에서 BFS를 그냥 사용하면, 이동 방향의 우선순위에 따라 벽을 더 많이 부수는 경로가 특정 위치에 먼저 도착할 수 있습니다.  

 

이런 문제를 해결하기 위해 특정 위치에 도착할 때마다 그때까지 부셨던 벽의 개수를 기록합니다. 그리고 새로운 경로에서 방문을 시도할 때, 갱신을 시도하는 값이 작을 때만 재방문을 허용합니다. 그러면 언젠가는 모두 최소 값으로 갱신되기 때문에 정답을 찾을 수 있습니다. 미로 크기가 100 * 100이라서 몇 번의 재탐색이 일어나라도 충분히 시간 안에 해결할 수 있습니다.

// 경로를 지나면서 벽을 몇 개 부셨는지 기록,
// 큰 값으로 초기화해서 처음 방문할 때는 항상 방문하면서 갱신하게 한다
val brokenCountAt = Array(maze.size) {
        IntArray(maze[0].size) { 10_000 }
    }
    
    ....

// 벽을 부셔야하는 상황에서 기존에 기록된 경로 값이 지금의 경로 값이 값보다 크다면 재탐색한다
            if (maze[nextRow][nextCol] == '1' && brokenCountAt[nextRow][nextCol] > brokenCountAt[row][col] + 1) {
                brokenCountAt[nextRow][nextCol] = brokenCountAt[row][col] + 1
                q.add(Pair(nextRow, nextCol))
            }
            // 벽을 부시지 않아도 되지만, 기존 경로 값이 지금 경로 값보다 크면 재탐색
            else if (maze[nextRow][nextCol] == '0' && brokenCountAt[nextRow][nextCol] > brokenCountAt[row][col]) {
                brokenCountAt[nextRow][nextCol] = brokenCountAt[row][col]
                q.add(Pair(nextRow, nextCol))
            }

 

참고로 DFS를 사용하면 한 방향으로 먼저 끝까지 탐색하고 돌아오는데, 다 탐색하고 왔더니 최소 경로가 아니라서 모두 재탐색을 해야하는 경우가 발생할 수 있기 때문에 BFS를 사용하는 것이 좋습니다.

 

풀이2

생각해보니 다익스트라 알고리즘이랑 아주 비슷한 것 같습니다. 사실 다익스트라랑 똑같습니다!ㅋㅋ 0과 1의 가중치를 갖고 있는 그래프 탐색 문제로 생각할 수 있습니다.

 

풀이1에서 문제가 됐던 건 무작정 탐색하면 최소로 벽을 부수는(최소 누적 가중치) 경로가 나오지 않는다는 거였습니다.이제 그 이유를 알 수 있는데요, 이동할 때 가중치를 고려하지 않았기 때문입니다. 문제에서 찾고자 하는 건 최소 가중치를 갖는 경로입니다. 따라서, 움직일 때마다 가중치가 작은 방향을 우선으로 탐색하면 특정 위치를 최소 누적 가중치를 갖고 최초로 방문할 수 있습니다. 

 

이러한 방식으로 구현하기 위해 2가지 방법을 사용할 수 있습니다. 하나는 우선순위 큐를 사용하는 것이고, 다른 하나는 양방향 큐를 사용하는 것입니다. 양방향 큐를 사용하는 경우 0을 탐색할 때는 큐의 앞쪽에, 1을 탐색할 때는 큐의 뒤쪽에 넣으면서 진행합니다. 그러면 항상 누적 가중치가 작은 경로를 전부 탐색한 후에, 그 다음 누적 가중치가 큰 경로들을 탐색하기 때문에 모든 위치를 최소 경로로 한번만 지날 수 있습니다. 

val brokenCountAt = Array<IntArray>(rowSize) { // 벽을 부순 횟수를 기록 + 방문 여부 표시 역할
        IntArray(colSize) { -1 }
    }
    
    ....

if (brokenCountAt[nextRow][nextCol] != -1) continue // 한번 방문한 곳은 재방문X

if (maze[nextRow][nextCol] == 0) {
    brokenCountAt[nextRow][nextCol] = brokenCountAt[row][col]
    deque.addFirst(Pair(nextRow, nextCol))
} else {
    brokenCountAt[nextRow][nextCol] = brokenCountAt[row][col] + 1
    deque.addLast(Pair(nextRow, nextCol))
}

 

여기서도, 가중치에 따라 이동 방향의 우선순위를 결정하기 때문에 DFS 기반으로 탐색을 하기엔 어렵습니다.  

 

생각 과정

  1. 특정 위치로 가는 그래프 탐색 문젠데, 최단거리가 아니라 최소 가중치 문제이다
  2. 노드를 한번씩만 방문하면 이동방향에 따라 최소 가중치 경로를 찾지 못할 수도 있다
  3. 최소 가중치 경로를 찾을수 있도록 조건에 따라 재탐색을 허용한다.
  4. 아니면 처음부터 최소 가중치로 탐색하면서 한번씩만 방문할 순 없을까 고민해본다.
  5. 다익스트라를 알고 있다면 다익스트라나 우선순위 큐를 떠올릴 수 있다.

 

코드1 (BFS사용, 조건에 따라 재탐색 허용)

import java.io.BufferedReader
import java.io.InputStreamReader
import java.util.*

fun main() = with(BufferedReader(InputStreamReader(System.`in`))) {
    val st = StringTokenizer(readLine())
    val colSize = st.nextToken().toInt()
    val rowSize = st.nextToken().toInt()
    val maze = Array(rowSize) {
        readLine().toCharArray()
    }

    print(getMinCount(maze))
}

fun getMinCount(maze: Array<CharArray>): Int {
    val dRow = intArrayOf(-1, 1, 0, 0)
    val dCol = intArrayOf(-0, 0, -1, 1)
    
    // 경로를 지나면서 벽을 몇 개 부셨는지 기록,
    // 큰 값으로 초기화해서 처음 방문할 때는 항상 방문하면서 갱신하게 한다
    val brokenCountAt = Array(maze.size) {
        IntArray(maze[0].size) { 10_000 }
    }
    val q: Queue<Pair<Int, Int>> = LinkedList()
    q.add(Pair(0, 0))
    brokenCountAt[0][0] = 0

    while (q.isNotEmpty()) {
        val (row, col) = q.poll()

        for (i in 0 until 4) {
            val nextRow = row + dRow[i]
            val nextCol = col + dCol[i]

            if (nextRow !in maze.indices || nextCol !in maze[0].indices) continue

            // 벽을 부셔야하는 상황에서 기존에 기록된 경로 값이 지금의 경로 값이 값보다 크다면 재탐색한다
            if (maze[nextRow][nextCol] == '1' && brokenCountAt[nextRow][nextCol] > brokenCountAt[row][col] + 1) {
                brokenCountAt[nextRow][nextCol] = brokenCountAt[row][col] + 1
                q.add(Pair(nextRow, nextCol))
            }
            // 벽을 부시지 않아도 되지만, 기존 경로 값이 지금 경로 값보다 크면 재탐색
            else if (maze[nextRow][nextCol] == '0' && brokenCountAt[nextRow][nextCol] > brokenCountAt[row][col]) {
                brokenCountAt[nextRow][nextCol] = brokenCountAt[row][col]
                q.add(Pair(nextRow, nextCol))
            }
        }
    }

    return brokenCountAt[maze.lastIndex][maze[0].lastIndex]
}

 

코드2 (낮은 가중치 우선 탐색)

import java.io.BufferedReader
import java.io.InputStreamReader
import java.util.*

// anima94님 풀이 참고 
fun main() = with(BufferedReader(InputStreamReader(System.`in`))) {
    val (colSize, rowSize) = readLine().split(" ").map(String::toInt)
    val maze = Array<IntArray>(rowSize) {
        val string = readLine()
        IntArray(colSize) { i ->
            string[i] - '0'
        }
    }
    
    // 벽을 부순 횟수를 기록 + 방문 여부 표시 역할
    val brokenCountAt = Array<IntArray>(rowSize) {
        IntArray(colSize) { -1 }
    }
    val deque = ArrayDeque<Pair<Int, Int>>()
    val dRow = intArrayOf(-1, 1, 0, 0)
    val dCol = intArrayOf(0, 0, -1, 1)

    brokenCountAt[0][0] = 0
    deque.add(Pair(0, 0))

    while (true) {
        val (row, col) = deque.pollFirst()

        if (row == rowSize - 1 && col == colSize - 1) {
            print(brokenCountAt[row][col])
            return
        }

        for (i in 0 until 4) {
            val nextRow = row + dRow[i]
            val nextCol = col + dCol[i]

            if (nextRow !in 0 until rowSize || nextCol !in 0 until colSize) continue
            if (brokenCountAt[nextRow][nextCol] != -1) continue // 한번 방문한 곳은 재방문X

            if (maze[nextRow][nextCol] == 0) {
                brokenCountAt[nextRow][nextCol] = brokenCountAt[row][col]
                deque.addFirst(Pair(nextRow, nextCol))
            } else {
                brokenCountAt[nextRow][nextCol] = brokenCountAt[row][col] + 1
                deque.addLast(Pair(nextRow, nextCol))
            }
        }
    }
}

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

 

7576번: 토마토

첫 줄에는 상자의 크기를 나타내는 두 정수 M,N이 주어진다. M은 상자의 가로 칸의 수, N은 상자의 세로 칸의 수를 나타낸다. 단, 2 ≤ M,N ≤ 1,000 이다. 둘째 줄부터는 하나의 상자에 저장된 토마토

www.acmicpc.net

풀이

인접 행렬 형태의 그래프에서 익은 토마토와 인접한, 익지 않은 토마토를 방문하는 그래프 탐색 문제이다.

 

인접한 네 방향을 우선으로 확인하기 때문에 BFS를 사용할 수 있다. 만약 DFS를 사용하면 아래 예시처럼 방문했던 노드를 다시 방문해야 하는 상황이 생기기 때문에 거의 O(N * M)의 시간 복잡도를 갖는다.

 

ex) 1과 0은 토마토의 상태, 2와 3은 (1 + 걸린 일수) 

DFS를 사용하면 노드를 다시 방문하게 된다

 

주의할 점은, 처음에 1로 주어진 토마토가 모두 Queue에 들어가는 시작점이 된다. 지금은 당연해 보이는데, 처음에 한 점에서만 시작한다고 생각해서 한참을 해맸다..ㅋㅋ

 

그리고 이전 토마토에서 인접한 곳을 방문하는 데 하루가 걸리므로, 그래프에 (이전 토마토의 그래프 값 + 1)을 저장하면 중복 방문 체크와 더불어 지난 일수도 알 수 있다. 

 

구현 방법

1. 그래프를 입력 받는다.

2. 익은 토마토(1)의 위치를 모두 큐에 넣고, 익지 않은 토마토의 개수도 세준다.

3. 그래프 값이 모두 1이라면 0을 출력하고 종료한다.

4. BFS로 모든 1에서부터 가능한 모든 0을 방문하면서, 그래프에 자신의 값에 1을 더한 값을 저장한다. 

5. 가능한 노드를 모두 탐색했는데 익지 않은 토마토가 남아있다면 -1을, 그렇지 않으면 그래프의 (최대값 -1)을 출력한다. (1에서부터 시작했으니까)

 

코드 1

import java.io.BufferedReader
import java.io.InputStreamReader
import java.util.*

var unripeCount = 0
val dRow = listOf(-1, 1, 0, 0)
val dCol = listOf(0, 0, -1, 1)

fun main() = with(BufferedReader(InputStreamReader(System.`in`))) {
    val input = readLine().split(" ")
    val n = input[1].toInt()
    val m = input[0].toInt()
    val graph = Array(n){ IntArray(m) }
    val queue: Queue<Pair<Int, Int>> = LinkedList()

    for (i in 0 until n){
        val st = StringTokenizer(readLine())

        for (j in 0 until m){
            val tomato = st.nextToken().toInt()
            graph[i][j] = tomato

            when (tomato) {
                1 -> queue.add(Pair(i, j))
                0 -> unripeCount++  // 익지 않은 토마토
            }
        }
    }

    if (unripeCount == 0){  // 처음부터 모든 토마토가 익어있으면
        print(0)
        return
    }

    val day = bfsToRipe(graph, queue)
    if (unripeCount > 0) print(-1) // 모든 위치를 방문했는데 익지 않은 토마토가 남아있으면 -1
    else print(day)
}

fun bfsToRipe(graph: Array<IntArray>, queue: Queue<Pair<Int, Int>>): Int{
    var day = 0

    while(queue.isNotEmpty()){
        val pair = queue.poll() // 한 번 주변에게 영향을 준 토마토는 더 이상 쓰이지 않는다
        val row = pair.first
        val col = pair.second
        // 그래프에는 (영향을 받은 토마토 값 + 1)을 넣어서 날짜를 저장한다
        // 처음 토마토 날짜가 1부터 시작되므로, 마지막에 (최종 날짜 - 1)을 반환한다
        day = graph[row][col]

        for (i in 0 until 4){
            val newRow = row + dRow[i]
            val newCol = col + dCol[i]

            // 그래프를 벗어나있거나, 익은 토마토이거나, 토마토가 들어있지 않은 곳이거나
            if (newRow !in graph.indices || newCol !in graph[0].indices || graph[newRow][newCol] != 0) continue

            unripeCount--
            graph[newRow][newCol] = graph[row][col] + 1 // 하루가 지났을 때 익지 않은 토마토가 영향을 받아서 익게된다
            queue.offer(Pair(newRow, newCol))
        }
    }

    return day - 1
}

 

코드 2

import java.io.BufferedReader
import java.io.InputStreamReader
import java.util.*

var unripeCount = 0
val dRow = listOf(-1, 1, 0, 0)
val dCol = listOf(0, 0, -1, 1)

fun main() = with(BufferedReader(InputStreamReader(System.`in`))) {
    val input = readLine().split(" ")
    val n = input[1].toInt()
    val m = input[0].toInt()
    val graph = Array(n){ IntArray(m) }
    val queue: Queue<Pair<Int, Int>> = LinkedList()

    for (i in 0 until n){
        val st = StringTokenizer(readLine())

        for (j in 0 until m){
            val tomato = st.nextToken().toInt()
            graph[i][j] = tomato

            when (tomato) {
                1 -> queue.add(Pair(i, j))
                0 -> unripeCount++  // 익지 않은 토마토
            }
        }
    }

    if (unripeCount == 0){  // 처음부터 모든 토마토가 익어있으면
        print(0)
        return
    }

    val day = bfsToRipe(graph, queue)
    if (unripeCount > 0) print(-1) // 모든 위치를 방문했는데 익지 않은 토마토가 남아있으면 -1
    else print(day)
}

fun bfsToRipe(graph: Array<IntArray>, queue: Queue<Pair<Int, Int>>): Int{
    var day = 0

    while(queue.isNotEmpty()){
        day++
        var size = queue.size // 현재 큐에 있는 토마토의 주변을 전부 탐색할 때마다 하루가 지난다

        repeat(size){
            val pair = queue.poll() // 한 번 주변에게 영향을 준 토마토는 더 이상 쓰이지 않는다
            val row = pair.first
            val col = pair.second

            for (i in 0 until 4){
                val newRow = row + dRow[i]
                val newCol = col + dCol[i]

                // 그래프를 벗어나있거나, 익은 토마토이거나, 토마토가 들어있지 않은 곳이거나
                if (newRow !in graph.indices || newCol !in graph[0].indices || graph[newRow][newCol] != 0) continue

                unripeCount--
                graph[newRow][newCol] = 1
                queue.offer(Pair(newRow, newCol))
            }
        }

    }

    // 마지막에 남아있는 토마토를 익히고 나서
    // 마지막 토마토 주변에 익지 않은 토마토가 있는지 한번 더 확인하기 때문에 1을 빼야한다
    return day-1 
}

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

 

9466번: 텀 프로젝트

이번 가을학기에 '문제 해결' 강의를 신청한 학생들은 텀 프로젝트를 수행해야 한다. 프로젝트 팀원 수에는 제한이 없다. 심지어 모든 학생들이 동일한 팀의 팀원인 경우와 같이 한 팀만 있을

www.acmicpc.net

 

풀이

문제를 이해하기 위해 예제를 그림으로 그려보았다.

 

어디서 시작해도 사이클이 생긴다

 

팀을 이루기 위해선 각 학생들이 선택한 학생들을 따라갔을 때, 사이클이 형성되어야 한다는 걸 알 수 있다. 그리고 사이클은 어디서 시작하든지 반드시 생긴다. 사이클이 생기지 않으려면 중간에 화살표가 끊기거나 화살표가 무한히 이어져야 하는데, 그럴려면 팀원을 선택하지 않은 학생이 있거나 학생 수가 무한히 많아야 하기 때문이다.

 

또한, 2 -> 1 -> 3 -> 3 처럼 중간에 다른 사이클이 생기는 학생이 팀에 속하지 않는 학생이 된다.

 

그래서 나는 각 학생이 선택한 학생을 배열에 저장한 후, dfs를 사용해서 사이클을 만드는 학생들의 수를 구했다. 그리고 전체 학생 수에서 빼주는 방식으로 답을 구했다.

 

구현 방식

(1) 모든 학생을 한번씩 시작점으로 탐색해서, 자기 자신으로 돌아오는지 확인

가장 간단해 보이는 건 모든 학생을 시작 지점으로 탐색해서 자기 자신으로 돌아오는지 확인하는 방법인데, 이렇게 하면 시간 초과가 발생한다. 탐색에 걸리는 시간은 O(n), 모든 학생마다 탐색하면 O(n^2) = 100억이 되기 때문이다.

 

그래서 그래프 탐색에서 시간을 줄이는 방법인, 한번 확인한 학생은 다시 확인하지 않도록 해야 문제를 해결할 수 있다. 

한번 방문했던 학생이 다시 나오면, 이전의 탐색 과정이 그대로 등장한다는 점을 이용한다.

(ex) 1 -> 2  -> 3 -> 1 을 탐색하고 나서, 4 -> 1이 나온다면 1 -> 2 -> 3 -> 1 탐색을 다시 반복하게 된다.)

 

(2) 중복 체크 자료구조 2개 사용 

확인한 학생은 다시 방문하지 않도록 해야 하는데, 문제는 사이클을 이루는 학생이 다시 나타났을 때 방금 탐색하다가 확인한 건지, 이전 탐색에서 확인한 건지 알 수가 없다는 것이다.

 

그래서 방문 체크하는 배열을 2개 사용하였다. 

- finished: 사이클을 형성 여부까지 확인했는지 체크

- visited: 한 번이라도 방문 했는지 체크

 

알고리즘) dfs (재귀 or 스택)

  1. 처음 방문하는 학생일 경우 visited에 체크하고 그 학생이 선택한 학생을 방문한다.
  2. 다시 방문하는 학생일 경우,
    1. finished가 체크된 학생 -> 이전의 탐색 과정이 그대로 나오므로 탐색을 종료한다.
    2. 그렇지 않다면, 사이클을 형성하는 학생이므로, 그 학생의 다음 학생부터 자기 자신으로 되돌아올 때까지 반복문을 돌리면서 사이클을 형성하는 학생 카운트를 증가시킨다.
  3. 함수나 스택을 빠져나가면서 사이클까지 모두 확인했음을 finished에 체크한다.

 

(3) 방문 확인 배열 + 탐색을 시작할 때마다 새로운 중복 체크 자료구조를 생성해서 사용

새 탐색을 시작할 때마다 추가 자료구조 사용

탐색 과정에서 이전에 사이클 여부까지 확인했는지를 필터링할 수는 있지만, 새로운 자료구조를 생성할 때마다 O(n)의 시간복잡도가 더 필요해서 결국 O(n^2)으로 시간 초과가 발생한다.

 

나는 여기서 많이 해맸었다..ㅋㅋ

 

코드1 (방식 2)

import java.io.BufferedReader
import java.io.InputStreamReader
import java.util.*

lateinit var graph: IntArray
lateinit var visited: BooleanArray
lateinit var finished: BooleanArray
var count = 0

fun main() = with(BufferedReader(InputStreamReader(System.`in`))) {
    val t = readLine().toInt()
    val answer = StringBuilder()

    repeat(t){
        val n = readLine().toInt()
        val st = StringTokenizer(readLine())
        count = 0
        graph = IntArray(n+1)
        visited = BooleanArray(n+1)
        finished = BooleanArray(n+1)

        for (i in 1..n){
            graph[i] = st.nextToken().toInt()
        }

        for (i in 1..n){
            if (visited[i].not()) dfs(i)
        }

        answer.append(n - count).appendLine()
    }

    print(answer)
}

fun dfs(student: Int){
    // 처음 방문한 학생
    if (visited[student].not()) {
        visited[student] = true // 방문 체크
        dfs(graph[student])     // 다음 학생 방문
    }
    else{
        if (finished[student]) return // 사이클 형성 여부까지 이미 확인한 학생
        else{
            var nextStudent = graph[student] // 자신부터 시작하면 while문이 안돌아간다

            while (nextStudent != student){
                count++ // 사이클을 형성하는 학생 수 증가
                nextStudent = graph[nextStudent]
            }
            count++ // 자기 자신도 센다 ex) 2 -> 1 -> 3 -> 2: 반복문에서 1,3을 세고 마지막에 2를 센다

            return
        }
    }

    finished[student] = true // 사이클 형성 여부까지 확인했음
}

 

코드2 (방식2와 개념은 같지만 추가 방문 체크용 자료 구조로 Map을 사용했습니다)

import java.io.BufferedReader
import java.io.InputStreamReader
import java.util.*

lateinit var graph: IntArray
lateinit var visited: BooleanArray
lateinit var finished: BooleanArray

fun main() = with(BufferedReader(InputStreamReader(System.`in`))) {
    val t = readLine().toInt()
    val answer = StringBuilder()

    repeat(t){
        val n = readLine().toInt()
        val st = StringTokenizer(readLine())
        var count = 0
        graph = IntArray(n+1)
        visited = BooleanArray(n+1)
        finished = BooleanArray(n+1)

        for (i in 1..n){
            graph[i] = st.nextToken().toInt()
        }

        for (i in 1..n){
            if (!visited[i]){
                count += dfs(i, mutableListOf(), HashMap<Int, Int>())
                // updateGroup(hasGroup, students, startIndex)
            }
        }

        answer.append(n - count).appendLine()
    }

    print(answer)
}

// Map을 추가 방문 체크 자료구조로 사용했습니다
fun dfs(student: Int, studentList: MutableList<Int>, indexMap: MutableMap<Int, Int>): Int {
    if (finished[student]) return 0 // 사이클 형성 여부가 이미 확인된 학생
    if (visited[student]) {         // 사이클이 형성됨
        return studentList.size - indexMap[student]!!
    }

    visited[student] = true
    indexMap[student] = studentList.size
    studentList.add(student)
    val count = dfs(graph[student], studentList, indexMap)

    finished[student] = true
    return count
}

+ Recent posts