https://programmers.co.kr/learn/courses/30/lessons/43238

 

코딩테스트 연습 - 입국심사

n명이 입국심사를 위해 줄을 서서 기다리고 있습니다. 각 입국심사대에 있는 심사관마다 심사하는데 걸리는 시간은 다릅니다. 처음에 모든 심사대는 비어있습니다. 한 심사대에서는 동시에 한

programmers.co.kr

프로세스

  1. 걸리는 시간(제한 시간)을 기준으로 이분 탐색을 한다.
  2. 제한 시간 안에 총 몇 명을 심사할 수 있는지 확인한다.
    1. if (심사된 인원 >= 입국 인원) --> end = mid - 1 (최소 값을 구하기 위해)
    2. else --> start = mid + 1 (제한 시간이 짧아서 전부 검사할 수 없기 때문)
  3. 이분 탐색이 끝나면 start를 반환한다.

풀이

처음엔 우선순위 큐를 이용하는 방법을 시도했다. pq에서 뺄 때마다 지금까지 걸린 시간 += 검사 시간, 검사한 인원이 n보다 클 때까지 진행하는 방식이었다. 그런데 최악의 경우 시간 복잡도가 10억(명) * log(십만)이라 그런지 시간 초과가 났다.

 

이 문제는 대놓고 이분 탐색 카테고리에 있던 문제였어서 이분 탐색에 초점을 맞추고 접근해봤다.

뭘 기준으로 이분 탐색을 할 수 있을까..

 

경험상 직관적이지 않던 이분 탐색 문제들은 모두 정답이 될 것을 기준으로 두고 있었다. 그래서 걸리는 시간을 기준으로 생각해봤다. 시간을 정해놓고, 시간 안에 모든 인원을 심사할 수 있는지 확인하는 것이다.

 

제한 시간 안에 모두 심사할 수 있는지는 심사관마다 걸리는 시간을 제한 시간으로 나누면 구할 수 있다. 시간 복잡도는 O(심사관 수)이다.

/**
 * 제한 시간 안에 모두 검사할 수 있는지 확인한다
 * @param) time: 제한 시간
 */
fun isPossibleInTime(time: Long, officers: IntArray, numOfPeople: Int): Boolean {
    var checkCount: Long = 0

    for (checkTime in officers) {
        checkCount += time / checkTime // 해당 심사관이 시간 안에 검사할 수 있는 인원 수
    }

    return if (checkCount >= numOfPeople) true else false
}

이분 탐색의 범위

  • 최소 시간: 심사관 수 >= 입국자 수, 각 심사관이 검사하는데 걸리는 시간 1분 -> 1분
  • 최대 시간: 심사관 1명, 심사 시간 10억 분, 입국자 10억 명 -> 10억 * 10억 분
const val MAX_SIZE: Long = 1_000_000_000
...
var start: Long = 1
var end: Long = MAX_SIZE * MAX_SIZE

탐색 범위 좁히기

걸리는 시간의 최소 값을 구하는 것이므로

  • 시간 안에 모두 심사 가능 -> 더 짧은 시간에 대해 확인해보기 위해 end = mid - 1
  • 불가능 -> 현재 시간이 너무 짧다는 의미이므로 start = mid + 1로 범위를 좁혀나간다.

그리고 이분 탐색이 종료됐을 때 start 값이 구하고자 하는 답이 된다.

코드

/* 걸린 시간을 기준으로 이분 탐색 */

import java.util.*

const val MAX_SIZE: Long = 1_000_000_000

class Solution {
    fun solution(n: Int, times: IntArray): Long {
        var start: Long = 1 // 심사관 >= 입국자, 검사 시간: 1분
        var end: Long = MAX_SIZE * MAX_SIZE // 심사관: 1명, 검사 시간: 10억 분, 입국자: 10억 명
        
        while(start <= end) {
            val mid = (start + end) / 2

            if (isPossibleInTime(mid, times, n)) {
                end = mid - 1
            } else {
                start = mid + 1
            }
        }
        
        return start
    }
    
