如果你打开力扣的题解区满眼是Java、Python、CKotlin几乎属于稀有物种。我一开始也是用Java刷力扣基础算法的刷到差不多两百题的时候换成了Kotlin换完才发现这个选择真的回不去了。同样一个思路用Kotlin写的代码通常能比Java少掉三分之一的样板跑大型测试用例性能也没有让我等得心慌。这篇文章主要分享我用Kotlin刷力扣基础算法以来最实在的实战经验热题100里到底翻来覆去考哪些基础算法、Kotlin有哪些其他语言没有的惯用法可以直接拿来解题、评测环境下有哪些你搜半天也找不到答案的细节坑以及我自己沉淀下来的刷题模板和本地开发流程。无论你是已经熟悉Java、Golang、Python还是刚学完Kotlin想找点题练手看完这篇应该都能直接上手。1. 从“用Java刷腻了”到“用Kotlin真香”我的选型过程1.1 力扣选择Kotlin的人为什么不多说句大实话力扣支持Kotlin提交很多年了但用的人一直不多。原因很简单大部分刷题的人是学生或者准备校招主力语言要么是C、Python要么是学校课上教的JavaKotlin在国内更多出现在Android开发岗位的简历里和算法面试关系不大。可我自己的体验是刷力扣基础算法这件事语言选对了效率能高很多。Kotlin和Java是同一个JVM生态几乎零迁移成本它有Java没有的空安全、类型推断、表达式化流程控制还有一套非常顺手的标准库函数。对已经在JVM系语言里泡过的人来说切到Kotlin刷题不是“换一门新语言重新学”而是“把以前写过的冗余代码删掉”。1.2 我和Java对比后的真实感受举一个最简单的例子两数之和。Java版往往长这样class Solution { public int[] twoSum(int[] nums, int target) { HashMapInteger, Integer map new HashMap(); for (int i 0; i nums.length; i) { int complement target - nums[i]; if (map.containsKey(complement)) { return new int[]{map.get(complement), i}; } map.put(nums[i], i); } return new int[0]; } }Kotlin版我最后落地的是这样class Solution { fun twoSum(nums: IntArray, target: Int): IntArray { val seen mutableMapOfInt, Int() for (i in nums.indices) { seen[target - nums[i]]?.let { return intArrayOf(it, i) } seen[nums[i]] i } return intArrayOf() } }两段代码逻辑完全一样但Kotlin少写了不少东西HashMapInt, Int()变成了mutableMapOf()循环索引不用从0开始手动维护map.containsKey加map.get两步判断合并成一句seen[...]?.let。这种简化在单题里看不出来当你连续刷几十道题累积节省的时间非常可观。1.3 标准库让解题代码变短的那些瞬间Kotlin最让我上头的不是语法本身而是标准库。比如字母异位词分组这一题Java要先把字符数组排序再转回字符串作为keyKotlin里groupBy一问加上一段排序逻辑就完成了分组。fun groupAnagrams(strs: ArrayString): ListListString { return strs.groupBy { it.toCharArray().sorted().joinToString() }.values.toList() }这种写法爽归爽但我必须提醒一句链式调用会创建中间集合在数据量特别大的时候有额外开销。刷基础算法阶段前两百题完全可以用等遇到卡性能的题再改成循环也不迟。Java和Kotlin的常见等价写法我整理成了一张表场景Java 写法Kotlin 写法定义不可变变量final int x 5;val x 5遍历数组for (int i 0; i arr.length; i)for (i in arr.indices)创建哈希表new HashMap()mutableMapOf()判空后调用方法if (obj ! null) obj.method();obj?.method()按规则分组手写循环维护MapgroupBy { ... }元素计数手写循环containsKeygroupingBy { it }.eachCount()这张表里的每一项直接对应刷题时的常见写法差异。看得懂这张表Kotlin刷题基本不会卡在语法上。2. 力扣“必刷基础算法”到底刷什么热题100的真实画像2.1 热题100里反复出现的基础算法类型我刷力扣热题100最大的感受是这些题并不是随机分布的背后有非常清晰的算法板块。把它们按类型切开大概是这样算法类型代表题目核心考察点数组与哈希两数之和、字母异位词分组空间换时间哈希表的覆盖范围双指针与滑动窗口移动零、盛最多水的容器、无重复字符的最长子串区间状态维护链表反转链表、合并两个有序链表、回文链表指针操作和哨兵节点二叉树二叉树中序遍历、最大深度、翻转二叉树递归结构与遍历框架图与DFS/BFS岛屿数量、腐烂的橘子连通性和层次扩散回溯全排列、子集、组合总和选择与撤销选择动态规划爬楼梯、打家劫舍、最大子数组和状态定义与转移方程数学技巧整数反转、回文数、x的平方根边界处理和位运算不要小看这个分类。刷题最怕的是“今天看一道矩阵题明天做一道链表题”大脑完全串联不起来。按板块刷每一类前两三道题打底后面遇到同类的直接套框架效率会高很多。2.2 先刷哪几类最划算我的推荐顺序是数组哈希 - 双指针 - 链表 - 二叉树 - DFS/BFS - 回溯 - 动态规划 - 图论。前四类是基础中的基础也是热题100里占比最重的部分。尤其是数组哈希和双指针几乎每场面试都可能出其中一道。而且这批题难度整体温和用新语言上手特别合适语言不熟的时候题目本身不该成为额外障碍。动态规划和回溯建议放在后面不是因为它们重要程度低而是因为它们需要前面的编码基础。如果你连二叉树递归都还没写顺直接做爬楼梯以外的DP题很容易被状态转移绕晕。2.3 怎么判断一道题算不算“基础算法”很多人有个误解觉得基础算法 简单的题。我在力扣上见过很多难度标记为“简单”但设计得很偏的题也见过标记为“中等”但真的是纯基础套路的题。我的判断标准有三条解法是否依赖某种通用算法框架比如双指针、二分、递归、DP、回溯核心数据结构是否是数组、链表、栈、队列、哈希表、树这些基本功这个思路是否能迁移到至少两到三道其他题上。符合这三条的不管标注难度是简单还是中等都属于“必刷基础算法”。热题100里的大多数题都符合这也是它为什么值得反复刷。3. 用Kotlin重写经典题从暴力到最优的六道代码实战3.1 两数之和暴力循环到哈希的一步之遥这道题我见过太多人第一反应是双重循环我也一样。但刷题经验告诉我看到“给一个数组找两个数满足某个条件”第一时间就应该想哈希表。暴力是O(n^2)哈希是O(n)在力扣上差别不是一点点。class Solution { fun twoSum(nums: IntArray, target: Int): IntArray { val seen mutableMapOfInt, Int() for (i in nums.indices) { seen[target - nums[i]]?.let { return intArrayOf(it, i) } seen[nums[i]] i } return intArrayOf() } }这里最值得记住的写法是seen[target - nums[i]]?.let { ... }。seen[key]返回的是Int?如果key不存在就是null?.let只在不为null时执行天然替代了if (map.containsKey(key))的套路。不用感叹号也不用两步判断一行搞定。有个小细节我们必须在确认答案存在之后再把当前值写进map否则可能出现同一个下标被用两次的情况。顺序写反是最常见的提交错误。3.2 无重复字符的最长子串滑动窗口加Map的经典套路这题我刷了三遍才彻底吃透。最简单的暴力思路是枚举每个子串去重判断但滑动窗口才是这一类题的标准解。class Solution { fun lengthOfLongestSubstring(s: String): Int { var left 0 var ans 0 val pos mutableMapOfChar, Int() s.forEachIndexed { right, c - pos[c]?.let { p - if (p left) left p 1 } pos[c] right if (right - left 1 ans) ans right - left 1 } return ans } }pos记录每个字符最近一次出现的位置。遇到重复字符时如果它出现在当前窗口内就把窗口左边界推进到它的下一位如果它出现在窗口外说明不影响当前窗口不需要移动。每次右指针移动后窗口长度right - left 1可能更新答案。很多人在这一步会犯错pos[c]返回的位置可能已经小于left了这时直接left p 1会把左边界拉回去导致结果错误。所以必须先判断p left。这个边界条件就是这道题和信息不完全的“背答案”之间的差距。3.3 合并两个有序链表递归与可空类型链表题在Kotlin里最突出的体验是空安全。力扣给出的ListNode定义中val是关键字所以属性名是val写的时候必须加反引号。另一个特点是next和参数都可能是null这意味着你写代码时被迫思考边界反而比Java省心。class Solution { fun mergeTwoLists(list1: ListNode?, list2: ListNode?): ListNode? { return when { list1 null - list2 list2 null - list1 list1.val list2.val - { list1.next mergeTwoLists(list1.next, list2) list1 } else - { list2.next mergeTwoLists(list1, list2.next) list2 } } } }这段代码里when被当作表达式使用每个分支末尾的值就是返回值。递归终止条件是某个链表为空直接返回另一个链表否则比较两个头节点值把较小节点的next指向剩余部分的合并结果。我第一次写这个解法时总觉得递归会创造出很长的调用链后来实测下来链表题递归深度一般不会超过节点数在力扣的默认栈深度下完全没问题。如果不想用递归迭代加哨兵节点的写法也值得练尤其是cur.next a ?: b这句把“处理剩余链表”压缩成了一行。3.4 二叉树中序遍历递归必须会迭代也要懂二叉树是Kotlin空安全最受益的领域。力扣的TreeNode定义中左右孩子都是TreeNode?递归遍历时再也不用写一堆判空。class Solution { fun inorderTraversal(root: TreeNode?): ListInt { val ans mutableListOfInt() root ?: return ans ans.addAll(inorderTraversal(root.left)) ans.add(root.val) ans.addAll(inorderTraversal(root.right)) return ans } }root ?: return ans这行是我最喜欢的Kotlin写法。它不是简单的判空而是把“如果节点为null直接返回当前list”这个意图表达得很清晰。递归版本背下来只需要一个顺序左、根、右。前序只是把root.val放到最前后序放到最后。但如果面试官继续问“你的递归会不会栈溢出”你就需要迭代版本。用显式栈模拟核心逻辑是一直往左走到底沿途入栈到底后弹出栈顶访问再转入右子树。class Solution { fun inorderTraversal(root: TreeNode?): ListInt { val ans mutableListOfInt() val stack ArrayDequeTreeNode() var cur root while (cur ! null || stack.isNotEmpty()) { while (cur ! null) { stack.addLast(cur) cur cur.left } cur stack.removeLast() ans.add(cur.val) cur cur.right } return ans } }本地IDE里写ArrayDeque时如果编辑器报错记得import java.util.ArrayDeque。力扣网页编辑器一般会给出提示但本地不自动导入。3.5 爬楼梯最朴素的动态规划“肌肉记忆”爬楼梯是最适合做DP入门的一道题因为状态转移太清晰了到第n阶只能从第n-1阶跨一步或者从第n-2阶跨两步。所以f(n) f(n-1) f(n-2)本质就是斐波那契。class Solution { fun climbStairs(n: Int): Int { if (n 2) return n var a 1 var b 2 for (i in 3..n) { val c a b a b b c } return b } }很多DP教程会先建一个IntArray(n1)来存所有状态但对这种只需要前两项的题滚动变量就够了。a代表f(i-2)b代表f(i-1)每轮计算新的c a b然后整体前移。这样做省内存也顺便训练了“空间优化”的直觉。顺便说打家劫舍那题也是这个套路只不过状态转移变成了f(i) max(f(i-1), f(i-2) nums[i])。把爬楼梯吃透再去对照打家劫舍你会发现DP没有想象中那么玄。3.6 全排列回溯算法在Kotlin里的干净写法回溯题是Kotlin局部函数最好的秀场。你可以直接在fun里面再定义fun dfs()变量自动共享不需要额外传参传一堆。class Solution { fun permute(nums: IntArray): ListListInt { val ans mutableListOfListInt() val path mutableListOfInt() val used BooleanArray(nums.size) fun dfs() { if (path.size nums.size) { ans.add(path.toList()) return } for (i in nums.indices) { if (used[i]) continue used[i] true path.add(nums[i]) dfs() path.removeAt(path.lastIndex) used[i] false } } dfs() return ans } }最核心的坑是ans.add(path.toList())。如果你直接ans.add(path)后面任何一次path.removeAt都会影响已经放进答案里的那个列表因为它们是同一个对象。toList()相当于拍了一张快照把当前路径复制一份存进去。used数组负责标记哪些元素已经用过这就是“选择-递归-撤销选择”的回溯骨架。没被选中的分支会走used[i] false这些代码看着重复但删除任何一行都会让结果错得离谱。稍后我会在模板章节再展开。4. 千万别踩的坑力扣Kotlin环境下的性能与写法注意事项4.1 提交物该写什么只有类和方法没有main这是新手入坑最容易踩的编译错误。力扣的代码编辑区只需要你填写Solution类里的方法实现不要写fun main()也不要写println打印输入。本地跑需要main提交时要删掉。我见过不少人在本地IDE里写完整程序跑通了然后把包含fun main的整个文件粘到力扣编译直接报错。解法很简单提交前检查文件末尾确认没有fun main确认所有测试代码都被注释掉。4.2 高阶函数有代价什么时候该换回for循环Kotlin的高阶函数写起来很爽但它本质上是lambda表达式加中间集合。比如nums.filter { ... }.map { ... }第一次filter生成一个中间list第二次map再生成一个list。数据量小这是优雅数据量到了十万级这就是负担。热题100里的基础算法题大部分数据规模在几百到几万之间链式调用几乎都能过。但如果你在跑最大子数组和、最长递增子序列这类题时发现超时优先检查自己的链式调用是不是创建了太多中间对象。我的习惯是思路确认后先把关键循环用for写出来再用Kotlin语法简化。举一个最大子数组和的例子var cur 0 var best Int.MIN_VALUE for (x in nums) { cur maxOf(cur x, x) best maxOf(best, cur) }这个版本没有中间集合没有lambda调用编译后和手写Java几乎没区别。不是说高阶函数不能写而是要知道边界在哪。4.3 可空性在链表和树题里的正确用法力扣的链表、树节点天然是可空的所以遍历时最常见的写法是var cur: ListNode? head while (cur ! null) { // 使用 cur.val cur cur.next }用while (cur ! null)而不是while (cur!!.next ! null)因为后者一旦cur为null就直接抛NPE。Kotlin的while条件会做智能转换在循环体内部编译器知道cur不为null不需要加任何符号。很多从Java转过来的人喜欢到处写!!能用但不推荐。!!的本意是“我确定它不是null”如果这个确信是错的程序直接崩溃。在链表题里用while (cur ! null)让编译器帮你证明远比手动打感叹号可靠。4.4 by lazy在刷题里到底有没有用热词里出现了by lazy我得说点反直觉的实话在力扣算法题里by lazy几乎没什么用武之地。by lazy适合类属性级别的延迟初始化比如一个类创建后某些重对象到第一次访问时才构建但力扣的Solution类通常每个测试用例都会新建一次调用里你更希望用局部变量而不是类属性。我唯一能想到的合理场景是把一个很大的预处理结果挂在Solution的属性上通过by lazy避免重复构建。但力扣每题单独运行一个实例实际收益非常小。by lazy是工程代码里的好东西不是算法题里的银弹别为了用而用。4.5 递归深度和栈溢出的阈值判断JVM的默认栈深度大概在数千层到上万层之间。二叉树中序遍历高度是n的极端情况如果链表化递归深度就很大。基础算法题里链表反转、二叉树遍历用递归都没问题但像岛屿数量那种在棋盘上蔓延的DFS最坏情况递归深度可能是m*n如果棋盘是200x200就有四万层栈必爆。遇到这种题要么把递归改成显式栈要么用BFS。判断阈值很简单递归深度超过几千就需要警惕。Kotlin没有额外优化别赌栈不会爆。5. 把刷题经验沉淀成模板我整理的Kotlin模板清单5.1 滑动窗口模板一类题盖住大半双指针题滑动窗口的核心框架很简单右指针一步步前进进入窗口左指针在窗口不满足条件时收缩。这个框架吃透无重复字符最长子串、最小覆盖子串、字符串排列一类题都能套。fun slide(nums: IntArray, k: Int): Int { var left 0 var ans 0 var windowSum 0 for (right in nums.indices) { windowSum nums[right] while (windowSum k) { windowSum - nums[left] left } ans maxOf(ans, right - left 1) } return ans }这里nums是整数数组条件可能换成“子串无重复字符”“子串包含某个字符集”等等。模板的价值不在代码本身而在于你遇到新题时知道这类题可以用“窗口两指针”去思考。5.2 二叉树遍历模板前中后序一个套路基础算法里的树题大多建立在遍历之上。前序、中序、后序的区别只是访问节点的位置不同用递归写Kotlin可以非常精简fun inorder(root: TreeNode?): ListInt if (root null) emptyList() else inorder(root.left) listOf(root.val) inorder(root.right)这段代码可读性极高但会创建很多中间List。如果题目数据量大建议写成mutableListOfInt()配合辅助函数fun dfs(node: TreeNode?, ans: MutableListInt) { node ?: return dfs(node.left, ans) ans.add(node.val) dfs(node.right, ans) }面试时我会先给这个版本再聊递归转迭代的思路。模板的价值是让你不用在基础树上花时间思考结构把精力留给题目真正的考点。5.3 回溯模板全排列子集组合的通用骨架回溯是所有DFS类题的母版。把“选择-递归-撤销选择”这个循环记牢全排列、组合、子集、N皇后都能套fun backtrack( path: MutableListInt, choices: ListInt, used: BooleanArray, ans: MutableListListInt ) { if (path.size choices.size) { ans.add(path.toList()) return } for (i in choices.indices) { if (used[i]) continue used[i] true path.add(choices[i]) backtrack(path, choices, used, ans) path.removeAt(path.lastIndex) used[i] false } }子集和组合题往往不需要used数组改成从startIndex开始枚举避免重复组合。这就是模板的迁移骨架不变剪枝条件变。5.4 并查集模板图论题的免死金牌岛屿数量、省份数量、冗余连接这类图论基础题用并查集非常顺手。Kotlin版模板我常年存着class DSU(val n: Int) { private val parent IntArray(n) { it } fun find(x: Int): Int { var i x while (parent[i] ! i) { parent[i] parent[parent[i]] i parent[i] } return i } fun union(a: Int, b: Int) { val ra find(a) val rb find(b) if (ra ! rb) parent[rb] ra } }parent[i] parent[parent[i]]是路径压缩让每次find都更接近O(1)。按秩合并在这个基础上还能再优化一点但基础题的约束下路径压缩基本够用。5.5 快速幂模板数学类的底层引擎求幂、取模这类数学题循环乘会超时快速幂是必备模板。Kotlin位运算写起来很自然fun qpow(base: Long, exp: Int, mod: Long): Long { var a base % mod var b exp var ans 1L while (b 0) { if ((b and 1) 1) ans ans * a % mod a a * a % mod b b shr 1 } return ans }每一次循环指数b右移一位a自乘。如果当前位是1就把a乘到结果里。这个过程等同于把指数拆成二进制按位累乘复杂度从O(n)降到O(log n)。刷题不需要背住所有模板只需要在需要用的时候能想起“这类题我整理过模板”然后翻出来改一改。我建议你把模板单独放一个文件比如Templates.kt随用随取。6. 本地IDE和快捷键把刷题流程工程化的操作细节6.1 为什么我把刷题主战场从网页搬到IDEA力扣网页编辑器在浏览器里写题体验其实还可以但它没有强大的重构、重命名、批量修改能力。刷题到一定程度代码量变大之后我越来越依赖IntelliJ IDEA代码可以当场编译运行断点调试能看变量状态快捷键能瞬间完成重命名和导入。我的流程很简单从力扣把题目描述复制到本地在src/main/kotlin里新建一个文件粘上class Solution和题干旁的样例写完先在本地跑测试通过后再把方法体粘回力扣提交。一来一回看起来多了一步实际调试效率提升非常明显。6.2 最常用的八组快捷键我自己常年用的快捷键如下Windows和macOS我同时给了操作Windows/LinuxmacOS我的用途重命名ShiftF6ShiftF6把a改成leftIndex提取变量CtrlAltVCmdAltV抽出重复表达式提取函数CtrlAltMCmdAltM把一大段递归逻辑拆成函数自动补全CtrlSpaceCtrlSpace补全函数名和变量名快速修复/导入AltEnterOptionEnter导入缺失的ArrayDeque查找选中项CtrlGCmdG跳到指定行选择下一个相同词AltJCtrlG批量修改多处相似变量注释/取消注释Ctrl/Cmd/临时屏蔽调试代码真正高频的其实就是AltEnter和ShiftF6。前者修导入后者改变量名。名字起得好代码可读性直接上一个台阶。6.3 一个本地跑题模板告别点击“运行”的焦虑很多人喜欢用println验证结果但输出要看一眼才能确定对不对。更好的办法是用check断言fun main() { val solution Solution() check(solution.lengthOfLongestSubstring(abcabcbb) 3) check(solution.lengthOfLongestSubstring(bbbbb) 1) check(solution.lengthOfLongestSubstring(pwwkew) 3) }如果所有断言通过程序静默退出如果某个样例不通过立刻抛异常告诉你预期值和实际值不一致。配合IDEA单测会更完整但刷题阶段check就够用了。我每个题文件底部都留着这个main提交时删掉下次复习时把新样例加进去直接跑。6.4 用Git按类型管理你的刷题仓库目录结构我建议按算法类型分而不是按日期分src/main/kotlin/ array/ slidingwindow/ linkedlist/ binarytree/ backtrack/ dp/ math/每个文件开头写一两行注释记录题目编号、思路、复杂度、踩坑点。比如// 3. 无重复字符的最长子串 // 双指针哈希表O(n) // 注意p left 时才更新left class Solution { ... }三个月后回头看你不是刷了三百道题而是建立了一个自己的算法查找表。遇到新题先翻这个表找相似思路比从头回忆快得多。我最满意的流程就是这套组合本地IDEA Git按类型归档 模板文件 每道题保留check断言。刚开始可能会觉得多花了一点时间但坚持几十题后你会发现自己对新题的拆解速度明显比单纯刷题更快。这是我用Kotlin刷力扣基础算法最值得分享的收获也希望能帮正在这条路上坚持的你少走几步弯路。