본문으로 건너뛰기

안동민 개발노트

본문 시작

다차원 배열

행과 열로 구성된 다차원 배열을 선언하고 초기화하며 행 우선 메모리 배치와 인덱스 접근 방식을 이해합니다.

현실 세계의 데이터는 항상 일렬로만 존재하지 않습니다.

예를 들어 수학에서 행렬(Matrix)이나 표(Table), 게임의 맵(Map)과 같이 행과 열의 개념을 가지는 2차원 데이터, 또는 큐브(Cube)나 3D 공간처럼 3차원 이상의 데이터를 표현해야 할 때가 있습니다.

이러한 다차원적인 데이터를 저장하기 위해 C++에서는 다차원 배열(Multidimensional Array)을 제공합니다.

이 장에서는 가장 흔하게 사용되는 2차원 배열을 중심으로 다차원 배열의 개념, 선언, 초기화, 요소 접근 방법을 다룹니다.

2차원 배열의 행·열 인덱스가 메모리에서 어떤 순서로 연속 배치되는지 핵심 흐름을 도식화했습니다.

C++ 다차원 배열이 배열을 원소로 갖는 중첩 타입이고 각 행이 연속해서 행 우선 순서로 배치되며, 각 차원의 유효 인덱스를 지켜야 한다는 구조와 계산표

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

cubeint[3][4] 슬라이스 두 개를 원소로 갖습니다. 차원이 늘어날 때마다 타입과 유효 범위도 한 단계씩 추가됩니다.

Undefined behavior built-in 배열은 첨자를 자동 검사하지 않습니다. 각 루프는 자신이 순회하는 차원의 크기보다 작은 동안만 진행해야 합니다.


다차원 배열이란 무엇인가?

다차원 배열은 배열의 배열이라고 생각할 수 있습니다.

예를 들어 2차원 배열은 1차원 배열들의 배열이며, 3차원 배열은 2차원 배열들의 배열입니다.

C++에서는 다차원 배열을 선언할 때 각 차원의 크기를 대괄호 []로 연달아 명시합니다.

주요 특징
  • 행과 열: 2차원 배열은 흔히 행(row)열(column)로 구성된 표 형태의 데이터 구조로 시각화됩니다.
  • 메모리 연속성: 1차원 배열과 마찬가지로, 다차원 배열의 모든 요소는 메모리에 연속적으로 저장됩니다. 2차원 배열에서는 첫 번째 행의 모든 요소 뒤에 두 번째 행이 이어지는 행 우선(row-major) 순서가 됩니다.
  • 고정된 크기: 이 장에서 다루는 표준 C++ built-in 배열 선언의 각 명시적 bound는 std::size_t로 변환된 상수식이어야 하며, 그 값은 0보다 커야 합니다. 컴파일러 확장으로 제공되는 가변 길이 배열(VLA)은 표준 C++ 기능이 아닙니다.

2차원 배열의 선언

2차원 배열을 선언할 때는 행의 크기와 열의 크기를 각각 대괄호 안에 명시합니다.

2차원 배열 선언 형식
데이터타입 배열이름[행_크기][열_크기];
  • 데이터타입: 배열 요소들의 타입.
  • 배열이름: 배열을 식별하는 이름.
  • 행_크기: std::size_t로 변환된 상수식이며 값이 0보다 큰 행(row) 개수.
  • 열_크기: std::size_t로 변환된 상수식이며 값이 0보다 큰 열(column) 개수.
2차원 배열 선언 예시
int matrix[3][4];     // 3행 4열의 정수형 2차원 배열 선언 (총 3 * 4 = 12개 요소)
double scores[5][2];  // 5명의 학생의 2과목 점수를 저장할 2차원 배열
char board[8][8];     // 체스판이나 바둑판처럼 8x8 크기의 문자형 배열

2차원 배열의 초기화

2차원 배열도 1차원 배열과 유사하게 중괄호 {}를 사용하여 초기화할 수 있습니다.

각 행을 별도의 중괄호로 묶어 명확하게 표현하는 것이 일반적입니다.

모든 요소 초기화
모든 요소 초기화
// 3행 4열 배열 초기화
int matrix[3][4] = {
    {1, 2, 3, 4},    // 0번 행 (matrix[0][0] ~ matrix[0][3])
    {5, 6, 7, 8},    // 1번 행 (matrix[1][0] ~ matrix[1][3])
    {9, 10, 11, 12}  // 2번 행 (matrix[2][0] ~ matrix[2][3])
};

