안녕하세요! 오늘은 백준의 동전2 문제에서 사용한 두 가지 접근 방식을 비교하면서, 실행 시간을 단축시키기 위해 적용한 최적화 기법들을 살펴보려고 합니다. 실무에서 사용할 때 주의할 점들도 함께 이야기해보겠습니다.
fun main() = with(System.`in`.bufferedReader()) {
val (n, k) = readLine().split(" ").map { it.toInt() }
val coins = buildSet {
repeat(n) {
add(readLine().toInt())
}
}.sorted()
val dp = IntArray(k + 1) { 10001 }
dp[0] = 0
coins.forEach { coin ->
for (amount in coin..k) {
dp[amount] = minOf(dp[amount], dp[amount - coin] + 1)
}
}
print(if (dp[k] == 10001) -1 else dp[k])
}
깔끔하고 이해하기 쉽습니다. Kotlin의 표준 라이브러리를 활용해서 간결하게 작성된 코드입니다. DP를 사용해야겠다 해서 사용했지만 묘하게 실행이 오래걸려서 이거 한번 줄여보자! 하고 삽질을 막 했습니다.
import java.util.*
fun main() {
val br = FastReader()
val n = br.nextInt()
val k = br.nextInt()
val coins = IntArray(n)
var size = 0
var minCoin = Int.MAX_VALUE
for (i in 0 until n) {
val coin = br.nextInt()
if (coin <= k && !containsCoin(coins, size, coin)) {
coins[size++] = coin
if (coin < minCoin) minCoin = coin
}
}
if (size == 0 || minCoin > k) {
print(-1)
return
}
val dp = IntArray(k + 1) { 10001 }
dp[0] = 0
if (size > 1) insertionSort(coins, size)
for (i in 0 until size) {
val coin = coins[i]
var amount = coin
while (amount <= k) {
val newCount = dp[amount - coin] + 1
if (newCount < dp[amount]) {
dp[amount] = newCount
}
amount++
}
if (dp[k] < 10001) {
val remainingMin = (k - amount) / coin + 1
if (remainingMin >= dp[k]) break
}
}
print(if (dp[k] == 10001) -1 else dp[k])
}
private class FastReader {
private val br = System.`in`.bufferedReader()
private var st: StringTokenizer? = null
fun nextInt(): Int {
while (st == null || !st!!.hasMoreTokens()) {
st = StringTokenizer(br.readLine())
}
return st!!.nextToken().toInt()
}
}
private fun containsCoin(coins: IntArray, size: Int, target: Int): Boolean {
for (i in 0 until size) {
if (coins[i] == target) return true
}
return false
}
private fun insertionSort(arr: IntArray, size: Int) {
for (i in 1 until size) {
val key = arr[i]
var j = i - 1
while (j >= 0 && arr[j] > key) {
arr[j + 1] = arr[j]
j--
}
arr[j + 1] = key
}
}
입력 처리에서 가장 큰 성능 차이를 만드는 부분이 바로 FastReader입니다.
private class FastReader {
private val br = System.`in`.bufferedReader()
private var st: StringTokenizer? = null
fun nextInt(): Int {
while (st == null || !st!!.hasMoreTokens()) {
st = StringTokenizer(br.readLine())
}
return st!!.nextToken().toInt()
}
}
StringTokenizer 재사용
split(" ")
는 매번 새로운 String 배열을 생성합니다BufferedReader 최적화
직접 정수 파싱
split(" ").map { it.toInt() }
는 리스트 생성과 map 변환 과정이 필요합니다private fun parseInteger(s: String): Int {
var num = 0
var i = 0
var isNegative = false
// 부호 처리
if (s[0] == '-') {
isNegative = true
i++
}
// 직접 숫자 계산 (charAt - '0'이 Integer.parseInt보다 빠름)
while (i < s.length) {
num = num * 10 + (s[i] - '0')
i++
}
return if (isNegative) -num else num
}
StringTokenizer는 Java 1.0부터 있던 클래스입니다. 문자열을 토큰으로 분리하는 가장 기본적인 방법입니다. 현대 Java/Kotlin 개발에서는 잘 사용되지 않지만, 알고리즘 문제 풀이에서는 여전히 많이 사용됩니다.
// split 사용 시
val input = "1 2 3 4 5"
val numbers = input.split(" ").map { it.toInt() } // 새로운 문자열 배열 생성 + map으로 변환
// StringTokenizer 사용 시
val st = StringTokenizer(input)
while (st.hasMoreTokens()) {
val number = st.nextToken().toInt() // 바로 파싱, 중간 배열 없음
}
// 기본 공백 구분 - 잘 동작
val st1 = StringTokenizer("1 2 3")
// 다중 구분자 설정 - 가능하지만 권장하지 않음
val st2 = StringTokenizer("1,2;3", ",;")
// 복잡한 패턴 - StringTokenizer로 처리 불가
val complexInput = "key1=value1;key2=value2" // key-value 쌍 파싱은 적절하지 않음
// 잘 동작하는 경우
"1 2 3 4 5" // 공백으로 구분된 숫자
"abc def ghi" // 공백으로 구분된 단어
// 주의가 필요한 경우
"1, 2, 3, 4, 5" // 쉼표와 공백이 같이 있는 경우
"1 2 3" // 다중 공백
// 부적절한 경우
"[1,2,3],[4,5,6]" // 배열 형태의 데이터
"name=kim;age=20" // key-value 형태
"2023-11-25" // 날짜 형식
장점
단점
그래서 실무에서는 권장되지 않지만, 알고리즘 문제처럼 단순한 입력(특히 공백으로 구분된 숫자 데이터)을 빠르게 처리해야 하는 경우에는 아직도 좋은 선택이 될 수 있습니다.
사용해도 괜찮은 경우
피해야 하는 경우
대안
두 번째로 살펴볼 부분은 동전을 처리하는 방식입니다.
val coins = IntArray(n)
var size = 0
var minCoin = Int.MAX_VALUE
for (i in 0 until n) {
val coin = br.nextInt()
if (coin <= k && !containsCoin(coins, size, coin)) {
coins[size++] = coin
if (coin < minCoin) minCoin = coin
}
}
조기 필터링
메모리 최적화
이러한 최적화 기법들은 백준과 같은 알고리즘 문제 풀이에서는 좋을수도 있지만 잘 보고 활용합시다! 1/2로 줄였지만 코드는 그렇지 않아버림...