샷건치며 배우는 자료구조 with C++, 4화
동적 배열에 대해 알아봅시다.
읽어주세요
자료구조를 배우기 위해 위대한 항로(?)를 넘어 여기까지 오신 분들 환영합니다. 본 게시글은 자료구조를 공부하면서 복습 겸 정리하기 위해 작성하였습니다. 개인적으로 강좌 형식 및 장난을 섞어가며 작성하는 걸 좋아하기 때문에 진지한 게시글을 원하신다면 뒤로 가기를 눌러주세요.
C++ 언어를 기반으로 하고 있습니다. 다른 프로그래밍 언어를 사용 중이신 경우 개념(이론)을 배우는 데 큰 문제는 없지만, 실제 코드로 구현할 땐 생각보다 차이가 있을 수 있습니다.
업데이트
- C 언어 구현 내용 제거
- 게시글의 내용이 너무 복잡해져 제거하였습니다.
게시글 개선을 위해 임시 숨김 처리
동적 배열
3화에서 배운 배열은 정적 배열Static Array입니다. 정적이라는 건 고정되어 있다는 뜻으로, 배열의 크기가 고정되어 있다는 걸 의미합니다. 반대로 동적 배열은 뭘까요?
동적 배열Dynamic Array에서 동적은 움직인다는 뜻으로, 크기가 자유롭게 변할 수 있는 배열을 의미합니다. 정적 배열은 크기가 고정되어 있어 원하는 데이터를 마음대로 추가하지 못한다는 단점이 있습니다. 동적 배열은 이 단점을 해결해줍니다.
동적 배열의 특징
-
동적인 크기
동적 배열도 마찬가지로 선언할 때 크기를 지정합니다. 하지만, 정적 배열과는 다르게 중간에 이 크기를 줄이거나 늘릴 수 있습니다.
-
동일한 데이터 타입의 요소
동적 배열도 동일한 데이터 타입의 요소만 가질 수 있습니다. 여러 데이터 타입과 섞일 수 없습니다.
-
연속적인 배치
동적 배열도 정적 배열과 마찬가지로 연속적인 배치를 보장합니다. 다만, 몇 가지 예외가 있으니 아래에서 알아봅시다.
-
인덱스를 통한 접근
동적 배열도 인덱스 연산자를 통해 원하는 데이터에 바로 접근할 수 있습니다.
동적 배열의 선언
C++ 언어는 new
키워드를 사용해 동적 할당을 수행할 수 있습니다. 사용이 완료되고 나면 delete
로 할당된 메모리를 해제합니다.
-
new int[SIZE]
new
키워드 다음에 데이터 타입을 명시하고[]
연산자 안에 크기를 명시합니다. 크기는 배열의 크기(요소의 수)입니다.C 언어와 마찬가지로 동적 할당 수행 시 힙 영역에 할당됩니다.
이니셜 라이저를 통해 초기화를 수행하지 않으면 쓰레기값을 가집니다.
-
new int[SIZE]{}
이니셜 라이저 안에 아무런 값을 명시하지 않으면 사용한 데이터 타입의 기본값으로 초기화됩니다.
-
new int[SIZE]{ 1, 2, 3 };
세 번째 요소까지 1, 2, 3의 값을 할당하고 나머지 요소는 사용한 데이터 타입의 기본값으로 초기화됩니다.
-
new int[SIZE]{ 1, 2, 3, 4, 5 };
모든 요소에 값을 명시하여 초기화합니다.
-
delete[]
동적 할당된 배열은 여러 개의 요소를 가지기 때문에
delete
키워드 다음에[]
를 반드시 수식해야 합니다. 그리고 댕글링 포인터를 방지하기 위해nullptr
를 할당합니다.
darray2.cpp | |
---|---|
std::vector
(이하 벡터) 컨테이너는 동적 배열을 관리하는 컨테이너로, 포인터를 이용하는 방식보다 매우 간편하고 안전하게 동적 배열을 사용할 수 있습니다. C++ 언어에서 권장되는 사용 방식입니다.
-
std::vector<int> vec1;
크기가 0인 빈 벡터를 생성합니다.
-
std::vector<int> vec2(5);
크기가 5인 벡터를 생성합니다. 모든 요소의 값은 사용한 데이터 타입의 기본값으로 초기화됩니다.
-
std::vector<int> vec3(5, 0);
크기가 5인 벡터를 생성하고 모든 요소의 값을 0으로 초기화합니다.
-
std::vector<int> vec4 = { 1, 2, 3, 4, 5 };
이니셜 라이저를 사용하면 벡터의 크기가 자동으로 설정되고 요소의 값을 원하는대로 초기화할 수 있습니다.
-
할당 해제
std::vector
는 명시적인 메모리 해제를 필요로하지 않습니다. 즉, 자동으로 이루어집니다. 정확히는 범위Scope를 벗어나면 자동으로 할당된 메모리가 해제됩니다. 물론 사용자가 직접shrink_to_fit()
메서드나swap()
메서드를 통해 해제할 수 있지만 불필요한 작업이 될 수 있어 오히려 코드 성능을 낮출 수 있습니다.
할당 해제 후 nullptr
를 할당하는 이유
댕글링 포인터Dangling Pointer를 방지하기 위합니다. 댕글링은 '(미완성된 상태로) 남아 있는'이라는 뜻을 가지고 있습니다. 즉, 허상을 가리키고 있는 포인터를 의미한다고 볼 수 있습니다. 이미 헤어졌는 데 구질구질하게 매달리는 것처럼 말이죠...(?)
댕글링 포인터 방지는 간단합니다. 동적 할당한 배열을 해제 처리한 후 nullptr
을 할당하면 됩니다. delete
로 메모리 해제를 수행하면 해당 메모리는 반환되지만 포인터는 아직도 할당했던 그 메모리 주소를 기억하고 있습니다. 이미 떠난 녀석인데 굳이 기억할 필요는 없습니다. 잘못된 사용으로 인해 접근 시 오류가 발생할 수 있기 때문에 반드시 방지 처리를 해야 합니다. 그리고 안전한 처리(이중 해제 방지 등)를 하려는 목적이 있습니다. 이미 할당 해제된 영역을 또 해제하면 오류가 발생합니다. 이를 방지하기 위해 nullptr
을 할당합니다. nullptr
을 해제 처리하는 건 아무 문제 없기 때문에 그렇습니다.
댕글링 포인터 방지를 위한 습관을 들이시는 게 좋습니다.
동적 배열의 차원
동적 배열도 정적 배열처럼 차원Dimension이 존재하고, 1차원부터 N차원까지 만들 수 있습니다.
이전 3화에서 언급했듯이 차원이라는 단어가 붙은 이유는 배열의 데이터를 저장하고 관리하는 방식이 공간적 구조를 보이기 때문입니다. 논리적인 모델로 나타내는 개념일 뿐 실제로는 1차원 형태로 데이터가 순차적으로 나열되어 배치됩니다. 다만, 1차원까지는 순차적이지만 2차원부터는 다소 배치되는 방식이 다릅니다.
2차원까지는 그럭저럭이지만 3차원부터는 코드를 구현하기 다소 귀찮고 복잡해서 3차원의 내용은 생략합니다.

