티스토리 뷰

알고리즘

[Algorithm Strategy] - 7. 분할 정복

강철곰탱이 2023. 8. 7. 21:37

더보기

📝 목차

1. 도입
2. 문제: 쿼드 트리 뒤집기 
3. 문제: 울타리 잘라내기
4. 문제: 팬미팅


1. 도입

분할 정복은 가장 유명한 알고리즘 디자인 패러다임으로, 각개 격파라는 말로 설명할 수 있다.

 

 

✏️ 분할정복과 일반적인 재귀호출과의 차이점

 

  • 재귀호출: 항상 문제를 한 조각과 나머지로 쪼갠다.
  • 분할 정복: 항상 문제를 절반씩으로 나눈다.

 

✏️ 분할정복의 세가지 구성 요소

 

  • 문제를 더 작은 문제로 분할하는 과정(divide)
  • 각 문제에 대해 구한 답을 원래 문제에 대한 답으로 병합하는 과정(merge)
  • 더이상 답을 분할하지 않고 곧장 풀 수 있는 매우 작은 문제(base case)

✏️ 분할 정복의 필요조건

 

  • 문제를 둘 이상의 부분 문제로 나누는 자연스러운 방법이 있어야 한다.
  • 부분 문제의 답을 조합해 원래 문제의 답을 계산하는 효율적인 방법이 있어야 한다.

 


 

📍 예제: 수열의 빠른 합과 행렬의 빠른 제곱

 

재귀호출을 사용하는 recursiveSum()함수와 똑같은 일을 하는 분할 정복을 이용한 fastSum()함수를 만들어보자.

 

1부터 n까지의 합을 n개의 조각으로 나눈 뒤, 이들을 반으로 뚝 잘라 n/2개의 조각들로 만들어진 부분 문제 두 개를 만든다.

(편의상 n은 짝수로 가정)

 

  • 첫 번째 부분 문제는 fastSum(n/2)로 나타낼 수 있지만, 두 번째 부분 문제는 그렇지 않다.
  • 문제를 재귀적으로 풀기 위해 각 부분 문제를 '1부터 n까지의 합' 꼴로 표현할 수 있어야 하는데,
  • 위의 분할에서 두 번째 조각은 'a부터 b까지의 합' 형태를 가지기 때문이다.

다음과 같이 두 번째 부분 문제를 fastSum(x)를 포함하는 형태로 바꿔써야 한다.

 

위와 같은 아이디어로 코드를 구현한 것이다.

이 함수에서 홀수와 짝수인 경우 어떻게 처리하는지 확인해보자. 

// 코드 7.1 1부터 n까지의 합을 구하는 분할 정복 알고리즘

// 필수 조건: n은 자연수
// 1부터 n까지의 합을 반환한다
func fastSum(_ n: Int) -> Int {
    // 기저 사례
    if n == 1 {return 1}
    if(n % 2 == 1)return fastSum(n - 1) + n 
    return 2 * fastSum(n/2) + (n/2) * (n/2)
    
}

 

시간 복잡도 분석
fastSum()은 recursiveSum()과의 차이는?
fastSum()은 recursiveSum()보다 함수 호출이 절반으로 줄어든다.

위는 fastSum(11)을 실행할 때 재귀 호출의 입력이 어떻게 변화하는지를 이진수 표현으로 보여준다.

 

  • fastSum()의 총 호출 횟수는 n의 이진수 표현의 자릿수 + 첫 자리를 제외하고 나타나는 1의 개수가 된다.
  • 두 값의 상한은 모두 lg(n)이므로 알고리즘의 실행 시간은 O(lg(n))이 된다.

 

 

행렬의 거듭제곱

 

n*n 크기의 행렬 A가 주어질 때, A의 거듭제곱 A^m은 A를 연속해서 m번 곱한 것이다.

만약 m의 크기가 매우 크다면 시간이 오래걸리게 된다.

 

  • 행렬의 곱셈은 O(n^3)의 시간이 들어 m-1번의 곱셈을 통해 A^m을 구하려면
  • 모두 O(n^3*m)의 시간이 걸린다. 

 

