阅读视图

发现新文章,点击刷新页面。
🔲 ☆

回归本源一一位运算及其应用(完整全文) – 2014国家集训队论文

回归本源一一位运算及其应用

镇海中学 沈洋

2014年信息学奥林匹克中国国家队候选队员论文

2014国家集训队论文

完整版pdf请从本github仓库 下载

摘要

在电子计算机中,位运算可以说是比四则运算史基本的运算,但由于它们 不是数学的基本运算且不常在生活中应用,容易被部分选手,特别是初学信息 学的选手忽视。本文希望通过对位运算及其应用的介绍,来使这一情况得到一 定的改善。

本文大致可分为三个部分。第一部分(第1节)对位运算基本概念进行 了介绍,第二部分(第2至4节)对位运算的几个应用进行了介绍,第三部分 (第5节)展示了一些例题。

几点说明

  • 约定二进制最高位为最左边,最低位为最右边,最低位记为第0位。

  • 本文中的所有程序均以C++语言描述,所涉及的语言细节、内建函数等均以GNU C++(GCC编译器)为标准。

  • 若无特别说明,本文中的位运算使用C++运算符表示。

  • 本文中汇编指x86汇编。

1 基本运算

1.1 与、或、非和异或

对于逻辑运算的"与"、"或"、"非"和"异或",想必大家都耳熟能详了。

其中"与"(∧)、"或"(∨)、"异或"(⊕)的真值表如下:

p q p ∧ q p ∨ q p ⊕ q
0 0 0 0 0
0 1 0 1 1
1 0 0 1 1
1 1 1 1 0

"非"(¬)的真值表如下:

p ¬p
0 1
1 0

位运算的"与"、"或"、"非"和"异或"可以看作逻辑运算在整数上的扩展。 它们的运算法则如下:

运算结果的每个二进制位的值等于两个运算数的对应二进制位进行逻辑"与"运算的结果。

运算结果的每个二进制位的值等于两个运算数的对应二进制位进行逻辑"或"运算的结果。

运算结果的每个二进制位的值等于运算数的对应二进制位进行逻辑"非"运算的结果。

异或 运算结果的每个二进制位的值等于两个运算数的对应二进制位进行逻辑"异或"运算的结果。

这四种位运算对应的汇编指令如下:

异或
and or not xor

由此可以看出,对于这四种运算,带符号整数和无符号整数并没有对应不同的汇编指令,它们的行为是相同的。 也就是说,对于带符号整数,符号位也会与其它位按照同样的方式处理。

1.2 移位运算

移位运算分为逻辑移位、算数移位、循环移位、带进位循环移位四种。 其中带进位循环移位涉及到CF标志位,因此不在我们的讨论范围内。为了准确区 分各种移位运算,在这一部分中我们使用汇编指令来表示它们。

当我们说将x左移或右移y位时,默认y非负且小于x的位宽1。 例如对于32位整数,y只能在0到31之间取值。

逻辑移位

逻辑左移对应的汇编指令为shl x y,它的功能是把x的每个二进制位向左移动y位,移动造成的最右边的空位由0补足,最左边的数溢出2。

逻辑右移与逻辑左移完全相反,它对应的汇编指令为shr x y,它的功能是把x 的每个二进制位向右移动y位,移动造成的最左边的空位由0 补足,最右边的数溢出。

算术移位

算术左移的汇编指令为sal x y,它与逻辑左移完全相同。

算术右移的汇编指令为sar x y,它与逻辑右移大体相同,唯一的区别在于移动造成的最左边的空位由符号位(最高位)补足而不是由0 补足。

循环移位

循环左移对应的汇编指令为rol x y,它的功能是把x的每个二进制位向左移动y位,移动造成的最右边的空位由最左边溢出的位补足。

循环右移与循环左移完全相反,它对应的汇编指令为ror x y,它的功能是 把x 的每个二进制位向右移动y位,移动造成的最左边的空位由最右边溢出的位 补足。

移位运算的数学意义

在逻辑移位、算术移位这两个看似复杂的定义背后实际上是有其数学含义的。逻辑移位被设计用于处理无符号整数,它的左移和右移分别与无符号整数的x × 2^y 和x ÷ 2^y 具有相同的效果: 算术移位被设计用于处理带符号整数,它的左移和右移分别与带符号整数的x × 2^y 和x ÷ 2^y 具有相同的效果(舍入方式为向 下舍入)。 这一点根据补码的定义比较容易证明,在此就不赘述了。

1若超出这个范围,汇编指令只考虑y的后几位,C语言将产生未定义行为。

2实际上,最后一个溢出的位会被放入标志位CF中,其他标志位也会相应发生变化,但这不在我们的讨论范围内,故在本文中略去对这些部分的描述。

C++中的移位运算

在C++中,无符号整数的移位使用逻辑移位,有符号整数的移位使用算术 移位。这样一来,无论是无符号整数还是带符号整数,我们都可以放心的使用 左移和右移来代替乘以二的幕或除以二的幕的操作。

C++中没有专门的对带符号整数进行逻辑右移的运算符,不过我们可以通过强制类型转换将它转换成无符号整数后再进行运算。

C++中没有提供循环移位操作符,但我们可以通过其他运算的组合来实现它。 例如对于32位无符号整数x,表达式(x << y) | (x >> (32 - y))可以实现循环左移的功能,而(x >> y) | (x << (32 - y))则可以实现循环 右移的功能。

2 二进制位的修改和查询

借由汇编指令、 内建函数以及基本位运算的组合,我们可以在二进制位的 层面上对整数进行一些修改和查询。 本节将介绍一些常用操作并给出一些易于 理解和扩展的实现3。 由于带符号整数的性质较为复杂,在这里我们只讨论无符号整数的相关操作,并以常用的32位无符号整数为例。

2.1 读某些位

读取x的第pos个二进制位是一个很常用的操作,它的实现方式是先将x右移pos位,使要读取的位移到最低位,再通过 & 1 将其取出。 代码如下4:

bool readBit(u32 x, int pos) {
    return (x >> pos) & 1;
}

3一些"黑技术"如利用64位指令处理32位操作数、 利用浮点数进行计算等并未涉及。 对此感兴趣的读者可以参考 http://graphics.stanford.edu/~seander/bithacks.html

4为简化代码,我们使用u32来表示32位无符号整数(unsigned int)。

有时候我们会将多个整数"打包"在一个整数中,一个典型的应用是将四 个字节打包为一个32位整型,那么读取的时候就需要形如"读x的第pos 位开始 的cnt位"这样的操作。 它的实现方式与上面别无二致: 首先将x右移pos位,再 通过& mask 来取出最后cnt位,其中mask的后cnt个位为1,其余位为0,可以 由(1 << cnt) - 1得到。 代码如下:

u32 readBits(u32 x, int pos, int cnt) {
    return (x >> pos) & ((1u << cnt) - 1);
}

通过上面两个例子,不难发现读取某个或某些二进制位的关键实际上是与 运算。 通过将原数和一个遮罩mask进行与运算,可以达到保留指定一些位(将遮罩mask的对应位设为1),清零其它位(遮罩mask的对应位设为0)的目的。

2.2 改某些位

对二进制位的修改实际上是"与"、"或"和"异或"运算的实际应用。

将某些位置为1

要实现这个功能,我们的办法是将原数与一个遮罩进行或运算。 对于要改为1 的位,我们将遮罩的对应位设为1,否则将对应位设为0,这样,原数与遮罩 进行或运算的结果就是答案。举例来说,将x的第pos 位置为1的代码如下:

u32 setBit(u32 x, int pos) {
    return x | (1u << pos);
}

将某些位置为0

这个操作与上一个操作正好相反,这一次我们要利用的是与运算。 我们构 造一个遮罩,对于要修改的位,我们将遮罩的对应位设为0,否则将其设为1 (注意,这与上一个操作中的遮罩完全相反),随后将原数与这个遮罩进行与运 算即可得到答案。 将x的第pos位置为0 的代码如下:

u32 clearBit(u32 x, int pos) {
    return x & ˜(1u << pos);
}

取反某些位

这一次是异或运算的应用。 同样构造一个遮罩,如果我们要取反某位,则 将遮罩的对应位设为1,否则将其设为0,随后将原数与这个遮罩进行异或运算 即可得到答案。 将x的第pos位取反的代码如下:

u32 flipBit(u32 x, int pos) {
    return x ^ (1u << pos);
}

2.3 求1的个数

分治法

求二进制位中1的个数与求各个二进制位的和是等价的,因此我们转而思考 如何求各个二进制位的和。 我们可以使用分治来解决这个问题:每次将整个数 分成两个部分,分别求出每个部分的和,再将它们相加。 利用位运算,我们可 以并行地完成每一层的工作,像下面这样:

int bitCount_1(u32 x) {
    x = ((x & 0xAAAAAAAAu) >> 1) + (x & 0x55555555u);
    x = ((x & 0xCCCCCCCCu) >> 2) + (x & 0x33333333u);
    x = ((x & 0xF0F0F0F0u) >> 4) + (x & 0x0F0F0F0Fu);
    x = ((x & 0xFF00FF00u) >> 8) + (x & 0x00FF00FFu);
    x = ((x & 0xFFFF0000u) >> 16) + (x & 0x0000FFFFu);
    return x;
}

它是这样工作的:

  • 第一步,有32个项需要相加,每一项占1 bit。 我们将其中的奇数项和偶数 项分别取出来(利用2.1 小节中提到的方法),并将奇数项右移1位和偶数 项"对齐",然后将他们相加。 这一步过后我们实际上将16对1 bit的项分 别相加,并将结果存放在了原来这两项所在的2 bit空间上。

  • 第二步,有16个项需要相加,每一项占2 bit。 我们将奇偶项分别相加,形成8个4 bit的项。

​ ...

  • 第五步,有两项需要相加,每一项占16 bit。 将这两项相加我们便得到了答案。

那么,还能再优化么?答案是肯定的。 看下面这段代码:

int bitCount_2(u32 x) {
    x-= ((x & 0xAAAAAAAAu) >> 1);
    x = ((x & 0xCCCCCCCCu) >> 2) + (x & 0x33333333u);
    x = ((x >> 4) + x) & 0x0F0F0F0Fu;
    x = ((x >> 8) + x) & 0x00FF00FFu;
    x = ((x >> 16) + x) & 0x0000FFFFu;
    return x;
}

这段代码较上一段的第一个差别是第一步的实现。 我们知道第一步的作用

是将奇数位和偶数位相加,根据第一个程序它的结果应为:

((x & 0xAAAAAAAAu) >> 1) + (x & 0x55555555u)

而x的值等于:

((x & 0xAAAAAAAAu) + (x & 0x55555555u)

将两式作差得到:

(x & 0xAAAAAAAAu) >> 1

因而将x减去(x & 0xAAAAAAAAu) >> 1便可以得到我们所求的结果。

另一个差别是第二步之后的实现,我们以第二步为例来说明。 第二步的作 用是将4对4 bit整数相加,但实际上此时每个整数最大只可能是4,这就表示, 即使是两个数相加的结果也能在4 bit的空间存下。 因此(x >> 4) + x可以正确地依次求出第0个数加第1个数,第1个数加第2个数,第2个数加第3个数……

我们从中取出我们需要的结果即可。

还能继续优化么?答案依然是肯定的:

int bitCount_3(u32 x) {
    x-= ((x & 0xAAAAAAAAu) >> 1);
    x = ((x & 0xCCCCCCCCu) >> 2) + (x & 0x33333333u);
    x = ((x >> 4) + x) & 0x0F0F0F0Fu;
    x = (x * 0x01010101u) >> 24;
    return x;
}

这段代码与上一段的不同点在于,上一段代码的最后两步操作被语句:

x = (x * 0x01010101u) >> 24

代替了。 我们知道上一段代码中第四、 第五步的作用是将x中的4个8 bit数相加,

那么,如果我们令:

x = (a<<24)+(b<<16)+(c<<8)+(d<<0)

则我们所求的答案便是:

a + b + c + d

我们来看看计算x * 0x01010101u 时发生了什么:

x*0x01010101
=(x<<24)+(x<<16)+(x<<8)+(x<<0)
=(a<<48)+(b<<40)+(c<<32)+(d<<24)+
         (a<<40)+(b<<32)+(c<<24)+(d<<16)+
                 (a<<32)+(b<<24)+(c<<16)+(d<< 8)+
                         (a<<24)+(b<<16)+(c<< 8)+(d<< 0)
=((a)   <<   48) +
((a+b)  <<   40) +
((a+b+c) <<  32) +
((a+b+c+d)<< 24) +
((b+c+d) <<  16) +
((c+d)   <<  8 ) +
((d)      << 0 )

由于x是32位整数,因此上式的前二项应当溢出,于是x 0x01010101的结果为((a+b+c+d)<<24) + ((b+c+d)<<16) + ((c+d)<<8) + (d)考虑 到a,b,c的实际意义,它们的值都不会超过8,因此上式的后二项的值必定 小 于1 << 24,也就是说它们不会对结果的最高8位造成任何影响。因此,x 0x01010101的结果的高8位便是我们所求的a+b+c+d,我们将它右移24位即可得到答案。

查表法

如果我们要求0到n中每一个数的答案的话,那么可以使用如下递推:

f(0) = 0,
f(i) = f(i >> 1) + (i & 1)

显然对于所有32位整数保存答案(至少在目前看来)是一件不太现实的事 情,不过好在这个问题是可以分割的,我们可以预处理所有16位整数的答案, 在询问时将被询问的整数拆成高16位和低16位分别计算答案。 于是预处理的代 码如下:

int cnt_tbl[65537];
void bitCountPre() {
    cnt_tbl[0] = 0;
    for (int i=1; i<65536; ++i)
        cnt_tbl[i] = cnt_tbl[i >> 1] + (i & 1);
}

回答询问的代码如下:

int bitCount_4(u32 x) {
    return cnt_tbl[x >> 16] + cnt_tbl[x & 65535u];
}

内建函数

GCC中提供了如下内建函数5来实现这个功能:

int __builtin_popcount (unsigned int x);

对于支持SSE4.2的机器,如果在编译时开启相应开关,则该函数会被翻译成汇编指令popcnt,否则该函数使用类似上面"查表法"的方法进行计算。

2.4 翻转位序

对于一个32位整数来说,翻转位序是指将它的第0位与第31位交换,第1位 与第30位交换,…… ,第i 位与第31 − i位交换,…… ,第15 位与第16 位交换。 例如对于32 位整数5,对它进行翻转位序将得到2684354560。

分治法

我们可以采用分治法来解决这个问题:首先将整个数分割成两个部分,分别翻转这两个部分,再将这两个部分对调。 同样地,借由位运算,我们可以把 每一层的工作并行完成,实现如下:

u32 bitRev_1(u32 x) {
    x = ((x & 0xAAAAAAAAu) >> 1) | ((x & 0x55555555u) << 1);
    x = ((x & 0xCCCCCCCCu) >> 2) | ((x & 0x33333333u) << 2);
    x = ((x & 0xF0F0F0F0u) >> 4) | ((x & 0x0F0F0F0Fu) << 4);
    x = ((x & 0xFF00FF00u) >> 8) | ((x & 0x00FF00FFu) << 8);
    x = ((x & 0xFFFF0000u) >> 16) | ((x & 0x0000FFFFu) << 16);
    return x;
}

这段代码是这样工作的:

  • 第一步,交换相邻两位。 这样就形成了16个长度为2的己经翻转的组。

  • 第二步,每两位为一组,交换相邻两组。 这就将16个长度为2的组变成了8个长度为4的己翻转的组。

……

  • 第五步,每16位为一组,交换相邻两组。 整个数翻转完成。

5关 于 本 文 中 提 到 的GCC内 建 函 数 的 具 体 说 明 及 其 他 相 关 内 建 函 数, 请 参 阅GCC于 册:

http://gcc.gnu.org/onlinedocs/gcc/Other-Builtins.html

查表法

我们首先预处理出所有16位整数翻转后的结果,这可以由如下递推得到:

f(0) = 0,
f(i) = (f(i >> 1) >> 1) | ((i & 1) << 15)

接下来,我们将待翻转的32位整数拆成两个16位整数,通过查表来得到这 两个16位整数的答案,再将它们对调便可以完成整个32位整数的翻转。 预处理 的代码如下:

u32 rev_tbl[65537];
void bitRevPre() {
    rev_tbl[0] = 0;
    for (int i=1; i<65536; ++i)
        rev_tbl[i] = (rev_tbl[i>>1]>>1) | ((i&1)<<15);
}

查询的代码如下:

u32 bitRev_2(u32 x) {
    return (rev_tbl[x & 65535] << 16) | rev_tbl[x >> 16];
}

2.5 求前缀/后缀0的个数

二分法

求前缀0与求后缀0是类似的,我们不妨以求前缀0为例。 首先给出代码:

int countLeadingZeros_1(u32 x) {
    int ans = 0;
    if (x >> 16) x >>= 16; else ans |= 16;
    if (x >> 8) x >>= 8; else ans |= 8;
    if (x >> 4) x >>= 4; else ans |= 4;
    if (x >> 2) x >>= 2; else ans |= 2;
    if (x >> 1) x >>= 1; else ans |= 1;
    return ans + !x;;
}

这段代码的实质是二分查找。 它是这样工作的:

  • 第一步,判断高16位是否为空。 若是,则高16位必然均为前缀0,我们给 答案加上16,并在第0至15位上继续二分:若不是,则前缀0只出现在则 高16位上,我们将它们右移到低16位上以便对它继续二分。

  • 第二步,判断此时的高8位是否均为前缀0,并选择一边继续二分下去。

    ……

  • 第六步,判断仅剩的那一位是否为0,若是,则给答案加1。

求后缀0个数的方式与此别无二致,因此根据上一段代码炮制一段求后缀0个数的代码并不是件难事:

int countTrailingZeros_1(u32 x) {
    int ans = 0;
    if (!(x & 65535u)) x >>= 16, ans |= 16;
    if (!(x & 255u )) x >>= 8, ans |= 8;
    if (!(x & 15u )) x >>= 4, ans |= 4;
    if (!(x & 3u )) x >>= 2, ans |= 2;
    if (!(x & 1u )) x >>= 1, ans |= 1;
    return ans + !x;
}

这段代码的工作原理与上一段基本相同,就不再赘述了。

查表法

我们依然以前缀0个数为例。 这个问题与同样是可以分割的。 我们可以先确 定第一个1出现在高16位中还是低16位中,再通过查表来得到这个16 位整数的 答案。 这张表可以通过如下递推得到:

f(0) = 16,
f(i) = f(i >> 1) - 1

于是我们就不难写出预处理的代码:

int clz_tbl[65537];
void countLeadingZerosPre() {
    clz_tbl[0] = 16;
    for (int i=1; i<65536; ++i)
        clz_tbl[i] = clz_tbl[i >> 1] - 1;
}

以及查询的代码:

int countLeadingZeros_2(u32 x) {
    if (x >> 16)
        return clz_tbl[x >> 16]; else
        return clz_tbl[x & 65535u] + 16;
}

求后缀0个数也可以通过几乎相同的方法实现。

内建函数

对于这两个问题,GCC也提供相应的内建函数。

求前缀0的内建函数为:

int __builtin_clz (unsigned int x);

它对应的汇编指令为bsr (Bit Scan Reverse)外加一个异或。

求后缀0的内建函数为:

int builtin_ctz (unsigned int x);

它对应的汇编指令为bsf (Bit Scan Forward)。

需要注意的是,当参数x为0时,这两个函数的行为是未定义的。

2.6 求第k个1的位置

这个问题可以转化为求最大的w,使得第0位到第w − 1中1的个数小于k。 这 显然是可以二分的。 于是,借助2.3小节中的cnt_tbl,我们便能实现一个简单的二分:

int kthBit_1(u32 x, int k) {
int ans = 0;
    if (cnt_tbl[x & 65535u] < k)
        k -= cnt_tbl[x & 65535u], ans |=16, x >>=16;
    if (cnt_tbl[x & 255u ] < k)
        k -= cnt_tbl[x & 255u ], ans |= 8, x >>= 8;
    if (cnt_tbl[x & 15u ] < k)
        k -= cnt_tbl[x & 15u ], ans |= 4, x >>= 4;
    if (cnt_tbl[x & 3u ] < k)
        k -= cnt_tbl[x & 3u ], ans |= 2, x >>= 2;
    if (cnt_tbl[x & 1u ] < k)
        k -= cnt_tbl[x & 1u ], ans |= 1, x >>= 1;
    return ans;
}

不借助cnt_tbl自然也是可以的。 我们可以发现,我们在二分的过程中所需要的区间和都是2.3小节中分治法计算过的区间,因此我们可以将分治的中间 值存下来供二分时使用,像下面这样:

int kthBit_2(u32 x, int k) {
    int s[5], ans = 0, t;
    s[0] = x;
    s[1] = x - ((x & 0xAAAAAAAAu) >> 1);
    s[2] = ((s[1] & 0xCCCCCCCCu) >> 2) + (s[1] & 0x33333333u);
    s[3] = ((s[2] >> 4) + s[2]) & 0x0F0F0F0Fu;
    s[4] = ((s[3] >> 8) + s[3]) & 0x00FF00FFu;
    t = s[4] & 65535u;
    if (t < k) k -= t, ans |=16, x >>=16;
    t = (s[3] >> ans) & 255u;
    if (t < k) k -= t, ans |= 8, x >>= 8;
    t = (s[2] >> ans) & 15u;
    if (t < k) k -= t, ans |= 4, x >>= 4;
    t = (s[1] >> ans) & 3u;
    if (t < k) k -= t, ans |= 2, x >>= 2;
    t = (s[0] >> ans) & 1u;
    if (t < k) k -= t, ans |= 1, x >>= 1;
    return ans;
}

2.7 提取末尾连续的1

我们知道对x加1之后,最右边连续的1会变为0,最右边的0则变为1,而其

它位不变。 利用这一点我们可以通过下面这个公式来提取x末尾连续的1:

x & (x ^ (x + 1))

2.8 提取lowbit

正整数x的lowbit是指x在二进制下最右边一个1开始至最低位的那部分,记 为lowbit(x)。 例如28 (11100B)6的lowbit 为4 (100B),16 (10000B) 的lowbit则为16 (10000B)。 lowbit的典型应用是树状数组和遍历集合。

有如下3个公式能帮我们求解lowbit:

lowbit(x) = x & (x ^ (x - 1))
lowbit(x) = x ^ (x & (x - 1))
lowbit(x) = x & -x

前两个公式利用了"将x减去1后,x中最右侧的1变为0,其之后的0变为1" 这一点,第3个公式则主要利用了补码的特性。读者可以自行完成这3个公式的证明。

2.9 遍历所有1

一般情况下,我们可以通过不断求lowbit并将它从原数中删去(通过异或) 来遍历每一个1。 当需要用到这个1所在的位置时,我们可以通过2.5小节中介绍 的求后缀0个数的方法来获知。

3 将整数用作集合

集合的种类有很多,但将它们的元素标号之后,都能映射到整数集合上。

因此在这里我们只讨论全集为[0, n)的整数集合。

二进制的每个位有0和1两种状态,这正好可以对应集合中某个元素是否存 在,因此我们可以用二进制数来表示集合一一如果某位为1就表示对应元素存在 于这个集合中,否则不存在。然而计算机中单个二进制数的位数是有限的,不 妨设其能处理的单个二进制数的最大位宽为w,那么,表示上述全集为[0, n)的 整数集合就需要至少in/wl个二进制数。 我们把每个二进制数叫做一"块",并 规定第i块记录第wiw(i + 1) − 1这个w 个数是否存在(0 ≤ i < in/wl,最后一块 略有不同)。 这样,我们便得到了一个维护集合的数据结构一一bitset。 我们将某 个bitset能表示的全集的大小称为该bitset的大小。

6我们用后缀B来表示一个二进制数。

从本质上来说,bitset实际上是压w位的二进制高精度整数,因此也可以进行与、或、非、异或、左移、右移这些位运算,甚至可以进行加减乘除。

3.1 加入某个元素

首先计算出被加入元素所属的块,再使用2.2小节所述的方法将对应位置为1。

时间复杂度: O(1)

3.2 删除某个元素

首先计算出被删除元素所属的块,再使用2.2小节所述的方法将对应位置为0。

时间复杂度: O(1)

3.3 查询某个元素是否存在

首先计算出待查询元素所属的块,再利用2.1小节所述的方法检查对应位是否为1。

时间复杂度: O(1)

3.4 交集

集合的交和bitset的与运算相对应。 x与y的交为x & y。

时间复杂度:O(n/w)

3.5 并集

集合的交和bitset的或运算相对应。 x与y的并为x | y。

时间复杂度:O(n/w)

3.6 差集

记A、B是两个集合,则所有属于A且不属于B的元素构成的集合叫做A与B的差。集合的差可以通过bitset的与运算和异或运算实现。x与y的差为x ^ (x & y)。

时间复杂度:O(n/w)

3.7 补集

集合的补与bitset的非运算相对应。 x的补为∼x。

时间复杂度: O(n/w)

3.8 统计元素个数

对于每个块分别使用2.3小节中介绍的方法计算1的个数,再将所有块的答案相加。

时间复杂度:O(n/w)

3.9 遍历集合元素

遍历所有块,对每个块使用2.9小节中介绍的方法遍历元素。

时间复杂度:O(n/w + cnt),其中cnt表示集合的元素个数。

3.10 求集合中第k小元素

朴素做法

首先从小到大依次遍历每个块,找出第k小元素所在的块,再利用2.6 小节中介绍的方法确定具体是哪个元素。

时间复杂度:O(n/w + log w)

更加高效的做法

在某些应用中,求第k小元素可能比其它集合操作的使用频率更大,此时我 们就会需要一个复杂度更优的查询第k小元素操作,同时要尽可能小地影响其它 集合操作的复杂度。

一种改进方式是利用线段树。 我们使用一颗辅助线段树来维护bitset中每个 块的元素个数。 这样一来,我们就可以通过在线段树上二分的方式快速确定 第k小元素所属的块。 这是一个经典的线段树操作,在这里就不再赘述。 找出第k小元素所在的块之后,我们就可以利用2.6小节中介绍的方法确定具体是哪个元素了。 这样改进之后,求第k小元素的时间复杂度降为了O(log n)。

添加了辅助线段树之后,其他的集合操作也要做出相应修改。 在加入、 删 除集合元素时,该元素所属的块的元素个数会发生改变,我们需要同时维护辅 助线段树的信息,因此这两个操作的时间复杂度变为了O(log n)。 在集合求交、 并、 差、 补的时候,我们可以直接暴力重建辅助线段树,时间复杂度维持不变 为O(n/w)。

3.11 求集合中大于x的最小元素

朴素做法

首先判断x所在块中是否存在大于x的元素,若是则直接返回这个元素,否 则从x所在的块开始向后遍历,找到x之后第一个包含元素的块,然后返回该块 的第一个元素。 其中,某块的第一个元素以及某块中比x大的第一个元素均可以 利用2.5小节中介绍的统计后缀0个数的方法求出。

时间复杂度:O(n/w)

更加高效的做法

类似于求第k小元素,我们也可以利用辅助数据结构来加速大于x的最小元素的查询。

这次我们利用的是bitset本身。 我们使用一个大小为n/w的辅助bitset7来保存每个块是否有元素,这样一来,我们可以通过如下流程来完成该查询:

  1. x所在块中存在大于x的元素,则直接返回这个元素,否则转2。

  2. 利用辅助线段树查询x所在块之后第一个含有元素的块,转3。

  3. 在该块中找第一个元素并返回结果。

改进之后,该查询的时间复杂度降为了O(logw n)。

同样地,其他的集合操作也要做出相应修改。 在加入、 删除集合元素时,我们需要同时维护辅助bitset中该元素所在块的信息,因此这两个操作的时间复杂度变为了O(logw n)。 在集合求交、 并、 差、 补的时候,我们可以直接暴力重建辅助bitset,时间复杂度维持不变为O(n/w)。

7这里的bitset是递归定义的,因此这个辅助bitset也支持快速求集合中大于x的最小元素。

3.12 将集合的全部或部分元素加上/减去x

将集合的全部元素加上/减去x

对集合a的每个元素都加上x只需要将a左移x位即可。 同样的,对集合a的每个元素都减去x 只需要将a右移x位即可。

时间复杂度:O(n/w)

将集合的部分元素加上/减去x

设原集合为a,要求改动的那部分元素组成的集合为b。 在这次修改中, a与b的差这部分元素是不会改变的,记为集合p:a与b的交这部分元素是需要 加上/减去x的,记为集合q。 之后我们利用移位操作对集合q作相应修改,再与 集合p求并即可。

时间复杂度:O(n/w)

3.13 枚举子集

枚举子集的关键在于,对于某个集合的某个子集,我们需要求出字典序排 在它前一位的集合或后一位的集合。 对于集合x和它的一个子集y,我们可以通 过(y - 1) & x来求出字典序排在它前一位的子集。因此,若要枚举x的子集, 我们只需从x本身开始,不断求前一个子集,直到枚举到空集为止即可。

3.14 枚举含有k个元素的集合

与枚举子集类似,枚举含有k个元素的集合的关键同样是对于某个含有k个 元素的集合求出字典序排在它前一位或后一位的集合。 这一次我们选择的是构 造字典序后一位的集合。

朴素的做法如下:设当前集合中的数从小到大依次为a0, a2, . . . , ak−1,其中 最小的可以增大的数为a j,那么我们把a j 加上1,并将a0到a j−1重置为0到 j − 1即 可得到下一个包含k 个元素的集合。

这个做法反映到bitset上是这样的: 设最右边连续的1是第i位到第 j位,那么 我们要做的就是将第 j位上的1向左移一位,同时将第i位到第 j − 1位上的1向右移 到最右边。 这可以由下面这段代码完成:

u32 nextComb(u32 x) {
    u32 l = x & -x, y = x + l;
    return y | (((x ^ y) / l) >> 2);
}

它是这样工作的:

  • 第一步,使用l = x & -x求出x的lowbit。

  • 第二步,y = x + l将第 j位上的1向左移动一位,同时将第i位到第 j−1位置为0(ij的含义见上文)。

  • 第三步,((x ^ y) / l) >> 2将第i位到第 j − 1位上的1提取出来并移到最右边。

  • 第四步,将这些1与y合并得到答案。

4 其他应用

除上两节所介绍的应用外,我们还可以利用位运算做一些其他事情。

4.1 判断奇偶性

根据奇偶性的定义,二进制最低位为0的数为偶数,最低位为1的数为奇数, 因此我们可以直接使用x & 1 来判断x的奇偶性。 若x & 1等于1则表明x为奇 数,否则x 为偶数。

4.2 乘以或除以二的幕

根据移位运算的实际意义,我们可以直接利用左移和右移实现,参见1.2 小节中的相关介绍。

4.3 对二的幕取模

x mod 2^y 相当于取出x的后y位,因此可以用x & ((1 << y) - 1)来实现相同的效果。

4.4 求log2 x的整数部分

x的位宽为w, 使 用2.5小节中介绍的方法计算出的前缀0个数为l, 那么 ⌊log2 x⌋ = w − 1 − l

4.5 交换两个数

这是异或的经典应用。 我们可以通过如下代码来交换a, b:

a ^= b, b ^= a, a ^= b

4.6 比较两个数是否相等

我们可以计算两个数的异或值,若等于零则说明这两个数相等,否则说明这两个数不相等。

4.7 取绝对值

对于一个w位带符号整数x,它的符号位sign = x >> (w - 1),我们可 以通过表达式(x ^ -sign) + sign来得到x的绝对值。 这一点根据补码的定 义比较容易证明,在此就不赘述了。

4.8 选择

我们可以利用表达式y ^ ((x ^ y) & -cond)来实现选择的功能:

  • cond = 1,其结果为x

  • cond = 0,其结果为y

5 例题

5.1 筷子

试题来源

经典问题

试题大意

有2n + 1个整数,其中某个数出现了奇数次,其他数出现了偶数次。 求出现了奇数次的那个数。

算法介绍

由于异或运算满足交换律,并且满足x ⊕ x = 0,因此将所有数异或起来即为答案。

5.2 Robot in Basement

试题来源

Codeforces 97D8

Yandex.Algorithm 2011 Finals

试题大意

在一个n × m (n, m ≤ 150)的网格上,有一些格子是障碍,并且网格边界上的格子均是障碍,另有一个非障碍的格子是出口。 有一个机器人可以根据程序在 该网格上行走。 一段程序是一个由UDLR 四种指令组成的字符串,机器人会依 次执行每个指令,一个指令会使机器人向指定的方向移动一格,如果对应格子 为障碍则不动。

现在给定一个长度为 l (l ≤ 10^5)的程序,求它的一个最短前缀p,使得对于一开始在网格图上任意非障碍位置的机器人,在执行完程序p之后都停在出口上。

8 http://codeforces.com/problemset/problem/97/D

算法介绍

首先不难想到一个O(nml)的朴素算法。 我们直接顺序执行给定的程序,并 按照题目指定的移动方式暴力维护网格上哪些格子有机器人,直到执行完某一 步后发现网格上只有出口位置有机器人时结束。

实际上我们可以利用bitset来维护网格上哪些格子有机器人。 一种表示方式 是利用bitset的第i · m + j个位置表示格子(i, j) (0 ≤ i < n, 0 ≤ j < m)上是否有机器 人。这样一来一个向左或向右移动的指令就对应了该bitset的左移或右移1位,一 个向上或向下的指令就对应了该bitset的左移或右移m位。 障碍的问题可以这样 解决:预处理cL, cR, cU, cD四个bitset,分别表示向左、 向右、 向上及向下走一 步会碰到障碍的位置。 以向左走为例,设原bitset为x,那么执行一个指令L之后 会撞到障碍的位置为x & cL,能正常向左走的位置为x ^ (x & cL),我们 知道能正常向左走的那些机器人应该被左移一位,而那些会撞到障碍的机器人 则应留在原地,因此最终的bitset应变为(x & cL) | (x ^ (x & cL))。 改进后的算法的时间复杂度为 O( nml / w),可以通过本题。

5.3 Quick Tortoise

试题来源

Codeforces 232E9

试题大意

在一个n × m (n, m ≤ 500)的网格上,有一些格子是障碍。共有q (q ≤ 6·10^5)个询问,每次询问是否能只通过向下走和向右走从格子(x1, y1)走到格子(x2, y2)。

算法介绍

首先考虑所有询问满足x1≤px2的情况。对于所有在第p行上方的格子(x,y),求它能走到第p行的哪些格子,己这个点集为*fx,y,它可以通过递推式fx,y=fx+1,yfx,y+1求出。类似地,对于所有在第p行下方的格子(x,y),求第p行的哪些格子能走到它,己这个点集为gx,y,它可以通过gx,y=gx−1,ygx,y−1求出。求出fx,ygx,y之后,我们就可以来处理询问了。对于询问(x1,y1),(x2,y*2),

若fx1,y1∩gx2,y2≠∅,则表示可以从(x1,y1)走到(x2,y2),反之则不能。

9 http://codeforces.com/problemset/problem/232/E

对于一般情况,我们可以使用分治转化成上面的情况进行求解。 例如我们正在处理所有满足l ≤ x1 ≤ x2 ≤ r的询问,令mid = ⌊(l+r)/2⌋,我们可以先按照上一段所述的方法处理所有满足l ≤ x{1} ≤ mid ≤ x{2} ≤ r的询问,再递归地处理剩下的满足l ≤ x1 ≤ x2 < mid的询问和满足mid < x1 ≤ x2 ≤ r的询问。 如果我们只对x轴进 行分治,那么这个算法时间复杂度为O( n·m^2 log n + q log n)。 如果我们交替对x轴 和y轴进行分治,它的时间复杂度可以进一步降为O((nm*)^1.5 + q log nm)。

5.4 Bags and Coins

试题来源

Codeforces 356D10

试题大意

n (n ≤ 70000)个包,每个包可以直接放在地上,也可以放在其他包里面, 并且允许多层嵌套。 有s (s ≤ 70000)个硬币分布在这n个包里。 如果拿出某个硬 币必须要打开第i个包,我们就称该硬币在第i个包里。 现在己知每个包里的硬币 数,第i个包里有*ai (ai* ≤ 70000)个硬币。 求一种满足条件的包的嵌套方式以及硬 币分布方式,或确定问题无解。

算法介绍

不妨设a1 ≥ a2 ≥ . . . ≥ *a*n

我们首先考虑s = a1的情况。 在这种情况下,我们只需要将第1个包放在地上,并在第i (1 ≤ i < n)个包中放入ai − ai+1 个硬币以及第i + 1个包,最后在第n个包中放入*a*n个硬币即可。

于是对于一般情况,即sa1的情况,问题就转化成了边一些包放在地 上(第1个包是必边的),让它们的硬币个数之和等于s。 这是一个经典的背包问 题。 我们可以用一个bitset来表示当前背包(通过当前添加的物品能够达到哪些总体积),设当前bitset为S,那么添加一个体积为x的物品后的bitset就会变 为S | (S << x)。 当添加完所有物品之后,我们检查最终的bitset中是否包 含s这个元素就能确定是否有解。

10 http://codeforces.com/problemset/problem/356/D

如何记录方案呢?我们引入一个 from数组,其中 from[i]表示在某种体积 达 到i的 方 案 中 最 后 加 入 的 物 品。 这 样 一 来,如 果 问 题 有 解,我 们 就 可 以 通 过 from数组不断追溯到上一个加进来的物品,也就可以恢复方案了。 from 数组 的维护也很容易,在添加第i个物品的时候,设添加之前的bitset为S,之后为Sn, 己Sn与S的差为D,我们枚举D的每个元素 j,并将 from[ j] 设为i即可。 由于D的 实际意义是加入第i个物品时新产生的可行体积的集合,因此 from数组的每个元素至多被设置一次,也就保证了复杂度。

该算法的时间复杂度为 O(n*s/w)。

参考文献

[1] Sean Eron Anderson, “Bit Twiddling Hacks”.

[2] Christer Ericson, “Advanced bit manipulation-fu”.

[3] 刘汝佳, 黄亮, <算法艺术与信息学竞赛), 清华大学出版社。

