Product of Array Except Self — 나눗셈 없이 `O(n)`/`O(1)`로 나머지 곱 구하기
전체 보기접기
- 01Merge Strings Alternately — 단일 루프로 `O(n + m)` 교차 병합
- 02Increasing Triplet Subsequence — `O(n)` 시간, `O(1)` 공간으로 푸는 그리디 패턴
- 03Move Zeroes — 쓰기 포인터 하나로 `O(n)`/`O(1)` 제자리 재배치
- 04String Compression — 읽기/쓰기 포인터로 `O(n)`/`O(1)` 제자리 압축
- 05Product of Array Except Self — 나눗셈 없이 `O(n)`/`O(1)`로 나머지 곱 구하기읽는 중
- 06Reverse Vowels of a String — 투 포인터 `O(n)` 스왑으로 모음만 뒤집기
- 07Can Place Flowers — `O(n)` 그리디 스캔으로 인접 금지 배치 판정
- 08시간 복잡도와 공간 복잡도 완전 정복 — Big-O 표기법부터 실전 분석까지
- 09Greatest Common Divisor of Strings — `GCD`로 `O(n + m)` 문자열 최대 공약 패턴
나를 제외한 곱, 어떤 문제인가요?
LeetCode 238번 문제는 이렇게 주어집니다.
정수 배열
nums가 주어졌을 때,answer[i]가nums[i]를 제외한 나머지 모든 원소의 곱이 되는 배열answer를 반환하세요. 나눗셈을 사용하지 않고O(n)에 풀어야 합니다.
예시는 이렇습니다.
nums = [1, 2, 3, 4]→[24, 12, 8, 6]nums = [-1, 1, 0, -3, 3]→[0, 0, 9, 0, 0]
제약은 다음과 같습니다.
2 <= nums.length <= 10^5-30 <= nums[i] <= 30- 곱은 32비트 정수 범위 안에 들어옴
나눗셈(전체 곱 / nums[i])을 쓰면 간단하지만, 문제가 명시적으로 금지합니다. 0이 포함된 경우 나눗셈 자체가 불가능하기도 합니다. 이 글에서는 이중 반복 브루트포스부터, 접두사·접미사 곱 배열, 그리고 변수 하나로 공간을 줄이는 풀이까지 단계적으로 정리합니다. 시간·공간 복잡도 표기가 익숙하지 않다면 시간 복잡도와 공간 복잡도 완전 정복을 먼저 읽어 보시는 것을 권합니다.
Phase 1. 브루트포스 — 매번 나머지를 곱하기
가장 직관적인 접근은 각 인덱스마다 자기를 건너뛰고 나머지를 모두 곱하는 것입니다.
fun productExceptSelf(nums: IntArray): IntArray {
val n = nums.size
val answer = IntArray(n)
for (i in 0 until n) {
var product = 1
for (j in 0 until n) {
if (j != i) product *= nums[j]
}
answer[i] = product
}
return answer
}
nums = [1, 2, 3, 4]를 따라가 봅시다.
i = 0:2 × 3 × 4 = 24i = 1:1 × 3 × 4 = 12i = 2:1 × 2 × 4 = 8i = 3:1 × 2 × 3 = 6
정답은 맞지만, 시간 복잡도가 O(n²)입니다. n이 최대 10^5이면 10^10번 연산이며, 제한 시간 안에 끝나지 않습니다.
Phase 2. 접두사 곱과 접미사 곱 — O(n) 시간
핵심 관찰: answer[i] = i 왼쪽 원소들의 곱 × i 오른쪽 원소들의 곱입니다.
nums = [1, 2, 3, 4]
prefix = [1, 1, 2, 6] ← i 왼쪽까지의 곱
suffix = [24, 12, 4, 1] ← i 오른쪽까지의 곱
answer = [1×24, 1×12, 2×4, 6×1] = [24, 12, 8, 6]
이 두 배열을 미리 만들면 됩니다.
fun productExceptSelf(nums: IntArray): IntArray {
val n = nums.size
val prefix = IntArray(n)
val suffix = IntArray(n)
prefix[0] = 1
for (i in 1 until n) {
prefix[i] = prefix[i - 1] * nums[i - 1]
}
suffix[n - 1] = 1
for (i in n - 2 downTo 0) {
suffix[i] = suffix[i + 1] * nums[i + 1]
}
val answer = IntArray(n)
for (i in 0 until n) {
answer[i] = prefix[i] * suffix[i]
}
return answer
}
시간 O(n), 공간 O(n) (prefix + suffix 배열)입니다. 나눗셈 없이 O(n)을 달성했지만, 보조 배열 두 개가 필요합니다.
Phase 3. 출력 배열 하나로 줄이기 — O(1) 추가 공간
관찰: prefix 배열은 answer에 직접 쓸 수 있고, suffix는 오른쪽에서 훑으면서 변수 하나로 누적하면 됩니다.
fun productExceptSelf(nums: IntArray): IntArray {
val n = nums.size
val answer = IntArray(n)
answer[0] = 1
for (i in 1 until n) {
answer[i] = answer[i - 1] * nums[i - 1]
}
var suffix = 1
for (i in n - 1 downTo 0) {
answer[i] *= suffix
suffix *= nums[i]
}
return answer
}
nums = [1, 2, 3, 4]를 따라가 봅시다.
1단계 — 접두사 곱을 answer에 기록:
answer[0] = 1answer[1] = 1 × 1 = 1answer[2] = 1 × 2 = 2answer[3] = 2 × 3 = 6answer = [1, 1, 2, 6]
2단계 — 오른쪽에서 접미사 곱을 누적하며 곱하기:
i = 3:answer[3] = 6 × 1 = 6,suffix = 1 × 4 = 4i = 2:answer[2] = 2 × 4 = 8,suffix = 4 × 3 = 12i = 1:answer[1] = 1 × 12 = 12,suffix = 12 × 2 = 24i = 0:answer[0] = 1 × 24 = 24,suffix = 24 × 1 = 24answer = [24, 12, 8, 6]✓
시간 O(n), 추가 공간 O(1)입니다. 출력 배열 answer는 문제가 요구하는 결과이므로 보조 공간에 포함하지 않습니다.
0이 포함된 경우도 맞나요?
nums = [-1, 1, 0, -3, 3]을 따라가 봅시다.
1단계 — 접두사 곱:
answer = [1, -1, -1, 0, 0]
2단계 — 접미사 곱 누적:
i = 4:answer[4] = 0 × 1 = 0,suffix = 3i = 3:answer[3] = 0 × 3 = 0,suffix = -9i = 2:answer[2] = -1 × -9 = 9,suffix = 0i = 1:answer[1] = -1 × 0 = 0,suffix = 0i = 0:answer[0] = 1 × 0 = 0,suffix = 0answer = [0, 0, 9, 0, 0]✓
0이 있는 위치의 접미사(또는 접두사)가 0이 되면서 자연스럽게 처리됩니다. 0을 제외한 위치(i = 2)만 양쪽 곱이 0이 아닌 값으로 남습니다.
세 풀이를 다시 비교
| 풀이 | 시간 | 공간 | 특징 |
|---|---|---|---|
| 브루트포스 (이중 반복) | O(n²) |
O(1) |
직관적이지만 큰 입력에서 시간 초과 |
| 접두사 + 접미사 배열 | O(n) |
O(n) |
두 방향 스캔, 관찰을 그대로 옮긴 중간 단계 |
| 출력 배열 + 접미사 변수 | O(n) |
O(1) |
접두사를 출력에 직접 기록, 접미사는 변수 하나로 누적 |
마무리
- "자기를 제외한 곱 = 왼쪽 곱 × 오른쪽 곱"이라는 분할이 이 문제의 핵심 — 나눗셈 없이 곱을 구할 수 있는 이유가 여기에 있습니다
- 접두사·접미사 배열을 먼저 떠올리면, 출력 배열 재활용 + 변수 하나로 공간을 줄이는 단계가 자연스럽게 따라온다 — Phase 2에서 Phase 3으로의 전환은 "같은 관찰을 더 적은 공간으로 표현하는 것"입니다
0이 포함된 입력은 별도 분기 없이 자연스럽게 처리된다 — 접두사나 접미사에0이 곱해지면 해당 방향의 누적 곱이0으로 전파되기 때문입니다- Phase 2 → Phase 3의 공간 압축은 Increasing Triplet Subsequence의
leftMin/rightMax배열 → 변수 두 개 압축과 같은 패턴 — "양방향 전처리를 한 방향은 배열에, 다른 방향은 변수에" 담는 기법은 여러 문제에서 반복됩니다
String Compression — 읽기/쓰기 포인터로 `O(n)`/`O(1)` 제자리 압축
다음 편Reverse Vowels of a String — 투 포인터 `O(n)` 스왑으로 모음만 뒤집기
다음으로 읽어볼 글
Greatest Common Divisor of Strings — `GCD`로 `O(n + m)` 문자열 최대 공약 패턴
두 문자열의 최대 공약 문자열을 구하는 LeetCode 1071번, 브루트포스부터 GCD 기반 O(n + m) 풀이까지 정리합니다.
시간 복잡도와 공간 복잡도 완전 정복 — Big-O 표기법부터 실전 분석까지
Big-O 표기법이 무엇인지, 대표적인 시간 복잡도가 각각 어떤 코드 패턴에서 나타나는지, 공간 복잡도는 어떻게 세는지 단계별로 정리합니다.
Can Place Flowers — `O(n)` 그리디 스캔으로 인접 금지 배치 판정
인접한 화단에 꽃을 동시에 심을 수 없을 때 `n`송이 배치가 가능한지 판단하는 LeetCode 605번, 그리디 선택이 왜 최적인지와 배열 수정 없이 `O(n)`/`O(1)`로 푸는 방법을 정리합니다.