// 학생 3명의 국어, 영어 점수
int studentScores[3][2] = {
    {90, 85}, // 0번 학생 (국어, 영어)
    {70, 75}, // 1번 학생
    {95, 90}  // 2번 학생
};

초기화 리스트의 행 크기 생략: 초기화가 있는 배열 정의에서는 가장 바깥쪽 첫 번째 대괄호 [] (행의 크기)를 생략할 수 있습니다.

이 경우 컴파일러가 초기화 리스트를 바탕으로 행의 개수를 자동으로 계산합니다.

하지만 안쪽 배열의 열 크기는 반드시 명시해야 합니다.

왜냐하면 열의 크기를 알아야 각 행의 시작 위치를 계산할 수 있기 때문입니다.

초기화 리스트의 행 크기 생략
int numbers[][3] = { // 열의 크기는 3으로 명시
    {1, 2, 3},
    {4, 5, 6}
}; // numbers는 2행 3열 배열로 자동 결정 (총 6개 요소)

// 만약 열의 크기를 생략하면 컴파일 오류 발생: int invalid_array[][] = {{1,2},{3,4}}; (X)

일부 요소만 초기화: 이 int 배열처럼 일부 원소만 명시하면 나머지 원소는 value-initialization되고 0 (zero)이 됩니다.

일부 요소만 초기화
int partial[2][3] = {
    {1, 2},  // 0번 행: {1, 2, 0}
    {4}      // 1번 행: {4, 0, 0}
};
// 최종적으로 { {1, 2, 0}, {4, 0, 0} }

내부 중괄호는 생략할 수도 있습니다. 이때 initializer clause는 aggregate의 원소와 하위 aggregate에 순서대로 대응하고, 명시하지 않은 int 원소는 여전히 0이 됩니다.

내부 중괄호 생략
int flat[2][3] = {1, 2, 3, 4};
// 결과: {{1, 2, 3}, {4, 0, 0}}

2차원 배열 초기화는 중괄호를 생략하더라도 안쪽 배열의 열 크기를 기준으로 각 행의 경계가 정해진다는 점이 핵심입니다.

C++ 다차원 built-in 배열 정의에서 모든 크기를 명시하거나 초기화 목록으로 첫 번째 크기만 추론하고, 부분 aggregate 초기화의 남은 정수 원소가 0이 되는 규칙을 비교한 표

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++ 기능이 아닙니다.

Brace elision

안쪽 중괄호를 생략해도 마지막 인덱스가 먼저 변한다

int flat[2][3]{1, 2, 3, 4};
// {{1, 2, 3}, {4, 0, 0}}

초기화 절은 각 하위 aggregate의 원소에 순서대로 대응합니다.

부분 aggregate 초기화의 나머지 int 원소는 value-initialization되며, 너무 많은 초기값은 ill-formed입니다.


2차원 배열 요소에 접근하기

요소에 접근할 때는 첫 번째 인덱스가 행을 고르고, 두 번째 인덱스가 그 행 안의 열을 고른다는 순서가 가장 중요합니다. int matrix[3][4]에서 matrix[1]의 타입은 네 정수를 담은 int[4]이고, 이어지는 [2]가 그 행의 세 번째 int를 선택합니다.

2차원 배열의 각 요소는 두 개의 인덱스(행 인덱스, 열 인덱스)를 사용하여 접근합니다.

두 인덱스 모두 0부터 시작합니다.

2차원 배열 요소 접근 형식
배열이름[행_인덱스][열_인덱스];
  • 행_인덱스: 0부터 행_크기 - 1까지의 정수.
  • 열_인덱스: 0부터 열_크기 - 1까지의 정수.
2차원 배열 요소 접근 예시
#include <cstddef>
#include <iostream>