4.3 / 5 ( 12 votes )
🔲 ☆

从零开始刷力扣 – 刷题列表【玩转力扣】

从零开始刷力扣 - 刷题列表

noone
发布于 2020-07-09

简介

是不是有许多小伙伴在刷力扣的时候感觉无从下手?从头按顺序开始刷的童鞋们可能会比较有感触,为什么才第四题就感觉很难了?没关系,本文将对力扣的 1-700 题中不需要会员的数据结构与算法题目(数据库与 shell 除外)进行分类,并推荐一个刷题的顺序。

完全零基础可以刷题吗?

不能,至少要基本掌握一门计算机语言的语法。但现在在网上随便搜一下就能搜到许多关于计算机语言的教程。当然,最好还是上一下正规的课程。

刷题顺序很重要吗?

重要。按照题目类别结构化地刷题的速度不仅更快,而且可以在刷完一类题之后进行总结。对于水平较高的小伙伴们来说,按照推荐的顺序刷,可以在 200 小时内刷完 500 多题。对于萌新们来说,按照推荐顺序刷,能更好地掌握数据结构与算法基础。

题目分类及刷题顺序推荐

一. 数组

题目分类 题目编号
数组的遍历 485495414628
统计数组中的元素 64569744844241274
数组的改变、移动 453665283
二维数组及滚动数组 118119661598419
数组的旋转 189396
特定顺序遍历二维数组 5459498
二维数组变换 5664873289
前缀和数组 303304238
题解 数组篇

二. 字符串

题目分类 题目编号
字符 520
回文串的定义 125
公共前缀 14
单词 43458
字符串的反转 344541557151
字符的统计 38738938324249451423657551696467535
数字与字符串间转换 2994125065395535375926403844381312273165481
子序列 392524521522
高精度运算 666741543306
字符串变换 482668
字符串匹配 28686459214
中心拓展法 5647

三. 数与位

题目分类 题目编号
数字的位操作 79479564231342326504263190191476461477693393172458258319405171168670233357400
简单数学题 49229507
快速幂 50372

四. 栈与递归

题目分类 题目编号
用栈访问最后若干元素 68271388
栈与计算器 150227224
栈与括号匹配 2063659132
递归 385341394

五. 链表

题目分类 题目编号
链表的删除 20323719
链表的遍历 430
链表的旋转与反转 61242069225
链表高精度加法 2445
链表的合并 2123

六. 哈希表

题目分类 题目编号
哈希表的查找、插入及删除 217633349128202500290532205166466138
哈希表与索引 1167599219220
哈希表与统计 59435055460945418
哈希表与前缀和 560523525

七. 贪心算法

题目分类 题目编号
数组与贪心算法 60512112256145557513540962117956572284524356464064816921575324517649678420
子数组与贪心算法 53134581152
子序列与贪心算法 334376659
数字与贪心 343
单调栈法 4965034563164023218485

八. 双指针法

题目分类 题目编号
头尾指针 3456801671516181142
同向双指针、滑动窗口 272680838261118764367420934385674247630
分段双指针 8632816088475
快慢指针 141142143234457287

九. 树

题目分类 题目编号
树与递归 10022210122643756361750857254365468787
树的层次遍历 102429690559662671513515637103107257623653104111112113129404199655116117
树的前序遍历 144589
树的前序序列化 606331652297449
树的后序遍历 145590
树的中序遍历与二叉搜索树 947005305382309817366945011095108109
重构二叉树 105106
二叉树的展开 114
最近公共祖先 235236
Morris中序遍历 50199
四叉树 558427

十. 图与搜索

题目分类 题目编号
图的建立与应用 565
深度优先搜索 17397
回溯法 526401363751527739216404647315566049178907993332
回溯法与表达式 241282679
回溯法与括号 22301
回溯法与贪心 488
广度优先搜索 133200695463542130417529127126433675
并查集 547684685
拓扑排序 399207210
有限状态自动机 65468

十一. 二分查找

题目分类 题目编号
二分查找应用(简单) 3743527836769441
二分查找应用(中等) 345402754363003546581624
二分查找与旋转数组 1531543381
二分查找与矩阵 74240
二分答案法 378668410483

十二. 二进制运算的应用

题目分类 题目编号
异或的应用 89136137260268
与或非的应用 371318201

十三. 动态规划

题目分类 题目编号
数组中的动态规划 5097033845551982136509163955212318830932264313403
子数组、子序列中的动态规划 689413446368416279
背包问题 322518474494377
矩阵中的动态规划 62636412057668822162917496329
动态规划与字符串匹配 58372971155161321311391405141044
状态压缩动态规划 464691698638473
区间中的动态规划 486664375312546
树形dp 337124
数位dp 233600

十四. 数据结构

题目分类 题目编号
数据结构设计——栈与队列 225232284622641155
数据结构设计——哈希表 676355380381
数据结构设计——哈希与双向链表 432146460
前缀树 208211648386677472421212336440
23373378632347692502630407295480
树状数组 307315493327673
线段树 699
平衡树(set/map) 352218363

十五. 采样

题目分类 题目编号
按权值采样 528497
蓄水池抽样 382398
拒绝采样 470478519

十六. 计算几何

题目分类 题目编号
计算几何基础 593447223149
分类讨论法 335
凸包 587
覆盖问题 391

十七. 常用技巧与算法

题目分类 题目编号
博弈论 292
分块 239164
倍增法 330
拓展欧几里得算法 365
洗牌算法 384
找规律 390672
分治法 395667
排序算法 147148
线性筛 204
摩尔投票法 229

结语

希望以上的分类及顺序能帮到大家,未来也有做各分类的题解的打算,欢迎一起讨论。


油猴脚本, 能把所有题目替换为超链接, 曾经发过被吞了, 不知道还有没有人需要

https://pastebin.com/daaaFm4D

password: vyCPqG1cVc

来自 https://leetcode-cn.com/circle/article/48kq9d/

4 / 5 ( 8 votes )
🔲 ☆

《奇思妙想: 15位计算机天才及其重大发现》第一部分

奇思妙想: 15位计算机天才及其重大发现 第一部分

奇思妙想 第一部分

第一部分 语言大师:如何与机器对话

奇思妙想 封面-极客中心

奇思妙想 封面-极客中心

假设现在第二次世界大战刚结束不久,而你是一位总工程师,负责一项计算机制造计划。你收集了一堆电线和开关,任务是构建出一台机器,以便预测飞机在空中的飞行模式。鉴于当时的技术水平,你只能使用一些不可靠的部件,而且构建的机器要便于装配。这台机器消耗的电力足以供应一个小型工厂。此外你还为它配备了一个足以容纳几千个字符的存储器、加法器、乘法器,以及一套用于计算的指令集。
指令是最不需要操心的问题,它们只需表达清楚,能够完成任务即可。
为了提高效率,你需要让各种指令尽可能与工程设计的细节相适应。这台机器的存储器具有不同的运行速度和能耗,因此你需要设定一个指令组用于在慢速存储器和快速存储器之间传递数据,设定另一个指令组用于在快速存储器中对数据进行四则算术运算或其他操作。这种分组方式无疑不利于编写预测飞行模式的程序,如果将存储器视为一种不分类的资源来进行指令分组,应该会容易不少,但你并不为此担心。在当时,一毛钱就能雇来一打学数学的编程人员,而相比之下,机器本身却非常精致、昂贵且罕见。
五年之后,情况发生了根本性的变化。你吃惊地发现,随着对程序的需求高涨以及硬件成本的降低,程序设计的费用要远高于硬件的购买和维护成本。(这种态势一直延续了下来。到了20世纪90年代中期,在大多数企业的软件开发环节中,程序设计的开支已经达到了硬件开支的50倍左右。)因此你为自己制定了两个新的目标:缩短程序第一个版本发布所需要的时间,以及让程序更易于改进,以适应客户需求的变化。
你决定修改编程人员与机器沟通所使用的语言。新的语言应当反映出所要解决问题的结构,而不再是反映存储器的快慢层级和算术运算。每一种行业都会根据自身的目的而发明某种语言,例如电影导演会用“ 开拍”(action)这个简单的术语来表示“ 摄像师开始拍摄,演员开始表演,其他人员各就各位”。由于在20世纪50年代中期,计算机主要应用于纯科学和数据处理,因此你更关注的是创造一种数学语言,包括运算公式、求和以及向量数组。为了开拓思路,你拜访了许多研究人员,也了解了许多项目,它们允许程序员输入类似(X + Y)/Z这样的数学表达式,然后计算机就能自行编译并且进行相应的运算。
到了1958年,有一门科学编程语言脱颖而出,它就是约翰·巴科斯 ① 和他在IBM公司的同事合作设计的Fortran(取自formula translation的缩写,即公式翻译)。这门语言至今仍然是自然科学界使用最为广泛的编程语言。

① 约翰·巴科斯(John Warner Backus,1924—2007)被称为Fortran语言之父,1977年获得图灵奖。本书第1章详细介绍了他的生平。

与此同时,一个研究团队在1956年开辟了计算机的一个崭新领域:人工智能(artificial intelligence)。这门学科的一个目标是创造出具备人类推理能力的计算机。由于人类大部分的推理过程都需要进行抽象(例如积木的相对位置、句子的语法、不同洗衣皂的优缺点),偏重数学运算的Fortran语言对此并不适用。卡内基-梅隆大学的艾伦·纽厄尔 ① 和赫伯特·西蒙 ② 建议用符号表(list of symbols)来表现抽象概念,并且把推理过程视为一种“ 符号操作”(symbol manipulation)。
假设有一块红色积木、一块蓝色积木和一块黄色积木。一个5岁大的小孩也可以告诉我们,如果红色积木在蓝色积木之上,而黄色积木又在红色积木之上,那么黄色积木必然也在蓝色积木之上。将前两个事实用符号表来表示,我们可能会写:(above redblock blueblock)以及(above yellowblock blueblock)。之后引用符号操作规则,我们便能得出:如果(above x y)和(above z x),则(above z y)。
年轻的约翰·麦卡锡 ① 对这一想法印象深刻,但他认为纽厄尔和西蒙的设计过于复杂。他简化了设计,加入了两个强大的功能,也就是我们所知的递归(recursion)和求值(eval),并且创造了一门称为Lisp的语言(取自“ list processing” 的缩写,即表处理)。Lisp不仅是人工智能方面的首选语言,且自1958年诞生起,和Fortran一起不断为计算机语言的设计提供灵感。
这一年的夏天,一个国际计算机科学家小组在苏黎世成立,开始设计纳入Lisp元素的Fortran语言的继任者。他们的精诚合作产生了里程碑式的Algol 60,它直接催生了Pascal、C和Ada这些语言,同时也是从PostScript到Excel等各种语言的直系祖先。

① 艾伦·纽厄尔(Allen Newell,1927—1992)专于计算机科学和认知信息学领域,是信息处理语言(IPL)的发明者之一。1975年他和赫伯特·西蒙因人工智能方面的基础贡献而一同被授予图灵奖
② 赫伯特·西蒙(Herbert Alexander Simon,中文名为司马贺,1916—2001)是现代一些重要学术领域,如人工智能、信息处理、决策制定、注意力经济、组织行为学、复杂系统等的创建人之一,并且获得了很多荣誉,如1975年的图灵奖、1978年的诺贝尔经济学奖以及1986年的美国国家科学奖章。

然而到了20世纪70年代早期,一些研究人员逐渐发现由Fortran、Algol和Lisp所衍生出来的上百种语言都存在着一个共同的弱点:写出来的程序让人很难读懂。在实际工作中,人们发现如果想修改程序的行为,往往都需要整个重写。
拥有生物学背景的艾伦·凯 ② 认为应该把程序构建成像活的生物那样:由独立自主的“ 细胞” 构成,彼此通过讯息传递进行合作。他认为通过自主的单元来构建程序,将会使这些单元能够融入新的上下文情境。对于这种“ 细胞”,他使用了“ 对象”(object)一词来称呼,并且将这种方式称为“ 面向对象”(object-orientation)。对象的行为取决于它得到的讯息。
比如说,在与图片有关的程序中,可以用“ 旋转30度”、“ 开始打印” 这样的讯息或命令来描述常用行为。而如果是操控登月机器人的程序,则可能是“ 收集岩石样本” 或者“ 找到高度超过1米的石块” 。20世纪70年代中期,艾伦·凯带领他在施乐公司的团队设计出了首个面向对象的语言Smalltalk。大批年轻人在数年间加入了它的用户群。由此开始,Smalltalk深刻地影响了C++等日趋成熟的编程语言,其自身也衍生了一批专业的追随者。
过去四十年间,世界上诞生了成百上千种编程语言,约有60种在今天仍然被广泛使用,其中包括文字处理、电子表格、图形设计和动画设计等领域的专用语言。就像工具帮助木匠解决问题一样,一门好的编程语言也能帮助我们解决计算机的问题。而且编程人员也正如木匠习惯于自己的工具那样习惯于自己钟爱的语言。

① 约翰·麦卡锡(John McCarthy,1927—2011)是人工智能方面的鼻祖,1971年获得图灵奖。参见本书第2章。
② 艾伦·C.凯(Alan Curtis Kay,1940-)在面向对象编程和窗口式图形用户界面方面作出了先驱性的贡献。2003年获得图灵奖。参见本书第3章。

语言学家本杰明·沃尔夫 ① 曾经说过:“ 语言塑造了我们思考的方式,决定了我们思考的内容。” 他这番话是针对人类语言所言,而计算机语言则进一步印证了这一主张,尤其是那些灵感来源于巴科斯、麦卡锡和凯的成果的计算机语言。

① 本杰明·沃尔夫(Benjamin Lee Whorf,1897—1941)是美国人类语言学的重要人物。1931年于耶鲁大学师从人类语言学家萨丕尔,提出了“ 萨丕尔-沃尔夫假说”,创立了“ 语言决定论” 和“ 语言相对论”,认为语言影响并制约着人类的思维模式。

