基础排序算法
基础数据结构的复习已经接近尾声,对基础排序与查找算法也有了更深的理解。本篇将总结三种基础排序算法和四种高级排序算法,以及二分查找的两种形式的实现。同时会讨论关于在.NET中算法运行时间的测量。
由于要讨论各种算法的效率,先记住几个名词。
-
时间复杂度:执行算法所需要的计算工作量。
-
空间复杂度:执行算法所需要消耗的内存空间,与时间复杂度统称算法复杂度。
-
排序算法稳定性:原序列中相同元素的相对顺序在排序后不发生改变。
(由于各算法实现在同一项目中且利用的是自己封装的数据源,所以算法中未说明的数据源可看作数组)
冒泡排序
思想:将较大数值浮动到列的右侧,将较小数值浮动到列的左侧。冒泡排序是可用的最慢的排序算法之一,但是是最容易理解的和实现的一种排序算法。
性能分析:若记录序列的初始状态为"正序",则冒泡排序过程只需进行一趟排序,在排序过程中只需进行n-1次比较,且无移动记录;反之,若记录序列的初始状态为"逆序",则需进行n(n-1)/2次比较和移动。因此冒泡排序总的时间复杂度为O(n*n)。冒泡排序是一种稳定的排序算法。
实现:
/// <summary>
/// 冒泡排序
/// </summary>
public void BubbleSort()
{
for (int j = d.count; j> 0; j--)
{
for (int i = 0; i < j-1 ; i++)//每过一轮,之后的轮次的比较次数都少1
{
if (d[i] > d[i + 1])
{
int temp = d[i];
d[i] = d[i + 1];
d[i + 1] = temp;
}
}
}
}
选择排序
思想:从乱序序列中选择最小值放与第一个元素交换位置,接着从除第一个元素外的乱序序列中选择最小值与第二个元素交换位置,重复操作。
性能分析:选择排序的交换操作介于 0 和 (n - 1)次之间。比较次数与关键字的初始状态无关,总的比较次数N=(n-1)+(n-2)+...+1=n*(n-1)/2。比较次数O(n^2),交换次数O(n),最好情况是,已经有序,交换0次;最坏情况是,逆序,交换n-1次。交换次数比冒泡排序少多了,由于交换所需CPU时间比比较所需的CPU时间多,n值较小时,选择排序比冒泡排序快,但是选择排序是一种不稳定的排序算法(基础排序算法中唯一的不稳定排序算法)。
实现:
/// <summary>
/// 选择排序
/// </summary>
public void SelectionSort()
{
int min,temp;
for (int i = 0; i < d.count; i++)
{
min=i;
for (int j = i+1; j < d.count; j++)
{
if (d[min]>d[j])
{
min=j;
}
}
temp = d[min];
d[min] = d[i];
d[i] = temp;
}
}
插入排序
思想:将序列分为有序和无序两部分(初始可令第一个元素为有序序列),将无序数列的第一个元素与有序数列的元素从后往前逐个进行比较,找出插入位置,将该元素插入到有序数列的合适位置中。
性能分析:要插入的记录个数为n-1,其中关键字的比较次数和记录移动次数是依赖于给出的待排序序列是否基本有序。在最佳情况下(待排序序列有序),比较次数和移动次数时间为o(1),所以时间复杂度为o(n).在最坏情况下(待排序序列逆序)和平均时间均为o(n^2).从上述分析中可以看出,直接插入排序适合记录数比较少、给定序列基本有序的情况。熟悉了排序过程我们发现,直接插入排序是一种稳定的排序算法。
实现:
/// <summary>
/// 插入排序
/// </summary>
public void InsertSort()
{
for (int i = 1; i < d.count; i++)
{
int currrenitem = d[i];//暂存起来,待找到合适位置插入
int currentkey = i;
while (currentkey > 0 && currrenitem < d[currentkey - 1])
{
d[currentkey] = d[currentkey - 1];
currentkey-=1;
}
d[currentkey] = currrenitem;
}
}
希尔排序
思想:就根本而言,希尔排序是插入排序的一种改进。其关键内容是对远距离而非相邻的数据进行比较。当算法循环遍历数据集合的时候,数据项之间的距离会缩短,直到算法对相邻的数据项进行比较为止。
性能分析:希尔排序的执行时间依赖于增量序列。好的增量序列的共同特征:
最后一个增量必须为1;
应该尽量避免序列中的值(尤其是相邻的值)互为倍数的情况。
有人通过大量的实验,给出了目前较好的结果:当n较大时,比较和移动的次数约在nl.25到1.6n1.25之间。在希尔排序开始时增量较大,分组较多,每组的记录数目少,故各组内直接插入较快,后来增量di逐渐缩小,分组数逐渐减少,而各组的记录数目逐渐增多,但由于已经按di-1作为距离排过序,使文件较接近于有序状态,所以新的一趟排序过程也较快。
实现:
/// <summary>
/// 希尔排序
/// </summary>
public void ShellSort()
{
int increment = 1;
for (; increment <= this.d.count / 9; increment = 3 * increment + 1) ;//得到最佳初始增量
for ( ; increment>0; increment/=3)//每一轮过后都改变增量
{
for (int i = increment; i < this.d.count; i+=increment)//对指定增量位置上的数据执行插入排序
{
int currentIndex = i;
int currentItem = d[i];
while (currentIndex >= increment && currentItem < d[currentIndex - increment])
{
d[currentIndex] = d[currentIndex - increment];
currentIndex -= increment;
}
d[currentIndex] = currentItem;
}
}
}
归并排序
思想:归并排序是很好的递归算法的实例。它把序列分成两部分,然后对每部分递归的进行排序。当两部分都排好序后再把它们组合到一起。
性能分析:
时间复杂度为O(nlogn) 这是该算法中最好、最坏和平均的时间性能。
空间复杂度为 O(n)
比较操作的次数介于(nlogn) / 2和nlogn - n + 1。
赋值操作的次数是(2nlogn)。
归并排序比较占用内存,但却效率高且稳定的算法(是高级排序算法中唯一稳定的排序算法)
实现:
/// <summary>
/// 递归进行归并排序
/// </summary>
/// <param name="t">待排序数据源</param>
/// <param name="first">数据源不为空的最小索引</param>
/// <param name="last">数据源不为空的最大索引</param>
public void mergersort(DataSource t,int first ,int last)
{
if (first<last)
{
int mid = (first + last) / 2;//得到中间索引
mergersort(t, first, mid);//递归左边序列
mergersort(t, mid + 1, last);//递归右边序列
mergersortover(t,first,mid,last);//合并左右为有序序列的序列
}
}
/// <summary>
/// 对排好的序列进行合并
/// </summary>
/// <param name="t">左右部分是有序序列的数据源</param>
/// <param name="first">数据源不为空的最小索引<</param>
/// <param name="mid">中间索引</param>
/// <param name="last">数据源不为空的最大索引</param>
public void mergersortover( DataSource t, int first, int mid, int last)
{
int forwardindex = first;//左边序列的第一个索引
int latterindex = mid + 1;//右边序列的第一个索引
DataSource result = new DataSource(last-first+1);//暂时存放排好序的元素
while(forwardindex <=mid&&latterindex <=last)
{
if (t[forwardindex]<t[latterindex])//左边序列元素小于右边序列元素
{
result.Add(t[forwardindex++]);
}
else //右边序列元素小于等于左边序列元素
{
result.Add(t[latterindex++]);
}
}
//处理左序列或者右序列中未加入排好序序列的元素
while (forwardindex<=mid)
{
result.Add(t[forwardindex++]);
}
while (latterindex <=last)
{
result.Add(t[latterindex++]);
}
//将排好序的序列反应到原数据源
for (int i = first; i <= last ; i++)
{
t[i] = result[i-first];
}
}
堆排序
思想:堆排序利用一种称为堆的数据结构来实现排序。堆和二叉树较类似,但是又有显著的不同。首先,构造堆通常采用的是数组而不是节点。并且堆有两个非常重要的条件:(1)堆必须是完整的,意味着每一位都有数据填充;(2)每个节点(用节点是方便描述,但实际上存储是在数组中)所包含的数据要大于(大根堆,故还有小根堆)或等于此节点下方的孩子节点所包含的数据。
因为堆顶元素最大,故在排序时我们可以取走堆顶元素作为有序序列的第一个元素放入有序序列中,接着将堆中剩余元素构造成堆,重复取走、构造操作。直到排序结束。
算法性能:
堆排序的时间,主要由建立初始堆和反复重建堆这两部分的时间开销构成。堆排序的最坏时间复杂度为O(nlogn)。堆序的平均性能较接近于最坏性能。由于建初始堆所需的比较次数较多,所以堆排序不适宜于记录数较少的文件。堆排序是就地排序,辅助空间为O(1),
它是不稳定的排序方法。
实现:
/// <summary>
/// 堆排序
/// </summary>
public void heapsort()
{
//初始化数据源为大根堆,完全二叉树的基本性质, 前半段为根结点,后半段为叶结点
for (int i = d.count / 2 - 1; i >= 0; i--)
{
BuildHeap(d, i, d.count);
}
//将数据源中的一位和最后一位交换(即将堆顶元素放到最后作为有序序列),同时对剩下
//的count-1位无序元素构造大根堆
for (int i = d.count-1; i >0; i--)
{
Swap(i, 0);
BuildHeap(d, 0, i);
}
}
/// <summary>
/// 构造大根堆
/// </summary>
/// <param name="ds">待构造数据源</param>
/// <param name="current">当前索引,用于构造堆</param>
/// <param name="count">元素数量</param>
public void BuildHeap(DataSource ds,int current,int count)
{
int left = 2 * current + 1;//左子节点
int right = 2 * current + 2;//右子节点
int large = current;
if (left<=count-1&&ds[left]>ds[large])
{
large = left;//若左子节点大,则改变large的值
}
if (right <=count-1 && ds[right] > ds[large])
{
large = right;//若右子节点大,则改变large的值
}
if (large!=current)//将新的大值与当前值换位
{
Swap(large, current);
BuildHeap(ds, large, count);//此时large值为之前的左子节点或右子节点,再次与它的子节点比较是否需要交换
}
}
/// <summary>
/// 交换元素
/// </summary>
/// <param name="a"></param>
/// <param name="b"></param>
public void Swap( int a, int b)
{
int temp = d[a];
d[a] = d[b];
d[b] = temp;
}
快速排序
思想:快速排序是一种改进了的冒泡排序。通过一趟排序将要排序的数据分割成独立的两部分,其中一部分的所有数据都比另外一部分的所有数据都要小,然后再按此方法对这两部分数据分别进行快速排序,整个排序过程可以递归进行,以此达到整个数据变成有序序列 。
性能分析:在最优情况下,它的排序时间复杂度为O(nlog2n)。即每次划分序列时,能均匀分成两个子串。但最差情况下它的时间复杂度将是O(n^2)。即每次划分子串时,一串为空。
实现:
/// <summary>
/// 快速排序主函数
/// </summary>
/// <param name="ds">待排序序列</param>
/// <param name="left">待排序序列最左索引</param>
/// <param name="right">待排序序列最右索引</param>
public void quicksort(DataSource ds,int left,int right)
{
int keyvalue;
if(left < right)
{
//快速排序的核心步骤,通过找到关键值的位置来排序,使得在关键值左边的值小于关键值,右边的值大于关键值
keyvalue = GetKeyValuePosition(ds,left,right);
//递归调用实现对左右序列的排序
quicksort(ds, left, keyvalue-1);
quicksort(ds, keyvalue + 1, right);
}
}
/// <summary>
/// 找到关键值的位置
/// </summary>
/// <param name="ds">待排序序列</param>
/// <param name="left">待排序序列最左索引</param>
/// <param name="right">待排序序列最右索引</param>
/// <returns>关键值位置</returns>
public int GetKeyValuePosition(DataSource ds, int left, int right)
{
int leftindex = left;
int rightindex = right;
int key = left;
while (leftindex < rightindex)
{
while (ds[leftindex] <= ds[key] && leftindex < rightindex)//左端缩进,直到值大于关键值
{
leftindex++;
}
while (ds[rightindex] > ds[key] && rightindex > leftindex)//右端缩进,直到值小于关键值
{
rightindex--;
}
if (leftindex<rightindex)//将小值放在左边,大值放在右边
{
int temp = ds[leftindex];
ds[leftindex] = ds[rightindex];
ds[rightindex] = temp;
}
}
//当rightindex==leftindex,可确定关键值的位置
if (ds[key] < ds[rightindex])
{
int temp = ds[key];
ds[key] = ds[rightindex - 1];
ds[rightindex - 1] = temp;
return rightindex - 1;
}
else
{
int temp = ds[key];
ds[key] = ds[rightindex];
ds[rightindex] = temp;
return rightindex;
}
}
二分查找算法
写排序算法时顺带写了二分查找算法的while版本和递归版本,同时支持查找当序列中有相同元素。
二分查找:
二分查找又称折半查找,优点是比较次数少,查找速度快,平均性能好;其缺点是要求待查表为有序表,且插入删除困难。因此,折半查找方法适用于不经常变动而查找频繁的有序列表。首先,假设表中元素是按升序排列,将表中间位置记录的关键字与查找关键字比较,如果两者相等,则查找成功;否则利用中间位置记录将表分成前、后两个子表,如果中间位置记录的关键字大于查找关键字,则进一步查找前一子表,否则进一步查找后一子表。重复以上过程,直到找到满足条件的记录,使查找成功,或直到子表不存在为止,此时查找不成功。
while版本实现:
/// <summary>
/// binary search
/// </summary>
/// <param name="value"></param>
/// <returns></returns>
public ArrayList binSearch_while(int value)
{
int low = 0;
int up = count;
int mid;
ArrayList result =new ArrayList();
while(low<=up)
{
mid = (low + up) / 2;
if (arr[mid] == value)
{
result.Add(mid);
int remid=mid;
while(remid<count-1&&mid>1)
{
while(arr[--mid]==value)
{
result.Add(mid);
if (mid==0)
{
break;
}
}
while(arr[++remid] == value )
{
result.Add(remid);
if (remid==count-1)
{
break;
}
}
}
return result;
}
else
{
if (arr[mid] < value)
{
low = mid+1;
}
else
up = mid-1;
}
}
return result;
}
递归版本实现:
/// <summary>
/// binary search
/// </summary>
/// <param name="value"></param>
/// <param name="low"></param>
/// <param name="up"></param>
/// <returns></returns>
public ArrayList binsearch_Recursion(int value, int low, int up)
{
ArrayList result = new ArrayList();
if (low > up)
{
return result;
}
else
{
int mid;
mid = (int)(low + up) / 2;
if (value < arr[mid])
{
return binsearch_Recursion(value, low, mid - 1);
}
else if (value > arr[mid])
{
return binsearch_Recursion(value, mid + 1, up);
}
else
{
result.Add(mid);
int remid = mid;
while (arr[--mid] == value)
{
result.Add(mid);
if (mid==0)
{
break;
}
}
while (arr[++remid] == value)
{
result.Add(remid);
if (remid==count-1)
{
break;
}
}
return result;
}
}
}
算法运行时间测量
接下来说说测试测量执行排序算法消耗的时间。很容易想到的是用如下这种方式测试时间:
DateTime startTime;
DateTime stopTime;
TimeSpan during;
startTime=DateTime.Now;
//程序代码
stopTime=DateTime.Now;
during=stopTime.Subtract(startTime);
用这种方法测量非常不准确,有两个原因:
1.代码测量的是从子程序调用开始到子程序返回主程序之间的时间,但是这种方式测量的时间也包括了与c#程序同时运行的其他进程所用的时间。
2..net环境的一个众所周知的特征是自动执行的垃圾回收。在运行.net程序是,系统可能在执行垃圾回收的任何一个时间暂停,这也导致了上述方式的误差。
改进方法:
1.用.net提供的process类获取当前进程(程序运行在其内的进程),同时获得该进程类的线程。通过获取线程执行的时间来精确时间测量。
2.对于可能发生在任何时候的垃圾回收,我们在运行测试代码时需要确保在执行的代码尽量少的垃圾回收。故可以在执行测量代码前先强制调用垃圾回收器来进行专门的垃圾回收。.net中执行垃圾回收的对象是GC。
在测试算法时间消耗时,我把时间测量封装到了一个Timing类中,代码如下:
using System;
using System.Diagnostics;
namespace Sorts
{
class Timing
{
TimeSpan startTime;
TimeSpan duration;
public Timing()
{
startTime = new TimeSpan();
duration = new TimeSpan();
}
public void StopIt()
{
duration = Process.GetCurrentProcess().TotalProcessorTime.Subtract(startTime);
}
public void StartIt()
{
GC.Collect();
GC.WaitForPendingFinalizers();
startTime = Process.GetCurrentProcess().TotalProcessorTime;
}
public TimeSpan Result()
{
return duration;
}
}
}
终于写完了,果断回宿舍怒刀一把~~~
date: 2013-04-26 19:28:57