    /**
     * 제한 시간 안에 모두 검사할 수 있는지 확인한다
     * @param) time: 제한 시간
     */
    fun isPossibleInTime(time: Long, officers: IntArray, numOfPeople: Int): Boolean {
        var checkCount: Long = 0
        
        for (checkTime in officers) {
            checkCount += time / checkTime // 해당 심사관이 시간 안에 검사할 수 있는 인원 수
        }
        
        return if (checkCount >= numOfPeople) true else false
    }
}

 

https://programmers.co.kr/learn/courses/30/lessons/87694

 

코딩테스트 연습 - 아이템 줍기

[[1,1,7,4],[3,2,5,5],[4,3,6,9],[2,6,8,8]] 1 3 7 8 17 [[1,1,8,4],[2,2,4,9],[3,6,9,8],[6,3,7,7]] 9 7 6 1 11 [[2,2,5,5],[1,3,6,4],[3,1,4,6]] 1 4 6 3 10

programmers.co.kr

처음에 어떻게 풀어야 할지 감이 안 와서 고민해보다가 다른 분들의 힌트를 봤다. 생활 패턴 고친다고 밤을 새워서 그런지(핑계 맞음ㅎㅎ) 봐도 무슨 말인지 모르겠더라. 짐 싸서 집 가는 길에 백준에서 비슷한 문제를 풀었던 기억이 갑자기 떠올랐다ㅋㅋ. 신기하게도 샤워하거나 산책하면 아이디어가 잘 떠오르는데, 이유는 뭘까?

 

아무튼 DFS나 BFS로 풀 수 있다는 것까진 알았는데, 이번엔 어떻게 테두리만 표시할지 떠오르지 않았다. 결국 다른 분의 풀이를 봤는데, 대단한 사고력이 필요하진 않았고 오히려 방법이 단순했다. 최근에 코테 실력이 많이 는 것 같아서 "나 이제 코테 좀 치나?ㅋㅋ" 싶었는데, 반성했다.

 

결론적으론 나에게 어려운 문제였다.

요약

  1. 좌표 및 사각형 영역을 2배로 늘린다
  2. 그래프에 모든 사각형의 테두리를 표시한다. (1 vs 0 or true vs false)
  3. 사각형의 테두리를 제외한 안쪽 영역(너비)을 빈공간으로 표시한다.
  4. 테두리 전체 길이(S)와 시작점에서 도착점(src- dst)까지의 길이를 각각 구한다.
  5. 정답: min(S - (src - dst), src - dst)

풀이

1. DFS/BFS로 해결할 수 있다

그래프를 점과 빈공간으로 표시할 때, 테두리만을 나타낼 수 있으면 시작점에서 도착점까지 얼마나 이동했는지를 DFS/BFS로 알 수 있다.

2. 좌표를 늘린다!

테두리를 따라가려면 인접한 1을 탐색하면 된다. 하지만 테두리간 거리가 1이면 위와 같은 상황에서 테두리를 잘못 따라갈 수 있다. 따라서, 좌표를 모두 2배로 늘려서 위와 같은 문제를 예방한다.

3. 어떻게 바깥의 테두리만 표시할 수 있을까?

1. 우선 모든 사각형의 테두리를 표시한다.

2. 테두리 안쪽 영역을 빈공간으로 표시한다. 그럼 사각형에 포함되어 있던 테두리가 제거된다.

코드

class Solution {
    static final int SIZE = 101;
    static boolean[][] board = new boolean[SIZE][SIZE]; // true: 점 

    int[] dR = new int[]{-1, 1, 0, 0};
    int[] dC = new int[]{0, 0, -1, 1};

