用Go語言計算PageRank

PageRank是搜索引擎結果排序的重要算法,其依賴的方式是鏈接結構分析,大致解釋就是一個網頁A有一個指向另一個網頁B的鏈接,就相當於A給B投票,獲得投票越多的網頁的PageRank值越高。並不是每個網頁的投票權重都是一樣的,自己PageRank越大的網頁投票權重越大,所以PageRank的計算公式是遞歸的,需要迭代計算,直到結果收斂。

我使用Go語言對真實網頁的數據WT2g進行了PageRank的計算,計算出的結果分佈如下圖:

PageRank Distribution

觀察發現,PageRank的分佈服從齊普夫分佈(Zipf Distribution),其中32%的網頁的PageRank爲最小值9.459×10^-7,超過一半的網頁的PageRank的值小於6.600×10^-6,而PageRank的最大值爲1.885×10^-3。

值得一提的是Go語言,推薦一個對Go語言特性的介紹:Go在Google:以軟件工程爲目的的語言設計。使用Go語言最大的感受是它的函數可以有多返回值,而且在各種API中這個特性被大量使用,而且約定多返回值的最後一個參數是error類型,表示是否有錯誤發生。這種錯誤處理的方法和C++、Java、Python、JavaScript使用的異常不同,倒是與C語言的錯誤處理相似。C語言習慣於把函數的返回值作爲「是否有錯誤發生」的標記,如果有錯誤再通過其他的手段(如全局變量error)來獲取,Go語言直接把錯誤作爲了一個返回值。Go語言還支持一等函數(First Class Function)和閉包,因此方便用來實現yield功能,下面代碼中的lineReader函數就是返回了一個生成器,用來按行讀取文件,每調用一次讀取一行,讀完以後釋放內存。Go語言還是一個顯式有指針的語言,同時也提供了垃圾回收,省去了手動維護內存的麻煩。

以下是用Go語言計算PageRank的代碼:

package main

import (
    "bufio"
    "errors"
    "fmt"
    "io"
    "math"
    "os"
    "strings"
)

type vertex struct {
    inDegree  int
    outDegree int
    pagerank  float64
}

type edge struct {
    start int
    end   int
}

var vertexs []vertex
var edges []edge
var vertexID map[string]int = make(map[string]int)
var numVertex int = 0

func lineReader(filename string) (func() (string, error), error) {
    f, err := os.Open(filename)
    if err != nil {
        return nil, err
    }
    buf := bufio.NewReaderSize(f, 64)
    return func() (string, error) {
        line, isPrefix, err := buf.ReadLine()
        if err != nil {
            if err == io.EOF {
                if err := f.Close(); err != nil {
                    return "", err
                }
            }
            return "", err
        }
        if isPrefix {
            return "", errors.New("buffer size to small")
        }
        return string(line), nil
    }, nil
}

func addVertex(vertexName string) int {
    var ID int
    var ok bool
    if ID, ok = vertexID[vertexName]; !ok {
        ID = numVertex
        vertexID[vertexName] = ID
        vertexs = append(vertexs, vertex{})
        numVertex++
    }
    return ID
}

func read() {
    readline, err := lineReader("wt2g_inlinks.source")
    if err != nil {
        panic(err)
    }
    for {
        line, err := readline()
        if err != nil {
            if err == io.EOF {
                break
            }
            panic(err)
        }
        // Line format is like "ID1\tID2"
        sections := strings.Split(line, "\t")
        if len(sections) != 2 {
            panic(errors.New("Illegal line format"))
        }
        start := addVertex(sections[0])
        end := addVertex(sections[1])
        edges = append(edges, edge{start, end})
    }
}

func calcPagerank(alpha float64, numIterations int) {
    // Initialize out degree of every vertex
    for i := range edges {
        edge := &edges[i]
        vertexs[edge.start].outDegree++
        vertexs[edge.end].inDegree++
    }
    var I = make([]float64, numVertex)
    var S float64
    for i := 0; i < numVertex; i++ {
        vertexs[i].pagerank = 1 / float64(numVertex)
        I[i] = alpha / float64(numVertex)
    }
    // Calculate pagerank repeatedly until converge (numIterations times)
    for k := 0; k < numIterations; k++ {
        for i := range edges {
            edge := &edges[i]
            I[edge.end] += (1 - alpha) * vertexs[edge.start].pagerank / float64(vertexs[edge.start].outDegree)
        }
        S = 0
        for i := 0; i < numVertex; i++ {
            if vertexs[i].outDegree == 0 {
                S += (1 - alpha) * vertexs[i].pagerank / float64(numVertex)
            }
        }
        for i := 0; i < numVertex; i++ {
            vertexs[i].pagerank = I[i] + S
            I[i] = alpha / float64(numVertex)
        }
    }
}

func main() {
    read()
    calcPagerank(0.15, 30)
    fmt.Println("Done")
}

BYVNotes是一個我用Go語言實現的簡單在線記事本網站,使用了Revel框架。

函数式程序设计为什么至关重要

Why Functional Programming Matters 函数式程序设计为什么至关重要

作者: John Hughes 原文地址:http://wiht.link/functional-prog

此论文作于1984年,作为查麦兹大学的备忘录流传了多年,经过小幅度修订的版本出现于1989年与1990年,即[Hug89]与[Hug90]。此版本基于原查麦兹大学备忘录的nroff源码,为LaTeX做了改动,使其更接近于印刷版本并纠正了少许错误。

本文由CloudiDust在 http://blog.csdn.net/ddwn/article/details/984390 最初翻译,并由BYVoid根据原文重新校对、整理和排版。

摘要

软件正在变得越来越复杂,因此良好的软件构架也越来越重要。结构良好的软件易于编写,易于除错,同时提供可复用组件库以降低未来开发的成本。传统型语言在程序模块化方面具有理念上的局限性,而函数式语言超越了局限。在本文中我们指出,函数式语言的两大特性,高阶函数与惰性求值,能够极大地促进模块化。作为例证,我们处理了列表和树,编写了一些数值算法,并实现了alpha-beta启发式搜索(一个人工智能算法,用于游戏系统中)。既然模块化是程序设计成功的关键,那么函数式语言对现实世界而言便极其重要了。

1.引言

本论文试图证明,对“现实世界”而言,函数式程序设计是极其重要的。同时,本文也试图明确指出函数式语言的长处,以帮助使用函数式语言的程序员们将这些长处发挥到极致。

函数式语言之所以被如此称呼,是因为程序完全是由函数组成的。主程序本身也是一个函数,以程序的输入为参数,并返回其输出。典型地,主函数通过其他函数定义,而这些函数又同样以更多的其他函数来定义,直到最低层的语言原生函数为止。这些函数与数学中的函数很相像,因此在本文中将以普通等式来定义它们。本文使用了Turner的程序语言Miranda中的表示方法,但对于之前没有函数式语言相关知识的读者,本文仍然是可读的。(Miranda是Research Software Ltd.的商标。)

函数式程序设计的特性与优点通常总结为类似这样:函数式程序不包含任何赋值语句,因此变量一旦被赋予一个值,就不再改变。更一般地说,函数式程序不包含任何副作用:一个函数除了计算它本身的值以外,不产生任何作用。这一特性消灭了“Bug”的一个主要来源,同时也使执行顺序不再重要——没有副作用能够改变一个表达式的值,故它可以在任何时刻被求值。这一特性将程序员从决定控制流的重担之下拯救出来。由于表达式可以在任何时刻被求值,程序员便可以随心所欲地使用变量的值来代替变量,反之亦然——也就是说,程序是“引用透明”的。这一自由使得函数式程序与它们传统的对应物相比,更容易数学化地控制。