1、约翰·巴科斯 不断进取的发明家

我们不知道自己想要什么,也不知道该怎样去做。一切都是顺其自然地发生。我们面临的第一个挑战就是不知道这种语言看上去应该是什么样子。然后就是怎样解析表达式——这是个大问题,而且在今天来看,我们当时的做法非常笨拙……

——约翰·巴科斯谈及Fortran的发明过程

常言道,需要乃发明之母。不过对有些人来说,发明的动力并非来自需要,而纯粹只是因为受不了事物的混乱和低效。约翰·巴科斯就是这样一位发明家。他开启了三个伟大的发明:第一门高级编程语言Fortran、为高级语言描述语法规则的巴科斯诺尔范式,以及函数式编程语言FP。
他的这三大贡献至今仍推动着世界上的科研和商务的发展。然而对巴科斯自己来说,这些发明之所以诞生,只是由于他对当时的概念工具感到不耐烦。
对效率低下的厌恶似乎来自于家庭的遗传。第一次世界大战前,巴科斯的父亲从阿特拉斯火药公司的一个小职员被提拔为首席化验师,这家公司主要生产用于炸药的硝化甘油。他的晋升有着充足的理由。

他们的工厂产量很低,而且经常会发生爆炸事故,却一直无法找出原因。产品的生产对作业温度极其敏感。我的父亲发现他们的高价德国温度计不准确。于是他前往德国,学习了温度计的制作技术,并且生产出了一批高质量的温度计。之后,工厂的爆炸事故明显少多了。
第一次世界大战期间,老巴科斯应征入伍成为一名军需官。战后他并未得到政府允诺的杜邦公司 ① 的职位,于是转行做了一名证券经纪人。到1924年约翰·巴科斯在费城出生时,他的父亲已经在一战后的经济繁荣中略有发迹。巴科斯在特拉华州威明顿市度过了他的童年时光,之后在宾夕法尼亚州波茨敦市就读于著名的希尔中学 ② 。
我每年的考试都不及格,从来就没认真过。我讨厌学习,成天游手好闲。为此我不得不每年都参加新罕布什尔州的暑期补习班。我其实很高兴,因为在那里我可以驾驶帆船,玩得非常痛快。
1942年巴科斯好不容易从希尔中学毕业,依照父亲的意愿进入弗吉尼亚大学主修化学。他对理论知识还比较感兴趣,但痛恨实验室。大部分时间,巴科斯都忙于参加各种派对,坐等年龄一到就去服兵役。到第二学期结束为止,他每周定期参加的课程只有一门不计学分的音乐鉴赏。最后他终于引起了校方的注意,弗吉尼亚大学的生涯也随之结束。随后他于1943年入伍。

① 杜邦公司(DuPont)是世界排名第二大的美国化工公司,创办于1802年,早期以制造火药而出名。
② 希尔中学(The Hill School)是一所美国的大学预科学校,1851年建立,十校联盟成员。著名校友还包括美国前国务卿贝克。

巴科斯成为了一名下士,在佐治亚州的斯图尔特堡带领一队防空兵。不久他在一次能力测试中表现优异,于是被指派前往匹兹堡大学攻读工程学预科。而之后的一次医学能力测试可以说是救了他的命。
战友们被装船参加阿登战役 ① ,而我则跑到哈弗福特学院念医学预科。
作为医学院预科的一部分,巴科斯在亚特兰大一家医院的神经外科病房实习,治疗头部创伤。
一次意外的巧合,巴科斯被诊断出患有骨肿瘤,在颅内安装了一块金属板。不久之后,他就读于花与第五大道医院的医学专科学校(现在的纽约医学院),但只坚持了九个月。
我受够了。医学专科学校的人不喜欢思考。他们只会死记硬背,而且也要求你这样做。在那里根本不允许你思考。
在此期间,巴科斯感觉颅内的那块金属板不太合适,于是到附近专门研制金属板的斯塔顿岛医院,希望能更换一个。由于对医院提出的设计方案不满意,他掌握了相关技术后自己设计了一款。在此之后,巴科斯退出了医学领域,在纽约找了一间小公寓住下来,每个月租金18美元。
当时我真的不知道这辈子到底想要干些什么。因为我喜欢音乐,所以想要搞一套高保真音响,但在当时并没有这种设备,于是我上了一所无线电技术学校。在那里我遇到了一位非常好的老师,也是我遇到的第一位好老师,他要我与他合作,帮某个杂志做些电路特性的计算工作。

① 阿登战役也称突出部战役(Battle of the Bulge),是第二次世界大战期间纳粹德国1944年西线最大的阵地反击战,双方投入近60个师,德军和盟军各伤亡8万余人,其中7.7万人为美军,被称为第二次世界大战期间美军最血腥的一次战役。

我记得做了一些相对简单的运算,得出了放大器电路曲线上的几个关键点。工作非常艰苦、乏味,但是它引发了我对数学的兴趣。它能付诸真正的应用,正是这一点吸引了我。
巴科斯进入了哥伦比亚大学的基础教育学院,开始读一些数学课程。他不喜欢微积分,但对代数很感兴趣。到了1949年春,25岁的巴科斯还有几个月就将拿到数学学士学位,但仍然对自己的前途一无所知。
就在这个春天的一天,巴科斯参观了位于麦迪逊大街的IBM计算机中心。当时他参观了可选循序电子计算器(Selective Sequence Electronic Calculator,SSEC),这是一台IBM早期基于电子真空管制造的机器。
SSEC体积非常庞大,占据了很大一个房间,其上密布着仪表和线路。在参观中,巴科斯向带他参观的人提出自己想找一份工作,而她让他去找主管谈。
我说算了,我没法去。当时我看上去很邋遢,头发也很乱。但是她坚持让我试试,所以我还是去了。他们让我参加一个测验,我做得还可以。
巴科斯受聘在SSEC上工作。这台机器实际上并不是现代意义上的计算机。它没有储存软件的存储器,因此程序每次都得靠穿孔纸带“ 喂” 进去。SSEC有着成千上万个电子机械部件,运行并不是很可靠。
在那台机器上工作很有乐趣。你可以独自使用整个机器。你必须时刻就位,因为那玩意儿每三分钟就会出错,然后停止运行。你必须想办法让它重新启动。
编程的情况也很原始。
一份操作说明和一份指令清单,这就是你能做的编程。要想完成一件事情,每个人都得自己想办法。而完成事情的方法有无数种,所以人们自然会用各种各样的方式来编程。
巴科斯在SSEC上工作了三年。他的首个大项目是月球历表的计算,即计算在任一给定时刻月球的位置。在当时,IBM有能力供养一个纯科学部门,他们曾与哥伦比亚大学合作一个项目,想办法将穿孔卡和纸带机用于科学研究。公司的收入来源主要是为企业和政府生产纸带机。夺人眼球的庞然大物SSEC只是为这种技术起到些宣传作用,而技术本身最终还是被IBM淘汰了。不管公司当初是出于什么动机,巴科斯毕竟从SSEC的工作中学到了很多东西。同时他也为科学计算作出了自己的第一次贡献:在小型机上做多位数运算的痛苦,迫使他开发了Speedcoding程序。

Speedcoding:用小字长表示大数值

在计算机领域,作为整体进行四则运算的一串数码单位称为“字”(word)。这些字的长度都是固定的,根据硬件不同,通常能包含8到32位数字。由于这种长度的限制,必须采用特别的手段才能表现出以埃(千万分之一毫米)或者光年为单位的数值。
著名数学家约翰·冯·诺依曼 ① 是科学计算领域的先驱之一。早在匈牙利的童年时期,冯·诺依曼就被视为数学天才,高中尚未毕业就发表了自己的首篇数学论文。他的记忆力也同样惊人,曾一句不漏地复述出整部《剑桥世界古代史》,而这只是为了取乐。他一生的科研成就包括古典数学和量子力学方面的基础理论,以及提出了博弈论。1933年冯·诺依曼移居美国,进入了普林斯顿大学高等研究院,并且在第二次世界大战期间的曼哈顿计划 ① 中承担了领导作用。

① 约翰·冯·诺依曼(John Von Neumann,1903—1957),美籍匈牙利人,现代电子计算机创始人之一,被称为计算机之父。他在计算机科学、经济学、量子力学及几乎所有数学领域都作出过重大贡献。1931年以不到30岁的年纪成为普林斯顿大学的终身教授,1933年转入该校高等研究院,与爱因斯坦等人一起为最初的六位教授。1994年被追授美国国家基础科学奖。

冯·诺依曼意识到,在原子弹的设计过程中需要涉及非常大和非常小的数字的运算,而宾夕法尼亚大学莫尔电机工程学院(Moore School)的一个电子计算器项目引起了他的兴趣。1944年他加入了这个项目,最初作为观察员,之后便开始亲身参与,并且大力提倡在计算机中既存储指令又存储数据的方法。这种方法在今天早已应用于所有的计算设备 ② 。
第二次世界大战结束后,冯·诺依曼研制了自己的计算机(名为“ 约翰尼克”),并且设计了一批早期程序,用于解答核物理中一些无法通过手工解决的问题。出于对科学计算的浓厚兴趣,他建议在计算机中使用一种“ 换算因子”(scaling factor)来存储和操作那些非常大或者非常小的数字。

① 曼哈顿计划(Manhattan Project)是美国陆军部自1942年起开始实施的核武器研究计划。该工程集中了当时西方国家(除纳粹德国外)最优秀的科学家,动员人数超过10万,历时3年、耗资20亿美元,于1945年成功地进行了世界上第一次核爆炸,并按计划制造出两颗实用的原子弹。整个工程取得圆满成功,但也引发了针对科学家道德的争论。
② 提倡并不等同于发明。冯·诺依曼将此归功于阿兰·图灵,其设想的“ 机器” 也能存储指令。而电子存储器的真正构建则应归功于莫尔学院的约翰·艾克特(John Eckert),他利用水银延迟线原理,用充满水银的振动管来存储数据,从而首开先河。——原书注

原理其实很简单。假设一个计算机字只能存储3位数字,那么要想表示数字517,就需要向计算机字中写入517,并且指定其换算因子为0。要想表示51.7,则同样写入517,然后指定换算因子为-1。表示5170则指定换算因子为1,表示5 170 000则指定换算因子为4,依次类推。正值的换算因子等于跟在数字后面的0的个数,而负值的换算因子则等于小数点后的数字个数。
在进行运算时,编程人员也许并不知道每次计算的精确结果,但他们应该能知道它的换算因子是多少——至少冯·诺依曼认为是这样。没错,原理确实很简单,但前提是你碰巧还是一个优秀的数学家。巴科斯从程序员的角度出发,对此提出了自己的看法。
这样的结果是,你必须对问题了如指掌,知道其中各种换算因子,否则数字就会溢出,或者因为取整时误差太大而出现错误。在这样一种计算机条件下,编程工作变得非常复杂。
与IBM的同事哈伦·海尔里克(Harlan Herrick)一起,巴科斯开发了一款名为Speedcoding的程序,利用浮点数来支持运算。浮点数自身带有换算因子,从而为编程人员卸下了重担。巴科斯在Speedcoding积累的经验为他日后迎接更大的挑战奠定了基础。
当时每个人都知道编程的费用是多么昂贵。租赁机器需要数百万美元,而编程的成本与之相比只多不少。编程费用居高不下是因为你必须雇佣很多人力用“ 汇编” 或者第二代语言写程序,这些语言与满是 0 和 1 的机器码或者说二进制系统相比,进步非常有限。汇编语言相当浪费时间,只为一台机器写命令就要费九牛二虎之力,然后还必须校正由此所产生的大批错误。在这种混乱拖沓中往往会丢失程序本来的目标。

Fortran:第一门高级计算机语言

1953年12月,巴科斯向当时他在IBM的老板卡斯伯特·赫德(Cuthbert Hurd)提交了一份备忘录,建议为IBM 704机设计一种编程语言(该机器当时已经具备浮点运算能力)。这个项目后来被称为“ 公式翻译”,也就是Fortran。它的目标非常明确。
这种语言只是为了大幅度缩短编程的时间。我并没有想过会在别的机器上使用它,当时也几乎没有什么别的机器。
但首先,巴科斯需要面对时任IBM公司顾问的冯·诺依曼的强烈反对。
他并不认为编程存在多大的问题。我认为他当时反对的主要理由之一是,在浮点运算中你很难知道自己会得到什么。而如果保持定点(fixed point),你起码在出现问题时知道问题出在哪里。但是他没有考虑到编程成本的问题。他认为 Fortran 简直就是不切实际、白费力气。
最终赫德还是批准了这项计划,冯·诺依曼也不再坚持。巴科斯招募的团队兼收并蓄、不拘一格,既有经验丰富的程序员,也有初出茅庐的数学系毕业生。1954年秋天,他的程序研究小组已经有了一个清晰的目标:为IBM 704打造一款能让编程更加容易的语言。
正如他们所见,设计语言并不是最困难的部分。真正的挑战在于如何翻译语言,让机器能够直接理解。进行这项翻译任务的程序被称为“ 编译器”(compiler)。今天的大学生可以在一学期之内就设计并实现出一个新的语言编译器,但在当时,巴科斯和他的团队缺少这种算法。尤其是对编译器的核心部分“ 语法分析器”(parser)的设计,他们一直止步不前。
计算机的语法分析和我们在初中学过的语句分析很类似(可能大部分人一毕业就都丢在脑后了)。在语句分析中,我们需要找出不同部分之间的关系,例如限定词、名词、动词、形容词和副词,然后用树形结构图表现出来(参见图1-1)。

奇思妙想 巴斯德范式1 - 极客中心

图1-1 分析一个英语句子

对于编译器而言,则是由语法分析器先画出树形结构图,再将“ 高级语言”(人类易于理解的语言)翻译成“ 机器语言”,以一系列指令的形式直接输入到计算机的电路中(不同模式的计算机往往支持不同的指令,这也是Windows系统的计算机上不能运行Mac程序的原因)。
机器语言程序的效率取决于设计人员使用那些存储速度高但存储时间短的寄存器(register)的效率。现代计算机最少有16个寄存器,有些则达到几千个。与寄存器相比,随机存取存储器RAM(Random Access Memory)可以储存数百万字节的海量信息,但由于其存储速度较慢,绝大部分计算行为仍然发生在寄存器中。

比如说,(A + B)/C这样一个数学表达式可能会被翻译成以下指令序列:
(1) 复制A的值到寄存器1
(2) 复制B的值到寄存器2
(3) 复制C的值到寄存器3
(4) 将寄存器1和寄存器2的值相加,并将结果放入寄存器1
(5) 用寄存器3除寄存器1的值,并将结果放入寄存器1

在这个例子中,算术运算需要将数据放入寄存器中(有些机器不需要将数据放入寄存器,但
相应的运算也会慢很多)。如果在下一步运算中,除了变量A、B、C外还需要D和E,那么编译器
就要决定是将D和E的值放入新的寄存器4和5,还是再次使用之前的1、2和3。这个决定又取决于
A、B和C将何时被再次用到,以及其他很多因素。这个问题非常复杂,至今也尚未完全得到解决。
巴科斯和他的团队属于首批寻求合理方案的人。
而最大的挑战在于Fortran的新功能: DO 循环语句。循环语句能让程序员更加方便地执行重复
性计算。比如说以下指令序列:

DO 13 J = 1, 100
C[J] = A[J] + B[J]
13 CONTINUE

该指令将A的第一个元素和B的第一个元素相加,得到C的第一个元素,然后将A的第二个元素和B的第二个元素相加,得到C的第二个元素,依次类推。

循环的贵族发明者

巴科斯和其他20世纪50年代的从业计算机科学家们可能不知道的是,循环这个概念早在100多年前就已经诞生了。1833年,诗人拜伦勋爵的女儿奥古斯塔·爱达① 遇到了查尔斯·巴贝奇 ② ,当时他正在设计一种名为“分析机”(Analytical Engine)的机械式计算机器。爱达在8岁时就展现了极佳的数学天赋,是当时极少数能够理解巴贝奇高瞻远瞩思想的人之一。二人展开了维多利亚式的终身合作关系,这也让爱达成为世界上当之无愧的第一位计算机程序员。在为分析机设计程序的过程中,爱达根据需要提出了循环和子程序的概念。
爱达整理出了一篇描述分析机的笔记,但拒绝以自己的名字发表,因为她认为女性不应当撰写科学论文。在巴贝奇和她的丈夫洛夫莱斯伯爵(the Earl of Lovelace)的敦促下,她才同意用自己的姓名缩写A.A.L署名。爱达的一生以悲剧结束:她变成了一个赌徒、酒鬼、瘾君子,最后死于癌症,享年36岁。完全是拜伦笔下的那种不幸结局。

要想有效地编译DO语句,需要用到一种特殊的“ 变址寄存器”(index register)。Fortran最初是为了IBM 704设计的,而IBM 704上只有3个变址寄存器,因此属于非常宝贵的资源。
哈伦·海尔里克一发明 DO 语句,我们就知道会有问题——如何实现?我们只有 3个变址存储器,但却需要面对许多下标(subscript)。(上例中的 J 就是一个下标,较复杂的程序可能会包含 20 个以上的下标。)
在程序里,我们很难去判断哪些信息应该使用哪一个变址存储器。如果只用常规方法,得到的代码会很糟糕。我们明白必须去分析代码出现的频率以及其他种种因素。
巴科斯对效率的关注带动了团队的所有成员。Fortran的设计者们很清楚,如果编译得到的机器语言还不如人们手写的效率高,编译就失去了价值,这门新的高级语言就会乏人问津。出于这种原因,他们把将近一半的工作重心都直接放在了如何生成高效的代码上。此举让Fortran一直因为良好的性能而广受赞誉。

