☰
fpinscala 单调性检查:用 Monoid 与 foldMapV 实现 O(log n) 有序序列判定
2026/10/12 16:05:11 网站建设 项目流程
  • 示例工程

【免费下载链接】fpinscala

Code, exercises, answers, and hints to go along with the book "Functional Programming in Scala"

项目地址:https://gitcode.com/gh_mirrors/fp/fpinscala
点击查看免费下载

本章主题源自《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.*'

设计要点与延伸思考

  1. Monoid 的关键是"合并即局部判定":本练习的精髓在于,有序性判定被编码进combine的局部比较(leftMax <= rightMin)中,而不依赖全局遍历顺序。这正是 Monoid 支持并行与分治折叠的根本原因——只要局部合并规则自洽,合并顺序就无关紧要。
  2. Option用于表达"无区间":空片段没有 min/max,empty = (true, None)让恒等元同时满足"空序列有序"与"合并不影响结果"两个要求,与optionMonoid的orElse语义一脉相承。
  3. 从foldMapV到parFoldMap:理解了本练习的foldMapV(ints, orderedMonoid)(...)调用形态,就能自然迁移到练习 8 的parFoldMap——只需把 Monoid 提升到Par上下文中(par(m)),foldMapV的递归分支即可并行执行,有序性判定随之获得并行化能力。
  4. 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"

项目地址:https://gitcode.com/gh_mirrors/fp/fpinscala
点击查看免费下载

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询