✏️ 거듭제곱 구하기 위한 분할 정복 알고리즘

 

  • A^m = A^m/2 × A^m/2
// 코드 7.2 행렬의 거듭제곱을 구하는 분할 정복 알고리즘

// 정방행렬을 표현하는 SquareMatrix 클래스
class SquareMatrix {
    // 기본 생성자
    init() {
        // 행렬 생성과 초기화 코드
    }
    
    // 행렬의 크기를 반환하는 함수
    func size() -> Int {
        // 행렬 크기 반환 코드
        return 0 // 실제 행렬 크기로 변경해야 함
    }
}

// n*n 크기의 항등 행렬(identity matrix)을 반환하는 함수
func identity(_ n: Int) -> SquareMatrix {
}

// A^m을 반환하는 함수
func pow(_ A: SquareMatrix, _ m: Int) -> SquareMatrix {
    // 기저 사례: A^0 = I
    if m == 0 {
        return identity(A.size())
    }
    if m % 2 > 0 {
        return pow(A, m - 1) * A
    }
    let half = pow(A, m / 2)
    // A^m = (A^(m/2)) * (A^(m/2))
    return half * half
}

 

나누어 떨어지지 않을 때의 분할과 시간 복잡도

문제의 크기가 매번 절반에 가깝게 줄어들면 기저 사례에 도달하기까지 걸리는 분할 횟수가 줄어들어

분할 정복 알고리즘은 가능한 한 절반에 가깝게 절반에 나누고자 한다.

 

m이 홀수일 때, A^m = A * A^m-1로 나누는 것이 A^7 = A^3 * A^4로 나누는 것보다 효율이 더 좋을 수 있다.

 

 

위의 예시는 어떻게 분할하느냐에 따라 시간 복잡도의 차이가 커진다는 것을 보여준다.

  • 절반으로 나누는 알고리즘이 큰 효율 저하를 일으킨다.
  • A^m을 찾기 위해 계산해야 할 부분 문제들이 늘어나기 때문이다.

 


 

📍 예제: 병합 정렬과 퀵 정렬

 

 

두 알고리즘 모두 분할 정복 패러다임을 사용한 알고리즘이다.

같은 아이디어로 정렬을 수행하지만 "시간이 많이 걸리는 작업을 분할 단계에서 하느냐", "병합 단계에서 하느냐"가 다르다.

 

 

1️⃣ 병합 정렬

 

  • 주어진 수열을 가운데에서 쪼개 비슷한 크기의 수열 두 개로 만든 뒤 재귀 호출을 이용해 각각 정렬한다.
  • 정렬된 배열을 하나로 합침으로써 정렬된 전체 수열을 얻는다.
  • 분할 과정은 O(1)만에 수행가능하지만, 병합 과정에서 O(n)의 시간이 걸린다.

출처: https://wooder2050.medium.com/%EC%A0%95%EB%A0%AC-%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98-%EC%A0%95%EB%A6%AC-54349222f432

 

2️⃣ 퀵 정렬

 

  • 병합과정이 필요 없도록 한쪽의 배열에 포함된 수가 다른 쪽 배열의 수보다 항상 작도록 배열을 분할한다.
  • '파티션'이라는 단계를 도입
    • 배열에 있는 수 중 임의의 기준 수(pivot)를 지정한 후 기준보다 작거나 같은 숫자를 왼쪽, 더 큰 숫자를 오른쪽으로 보내는 과정 
  • 분할 과정은 O(n)의 시간이 걸리며 선택한 기준에 따라 비효율적인 분할을 불러올 수 있지만, 병합 과정이 필요없다.

출처: https://wooder2050.medium.com/%EC%A0%95%EB%A0%AC-%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98-%EC%A0%95%EB%A6%AC-54349222f432

 

시간 복잡도 분석

 