    public int solution(int[][] rectangle, int characterX, int characterY, int itemX, int itemY) {
        // 좌표 2배로 늘리기
        int srcRow = characterY * 2;
        int srcCol = characterX * 2;
        int dstRow = itemY * 2;
        int dstCol = itemX * 2;

        markRect(rectangle); // 그래프에 사각형 테두리만 표시
        
        // + 1: 마지막 위치에서 시작점으로 돌아온 거리
        int totalDistance = findDistance(srcRow, srcCol, srcRow, srcCol, new boolean[SIZE][SIZE], 0) + 1; 
        int distance = findDistance(srcRow, srcCol, dstRow, dstCol, new boolean[SIZE][SIZE], 0);

        return Math.min(distance, totalDistance - distance) / 2;
    }

    private void markRect(int[][] rectangles) {
        for (int[] rect: rectangles) {
            int firstRow = 2* rect[1];
            int firstCol = 2* rect[0];
            int secondRow = 2* rect[3];
            int secondCol = 2* rect[2];

            markEdge(firstRow, firstCol, secondRow, secondCol);
        }

        for (int[] rect: rectangles) {
            int firstRow = 2* rect[1];
            int firstCol = 2* rect[0];
            int secondRow = 2* rect[3];
            int secondCol = 2* rect[2];

            markSpace(firstRow, firstCol, secondRow, secondCol);
        }
    }

    // 그래프에 테두리 모두 표시
    private void markEdge(int firstRow, int firstCol, int secondRow, int secondCol) {
        for(int row = firstRow; row <= secondRow; row++) {
            board[row][firstCol] = true;
        }
        for(int col = firstCol + 1; col <= secondCol; col++) {
            board[secondRow][col] = true;
        }
        for(int row = secondRow - 1; row >= firstRow; row--) {
            board[row][secondCol] = true;
        }
        for (int col = secondCol - 1; col > firstCol; col--) {
            board[firstRow][col] = true;
        }
    }

    // 테두리를 제외한 사각형 너비 영역을 모두 빈공간으로 표시한다
    private void markSpace(int firstRow, int firstCol, int secondRow, int secondCol) {
        for (int row = firstRow + 1; row < secondRow; row++) {
            for (int col = firstCol + 1; col < secondCol; col++) {
                board[row][col] = false;
            }
        }
    }

    // DFS
    private int findDistance(int row, int col, final int dstRow, final int dstCol, final boolean[][] visited, int count) {
        if (count > 0 && row == dstRow && col == dstCol) {
            return count;
        }

        visited[row][col] = true;

        for (int i = 0; i < 4; i++) {
            int newRow = row + dR[i];
            int newCol = col + dC[i];

            if (newRow >= 0 && newRow < SIZE && newCol >= 0 && newCol < SIZE && board[newRow][newCol] && !visited[newRow][newCol]) {
                return findDistance(newRow, newCol, dstRow, dstCol, visited, count+1);
            }
        }

        return count;
    }
}

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

 

13397번: 구간 나누기 2

첫째 줄에 배열의 크기 N과 M이 주어진다. (1 ≤ N ≤ 5,000, 1 ≤ M ≤ N) 둘째 줄에 배열에 들어있는 수가 순서대로 주어진다. 배열에 들어있는 수는 1보다 크거나 같고, 10,000보다 작거나 같은 자연수

www.acmicpc.net

 

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

 

어제 백준에서 구간 나누기2 문제를 풀어봤습니다. 이전에 풀어본 유형이어서 알고리즘은 떠올랐지만 구체적인 풀이법이 떠오르지 않더라구요. 왜 그렇게 풀어야했는지 제대로 이해하지 못하고 넘어갔던 것 같아서 이번 기회에 정리해보려고 합니다.

 

선 정리

  1. 구간을 어떻게 나눌까 -> 반대로 접근해서 구간 점수의 최대값을 기준으로 구간을 나눌 수 있는지 확인한다. -> 이분 탐색
  2. 점수 최대값의 범위: 0 ~ (배열 최대값 - 최소값)
  3. 구간을 나눌 때, 구간마다 최대한 길게 만든다 -> M개 이하가 될 확률을 높일 수 있음
    1. M개 이하로 나누는데 성공 -> 더 점수를 낮춰도 나눌 수 있는지 확인 -> upper = mid - 1
    2. M개 이하로 나누는데 실패 -> 점수를 더 높여서 확인 -> lower = mid + 1  
    3. 왜? 우리의 목표는 점수 최대 값의 최소값을 구하는 것이며, 구간 당 가능한 점수가 클수록 구간의 길이가 길어질 확률이 높아지기 때문이다. 

