from collections import deque

def solution(queue1, queue2):
    queue1 = deque(queue1)
    queue2 = deque(queue2)
    sumQ1 = sum(queue1)
    sumQ2 = sum(queue2)
    
    count = 0
    
    for _ in range(len(queue1) * 3):
        if sumQ1 == sumQ2:
            return count
        
        if sumQ1 > sumQ2:
            count += 1
            sumQ1 -= queue1[0]
            sumQ2 += queue1[0]
            queue2.append(queue1.popleft())
            
        else:
            count += 1
            sumQ1 += queue2[0]
            sumQ2 -= queue2[0]
            queue1.append(queue2.popleft())
    
    return -1

내가 작성한 풀이

 

def solution(queue1, queue2):
    indicator2=sum(queue1)-int(sum(queue1+queue2)/2)
    answer=0
    sub_list=(queue1+queue2+queue1)[::-1]
    add_list=(queue2+queue1+queue2)[::-1]
    while indicator2!=0:
        try:
            if indicator2>0:
                indicator2-=sub_list.pop()
            else:
                indicator2+=add_list.pop()
        except:
            return -1
        answer+=1
    return answer

추가로 발견한 좋은 풀이

'PS > 투 포인터' 카테고리의 다른 글

[프로그래머스] No.118667 두 큐 합 같게 만들기 01  (0) 2023.10.02

 

 

프로그래머스

코드 중심의 개발자 채용. 스택 기반의 포지션 매칭. 프로그래머스의 개발자 맞춤형 프로필을 등록하고, 나와 기술 궁합이 잘 맞는 기업들을 매칭 받으세요.

programmers.co.kr

표현 가능한 이진트리를 풀고 나서, 생각 어려운 문제에 쉬운 문제로 쉬어가려고 했다.

그래서 같은 카카오 출제 문제지만, 이보다 전년도 2번 문제를 선택했다.

카카오가 문제를 어렵게 출제하기로 유명하니, 연습을 한다면 기왕이면 어럽게 하고자 카카오로 골랐다.

1번 문제는 예전에 손쉽게 풀었었고, 바로 다음 문제인 2번을 필연적으로 고르게 됐다.

 

문제 자체는 이미지도 없어서 짧은 편이다.

두 큐가 있을 때, 한 쪽에서 값을 꺼낸 뒤 다른 쪽 큐에 커낸 값을 넣는다.

어느 쪽 큐에서 꺼내든 상관없지만, 두 큐의 합이 같아질 때까지 이를 반복한다.

두 큐의 합이 같아지는 경우는 다양하게 있을 수 있다.

그럴 때 최소 횟수는 과연 얼마나 걸릴까를 구하는 문제다.

 

처음에는 두 큐를 활용한 방법이니 collections.deque를 사용해야 하나? 했지만 굳이 그럴 필요가 없다고 느꼈다.

deque를 사용하지 않고 해결하기 위해 생각한 방법은 누적합(prefix sum)을 활용하는 방법이다.

개수가 몇 개인지, 어디서부터 어디까지 더하는지는 우선순위가 상대적으로 낮다.

우선 두 큐의 합이 같아야 한다는 것이다.

그래서 두 큐를 연결한 다음, 마지막 요소(위의 예시에서는 30)의 반이 될 때까지 탐색을 진행한다.

요소를 더한 다음에 이 값을 비교하는 게, 누적합을 구한 뒤 뺄셈을 하는 방식이 생각났다.

따라서 빠르게 구할 수 있다고 생각해 누적합 방식을 채택했다.

 

이때 주의할 점은 2가지가 있다. 하나는 slicing이고, 다른 하나는 우선순위다.

우선 slicing을 생각해보자. 2 + 7 + 2 + 4일 때, 30의 절반인 15가 된다.

하지만 누적합을 활용하면 18 - 3, 즉 시작하는 위치는 한 칸 앞이 된다. 이 점을 유의해서 코드를 작성한다.

우선 순위를 생각해보자. 위에서는 15가 되는 구간이 2 + 7 + 2 + 4와 4 + 6 + 5로 2개가 있다.

문제의 목적은 "작업의 최소 횟수"를 구하는 문제다. 그러니까 시작 지점에서 최대한 가까워야 한다.

따라서 먼저 발견하는 범위가 정답이 된다.

 

