본문 바로가기

알고리즘

[프로그래머스] 보석 쇼핑 [2020 카카오 인턴십, Two Pointers]

반응형

programmers.co.kr/learn/courses/30/lessons/67258

 

코딩테스트 연습 - 보석 쇼핑

["DIA", "RUBY", "RUBY", "DIA", "DIA", "EMERALD", "SAPPHIRE", "DIA"] [3, 7]

programmers.co.kr

[본 문제는 정확성과 효율성 테스트 각각 점수가 있는 문제입니다.]

개발자 출신으로 세계 최고의 갑부가 된 어피치는 스트레스를 받을 때면 이를 풀기 위해 오프라인 매장에 쇼핑을 하러 가곤 합니다.
어피치는 쇼핑을 할 때면 매장 진열대의 특정 범위의 물건들을 모두 싹쓸이 구매하는 습관이 있습니다.
어느 날 스트레스를 풀기 위해 보석 매장에 쇼핑을 하러 간 어피치는 이전처럼 진열대의 특정 범위의 보석을 모두 구매하되 특별히 아래 목적을 달성하고 싶었습니다.
진열된 모든 종류의 보석을 적어도 1개 이상 포함하는 가장 짧은 구간을 찾아서 구매

예를 들어 아래 진열대는 4종류의 보석(RUBY, DIA, EMERALD, SAPPHIRE) 8개가 진열된 예시입니다.

진열대 번호 1 2 3 4 5 6 7 8
보석 이름 DIA RUBY RUBY DIA DIA EMERALD SAPPHIRE DIA

진열대의 3번부터 7번까지 5개의 보석을 구매하면 모든 종류의 보석을 적어도 하나 이상씩 포함하게 됩니다.

진열대의 3, 4, 6, 7번의 보석만 구매하는 것은 중간에 특정 구간(5번)이 빠지게 되므로 어피치의 쇼핑 습관에 맞지 않습니다.

진열대 번호 순서대로 보석들의 이름이 저장된 배열 gems가 매개변수로 주어집니다. 이때 모든 보석을 하나 이상 포함하는 가장 짧은 구간을 찾아서 return 하도록 solution 함수를 완성해주세요.
가장 짧은 구간의 시작 진열대 번호와 끝 진열대 번호를 차례대로 배열에 담아서 return 하도록 하며, 만약 가장 짧은 구간이 여러 개라면 시작 진열대 번호가 가장 작은 구간을 return 합니다.

[제한사항]

  • gems 배열의 크기는 1 이상 100,000 이하입니다.
    • gems 배열의 각 원소는 진열대에 나열된 보석을 나타냅니다.
    • gems 배열에는 1번 진열대부터 진열대 번호 순서대로 보석이름이 차례대로 저장되어 있습니다.
    • gems 배열의 각 원소는 길이가 1 이상 10 이하인 알파벳 대문자로만 구성된 문자열입니다.

입출력 예

gems result
["DIA", "RUBY", "RUBY", "DIA", "DIA", "EMERALD", "SAPPHIRE", "DIA"] [3, 7]
["AA", "AB", "AC", "AA", "AC"] [1, 3]
["XYZ", "XYZ", "XYZ"] [1, 1]
["ZZZ", "YYY", "NNNN", "YYY", "BBB"] [1, 5]

입출력 예에 대한 설명

입출력 예 #1
문제 예시와 같습니다.

입출력 예 #2
3종류의 보석(AA, AB, AC)을 모두 포함하는 가장 짧은 구간은 [1, 3], [2, 4]가 있습니다.
시작 진열대 번호가 더 작은 [1, 3]을 return 해주어야 합니다.

입출력 예 #3
1종류의 보석(XYZ)을 포함하는 가장 짧은 구간은 [1, 1], [2, 2], [3, 3]이 있습니다.
시작 진열대 번호가 가장 작은 [1, 1]을 return 해주어야 합니다.

입출력 예 #4
4종류의 보석(ZZZ, YYY, NNNN, BBB)을 모두 포함하는 구간은 [1, 5]가 유일합니다.
그러므로 [1, 5]를 return 해주어야 합니다.

풀이

보통의 효율성 테스트가 존재하는 문제는 O(n)으로 해결할 수 있습니다. 이를 염두에 두고 문제를 읽어봅시다. 어피치는 특정 범위의 물건을 싹쓸이 구매하므로, 어피치가 구매하는 물건은 범위로 나타낼 수 있습니다. 앞에서부터 시작해 범위를 좁혀나간다면, 좋을 것 같습니다. 범위를 표현하는 문제는 Two Pointers 기법을 자주 사용합니다. head와 tail을 가리키는 포인터를 두고, 조건에 맞게 head나 tail을 늘리는 식으로 활용합니다.

