포스트

C++의 vector를 어떻게 이해해야 하는가

vector는 C++에서 가장 자주 쓰는 컨테이너 중 하나다. 개념적으로는 동적 배열(dynamic array)에 가깝다.


vector의 핵심

  • 연속된 메모리 공간을 사용한다
  • 크기를 동적으로 늘릴 수 있다
  • 인덱스 접근이 빠르다

즉, 배열의 장점과 동적 확장의 편의성을 함께 가져가는 구조다.

cppreference는 vector의 원소가 연속으로 저장되므로 반복자뿐 아니라 일반 포인터에 오프셋을 더하는 방식으로도 접근할 수 있고, vector 원소에 대한 포인터를 배열 원소 포인터를 기대하는 함수에 넘길 수 있다고 설명한다(cppreference: std::vector). C 스타일 API에 v.data()를 넘길 수 있는 이유다.


size와 capacity

vector를 이해하는 데 가장 중요한 구분은 크기(size)와 용량(capacity)이다.

  • size(): 지금 들어 있는 원소 수
  • capacity(): 재할당 없이 담을 수 있는 원소 수

vector는 원소를 넣을 때마다 메모리를 새로 잡지 않는다. 앞으로 늘어날 것을 대비해 여유 공간을 미리 잡아 두고, 그 여유가 다 찼을 때만 더 큰 공간으로 옮긴다. 그래서 같은 원소 수의 정적 배열보다 메모리를 더 쓰는 경우가 많다.

1
2
3
4
5
6
7
8
9
10
11
12
#include <iostream>
#include <vector>
using namespace std;

int main() {
    vector<int> v;
    for (int i = 0; i < 10; ++i) {
        v.push_back(i);
        cout << "size=" << v.size() << " capacity=" << v.capacity() << '\n';
    }
    return 0;
}

이 코드를 돌려 보면 size는 1씩 늘지만 capacity는 가끔씩 한꺼번에 커진다. 한 번에 얼마나 커지는지는 표준 라이브러리 구현에 따라 다를 수 있으므로, 출력되는 capacity 값은 환경마다 다를 수 있다.


연산별 비용

cppreference가 정리한 복잡도는 다음과 같다.

연산복잡도
임의 접근 (v[i], v.at(i))O(1)
맨 뒤 삽입, 삭제 (push_back, pop_back)분할 상환 O(1)
중간 삽입, 삭제 (insert, erase)끝까지의 거리에 비례하는 O(n)

맨 뒤 삽입이 분할 상환 O(1)인 이유는 위의 capacity 때문이다. 대부분의 push_back은 남은 공간에 원소를 하나 놓기만 한다. 공간이 부족한 순간에만 전체 원소를 새 공간으로 옮기는 O(n) 작업이 생기고, 이 비용을 전체 삽입 횟수에 나눠 보면 한 번당 상수가 된다.


왜 자주 쓰이는가

대부분의 문제 풀이와 일반 애플리케이션 코드에서:

  • 순차 저장
  • 인덱스 접근
  • 뒤에 원소 추가

가 많기 때문이다. 이런 경우 vector는 기본 선택이 되기 쉽다.

문제 풀이에서 그래프의 인접 리스트를 vector<int> adj[N]처럼 표현하는 것도 같은 이유다. 각 정점의 이웃을 뒤에 계속 추가하고, 탐색할 때는 앞에서부터 순서대로 읽는다.


주의할 점

크기가 늘어날 때 내부 버퍼를 재할당하면서 기존 원소를 복사할 수 있다. 따라서 참조나 포인터가 무효화될 수 있다는 점을 알아야 한다.

cppreference의 push_back 설명은 이를 정확히 적는다. 연산 후 새 size()가 이전 capacity()보다 크면 재할당이 일어나고, 이때 end()를 포함한 모든 반복자와 원소에 대한 모든 참조가 무효화된다. 그렇지 않으면 end() 반복자만 무효화된다(cppreference: push_back).

이 규칙 때문에 아래 코드는 위험하다.

1
2
3
4
vector<int> v = {1, 2, 3};
int& first = v[0];
v.push_back(4);      // 재할당이 일어나면 first는 해제된 메모리를 가리킨다
cout << first;       // 정의되지 않은 동작이 될 수 있다

반복문 안에서 같은 vector에 push_back하면서 반복자로 순회하는 코드도 같은 이유로 깨질 수 있다. erase도 비슷하다. 지운 지점과 그 뒤의 반복자, 참조가 무효화된다(cppreference: erase).


재할당을 줄이는 방법: reserve

넣을 원소 수를 미리 안다면 reserve로 용량을 먼저 확보한다. cppreference는 원소 수를 미리 알 때 reserve()로 재할당을 없앨 수 있다고 설명한다.

1
2
3
4
5
vector<int> v;
v.reserve(n);              // capacity를 n 이상으로 확보
for (int i = 0; i < n; ++i) {
    v.push_back(i);        // n개까지는 재할당이 일어나지 않는다
}

reserve(new_cap)는 new_cap이 현재 용량보다 클 때만 새 공간을 잡고, 그때는 모든 반복자와 참조가 무효화된다. 현재 용량 이하라면 아무것도 하지 않는다(cppreference: reserve). reserve는 용량만 늘리고 크기는 그대로라는 점도 헷갈리기 쉽다. reserve(n) 직후에 v[0]에 접근하면 안 된다. 크기까지 n으로 만들려면 resize(n)을 쓴다.


operator[]와 at

  • v[i]: 범위 검사를 하지 않는다. 존재하지 않는 원소에 접근하면 정의되지 않은 동작이다.
  • v.at(i): 범위 검사를 하고, 범위를 벗어나면 std::out_of_range 예외를 던진다.

문제 풀이처럼 성능이 중요하고 인덱스가 확실한 곳에서는 []를, 입력에 따라 인덱스가 범위를 벗어날 수 있는 곳에서는 at을 쓰면 버그를 일찍 발견할 수 있다.


정리

vector는 C++에서의 기본 동적 배열이다. 빠른 인덱스 접근과 유연한 크기 조절이 장점이지만, 재할당과 참조 무효화 특성을 함께 이해하고 써야 한다.

참고한 자료외부 출처 6

외부 출처

이 글은 저작권자의 CC BY 4.0 라이선스를 따릅니다.

변경이력

2번 수정

  1. docs(posts): separate the sections of every post with a thematic break
  2. docs(posts): fill out thin notes, book chapters and solutions

댓글

아직 댓글이 없습니다