第3章 基本排序算法
对计算机中数据存储的两个最常见的操作是排序与查找。自从计算机工业开始时这就是明确的,所以排序与查找也是计算机科学领域两个研究最多的操作。本书讨论的大部分数据结构都是主要设计为使结构中的数据存储的排序及/或查找更容易且更高效。
本章将为你介绍排序与搜索数据的基本算法。这些算法只需要像数组这样的数据结构,唯一"高级"的计算机技术就是循环。本章也介绍了我们整本书都使用的非正式的分析不同算法速度与效率的技术。
排序算法
我们每日工作中对大部分数据最常做的就是排序。我们通过按字母排序来搜索一个字典中的定义。我们通过姓氏的字母顺序在本子中查找一个电话号码。邮局按多种方式将邮件排序 – 先按邮政编码,然后是地址,然后是姓名。排序是我们处理数据时的一种基本的方法,应该仔细研究。
就像我们之前提到的,有大量的工作被投入到不同排序技术的研究。虽然一些非常高级的排序算法已被发明出来,你也应当首先学习一些简单的排序算法。这些算法如插入排序,冒泡排序,及选择排序。这些算法都很易懂且很易用。虽然无论如何他们不是最好的排序算法,但是针对小的数据集或者其它一些特殊情况,它们是要使用的最佳算法。
一个数据类测试框架
要检查这些算法,我们首先需要一个测试框架来实现并测试它们。我们要构建一个类来封装在一个数组上执行的常见操作 – 元素插入,元素访问,及显示数组的内容。如下是代码:
1 class CArray 2 { 3 private int[] arr; 4 private int upper; 5 private int numElements; 6 7 public CArray() 8 { } 9 10 public CArray(int size) 11 { 12 arr = new int[size]; 13 upper = size - 1; 14 numElements = 0; 15 } 16 public void Insert(int item) 17 { 18 arr[numElements] = item; 19 numElements++; 20 } 21 public void DisplayElements() 22 { 23 for (int i = 0; i <= upper; i++) 24 Console.Write(arr[i] + " "); 25 } 26 public void Clear() 27 { 28 for (int i = 0; i <= upper; i++) 29 arr[i] = 0; numElements = 0; 30 } 31 } 32 33 class class1 34 { 35 static void Main() 36 { 37 CArray nums = new CArray(); 38 for (int i = 0; i <= 49; i++) 39 nums.Insert(i); 40 nums.DisplayElements(); 41 } 42 }
这段代码输入如下:
0 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 42 43 44 45 46 47 48 49
在离开CArray类开始检查排序与搜索算法之前,我们需要明白怎样真实的向CArray对象中存储数据。为了更有效的证明不同的排序算法的工作情况,数组中的数据需要随机排序。最好的实现方法是使用一个随机数生成器来将每个数组元素赋给待测数组。
C#中可以使用Random类来创建随机数。这个类的对象可以产生随机数。要实例化一个Random类,你需要向类构造函数传入一个种子。这个种子可以被看作随机数生成器可以生成的数值范围的上界。
这有另一个使用CArray类存储数字的程序,使用随机数生成器选择要存储于数组中的数据。
1 static void Main() 2 { 3 CArray nums = new CArray(); 4 Random rnd = new Random(100); 5 for (int i = 0; i < 10; i++) 6 nums.Insert((int)(rnd.NextDouble() * 100)); 7 nums.DisplayElements(); 8 }
这段程序输出如下:
72 54 59 30 31 78 2 77 82 72
冒泡排序
首先要介绍的排序算法是冒泡排序。冒泡排序是可用的排序算法中最慢的之一,但是它也是最易理解最易使用的最简单的排序算法,这使它成为我们学习的最佳候选。
这个排序的名称来源于一个元素像"泡一样"由数组的一段"浮动"到另一端。假定你要将一个数列按升序排列,较大的值浮动到右侧同时较小的值移动到左侧。这个行为是通过在列表中移动多次,比较临近的值,如果左侧值大于右侧值则交换它们。
图3.1展示了冒泡排序的工作。两个数(2与72)被插入到上一个例子的数组中,以圆圈高亮表示。你可以观察到72怎样被由数组的开始部分移动到数组的中部,同时也可以看到2怎样穿过数组的中部被移到数组的开始部分。
如下展示了冒泡排序算法的代码:
1 public void BubbleSort() 2 { 3 int temp; 4 for (int outer = upper; outer >= 1; outer--) 5 { 6 for (int inner = 0; inner <= outer - 1; inner++) 7 if ((int)arr[inner] > arr[inner + 1]) 8 { 9 temp = arr[inner]; 10 arr[inner] = arr[inner + 1]; 11 arr[inner + 1] = temp; 12 } 13 } 14 }
这段代码有几个需要注意地方。首先,交换两个数组元素的代码写在一行中而不是一个子程序内。一个处理交换用的子函数由于被多次调用可能会降低排序的速度。由于交换代码只有3行长,程序的清晰度不会由于没有把代码放在一个子程序中而受影响。
更重要的,注意外部循环由数组尾部开始向数组开始部分移动。如果你回头去看图3.1,数组的最大值在正好在数组的末尾。这意味着数组中比外部循环内的值都大的元素的下标已经在其该在的位置,算法无需再次访问这些值。
内部循环由数组的第一个元素开始,当其在数组中向后移动到最后一个位置时结束。内部循环比较内部索引及内部索引+1这两个相邻位置的值,如果需要则交换他们。
检查排序过程
在开发一个算法过程中你可能想要做的一件事就是查看程序运行时代码的时时结果。当你使用Visual Studio.NET时,可以通过IDE的调试来完成这个工作。然而,有些时候,你真正想查看是数组的显示(或其它你正在构建,排序或搜索的数据结构)。一个简单的完成这个功能方式是在代码中合适的位置插入一段显示变量的方法。
针对前述的冒泡排序算法,在排序过程中检查数组怎样变化的最佳位置是在内部循环与外部循环之间。如果我们对这两个循环的每次迭代都进行这个操作,我们可以查看数组排序过程中数组值的移动过程的记录。
例如,如下是修改后可以显示中间结果的排序方法:
1 public void BubbleSort() 2 { 3 int temp; 4 for (int outer = upper; outer >= 1; outer--) 5 { 6 for (int inner = 0; inner <= outer - 1; inner++) 7 { 8 if ((int)arr[inner] > arr[inner + 1]) 9 { 10 temp = arr[inner]; 11 arr[inner] = arr[inner + 1]; 12 arr[inner + 1] = temp; 13 } 14 } 15 this.DisplayElements(); 16 } 17 }
DiaplayElements()方法被放置于两个循环之间。主程序如下修改:
1 static void Main() 2 { 3 CArray nums = new CArray(10); 4 Random rnd = new Random(100); 5 for (int i = 0; i < 10; i++) 6 nums.Insert((int)(rnd.NextDouble() * 100)); 7 Console.WriteLine("Before sorting: "); 8 nums.DisplayElements(); 9 Console.WriteLine("During sorting: "); 10 nums.BubbleSort(); 11 Console.WriteLine("After sorting: "); 12 nums.DisplayElements(); 13 }
如下显示上述代码的输出:
选择排序
下一个要学习的排序是选择排序。这种排序工作方式是,由数组的起始部分开始,比较第一个元素与数组中另一个元素。最小的元素被置于位置0,然后排序由位置1继续进行。这个过程持续到除了最后一个位置之外的每个位置都在一个循环中作为起始点。
选择排序算法用到两个循环。外侧循环由数组中第一个元素向下一个元素移动直到最后一个元素。反之,内部循环由数组的第二个元素向最后一个元素移动,同时查找比外部循环当前索引所代表的元素的值小的元素。在每次内部循环执行后,数组中最小的值被放置到数组中合适的位置。图3.2展示了使用前面用过的CArray数组数据表示的工作过程。
下面列出实现选择排序算法的代码:
1 public void SelectionSort() 2 { 3 int min, temp; 4 for (int outer = 0; outer <= upper; outer++) 5 { 6 min = outer; 7 for (int inner = outer + 1; inner <= upper; inner++) 8 if (arr[inner] < arr[min]) 9 min = inner; 10 temp = arr[outer]; 11 arr[outer] = arr[min]; 12 arr[min] = temp; 13 } 14 }
要演示这个算法怎样工作,紧接着外部循环的语句放置一个对showArray()方法的调用。输出看起来如下所示:
本章我们要学习的最后一个最容易理解的之一的基本排序算法是 – 插入排序。
插入排序
插入排序以一种类似我们平常按数字或字母排序事物的方式来进行。我要求一个班级学生没人上交一份包含他们姓名,学号,即一个简介的卡片。学生们交上来时,卡片都是随机排序的,但是我想将他们以字母排序,那样我就可以依次来制作一个座次图。
我将卡片带回我的办公室,打扫干净我的办公桌,并取出第一张卡片。卡片上的名字是Smith。我将其放置在桌子的左上角并取来第二张卡片。他是Brown。我将Smith的卡片右移,并将Brown放在Smith的位置。下一张是Williams。这张卡片可以被插入最右侧而无需移动其它卡片。下一张卡片是Acklin。它需要放置在一列的开始位置,所以其它所有卡片都需要向右移动一个位置以腾出空间。这就是插入排序的工作方式。
插入排序的代码如下,后面有其工作方式的解释:
1 public void InsertionSort() 2 { 3 int inner, temp; 4 for (int outer = 1; outer <= upper; outer++) 5 { 6 temp = arr[outer]; 7 inner = outer; 8 while (inner > 0 && arr[inner - 1] >= temp) 9 { 10 arr[inner] = arr[inner - 1]; 11 inner -= 1; 12 } 13 arr[inner] = temp; 14 } 15 }
插入排序有两个循环。外部循环在数组中遍历各元素同时内部循环比较外部循环中选择的元素与数组中与其相邻的前面一个元素的大小。如果外部循环中选择的元素比内部循环中选择的元素小,数组中这些比内部循环中选择元素大的元素将右移以为内部循环选择的元素腾出空间,正如之前的例子描述的那样。
我们来看一下在之前例子中用于展示排序的数组使用插入排序的情况。如下是输出:
这清楚的展示了插入排序不是进行交换,而是将较大的元素向右移动以给较小的元素在数组的左侧留出空间。
基本排序算法的耗时对比
这三种排序算法在复杂度与理论上都非常相似,至少,每一个与其它两个对比表现都相似。我们可以使用Timing类来比较这三个算法来判断在排序一个较大的数集时的耗时是否有一个较其余二者突出。
要完成测试,我们使用前面使用过的相同的基础代码来演示每个算法的工作。然而在下面的测试中,数组的大小是不同的以展示3中算法在对较小的数集与较大的数集时的表现。时间测试分别使用100个元素,1000个元素及10000个元素大小的数据来执行。
如下是代码:
1 static void Main() 2 { 3 Timing sortTime = new Timing(); 4 Random rnd = new Random(100); 5 int numItems = 1000; 6 CArray theArray = new CArray(numItems); 7 for (int i = 0; i < numItems; i++) 8 theArray.Insert((int)(rnd.NextDouble() * 100)); 9 sortTime.startTime(); 10 theArray.SelectionSort(); 11 sortTime.stopTime(); 12 Console.WriteLine("Time for Selection sort: " + sortTime.getResult().TotalMilliseconds); 13 theArray.Clear(); 14 for (int i = 0; i < numItems; i++) 15 theArray.Insert((int)(rnd.NextDouble() * 100)); 16 sortTime.startTime(); 17 theArray.BubbleSort(); 18 sortTime.stopTime(); 19 Console.WriteLine("Time for Bubble sort: " + sortTime.getResult().TotalMilliseconds); 20 theArray.Clear(); 21 for (int i = 0; i < numItems; i++) 22 theArray.Insert((int)(rnd.NextDouble() * 100)); 23 sortTime.startTime(); 24 theArray.InsertionSort(); 25 sortTime.stopTime(); 26 Console.WriteLine("Time for Selection sort: " + sortTime.getResult().TotalMilliseconds); 27 }
程序的输出是:
Time for Selection sort: 10.0144
Time for Bubble sort: 10.0144
Time for Insertion sort: 20.0288
可以看出选择与冒泡排序执行的速度相同,而插入排序大约要慢两倍。
让我们来比较当数组大小为1000个时算法的效率:
Time for Selection sort: 40.0576
Time for Bubble sort: 500.72
Time for Insertion sort: 871.2528
如上,我们看到数组的大小对对算法的性能产生很大的影响。选择排序大约比冒泡排序快100多倍,比插入排序快200多倍。
当我们将数组大小提升到10000个以后,我们真正的看到数组大小对三个算法的影响:
Time for Selection sort: 2864.1184
Time for Bubble sort: 53607.0832
Time for Insertion sort: 84751.8672
三种算法的性能都很明显的降低,虽然选择排序仍然比其它两种排序快数倍。很明显,这三种算法对排序大的数据集都不是理想的。有针对大的数据集处理更有效率的排序算法。我们将在第16章学习它们的设计。
摘要
本章,我们讨论了排序数据的三种算法 – 选择排序,冒泡排序与插入排序。所有这些算法都很容易实现而且针对小数据集它们工作都很不错。选择排序是最有效率的算法,然后是冒泡排序和插入排序。正如我们在本章末尾看到的,这三种算法都不适合大的数据集。(例如超过几千个元素。)
练习
1. 创建一个至少包含100个字符串的数据文件。你可以自己创建列表,或者一些类型的文本文件中拷贝一些值,或者你甚至可以通过生成随机字符串来创建文件。使用本章前面讨论的每种排序算法来对文件进行排序。创建一个程序来测试每个算法的耗时,并输出与本章最后一节类似的耗时结果输出。
2. 创建一个1000个整数的数组并按数字排序。编写一个程序在这个数组上运行每一个算法,并比较耗时。比较这个排序一个随机整数数组的时间。
3. 创建一个1000个整数的数组并按数字逆序排序。编写一个程序在这个数组上运行每一个算法,给每种算法计时,并比较这个时间。