这样的“优点”列表很不错,但如果说外行人不把它当回事,这也并不会令人惊讶。它列出了很多内容关于函数式程序设计“没有”什么(它没有赋值,没有副作用,没有控制流)但却没多说它“有”什么。函数式程序员听起来很像是中世纪的僧侣似的,他们禁绝了尘世中的种种乐趣并且期望这能使自己变得高洁。对于那些更关心物质利益的人而言,这些“优点”并没有多大的说服力。

函数式程序员们争辩说,函数式程序设计确实有巨大的物质利益——一个函数式程序员拥有比他传统型的同行高得多的生产力,因为函数式程序短得多。但这有什么道理吗?在这些“优点”的基础之上,唯一的很靠不住的借口就是,传统的程序中有90%是赋值语句,而在函数式程序中这些全都可以省略!这真是太荒唐了,如果省略赋值语句可以带来如此巨大的好处,那么FORTRAN程序员们早该这样干了二十年了。通过省略特性来使语言更加强大在逻辑上是不可能的,不论这种特性是多么糟糕。

甚至函数式程序员都应该对这些所谓的“优点”表示不满意,因为它们对于发掘函数式语言的威力毫无帮助。不可能写出一个特别地(particularly)缺少了赋值语句或者特别地引用透明的程序。这些不是什么衡量程序质量的尺度,因此盯紧了它们(以此证明函数式语言的强大)也不理想。

很明显,对函数式程序设计的特性的描述是不完备的。我们必须找出一些东西来填补——它们不但要解释函数式程序设计的威力,更要给函数式程序员们一个明确的追求目标。

2.与结构化程序设计的相似性

指出函数式与结构化程序设计之间的相似性是很有帮助的。过去,结构化程序设计的特性与优点被总结为类似这样:结构化程序不包含goto语句;结构化程序中的语句块没有多入口与多出口;结构化程序与它们传统的对应物相比,更容易数学化地控制。这些结构化程序设计的“优点”与我们之前所谈到的函数式程序设计的“优点”在本质上很相似。这些叙述本质上都是否定式的,从而导致了诸如“不可或缺的goto”之类一大堆徒劳的争论。

事后诸葛亮式地说,很明显地,结构化程序设计的这些特性,尽管很有用,但没有触及问题的核心。结构化程序与非结构化程序之间最重要的区别就是,结构化程序是用模块化的方法设计的。模块化设计带来了生产力的巨大提升:首先,小模块可以很快很容易地编写;其次,通用模块可以被重用,使以后的程序可以更快地开发;再次,程序的模块可以被独立测试,减少了除错的时间。

“不使用goto”等等这一类特性,对于这一提升没什么作用。这些特性促进了“程序设计的小改良”,然而模块化设计却促进了“程序设计的大进化”。因此,程序员在FORTRAN或汇编语言中都可以享受结构化程序设计带来的好处,哪怕那需要一点额外的工作。

模块化设计是成功的程序设计的关键,这一观点现在已经被普遍地接受了,而诸如Modula-II[Wir82],Ada[oD80]以及Standard ML[MTH90]之类的程序语言都内置了语言特性以促进模块化。然而,有一点非常重要,却常常被忽略。当编写一个模块化程序以解决问题的时候,程序员首先把这个问题分解为子问题,而后解决这些子问题并把解决方案合并。程序员能够以什么方式分解问题,直接取决于他能以什么方式把解决方案粘起来。因此,为了能在观念上提升程序员将问题模块化的能力,必须在程序语言提供中提供各种新的黏合剂。复杂的作用域规则与对分块编译的支持只对文本层面的细节有帮助,它们没有提供能表达新观念的工具以分解问题。

通过与木匠行业的类比可以认识到黏合剂的重要性。先制作椅子的各部分——坐垫,椅子腿,靠背,等等——而后用正确的方法钉起来,那么制作一把椅子是很容易的。但这取决于将木板与插接口结合起来的能力。如果缺乏这种能力,那么制作椅子的唯一方式,就是将它从一大块木头里整个地切割出来,这是一项艰巨得多的任务。这个例子同时表明了模块化的非凡威力与拥有合适的黏合剂的重要性。

现在让我们回到函数式程序设计上来。在这篇论文余下的部分里,我们将指出,函数式语言提供了两种新的、非常重要的黏合剂。我们将给出许多可以使用新方法模块化的示例程序,它们因此变得很简洁。这就是函数式程序设计威力的关键——它允许了大幅改进的模块化设计。这也正是函数式程序员必须追求的目标——更小、更简洁、更通用的模块,用我们将要描述的新黏合剂黏合起来。

3.把函数粘起来

两种黏合剂中的第一种,使简单的函数可以聚合起来形成复杂的函数。以一个简单的处理问题来说明:将列表中的元素累加起来。我们用下面的语句定义列表:

listof X :: = nil | cons X (listof X)

这说明,一个元素类型为X的列表(不论X是什么),或是nil,代表一个没有元素的空列表,或是一个X与另一个X的列表的conscons代表一个列表,其首元素为X,而第二个以及后续元素即是另一个X的列表的元素。此处的X可以代表任何类型——例如,如果X是一个Integer(整数类型),那么这个定义就是说,一个整数列表,或者是空的,或者是一个整数与另一个整数列表的cons。依照通常的实践,我们写列表时,只是简单地将其元素包含在方括号里,而不是将consnil显式地写出来。方便起见,这是一个简单的速记法。例如:

[] 表示 nil
[1] 表示 cons 1 nil
[1 2 3] 表示 cons 1 ( cons 2 ( cons 3 nil ))

列表中的元素可以通过一个递归函数sum进行累加。sum必须为两类参数进行定义:一个空列表(nil),以及一个cons。由于没有数字存在时,累加结果是0,因此我们定义:

sum nil = 0

又因为cons的累加和可以通过将列表的第一个元素加到其余元素的累加和上的方式进行计算,所以可以定义:

sum (cons num list) = num + sum list

检查定义可以发现计算sum时,只有下面用红色粗体标出的部分是特化的:

sum nil = 0

sum (cons num list) = num + sum list

这说明sum的计算可以通过一个通用的递归模式和特化的部分来模块化。这个通用的递归模式习惯上被称为“递减”(reduce),因此sum可以表达为:

sum = reduce add 0

方便起见,reduce传递了一个二元函数addadd是这样定义的:

add x y = x + y

只要将sum的定义参数化,我们便可以得到reduce的定义,即:

(reduce f x) nil = x
(reduce f x)(cons num list) = f num ((reduce f x) list)

这里我们写出了(reduce f x)两边的括号以强调它代替了sum。习惯上括号是省略的,因此 ((reduce f x) list)写作(reduce f x list)。一个三元函数如reduce,当只提供两个参数时,将成为关于那个余下参数的一元函数。一般地,对一个N元函数,提供了M(< N)个参数后,该函数便成为了关于余下的N-M个参数的函数。我们在下文中将遵守这一约定。

用这种方式将sum模块化之后,我们就可以通过对这部分的重用来收获福利了。最有趣的部分就是,reduce可以(直接)用于编写一个函数来计算列表中元素的累乘积,而不需要更多的编程步骤:

product = reduce multiply 1

它也可以用来测试一个布尔值的列表中是否至少有一个元素为真:

anytrue = reduce or false

或者它们是否都为真:

alltrue = reduce and true