후 풀이

언뜻 읽어보면 점수의 최댓값의 최솟값, 말이 좀 복잡해 보입니다. 저는 구간을 나누면 점수가 계산되고 그 중 최댓값이 존재할텐데, 그 최댓값을 최소로 만들고 싶다. 즉, '구간을 어떻게 나눠야 가장 작은 (점수 최댓값: maxScore)을 구할 수 있을까'라고 이해했습니다. 

 

어떻게 구간을 나눠야 최소값을 얻을 수 있을까에 대한 문제구나 생각하고, 완전 탐색을 먼저 떠올렸습니다. 하지만 M개 이하로 나누기 때문에 시간 복잡도가 팩토리얼 수준으로 나와서 불가능하다고 판단했습니다.

반대로 접근한다

어떻게 해결할 수 있을까,,, 결론적으로 반대로 접근해서 해결할 수 있는 문제였습니다. 즉, 점수 최댓값을 먼저 설정하고, 그걸 기준으로 M개 이하의 구간을 나눌 수 있는지 확인하는 겁니다. 그 과정에서 이분 탐색을 사용합니다.

  1. 점수 최대값의 범위는 배열이 하나일 때의 최소값 0에서 배열 내 최대값과 최소값의 차이 즉, 0 ~ (max(array) - min(array))입니다.
  2. 이제 점수 (최대값)을 기준으로 구간을 나눠봅니다. 구간마다 최대한 많은 길이를 차지하도록 만듭니다. 왜냐면,
    1. M개 이하로 구간을 나눠야하기 때문에 구간을 최대한 길게 만들수록 성공 확률이 높아집니다.
    2. 앞의 구간에서 최대한 많은 숫자를 가져갈수록, 뒤에 남은 숫자들의 범위가 작아집니다. 그러면 그만큼 뒤에 구간의 점수도 작아질 확률이 높아집니다.
  3. M개 이하로 구간을 만들지 못했다면, 점수를 더 크게 해서 구간을 나눌 수 있는지 다시 확인합니다. 반대로 성공했다면, 점수를 더 작게 해도 구간을 나눌 수 있는지 확인합니다. 이렇게 기준을 잡을 수 있는건, 점수가 클수록 구간이 길어질 확률이 높아지기 때문입니다. 즉, 평균적으로 구간마다 더 많은 숫자를 가질 수 있게 됩니다. 그리고, 우리의 목표는 점수 최대값의 최소값을 구하는 것이니까요!

 

코드

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

fun main() = with(BufferedReader(InputStreamReader(System.`in`))) {
    // 입력
    val (n, divideLimit) = readLine().split(" ").map(String::toInt)
    val array = with(StringTokenizer(readLine())){
        IntArray(n){
            this.nextToken().toInt()
        }
    }
    
    var lower = 0
    var upper = array.maxOrNull()!! - array.minOrNull()!!

    if (n == 1) { // 배열 크기가 하나일 때
        print(0)
        return
    } else if (divideLimit == 1) { // 구간 1개 -> 배열을 나눌 수 없을 때
        print(upper - lower)
        return
    }

    // 점수의 최대값을 기준으로 이분 탐색
    // 점수의 최댓값이 mid가 되도록 구간을 가장 길게 나눠본다
    // 나눌 수 있으면 최소값을 찾기 위해 더 작은 최대값을 찾아본다
    // 나눌 수 없으면 더 큰 최대값을 찾는다
    // 구간의 점수가 커질수록 구간이 길어지고 구간 개수가 작아질 확률이 높아진다 
    while (lower <= upper) {
        val mid = (lower + upper) / 2

        if(isValid(array, mid, divideLimit)) {
            upper = mid - 1
        } else {
            lower = mid + 1
        }
    }

    print(lower)
}

