冒泡排序是种很简单的排序方式. 如果用循环方式, 通常就是两层循环.
由于两层循环都是与元素个数 N 线性相关, 所以可以简单估算出它的时间复杂度是 O(N2), 通常而言, 这是较糟糕的复杂度.
当然, 这也几乎是所有简单方式的宿命, 想简单就别想效率高!
前面篇章说到递归也是一种循环, 所以普通循环能解决的问题, 用递归也能解决. 我们来看看怎么写出它来.
定义好接口先
首先啰嗦几句写代码习惯问题:
要有服务意识, 要有 API 意识.
先把接口定义好, 与具体的实现分开. 另外则作一些基本的判断, 内部实现就不需要再作判断了, 也能简化内部实现:
// 对外接口
public static void sort(int[] array) {
if (array != null && array.length > 1) {
bubbleSort(array);
}
}
// 具体内部实现
private static void bubbleSort(int[] array) {
// TODO
}
想更加通用就用
Comparable
接口, 不过这些不是这里的重点, 这里就简单用array
了.
主体结构
前面说到, 递归分成 基本情形(base case) 和 递归情形(recursive case), 所以, 不管三七二十一, 先把主体结构写出来:
private static void bubbleSort(int[] array) {
// TODO
if (baseCase) {
return;
} else {
// 1. do something maybe
// ...
// 2. 递归调用
// bubbleSort(xxx);
}
}
基本情形(base case)
这个简单, 当只有一个元素时, 自然就无需排序了, 所以:
if (array.length == 1) {
return;
}
显然, base case 没做任何事, 因此只要把代码条件反转一下, 就可以省略掉这个 base case 了.
递归情形(recursive case)
如何找出递归模式呢? 我们从一个具体的事例考虑:
假如有四个元素: 6 9 8 4, 假设向右边冒泡, 那么从左向右两两比较并根据情况决定是否交换:
第一次比较 6 和 9, 6 比 9 小, 无需交换, 继续;
第二次比较 9 和 8, 9 比 8 大, 所以交换它们得到: 6 8 9 4;
第三次比较 9 和 4, 9 比 4 大, 交换, 得到: 6 8 4 9.
那么, 经过一轮冒泡之后, 最大的元素 9 已经冒泡到了最右边上, 然后下一步就可以只考虑剩下的三个元素了, 而这正好可视作一个新的待排序的数组.
先不考察冒泡的细节, 用一个抽象的 bubble 表示它, 那么可以归纳出如下递归模式:
bubbleSort 一个元素为 N 的数组就是:
- 进行一轮冒泡(bubble),
- 然后对左边的 N-1 个元素(子数组)递归地进行 bubbleSort.
如下图示:
据此, 写出以下代码(省略了 base case):
private static void bubbleSort(int[] array) {
// TODO pseudo code
if (array.length > 1) {
bubble(array);
bubbleSort(array.subArray(0, array.length - 1));
}
}
你可能会想,
array
并没有subArray
方法呀, 另外应该是在原数组上排序.没错, 这里也注明了这是伪代码(pseudo code), 此刻只需要思考抽象的逻辑是否有问题即可, 诸如是否会发生无限递归呀等情况.
敲定细节
代码中还是伪代码的形式, 显然我们是要在原数组上排序, 这使得我们必须增加一个显式的参数.
如果 array 有更加对象化的方法来能直观表示 subArray(并非指复制的那种, 而是一种指向新位置的引用), 那么增加参数就不是必要的了.
目前我们无法摆脱 array 的结构限制去思考. (这其实暴露了 java 在表达能力上的缺陷或者说 array 的设计上存在一些问题, 使得我们只能笨拙地去表达我们想要的意思)
根据前面的归纳, 定为 N-1 来表示子数组 subArray:
bubbleSort(n – 1, array);
注: 你可能忍不住会把这里的 n 想像成 array 的 index. 没错, 它最终是要映射到 index 上, 但此刻你只要抽象地从概念上把握它即可, 这里的 n 就是指 n 个元素, 它不是什么 index.
方法要形成递归, 所以定义处也修改, 增加一参数 n:
private static void bubbleSort(int n, int[] array)
公共接口里调用时也因此要作改动, 需要显式传入 N, 对于初始情况, N 也即是 array.length
:
bubbleSort(array.length, array);
另外有一点要特别注意, 因为不是传递 subArray, array.length
始终不变, 所以 if 语句中的判断已经不正确:
必须由
if(array.length > 1)
改为if(n > 1)
现在考察 bubble, 显然 N 跟 array 都要往里面传. 现在直接将 bubble 方法考虑成递归的, 以减少中间环节. 那么, 还要再增加一个参数 bn, 表示有几个元素需要 bubble.
让 IDE 生成方法签名, 最终结果如下:
// 对外接口
public static void sort(int[] array) {
if (array != null && array.length > 1) {
bubbleSort(array.length, array);
}
}
// 具体内部实现
private static void bubbleSort(int n, int[] array) {
if (n > 1) {
bubble(n, n, array);
bubbleSort(n - 1, array);
}
}
private static void bubble(int bn, int n, int[] array) {
// TODO
}
注:
bubble(n, n, array)
中第一个 n 表示有 n 个元素需要冒泡;
第二个 n 表示有 n 个元素需要排序.
其实你也能感觉到, 这完全就是面向过程式的编程.
至此, 主体结构已经 OK 了. 现在考虑冒泡 bubble 的实现问题.
冒泡子过程
冒泡同样可视作一个递归的过程. 按着一样的思路, 先是一个大体的框架:
private static void bubble(int bn, int n, int[] array) {
if (baseCase) {
return;
} else {
// 1. do something
// ...
// 2. 递归调用
// bubble(xx, n, array);
}
}
基本情形(base case)
显然, bn==1
时表示只有一个元素, 就无需冒泡了, 直接返回:
if (bn == 1) {
return;
}
显然, 这一 case 也是可以省略的.
递归情形(recursive case)
其实就是一个从左到右不断比较与交换的过程. 根据前面的例子, 同样可以归纳出它的递归模式如下:
bubble一个元素为 N 的数组就是:
- 对第一个元素(以及它后续的那个元素)进行一次比较和交换(compareAndSwitch),
- 然后对余下的 N-1 个元素递归地进行 bubble.
比较与交换同样的先用一个抽象的过程表示, 据此, 得出代码如下(同样省略了 base case):
private static void bubble(int bn, int n, int[] array) {
if (bn > 1) {
compareAndSwitch(bn, n, array);
bubble(bn - 1, n, array);
}
}
private static void compareAndSwitch(int bn, int n, int[] array) {
// TODO
}
你会发现这里的 bubble 与前面的 bubbleSort 在形式上是非常像的.
有些人可能会在 bn 或者 n 的实际含义上纠结, 总是忍不住把它们往 array 的 index 上去想, 这实际上还是没能摆脱往具体实现层面上去思考的惯性. 我们应该抽象地去思考, 不要老惦记着 array, 其实关键就在于要表达清楚我们的意思, 这样就行了.
我们最终肯定要处理 index 的问题, 但目前还不是时候, 你应该坚信 index 肯定能用 bn 及 n 表达出来, 你只需往下传递它们就是了.
比较与交换
现在终于要处理让我们有点头痛的 index 问题了.
我们只需考察最简单的两个元素第一次比较的情况, 此时 n=2, bn=2, 我们要比较 array[0], array[1], 显然 0=n-bn, 也即是要比较 array[n-bn] 和 array[n-bn+1].
最终结果如下:
private static void compareAndSwitch(int bn, int n, int[] array) {
int index = n - bn;
if (array[index] > array[index + 1]) {
int temp = array[index];
array[index] = array[index + 1];
array[index + 1] = temp;
}
}
你可能有点怀疑, 这样就正确了吗? 我们可以实际测试一下即可, 通常八九不离十.
改进
如果一轮 bubble 没有发生任何交换, 意味着数组已经是有序的了, 可以提前 break
. 可以引入一个 boolean
来达到这一点, 这里就不演示了. 最终的代码如下:
// 对外接口
public static void sort(int[] array) {
if (array != null && array.length > 1) {
bubbleSort(array.length, array);
}
}
// 具体内部实现
private static void bubbleSort(int n, int[] array) {
if (n > 1) {
bubble(n, n, array);
bubbleSort(n - 1, array);
}
}
// 冒泡
private static void bubble(int bn, int n, int[] array) {
if (bn > 1) {
compareAndSwitch(bn, n, array);
bubble(bn - 1, n, array);
}
}
// 比较与交换
private static void compareAndSwitch(int bn, int n, int[] array) {
int index = n - bn;
if (array[index] > array[index + 1]) {
int temp = array[index];
array[index] = array[index + 1];
array[index + 1] = temp;
}
}
总结
其实写递归程序的套路经常是比较固定的, 大的方面就是一个 if else, 递归部分可以放 if, 也可以放在 else, 就看 if 条件是如何判断了.
然后就是找出递归模式, 把条件填充上去. 暂时不明朗的地方, 可以先用一个抽象来表达, 然后逐步地把细节完善.
对递归程序而言, 通常一旦正确了, 它就是正确的.
当然, 这好像是一句废话. 为什么这么说呢? 要知道正确的方式通常只有一种, 而错误的方式则有千万种.
俄国有位师太(叫托尔斯泰), 在他的<<安娜·卡列妮娜>>中曾经曰过: "幸福的家庭总是相似的, 不幸的家庭各有各的不幸. "
‘All happy families are alike; each unhappy family is unhappy in its own way.’–Leo Tolstoy Anna Karenina
所以, 这里真正想表达的意思是, 递归式通常更为精练, 没有过多枝枝蔓蔓的东西. 它的环节比较少, 过程式的东西较少, 写法通常也很固定, 因而减少了你犯错的机会. 你只要找准了终止条件和递归模式, 它通常就自然而然的正确了.
另外则是要强调抽象的思考问题, 不要过早地陷于具体实现及细节的纠缠中, 而是要一步步地最终使得一切水落石出.
当然, 就这个例子而言, 比起普通循环而言, 并没有什么优势.
递归反而可能显得有点拖沓, 这里不过是想说明普通循环能解决的问题也能用递归来解决;
另一方面, 如果还不习惯于递归式的表达, 恐怕写起来比普通循环更费劲;
此外, 除非是简单小量的排序, 否则性能也是个问题.
在下一篇章中, 将探讨一个更加复杂的例子, 也即是前面提到的求换零钱种数的问题, 那时才能体现出递归的优势来.