← Study Hub

06 · 자료구조와 알고리즘

복잡도·컬렉션·해시·이진탐색·정렬 안정성·페이지 병합을 CS 원리와 iOS 런타임(값 타입·COW·해시 시딩) 양쪽으로 답하는 심화 7문항
진행률
0 / 0 완료

← 챕터 목록

자료구조·알고리즘 면접의 핵심은 복잡도 표를 외우는 게 아니라, 왜 그 복잡도가 나오는지iOS 런타임(값 타입·Copy-on-Write·해시 시딩)이 그 이론을 어떻게 비트는지를 함께 말하는 것이다. 아래 7문항을 그 두 축으로 답한다.

Q1. Array, Set, Dictionary의 평균·최악 시간 복잡도는 무엇인가?

🔑 30초 답변

Array는 인덱스 접근이 O(1), 끝에 추가는 amortized(분할상환) O(1), 중간 삽입·삭제와 값 검색은 O(n)이다. Set·Dictionary는 해시 기반이라 조회·삽입·삭제가 평균 O(1)이지만 최악 O(n)(모든 키가 한 버킷에 몰릴 때)이다. Swift는 실행마다 해시 시드를 무작위화해 이 최악을 악의적으로 유발하기 어렵게 만든다. 실무에서 더 자주 발목을 잡는 건 Array의 COW 복사 비용과 removeFirst()가 O(n)이라는 함정이다.

CS 원리

배열은 원소가 연속된 메모리에 놓이므로 주소 = base + i × stride 계산 한 번으로 임의 인덱스에 O(1)로 닿는다. 끝에 추가할 때 용량이 남으면 O(1)이고, 꽉 차면 더 큰 버퍼를 새로 잡아 전체를 복사하므로 그 한 번은 O(n)이다. 다만 용량을 대개 2배씩 늘리기 때문에 이 비싼 복사가 드물게 일어나 여러 번의 append에 비용을 나누면 평균 O(1)이 된다(amortized). 중간 삽입·삭제는 뒤 원소를 한 칸씩 밀어야 해 O(n)이다.

해시 테이블은 키를 해시 함수로 정수로 바꿔 버킷 인덱스를 정한다. 충돌이 없다면 위치가 바로 나오므로 O(1), 반대로 모든 키가 같은 버킷으로 몰리면 사실상 선형 탐색이 되어 O(n)이다.

iOS에서는

Swift의 Array·Set·Dictionary는 모두 값 타입이며 Copy-on-Write로 구현된다. 저장소를 고유하게 참조 중이면 변경이 제자리에서 일어나지만, 다른 곳과 공유 중이면 첫 변경에서 저장소 전체를 복제하므로 그 순간 O(n)의 숨은 비용이 든다.

Apple 문서append(_:)를 "같은 배열에 여러 번 호출할 때 평균 O(1)"로 명시한다. Set/Dictionary의 조회·삽입·삭제 평균 O(1)은 원소가 Hashable이어야 성립한다. 또한 Swift의 Hasher는 기본적으로 프로세스마다 무작위 시드를 쓰기 때문에(뒤 Q4 참고), 순회 순서가 실행마다 달라질 수 있고 그 순서에 의존하면 안 된다.

연산ArraySet / Dictionary
인덱스/키 조회O(1)평균 O(1) · 최악 O(n)
끝에 추가 (append/insert)amortized O(1)평균 O(1) · 최악 O(n)
중간 삽입·삭제O(n)-
맨 앞 삭제 (removeFirst)O(n)-
값 포함 검사 (contains)O(n) (선형탐색)평균 O(1)

실험 · 도구

reserveCapacity(_:)를 준 배열과 안 준 배열에 대량 append를 돌려 재할당 횟수 차이를 본다. 큐처럼 removeFirst()를 반복하는 코드와 removeLast()를 반복하는 코드를 XCTestmeasure {}로 재면, 앞의 것만 입력 크기 제곱으로 느려지는 걸 관찰할 수 있다. Instruments의 Time Profiler로 어느 프레임이 오래 걸리는지, Allocations로 COW 복사가 튀는지 확인한다.

