정렬된 범위에 대해 동작하는 알고리즘이 어떤 것들인지 파악해 두자.

특정 알고리즘들은 미리 정렬시킨 범위를 넣어주어야 한다. 만약 이러한 규칙을 지키지 않았을 경우에는 실행시 동작을 예측 할수 없게 된다.

정렬된 데이터를 넘겨야 동작하는 알고리즘
















binary_search lower_bound upper_bound equal_range
set_union set_intersection set_difference set_symmetric_difference
merge inplace_merge
include

binary_search, lower_bound, upper_bound, equal_range 등은 내부적으로 이진 탐색을 사용해서 값을 찾기 때문에 반듯이 정렬된 데이터 범위가 필요하다.
일반적으로 위의 알고리즘들은 로그 시간의 성능을 내준다고 하지만 임의 접근 반복자를 사용할때에만 그렇다. 예를 들어 양방향 반복자등을 사용한다면 한곳에서 다른 곳으로 이동시 인접한 데이타만으로 이동이 가능하므로 선형시간 이상이 걸리게 된다.

set_union, set_intersection, set_difference, set_symmetric_difference 은 set 조작 알고리즘이며 선형시간의 효율을 보인다. 만약 정렬되지 않은 데이타 범위로 동작할 때는 동작자체는 하지만 선형시간을 초과하는 동작시간을 보여준다.

merge, inplace_merge 알고리즘은 merge sort 의 일부분으로 생각할수 있다. 즉 두개의 정렬된 데이타 범위를 합쳐 정렬된 하나의 범위로 만드는 동작이다.

include 는 어떤 범위안에 원하는 값이 들어있는지의 여부를 알아볼때 사용한다. 물론 정렬된 것을 가정하고 동작하므로 정렬되어 있지 않다면 수행시간이 느려지게 된다.

unique 류의 알고리즘은 인접한 동일값에 대해 작용하는 알고리즘으로 정렬되어 있을 경우와 정렬되어 있지 않을 경우 결과가 달라질수 있다. 데이터 범위에서 중복된 값을 모두 제거하고 싶다면 정렬을 한뒤 unique 를 호출해야 하는 것이다.


정렬 기준에 대한 첨언

binary_search, lower_bound, upper_bound, equal_range 등의 알고리즘은 당연히 정렬된 기준과 동일한 기준으로 검색기준을 가져야 정상적으로 작동을 할것이다.
vector<int> v;

sort(v.begin(), v.end(), greater<int>());

bool a5Exists = binary_search(v.begin(), v.end(), 5);
위의 코드를 볼때 데이터의 범위는 내림차순으로 정렬을 했지만 기본적으로 binary_search는 오름차순을 기준으로 한다. 위의 코드를 정상적으로 작동하게 하기 위해서는
bool a5Exists = binary_search(v.begin(), v.end(), 5, greater<int>());
으로 수정을 해서 binary_search 알고리즘이 내림차순으로 정렬된 데이터 범위라는 것을 알수 있도록 해야 한다.

또한 unique류 를 제외하고 위에서 언급한 정렬된 범위에 대해 동작하는 알고리즘은 두개의 값이 같은지 판정할때 동등성을 기준으로 삼는다.
이와는 반대로 unique류의 알고리즘은 상등성에 의해 두 객체의 같음을 판별하게 된다.(EffectiveSTL19 참조)

remove와 비슷한 알고리즘을 포인터의 컨테이너에 적용할 때에는 각별히 조심하자.

EffectiveSTL32에서 언급한 것처럼 remove(remove_if, unique)는 지워질 데이터에 대해서 지워 지지 않는 데이타로 덮어써 버리는 동작을 취한다. 이런 동작은 컨테이너의 요소타입이 포인터일 경우 심각한 메모리 누수 현상을 일으킬 가능성이 있다.

이 경우 partition 알고리즘으로 지워질 대상이 되는것과 남아있을 데이타를 분리한뒤 지워질 대상의 메모리를 해제하고 erase 를 호출하면 깔끔하다.

