Overview
- 응용프로그램에서 동작성능 & 안정성 확보를 위해 적절한 data stacture를 선택하는 것이 매우 중요하다.
- 이번 장에서는 linear data structures를 소개하고 있다.
- 자료구조를 잘 이해하고 있으면, 성능 향상, 표준화, 가독성, 유지 보수 등의 관점에서 유리하게 데이터를 관리할 수 있다.
연속된 자료 구조 & 연결된 자료 구조
- 어떤 자료 구조를 선택할 것인가에 대한 적합한 지표로 time complexity가 있다.
- 시간 복잡도(time complexity)는 특정 작업 수행 시 걸리는 시간을 데이터 크기에 대한 수식으로 표현하는 방식이다.
- 데이터 크기가 변하면 연산 시간이 어떻게 변하는지를 보여준다.
연속된 자료 구조
- 모든 원소를 단일 메모리 chunk에 저장한다.
- 모든 원소가 같은 Type이면 모든 원소는 같은 크기의 메모리를 사용하므로 sizeof(type)으로 표시될 수 있다.
- 해당 자료 구조에서는 배열 전체 크기에 상관없이 원소에 곧바로 접근할 수 있다.
- BA(Base Address) + i*sizeof(type), 해당 수식으로 곧바로 접근
- 데이터 접근 시간은 항상 일정하므로 Big-O 표기법으로 나타내면 O(1)로 표시할 수 있다.
- 배열의 유형
- static array
- 선언 블록 끝나면 소멸 -> stack 메모리에 할당되기 때문
int arr[size]
- dynamic array
- 생성 시점과 해제 시점을 자유롭게 결정 -> Heap 메모리에 할당 되기 때문
int* arr = new int[size]
- 배열은 하나의 원소 접근 시 인접 원소 몇개도 함께 cache로 가져온다. (cache locality)
- 다시 주변 원소 접근시 캐시로 접근하므로 매우 빠른 동작 발생
- 배열은 연속된 원소에 매우 빠르게 접근할 수 있다는 큰 장점이 생긴다.
연결된 자료 구조
- Node 라고 하는 여러 메모리 chunk에 데이터를 저장
- 서로 다른 메모리 위치에 데이터가 저장된다.
- linked list
- linked list의 각각 노드는 저장할 data, 다음 node를 가리키는 pointer를 가지고 있다.
- 맨 마지막 노드는 NULL을 next node pointer로 갖고 있음으로서 끝을 의미하게 해준다.
- i 번째 원소 접근 시 linked list 내부를 연속적으로 이동하므로 O(n) 이다.
- 새로운 원소 삽입 시 New Node 생성, 각 Node의 next pointer 수정
- 기존 원소 제거 시 더 이상 연결 리스트에 연결되어 있지 않도록 next pointer를 수정
- 이후 메모리 할당 해제 및 다른 적절한 처리를 수행
- 연결 리스트는 배열과 달리 메모리 영역이 달라 cache locality가 좋지 않다.
- 연결 리스트에서 모든 원소를 차례대로 방문하는 경우 이론적으로 시간 복잡도는 동일하나 실제로는 연결 리스트 성능이 조금 더 떨어진다.
연속 vs 연결
- 연속 (array)
- 임의 접근 O(1)
- 맨 뒤 원소 삽입 O(1)
- 중간 원소 삽입 O(n)
- cache locality Good
- 연결 (linked list)
- 임의 접근 O(n)
- 맨 뒤 원소 삽입 O(1)
- 중간 원소 삽입 O(1)
- cache locality Bad
- C++ 에서는 std::array, std::vector, std::List 같은 다양한 자료구조 클래스를 제공하고 있다.
C style array의 제약사항
- 메모리 할당과 해제를 수동으로 처리
- [] 연산자에서 배열 크기보다 큰 원소를 참조하는 것을 검사 못한다.
- segmentation fault or memory 손상으로 이어진다.
- 배열을 중첩해서 사용하면, 문법이 매우 복잡
- deep copy가 기본적으로 동작하지 않는다.
- std::array는 위 문제를 회피할 수 있도록 만들었다.
std::array
std::array<int, 10> arr1;
- 원소의 type, 배열 size를 매개변수로 하는 클래스 template이다.
for (int i = 0; i < arr.size(); i++)
std::cout << arr[i] <<" ";
- [] 연산자 제공
- 빠른 동작을 위해 전달 index 값이 배열 size보다 큰지는 검사하지 않는다.
try
{
std::cout << arr.at(3) << std::endl;
}
catch (const std::out_of_range& ex)
{
std::cerr << ex.what() << std::endl;
}
- at(index)도 제공 ([]과 같은 기능)
- 인자로 전달된 index 값이 유효하지 않으면 std::out_of_range 예외 발생
- 해당 메서드 사용 시, 예외에 적절하게 처리 가능하다.
- 유효하지 않은 index에 접근할 수 있는 상황이면 예외 처리 코드를 만들 수 있다.
template <size_t N>
void print(const std::array<int, N>& arr);
- 함수 매개변수에서 size를 지정한 array를 선언하면, 다른 size 인 array 삽입 시 에러가 난다.
- 복잡하게 template으로 만들어줘야 된다.
- 기본적으로 array 객체 전달 시 새로운 배열에 모든 원소가 deep copy 되므로 reference를 사용하면 좋다.
for (auto it = arr.begin(); it != arr.end(); it++)
{
auto element = (*it);
std::cout << element << ' ';
}
- 반복자, begin(), end()라는 멤버 변수를 제공하고 있으며 가장 첫 번째 원소, 마지막 원소의 위치를 반환하고 있다. 범위 기반 for문은 반복자 덕분에 사용 가능하다.
- const_iterator : 일반 iterator의 const version
- const로 선언한 array에 iterator를 반환 받으면 const_iterator가 나온다.
- reverse_iterator : 역방향 이동 iterator
- 이 반복자를 ++과 같은 연산자와 함께사용하면 반대 방향으로 이동하게 된다.
- front()
- back()
- data()
- 실제 데이터 메모리 버퍼를 가리키는 pointer 반환 (예전 스타일 함수 사용시 유용)
- 배열은 관계 연산자 사용 시 두 배열의 크기가 같아야한다.
std::vector
- array의 단점
- size는 컴파일 시간에 결정되어야 하는 상수
- 메모리 할당 방법 변경 불가
- 대부분 응용프로그램에서 데이터는 동적이며 고정 크기 X
- vector는 가변 크기 배열이다.
std::vector<int> vec;
std::vector<int> vec = { 1, 2, 3, 4 };
# 크기가 10
std::vector<int> vec(10);
크기가 10, 모든 원소가 5로 초기화
std::vector<int> vec(10, 5);
- 원소 크기를 지정하지 않고 선언 가능
- 벡터 크기 명시적 지정 없을 경우 컴파일러에 따른 용량을 갖는 벡터 생성
원소 추가
- push_back()
- 맨 마지막에 새로운 원소 추가
- vector의 size가 모두 차면 2*size 만큼 메모리 새로 할당
- 새로 할당한 메모리로 기존 원소 전부 복사/이동
- 데이터 포인터를 새로 할당한 메모리 주소로 지정
- size가 모두 찬 상태의 push_back()이면 위 동작 이후 맨 뒤에 원소 삽입
- 이 경우, O(n)
- insert()
- 원하는 위치에 원소를 추가
- 지정 반복자 위치 다음 모든 원소를 이동 시키는 연산 필요
- 필요할 경우 메모리 새로 할당하는 작업도 수행
- O(n)
vec.insert(vec.begin(), 0);
- vector는 push_front()를 지원하지 않아 insert를 사용해야 될 수도 있다.
vec.insert(find(vec.begin(), vec.end(), 1), 4);
- 1 앞에 4추가하는 예시다.
- push_back, insert는 추가할 원소를 먼저 임시로 생성한 후 벡터 버퍼 내부 위치로 복사 또는 이동을 수행한다.
- emplace_back() or emplace()
- 새 원소 위치에서 곧바로 객체가 생성되도록 최적화
원소 제거
- pop_back()
- 맨 마지막 원소 제거
- vector size - 1
- erase()
- 반복자 하나를 받아 해당 위치 원소를 제거하는 방법, 시작과 끝을 나타내는 반복자를 받아 시작 부터 끝 바로 앞 원소까지 제거 하는 식으로 오버로딩 되어 있다.
- 특정 위치 원소 삭제 후 뒤 쪽 원소들을 이동해야한다.
유용한 함수
- clear() : 모든 원소 제거하여 완전히 비어있는 벡터로 만든다.
- reserve(capacity)
- 벡터에서 사용할 용량 지정
- 매개변수 값이 현재 용량보다 같거나 작으면 아무 동작하지 않고, 해당 용량보다 커야 동작한다.
- shrink_to_fit() : 여분의 메모리 공간을 해제하는 용도로 사용
- 함수 호출 시 벡터 용량이 벡터 크기와 같게 설정
- 벡터 크기가 더이상 변경되지 않을 때 사용하면 유용
할당자
- template 매개 변수에서 data type 다음에 allocator 전달 가능
- 사용자 정의 할당자를 사용하려면 정해진 interface를 따라야한다.
- 할당자는 allocate(), deallocate(), construct(), destoy() 등의 함수를 제공해야한다.
- 자동 메모리 관리 및 자체적인 메모리풀을 사용하는 경우 유용하다.
- 평균적으로 벡터의 성능은 배열에 비해 그리 나쁘지 않다. 성능과 유연성으로 널리 사용되는 STL 컨테이너다.
std::forward_list
- 연속된 자료 구조에서는 데이터 중간에 자료를 추가하거나 삭제하는 작업이 매우 비효율적
- forward_list는 성능 유지를 위해 전체 리스트 크기 or 첫 번쨰 원소를 제외한 나머지 원소에 직접 접근하는 기능을 제공하지 않는다.
- 원소 삽입, 삭제, 순서 뒤집기, 분할을 위한 기능 등은 제공한다.
- 연결 리스트의 메모리 사용량, 성능에 영향 X
원소 삽입
std::forward_list<int> fwd_list = {1, 2, 3};
fwd_list.push_front(0);
auto it = fwd_list.begin();
fwd_list.insert_after(it, 5);
- push_front()
- forward_list는 마지막 원소에 직접 접근이 어려우므로 push_back()이 제공되지 않는다.
- insert_after()
- 특정 위치에 원소 삽입 시 사용하는 함수다.
- 새로운 원소 삽입 후 해당 위치 앞에 있는 원소의 next 포인트를 수정해야해서 해당 함수이름이 저따구다.
- 삽입 후 원소 이동이 필요없는 자료구조 이므로 배열 기반 구조보다 더 빠르게 작동한다.
- 즉, 삽입 함수는 원소 크기에 영향을 받지 않는다. O(1)
- emplace_front(), emplace_after() 함수도 제공한다.
- 앞에 배열에서의 설명과 마찬가지로 추갖거인 복사/이동을 거치지 않아 더 효율적이다.
원소 삭제
std::foward_list<int> fwd_list = {1, 2, 3, 4, 5};
fwd_list.pop_front();
auto it = fwd_list.begin();
fwd_list.erase_after(it);
fwd_list.erease_after(it, fwd_list.end());
- pop_front()
- 맨 처음 원소 제거
- 원소 이동 필요 X -> O(1)
- erase_after()
- 특정 원소를 가리키는 반복자를 인자로 받아 다음 위치 원소를 삭제
기타 멤버 함수
std::forward_list<citizen> citizens = {{"Kim", 22}, {"Lee", 25}};
citizens.revmoe_if([](const citizen &c) {
return (c.age < 19);
}
- rmove()
- 삭제할 원소 값 하나를 매개변수로 받는다.
- 데이터 타입에 정의된 등호 연산자를 사용해 전달된 값과 일치하는 모든 원소를 찾아 삭제한다.
- remove_if()
- remove()는 등호 연산에만 의존하므로 좀더 유연한 메서드가 필요
- remove_if()는 뎅터 원소 값 하나를 인자로 받아 bool값을 반환하는 조건자 함수를 인자로 받는다.
- 해당 조건자가 true를 반환하는 모든 데이터 원소를 리스트에서 삭제한다.
- lambda expression 사용 가능
- 위 함수들은 원소 값을 검사해 삭제하는 멤버함수다.
std::forward_list<int> list = {23, 0, 1, -3};
list1.sort()
list1.sort(std::greater<int>());
- sort() : 원소 데이터 정렬
- array 자료들은 std::sort(first_iter, last_iter) 함수를 통해 원소를 정렬할 수 있다. 하지만 연결 리스트 같은 자료 구조는 특정 원소에 임의 접근이 불가능해 std::sort() 함수를 사용할 수는 없다.
- forawrd_list에서 제공하는 sort()는 < 연산자 기반, 다른 하나는 매개 변수로 전달된 comparator(비교자)를 사용하는 형태로 지원한다.
- 비교자는 default로 std::less<value_type> 이다. -> 첫 번쨰 인자가 두 번쨰 보다 작으면 true를 반환하는 바교자 (< 연산자 래퍼임)
- O(nlogn)
std::forward_list<int> list1 = { 2, 54, 1 };
list1.reverse();
list1.unique();
- reverse() : 저장된 원소의 순서를 역순으로 변경
- unique() : 홀로 나타나는 원소는 놔두고, 인접해서 중복되는 원소에 대해 첫 번째만 남겨두고 나머지는 제거한다.
- 인자가 없는 형태, 등호 연산자를 사용해 같은지 판단하는 형태 두 가지 형태로 제공된다.
- 시간 복잡도가 선형인데, 이는 각각 우너소를 나머지 원소 전체와 비교하는 형태로 동작하지 않음을 암시한다.
- 따라서 유일한 원소들만 남게 만들려면 먼저 리스트를 정렬한 후 unique() 함수를 사용해야된다.
반복자
- 배열과 벡터에서 사용되는 반복자는 기능 면에서 가장 유연한데, 연속된 자료 구조를 사용하므로 특정 위치의 원소에 곧바로 접근할 수 있기때문이다.
- forward:list 의 경우 기본적으로 역방향 이동 기능을 제공하지 않는다.
- 이전 노드로 이동하려면 처음 노드부터 다시해서 찾아가야됨
- 해당 반복자는 증가 연산만 가능하고, 이런 반복자를 forward iterator라고 한다.
- 반복자 타입에 따라 사용할 수 있는 함수 중 advance(), next(), prev()가 있다.
- advance() : 반복자와 거리 값을 인자로 받고 반복자를 거리 만큼 증가시킨다.
- next(), prev() : 반복자와 거리 값을 받아 해당 반복자에서 지정한 거리만큼 떨어진 위치의 반복자를 반환한다.
- 해당 함수들은 해당 반복자가 지원하는 경우에만 동작한다.
- 순방향 반복자에 대해서 prev() 함수 호출 시 에러 발생
- 해당 함수들은 반복자 타입에따라 동작 시간이 결정된다.
advance()
auto it = vec.begin();
it += 8;
advance(it, -3);
auto it = fwd.begin();
advance(it, 5);
- fwd iterator의 경우 선형 시간으로 동작한다. O(n)
- fwd iterator의 경우 -5 등의 음수 값을 사용하면 Error
it += 2;
사용 시, operator+= 에 대해 지원하지 않는다는 Error
std::list
- std::forward_list는 단순 linked list의 래퍼 형태였다.
- 유용한 기능 중 리스트 끝에 원소 추가, 역방향이동, 크기 반환 등을 제공하지 않았다.
- 위를 해결하기 위해 std::list는 이중 연결 리스트(doubly-linked list)를 제공한다.
strcut doubly_linked_list_node
{
int data;
doubly_linked_list_node* next;
doubly_linked_list_node* prev;
}
- 이중 연결 리스트 노드는 이전 노드도 가리키는 포인터가 추가로 있다.
- 메모리가 forward_list 대비 약간 증가
- 이 포인터를 욕해 역방향 이동 지원
- push_back(), size() 함수를 지원
멤버함수
- insert(), emplace(), push_back(), emplace_back(), pop_back() 제공
std::list<int> list1 = {1, 2, 3, 4, 5};
list1.push_back(6);
list1.insert(next(list1.begin()), 0)); 맨 처음원소 다음에 0 추가
list1.insert(list1.end(), 7);
list1.pop_back();
- fwd_list와 이론적으로 함수 시간 복잡도는 같지만, std::list에서 좀 더 많은 연산이 필요
- 두 개의 포인터를 갖고 있으면서 삽입, 삭제 연산 시 두 개의 포인트럴 관리해야하므로 대략 두배 연산을 수행해야하는 것을 추론할 수 있다.
양방향 반복자
- 배열 기반의 임의 접근 반복자와 fwd_list 기반 순방향 반복자의 중간 정도 유연성을 가지고 있다.
- 역방향 이동 가능(reverse iterator 사용 가능)
- 임의 접근 반복자 처럼 특정 위치로 한 번에 이동하는 것은 불가능
- 어느 방향으로 움직일 수 있는 반복자여서 양방향 반복자라고 불릴 뿐이다.
반복자 무효화
auto v_it = vec.begin() + 4;
vec.insert(vec.begin() + 2, 0)
- vec의 경우 push_back은 새로 메모리 공간을 할당하고 기존 모든 원소를 새 메모리 공간으로 복사하는 동작이 발생하는 경우가 있다.
- 이때, 기존 사용하던 모든 반복자, 포인터, 참조는 모두 무효화된다. -> 버그 및 에러 발생
- 위 코드의 경우 insert() 함수 수행 시 메모리 재할당이 필요한데, 원소 삽입 위치 이후 원소를 모두 이동해야하면서 이 위치를 가리키는 반복자는 무효화된다.
auto list_it = next(lst.begin(), 4);
list_it.insert(next(lst.begin(), 2), 0);
- linked list 류 에서는 삽입 또는 삭제 동작에서 원소 이동이 발생하지 않으므로 반복자가 무효화 되지 않는다.
- std::list는 sts::forward_list 보다 더 많은 함수를 제공하면서 시간 복잡도도 대부분 빠르다. 그래서 더 많이 사용된다. 메모리 또는 성능 최적화 하고 싶은 경우에만 forard_list를 제한적으로 사용하면 될 것 같다.
std::deque
- 배열 기반, 연결 리스트 기반이 섞여있는 형태
- 벡터는 가변 길이 배열이면서 push_froint(), pop_front() 등 비용이 많이 드는 경우가 있다. 이러한 단점을 극복할 수 있는 자료형이다.
구조
- C++ 표준
- push_front(), pop_front(), push_back(), pop_back() 동작이 O(1) 으로 동작
- 모든 원소에 대해 임의 접근 동작 O(1) 으로 동작
- deque 중간에 원소 삽입 및 삭제는 O(n) 으로 동작, 실제로 최대 n/2 단계 동작
- deque은 양방향으로 매우 빠른 확장, 모든 원소에 임의 접근을 제공해야한다.
- n/2 단계 동작으로 보아 원소 중간 삽입 및 삭제 시 모든 원소 이동 동작을 수행함을 추론할 수 있다.
- 원소를 항상 오른쪽으로 이동해야만하는 것은 아니다.
- 원소 삽입 위치에서 가장 가까운 끝 쪽으로 나머지 원소를 이동해도 된다.
- 특정 위치에서 가장 가까운 끝은 컨테이너 내부 삽입 위치에서 n/2 이상 떨어져있을 수 없으므로 최대 n/2 시간 복잡도를 가진다는 것이다.
- deque은 단일 매모리 청크를 사용하지 않는다.
- 크기가 같은 여러 개의 메모리 청크를 사용해 데이터 저장
- 청크 인덱스 및 크기를 통해 특정 위치의 원소가 어느 청크에 저장되어있는지 알 수 있는 것이다.
- 메모리 청크 주소를 연속적인 메모리 구조에 저장해 놓고 사용하면 O(1) 의 시간 복잡도로 원소 임의 접근이 가능해 진다.
- 임의 접근은 Vector 처럼 상수 complexity지만, 어떤 청크의 몇번째다라는 연산을 해야하므로 더 오래걸리는 연산이긴 하다.
- 청크로 관리한다는 것은 vector에 비해 Cache locality가 낮을 수 있음에 유의
- deque 맨 앞에 new 원소 추가 시 첫 번째 메모리 청크에 여유 공간이 없다면 새로운 청크를 할당하고, 이 메모리 청크 주소를 맨 첫 번째 메모리 청크주소로 설정한다.
- 청크 주소를 저장하는 메모리 공간을 새로 할당하지만 데이터 이동은 일어나지 않아도 된다.
- 메모리 재할당을 최소화하려면 첫 청크부터 원소를 추가하지 않고, 중간 위치의 청크부터 원소를 저장할 수 있다. 이런 방식을 이용하면 일정 횏수의 push_front() 함수에 대해 메모리 재할당을 피할 수 있다.
std::deque<int> deq = {1, 2, 3, 4, 5};
deq.push_front(0);
deq.push_back(6);
deq.insert(deq.begin() + 2, 10);
deq.pop_back();
deq.pop_front();
deq.erase(deg.begin() + 1);
deq.erase(deq.begin() + 3, deq.end());
- deque은 데이터를 앞 뒤로 매우 빠르게 원소를 삽입하거나 삭제할 수 있다.
컨테이너 어댑터
-
기존 컨테이너를 기반으로 만들어지는 컨테이너들이 있다.
- 이는 코드에 좀 더 의미를 부여하고 싶거나 의도하지 않는 함수를 실수로 사용하지 못하도록 제한하고 싶거나, 특별한 인터페이스를 새롭게 제공하고 싶은 경우에 래핑한다.
std::stack
std::stack<int> stk;
stk.push(1);
stk.pop();
stk.push(2);
stk.pop_front(0);
-
LIFO 구조
-
해당 구조의 명확성을 위해 사용, pop_front() 안되게 해보림
-
std::deque으로 만든 간단한 래퍼로, 스택 자료형에 필요한 인터페이스만 제공
-
emtpy(), size(), top() (<- back()), push(), pop() (<- pop_back()), emplace()
std::queue
std::queue<int> q;
q.push(1);
q.push(2);
q.push(3);
q.pop();
std::priority_queue
- heap이라고 부르는 매우 유용한 구조를 제공한다.
- heap : 컨테이너에서 가장 작은(or 가장 큰) 원소에 빠르게 접근할 수 있는 자료 구조
- 최대/최소 원소에 접근하는 동작이 O(1)
- 원소 삽입 O(logn)
- 원소 제거는 최대/최소 원소에 대해서만 가능
- 최대/최소 한번에 빠르게 접근하는게 아니라 컨테이너 비교자에 의해 최대 or 최소에 빠르게 접근할지가 정해진다.
- std::vector를 기본 컨테이너로 사용
- 비교자는 std::less가 기본 (최대 힙)-> 최대 원소가 top
- 새로운 원소 삽입 시 최대 or 최소 원소가 최 상위에 위치되돍 하기 위해 단순히 기본 컨테이너 함수를 호출하는 형태로 도작할 수 없다. 대신 비교자를 사용해 최대 또는 최소 데이터를 맨 위 까지 끌어올리는 heapify 알고리즘을 구현해야 한다.
- 이런 연산이 컨테이너 크기에 대해 O(logn) 소요
- 우선순위 큐 생성자는 O(n) 시간 복잡도로 빠르게 동작하는 다른 힙 생성 알고리즘으로 초기화한다.
어댑터 반복자
- 어댑터에 대해서는 반복자를 지원하지 않는다.
- 모든 어댑터는 각 자료구조에서 꼭 필요한 기능만 지원 (모든 원소 순회 등 필요 X)