[Kotlin] Fast I/O (빠른 입출력)

Hood·2024년 12월 27일

Kotlin

목록 보기
12/18
post-thumbnail

✍ 코틀린과 친해지자

PS 문제를 하나씩 풀다 보니,
한 번쯤 정리해 두면 좋겠다고 느낀 Kotlin 입출력 문법을 모아본 글입니다.


들어가기 전

코틀린으로 PS를 풀다 보면 자연스럽게 빠른 입출력이라는 말을 자주 듣게 됩니다.
그런데 왜 빠른지, 어떤 상황에서 어떤 도구를 써야 하는지는 처음엔 감이 잘 오지 않습니다.

원문: “Java Scanner is a slow tool.”
Kotlin 공식 문서는 표준 입력에서 Scanner를 사용할 수 있다고 설명하면서도
일반적인 경우에는 더 단순한 readln()을 우선적으로 생각하라고 안내합니다.
즉, 모든 문제에서 무조건 복잡한 입력 도구가 필요한 것은 아니고
문제의 입력 형태에 따라 선택하는 것이 중요합니다.

참고로 Kotlin 공식 문서에서 readLine()은 “obsolete and will be deprecated soon”이라고 안내합니다. 그래서 이번 글에서는 readLine() 대신 readln() 기준으로 설명합니다.

백준 온라인 저지에는 입력과 출력 속도를 비교한 자료도 있습니다.
이 글에서는 그 흐름을 참고하되
각 도구가 실제로 무엇을 하는지 공식 문서 기준으로 이해하는 데 집중해보겠습니다.


기본 입출력

Kotlin/JVM에서 표준 입력을 다루는 가장 대표적인 방법은 readln()과 Java의 Scanner입니다.
입력이 단순한 문제라면 readln()만으로도 충분하고, 입력값을 타입별로 바로 받고 싶다면 Scanner를 사용할 수 있습니다.

1. readln()

원문: “The readln() function reads a line from the standard input.”
readln()은 표준 입력으로부터 한 줄 전체를 문자열로 읽는 함수입니다.
가장 단순하게 입력을 받을 수 있다는 장점이 있지만, 숫자로 사용하려면 형변환이 필요합니다.

사용 방법

fun main() {
    val n = readln()
    print(n)
}

입력값은 문자열로 들어오기 때문에, 정수로 사용하려면 다음처럼 변환이 필요합니다.

fun main() {
    val n = readln().toInt()
    println(n)
}

원문: “To read multiple values separated by delimiters, use the .split() function.”
readln()은 공백을 기준으로 자동 분리해 주지 않습니다.
그래서 한 줄에 여러 값이 들어오는 경우에는 split()으로 직접 나누어야 합니다.

fun main() {
    val (x, y) = readln().split(" ").map { it.toInt() }
    println("$x $y")
}

위 코드는 입력받은 한 줄을 공백 기준으로 나누고, 각 값을 Int로 변환한 뒤 x, y에 저장하는 예제입니다.


2. Scanner

원문: “A simple text scanner which can parse primitive types and strings using regular expressions.”
Scanner는 정규 표현식을 이용해 문자열과 기본형 타입을 파싱할 수 있는 자바의 입력 도구입니다.
Kotlin에서도 그대로 사용할 수 있으며, 공백 단위로 값을 읽는 문제에서 직관적으로 사용하기 좋습니다.

사용 방법

import java.util.Scanner

fun main() {
    val scanner = Scanner(System.`in`)

    val line = scanner.nextLine()
    println(line)
}

문자열 한 줄을 통째로 받고 싶다면 nextLine()을 사용하고,
공백 기준으로 값을 하나씩 받고 싶다면 next()nextInt() 같은 메서드를 사용할 수 있습니다.

import java.util.Scanner

fun main() {
    val scanner = Scanner(System.`in`)

    val name = scanner.next()
    val age = scanner.nextInt()

    println(name)
    println(age)
}

원문: “Java Scanner is a slow tool.”
Scanner는 사용법이 간단하고 직관적이지만, 내부적으로 토큰을 해석하고 파싱하는 과정이 들어가기 때문에 입력량이 많은 PS 문제에서는 상대적으로 느릴 수 있습니다.

즉, Scanner의 장점은 편리함이고, 단점은 속도라고 볼 수 있습니다.


빠른 입출력

이제부터는 PS에서 자주 사용하는 빠른 입출력 도구들을 알아보겠습니다.
대표적으로 BufferedReader, BufferedWriter, StringBuilder, StringTokenizer, StreamTokenizer가 있습니다.

1. BufferedReader / BufferedWriter

먼저 버퍼(buffer)라는 개념부터 이해하면 좋습니다.

버퍼는 데이터를 한 번에 조금씩 주고받지 않고,
임시 공간에 모아 두었다가 한꺼번에 처리하는 메모리 영역입니다.

