- 示例工程
【免费下载链接】fpinscala
Code, exercises, answers, and hints to go along with the book "Functional Programming in Scala"
本章主题源自《Functional Programming in Scala》(fpinscala 仓库)第 10 章"Monoids"的练习 9。原文文档 answerkey/monoids/09.answer.md 给出了一组精炼但完整的答案代码:借助"区间 + 无序标记"这一状态载体,把"判断一个IndexedSeq[Int]是否非降序有序"这个看似不可并行化的问题,转化为一个可以并行折叠的 Monoid 实例,再交由foldMapV以分治方式完成判定。读完本文,你将理解"区间折叠"这一 Monoid 设计范式,能够独立实现ordered判定函数,并掌握如何把它嵌入仓库的练习测试体系进行验证。
练习背景:从 foldMapV 到并行折叠
练习 9 所在的章节围绕 Monoid 的"分治折叠"展开。在 src/main/scala/fpinscala/answers/monoids/Monoid.scala 中,foldMapV被定义为对IndexedSeq的平衡递归折叠:
def foldMapVA, B(f: A => B): B = if as.length == 0 then m.empty else if as.length == 1 then f(as(0)) else val (l, r) = as.splitAt(as.length / 2) m.combine(foldMapV(l, m)(f), foldMapV(r, m)(f))foldMapV把序列从中间一分为二,递归地对两半分别做foldMap,再用 Monoid 的combine合并结果。它的作用域不止于本练习——在同一个文件里,parFoldMap正是借助foldMapV结合ParMonoid 实现并行归约(对应练习 8,见 answerkey/monoids/08.answer.md),count单词计数(练习 10、11)也复用了它。因此,只要为"有序性判断"构造出合适的 Monoid,同样的问题就能以任意顺序、任意分区方式合并求解。
核心思路:区间 + 无序标记的 Monoid
练习 9 的难点在于:判断整个序列有序,不能简单地把左半、右半各自"是否有序"用布尔值合并——因为即使左右两半各自有序,连接处仍可能无序(左半的最后一个元素大于右半的第一个元素)。答案是提示文档 answerkey/monoids/09.hint.md 所说的:让每个片段携带两类信息:
- 区间(interval):该片段中元素的最小值与最大值;
- 无序标记:该片段内部是否已经发现违反有序性的位置。
只要左右两半都内部有序,且左半最大值leftMax不超过右半最小值rightMin,合并后的片段就依然有序;否则无序标记置为false。合并后的区间自然是(leftMin, rightMax)。
参考答案逐行拆解
练习 9 的完整答案如下(摘自 answerkey/monoids/09.answer.md):
opaque type Interval = (Int, Int) val orderedMonoid: Monoid[(Boolean, Option[Interval])] = new: def combine(a1: (Boolean, Option[Interval]), a2: (Boolean, Option[Interval])) = (a1(1), a2(1)) match case (Some((leftMin, leftMax)), Some((rightMin, rightMax))) => (a1(0) && a2(0) && leftMax <= rightMin, Some((leftMin, rightMax))) case _ => (a1(0) && a2(0), a1(1).orElse(a2(1))) val empty = (true, None) def ordered(ints: IndexedSeq[Int]): Boolean = foldMapV(ints, orderedMonoid)(i => (true, Some((i, i))))(0)逐部分解析:
1. 不透明类型别名Interval
opaque type Interval = (Int, Int)opaque type(不透明类型别名)是 Scala 3 的机制:在定义它的作用域内,Interval可以被当作(Int, Int)使用;在作用域之外,Interval与元组类型互不兼容,防止把任意二元组误当作区间传入。练习答案直接写成(Int, Int)亦可,opaque的价值在于类型安全与可读性。
2. Monoid 载体:(Boolean, Option[Interval])
Monoid 的元素类型是一个二元组:
- 第一个分量
Boolean:记录"到目前为止该片段内部是否有序"; - 第二个分量
Option[Interval]:记录该片段的取值范围。用Option包装,是因为空片段没有取值区间,需要以None表示(Monoid的empty必须能代表空片段)。
3.combine的两种分支
combine对两个片段的区间做模式匹配:
- 两个片段都有区间(
Some/Some分支):若a1内部有序、a2内部有序,且leftMax <= rightMin(左半最大值不超过右半最小值),则合并结果有序;新区间为(leftMin, rightMax); - 至少一个片段为空(
case _分支):合并后是否有序取决于两者原有标记的与运算a1(0) && a2(0);区间用orElse取非空者——这正是 src/main/scala/fpinscala/answers/monoids/Monoid.scala 中optionMonoid的combine(x, y) = x orElse y语义,空片段对合并结果不产生任何影响。
4.empty = (true, None)
空序列被判定为"有序"(true),且没有取值区间(None)。这是恒等元:任意片段与空片段合并,结果保持原片段不变——(true, None)与(b, Some(iv))合并走case _分支,得到(true && b, None.orElse(Some(iv))) = (b, Some(iv))。
5. 映射函数i => (true, Some((i, i)))
单个元素映射为"有序、区间即该元素本身"——这正是"区间"模型的边界情形:元素本身就是片段的最小值与最大值。
6. 末尾的(0):元组的投影
答案代码末尾的(0)是元组的第一分量投影,等价于._1。因此ordered的返回类型是Boolean而非整个二元组:
foldMapV(ints, orderedMonoid)(i => (true, Some((i, i))))(0)对任意ints应用后,得到"整个序列是否有序"的布尔值。
验证:区间合并的正确性
判定leftMax <= rightMin就能保证连接处有序,其依据是:若左右两半各自内部有序,那么左半的最大值必然是其最后一个元素,右半的最小值必然是其第一个元素;只要"左半最后一个元素 ≤ 右半第一个元素",整个序列在连接处就不会出现逆序。区间合并因此是自洽的:合并后的区间(leftMin, rightMax)涵盖了左右两半的全部取值。
仓库提供的完整实现(src/main/scala/fpinscala/answers/monoids/Monoid.scala 第 127~140 行)采用了等价但更结构化的写法:用case class Interval(ordered: Boolean, min: Int, max: Int)直接承载"有序标记 + 区间",把三元状态合并进一个类型,combine的分支处理与答案完全一致:
case class Interval(ordered: Boolean, min: Int, max: Int) val orderedMonoid: Monoid[Option[Interval]] = new: def combine(oa1: Option[Interval], oa2: Option[Interval]) = (oa1, oa2) match case (Some(a1), Some(a2)) => Some(Interval(a1.ordered && a2.ordered && a1.max <= a2.min, a1.min, a2.max)) case (x, None) => x case (None, x) => x val empty = None def ordered(ints: IndexedSeq[Int]): Boolean = foldMapV(ints, orderedMonoid)(i => Some(Interval(true, i, i))) .map(_.ordered).getOrElse(true)两种写法在语义上完全等价:答案用(Boolean, Option[Interval])二元组区分"有序标记"与"区间",完整实现把它们合并进Interval,再整体包一层Option;empty分别是(true, None)与None,映射函数分别产生(true, Some((i, i)))与Some(Interval(true, i, i))。从"空序列有序"这一约定可以看出,单元素与空序列都是有序的,ordered(IndexedSeq.empty)与ordered(IndexedSeq(42))均返回true。
测试验证与运行方式
仓库为这道练习提供了属性测试。在 src/test/scala/fpinscala/exercises/monoids/MonoidSuite.scala 中,Monoid.ordered的测试把答案实现与被测实现的结果直接对照:
test("Monoid.ordered")(genIntIndexedSeq): ints => assertEquals(ordered(ints), ints == ints.sorted)即"ordered(ints)等价于ints与其排序结果相等"。这里的ordered来自import fpinscala.exercises.monoids.Monoid.*,测试借助仓库的PropSuite对genIntIndexedSeq生成的随机整数序列断言该性质。更严格的语义校验是直接用foldMapV的平衡合并结果与线性扫描结果比对:由于combine满足结合律,任意合并顺序得到的最终布尔值都相同。
仓库使用 Scala CLI 构建(参见 README.md)。在仓库根目录下运行全部测试:
scala-cli test .单跑 monoids 包的相关测试:
scala-cli test . -- 'fpinscala.exercises.monoids.*'设计要点与延伸思考
- Monoid 的关键是"合并即局部判定":本练习的精髓在于,有序性判定被编码进
combine的局部比较(leftMax <= rightMin)中,而不依赖全局遍历顺序。这正是 Monoid 支持并行与分治折叠的根本原因——只要局部合并规则自洽,合并顺序就无关紧要。 Option用于表达"无区间":空片段没有 min/max,empty = (true, None)让恒等元同时满足"空序列有序"与"合并不影响结果"两个要求,与optionMonoid的orElse语义一脉相承。- 从
foldMapV到parFoldMap:理解了本练习的foldMapV(ints, orderedMonoid)(...)调用形态,就能自然迁移到练习 8 的parFoldMap——只需把 Monoid 提升到Par上下文中(par(m)),foldMapV的递归分支即可并行执行,有序性判定随之获得并行化能力。 opaque type的类型卫生:练习答案用opaque type Interval = (Int, Int)把"区间"从裸元组中抽象出来,在获得可读性的同时避免了外部代码把任意(Int, Int)当作区间使用。
若想继续深入 Monoid 的同类技巧,可阅读同章后续练习:answerkey/monoids/10.answer.md(WC单词计数 Monoid)与 answerkey/monoids/11.answer.md(借助foldMapV的count),它们与本文共享同一套"局部状态 + 合并规则"的建模范式。
- 示例工程
【免费下载链接】fpinscala
Code, exercises, answers, and hints to go along with the book "Functional Programming in Scala"
相关推荐
用 Monoid 与 foldMapV 实现单词计数:fpinscala 第 10 章练习 11 源码详解
用 Monoid 与 foldMapV 实现单词计数:fpinscala 第 10 章练习 11 源码详解 导读 本文围绕 fpinscala 仓库 answe
示例工程xi-editor Rope Science(四):用单调 Monoid 在 Rope 上实现 O(log n) 括号匹配
xi editor Rope Science(四):用单调 Monoid 在 Rope 上实现 O log n 括号匹配 本文为 xi editor( READ
开发工具codeforces-go 题解精讲:Minimum Array Length After Pair Removals——有序数组众数判定与 O(log n) 二分查找
codeforces go 题解精讲:Minimum Array Length After Pair Removals——有序数组众数判定与 O log n 二
科学计算
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考