1️⃣  병합 정렬

 

  • 병합에 필요한 과정은 O(n), 분할 과정에 필요한 과정은 O(lg(n))으로 항상 일정
  • 문제의 크기는 항상 거의 절반으로 나누어지기 때문에 필요한 단계의 수는 O(lg(n))으로 시간 복잡도는 O(nlg(n))이 된다.

 

 

2️⃣ 퀵 정렬

 

  • 대부분의 시간을 차지하는 것은 주어진 문제를 두 개의 부분 문제로 나누는 파티션 과정이다.
  • 파티션은 주어진 수열의 길이에 비례하여 시간이 걸리므로, 병합 정렬에서의 병합 과정과 다를 것이 없다.
  • 부분 문제가 절반에 가깝게 나눠질 때 평균적으로 퀵 정렬의 시간 복잡도는 O(nlg(n))이 된다.
  • 최악의 경우(기준으로 택한 원소가 최소 원소나 최대 원소인 경우)는 시간복잡도가 O(n^2)이된다. 

 


 

📍 예제: 카라츠바의 빠른 곱셈 알고리즘

 

카라츠바 빠른 곱셈 알고리즘: 두 개의 정수를 곱하는 알고리즘, 수백 자리나 수만 자리의 큰 숫자를 다룸

 

두 정수의 곱을 구하는 과정

 

 

// 코드 7.3 두 큰 수를 곱하는 O(n^2)시간 알고리즘

// num 배열의 자릿수 올림을 처리한다
func normalize(_ num: inout [Int]) {
    num.append(0)
    
    // 자릿수 올림을 처리한다
    for i in 0..<num.count {
        if num[i] < 0 {
            let borrow = (abs(num[i]) + 9) / 10
            num[i + 1] -= borrow
            num[i] += borrow * 10
        } else {
            num[i + 1] += num[i] / 10
            num[i] %= 10
        }
    }
    
    while num.count > 1 && num.last == 0 {
        num.removeLast()
    }
}

// 두 긴 자연수의 곱을 반환한다
// 각 배열에는 각 수의 자릿수가 1의 자리에서부터 시작해 저장되어 있다
// 예 : multiply([3, 2, 1], [6, 5, 4]) = 123 * 456 = 56088 = [8, 8, 0, 6, 5]
func multiply(_ a: [Int], _ b: [Int]) -> [Int] {
    var c = [Int](repeating: 0, count: a.count + b.count + 1)
    for i in 0..<a.count {
        for j in 0..<b.count {
            c[i + j] += a[i] * b[j]
        }
    }
    normalize(&c)
    return c
}

 

    • 배열을 뒤집으면 입출력할 때는 불편하지만, A[i]에 주어진 자릿수의 크기를 10^i로 쉽게 구할 수 있다.
    • 자릿수 올림을 처리하는 normalize()에서 자릿수가 음수인 경우도 처리한다.
      • multiply()에서 덧셈밖에 하지 않아, 자릿수가 음수가 될 일이 없다.
    • 두 정수의 길이가 모두 n인 경우 시간 복잡도는 O(n^2)이다. (n번 실행되는 반복문이 이중이기 때문)

 

카라츠바 알고리즘

 

// 코드 7.4 카라츠바의 빠른 정수 곱셈 알고리즘

// a += b * (10^k)를 구현한다
func addTo(_ a: inout [Int], _ b: [Int], _ k: Int) {
    let length = b.count
    a.append(contentsOf: repeatElement(0, count: max(a.count + 1, b.count + k)))
    for i in 0..<length {
        a[i + k] += b[i]
    }
    normalize(&a) // 정규화
}

// a -= b; 를 구현한다. a >= b를 가정한다
func subFrom(_ a: inout [Int], _ b: [Int]) {
    let length = b.count
    a.append(contentsOf: repeatElement(0, count: max(a.count + 1, b.count) + 1))
    for i in 0..<length {
        a[i] -= b[i]
    }
    normalize(&a) // normalize()가 음수를 처리하는 이유
}