def solution(queue1, queue2):
    all_sum = sum(queue1) + sum(queue2)
    
    if all_sum % 2 == 1:
        return -1
    
    count = 0
    answer = all_sum // 2
    prefix = queue1 + queue2
    length = len(prefix)
        
    # Calculating prefix sum
    for i in range(1, length):
        prefix[i] += prefix[i-1]
        
    # Calculating min len dif
    for start in range(length-1):
        for end in range(start+1, length):
            if prefix[end] - prefix[start] == answer:
                count = start + end - len(queue1) + 2
                return count
                
    return -1

 

 

우선 같은 두 값을 더하면 무조건 짝수가 된다. (N + N = 2N)

그럼 모든 값을 더한 값이 홀수면, 애초에 성립이 불가능하다는 말이다.

그래서 조기 종료를 위해 if all_sum % 2 == 1: return -1 구문을 넣어줬다.

 

그 다음 위에서 생각한 것을 그대로 코드로 옮겼다.

누적합을 구하고(for i in range(1, length)),

각 요소들을 하나씩 확인하면서(for start in range(length-1) for end in range(start+1, length)),

만약 구간의 차가 정답과 같다면, 정답을 계산한다.

 

start와 end 지점은 하나의 큐가 가져야 할 범위다.

따라서 두 큐를 연결한 전체의 큐에서, 해당 범위만큼을 따로 빼내는 작업(몇 칸을 이동해야 하는지)을 세야 한다.

start의 앞부분을 다른 큐의 뒷부분으로 이동해야 한다. 위에서 유의할 점을 생각하면 start + 1만큼 이동한다.

end 자체가 아니라 앞의 queue1의 길이만큼 빼준 만큼 다른 큐의 뒷부분으로 이동해야 한다.

그리고 index는 0부터 시작하니 1을 더해줘야 한다. 그래서 end - len(queue1) + 1만큼 이동해야 한다.

따라서, 정답을 계산하는 식은 start + end - len(queue1) + 2가 된다.  

 

틀린 것은 둘째치더라도, TLE가 너무 많이 나온다.

생각해보니까 누적합에서 값을 찾을 때 이중 반복문을 활용해 시간복잡도가 O(n^2)이 나온다.

하나의 리스트에서 두 개의 위치 정보를 이용하여 탐색하는 방법.

하지만 시간복잡도가 O(n^2)보다는 짧아야만 한다.

모든 조건을 만족하는 알고리즘 '투 포인터'가 있었다.

 

투 포인터를 활용하면 누적합을 이용하기 애매해진다.

이용하라면 할 수는 있겠지만, 그래도 계속해서 추가적인 연산이 필요하다.

방법을 바꿔서 누적합이 아니라, 포인터가 움직일 때마다 연산을 하는 방법을 선택했다.

end 포인터가 이동하여 가리키는 요소를 더한다. 왜냐하면 새롭게 포함하는 값이기 때문이다.

start 포인터가 이동하기 전 가리키는 요소를 뺀다. 왜냐하면 범위를 벗어난 값이기 때문이다.

start와 end가 새로운 지점을 가리킬 때마다 sum() 내장 함수를 사용하는 방법은 지양한다.

sum() 내장 함수 자체가 O(n)이기 때문에 시간 초과가 걸릴 수 있기 때문이다.

 

def solution(queue1, queue2):
    mid_sum, ep = 0, 0
    full_que = queue1 + queue2
    
    if sum(full_que) % 2 == 1:
        return -1
        
    half = sum(full_que) // 2
    
    for sp in range(len(full_que)):
        while mid_sum < half and ep < len(full_que):
            mid_sum += full_que[ep]
            ep += 1
            
        if mid_sum == half:
            return sp + ep - len(queue1)
        
        mid_sum -= full_que[sp]
    
    return -1

투 포인터를 활용한 알고리즘 방식을 코드를 다시 짰다.

이중 반복문 부분만 투 포인터로 바꾸고, 조기 종료 같은 부분은 그대로 유지했다.

투 포인터로 돌다가 역시나 처음에 만나는 값이 가장 적게 이동할 수 있는 값이다.

그래서 mid_sum이 half와 같아진다면 sp + ep - len(queue1)을 반환하고 종료한다.

이번에는 return 값에는 +2가 없어졌다.

sp(start pointer)와 ep(end pointer)가 가리키는 값이 먼저 한 칸씩 이동하기 때문에 굳이 +2를 할 필요가 없어졌다.

 

TLE는 확실하게 없어졌다. 하지만 2개의 반례가 있는 듯했다.

어떤 반례가 있나 생각해보니, 위의 코드는 start와 end가 다른 큐에 속하는 경우만 해당한다는 사실을 알았다.   

 

본 코드에서 return하는 값은 sp + ep - len(queue1)이다.

이 경우는 위의 그림처럼 정상적인 경우라고 불러보자.

