本周,我将首先实现Dijkstra算法 ,然后将代码迁移到功能更友好的设计中。
这是从命令式函数式编程的重点series.Other职位包括4 个职位:
- 使用箭头从命令式编程到函数式编程
- 从命令式编程到函数式编程,一种方法
- 从命令式编程到函数式编程:分组问题(以及解决方法)
- 从命令式编程到函数式编程:Dijkstra算法 (本文)
几句话的Dijkstra算法
图由节点和边组成 。 一条边链接两个节点。
图有不同类型:在加权图中,每个边都有关联的权重。
在有向图中,边缘只能沿一个方向行进; 在无向图中,它可以沿任何方向传播。 因此,
Dijkstra的算法允许找到图中的最短路径。 未加权图可以视为所有权重都相同的加权图,而无向图中的边等于有向图中每个方向上的两个边的等效值-权重相同。 因此,任何图都是该算法的候选结构,无论加权与否,是否有向。 唯一的要求是权重必须为正。
算法如下:
- 将起始节点设置为当前节点
- 重复以下操作,直到当前节点为结束节点为止:
- 查找当前节点的边可访问的所有节点
- 计算访问未访问节点的总权重
- 将当前节点标记为已访问
- 前往重量较轻的节点
天真但可行的方法
这是该算法的直接实现:
classGraph(privatevalweightedPaths:Map<String,Map<String,Int>>){
privatevalevaluatedPaths=mutableMapOf<String,Int>()
funfindShortestPath(start:String,end:String):Int{
varcurrent=start
while(current!=end){
current=updateShortestPath(current)
}
returnevaluatedPaths.getValue(end)
}
privatefunupdateShortestPath(node:String):String{
valsubGraph=weightedPaths[node]
for(entryinsubGraph!!){
valcurrentDistance=evaluatedPaths
.getOrDefault(entry.key,Integer.MAX_VALUE)
valnewDistance=entry.value+evaluatedPaths.getOrDefault(node,0)
if(newDistance<currentDistance)
evaluatedPaths[entry.key]=newDistance
}
evaluatedPaths.remove(node)
returnevaluatedPaths.minBy{it.value}?.key!!
}
}
该代码段绝对不是FP友好的,因为它包含以下两个方面:
- 可变状态, 例如
evaluatedPaths
路径 - 和循环 -一个
while
循环和一个for
循环
迁移到函数式编程
迁移到FP意味着要替换:
- 递归循环
- 具有传递参数的全局可变状态
另外,Kotlin程序员可能已经注意到!!
两种用法!!
运算符:尽管类型系统推断该值可以为空 ,但作为开发人员,我们告诉编译器不能。
删除!!
拐杖
有两次出现!!
。 发生这两种情况的原因是,当从Map
获取数据时,没有什么可以保证类型系统有相应的键:
valmap=mapOf("A"to1)
vala=map.get("A")// 1
valb=map.get("B")// null
从get()
返回的类型get()
为null 。
为了使它以更惯用的方式不可为空 ,如果返回的值为null ,我们可以抛出一个异常:
valmap=mapOf("A"to1)
vala=map.get("A")?:throwRuntimeException("Cannot happen")
valb=map.get("B")?:throwRuntimeException("Will happen")
对于编译器, a
和b
现在为String
类型,即使在运行时,也会抛出“ B”。 尽管异常使函数的部分和部分功能不符合FP,但这是一个很好的步骤。
对于迭代,它甚至更容易。 以下两行是等效的,但第二行更为惯用:
for(entryinsubGraph!!){}
subGraph?.forEach{}
用递归替换循环
代码中存在两个循环:一个while
循环和一个for
循环。 让我们用它们的等效功能编程-递归替换它们。 递归是一个调用自身的函数。
findShortestPath()
实现可以替换为:
funfindShortestPath(start:String,end:String):Int{
recurseFindShortestPath(start,end)
returnevaluatedPaths.getValue(end)
}
recurseFindShortestPath()
函数是递归的-顾名思义。 递归函数提供了不同的分支,最重要的一个是stop分支。 在我们的情况下,这里的stop分支是node
到达end
。
privatefunrecurseFindShortestPath(node:String,end:String):String{
returnif(node==end)end
// else ...
}
另一个分支包含与以前相同的代码,但有一个小的变化:我们通过与更新的节点调用相同的函数来计算下一个节点。
privatefunrecurseFindShortestPath(node:String,end:String):String{
returnif(node==end)end
else{
// ... same code here
valnextNode=evaluatedPaths.minBy{it.value}?.key
?:throwRuntimeException("Map was empty")
recurseFindShortestPath(nextNode,end)
}
}
递归的最大问题是当调用在堆栈上相互堆叠时。 因为它基于真实世界的硬件,所以堆栈有一个限制,无论它有多高。 达到目标后,就会发生臭名昭著的StackOverflowError
。 为了避免这种情况,编译器实际上可以生成等效的非递归字节码 。
这要求:
- 用
tailrec
标记的功能 - 该函数的最后一条指令应该是递归调用
因为我们的函数符合第二个条件,所以只需向其添加tailrec
关键字并优化生成的字节码 ,这是tailrec
。
一次删除全局状态
用参数替换状态比用递归替换循环更难。 因此,我们将逐步进行操作:第一步是将全局状态移动到局部状态,但暂时保持可变状态。
这是初始代码:
classGraph(privatevalweightedPaths:Map<String,Map<String,Int>>){
privatevalevaluatedPaths=mutableMapOf<String,Int>()
funfindShortestPath(start:String,end:String):Int{
recurseFindShortestPath(start,end)
returnevaluatedPaths.getValue(end)
}
privatetailrecfunrecurseFindShortestPath(node:String,end:String):String{
// ...
recurseFindShortestPath(nextNode,end)
}
}
移动状态后,代码变为如下所示:
classGraph(privatevalweightedPaths:Map<String,Map<String,Int>>){
funfindShortestPath(start:String,end:String):Int{
valpaths=mutableMapOf<String,Int>() (1)
recurseFindShortestPath(start,end,paths) (2)
returnpaths.getValue(end)
}
privatetailrecfunrecurseFindShortestPath(node:String, (2)
end:String,
paths:MutableMap<String,Int>):String{
// ...
recurseFindShortestPath(nextNode,end,paths) (2)
}
}
- 在函数内部移动可变状态
- 更改功能签名以接受状态并在每次调用时传递它
将状态添加到返回值
因为我们需要不可变状态,所以下一步就是还要沿着现有的返回值返回映射。
classGraph(privatevalweightedPaths:Map<String,Map<String,Int>>){
funfindShortestPath(start:String,end:String):Int{
val(_,paths)=recurseFindShortestPath(start,end,mutableMapOf()) (1)
returnpaths.getValue(end)
}
privatetailrecfunrecurseFindShortestPath(node:String,
end:String,
paths:MutableMap<String,Int>):
Pair<String,MutableMap<String,Int>>{ (2)
returnif(node==end)endtopaths (3)
else{
// ...
recurseFindShortestPath(nextNode,end,paths) (4)
}
}
}
- 使用Kotlin解构将
Map
分配给专用变量 - 更改函数签名以返回带有
String
的Map
,将二者包装Pair
- 将
String
与Map
组装在一起,以同时返回 - 根据新签名更改递归调用
限制变异
在继续之前,我们将recurseFindShortestPath()
函数的很大一部分提取到专用函数中,以将可变性包含在较小的范围内:
classGraph(privatevalweightedPaths:Map<String,Map<String,Int>>){
funfindShortestPath(start:String,end:String):Int{/* */}
privatetailrecfunrecurseFindShortestPath(node:String,
end:String,
paths:MutableMap<String,Int>):
Pair<String,MutableMap<String,Int>>{
returnif(node==end)endtopaths
else{
valupdatedPaths=updatePaths(node,paths)
valnextNode=updatedPaths.minBy{it.value}?.key
?:throwRuntimeException("Map was empty")
recurseFindShortestPath(nextNode,end,updatedPaths)
}
}
privatefunupdatePaths(node:String,
paths:MutableMap<String,Int>):MutableMap<String,Int>{
// ...
}
}
在这一点上,我们希望开始从可变状态迁移到不可变状态。 让我们更改recurseFindShortestPath()
函数的签名:
privatetailrecfunrecurseFindShortestPath(node:String,
end:String,
paths:Map<String,Int>):
Pair<String,Map<String,Int>>{ (1)
returnif(node==end)endtopaths
else{
valupdatedPaths=updatePaths(node,paths.toMutableMap()) (2)
valnextNode=updatedPaths.minBy{it.value}?.key
?:throwRuntimeException("Map was empty")
recurseFindShortestPath(nextNode,end,updatedPaths) (3)
}
}
- 将签名更改为使用不可变类型而不是可变类型, 即从
MutableMap
到Map
- 传递可变类型,因为调用的函数需要它
- 函数调用无变化
下一步是以相同的方式更改被调用函数的签名。 为了使更改很小,我们将在函数本身内部继续使用可变状态:
privatefunupdatePaths(node:String,
paths:Map<String,Int>):Map<String,Int>{ (1)
varupdatedPaths=paths (2)
weightedPaths[node]?.forEach{
valcurrentDistance=paths.getOrDefault(it.key,Integer.MAX_VALUE)
valnewDistance=it.value+paths.getOrDefault(node,0)
if(newDistance<currentDistance)
updatedPaths=updatedPaths+(it.keytonewDistance) (3)
}
returnupdatedPaths-node (4)
}
- 仅在函数签名中使用不可变类型
- 将
Map
分配给var
,以便可以将其重新分配 - 通过将
Pair
实例添加到现有Map
来创建新Map
,然后重新分配var
- 创建一个新的
Map
通过删除其关键是入口node
从Map
即从路径中删除当前节点,所以它不会再次评估
此时,可以将单个路径的更新提取到其自己的函数中。 这将有助于我们消除可变性的所有痕迹:
privatefunupdatePaths(node:String,paths:Map<String,Int>):Map<String,Int>{
varupdatedPaths=paths
weightedPaths[node]?.forEach{
updatedPaths=updatedPaths+updatePath(paths,it,node)
}
returnupdatedPaths-node
}
提取的函数根本没有可变状态,仅使用不可变的类型:
privatefunupdatePath(paths:Map<String,Int>,
entry:Map.Entry<String,Int>,
node:String):Map<String,Int>{ (1)
valcurrentDistance=paths.getOrDefault(entry.key,Integer.MAX_VALUE)
valnewDistance=entry.value+paths.getOrDefault(node,0)
returnif(newDistance<currentDistance)
paths+(entry.keytonewDistance) (2)
else
paths (2)
}
- 该函数的签名仅使用不可变的类型
- 由于地图是不可变的,因此可以使用
+
运算符来组成。 这会返回一个新的地图实例,该条目是两个组成的地图的条目
最后一步是,既然它变得更容易理解,就从updatePaths()
的实现中删除可变性。 为此,可以将在可变状态下工作的forEach()
迁移到一系列转换中:
privatefunupdatePaths(node:String,paths:Map<String,Int>)=
(weightedPaths[node]
?.map{updatePath(paths,it,node)} (1)
?.fold(emptyMap()){acc,item->acc+item} (2)
?:emptyMap<String,Int>()) (3)
-node
- 第一个转换是使用
updatePath()
函数将Map
项映射到另一个Map
项 - 将所有
Map
条目收集到一个Map
- 如果
weightedPaths[node]
为null
则返回一个空Map
。 这使得评估表达式的类型成为不可为null的Map
,然后可以将其组成。 否则,我们将无法编译下一行,因为-
无法在可为null的类型上调用
到那时,源代码具有函数式编程的所有功能:不变性和递归。
结论
从命令式迁移到功能式主要基于:
- 通过递归替换循环
- 将可变状态移动到累加器参数和返回值
如果不习惯函数式编程,那么简单的步骤会很有帮助。
翻译自: https://blog.frankel.ch/imperative-functional-programming/4/