① 奥古斯塔·爱达·拜伦(Augusta Ada Byron,1815—1852)是英国诗人拜伦之女,母亲是数学家安娜·伊莎贝拉(Anne Isabella Milbanke,1792—1860)。婚后成为洛夫莱斯伯爵夫人。爱达翻译了查尔斯·巴贝奇早期的程序设计著作《分析机概论》(analytical engine),她被公认为是世界上第一位计算机程序员。1980年美国国防部开发的编程语言就以Ada为名,纪念这位先驱。
② 查尔斯·巴贝奇(Charles Babbage,1791—1871)是英国文学家、数学家、哲学家、机械工程师,计算机概念的第一奠基人。著有世界上第一部关于计算机程序的专著。他发明的分析机是现代电子计算机的雏型。

IBM 704机总共只有大概80位用户,包括通用电气公司、联合飞机制造公司、洛克希德公司等一些飞机制造行业的厂商。1957年4月,西屋电气公司成为了Fortran的第一个商业用户。巴科斯的团队为他们送过去一套存有语言编译器的穿孔卡片。
他们意识到这就是全套 Fortran,没有看任何说明就让它运行起来。当时他们在研究流体力学——计算飞机机翼结构的承压性能等,用于新型飞机的设计。在此之前,他们只能通过大型计算器或者风洞来进行研究。
Fortran的第一次应用没有遇到任何问题。但不久之后西屋公司的科学家和其他一些人发现Fortran编译器中存在着大量错误。巴科斯团队在随后的6个月内修正了这些错误。
巴科斯领导他的精英团队工作了4年,进行了各种复杂的尝试,耗费了大量的精力,但他始终对自己在其中的贡献保持极度的谦逊。
让我独自获得如此多的荣誉,可以说对其他成员很不公平。还有罗伯特·纳尔逊、哈伦·海尔里克、洛伊丝·海贝特、罗伊·纳特、艾文·齐洛尔、谢尔登·贝斯特、大卫·赛尔、理查德·戈德堡、彼得·谢里登。是他们创造了大量的成果。管理这样一个团队并不困难,大家干得都很愉快,我的主要职责就是每天下午两点钟提醒大家结束午餐时的象棋比赛,让他们继续工作。

在发布40多年后,Fortran依然是科学计算的首选语言之一。这门语言至今仍在不断改进的事实就是最好的证明。例如在1992年,一个国际标准委员会给Fortran加入了一个新特性,程序员能够命令编译器允许多台计算机(如果它们有空的话)共同完成一个 DO 循环语句。不过这些故事说来话长,已经脱离了本书的范围。
20世纪50年代后期,巴科斯停止了自己在Fortran方面的工作。然而他对编程语言的贡献才刚刚开始。
1958年5月,一个由商界、学术界的杰出计算机科学家组成的国际委员会在苏黎世召开会议,目标是改进Fortran,并设计出一种单一的、标准化的计算机语言。他们的成果是一种国际化的代数算法语言(algebraic language),后来被称为Algol。
与Fortran相比,Algol有两个显著的优点。首先,这门新的语言引入了局部变量(local variable)的概念。每一个程序都会命名很多不同的数据元素,例如在前文Fortran的那个例子中,我们命名了元素A、B和C。通常一个程序包含的数据元素很多,因此程序员可能会由于疏忽而导致重复命名,也就是将一个已经存在的名称又指派给了一个新的元素,而这会引发一些恼人的错误。名字太常见的人一定能直观地感受到这种麻烦:账单寄错了地址,信用卡因为错误的原因而被拒绝,或者深夜接到电话结果找错了人,等等。在程序设计领域,在整个程序中一直保持含义不变的名称叫做“ 全局变量”(global),而弄混对象的情况则叫做“ 名称冲突”(name collision)。
避免名称冲突的方式之一是让名称的使用环境局部化。比如说,在约克城提到“ 公爵” 一般指的是约克公爵 ① ,而在新港爵士音乐节上提到“ 公爵”,则很可能指的是艾灵顿公爵 ② 。与之相似,局部变量指的是名称只在某个有限环境下有效的计算机存储单元。在这个环境之外,同样的名称可以指派给其他的存储单元。
Fortran只允许全局命名,而Algol则可以局部命名。除了更加方便之外,局部命名也使得随后约翰·麦卡锡引入的“ 递归” 这种编程形式成为可能。
计算机的递归函数是在一定程度上按照自身来定义的。举一个日常生活中递归定义的例子,假设一个女子对自己母系祖先的定义为:我的母亲是我的母系祖先,而我母亲的所有母系祖先都是我的母系祖先。这个定义初看起来好像是循环的,不过让我们仔细地揣摩一下。
假设安妮是芭芭拉的母亲,芭芭拉是卡罗尔的母亲,卡罗尔是多娜的母亲(我们也可以继续定义尤妮斯、弗洛伦斯,等等)。根据我们的定义,芭芭拉是卡罗尔的母亲,因此她就是卡罗尔的母系祖先。而安妮也是卡罗尔的母系祖先,因为她是芭芭拉的母系祖先(芭芭拉的母亲)。我们已经知道安妮是卡罗尔的母系祖先,那么她必然也是多娜的母系祖先。
通过递归,程序员能够将一个问题拆分成许多个同样的小问题,然后将各个小问题的解答整合到一起。比如说,要想整理一大堆文件,我们可以将它们分成两半,先整理一半,再整理另一半。最后再把它们放在一起。

① 约克公爵(Duke of York)是贵族头衔,通常被授给英国国王的第二个儿子,除非该头衔由一个前任君主的儿子所拥有。
② 艾灵顿公爵(Edward“ Duke” Ellington,1899—1974)是美国著名作曲家、钢琴家,对于爵士音乐的发展影响深远。

从严格的理论意义上来说,递归和局部命名并没有让Algol变得比Fortran更强。但是它们启发了一种新的思考方式,从而在日后产生了深度优先搜索(depth-first search)等算法技巧。我们将会在后面章节中讨论这种算法。
巴科斯喜欢Algol蕴含的那些思想,但同时也意识到,要想清晰地表达它们将会非常困难。
他们只是坐在那里玩文字游戏:说明应该这样,而例子应该那样。这些 Algol 委员会(满是关于术语的徒劳辩论)让人非常烦恼,我发现得做点什么。人们需要学会如何准确地使用 Algol。
为了解决这一问题,巴科斯使用了一种叫做“ 上下文无关文法”(context-free languages)的形式体系。该体系刚刚由语言学家诺姆·乔姆斯基 ① 发明(参见下页的补充内容“ 上下文无关文法与巴科斯-诺尔范式”)。乔姆斯基的发明则来源于埃米尔·波斯特在重写语法方面的成果。
至于巴科斯到底是如何想到这种综合方法的,这个问题一定会让历史学家们忙上一阵。有一点我很困惑。我认为自己研究语法的想法来源于埃米尔·波斯特,因为我曾经在莱姆庄园(Lamb Estate,IBM设在马萨诸塞州哈德逊的智囊机构)上过马丁·戴维斯 ② 的课……所以我想如果需要描述什么东西,直接按波斯特说的那样做就行。但是马丁· 戴维斯告诉我说他很久以后才教过这门课(根据戴维斯的记录是在 1960 年和 1961 年)。

① 诺姆·乔姆斯基(Noam Chomsky,1928—)是麻省理工学院教授,为20世纪理论语言学研究作出了重要贡献。
② 马丁·戴维斯(Martin Davis,1928—)也是计算机科学发展史上的先驱人物,纽约大学名誉教授。

所以我不知道应该怎样解释。对于乔姆斯基我一无所知。我是一个孤陋寡闻的人 ① 。
巴科斯的发明最终成为著名的巴科斯-诺尔范式(Backus-Naur Form,缩写为BNF,其中经历了一连串的偶然)。第一个偶然发生在1959年6月,当时联合国教科文组织将在巴黎召开一次关于Algol的会议,而巴科斯准备了一份有关精确语法的报告,准备在会上发言。
这份报告我完成得太晚了,结果未能被收录进大会的官方报告集。于是我只好自己带了一堆报告到会议上,因此分发的效果并不太理想。但是彼得·诺尔(Peter Naur)读到了它,所以一切都不一样了。

诺尔是一位来自丹麦的数学家。他改进了巴科斯的符号表示法,并用于描述整个Algol语言。当编程语言界开始试用Algol时,诺尔的参考手册被公认为是描述该语言语法的最好参考资料。

上下文无关文法与巴科斯-诺尔范式

为了方便理解巴科斯诺尔范式,我们可以参照以下一些英语短语的描述。其中“名词短语”和“动词短语”这两个术语借用自现代语言学。

句子 →名词短语 动词短语
名词短语 → 冠词 形容词 名词 | 冠词 名词
动词短语 → 动词 名词短语
形容词 → 红色的 | 蓝色的 | 黄色的 | 大的 | 小的 | 聪明的
名词 → 房子 | 女孩 | 男孩
动词 → 喜欢 | 打
限定词 → the | a

竖线符号“|”表示可选。比如说,限定词可以是“the” 也可以是“a”。这种语法告诉我们,“the girl likes the boy”(女孩喜欢男孩)是一个正确的句子,因为“the girl”构成了一个名词短语,而“likes the boy”是一个动词短语。而“a smart house likes the boy”(聪明的房子喜欢男孩)这句话尽管在语义上不合常理,在语法上仍然是正确的。
在编程语言中,巴科斯-诺尔范式也具有这种特性。只要遵从其规则,我们就能得到语法上正确的程序,编译器就能成功地将它翻译成机器语言,并且保留其在高级语言程序中的原有含义。当然,原始的程序仍然可能没有意义。编译器只保证翻译的正确性,与原程序是否正确无关。

从自己的发明中解放程序设计

巴科斯发明了世界上最早和最流行的编程语言之一,并且发展了一种可以描述上千种语言的符号系统。很多人,甚至很多杰出的科学家都可能会满足于这些成就而止步不前。但巴科斯没有。
他甚至并不确定自己是否喜欢这些成果。
当你写完一个 Fortran 程序后,其实并不知道程序到底会怎样运行。你只知道它读取了两个数,然后将它们相乘,然后把值储存起来,然后怎样怎样,最后进行判断,如此等等。但你很难弄清楚它实际上都计算了些什么。你也很难用其他方法进行计算,因为你基本上并不理解程序在做什么事情。
巴科斯的目标是让程序员只需表达出他们“要什么”,而无需知道“ 怎样做”。1977年他荣获图灵奖,并发表了名为“程序设计能否从冯·诺依曼形式中解放出来” 的演讲,向整个计算机科学界提出了自己的观念。

在这里提到冯·诺依曼与他早年间曾反对Fortran的开发并无关系。巴科斯所针对的是冯·诺依曼对计算机的特征阐述,即处理器与存储器相连接,而程序和数据都储存在存储器中。在巴科斯看来,这种特征意味着这样一个基本循环过程:从存储器中调取数据,对其执行一些操作,然后将结果返回存储器。而他认为凡是遵循这种范式的编程语言都必然缺乏透明度。以约翰·麦卡锡的Lisp(主要用于人工智能)和肯尼斯·艾佛森 ① 的APL语言为基础,巴科斯提出了一种叫做FP的语言,其主要目的是用数学函数来构建程序。
“ 一般” 语言(冯·诺依曼式的语言)和类似FP的“ 函数式” 语言的主要差别在于,前者实际上是在直接修改计算机内存,而后者则主要依赖于“ 复合函数”(function composition)。巴科斯在他的图灵奖获奖演说中介绍了FP,并使用了求两个数组的内积作为例子。这是在物理中常用的一种运算。标准的冯·诺依曼式语言大体上会以如下形式来写这个运算:

c := 0
for i := 1 step 1 until n do
c := c + a[i] * b[i]

巴科斯从几个方面批评了这一公式,尤其是以下两点。第一,c不断被修改,因此理解该程序的唯一办法就是理解这种修改其实是把一个新的乘积(product)加在一个运行中的总和(total)上。因此若想理解程序,人们必须能在头脑中执行一遍代码。第二,在公式里定义了动态数组(a和b)及其长度(n)。要想适用于一般的动态数组,这些都需要作为“ 参数” 进行传递——在大多数语言中,这都是一个比较棘手的过程。而在FP中,内积运算可以定义为如下形式:

① 肯尼斯·艾佛森(Kenneth Iverson,1920—2004)1962年开发了APL(A Programming Language),这是一种强大、表达丰富而且简明的编程语言。1979年他因对数学表达式和编程语言理论的贡献而获得图灵奖。

Def Innerproduct = (Insert +)(ApplyToAll *)(Transpose)

这个公式要从右往左理解。“ Transpose” 是将两个动态数组的对应元素配对。在上例中,a[1]应和b[1]配对,a[2]应和b[2]配对,依次类推。“(ApplyToAll *)” 是用每一对的乘积来替换它的值,而“ (Insert +)” 则将这些乘积累加。

巴科斯认为这样做有三个主要的优点。其一,没有隐藏的状态(例如上面程序中的变量c);
其二,它对任意两个同样长度的动态数组都有效,因为它没有定义参数(也就避开了参数传递的问题);其三,这当中没有涉及重复运算,因为在这个叫做“ 复合” 的过程中,每一步操作,或者说每一个“函数”(Transpose、ApplyToAll和Insert)都只针对前一步的结果应用了一次。

具有讽刺意味的是,函数式语言,包括FP,并没有流行起来,而其原因正是Fortran流行的原因:函数式语言的程序很难编译成高效的形式。随着处理器和存储器运转速度的提高,权威人士预测人们对程序效率的关注可能会大大降低。不过这些预测至今尚未实现。
除此之外,FP语言也不适用于许多日常的编程任务,尤其是那些需要经常读取数据、修改数据然后将其返回数据库的任务,例如更新账目明细,等等。像这样的任务天生就适于使用冯·诺依曼范式。
设计一种函数语言然后将其与实际工作结合起来是很难的。每一个试图这样做的人都会遇到各种各样的问题。
就算巴科斯没有解决这个问题,他也漂亮地提出了这个问题。其他计算机科学家将接过他手中的火炬继续前进。1991年退休之后,巴科斯功成身退,离开了计算机科学界乃至整个科学界。

他每天都练习冥想,并阅读克里希那穆提 ① 和伊娃·皮拉卡斯 ② 有关人类内省方面的著作。
大多数科学家之所以成为科学家,是因为他们畏惧生活。在科学中有所成就无疑非常诱人,因为在此过程中无需与人产生冲突,无需应付艰难的人际关系,可以完全按自己的方式生活。在这个近乎纯净的象牙塔里,你可以全力施展自己的才华,而没有任何痛苦。与生活中的困难相比,解决科学问题的困难简直就是微不足道的。
而自我反省则不是一种科学活动:它不可重复,也没有好的理论用以指导你如何去做或者追求什么。通过对自己的反省,你可以真正理解宇宙的奥秘,这是非常奇妙的一件事情。而这是通过任何物理定律都做不到的。

① 克里希那穆提(Jiddu Krishnamurti,1895—1986)被公认为20世纪最伟大的灵性导师。他一生走访全球70多个国家演讲,演讲被辑录成80多本书,并被译为50多种语言,被印度及当代佛家学者认为是龙树菩萨再世。
② 伊娃·皮拉卡斯(Eva Pierrakos)是小说家雅各布·韦士曼之女。她创建了自我转化体系Pathwork,目的是达到人性的纯净化。

2、约翰·麦卡锡不走寻常路的常识逻辑学家

如果希望计算机具有一般的智能,那么其外在结构就必须基于一般的常识和推理。

--约翰·麦卡锡

一个5岁的小女孩在玩一辆塑料玩具卡车,把它推来推去,嘴里模仿着喇叭声。她知道不能在餐桌上玩它,也不能用它去打弟弟的头。去学校之前,她会把卡车放到弟弟够不着的地方。放学回家后,她也知道在原来的地方可以找到自己的玩具车。

引导她的行为和期望的推理非常简单,任何一个同龄的小孩都能理解。但是大多数计算机却不能。计算机的问题一部分在于它缺少一般5岁小孩能从父母那里学到的日常社会知识,例如不能损坏家具,不能伤到自己的弟弟;另一部分在于计算机没有我们的日常推理能力。人类使用的是一种基于经验的常识推测体系,它与常规逻辑不同,因此也与一般计算机程序员的思维不同。常规逻辑使用的是一种被称为"演绎"(deduction)的推理形式。演绎让我们能从"所有失业演员都当了服务生"和"托米是个失业演员"这两个陈述中推断出"托米是个服务生"这一新的陈述。其优点在于它的可靠性--如果前提成立,那么结论一定成立。同时演绎推理也是 "单调的"(monotonic,数学术语,其基本含义为"不变的")。如果你发现了新的事实,但并不与前提相矛盾,那么结论仍然成立。

然而,尽管我们大多数人都在学校学过演绎推理,却很少在实际生活中用到。5岁的小女孩相信自己的卡车还在原来的位置,是因为她把它放在了弟弟够不着的地方。但如果某天她在出门时看到弟弟学会了爬凳子,可能就不会这么有把握了。5岁小孩掌握的常识推理主要依靠基于经验的推测,而这可能会由于新事实的出现而不得不作出非单调的修改。而且并不是只有5岁小孩会如此。

