Kotlin函数式编程经典案例

作者: 宛丘之上兮 | 来源:发表于2018-02-01 12:32 被阅读111次
望宸阁

Kotlin的一个很好的特性是支持函数式编程,本文分为集合操作幂集排序三部分来分析下Kotlin函数式编程。

image.png

集合操作

假设我们有一个学生的模型类:

data class Student(
        val name: String,
        val surname: String,
        val passing: Boolean,
        val averageGrade: Double // > 4
)

包含姓、名、是否及格、平均成绩四个属性。我们从所有学生中筛选出成绩最好的10个人:

var seleteTen = students.filter { it.passing && it.averageGrade > 4 }
            .sortedBy { it.averageGrade }
            .take(10)
            .sortedWith(compareBy({it.surname}, {it.name}))
  1. 首先必须及格并且平均成绩 > 4
  2. 根据平均成绩排序(默认从小到大)
  3. 从排好序的学生中选择前10个
  4. 将这10个学生根据姓排序,姓一样的话再根据名排序

如果我们不根据姓名排序,而是保持原来的顺序不变,我们可以添加索引保持顺序不变:

var obtainOrder = students.filter { it.passing && it.averageGrade > 4.0 }
            .withIndex() // 1
            .sortedBy { (_, s) -> s.averageGrade } // 2
            .take(10)
            .sortedBy { (i, _) -> i } // 3
            .map { (_, s) -> s } // 4
  1. 为每个学生添加当前的索引
  2. 使用之前我们需要析构值和索引
  3. 根据索引排序
  4. 删除索引并仅保持值

完整代码:

fun main(vararg args: String) {
    var zzh = Student("zzh", "zzh", true, 100.0)
    var zyw = Student("zyw", "zyw", true, 200.0)
    var lj = Student("lj", "lj", true, 120.0)
    var lcg = Student("lcg", "lcg", true, 110.0)
    var zzy = Student("zzy", "zzy", true, 400.0)
    var fgt = Student("fgt", "fgt", true, 123.0)
    var efr = Student("efr", "efr", true, 118.0)
    var lok = Student("lok", "lok", true, 191.0)
    var hgt = Student("hgt", "hgt", true, 864.0)
    var jnd = Student("jnd", "jnd", true, 765.0)
    var tpc = Student("tpc", "tpc", true, 491.0)
    var hpz = Student("hpz", "hpz", true, 691.0)

    var fhy = Student("fhy", "fhy", false, 1.0)
    var yly = Student("yly", "yly", false, 00.0)
    var weg = Student("weg", "weg", false, 2.2)
    var tyu = Student("tyu", "tyu", false, 2.5)
    var qwe = Student("qwe", "qwe", false, 3.2)
    var oiu = Student("oiu", "oiu", false, 1.6)
    var iuh = Student("iuh", "iuh", false, 3.1)
    var pnh = Student("pnh", "pnh", false, 1.9)

    var students = arrayListOf<Student>(zzh, zyw, lj, lcg, zzy, fgt, efr, lok, hgt, jnd, tpc, hpz,
            fhy, yly, weg, tyu, qwe, oiu, iuh, pnh)

    var seleteTen = students.filter { it.passing && it.averageGrade > 4 }
            .sortedBy { it.averageGrade }
            .take(10)
            .sortedWith(compareBy({it.surname}, {it.name}))
//            .toList()
    seleteTen.forEach {
        println(it)
    }

    println("----------------")
    var obtainOrder = students.filter { it.passing && it.averageGrade > 4.0 }
            .withIndex() // 1
            .sortedBy { (_, s) -> s.averageGrade } // 2
            .take(10)
            .sortedBy { (i, _) -> i } // 3
            .map { (_, s) -> s } // 4
    obtainOrder.forEach {
        println(it)
    }
}

data class Student(
        val name: String,
        val surname: String,
        val passing: Boolean,
        val averageGrade: Double // > 4
)
image.png

幂集

高中数学我们学习了集合的概念,应该还记得有个幂集(Powerset)的知识点:所谓幂集,就是原集合S中所有的子集(包括全集和空集)构成的一个集合P(S)或者称作2S,P(S) = { U | U ⊆ S }。

假设我们有这样的一个集合:S = {1,2,3},那么S的幂集就是:P(S) = {{}, {1}, {2}, {3}, {1,2}, {1,3}, {2,3}, {1,2,3}}