理解(reduce f a)的一种方式是,将其看作一个将列表中的所有cons替换为f,将所有nil替换为a的函数。以列表[1,2,3]为例,既然它表示:

cons 1 (cons 2 (cons 3 nil))

那么(reduce add 0)将其转换为:

add 1 (add 2 (add 3 0)) = 6

(reduce multiply 1)将其转换为:

multiply 1 (mulitiply 2 (mulitiply 3 1)) = 6

于是,很明显地,(reduce cons nil)将复制列表本身。既然将一个列表追加到另一个列表上的方式是将前一个列表的元素cons到后一个列表前部,我们便得到:

append a b = reduce cons b a

例如:

append [1,2] [3,4] = reduce cons [3,4] [1,2]
= (reduce cons [3,4]) (cons 1 ( cons 2 nil ))
= cons 1 ( cons 2 [3,4]))
= [1,2,3,4] (将cons替换为cons,将nil替换为[3,4])

一个用于将列表中全部元素翻倍的函数可以写作:

doubleall = reduce doubleandcons nil
doubleandcons num list = cons ( 2*num ) list

doubleandcons可以进一步模块化,首先分解为:

doubleandcons = fandcons double
double n = 2*n
fandcons f elem list = cons (f elem) list

继续分解:

fandcons f = cons . f

其中“.”(函数复合,一个标准运算符)定义为:

(f . g) h = f(g h)

为证明fandcons的定义是正确的,我们代入一些参数:

fandcons f elem
= (cons . f) elem
= cons (f elem)

因此

fandcons f elem list = cons (f elem) list

最终得到的版本是:

doubleall = reduce (cons . double) nil

继续模块化,我们得到:

doubleall = map double
map f = reduce (cons . f) nil

其中map使任意的函数作用于列表的全部元素之上。map是另一个很通用的函数。

我们甚至可以写出一个函数累加矩阵中的所有元素,该矩阵用列表的列表表示。这个函数是:

summatrix = sum . map sum

map sum使用函数sum分别计算所有行的元素之和,而后最左边的sum将每一行的元素之和累加起来,从而得到整个矩阵的累加和。

这些例子应该已经足以使读者确信,一点模块化的努力可以产生很大的效果。通过将一个简单的函数(sum)模块化为一个“高阶函数”与一些简单参数的聚合,我们得到了一个部件(reduce),它可以用于编写与列表有关的许多函数,而又不再需要(更多的)编程努力。不止是对有关列表的函数可以这么干,举另外一个例子,考虑数据类型“有序标记树”,其定义是:

treeof X ::= node X (listof (treeof X))

这个定义表明,一棵X的树,由一个标记类型为X的结点(node),以及一个子树列表组成,而这些子树也是X的树。例如,树:

           1 o
            /\
           /  \
          /    \
        2 o     o 3
                |
                |
                |
                o 4

可以被表示成:

node 1(cons
  (node 2 nil)
  (cons (node 3
    (cons (node 4 nil) nil)
  )
nil))

我们不再给出一个函数例子并将它抽象为高阶函数,取而代之的是,直接给出一个类似于reduce的函数redtree。回忆一下,reduce有两个参数,一个用于取代cons,另一个用于取代nil。既然树由nodeconsnil组成,那么redtree必须有三个参数——用于分别取代上述三者。由于树和列表不是同一种类型,我们得定义两个函数分别处理它们。因此我们定义:

redtree f g a (node label subtrees) = f label (redtree' f g a subtrees )
redtree' f g a (cons subtree rest) = g (redtree f g a subtree) (redtree' f g a rest)
redtree' f g a nil = a

[这相当于f取代nodeg取代consa取代nil。]

很多有趣的函数都可以通过把redtree和其他函数粘起来的方法来定义。例如,要把一棵数字树上的所有标记累加起来,可以使用:

sumtree = redtree add add 0

以我们刚才表述的那棵树为例,sumtree展开成:

add 1
(add (add 2 0)
(add (add 3
(add (add 4 0) 0))
0))
= 10

要生成一个包含树中全部标记的列表,可以用:

labels = redtree cons append nil

仍然是那个例子,得到:

cons 1
(append (cons 2 nil)
(append (cons 3
(append (cons 4 nil) nil))
nil))
= [1,2,3,4]

最后,可以定义一个类似于map的函数,此函数使函数f作用于树中的全部标记上:

maptree f = redtree (node . f) cons nil

以上这些操作之所以可行,是因为函数式语言允许将传统型语言中不可分解的函数表达为一些部件的聚合,也就是一个泛化的高阶函数与一些特化函数的聚合。这样的高阶函数一旦定义,便使得很多操作都可以很容易地编写出来。不论何时,只要一个新的数据类型被定义,就应当同时定义用于处理这种数据的高阶函数。这样就简化了对数据类型的处理,同时也将与它的表示细节相关的知识局部化了。与函数式语言最相像的传统程序语言是可扩展语言,只要有需求,这种程序语言就好像随时都可以扩展出新的控制结构一样。

4.把程序粘起来

函数式语言提供的另一种黏合剂使得所有程序都可以粘在一起。回忆一下,一个完整的函数式程序只不过是一个从输入映射到输出的函数。如果fg是这样的程序,那么对程序(g.f)当提供了输入参数input之后,得到:

g (f input)

程序f计算自身的输出,此输出被用作程序g的输入。传统上,这是通过将f的输出储存在临时文件中实现的。这种方法的毛病是,临时文件可能会占用太大的空间,以至于将程序黏合起来变得很不现实。函数式语言提供了一种解决方案。程序fg严格地同步运行,只有当g试图读取输入时,f才启动,并且只运行足够的时间,恰好可以提供g需要读取的输出数据。而后f将被挂起,g将继续执行,直到它试图读取另一个输入。一个额外的好处是,如果g没有读取完f的全部输出就终止了,那么f也将被终止。f甚至可以是一个不会自行终止的程序,它可以产生无穷多的输出,因为当g运行结束时,f也将被强行终止。这就使得终止条件可以与循环体分离——一种强大的模块化形式。

这种求值方式使得f尽可能地少运行,因此被称为“惰性求值(lazy evaluation)”。它使得将程序模块化为一个产生大量可能解的生成器与一个选取恰当解的选择器的方案变得可行。有些其他的系统也允许程序以这种方式运行,但只有函数式语言对每一个函数调用都一律使用惰性求值,使得程序的每个部分都可以用这种方式模块化。惰性求值也许是函数式程序员的拿手利器中威力最大的模块化工具。

4.1.牛顿-拉夫森求根法

我们将编写一些数值算法以展现惰性求值的威力。首先,考虑用于求解平方根的牛顿-拉夫森算法。该算法从一个初始的近似值a0开始计算数N的平方根,为了求得更好的解,它使用下述规则:

a(n+1) = (a(n) + N/a(n)) / 2

如果近似值序列趋近于某一个极限a,那么

a = (a + N/a) / 2
2a = a + N/a
a = N/a
a*a = N
a = squareroot(N)

事实上,这个近似值序列确实迅速地趋近于一个极限。平方根算法取一个允许误差eps为参数,当两个相邻的近似值之差的绝对值小于eps时,算法便终止了。

这个算法通常被编写为类似下面这样:

C N IS CALLED ZN HERE SO THAT IT HAS THE RIGHT TYPE
X = A0
Y = A0 + 2.*EPS
C THE VALUE OF Y DOES NOT MATTER SO LONG AS ABS(X-Y).GT.EPS
100 IF (ABS(X-Y).LE.EPS) GOTO 200
Y = X
X = (X + ZN/X) / 2
200 CONTINUE
C THE SQUARE ROOT OF ZN IS NOW IN X

