프로그래머스 - 행렬의 곱셈 sub. 파이썬에서 * 기호의 다양한 용도

프로그래머스

문제

2차원 행렬 arr1과 arr2를 입력 받아, arr1에 arr2를 곱한 결과를 반환하는 함수, solution을 완성해주세요.

제한 조건
행렬 arr1, arr2의 행과 열의 길이는 2 이상 100 이하입니다.
행렬 arr1, arr2의 원소는 -10 이상 20 이하인 자연수입니다.
곱할 수 있는 배열만 주어집니다.

def solution(arr1, arr2):
    answer = []

    for arr1_row in arr1:
        print(f'arr1_row = {arr1_row}')
        answer_row = []
        for arr2_col in zip(*arr2):
            print(f'arr2_col = {arr2_col}')
            answer_index = []
            for i in zip(arr1_row, arr2_col):
                answer_index.append(i[0] * i[1])
            answer_index = sum(answer_index)
            answer_row.append(answer_index)
        answer.append(answer_row)

    return answer
>> 리팩토링 한 코드

def solution2(arr1, arr2):
    answer = []

    for arr1_row in arr1:
        answer_row = []
        for arr2_col in zip(*arr2):
            answer_index = 0
            for x, y in zip(arr1_row, arr2_col):
                answer_index += x * y
            answer_row.append(answer_index)
        answer.append(answer_row)

    return answer

내 생각엔 이랬는데

이걸 푸는 데 며칠이 걸렸는지 모르겠다. 물론 온전히 이 문제에만 매달려 있던 건 아니었지만... 남는 시간 동안 계속 고민하게 만들긴 했다. 종이에 풀면 1분 안에 호다닥 풀 텐데... numpy 라이브러리 썼으면 쉽게 풀었을 텐데.. 그래도 문제 해결이 목적이 아니었으니 인고했다.

제일 애먹었던 건 반복문을 3중으로 중첩해서 써서 푸는 게 맞는가에 대한 확신을 세우는 부분이었다. 결론적으로 3중첩을 해야 하더라...

행렬과 행렬을 곱하는 게 너무 오랜만이라 우선 종이에 임의의 테스트 케이스를 잔뜩 만들고 행렬 간 곱셈의 원리부터 정리해봤다.

행렬 A*B의 과정

우선 행렬의 곱셈이 성립하기 위해선 행렬 A의 열의 수와 행렬 B의 행의 수가 일치해야 하지만 문제의 제한 조건에서 곱할 수 있는 배열만 준다고 했으니 이건 쉽게 넘어갈 수 있었다.

다음은 행렬을 곱하는 과정을 도식화해보았다.

행렬 A가 i * j의 크기를, 행렬 B가 j * k의 크기를 갖고 있다면 새로 생성될 행렬 X의 크기는 i * k일 것이다. 이때 행렬 X의 (1, 1) 번째 요소의 값을 구하기 위해선 행렬 A의 첫 번째 행과 행렬 B의 첫 번째 열이 필요하다. 며칠 고민하게 한 부분이 바로 여기였다.

a = [[2, 3, 2], [4, 2, 4], [3, 1, 4]]
b = [[5, 4, 3], [2, 4, 1], [3, 1, 1]]
일 때

answer[0][0] = a[0][0] * b[0][0] + a[0][1] * b[1][0] + a[0][2] * b[2][0]
answer[0][1] = a[0][0] * b[0][1] + a[0][1] * b[1][1] + a[0][2] * b[2][1]
∙∙∙
answer[i][k] = a[i][0] * b[0][k] + a[i][1] * b[1][k] + ∙∙∙ 
			   + a[i][j] * b[j][k]

answer = [[22, 22, 11], [36, 28, 18], [29, 20, 14]]

위와 같은 과정으로 동작하게 해줘야 한다는 건 금방 찾을 수 있었다.