就算是公认的演绎推理大师歇洛克·福尔摩斯,也并不经常用到演绎推理。在关于一匹受伤赛马的冒险故事《银色马》中,福尔摩斯运用其天赋的洞察力得出结论,看门狗没有叫是因为它认识罪犯。我们的侦探确实才智超群,而且推论看来合情合理,最后在故事中也证明是正确的,但他用的却不是演绎推理--狗可能是被麻醉了、戴了口套,或者当时正在野地里追兔子。

程序员知道如何让计算机进行演绎推理,因为计算机能够理解其中涉及的数学。但如果想让计算机进行人类赖以生存的这种推测性的(而又常常是正确的)常识推理,就得发明一种全新的数理逻辑。而这正是约翰·麦卡锡为自己设立的目标之一。

麦卡锡的成名还有其他原因。他发明了人工智能领域的首要语言Lisp(list processing,表处理),而且自其诞生之日起,就为编程语言设计提供了丰饶的思想源泉。同时,作为一名教师和难题设计师,他在密码学和平面性检验等亚学科领域激发了众多计算机科学家的灵感。我们将在拉宾 和陶尔扬 的章节中再行描述。

约翰·麦卡锡1927年出生于波士顿一个共产党积极分子家庭,童年在四处奔波中度过。他的父亲是一名爱尔兰天主教徒,先后做过木匠、渔民和工会组织者,全家一直马不停蹄地奔波,从波士顿搬到纽约,然后又搬到洛杉矶。他的母亲是立陶宛犹太人,最初在联邦通讯社当新闻记者,后来就职于一家共产主义报刊,最后成为了一名社会工作者。麦卡锡早年对科学的兴趣与家庭的政治信仰密不可分。

当时普遍相信技术对人类必将有利无害。我记得还是小孩的时候,曾读过一本书叫做《十万个为什么》,是前苏联作家米·伊林 在20世纪30年代早期写的一套通俗科普

书。在美国好像没有这样的书。有趣的是,十几年前我在报上看到过报道一个特别早熟的中国孩子,而他也读过《十万个为什么》。

麦卡锡认为自己的青少年时期平淡无奇,但事实证明并非如此。在上高三时,他得到了一份加州理工学院的课程目录,上面列出了该校一年级和二年级的微积分课本。他买了这些书,完成了所有的练习题目。这使得他最终在1944年进入加州理工后得以免修头两年的数学课程。

1948年,麦卡锡开始攻读数学系的硕士学位。同年9月他参加了加州理工主办的希克森脑行为机制研讨会,大数学家、计算机设计大师约翰·冯·诺依曼在会上演讲了一篇关于自复制自动机(self-replicating automata)的论文,这是一种可以对自身进行复制的机器。尽管当时的与会人员并没有明确地将机器智能与人类智能联系起来,但冯·诺依曼的讲话却激发了麦卡锡的好奇心。

1949年在普林斯顿大学数学系作博士论文时,麦卡锡首次开始尝试在机器上模拟人类智能。

我把有智能的东西看做是一个有限自动机,与同样是有限自动机的环境相连。我和约翰·冯·诺依曼见了面,他对此非常赞成,敦促我一定要把这篇论文写出来。但最后我并没有写出来,因为我认为它还不够成熟。

"自动机"模拟的是随着时间从一个状态转入另一个状态的机器。比如说,普通的手动变速箱汽车在驾驶员点火启动之后会从"熄火"状态转入"空挡但启动"状态。如果驾驶员挂挡前进则转入"启动且挂一挡"状态。而"交互式自动机"(interacting automaton)则是根据其自身的状态以及它所观察到的其他自动机的状态决定从某个状态转入另一状态。有些自动机是智能的(可看做是自带驾驶员),但并不是必须智能。交互式自动机试图在这两种类型之间建立一种连续性的统一体。

麦卡锡放弃了自己对利用自动机模拟人类智能的首次尝试。但在十几年之后,当他从事情境演算(situational calculus)方面的工作时,关于状态和状态转换的思想将重新浮出水面。

在这段时间中,麦卡锡始终没有放弃制造一台像人类那样智能的机器这一想法。1952年夏,普林斯顿大学的一个研究生杰里·雷纳(Jerry Rayna)向麦卡锡建议,可以找一些对机器智能感兴趣的人去收集一些该领域的文章。麦卡锡找的第一批人就有克劳德·香农 ,"信息论"亦即通信数学理论的发明者。香农的理论最初用于远程通信,后被广泛用于语言学、数学以及计算机科学等领域。

香农不喜欢华而不实的术语堆砌。他整理的卷宗为《自动机研究》(Automata Studies)。而其中收集到的文章让我很失望,里面有关智能的内容并不多。

所以在1955年开始筹备达特茅斯计划时,我希望开门见山,使用了"人工智能"这一术语,目的是让参与者们弄清楚我们是在干什么。

1956年在达特茅斯学院举办的夏季人工智能研讨会是计算机科学史上的一座里程碑。这项涉及10人、耗时2个月的雄心勃勃的研究计划,其目标是"基于'我们能够精确、全面地描述人类智能中的学习等特征,并制造出机器模拟之'这一构想,继续阔步前进"(引自其提案)。

研讨会的四位组织者--麦卡锡、马文·明斯基 (当时还在哈佛大学)、纳撒尼尔·罗切斯特 (IBM的杰出计算机设计师)和香农--向洛克菲勒基金会申请了一笔资金支持,金额在今天看来几乎少得可怜:主要组织者每人1200美元,再加上"外地与会人员的火车票",总共7500美元。

麦卡锡在提案中写到,他将研究语言和智能二者间的关系,希望通过程序使计算机能"进行棋类游戏并完成其他任务"。时隔40年后回忆起这次研讨会时,麦卡锡以他特有的直率形容了自己当时的愿景和期望。

我为这次会议设定的目标完全不切实际,以为经过一个夏天的讨论就能搞定整个项目。我之前从未参与过这种模式的会议,只是略有耳闻。实际上,它和那种以研究国防为名义的军事夏令营没什么区别。

创造一台真正智能的机器是一个极为困难的过程。尽管这次会议在实质上并未解决任何具体问题,但它确立了一些目标和技术方法,使人工智能获得了计算机科学界的承认,成为一个独立的而且最终充满着活力的新兴科研领域。虽然大多数与会者在会后并未继续从事该领域的研究,但另外那少数人中却产生了一批在该领域影响深远的成就。

来自卡内基梅隆大学的艾伦·纽厄尔、赫伯特·西蒙和J. C.肖(J. C. Shaw)描述了他们的第二代信息处理语言(Information Processing Language,IPL 2)。这三位科学家致力于构建一种名为"逻辑理论机"的程序,用于验证基本逻辑和博弈论中的定理。而为了做到这一点,他们设计出IPL 2这种程序语言以便于操作对象符号,例如象棋的棋子或者逻辑变量里的真值。由于这种操作与针对数字的算术运算非常不同,他们提议使用一种所谓的"表结构"。

让我们冒昧地借用《爱丽丝梦游仙境》来说明如何利用表来进行符号处理。假设柴郡猫告诉爱丽丝"不是我疯了,就是帽匠疯了"。我们用C、H、A代表三种观点,分别是柴郡猫疯了、帽匠疯了和爱丽丝疯了。猫之前的那句声明可以用表的形式表示为(or C H)。然后猫又告诉爱丽丝"不是你疯了,就是帽匠疯了"。聪明的爱丽丝会把这个声明和之前的那个一起表示为(and (or C H) (or A H))。最后猫又说"我们三个中只有一个疯了"。也就是说,至少有两个没有疯。爱丽丝可以将其表示为(and (or C H) (or A H) (or (and (not A) (not C)) (and (not A) (not H)) (and (not C) (not H))))。

把这些声明表示成表的形式之后,我们就可以定义一些表操作的规则,例如(and (or X Y) (or Z Y)) = (or (and X Z) Y)。也就是说,如果不是X成立就是Y成立,而且不是Z成立就是Y成立,那么不是X和Z都成立,就是Y成立。应用这样一些规则,我们就能得出结论(and H (not C) (not A))。那么,根据柴郡猫的说法,只有帽匠疯了。

通过表来进行逻辑推理的优点在于,在推理的过程中表可以扩展、收缩和重组。此外,我们可以用同一种形式表示规则和数据。在研讨会大多数与会者们看来,表操作无疑是这次的赢家。达特茅斯会议的另一项成就是马文·明斯基关于构建几何定理证明机的提案。明斯基在纸上试验了几个例子,认为证明几何定理可能是对纽厄尔和西蒙提出的基于规则的方法的一种很好的应用。IBM公司的赫伯特·格林特(Herbert Gelernter)和纳撒尼尔·罗切斯特决定去实现这个程序。格林特日后又开发了一种帮助有机化学家合成新化合物的工具,他的儿子大卫·格林特(David Gelernter)是并行程序设计和医学人工智能领域著名的研究者及设计者。麦卡锡身为这个定理证明项目的顾问,有机会为智能行为编写程序。

格林特和他的助手卡尔·格贝里希(Carl Gerberich)采纳了我的建议,以Fortran为蓝本设计了FLPL--Fortran表处理语言(Fortran List Processing Language)。其中也加入了一些他们自己的想法。

1956年,约翰·巴科斯和他在IBM的团队发布了首个高级编程语言Fortran,将从事数字运算的程序员从为每一台计算机写汇编语言中解放出来。直到今天,Fortran仍然是科学和工程计算中的通用语言。FLPL首次尝试了扩展Fortran的符号操作能力。1958年夏天在IBM工作时,麦卡锡试图用FLPL为自己在高中时常用的代数微分应用写一个表程序,但很快发现需要用到递归条件表达式 ,而Fortran却不支持递归。

如果Fortran支持递归,我就能用FLPL做下去。我甚至也考虑了如何往Fortran中加入递归的问题,但是那样做过于复杂。

事实证明,IBM很快就失去了对人工智能的兴趣。一些客户认为智能机器可能会威胁到他们的工作岗位,因此20世纪60年代初期的IBM市场营销都把计算机说成是非智能的快速运算设备,百依百顺、只按要求行事。

麦卡锡不再纠缠于修补Fortran,而是转头发明了Lisp。纽厄尔、肖和西蒙后来把IPL形容为一种越变越复杂的语言,而麦卡锡则把他的Lisp形容为一种越变越简单的语言。

"Lisp"是"list processing language"(表处理语言)的缩写。确如其名,Lisp中所有的数据都用表来表示。这些表都被包含在圆括号中。比如说,(Robert taught Dennis)可能就是表示"罗伯特教丹尼斯"这个句子的一个表。在这种情况下,顺序是很重要的,因为它指明了是谁在教谁。

而(apple tomato milk)则可能表示一个购物清单。在这种情况下,顺序就不重要了,因为这三种商品可以按任意顺序购买。上述这两个例子中,表都包含了"原子"(atom)作为元素。原子和表不同,是Lisp中最小的符号单位,不包含其他任何组成部分。而表还能够包含(而且一般都会包含)其他表作为组成部分。比如说,(Robert taught(Carol and Dennis))反映了句子的语法结构,其中的圆括号指明卡罗尔和丹尼斯都是动词"教"的对象。再比如,(times 6(plus x y))表示6 ×(x + y)。这里的顺序也很重要,而且圆括号表明x和y是一起的。

通过这种方式,表不仅能够表示构成科学和工程的标准数学结构,还能表示构成语言的语句结构。

从一开始,麦卡锡就拥有一个热情高涨的合作者团队。

当我在1958年秋天回到麻省理工的时候,我和明斯基有了一个大工作室、一台键控打孔机,此外还配备了一名秘书、两个程序员和六个数学专业的研究生。我们是在春天时向杰里·威斯纳(Jerry Wiesner)申请的这些,理由是为我们的人工智能项目做准备。

我们连书面提案都没准备,申请就得到了批准。很幸运,当时麻省理工的电子研究实验室刚刚与美国军方签署了一份无固定目标的双向合作协议,而相应的资源还没有到位。我想这种灵活的资源调配正是美国的人工智能研究起步领先于其他国家的原因之一。纽厄尔-西蒙的研究之所以能进行,也是由于美国空军在当时向兰德公司提供了弹性的支持。

随着工作的深入,麦卡锡希望改进这种语言的表达能力。1959年,为了展示Lisp可以明确地表达任何可计算函数,他加入了一个叫做"求值"(eval)的功能。

"求值"允许程序定义新的函数或者过程(procedure),然后将其作为程序的一部分执行。而大多数语言在执行新函数之前都会强制程序中止运行,并且"重新编译"。由于求值函数可以带动并执行任何函数,它扮演了一种"通用图灵机" 的角色,是其他计算机的通用模拟器。

求值概念具有非常实际的意义。比如说,由于国际金融市场时刻都在变化,股票交易所必须每周7天、每天24小时不停地提供计算服务。如果有人写了一个程序,可以用新的方法分析路透社的股票数据,股票经纪人很可能希望马上就使用它,但又绝不想中断自己机器的使用。求值让这一切成为可能。

Lisp

Lisp的基本操作

典型的Lisp函数和过程要么是把一个表拆开,要么是把几个表合并成一个新表。比如说,"追加"(append)函数可以将两个表首尾相接,从而生成一个新表。如果我们打算用名词和动词短语造句,这可能会很有用,例如现在有两个人物鲍勃和爱丽丝,那么(append (Bob kissed) (Alice))将生成(Bob kissed Alice)这个新表。

另一个有用的函数"倒置"(reverse)可以颠倒表中的元素顺序。例如(reverse (append (Bob kissed) (Alice)))将生成(reverse (Bob kissed Alice)),之后再生成(Alice kissed Bob)。而如果把两个函数互换一下,例如(append (reverse (Bob kissed)) (Alice)),则会生成(append (kissed Bob) (Alice)),之后再生成(kissed Bob Alice)。

不论鲍勃和爱丽丝之间的关系如何,我们能够看出Lisp程序可以由函数构成,而这些函数可以处理并产生新的表。这正是吸引巴科斯发明FP的东西。

Lisp中的递归和求值

麦卡锡把递归作为Lisp处理策略中的核心部分。递归是用操作本身对其进行定义的一种方法,因此程序员可以把问题变简单后再进行定义,从而绕过了循环定义的禁忌。

比如说,如果某个表只包含一个元素,我们可以定义这个表倒置后仍然为它本身。如果这个表包含多个元素,我们可以如下定义它的倒置:倒置除第一个元素以外的所有元素,再在其后追加上第一个元素。这句话看起来有点拗口,我们可以这样说明:把表L中的第一个元素称作是L的头部,表示为(head L),而余下的元素称作L的尾部,表示为(tail L)。根据Lisp的理念,我们就可以写出这样一段程序(语法并不精确):

define (reverse L) as
if L has one element then L
else (append (reverse (tail L)) (list (head L)))

我们可以套用一个具体的例子,而这次变成了三角关系:鲍勃、爱丽丝再加上卡罗尔。那么(reverse (Alice Bob Carol)) = (append ((reverse (Bob Carol)) (Alice))) = (append ((Carol Bob) (Alice))) = (Carol Bob Alice)。

由于函数和过程本身是定义为表的,因此我们也可以用其他函数和过程来构造它们。这样求值函数就可以带动并执行这种函数或过程了。

Lisp中蕴含的思想吸引了负责设计Algol语言(巴科斯和诺尔为其发明了巴科斯 诺尔范式)的国际委员会。1960年,在该委员会于巴黎召开的会议上,麦卡锡正式提出了递归和条件表达式这两个概念。在标记方法上,委员会有了些争论,但最终仍然接受了他的思想。

Algol是第一个采用Lisp创新的语言,但绝对不是最后一个。Algol的后继语言如Pascal、C、Ada以及其他大多数现代编程语言都支持递归和条件表达式。但直到最近,主流语言都不支持求值,主要原因在于语言设计者们担心程序员往运行中的程序里添加新功能可能会很危险。不过如今的很多程序都必须每周7天、每天24小时地连续运行,人们对求值这种特性的需求越来越迫切,因此大多数实验性语言都包含了求值或类似求值的功能。

近50年来,Lisp一直是人工智能领域的标准语言。麦卡锡并未预料到它会有如此长的寿命,甚至曾建议将其修改成类似Algol那样。然而该领域的编程人员仍然喜欢Lisp最初的语法。麦卡锡和在他之前的巴科斯、之后的艾伦·C. 凯一样,最终已无法控制自己发明的语言的发展方向。

让常识合乎逻辑

幸运的是,麦卡锡一直都只是把Lisp看做一种手段,目的是达到他的主要目标(至今也依然如此):制造一台像人那样有智慧的机器。

在他于1959年发表的论文《具有常识的程序》中,麦卡锡详细地阐述了这一目标。以这篇论文为起点,他开始了穷其一生的不懈求索,力求将数学的精确应用到所谓"常识"这一难以捉摸的推理形式之中。

为了具体说明这一目标,他将具有常识的程序定义为"对任意给定的事物(自己已知的或别人告知的),能够独立演绎推理出它将产生的一系列直接结果"。作为例子,他描述了一个人从书桌边离开并驱车前往机场这一事件涉及的推理过程 。