问题来啦,怎么使用Kotlin代码实现求幂集的算法呢?如果你想挑战自己,那么请停止阅读本文,自己实现这个算法之后再来继续阅读。

我们要通过观察来发现规律,比如对于幂集P(S)的每一个不可拆分的元素比如1,所有包含这个元素的集合是{1}, {1,2}, {1,3}, {1,2,3},不包含这个元素的集合是:{}, {2}, {3}, {2,3}

注意第二个集合是集合{2,3}的幂集,第一个集合是这个幂集的每个元素加上1形成的集合。方法来了:先取出一个元素e,计算出其余元素组成的集合的幂集A,将A内的每个集合加上元素e形成集合B,集合A与B的并集就是最终我们要求的幂集。

fun <T> powerset(set: Set<T>): Set<Set<T>> {
    val first = set.first()
    val powersetOfRest = powerset(set.drop(1).toSet())
    return powersetOfRest.map { it + first }.toSet() + powersetOfRest
}

上面的代码还有个问题,因为第一行的first()方法不允许空集合调用,否则会抛出异常,那么我们就定义空集合的幂集还是空集合:

fun <T> powerset(set: Set<T>): Set<Set<T>> {
    return if (set.isEmpty()) setOf(setOf())
    else {
        val first = set.first()
        val powersetOfRest = powerset(set.drop(1).toSet())
        powersetOfRest.map { it + first }.toSet() + powersetOfRest
    }
}
image.png

我们分析下算法流程,比如要计算集合{1,2,3}的幂集:

  1. powerset({1,2,3}) = powerset({2,3}) + powerset({2,3}).map { it + 1 }
  2. powerset({2,3}) = powerset({3}) + powerset({3}).map { it + 2}
  3. powerset({3}) = powerset({}) + powerset({}).map { it + 3}
  4. powerset({}) = {{}}
  5. powerset({3}) = {{}, {3}}
  6. powerset({2,3}) = {{}, {3}} + {{2}, {2, 3}} = {{}, {2}, {3}, {2, 3}}
  7. powerset({1,2,3}) = {{}, {2}, {3}, {2, 3}} + {{1}, {1, 2}, {1, 3}, {1, 2, 3}} = {{}, {1}, {2}, {3}, {1,2}, {1,3}, {2,3}, {1,2,3}}

上面的代码不够kotlinc,什么叫做kotlinc?我们使用Kotlin提供的操作符let进行优化,不懂let操作符的同学可以看下这篇文章,下面优化后的代码就比较kotlinc,感受下:

fun <T> powerset(set: Set<T>): Set<Set<T>> {
    return if (set.isEmpty()) setOf(setOf())
    else {
        powerset(set.drop(1).toSet())
                .let { it + it.map { it + set.first() } }
    }
}

只能对集合才能求幂集,那么我们可以使用Kotlin的一个特性:扩展函数,我们对集合类Set添加扩展函数:

fun <T> Collection<T>.powerset(): Set<Set<T>> =
        if (isEmpty()) setOf(setOf())
        else drop(1)
                .powerset()
                .let { it + it.map { it + first() } }

众所周知,上面的递归方法有很大的风险,因为每次递归的上一次递归都要保存在内存中,对于很大的集合用递归的方法求其幂集的话肯定会出现堆栈溢出的问题。

Kotlin支持一种叫做尾递归的编程风格。这允许一些通常意义上的使用递归函数替代循环的算法,当一个函数被tailrec修饰并遇到符合的形式,编译器会优化递归,替代为一个快速并有效率的基于循环的版本来避免堆栈溢出的风险。

因此,我们需要用尾递归进行优化。tailrec要求函数调用自身必须是最后才能做的事情,否则tailrec这个关键字就形同虚设并会有语法警告,而且tailrec不能与 try/cathch/finally块一起使用,这样的话其实内存只保存最后一次的迭代,不存在堆栈溢出的问题:

fun <T> Collection<T>.powerset(): Set<Set<T>> =
        powerset(this, setOf(setOf()))

private tailrec fun <T> powerset(left: Collection<T>, acc: Set<Set<T>>): Set<Set<T>> =
        if (left.isEmpty()) acc
        else powerset(left.drop(1), acc + acc.map { it + left.first() })

求幂集还有第二种方法,利用幂集和二进制之间的关系:

fun <T> Collection<T>.findPowersetByBinary(): Set<Set<T>> =
        mutableSetOf(setOf<T>()).apply {
            for (i in 0 until Math.pow(2.0, this@findPowersetByBinary.size.toDouble()).toInt()) {
                mutableSetOf<T>().run {
                    var temp = i
                    var index = 0
                    do {
                        takeIf { temp and 1 == 1 }?.run {
                            add(this@findPowersetByBinary.elementAt(index))
                        }
                        temp = temp shr 1
                        index++
                    } while (temp > 0)
                    this@apply.add(this)
                }
            }
        }

上面代码由两层循环完成。假设集合S的个数为n,那么它的幂集元素的个数就是2n,在[0,2n)的整数区间上任取一个值x,x的二进制表示可以用来表示S的一个子集:对于x的第i位,如果为1,则此子集包含S的第i个元素,否则不包含。因此,只要遍历[0,2n)的整数区间,就能列举出S的所有子集,这些子集的集合就是S的幂集。

我们分析下算法流程,比如要计算集合{1,2,3}的幂集,遍历[0,23)的整数区间:

  • 0 ----> 对应的二进制0 ----> {}
  • 1 ----> 对应的二进制1 ----> {1}
  • 2 ----> 对应的二进制10 ----> {2}
  • 3 ----> 对应的二进制11 ----> {1,2}
  • 4 ----> 对应的二进制100 ----> {3}
  • 5 ----> 对应的二进制101 ----> {1,3}
  • 6 ----> 对应的二进制110 ----> {2,3}
  • 7 ----> 对应的二进制111 ----> {1,2,3}

把这8个集合组合起来的结果{{}, {2}, {3}, {2, 3}} + {{1}, {1, 2}, {1, 3}, {1, 2, 3}} = {{}, {1}, {2}, {3}, {1,2}, {1,3}, {2,3}, {1,2,3}}就是最终的幂集。

代码来自KotlinDiscreteMathToolkit库,它提供了离散数学很多有用的工具。

快速排序

我们将实现Quicksort(快速排序)算法。快排很简单:我们从源列表中挑选一个哨兵元素pivot,对元素小于pivot的列表A和元素不小于pivot的列表B这两个列表分别进行快排,最后的A + povit + B就是排好序的列表:

fun <T : Comparable<T>> List<T>.quickSort(): List<T> =
        if(size < 2) this
        else {
            val pivot = first()
            val (smaller, greater) = drop(1).partition { it <= pivot}
            smaller.quickSort() + pivot + greater.quickSort()
        }
// Usage
listOf(2,5,1).quickSort() // [1,2,5]
这代码真是比java简单太多了,而且易读,这就是函数式编程的美。 image.png

这个方法的一个注意点是算法性能,必须承认算法性能需要优化,但是算法很短而且易读。

如果你想要一个高度优化的算法,那么可以使用Java标准库。它是根据不同情形使用不同的算法,效率更高。效率究竟有多高?我们来比较两个算法:

val r = Random()
listOf(100_000, 1_000_000, 10_000_000)
    .asSequence()
    .map { (1..it).map { r.nextInt(1000000000) } }
    .forEach { list: List<Int> ->
        println("Java stdlib sorting of ${list.size} elements took ${measureTimeMillis { list.sorted() }}")
        println("quickSort sorting of ${list.size} elements took ${measureTimeMillis { list.quickSort() }}")
    }

在我的机器得到以下结果:

Java stdlib sorting of 100000 elements took 83
quickSort sorting of 100000 elements took 163
Java stdlib sorting of 1000000 elements took 558
quickSort sorting of 1000000 elements took 859
Java stdlib sorting of 10000000 elements took 6182
quickSort sorting of 10000000 elements took 12133

可以看到quickSort方法慢了2倍。对大列表来说,它有同样的可伸缩性。通常情况下,结果会有0.1ms到0.2ms的误差。算法更加简单易读,因此在一些情况下我们可以使用简单易读的算法,虽然性能可能不是最优的。


如果你对Kotlin感兴趣,可以参加Kotlin Academy。它是致力于Kotlin的开放社区。我在我的Twitter也发布很多资源。我的联系方式:@marcinmoskala。如果你需要我的帮助,请记住:I am open for consultations

翻译自原文地址

相关文章

网友评论

    本文标题:Kotlin函数式编程经典案例

    本文链接:https://www.haomeiwen.com/subject/kynrzxtx.html