하지만 부득이한 사정으로 remove 류의 함수만을 사용해야 한다면 for_each 등으로 직접 컨테이너의 요소중 삭제될 데이타의 메모리를 해제해 준뒤 remove-erase 합성문을 호출해 주어야 한다.

이런 것을 신경 쓰고 싶지 않다면 스마트 포인터를 사용하도록 하자!

요소를 정말로 제거하고자 한다면 remove 류의 알고리즘에는 꼭 erase를 붙여 사용하자.

remove 알고리즘의 경우 범위를 나타내는 한쌍의 반복자를 인자로 받는다. 여기서 문제가 생기는데 반복자만으로는 어떤 컨테이너 인지 알수 없을 뿐더러 해당 컨테이너를 접근할수도 없다.
그 결과 remove 알고리즘을 호출하더라 하더라도 컨테이너에 들어있는 요소의 개수가 삭제되지 않는 결과가 나타나게 된다. 이유는





romove는 어느것도 진짜로 없애지 않는다. 없앨수 없기 때문이다.

실제로 remove가 하는 일은 지워질 데이터와 남아있을 데이터를 재배열 하는 것이다.
vector<int> v;
v.reserve(10);
for(int i=1; i<=10; ++i)
{
v.push_back(i);
}
v[3] = v[5] = v[9] = 99;
remove(v.begin(), v.end(), 99);
위의 코드는 remove를 실제로 사용하는 코드이다. 위의 코드중 remove를 호출하기 전의 상태는
   v.begin()                     v.end()
↓ ↓
┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐
│1 │2 │3 │99│5 │99│7 │8 │9 │99│
└──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘
같은 상태이다. remove를 호출한 이후는
   v.begin()           v.end()
↓ ↓
┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐
│1│2│3│5│7│8│9│?│?│?│
└─┴─┴─┴─┴─┴─┴─┴─┴─┴─┘

remove에서 리턴된 반복자위치
위와 같은 상태를 갖게 된다. 즉 remove는 남아있을 값을 앞쪽으로 모아 놓고 논리적인 마지막 위치의 반복자를 리턴하게 되는 것이다. 이후 remove에서 리턴한 반복자와 원래 컨테이너의 end() 반복자 사이의 요소를 컨테이너의 erase를 호출하여 삭제해줘야 한다.
vector<int> v;

v.erase(remove(v.begin(), v.end(), 99), v.end());
즉 위와 같은 형식의 코드가 구성된다.
remove와 유사한 알고리즘으로 remove_if 가 존재하며 비슷하게 동작하는 unique(인접한 중복갑제거)도 존재한다. 즉 remove_if와 unique등은 remove와 마찬가지로 실제로 데이타를 삭제하기 위해서는 erase를 같이 호출해 주어야 한다.

주의할점 1
list의 경우는 예외적으로 remove 와 unique 맴버함수가 존재하며 위의 설명과는 다르게 바로 데이타를 삭제한다.

주의할점 2
위의 remove, remove_if, unique등은 삭제 대상이 되는 데이타는 신경쓰지 않고 덮어써 버리는 구조를 취하게 되므로 포인터를 컨테이너에 넣는 경우 주의해야 한다.(EffectiveSTL33)

정렬시의 선택 사항들을 제대로 파악해보자



  • vector, string, deque 혹은 C++ 배열에 대해 전체 정렬을 수행할 필요가 있을 때에는 sort 나 stable 소트를 사용한다.
  • vector, string, deque 혹은 C++ 배열에 대해 상위 n개의 요소만 순서에 맞추어 뽑아내고 싶다면 partial_sort를 사용한다.
  • vector, string, deque 혹은 C++ 배열에 대해 상위 n개의 요소를 뽑되 순서를 고려할 필요가 없다면 nth_element가 적합하다.
  • 표준 시퀀스 컨테이너가 있고, 이 컨테이너의 요소들을 어떤 기준에 만족하는 것들과 그렇지 않은 것들을 모아 구분하고 싶다면 partition 혹시 stable_partition 을 사용한다. (등급을 구분하거나 더 나아가 몇 등급이상의 것만 얻거나)
  • list의 경우 partition, stable_partition 은 직접 사용이 가능하며 sort 와 stable_sort 알고리즘 대신 list::sort 멤버 함수를 사용할수 있다. 만일 partial_sort나 nth_element의 기능이 필요하다면 백터등으로 변환하거나 반복자를 백터에 넣어 정렬하는 간접적인 방법을 사용해야 한다.
