이번 포스트에서는 STL 에 대해 살펴본 후, STL vector, list에 대해 알아본다.
그리고 각 컨테이너에서 사용되는 iterator를 우리만의 Vector, List, Iterator 클래스로 간단하게 구현해보고 시간복잡도까지 이해하는 것으로 마무리하고자 한다 〰️ 🌱
표준 템플릿 라이브러리 STL 은 C++ Template 으로 작성된 많은 제네릭 클래스와 함수 라이브러리로서,
HP사가 1994년에 처음 세상에 내놓은 이후 일반화 프로그래밍 혹은 제네릭 프로그래밍이라는 새로운 프로그래밍 패러다임을 가져왔다.
ISO/ANSI C++ 표준위에서는 논란 끝에 STL 을 C++ 의 표준으로 채택하여 현재 C++ 표준 라이브러리에 포함하고 있다.
STL 에 구현된 제네릭 함수나 클래스를 이용하면 보다 쉽게 C++ 프로그램을 구축할 수 있다.
STL 에 포함된 Generic 클래스와 함수들은 다음과 같이 3종류로 분류된다.
컨테이너
iterator
알고리즘
stl 을 사용하여 프로그램을 작성하려면, 이 세 가지 중 하나만 사용하게 되는 경우는 드물다.
예를 들어 list 컨테이너에 저장된 값을 검색하거나 삭제하기 위해 iterator를 사용하며, list에 저장된 값을 정렬하기 위해 sort() 등 알고리즘 함수를 사용한다. 사실상 STL의 세 가지 요소가 거의 함께 사용되므로, 독자들은 이 3가지를 모두 알아야 한다.
STL 컨테이너 클래스는 컨테이너에 저장되는 원소를 다루는 방식에 따라 3가지로 분류된다.
🔖 STL 컨테이너는 스스로 크기를 조절하여 원소의 개수로 인한 사용자의 부담을 덜어주고, STL iterator는 컨테이너 종류에 무관하게 컨테이너의 원소들을 검색할 수 있도록 설계되었다. STL 알고리즘 역시 컨테이너 종류에 상관없이 작동하도록 설계되었다.
컨테이너 클래스 | 설명 | 헤더 파일 |
---|---|---|
vector | 가변 크기의 배열을 일반화한 클래스 | <vector> |
deque | 앞뒤 모두 입력 가능한 큐 클래스 | <deque> |
list | 빠른 삽입/삭제 가능한 리스트 클래스 | <list> |
set | 정렬된 순서로 값을 저장하는 집합 클래스. 값은 유일 | <set> |
map | (key,value)쌍을 저장하는 맵 클래스 | <map> |
stack | 스택을 일반화한 클래스 | <stack> |
queue | 큐를 일반화한 클래스 | <queue> |
iterator의 종류 | iterator에 ++연산 후 방향 | read/write |
---|---|---|
iterator | 다음 원소로 전진 | read/write |
const_iterator | 다음 원소로 전진 | read |
reverse_iterator | 지난 원소로 후진 | read/write |
const_reverse_iterator | 지난 원소로 후진 | read |
copy()
, merge, random, rotate,
equal, min, remove, search,
find, move, replace, sort,
max, partition, reverse, swap
iterator 는 컨테이너 안에 있는 원소들을 하나씩 순차적으로 접근하기 위한 원소에 대한 포인터이다.
iterator 를 생성하려면 컨테이너 템플릿에 구체적인 타입을 지정하여, 원소의 타입이 드러나도록 하여야 한다.
각 컨테이너는 추가, 삭제, 순회, 검색의 기능을 하는 함수가 있다.
template<typename T>
class Vector
{
public:
Vector() : _data(nullptr), _size(0), _capacity(0)
{
}
~Vector()
{
if(_data)
delete[] _data;
}
void push_back(const T& val)
{
if(_size == _capacity)
{
// 증설 작업
int newCapacity = static_cast<int>(_capacity * 1.5);
if(newCapacity==_capacity)
newCapacity++;
reserve(newCapacity);
}
_data[size] = val; // 데이터 저장
_size++; // 데이터 개수 증가
}
void reserve(int capacity)
{
_capacity = capacity;
T* newData = new T[_capacity];
// 데이터 복사
for(int i=0; i<_size; i++)
newData[i] = _data[i];
// 기존에 있던 데이터를 삭제해준다
if(_data)
delete[] _data;
// 교체
_data = newData;
}
// 레퍼런스로 반환하는 이유는, 데이터를 직접 건드릴 수 있도록!
T& operator[](const int pos) {return _data[pos];}
int size() {return _size;}
int capacity() {return _capacity;}
public:
typedef Iterator<T> iterator;
iterator begin() { return iterator(&_data[0]); }
iterator end() { return begin() + _size; }
private:
T* _data;
int _size;
int _capacity;
};
int main()
{
Vector<int> v;
v.push_back(100);
cout << v.size() << endl;
cout << v[0]; << endl;
}
어떠한 컨테이너를 사용하더라도 동일한 인터페이스를 제공한다. 다른 자료구조로 사용하기 편하다는 점.
iterator는 컨테이너 안에 있는 원소들을 하나씩 순차적으로 접근하기 위한, 원소에 대한 포인터다.
다음/이전 원소로 이동이 가능하며 시작부터 끝까지 순회가 가능하다.
즉 추가 삭제 순회 검색이 가능하다.
포인터는 말 그대로 주인님이 딱히 없는데, 이터레이터는 누구에게 소속된 것이라는 차이가 있다.
이터레이터 자체는 클래스이지만, 그 클래스 타입을 마치 우리가 포인터처럼 사용하게끔 이런 온갖 문법들을 다 지원한다.
*
operator overloading 을 지원하게끔 뭔가를 랩핑해가지고 만들어준다
포인터 연산처럼 연산이 가능하다.
end() 로 확인해서 보면, 유효하지 않은 쓰레기 값을 들고 있다.
주의할 점: 삭제
벡터에서 사용하는 iterator 를 구현해보며 더 이해해보자
template<typename T>
class Iterator
{
public:
Iterator(): _ptr(nullptr)
{
}
Iterator(T*ptr): _ptr(ptr)
{
}
// 전위형
Iterator& operator++()
{
_ptr++;
return *this;
}
// 후위형
Iterator& operator++(int)
{
Iterator temp = *this;
_ptr++;
return temp;
}
// 전위형
Iterator& operator--()
{
_ptr--;
return *this;
}
// 후위형
Iterator& operator--(int)
{
Iterator temp = *this;
_ptr--;
return temp;
}
Iterator operator+(const int count)
{
Iterator temp = *this;
temp._ptr += count;
return temp;
}
Iterator operator-(const int count)
{
Iterator temp = *this;
temp._ptr -= count;
return temp;
}
bool operator==(const Iterator& right)
{
return _ptr == right._ptr;
}
bool operator!=(const Iterator& right)
{
return !(*this == right);
}
T& operator*()
{
return *_ptr;
}
public:
T* _ptr;
}
O(N)
O(1)
O(N)
O(N)
O(1)
O(1)
, 최악: O(N)
push_back()
은 상수 시간 복잡도 O(1)
를 갖는다. 이는 벡터의 용량(capacity)이 충분하다면 삽입 연산이 빠르게 수행되는 것을 의미한다. 하지만 용량이 부족한 경우에는 요소를 추가하기 위해 메모리를 다시 할당해야 하므로 선형 시간 복잡도 O(n)
가 될 수도 있다. 즉, push_back()
의 평균 시간 복잡도는 O(1)
이지만 최악의 경우에는 O(n)
이 될 수 있다.O(1)
O(1)
O(1)
O(1)
O(1)
O(n)
를 갖는다. 이 알고리즘은 특정 값과 일치하는 요소를 찾아서 제외하고, 나머지 요소들을 앞으로 이동시키는 작업을 수행한다. 따라서 remove() 알고리즘을 사용하면 해당 요소의 개수에 비례하여 시간이 소요된다.erase()
함수를 사용하여 제외된 요소들을 벡터에서 삭제할 수 있다. erase()
함수는 삭제된 요소들을 한 번에 한 칸씩 앞으로 이동시키는 작업을 수행하므로 최악의 경우에는 선형 시간 복잡도 O(n)
를 가질 수 있다.𝑸. 벡터에서 size 와 capacity 의 차이는?
𝐀. capacity 는 할당된 공간, size 는 실제 데이터 크기
𝑸.
reserve
를 먼저 하는 이유?
𝐀. 이사 비용을 아낄 수 있고 메모리 파편화도 아낄 수 있다
연결리스트이며, node 기반의 자료구조이다. STL의 리스트처럼 양방향 연결리스트, 그리고 연결리스트에서 사용하는 이터레이터를 만들어보면서 더 이해해보자!
templaye <typename T>
class Node
{
public:
Node() : _next(nullptr), _prev(nullptr), _data(T())
{
}
Node(const T& value) : _next(nullptr), _prev(nullptr), _data(value)
{
}
public:
Node* _next;
Node* _prev;
T _data;
};
template<typename T>
class Iterator
{
public:
Iterator() : _node(nullptr) {}
Iterator(Node<T>* node) : _node(node) {}
// 연산자 오버로딩
// ++it 전위
Iterator& operator++()
{
_node = _node->_next;
return *this;
}
// it++ 후위
Iterator& operator++()
{
Iterator<T> temp = *this;
_node = _node->_next;
return temp;
}
// --it 전위
Iterator& operator--()
{
_node = _node->_prev;
return *this;
}
// it-- 후위
Iterator& operator--()
{
Iterator<T> temp = *this;
_node = _node->_prev;
return temp;
}
T& operator*()
{
return _node->_data;
}
bool operator==(const Iterator& right)
{
return _node == right._node;
}
bool operator!=(const Iterator& right)
{
return _node != right._node;
}
public:
Node<T>* _node;
};
template <typename T>
class List
{
public:
List() : _size(0)
{
// header 는 자기 자신을 가리킨다
_header = new Node<T>();
_header->_next = _header;
_header->_prev = _header;
}
~List()
{
while(_size > 0)
pop_back();
delete _header;
}
void push_back(const T& value)
{
AddNode(_header, value);
}
void pop_back()
{
RemoveNode(_header->_prev);
}
Node<T>* AddNode(Node<T>* before, const T& value)
{
Node<T>* node = new Nodee<T>(value);
Node<T>* prevNode = before->_prev;
prevNode->_next = node;
node->_prev = prevNode;
node->_next = before;
before->_prev = node;
_size++;
return node;
}
Node<T>* RemoveNode(Node<T>* node)
{
Node<T>* prevNode = node->_prev;
Node<T>* nextNode = node->_next;
prevNode->_next = nextNode;
nextNode->_next = prevNode;
delete node;
_size--;
return nextNode;
}
int size() { return _size; }
public:
typedef Iterator<T> iterator;
iterator begin() { return iterator(_header->_next); }
iterator end() { return iterator(_header); }
iterator insert(iterator it, const T& value)
{
Node<T>* node = AddNode(it._node, value);
return iterator(node);
}
iterator erase(iterator it)
{
Node<T>* node = RemoveNode(it._node);
return iterator(node);
}
public:
Node<T>* _header;
int _size;
};
int main()
{
List<int> li;
List<int>::iterator eraseIt;
for(int i=0; i<10; i++)
{
if(i==5)
{
earseIt = li.insert(li.end(), i);
}
else
{
li.push_back(i);
}
}
li.pop_back();
li.erase(eraseIt);
for(list<int>::iterator it = li.begin(); it!=li.end(); ++li)
{
cout << (*li) << endl;
}
return 0;
}
O(N)
O(1)
O(1)
이터레이터로 들고있을 때처럼 위치를 기억하고 있을 때 빠르다O(1)
O(1)
O(1)
O(1)
O(1)
O(1)
이터레이터로 들고있을 때처럼 위치를 기억하고 있을 때 빠르다(C# 에서의 리스트는 벡터이다)
double-ended queue
배열 형태로 만들어졌다는 것은 벡터와 유사하나, 복사 이사하지 않고 메모리를 따로 파서 더 저장하는 형태이다.
#include <deque>
int main()
{
deque<int> dq;
dq.push_back(1);
dq.push_front(2);
cout << dq[0] << endl;
}
틀린 내용이 있다면 댓글로 알려주시면 감사하겠습니다 😊