: 一 栈和队列
http://www.cnblogs.com/yangecnu/p/Introduction-Stack-and-Queue.html
最近晚上在家里看Algorithems,4th Edition,我买的英文版,觉得这本书写的比较浅显易懂,而且“图码并茂”,趁着这次机会打算好好学习做做笔记,这样也会印象深刻,这也是写这一系列文章的原因。另外普林斯顿大学在Coursera 上也有这本书同步的公开课,还有另外一门算法分析课,这门课程的作者也是这本书的作者,两门课都挺不错的。
计算机程序离不开算法和数据结构,本文简单介绍栈(Stack)和队列(Queue)的实现,.NET中与之相关的数据结构,典型应用等,希望能加深自己对这两个简单数据结构的理解。
1. 基本概念
概念很简单,栈 (Stack)是一种后进先出(last in first off,LIFO)的数据结构,而队列(Queue)则是一种先进先出 (fisrt in first out,FIFO)的结构,如下图:
2. 实现
现在来看如何实现以上的两个数据结构。在动手之前,Framework Design Guidelines这本书告诉我们,在设计API或者实体类的时候,应当围绕场景编写API规格说明书。
1.1 Stack的实现
栈是一种后进先出的数据结构,对于Stack 我们希望至少要对外提供以下几个方法:
Stack<T>() |
创建一个空的栈 |
void Push(T s) |
往栈中添加一个新的元素 |
T Pop() |
移除并返回最近添加的元素 |
boolean IsEmpty() |
栈是否为空 |
int Size() |
栈中元素的个数 |
要实现这些功能,我们有两中方法,数组和链表,先看链表实现:
栈的链表实现:
我们首先定义一个内部类来保存每个链表的节点,该节点包括当前的值以及指向下一个的值,然后建立一个节点保存位于栈顶的值以及记录栈的元素个数;
class Node { public T Item{get;set;} public Node Next { get; set; } }
private Node first = null; private int number = 0;
现在来实现Push方法,即向栈顶压入一个元素,首先保存原先的位于栈顶的元素,然后新建一个新的栈顶元素,然后将该元素的下一个指向原先的栈顶元素。整个Pop过程如下:
实现代码如下:
void Push(T node) { Node oldFirst = first; first = new Node(); first.Item= node; first.Next = oldFirst; number++; }
Pop方法也很简单,首先保存栈顶元素的值,然后将栈顶元素设置为下一个元素:
T Pop() { T item = first.Item; first = first.Next; number--; return item; }
基于链表的Stack实现,在最坏的情况下只需要常量的时间来进行Push和Pop操作。
栈的数组实现:
我们可以使用数组来存储栈中的元素Push的时候,直接添加一个元素S[N]到数组中,Pop的时候直接返回S[N-1].
首先,我们定义一个数组,然后在构造函数中给定初始化大小,Push方法实现如下,就是集合里添加一个元素:
T[] item; int number = 0; public StackImplementByArray(int capacity) { item = new T[capacity]; }
public void Push(T _item) { if (number == item.Length) Resize(2 * item.Length); item[number++] = _item; }
Pop方法:
public T Pop() { T temp = item[--number]; item[number] = default(T); if (number > 0 && number == item.Length / 4) Resize(item.Length / 2); return temp; }
在Push和Pop方法中,为了节省内存空间,我们会对数组进行整理。Push的时候,当元素的个数达到数组的Capacity的时候,我们开辟2倍于当前元素的新数组,然后将原数组中的元素拷贝到新数组中。Pop的时候,当元素的个数小于当前容量的1/4的时候,我们将原数组的大小容量减少1/2。
Resize方法基本就是数组复制:
private void Resize(int capacity) { T[] temp = new T[capacity]; for (int i = 0; i < item.Length; i++) { temp[i] = item[i]; } item = temp; }
当我们缩小数组的时候,采用的是判断1/4的情况,这样效率要比1/2要高,因为可以有效避免在1/2附件插入,删除,插入,删除,从而频繁的扩大和缩小数组的情况。下图展示了在插入和删除的情况下数组中的元素以及数组大小的变化情况:
分析:1. Pop和Push操作在最坏的情况下与元素个数成比例的N的时间,时间主要花费在扩大或者缩小数组的个数时,数组拷贝上。
2. 元素在内存中分布紧凑,密度高,便于利用内存的时间和空间局部性,便于CPU进行缓存,较LinkList内存占用小,效率高。
2.2 Queue的实现
Queue是一种先进先出的数据结构,和Stack一样,他也有链表和数组两种实现,理解了Stack的实现后,Queue的实现就比较简单了。
Stack<T>() |
创建一个空的队列 |
void Enqueue(T s) |
往队列中添加一个新的元素 |
T Dequeue() |
移除队列中最早添加的元素 |
boolean IsEmpty() |
队列是否为空 |
int Size() |
队列中元素的个数 |
首先看链表的实现:
Dequeue方法就是返回链表中的第一个元素,这个和Stack中的Pop方法相似:
public T Dequeue() { T temp = first.Item; first = first.Next; number--; if (IsEmpety()) last = null; return temp; }
Enqueue和Stack的Push方法不同,他是在链表的末尾增加新的元素:
public void Enqueue(T item) { Node oldLast = last; last = new Node(); last.Item = item; if (IsEmpety()) { first = last; } else { oldLast.Next = last; } number++; }
同样地,现在再来看如何使用数组来实现Queue,首先我们使用数组来保存数据,并定义变量head和tail来记录Queue的首尾元素。
和Stack的实现方式不同,在Queue中,我们定义了head和tail来记录头元素和尾元素。当enqueue的时候,tial加1,将元素放在尾部,当dequeue的时候,head减1,并返回。
public void Enqueue(T _item) { if ((head - tail + 1) == item.Length) Resize(2 * item.Length); item[tail++] = _item; } public T Dequeue() { T temp = item[--head]; item[head] = default(T); if (head > 0 && (tail - head + 1) == item.Length / 4) Resize(item.Length / 2); return temp; } private void Resize(int capacity) { T[] temp = new T[capacity]; int index = 0; for (int i = head; i < tail; i++) { temp[++index] = item[i]; } item = temp; }
3. .NET中的Stack和Queue
在.NET中有Stack和Queue泛型类,使用Reflector工具可以查看其具体实现。先看Stack的实现,下面是截取的部分代码,仅列出了Push,Pop方法,其他的方法希望大家自己使用Reflector查看:
可以看到.NET中的Stack的实现和我们之前写的差不多,也是使用数组来实现的。.NET中Stack的初始容量为4,在Push方法中,可以看到当元素个数达到数组长度时,扩充2倍容量,然后将原数组拷贝到新的数组中。Pop方法和我们之前实现的基本上相同,下面是具体代码,只截取了部分:
[Serializable, ComVisible(false), DebuggerTypeProxy(typeof(System_StackDebugView<>)), DebuggerDisplay("Count = {Count}"), __DynamicallyInvokable] public class Stack<T> : IEnumerable<T>, ICollection, IEnumerable { // Fields private T[] _array; private const int _defaultCapacity = 4; private static T[] _emptyArray; private int _size; private int _version; // Methods static Stack() { Stack<T>._emptyArray = new T[0]; } [__DynamicallyInvokable] public Stack() { this._array = Stack<T>._emptyArray; this._size = 0; this._version = 0; } [__DynamicallyInvokable] public Stack(int capacity) { if (capacity < 0) { ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.capacity, ExceptionResource.ArgumentOutOfRange_NeedNonNegNumRequired); } this._array = new T[capacity]; this._size = 0; this._version = 0; } [__DynamicallyInvokable] public void CopyTo(T[] array, int arrayIndex) { if (array == null) { ThrowHelper.ThrowArgumentNullException(ExceptionArgument.array); } if ((arrayIndex < 0) || (arrayIndex > array.Length)) { ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.arrayIndex, ExceptionResource.ArgumentOutOfRange_NeedNonNegNum); } if ((array.Length - arrayIndex) < this._size) { ThrowHelper.ThrowArgumentException(ExceptionResource.Argument_InvalidOffLen); } Array.Copy(this._array, 0, array, arrayIndex, this._size); Array.Reverse(array, arrayIndex, this._size); } [__DynamicallyInvokable] public T Pop() { if (this._size == 0) { ThrowHelper.ThrowInvalidOperationException(ExceptionResource.InvalidOperation_EmptyStack); } this._version++; T local = this._array[--this._size]; this._array[this._size] = default(T); return local; } [__DynamicallyInvokable] public void Push(T item) { if (this._size == this._array.Length) { T[] destinationArray = new T[(this._array.Length == 0) ? 4 : (2 * this._array.Length)]; Array.Copy(this._array, 0, destinationArray, 0, this._size); this._array = destinationArray; } this._array[this._size++] = item; this._version++; } // Properties [__DynamicallyInvokable] public int Count { [__DynamicallyInvokable, TargetedPatchingOptOut("Performance critical to inline this type of method across NGen image boundaries")] get { return this._size; } } }
下面再看看Queue的实现:
[Serializable, DebuggerDisplay("Count = {Count}"), ComVisible(false), DebuggerTypeProxy(typeof(System_QueueDebugView<>)), __DynamicallyInvokable] public class Queue<T> : IEnumerable<T>, ICollection, IEnumerable { // Fields private T[] _array; private const int _DefaultCapacity = 4; private static T[] _emptyArray; private int _head; private int _size; private int _tail; private int _version; // Methods static Queue() { Queue<T>._emptyArray = new T[0]; } public Queue() { this._array = Queue<T>._emptyArray; } public Queue(int capacity) { if (capacity < 0) { ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.capacity, ExceptionResource.ArgumentOutOfRange_NeedNonNegNumRequired); } this._array = new T[capacity]; this._head = 0; this._tail = 0; this._size = 0; } public T Dequeue() { if (this._size == 0) { ThrowHelper.ThrowInvalidOperationException(ExceptionResource.InvalidOperation_EmptyQueue); } T local = this._array[this._head]; this._array[this._head] = default(T); this._head = (this._head + 1) % this._array.Length; this._size--; this._version++; return local; } public void Enqueue(T item) { if (this._size == this._array.Length) { int capacity = (int)((this._array.Length * 200L) / 100L); if (capacity < (this._array.Length + 4)) { capacity = this._array.Length + 4; } this.SetCapacity(capacity); } this._array[this._tail] = item; this._tail = (this._tail + 1) % this._array.Length; this._size++; this._version++; } private void SetCapacity(int capacity) { T[] destinationArray = new T[capacity]; if (this._size > 0) { if (this._head < this._tail) { Array.Copy(this._array, this._head, destinationArray, 0, this._size); } else { Array.Copy(this._array, this._head, destinationArray, 0, this._array.Length - this._head); Array.Copy(this._array, 0, destinationArray, this._array.Length - this._head, this._tail); } } this._array = destinationArray; this._head = 0; this._tail = (this._size == capacity) ? 0 : this._size; this._version++; } public int Count { [__DynamicallyInvokable, TargetedPatchingOptOut("Performance critical to inline this type of method across NGen image boundaries")] get { return this._size; } } }
可以看到.NET中Queue的实现也是基于数组的,定义了head和tail,当长度达到数组的容量的时候,使用了SetCapacity方法来进行扩容和拷贝。
4. Stack和Queue的应用
Stack这种数据结构用途很广泛,比如编译器中的词法分析器、Java虚拟机、软件中的撤销操作、浏览器中的回退操作,编译器中的函数调用实现等等。
4.1 线程堆 (Thread Stack)
线程堆是操作系型系统分配的一块内存区域。通常CPU上有一个特殊的称之为堆指针的寄存器 (stack pointer) 。在程序初始化时,该指针指向栈顶,栈顶的地址最大。CPU有特殊的指令可以将值Push到线程堆上,以及将值Pop出堆栈。每一次Push操作都将值存放到堆指针指向的地方,并将堆指针递减。每一次Pop都将堆指针指向的值从堆中移除,然后堆指针递增,堆是向下增长的。Push到线程堆,以及从线程堆中Pop的值都存放到CPU的寄存器中。
当发起函数调用的时候,CPU使用特殊的指令将当前的指令指针(instruction pointer),如当前执行的代码的地址压入到堆上。然后CPU通过设置指令指针到函数调用的地址来跳转到被调用的函数去执行。当函数返回值时,旧的指令指针从堆中Pop出来,然后从该指令地址之后继续执行。
当进入到被调用的函数中时,堆指针减小来在堆上为函数中的局部变量分配更多的空间。如果函数中有一个32位的变量分配到了堆中,当函数返回时,堆指针就返回到之前的函数调用处,分配的空间就会被释放。
如果函数有参数,这些参数会在函数调用之前就被分配在堆上,函数中的代码可以从当前堆往上访问到这些参数。
线程堆是一块有一定限制的内存空间,如果调用了过多的嵌套函数,或者局部变量分配了过多的内存空间,就会产生堆栈溢出的错误。
下图简单显示了线程堆的变化情况。
4.2 算术表达式的求值
Stack使用的一个最经典的例子就是算术表达式的求值了,这其中还包括前缀表达式和后缀表达式的求值。E. W. Dijkstra发明了使用两个Stack,一个保存操作值,一个保存操作符的方法来实现表达式的求值,具体步骤如下:
1) 当输入的是值的时候Push到属于值的栈中。
2) 当输入的是运算符的时候,Push到运算符的栈中。
3) 当遇到左括号的时候,忽略
4) 当遇到右括号的时候,Pop一个运算符,Pop两个值,然后将计算结果Push到值的栈中。
下面是在C#中的一个简单的括号表达式的求值:
/// <summary> /// 一个简单的表达式运算 /// </summary> /// <param name="args"></param> static void Main(string[] args) { Stack<char> operation = new Stack<char>(); Stack<Double> values = new Stack<double>(); //为方便,直接使用ToChar对于两位数的数组问题 Char[] charArray = Console.ReadLine().ToCharArray(); foreach (char s in charArray) { if (s.Equals(‘(‘)) { } else if (s.Equals(‘+‘)) operation.Push(s); else if (s.Equals(‘*‘)) operation.Push(s); else if (s.Equals(‘)‘)) { char op = operation.Pop(); if (op.Equals(‘+‘)) values.Push(values.Pop() + values.Pop()); else if (op.Equals(‘*‘)) values.Push(values.Pop() * values.Pop()); } else values.Push(Double.Parse(s.ToString())); } Console.WriteLine(values.Pop()); Console.ReadKey(); }
运行结果如下:
下图演示了操作栈和数据栈的变化。
在编译器技术中,前缀表达式,后缀表达式的求值都会用到堆。
4.3 Object-C中以及OpenGL中的图形绘制
在Object-C以及OpenGL中都存在”绘图上下文”,有时候我们对局部对象的绘图不希望影响到全局的设置,所以需要保存上一次的绘图状态。下面是Object-C中绘制一个圆形的典型代码:
- (void)drawGreenCircle:(CGContextRef)ctxt { UIGraphicsPushContext(ctxt); [[UIColor greenColor] setFill]; // draw my circle UIGraphicsPopContext(); } - (void)drawRect:(CGRect)aRect { CGContextRef context = UIGraphicsGetCurrentContext(); [[UIColor redColor] setFill]; // do some stuff [self drawGreenCircle:context]; // do more stuff and expect fill color to be red }
可以看到,在drawGreenCircle方法中,在设置填充颜色之前,我们Push保存了绘图上下文的信息,然后在设置当前操作的一些环境变量,绘制图形,绘制完成之后,我们Pop出之前保存的绘图上下文信息,从而不影响后面的绘图。
4.4 一些其他场景
有一个场景是利用stack 处理多余无效的请求,比如用户长按键盘,或者在很短的时间内连续按某一个功能键,我们需要过滤到这些无效的请求。一个通常的做法是将所有的请求都压入到堆中,然后要处理的时候Pop出来一个,这个就是最新的一次请求。
Queue的应用
在现实生活中Queue的应用也很广泛,最广泛的就是排队了,”先来后到” First come first service ,以及Queue这个单词就有排队的意思。
还有,比如我们的播放器上的播放列表,我们的数据流对象,异步的数据传输结构(文件IO,管道通讯,套接字等)
还有一些解决对共享资源的冲突访问,比如打印机的打印队列等。消息队列等。交通状况模拟,呼叫中心用户等待的时间的模拟等等。
5. 一点点感悟
本文简单介绍了Stack和Queue的原理及实现,并介绍了一些应用。
最后一点点感悟就是不要为了使用数据结构而使用数据结构。举个例子,之前看到过一个数组反转的问题,刚学过Stack可能会想,这个简单啊,直接将字符串挨个的Push进去,然后Pop出来就可以了,完美的解决方案。但是,这是不是最有效地呢,其实有更有效地方法,那就是以中间为对折,然后左右两边替换。
public static void Reverse(int[] array, int begin, int end) { while (end > begin) { int temp = array[begin]; array[begin] = array[end]; array[end] = temp; begin++; end--; } }
浅谈算法和数据结构: 二 基本排序算法
http://www.cnblogs.com/yangecnu/p/Introduction-Insertion-and-Selection-and-Shell-Sort.html
本篇开始学习排序算法。排序与我们日常生活中息息相关,比如,我们要从电话簿中找到某个联系人首先会按照姓氏排序、买火车票会按照出发时间或者时长排序、买东西会按照销量或者好评度排序、查找文件会按照修改时间排序等等。在计算机程序设计中,排序和查找也是最基本的算法,很多其他的算法都是以排序算法为基础,在一般的数据处理或分析中,通常第一步就是进行排序,比如说二分查找,首先要对数据进行排序。在Donald Knuth 的计算机程序设计的艺术这四卷书中,有一卷是专门介绍排序和查找的。
排序的算法有很多,在维基百科上有这么一个分类,另外大家有兴趣也可以直接上维基百科上看相关算法,本文也参考了上面的内容。
首先来看比较简单的选择排序(Selection sort),插入排序(Insertion sort),然后在分析插入排序的特征和缺点的基础上,介绍在插入排序基础上改进的希尔排序(Shell sort)。
一 选择排序
原理:
选择排序很简单,他的步骤如下:
- 从左至右遍历,找到最小(大)的元素,然后与第一个元素交换。
- 从剩余未排序元素中继续寻找最小(大)元素,然后与第二个元素进行交换。
- 以此类推,直到所有元素均排序完毕。
之所以称之为选择排序,是因为每一次遍历未排序的序列我们总是从中选择出最小的元素。下面是选择排序的动画演示:
实现:
算法实现起来也很简单,我们新建一个Sort泛型类,让该类型必须实现IComparable接口,然后我们定义SelectionSort方法,方法传入T数组,代码如下:
/// <summary> /// 排序算法泛型类,要求类型实现IComparable接口 /// </summary> /// <typeparam name="T"></typeparam> public class Sort<T> where T : IComparable<T> { /// <summary> /// 选择排序 /// </summary> /// <param name="array"></param> public static void SelectionSort(T[] array) { int n = array.Length; for (int i = 0; i < n; i++) { int min = i; //从第i+1个元素开始,找最小值 for (int j = i + 1; j < n; j++) { if (array[min].CompareTo(array[j]) > 0) min = j; } //找到之后和第i个元素交换 Swap(array, i, min); } } /// <summary> /// 元素交换 /// </summary> /// <param name="array"></param> /// <param name="i"></param> /// <param name="min"></param> private static void Swap(T[] array, int i, int min) { T temp = array[i]; array[i] = array[min]; array[min] = temp; } }
下图分析了选择排序中每一次排序的过程,您可以对照图中右边的柱状图来看。
测试如下:
static void Main(string[] args) { Int32[] array = new Int32[] { 1, 3, 1, 4, 2, 4, 2, 3, 2, 4, 7, 6, 6, 7, 5, 5, 7, 7 }; Console.WriteLine("Before SelectionSort:"); PrintArray(array); Sort<Int32>.SelectionSort(array); Console.WriteLine("After SelectionSort:"); PrintArray(array); Console.ReadKey(); }
输出结果:
分析:
选择排序的在各种初始条件下的排序效果如下:
- 选择排序需要花费 (N – 1) + (N – 2) + ... + 1 + 0 = N(N- 1) / 2 ~ N2/2次比较 和 N-1次交换操作。
- 对初始数据不敏感,不管初始的数据有没有排好序,都需要经历N2/2次比较,这对于一些原本排好序,或者近似排好序的序列来说并不具有优势。在最好的情况下,即所有的排好序,需要0次交换,最差的情况,倒序,需要N-1次交换。
- 数据交换的次数较少,如果某个元素位于正确的最终位置上,则它不会被移动。在最差情况下也只需要进行N-1次数据交换,在所有的完全依靠交换去移动元素的排序方法中,选择排序属于比较好的一种。
二 插入排序
原理:
插入排序也是一种比较直观的排序方式。可以以我们平常打扑克牌为例来说明,假设我们那在手上的牌都是排好序的,那么插入排序可以理解为我们每一次将摸到的牌,和手中的牌从左到右依次进行对比,如果找到合适的位置则直接插入。具体的步骤为:
- 从第一个元素开始,该元素可以认为已经被排序
- 取出下一个元素,在已经排序的元素序列中从后向前扫描
- 如果该元素小于前面的元素(已排序),则依次与前面元素进行比较如果小于则交换,直到找到大于该元素的就则停止;
- 如果该元素大于前面的元素(已排序),则重复步骤2
- 重复步骤2~4 直到所有元素都排好序 。
下面是插入排序的动画演示:
实现:
在Sort泛型方法中,我们添加如下方法,下面的方法和上面的定义一样
/// <summary> /// 插入排序 /// </summary> /// <param name="array"></param> public static void InsertionSort(T[] array) { int n = array.Length; //从第二个元素开始 for (int i = 1; i < n; i++) { //从第i个元素开始,一次和前面已经排好序的i-1个元素比较,如果小于,则交换 for (int j = i; j > 0; j--) { if (array[j].CompareTo(array[j - 1]) < 0) { Swap(array, j, j - 1); } else//如果大于,则不用继续往前比较了,因为前面的元素已经排好序,比较大的大就是教大的了。 break; } } }
测试如下:
Int32[] array1 = new Int32[] { 1, 3, 1, 4, 2, 4, 2, 3, 2, 4, 7, 6, 6, 7, 5, 5, 7, 7 }; Console.WriteLine("Before InsertionSort:"); PrintArray(array1); Sort<Int32>.InsertionSort(array1); Console.WriteLine("After InsertionSort:"); PrintArray(array1); Console.ReadKey();
输出结果:
分析:
插入排序的在各种初始条件下的排序效果如下:
1. 插入排序平均需要N2/4次比较和N2/4 次交换。在最坏的情况下需要N2/2 次比较和交换;在最好的情况下只需要N-1次比较和0次交换。
先考虑最坏情况,那就是所有的元素逆序排列,那么第i个元素需要与前面的i-1个元素进行i-1次比较和交换,所有的加起来大概等于N(N- 1) / 2 ~ N2 / 2,在数组随机排列的情况下,只需要和前面一半的元素进行比较和交换,所以平均需要N2/4次比较和N2/4 次交换。
在最好的情况下,所有元素都排好序,只需要从第二个元素开始都和前面的元素比较一次即可,不需要交换,所以为N-1次比较和0次交换。
2. 插入排序中,元素交换的次数等于序列中逆序元素的对数。元素比较的次数最少为元素逆序元素的对数,最多为元素逆序的对数 加上数组的个数减1。
3.总体来说,插入排序对于部分有序序列以及元素个数比较小的序列是一种比较有效的方式。
如上图中,序列AEELMOTRXPS,中逆序的对数为T-R,T-P,T-S,R-P,X-S 6对。典型的部分有序队列的特征有:
- 数组中每个元素离最终排好序后的位置不太远
- 小的未排序的数组添加到大的已排好序的数组后面
- 数组中只有个别元素未排好序
对于部分有序数组,插入排序是比较有效的。当数组中逆元素的对数越低,插入排序要比其他排序方法要高效的多。
选择排序和插入排序的比较:
上图展示了插入排序和选择排序的动画效果。图中灰色的柱子是不用动的,黑色的是需要参与到比较中的,红色的是参与交换的。图中可以看出:
插入排序不会动右边的元素,选择排序不会动左边的元素;由于插入排序涉及到的未触及的元素要比插入的元素要少,涉及到的比较操作平均要比选择排序少一半。
三 希尔排序(Shell Sort)
原理:
希尔排序也称之为递减增量排序,他是对插入排序的改进。在第二部插入排序中,我们知道,插入排序对于近似已排好序的序列来说,效率很高,可以达到线性排序的效率。但是插入排序效率也是比较低的,他一次只能将数据向前移一位。比如如果一个长度为N的序列,最小的元素如果恰巧在末尾,那么使用插入排序仍需一步一步的向前移动和比较,要N-1次比较和交换。
希尔排序通过将待比较的元素划分为几个区域来提升插入排序的效率。这样可以让元素可以一次性的朝最终位置迈进一大步,然后算法再取越来越小的步长进行排序,最后一步就是步长为1的普通的插入排序的,但是这个时候,整个序列已经是近似排好序的,所以效率高。
如下图,我们对下面数组进行排序的时候,首先以4为步长,这是元素分为了LMPT,EHSS,ELOX,AELR几个序列,我们对这几个独立的序列进行插入排序,排序完成之后,我们减小步长继续排序,最后直到步长为1,步长为1即为一般的插入排序,他保证了元素一定会被排序。
希尔排序的增量递减算法可以随意指定,可以以N/2递减,只要保证最后的步长为1即可。
实现:
/// <summary> /// 希尔排序 /// </summary> /// <param name="array"></param> public static void ShellSort(T[] array) { int n = array.Length; int h = 1; //初始最大步长 while (h < n / 3) h = h * 3 + 1; while (h >= 1) { //从第二个元素开始 for (int i = 1; i < n; i++) { //从第i个元素开始,依次次和前面已经排好序的i-h个元素比较,如果小于,则交换 for (int j = i; j >= h; j = j - h) { if (array[j].CompareTo(array[j - h]) < 0) { Swap(array, j, j - h); } else//如果大于,则不用继续往前比较了,因为前面的元素已经排好序,比较大的大就是教大的了。 break; } } //步长除3递减 h = h / 3; } }
可以看到,希尔排序的实现是在插入排序的基础上改进的,插入排序的步长为1,每一次递减1,希尔排序的步长为我们定义的h,然后每一次和前面的-h位置上的元素进行比较。算法中,我们首先获取小于N/3 的最大的步长,然后逐步长递减至步长为1的一般的插入排序。
下面是希尔排序在各种情况下的排序动画:
分析:
1. 希尔排序的关键在于步长递减序列的确定,任何递减至1步长的序列都可以,目前已知的比较好的序列有:
- Shell‘s 序列: N/2 , N/4 , ..., 1 (重复除以2);
- Hibbard‘s 序列: 1, 3, 7, ..., 2k - 1 ;
- Knuth‘s 序列: 1, 4, 13, ..., (3k - 1) / 2 ;该序列是本文代码中使用的序列。
- 已知最好的序列是 Sedgewick‘s (Knuth的学生,Algorithems的作者)的序列: 1, 5, 19, 41, 109, ....
该序列由下面两个表达式交互获得:
- 1, 19, 109, 505, 2161,….., 9(4k – 2k) + 1, k = 0, 1, 2, 3,…
- 5, 41, 209, 929, 3905,…..2k+2 (2k+2 – 3 ) + 1, k = 0, 1, 2, 3, …
“比较在希尔排序中是最主要的操作,而不是交换。”用这样步长的希尔排序比插入排序和堆排序都要快,甚至在小数组中比快速排序还快,但是在涉及大量数据时希尔排序还是比快速排序慢。
2. 希尔排序的分析比较复杂,使用Hibbard’s 递减步长序列的时间复杂度为O(N3/2),平均时间复杂度大约为O(N5/4) ,具体的复杂度目前仍存在争议。
3. 实验表明,对于中型的序列( 万),希尔排序的时间复杂度接近最快的排序算法的时间复杂度nlogn。
四 总结
最后总结一下本文介绍的三种排序算法的最好最坏和平均时间复杂度。
名称 |
最好 |
平均 |
最坏 |
内存占用 |
稳定排序 |
插入排序 |
n |
n2 |
n2 |
1 |
是 |
选择排序 |
n2 |
n2 |
n2 |
1 |
否 |
希尔排序 |
n |
nlog2n 或 n3/2 |
依赖于增量递减序列目前最好的是 nlog2n |
1 |
否 |
希望本文对您了解以上三个基本的排序算法有所帮助,后面将会介绍合并排序和快速排序。
浅谈算法和数据结构: 三 合并排序
合并排序,顾名思义,就是通过将两个有序的序列合并为一个大的有序的序列的方式来实现排序。合并排序是一种典型的分治算法:首先将序列分为两部分,然后对每一部分进行循环递归的排序,然后逐个将结果进行合并。
合并排序最大的优点是它的时间复杂度为O(nlgn),这个是我们之前的选择排序和插入排序所达不到的。他还是一种稳定性排序,也就是相等的元素在序列中的相对位置在排序前后不会发生变化。他的唯一缺点是,需要利用额外的N的空间来进行排序。
一 原理
合并排序依赖于合并操作,即将两个已经排序的序列合并成一个序列,具体的过程如下:
- 申请空间,使其大小为两个已经排序序列之和,然后将待排序数组复制到该数组中。
- 设定两个指针,最初位置分别为两个已经排序序列的起始位置
- 比较复制数组中两个指针所指向的元素,选择相对小的元素放入到原始待排序数组中,并移动指针到下一位置
- 重复步骤3直到某一指针达到序列尾
- 将另一序列剩下的所有元素直接复制到原始数组末尾
该过程实现如下,注释比较清楚:
private static void Merge(T[] array, int lo, int mid, int hi) { int i = lo, j = mid + 1; //把元素拷贝到辅助数组中 for (int k = lo; k <= hi; k++) { aux[k] = array[k]; } //然后按照规则将数据从辅助数组中拷贝回原始的array中 for (int k = lo; k <= hi; k++) { //如果左边元素没了, 直接将右边的剩余元素都合并到到原数组中 if (i > mid) { array[k] = aux[j++]; }//如果右边元素没有了,直接将所有左边剩余元素都合并到原数组中 else if (j > hi) { array[k] = aux[i++]; }//如果左边右边小,则将左边的元素拷贝到原数组中 else if (aux[i].CompareTo(aux[j]) < 0) { array[k] = aux[i++]; } else { array[k] = aux[j++]; } } }
下图是使用以上方法将EEGMR和ACERT这两个有序序列合并为一个大的序列的过程演示:
二 实现
合并排序有两种实现,一种是至上而下(Top-Down)合并,一种是至下而上 (Bottom-Up)合并,两者算法思想差不多,这里仅介绍至上而下的合并排序。
至上而下的合并是一种典型的分治算法(Divide-and-Conquer),如果两个序列已经排好序了,那么采用合并算法,将这两个序列合并为一个大的序列也就是对大的序列进行了排序。
首先我们将待排序的元素均分为左右两个序列,然后分别对其进去排序,然后对这个排好序的序列进行合并,代码如下:
public class MergeSort<T> where T : IComparable<T> { private static T[] aux; // 用于排序的辅助数组 public static void Sort(T[] array) { aux = new T[array.Length]; // 仅分配一次 Sort(array, 0, array.Length - 1); } private static void Sort(T[] array, int lo, int hi) { if (lo >= hi) return; //如果下标大于上标,则返回 int mid = lo + (hi - lo) / 2;//平分数组 Sort(array, lo, mid);//循环对左侧元素排序 Sort(array, mid + 1, hi);//循环对右侧元素排序 Merge(array, lo, mid, hi);//对左右排好的序列进行合并 } ... }
以排序一个具有15个元素的数组为例,其调用堆栈为:
我们单独将Merge步骤拿出来,可以看到合并的过程如下:
三 图示及动画
如果以排序38,27,43,3,9,82,10为例,将合并排序画出来的话,可以看到如下图:
下图是合并排序的可视化效果图:
对6 5 3 1 8 7 24 进行合并排序的动画效果如下:
下图演示了合并排序在不同的情况下的效率:
四 分析
1. 合并排序的平均时间复杂度为O(nlgn)
证明:合并排序是目前我们遇到的第一个时间复杂度不为n2的时间复杂度为nlgn(这里lgn代表log2n)的排序算法,下面给出对合并排序的时间复杂度分析的证明:
假设D(N)为对整个序列进行合并排序所用的时间,那么一个合并排序又可以二分为两个D(N/2)进行排序,再加上与N相关的比较和计算中间数所用的时间。整个合并排序可以用如下递归式表示:
D(N)=2D(N/2)+N,N>1;
D(N)=0,N=1; (当N=1时,数组只有1个元素,已排好序,时间为0)
因为在分治算法中经常会用到递归式,所以在CLRS中有一章专门讲解递归式的求解和证明,使用主定理(master theorem)可以直接求解出该递归式的值,后面我会简单介绍。这里简单的列举两种证明该递归式时间复杂度为O(nlgn)的方法:
Prof1:处于方便性考虑,我们假设数组N为2的整数幂,这样根据递归式我们可以画出一棵树:
可以看到我们对数组N进行MergeSort的时候,是逐级划分的,这样就形成了一个满二叉树,树的每一及子节点都为N,树的深度即为层数lgN+1,满二叉树的深度的计算可以查阅相关资料,上图中最后一层子节点没有画出来。这样,这棵树有lgN+1层,每一层有N个节点,所以
D(N)=(lgN+1)N=NlgN+N=NlgN
Prof2:我们在为递归表达式求解的时候,还有一种常用的方法就是数学归纳法,
首先根据我们的递归表达式的初始值以及观察,我们猜想D(N)=NlgN.
- 当N=1 时,D(1)=0,满足初始条件。
- 为便于推导,假设N是2的整数次幂N=2k, 即D(2k)=2klg2k = k*2k
- 在N+1 的情况下D(N+1)=D(2k+1)=2k+1lg2k+1=(k+1) * 2k+1,所以假设成立,D(N)=NlgN.
2. 合并排序需要额外的长度为N的辅助空间来完成排序
如果对长度为N的序列进行排序需要<=clogN 的额外空间,认为就是就地排序(in place排序)也就是完成该排序操作需要较小的,固定数量的额外辅助内存空间。之前学习过的选择排序,插入排序,希尔排序都是原地排序。
但是在合并排序中,我们要创建一个大小为N的辅助排序数组来存放初始的数组或者存放合并好的数组,所以需要长度为N的额外辅助空间。当然也有前人已经将合并排序改造为了就地合并排序,但是算法的实现变得比较复杂。
需要额外N的空间来辅助排序是合并排序的最大缺点,如果在内存比较关心的环境中可能需要采用其他算法。
五 几点改进
对合并排序进行一些改进可以提高合并排序的效率。
1. 当划分到较小的子序列时,通常可以使用插入排序替代合并排序
对于较小的子序列(通常序列元素个数为7个左右),我们就可以采用插入排序直接进行排序而不用继续递归了),算法改造如下:
private const int CUTOFF = 7;//采用插入排序的阈值 private static void Sort(T[] array, int lo, int hi) { if (lo >= hi) return; //如果下标大于上标,则返回 if (hi <= lo + CUTOFF - 1) Sort<T>.SelectionSort(array, lo, hi); int mid = lo + (hi - lo) / 2;//平分数组 Sort(array, lo, mid);//循环对左侧元素排序 Sort(array, mid + 1, hi);//循环对右侧元素排序 Merge(array, lo, mid, hi);//对左右排好的序列进行合并 }
2. 如果已经排好序了就不用合并了
当已排好序的左侧的序列的最大值<=右侧序列的最小值的时候,表示整个序列已经排好序了。
算法改动如下:
private static void Sort(T[] array, int lo, int hi) { if (lo >= hi) return; //如果下标大于上标,则返回 if (hi <= lo + CUTOFF - 1) Sort<T>.SelectionSort(array, lo, hi); int mid = lo + (hi - lo) / 2;//平分数组 Sort(array, lo, mid);//循环对左侧元素排序 Sort(array, mid + 1, hi);//循环对右侧元素排序 if (array[mid].CompareTo(array[mid + 1]) <= 0) return; Merge(array, lo, mid, hi);//对左右排好的序列进行合并 }
3. 并行化
分治算法通常比较容易进行并行化,在浅谈并发与并行这篇文章中已经展示了如何对快速排序进行并行化(快速排序在下一篇文章中讲解),合并排序一样,因为我们均分的左右两侧的序列是独立的,所以可以进行并行,值得注意的是,并行化也有一个阈值,当序列长度小于某个阈值的时候,停止并行化能够提高效率,这些详细的讨论在浅谈并发与并行这篇文章中有详细的介绍了,这里不再赘述。
六 用途
合并排序和快速排序一样都是时间复杂度为nlgn的算法,但是和快速排序相比,合并排序是一种稳定性排序,也就是说排序关键字相等的两个元素在整个序列排序的前后,相对位置不会发生变化,这一特性使得合并排序是稳定性排序中效率最高的一个。在Java中对引用对象进行排序,Perl、C++、Python的稳定性排序的内部实现中,都是使用的合并排序。
七 结语
本文介绍了分治算法中比较典型的一个合并排序算法,这也是我们遇到的第一个时间复杂度为nlgn的排序算法,并简要对算法的复杂度进行的分析,希望本文对您理解合并排序有所帮助,下文将介绍快速排序算法。