다차원 배열
행과 열로 구성된 다차원 배열을 선언하고 초기화하며 행 우선 메모리 배치와 인덱스 접근 방식을 이해합니다.
성적표나 게임 맵, 행렬처럼 행과 열로 이루어진 데이터는 값을 한 줄로 늘어놓기보다 표 모양 그대로 다루는 편이 자연스럽습니다. C++에서는 원소가 다시 배열인 배열, 즉 다차원 배열(multidimensional array)로 이런 데이터를 표현합니다.
이 글은 가장 많이 쓰는 2차원 배열을 중심으로 선언과 초기화, 두 인덱스로 원소를 고르는 방법, 원소가 메모리에 놓이는 행 우선 순서를 봅니다. 3차원 배열과, 함수에 넘기거나 크기를 실행 중에 정해야 할 때 쓰는 대안도 함께 정리합니다.
배열의 배열
int matrix[3][4]는 int 네 개짜리 배열 int[4]를 원소로 세 개 가진 배열입니다. 흔히 3행 4열의 표로 생각하며, 첫 번째 인덱스가 행(row), 두 번째 인덱스가 열(column)을 고릅니다.
C++ · NESTED ARRAY OBJECT
다차원 배열은 행 배열을 원소로 갖고 차원별 경계를 지킨다
int matrix[3][4]는 int[4] 행 세 개를 원소로 갖는 하나의 배열입니다. 모든 원소는 연속되고, 마지막 차원의 인덱스가 가장 빠르게 변합니다.
행을 고른 뒤 그 행의 원소를 고른다
int matrix[3][4];
matrix // int[3][4]
matrix[row] // int[4]
matrix[row][col] // int
첫 첨자는 바깥 배열의 원소인 행을 선택하고, 둘째 첨자는 선택된 int[4] 안의 원소를 선택합니다.
논리적인 표는 행 우선 한 줄로 이어진다
cell [0][0] [0][1] [0][2] [0][3]
offset 0 1 2 3
cell [1][0] [1][1] [1][2] [1][3]
offset 4 5 6 7
cell [2][0] [2][1] [2][2] [2][3]
offset 8 9 10 11
원소 오프셋 = row * 4 + col
matrix[1][2]의 원소 오프셋은 1 * 4 + 2, 즉 6입니다.
행과 열을 각각 검증한다
0 <= row && row < 3
0 <= col && col < 4
matrix[3][0]과 matrix[1][4]는 모두 범위 밖입니다. 인접 행의 메모리가 이어져 보여도 안쪽 배열의 경계를 넘어 원소처럼 역참조하면 동작이 정의되지 않습니다.
3차원도 같은 중첩 규칙을 반복한다
int cube[2][3][4];
cube[depth][row][col]
offset =
(depth * 3 + row) * 4 + col
cube는 int[3][4] 슬라이스 두 개를 원소로 갖습니다. 차원이 늘어날 때마다 타입과 유효 범위도 한 단계씩 추가됩니다.
Undefined behavior built-in 배열은 첨자를 자동 검사하지 않습니다. 각 루프는 자신이 순회하는 차원의 크기보다 작은 동안만 진행해야 합니다.
그림처럼 matrix[1]은 둘째 행 전체인 int[4] 배열이고, 여기에 [2]를 붙인 matrix[1][2]가 그 행의 셋째 int입니다. 열두 원소는 0행의 네 원소, 1행의 네 원소, 2행의 네 원소 순서로 빈틈없이 이어지므로, 마지막 인덱스인 열이 가장 빨리 바뀝니다. 이런 배치를 행 우선(row-major) 순서라고 하며, 첫 원소를 0번으로 세면 matrix[row][col]은 row * 4 + col번 자리에 있어 matrix[1][2]는 6번 자리, 즉 일곱 번째 원소입니다.
배치가 이어져 있어도 각 행은 int[4]라는 독립된 배열입니다. 그래서 matrix[0][4]를 다음 행의 첫 원소처럼 쓰는 것은 올바른 인덱싱이 아니며, 열 인덱스는 0부터 3까지만 쓸 수 있습니다.
선언과 초기화
2차원 배열은 행의 개수와 열의 개수를 대괄호에 차례로 적어 선언합니다. 1차원 배열처럼 각 크기는 0보다 큰 상수식이어야 합니다.
int matrix[3][4]{}; // 3행 4열, 원소 12개, 모두 0
double scores[5][2]{}; // 학생 5명의 2과목 점수
char board[8][8]{}; // 8x8 판초기화할 때는 보통 행마다 중괄호를 따로 묶습니다. 행 안의 값이 모자라면 남은 원소는 0이 되고, 초기화가 있으면 첫 번째 크기인 행의 개수는 생략할 수 있습니다.
int full[2][3]{
{1, 2, 3},
{4, 5, 6}
};
int inferred[][3]{
{1, 2, 3},
{4, 5, 6}
}; // 행 2개로 int[2][3]
int partial[2][3]{
{1, 2}, // 1 2 0
{4} // 4 0 0
};
int flat[2][3]{1, 2, 3, 4}; // {1, 2, 3}, {4, 0, 0}
// int invalid[][]{{1, 2, 3}}; // 컴파일 오류: 열 크기가 필요행의 개수는 생략할 수 있지만 열의 개수는 생략할 수 없습니다. 바깥 배열의 원소 타입인 int[3]이 정해져야 행 하나의 크기를 알고, 다음 행이 어디서 시작하는지 정할 수 있기 때문입니다. flat처럼 안쪽 중괄호를 생략하면 값이 행 우선 순서로 0행부터 채워지므로, 행마다 중괄호를 묶은 partial과 결과가 다릅니다. 아래 그림은 이 세 가지 초기화 형태를 비교한 것입니다.
C++ · AGGREGATE INITIALIZATION
첫 크기는 추론할 수 있지만 안쪽 배열 크기는 남겨야 한다
초기화가 있는 배열 정의에서는 바깥쪽 첫 크기만 목록에서 추론할 수 있습니다. 안쪽 크기는 각 원소 배열의 완전한 타입을 만들기 위해 남겨야 합니다.
| 형태 | 정의 | 컴파일러가 결정하는 계약 |
|---|---|---|
| 모든 크기 명시 | int a[2][3]{{1, 2, 3}, {4, 5, 6}}; |
int[2][3], 여섯 원소를 행별로 초기화 |
| 첫 크기 추론 | int b[][3]{{1, 2, 3}, {4, 5, 6}}; |
초기화된 행 두 개에서 int[2][3] 추론 |
| 부분 초기화 | int c[2][3]{{1, 2}, {4}}; |
{{1, 2, 0}, {4, 0, 0}} |
모든 크기 명시
int a[2][3]{
{1, 2, 3},
{4, 5, 6}
};
타입은 int[2][3]입니다.
첫 크기 추론
int b[][3]{
{1, 2, 3},
{4, 5, 6}
};
행 두 개에서 첫 크기 2를 추론합니다.
부분 초기화
int c[2][3]{
{1, 2},
{4}
};
// {{1, 2, 0}, {4, 0, 0}}
안쪽 크기는 생략하지 않는다
int valid[][3]{{1, 2, 3}};
// ill-formed
int invalid[][]{{1, 2, 3}};
바깥 배열의 원소 타입인 int[3]이 먼저 완전해야 다음 행의 시작 위치와 전체 타입을 결정할 수 있습니다.
표준 built-in 배열의 크기는 고정된다
constexpr std::size_t rows = 2;
int fixed[rows][3]{};
각 명시적 bound는 std::size_t로 변환된 상수식이어야 하며 값은 0보다 커야 합니다. 실행 중 정해지는 VLA는 표준 C++ 기능이 아닙니다.
안쪽 중괄호를 생략해도 마지막 인덱스가 먼저 변한다
int flat[2][3]{1, 2, 3, 4};
// {{1, 2, 3}, {4, 0, 0}}
초기화 절은 각 하위 aggregate의 원소에 순서대로 대응합니다.
부분 aggregate 초기화의 나머지 int 원소는 value-initialization되며, 너무 많은 초기값은 ill-formed입니다.
원소 읽기와 순회
원소는 배열[행][열]로 읽고 씁니다. 모든 원소를 다룰 때는 for 반복문에서 본 중첩 for를 써서, 바깥 루프가 행을, 안쪽 루프가 열을 돕니다.
#include <cstddef>
#include <iostream>
#include <iterator>
int main() {
int scores[3][2]{
{90, 85},
{70, 75},
{95, 90}
};
std::cout << scores[1][0] << ' ' << scores[2][1] << '\n';
scores[0][1] = 88;
for (std::size_t i = 0; i < std::size(scores); ++i) {
std::cout << "student " << i << ':';
for (std::size_t j = 0; j < std::size(scores[i]); ++j) {
std::cout << ' ' << scores[i][j];
}
std::cout << '\n';
}
}70 90
student 0: 90 88
student 1: 70 75
student 2: 95 90std::size(scores)는 행의 개수 3이고, std::size(scores[i])는 한 행의 원소 개수 2입니다. scores[1][0]은 1행의 0열인 70, scores[2][1]은 2행의 1열인 90입니다. scores[0][1] = 88로 0행 1열만 바뀌었으므로 첫 줄이 90 88이 됩니다.
범위 기반 for로도 순회할 수 있습니다. 바깥 루프가 받는 것은 행 배열이므로, 복사하지 않고 그 행을 그대로 가리키도록 const auto&로 받습니다.
#include <iostream>
#include <iterator>
int main() {
int grid[2][3]{{1, 2}, {4}};
int sum{};
for (const auto& row : grid) {
for (int value : row) {
sum += value;
}
}
std::cout << std::size(grid) << ' ' << std::size(grid[0]) << '\n';
std::cout << grid[0][2] << ' ' << grid[1][0] << ' '
<< grid[1][2] << ' ' << sum << '\n';
}2 3
0 4 0 7grid의 행은 {1, 2, 0}과 {4, 0, 0}이므로 원소의 합은 7입니다. row를 auto로 받으면 배열은 복사되지 않고 포인터로 바뀌어 안쪽 범위 기반 for를 쓸 수 없으므로, 행을 받을 때는 참조로 받습니다.
3차원 배열
차원은 더 늘릴 수 있습니다. int cube[2][2][2]는 2행 2열 평면 두 개로 생각할 수 있으며, 원소는 cube[깊이][행][열]로 고릅니다.
#include <iostream>
int main() {
int cube[2][2][2]{
{{1, 2}, {3, 4}},
{{5, 6}, {7, 8}}
};
std::cout << cube[0][1][0] << ' ' << cube[1][0][1] << '\n';
}3 6cube[0][1][0]은 0번 평면의 1행 0열인 3이고, cube[1][0][1]은 1번 평면의 0행 1열인 6입니다. 3차원에서도 마지막 인덱스가 가장 빨리 바뀌므로, cube[d][r][c]는 첫 원소를 0번으로 셀 때 (d * 2 + r) * 2 + c번 자리에 있습니다. 차원이 늘수록 인덱스의 뜻과 중첩 루프의 순서를 따라가기 어려워지므로, 3차원 이상은 꼭 필요할 때만 씁니다.
함수에 넘기기와 대안
2차원 배열을 함수 매개변수로 받으면 바깥 배열은 포인터가 되어 행의 개수를 잃지만, 열의 개수는 타입에 남아야 합니다. 그래서 void print(const int m[][3], std::size_t rows)처럼 열 크기는 매개변수 타입에 적고 행의 개수는 따로 넘깁니다. 이 방식은 열 크기가 다른 배열을 받을 수 없으므로 쓰임이 좁습니다.
| 표현 | 특징 |
|---|---|
std::array<std::array<int, 3>, 2> | 크기가 타입에 남고, 값처럼 복사하며 size()를 씀 |
std::vector<std::vector<int>> | 행과 열을 실행 중에 정할 수 있지만, 행마다 저장 공간이 따로 있음 |
std::vector<int>와 row * cols + col | 원소를 한 줄로 이어 저장하고, 위치를 직접 계산 |
크기가 정해진 작은 표는 std::array의 중첩이 편합니다. 실행 중에 크기가 정해지는 큰 격자는 원소를 하나의 std::vector에 이어 담고 row * cols + col로 위치를 계산하는 방법이 자주 쓰이며, 내장 2차원 배열의 행 우선 배치를 직접 만드는 셈입니다. 다차원 배열에서도 인덱스가 범위를 벗어나면 정의되지 않은 동작이므로, 행과 열을 각각의 크기 안에서 검사해야 합니다.
더 깊이: C++23의 다차원 첨자와 std::mdspan
row * cols + col로 위치를 직접 계산하던 일은 C++23에서 표준 도구로 할 수 있게 되었습니다. 먼저 C++23은 operator[]가 인자를 여러 개 받을 수 있게 해, 클래스가 grid[row, col] 같은 첨자를 정의할 수 있게 했습니다. 그 준비로 C++20은 첨자 안에 괄호 없이 쉼표 연산자를 쓰는 a[b, c]를 권장하지 않는 기능으로 바꾸었고, C++23부터 이 모양은 오버로드한 operator[]를 부르는 뜻으로만 쓰입니다. 그래서 내장 배열에 a[1, 2]를 쓰면 쉼표 연산자로 해석되지 않고 컴파일되지 않으며, 쉼표 연산자가 꼭 필요하면 a[(b, c)]처럼 괄호로 감쌉니다.
<mdspan>의 std::mdspan은 이미 있는 원소를 소유하지 않고 다차원으로 보여 주는 뷰입니다. 앞의 std::vector<int>를 std::mdspan으로 감싸고 행과 열의 크기를 알려 주면 m[row, col]로 원소에 접근할 수 있으며, 기본 배치인 std::layout_right는 오른쪽 인덱스가 가장 빨리 바뀌는 행 우선 배치입니다. 배치 정책을 열 우선 배치인 std::layout_left로 바꾸면, 열 단위로 이어 저장한 데이터도 같은 m[row, col] 문법으로 다룰 수 있습니다.
다차원 첨자는 GCC 12부터 쓸 수 있지만, libstdc++의 std::mdspan은 GCC 16에서야 들어왔습니다. 그래서 이 교재의 C++20 환경에서는 본문처럼 위치를 직접 계산하고, 쓰는 컴파일러가 지원하는지 cppreference의 std::mdspan 문서와 지원표를 확인한 뒤 도입합니다.
정리
- 다차원 배열은 배열을 원소로 갖는 배열이며,
matrix[row]는 한 행 배열,matrix[row][col]은 그 행의 원소입니다. - 원소는 행 우선 순서로 이어서 저장되어 마지막 인덱스가 가장 빨리 바뀌고,
[row][col]은 첫 원소를 0번으로 셀 때row * 열의 수 + col번 자리의 원소입니다. - 초기화에서 행의 개수는 생략할 수 있지만 열의 개수는 생략할 수 없고, 남은 원소는 0이 됩니다.
- 안쪽 중괄호를 생략하면 값이 행 우선 순서로 채워지므로, 행마다 중괄호를 묶은 초기화와 결과가 다를 수 있습니다.
- 범위 기반
for로 행을 받을 때는const auto&를 쓰고, 각 차원의 인덱스는 그 차원의 크기 안에서만 씁니다.
연습 문제
1. 안쪽 중괄호 생략
grid_sum.cpp의 초기화만 int grid[2][3]{1, 2, 3, 4};로 바꾸면 둘째 출력 줄은 무엇인가요?
해설 보기
값이 행 우선 순서로 채워져 행은 {1, 2, 3}과 {4, 0, 0}이 됩니다. grid[0][2]는 3, grid[1][0]은 4, grid[1][2]는 0이고 합은 10이므로 3 4 0 10이 출력됩니다.
2. 원소의 위치
3행 4열 배열 int m[3][4]에서 m[2][1]은 첫 원소부터 몇 번째에 있나요? m[0][4]로 m[1][0]을 읽으려는 코드는 왜 잘못인가요?
해설 보기
첫 원소를 0번으로 세면 2 * 4 + 1, 즉 9번 자리이므로 열 번째 원소이고, 바이트로는 첫 원소에서 9 * sizeof(int)만큼 떨어져 있습니다. m[0]은 원소가 네 개인 int[4] 배열이어서 열 인덱스는 0부터 3까지만 올바릅니다. 메모리에서 다음 행이 바로 이어지더라도 m[0][4]는 그 행 배열의 범위를 벗어난 접근입니다.
3. 루프 순서 바꾸기
student_scores.cpp에서 값을 바꾼 뒤, 바깥 루프가 열 j를, 안쪽 루프가 행 i를 돌며 scores[i][j]를 한 줄로 이어 출력하면 어떤 순서로 나오나요?
해설 보기
0열의 값을 모든 행에서 먼저 출력하고, 그다음 1열을 출력하므로 90 70 95 88 75 90입니다. 저장 순서인 90 88 70 75 95 90과 다르며, 같은 원소를 모두 다루더라도 루프 순서에 따라 출력 순서가 달라집니다.
다음 글 C 스타일 문자열에서는 char 배열 끝에 널 문자를 두어 문자열을 나타내는 C 방식의 문자열과, 그 길이와 크기를 다룰 때의 주의점을 봅니다.