// 두 긴 정수의 곱을 반환한다
func karatsuba(_ a: [Int], _ b: [Int]) -> [Int] {
    let an = a.count, bn = b.count

    // a가 b보다 짧을 경우 둘을 바꾼다
    if an < bn { return karatsuba(b, a) }
    // 기저 사례: a나 b가 비어 있는 경우
    if an == 0 || bn == 0 { return [] }
    // 기저 사례: a가 비교적 짧은 경우 O(n^2) 곱셈으로 변경한다
    if an <= 50 { return multiply(a, b) }

    let half = an / 2
    // a와 b를 밑에서 half 자리와 나머지로 분리한다
    let a0 = Array(a[..<half])
    let a1 = Array(a[half...])
    let b0 = Array(b[..<min(b.count, half)])
    let b1 = Array(b[min(b.count, half)...])

    // z2 = a1 * b1
    let z2 = karatsuba(a1, b1)
    // z0 = a0 * b0
    let z0 = karatsuba(a0, b0)
    // a0 = a0 + a1; b0 = b0 + b1
    addTo(&a0, a1, 0)
    addTo(&b0, b1, 0)
    // z1 = (a0 * b0) - z0 - z2;
    var z1 = karatsuba(a0, b0)
    subFrom(&z1, z0)
    subFrom(&z1, z2)
    // ret = z0 + z1 * 10^half + z2 * 10^(half*2)
    var ret: [Int] = []
    addTo(&ret, z0, 0)
    addTo(&ret, z1, half)
    addTo(&ret, z2, half + half)
    return ret
}

 

1. 두 수를 각각 절반으로 쪼갠다.

만약 a와 b가 각각 256자리 수라면 a1,b1은 첫 128자리, a0과 b0은 그 다음 128자리를 저장한다.

 

2. a*b를 네 개의 조각을 이용해 표현

큰 정수 두개를 한번에 곱하는 대신, 절반 크기로 나눈 작은 조각을 네번 곱한다. 

  • 덧셈과 시프트 연산에 걸리는 시간: O(n)
  • n/2길이 조각들의 곱셈 네 번: 4 * T(n/2)

길이 n인 두 정수를 곱하는데 드는 시간  = O(n) + 4 * T(n/2) 

전체 시간 복잡도 => O(n^2)

 

3. 세 번의 곱셈으로 계산

 

다음과 같이 a*b로 표현했을 때 네 번 대신 세 번의 곱셈으로 이 값을 계산할 수 있다.

 

 

조각들의 곱을 각각 z2, z1, z0이라고 하자. 

우선 z0와 z2를 각각 한 번의 곱셈으로 구한다. 

 

 

z0과 z2를 빼서 z1을 구할 수 있다. 다음과 같은 코드 조각을 이용할 수 있다. 

 

z2 = a1 * b1;
z0 = a0 * b0;
z1 = (a0 + a1) * (b0 + b1) - z0 - z2;

세 결과를 다시 적절히 조합해 원래 두 수의 답을 구할 수 있다.

 


🔎 시간 복잡도 분석

 

카라츠바 알고리즘은 두 개의 입력을 절반씩으로 쪼갠 뒤, 세 번 재귀 호출을 하기 때문에 지금까지의 예제와는 다르게 분석해야 한다. 

 

  1. 카라츠바 알고리즘의 수행시간을 병합 단계와 기저 사례의 두 부분으로 나누자.
    • 코드 7.4 구현에서 병합 단계의 수행시간은 addTo() subFrom()의 수행 시간에 지배,
    • 기저 사례의 처리시간은 multiply()의 수행시간에 지배된다.
  2. 기저 사례를 처리하는 데 드는 총 시간을 알아보자.
    • 자릿수 n이 2의 거듭제곱 2^k라고 했을 때 재귀 호출의 깊이는 k이다.
    • 한번 쪼갤 때마다 해야 할 곱셈의 수가 세 배씩 늘어나기 때문에 마지막 단계에는 3^k의 부분 문제가 있다.
    • 마지막 단계는 두 수 모두 한자리기 때문에 곱셈 한번으로 해결할 수 있다.
    • 따라서 곱셈의 수는 O(3^k)가 된다.
    • O(3^k) = (3^(lgn)) = O (n^(lg3))
  3. 병합 단계에 드는 시간의 총 합을 구해보자.
    • addTo()subFrom()은 숫자의 길이에 비례하는 시간만이 걸리도록 구현할 수 있다.
    • 따라서 각 단계에 해당하는 숫자의 길이를 모두 더하면 병합 단계에 드는 시간 계산할 수 있다.
    • 단계가 내려갈 때마다 숫자의 길이 절반, 부분 문제의 개수는 세 배 증가
    • i번째 단계에서 필요한 연산 수는 (3/2)^i * n이 된다.
    • 시간 복잡도는 곱셈이 지배하며, 최종 시간 복잡도는 O(n^lg3)이다. 

 