[这是一段FORTRAN的程序,C代表注释行。.LE.是“Less than or Equal to”(小于或等于)的缩写,同理.GT.是“大于”的意思。]

在传统型语言中,这个程序是不可分解的。我们将利用惰性求值将其化为更加模块化的形式,而后演示所生成部件的一些其他用途。

由于牛顿-拉夫森算法计算的是一个近似值的序列,故将它写作一个使用近似值列表的程序就再自然不过了。每个近似值都可以通过下面的函数从前一个值计算得到:

next N x = (x + N/x) / 2

因此(next N)是从一个近似值映射到下一个值的函数。调用函数f,得到近似值序列:

[a0, f a0, f(f a0), f(f(f a0)), ...]

我们可以定义一个函数来计算:

repeat f a = cons a (repeat f (f a))

因此近似值序列可以这样计算:

repeat (next N) a0

repeat是一个具有“无穷”输出的函数的例子——但这没关系,因为超出程序其余部分需求的近似值并不会被计算。无穷性只是潜在的:它只说明,只要有需求,就可以计算出任意数量的近似值,repeat本身不会强加任何限制。

求根函数的剩余部分是函数within,它取一个允许误差与一个近似值列表作为参数,并在列表中查找差值不超过允许误差的一对相邻的近似值。这个函数可以定义为:

within eps (cons a (cons b rest)) = {
= b, 如果 abs(a-b) <= eps
= within eps (cons b rest), 其他情况
}

将这两个部件结合起来,

sqrt a0 eps N = within eps (repeat (next N) a0)

现在我们得到了求根函数的两大部件,便可以尝试用不同的方式组合它们。将要进行的修改之一,是将判断条件改为“相邻近似值的比趋近1”而不是“差趋近0”。这对于非常小的数字而言更加合适(当初始的相邻近似值之间的差值很小时),对非常大的数字也是如此(当舍尾产生的误差比允许误差大很多时)。我们只需要定义一个函数来替换within,而并不需要改写生成近似值的部件:

relative eps (cons a (cons b rest)) =
= b, 如果 abs(a-b) <= eps*abs b
= relative eps (cons b rest), 其他情况

[注意:relative里的epswithin里的eps定义是不同的!前者是绝对误差后者是相对误差!]

4.2.数值微分

我们已经重用了平方根近似值序列,当然,对函数withinrelative的重用也是可能的,它们能够与任何一个生成近似值序列的数值算法配合。我们将这样来编写数值微分算法。

函数在某一点的微分,便是其图象在该点的斜率。通过分别计算函数在该点与一个临近点处的取值,而后计算两点连线斜率的方法,可以很容易地估计出微分的值。这基于一个假定:如果这两点靠得足够近,那么函数图象在两点之间不会弯曲得很厉害。于是有下述定义:

easydiff f x h = (f(x+h)-f x) / h

为了得到良好的近似值,h应该很小。不幸的是,如果h太小,那么f(x+h)f(x)会相当接近,因此在相减过程中产生的舍尾误差可能会掩盖了计算结果。如何为h选取恰当的值呢?解决这个矛盾一种合理的方案是:从一个较大取值开始,不断减小h的值,并求出一个近似值序列。这个序列将趋近于该点的导数,但最终会由于舍尾误差的存在而不可救药地变得不精确。如果我们用(within eps)来选取第一个足够精确的近似值,那么舍尾误差影响结果的风险将会大大降低。我们需要一个函数来计算这个序列:

differentiate h0 f x = map (easydiff f x) (repeat halve h0)
halve x = x/2

此处h0h的初值,而后继取值是通过不断减半得到的。通过这个函数,任意点处的导数可以这样计算:

within eps (differentiate h0 f x)

但是,甚至这个方案也不是那么令人满意的,因为近似值序列收敛得相当慢。解决这个问题需要一点数学知识,序列中的元素可以记为:

微分的精确值 + 一个关于h的误差项

理论表明,该误差项与h的某一次幂大致成正比,因此当h减小时,误差也会减小。设精确值为A,而误差项为B*h**n [**是求幂运算符]。由于计算每个近似值时所用的h取值是下一个的两倍,故任意两个连续的近似值可以表示成:

a(i)   = A + B*(2**n)*(h**n)
a(i+1) = A + B*(h**n)

现在就可以消去误差项了,我们得到:

A=(a(i+1)*(2**n) - a(i))/(2**n - 1)

当然,误差项只不过“大致”与h的某一次幂(成正比),因此这个结论也是近似的。但这是一个好得多的近似。这一改进可以通过下述函数作用于所有相邻的近似值对之上:

elimerror n (cons a (cons b rest)) =
= cons ((b*(2**n)-a)/(2**n-1)) (elimerror n (cons b rest))

从一个近似值序列中消除误差项的操作产生了另一个收敛速度快得多的序列。

使用elimerror之前还有一个问题需要解决——我们必须知道n的正确值。通常这个值很难预测,但却很容易衡量。不难验证,下述函数能够正确地消除误差项,但在此我们并不给出证明。

order (cons a (cons b (cons c rest))) =
= round(log2( (a-c)/(b-c) - 1 ))
round x = 最接近x的整数
log2 x  = x以2为底的对数

现在,一个通用的近似值序列优化函数可以定义为:

improve s = elimerror (order s) s

使用improve能够更加高效地计算函数f的导数,如下:

within eps (improve (differentiate h0 f x))

improve只对利用一个不断减半的参数h计算得到的近似值序列适用。但是,如果improve作用于这样的序列,那么其结果也是一个这样的序列!这意味着一个近似值序列可以优化不止一次。每一次优化的过程中,都有一个不同的误差项被消除,因此优化产生的序列收敛得越来越快。因此,可以非常高效地计算导数:

within eps (improve (improve (improve (differentiate h0 f x))

从数值分析的角度讲,这似乎是一个“第四阶方法”(fourth order method),可以迅速地给出准确的结果。甚至可以定义:

super s = map second (repeat improve s)
second (cons a (cons b rest)) = b

super函数使用repeat improve来生成一个不断被优化的近似值的序列的序列。[就是说,生成一个序列,其中每一个元素是一个近似值序列,而这个元素是用前一个元素优化得到的。]同时,super提取出每个近似值序列中的第二个元素,构造出一个新的序列(已经确认,第二元素是最佳选择——它比首元更精确,而且不需要额外的计算)。这个算法的确非常复杂——更多的近似值被计算的同时,它使用了不断优化的数值方法。可以用下面的程序非常非常高效地计算导数:

within eps (super (differentiate h0 f x))

这个案例可能就像是用大锤敲碎坚果一样(大材小用),但关键是,甚至一个像super一样复杂的函数,当被惰性求值的方法模块化时,也会变得很容易表达。

4.3.数值积分

在这一部分我们将讨论的最后一个例子是数值积分。问题的描述很简单:给出一个返回实数,并有一元实数参数的函数,以及两个端点ab,估算两点之间曲线f下方的面积。估算面积的最简单方法是假定f趋近于直线,此时面积就是:

easyintegrate f a b = (f a + f b)*(b-a)/2

不幸的是,除非ab足够接近,否则这个估算似乎非常不精确。更好的估算方法是,将a与b之间的区间分为两段,分别估算子区间上的面积,再将结果加起来。我们可以定义一个不断趋近于准确值的积分近似值序列,首先使用上述方程进行第一次近似,而后将分别趋近于两个子区间上的子积分准确值的(两个)近似值累加起来以得到新的(积分总体的)近似值。计算这个序列可以使用函数:

intergrate f a b = cons (easyintergrate f a b)
(map addpair (zip (intergrate f a mid)
(intergrate f mid b)))
式中 mid = (a+b)/2

zip是另一个标准的表处理函数。它读取两个列表,并返回一个有序对的列表,每个有序对由两个输入列表中对应的元素组成。从而第一对由列表一和列表二的首元组成,第二对由列表一和列表二的第二个元素组成,以此类推。zip可以定义为:

zip (cons a s) (cons b t) = cons (pair a b) (zip s t)

在函数intergrate中,zip用于生成由两个子区间上相对应的积分近似值对组成的列表,而map addpair用于将有序对中的元素相加,从而生成一个原积分的近似值列表。

实际上,这个版本的intergrate函数相当低效,因为它持续不断地重复计算f的值。就像所写的一样,easyintergrate计算了fab两处的值,而对intergrate的递归调用将重复计算它们。同样的,(f mid)也在递归调用中重复计算了。因此,更可取的是下述从不重复计算f的版本:

intergrate f a b = interg f a b (f a) (f b)
integ f a b fa fb = cons ((fa+fb)*(b-a)/2)
(map addpair (zip (interg f a m fa fm)
(interg f m b fm fb)))
式中 m = (a+b)/2
fm = f m

integrate给出了一个不断趋近准确值的积分近似值列表,正如differentiate在上一小节中所做的一样。因此可以写出计算式以求出所需任意精度的积分值,如下:

within eps (intergrate f a b)
relative eps (integrate f a b)

这个积分算法与上一小节中的第一个微分算法有着同样的缺点——它收敛得相当慢。序列中的第一个近似值仅仅用了两个相距(a-b)的点来计算(通过easyintergrate)。第二个近似值也(除了ab之外)用到了中点,因此相邻两点之间的间距仅为(b-a)/2。第三个近似值在两个子区间上作同样的处理,因此间距仅为(b-a)/4。很清楚,每个近似值对应的相邻两点之间的间距在计算下一个值时被减半了。将这一间距看作h,那么这个序列就可以成为上一小节中定义的improve函数的优化对象了。因此我们可以写出(函数来计算)快速收敛的积分近似值序列,例如:

super (intergrate sin 0 4)
improve (intergrate f 0 1)
式中 f x = 1/(1+x*x)

(后一个序列是用于计算pi/4的“第八阶方法”。其中的第二个近似值只需要计算5次f的取值,但却具有5位准确数字。)

在本节中我们选取了一些数值算法并将它们函数化地编写出来,把惰性求值当做了黏合部件的黏合剂。由于惰性求值的存在,使得我们可以用很多新的方式来模块化这些算法,从而产生用途广泛的函数,例如withinrelativeimprove。通过这些部件的不同组合,我们简单而明了地编写出了一些相当不错的数值算法。

5.人工智能中的例子

我们已经指出,函数式语言威力强大主要是因为它们提供了两种新的黏合剂:高阶函数和惰性求值。在本节中,我们将讨论人工智能中一个大一点的实例,并演示如何使用这两种黏合剂来十分简单地编写它。

我们选取的实例是alpha-beta“启发式搜索”,一个用于估计游戏者所处形势好坏的算法。该算法预测游戏局势的可能发展,但会避免对无意义局势的进一步探究。

令游戏局势使用position类型的对象来表示。这个类型依据游戏的不同而不同,我们不对此作任何假定。必然有一种方法可以知晓对某一个局势能够采取的行动:假定有一个函数:

moves: position -> listof position

该函数以一个游戏局势为参数,并返回一个可以由自变量出发,通过一步行动而形成的position的列表。以noughts and crosses游戏(tic-tac-toe)为例:

这个函数假定通过当前局势总是可以判定现在是哪位游戏者的回合。在noughts and crosses中,可以通过数出0X的数目来做到这一点。在类似于象棋的游戏中,可能必须在position类型中显式包含这一信息。

利用函数moves,第一步是构造一棵博弈树。这棵树的结点都用局势来标记,而一个结点的子结点用从该结点一步便可到达的局势标记。也就是说,如果一个结点标记为局势p,那么它的子结点将使用(moves p)中的局势来标记。一棵博弈树完全有可能是无穷的,如果这个游戏可以在双方都不胜的情形下永远进行下去的话。博弈树与第2节中讨论的树完全类似——每个结点都有一个标记(它所代表的局势)与一个子结点列表。因此我们可以使用相同的数据类型来表示它们。

博弈树是通过反复运用moves而构造出来的。构造从根局势开始,moves用于生成根结点处子树的标记,而后moves被用于生成子树的子树,依此类推。这一递归模式可以用一个高阶函数表示:

reptree f a = node a (map (reptree f) (f a))

使用这个函数可以定义另一个函数,该函数从一个特定的局势开始生成博弈树:

gametree p = reptree moves p

例如图1所示。此处使用的高阶函数reptree与上一节中用于构造无穷列表的函数repeat是类似的。

图1: 一棵博弈树的实例

alpha-beta算法从一个给定的局势出发,就游戏的发展将会是有利还是不利作出判断。然而,要做到这一点,它必须能够在不考虑下一步的情况下粗略地估计某一个局势的“价值”。在后继局势不可预测时必须使用这一函数,它也可以用来对算法进行先期引导。静态估价的结果是从计算机的角度考虑的,是对该局势的前途的度量(假设在游戏中计算机与人对抗)。结果越大,局势对计算机而言越好。结果越小,局势越糟。最简单的此类函数将会,比如说,对计算机确定胜利的局势返回+1,对计算机确定失败的局势返回-1,而对其它的局势返回0。在现实中,静态估价函数会衡量各种使局势“看上去不错”的因素。例如,具体的好处,以及象棋中对中心的控制。假定有这样一个函数:

static: position -> number

既然一棵博弈树是一个(treeof position),那么它就可以被函数(maptree static)转换为一个(treeof number),该函数对树中所有的(也许是无穷多个)局势进行静态估价。此处使用了第2节中定义的函数maptree

给出一棵静态估价树之后,其中各个局势的真值究竟是多大?特别地,对根局势应该赋予什么值?不是它的静态值,因为那只是一个粗略的猜测。一个结点被赋予的值,必须由其子结点的真值决定。这一过程的完成,基于每个游戏者都会选择对自己最有利的行动的假定。回忆一下,高值意味着计算机的有利形势。很明显,当计算机从任意的局势开始下一步行动时,它将选择通往真值最高的子结点的行动。类似地,对手将会选择通往真值最低的子结点的行动。假定计算机与其对手轮流行动,那么当轮到计算机行动时,节点的真值用函数maximise计算,反之用minimise计算。

[所谓“真值”(true value),可能是我翻译得不好,此处理解为类似“真正的价值”的意思吧,是一个量度,不是逻辑学里的0和1哦。]

maximise (node n sub) = max (map minimise sub)
minimise (node n sub) = min (map maximise sub)

此处maxmin是关于列表的函数,分别返回列表中元素的最大值与最小值。上述定义是不完整的,因为它们将永远递归下去——没有给出边界情形。我们必须定义没有后继的结点的值(其标记)。因此静态估价用于任一游戏者胜利或者后继局势不可预测的情况下。maximiseminimise的完整定义是:

maximise (node n nil) = n
maximise (node n sub) = max (map minimise sub)
maximise (node n nil) = n
maximise (node n sub) = min (map minimise sub)

在这个阶段,几乎已经可以写出一个取一个局势作为参数并返回其真值的函数了。可能是:

evaluate = maximise . maptree static . gametree

这个定义有两个问题。首先,它不适用于无穷树。maximise不断地递归直到找到一个没有子树的结点——树的端点。如果没有端点那么maximise就不会返回结果。第二个问题与第一个有关——甚至有穷的博弈树(如noughts and crosses里的那棵)事实上也可能相当大。估价整棵博弈树是不现实的——搜索必须被限定在接下去的几步之内。为此可以将树剪至一个固定的深度:

prune 0 (node a x) = node a nil
prune n (node a x) = node a (map (prune (n-1)) x)

(prune n)取一棵树作为参数并“剪去”与根结点的距离超过n的所有结点。如果一棵博弈树被剪枝,那么将强制maximise对深度为n的结点执行静态估价而不是进一步递归。因此evaluate可以被定义为:

evaluate = maximise . maptree static . prune 5 . gametree

这将考虑其后(比如说)5步的形势。

在此开发过程中我们已经使用了高阶函数与惰性求值。高阶函数reptreemaptree使得我们能够很容易地构造与处理博弈树。更重要的是,惰性求值确保了我们可以使用这种方式模块化evaluate。由于博弈树具有潜在的无穷结果,在没有惰性求值的情况下,程序将永远不会终止。我们将不能写:

prune 5 . gametree

而不得不将这两个函数整合成一个只构造树的前五层的函数。更糟糕的是,甚至那前五层都可能已经太大以至于无法在同一时间内存储于内存中。而在我们所写的程序中,函数

maptree static . prune 5 . gametree

只是构造出了树中maximise所需的部分。由于每一部分都可以在被maximise处理完之后丢弃(被垃圾收集器回收),故完整的树从来没有存储于内存中。只有树的一小部分在某一段时间内被储存着。因此这个惰性程序很有效率。这一效率取决于maximise(组合链上的最后一个函数)与gametree(第一个函数)的相互作用,因此在没有惰性求值的情况下,要完成任务,只能将组合链上的所有函数整合成一个大函数。这是对模块化的强烈破坏,但也是通常的做法。通过单独修补每个部件,我们就可以优化估价算法——这相对简单。而一个传统型程序员必须把整个程序作为一个单元来修改,这就困难多了。

到目前为止,我们只是描述了简单的对最大最小值的处理(minimaxing)。但alpha-beta算法的核心是“计算maximiseminimise的值时常常不需要考虑整棵树”这一观察结果。考虑树:

          max
          / \
         /   \
        /     \
       /       \
     min       min
     / \       / \
    /   \     /   \
   1     2   0     ?

相当奇怪地,为了估价这棵树,并不需要知道问号处的值。左子树的最小值是1,但右子树的最小值显然是一个小于或等于0的值。因此这两个最小值的最大值必然是1。这一观察结果可以被泛化并内建到maximiseminimise之中。

第一步是将maximise拆分成max对一个数字列表的作用。也就是,将maximise分解为:

maximise = max . maximise'

minimise可以用类似的方法分解。由于maximiseminimise是完全对称的,故我们将只讨论maximise,而假定minimise也照此处理。)一旦这样分解之后,maximise可以使用minimise'来发现minimise将对哪些数字求最小值,并且不再使用minimise本身。而后便可以在不查看某些数字的情况下便将它们丢弃。由于惰性求值的存在,如果maxmise并不会查看所有的数字列表,那么一部分列表将不会被计算,这是对计算机时间的潜在节约。

maxmaximise中“约分出来”是很简单的,得到:

maximise' (node n nil) = cons n nil
maximise' (node n l) = map minimise l
= map (min . minimise') l
= map min (map minimise' l)
= mapmin (map minimise' l)
式中 mapmin = map min

由于minimise'返回一个数字列表,而这个列表的最小值是minimise的结果,故(map minimise' l)返回一个数字列表的列表。Maximise'应该返回这些列表中每个列表的最小值组成的列表,但只有其中[Maximise的返回值中]的最大值才有用。我们应该定义一个mapmin的新版本以忽略那些最小值不重要的列表[在(map minimise' l)的返回值中]的最小值。

mapmin (cons nums rest) =
= cons (min nums) (omit (min nums) rest)

函数omit传递一个“潜在的最大值”——当前所发现的最小值中最大的一个——并忽略任何比该值小的最小值。

omit pot nil = nil
omit pot (cons nums rest) =
= omit pot rest, if minleq nums pot
= cons (min nums) (omit (min nums) rest), otherwise

minleq以一个数字列表和一个潜在最大值为参数,如果列表的最小值小于或等于潜在最大值就返回真。要完成这一工作,它并不需要扫描整个列表!如果列表中有任意一个元素小于或等于潜在最大值,那么列表的最小值肯定也是如此。该特别元素之后的所有元素都是无关紧要的——它们就像是上面例子中的问号一样。因此minleq可以被定义为:

minleq nil pot = false
minleq (cons num rest) port = true, if numn2

此处sort是多用途排序函数。现在估价函数定义为:

evaluate = max . maximise' . highfirst . maptree static . prune 8 . gametree

也可能认为,为了限制搜索,只要考虑计算机或者对手的前三个最佳行动也已经足够了。要编写这样的程序,只需要把highfirst换成(taketree 3 . highfirst),其中:

taketree n = redtree (nodett n) cons nil
nodett n label sub = node label (take n sub)

taketree将树上所有的结点替换为最多有n个子结点的结点,它使用了函数(take n),而该函数返回列表的前n个元素(如果列表比n短,那么返回的元素就少一些)。

另一种优化是对剪枝的改良。上述程序甚至在局势非常dynamic的情形下也会向前搜索固定的深度——(但是,)例如在国际象棋中,一旦皇后被威胁,也许就可以决定不再搜索了。通常可以定义某些“dynamic”的形势,并在遇到这样的结点之一时,不再继续搜索而停止。假定有函数“dyramic”用以确定这样的形势,那么只需要为prune追加一个定义等式:

prune 0 (node pos sub) = node pos (map (prune 0) sub), if dynamic pos

在像这个程序一样模块化的程序里,作出这样的改动是很简单的。如前所述,这个程序的效率,关键是由链中的最后一个函数maximise与第一个函数gametree的相互作用决定的,因此若没有惰性求值,就只能写成一个单一的程序。这样的程序难于编写,难于修改,而且,非常难于理解。

6.结论

在本文中,我们指出模块化是成功的程序设计的关键。以提高生产力为目标的程序语言,必须良好地支持模块化程序设计。但是,新的作用域规则和分块编译的技巧是不够的——“模块化”不仅仅意味着“模块”。我们分解程序的能力直接取决于将解决方案粘在一起的能力。为了协助模块化程序设计,程序语言必须提供优良的黏合剂。函数式程序语言提供了两种新的黏合剂——高阶函数惰性求值。利用这些黏合剂可以将程序用新的、令人激动的方式模块化,对此我们举出了很多实例。越小、越通用的模块越可能被广泛地重用,使后续的程序设计工作变得简单。这解释了为什么函数式程序与传统型程序比较,要小得多,也容易编写得多。它也为函数式程序员提供了一个追求目标。如果程序的任何部分是杂乱或者复杂的,那么程序员就应当尝试将其模块化并泛化其部件。他应当期望把高阶函数和惰性求值用作他做此事的工具。

当然,我们并不是指出高阶函数与惰性求值的力与美的第一人。例如,Turner展示了这两者如何在一个生成化学结构的程序里大显身手[Tur81]。Abelson和Sussman强调“流”(惰性列表)是构架程序的强大工具[AS86]。Henderson使用了流来构架函数式操作系统[P.H82]。本论文的主要贡献是,断言了模块化自身,便是函数式语言强大威力的关键。

这与当前有关惰性求值的论战也有关联。有些人认为函数式语言应当是惰性的,而其他人认为不是这样。有些人走折衷路线,只提供惰性列表以及用于构造它们的特殊语法(例如,在SCHEME中[AS86])。本论文提供了更进一步的证据,证明惰性求值非常重要以至于不能被降为二等公民。这也许是函数式程序员所拥有的最强大的黏合剂。人们不应当阻碍对这样一个极为重要的工具的使用。

致谢

在牛津程序设计研究组与Phil Wadler和Richard Bird的多次交谈对本论文的写作帮助甚大。约特堡查麦兹大学的Magnus Bondesson指出了一个数值算法的早期版本中的严重错误,同时也协助了很多其他算法的开发。本论文在英国科学与工程研究评议会提供的研究基金赞助下发表。

参考文献

  • [AS86] H.Abelson,G.J.Sussman. 计算机程序的构造与解释. 麻省理工学院出版社,波士顿,1986
  • [Hug89] J.Hughes. 函数式程序设计为什么至关重要. 计算机月刊,32(2),1989
  • [Hug90] John Hughes. 函数式程序设计为什么至关重要. D.Turner主编,函数式编程的研究主题. Addison Wesley,1990
  • [MTH90] R.Milner,M.Tofte,R.Harper. Standard ML的定义. 麻省理工学院出版社,1990
  • [oD80] 美利坚合众国国防部. 程序语言Ada参考手册. Springer-Verlag,1980
  • [P.H82] P.Henderson. 纯函数式操作系统. 1982
  • [Tur81] D.A.Turner. 应用语言在语义上的优雅性. 1981年度函数式语言与计算机架构会议会报,海边温特渥,普茨茅斯,新汉普夏郡,1981
  • [Tur85] D.A.Turner. Miranda: 拥有多态类型的非严格语言. 1985年度函数式语言与计算机架构会议会报,1-16页,南锡,法国,1985
  • [Wir82] N.Wirth. Modula-II程序设计. Springer-Verlag,1982

基於統計語言模型的拼音輸入法

這是我離散數學課的大作業,用圖論算法解決某個複雜的問題,我選的題目是基於統計語言模型的拼音輸入法。通俗地講,就是實現一個支持智能組句的拼音輸入法。注意是智能組句,不是智能組詞,組詞其實就是查詞典,哪怕是人也是這麼做的,只不是這部詞典在大腦中而已,否則就是“造詞”了。而電腦組句的方法就和人有很大區別了,因為讓電腦理解一個句子的結構是極端困難的,尤其是作為分析語的中文,所以說這裡面蘊含著許多難題。說到這裡我想到了關於人工智能的討論,心理學家和工程科學家在對人工智能的認識上有著根本的分歧,即工程科學家認為人工智能就是實際效果顯得有類似人類的智能,而心理學家則把具有模仿認為思考、推理和行為的能力作為人工智能的判斷標準。在不同的認知基礎上人工智能的研究朝著不同的方向發展,事實情況是基於效果的人工智能的水平不斷在進步,而基於模仿的人工智能難有突破。作為一個拼音輸入法,實際效果比所謂智能的理解更為重要(況且作為表達思想和意志的工具,許多人並不希望自己輸入的所有內容被電腦“理解”),而統計語言模型就是一個被廣為應用的手段。

統計語言模型(Statistical Language Model)是聽起來像是個很深奧的東西,其實說出來並不複雜,簡而言之就是一個經過整理的大量語料的統計數據。這個數據有什麼用呢?用處非常大,像機器翻譯、語音識別、中文分詞、信息檢索乃至數據挖掘,都可能要用到統計語言模型,把它用到輸入法上面其實是最直接的使用。舉個簡單的例子來說,“wo shi zhong guo ren”這個音節序列中,“wo shi”可能對應了“我是”、“臥室”、“我市”、“臥式”等詞,而“zhong guo ren”則可能是“中國人”或者“種果人”,最佳的組句方案是什麼呢?這就要用到統計語言模型了,我們在這一大堆統計數據中,分別找到詞頻最大的單詞,如“我是”和“中國人”,句子就可以組合出來了。聽起來是不是很自然的想法呢?找出詞頻最大的詞組合到一起,就成了句子。事實上就是這樣,不少輸入法都以這種方式實現,而且效果也不差。例如早期的拼音加加、紫光拼音,Linux平臺下ibus-pinyin。追溯一下,使用這種方法的鼻祖應該是智能ABC輸入法吧,在當年這可是改變了中國人輸入習慣的一個劃時代產品。然而統計語言模型的應用遠遠不止於此,例如我們收集到的統計語言模型中,從單詞的詞頻上來說,“臥室”可能會比“我是”更高一些,但顯然“我是中國人”比“臥室中國人”更好,所以單看每個單詞的詞頻有時候不一定是最好的,這怎麼辦呢?我們可以不但考慮單詞的詞頻,也考慮兩個詞組合在一起的頻率,這樣的話“我是中國人”肯定是最好的結果了。甚至我們可以統計每三個詞、四個詞、乃至多元組的頻率,則必定會有更好的效果,於是N-gram模型應運而生。如果沒記錯的話,微軟拼音應該是最早做這種嘗試的輸入法了,只可惜微軟拼音的輸入模式偏偏那麼怪異,再加上推廣手段不力,一直默默無聞,反而是搜狗拼音在2006年異軍突起,迅速佔據了桌面市場。目前像搜狗、Google、QQ拼音等輸入法都採用了2-gram或者3-gram語言模型,在Linux和mac平臺下,開源的SunPinyin也是基於3-gram的。

統計語言模型說到底依賴於大量語料的統計,詞彙是數以十萬計的,兩個詞組合起來數據量就達到了百億,三元組則更是天文數字,何處找如此大的規模的語料來進行統計呢?想必一般人是沒有辦法,只有做搜索引擎的商業巨擘才有實力來做。但是時間和空間畢竟有限,不可能把輸入法做成如此一個龐然大物,桌面用戶是消受不起的,因此實際上在使用統計語言模型時,是需要對不少情況進行數學計算擬合出一個近似結果的,這便是從有窮模擬無窮的量化思想的體現。另一個方面,不少公司開始熱衷於做雲輸入法,用一臺超級計算機來計算龐大的數據,只需給出用戶結果,這樣就不必考慮用戶終端的計算能力了。

說了這麼多,談談我的設計吧。我用了盡可能簡單的建模方法實現了一個基於2-gram的拼音輸入法,為了突出圖論(畢竟是圖論課),我還設計了具有歧義的拼音字串的多重解析(如“翻案”和“發難”,對應fanan)。在我的設計中,我大量參考了SunPinyin,也得到了來自SunPinyin作者孫勇的不少幫助。我的程序的全部源碼和數據在slm_based_pinyin_ime.7z,源碼以Apache License 2.0發佈,數據來自open-gram項目。因為和SunPinyin使用了同樣的詞庫和語言模型,所以在測試中不少組句結果會與SunPinyin很接近,有心人可以試試比較一下。此外程序的圖標是ibus-pinyin的。寫完以後我發現我求k優最長路徑的算法寫得不好,在k比較大的時候會很慢,其實可以做到線性複雜度的。下面是一個演示文稿。

演示文稿

SLMPIME

最长公共子串问题的后缀数组解法

[最长公共子串]

最长公共子串(Longest Common Substring ,简称LCS)问题,是指求给定的一组字符串长度最大的共有的子串的问题。例如字符串"abcb","bca","acbc"的LCS就是"bc"。

求多串的LCS,显然穷举法是极端低效的算法。改进一些的算法是用一个串的每个后缀对其他所有串进行部分匹配,用KMP算法,时间复杂度为O(NL^2),其中N为字符串个数,L为每个串的长度。更优秀的有广义后缀树的方法,时间可以达到 O(NL)。本文介绍一种基于后缀数组的LCS解法,利用二分查找技术,时间复杂度可以达到O(NLlogL)。

[最长公共子串问题的后缀数组解法]

关于后缀数组的构建方法以及Height数组的性质,本文不再具体介绍,可以参阅IOI国家集训队2004年论文《后缀数组》(许智磊)和IOI国家集训队2009年论文《后缀数组——处理字符串的有力工具》(罗穗骞)

回顾一下后缀数组,SA[i]表示排名第i的后缀的位置,Height[i]表示后缀SA[i]和SA[i-1]的最长公共前缀(Longest Common Prefix,LCP),简记为Height[i]=LCP(SA[i],SA[i-1])。连续的一段后缀SA[i..j]的最长公共前缀,就是H[i-1..j]的最小值,即LCP(SA[i..j])=Min(H[i-1..j])。

求N个串的最长公共子串,可以转化为求一些后缀的最长公共前缀的最大值,这些后缀应分属于N个串。具体方法如下:

设N个串分别为S1,S2,S3,...,SN,首先建立一个串S,把这N个串用不同的分隔符连接起来。S=S1[P1]S2[P2]S3...SN-1[PN-1]SN,P1,P2,...PN-1应为不同的N-1个不在字符集中的字符,作为分隔符(后面会解释为什么)。

接下来,求出字符串S的后缀数组和Height数组,可以用倍增算法,或DC3算法。

然后二分枚举答案A,假设N个串可以有长度为A的公共字串,并对A的可行性进行验证。如果验证A可行,A'(A'<A)也一定可行,尝试增大A,反之尝试缩小A。最终可以取得A的最大可行值,就是这N个串的最长公共子串的长度。可以证明,尝试次数是O(logL)的。

