在编程竞赛(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) 功能:
或者, 你也可以为函数 f
编写命令式(imperative)的实现, 使用传统的 while 循环, 和可变的变量, 在 Kotlin 中表达为 var:
在 Kotlin 中, 由于普遍使用类型推断(type-inference), 很多地方的类型声明是可选的, 但在编译时刻, 每一个声明仍然具有一个确定的静态类型.
现在, 只需要编写 main 函数, 读取输入, 以及实现题目要求的算法的其他部分 — 通过标准输入给定的初始整数 n
, 对它反复执行函数 f
, 计算产生的不同的整数的个数.
默认, Kotlin 运行在 JVM 平台, 能够直接访问大量而且高效的库, 包括通用的集合与数据结构, 比如动态大小的数组 (ArrayList
), 基于 hash 的 map 和 set (HashMap
/HashSet
), 基于树(tree) 的有序 map 和 set (TreeMap
/TreeSet
). 我们使用整数的 hash set 来追踪执行函数 f
时已经到达过的数值, 这个问题的简单的命令式编程风格的版本可以编写如下:
在编程竞赛中不需要处理输入错误的情况. 编程竞赛中的输入格式永远是正确的, 实际的输入不会与题目中描述的不同. 因此你可以使用 Kotlin 的 readln()
函数. 它假定输入字符串存在, 否则会抛出异常. 类似的, 如果输入字符串不是整数, String.toInt()
函数会抛出异常.
注意, 在 readLine() 函数调用之后使用了 Kotlin 的 null 判断操作符 !!
. Kotlin 的 readLine()
函数定义是返回一个 可为 null 的类型 String?
, 并在输入结束时返回 null
, 因此要求开发者处理没有输入的情况.
在编程竞赛中, 没有必要处理输入格式不正确的情况. 输入格式总是会明确指定, 而且实际输入不会违反题目描述中指定的输入格式. 这就是 null 判断操作符 !!
的含义 — 它假定输入字符串总是存在, 否则抛出一个异常. String.toInt() 也与此类似.
所有的在线编程竞赛都允许使用预先编写的代码, 因此你可以定义自己的工具函数库, 让你的解题代码更加简短, 容易阅读, 也容易编写. 然后可以使用这些代码作为解题答案的模板. 比如, 在编程竞赛中可以定义下面的辅助函数, 来读取输入:
注意这里使用了 private
可见度修饰符. 虽然可见度修饰符的概念与编程竞赛无关, 但通过使用它, 你可以从相同的代码模板创建多个解答文件, 而不会由于相同的包内存在多个同名的 public 声明而出现编译错误.
函数式操作符示例: 长数(Long Number)问题
对于更复杂的问题, Kotlin 对集合的函数式操作的扩展库可以很便利的减少样板代码, 让代码变成自顶向下的线性结构, 以及从左向右的数据变换管道. 比如, 问题 B: 长数(Long Number) 要求实现一个贪婪算法, 这个算法可以通过这种风格来实现, 完全不需要使用可变的变量:
在这段密集的代码中, 除了集合的变换之外, 你还看到很多 Kotlin 的便利功能, 比如局部函数, 以及 elvis 操作符 ?:
, 它可以将 惯用法 "如果值为正, 则使用这个值, 否则使用字符串长度", 写成简洁可读的表达式 .takeIf { it >= 0 } ?: s.length
, 但是 Kotlin 也完全可以定义额外的值可变的变量, 以命令式的风格来表达同样的代码.
在编程竞赛中, 为了让这种读取输入的任务更加简洁, 你可以定义下面这些辅助性的输入读取函数:
通过这些辅助函数, 读取输入的那部分代码可以变得更简单, 可以与题目描述的输入规格逐行对应:
注意, 在编程竞赛中为变量取的名字, 经常会比实际工作中要短, 因为代码只编写一次, 之后就不再维护了. 但是, 这些变量名仍然遵守一些规则, 以便于记忆 — a
表示数组, i
, j
, 等等表示下标, r
, 和 c
表述表中的行和列数, x
和 y
表示座标, 等等. 对输入数据使用与题目描述中相同的名称会比较简单. 但是, 更加复杂的问题要求更多的代码, 因此需要变量和函数的名称更长, 而且含义清晰.
更多提示和技巧
编程竞赛题目的输入通常类似如下:
输入的第一行包含两个整数 n
和 k
在 Kotlin 中, 可以对整数 List 使用 解构声明(Destructuring Declaration) 功能, 简洁的解析这样的输入行:
也可能会使用 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"
作为分隔符, 比如:
学习 Kotlin
Kotlin 很容易学习, 尤其是对于那些已经熟悉 Java 的程序员. 针对软件开发者的 Kotlin 基本语法简短介绍, 请参见在本站参考文档: 基本语法.
IDEA 已经内置了 Java 到 Kotlin 转换器. 可供熟悉 Java 的人用来学习对应的 Kotlin 语法结构, 但它并不完美, 还需要你自己来熟悉 Kotlin, 并学习 Kotlin 惯用法.
要学习 Kotlin 语法和 Kotlin 标准库 API, 一个很好的资源是 Kotlin Koan.