reserveCapacity와 removeFirst 함정 (Swift)
var a = [Int]()
a.reserveCapacity(10_000)          // 재할당 제거 → append가 진짜 amortized O(1)
for i in 0..<10_000 { a.append(i) }

// ❌ 배열을 큐처럼 쓰면 removeFirst가 매번 O(n) → 전체 O(n^2)
while !a.isEmpty { _ = a.removeFirst() }

프로젝트 적용

⚠️ 흔한 오해

"Set·Dictionary는 항상 O(1)"이라는 말은 평균에 한정된 이야기다. 최악은 O(n)이며(모든 키 충돌·재해시 순간), Array.append도 "항상 O(1)"이 아니라 amortized여서 재할당이 걸린 단일 호출은 O(n)이다.

꼬리 질문

Q2. 시간 복잡도와 공간 복잡도는 무엇이며 실제 입력 크기와 어떻게 연결되는가?

🔑 30초 답변

시간 복잡도는 입력 크기 n이 커질 때 연산 횟수가 어떻게 증가하는지, 공간 복잡도는 추가로 쓰는 메모리가 어떻게 증가하는지를 나타내는 점근적 상한(Big-O)이다. 상수와 저차항을 버리므로 "n이 충분히 클 때의 성장률"만 본다. 그래서 실무에선 갈래가 갈린다. n이 작으면(화면 행 수처럼) 숨겨진 상수(ARC 리테인, COW 복사, 캐시 미스)가 승부를 가르고, n이 무한정 커질 수 있으면(무한 스크롤 피드, 거래 내역) 점근 등급이 지배한다.

CS 원리

Big-O는 상한, Θ는 위아래로 딱 맞는 한계, Ω는 하한을 뜻한다. 점근 표기는 n을 무한대로 보낼 때의 기준이라 상수 배와 낮은 차수 항을 무시한다. 그래서 3n + 100도, 0.5n도 모두 O(n)이다. 성장률만 놓고 보면 O(1) < O(log n) < O(n) < O(n log n) < O(n²) 순서로 나빠진다. 한 번은 비싸지만 평균으로 싸지는 경우를 분할상환(amortized)으로, 최악의 한 번을 worst-case로 따로 말한다. 공간 복잡도는 보통 입력을 뺀 추가(auxiliary) 메모리를 재며, 재귀 호출이 쌓는 스택 깊이도 여기에 포함된다.

iOS에서는

화면에 보이는 행처럼 n이 작을 때는 점근 등급보다 상수가 지배한다.

실험 · 도구

입력 크기를 2배씩 늘리며 measure {}로 시간을 재면, 실측 곡선이 이론 등급과 맞는지 볼 수 있다(2배 늘렸을 때 O(n)은 2배, O(n²)은 4배). Instruments의 Time Profiler로 CPU 시간, Allocations로 메모리 증가, os_signpost로 특정 구간의 지속 시간을 계측한다.

프로젝트 적용

⚠️ 흔한 오해

"Big-O가 작으면 항상 빠르다"는 틀렸다. Big-O는 n이 클 때의 성장률일 뿐, 작은 n에선 숨은 상수가 승부를 가른다. 또 "O(1) = 즉시"도 아니다. 입력과 무관한 상수 시간이라는 뜻이지 0이 아니다.

꼬리 질문

Q3. Stack과 Queue는 어떤 문제에 적합한가?

🔑 30초 답변

Stack은 LIFO(나중에 넣은 걸 먼저 꺼냄)라 가장 최근 것부터 되짚어야 하는 문제 — 되돌리기(undo), 화면 뒤로가기, DFS, 괄호·수식 파싱, 백트래킹 — 에 맞는다. Queue는 FIFO(먼저 넣은 걸 먼저 꺼냄)라 들어온 순서대로 공평하게 처리하는 문제 — BFS, 작업 스케줄링, 버퍼링(생산자-소비자), 이벤트 처리 — 에 맞는다. iOS의 UINavigationController는 화면을 스택으로, DispatchQueue는 작업 제출을 FIFO로 다룬다.