于是问题就集中到了,如何验证给定的长度A是否为可行解。方法是,找出在Height数组中找出连续的一段Height[i..j],使得i<=k<=j均满足Height[k]>=A,并且i-1<=k<=j中,SA[k]分属于原有N个串S1..SN。如果能找到这样的一段,那么A就是可行解,否则A不是可行解。

具体查找i..j时,可以先从前到后枚举i的位置,如果发现Height[i]>=A,则开始从i向后枚举j的位置,直到找到了Height[j+1]<A,判断[i..j]这个区间内SA是否分属于S1..SN。如果满足,则A为可行解,然后直接返回,否则令i=j+1继续向后枚举。S中每个字符被访问了O(1)次,S的长度为NL+N-1,所以验证的时间复杂度为O(NL)。

到这里,我们就可以理解为什么分隔符P1..PN-1必须是不同的N-1个不在字符集中的字符了,因为这样才能保证S的后缀的公共前缀不会跨出一个原有串的范围。

后缀数组是一种处理字符串的强大的数据结构,配合LCP函数与Height数组的性质,后缀数组更是如虎添翼。利用后缀数组,容易地求出了多个串的LCS,而且时空复杂度也相当优秀了。虽然比起后缀树的解法有所不如,但其简明的思路和容易编程的特点却在实际的应用中并不输于后缀树。