범위 안에 존재하는 보석의 종류를 세어 전체 종류의 수보다 작으면 => tail을 늘려 범위를 늘려줍니다.
범위 안에 존재하는 보석의 종류를 세어 전체 종류의 수와 같으면 => head를 늘려 범위를 좁혀줍니다.

위와 같은 방식으로 탐색을 진행하며 head나 tail이 범위를 벗어날 경우 종료합니다. 문제의 예시를 Two Pointers로 탐색하는 과정은 다음과 같습니다.

TEST CASE: [D, R, R, D, D, E, S, D],    RESULT = [3, 7],    KINDS = 4
head tail range kinds
0 0 [D] 1
0 1 [D, R] 2
0 2 [D, R, R] 2
0 3 [D, R, R, D] 2
0 4 [D, R, R, D, D] 2
0 5 [D, R, R, D, D, E] 3
0 6 [D, R, R, D, D, E, S] 4
1 6 [R, R, D, D, E, S] 4
2 6 [R, D, D, E, S] 4
3 6 [D, D, E, S] 3
3 7 [D, D, E, S, D] 3
3 8 OUT OF RANGE -
 

보석의 종류가 전체 종류의 수와 같을 때, head를 갱신하기 앞서 tail - head와 answer[1] - answer[0]을 비교하여 크기가 더 작거나, 크기가 같다면 인덱스가 더 작은 쪽으로 answer를 갱신합니다. RESULT의 인덱스는 1부터 시작하기 때문에 answer = [head+1, tail+1]과 같이 1 씩 더해 갱신합니다.

def solution(gems):
    gemSet = set()
    for gem in gems:
        if gem not in gemSet:
            gemSet.add(gem)
    kinds = len(gemSet)
    
    answer = [1, len(gems)]
    left = 0; right = 0
    while left <= right and right < len(gems):
        gemSet = set()
        for gem in gems[left:right+1]:
            if gem not in gemSet:
                gemSet.add(gem)
        
        if len(gemSet) == kinds:
            if answer[1]-answer[0] > right-left:
                answer = [left+1, right+1]
            left += 1

        elif len(gemSet) < kinds:
            right += 1
        
    return answer

그러나 범위 내 보석의 종류를 세는 방법이 문제입니다. 위와 같이 루프마다 보석 종류를 세는 코드의 경우 정확성 테스트에서 만점을 받더라도 그보다 배점이 큰 효율성 테스트는 모두 실패하기 때문에 합계 점수가 33.3/100 밖에 되지 않습니다.

"범위 내 보석의 종류를 세는 법"을 최적화 하기 위해서는 주어진 조건에서 범위는 항상 1씩 변화한다는 점을 떠올려야 합니다. 그렇다면 범위가 변경될 때마다 해당하는 보석의 수를 변경해주는 방식으로 보석의 종류를 셀 수 있습니다.

 
range gemDict len(gemDict)
[D] {D: 1} 1
[D, R] {D: 1, R: 1} 2
[D, R, R] {D: 2, R: 1} 2
[D, R, R, D] {D: 2, R: 2} 2
[D, R, R, D, D] {D: 3, R: 2} 2
[D, R, R, D, D, E] {D: 3, R: 2, E: 1} 3
[D, R, R, D, D, E, S] {D: 3, R: 2, E: 1, S: 1} 4
[R, R, D, D, E, S] {D: 2, R: 2, E: 1, S: 1} 4
[R, D, D, E, S] {D: 2, R: 1, E: 1, S: 1} 4
[D, D, E, S] {D: 2, E: 1, S: 1} 3
[D, D, E, S, D] {D: 3, E: 1, S: 1} 3

매번 범위가 바뀔 때마다 gemDict를 갱신해주면 for-loop 없이, key의 개수로 종류를 셀 수 있습니다. 주의해야 할 점은 보석의 수가 0이 되면 key를 삭제해주어야 한다는 점입니다. 이를 코드로 구현해봅시다.

def solution(gems):
    gemSet = set()
    for gem in gems:
        if gem not in gemSet:
            gemSet.add(gem)
    kinds = len(gemSet)
    
    gemDict = {gems[0]: 1}
    answer = [1, len(gems)]
    left = 0; right = 0
    while left <= right and right < len(gems):
        if len(gemDict) == kinds:
            if answer[1]-answer[0] > right-left:
                answer = [left+1, right+1]
            
            gemDict[gems[left]] -= 1
            if gemDict[gems[left]] == 0:
                del gemDict[gems[left]]
            left += 1

        elif len(gemDict) < kinds:
            right += 1
            if right >= len(gems):
                break
                
            if gems[right] not in gemDict:
                gemDict[gems[right]] = 1
            else:
                gemDict[gems[right]] += 1
        
    return answer
반응형