JS | 2차원 배열의 행과 열 바꾸기 / Transposing a 2D-array in JavaScript

코딩테스트에서 종종 2차원 배열 문제가 나오는데, 2차원 배열의 행과 열을 바꾸면 쉽게 풀릴 것 같은 문제들이 있다. 언젠가 구현해보겠다고 메모에 써놨는데 마침 오늘 크레인 인형뽑기 게임을 다시 풀게 되어서 정리를 해놓기로 마음먹었다.

1. reduce와 map을 활용한 방법

구글에 검색해보면 첫 번째로 나오는 stackoverflow 사이트에 사람들이 여러 가지 방법을 댓글로 써두었다. 먼저 reduce가 들어간 코드부터 살펴보자.

// 코드 복붙이 필요하다면 여기서 하세요~~ 😀
const transpose = matrix => matrix.reduce(
  (result, row) => row.map((_, i) => [...(result[i] || []), row[i]]),
  []
);

그냥 보기만 해도 복잡하다. 어떻게 동작하는 건지 한 눈에 잘 파악이 안 된다. 😭 reduce는 나에게 아직 넘 어렵다..

어떤 구조인지 보면,

1) 우선 2차원 배열 matrix를 인자로 받아, reduce를 진행한다.
2) reduce는 콜백함수(이하-reducer)와 initialValue인 빈 배열 []를 인자로 받는다. matrix가 2차원 배열이므로 reducer에는 행(row)이 하나씩 인자로 들어갈 것이다.
3) reducer는 인자로 받은 행(row)을 가지고 map을 진행한다. 행(row)의 각 원소는 어떤 배열로 mapping이 되는데, row 하나의 모든 원소가 mapping이 되면 그 최종 배열(= map의 리턴값)이 result에 할당되어 다음 row와 함께 reduce가 진행된다.


reducer가 호출될 때마다 값은 어떻게 변경될까? 표로 한 번 정리해보자.

// 입력값

[
  [1, 2, 3, 4], 
  [5, 6, 7, 8], 
  [9, 10, 11, 12], 
  [13, 14, 15, 16]
]

reducer가 호출될 때마다 원래 열(col)이었던 원소가 추가된다. reducer의 리턴값이 accumulator(여기서는 result 변수)에 저장되어 계속 덧붙여나가는 방식이다. 덧붙이는 것은 result가 배열 형태이므로 ...를 이용해 펼치고 다음 원소 row[i]와 함께 새로운 배열을 만든다. row[i]는 결국 map이 보고 있는 현재 원소이므로 _로 대체하면 되겠다. 사용하지 않겠다는 의미의 _도 적절한 변수로 바꾸면 더 괜찮을 것 같다.

map 콜백함수에 result[i] || [] 라고 쓴 것은 맨 처음 reduceinitialValue[]이기 때문에 [][i]가 존재하지 않는 경우를 대비한 코드이다.

const transpose = matrix => matrix.reduce(
  (result, row) => row.map((e, i) => [...(result[i] || []), e]),
  []
);

코드를 약간 수정하면 위와 같이 쓸 수 있겠다. 아무래도 동작 과정을 여러 번 봐야할 것 같다. 지금 나보고 reduce 사용해서 짜보라면 저렇게 못 짤 것 같다. ㅋㅋㅋ 여전히 한 눈에 안들어옴.. 😬
위의 코드는 행, 열의 개수가 다를 때에도 정상적으로 동작한다.


2. Destructuring을 활용한 방법

행과 열을 바꾸는 것은 2차원 배열의 두 인덱스를 서로 바꿔주면 쉽게 해결된다. 이중 for문과 destructuring을 활용하면 쉽게 구현할 수 있다.

function transpose(matrix) {
  for (let i = 0; i < matrix.length; i++) {
    for (let j = 0; j < i; j++) {
      [matrix[i][j], matrix[j][i]] = [matrix[j][i], matrix[i][j]];
    }
  }
}

j의 범위가 좀 안 와닿는데 예시 배열을 하나 정해놓고 해보면 빠짐없이 되긴 한다. i === j일 때는 위치의 변화가 없으므로 i === j인 임의의 선을 기준으로 한 쪽만 보면 되는 것 같다.

이 코드에서는 문제점이 두 가지 있다.

1) 입력 받은 2차원 배열의 값을 직접 바꾼다.
→ 이 문제는 transpose 함수 안에 2차원 배열을 깊은 복사하고 복사한 배열을 수정하여 리턴하는 방식으로 짜면 원본 배열의 손상을 방지할 수 있다.

2) n * n 인 2차원 배열에 대해서만 동작한다.
→ 행과 열의 개수가 다르다면 단순히 인덱스끼리 바꾸는 것으로는 해결이 안 된다. 예를 들면 아래와 같은 결과가 나온다.

// 원래 배열
[
  [1, 2, 3],
  [4, 5, 6]
]

// 기댓값
[
  [1, 4],
  [2, 5],
  [3, 6]
]

// 결과
[
  [1, 4, 3],
  [2, 5, 6]
]

리턴할 배열의 크기를 미리 정해둔 뒤에 destructuring을 하면 잘 동작하지 않을까 하는 생각이 든다. Array.fill을 사용할 때 value(배열을 채울 값)에 객체를 넣을 경우 그 참조만 복사해오기 때문에 주의해야 한다. - 관련 글: 이브의 블로그

Array.fill을 사용하기 전에 두 가지만 짚고 넘어가보자.

let arr = [];
arr.length = 5;
arr.fill(0)
console.log(arr) // [0, 0, 0, 0, 0]

