Kotlin 语言参考文档 中文版 Help

在编程竞赛(Competitive Programming)中使用 Kotlin

本教程针对的读者是以前未使用过 Kotlin 的编程竞赛参加者, 以及以前未参加过编程竞赛的 Kotlin 开发者. 对这两种情况, 我们假设读者已经具备相应的编程技能.

编程竞赛 是一种智力竞赛, 参赛者编写程序来解决指定的算法问题, 并要满足严格的限定条件. 这里的程序可能很简单, 任何开发者都能够解决, 只需要很少的代码就能得到答案, 也可能很复杂, 需要知道特定的算法, 数据结构, 以及大量的实践经验. 尽管 Kotlin 并不是针对编程竞赛特别设计的, 但它恰好适合这一领域, 能够大量减少程序员需要编写和阅读的样板代码(Boilerplate Code), 因此程序员既能够象使用动态类型(dynamically-typed)脚本语言那样高效率的读写代码, 同时又拥有静态类型(statically-typed)语言提供的工具支持和性能优势.

关于如何设置 Kotlin 开发环境, 请参见 Kotlin/JVM 入门. 在编程竞赛中, 通常会创建单个项目, 然后每个问题的解答会在单个源代码文件中编写.

简单的示例: 可达数(Reachable Number)问题

下面我们来看一个具体的例子.

Codeforces 第 555 轮已于 4月26日 举办了第 3 组, 因此它有很多问题可供任何开发者尝试. 你可以通过 这个链接 来阅读这些问题. 其中最简单的问题是 问题 A: 可达数(Reachable Number). 它要求实现题干部分描述的一个简单算法.

我们来解决这个问题, 首先创建一个任意名称的 Kotlin 源代码文件. A.kt 也可以. 首先, 你需要实现题干部分指定的一个函数, 如下:

我们的函数 f(x) 如下: 我们对 x 加 1, 然后, 如果结果数字的末尾存在至少 1 个 0, 我们删除这个 0.

Kotlin 是一种注重实践、无固定成见的语言, 既支持命令式(imperative), 也支持函数式(function programming)编程风格, 并不强迫开发者使用哪一种. 你可以用函数式编程风格来实现函数 f, 使用 Kotlin 的 尾递归(tail recursion) 功能:

tailrec fun removeZeroes(x: Int): Int = if (x % 10 == 0) removeZeroes(x / 10) else x fun f(x: Int) = removeZeroes(x + 1)

或者, 你也可以为函数 f 编写命令式(imperative)的实现, 使用传统的 while 循环, 和可变的变量, 在 Kotlin 中表达为 var:

fun f(x: Int): Int { var cur = x + 1 while (cur % 10 == 0) cur /= 10 return cur }

在 Kotlin 中, 由于普遍使用类型推断(type-inference), 很多地方的类型声明是可选的, 但在编译时刻, 每一个声明仍然具有一个确定的静态类型.

现在, 只需要编写 main 函数, 读取输入, 以及实现题目要求的算法的其他部分 — 通过标准输入给定的初始整数 n, 对它反复执行函数 f, 计算产生的不同的整数的个数.

默认, Kotlin 运行在 JVM 平台, 能够直接访问大量而且高效的库, 包括通用的集合与数据结构, 比如动态大小的数组 (ArrayList), 基于 hash 的 map 和 set (HashMap/HashSet), 基于树(tree) 的有序 map 和 set (TreeMap/TreeSet). 我们使用整数的 hash set 来追踪执行函数 f 时已经到达过的数值, 这个问题的简单的命令式编程风格的版本可以编写如下:

fun main() { var n = readln().toInt() // 从标准输入读取整数 val reached = HashSet<Int>() // 可变的 hash set while (reached.add(n)) n = f(n) // 反复执行函数 f println(reached.size) // 将答案打印到标准输出 }

在编程竞赛中不需要处理输入错误的情况. 编程竞赛中的输入格式永远是正确的, 实际的输入不会与题目中描述的不同. 因此你可以使用 Kotlin 的 readln() 函数. 它假定输入字符串存在, 否则会抛出异常. 类似的, 如果输入字符串不是整数, String.toInt() 函数会抛出异常.

fun main() { var n = readLine()!!.toInt() // 从标准输入读取整数 val reached = HashSet<Int>() // 可变的 hash set while (reached.add(n)) n = f(n) // 反复执行函数 f println(reached.size) // 将答案打印到标准输出 }

注意, 在 readLine() 函数调用之后使用了 Kotlin 的 null 判断操作符 !!. Kotlin 的 readLine() 函数定义是返回一个 可为 null 的类型 String?, 并在输入结束时返回 null, 因此要求开发者处理没有输入的情况.

在编程竞赛中, 没有必要处理输入格式不正确的情况. 输入格式总是会明确指定, 而且实际输入不会违反题目描述中指定的输入格式. 这就是 null 判断操作符 !! 的含义 — 它假定输入字符串总是存在, 否则抛出一个异常. String.toInt() 也与此类似.

所有的在线编程竞赛都允许使用预先编写的代码, 因此你可以定义自己的工具函数库, 让你的解题代码更加简短, 容易阅读, 也容易编写. 然后可以使用这些代码作为解题答案的模板. 比如, 在编程竞赛中可以定义下面的辅助函数, 来读取输入:

private fun readStr() = readln() // 读取单个字符串行 private fun readInt() = readStr().toInt() // 读取单个整数 // 对你的解答中需要用到的其他类型, 编写类似函数
private fun readStr() = readLine()!! // 读取单个字符串行 private fun readInt() = readStr().toInt() // 读取单个整数 // 对你的解答中需要用到的其他类型, 编写类似函数

注意这里使用了 private 可见度修饰符. 虽然可见度修饰符的概念与编程竞赛无关, 但通过使用它, 你可以从相同的代码模板创建多个解答文件, 而不会由于相同的包内存在多个同名的 public 声明而出现编译错误.

函数式操作符示例: 长数(Long Number)问题

对于更复杂的问题, Kotlin 对集合的函数式操作的扩展库可以很便利的减少样板代码, 让代码变成自顶向下的线性结构, 以及从左向右的数据变换管道. 比如, 问题 B: 长数(Long Number) 要求实现一个贪婪算法, 这个算法可以通过这种风格来实现, 完全不需要使用可变的变量:

fun main() { // 读取输入 val n = readln().toInt() val s = readln() val fl = readln().split(" ").map { it.toInt() } // 定语局部函数 f fun f(c: Char) = '0' + fl[c - '1'] // 贪婪查找第一个和最后一个下标 val i = s.indexOfFirst { c -> f(c) > c } .takeIf { it >= 0 } ?: s.length val j = s.withIndex().indexOfFirst { (j, c) -> j > i && f(c) < c } .takeIf { it >= 0 } ?: s.length // 组合答案, 并输出 val ans = s.substring(0, i) + s.substring(i, j).map { c -> f(c) }.joinToString("") + s.substring(j) println(ans) }
fun main() { // 读取输入 val n = readLine()!!.toInt() val s = readLine()!! val fl = readLine()!!.split(" ").map { it.toInt() } // 定语局部函数 f fun f(c: Char) = '0' + fl[c - '1'] // 贪婪查找第一个和最后一个下标 val i = s.indexOfFirst { c -> f(c) > c } .takeIf { it >= 0 } ?: s.length val j = s.withIndex().indexOfFirst { (j, c) -> j > i && f(c) < c } .takeIf { it >= 0 } ?: s.length // 组合答案, 并输出 val ans = s.substring(0, i) + s.substring(i, j).map { c -> f(c) }.joinToString("") + s.substring(j) println(ans) }

在这段密集的代码中, 除了集合的变换之外, 你还看到很多 Kotlin 的便利功能, 比如局部函数, 以及 elvis 操作符 ?:, 它可以将 惯用法 "如果值为正, 则使用这个值, 否则使用字符串长度", 写成简洁可读的表达式 .takeIf { it >= 0 } ?: s.length, 但是 Kotlin 也完全可以定义额外的值可变的变量, 以命令式的风格来表达同样的代码.

在编程竞赛中, 为了让这种读取输入的任务更加简洁, 你可以定义下面这些辅助性的输入读取函数:

private fun readStr() = readln() // 读取单个字符串行 private fun readInt() = readStr().toInt() // 读取单个整数 private fun readStrings() = readStr().split(" ") // 读取多个字符串 private fun readInts() = readStrings().map { it.toInt() } // 读取多个整数
private fun readStr() = readLine()!! // 读取单个字符串行 private fun readInt() = readStr().toInt() // 读取单个整数 private fun readStrings() = readStr().split(" ") // 读取多个字符串 private fun readInts() = readStrings().map { it.toInt() } // 读取多个整数

通过这些辅助函数, 读取输入的那部分代码可以变得更简单, 可以与题目描述的输入规格逐行对应:

// 读取输入 val n = readInt() val s = readStr() val fl = readInts()

注意, 在编程竞赛中为变量取的名字, 经常会比实际工作中要短, 因为代码只编写一次, 之后就不再维护了. 但是, 这些变量名仍然遵守一些规则, 以便于记忆 — a 表示数组, i, j, 等等表示下标, r, 和 c 表述表中的行和列数, xy 表示座标, 等等. 对输入数据使用与题目描述中相同的名称会比较简单. 但是, 更加复杂的问题要求更多的代码, 因此需要变量和函数的名称更长, 而且含义清晰.

更多提示和技巧

编程竞赛题目的输入通常类似如下:

输入的第一行包含两个整数 nk

在 Kotlin 中, 可以对整数 List 使用 解构声明(Destructuring Declaration) 功能, 简洁的解析这样的输入行:

val (n, k) = readInts()

也可能会使用 JVM 的 java.util.Scanner 类来解析缺少结构的输入格式. Kotlin 被设计为能够与 JVM 的库良好交互, 因此在 Kotlin 中使用这些库会感到非常自然. 但是, 请注意 java.util.Scanner 非常的慢. 实际上, 它太慢了, 以至于解析 105 以上个整数时, 可能会超过题目通常要求的 2 秒运行时间限制, 这样的输入, 使用简单的 Kotlin 函数 split(" ").map { it.toInt() } 就可以解决.

在 Kotlin 中输出通常使用简单的 println(...) 调用, 并使用 Kotlin 的 字符串模板. 但是, 如果输出包含 105 行以上时, 一定要注意. 调用这样多次的 println 会非常的慢, 因为在 Kotlin 中标准输出会在每一行之后自动刷出. 要从数组或列表输出大量行内容, 更快的方式是使用 joinToString() 函数, 用 "\n" 作为分隔符, 比如:

println(a.joinToString("\n")) // 数组/列表的每个元素成为单独的行

学习 Kotlin

Kotlin 很容易学习, 尤其是对于那些已经熟悉 Java 的程序员. 针对软件开发者的 Kotlin 基本语法简短介绍, 请参见在本站参考文档: 基本语法.

IDEA 已经内置了 Java 到 Kotlin 转换器. 可供熟悉 Java 的人用来学习对应的 Kotlin 语法结构, 但它并不完美, 还需要你自己来熟悉 Kotlin, 并学习 Kotlin 惯用法.

要学习 Kotlin 语法和 Kotlin 标准库 API, 一个很好的资源是 Kotlin Koan.

最终更新: 2024/10/17