使用F#来实现哈夫曼编码吧

时间:2022-02-22 19:07:56

最近算法课要求实现哈夫曼编码,由于前面的问题都是使用了F#来解决,偶然换成C#也十分古怪,报告也不好看,风格差太多。一开始是打算把C#版本的哈夫曼编码换用F#来写,结果写到一半就觉得日了狗了。。。毕竟FP水平图样,到处mutable,各种<-...于是想看看有没有现成的F#实现的哈夫曼编码。

F#的算法实现这种东西本身不好找,不过M$似乎有着预见性,得来全不费功夫。。。

原文

open System

/// 哈夫曼编码使用了一个叶子节点为输入符号,
/// 内部节点是他们所有符号组合的期望频率的
/// 二叉树。
type HuffmanTree = 
    | Leaf of char * float
    | Node of float * HuffmanTree * HuffmanTree

/// 为包含给定符号的字符串和期望的频率提供编码和解码
type HuffmanCoder(symbols: seq<char>, frequencies : seq<float>) =
   
    /// 从输入的频率构建一个哈夫曼编码树
    let huffmanTreeLeafs =    
        Seq.zip symbols frequencies
        |> Seq.toList
        |> List.map Leaf
        
    /// 用于从哈夫曼编码树的节点获取频率
    let frequency node =
        match node with
        | Leaf(_,p) -> p
        | Node(p,_,_) -> p    

    /// 从根节点列表构建一个哈夫曼编码树,遍历它直到唯一根节点
    let rec buildCodeTree roots = 
        match roots |> List.sortBy frequency with
        | [] -> failwith "Cannot build a Huffman Tree for no inputs" 
        | [node] -> node
        | least::nextLeast::rest -> 
                   let combinedFrequency = frequency least + frequency nextLeast
                   let newNode = Node(combinedFrequency, least, nextLeast)
                   buildCodeTree (newNode::rest)
               
    let tree = buildCodeTree huffmanTreeLeafs
     
    /// 为哈夫曼编码树的所有叶子构建哈夫曼编码表
    let huffmanCodeTable = 
        let rec huffmanCodes tree = 
            match tree with
            | Leaf (c,_) -> [(c, [])]
            | Node (_, left, right) -> 
                let leftCodes = huffmanCodes left |> List.map (fun (c, code) -> (c, true::code))
                let rightCodes = huffmanCodes right |> List.map (fun (c, code) -> (c, false::code))
                List.append leftCodes rightCodes
        huffmanCodes tree 
        |> List.map (fun (c,code) -> (c,List.toArray code))
        |> Map.ofList

    /// 使用哈夫曼编码表编码字符串
    let encode (str:string) = 
        let encodeChar c = 
            match huffmanCodeTable |> Map.tryFind c with
            | Some bits -> bits
            | None -> failwith "No frequency information provided for character '%A'" c
        str.ToCharArray()
        |> Array.map encodeChar
        |> Array.concat
       
    /// 使用哈夫曼编码树将一个二进制数组解码为字符串
    let decode bits =
        let rec decodeInner bitsLeft treeNode result = 
            match bitsLeft, treeNode with
            | [] , Node (_,_,_) -> failwith "Bits provided did not form a complete word"
            | [] , Leaf (c,_) ->  (c:: result) |> List.rev |> List.toArray
            | _  , Leaf (c,_) -> decodeInner bitsLeft tree (c::result)
            | b::rest , Node (_,l,r)  -> if b
                                         then decodeInner rest l result
                                         else decodeInner rest r result
        let bitsList = Array.toList bits
        new String (decodeInner bitsList tree [])
                 
    member coder.Encode source = encode source
    member coder.Decode source = decode source

模式匹配

模式匹配是F#中相当基本并且非常强大的特性。使用模式匹配可以让代码在清晰地表达其行为的同时更加简洁。上方所陈的每一个函数都使用到了模式匹配——亦是大量的F#典型代码。

简单说来,比如在huffmanCodes里,模式匹配使得其可以在可能出现的联合数据结构中轻松切换:

match tree with
| Leaf (c,_) -> //...
| Node (_, left, right) -> //...

更多复杂的例子(比如上面的decodeInner)不难发现模式匹配有助于引导你的代码。你例举了每一个你知道如何处理的情形,并且你所匹配的叶子节点暗示了数据需要在那种情形下定义。然后编译器将会热情地告诉你你有哪些没有覆盖到的情形。当我一开始撰写这个函数的时候,我就没有考虑到第一种情况,然后编译器告诉我

Warning: Incomplete pattern matches on this expression. The value '([],Node (_, _, _))' will not be matched 

很明显对了!这个特定的输入指示了用户可能提供非法输入!

管道

管道是一个用来描述声明一系列输入执行操作的不错的方式。这种哲学思想类似于在命令行里的管道——将左边的输入作为参数传递给右边。

因为F#库提供了一票很好的基本类型的操作用于处理你的数据,所以很容易通过管道描述这一系列的转换操作。例如,您可以很容易地声明筛选、映射、折叠、压缩或是重新包装数据。

集合

代码使用了4种常用 F#/.NET 集合:

  • F# 列表:不可变链表,在一个用到了列表的递归算法里用到了。
  • F# 映射:不可变字典,用于存储每个符号。
  • F# 序列 = .NET "IEnumerable":基本集合的接口,用于输入。
  • .NET 数组:基本类型的数组用于输出编码。

值得注意的是,在集合间切换非常容易,使用诸如List.toArrayMap.ofList的函数就能轻松搞定。

“这是.NET的一部分!”又曰“华而不实”

当我写这段代码的时候,我开启了实验模式,我仅仅在表层写了一些小的函数然后用F# Interactive来执行。当我觉得这个函数工作正常便想将所有的功能使用一个类来包装起来,然后给出一个漂亮的.NET接口,我就是这样做的:

  1. 把所有的内容拍到一个级别
  2. 把代码包裹起来:

    type HuffmanCoder(symbols : seq<char>, frequencies : seq<float>) = 
    
        // 要包裹的代码...
    
        member coder.Encode source = encode source
        member coder.Decode source = decode source

真是不能低估这神奇的能力!在F#里,从实验编码到零件设计编码的过渡简单而平稳。之所以F#可以这么来,是因为其是一个混合了函数式和面向对象的语言并且巧妙地被集成进了.NET。并且正是因为这个缘故,使用F#可以轻松构建大型.NET系统的组件。我听说有人对F#中的函数式面向对象批评曰其“华而不实”,但是我非常喜欢它,因为不论什么时候它都能让我纵享丝滑。:-)

如果你感兴趣,这是C#的写法:

public class HuffmanCoder
{
    public HuffmanCoder(IEnumerable<char> symbols, IEnumerable<double> frequencies);

    public string Decode(bool[] source);
    public bool[] Encode(string source);
}

嗯!

我觉得这段代码真是一个绝佳的例子让我表达了在某些事情上我更偏向于F#。算法代码的特性与F#的语法和编程风格更加契合。你可以先只写一大堆简单、可组合的函数,每个函数就那么几行的模式匹配和管道运算。当你这么做,你就可以使用F# Interactive来测试算法和代码,把它们变成正确函数。当他们工作时,你可以平稳地将它们整合进.NET组件并且提供给其它.NET组件。