String Compression — 읽기/쓰기 포인터로 `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)` 문자열 최대 공약 패턴
- 10Container With Most Water — 투 포인터 `O(n)`로 최대 넓이 한 번에 찾기
- 11Is Subsequence — 투 포인터 `O(|s| + |t|)`로 부분 수열 판정
- 12Richest Customer Wealth — 2차원 배열에서 행 합의 최댓값 찾기
- 13Running Sum of 1d Array — 누적합의 가장 기본 형태
- 14Roman to Integer — 감산 규칙을 한 번의 스캔으로 처리하기
- 15Palindrome Number — 문자열 없이 절반만 뒤집는 숫자 팰린드롬 판정
- 16Max Number of K-Sum Pairs — 정렬 `O(n log n)`에서 해시 `O(n)`까지 최대 페어 수 세기
- 17Ransom Note — 문자 개수로 문자열 구성 가능 여부 판정하기
- 18Middle of the Linked List — slow/fast 포인터로 중간 노드 찾기
- 19Max Consecutive Ones — 한 번의 스캔으로 가장 긴 1 구간 찾기
- 20Find Numbers with Even Number of Digits — 자릿수 개수의 짝수 여부 세기
- 21Merge Sorted Array — 뒤에서부터 채우는 제자리 병합
- 22Duplicate Zeros — 고정 길이 배열을 오른쪽에서부터 제자리 갱신하기
- 23Squares of a Sorted Array — 절댓값 기준 투 포인터로 `O(n)` 정렬 유지하기
- 24Valid Mountain Array — 올라갔다가 내려오는 배열 판별하기
- 25Check If N and Its Double Exist — 어떤 값과 그 두 배가 함께 있는지 확인하기
- 26Remove Duplicates from Sorted Array — 정렬 배열을 고유 값 구간으로 압축하기
- 27Remove Element — 앞쪽 유효 구간으로 압축하는 제자리 제거
- 28Replace Elements with Greatest Element on Right Side — 오른쪽 최댓값으로 바꾸기
- 29Sort Array By Parity — 짝수를 앞쪽으로 모으기
- 30Height Checker — 기대 순서와 다른 위치 세기
- 31Third Maximum Number — 세 변수로 상위 3개를 추적해 찾기
- 32Reorder Data in Log Files — 문자 로그만 정렬하고 숫자 로그는 순서 유지하기
- 33Valid Palindrome — 투 포인터로 영숫자만 비교하는 팰린드롬 판정
- 34Find All Numbers Disappeared in an Array — 음수 마킹으로 `O(n)`/`O(1)` 누락 숫자 찾기
- 35Most Common Word — 금지어를 제외하고 가장 자주 나온 단어 찾기
- 36Group Anagrams — 정렬 키와 문자 개수 키로 애너그램 묶기
문자열 압축, 어떤 문제인가요?
LeetCode 443번 문제는 이렇게 주어집니다.
문자 배열
chars가 주어졌을 때, 연속된 같은 문자 그룹을 '문자 + 개수' 형식으로 압축하세요. 개수가1이면 문자만 씁니다. 압축 결과를chars배열 자체에 덮어쓰고, 압축된 길이를 반환하세요. 상수 추가 공간만 사용해야 합니다.
예시는 이렇습니다.
['a','a','b','b','c','c','c']→ 길이6,['a','2','b','2','c','3']['a']→ 길이1,['a']['a','b','b','b','b','b','b','b','b','b','b','b','b']→ 길이4,['a','b','1','2']
제약은 다음과 같습니다.
1 <= chars.length <= 2000chars[i]는 영소문자, 영대문자, 숫자, 기호
세 번째 예시에서 'b'가 12번 반복되면 "12"를 한 글자씩 '1', '2'로 분리해서 써야 합니다. 이 글에서는 별도 버퍼를 쓰는 방식부터, 읽기/쓰기 포인터로 제자리 압축하는 방식까지 정리합니다. 시간·공간 복잡도 표기가 익숙하지 않다면 시간 복잡도와 공간 복잡도 완전 정복을 먼저 읽어 보시는 것을 권합니다.
Phase 1. 별도 버퍼에 압축 — 가장 직관적인 풀이
먼저 StringBuilder에 압축 결과를 쓴 뒤, 원본 배열에 복사하는 방식입니다.
fun compress(chars: CharArray): Int {
val sb = StringBuilder()
var i = 0
while (i < chars.size) {
val ch = chars[i]
var count = 0
while (i < chars.size && chars[i] == ch) {
count++
i++
}
sb.append(ch)
if (count > 1) sb.append(count)
}
for (j in sb.indices) {
chars[j] = sb[j]
}
return sb.length
}
['a','a','b','b','c','c','c']를 따라가 봅시다.
ch = 'a',count = 2→sb = "a2"ch = 'b',count = 2→sb = "a2b2"ch = 'c',count = 3→sb = "a2b2c3"sb를chars에 복사 → 길이6
시간 O(n), 공간 O(n) (StringBuilder 크기)입니다. 로직은 간단하지만, 문제가 요구하는 상수 추가 공간 조건을 만족하지 못합니다.
Phase 2. 읽기/쓰기 포인터 — 제자리 압축
별도 버퍼를 없애려면, read(읽기)와 write(쓰기) 포인터 두 개로 같은 배열 위에서 작업하면 됩니다.
fun compress(chars: CharArray): Int {
var read = 0
var write = 0
while (read < chars.size) {
val ch = chars[read]
var count = 0
while (read < chars.size && chars[read] == ch) {
count++
read++
}
chars[write++] = ch
if (count > 1) {
for (digit in count.toString()) {
chars[write++] = digit
}
}
}
return write
}
같은 입력 ['a','a','b','b','c','c','c']를 따라가 봅시다.
초기: ['a','a','b','b','c','c','c']
R W=0
그룹 'a' (count=2): read 0→2
chars[0]='a', chars[1]='2' → write=2
['a','2','b','b','c','c','c']
R=2 W=2
그룹 'b' (count=2): read 2→4
chars[2]='b', chars[3]='2' → write=4
['a','2','b','2','c','c','c']
R=4 W=4
그룹 'c' (count=3): read 4→7
chars[4]='c', chars[5]='3' → write=6
['a','2','b','2','c','3','c']
R=7 W=6
write = 6 반환 ✓
write가 read를 앞지르지 않는 이유
제자리 알고리즘에서 가장 중요한 안전성 질문입니다. 같은 배열의 앞쪽에 쓰면서 뒤쪽을 읽는데, 아직 읽지 않은 데이터를 덮어쓰지 않을까요?
k개의 연속 같은 문자 그룹을 처리할 때:
read는k칸 전진합니다write는1 + (k의 자릿수)칸 전진합니다
k |
read 전진 |
write 전진 |
write ≤ read? |
|---|---|---|---|
| 1 | 1 | 1 | ✓ (같음) |
| 2~9 | 2~9 | 2 | ✓ |
| 10~99 | 10~99 | 3 | ✓ |
| 100~999 | 100~999 | 4 | ✓ |
k ≥ 1이면 항상 write 전진 ≤ read 전진입니다. 압축은 원본보다 짧거나 같으므로, write가 read를 앞지를 수 없습니다.
10 이상의 개수는 어떻게 처리하나요?
['a','b','b','b','b','b','b','b','b','b','b','b','b']에서 'b'가 12번 반복됩니다.
그룹 'a' (count=1): chars[0]='a' → write=1
그룹 'b' (count=12):
chars[1]='b' → write=2
count.toString() = "12"
chars[2]='1', chars[3]='2' → write=4
write = 4 반환, chars = ['a','b','1','2',...] ✓
count.toString()이 "12"를 '1', '2' 두 문자로 분리해 주므로, 별도의 자릿수 분해 로직 없이 자연스럽게 처리됩니다.
두 풀이를 다시 비교
| 풀이 | 시간 | 공간 | 특징 |
|---|---|---|---|
StringBuilder 버퍼 |
O(n) |
O(n) |
직관적이지만 추가 버퍼 필요 |
| 읽기/쓰기 포인터 | O(n) |
O(1) |
같은 배열에서 제자리 압축, 문제 조건 충족 |
마무리
- 읽기/쓰기 포인터 패턴의 핵심은 "압축 결과가 원본보다 항상 짧거나 같다"는 성질 — 이 성질이
write가read를 앞지르지 않음을 보장하고, 제자리 압축을 가능하게 합니다 - 10 이상의 개수는
count.toString()으로 자릿수를 자연스럽게 분리할 수 있다 —"12"를'1','2'로 나누는 별도 로직이 필요 없습니다 - 개수가
1이면 숫자를 쓰지 않는 조건을 빠뜨리기 쉽다 —if (count > 1)분기가 없으면['a']가['a','1']로 잘못 압축됩니다
Move Zeroes — 쓰기 포인터 하나로 `O(n)`/`O(1)` 제자리 재배치
다음 편Product of Array Except Self — 나눗셈 없이 `O(n)`/`O(1)`로 나머지 곱 구하기
다음으로 읽어볼 글
Group Anagrams — 정렬 키와 문자 개수 키로 애너그램 묶기
애너그램 문자열을 같은 그룹으로 묶는 LeetCode 49번, 정렬된 문자열을 키로 쓰는 풀이부터 문자 개수 배열을 해시 키로 바꾸는 `O(n * k)` 풀이까지 정리합니다.
Most Common Word — 금지어를 제외하고 가장 자주 나온 단어 찾기
문단에서 금지어를 제외하고 가장 많이 등장한 단어를 찾는 LeetCode 819번, 문장 부호 제거와 소문자 정규화, 해시 카운팅 풀이를 정리합니다.
Find All Numbers Disappeared in an Array — 음수 마킹으로 `O(n)`/`O(1)` 누락 숫자 찾기
1부터 n까지의 숫자 중 배열에 나타나지 않은 값을 찾는 LeetCode 448번, 완전 탐색부터 존재 배열, 음수 제자리 마킹 풀이까지 정리합니다.