int main() {
    int studentScores[3][2] = {
        {90, 85}, // 0번 학생: 국어 90, 영어 85
        {70, 75}, // 1번 학생: 국어 70, 영어 75
        {95, 90}  // 2번 학생: 국어 95, 영어 90
    };

    // 특정 요소에 접근
    std::cout << "1번 학생의 국어 점수: " << studentScores[1][0] << std::endl; // 출력: 70
    std::cout << "2번 학생의 영어 점수: " << studentScores[2][1] << std::endl; // 출력: 90

    // 요소 값 변경
    studentScores[0][1] = 88; // 0번 학생의 영어 점수를 85에서 88로 변경
    std::cout << "변경된 0번 학생의 영어 점수: " << studentScores[0][1] << std::endl; // 출력: 88

    // 중첩 루프를 사용하여 모든 요소 출력
    // 행의 개수: sizeof(studentScores) / sizeof(studentScores[0])
    // 열의 개수: sizeof(studentScores[0]) / sizeof(studentScores[0][0])

    std::size_t numRows = sizeof(studentScores) / sizeof(studentScores[0]); // 3
    std::size_t numCols = sizeof(studentScores[0]) / sizeof(studentScores[0][0]); // 2

    std::cout << "\n모든 학생의 점수 표:" << std::endl;
    for (std::size_t i = 0; i < numRows; ++i) { // 행(학생) 루프
        std::cout << i << "번 학생: ";
        for (std::size_t j = 0; j < numCols; ++j) { // 열(과목) 루프
            std::cout << studentScores[i][j] << " ";
        }
        std::cout << std::endl;
    }
    /* 출력:
    0번 학생: 90 88
    1번 학생: 70 75
    2번 학생: 95 90
    */
    return 0;
}

sizeof의 피연산자에는 배열-포인터 변환이 적용되지 않으므로, studentScores가 실제 배열인 현재 범위에서는 전체 행 수가 보존됩니다. 배열을 함수 매개변수로 받으면 바깥 배열 매개변수 자체가 포인터로 조정되므로 같은 식으로 호출자의 전체 행 수를 구할 수 없습니다.


3차원 이상의 다차원 배열

C++은 3차원 이상의 배열도 지원합니다.

3차원 배열은 배열이름[깊이][행][열]과 같은 형식으로 생각할 수 있습니다.

각 차원의 인덱스를 추가하여 접근합니다.

3차원 배열 선언 형식
데이터타입 배열이름[차원1_크기][차원2_크기][차원3_크기]...;
3차원 배열 선언 예시
int cube[2][3][4]; // 2개의 3행 4열 평면을 가진 3차원 배열

// 초기화 예시
int cube_data[2][2][2] = {
    {{1, 2}, {3, 4}},  // 첫 번째 "슬라이스" (0번 깊이)
    {{5, 6}, {7, 8}}   // 두 번째 "슬라이스" (1번 깊이)
};

// 요소 접근 예시
std::cout << cube_data[0][1][0] << std::endl; // 출력: 3 (첫 번째 슬라이스, 두 번째 행, 첫 번째 열)
std::cout << cube_data[1][0][1] << std::endl; // 출력: 6 (두 번째 슬라이스, 첫 번째 행, 두 번째 열)

// 모든 요소 출력 (중첩 for 루프 3개)
for (int d = 0; d < 2; ++d) { // 깊이
    for (int r = 0; r < 2; ++r) { // 행
        for (int c = 0; c < 2; ++c) { // 열
            std::cout << cube_data[d][r][c] << " ";
        }
        std::cout << std::endl;
    }
    std::cout << std::endl; // 각 슬라이스 구분
}

다차원 배열의 차원이 많아질수록 코드의 복잡성이 증가하고, 가독성이 떨어질 수 있습니다.

일반적으로 2차원 배열이 가장 흔하게 사용되며, 3차원 이상은 특수한 경우에만 고려하는 것이 좋습니다.

차원이 늘어날수록 인덱스의 의미와 중첩 루프의 순서를 먼저 정리해 두는 것이 중요합니다. 연속 원소 오프셋은 2차원의 row * 열_크기 + col에서 3차원의 (depth * 행_크기 + row) * 열_크기 + col로 같은 규칙을 한 단계 확장합니다.


다차원 배열과 인덱스 범위 초과

1차원 배열과 마찬가지로, 다차원 배열에서 인덱스 범위를 벗어난 원소를 읽거나 쓰면 동작이 정의되지 않습니다(undefined behavior). 프로그램이 즉시 중단될 수도 있지만, 우연히 동작하는 것처럼 보이거나 다른 데이터를 손상할 수도 있습니다.

C++ 컴파일러는 이러한 오류를 잡아주지 않으므로, 프로그래머가 각 차원의 유효한 인덱스 범위를 철저히 지켜야 합니다.

for 루프를 사용하여 다차원 배열을 순회할 때는 각 루프의 조건이 해당 차원의 크기를 정확히 반영하도록 주의해야 합니다.

범위 초과를 피하려면 행과 열을 각각 독립적인 유효 범위로 확인해야 합니다. 메모리에서 다음 행이 바로 이어지더라도 matrix[0][4]처럼 안쪽 int[4]의 경계를 넘어 원소처럼 역참조하는 표현은 올바른 인덱싱이 아닙니다.