본문 바로가기
Topos Uranos

요약

많은 데이터는 한 줄이 아니라 표 모양을 이룬다. 학생별·시험별로 정리한 한 반의 성적, 게임판, 행렬이 그렇다. 이 수업은 2차원 구조인 표를 C++의 두 가지 형태로 소개한다. 실행할 때 크기가 정해지는 벡터의 벡터와, 컴파일할 때 크기를 아는 배열의 배열이다. 이어서 행 단위 순회와 열 단위 순회, 비용이 삼차인 행렬의 전치와 곱, 가장자리를 조심하게 만드는 칸의 이웃 칸, 그리고 메모리가 보는 방식대로 인덱스 i * columns + j를 써서 하나의 벡터에 저장한 표를 다룬다.

학습 목표

  1. 표를 std::vector<std::vector<T>> 타입이나 std::array의 std::array 타입으로 나타내고, 크기를 컴파일할 때 아는지에 따라 둘 중 하나를 고를 수 있다.
  2. 표를 행 단위와 열 단위로 순회하고, 각 행과 각 열의 합계와 평균을 계산할 수 있다.
  3. 어떤 인덱스도 가장자리 밖으로 나가지 않도록 주의하면서 표 연산(전치, 행렬의 곱, 한 칸의 이웃 칸 세기)을 구현할 수 있다.
  4. 인덱스 i * columns + j를 써서 표를 평탄화한 벡터로 나타내고, 칸과 그 위치 사이의 대응을 예를 들어 설명할 수 있다.

표

행과 열

mm개의 행과 nn개의 열로 된 표는 같은 타입의 데이터 m⋅nm \cdot n개의 모음이며, 각 데이터는 두 인덱스로 지정된다. 하나는 행 인덱스 ii로, 0≤i<m0 \leq i < m 범위에 있고, 다른 하나는 열 인덱스 jj로, 0≤j<n0 \leq j < n 범위에 있다. 수학은 이것을 행렬이라 부르고 aija_{ij} 형태로 쓴다. 대수학과 기하학 II 강좌는 행렬과 그 연산을 다루고, 이 수업은 그중 몇 가지를 프로그램으로 쓴다. 인덱스의 순서에는 예외가 없다. 먼저 행, 나중에 열이다. 둘을 혼동하는 것이 표에서 가장 흔한 오류이며, 두 인덱스가 같은 타입이므로 컴파일러는 이를 경고할 수 없다.

벡터 수업은 크기가 변하는 시퀀스를 소개했다. 그렇다면 표는 행의 시퀀스이고, 각 행은 다시 시퀀스이다. 곧 원소가 벡터인 벡터, std::vector<std::vector<int>> 타입이다.

C++ 프로그램
#include <print>#include <vector>​int main(){    std::vector<std::vector<int>> table{{1, 2, 3}, {4, 5, 6}};    std::println("{}", table);    std::println("행 {}개, 열 {}개", table.size(), table[0].size());    std::println("1행: {}; 1행 2열: {}", table[1], table[1][2]);    table[0][1] = 20;    for (const std::vector<int>& row : table) {        for (const int value : row) {            std::print("{:>4}", value);        }        std::println("");    }    const std::vector<std::vector<int>> zeros(3, std::vector<int>(4));    std::println("{}", zeros);}
출력
[[1, 2, 3], [4, 5, 6]]행 2개, 열 3개1행: [4, 5, 6]; 1행 2열: 6   1  20   3   4   5   6[[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]]

6번 줄은 표를 행의 목록으로 쓰며, 각 행은 자기 중괄호로 감싼다. 범위 서식은 이를 대괄호로 같은 모양으로 출력한다. table.size() 값은 행의 수이고, table[0].size() 값은 첫 행의 길이로, 제대로 된 표에서는 열의 수이다. table[1] 값은 1행 전체, 곧 하나의 std::vector<int> 객체이고, table[1][2] 값은 그 행의 원소 2이다. 이중 인덱스는 새로운 연산이 아니라 연속된 두 번의 접근이며, 첫째가 행을 고르고 둘째가 그 행 안에서 열을 고른다. 11번 줄부터 16번 줄까지는 중첩된 두 개의 범위 기반 for 반복문으로 표를 직사각형 모양으로 쓴다. 행은 회차마다 복사하지 않도록 상수 참조로 받는다. 17번 줄은 0으로 채운 3행 4열의 표를 만든다. 벡터 수업에서처럼 소괄호를 쓰면, 0 네 개로 된 행의 복사본 3개를 요청하게 된다.

예시행 단위 순회와 열 단위 순회

3행 4열의 표를 먼저 한 행씩(i 변수는 바깥 반복문에), 그런 뒤 한 열씩(반복문을 맞바꾸어) 순회한다. 각 칸에는 방문 순서가 표시된다.