stable 이 붙은 정렬알고리즘은 정렬이전의 순서를 유지시켜준다.

그리고 간단하게 정렬알고리즘의 성능으로 순위를 내보자면

  1. partition
  2. stable_partition
  3. nth_element
  4. partial_sort
  5. sort
  6. stable_sort
순으로 나타난다.(하지만 성능은 상황에 따라 상대적인것)
nth_element 의 또 다른 사용법과 다른 알고리즘의 사용예는 책을 참고

알고리즘의 데이터 기록범위는 충분히 크게 잡자

컨테이너를 대상으로 알고리즘을 수행할때는 언제나 알고리즘의 결과가 어디에 입력이 되는지를 생각해야 한다. 예를 들어

int transmogrify(int x);                // 정수 x를 받아
// 새로운 값을 생성
vector<int> values;
vector<int> result;
transform(values.begin(), values.end(),
result.end(), // 알고리즘의 결과를
transmogrify); // result 뒤에 추가 시도
의 경우 transform 알고리즘으로 transmogrify의 결과를 result의 뒤에 덧붙이는 결과를 기대 하겠지만 실제로 result.end()는 실제로 할당되어 있는 객체가 아닐뿐더라 result.end() 이후도 마찬가지 이다. 즉 해당코드는 알수없는 장소에 데이터를 기록하려 시도하다 런타임 에러를 유발하게 된다.
이런 경우 사용할수 있는것이 back_inserter, front_inserter, inserter 등이다. 이것들은 내부적으로 push_back, push_front, insert 등을 호출하여 컨테이너의 항목을 늘려주고 그 항목에 해당하는 반복자를 리턴해준다.
int transmogrify(int x);
//…
vector<int> values;
list<int> result;
transform(values.begin(), values.end(),
front_inserter(result),
transmogrify);
주의할 점은 각각의 컨테이너가 구현방식이 다르므로 사용할수 있는 함수의 종류도 다르다며 각 함수의 효율도 차이가 있다. 그래서 예제 코드도 vector에서 list로 바꿨다.
list 같은 의 경우 노드 단위의 컨테이너 이므로 앞,뒤 삽입이 자유로우며 삽입에 따른 오버헤드 역시 별로 없다. 하지만 임의 위치에 삽입은 탐색에 대한 오버헤드가 존재한다.
vector 등의 front_inserter를 지원하지 않고 모든 삽입의 경우 재할당 오버헤드가 발생할 가능성이 있다. 하지만 reserve 나 resize 를 이용해 재할당 오버헤드를 방지할수 있다.(물론 reserve등을 쓴다고 해도 back_inserter 등의 함수를 사용해야 반복자 위치를 정상적으로 증가 시킬수 있다.)
즉 각 컨테이너의 구현 방식에 따라 효율적인 방법을 선택해야 하는것이다.

결론은 목적지 범위를 지정해야 하는 알고리즘을 사용할 때에는 언제나 이 목적지 범위의 크기를 미리 확보해 두든지, 알고리즘이 실행될 때 자동으로 증가하도록 만들어야 한다는 것이다.

문자 단위의 입력에는 istreambuf_iterator의 사용도 적절하다.

어떤 텍스트 파일을 string 객체에 복사해야 한다고 할때

ifstream inputFile(“file.txt”);
string fileData((istream_iterator<char>(inputFile)),
istream_iterator<char>());
같은 코드를 사용할수 있다.

