[백준 / 11728 / Python] 배열 합치기 최적화: 시간복잡도 O(N log N)에서 O(N)으로 개선하기

2025. 6. 23. 11:55·Algorithm/백준
반응형

알고리즘 문제를 풀다 보면 "동작은 하지만 더 효율적으로 할 수 있지 않을까?" 라는 생각이 들 때가 있습니다. 오늘은 백준 11728번 '배열 합치기' 문제를 통해 단순한 접근법에서 최적화된 해결책으로 발전하는 과정을 살펴보겠습니다.


문제 분석

문제 설명

백준 11728번: 배열 합치기

  • 시간 제한: 1.5초
  • 메모리 제한: 256MB
  • 정답 비율: 46.789%

문제 내용: 이미 정렬되어 있는 두 배열 A와 B가 주어질 때, 두 배열을 합친 다음 정렬해서 출력하는 프로그램을 작성해야 합니다.

입력 조건

  • 첫째 줄: 배열 A의 크기 N, 배열 B의 크기 M (1 ≤ N, M ≤ 1,000,000)
  • 둘째 줄: 배열 A의 내용 (정렬된 상태)
  • 셋째 줄: 배열 B의 내용 (정렬된 상태)
  • 배열 원소: 절댓값이 10⁹보다 작거나 같은 정수

예제 입력/출력

입력 출력
2 2
3 5
2 9
2 3 5 9
2 1
4 7
1
1 4 7
4 3
2 3 5 9
1 4 7
1 2 3 4 5 7 9

첫 번째 접근법: 무차별 대입

처음 문제를 접했을 때 가장 직관적으로 떠오르는 방법입니다.

import sys
input = sys.stdin.readline

N, M = map(int, input().split())
A = list(map(int, input().split()))
B = list(map(int, input().split()))

# 두 배열을 합치고 정렬
C = A + B
C.sort()
print(" ".join(map(str, C)))

동작 과정

  1. 두 배열 A와 B를 단순히 연결 (A + B)
  2. 합쳐진 배열을 정렬 (C.sort())
  3. 결과 출력

시간 복잡도 분석

  • 배열 합치기: O(N + M)
  • 정렬: O((N + M) log(N + M))
  • 전체: O((N + M) log(N + M))

이 방법의 가장 큰 문제점은 이미 정렬된 배열의 특성을 전혀 활용하지 못한다는 것입니다. 마치 이미 정리된 두 개의 책장에서 책을 모두 꺼내서 다시 섞어서 정리하는 것과 같습니다.


두 번째 접근법: 투 포인터 (Two Pointers)

핵심 아이디어: 이미 정렬된 두 배열의 특성을 활용하여, 각 배열의 앞에서부터 비교해가며 작은 값부터 차례로 선택합니다.

import sys
input = sys.stdin.readline

N, M = map(int, input().split())
A = list(map(int, input().split()))
B = list(map(int, input().split()))

i = j = 0  # 각 배열의 포인터
result = []

# 두 배열을 모두 순회할 때까지 반복
while i < N and j < M:
    if A[i] <= B[j]:
        result.append(A[i])
        i += 1
    else:
        result.append(B[j])
        j += 1

# 남은 원소들 추가
result.extend(A[i:])  # A 배열에 남은 원소들
result.extend(B[j:])  # B 배열에 남은 원소들

print(" ".join(map(str, result)))

동작 과정 시각화

두 배열 A = [2, 3, 5, 9], B = [1, 4, 7]을 병합하는 과정을 살펴보겠습니다:

초기 상태:
A: [2, 3, 5, 9]  i=0 (가리키는 값: 2)
B: [1, 4, 7]     j=0 (가리키는 값: 1)
result: []

1단계: A[0]=2 vs B[0]=1 → 1이 더 작음
A: [2, 3, 5, 9]  i=0
B: [1, 4, 7]     j=1 (가리키는 값: 4)
result: [1]

2단계: A[0]=2 vs B[1]=4 → 2가 더 작음
A: [2, 3, 5, 9]  i=1 (가리키는 값: 3)
B: [1, 4, 7]     j=1
result: [1, 2]

3단계: A[1]=3 vs B[1]=4 → 3이 더 작음
A: [2, 3, 5, 9]  i=2 (가리키는 값: 5)
B: [1, 4, 7]     j=1
result: [1, 2, 3]

... (계속)

최종 결과: [1, 2, 3, 4, 5, 7, 9]

시간 복잡도 분석

  • 비교 연산: 최대 N + M 번
  • 배열 복사: O(N + M)
  • 전체: O(N + M)