附:POI 2000 Repetitions 最长公共子串

/* 
 * Problem: POI2000 pow
 * Author: Guo Jiabao
 * Time: 2009.4.16 21:00
 * State: Solved
 * Memo: 多串最长公共子串 后缀数组 二分答案
*/
#include <iostream>
#include <cstdio>
#include <cstdlib>
#include <cmath>
#include <cstring>
const int MAXL=10011,MAXN=6;
using namespace std;
struct SuffixArray
{
    struct RadixElement
    {
        int id,k[2];
    }RE[MAXL],RT[MAXL];
    int N,A[MAXL],SA[MAXL],Rank[MAXL],Height[MAXL],C[MAXL];
    void RadixSort()
    {
        int i,y;
        for (y=1;y>=0;y--)
        {
            memset(C,0,sizeof(C));
            for (i=1;i<=N;i++) C[RE[i].k[y]]++;
            for (i=1;i<MAXL;i++) C[i]+=C[i-1];
            for (i=N;i>=1;i--) RT[C[RE[i].k[y]]--]=RE[i];
            for (i=1;i<=N;i++) RE[i]=RT[i];
        }
        for (i=1;i<=N;i++)
        {
            Rank[ RE[i].id ]=Rank[ RE[i-1].id ];
            if (RE[i].k[0]!=RE[i-1].k[0] || RE[i].k[1]!=RE[i-1].k[1])
                Rank[ RE[i].id ]++;
        }
    }
    void CalcSA()
    {
        int i,k;
        RE[0].k[0]=-1;
        for (i=1;i<=N;i++)
            RE[i].id=i,RE[i].k[0]=A[i],RE[i].k[1]=0;
        RadixSort();
        for (k=1;k+1<=N;k*=2)
        {
            for (i=1;i<=N;i++)
                RE[i].id=i,RE[i].k[0]=Rank[i],RE[i].k[1]=i+k<=N?Rank[i+k]:0;
            RadixSort();
        }
        for (i=1;i<=N;i++)
            SA[ Rank[i] ]=i;
    }
    void CalcHeight()
    {
        int i,k,h=0;
        for (i=1;i<=N;i++)
        {
            if (Rank[i]==1)
                h=0;
            else
            {
                k=SA[Rank[i]-1];
                if (--h<0) h=0;
                for (;A[i+h]==A[k+h];h++);
            }
            Height[Rank[i]]=h;
        }
    }
}SA;
int N,Ans,Bel[MAXL];
char S[MAXL];
void init()
{
    int i;
    freopen("pow.in","r",stdin);
    freopen("pow.out","w",stdout);
    scanf("%d",&N);
    SA.N=0;
    for (i=1;i<=N;i++)
    {
        scanf("%s",S);
        for (char *p=S;*p;p++)
        {
            SA.A[++SA.N]=*p-'a'+1;
            Bel[SA.N]=i;
        }
        if (i<N)
            SA.A[++SA.N]=30+i;
    }
}
bool check(int A)
{
    int i,j,k;
    bool ba[MAXN];
    for (i=1;i<=SA.N;i++)
    {
        if (SA.Height[i]>=A)
        {
            for (j=i;SA.Height[j]>=A && j<=SA.N;j++);
            j--;
            memset(ba,0,sizeof(ba));
            for (k=i-1;k<=j;k++)
                ba[Bel[SA.SA[k]]]=true;
            for (k=1;ba[k] && k<=N;k++);
            if (k==N+1)
                return true;
            i=j;
        }
    }
    return false;
}
void solve()
{
    int a,b,m;
    SA.CalcSA();
    SA.CalcHeight();
    a=0;b=SA.N;
    while (a+1<b)
    {
        m=(a+b)/2;
        if (check(m))
            a=m;
        else
            b=m-1;
    }
    if (check(b))
        a=b;
    Ans=a;
}
int main()
{
    init();
    solve();
    printf("%dn",Ans);
    return 0;
}