CS 원리

Stack은 한쪽 끝(top)에서만 push/pop/peek 하며 셋 다 O(1)이다. Queue는 뒤에서 enqueue, 앞에서 dequeue 하며 역시 O(1)이다. 선택 기준은 단순하다. "가장 최근 것을 먼저 처리"하고 싶으면 LIFO, "먼저 온 것을 먼저 처리"하고 싶으면 FIFO다.

Stack · LIFO Queue · FIFO D (top) C B A (bottom) push pop A B C D front back enqueue dequeue
Stack은 top 한쪽에서만 넣고 빼 최근 것이 먼저 나온다. Queue는 뒤로 넣고(enqueue) 앞에서 빼(dequeue) 들어온 순서대로 나온다. 두 구조 모두 양 끝 연산은 O(1)이다.

iOS에서는

프로젝트 적용

⚠️ 흔한 오해

"DispatchQueue는 완전한 FIFO다"는 절반만 맞다. 실행 순서까지 FIFO인 건 직렬 큐뿐이다. 동시 큐는 작업을 순서대로 시작하지만 병렬로 돌아 완료 순서는 뒤섞일 수 있다.

꼬리 질문

Q4. Hash table은 어떻게 동작하며 collision은 어떻게 처리하는가?

🔑 30초 답변

해시 테이블은 키를 해시 함수로 정수로 바꾸고, 그 값을 버킷 개수로 나눈 나머지를 인덱스로 삼아 O(1)에 저장·조회한다. 서로 다른 키가 같은 버킷을 가리키는 충돌은 크게 (1) 체이닝(버킷마다 연결 리스트) 또는 (2) 개방 주소법(빈 슬롯을 탐사)으로 푼다. 원소가 많아져 부하율이 임계를 넘으면 더 큰 테이블로 재해시한다. Swift의 Set·Dictionary는 프로세스마다 무작위 시드를 쓰는 Hasher(SipHash)를 사용한다.

CS 원리

이상적인 해시 함수는 키를 버킷에 고르게 흩뿌리고, 입력이 조금만 달라도 결과가 확 바뀐다(애벌런치). 그래도 버킷 수는 유한하므로 충돌은 반드시 생긴다. 처리 방식은 두 갈래다.

부하율(원소 수 ÷ 버킷 수)이 임계를 넘으면 버킷을 키우고 전부 다시 배치한다. 이 재해시는 O(n)이지만 드물게 일어나 amortized로 흡수된다.

key A key B hash(A) % 8 = 3 hash(B) % 8 = 3 선형 탐사 +1 A B 0 1 2 3 4 5 6 7
A와 B가 모두 버킷 3으로 해싱되어 충돌한다. 개방 주소법은 3이 차 있으면 다음 슬롯(4)으로 선형 탐사해 B를 넣는다. 조회 때도 같은 규칙으로 따라간다.

iOS에서는

Swift의 Set·DictionaryHasher(SipHash 계열)로 해시를 만든다. 이 해시는 기본적으로 프로세스가 실행될 때마다 무작위 시드를 쓴다(SE-0206). 그래서 (1) 악의적으로 충돌을 몰아 넣어 O(n)으로 떨어뜨리는 hash-flooding 공격을 막고, (2) 그 대가로 순회 순서가 실행마다 달라진다. 표준 라이브러리의 네이티브 해시 저장소는 구현 세부로는 개방 주소법을 쓰지만, 이는 공개 API 보장이 아니라 내부 구현이다.

Hashable 규약이 핵심이다. 같으면 해시도 같아야 한다(a == ba.hashValue == b.hashValue). 이 규약이 깨지면 조회가 실패한다. 반대로 해시가 같아도 값이 다를 수 있고(정상적인 충돌), 최종 판정은 항상 ==가 한다. 키를 넣은 뒤 해시에 영향을 주는 속성을 바꾸면 그 키를 다시 찾지 못하게 된다.