증명

  1. 표. 행 인덱스는 왼쪽에, 열 인덱스는 위쪽에 있다.

  2. 행 단위: i=0i = 0일 때 안쪽 반복문은 j=0,1,2,3j = 0, 1, 2, 3을 순회한다.

  3. i=1i = 1일 때 안쪽 반복문은 다시 j=0j = 0에서 시작한다.

  4. i=2i = 2일 때: 표는 글을 읽듯이 한 줄씩 순회되었다.

  5. 열 단위: j=0j = 0일 때 안쪽 반복문은 i=0,1,2i = 0, 1, 2를 순회한다.

  6. j=1j = 1일 때, 두 번째 열.

  7. j=2j = 2일 때.

  8. j=3j = 3일 때. 각 칸은 다시 정확히 한 번씩 방문되었다. 반복문을 맞바꾸어도 무엇을 방문하는지는 바뀌지 않고, 언제 방문하는지가 바뀐다.

길이가 다른 행

벡터의 벡터의 행들이 같은 길이여야 할 이유는 없다. 각 행은 독립된 벡터이기 때문이다. 파스칼의 삼각형처럼 바로 그것이 원하는 바일 때도 있다. 그러나 정의상 직사각형인 표에서 더 짧은 행은 데이터의 오류이며, table[0].size() 값을 열의 수로 쓰는 프로그램은 그 행 밖을 읽는다. 따라서 밖에서 들어온 표는 확인해야 한다.

C++ 프로그램
#include <print>#include <vector>​// 사전 조건: 없음.// 사후 조건: table에 행이 하나 이상 있고 모든// 행의 길이가 첫 행과 같으면 true를 반환한다.bool isRectangular(const std::vector<std::vector<int>>& table){    if (table.empty()) {        return false;    }    for (const std::vector<int>& row : table) {        if (row.size() != table[0].size()) {            return false;        }    }    return true;}​int main(){    const std::vector<std::vector<int>> pascal{{1}, {1, 1}, {1, 2, 1}, {1, 3, 3, 1}};    const std::vector<std::vector<int>> grid{{1, 2, 3}, {4, 5, 6}};    std::println("{} {}", pascal, isRectangular(pascal));    std::println("{} {}", grid, isRectangular(grid));}
출력
[[1], [1, 1], [1, 2, 1], [1, 3, 3, 1]] false[[1, 2, 3], [4, 5, 6]] true

이 함수는 행이 없는 표를 제외한다. 그런 표에는 table[0] 원소가 없다. 이 수업에서 table[0].size() 값을 읽는 함수들은 사전 조건으로 직사각형이고 비어 있지 않은 표를 요구하며, isRectangular 함수로 이를 보장할 수 있다.

배열의 배열

크기를 컴파일할 때 알면(체스판, 마방진) 표는 std::array의 std::array 타입일 수 있다. std::array<std::array<int, 3>, 3> 타입은 안쪽에서 바깥쪽으로 “정수 3개짜리 행 3개”로 읽는다. 행, 열, 대각선의 합이 모두 15인 3차 마방진을 예로 든다.

C++ 프로그램
#include <array>#include <print>​int main(){    constexpr std::array<std::array<int, 3>, 3> magic{{        {2, 7, 6},        {9, 5, 1},        {4, 3, 8},    }};    std::println("{}", magic);    std::println("가운데 {}, {}바이트", magic[1][1], sizeof(magic));}
출력
[[2, 7, 6], [9, 5, 1], [4, 3, 8]]가운데 5, 36바이트

중괄호는 양 끝에 세 개씩 있다. std::array 타입의 멤버는 C 배열 하나뿐이어서, 가장 바깥 중괄호는 std::array 타입의 것이고, 두 번째는 그 내부 배열의 것이며, 세 번째만 각 행의 것이다. 중괄호가 두 개면 첫 행이 내부 배열의 초기화자로 받아들여지고, 두 번째 행은 더 초기화할 것이 없다.

C++ 프로그램
#include <array>#include <print>​int main(){    const std::array<std::array<int, 3>, 2> table{{1, 2, 3}, {4, 5, 6}};    std::println("{}", table);}
컴파일러 오류 메시지
main.cpp: In function ‘int main()’:main.cpp:6:71: error: too many initializers for ‘const std::array<std::array<int, 3>, 2>’    6 |     const std::array<std::array<int, 3>, 2> table{{1, 2, 3}, {4, 5, 6}};      |                                                                       ^

Too many initializers: {4, 5, 6} 목록이 남는다. 평평한 목록 {1, 2, 3, 4, 5, 6}도 컴파일되고 행을 차례로 채우지만, 표의 모양을 감춘다. 이 강좌는 중괄호 세 겹을 쓴다.

sizeof 연산자의 36바이트는 4바이트 int 아홉 개의 크기이며(흔한 크기이지만 표준이 보장하지는 않는다), 그 밖에는 아무것도 없다. 배열의 배열은 행을 차례로 하나의 블록에 저장한다. 반면 벡터의 벡터는 행들을 담는 블록 외에 행마다 블록을 하나씩 요청하며, 그 블록들이 붙어 있을 필요는 없다. 따라서 선택은 배열 수업과 벡터 수업의 규칙을 따른다.

  • 두 크기를 모두 컴파일할 때 알면 배열의 배열: 블록이 하나이고, 직사각형 모양이 타입으로 보장되며, constexpr 키워드도 쓸 수 있다.
  • 어느 한쪽을 실행할 때에만 알면 벡터의 벡터. 이때는 행들의 길이가 같은지 확인할 의무가 있다. 아니면 마지막 절의 평탄화한 벡터.