들어가며
코딩 테스트를 풀다 보면 식 자체는 거의 맞는데, 상태(State)를 끝까지 추적하는 능력이 부족해서 정답을 맞히지 못하는 경우가 있었다.
https://school.programmers.co.kr/learn/courses/30/lessons/132267
최근 콜라 문제를 풀면서도 비슷한 실수를 했다.
처음에는 현재 가진 빈 병의 개수를 n이라고 두고, a개의 빈 병을 주면 b개의 콜라를 받을 수 있다는 조건을 다음과 같이 구현했다.
answer += (n / a) * b;
n = n / a + n % a;
첫 번째 줄에서는 (n / a) * b를 이용해 새로 받은 콜라의 개수를 정확하게 계산했다.
하지만 다음 반복에서 사용할 빈 병의 개수를 갱신할 때는 n / a만 사용했다. 이때 n / a는 받은 콜라병의 개수가 아니라 교환한 횟수이다. 한 번 교환할 때 b개의 콜라를 받기 때문에 새로 받은 콜라의 개수는 반드시 (n / a) * b가 되어야 한다.
정확한 코드는 다음과 같다.
class Solution {
public int solution(int a, int b, int n) {
int answer = 0;
while (n >= a) {
int exchangeCount = n / a;
int receivedCola = exchangeCount * b;
int remainingBottles = n % a;
answer += receivedCola;
n = receivedCola + remainingBottles;
}
return answer;
}
}
이 코드는 처음 작성한 코드보다 길다. 하지만 각 값의 의미가 변수명으로 분명하게 드러난다.
exchangeCount = 교환한 횟수
receivedCola = 새로 받은 콜라의 개수
remainingBottles = 교환하지 못하고 남은 기존 빈 병
n = 다음 반복에서 사용할 전체 빈 병의 개수
식을 짧게 작성하면 의미가 섞일 수 있다
처음 코드에서는 n / a라는 식을 여러 번 직접 사용했다.
answer += (n / a) * b;
n = n / a + n % a;
문법적으로는 짧고 간단해 보이지만, 같은 식이 상황마다 어떤 의미를 가지는지 구분하기 어렵다. n / a는 교환한 횟수이고, (n / a) * b는 실제로 받은 콜라병의 개수이다. 둘은 서로 다른 값인데 식을 바로 작성하면 머릿속에서 같은 값처럼 느껴질 수 있다.
반면 중간값을 변수로 분리하면 의미가 고정된다.
int exchangeCount = n / a;
int receivedCola = exchangeCount * b;
이제 다음 상태를 계산할 때 exchangeCount를 넣어야 하는지 receivedCola를 넣어야 하는지를 변수명만 보고도 판단할 수 있다.
n = receivedCola + remainingBottles;
만약 다음과 같이 작성하려 한다면 변수명 자체가 이상함을 알려준다.
n = exchangeCount + remainingBottles;
다음 반복에서 필요한 값은 교환 횟수가 아니라 보유한 빈 병의 개수이기 때문이다.
⭐️변수는 값을 저장하는 공간만이 아니다
코딩테스트에서 변수는 별도로 값을 반드시 저장을 해야지 문제가 풀리는 경우에만 사용을 했었다.
하지만 변수는 프로그램 안에서 특정 값의 의미를 이름으로 표현하는 역할도 한다.
예를 들어 다음 코드는 계산 결과만 보면 맞을 수도 있다.
int x = n / a;
int y = x * b;
int z = n % a;
하지만 x, y, z만 보고는 각각이 무엇을 의미하는지 알기 어렵다. 코드 작성 당시에는 기억할 수 있어도 시간이 지나거나 로직이 복잡해지면 쉽게 혼동한다.
다음처럼 의미가 드러나는 이름을 사용하면 코드 자체가 설명이 된다.
int exchangeCount = n / a;
int receivedCola = exchangeCount * b;
int remainingBottles = n % a;
좋은 변수명은 주석을 줄이고 실수를 방지한다. 특히 코딩 테스트에서는 짧은 시간 안에 여러 상태를 추적해야 하므로 의미 있는 변수명이 생각을 정리하는 도구가 된다.
현재 상태, 행동, 다음 상태를 구분해야 한다
반복 문제에서는 보통 다음 세 단계가 존재한다.
현재 상태
→ 어떤 행동을 수행함
→ 다음 상태가 만들어짐
콜라 문제에서는 다음과 같다.
현재 상태
현재 보유한 빈 병 n개
행동
a개의 빈 병을 주고 b개의 콜라를 받음
다음 상태
새로 받은 콜라를 마시고 생긴 빈 병
+ 교환하지 못하고 남은 기존 빈 병
이를 코드로 옮기면 다음과 같다.
int exchangeCount = n / a;
int receivedCola = exchangeCount * b;
int remainingBottles = n % a;
n = receivedCola + remainingBottles;
중간값을 분리하지 않으면 현재 상태와 다음 상태가 한 줄의 계산식 안에 섞인다. 반면 변수를 분리하면 상태 변화가 눈에 보인다.
예제만 믿으면 오류가 가려질 수 있다
b가 1이 아닌 경우도 계산해보긴 했지만 b가 1인 경우의 차이점을 발견을 못하고 문제점을 파악에 실패하였다.
콜라 문제의 대표 예제에서는 b가 모두 1이었다.
a = 2, b = 1, n = 20
a = 3, b = 1, n = 20
이 경우 다음 두 식은 같은 결과를 만든다.
n / a
(n / a) * b
왜냐하면 b = 1이기 때문이다.
(n / a) * 1 = n / a
따라서 b를 빼먹은 잘못된 코드도 대표 예제를 통과할 수 있다. 이를 발견하려면 b가 1이 아닌 값을 직접 넣어봐야 한다.
a = 3
b = 2
n = 20
이때 첫 교환에서 다음과 같이 계산된다.
교환 횟수 = 20 / 3 = 6
받은 콜라 = 6 * 2 = 12
남은 빈 병 = 20 % 3 = 2
다음 빈 병 = 12 + 2 = 14
중간값을 변수로 분리하고 직접 추적하면 오류가 훨씬 잘 보인다.
코딩 테스트에서는 짧은 코드가 항상 좋은 코드가 아니다
코딩 테스트를 풀 때 코드를 최대한 짧게 작성하는 것이 미덕인 것처럼 생각이 들기도 하고, 굳이 변수를 도입하는 것이 번거로워서 회피했던 것도 사실이다. 하지만 아직 문제 해결 과정을 익히는 단계에서는 지나치게 짧은 코드가 오히려 실수를 만든다.
다음 코드는 짧지만 각 값의 역할이 숨겨져 있다.
answer += (n / a) * b;
n = (n / a) * b + n % a;
다음 코드는 조금 더 길지만 상태 변화가 명확하다.
int exchangeCount = n / a;
int receivedCola = exchangeCount * b;
int remainingBottles = n % a;
answer += receivedCola;
n = receivedCola + remainingBottles;
두 코드는 같은 결과를 만들지만 두 번째 코드는 검증하기 쉽고 수정하기 쉽다. 코딩 테스트에서 중요한 것은 코드 길이가 아니라 제한 시간 안에 정확한 코드를 작성하는 것이다.