상세 컨텐츠

본문 제목

[C++11] 인덱스 정보를 유지하면서 효율적으로 정렬하기

프로그래밍/테크닉

by ∫2tdt=t²+c 2022. 5. 30. 01:01

본문

정렬은 컴퓨터 알고리즘에서 매우 자주 쓰이는 중요한 알고리즘입니다. 그래서 대부분의 프로그래밍 언어들은 정렬하는 방법을 언어 문법 차원에서든 기본 라이브러리로든 반드시 제공합니다. 언어 차원에서 제공해주는 정렬 함수는 대체로 최적화가 잘 되어 있어 빠르므로, 컴공 과제를 푸는게 아닌 이상은 정렬을 직접 구현해서 쓸 일은 거의 없지요. 그러나 종종 기본적으로 제공되는 정렬 함수만으로는 부족함을 느낄 때가 있습니다. 바로 인덱스 정보를 유지하면서 정렬을 해야하는 경우입니다.

배열을 정렬할 때 원본의 인덱스 정보를 유지하는 작업은 생각보다 자주 필요하다.

위의 그림에서와 같이 총 5개의 문자열로 이뤄진 배열을 정렬하는데, 정렬 후 배열의 각 요소들이 원래 어느 위치에 있었는지를 함께 파악해야하는 경우가 있습니다. 배열과 관련된 다양한 문제를 해결하다보면 생각보다 자주 필요한 작업인데요, 이에 대한 기능을 자체적으로 제공해주는 언어는 거의 없습니다. 왜냐면 사실 몇 가지 손질만 하면 기본 정렬 함수를 통해 위의 문제를 해결할 수 있거든요. 가장 간단한 방법은 배열의 각 요소들을 직접 정렬하는 대신 (요소, 인덱스) 형태의 튜플의 배열을 만든 뒤, 이를 정렬하는 것입니다. 그리고 다시 요소와 인덱스를 떼어서 필요한 곳에 쓰면 대성공이지요.

그런데 C++을 사용하는 개발자 입장에서는 이 방법이 영 탐탁지 않을지 모릅니다. 왠지 이 방법이 가장 빠른 방법이 아닐것 같거든요. 그래서 이번 포스팅에서는 C++11을 기준으로, 인덱스 정보를 유지하면서 효율적으로 배열을 정렬하는 방법에 대해서 고민해보았습니다.

문제 상황 정의 

먼저 우리가 최적화하여 해결해보고자 하는 문제를 명확히 정의해보도록 하겠습니다. 1) 정렬가능한 타입 T의 값 n개가 연속한 메모리 공간에 위치해 있습니다. 2) n개의 T값을 inplace[각주:1]로 정렬하고, 추가로 각 T의 요소들의 원래 위치를 구하여 함께 반환해야 합니다. 

장황하게 적어놨지만 아주 명확한 요구사항이죠? 이 요구사항에 맞춰서 바로 떠올릴 수 있는 구현은 다음과 같은 모양일 겁니다.

가장 기본적인 방법

쉽게 짠 만큼 아주 비효율적인 코드지요. 일단 임시 벡터 t를 쓰는게 마음에 걸립니다. 입력 받은 벡터 v에서 모든 값들을 t에 다 복사해서 옮긴 뒤, 이를 정렬하고, 그 결과를 다시 또 v에 옮기고 있으니깐요. 아주 비효율적입니다. 만약 임의의 타입인 A가 복사하기에 매우 비싸고 거대한 타입이라면 여기서 v에서 t로, t에서 v로 값을 복사하는 데에 아주 큰 오버헤드가 발생할 것입니다. 다행히도 C++11부터는 값을 복사하는 대신 이동시키는 방법이 추가됐죠. 이를 적극 활용해봅시다.

기본적으로는 sortBaseline과 동일한 함수인데 A 값을 옮길때 move 함수를 덧붙여 값이 복사 대신 이동되도록 한 것만 차이가 있습니다. A를 복사하는 문제는 해결했으나, 이동해야하는 것 역시 찝찝합니다. 꼭 v에서 t로 값을 이동해서 정렬하고, 다시 이걸 역으로 t에서 v로 이동시켜야만하는 걸까요? 그러지 말고 v에다 대고 바로 정렬을 수행할 순 없을까요?