성능 비교

시간 복잡도 비교

방법 시간 복잡도 N=M=500,000일 때
무차별 정렬 O((N+M) log(N+M)) 약 19,930,000 연산
투 포인터 O(N+M) 약 1,000,000 연산

성능 향상: 약 20배 빠름

실제 실행 시간 측정

import time
import random

def generate_sorted_array(size):
    """정렬된 배열 생성"""
    arr = [random.randint(1, 1000000) for _ in range(size)]
    return sorted(arr)

# 테스트 데이터 생성
N = M = 100000
A = generate_sorted_array(N)
B = generate_sorted_array(M)

# 방법 1: 무차별 정렬
start_time = time.time()
C1 = A + B
C1.sort()
time1 = time.time() - start_time

# 방법 2: 투 포인터
start_time = time.time()
i = j = 0
C2 = []
while i < N and j < M:
    if A[i] <= B[j]:
        C2.append(A[i])
        i += 1
    else:
        C2.append(B[j])
        j += 1
C2.extend(A[i:])
C2.extend(B[j:])
time2 = time.time() - start_time

print(f"무차별 정렬: {time1:.4f}초")
print(f"투 포인터:   {time2:.4f}초")
print(f"성능 향상:   {time1/time2:.1f}배")

투 포인터 기법의 핵심 원리

1. 정렬된 자료구조의 특성 활용

투 포인터 기법은 이미 정렬된 자료구조의 순서 정보를 최대한 활용합니다. 각 배열의 현재 위치에서 가장 작은 값끼리만 비교하면 되므로, 불필요한 비교를 줄일 수 있습니다.

2. 탐욕적 선택 (Greedy Choice)

각 단계에서 현재 상황에서 최선의 선택을 합니다. 두 포인터가 가리키는 값 중 작은 값을 선택하는 것이 항상 최적해가 됩니다.

3. 분할 정복의 병합 단계

이 기법은 사실 병합 정렬(Merge Sort)의 병합 단계와 동일한 원리입니다. 이미 정렬된 두 부분을 효율적으로 합치는 핵심 알고리즘입니다.


실무 응용 사례

1. 데이터베이스 조인 최적화

-- 정렬된 두 테이블을 병합할 때 사용되는 Sort-Merge Join
SELECT * FROM tableA A
JOIN tableB B ON A.id = B.id
ORDER BY A.id, B.id;

2. 로그 파일 병합

def merge_log_files(file1_path, file2_path, output_path):
    """시간 순으로 정렬된 두 로그 파일을 병합"""
    with open(file1_path) as f1, open(file2_path) as f2, \
         open(output_path, 'w') as out:

        line1 = f1.readline()
        line2 = f2.readline()

        while line1 and line2:
            time1 = extract_timestamp(line1)
            time2 = extract_timestamp(line2)

            if time1 <= time2:
                out.write(line1)
                line1 = f1.readline()
            else:
                out.write(line2)
                line2 = f2.readline()

        # 남은 라인들 처리
        out.writelines(f1)
        out.writelines(f2)

3. 실시간 데이터 스트림 병합

def merge_sorted_streams(stream1, stream2):
    """정렬된 두 데이터 스트림을 실시간으로 병합"""
    try:
        val1 = next(stream1)
        val2 = next(stream2)

        while True:
            if val1 <= val2:
                yield val1
                val1 = next(stream1)
            else:
                yield val2
                val2 = next(stream2)

    except StopIteration:
        # 한 스트림이 끝나면 나머지 스트림의 데이터를 모두 반환
        yield from stream1
        yield from stream2

주의사항과 예외 처리

1. 빈 배열 처리

def merge_arrays_safe(A, B):
    """빈 배열을 고려한 안전한 병합"""
    if not A:
        return B
    if not B:
        return A

    i = j = 0
    result = []

    while i < len(A) and j < len(B):
        if A[i] <= B[j]:
            result.append(A[i])
            i += 1
        else:
            result.append(B[j])
            j += 1

    result.extend(A[i:])
    result.extend(B[j:])
    return result

2. 메모리 효율성 고려

def merge_arrays_generator(A, B):
    """메모리 효율적인 제너레이터 버전"""
    i = j = 0

    while i < len(A) and j < len(B):
        if A[i] <= B[j]:
            yield A[i]
            i += 1
        else:
            yield B[j]
            j += 1

    yield from A[i:]
    yield from B[j:]

# 사용 예시
result = list(merge_arrays_generator(A, B))

