프로그래머스: 징검다리 건너기 Swift 풀이(재밌는 자료구조)

5 분 소요


요새는 Swift로 프로그래머스 문제를 주에 3개씩 풀고 있는데
재밌는 자료구조를 발견해서 가져왔슴니다


징검다리 건너기

https://school.programmers.co.kr/learn/courses/30/lessons/64062

대충 각 디딤돌의 사용 가능한 횟수를 주고, k개는 디딤돌 없어도 건너뛸 수 있다고 할 때 얼마나 많이 건널 수 있을까?? 하는 문제

조건

  • 배열 크기 20만 이하
  • 각 값은 2억 이하

풀이1

import Foundation

func solution(_ stones:[Int], _ k:Int) -> Int {
    var ans = Int.max
    
    var r = k-1
    while r < stones.count {
        r += 1
        // print(stones[r-k..<r])
        ans = min(ans, stones[r-k..<r].max()!)
    }
    
    return ans
}

대충 k만큼 이어진 구간을 윈도우로 해서 거기서 max 값들의 min 값을 찾으면 안 되나?? 해서 해 봄

채점 결과(효율성 시간 초과)
정확성  테스트
테스트 1 〉	통과 (0.03ms, 16.5MB)
테스트 2 〉	통과 (0.05ms, 16.3MB)
테스트 3 〉	통과 (0.06ms, 16.4MB)
테스트 4 〉	통과 (0.11ms, 16.6MB)
테스트 5 〉	통과 (0.08ms, 16.5MB)
테스트 6 〉	통과 (0.57ms, 16.5MB)
테스트 7 〉	통과 (3.76ms, 16.4MB)
테스트 8 〉	통과 (5.18ms, 16.5MB)
테스트 9 〉	통과 (9.38ms, 16.4MB)
테스트 10 〉	통과 (0.13ms, 16.5MB)
테스트 11 〉	통과 (0.06ms, 16.4MB)
테스트 12 〉	통과 (0.06ms, 16.1MB)
테스트 13 〉	통과 (0.09ms, 16.3MB)
테스트 14 〉	통과 (0.56ms, 16.4MB)
테스트 15 〉	통과 (3.50ms, 16.4MB)
테스트 16 〉	통과 (4.62ms, 16.2MB)
테스트 17 〉	통과 (8.88ms, 16.3MB)
테스트 18 〉	통과 (0.04ms, 16.3MB)
테스트 19 〉	통과 (0.11ms, 16.3MB)
테스트 20 〉	통과 (0.09ms, 16.2MB)
테스트 21 〉	통과 (0.60ms, 16.4MB)
테스트 22 〉	통과 (2.49ms, 16.4MB)
테스트 23 〉	통과 (5.52ms, 16.4MB)
테스트 24 〉	통과 (10.29ms, 16.2MB)
테스트 25 〉	통과 (0.05ms, 16.3MB)
효율성  테스트
테스트 1 〉	실패 (시간 초과)
테스트 2 〉	실패 (시간 초과)
테스트 3 〉	실패 (시간 초과)
테스트 4 〉	실패 (시간 초과)
테스트 5 〉	실패 (시간 초과)
테스트 6 〉	실패 (시간 초과)
테스트 7 〉	실패 (시간 초과)
테스트 8 〉	실패 (시간 초과)
테스트 9 〉	실패 (시간 초과)
테스트 10 〉	실패 (시간 초과)
테스트 11 〉	실패 (시간 초과)
테스트 12 〉	실패 (시간 초과)
테스트 13 〉	실패 (시간 초과)
테스트 14 〉	실패 (시간 초과)

논리는 맞긴 하네
복잡도는 (n-k) 순회, k개에서 max 찾기 해서 $O((n-k)k)$인데 시간 초과 남
k = n/2 일 때 최댓값이니까 $O(n^2/4)$

풀이2

위 풀이에서 매번 k개 중의 최댓값 구하는 게 문제인데…
그럼 큐 형태면서 최댓값 빠르게 구할 수 있는 거 없나? 뭔가 저장해 놓으면 어째 될 거 같은데… 싶어서 찾아 봄

그리고 스택 오버플로우에 누가 올린 정확히 똑같은 문제를 찾았슴니다 -> 어느 아마존의 인터뷰 문제

해당 기능을 위한 자료구조 설명은 여기 -> push, pop, getMin이 상수인 자료구조

class MaxQueue {
    private var pushStack: [(val: Int, maxVal: Int)] = []
    private var popStack: [(val: Int, maxVal: Int)] = []
    
    func push(_ val: Int) {
        var maxVal = val
        if !pushStack.isEmpty {
            maxVal = max(maxVal, pushStack.last!.maxVal)
        }
        pushStack.append((val: val, maxVal: maxVal))
    }
    
    func pop() -> Int? {
        if popStack.isEmpty {
            if pushStack.isEmpty {
                return nil
            } else {
                var val = pushStack.popLast()!.val
                popStack.append((val: val, maxVal: val))
                while !pushStack.isEmpty {
                    val = pushStack.popLast()!.val
                    let maxVal = max(popStack.last!.maxVal, val)
                    popStack.append((val: val, maxVal: maxVal))
                }
            }
        }
        return popStack.popLast()!.val
    }
    
    func getMax() -> Int? {
        if pushStack.isEmpty {
            if popStack.isEmpty {
                return nil
            } else {
                return popStack.last!.maxVal
            }
        } else if popStack.isEmpty {
            return pushStack.last!.maxVal
        } else {
            return max(pushStack.last!.maxVal, popStack.last!.maxVal)
        }
    }
}

재밋네요…
일단 스택 2개로 큐 구현하고 각 스택은 자기 값과 현재 맥스 값을 튜플로 가집니다