※ 입력의 크기가 작을 경우 O(N^2)알고리즘보다 느린 경우가 많다.

 


2. 문제: 쿼드 트리 뒤집기

 

문제 

 

 

algospot.com :: QUADTREE

쿼드 트리 뒤집기 문제 정보 문제 대량의 좌표 데이터를 메모리 안에 압축해 저장하기 위해 사용하는 여러 기법 중 쿼드 트리(quad tree)란 것이 있습니다. 주어진 공간을 항상 4개로 분할해 재귀적

algospot.com

 

 

  • 쿼드 트리: 대량의 좌표 데이터를 메모리 안헤 압축해 저장하기 위해 사용하는 여러 기법 중 하나 
  • 주어진 공간을 항상 4개로 분할해 재귀적으로 표현

 

풀이

 

1️⃣ 접근 방법

 

문제를 푸는 가장 무식한 방법으로 주어진 그림의 쿼드 트리 압축을 풀어 실제 이미지를 얻고 상하 반전한 뒤 다시 쿼드 트리 압축하는 것이 있다. 하지만 원본 그림의 크기 제한 때문에 무식하게는 못 푼다. 

 

이런 경우, 문제 접근 방법 두 가지

 

  1. 큰 입력에 대해서도 동작하는 효율적인 알고리즘 처음부터 새로 만들기
  2. 작은 입력에 대해서만 동작하는 단순한 알고리즘으로부터 시작해서 최적화해 나가기

우선 그림의 크기에 신경쓰지 않고 더 쉬운 단순한 알고리즘부터 시작하여 최적화해 나가보자. 

 

2️⃣ 쿼드 트리 압축 풀기

 

쿼드 트리가 재귀적으로 정의되어 있어 압축하거나 해제하는 과정은 재귀 호출로 구현하는 것이 가장 자연스럽다.

문자열 s의 압축을 해제해 N*N 크기의 배열을 저장하는 함수 decompress()를 구현해보자.

var decompressed = Array(repeating: Array(repeating: Character(" "), count: MAX_SIZE), count: MAX_SIZE)
func decompress(_ it: inout String.Iterator, _ y: Int, _ x: Int, _ size: Int) {}

 

  • 기저 사례는 s의 첫 글자가 w나 b인 경우, 이때는 배열 전체에 해당 색을 색칠하고 종료한다. 
  • 첫 글자가 x라면 decompress()는 s의 나머지 부분을 넷으로 쪼개 재귀 호출한다. 

 

3️⃣ 압축 문자열 분할하기

 

각 부분의 길이가 일정하지 않아 s의 나머지 부분을 넷으로 쪼개기가 까다롭다. 

 

이를 해결하기 위한 두 가지 방법,

 

  1. 주어진 위치에서 시작하는 압축 결과의 길이를 반환하는 함수 getChunkLength()를 만든다.
    • getChunkLength()를 재귀 호출로 작성하는 것은 어렵지 않지만, 비슷한 일을 하는 두 개의 함수를 각각 작성하는 점이 별로다. 
  2. s를 미리 쪼개는 것이 아니라 decompress()함수에서 '필요한 만큼 가져다 쓰도록' 하는 것이다.
    • decompress()함수에 s를 통째로 전달하는 것이 아니라, s의 한 글자를 가리키는 포인터를 전달한다.

 