원문: “Reads text from a character-input stream, buffering characters so as to provide for the efficient reading of characters, arrays, and lines.”
BufferedReader는 문자 입력을 버퍼에 저장해 두었다가 읽기 때문에,
한 글자씩 즉시 처리하는 방식보다 더 효율적으로 동작합니다.

즉, 입력을 받을 때마다 바로 처리하는 것이 아니라
어느 정도 모아 두었다가 한 번에 읽기 때문에 속도상 이점이 생깁니다.

원문: “Writes text to a character-output stream, buffering characters so as to provide for the efficient writing of single characters, arrays, and strings.”
BufferedWriter도 마찬가지로 출력 내용을 버퍼에 모아 두었다가 한 번에 내보내므로,
출력이 많은 문제에서 더 효율적입니다.

사용 방법

fun main() {
    val br = System.`in`.bufferedReader()
    val bw = System.`out`.bufferedWriter()

    val n = br.readLine()
    bw.write(n)

    bw.flush()
    bw.close()
    br.close()
}

위 코드는 입력을 한 줄 받아 그대로 출력하는 예제입니다.
BufferedReader는 보통 readLine()으로 줄 단위 입력을 받고,
BufferedWriterwrite()로 출력 문자열을 버퍼에 저장합니다.

마지막에는 flush()를 통해 버퍼에 남은 내용을 실제로 출력해야 합니다.

BufferedReader Method

Method설명
.close()입력 스트림을 닫고 사용하던 자원을 정리한다.
.read()한 글자를 읽어 정수형으로 반환한다.
.readLine()한 줄을 읽어 문자열로 반환한다.

BufferedWriter Method

Method설명
.newLine()줄바꿈 문자를 출력한다.
.flush()버퍼에 남은 출력 내용을 비운다.
.close()출력 스트림을 닫는다.

2. StringBuilder

문자열을 여러 번 이어 붙여야 할 때는 StringBuilder가 유용합니다.

원문: “A mutable sequence of characters.”
StringBuilder수정 가능한 문자열 시퀀스입니다.
즉, 문자열을 계속 새로 만드는 대신 하나의 버퍼 안에 이어 붙여 나갈 수 있습니다.

일반적인 String은 값을 바꾸는 것처럼 보여도 실제로는 새로운 문자열 객체가 만들어집니다.
그래서 문자열 덧셈이 반복되면 비효율적일 수 있습니다.

반면 StringBuilder는 같은 버퍼를 계속 사용하므로,
문자열을 많이 누적해야 하는 경우 훨씬 효율적입니다.

사용 방법

fun main() {
    val br = System.`in`.bufferedReader()
    val str = br.readLine()

    val sb = StringBuilder()
    sb.append(str)
    sb.append('a')

    print(sb)
}

위 예제에서는 입력받은 문자열 뒤에 문자 a를 추가하고 있습니다.

PS에서는 보통 반복문 안에서 결과를 하나씩 append()하고,
마지막에 한 번만 출력하는 방식으로 많이 사용합니다.


3. StringTokenizer

원문: “The string tokenizer class allows an application to break a string into tokens.”
StringTokenizer는 문자열을 구분자를 기준으로 잘라 여러 개의 토큰으로 나누는 클래스입니다.
즉, 이미 읽어 온 한 줄 문자열을 빠르게 분리할 때 유용합니다.

특히 BufferedReader와 함께 사용하면
한 줄 입력을 받고, 그 안의 값들을 공백 단위로 빠르게 꺼내는 패턴을 만들 수 있습니다.

in Kotlin

import java.io.BufferedReader
import java.io.InputStreamReader
import java.util.StringTokenizer

fun main() {
    val br = BufferedReader(InputStreamReader(System.`in`))
    val tokenizer = StringTokenizer(br.readLine())

    while (tokenizer.hasMoreTokens()) {
        println("토큰 : ${tokenizer.nextToken()}")
    }
}

원문: “The nextToken method returns the next token from this string tokenizer.”
nextToken()은 다음 토큰을 문자열로 반환하고,
hasMoreTokens()는 아직 읽지 않은 토큰이 남아 있는지 확인합니다.

StringTokenizer Method

Method설명
.nextToken()다음 토큰을 문자열로 반환한다.
.hasMoreTokens()다음 토큰이 존재하는지 확인한다.
.countTokens()남아 있는 토큰의 개수를 반환한다.

4. StreamTokenizer

원문: “The StreamTokenizer class takes an input stream and parses it into ‘tokens’, allowing the tokens to be read one at a time.”
StreamTokenizer는 입력 스트림 자체를 토큰 단위로 분석하여,
토큰을 하나씩 읽을 수 있게 해주는 클래스입니다.

즉, StringTokenizer문자열 한 줄을 나누는 도구라면,
StreamTokenizer입력 스트림 자체를 토큰으로 해석하는 도구라고 볼 수 있습니다.

