1. 문제 : 모의고사
https://school.programmers.co.kr/learn/courses/30/lessons/42840
각각의 수포자들은 각자 패턴을 가지고 문제를 찍는다.
정답을 담은 배열이 주어질 때, 가장 높은 점수를 받은 사람(들)의 배열을 반환하면 된다.
정답을 담은 배열을 모두 돌아야하기 때문에 완전탐색에 대한 문제이다.
2. 풀이 방법
1. 의사코드
1.문제 조건 : 수포자의 찍는 패턴 선언
2.각 수포자의 점수 저장 (점수 : 패턴의 값 == 정답의 배열)
3. 최대값 찾기
4. 최대값을 가진 수포자들의 번호를 리스트에 담음
5. 리스트를 배열로 변환하여 리턴
2.구현
정답을 담은 List를 Array로 변환할 때 stream을 사용해 구현했다.
import java.util.*;
class Solution {
public int[] solution(int[] answers) {
// 1.문제 조건 : 찍는 패턴 선언
int[][] patterns = {
{1, 2, 3, 4, 5},
{2, 1, 2, 3, 2, 4, 2, 5},
{3, 3, 1, 1, 2, 2, 4, 4, 5, 5}
};
// 2.점수 저장하는 배열
int[] scores = new int[patterns.length];
// 3.각 수포자의 점수 저장 (점수 : 패턴의 값 == 정답의 배열)
for(int i=0; i<patterns.length; i++) {
for(int j=0; j<answers.length; j++) {
if(patterns[i][ j % patterns[i].length] == answers[j]) {
scores[i] += 1;
}
}
}
// 4. 최대값 찾기
int maxScore = Math.max(scores[0], Math.max(scores[1], scores[2]));
// 5. 최대값을 가진 수포자들의 번호를 리스트에 담음
ArrayList<Integer> answer = new ArrayList<>();
for (int i = 0; i < scores.length; i++) {
if (scores[i] == maxScore) {
answer.add(i + 1);
}
}
// 6. 리스트를 배열로 변환하여 리턴
return answer.stream().mapToInt(Integer::intValue).toArray();
}
}
스트림을 사용하면 반복문을 사용하지 않고 컬렉션의 데이터를 배열에 담아 반환하거나 필터링할 수 있어 코드의 양을 줄이거나 가독성을 향상시킬 수 있는 반면 성능의 문제가 발생한다!
그럼 이 문제에서 스트림을 사용했을 때와 for-loop을 사용했을때의 성능과 가독성 차이는 어떻게 될까?
3. 스트림과 for문 비교
먼저 코드를 보며 가독성 차이를 비교해보자. 글의 가독성을 위해 차이나는 부분만 서술하겠다.
Stream 사용 예
다른 문제에 비해 아주 간단한, stream을 사용한 예시이다. toArray()를 통해 arrayList를 array로 바꿔서 반환하였다.
코드가 짧기도 하고 코드의 의도가 잘 나타난다.
// 6. 리스트를 배열로 변환하여 리턴
return answer.stream().mapToInt(Integer::intValue).toArray();
for문 사용 예
옮겨 담을 배열을 새로 만들었고, List에 있는 값을 for문을 사용해 모든 요소를 순회하며 배열에 옮겨담으며 반환하였다.
코드가 그렇게 길지는 않지만 한눈에 들어오지는 않는다.
// 6.리스트를 배열로 변환하여 리턴
int[] answer = new int[answerList.size()];
int cnt = 0;
for(int num : answerList) {
answer[cnt++] = num;
}
return answer;
성능 비교
스트림을 사용했을때의 성능은 어림잡아 볼 때 평균적으로 3ms이다.
반복문을 사용했을 때의 성능은 약 0.5ms이다. 겨우 리스트를 배열로 변환하는 작업인데도 약 6배 성능이 차이나는 부분을 알 수 있다.
이 문제에서는 엄청 큰 차이는 아니지만, 반복문을 사용했을 때 직접 배열에 접근하여 값을 바꾸므로 스트림API와 달리 중간 객체 생성이나 불필요한 메서드 호출이 없어서 더 빠른 것으로 보인다.
그렇다면, stream은 항상 성능에서 불리할까?
정답은 '항상' 그런 것은 아니다! 대표적으로, 반례로써 중복을 제거하는 경우를 가정해보자.
스트림API, .distinct()를 사용할 경우 코드는 겨우 한 줄이며, 시간 복잡도는 O(n)이다. (HashSet 사용)
하지만 중첩반복문을 통해 List를 순회하며 중복을 제거하는 코드를 직접 만들었을 때의 시간복잡도는 O(n^2)이다.
이는 제한시간이 1초라고 하면 O(n)의 가용범위는 1천만~ 2천만인 반면 O(n^2)의 3천~5천으로 무시할 수 없는 차이이다.
성능을 떠나서라도 코드가 길어지는 것 자체가 가독성과 구현 시간측면에서 매우 불리하게 작용한다. 아래는 for-loop만을 가지고 중복을 제거하는 코드를 구현한 예시이다.
import java.util.ArrayList;
import java.util.List;
public class DistinctExample {
public static void main(String[] args) {
int[] numbers = {1, 2, 2, 3, 3, 4, 5};
List<Integer> distinctNumbers = new ArrayList<>();
for (int i = 0; i < numbers.length; i++) {
boolean isDuplicate = false;
for (int j = 0; j < distinctNumbers.size(); j++) {
if (numbers[i] == distinctNumbers.get(j)) {
isDuplicate = true;
break;
}
}
if (!isDuplicate) {
distinctNumbers.add(numbers[i]);
}
}
// 결과 출력
System.out.println(distinctNumbers);
}
}
스트림API를 사용할 때 박싱/언박싱, 컬렉션 변환 등의 작업에서 성능이 저하되고 디버깅이 어렵다는 단점이 있다. 하지만 가독성과 편의성에서 압도적으로 뛰어나다.
아직 실무를 겪어보진 못했지만 프로그래머스 문제를 풀다보면 실무에서 스트림을 상당히 많이 사용한다고 한다. 이미 있는 API를 갖고 직접 구현할 필요가 없는, 한마디로 압도적인 편의성이 그 이유일 것이라 생각한다. 하지만 대용량 데이터를 다루는 경우나 성능 차이가 매우 중요한 경우에는 성능 차이를 잘 계산/비교해보고 사용하는 것이 적절할 것이다.
하지만 코드를 짜는 1분 1초가 중요한 코딩테스트 관점에서는 N수가 너무 크지 않은 이상 스트림API를 적극 사용하는것이 대체로 현명하다고 한다. (인용 : Reference)
4. 결론
1.스트림 API를 사용하면 가독성이 뛰어나게 할 수 있지만 그렇지 않은 경우에 비해 성능이 저하될 수 있다. 하지만 또 매번 그런건 아니다.
2.대체로 코딩테스트 관점에서는 시간 복잡도 측면에서 for문의 반복문과 스트림은 성능에 큰 차이가 없다.
3.따라서 시간 제한에 걸리는 것이 아닌 이상 가급적 스트림 API를 통해 빠르게 문제를 푸는 것이 현명하다.
4.하지만 가장 좋은건 모든 성능을 비교할 수 있는 능력과 사고력을 갖추는 것이니, 둘 다 공부하면 좋다.
5.Reference
김희성, 박경록. (2024). 코딩 테스트 합격자 되기: 자바 편. 골든래빗(주). https://product.kyobobook.co.kr/detail/S000212576322