아래의 코드는 2의 방법을 이용해 구현한 코드이다.

// 코드 7.5 쿼드 트리 압축을 해제하는 재귀 호출 알고리즘

var decompressed = Array(repeating: Array(repeating: Character(" "), count: MAX_SIZE), count: MAX_SIZE)

func decompress(_ it: inout String.Iterator, _ y: Int, _ x: Int, _ size: Int) {
    // 한 글자를 검사할 때마다 반복자를 한 칸 앞으로 옮긴다
    guard let head = it.next() else { return }
    
    // 기저 사례: 첫 글자가 b 또는 w인 경우
    if head == "b" || head == "w" {
        for dy in 0..<size {
            for dx in 0..<size {
                decompressed[y + dy][x + dx] = head
            }
        }
    } else {
        let half = size / 2
        // 네 부분을 각각 순서대로 압축 해제한다
        decompress(&it, y, x, half)
        decompress(&it, y, x + half, half)
        decompress(&it, y + half, x, half)
        decompress(&it, y + half, x + half, half)
    }
}
  • STL의 문자열에서 지원하는 반복자(iterator)를 재귀 호출에 전달한다. 
  • 이때 반복자가 참조형으로 전달되기 때문에 재귀 호출 내부에서 반복자를 옮기면, 다음 재귀 호출에도 반복자는 항상 다음 부분의 시작위치를 가리키게 된다. 

 

4️⃣ 압축 다 풀지 않고 뒤집기

 

곧이 곧대로 압축을 풀기에는 문제에서 다루는 그림 크기가 너무 크다.

압축을 해제한 결과를 메모리에 다 저장하는 대신, 결과 이미지를 뒤집은 결과를 곧장 생성하는 코드를 작성해보자.

// 코드 7.6 쿼드 트리 뒤집기 문제를 해결하는 분할 정복 알고리즘

func reverse(_ it: inout String.Iterator) -> String {
    guard let head = it.next() else { return "" }
    
    if head == "b" || head == "w" {
        return String(head)
    } else {
        let upperLeft = reverse(&it)
        let upperRight = reverse(&it)
        let lowerLeft = reverse(&it)
        let lowerRight = reverse(&it)
        // 각각의 네 부분을 반대로 잘 합친다
        return "x" + lowerLeft + lowerRight + upperLeft + upperRight
    }
}

 

🔎 시간 복잡도 분석

 

  • reverse() 함수는 한번 호출될 때마다 주어진 문자열의 한 글자씩 사용한다.
  • 함수가 호출되는 횟수는 문자열의 길이에 비례하므로 O(n)이 된다. 

 


3. 문제: 울타리 잘라내기

 

 

문제

 

 

algospot.com :: FENCE

울타리 잘라내기 문제 정보 문제 너비가 같은 N개의 나무 판자를 붙여 세운 울타리가 있습니다. 시간이 지남에 따라 판자들이 부러지거나 망가져 높이가 다 달라진 관계로 울타리를 통째로 교체

algospot.com

 

 

  • 판자의 너비가 1으로 고정되어 있다.
  • 주어진 울타리에서 잘라낼 수 있는 최대 직사각형의 크기를 구해라

 

풀이

 

1️⃣ 무식하게 풀 수 있을까

 

배열 h[]가 주어졌을 때 l번 판자부터 r번 판자까지 잘라내서 사각형을 만든다고 하자.

2중 for문으로 가능한 모든 l과 r을 순회하며 사각형의 넓이를 계산하는 것이다. 

// 코드 7.7 울타리 잘라내기 문제를 해결하는 O(n^2) 알고리즘

// 판자의 높이를 담은 배열 h[]가 주어질 때 사각형의 최대 너비를 반환한다
func bruteForce(_ h: [Int]) -> Int {
    var ret = 0
    let N = h.count
    // 가능한 모든 left, right 조합을 순회한다
    for 수비 in 0..<N {
        var minHeight = h[수비]
        for 희진 in 수비..<N {
            minHeight = min(minHeight, h[희진])
            ret = max(ret, (희진 - 수비 + 1) * minHeight)
        }
    }
    return ret
}