2차원 배열은 엑셀의 시트 같은 모습을 보입니다. (행, 열) 또는 (X, Y)와 같이 구분할 수 있습니다.
C++ 언어에서 단일 포인터(싱글 포인터)로 2차원 배열의 선언, 초기화, 해제하는 코드입니다.
단일 포인터로 2차원 배열을 동적 할당하여 생성할 경우 메모리에 연속적으로 데이터가 배치됩니다. 다만 접근을 할 때 dArr[(i * col) + j]
처럼 해야합니다. 정적 배열 때 [][]
연산자를 사용할 수가 없습니다. 왜냐하면 1차원 배열처럼 할당되는 것이기 때문에 그렇습니다. 그래서 접근할 때 수학적인(?) 연산을 통해 접근해야 합니다.
단일 포인터로 2차원 배열을 동적 할당하여 생성하는 방식은 1차원 배열처럼 메모리에 연속적으로 배치되기 때문에 캐시 효율성이 좋습니다. 다만.. 다루는 방식이 복잡합니다.
정말로 연속되어 배치되나요?
0x5a2b399422bc, 0x5a2b399422c0, 0x5a2b399422c4,
0x5a2b399422c8, 0x5a2b399422cc,0x5a2b399422d0,
실행 결과를 보시면 알 수 있듯이 int
크기만큼 배치되어 있는 걸 확인할 수 있습니다.
이중 포인터로 2차원 배열을 동적 할당하는 코드입니다. 오류 처리 코드가 있어 다소 길어보이지만 복잡하진 않습니다.
-
동적 할당
일반적으로 2차원 배열을 동적 할당할 때 이중 포인터를 사용합니다. 이 배열은 사실상 포인터 배열이고, 여러 개의 행(1차원 배열)을 동적으로 할당하여 2차원 배열을 구현할 수 있습니다.
dArr
은 이중 포인터라 포인터를 저장합니다. 정확히는int*
포인터를 저장하는 배열입니다. 각 요소는 열을 가리키는 포인터구요.C 언어와는 다르게
sizeof
연산자를 사용할 필요가 없어서 그나마 단순합니다. -
각 행에 열 할당
각 행에 열을 할당합니다.
각 행은
COLS
크기만큼 담기 때문에new int[COLS]
를 수행합니다.dArr[i]
는int
데이터를 가리키는 포인터이기 때문입니다. -
초기화
단일 포인터가 아닌 이중 포인터로 동적 할당된 2차원 배열은
[][]
연산자를 사용할 수 있습니다. 단일 포인터에 비하면 사용하는 방식이 매우 간편하죠. -
할당 해제
각 행은 동적 할당되었기 때문에 반복문을 통해
delete
로 할당된 공간을 해제해야 합니다. 그리고 마지막으로dArr
에 대한 해제 처리를 수행합니다.
이중 포인터로 동적 할당된 2차원 배열은 연속적으로 저장이 안 되나요?
--------------------
0x5b203d8262d0, 0x5b203d8262d4, 0x5b203d8262d8,
--------------------
dArr[row]: 0x5b203d8262b8
--------------------
0x5b203d8262f0, 0x5b203d8262f4, 0x5b203d8262f8,
--------------------
dArr[row]: 0x5b203d8262c0
--------------------
0x5b203d826310, 0x5b203d826314, 0x5b203d826318,
--------------------