하지만 ifstream_iterator는 실제 스트림 일기를 수행할때에 operator<< 함수를 사용하며, 기본적으로 이 연산자 함수는 공백문자를 건너 뛴다. 즉 공백 문자를 포함하여 파일을 읽어들이고 싶다면 입력 스트림의 skipws 플래그를 설정해제 한다.
ifstream inputFile(“file.txt”);
inputFile.unsetf(ios::skipws);
string fileData((istream_iterator<char>(inputFile)),
istream_iterator<char>());

하지만 어떤 서식 처리가 없이 그냥 문자열을 읽어 들이는 경우 위의 코드는 계선의 여지가 있다. istream_iterator가 사용하는 operator<< 함수는 서식화 입력을 수행하기 때문에 그에 따른 오버헤드가 존재한다.

즉 서식화 입력이 필요가 없다면 istream_iterator 대신 istreambuf_iterator을 사용할수 있다. istream_iterator<char>가 operator<<를 통해 입력 스트림으로부터 문자를 읽어 들이는 반면 istreambuf_iterator<char> 객체는 스트림 자체의 버퍼를 직접 건드려서 다음 문자를 바로 읽어 들인다.
ifstream inputFile(“file.txt”);
string fileData((istreambuf_iterator<char>(inputFile)),
istreambuf_iterator<char>());

istreambuf_iterator는 간단하게 파일의 내용을 읽어들일뿐만 아니라 istream_iterator에 비교해서 볼때 속도도 빠르다.
ostreambuf_iterator 역시 비슷한 속성을 가지고 있다.(문자단위의 비서식화 출력 스트림 반복자)

reverse_iterator에 대응되는 기점 반복자(base iterator)를 사용하는 방법을 정확하게 이해하자

아래는 vector에 1~5까지의 5개의 int 데이타를 넣고 reverse_iterator를 써서 3을 가리키게 한후, iterator하나를 revserse_iterator의 기점으로 세팅하는 코드이다.


vector<int> v;
v.reserve(5); // 미리 할당 EffectiveSTL14 를 참고

for( int i=1; i<=5; i++) {
v.push_back(i);
}

// 3을 가리킨다.
vector<int>::reverse_iterator ri = find(v.rbegin(), v.rend(), 3);

// i가 ri의 기점과 같게 한다.
vector<int>::iterator i(ri.base());


위의 코드를 도식화 해서 표현하자면 이렇게 된다.

rend()  rbegin()
↓ ↓
┌─┬─┬─┬─┬─┐
│1│2│3│4│5│
└─┴─┴─┴─┴─┘
↑ ↑
begin() end()
(크아 그리느라 죽는줄 알았어!)

insert나 erase는 reverse_iterator를 인자로 받아들이지 않는 경우가 있기 때문에 위의 경우와 같이 reverse_iterator를 사용하다가 insert나 erase가 필요한 경우는 iterator로 변환을 해야 한다. 그 때 사용하는 메소드가 바로 base이고 위의 그림과 같은 대응되는 위치를 갖는다.


ri와 i, rbegin()과 end() 등의 위치가 미묘하게 다른것을 볼수 있는데
insert를 할 경우에는 저런 관계가 아주 정확하게 성립된다. STL의 세계 에서의 요소 삽입은 반복자가 가르키는 위치의 바로 에서 이루어 진다는 점을 생각할때 3을 가르키는 ri의 기점 반복자가 4를 가리키고 있으므로 ri의 base() 호출에 의한 i로 insert() 를 호출시 정확히 원하는 위치인 3과 4 사이에 데이타를 삽입하게 된다.


 


하지만 erase는 다르다.
위의 그림을 볼때 ri가 가르키는 데이타를 삭제해서 나타나는 원하는 결과는 3이 삭제되는것일 것이지만 base()함수를 이용해 기점 반복자 i를 얻어 erase()를 호출하면 4가 삭제되어 버릴것이다.
이런 erase관련 문제를 해결하기 위해서 reverse_iterator의 위치 값을 하나더 증가시킨뒤 base() 를 호출하는 방법을 생각할수 있다.
v.erase( (++ri).base() );       // ri가 가르키는 요소를 삭제한다.
즉 ri는 (++ri)에 의해서 2를 가르키게 되고 ri의 base() 결과인 i는 3을 가르키게 된다.

 