이중 for문으로 O(n^2)의 시간이 걸리는데 입력의 최대 크기가 20,000이므로 해결하기 어렵다고 예측된다.

 

2️⃣ 분할 정복 알고리즘의 설계

 

n개의 판자를 절반으로 나눠 두 개의 부분 문제를 만들자.

 

우리가 찾는 최대 직사각형은 다음 세 가지 중 하나에 속할 것이다.

 

  1. 가장 큰 직사각형을 왼쪽 부분 문제에서만 잘라낼 수 있다.
  2. 가장 큰 직사각형을 오른쪽 부분 문제에서만 잘라낼 수 있다.
  3. 가장 큰 직사각형은 왼쪽 부분 문제와 오른쪽 부분 문제에 걸쳐있다.

 

3️⃣ 양쪽 부분 문제에 걸친 경우의 답

 

// 코드 7.8 울타리 잘라내기 문제를 해결하는 분할 정복 알고리즘

var h: [Int] = [] // 각 판자의 높이를 저장하는 배열

// h[left..right] 구간에서 찾아낼 수 있는 가장 큰 사각형의 넓이를 반환한다
func solve(_ left: Int, _ right: Int) -> Int {
    // 기저 사례: 판자가 하나밖에 없는 경우
    if left == right { return h[left] }
    // [left, mid], [mid + 1, right]의 두 구간으로 문제를 분할한다
    let mid = (left + right) / 2
    // 분할한 문제를 각개격파
    var ret = max(solve(left, mid), solve(mid + 1, right))
    // 부분 문제 3: 두 부분에 모두 걸치는 사각형 중 가장 큰 것을 찾는다
    var lo = mid, hi = mid + 1
    var height = min(h[lo], h[hi])
    // [mid, mid + 1]만 포함하는 너비 2인 사각형을 고려한다
    ret = max(ret, height * 2)
    // 사각형이 입력 전체를 덮을 때까지 확장해 나간다
    while left < lo || hi < right {
        // 항상 높이가 더 높은 쪽으로 확장한다
        if hi < right && (lo == left || h[lo - 1] < h[hi + 1]) {
            hi += 1
            height = min(height, h[hi])
        } else {
            lo -= 1
            height = min(height, h[lo])
        }
        // 확장한 후 사각형의 넓이
        ret = max(ret, height * (hi - lo + 1))
    }
    return ret
}

 

양쪽 부분 문제에 모두 걸치는 직사각형 중 가장 큰 것 어떻게 찾을까?

 

  • 직사각형은 반드시 부분 문제 경계에 있는 두 판자를 포함한다.
  • 가장 큰 사각형을 찾기 위해 항상 사각형의 높이를 최대화하는 방향으로 확장해야 한다. 
  • 재귀 호출 함수 solve()는 판자의 높이를 저장하는 배열 h[]의 구간 [left, right]를 넘겨 받아 해당 구간에서 잘라낼 수 있는 최대 사각형의 너비를 반환한다. 

 

 

4️⃣ 정당성 증명

 

 

귀류법을 이용해 증명해보자.

 

  1. 어떤 사각형 R이 이 과정을 통해 찾은 최대 직사각형보다 더 크다고 가정해보자.
  2. 너비가 2인 사각형에서 시작해 사각형 너비를 늘려가고, 고려한 사각형들 중 R과 너비가 같은 사각형이 반드시 하나 있다. => R'
  3. 너비가 같으므로 R이 더 넓기 위해서 높이가 반드시 R'보다 높아야 한다.
  4. R이 R'보다 높으니, R에 포함된 모든 판자들은 A보다 높아야 한다.
  5. A보다 낮거나 높이가 같은 판자를 만나야만 A를 선택한다.
    • 하지만, R'을 만드는 과정에서 만나는 반대쪽 판자들 모두 R에 속해 있어, A보다 높다.
  6. 따라서 A를 선택하는 경우가 있을 수 없다. => R이 R'보다 높다는 가정은 모순이다.

 