잘못된 Hashable — 모든 키가 충돌해 O(n)으로 퇴화 (Swift)
struct BadKey: Hashable {
    let id: Int
    func hash(into hasher: inout Hasher) { }               // 아무것도 안 섞음 → 항상 같은 해시
    static func == (l: BadKey, r: BadKey) -> Bool { l.id == r.id }
}
// 규약(== 이면 hash 같음)은 지켰지만, 모든 키가 한 버킷에 몰려
// Set/Dictionary 조회가 평균 O(1)이 아니라 O(n)으로 떨어진다.

실험 · 도구

BadKey처럼 hash(into:)를 비운 타입과, 컴파일러가 자동 합성한 정상 타입을 각각 Set에 대량 삽입·조회해 시간을 비교하면 O(n) 퇴화를 눈으로 확인할 수 있다. 테스트에서 순회 순서를 고정하고 싶으면 환경변수 SWIFT_DETERMINISTIC_HASHING=1로 시드를 끌 수 있다(테스트 전용, 프로덕션 금지).

프로젝트 적용

⚠️ 흔한 오해

"해시값이 같으면 같은 객체다"는 틀렸다. 충돌은 정상이며 최종 동일성은 ==가 판정한다. 반대로 "==인데 해시가 달라도 괜찮다"도 틀렸다. 그건 규약 위반이라 넣은 값을 못 찾는 버그가 된다.

꼬리 질문

Q5. Binary search를 적용할 수 있는 전제 조건은 무엇인가?

🔑 30초 답변

이진탐색의 전제는 둘이다. (1) 데이터가 검색에 쓰는 것과 같은 기준으로 정렬돼 있어야 하고, (2) 임의 인덱스 접근이 O(1)이어야(RandomAccessCollection) 진짜 O(log n)이 나온다. 연결 리스트처럼 임의 접근이 O(n)이면 이진탐색을 해도 전체가 O(n)으로 무너진다. Swift 표준 라이브러리엔 이진탐색 함수가 없어 직접 구현하거나 swift-algorithms의 partitioningIndex를 쓴다.

CS 원리

이진탐색은 정렬된 구간의 가운데를 보고 목표와 비교해 절반을 통째로 버린다. 매번 후보가 반으로 줄어 비교는 O(log n)이다. 성립 조건은 정렬 순서와 비교 술어가 일치하는 것이다(이름순으로 정렬해 두고 날짜로 이진탐색하면 안 된다). 중복이 있으면 "처음 위치"와 "마지막 위치"를 구분해 찾아야 한다(lower/upper bound). 그리고 가운데 계산은 (lo + hi) / 2 대신 lo + (hi - lo) / 2로 오버플로를 피한다.

lo mid hi 1 3 5 7 9 11 13 15 17 arr[mid]=9 < 13 → 왼쪽 절반 버림 lo mid hi 1 3 5 7 9 11 13 15 17 arr[mid]=13 = target ✓
정렬된 9개 원소에서 13을 찾는다. 첫 mid(9)가 목표보다 작으니 왼쪽 절반을 버리고, 남은 구간의 mid에서 13을 찾는다. 후보가 매번 절반으로 준다.

iOS에서는

Swift 표준 라이브러리에는 binarySearch가 없다(주목할 만한 공백). 직접 구현하거나 swift-algorithmspartitioningIndex(where:)를 쓴다. ArrayRandomAccessCollection이라 인덱스 이동이 O(1)이지만, Collection만 만족하는 타입에선 index(_:offsetBy:)가 O(k)라 이진탐색의 이점이 사라진다. 또 Swift는 Int 오버플로 시 조용히 넘어가지 않고 트랩(크래시)한다. 다만 64비트에서 배열 인덱스 합(lo + hi)이 넘칠 일은 사실상 없고, 큰 정수 값 구간을 이진탐색할 때 (lo + hi) / 2가 실제로 위험해진다. 어느 쪽이든 lo + (hi - lo) / 2가 안전한 습관이다.