base()로 기점 반복자를 얻은뒤 그 기점 반복자의 위치를 변경하는 것도 방법이 될수 있지만 vector 와 string에서 문제가 발생한다. 자세한 내용은 책 참조

const_iterator를 iterator로 바꾸는 데에는 distance와 advance를 사용하자.

EffectiveSTL26 에서 설명한 것 처럼 iterator만 받아들이는 함수가 있어서 const_iterator를 iterator로 변환해야 하는 경우가 생길수 있는데 일반적으로 생각하는 캐스팅 연산으로는 변경을 할수가 없다. iterator 와 const_iterator 는 서로 상속 관계등이 존재하는것이 아닌 전혀 다른 클래스이기 때문이다. (vector 나 string은 iterator가 포인터이기 때문에 가능할수도 있다.)

const_iterator 에서 iterator의 변환은 아래의 코드와 같은식으로 진행된다.
typedef deque<int> IntDeque;
typedef IntDeque::iterator Iter;
typedef IntDeque::const_iterator ConstIter;

IntDeque d;

ConstIter ci;
// 생략
Iter i(d.begin());
advance(i, distance<ConstIter>(i,ci));

distance는 같은 컨테이너의 두 반복자 사이의 거리를 알려주며 advance는 첫번째 인자인 반복자를 두번째 인자의 지정된 거리만큼 전진 시킨다.
distance<ConstIter>(i,ci) 에 의해서 i가 자동은 const_iterator 로 변환되후(iterator는 const_iterator로 변환가능 EffectiveSTL26 참조) ci와의 거리를 구한다. advance는 그 거리만큼 i를 이동시켜 결과적으로 ci와 같은 위치를 가르키는 iterator를 얻게 된다.

이 변환 방법은 다 반복자 사이의 거리를 알아내는 것, 그리고 전진시키는 과정에서 각 반복자의 특성을 그래로 반영받는다. 즉 vector등은 상수시간에 처리가 될수 있지만 list등의 노드기만 컨테이너들은 선형시간이 걸릴수 있다.

const_iterator 나 reverse_iterator, const_reverse_itertor도 좋지만 역시 쓸만한 것은 iterator 이다

우선 반복자간의 변환가능성을 보면

  1. iterator는 모든 다른 반복자로 변환이 가능하다.
  2. reverse 반복자류는 해당하는 종류의 반복자로 base() 맴버함수를 통해 변환이 가능하다.
    (reverse_iterator -> iterator, const_reverse_iterator -> const_iterator )
  3. reverse_iterator는 const_reverse_iterator 로 변환이 가능한다.
하지만 STL에서는 반복자 사용시 아래와 같은 제약이 발생하는 경우가 있다.



  • 어떤 형태의 insert와 erase 멤버함수는 무조건 iterator 만을 넘겨야 한다.
  • const_iterator 를 iterator로 암시적으로 변환하는 방법은 없고 굳이 바꾸자면 EffectiveSTL27의 내용을 참조한다. 하지만 일반성도 떨어지고 효율에 대한 보장도 할수 없다.
  • reverse_iterator를 iterator로 변환할수 있으나, 변환후 약간의 조정이 필요하다. 자세한 내용은 EffectiveSTL28을 참고한다.
무척이나 미묘한 경우이며 별것 아니지만 해결하기 난감한 경우이다. 또한 const_iterator 와 iterator 를 비교하는 경우도 생길수 있는대 논리적으로 라면 반복자는 위치를 가르키는 것이므로 비교 연산에 문제가 없어야 하지만 특정 STL은 const_iterator 의 operator== 연산자를 잘못 구현하여 두 반복자 간의 비교가 컴파일이 안되는 경우도 발생할수 있다. 이런 여러가지 제한 상황을 생각할때 그냥 iterator 를 쓰는게 속이 편한 경우가 많다.

물론 const나 reverse는 STL에서 버그를 예방하고 유용하게 쓰이는 면이 있으므로 제한 상황을 잘알아두는 것이 좋다.