프로그래머스 Lv.1에 있는 "성격 유형 검사하기" 문제에 대한 풀이 및 탐구에 대한 기록.
2022 KAKAO INTERNSHIP 출제 문제.
정답률 52%
목 차
문제 분석
Input
- survey: 판별할 유형의 종류
- choices: survey의 길이 = 질문의 개수
Output
- answer: 성격 유형 (4자리의 알파벳)
성격 유형
- R <-> T
- C <-> F
- J <-> M
- A <-> N
점수 체계: A3점 -> A2점 -> A1점 -> 0점 -> B1점 -> B2점 -> B3점
A, B 중 높은 점수 = 성격. 점수가 같을 시 알파벳 순.
문제 풀이
초안
소요 시간: 9분 11초. 시도 횟수: 1회.
using System;
public class Solution {
public string solution(string[] survey, int[] choices) {
int[] type1 = new int[2];
int[] type2 = new int[2];
int[] type3 = new int[2];
int[] type4 = new int[2];
for (int i = 0; i < choices.Length; i++) {
if (choices[i] == 4) continue;
switch (survey[i][0]) {
case 'R':
calculateScore(type1, choices[i], true);
break;
case 'T':
calculateScore(type1, choices[i], false);
break;
case 'C':
calculateScore(type2, choices[i], true);
break;
case 'F':
calculateScore(type2, choices[i], false);
break;
case 'J':
calculateScore(type3, choices[i], true);
break;
case 'M':
calculateScore(type3, choices[i], false);
break;
case 'A':
calculateScore(type4, choices[i], true);
break;
case 'N':
calculateScore(type4, choices[i], false);
break;
}
}
string answer = "";
if (type1[0] >= type1[1]) answer += "R";
else answer += "T";
if (type2[0] >= type2[1]) answer += "C";
else answer += "F";
if (type3[0] >= type3[1]) answer += "J";
else answer += "M";
if (type4[0] >= type4[1]) answer += "A";
else answer += "N";
return answer;
}
public void calculateScore(int[] type, int score, bool isUpper) {
switch (score) {
case 1:
score = 3;
break;
case 7:
score = 3;
isUpper = !isUpper;
break;
case 2:
score = 2;
break;
case 6:
score = 2;
isUpper = !isUpper;
break;
case 3:
score = 1;
break;
case 5:
score = 1;
isUpper = !isUpper;
break;
}
if (isUpper) type[0] += score;
else type[1] += score;
}
}
솔직히 카카오 인턴 문제에 정답률 50% 치고는 생각보다 쉬웠다... 카카오 부분은 물론 제일 쉬운 문제 중 하나일 거라 생각하지만.
초안에서는 빠르게 코드를 완성하는 것을 목표로 두고 내가 가장 직관적이라고 느 switch문 위주로 사용했다.
리팩토링된 답안
using System;
using System.Collections.Generic;
public class Solution
{
public string solution(string[] survey, int[] choices)
{
Dictionary<char, int> scores = new Dictionary<char, int>() {
{'R', 0}, {'T', 0},
{'C', 0}, {'F', 0},
{'J', 0}, {'M', 0},
{'A', 0}, {'N', 0}
};
for (int i = 0; i < choices.Length; i++)
{
if (choices[i] == 4) continue;
int score = Math.Abs(choices[i] - 4);
if (choices[i] < 4) scores[survey[i][0]] += score;
else scores[survey[i][1]] += score;
}
string answer = "";
answer += scores['R'] >= scores['T'] ? "R" : "T";
answer += scores['C'] >= scores['F'] ? "C" : "F";
answer += scores['J'] >= scores['M'] ? "J" : "M";
answer += scores['A'] >= scores['N'] ? "A" : "N";
return answer;
}
}
GPT를 거쳐 리팩토링을 시도했으나 오히려 이전보다 실행시간으로는 더 긴 시간이 걸렸다. 이 코드는 테스트 코드 평균 약 2.2ms 가 걸린 반면, 초안은 약 0.4ms의 시간이 걸렸다.
시간 차이가 나는 예상 원인은
1. Dictionary의 사용
2. 가독성을 포기한 switch문의 효율성
으로 보인다. 하지만, 실무에서는 초안처럼 나만 알아볼 수 있는 코드보다 아래처럼 가독성이 좋은 코드가 총 업무시간의 결과로 보았을 때 오히려 좋을 수 있다는 생각이 들어서 이 코드 역시 블로그에 게재한다. 추후 두 코드의 중간점을 찾아 가독성이 좋으면서도 코드효율을 유지할 수 있는 방법을 찾게 된다면 추가할 예정이다.
1차원 배열과 2차원 배열
만약 원소의 개수가 같다면, 1차원 배열을 여러 개 만드는 것과 2차원 배열 1개를 만드는 것에는 어떤 차이가 있을까?
이론
2차원 배열의 메모리 접근 효율성
- 연속적 메모리 할당: 2차원 배열은 메모리에서 연속된 공간에 할당됩니다. 예를 들어, int[,] array2D = new int[3, 3];는 메모리에서 9개의 정수형 요소가 연속적으로 저장됩니다. 이로 인해, 특정 요소에 접근할 때 인접한 메모리 위치에 있는 요소들도 캐시에 함께 로드될 가능성이 큽니다. 이는 캐시 효율성을 높이며 메모리 접근 속도를 증가시킬 수 있습니다.
- 캐시 히트율: 연속된 메모리 접근은 캐시 히트율을 높여 메모리 접근이 빠르게 이루어집니다. 이는 특히 루프를 통해 배열의 요소들을 순차적으로 접근할 때 성능 향상으로 이어집니다.
1차원 배열 3개의 메모리 접근 효율성
- 비연속적 메모리 할당: 3개의 1차원 배열은 각각 독립적으로 메모리 공간에 할당됩니다. 이들 배열이 메모리에서 서로 인접하게 배치되지 않을 수 있기 때문에, 연속적인 메모리 접근이 보장되지 않습니다. 따라서, 한 배열의 요소에 접근한 후 다른 배열의 요소에 접근할 때 캐시 미스(cache miss)가 발생할 가능성이 더 높습니다.
- 캐시 비효율: 각 배열이 메모리에서 분리되어 있기 때문에, 여러 배열의 요소를 순차적으로 접근할 때 캐시 비효율이 발생할 수 있습니다. 이는 메모리 접근 속도에 영향을 미칠 수 있습니다.
결론
메모리 접근 효율성 측면에서 2차원 배열이 더 유리합니다. 2차원 배열은 메모리에서 연속적으로 배치되기 때문에 캐시 히트율이 높아져, 데이터 접근 시 성능이 향상될 수 있습니다. 반면, 3개의 1차원 배열을 사용하는 경우 메모리 접근이 비연속적일 수 있어 캐시 비효율이 발생할 가능성이 높습니다.
실험
실제로 유의미한 차이가 있는지 확인하기 위해, 1차원 배열 여럿과 2차원 배열 하나에 대한 각각의 코드를 짜고 실행 시간을 측정해보기로 했다.
실험에 사용한 코드는 다음과 같다:
using System;
using System.Diagnostics;
class Program
{
static void Main()
{
int arraySize = 10000;
int[] type1 = new int[arraySize];
int[] type2 = new int[arraySize];
int[] type3 = new int[arraySize];
int[] type4 = new int[arraySize];
Stopwatch stopwatch = new Stopwatch();
stopwatch.Start();
for (int i = 0; i < 10000; i++)
{
for (int j = 0; j < arraySize; j++)
{
CalculateScore(type1, j, true);
CalculateScore(type2, j, false);
CalculateScore(type3, j, true);
CalculateScore(type4, j, false);
}
}
stopwatch.Stop();
Console.WriteLine($"1차원 배열 4개의 실행 시간: {stopwatch.ElapsedMilliseconds} ms");
}
static void CalculateScore(int[] type, int index, bool isUpper)
{
if (isUpper) type[index] += 1;
else type[index] -= 1;
}
}
using System;
using System.Diagnostics;
class Program
{
static void Main()
{
int arraySize = 10000;
int[,] types = new int[4, arraySize];
Stopwatch stopwatch = new Stopwatch();
stopwatch.Start();
for (int i = 0; i < 10000; i++)
{
for (int j = 0; j < arraySize; j++)
{
CalculateScore(types, 0, j, true);
CalculateScore(types, 1, j, false);
CalculateScore(types, 2, j, true);
CalculateScore(types, 3, j, false);
}
}
stopwatch.Stop();
Console.WriteLine($"2차원 배열의 실행 시간: {stopwatch.ElapsedMilliseconds} ms");
}
static void CalculateScore(int[,] types, int typeIndex, int index, bool isUpper)
{
if (isUpper) types[typeIndex, index] += 1;
else types[typeIndex, index] -= 1;
}
}
1차 측정
- 1차원 배열 4개의 실행 시간: 1074 ms
- 2차원 배열의 실행 시간: 1442 ms
2차 측정
- 1차원 배열 4개의 실행 시간: 1114 ms
- 2차원 배열의 실행 시간: 1465 ms
이 아래는 위의 결과에 대한 GPT의 변명이다.:
#GPT의 변명
결과를 보면, 2차원 배열을 사용한 경우가 오히려 1차원 배열 4개를 사용한 경우보다 실행 시간이 더 길게 나왔습니다. 이는 예상과는 다르게 나타났는데, 그 이유는 여러 가지가 있을 수 있습니다.
가능한 원인:
- 메모리 할당 및 접근 패턴: 2차원 배열의 경우, C#에서 내부적으로 일차원적으로 배치된 메모리 공간에 접근할 때 추가적인 오버헤드가 발생할 수 있습니다. 특히, 작은 배열 크기에서는 이러한 오버헤드가 더 크게 느껴질 수 있습니다.
- 컴파일러 최적화: C# 컴파일러는 1차원 배열의 메모리 접근을 보다 효율적으로 최적화할 수 있습니다. 반면 2차원 배열의 경우, 메모리 접근이 단순하지 않기 때문에 최적화가 덜 효과적일 수 있습니다.
- 캐시 효율성: 작은 데이터 세트에서는 캐시 효율성의 차이가 미미할 수 있습니다. 1차원 배열과 2차원 배열 간의 캐시 히트율 차이가 결과적으로 실행 시간에 큰 영향을 미치지 않을 수 있습니다.
결론:
실제로 작은 크기의 배열에서는 2차원 배열보다 1차원 배열이 더 빠를 수 있으며, 이는 특정 상황에서 2차원 배열의 접근 방식이 추가적인 오버헤드를 발생시킬 수 있기 때문입니다.
실험을 확장할 수 있는 방법:
- 더 큰 배열 크기: 배열 크기를 다시 증가시키고, 반복 횟수를 조정하여 메모리 접근 차이를 극대화할 수 있습니다.
- 다른 프로파일링 도구 사용: 메모리 접근 패턴이나 캐시 효율성을 더 정확하게 분석하기 위해, 프로파일링 도구를 사용하여 캐시 미스나 CPU 사용률을 분석할 수 있습니다.
진짜 결론: 역시 해봐야 진짜인지 증명할 수 있다...
테스트 코드를 이처럼 짠 이유는 내가 만든 초안과 같은 경우에도 이 말이 통용되는지가 알고싶었기 때문이다. 코드는 항상 상황과 경우에 따라 달라지기 때문에 진짜 효율이 어떤지는 직접 프로젝트를 진행할 때 프로파일링을 해야겠다.
느낀 점
나는 항상 공부할 때 나 스스로 한 번 한 후, GPT를 통해 점검하는 시간을 가진다. 하지만 오늘의 경험을 통해 왜 개발자를 GPT만으로 대체할 수 없는지 느낄 수 있었다. GPT와 같은 AI가 얼마나 똑똑해지든, 개발자의 숫자를 줄일지언정, 없애지는 못할 것이다.
'Software Engineering > Algorithm' 카테고리의 다른 글
[BaekJoon Bronze2] C++: 블랙잭 (1) | 2024.09.08 |
---|---|
[Programmers Lv. 2] C++: 최댓값과 최솟값 (0) | 2024.09.07 |
[Programmers Lv. 1] C++: 가장 많이 받은 선물 (0) | 2024.09.01 |
[Programmers Lv. 1] C++: 로또의 최고 순위와 최저 순위 (0) | 2024.08.28 |
[Programmers Lv. 1] C#: 모의고사 (0) | 2024.08.27 |