fun isValid(array: IntArray, standard: Int, divideLimit: Int): Boolean {
    var count = 0
    var max = 0
    var min = 10_001

    for (i in array.indices) {
        max = max(max, array[i])
        min = min(min, array[i])

        // 최대값 or 최소값이 더 크/작아져서 더 길게 나눌 수 없을 때
        if (max - min > standard) {
            count++
            max = array[i]
            min = array[i]
        }
    }
    count++ // 마지막 구간

    return count <= divideLimit
}

 

후기

어떻게 생각하면 풀 수 있다는 건 확실히 알게됐지만, 왜 이렇게 접근해야만 하는지는 사실 아직도 와닿지가 않습니다. 이렇게 접근할 수도 있다는 걸 숙지하고, 사고의 폭을 넓혀가야 하는 걸까요? 조금은 답답하기도 한데, 이와 관련해서 댓글로 남겨주신다면 정말 감사하겠습니다.

 

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

 

1208번: 부분수열의 합 2

첫째 줄에 정수의 개수를 나타내는 N과 정수 S가 주어진다. (1 ≤ N ≤ 40, |S| ≤ 1,000,000) 둘째 줄에 N개의 정수가 빈 칸을 사이에 두고 주어진다. 주어지는 정수의 절댓값은 100,000을 넘지 않는다.

www.acmicpc.net

 

생각 과정

  1. 문제 재해석: 앞, 뒤 순서를 만족시키되 연속 상관없이 숫자들을 선택해서 더했을 때 값이 S가 되는 경우의 수를 구하시오
  2. 모든 경우의 수를 확인한다면 2^40가지가 나오기 때문에 시간 내에 해결할 수 없다.
  3. 수열을 절반으로 나누면 2^20가지이므로 각각의 부분합은 모두 구할 수 있다.
  4. 합 S가 정해져 있으므로, 한쪽 부분합 배열에서 숫자를 선택하면 다른 한쪽에 존재하는지 확인할 숫자가 정해진다.
  5. 한쪽의 부분합을 순회하면서 다른 한쪽에 원하는 숫자가 있는지 찾는다 -> 해싱, 투포인터, upper_bound - lower_bound를 사용해서 해결할 수 있다.
  6. 피자 판매(https://www.acmicpc.net/problem/2632)와 비슷한 문제였다. (피자 판매 포스팅: https://best-human-developer.tistory.com/60

 

코드 (해싱)

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

lateinit var numbers: IntArray
val leftSumCount: MutableMap<Int, Int> = HashMap()
var goal = 0

fun main() = with(BufferedReader(InputStreamReader(System.`in`))) {
    val (n, mGoal) = readLine().split(" ").map(String::toInt)
    val st = StringTokenizer(readLine())
    numbers = IntArray(n) { st.nextToken().toInt() }
    goal = mGoal

    recordLeftSum(0, 0)
    val answer = countAnswer(n / 2, 0)

    if (goal == 0) print(answer - 1) // 각 구간에서 아무것도 뽑지 않아서 0이 된 경우를 뺀다
    else print(answer)
}

// 왼쪽 절반 구간에서 구할 수 있는 모든 부분수열의 합을 key, 개수를 value로 Map에 저장한다
fun recordLeftSum(idx: Int, sum: Int) {
    if (idx >= numbers.size / 2) {
        leftSumCount[sum] = leftSumCount.getOrDefault(sum, 0) + 1
        return
    }

    recordLeftSum(idx + 1, sum)
    recordLeftSum(idx + 1, sum + numbers[idx])
}

// 오른쪽 구간 부분수열의 합(rightSum)을 모두 구해서,
// 자신과 합했을 때 목표 숫자를 만드는 leftSum의 개수를 Map에서 찾아서 반환한다
fun countAnswer(idx: Int, sum: Int): Long {
    if (idx >= numbers.size) {
        return leftSumCount.getOrDefault(goal - sum, 0).toLong()
    }

    return countAnswer(idx + 1, sum) + countAnswer(idx + 1, sum + numbers[idx])
}

+ Recent posts