오버플로에 안전한 이진탐색 (Swift)
func binarySearch<T: Comparable>(_ a: [T], _ target: T) -> Int? {
    var lo = 0, hi = a.count - 1
    while lo <= hi {
        let mid = lo + (hi - lo) / 2       // (lo+hi)/2 는 오버플로 위험(Swift는 트랩)
        if a[mid] == target { return mid }
        else if a[mid] < target { lo = mid + 1 }   // 정렬 기준과 같은 비교여야 함
        else { hi = mid - 1 }
    }
    return nil
}

실험 · 도구

정렬 안 된 배열에 이진탐색을 돌리면 크래시가 아니라 조용히 틀린 결과(있는 값을 없다고 하거나 그 반대)를 낸다는 걸 확인한다. 반복 횟수를 로그로 세면 정렬된 n개에서 ⌊log₂ n⌋ + 1번을 넘지 않는다.

프로젝트 적용

⚠️ 흔한 오해

"정렬만 돼 있으면 어디서든 O(log n)"은 틀렸다. 임의 접근이 O(1)이 아니면(연결 리스트) O(n)이 된다. 또 "이진탐색은 항상 선형탐색보다 빠르다"도 아니다. 작은 n이나 캐시 지역성이 좋은 경우 선형탐색이 빠를 수 있고, 정렬 비용까지 포함하면 단 한 번 조회엔 선형이 낫다.

꼬리 질문

Q6. 정렬 알고리즘의 안정성은 UI 데이터에서 언제 중요한가?

🔑 30초 답변

안정 정렬(stable sort)은 정렬 키가 같은 원소들의 원래 상대 순서를 보존한다. UI에서 두 경우에 중요하다. (1) 다단계 정렬 — 2차 키로 먼저 정렬한 뒤 1차 키로 다시 정렬할 때 안정성이 없으면 동점 그룹 안의 순서가 뒤엉킨다. (2) diffable 스냅샷 갱신 — 같은 값끼리 순서가 매번 달라지면 셀이 이유 없이 자리를 바꾸는 애니메이션과 깜빡임이 생긴다. Swift 5부터 표준 sort는 안정성이 보장된다.

CS 원리

안정 정렬은 같은 키를 가진 원소들의 입력 순서를 그대로 둔다. 그래서 다단계 정렬을 "덜 중요한 키 먼저, 더 중요한 키 나중"으로 안정 정렬을 연쇄하면 원하는 다중 기준이 완성된다. 병합정렬과 Timsort는 안정적이고, 일반적인 퀵정렬·힙정렬은 불안정하다.

입력(이름순 정렬 상태)안정 정렬 → 점수순불안정 정렬 → 점수순
Ann·90 / Bea·90 / Cy·80Cy·80, Ann·90, Bea·90 (동점 이름순 유지)Cy·80, Bea·90, Ann·90 (순서 뒤섞임 가능)

iOS에서는

Apple 문서는 현재 sort(by:)/sorted(by:)에 대해 "정렬 알고리즘은 안정성이 보장된다"고 명시한다. 이 보장은 Swift 5에서 도입됐고, 그 전에는 introsort 기반이라 불안정했다. NSDiffableDataSourceSnapshot으로 목록을 갱신할 때는 항목 순서가 곧 애니메이션을 결정한다. 동점 원소의 순서가 스냅샷마다 흔들리면 실제로는 변한 게 없는데도 move 애니메이션이 남발된다. 즉 정렬 안정성이 애니메이션 안정성으로 직결된다.

다단계 정렬 — 안정성 연쇄 vs 튜플 비교 (Swift)
// 방법1: 안정 정렬 연쇄 — 덜 중요한 키(이름) 먼저, 중요한 키(날짜) 나중
items.sort { $0.name < $1.name }
items.sort { $0.date < $1.date }   // 같은 날짜끼리 이름순 유지(안정성 보장에 의존)

// 방법2: 튜플 비교 — tie-breaker를 명시(결정적, 안정성에 덜 의존)
items.sort { ($0.date, $0.name) < ($1.date, $1.name) }