🔎 시간 복잡도 분석

 

  • 너비가 2인 사각형에서 시작해 너비가 n인 사각형까지 하나하나 검사하므로 O(n), 문제를 절반으로 나눠 O(lgn)
  • 최종 시간 복잡도 => O(nlgn)

 

🔎 선형 시간 알고리즘

 

스택을 결합한 선형 시간 알고리즘으로도 문제를 풀 수 있다. 

 


4. 문제: 팬미팅

 

 

문제

 

 

algospot.com :: FANMEETING

팬미팅 문제 정보 문제 가장 멤버가 많은 아이돌 그룹으로 기네스 북에 올라 있는 혼성 팝 그룹 하이퍼시니어가 데뷔 10주년 기념 팬 미팅을 개최했습니다. 팬 미팅의 한 순서로, 멤버들과 참가

algospot.com

  • 줄을 선 멤버들과 팬들의 성별이 주어질 때, 남성 팬과는 포옹 대신 악수를 한다.
  • 멤버가 동시에 포옹을 하는 일이 몇 번이나 있을지 계산해라

 

풀이

 

1️⃣ 접근 방법

 

가장 단순한 방법으로 문제에 나와있는 과정대로 단순히 팬미팅과정을 하나하나 시뮬레이션 하여 풀어보자.

팬의 수 N, 멤버 수 M일때 각각 20만에 달하는 수를 시간 안에 풀기 어렵다.

 

2️⃣ 곱셈으로의 변형

 

두 큰 수의 곱셈으로 이 문제를 바꾸자.

 

아래의 그림 7.10은 길이 3인 정수 A와 길이 6인 정수 B의 곱 C를 어떻게 계산하는지 보여준다.

 

A의 원소들을 B의 연속된 원소들과 각각 곱한 후 그들의 합을 얻은 것이다. 

A의 원소들과 B의 원소들이 서로 순서가 바뀌어 있다는 문제가 있지만, A의 원소들의 앞뒤를 뒤집은 다음 곱셈을 하면 문제가 없을 거다.

 

 

A의 숫자들을 왼쪽으로 한 칸씩 움직이면서 B의 해당하는 숫자와 곱한 결과를 얻을 수 있다. 

 

  • 각 자릿수는 한 사람의 성별을 나타내며 남성은 1, 여성은 0으로 표시한다.
  • 남성과 남성이 만나면 두 자릿수의 곱 => 1
  • 이 외의 경우는 => 0

=> Ci가 0이라면 해당 위치에서 남성 팬과 남성 멤버가 만나는 일이 없고, 따라서 모든 멤버가 포옹을 한다. 

 

3️⃣ 카라츠바 알고리즘을 이용한 구현

 

// 코드7.9 카라츠바의 빠른 곱셈을 이용해 팬미팅 문제를 해결하는 함수
func karatsuba(_ a: [Int], _ b: [Int]) -> [Int] {}

func hugs(_ members: String, _ fans: String) -> Int {
    let N = members.count, M = fans.count
    var A: [Int] = Array(repeating: 0, count: N)
    var B: [Int] = Array(repeating: 0, count: M)
    for (i, char) in members.enumerated() {
        A[i] = (char == "M") ? 1 : 0
    }
    for (i, char) in fans.reversed().enumerated() {
        B[i] = (char == "M") ? 1 : 0
    }
    // 카라츠바 알고리즘에서 자리 올림은 생략한다
    let C = karatsuba(A, B)
    var allHugs = 0
    for i in (N - 1)..<M {
        if C[i] == 0 {
            allHugs += 1
        }
    }
    return allHugs
}

 

  • 알고리즘의 수행 시간은 두 수의 곱셈에 좌우되므로 => O(n^(lg3))

 

 

 

 

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/05   »
1 2 3
4 5 6 7 8 9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28 29 30 31
글 보관함