知识图库
Java知识库
JDK线程池实现原理
Java中的强、软、弱、虚引用
深入拆解Java虚拟机
01 开篇词 | 为什么我们要学习Java虚拟机?
02 Java代码是怎么运行的?
03 Java的基本类型
04 Java虚拟机是如何加载Java类的?
05 JVM是如何执行方法调用的?(上)
06 JVM是如何执行方法调用的?(下)
7 JVM是如何处理异常的?
Java面试常见问题整理
Java面试常见问题-Java 基础篇
Java面试常见问题-Jvm篇
Java面试常见问题-并发篇
Android知识库
Kotlin编程第一课
1 开篇词 | 入门Kotlin有多容易,精通Kotlin就有多难
2 Kotlin基础语法:正式开启学习之旅
3 面向对象:理解Kotlin设计者的良苦用心
4 Kotlin原理:编译器在幕后干了哪些“好事”?
5 实战:构建一个Kotlin版本的四则运算计算器
6 object关键字:你到底有多少种用法?
7 扩展:你的能力边界到底在哪里?
8 高阶函数:为什么说函数是Kotlin的“一等公民”?
9 实战:用Kotlin写一个英语词频统计程序
10 加餐一 | 初识Kotlin函数式编程
11 委托:你为何总是被低估?
12 泛型:逆变or协变,傻傻分不清?
13 注解与反射:进阶必备技能
14 实战:用Kotlin实现一个网络请求框架KtHttp
15 加餐二 | 什么是“表达式思维”?
16 加餐三 | 什么是“不变性思维”?
17 加餐四 | 什么是“空安全思维”?
18 春节刷题计划(一)| 当Kotlin遇上LeetCode
19 春节刷题计划(二)| 一题三解,搞定版本号判断
20 春节刷题计划(三)| 一题双解,搞定求解方程
21 春节刷题计划(四)| 一题三解,搞定分式加减法
22 什么是“协程思维模型”?
23 如何启动协程?
24 挂起函数:Kotlin协程的核心
25 Job:协程也有生命周期吗?
26 Context:万物皆为Context?
27 实战:让KtHttp支持挂起函数
28 期中考试 | 用Kotlin实现图片处理程序
29 题目解答 | 期中考试版本参考实现
30 Channel:为什么说Channel是“热”的?
31 Flow:为什么说Flow是“冷”的?
32 select:到底是在选择什么?
33 并发:协程不需要处理同步吗?
34 异常:try-catch居然会不起作用?坑!
35 实战:让KtHttp支持Flow
36 答疑(一)| Java和Kotlin到底谁好谁坏?
37 集合操作符:你也会“看完就忘”吗?
38 协程源码的地图:如何读源码才不会迷失?
39 图解挂起函数:原来你就是个状态机?
40 加餐五 | 深入理解协程基础元素
41 launch的背后到底发生了什么?
42 Dispatchers是如何工作的?
43 CoroutineScope是如何管理协程的?
44 图解Channel:如何理解它的CSP通信模型?
45 图解Flow:原来你是只纸老虎?
46 Java Android开发者还会有未来吗?
47 Kotlin与Jetpack简直是天生一对!
48 用Kotlin写一个GitHub Trending App
49 结课测试 | “Kotlin编程第一课”100分试卷等你来挑战!
50 结束语 | 不忘初心
Android Framework 教程—基础篇
01 Ubuntu 使用快速入门
02 Make 构建工具入门
03 理解 Unicode UTF-8 UTF-16 UTF-32
04 Linux Shell 脚本编程入门1——核心基础语法
05 SeAndroid 使用极速上手
06 理解 C++ 的 Memory Order
07 AOSP 极速上手
08 系统开发工具推荐
09 添加 Product
运动相关知识
爱上跑步
01 开篇词 | 跑步,不那么简单的事儿
02 跑两步就喘了,是不是我不适合跑步?
03 正确的跑步姿势是什么样的?
04 为什么跑步要先热身?
05 怎样制定你的第一个10公里跑步计划?
06 快跑和慢跑,哪个更燃脂?
07 普通跑步者应该如何选择跑鞋?
08 买跑步装备,不要踩这些坑儿
09 跑步前到底应不应该吃东西?
10 跑步到底伤不伤膝盖?
11 参加了20场马拉松,我是如何准备的?
12 除了马拉松,还能参加哪些跑步赛事?
13 热点问题答疑 :跑完第二天浑身疼,还要不要继续跑?
健身房计划
[DeepSeek]减脂塑形计划
【DeepSeek】训练周期安排
每日餐饮热量控制
减脂期间食物推荐避坑指南
HarmonyOS知识库
其他知识类目
心理学相关
如何学点心理学——关于非专业人士学心理学的一点建议
投射性认同
-
+
首页
19 春节刷题计划(二)| 一题三解,搞定版本号判断
今天是除夕夜,先祝你虎年春节快乐! 在上节刷题课中,我给你留了一个作业,那就是:用Kotlin来完成 LeetCode的165号题《版本号判断》。那么今天这节课,我就来讲讲我的解题思路,希望能给你带来一些启发。 这道题目其实跟我们平时的工作息息相关。给你两个字符串代表的版本号,需要你判断哪个版本号是新的,哪个版本号是旧的。比如,2.0与1.0对比的话,2.0肯定是新版本,1.0肯定是旧版本。对吧? 不过,这里面还有一些问题需要留意,这些都是我们在正式写代码之前要弄清楚的。 - 首先,版本号是可能以0开头的。比如0.1、1.01,这些都是合理的版本号。 - 另外,如果是以0开头的话,1个0和多个0,它们是等价的,比如1.01、1.001、1.00001之间就是等价的,也就是说这几个版本号其实是相等的。 - 还有,1.0、1.0.0、1.0.0.0它们之间也是等价的,也就是说这几个版本号也是相等的。 ### 思路一 好了,理解了题意以后,我们就可以开始写代码了,LeetCode上面给了我们一个待实现的方法,大致如下: ```kotlin fun compareVersion(version1: String, version2: String): Int { // 待完善 } ``` 分析完题目以后,也许你已经发现了,这道题目其实并不需要什么特殊的数据结构和算法基础,这是一道单纯的“模拟题”。我们脑子里是如何对比两个版本号的,我们的代码就可以怎么写。 下面我做了一个动图,展示了版本号对比的整体流程。  我们可以看到,这个对比的流程,大致可以分为以下几个步骤。 - 第一步,将版本号的字符串用“点号”进行分割,得到两个字符串的列表。 - 第二步,同时遍历这两个列表,将列表中的每一个元素转换成整数,比如,当遍历到第二位的时候,5、05这两个字符串,都会转换成数字5。这里有个细节,那就是当版本号的长度不一样的时候,比如,遍历到7.05.002.2的最后一位时,7.5.2其实已经越界了,这时候我们需要进行补零,然后再转换成数字。 - 第三步,根据转换后的数字进行对比,如果两者相等的话,我们就继续遍历下一位。如果不相等的话,我们就能直接返回对比的结果了。 - 第四步,如果两个版本号都遍历到了末尾,仍然没有对比出大小的差异,那么我们就认为这两个版本号相等,返回0即可。 所以,按照上面的思路,我们可以把compareVersion()这个函数分为以下几个部分: ```kotlin fun compareVersion(version1: String, version2: String): Int { // ① 使用“.”,分割 version1 和 version2,得到list1、list2 // ② 同时遍历list1、list2,取出的元素v1、v2,并将其转换成整数,这里注意补零操作 // ③ 对比v1、v2的大小,如果它们两者不一样,我们就可以终止流程,直接返回结果。 // ④ 当遍历完list1、list2后仍然没有判断出大小话,说明两个版本号其实是相等的,这时候应该返回0 } ``` 那么接下来,其实就很简单了。我们只需要将注释里面的自然语言,用代码写出来就行了。具体代码如下: ```kotlin fun compareVersion(version1: String, version2: String): Int { // ① 分割 val list1 = version1.split(".") val list2 = version2.split(".") var i = 0 while (i < list1.size || i < list2.size) { // ② 遍历元素 val v1 = list1.getOrNull(i)?.toInt()?:0 val v2 = list2.getOrNull(i)?.toInt()?:0 // ③ 对比 if (v1 != v2) { return v1.compareTo(v2) } i++ } // ④ 相等 return 0 } ``` 在上面的代码中,有两个地方需要格外注意。 - 一个是while循环的条件。由于list1、list2的长度可能是不一样的,所以,我们的循环条件是:list1、list2当中只要有一个没有遍历完的话,我们就要继续遍历。 - 还有一个需要注意的地方,getOrNull(i),这是Kotlin独有的库函数。使用这个方法,我们不必担心越界问题,当index越界以后,这个方法会返回null,在这里我们把它跟 Elvis表达式结合起来,就实现了自动补零操作。这也体现出了Kotlin表达式语法的优势。 好,到这里,我们就用第一种思路实现了版本号对比的算法。下面我们再来看看第二种思路。 ### 思路二 前面的思路,我们是使用的Kotlin的库函数split()进行分割,然后对列表进行遍历来判断的版本号。其实,这种思路还可以进一步优化,那就是我们自己遍历字符串,来模拟split的过程,然后在遍历过程中,我们顺便就把比对的工作一起做完了。 思路二的整体过程比较绕,我同样是制作了一个动图来描述这个算法的整体流程:  以上的整体算法过程,是典型的“双指针”思想。运用这样的思想,我们大致可以写出下面这样的代码: ```kotlin fun compareVersion(version1: String, version2: String): Int { val length1 = version1.length val length2 = version2.length // ① var i = 0 var j = 0 // ② while (i < length1 || j < length2) { // ③ var x = 0 while (i < length1 && version1[i] != '.') { x = x * 10 + version1[i].toInt() - '0'.toInt() i++ } i++ // ④ var y = 0 while (j < length2 && version2[j] != '.') { y = y * 10 + version2[j].toInt() - '0'.toInt() j++ } j++ // ⑤ if (x != y) { return x.compareTo(y) } } // ⑥ return 0 } ``` 这段代码一共有6个注释,我们来一个个解释。 - 注释①,代表的就是我们遍历两个版本号的index,双指针,指的就是它们两个。 - 注释②,最外层的while循环,其实就是为了确保双指针可以遍历到两个字符串的末尾。你注意下这里的循环条件,只要version1、version2当中有一个没到末尾,就会继续遍历。 - 注释③,这里就是在遍历version1,一直到字符串末尾,或者遇到“点号”。在同一个循环当中,我们会对x的值进行累加,这个做法其实就是把字符串的数字转换成十进制的数字。 - 注释④,这里和注释③的逻辑一样,只是遍历的对象是version2。 - 注释⑤,这里会对累加出来的x、y进行对比,不相同的话,我们就可以返回结果了。 - 注释⑥,如果遍历到末尾还没有结果,这就说明version1、version2相等。 现在,我们就已经用Kotlin写出了两个题解,使用的思路都是命令式的编程方式。也许你会好奇,这个问题能用函数式的思路来实现吗? 答案当然是可以的! ### 思路三 我们在前面就提到过,Kotlin是支持多范式的,我们可以根据实际场景来灵活选择编程范式。那么在这里,我们可以借鉴一下前面第一种解法的思路。 其实,想要解决这个问题,我们只要能把version1、version2转换成两个整数的列表,就可以很好地进行对比了。我制作了一个动图,方便你理解:  根据这个流程,我们可以大致写出下面这样的代码: ```kotlin fun compareVersion(version1: String, version2: String): Int = version1.split(".") .zipLongest(version2.split("."), "0") // ① .onEach { // ② with(it) { if (first != second) { return first.compareTo(second) } } }.run { return 0 } ``` 这段代码看起来很简洁,核心的逻辑在两个方法当中,我分别用注释标注了。 - 注释①,zipLongest()这个方法,它的作用是将version1、version2对应的列表合并到一起,它返回值的类型是List>。 - 注释②,onEach(),其实它是一个高阶函数,它的作用就是遍历List当中的每一个Pair,将其中的整型版本号拿出来对比,如果不一样,就可以直接返回结果。 现在,你可能会感慨,这代码看起来真香啊!这个嘛……别高兴得太早。虽然Kotlin支持基础的zip语法,但它目前还不支持zipLongest()这么高级的操作符。 那么这该怎么办呢?我们只能自己来实现zipLongest()了!为了让前面的代码通过编译,我们必须要自己动手实现下面三个扩展函数。 ```kotlin private fun Iterable.zipLongest( other: Iterable, default: String ): List> { val first = iterator() val second = other.iterator() val list = ArrayList>(minOf(collectionSizeOrDefault(10), other.collectionSizeOrDefault(10))) while (first.hasNext() || second.hasNext()) { val v1 = (first.nextOrNull() ?: default).toInt() val v2 = (second.nextOrNull() ?: default).toInt() list.add(Pair(v1, v2)) } return list } private fun Iterable.collectionSizeOrDefault(default: Int): Int = if (this is Collection<*>) this.size else default private fun Iterator.nextOrNull(): T? = if (hasNext()) next() else null // Pair 是Kotlin标准库提供的一个数据类 // 专门用于存储两个成员的数据 // 提交代码的时候,Pair不需要拷贝进去 public data class Pair( public val first: A, public val second: B ) : Serializable { public override fun toString(): String = "($first, $second)" } ``` 这三个扩展函数实现起来还是比较简单的,zipLongest()其实就是合并了两个字符串列表,然后将它们按照index合并成Pair,另外那两个扩展函数都只是起了辅助作用。 这样,我们把前面的代码一起粘贴到LeetCode当中,其实代码是可以通过的。不过呢,我们的代码当中其实还有一个比较深的嵌套,看起来不是很顺眼: ```kotlin fun compareVersion(version1: String, version2: String): Int = version1.split(".") .zipLongest(version2.split("."), "0") .onEach { // 这里的嵌套比较深 with(it) { if (first != second) { return first.compareTo(second) } } }.run { return 0 } ``` 你可以注意到,在onEach当中,有一个代码块,它有两层嵌套,这看起来有点丑陋。那么,我们能不能对它进一步优化呢? 当然是可以的。 这里,我们只需要想办法让onEach当中的Lambda,变成带接收者的函数类型即可。具体做法就是,我们自己实现一个新的onEachWithReceiver()的高阶函数。 ```kotlin // 注意这里 // ↓ inline fun > C.onEachWithReceiver(action: T.() -> Unit): C { return apply { for (element in this) action(element) } } // 注意这里 // Kotlin库函数当中的onEach ↓ public inline fun > C.onEach(action: (T) -> Unit): C { return apply { for (element in this) action(element) } } ``` 上面的代码展示了`onEach()`和`onEachWithReceiver()`之间的差别,可以看到,它们两个的函数体其实没有任何变化,区别只是action的函数类型而已。 所以在这里,借助`onEachWithReceiver()`,就可以进一步简化我们的代码: ```kotlin fun compareVersion(version1: String, version2: String): Int = version1.split(".") .zipLongest(version2.split("."), "0") .onEachWithReceiver { // 减少了一层嵌套 if (first != second) { return first.compareTo(second) } }.run { return 0 } ``` 在这段代码中,我们把onEach()改成了onEachWithReceiver(),因为它里面的Lambda是带有接收者,原本的Pair对象变成了this对象,这样,我们就可以直接使用first、second来访问Pair当中的成员了。 现在,就让我们来看看整体的代码吧: ```kotlin fun compareVersion(version1: String, version2: String): Int = version1.split(".") .zipLongest(version2.split("."), "0") .onEachWithReceiver { if (first != second) { return first.compareTo(second) } }.run { return 0 } private inline fun > C.onEachWithReceiver(action: T.() -> Unit): C { return apply { for (element in this) action(element) } } private fun Iterable.collectionSizeOrDefault(default: Int): Int = if (this is Collection<*>) this.size else default private fun Iterator.nextOrNull(): T? = if (hasNext()) next() else null private fun Iterable.zipLongest( other: Iterable, default: String ): List> { val first = iterator() val second = other.iterator() val list = ArrayList>(minOf(collectionSizeOrDefault(10), other.collectionSizeOrDefault(10))) while (first.hasNext() || second.hasNext()) { val v1 = (first.nextOrNull() ?: default).toInt() val v2 = (second.nextOrNull() ?: default).toInt() list.add(Pair(v1, v2)) } return list } ``` 好了,这就是我们的第三种思路。看完这三种思路以后,你会更倾向于哪种思路呢? ### 小结 这节课,我们使用了三种思路,实现了LeetCode的165号题《版本号判断》。其中,前两种思路,是命令式的编程方式,第三种是偏函数式的方式。在我看来呢,这三种方式各有优劣。 - 思路一,代码逻辑比较清晰,代码量小,时间复杂度、空间复杂度较差。 - 思路二,代码逻辑比较复杂,代码量稍大,时间复杂度、空间复杂度非常好。 - 思路三,代码主逻辑非常清晰,代码量大,时间复杂度、空间复杂度较差。 第三个思路其实还有一个额外的优势,那就是,我们自己实现的扩展函数,可以用于以后解决其他问题。这就相当于沉淀出了有用的工具。 ### 小作业 好,最后,我还是给你留一个小作业,请你用Kotlin来完成LeetCode的640号题《求解方程》。这道题目我同样会在下节课给出答案解析。 欢迎继续给我留言,我们下节课再见。
嘿手大叔
2024年10月28日 16:45
转发文档
收藏文档
上一篇
下一篇
手机扫码
复制链接
手机扫一扫转发分享
复制链接
Markdown文件
分享
链接
类型
密码
更新密码