프로젝트 적용

⚠️ 흔한 오해

"정렬은 다 똑같고 전부 안정적이다"는 틀렸다. 힙정렬·일반 퀵정렬은 불안정하다. 또 "Swift sort는 원래부터 안정적이었다"도 틀렸다. 안정성은 Swift 5에서 보장됐고 그 전 버전은 그렇지 않았다.

꼬리 질문

Q7. Pagination 데이터를 중복 없이 효율적으로 병합하려면 어떻게 하는가?

🔑 30초 답변

각 아이템의 안정적 고유 ID를 기준으로 Set<ID>에 "이미 본 것"을 담고, 새 페이지에서 처음 보는 ID만 추가하면 전체 O(n)에 중복 없이 병합된다. Swift에선 seen.insert(id).inserted가 O(1) 판정과 삽입을 한 번에 해 준다. 더 근본적으로는 offset 기반 대신 cursor(keyset) 페이지네이션을 쓰면 목록이 중간에 바뀌어도 중복·누락이 생기지 않는다.

CS 원리

병합의 핵심은 "이미 봤는지"를 싸게 판정하는 것이다. 매번 배열 전체를 훑어 contains로 확인하면 O(n) × n = O(n²)이 된다. 해시 집합으로 판정하면 O(1)이라 전체가 O(n)으로 떨어진다. 두 페이지가 같은 키로 정렬돼 있다면, 병합정렬의 merge 단계처럼 앞에서부터 훑으며 O(n+m)에 합치고 동일 키를 걸러낼 수도 있다.

새 페이지 id 7 id 3 id 9 seen : Set<ID> { 1, 3, 5 } insert(id).inserted? 결과(추가) id 7 ✓ id 9 ✓ id 3 은 중복 → 버림
새 페이지의 각 id를 seen 집합과 대조한다. 처음 본 7·9만 결과에 추가되고, 이미 있던 3은 버려진다. 판정·삽입이 O(1)이라 병합 전체가 O(n)이다.

iOS에서는

Set.insert(_:)(inserted: Bool, memberAfterInsert)를 돌려주므로, 삽입 성공 여부로 "처음 본 것"을 바로 가릴 수 있다. 순서를 보존해야 하면 swift-collectionsOrderedSet/OrderedDictionary가 삽입 순서 유지 + O(1) 유일성을 함께 준다. Diffable data source는 item identifier가 유일해야 하며, 중복 identifier를 append하면 런타임 경고나 크래시가 난다. 그래서 클라이언트 dedup은 선택이 아니라 필수다. ID는 Identifiable의 서버 id처럼 안정적이고 유일한 값이어야 하며, 배열 인덱스나 객체 주소를 쓰면 안 된다.

Set.insert로 O(n) 중복 제거 병합 (Swift)
var items: [Feed] = []
var seen = Set<Feed.ID>()

func merge(_ page: [Feed]) {
    for item in page where seen.insert(item.id).inserted {
        items.append(item)          // 처음 본 id만 추가 → 페이지 크기에 비례(O(page))
    }
}

실험 · 도구

같은 페이지를 일부러 두 번 주입한 뒤 diffable 스냅샷을 apply 해, dedup이 없을 때 콘솔에 뜨는 중복 identifier 경고와 dedup을 넣은 뒤의 정상 동작을 비교한다. contains로 매번 전체 배열을 훑는 O(n²) 버전과 Set 기반 O(n) 버전을 measure {}로 재면 페이지가 쌓일수록 차이가 벌어진다.

프로젝트 적용

⚠️ 흔한 오해

"offset 페이지네이션이면 클라이언트 dedup은 필요 없다"는 틀렸다. 목록에 새 글이 끼어들면 같은 아이템이 두 페이지에 걸치거나 건너뛰어진다. 또 "Set에 넣으면 순서가 유지된다"도 틀렸다. Set은 순서가 없으므로, 순서가 필요하면 OrderedSet이나 별도 배열을 함께 쓴다.

꼬리 질문

출처 · 참고자료