사실상 위와 같이 배치되었다고 보시면 됩니다. 이중 포인터로 동적 할당을 수행하면 기본적으로 전체 데이터에 대한 연속성이 보장되지 않습니다. 왜냐하면 각 행에 별도로 동적 할당을 수행하고 있기 때문에 그렇습니다. 힙 메모리의 할당은 연속성이 보장되지 않거든요. 그래서 연속성을 보장하려면 단일 포인터로 수행해야 합니다.
C++ 언어에서는 포인터와 new
를 사용하는 것보다 std::vector
컨테이너를 사용하는 게 편하고 쉽습니다.
-
선언
std::vector
컨테이너도 단일 포인터와 이중 포인터의 방식처럼 연속성과 비연속성을 가질 수 있습니다.연속성을 가지려면
std::vector<int> vec1(ROWS * COLS);
와 같이 작성해야 합니다.ROWS
와COLS
를 곱한 수만큼 한 번에 할당하죠.비연속성은 각 행마다 개별적으로 할당되기 때문에 메모리 공간이 이어있지 않습니다.
-
초기화
연속성을 가지는 컨테이너의 경우
size()
메서드를 호출하면 전체 크기가 반환되기 때문에 별도의 차원 크기가 관리되고 있어야 합니다. 비연속성은 상관없이size()
메서드를 사용하면 됩니다. -
할당 해제
동적 배열의 선언에서 설명했듯,
std::vector
컨테이너는 별도의 해제 처리를 명시적으로 하지 않아도 됩니다.
동적 배열의 크기 변경
동적 배열의 묘미는 크기 변경(리사이징Resizing, 이하 리사이징)입니다.
리사이징은 프로그램 실행 중(런타임)에 배열의 크기를 임의로 조정하는 작업입니다. 기존의 정적 배열은 크기가 고정되어 있어 데이터를 더 추가하고 싶어도 할 수 없었는데, 동적 배열은 이러한 작업이 가능합니다.
다만, 리사이징을 자주 사용할 경우 메모리 단편화가 발생할 수 있어 주의를 요합니다. 또한, 기존의 데이터를 유지하기 위해 복사 비용이 발생하여 성능 저하가 발생할 수 있습니다.
C++ 언어에서 new
키워드로 동적 할당을 수행할 수 있습니다. 물론 리사이징도 가능한데요, C 언어의 realloc()
함수와 같은 기능을 제공하지 않기 때문에 일일이 복사 및 해제 처리하는 코드를 수행해야 합니다. 조금 귀찮죠. 아! 데이터가 손실되어도 상관없다면 기존의 동적 할당된 배열을 해제 처리한 후 다시 동적 할당해도 됩니다.
위 코드 예시에서 dArr
은 newDArr
을 가리키기 때문에 별도로 newDArr
을 해제할 필요 없습니다.
resizing_darray_in_cpp2.cpp | |
---|---|
std::vector
컨테이너의 리사이징은 매우 간단합니다. resize()
메서드를 호출하면 끝입니다. 매개변수로 크기를 받습니다. 크기가 확장되면서 해당 공간에 있는 요소는 기본값으로 초기화됩니다.
이 컨테이너는 메모리 관리가 자동으로 수행되기 때문에 직접 메모리 할당 및 해제를 안 해도 됩니다.
동적 배열의 시간 복잡도
연산 | new |
std::vector |
---|---|---|
접근 | ||
탐색 | ||
삽입 | ||
제거 |
-
접근
동적 배열은 정적 배열과 마찬가지로
[]
연산자로 바로 접근할 수 있기 때문에 상수 시간이 걸립니다. -
탐색
원하는 데이터가 첫 번째 요소로 있으면
이지만... 평균적으로는 입니다. -
삽입 및 제거
빈 공간이 있고 시프트 연산을 전제로 한다면
이 걸립니다. 단, 배열의 마지막 위치에 삽입하거나 삭제한다면 이동할 요소가 없어서 입니다.사실
std::vector
를 제외한 동적 할당 방식은 삽입 및 제거가 불가능합니다. 동적 할당이어도 기본적으로 고정이기 때문입니다. 만약 재할당을 통해 데이터 복사 후 추가한다면 평균적으로 입니다.std::vector
는push_back()
메서드로 맨 끝에 데이터를 삽입할 경우 , 중간 삽입은 삽입 이후에 있는 요소를 이동해야 하기 때문에 입니다. 제거도 마찬가지 입니다.
정리
동적 배열은 기본적으로 정적 배열과 같은 특징을 갖고 있는데 런타임 중 크기를 변경할 수 있어 조금 더 유연한 메모리 효율성을 갖고 있습니다. 물론... 메모리 단편화와 데이터 손실 등의 위험이 존재하지만 미리 충분한 크기를 할당하고 변경 빈도를 낮추면 효율적으로 사용할 수 있습니다. 또는 C++의 std::vector
컨테이너를 사용하는 것도 좋은 방법이죠.
연습문제 1
C# 언어에는 가변 배열Jagged Array이라 불리는 배열이 있습니다. 각 행의 길이가 다른 배열을 갖는 2차원 배열이죠. 이를 구현해보세요.
행의 수는 3개이고, 각 행은 3개, 7개, 4개의 요소를 갖는다.