template<
    class Ty,
    class Allocator = std::allocator<Ty>
> class deque;

   deque는 컨테이너의 시작과 끝의 삽입과 삭제가 빠른 자료구조를 구현한 것이다. <deque> 헤더에 구현되어 있다. deque는 double ended queue의 줄임말이다. std의 deque는 독특하게 O(1)의 속도로 모든 원소에 접근할 수 있다는 특징이 있다.

1. 사용

생성

다양한 방법을 이용해 생성 가능하다. 빈 컨테이너를 생성하거나, 미리 요소가 추가된 컨테이너를 생성할 수 있다. 각 생성자들은 Allocator를 추가적으로 받을 수 있다.

  1. 기본 생성자
  2. size를 전달하는 생성자
  3. initializer_list를 전달하는 생성자
  4. 시작과 끝의 이터레이터를 전달하는 생성자
  5. 복사 생성자
  6. 이동 생성자
// 1.	기본 생성자
//		아무것도 할당되지 않은 상태로 초기화.
deque<int> q1;

// 2.	size를 전달 받는 생성자.
//		size이상의 공간을 할당하고 값을 기본값 으로 채움
deque<int> q2(5);						// 0 0 0 0 0

// 2-2.	size와 초기화 값 v을 받는 생성자
//		size이상의 공간을 할당하고,  v값으로 채움
deque<int> q2_2(5, 1);					// 1 1 1 1 1

// 3.	initializer_list
deque<int> q3 = { 1,2,3,4,5 };		// 1 2 3 4 5
deque<int> q3_2{ 1,2,3,4,5 };
deque<int> q3_3({ 1,2,3,4,5 });

// 4.	iterator
deque<int> q4(q3.begin(), q3.end());	// 1 2 3 4 5;

// 5.	copy
deque<int> q5(q4);					// 1 2 3 4 5

// 6. move
deque<int> q6(move(q5));			// 1 2 3 4 5

접근

   front()back()을 통해서 양쪽 끝단에 있는 요소의 레퍼런스를 얻을 수 있다. 또, 원하는 인덱스의 요소를 at() 또는 [] 연산자로 레퍼런스를 얻을 수 있다.

#include<iostream>
#include<deque>

using namespace std;

int main() {
	deque<int> q{ 1,2,3,4,5 };

	q.front() = 10;		// 1->10
	q.back();			// 5

	q.at(3) = 0;			// 4->0

	for (int i = 0; i < 5; i++) {
		cout << q[i] << endl;		// 10 2 3 0 5
	}
}

이터레이터

   deque의 이터레이터는 모든 연산을 지원한다.


용량

shrink_to_fit()이 존재하는 이유를 알기 위해서는 deque의 내부 구조를 이해해야 한다.

  • size() - 현재 보관 중인 요소의 개수를 반환한다.
  • max_size() - 메모리상 할당 가능한 요소의 개수를 반환한다.
  • empty() - 컨테이너가 비어있는지 확인한다.
  • shrink_to_fit() - 할당되어있지만, 보관 중인 요소가 없는 메모리를 해제한다.
deque<int> q{ 1,2,3,4,5 };
q.size();			// 5
q.empty();			// false
q.max_size();		// 메모리의 총량

q.pop_back();		// 5를 제거한다.
q.shrink_to_fit();	// 5가 있던 요소의 메모리 블록을 해제한다.

수정

   insert()emplace()로 원하는 위치에 요소를 삽입할 수 있다. erase()를 이용해 원하는 위치의 요소를 삭제할 수 있다. 인덱스를 지정해 일어나는 연산은 모두 O(N)이다.    push_back(), push_front(), emplace_back(), emplace_front()를 통해 양쪽 끝에서 삽입이 가능하다. pop_back(), pop_front()를 통해 양쪽 끝의 원소를 삭제할 수 있다. 양쪽 끝에서 일어나는 연산은 모두 O(1)이다.

deque<int> q{ 1,3,4 };
auto iter = q.begin() + 1;
iter = q.insert(iter, 2);		// 1 2 3 4
q.insert(iter+1, 100);			// 1 2 100 3 4 
q.emplace(q.end(), 5);			// 1 2 100 3 4 5