레퍼런스 사용하기

C++에서는 레퍼런스라는 타입을 제공합니다. 포인터처럼 다른 변수를 참조하는 녀석인데, 초기화할때만 참조 대상을 설정할 수 있고, 그 이후에는 해당 변수의 별칭처럼 사용된다는 특징이 있죠.

그리고 또 레퍼런스 타입은 std::pair 안에 들어갈 수도 있습니다. 그럼 레퍼런스를 이용해서 위의 문제를 풀어볼 수도 있지 않을까요?

레퍼런스가 별칭을 지어주는것이라는 걸 이해하면, pair<A&, size_t>의 값을 수정하면 결국 A&가 가리키는 대상인 v가 바로 수정된다는 것도 이해할 수가 있습니다. 이렇게해서 정렬을 수행하면, t를 정렬하는게 바로 v에 반영되겠죠. 참 쉽죠?

그런데 사실 위 코드는 제대로 정렬을 수행하지 못합니다. 레퍼런스 타입의 한계 때문입니다. 특정 타입에 대해 정렬이 작동하려면 다음 코드가 문제 없이 작동해야합니다.

두 변수의 값을 바꿀 때(swap) 흔히 사용하는 방법이죠. 근데 만약 X자리에 일반 타입이 아닌 레퍼런스 타입 X&가 대신 들어간다면 어떻게 될까요?

두 값이 서로 맞바뀌는게 아니라 같아져 버리게 됩니다. 이런!!! 이래서는 제대로 정렬이 수행될 수가 없습니다. 따라서 pair에 A&를 넣더라도, 위와 같이 값을 잠시 저장해둘때에는 레퍼런스(A&) 대신 일반 타입(A)을 사용하도록 해야합니다. std::sort 함수 내부를 뜯어 고쳐야하는 걸까요?

맞춤형 Iterator를 구현하자

다행히도 STL에서 제공하는 algorithm들은 내부를 직접 수정하지 않고도, 입력으로 사용하는 iterator를 조절하여서 그 동작을 맞춤형으로 바꿀 수 있게 구현되어 있습니다. 즉, pair<A&, size_t> 타입에 대한 iterator이지만, 값을 잠시 저장해둘 때는 pair<A&, size_t> 대신 pair<A, size_t>를 사용하도록 하는 맞춤형 iterator를 구현하면 위의 문제를 피해갈 수 있다는 것이죠. 이를 위해 iterator에서는 value_type, reference, pointer 등을 직접 지정할 수 있게 하고 있습니다. 그럼 바로 구현해봅시다.

아주 간단한 iterator입니다. 대부분의 기능은 부모인 Iter에서 상속받고, 오직 value_type 부분만 약간 손봐줬습니다. 기존 Iterator를 그대로 가져다가 makeRefIdxIterator 함수에 넣으면 자동으로 RefIdxIterator가 되지요. 이 녀석을 이용해 std::sort를 수행하면 이제 레퍼런스가 포함된 pair도 문제 없이 정렬해낼 수 있습니다.

참 쉽죠~? 그럼 얼마나 효율적으로 동작하는지 한번 테스트해봅시다. 

Copy와 Move가 호출되는 횟수 비교

위의 방법들을 기준으로 각각 copy와 move가 얼마나 자주 호출되는지를 세어보면, 실제로 이 방법이 효율적인지 아닌지를 확인해 볼 수 있겠죠. 다음과 같이 copy와 move 연산의 횟수를 세어주는 래퍼 템플릿을 하나 정의해보도록 하겠습니다.

이제 타입 A 대신에 AssignmentCounter<A>를 사용한다면, A의 복사/이동 횟수를 세어볼 수 있습니다. 그럼 테스트를 위해 다음과 같이 A = vector<size_t>로 두고, 이에 대한 정렬을 수행해보겠습니다.