확장 응용: K개 배열 병합

import heapq

def merge_k_sorted_arrays(arrays):
    """K개의 정렬된 배열을 효율적으로 병합"""
    # 힙을 사용한 K-way merge
    heap = []
    result = []

    # 각 배열의 첫 번째 원소를 힙에 추가
    for i, arr in enumerate(arrays):
        if arr:  # 빈 배열이 아닌 경우
            heapq.heappush(heap, (arr[0], i, 0))

    while heap:
        val, arr_idx, elem_idx = heapq.heappop(heap)
        result.append(val)

        # 해당 배열의 다음 원소가 있다면 힙에 추가
        if elem_idx + 1 < len(arrays[arr_idx]):
            next_val = arrays[arr_idx][elem_idx + 1]
            heapq.heappush(heap, (next_val, arr_idx, elem_idx + 1))

    return result

# 시간 복잡도: O(N log K), N은 총 원소 개수, K는 배열 개수

마무리

배열 합치기 문제를 통해 단순한 해결책에서 최적화된 해결책으로 발전하는 과정을 살펴보았습니다.

핵심 포인트

  • 문제의 조건을 잘 관찰하기: "이미 정렬된 배열"이라는 조건이 핵심
  • 시간 복잡도 분석의 중요성: O(N log N)에서 O(N)으로 약 20배 성능 향상
  • 투 포인터 기법의 범용성: 다양한 실무 상황에서 활용 가능
  • 점진적 최적화: 동작하는 코드부터 시작해서 단계적으로 개선

개발자로서의 인사이트

좋은 개발자는 "동작하는 코드"에서 멈추지 않고 "더 나은 코드"를 고민합니다. 특히 대용량 데이터를 다루거나 실시간 처리가 필요한 상황에서는 이런 최적화가 서비스의 성패를 좌우할 수 있습니다.

알고리즘 문제 풀이는 단순히 정답을 맞히는 것을 넘어서, 효율적인 사고방식을 기르는 훈련입니다. 오늘 배운 투 포인터 기법을 다른 문제에도 적용해보시기 바랍니다.


참고 자료

  • 백준 11728번: 배열 합치기
  • 알고리즘 문제해결전략 - 분할 정복
  • Introduction to Algorithms - Merge Sort
  • 투 포인터 기법 완전 정복
반응형
저작자표시 비영리 변경금지 (새창열림)

'Algorithm > 백준' 카테고리의 다른 글

[백준 / 2578 / Python] 빙고  (2) 2025.06.14
[백준 / 2239 / Python] 스도쿠  (0) 2025.06.13
[백준 / 17404 / Python] RGB거리 2  (2) 2025.06.13
[백준 / 2342 / Python] Dance Dance Revolution  (0) 2025.06.13
[백준 / 2143 / Python] 두 배열의 합  (0) 2025.06.13
'Algorithm/백준' 카테고리의 다른 글
  • [백준 / 2578 / Python] 빙고
  • [백준 / 2239 / Python] 스도쿠
  • [백준 / 17404 / Python] RGB거리 2
  • [백준 / 2342 / Python] Dance Dance Revolution
Kun Woo Kim
Kun Woo Kim
안녕하세요, 김건우입니다! 웹과 앱 개발에 열정적인 전문가로, React, TypeScript, Next.js, Node.js, Express, Flutter 등을 활용한 프로젝트를 다룹니다. 제 블로그에서는 개발 여정, 기술 분석, 실용적 코딩 팁을 공유합니다. 창의적인 솔루션을 실제로 적용하는 과정의 통찰도 나눌 예정이니, 궁금한 점이나 상담은 언제든 환영합니다.
  • Kun Woo Kim
    WhiteMouseDev
    김건우
  • 깃허브
    포트폴리오
    velog
  • 전체
    오늘
    어제
  • 공지사항

    • [인사말] 이제 티스토리에서도 만나요! WhiteMouse⋯
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
    • 분류 전체보기 (99) N
      • Frontend Development (38)
      • Backend Development (21) N
      • Algorithm (33) N
        • 백준 (11) N
        • 프로그래머스 (17)
        • 알고리즘 (5)
      • Infra (1)
      • 자료구조 (3)
  • 링크

    • Github
    • Portfolio
    • Velog
  • 인기 글

  • 태그

    frontend development
    tailwindcss
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
Kun Woo Kim
[백준 / 11728 / Python] 배열 합치기 최적화: 시간복잡도 O(N log N)에서 O(N)으로 개선하기
상단으로

티스토리툴바