机械力学认为整体完全等于其部分之和,反之亦然;不管把组成整体的各部分进行多少次的分解组合,或者按照什么样的顺序进行分解组合,整体的行为始终不变。这意味着各部分之间不存在相互作用,也与自身过去的历史无关。任何部分在适当的时间到达适当的位置后,就会从这个位置开始继续完成它那完全唯一确定的行为。
——Karl Deutsch
当我们把事务分解成小的部件或小的特性时,我们其实是在努力放大或夸大那些明显的独立性,而忽视了(至少在一段时间内)组合体所具有的本质上的整体性和个体特征。我们将机体分解成器官,将骨架分解成骨骼。心理学的教学也采用了类似的方法,通过分析组成因素而给出关于思想活动的主观判断:但我们却清楚地知道判断或知识、勇气或温和、爱或恐惧并不会独立存在,而是不同程度地多方面综合在一起,或者通过关联成为一个整体。
——D'Arcy Thompson
为了应付不熟悉而且复杂的现象,我们会尽量
1. 获得“全面”的观点——足够广泛,可以包含我们感兴趣的所有现象——这样我们就不会感到惊讶;
2. 获得“最小”的观点——将不必区分的状态合并——这样就不会使观察的负担过重;
3. 获得“独立”的观点——将观察到的状态分解成不相干的部分——这样就可以减少对脑力的要求。
——温伯格 《系统化思维导论》
倒推分解
一九七六年的冬天,李恕权当时十九岁,在休士顿太空总署的太空梭实验室里工作,同时也在总署旁边的休士顿大学主修电脑。纵然忙于学校、睡眠与工作之间,这几乎占据了他一天二十四小时的全部时间,但只要有多余的一分钟,他总是会把所有的精力放在音乐创作上。
李恕权深知写歌词不是自己所擅长的,所以通过一番努力,终于找到了一个好搭档,她的名字叫做凡內芮(Valerie Johnson)。
一个星期六的周末,凡內芮热情地邀请他至她家的牧場烤肉。凡內芮知道他对音乐有着无比的执着与热情,然而,面对那遥远的音乐界及整个美国陌生的唱片市场,他们一点管道都没有。他们坐在德州的乡下,不知道下一步该如何走。突然间,凡內芮冒出了一句话:
“想像你五年后在做什么?”
李恕权沉思了几分钟,开始告诉她:“第一,五年后,我希望能有一张唱片在市场上,而这张唱片很受欢迎,可以得到许多人的肯定。第二,我住在一个有很多很多音乐的地方,能天天与一些世界一流的乐师一起工作。”
凡內芮说:“好,既然这样,我们就把这个目标倒算回来。如果第五年,你有一张唱片在市场上,那么你的第四年一定是要跟一家唱片公司签上合约。”
“那么你的第三年一定是要有一个完整的作品,可以拿给很多很多的唱片公司听,对不对?”
“那么你的第二年,一定要有很棒的作品开始录音了。”
“那么你的第一年,就一定要把你所有要准备录音的作品全部编曲,排练就位准备好。”
“那么你的第六个月,就是要把那些没有完成的作品修饰好,然后让你自己可以逐一筛选。”
“那么你的第一个月就是要把目前这几首曲子完工。”
“那么你的第一个礼拜就是要先列出一整个清单,排出哪些曲子需要修改,哪些需要完工。”
“好了,我们现在不就已经知道你下个星期一要做什么了吗?”凡內芮笑笑说。
“喔,对了。你还说你五年后,要生活在一个有很多音乐的地方,然后与许多一流的乐师一起忙着工作,对吗?”她急忙地补充说。“如果,你的第五年已经在与这些人一起工作,那么你的第四年照道理应该有你自己的一个工作室或录音室。那么你的第三年,可能是先跟这个圈子里的人在一起工作。那么你的第二年,应该不是住在德州,而是已经住在纽约或是洛杉机了。”
次年(一九七七年),李恕权辞掉了令许多人羨慕的太空总署的工作,离开了休士顿,搬到洛杉机。
不敢说是恰好五年,但大约可说是第六年。一九八三年,李恕权的唱片在亚洲开始销起來,他一天二十四小時几乎全都忙着与一些顶尖的音乐高手,日出日落地一起工作。
如果……就能……
叫兽:“请同学们用如果……就能……造句”。
阿基米德:“如果给我一个支点,我就能撬起整个地球”。
程序员甲:“如果L是一个有序的数组,就能把它与另一个数字b合并成一个新的有序的数组”(插入排序)。
冯·诺伊曼:“如果L、R都是有序的数组,就能把它们合并成一个新的有序的数组”(归并排序)。
李恕权:“如果第四年能跟一家唱片公司签上合约,第五年就很可能可以出唱片”。
叫兽:“你怎么有点底气不足啊?”
李恕权:“因为生活可能会欺骗我,但计算机不会。”
递归分解 当问题太过复杂而无法直接求解时,一个最简单的方法就是,把问题分解成相互独立的子问题分别求解,再想办法把子问题的解合并成整个问题的解。如果子问题还是比较复杂而无法直接求解,可以再次对其进行分解,就像李恕权所做的那样。不过对于算法来说,由于通常输入的元素个数(或者说计算的复杂度)是不确定的,导致我们无法确定需要分解多少次才能把子问题简化到可以直接求解的程度,所以我们在做分解的时候不能像李恕权那样自由,一般来说都必须让子问题与原问题具有相似的结构、只是规模较小,这样才能递归地进行分解、解决。这种先分解再合并的解决问题的方法,就叫做分治法(divide-and-conquer)。
分治法
分解(Divide):将原问题分解成一系列子问题;
解决(Conquer):递归地解决各子问题。若子问题足够小,则直接求解;
合并(Combine):将子问题的结果合并成原问题的解。
分治版插入排序
回顾一下上一篇介绍的使用增量(incremental)方法的插入排序,可以用一句话来概括其思路:“因为可以把子数组L处理成有序的,所以能进一步把L与一个元素b合并成新的有序的数组……”。下面让我们来实现一个分治版的插入排序,用一句话来说就是:“把输入分解成一个子数组L和一个元素b,如果可以把L处理成有序的,就能把L和b合并成新的有序的数组……”。
分解:将n个元素分解成n-1个元素的子数组L和一个元素b;
解决:用插入排序法对L递归地排序;
合并:把L与b合并成新的有序的数组。
分治版的插入排序函数名为DCInsertionSort(),代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | // 分治版的插入排序 // 调用:DCInsertionSort(a1, 0, a1.Length - 1); static void DCInsertionSort( int [] s, int startIndex, int endIndex) { // 判断是否能够直接解决 int n = endIndex - startIndex + 1; // 输入的元素个数 if (n > 1) // 需要进一步分解 { // 分解: 将n个元素分解成n-1个元素的子数组L和一个元素b int lEndIndex = endIndex - 1; // 子数组L的上标 int b = s[endIndex]; // 递归解决 DCInsertionSort(s, startIndex, lEndIndex); // 合并: 因为L是一个有序的数组,所以能把它与另一个数字 b 合并成一个新的有序的数组 int j = lEndIndex; while (j >= startIndex && s[j] > b) { s[j + 1] = s[j]; j--; } s[j + 1] = b; } else { // 数组长度为1时,本身就是有序的,不需要处理,直接返回 return ; } } |
可以对比一下上一篇的增量法的插入排序。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | static int [] InsertionSort( int [] s) { for ( int i = 0; i < s.Length-1; i++) // s[0..i] 是每次迭代前的 L { int b = s[i + 1]; // s[0..i+1] 是迭代后的 L int j = i; while (j >= 0 && s[j] > b) { s[j + 1] = s[j]; j--; } s[j + 1] = b; } return s; } |
归并排序
下面,我们把分治版插入排序做一点小小的改动,改成把输入分解成两个数组L和R,这就是归并排序。
分解:将n个元素分成各含n/2个元素的子数组L和R;
解决:用归并排序法对两个子数组递归地排序;
合并:把两个有序的数组L和R合并成排序结果。
现在关键的问题是,冯冯同学当真没有骗我们,确实有办法把有序的数组L和R合并吗?答案是肯定的,因为即使R是无序的,我们也可以使用插入排序法把R中的元素一个一个地插入有序的数组L中。不过,既然R也是有序的,是否还有更加高效的合并方法呢?有的,因为当L和R都是有序的数组时,这两个数组中最小的那个元素不是L[0]就是R[0],这样我们只要每次都从L或R中取出最小的一个元素,最后得到的就是合并后的有序数组了。代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 | // 将有序的数组s[p..q]和s[q+1..r]合并成有序的数组s[p..r] // 示例: // int[] s = new int[] { 2, 4, 5, 1, 3 }; // Merge(s, 0, 2, 4); ==> s 将变成 {1, 2, 3, 4, 5 } static void Merge( int [] s, int p, int q, int r) { // 创建数组L=s[p..q] IList< int > L = new List< int >(); for ( int i = p; i<=q; i++) L.Add(s[i]); // 创建数组R=s[q+1..r] IList< int > R = new List< int >(); for ( int i = q+1; i<=r; i++) R.Add(s[i]); // 把L和R合并成有序的数组s[p..r] for ( int j=p; j<=r; j++) { if (L.Count <= 0) { s[j] = R[0]; R.RemoveAt(0); } else if (R.Count <= 0) { s[j] = L[0]; L.RemoveAt(0); } else if (L[0] <= R[0]) { s[j] = L[0]; L.RemoveAt(0); } else { s[j] = R[0]; R.RemoveAt(0); } } } |
注:上面的代码使用List和List.RemoveAt()是为了使代码更加直观。实际上使用普通数组也可以实现同样的功能,而且效率会更高些,譬如可以这么写:

// 将有序的数组s[p..q]和s[q+1..r]合并成有序的数组s[p..r]
// 示例:
// int[] s = new int[] { 2, 4, 5, 1, 3 };
// Merge(s, 0, 2, 4); ==> s 将变成 {1, 2, 3, 4, 5 }
static void Merge(int[] s, int p, int q, int r)
{
// 创建数组L=s[p..q]
int[] L = new int[q-p+1];
int li = 0;
for(int i = p; i<=q; i++)
{
L[li] = s[i];
li++;
}
// 创建数组R=s[q+1..r]
int[] R = new int[r-q];
int ri = 0;
for(int i = q+1; i<=r; i++)
{
R[ri] = s[i];
ri++;
}
li = 0;
ri = 0;
// 把L和R合并成有序的数组s[p..r]
for(int j=p; j<=r; j++)
{
if(li > L.Length-1)
{
s[j] = R[ri];
ri++;
}
else if(ri > R.Length-1)
{
s[j] = L[li];
li++;
}
else if(L[li] <= R[ri])
{
s[j] = L[li];
li++;
}
else
{
s[j] = R[ri];
ri++;
}
}
}
解决了合并的问题,实现归并排序就没什么难度了,它看上去与DCInsertionSort()没什么两样:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | // 归并排序 // 示例:MergeSort(a1, 0, a1.Length - 1); static void MergeSort( int [] s, int p, int r) { // 判断是否能够直接解决 if (p < r) // 需要进一步分解 { // 分解: 将n个元素分成各含n/2个元素的子数组L=s[p..q]和R=s[q+1..r]; int q = (p + r) / 2; // 递归解决 MergeSort(s, p, q); // 递归排序L MergeSort(s, q+1, r); // 递归排序R // 合并: 因为L、R都是有序的数组,所以能把它们合并成一个新的有序的数组 Merge(s, p, q, r); } else { // p==r时,数组长度为1,本身就是有序的,不需要处理,直接返回 return ; } } |
归并排序的时间代价为Θ(n log n)。
Stooge排序
分治法的核心思想是把一个复杂的问题分解成若干相互独立、更加简单的子问题,再对子问题分别求解。这里“相互独立”很重要,如果解决一个子问题时会影响到其它子问题,就会非常麻烦。由Howard、Fine等教授提出的Stooge排序是一个经典的反面教材,可以让我们对“如果解决子问题时会相互影响会怎样?”有一个感性认识。
如果说Stooge排序是一个坏榜样的话,那他也是个有个性的反派,你可以听到他说:“就算子问题会相互影响,哥仍然能得到正确结果!还不用特意去合并子数组。”Stooge也是使用分治法
分解:将n个元素的数组s[p..r]分成2个各含2/3个元素的子数组L和R,L=s[p..r - ⌊n/3⌋],R=[p + ⌊n/3⌋..r],L和R重叠的元素个数为 (r - ⌊n/3⌋) - (p + ⌊n/3⌋) + 1 = ⌈n/3⌉;
解决:用Stooge排序以“排序L、排序R、再次排序L”的顺序递归地排序;
合并:子数组经过排序后,整个数组就已经是有序的,不需要进行额外的合并操作。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | // Stooge 排序 // 示例:StoogeSort(a1, 0, a1.Length - 1); static void StoogeSort( int [] s, int p, int r) { int n = r - p + 1; // 输入的元素个数 // 判断是否能够直接解决 if (n > 2) // 需要进一步分解 { // 分解: 将n个元素的数组s[p..r]分成2个各含2/3个元素的子数组L和R,L=s[p..r - n/3],R=[p + n/3..r] int k = n / 3; // 递归解决 StoogeSort(s, p, r-k); // 递归排序L StoogeSort(s, p+k, r); // 递归排序R StoogeSort(s, p, r-k); // 再次递归排序L // 合并:子数组经过排序后,整个数组就已经是有序的,不需要进行额外的合并操作。 } else if (n == 2) // 输入有2个元素时, 直接求解 { if (s[p] > s[r]) { int temp = s[p]; s[p] = s[r]; s[r] = temp; } } else // 输入只有1个元素或者是空数组,直接返回 { return ; } } |
这里有两个有趣的问题:1)Stooge排序为什么能得到正确结果?2)为什么它不需要合并?
对于这两个问题,我们可以给出形式化的证明:L∩R的元素个数为⌈n/3⌉ => 对L排序将使L∩R为L里面最大的⌈n/3⌉个元素 => 对R排序将使 R-L 含有整个数组中最大的那些元素并且是有序的 => 再次对L排序使L有序后,整个数组有序。
不过,我们白话文更加关心的问题是,如何才能在直觉上有“这样做也许可以哟”的感觉呢?像Stooge这种处理L会影响R,处理R又会影响L的情况,就算计算机不崩溃,我们的脑袋也快崩溃了。有时候不得不承认自己实在有够笨,特别是在思考这种远离人类本能的领域的问题的时候。这种时候,如果不把问题抽象几个层次,或者将其简化到过分的地步的话,就很难会有什么直觉。
我们可以在几个不同的层面上思考
1)思考“最小”的情况,以减轻观察的负担。考虑排序 { 9, 8, 7 } 的情况。首先,很容易发现按照“排序L、排序R、再次排序L”的步骤确实可以使整个数组有序。其次,我们可以感觉到,中间那个元素(也就是L∩R)起到了一种“纽带”的作用,就是通过它将最大的那个元素“9”搬运到了R中。
2)思考“独立”的部分,以减少对脑力的要求。虽然处理L和R的时候会互相影响,但是我们仍然可以努力找出最先稳定的部分,就是 R-L。先排序L再排序R的目的,就是使R-L含有整个数组中最大的那几个元素并且是有序的,这一部分稳定后就可以独立出来不再受影响了。
3)思考“次小”的情况,比较它与“最小”情况在结构上有怎样的一致性。我们把“最小”系统的规模扩大一倍,考虑排序 { 9, 8, 7, 6, 5, 4 } 的情况。最贴近“最小”情况的划分方法是L={ 9, 8, 7, 6 }, R={ 7, 6, 5, 4 },可以得到正确结果。如果让L={ 9, 8, 7, 6 }, R={ 6, 5, 4 },无法得到正确结果。如果让L={ 9, 8, 7, 6, 5 }, R={ 8, 7, 6, 5, 4 },可以得到正确结果。由此,我们可以知道关键的地方是L∩R的元素个数必须大于等于⌈n/3⌉。
4)思考“全面”的情况,以验证我们的想法并丰富细节。输入的元素个数并不总是偶数个或者是3的倍数。考虑各种可能的情况,不但可以让我们对算法的正确性更有信心,有时甚至可以让我们从另一个侧面得到灵感。
Stooge实在是一个很巧妙的算法,通过让L和R有一部分交叠,竟然可以免去合并操作。不过,它的时间代价是高昂的,达到Θ(n2.7),比插入排序还要慢。一旦被贴上“不实用算法”的标签,就少有人愿意多看它几眼了。正如一个人要是米有钱,就难免会觉得自己很失败一样。
下一篇要介绍的快速排序,同样是一个通过巧妙的方法免去合并操作的算法,只不过它的速度与stooge相比却是是一个天上,一个地下。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· 10年+ .NET Coder 心语 ── 封装的思维:从隐藏、稳定开始理解其本质意义
· 地球OL攻略 —— 某应届生求职总结
· 提示词工程——AI应用必不可少的技术
· 字符编码:从基础到乱码解决
· Open-Sora 2.0 重磅开源!
2009-03-16 在异步调用匿名函数时明智地使用局部变量