좋습니다~ 일단 결과는 일치하는 것으로 잘 나오네요. 확실히 sortBaseline이나 sortMoveBaseline은 불필요한 copy 및 move가 발생하고 있는것을 확인할 수 있구요. 그런데 sortReferenceFixed의 경우 copy 연산이 일부 발생하고 있는 걸 확인할 수 있습니다. 어디서 문제가 발생했을까요? copy 연산 쪽에 디버그 브레이킹포인트를 찍고 살펴보면 다음과 같이 pair의 이동 생성자 쪽에서 복사가 발생하고 있음을 확인할 수 있습니다. (다음은 GCC 4.8에 포함된 STL pair의 구현 중 일부입니다.)

U1이 A& 타입이라면 std::forward 후에도 A&타입이 되므로, 이동 생성자(A(A&&))가 호출되는 대신 복사 생성자(A(const A&))가 호출됩니다. 이 부분을 강제로 std::move로 고칠수 있다면 복사를 피할 수 있겠죠? STL pair의 내부 구현을 고칠 수 있는 방법이 있을까요........? 아쉽게도 그런 방법은 없기 때문에 위와 같은 상황에서 move가 호출되도록 하는 MovingPair를 직접 구현해보았습니다.

그리고 sortReferenceFixed2에서 pair 대신 MovingPair를 사용하게 함으로써 복사생성자가 불필요하게 호출되지 않고 이동생성자만 호출되도록 고칠 수 있습니다.

https://godbolt.org/z/Y1hqo4PM9

작동되는 전체 코드는 위의 링크에서 확인해 볼 수 있어요~!

유용성과 한계

다소 복잡한 과정을 거치긴 했지만, 이제 레퍼런스를 통한 정렬이 가능해졌습니다. 이를 단순히 정렬 전의 인덱스 정보를 계산하는데에만 쓰긴 아깝습니다. 곰곰히 생각해보면 이 기능이 다양한 곳에 유용하게 쓰일 수 있다는 걸 알수 있는데요, 먼저 임의의 배열 2개 이상을 묶어서 함께 정렬하는데에 사용할 수 있죠(아래의 zipped sort 단락 참조). 또 Random Access를 지원하지 않는 Container에 대해서도 각 요소의 reference만 떼어와서 vector로 만들고, 이를 정렬하는 방식으로 빠르게 정렬을 수행하는데에도 쓸 수 있습니다. C++의 레퍼런스 타입은 컴파일 이후 대체로 포인터로 치환되므로 전체 Container를 복사할 필요 없이 포인터 배열만 추가로 사용한다는 점은 최적화 시 큰 이점이 됩니다.

다만 이 방법이 항상 최적은 아닐 수 있습니다. 여기서는 비교적 무거운 타입을 예시로 사용했지만, 단순 정수/실수 타입 등은 레퍼런스/포인터를 통해 간접 참조를 하게 되는게 오히려 성능 상 손해일 수 있습니다. 이 경우는 차라리 전체 값을 복사하는게 더 효율적일 수도 있죠. 이에 대해서는 어떤 방법이 더 효율적인지 구체적으로 실험해볼 필요가 있겠습니다.

Zipped Sort

여기서 다룬, 둘 이상의 배열을 튜플로 엮어서 함께 정렬을 한다는 개념은 Zipped Sort라고 불립니다. 그리고 앞으로 C++ 표준 라이브러리 ranges v3에 들어갈 수 있도록 현재 표준 작업을 진행 중에 있습니다. (참고 링크)

정말 기대되는 기능이죠? 하지만 표준에 들어가서 현업에서 널리 쓰이게 되는 때는 머나먼 미래가 될 것 같네요. 대부분의 컴파일러가 ranges 라이브러리를 완벽하게 지원하게 되어, 이 포스팅과 같은 고민 없이 그냥 ranges를 쓸 수 있으면 제일 좋겠지만, 일단 그 전까지는 위와 같이 우회하는 방법을 고려해봐야겠습니다.

  1. 정렬한 결과 값을 입력한 값과 동일한 위치에 쓰는 것을 뜻함. 이 경우 결과값을 별도의 메모리에 저장할 필요가 없기 때문에 메모리 효율적임. [본문으로]

관련글 더보기

댓글 영역