q.erase(q.begin()+2);			// 1 2 3 4 5

q.push_back(6);		// 1 2 3 4 5 6
q.push_front(0);	// 0 1 2 3 4 5 6		

q.pop_front();		// 1 2 3 4 5 6
q.pop_front();		// 2 3 4 5 6
q.pop_front();		// 3 4 5 6
q.pop_front();		// 4 5 6

q.pop_back();		// 4 5

   resize()는 현재 size보다 작게 설정하면 해당 인덱스 전까지 모든 요소들이 삭제된다. 또, size보다 큰 값으로 설정하면 첫 요소를 기준으로 해당하는 크기만큼 추가적으로 할당한다. 새로 추가된 요소들을 일정한 값으로 채운다. clear()를 이용하면 현재 할당된 데이터를 삭제할 수 있다.

deque<int>q{ 1,2,3,4,5,6,7 };
q.resize(3);		// 1 2 3
q.resize(5, 10);	// 1 2 3 10 10

q.clear();			// empty


 

2. 내부 구조

   deque 내부 기능 구현에 호기심이 생길만한 가장 큰 요소는 어떻게 앞단 삽입이 O(1) 이면서 Random Access 역시 O(1)을 보장하고 있느냐이다. 그 외에도 메모리적으로 어떻게 관리되는지 궁금할 수 있다. 이러한 궁금증을 해소하기 위해서 내부 구현에 대한 설명을 3단계로 나누어봤다.


1_ 특정 단위 크기의 메모리 블록으로 관리한다.

   deque는 일정한 크기의 메모리 블록 단위로 메모리를 할당하고, 블록에 요소들을 보관한다. msvc++에서는 16바이트 단위로 블록이 생성된다. 예를 들어 deque<int>int[4]가 하나의 블록이 된다.

블록에 여유 메모리가 있다면, 블록을 채운식으로 삽입연산이 일어난다.
만약, 블록이 다 채워진 경우, 추가적인 블록을 생성하고, 블록간의 관계를 정의한다.
   또, 삽입의 방향이 다른 경우, 새로운 블록을 생성해야할 수도 있다. 위 그림에서 push_front() 메서드를 수행하려면 1 이전에 공간이 필요한데, block 1에는 1 이전에 공간이 없다. 때문에 새로운 블록을 생성하고, block 1 과 연결해야 한다.

   이러한 식으로 블록 단위로 요소들을 관리하고, 블록은 연결 관계에 의해서 일련의 배열이라고 추상화 할 수 있다. 그러기 위해서는 연결 관계를 정의해야하는데, 가장 쉬운 방법을 블록을 연결리스트로 연결하는 것이다. 그러나, 그렇게 되면 random access시 O(n)이 되는 문제가 있다.


2_ map을 통해 인덱싱한다.

   random access 시에 O(1)을 보장하기 위해 사용할 수 있는 방법은 별도의 인덱싱 테이블을 만드는 것이다. 그리고 그것을 map이라고 한다. map은 인덱스와 블록의 메모리 주소를 1:1로 대응시킨 테이블이라고 볼 수 있다. map에 원하는 인덱스를 요청하면 그에 맞는 블록의 메모리 주소를 반환한다.


3_ (msvc++ 에서는) map으로 동적할당된 요소 타입의 배열 포인터를 사용한다.

   msvc++에서는 block의 포인터를 저장하기 위한 배열 포인터와 front가 속한 block이 저장된 인덱스, 요소의 개수만을 가지고 map을 구현한다. 최초 삽입시 맵의 크기는 8로 고정되어 있으며, 블록이 8개가 넘어가면 2씩 곱하면서 크기를 새로 할당한다. (8 -> 16 -> 32 ..)

   최초 block은 0번 인덱스에 저장된다. 만약, 최초 block이 꽉 찼고, push_back()연산이 일어난다면, 새로 만들어지는 블록은 1번 인덱스에 삽입된다. 만약 push_front()연산을 수행해야 한다면, 배열의 마지막 위치인 7번에 새로운 블록을 생성하고, 블록의 끝 인덱스인 7*4+4인 32를 가리키는 31번 인덱스를 offset으로 설정한다. 즉, 선형 배열을 시작과 끝이 연결된 원형처럼 사용하는 것이다.


   추가적으로 back에 새로운 block이 필요하면 2번에 저장하고, 새로운 front 블록이 필요하다면 6번 위치에 block에 새로 추가하고, offset을 변경한다.

   삽입이 지속되어서 9번째 block이 생성되어야할 때, 새로 2배인 크기가 16인 배열 포인터를 할당하고, 기존 map에서 offset 부터 offset index를 유지하면서 복사한다.