그래서 그다음은...? 행렬 A의 행을 꺼내는 건 쉽다고 쳐도 행렬 B를 열 단위로 어떻게 꺼낼지 한참 고민했다.

아예 행렬 B의 행과 열을 뒤집은 새로운 행렬로 새로 구성하는 게 더 빠르지 않을까라는 생각이 미쳤을 때쯤 파이썬에서 Asterisk 기호(*) 의 다양한 역할에 대한 글을 보게 됐다. 더불어 zip() 함수에 iterables 한 인자를 넣을 때 Asterisk 기호(*)를 덧붙여 함수를 호출하면 unzip 할 수 있음을 알게 됐다.

이 뒤로 생각해 둔 과정을 구현하는 데는 그리 오랜 시간이 걸리지 않았고 리팩토링을 통한 성능 개선도 할 수 있었다. 리팩토링을 거친 코드를 comprehension식으로 변환한다면 조금 더 성능을 향상시킬 수 있겠지만 이에 관한 코드는 프로그래머스에서도 볼 수 있어 넘어갈까 한다.

Python에서 Asterisk 기호(*)의 용도

파이썬에 * 기호는 대략 4가지 기능이 있다.

  1. 연산자

기본 중의 기본. 우항과 좌항의 곱셈을 할 때 쓰는 연산자. 덤으로 ** 는 제곱 연산을 할 때 쓴다.

  1. list, dictionary 등의 컨테이너 자료형의 반복 확장

  1. 함수에 갯수가 정해지지 않은 인자를 받고자 할 때

함수를 정의할 때 함수 내부에 들어갈 인자를 정의할 때 인수를 꼭 정의해 주어야 한다. 만약 이 인자가 몇 개 들어올지 모른다면? 혹은 어떤 인자라도 모두 받아 처리해야 한다면? 이럴 때 가변 인자를 설정해 사용하며 다음과 같은 모습이다.

def func(*args, **kwargs)
	pass

이 역시 중요한 개념이긴 하지만 이번 글의 주제가 아니기에 다음에 다시 포스팅 할까 한다.

  1. 컨테이너 자료형을 unpack 할 때

가장 간단한 예제를 먼저 들어볼까 한다.

리스트가 벗겨졌다. 이걸 어디에 쓰냐면

이런 식으로 가변적으로 unpack 해서 할당할 수도 있다.

혹은 이번 문제를 풀 때 정말 유용하게 썼던 기능인데 zip() 함수에 인자를 넣을 때 *를 통해 unzip 을 할 수도 있다. 이는 공식문서에서도 설명하고 있다.

unzip을 통해 문제에 어떻게 적용했는지는 다음과 같다.

arr2 = [[5, 4, 3], [2, 4, 1], [3, 1, 1]] 의 행렬을 예로 들어보자.

현재 각 행의 0, 1, 2번째 요소들끼리만 따로 모아 열 기준으로 재정의 하고 싶다면 가장 쉽게 떠오르는 방법은 다음과 같을 것이다.(내 기준이다...!)

(다른 방법을 쓰면 더 효율적으로 행과 열을 변환할 수 있겠지만 일단 예시니까 넘어가자.)
고작 행과 열 변환에 이중 반복문을 쓰는 건 너무 비효율적이지 않은가..? 주요 로직도 아니고 고작 데이터 전처리 단계일 텐데 말이다!


이럴 때 zip() 함수에 * 기호를 응용하면 쉽게 해결할 수 있다.



profit!

해당 과정을 통해 두 번째 인자로 주어질 arr2의 행-열 변환을 쉽게 마칠 수 있었고 이를 거침으로써 행렬의 곱셈을 조금 더 쉽게 마무리할 수 있었다.

이제... 3일째 계속 쓰고 있던 제너레이터를 주제로 한 글을 마무리하러 가야겠다...


reference

2차원 리스트 뒤집기 - ⭐️zip⭐️
파이썬의 Asterisk(*) 이해하기

좋은 웹페이지 즐겨찾기