在论文介绍后的讨论环节中,著名的逻辑学家和语言学家耶霍舒亚·巴尔 希勒尔 将麦卡锡的方法形容为"半生不熟的"和"伪哲学的"。他认为麦卡锡所提倡的推理不能被称为是"演绎推理",因为其结论并不总是成立。希勒尔指出:"叫辆出租车去机场不是更便宜吗?难道不能退订这趟航班,或者做点别的事情?"

麦卡锡回应了批评,他同意自己的论文基于"不明确的哲学假定之上。……每当我们设计程序让计算机学习经验时,就是在把某种认识论添加到程序中。"从那时起,解决认识论问题成了麦卡锡和其他少数人工智能理论家研究的重心所在。

在读者了解这些研究之前,也许想知道最开始为什么会有人想把常识纳入到计算机程序中来。这对科学或社会能带来什么好处?麦卡锡给出了答案。

所有的科学以及专业理论中都引入了常识。当你想要改进理论时,你总要回到常识推理,因为是常识推理主导着你的试验。

所以,如果有人想设计出更好的象棋程序,就需要对自己的成果进行试验,让它分析各种棋局。有关进行何种试验的所有推理都立足于常识框架之内。

我们从很多科学家的著作中都能找到类似的观点。例如理查德·费曼 在他著名的物理学讲义中讨论对称时,就提出了如下观点:

我们所有的物理思想,在应用中都需要一定的常识。它们并不只是纯粹的数学的或抽象的思想。当我们说把仪器移动到新的位置,得到的现象相同时,必须正确理解这句话的意思。它的意思是我们移动了所有我们认为相关的东西。如果得到的现象不同,可能是因为有些相关的东西被遗漏了而没有移动,我们就要把它们找出来。

常识推理的主要优点之一在于其应变性。当环境中出现了新情况时,它能够很好地适应。麦卡锡给出了下面的例子。

假设一位旅客要从格拉斯哥经伦敦飞往莫斯科。再假定他知道自己得购买机票,也知道整个旅程的目的地顺序。

很多程序都能够进行如下推理:如果他从格拉斯哥飞到伦敦,再从伦敦飞到莫斯科,那么他就会在莫斯科。但如果他在伦敦把机票丢了怎么办?原计划将不再有效,但如果原计划中包含了再买一张机票的情况,就不会有问题。然而现有的实用程序都不能像这样细致地设置环境条件。

1964年,麦卡锡已是斯坦福大学的人工智能实验室主任。他提出了一种名为"情境演算"的逻辑理论,其中"情境"代表着世界的一个状态。当主体(agent)行动时,情境就会相应发生变化。主体下一步如何行动取决于他对情境的了解。

在麦卡锡的旅行例子中,没有意识到自己丢了机票的旅客会直接前往机场,而意识到自己丢了机票的旅客则可能会去旅行社,找(假设有智能的)代理人进行交涉。

情境演算和有限自动机(finite automata)理论中都有状态转换的概念。但情境演算中的推理不仅取决于情境,同时还取决于主体对情境的了解。主体知道或能够知道得越多,作出的决策就会越正确--而这正是他所希望的。情境演算理论吸引了众多研究人员,他们用各种方式应用这一理论或其变体,但它本身也引起了新的大问题。

在主体众多且互相联系的世界中,与一个主体相关的情境可能会随着其他主体的行为发生改变。当然,在我们的常识世界中,我们知道其他主体的大多数行为在实质上并不会影响我们自己的决策。

比如说,丢三落四的旅客(主体A)的行为不会因为美国总统(主体B)在华盛顿特区晨跑时买了一个小松饼而发生改变。逻辑不会告诉我们这两个行为无关,但是直觉会让我们得出这样的结论(除非你相信那些子虚乌有的阴谋论)。

在与爱丁堡大学的帕特里克·海耶斯 合著的一本书中,麦卡锡用"框架问题"(frame problem)来表示如何简洁地找出不受某特定行为影响的众多事实这一普遍问题。

简而言之,当特定的行为发生时,框架问题不必具体列出所有未变化的事物。

大多数成功的破案都依赖于对框架问题的洞察。英勇的侦探在找到破案线索时,实际上是辨别出了多数人可能会摒弃或忽视的行为和主体之间的联系。例如在《银色马》中,福尔摩斯从看门狗没有叫这一行为确认了狗的主人就是罪犯。而其他人,包括他可敬的伙伴华生医生在内都没有想到这一点。

福尔摩斯在他的调查开始之前作出了如下评论:"对这件案子,思维推理的艺术,应当用来仔细查明事实细节,而不是用来寻找新的证据。这件惨案极不平凡,如此费解,并且与那么多人有切身利害关系,使我们颇费推测、猜想和假设。困难在于,需要把那些确凿的事实--无可争辩的事实--与那些理论家、记者的虚构粉饰之词区别开来。"

计算机所处的环境随时都可能出现新的、意外的事实。要想解决框架问题,它必须像歇洛克·福尔摩斯那样运用一套新的推理方法。1974年马文·明斯基出版了《表征知识的框架》(A Framework for Representing Knowledge,麻省理工学院出版社)一书,主张人工智能不应该使用逻辑,因为逻辑与生俱来就是保守的。

为了理解他的论点,我们假设你听说朋友有一只叫做班卓的鸟。你想象中的情境可能包括班卓会飞。但如果现在你的朋友告诉你,班卓是只企鹅,或者被剪过翅膀,或者腿被绑起来了,或者惧怕飞行,你就不会再认为班卓会飞了。虽然没有违背班卓是只鸟的事实,但新的信息却让你改变了最初的结论。

明斯基把这种推理形式称为是"非单调的"(nonmonotonic)。经典的演绎推理都是单调的--只要和旧的信息不矛盾,新的信息就不会改变原来的结论。日常的推理中,充满了非单调性。我们相信总统先生在华盛顿特区大吃小松饼不会影响到伦敦希斯罗机场的旅客,但如果我们发现当地著名的萨克斯音乐会就以他吃小松饼作为开场信号,我们就会改变主意了。

麦卡锡把非单调性看作是解决框架问题的一种途径。有智慧的主体在进行推理时,从直觉上会先假定遥远的行为不会产生影响;而一旦发现这些行为事实上可能有关,则需要重新推理。与明斯基不同,麦卡锡认为逻辑是解决这个问题唯一可靠的基础。为了做到这一点,逻辑需要一种用于推测的形式化基础。

麦卡锡提出了"限定推理"(circumscription)。这一规则允许主体进行如下推测:"我已经知道了全部可能具有性质P的对象。因此如果遇到新的对象,我会假设它不具有性质P。"

例如对于班卓来说,你假定它会飞。也就是说你针对鸟类限定了"不会飞"的性质。而对于一只叫做强波的大象来说,你限定了"会飞"这一性质,即在没有相反证据的情况下,假定这只大象不会飞--因为它不具备会飞的性质 。因此,限定推理便于我们对事物进行基于经验的猜测。在了解到更多事实之后可能会证明它是错误的,但同时它也使我们能够作出一些有用的假设。

在一个给定的情境中,要想知道应该限定哪些性质,必须先了解世界上的事实。在班卓和强波的例子中,我们必须知道普通的鸟都会飞,而普通的大象都不会。人类几乎不假思索就能运用这些知识,但对计算机而言却是重大的挑战。

比如说,百科全书告诉你拿破仑死于1821年,而威灵顿公爵 死于1852年。拿破仑死时,是英国政府关押在圣赫勒拿岛的囚徒。

但是百科全书本身很可能不会告诉你威灵顿公爵是否听说了拿破仑的死讯。你必须用常识去分析百科的内容才能知道。

你也许会进行如下推理:威灵顿公爵很可能听说了拿破仑的死讯,因为他的戎马一生中有大半都为拿破仑所困扰。因此你可以限定"未听说自己关注的人的死讯"作为性质P。而更简单的问题,拿破仑是否听说了威灵顿公爵的死讯?你可以限定"听说比自己晚死的人的死讯"作为性质P。

最根本的问题在于,如何利用情境的上下文背景来作出自然的推测,例如将军普遍都会关注他们的对手。麦卡锡开始开发一种新的逻辑形式,使其能够处理和利用上下文背景。

其中的部分目标就是要理解人们在说什么。如果AI系统无法利用上下文背景,它就不能理解人们在对话中叙述的内容。

这个问题的难度极大。不仅是机器难以正确地应用上下文背景,就算是人类自身有时也会有困难。已故的前白宫发言人托马斯·奥尼尔 曾讲过一个欢迎新任总统罗纳德·里根 就职的故

事。奥尼尔告诉里根,他有格罗弗·克利夫兰 用过的书桌。里根笑着说他曾经在电影里扮演过克利夫兰。里根说的是棒球运动员格罗弗·克利夫兰·亚历山大 ,而奥尼尔说的则是美国的两任总统。

如此多的问题,如此少的答案

开启了常识推理的潘多拉魔盒 ,促使麦卡锡开始研究一种能允许任意加入新的事实的形式体系。他将其称为"容变能力"(elaboration tolerance)。一旦允许变化,新的事实可能会使推理者在保留部分结论的同时改变之前作出的一些结论。要想得到所有正确和不正确的结论,可能需要用到基于某些预判的可能性得到的限定推测,例如鸟会飞,而大象不会。至于如何判断哪些推测可能成立,这需要知识,以及把知识系统化、整理成上下文背景或微理论(micro theory)的方法。推理者采取行动,知识也会相应增长,因而又会创造出新的事实。这些能力环环相扣、相互依赖。

在1959年发表的论文中,麦卡锡提出其目标是构建一台机器,使其能够"执行一些简单的、任何非低能的人都能进行的文字推理过程"。从字面上看,这一任务似乎并不算难事,但目标依然非常遥远。

读到这里也许有读者会问,麦卡锡试图为人工智能提供逻辑基础,他的众多尝试到底算是成功了,还是失败了?在他自己看来,自情境演算之后他的工作鲜有付诸实践的。医疗诊断或者预测股票价格等专业系统的开发者们,无不想方设法地避免在自己的系统中进行非单调推理或者一般上下文背景推理。麦卡锡和他的同事们揭示出来的困难,如今成了"不得靠近"的警示牌。

但在未来可能会完全不同。如果项目的覆盖面广阔,而又深不见底(就像道格拉斯·莱纳特的Cyc ,试图对亿万事实和规则进行编码),就必将面临许多难以解决的困难。任何这类使用逻辑的项目都必须建立在麦卡锡的工作成果之上(我们在莱纳特一章中将会提到,他的项目运用了R. V. 古哈 提出的"微理论",而古哈则师从麦卡锡)。现有的逻辑工具能否够用?麦卡锡承认他并不知道。

利用逻辑表达世界中的事实的进展一直都很缓慢。亚里士多德没有发明形式体系。莱布尼茨 没有发明命题演算,尽管这种形式体系比他和牛顿同时发明的微积分更加简单。乔治·布尔 发明了命题演算,却没有发明谓词演算。戈特洛布·弗雷格 发明了谓词演算,但从未尝试过将非单调推理形式化。我想我们人类明白,要明确地表征我们思维过程中的各种事实,表面来看似乎简单,实际上是很困难的。

3、艾伦·C·凯清晰的浪漫主义梦想

所有对事物的认识都始自于我们不愿盲目地接受这个世界。

--艾伦·C.凯

长久以来,科学家们一直梦想着机器能够改善人们生活的质量。早在17世纪,哲学家和数学家戈特弗里德·威廉·冯·莱布尼茨就曾设想过一种机器,能通过逻辑推理来平息所有的争论。三个世纪之后的1945年,计算机的雏型、微分分析仪(differential analyzer)的发明人范内瓦·布什 在《大西洋月刊》杂志上发表了一篇名为《诚如所思》 的文章。在这篇文章中,布什预测将来会出现一种叫做"Memex"的设备 ,能够让普通人在资料库中以链接的形式查询、浏览各种微缩胶片,从多方面了解自己感兴趣的主题。科幻作家罗伯特·海因莱因 后来基于他的创意写了一则短篇故事,而这则故事让一个14岁的小男孩着了迷,他就是艾伦·凯。

从那时起,凯开始了他自己充满理想主义的设计发明生涯。他在FLEX机及其后续的Dynabook原型机、编程语言Smalltalk等方面作出的贡献,都极大地影响了现代个人计算机的设计。由他创造出的术语"面向对象"(object-orientation)则是20世纪90年代中期以来编程领域最基本的规范之一。而他与洛杉矶一所公立学校合作的项目,也反映出其自身对于教育体制的不满和反思。

dynaBook - Alan Kay - 极客中心

dynabook - Alan Kay - 极客中心

1940年,艾伦·凯出生于美国麻萨诸塞州的春田市,一年后举家迁至他父亲的故乡澳大利亚。凯的父亲是一位设计假肢的生理学家,母亲则是一位艺术家和音乐家。

我的父亲是科学家,母亲是艺术家,所以在我童年的家庭氛围中充满了各式各样的想法,以及各种各样表达它们的方式。我至今也从未把"艺术"和"科学"分开过。

我的外祖母是一名教师、学者和女权运动者,同时也是麻省大学阿默斯特分校的创始人之一。外祖父是克利夫顿·约翰逊,一位相当有名的插画家、摄影师和作家(出版了100多本书)。此外他还是一位音乐家,弹奏钢琴和管风琴。我出生于他去世的那一年,家里人都认为我是最像他的后代,不管是在兴趣上还是气质上。

1945年初,澳大利亚有可能受到日本的侵略,凯全家又重返美国。1945年到1949年,他们一直住在麻省海德莱市郊的约翰逊农场。凯3岁还在澳洲时就学会了阅读,因此很陶醉于农场的新环境--家里有近6000本书,以及大量的绘画作品。

我记得读过维利·雷 的一本书,名字叫《火箭、导弹和星际旅行》。给我触动很深的一点是,如果你从某个星球要去另一个星球,直着飞过去是不对的。你不能让飞船直接瞄准那颗行星,而应该瞄准它即将运行到的某个地方。

有关星际旅行的憧憬让凯兴奋不已,但学校生活对他来说就很乏味了。

到上学时,我已经读过好几百本书了。我从一年级开始就知道老师们在骗我们,因为我早就从书里知道了其他观点。学校里基本上只允许一种观点:老师的观点或者教科书的观点。他们不喜欢你持有不同的意见,所以就会出现斗争。当然了,我会用自己五岁的声音和他们争论。

凯从他的母亲那里接受了音乐的熏陶。他是学校唱诗班的男高音领唱,十几岁开始弹吉他,二十岁时甚至能以专业吉他手的身份挣钱谋生。他看到了音乐与计算之间的直接联系。

计算机程序有点类似格里高利圣歌 --单声部的旋律不断在乐章中来回变化。并行程序则更类似于复调 。

根据凯的类比,格里高利圣歌的一个乐章会针对某段主旋律进行多个变奏 。而在计算机程序的循环中,同一段指令序列也会被重复多遍,每一次都会以不同的值开始,直到某个"终止值"(stopping value)判断循环结束,之后转入下一个循环,类似于格里高利圣歌的下一段旋律。而在复调音乐中,多个不同的主旋律会同时进行,就像在并行程序中会同时执行多段指令序列。

1949年,凯的父亲获得了纽约一家医院的工作,全家迁往长岛。凯进入了布鲁克林技术高中,纽约市最好的科技高中之一。由于有不服从学校的行为,他被勒令暂时休学,很快又患上了风湿热。他以为必须复读一年,结果发现自己已经拿到了足够的学分,完全可以直接毕业。问题是,下一步怎么办?

我去了西弗吉尼亚州的贝森尼学院念书,主修生物,辅修数学,但后来在1961年因为抗议学校关于犹太人的限额问题被赶了出来。于是我到了丹佛,教了一年的吉他课。然后我被召入伍。当时有这么一项条款,就是如果军队要招募你,你可以自选一个志愿兵种进行申请。

于是我到美国空军待了两年。我爱读书,于是读了军方的所有规章制度,发现我正在接受培训成为士官。而如果我退出了士官培训,就会变成普通士兵,相应的服役期也会从4年变成2年。所以我就退出了那个培训。

在美国空军时,凯通过了一项能力测试,成为了一名程序员,在IBM 1401上工作。这个型号的计算机在当时非常流行,市面上大约有15 000台。

他们需要编程人员。在那个年代,编程是一种低级职业,而且大部分程序员都是女性。我的老板就是女的。他们也招收语言学家……的确是个很有意思的群体。

当时我有个朋友,他负责的部分就是我们今天所说的操作系统(控制计算机的程序)。你要知道,1401的存储器只有8K(8000个字符),所以它的操作系统必须小于1K。结果他做到了,机器能够进行真正复杂的批处理,简直是个奇迹。我协助他工作,所以也了解了一些编程的思想。

新思路的萌芽

要想成为伟大的软件设计者,首先需要有能力把单纯的想法转化为正确、有效的程序,其次要能慧眼识珠,善于发现其他的优秀编程思想的价值。

1961年,凯的工作是为空军解决各个航空训练设备之间数据和过程(procedure)的传输问题。他发现不知哪个程序员想出了一个聪明的办法,就是把数据和相应的过程捆绑到一起发送。通过这种方式,新设备里的程序就可以直接使用过程,而无需了解其中数据文件的格式。如果需要数据方面的信息,可以让过程自己到数据文件里找。这种抛开数据、直接操作过程的想法对凯而言是一个极大的触动,为他日后形成"对象"的概念埋下了伏笔。

离开空军后,凯认为自己应该完成大学学业,于是考入了科罗拉多大学。