이를 코드로 구현하면 아래와 같다.

#include<cassert>
#include<memory>

// int형 전용 Deque
// 연산은 대부분 msvc++ std::deque의 연산을 그대로 가져오려고 했음
// https://github.com/microsoft/STL/blob/main/stl/inc/deque
class Deque {
private:
	const int blockSize = 4;
	const int minimumMapSize = 8;

	int** map;
	int mapSize;
	int offset;
	int size;

	// map의 크기를 확장한다.
	void expandMap(int count) {
		int newSize = mapSize << count;
		int** newMap = constructMap(newSize);

		int blockOffset = offset / blockSize;
		
		// offset 부터 끝까지 복사
		const int copySize = mapSize-blockOffset;
		copyMap(newMap, blockOffset, map, blockOffset, copySize);
		if (blockOffset) {
			const int restSize = (mapSize - copySize);
			copyMap(newMap, blockOffset + copySize, map, 0, restSize);
		}

		if (map != nullptr) {
			delete[] map;
		}
		map = newMap;
		mapSize = newSize;
	}

	int** constructMap(int size) {
		int** m = new int* [size];
		memset(m, 0, size * sizeof(int*));
		return m;
	}

	void copyMap(int** newMap, int newOff, int** oldMap, int oldOff, int _size) {
		_size *= sizeof(int*);
		memcpy_s(newMap + newOff, _size, oldMap + oldOff, _size);
	}
	int getBlock(int i) {
		return (i / blockSize) & (mapSize - 1); // 나머지 연산 대신 & 사용
	}

	int* newBlock() {
		return new int[blockSize];
	}


public:
	Deque()
		: map(constructMap(minimumMapSize)),
		mapSize(minimumMapSize),
		offset(0), size(0) {}
	~Deque() {
		for (int i = 0; i < mapSize; i++) {
			if (map[i] != nullptr) {
				delete[] map[i];
			}
		}
		delete[] map;
	}

	void push_front(int v) {
		// 맵이 꽉 찼다면 확장한다.
		if ((offset + size) % blockSize == 0 && mapSize <= ((size + blockSize) / blockSize)) {
			expandMap(1);
		}

		// 새로운 offset을 구한다.
		offset &= mapSize * blockSize - 1;  // 나머지 연산 대신 & 사용
		int newOffset = offset != 0 ? offset : mapSize * blockSize;
		
		// offset 위치에 block이 없다면 생성한다.
		const auto block = getBlock(--newOffset);
		if (map[block] == nullptr) {
			map[block] = newBlock();
		}

		// 요소를 삽입한다.
		map[block][newOffset & (blockSize-1)] = v;
		offset = newOffset;
		++size;
	}

	void push_back(int v) {
		// 맵이 꽉 찼다면 확장한다.
		if ((offset + size) % blockSize == 0 && mapSize <= ((size + blockSize) / blockSize)) {
			expandMap(1);
		}

		// 새로운 offset을 구한다.
		offset &= mapSize * blockSize - 1;  // 나머지 연산 대신 & 사용
		int newOffset = offset + size;
		const int block = getBlock(newOffset);
		if (map[block] == nullptr) {
			map[block] = newBlock();
		}

		// 요소를 삽입한다.
		map[block][newOffset & (blockSize-1)] = v;
		++size;
	}

	int& operator[](int index) {
		assert((0 <= index && index < size));
		auto block = getBlock(index+offset);
		return map[block][index & (blockSize-1)];
	}

	int& front() { return (*this)[0]; }
	int& back() { return (*this)[size-1]; }
};