백준에 있는 자료구조 문제를 풀다가 쉬워보여서 신명나게 빨리 풀고 제출해보았지만, 이게 웬걸.. 시간초과다.
문제는 정말 쉬운 문제이다.
원소가 하나 남을 때까지 맨 앞의 원소를 삭제하거나, 맨앞의 원소를 삭제하고 바로 뒤에 추가하는 행동을 반복하면 된다.
물론 문제에서 요구하는건 큐를 유도하지만 문득 MutableList
를 사용해서도 풀 수 있지 않을까?
하는 마음에 Queue
의 remove()
을 MutableList
의 removeFirst()
로 먼저 구현해보았다.
시간초과
어 이거 왜이러지? 하면서 다시 정해법인 Queue
를 사용해서 풀자.
정답입니다!
다음은 내가 풀이한 코드의 두방법을 시간을 재보는 코드이다.
import java.util.LinkedList
import java.util.Queue
import kotlin.system.measureTimeMillis
fun main() {
val n = readln().toInt()
val list = MutableList(n) { it + 1 }
var toggle1 = true
val listTime = measureTimeMillis {
while (list.size != 1) {
if (toggle1) list.removeFirst() else list.add(list.removeFirst())
toggle1 = !toggle1
}
}
val queue: Queue<Int> = LinkedList()
repeat(n) {
queue.add(it + 1)
}
var toggle2 = true
val queueTime = measureTimeMillis {
while (queue.size != 1) {
if (toggle2) queue.remove() else queue.add(queue.remove())
toggle2 = !toggle2
}
}
println("$listTime mills")
println("$queueTime mills")
}
문제에서 요구하는 최대 n은 500,000 이기때문에 최대값으로 시간을 측정해보면? 다음과 같은 결과가 나온다.
약 500배가 차이나는 엄청난 시간 속도 차이를 볼 수 있다. 왜그럴까?
Kotlin
에서MutableList
의 구현체로Java
의ArrayList()
가 나온다.
또한 나는 실험에 있어서Queue
의 구현체로Java
의LinkedList()
를 사용했다.
결국에ArrayList()
와LinkedList()
의 작동 방식 차이에서 나오는 시간차이지 않을까? 싶었다.
먼저 나는
removeFirst()
가 어떻게 구현이 되어있는지 확인해보았다.
다음과 같이 구현 되어있었다.
리스트가 비었으면 예외를, 그렇지 않다면removeAt(0)
을 호출한다.
@SinceKotlin("1.4")
@WasExperimental(ExperimentalStdlibApi::class)
public fun <T> MutableList<T>.removeFirst(): T
= if (isEmpty()) throw NoSuchElementException("List is empty.") else removeAt(0)
다시
removeAt()
으로 가보자.
MutableList
인터페이스가 나오는데 내가 궁금한건 정작 코드가 실행되는 구현체다.
나는
ArrayList
가 궁금한 것이니 3개 다 찾아봤지만,
단 하나도 구현이 된 코드를 볼 수 없었다.
코틀린의expect class
만 있어서 구현이 되어있진 않고 추상클래스 처럼 명시되어있었다. (이거 어떻게 JVM꺼 actual class를 볼 수 있는지 아시는 분 댓글 주시면 너무 감사하겠습니다 ㅠㅠ)
자 다시 돌아와서 결국 찾아낸건 Kotlin 공식 문서에서 검색하여 깃허브 링크를 타고 Native로 구현된 걸 볼 수 있었다. 하지만 이게 JVM 상에서 실행했을 때와 같은 로직을 수행하는지는 알지 못하여 나중에 JVM actual class을 볼 수 있는 방법을 다시 알게되면 수정하겠다.
override actual fun removeAt(index: Int): E {
checkIsMutable()
checkForComodification()
AbstractList.checkElementIndex(index, length)
return removeAtInternal(offset + index)
}
checkIsMutable()
checkForComodification()
AbstractList.checkElementIndex(index, length)
직접 더 들어가서 위의 이 친구들을 확인해보니 딱히 시간 복잡도를 잡아 먹을 거 같진 않았다.
문제는
removeAtInternal
인거 같으니 다시 한번 들어가서 확인해보자.private fun removeAtInternal(i: Int): E { registerModification() if (backingList != null) { val old = backingList.removeAtInternal(i) length-- return old } else { val old = backingArray[i] backingArray.copyInto(backingArray, startIndex = i + 1, endIndex = offset + length, destinationOffset = i) backingArray.resetAt(offset + length - 1) length-- return old } }
마찬가지로
registerModification()
는O(1)
의 작업이니 무시하도록 하자.
backingList
가 존재하냐 하지않냐에 대한 분기문이 있고, 결국에 많은 시간을 잡아먹는 애는backingArray.copyInto()
이 부분이다.
배열을 복사하는 로직이다 보니 당연스럽게 시간을 많이 사용하게 한다.
다시 돌아와서 정리해보자면
removeFirst()
를 호출하면 결국에removeAt(0)
을 호출하는 것이고 곧removeAtInternal()
을 호출한다.
또한 이것은 배열을 복사하는 행위를 하게 된다.
이것은 매우 큰 리소스를 잡아먹는 행위이다.
문제에서 보면 최대값이 500,000이므로 결국에 배열 복사를 50만번 하는 것과 같다. 32초가 걸린 이유가 여기에 있다.
그럼 다시 이번엔 Queue를 확인해보자
Queue는 구현체로LinkedList()
를 사용했다.
그리고 내가 사용했던대로remove()
를 호출하면removeFirst()
가 호출되고 결국에 다음과 같이unlinkFirst()
를 호출하게 된다.
로직을 보면 알겠지만, 상대적으로 ArrayList()의removeFirst()
보다 훨씬 빠르다.
그러다 보니 극단적인 시간 차이가 난 것 같다.
private E unlinkFirst(Node<E> f) {
// assert f == first && f != null;
final E element = f.item;
final Node<E> next = f.next;
f.item = null;
f.next = null; // help GC
first = next;
if (next == null)
last = null;
else
next.prev = null;
size--;
modCount++;
return element;
}
결론은 내가 세운 가설대로
ArrayList
와LinkedList
의 작동 방식 차이에서 있었다.
ArrayList
는 결국에Object[]
을 가지고 있고 원소를 추가, 제거 함에 따라 배열을 복사하는 오버헤드가 있다.
반면LinkedList
는 구현 그대로 앞의 노드만 참조를 해제하고 다음 노드를 제일 처음 노드라고 지정만 해놓으면 되는 차이였다.
개발자로서 항상 자료구조가 중요하단 생각이 든다.
자료구조의 내부 사정을 알아야 추후에 성능을 더 중요시 할 때와 성능보다 코드의 가독성 및 편의성을 생각할지 고를수 있는 선택권이 비로소 생긴다고 생각한다. (ex.IntArray
와List<Int>
의 차이)
내가 자료구조를 잘 모르면 그런 생각이나 할 수 있을까?
늘 겸손해지는 프로그래밍 세계이다.
긴글 읽어주셔서 감사합니다!
개선할 점, 혹은 잘못된 점이 있다면 댓글 주시면 정말 감사하겠습니다! 😙