코딩테스트에서 종종 2차원 배열 문제가 나오는데, 2차원 배열의 행과 열을 바꾸면 쉽게 풀릴 것 같은 문제들이 있다. 언젠가 구현해보겠다고 메모에 써놨는데 마침 오늘 크레인 인형뽑기 게임을 다시 풀게 되어서 정리를 해놓기로 마음먹었다.
구글에 검색해보면 첫 번째로 나오는 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] || []
라고 쓴 것은 맨 처음 reduce
의 initialValue
가 []
이기 때문에 [][i]
가 존재하지 않는 경우를 대비한 코드이다.
const transpose = matrix => matrix.reduce(
(result, row) => row.map((e, i) => [...(result[i] || []), e]),
[]
);
코드를 약간 수정하면 위와 같이 쓸 수 있겠다. 아무래도 동작 과정을 여러 번 봐야할 것 같다. 지금 나보고 reduce 사용해서 짜보라면 저렇게 못 짤 것 같다. ㅋㅋㅋ 여전히 한 눈에 안들어옴.. 😬
위의 코드는 행, 열의 개수가 다를 때에도 정상적으로 동작한다.
행과 열을 바꾸는 것은 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 생성자 함수로 빈칸의 개수를 설정해줘도 된다.
둘째. 이브의 블로그 글에서 문제가 됐던 부분은 fill
의 value
로 new Array(3)
, 즉 참조형 데이터인 배열 객체를 넘겨줬던 것이다. 속 배열은 reduce를 사용해서 배열 리터럴로 붙여나가면 해결이 될 것 같다. 또는 Array.from을 사용하는 방법도 있다.
// 만들고자 하는 행의 개수를 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;
}
// 만들고자 하는 행의 개수를 r, 열의 개수를 c 라고 할 때,
Array.from({ length: r }, () => new Array(c).fill(0))
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 parameter
에 mapFn
이 있다. 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
방법이 더 쉽게 느껴진다.
다른 좋은 방법, 쉬운 방법이 있다면 댓글로 알려주세요. ღ'ᴗ'ღ
행, 열 바꾸는 함수를 reduce로 작성해봤는데 행, 열의 개수가 다를 때를 생각하지 못 했어요ㅠㅠ 이 글을 보고 깨달아서 해보니 제가 작성한 함수는 같을 때만 제대로 동작하네요😞 덕분에 깨닫고 고치러 갑니다!!