그럼 start가 Q1에 해당하는 자리에 있고, end가 Q2에 해당하는 자리에 있어야만 한다.

 

만약 start와 end가 모두 Q1에 있다면? 혹은 모두 Q2에 있다면? retrun은 달라져야 한다.

위의 그림처럼 start와 end가 모두 Q1 혹은 Q2에 있는 예외의 상황이 존재한다.

각각의 상황에서는 return값이 모두 달라지는 경우의 수를 크게 3가지로 나눴다.

1. start가 가장 처음 index를 가리키고 있을 때

2. end가 가장 마지막 index를 가리키고 있을 때

3. 둘 다 끝이 아닌 중간값을 가리키고 있을 때

 

1번의 경우는 '(start ~ end) + 나머지'에서 나머지만을 다른 큐로 이동해야 한다.

그러기 위해서는 (start ~ end)을 다른 큐의 뒤로 옮기고, 다른 큐의 전체를 기존 큐로 가져와야 한다.

따라서 return 값은 ep + length가 된다.

2번의 경우는 '나머지 + (start ~ end)'에서 나머지만을 다른 큐로 이동해야 한다.

이 경우는 쉽다. 나머지 부분만 다른 큐로 보내면 된다. 따라서 return 값은 sp가 된다.

3번의 경우는 '나머지 + (start ~ end) + 나머지'에서 양끝단의 나머지를 전부 떼어내야 한다.

이 경우는 처음과 유사하다. 처음부터 end까지 다른 큐로 보내고, length(다른 큐)와 나머지를 기존 큐로 보낸다.

따라서 return 값은 ep + length + sp가 된다.

 

if mid_sum == half:
    if sp >= length and ep >= length:
        sp -= length
        ep -= length

    if sp < length and ep < length:
        if sp == 0:
            return ep + length
        elif ep == length - 1:
            return sp
        else:
            return ep + sp + length

    else:
        return sp + ep - length

mid_sum == half 부분만 위에 보이는 코드처럼 바꿔주었다.

최대한 중복하는 코드를 줄이기 위해서 생각해보았다.

설명을 할 때 Q1에 해당하는 부분만 고려해주었다. 그야 Q1과 Q2는 사실상 같은 경우기 때문이다.

Q1에 해당하는 요소를 Q2에 보내고, Q2도 Q1으로 보낸다. 그럼 같은 경우다.

따라서 고려해야 하는 것은 index만 length만큼 뺴주면, '이동하는 횟수' 자체는 동일하다.

 

기존에 9번 14번은 그대로 틀린 상태고, 1번을 오히려 추가로 틀렸다.

로직에서 어떤 부분이 문제가 있을까 생각해보니, 언제나 처음이 가장 빠르다는 것을 보장해주지 않았다.

예를 들어서 queue1 값이 [2, 3, 2]이고, queue2 값이 [1, 1, 1]이라고 가정해보자.

그럼 전체의 합은 10이고, 각 큐가 5가 될 때까지 요소들을 이동할 것이다.

투 포인터로 값을 찾아보니, 이게 웬 걸 처음부터 2, 3만 있으면 5를 만족한다.

이 경우는 start가 처음에 있는 경우다.

ep + length값에 의해서 return(옮기는 최소 횟수)은 5가 된다.

 

진짜 그럴까? 아니다.

[2, 3, 2] 부분에서 [2, 3]을 더해도 5가 되는 건 맞지만, [3, 2]를 더해도 5가 된다.

따라서 queue1의 첫 요소인 2를 queue2로 옮기기만 해도 문제를 해결할 수 있다.

틀린 로직으로 운좋게 테스트 케이스를 통과해도 그게 과연 문제를 풀었다고 할 수 있을까?

아니라고 생각한다. 예외로 return하는 경우가 많아져 코드가 보기 싫었었다.

언제나 빠름을 보장하는 게 아니라는 것도 알았기에, 코드를 전면 수정했다.

 

처음으로 돌아가서, 다시 생각해보았다.

두 큐의 길이는 같고, 각 큐는 최대 300,000만의 길이를 갖는다.

만약 특별한 알고리즘 없이 하나하나 넘기면서 확인하는 방법은 어떨까?라는 생각을 했다.

TLE를 염두해야 하니, 얼마나 반복하는지를 가장 중요하게 고려해야 한다.

 

Q1과 Q2가 있는 초기 상태를 생각해보자. 그리고 값을 찾지 못하는 최악의 경우를 생각해보자.

Q1을 하나하나 넘겨서 Q2에 추가하면서 종료 조건이 되는지 확인한다. 종료를 안 했다.