푸시할 땐

  • pushStack이 있다면 pushStack의 max를 max로 하여 넣기

팝할 땐

  • popStack, pushStack 모두 비었다면 없는 거니까 nil
  • popStack이 비었다면
    • pushStack에서 꺼내서 popStack으로 옮기기(maxVal은 val로, 나중에 생각해 보면 push가 비었을 때 getMax하면 얘가 마지막에 팝될 애이므로 해당 구간에선 자기가 최고일 거고, push가 있을 땐 둘이 비교해서 max 리턴하면 되니까)
    • 그리고 빌 때까지 계속 꺼내서 popStack의 maxVal과 비교하여 자신의 maxVal 정하고 popStack에 다시 넣기
  • 이제 popStack이 있으니 마지막 걸 꺼내 주기

전부 amortized $O(1)$이라네요

이게 뭔 소리야?! pop할 때 다 옮기고 난리 났구만 뭔 O(1) 싶으시다면 아래 설명을 읽어 주세요

amortized

제일 많이 드는 예는 C++의 벡터에 push_back() 입니다
벡터가 일단 capacity만큼 자리 차지 하고, 거기서 넘어가면 크기가 2배인 곳에다가 옮기잖아요

근데 생각해보면 이거 다 옮겨 버리는데 시간 복잡도 최악의 경우엔 사실 $O(n)$아님?? 싶은데
저런 경우가 어쩌다 한 번밖에 안 나오니까 이걸 계산을 해 보면은

평소에는 $O(1)$로 하다가 마지막 딱 한 번만 옮겨야 해서 $O(n)$이면 사실상 $\frac{O(1) * (n-1) + O(n) * 1}{n}$ 해서 $O(1)$이나 마찬가지인 거임!!

위 자료구조도 마찬가지로 popStack이 비었을 때에만 pushStack을 popStack 쪽으로 옮기고, 평상시에는 옮겨 뒀던 거 쓰면 되니까 사실상 평균 내면 거의 $O(1)$이라 봐도 된다는 뜻임니다


import Foundation

func solution(_ stones:[Int], _ k:Int) -> Int {
    var ans = Int.max
    
    var window = MaxQueue()
    stones[0..<k-1].forEach { window.push($0) }
    var r = k-1
    while r < stones.count {
        window.push(stones[r])
        r += 1
        ans = min(ans, window.getMax()!)
        window.pop()
    }
    
    return ans
}

해당 자료구조 써서 넣고 빼고 맥스 비교 해보면

채점 결과(통과)
정확성  테스트
테스트 1 〉	통과 (0.06ms, 16.6MB)
테스트 2 〉	통과 (0.06ms, 16.3MB)
테스트 3 〉	통과 (0.11ms, 16.3MB)
테스트 4 〉	통과 (0.10ms, 16.4MB)
테스트 5 〉	통과 (0.24ms, 16.4MB)
테스트 6 〉	통과 (0.55ms, 16.5MB)
테스트 7 〉	통과 (1.57ms, 16.6MB)
테스트 8 〉	통과 (1.11ms, 16.3MB)
테스트 9 〉	통과 (1.33ms, 16.3MB)
테스트 10 〉	통과 (0.24ms, 16.3MB)
테스트 11 〉	통과 (0.14ms, 16.5MB)
테스트 12 〉	통과 (0.11ms, 16.4MB)
테스트 13 〉	통과 (0.25ms, 16.4MB)
테스트 14 〉	통과 (0.80ms, 16.6MB)
테스트 15 〉	통과 (1.27ms, 16.5MB)
테스트 16 〉	통과 (1.90ms, 16.6MB)
테스트 17 〉	통과 (1.90ms, 16.5MB)
테스트 18 〉	통과 (0.11ms, 16.2MB)
테스트 19 〉	통과 (0.11ms, 16.4MB)
테스트 20 〉	통과 (0.16ms, 16.4MB)
테스트 21 〉	통과 (0.64ms, 16.3MB)
테스트 22 〉	통과 (2.06ms, 16.7MB)
테스트 23 〉	통과 (1.08ms, 16.2MB)
테스트 24 〉	통과 (1.12ms, 16.6MB)
테스트 25 〉	통과 (0.09ms, 16.6MB)
효율성  테스트
테스트 1 〉	통과 (194.50ms, 25.6MB)
테스트 2 〉	통과 (212.44ms, 25.7MB)
테스트 3 〉	통과 (211.92ms, 25.7MB)
테스트 4 〉	통과 (213.86ms, 25.6MB)
테스트 5 〉	통과 (211.39ms, 25.5MB)
테스트 6 〉	통과 (202.16ms, 25.5MB)
테스트 7 〉	통과 (193.14ms, 25.4MB)
테스트 8 〉	통과 (164.61ms, 28.5MB)
테스트 9 〉	통과 (168.79ms, 25.3MB)
테스트 10 〉	통과 (143.84ms, 28.6MB)
테스트 11 〉	통과 (167.61ms, 25.8MB)
테스트 12 〉	통과 (134.08ms, 28.6MB)
테스트 13 〉	통과 (192.35ms, 25.6MB)
테스트 14 〉	통과 (178.66ms, 25.7MB)

성공~~
$O(n-k)$와 amortized $O(1)$ 입니다



와… 이렇게 수준 높은 문제를 낸다고? 싶었는데(위 스택 오버플로우에서도 아마존에서 이상한 문제 냈다고 누가 뭐라 함)

이거 사실 거의 이분탐색으로 푼다네요
이분탐색 풀이는 구글링 해서 다른 분들 거 보셔요 저는 이분탐색으로 다시 풀어보러 가겟슴니다…


댓글남기기