첫째. Array.fill은 채우려는 배열에 원소가 있어야 (정확히는 빈 칸이든 값이 원래 있든, 자리가 마련되어 있어야) 그 원소를 대체하면서 채워지는 것이다. 따라서 위 코드에서 arr.length = 5; 부분이 없다면 결과는 [] 이렇게 나온다. Array 생성자 함수로 빈칸의 개수를 설정해줘도 된다.

둘째. 이브의 블로그 글에서 문제가 됐던 부분은 fillvaluenew Array(3), 즉 참조형 데이터인 배열 객체를 넘겨줬던 것이다. 속 배열은 reduce를 사용해서 배열 리터럴로 붙여나가면 해결이 될 것 같다. 또는 Array.from을 사용하는 방법도 있다.


2-1. reduce를 사용하여 2차원 배열 만들기

// 만들고자 하는 행의 개수를 r, 열의 개수를 c 라고 할 때,
const make2Darr = (r, c) => {
  new Array(r).fill(0).reduce((acc, _) => {
    acc.push(new Array(c).fill(0));
    return acc;
  }, [])
}

reduce를 쓰려면 우선 배열이 있어야 하기 때문에 행의 개수 r만큼의 0을 가지는 배열을 생성. 예를 들어 행이 3개라 치면 [0, 0, 0] 이렇게 만들어둔다. 0이라는 값은 전혀 쓸 일이 없기 때문에 뭘로 넣어도 상관없다. reduce에 초기 값을 빈 배열로 설정해둔다. reducer에서는 acc에 배열을 푸시하고 푸시가 완료된 acc를 다시 리턴해준다. 푸시하는 배열은 열의 개수 c를 길이로 하는 배열이다.

생각해보면 이 과정은 행의 개수 횟수만큼 반복문을 돌며 초기 빈 배열에 길이 c인 배열을 push해주는 것과 같다.

// 만들고자 하는 행의 개수를 r, 열의 개수를 c 라고 할 때,
const makeArr = (r, c) => {
  const temp = [];
  for(let i = 1; i <= r; i++) {
    temp.push(new Array(c).fill(0))
  }
  return temp;
}

2-2. Array.from을 사용하여 2차원 배열 만들기

// 만들고자 하는 행의 개수를 r, 열의 개수를 c 라고 할 때,
Array.from({ length: r }, () => new Array(c).fill(0))

Array.from MDN

Array.from의 필수 파라미터는 배열로 변환하고자 하는유사 배열 객체나 반복 가능한 객체이다. 객체의 프로퍼티 키에 index를 쓰고, length 프로퍼티를 주면 유사배열객체가 된다. 그런데 여기서, 유사배열객체를 배열로 바꿀 때, length개 만큼의 원소가 무조건 생기고 인덱스를 지정해준 프로퍼티는 인덱스에 맞는 자리에 들어가게 된다. 만약 인덱스에 해당하는 값이 없으면 배열에 undefined로 들어가게 된다. 예를 들면 다음과 같다.

const obj = {
  0: 'akak',
  2: 1223445,
  length: 3
}

console.log(Array.from(obj)); // [ 'akak', undefined, 1223445 ]

index 1에 해당하는 프로퍼티가 없어서 undefined로 변환되었다.

그런데 Array.from의 첫번째 optional parametermapFn이 있다. 2-2의 코드에서는 mapFn을 인자로 넣어서 우선 행의 개수 r을 길이로 하는 배열을 만들고, 그 각각의 원소에 열의 개수 c를 길이로 하는 new Array(c).fill(0)을 맵핑하였다.


여기까지 '리턴할 2차원 배열의 크기를 미리 정해주기' 위해 행과 열의 개수가 주어졌을 때 2차원 배열을 만드는 두 가지 방법을 살펴보았다. 그렇다면 이제, 원본 배열의 행과 열의 개수를 알아내 변환할 2차원 배열의 행과 열을 세팅해주고, destructuring을 이용하면 된다.


예시코드

const original = [
  [1, 2, 3],
  [4, 5, 6]
]
const rowOfOrigin = original.length;
const colOfOrigin = original[0].length;
const transposed = Array.from({ length: colOfOrigin }, () => new Array(rowOfOrigin).fill(0));
// 어차피 transposed 배열의 값들을 바꿔줄 것이기 때문에 fill(0)은 안 써도 잘 동작하긴 한다.

for(let i = 0; i < rowOfOrigin; i++) {
  for(let j = 0; j < colOfOrigin; j++) {
    [transposed[j][i]] = [original[i][j]]; // destructuring
  }
}
console.log(transposed); // [ [ 1, 4 ], [ 2, 5 ], [ 3, 6 ] ]

조금 더 일반화시켜서 함수로 만들어보면 다음과 같다.

const transpose = (original) => {
  const rowOfOrigin = original.length;
  const colOfOrigin = original[0].length;
  const transposed = Array.from({ length: colOfOrigin }, () => new Array(rowOfOrigin).fill(0));
  for(let i = 0; i < rowOfOrigin; i++) {
    for(let j = 0; j < colOfOrigin; j++) {
      [transposed[j][i]] = [original[i][j]];
    }
  }
  return transposed;
}

정리

2차원 배열의 행, 열을 바꾸는 함수를 1. reduce 2. destructuring 두 가지 방법으로 구현해보았다. 행, 열의 길이가 다를 때도 잘 작동하도록 함수를 짜봤다. 다른 방법도 많겠지만 일단 저렇게 두 가지 방법을 살펴보았고, 개인적으로 reduce는 아직 코드를 보자마자 직관적으로 이해가 안 돼서 destructuring 방법이 더 쉽게 느껴진다.

다른 좋은 방법, 쉬운 방법이 있다면 댓글로 알려주세요. ღ'ᴗ'ღ

좋은 웹페이지 즐겨찾기