그럼 다시 Q2를 Q1으로 하나하나 넘기면서 종료 조건이 되는지 확인한다. 이래도 종료를 안 한다.

이렇게 되면 Q1과 Q2가 다시 각각의 큐로 독립된 상태로 돌아왔다.

이번에는 Q2를 Q1으로 하나하나 넘기면서 종료 조건이 되는지 확인한다. 종료를 안 했다.

어라? Q1에서 Q2로 넘겼을 때와, Q2에서 Q1으로 넘겼을 때 결과가 동일하다.

따라서 (하나의 큐 길이 * 3)번이 최악의 경우가 된다.

 

입력 조건에서 최대 300,000의 입력을 갖음을 보장한다.

그럼 O(900,000)이라는 시간에 걸쳐서 문제를 푼다.

처음에 이중 반복문으로 풀었을 때는 30만의 제곱이라 O(900억)이라 TLE가 떴다.

하지만 90만 정도면 충분하지 않을까? 생각해서 코드를 작성했다.

 

def solution(queue1, queue2):
    sumQ1 = sum(queue1)
    sumQ2 = sum(queue2)
    
    count = 0
    
    for _ in range(len(queue1) * 3):
        if sumQ1 == sumQ2:
            return count
        
        if sumQ1 > sumQ2:
            count += 1
            sumQ1 -= queue1[0]
            sumQ2 += queue1[0]
            queue2.append(queue1.pop(0))
            
        else:
            count += 1
            sumQ1 += queue2[0]
            sumQ2 -= queue2[0]
            queue1.append(queue2.pop(0))
    
    return -1

코드 자체는 별 게 없다.

queue1의 합을 구한 sumQ1과 queue2의 합을 구한 sumQ2를 구한다.

만약 sumQ1이 sumQ2보다 크다면? queue1에서 queue2로 값을 옮겨야 한다.

아니라면 반대의 행동을 취하면 된다.

그렇게 'len(queue1) * 3'번 반복하면서 두 큐가 같아질 때까지 확인한다.

같아지지 않으면 -1을 같아진다면 count를 반환한다.

 

아이디어 자체는 맞았고, 9번과 14번에서 발생하던 예외도 AC를 받았다.

하지만 처음에 시간을 오래 잡아먹던, 22, 23, 24, 26번의 케이스에서 다시 TLE가 떴다.

실은 처음부터 한 가지 우려했던 상황이 있었다. list.pop(0) 부분이다.

list.pop()은 시간복잡도가 O(1)이고, list.pop(index)는 시간복잡도가 O(n)인 것은 알고 있었다.

하지만 index가 0에 접근하는 거니까 O(1)과 같지 않을까?라는 생각으로 작성했고 TLE가 떴다.

그래서 해당 부분을 바꿔보기로 했다.

 

# Before
queue1, queue2
queue2.append(queue1.pop(0))
queue1.append(queue2.pop(0))

# After
deque(queue1), deque(queue2)
queue2.append(queue1.popleft())
queue1.append(queue2.popleft())

왼쪽에서 요소를 뽑을 때 O(1)이 걸리기 위해서는 deque를 사용할 수 있다. 

가장 왼쪽에서 제거하거나, 가장 왼쪽에 추가할 때 deque를 사용하면 O(1)로 해결할 수 있다.

 

그제서야 모든 테스트 케이스에서 AC를 받을 수 있었다.

 

아무래도 작성한 코드가 마음에 안 들기도 하고, 나보다 더 나은 코드를 배우기 위해 이번에도 다른 풀이를 확인해보았다.

오... 엄청난 풀이를 하나 건졌다.

3번 반복한다는 것을 그냥 queue + queue + queue로 처리해버렸다.

그리고 deque를 사용하지 않고 pop()을 O(1)으로 풀기 위해서는? 뒤집으면 된다.

indicator에는 queue1이 기준(절반)보다 큰지 작은지가 들어가있다.

indicator가 양수라면, 즉 queue1이 기준보다 크다면, sub_list에서 pop을 하면 된다.

그게 아니라 queue2가 기준보다 크다면, 더해줘야 하니 add_list에서 pop을 하면 된다.

그리고 한 쪽을 전부 탐색했다면 try-except로 빠져나와 -1을 반환한다.

진짜 이런 생각은 어떻게 하는지... 아직도 배워서 성장할 게 많이 남아 행복하다. 

'PS > 투 포인터' 카테고리의 다른 글

[프로그래머스] No.118667 두 큐 합 같게 만들기 完  (0) 2023.10.02

+ Recent posts