科罗拉多的数学系比贝森尼学院要好,但是生物系不行。大部分时间我都泡在博尔德分校的校剧院里写舞台音乐。不论什么专业,任何人都能参与剧院的活动,而一年大概能上演25部作品,很了不起。我根据《霍比特人历险记》 写了一部戏,也参与了其他一些作品。

凯曾认真考虑过自己是否应该转行从事音乐,但最终还是拿到了数学和分子生物学的双学位,于1966年顺利从科罗拉多大学毕业。他又一次面临自己职业生涯的选择问题。

我考虑过医学,但感觉自己没有足够的责任心。现在我还是这么认为。

他还考虑过哲学,但最终放弃了。最后他决定去犹他州立大学念计算机科学系。

关于犹他大学,我只知道它的海拔超过4000英尺,而且有博士点。我很享受山里新鲜的空气。之前我曾想过去威斯康星大学念哲学,幸亏没去成。而科罗拉多的博尔德分校没有博士点。于是我身无分文跑去了犹他。对计算机我还比较感兴趣。

除他之外,计算机科学系还有6个研究生和3位教授,由戴夫·埃文斯(Dave Evans)带领,在此之前他曾经在加州大学伯克利分校负责计算机科学系。

进了犹他读博,在分到办公桌之前你先得读一大堆手稿。它们是关于Sketchpad 的。基本上,如果你理解不了Sketchpad,在犹他就别想挺胸做人。

他们还有一项传统,就是让新来的研究生干最脏最累的活。我的办公桌上摆着一堆磁带和一张纸条,上面写着:"这是UNIVAC 108机上跑的Algol语言。如果它不能运行,就把它弄好。"那其实就是第一版的Simula 。

伊凡·苏泽兰 于1962年提出的"画板"Sketchpad,是有史以来第一个交互式计算机图形系统,而且非常成熟。它提出了主图(master drawing)和例图(instance drawing)的概念。程序员可以在"主图"部分定义各种限制条件。这些条件可以很简单,例如"直线L的端点与某点P之间的距离应为1英寸";也可以很复杂,例如"形状M的弯曲应遵从牛顿定律"。Sketchpad会检查各个例图,然后根据主图的这些限制条件对它们进行修改。

Simula语言是1965年由挪威人克利斯登·奈加特和奥利-约翰·达尔 共同开发的一种语言,它也支持类似主图与例图的概念,只是相关术语有所不同。在这两种语言中,程序员可以定义主图的行为,然后让每个例图都遵从这一行为。关于这些想法,凯进行了很多思考。他开始寻找某种基础构件,支持一种简单、有效的编程风格。

我的灵感就是把这些看做生物学上的细胞。我不确定这一灵感来自何处,但肯定不是在我看到Sketchpad的时候。Simula也没有给我太多启发。

与生物学的类比让凯总结出三大原则。第一,每个"例"细胞都遵从"主"细胞的某些基本行为;第二,每个细胞都能独立运作,它们之间由能透过细胞膜的化学信号进行通信。第三,细胞会分化--根据环境不同,同一个细胞可以变成鼻子的细胞,也可以变成眼睛或者脚趾甲的细胞。在日后凯将会把"主/例"区别、信息传递和分化原则运用到Smalltalk的设计中,但此刻它们还只是一些初步的想法,似乎重要,却尚无用武之地。

FLEX和Dynabook

犹他州立大学有许多项目的资金支持都来自于美国国防部的高级研究规划署(Advanced Research Projects Agency,ARPA),当时可谓是ARPA的黄金时代。1962年至1964年规划署的负责人是J.C.R.利克里德 ,他拥有心理学背景,而且对人机交互领域非常感兴趣。

从1962年到1970年,ARPA赞助了一系列大胆的项目,其中一些只是出于对项目负责人的信心,根本与军事无关。1970年的曼斯菲尔德修正案要求ARPA只赞助与军事相关的项目,规划署的名字也被改为国防高级研究规划署(Defense Advanced Research Projects Agency,DARPA)。1993年联邦政府又将名字改回了ARPA,再次回归亲民方针。

在ARPA的资助下,麻省理工学院林肯实验室的威斯利·克拉克 于1963年开发出首台个人计算机LINC 。这种计算机重达数百磅,与今天的掌上电脑相去甚远,但在当时这种项目只要存在就足以令人兴奋。类似的研究很快跟进,而戴夫·埃文斯对此也有所耳闻。

埃文斯曾经在本迪克斯(Bendix)公司当过副总裁,这是一家干实事的公司。他经常为手下带的学生找一些咨询方面的工作。我去了几个月之后,他也帮我找了一份当地的咨询工作。

当时是1967年,戴夫·埃文期认识的一个家伙正打算设计一种台式机,但他不懂软件。我懂软件,所以这看上去是个绝配。

这个"家伙"就是埃德·基德尔(Ed Cheadle)。这个项目就是FLEX机。

基德尔是个很棒的家伙,典型的得克萨斯大块头,天性乐观。我们俩相处得非常融洽,很快开始讨论应该如何设计这台机器。他希望把它设计成一种能为医生、律师、专家服务的工程工作站。

很快我们就遇上了个人计算机最主要的问题:无法预测用户的需求。一旦所有人都有权使用,你就会出问题。所以我很早就对可扩展语言(用户能够根据自身领域的术语和操作进行修改的计算机语言)产生了兴趣。

兰德公司曾试验过JOSS,这是一种为非计算机用户--这里也就是经济学家--设计的系统。我们的想法是把FLEX做成像JOSS那样。事实也确实如此,FLEX有多个窗口,甚至还有类似图标那样的东西。

尽管FLEX拥有现代个人计算机的一些特征,凯仍然觉得不太满意,部分原因在于它笨重的个头有350磅。1968年夏,他在伊利诺伊大学进行了一次有关FLEX机的演讲。演讲受到了欢迎,而凯却对紧随其后的一次参观记忆犹新。

在那里我们看到了第一块平板显示器。从会议回来后我花了大半个晚上的时间,试图用摩尔定律计算出什么时候我才能把FLEX机装到一块小显示器的背面去。

1965年,物理学家戈登·摩尔 就预测,从基准年1959年开始,集成电路板上的密度会逐年翻番。也就是说电路的尺寸每年都会减半。依次计算,凯发现FLEX的20 000个电路元件在1980年左右可以放入显示器的背面--事实证明这一猜测颇为准确。

1968年秋季,凯拜访了麻省理工人工智能实验室的西蒙·派珀特博士② 。派珀特曾与瑞士儿童心理学家让·皮亚杰 一同工作过,他开发了一种简单的编程语言LOGO,并且提倡教儿童使用这种语言自行编程。凯亲眼看到麻省莱克星顿公立学校的孩子们正在编写真正的程序。这一经历改变了他对个人计算机的看法。

西蒙·派珀特(Seymour Papert,1928—),美国麻省理工学院终身教授,教育信息化奠基人,数学家、计算机科学家、心理学家、教育家,近代人工智能领域的先驱之一。他于1968年基于LISP创立了LOGO编程语言。LOGO是一种解释型语言,它内置一套海龟绘图(Turtle Graphics)系统,通过向海龟发送命令,用户可以直观地学习程序的运行过程,因此很适合于儿童以及数学教学。

我们曾把个人计算机比作私人汽车,而大型机则是公共铁路。

在看到派珀特所做的工作之后,我发现之前的比喻不再成立。孩子们自然不会去开车,我们需要关心的是他们如何识别媒介,掌握文明社会的符号系统。

拿到犹他州立大学的博士学位之后,凯到斯坦福大学人工智能实验室工作了一年(1969年至1970年),教授系统设计。

我对人工智能真的不太感兴趣,部分原因在于它太难了。我的生物学背景告诉我,为人工智能建立一套可行性标准绝不是件容易的事。每次我坐下来思考这个问题,总发现那些令其他人满意的成果无法令我满意。在我看来,他们所做的东西并不能称为是智能。

马文·明斯基是一个例外,他发展出了"心智社会"(society of minds)的想法。明斯基对生物学很感兴趣,而且他越想越觉得从生物学上得到了很多启示。

在斯坦福教书期间,凯开始构思另一种个人计算机--书本般大小,能将用户(特别是孩子们)与世界相连。

1970年7月,施乐公司的首席科学家杰克·高曼(Jack Goldman)说服高层在加州帕洛阿尔托市建立了一个长期科研部门--帕洛阿尔托研究中心(即PARC,Palo Alto Research Center),并邀请ARPA的梦想家鲍勃·泰勒 加盟管理。泰勒许诺凯以极大的自由,让他有机会"听从自己的本能行动"。于是凯加入了施乐PARC,开始设计一种叫做KiddiKomp的笔记本电脑,用一块小电视屏幕(14英寸)作为显示器,并配有可拆卸的键盘和鼠标。

在凯加入之后,泰勒从一家名叫伯克利计算机公司的濒临倒闭的小企业挖来了很多人才,其中包括巴特勒·兰普森 (1993年获得图灵奖)等名人。随后,从斯坦福研究院的增智研究中心(Augmentation Research Center)的道格·恩格尔巴特 带领的开拓性用户界面团队也来了许多研究人员,包括鼠标的合作发明人比尔·英格利什(Bill English)。英格利什鼓励凯申报预算、建立团队。

凯不情不愿地当起了领导者,组建了"学习研究工作组"(Learning Research Group,LRG),开始招募和他一样对笔记本电脑这一想法怀有热情的人。到了1971年夏天,凯开始设计一种新的语言,名字叫做Smalltalk。

这个名字非常不起眼,以至于它的任何成就都会让人感到惊喜。

"面向对象"的诞生

Smalltalk确实与生物学上的类比相吻合:相互独立的个体(细胞)通过发送讯息彼此交流。每一条讯息都包含了数据、发送者地址、接收者地址,以及有关接收者如何对数据实施操作的指令。凯希望这种简单的讯息机制能贯彻到整个语言中去。到了1972年9月,他已经完成了对基本想法的简化,甚至能把Smalltalk的整个定义写在一张纸上。这些想法组成了凯所谓"面向对象"的核心内容,它在20世纪90年代变成了软件设计的基本技巧。

如今,对"面向对象"这一术语过度的滥用几乎导致它失去了自己本来的意义。而它最基础的含义仍然来自类比的对象:独立、相互交流的生物细胞。细胞在特定环境下对特定化学信号产生特定蛋白质。与此类似,计算机对象也会对特定计算机讯息作出特定反应。比如说,一个代表视频游戏的计算机对象,会对敲击键盘、移动鼠标等讯息作出改变屏幕显示并同时调整得分的反应。而玩家们只需了解游戏外在的表现即可,无需弄清背后的工作原理。把内部活动的信息隐藏起来,这就是面向对象的精髓。

由于所有的交流都是通过讯息来实现的,因此只要对象收到的讯息和给出的反应能够适用于新的环境,那么对象就可以从一个上下文情境中提取出来,放入另一个上下文情境。这表示软件对象可以迎合设计师尚未设想到的用途,就像一个设计巧妙的车载收音机既可以装入已有的汽车,也可以装入那些尚未设计出来的汽车。

这样做有什么好处呢?原则上,它能让软件设计人员像土木工程师设计建筑那样构造程序:从工厂购买通用的元件,然后用定制化的软件当做"铆钉"将它们固定到一起。比如说,软件生产商可能会为图片对象定义行为,允许它们旋转、放大、投射、修改颜色、延展等。只要程序中需要操作图片,程序员就可以直接从生产商那里订购图片的"类",然后根据自己的需要在其中添加行为。除此之外,就像预制活动房屋由水槽、马桶、书桌等元件构成一样,程序中新的行为也能由面向对象系统中的元件构建。因此,一个既提供图片又提供搜索功能的图片数据库,其实就是数据库"类"和图片"类"结合的结果。正是因为凯的发明,我们的世界不必再为每一个应用都设计一门新的语言,因为通过创建对象,我们能够模拟任何存在物。

Smalltalk:孩子就是设计师

1972年9月,凯完成了Smalltalk的第一版设计。几天之内,团队成员丹·英格斯(Dan Ingalls)就让语言成功运行。下一个问题是让孩子们学会并使用Smalltalk。凯对此雄心勃勃,他希望Smalltalk能够引发儿童教育方式的改革。在皮亚杰、杰罗姆·布鲁纳 以及鲁道夫·安海姆 的影响下,凯认为图形比单纯的文本更利于儿童的学习。很快地,泰德·克勒(Ted Kaehler)和黛安娜·梅莉(Diana Merry)用Smalltalk编写了基本的制图法,鲍勃·舒尔(Bob Shur)则搭建了动画系统(每秒2到3帧)。

凯招募了阿黛尔·戈德堡 ,还有斯坦福大学的儿童工作者史蒂夫·文依(Steve Weyer)。

1974年戈德堡设计出一种方法,用一个叫做"乔"(Joe)的小图形框来教Smalltalk。"Joe turn 30!"这个命令可以让"乔"旋转30度,"Joe grow 15!"命令可以让"乔"放大15%。图形框还可以被复制(例如把"乔"复制给"吉尔")、连续翻转("forever do"命令)等。

有些孩子学得很快。一个12岁的孩子设计了一个画图系统,和苹果的MacDraw(或者Windows的Draw程序)非常相似。还有个15岁的孩子捣鼓出了一个设计电路的程序。不过据凯所说,在戈德堡教的孩子里只有5%做到了这种超级成就。其他的孩子都很喜欢和"乔"一起工作,但上不了"档次"。无论如何,任何人都能很快上手,这一事实已经说明了设计的质量。

不过,施乐的管理层并不像孩子们这么热情。1976年,官僚主义的惰性占了上风,高层否决了对PARC在硬件和软件设计方面继续供给资源。作为反驳,凯和他的同事们争辩说公司应该敢于冒一点险。在一份备忘录中,凯(正确地)预测在20世纪90年代会出现数以百万计的个人计算机,而且会联结至全球化的公用信息设施(也就是互联网)。他指出,施乐拥有生产力资源、营销基础,麾下还有"绝大多数世界上最优秀的软件设计师",足以称霸市场。遗憾的是这一点未能实现。

他们对这些成果的重大意义完全没有概念--因为没有先例可循。我们也没有为他们做好热身。

就算我们做好了热身,生意人也毕竟不是科学家,他们更像是工程师。一般而言,他们要的不是理解事物,而是想应用。在某些情况下,面对新现象时我们必须花时间去理解它,而不仅只是考虑把它拿来干什么。施乐本身也遇到过这种情况。20世纪50年代时,理特(咨询公司) 也曾预测说静电复印术不可能有市场,但是施乐坚持了下来。

施乐从未下力气追捧过PARC的技术,而苹果公司却正好相反。1979年,史蒂夫·乔布斯、杰夫·拉斯金 和苹果的其他技术专家拜访了凯的团队,参观一个演示程序。他们对所见印象深刻,但最刻骨铭心的莫过于当乔布斯抱怨屏幕显示算法时,丹·英格斯在一分钟之内就解决了问题。乔布斯试图找施乐买下这款软件,但是尽管在苹果小有投资,施乐仍然拒绝了这一建议。

之后的Smalltalk由施乐的一家分支子公司ParcPlace继续运营,依然因为其惊人的可塑性而赢得大批追随者。20世纪90年代,德州仪器(TI)公司有一个编程项目,但对是用当前最流行的C++还是Smalltalk犹豫不决。TI的Smalltalk团队提出进行一次"编码大赛"。他们打算出三个人,与任意数量的C++程序员展开比赛:早晨由中立方指定一个问题,两队同时开始编程,到中午由中立方再对之前的问题进行小范围的改动。比赛的目的在于判断哪一个团队的程序功能更强。在德州仪器的这次试验中,Smalltalk团队轻而易举地取得了胜利。

让校园天翻地覆

1984年凯加盟苹果之前,曾以驻场思想家的身份为雅达利公司 工作了一年。在雅达利,他监督了一些项目,其中的Vivarium是在他到雅达利后才开始构想的,后来成了麻省理工媒体实验室和个性化中心学校(洛杉矶的一所公立学校)的合作项目。

Vivarium让8岁左右的小学生用计算机记录真实数据,并与其他学校的孩子们比较结果。比如说,个性化中心的学生曾记录过学校街区的汽车交通数据,然后与全球范围内其他有相似条件的学校进行比较。通过媒体实验室提供的软件,孩子们可以自行模拟生态系统,并测试诸如"极度饥饿的动物是否会试图吃掉原本捕食自己的动物"这样的问题。

表面上看,我们可以说,凯所构想的个人计算机原型Dynabook已经实现。毕竟笔记本电脑、调制解调器、手写识别系统、数据库存取和超文本链接在今天已经随处可见。但是凯仍然抱有隐忧,他认为计算机有可能会沦为"某种大众鸦片"。

人们想要的东西往往并不是他们真正需要的,因为两者是受不同的原因驱动的。技术应该让愿望和需求保持一致,并同时满足两者。这非常重要。

如果生产出计算机却没有正确的价值体系--这就像生产出钢琴却没有作曲一样--你仍然徘徊在门外,只会简单地弹一些现成的儿歌。我们应该让孩子们有自己的传播媒介。

苏格拉底曾经抱怨过写作。他认为著述会强迫读者接受意见,而不是参与讨论。而计算机远比书本更加生动。不管是公认的观点还是计算机模拟的结果,每一个孩子都应该有机会亲身进行测试、辨别再得出结论。问题是,我们的社会将会鼓励还是压制这种可能性呢?

4.2 / 5 ( 10 votes )
❌