in Kotlin

import java.io.InputStreamReader
import java.io.StreamTokenizer

fun main() {
    val tokenizer = StreamTokenizer(InputStreamReader(System.`in`))

    while (true) {
        val tokenType = tokenizer.nextToken()

        when (tokenType) {
            StreamTokenizer.TT_NUMBER -> {
                println("입력이 정수면? : ${tokenizer.nval.toInt()}")
            }
            StreamTokenizer.TT_WORD -> {
                println("입력이 문자열이면? : ${tokenizer.sval}")
            }
            StreamTokenizer.TT_EOF -> {
                println("입력 종료.")
                break
            }
            else -> {
                println("Char : '{${tokenizer.ttype.toChar()}}'")
            }
        }
    }
}

원문: “If the current token is a number, the value is in nval.”
StreamTokenizernextToken()으로 다음 토큰을 읽고,
그 결과를 nval, sval, ttype 같은 필드로 확인합니다.

  • 숫자면 nval
  • 단어면 sval
  • 토큰 종류는 ttype

이런 식으로 현재 토큰의 정보를 가져올 수 있습니다.

StreamTokenizer Method

Method설명
.nextToken()다음 토큰을 읽고 토큰 타입을 반환한다.
.nval현재 토큰이 숫자인 경우 값을 double로 저장한다.
.sval현재 토큰이 단어인 경우 문자열 값을 저장한다.
.ttype현재 토큰의 타입 정보를 저장한다.

그럼 궁극적으로 뭐가 제일 빠를까?

입출력 속도는 문제의 입력 형식과 출력량, 문자열 처리 여부에 따라 달라지기 때문에
“항상 이것이 절대적으로 가장 빠르다”라고 단정하기는 어렵습니다.

다만 PS에서 자주 쓰이는 패턴은 확실히 있습니다.

  • 간단한 입력: readln()
  • 많은 줄 입력: BufferedReader
  • 한 줄 안의 여러 값 분리: BufferedReader + StringTokenizer
  • 토큰 단위 빠른 입력: StreamTokenizer
  • 많은 출력 누적: StringBuilder, BufferedWriter

예를 들어 StreamTokenizer를 감싸서 아래처럼 사용할 수 있습니다.

import java.io.StreamTokenizer

fun main() = with(StreamTokenizer(System.`in`.bufferedReader())) {
    fun nextInt(): Int {
        nextToken()
        return nval.toInt()
    }

    fun nextString(): String {
        nextToken()
        return sval
    }

    val n = nextInt()
    println(n)

    val str = nextString()
    println(str)
}

이 방식은 PS에서 꽤 빠르게 동작하는 입력 패턴 중 하나입니다.


BOJ 1000

가장 간단한 문제인 BOJ 1000으로 입력 방식을 비교해 보겠습니다.

1. readln()

fun main() {
    val (a, b) = readln().split(" ").map { it.toInt() }
    println(a+b)
}

2. Scanner

import java.util.Scanner

fun main() {
    val sc = Scanner(System.`in`)
    val a = sc.nextInt()
    val b = sc.nextInt()
    print(a + b)
}

3. BufferedReader

fun main() {
    val br = System.`in`.bufferedReader()
    val bw = System.`out`.bufferedWriter()

    val (a, b) = br.readLine().split(" ").map { it.toInt() }

    bw.write("${a + b}")
    bw.flush()

    br.close()
    bw.close()
}

4. StreamTokenizer + bufferedReader

import java.io.StreamTokenizer

fun main() = with(StreamTokenizer(System.`in`.bufferedReader())) {
    fun nextInt(): Int {
        nextToken()
        return nval.toInt()
    }

    val a = nextInt()
    val b = nextInt()
    print(a + b)
}

속도는 채점 환경에 따라 달라질 수 있기 때문에 절대적인 순위를 말하기는 어렵습니다.
다만 일반적으로는 Scanner보다 BufferedReader, StringTokenizer, StreamTokenizer 계열이 PS에서 더 자주 쓰이는 편입니다.


📌 결론

빠른 입출력에는 정답이 하나만 있는 것은 아닙니다.
중요한 것은 문제의 입력과 출력 형식에 맞게 적절한 도구를 고르는 것이니 아래를 참조하여 제일 빠른 입출력을 사용해 봅시다!

  • 가장 단순하게 시작하고 싶다면 readln()
  • 입력량이 많다면 BufferedReader
  • 한 줄 안의 값을 빠르게 나누려면 StringTokenizer
  • 토큰 단위 입력을 직접 다루고 싶다면 StreamTokenizer
  • 출력이 많다면 StringBuilder, BufferedWriter

참고자료

profile
달을 향해 쏴라, 빗나가도 별이 될 테니 👊

0개의 댓글