八大排序的思想讲解与排序算法可视化

  可视化的动图可以帮助我们理解排序算法,在了解了排序算法的思想后,观察动图可以加深我们对排序算法的理解。

  本文全部代码已上传Gitee


一、插入排序

1.直接插入排序

  核心思想
  把一个数插入一个有序区间。

  实现方法:假设0—end是已经有序的区间,我们用x存储end后面一个位置的元素,表示要把x存储到0—end的有序区间中。
  如果end所指元素比x大,就把end所指的元素赋给后面一个位置的元素(相当于把end所指元素往后移动一个格子),然后end=end-1使end指向前一个元素,继续比较;
  如果end所指元素比x大,end后面的这个格子已经被我们空出来了,就把end的下一个位置的元素赋值成x,end从0开始一直循环到n-2就能把所有元素都插入进来了。

  可视化
请添加图片描述

void InsertSort(int* a, int n)
{
	assert(a);
	for (int i = 0; i <= n - 2; i++)
	{
		int end = i;
		int x = a[end + 1];
		//x已经保存了a[end + 1] 所以后面再覆盖也可以
		//因此end只能落在n-2
		while (end >= 0)
		{
			//如果end指的元素比x大 
			//那就往后挪
			if (a[end] > x)
			{
				a[end + 1] = a[end];
				--end;
			}
			else
			{
				break;
			}
		}
		//插入在最头上和插入在中间都在这里处理
		a[end + 1] = x;

	}
}

时间复杂度分析

  直接插入排序最坏情况是逆序(每插入一个都要移动,从第二个元素开始,第二个元素需要移动1次,第三个元素需要移动2次,…,第n个元素需要移动n-1次)
1 + 2 + 3 + . . . + n − 1 = n ( n − 1 ) 2 1+2+3+...+n-1=\frac{n(n-1)}{2} 1+2+3+...+n1=2n(n1)
  取最大项就是O(N^2).
  最好情况是已经有序或者基本有序,就只需要遍历一次数组(有序)或者偶尔几个元素需要移动几次格子再插入其他的直接插入在end所指元素后面就行(基本有序),故最好情况下时间复杂度是O(N)。

2.希尔排序

  插入排序面对逆序或不太有序的情况下效率比较低,但是面对基本有序的情况它是非常棒的排序(O(N))。

  核心思想:
  希尔排序就是在直接插入排序上优化,既然对基本有序的情况直接插入排序很棒,那我先分成gap组进行一个预排序(这个过程可以使数组基本有序),然后再进行一个直接插入排序,那么怎么样进行预排序呢?

预排序步骤:

  1. 单趟预排序

   按gap分组,分成gap组,gap>1,对每个组进行插入排序,使总体数组看起来接近有序
  实际上就是把0 0+gap 0+2gap…视为一组,1 1+gap 1+2gap…视为一组…对每一组进行直接插入排序,这样每一组都是有序的了,总体数组就比之前有有序多了
  那么对0,0+gap,0+2gap…这一组预排序的单趟排序代码如下(这里gap取3):

//分组的单趟
//按gap分组进行预排序
    int gap = 3;
    int end = 0;
    int x = a[end + gap];
	while (end >= 0)
	{
		if (a[end] > x)
		{
			a[end + gap] = a[end];
			end -= gap;
		}
		else
		{
			break;
		}
	}
	a[end + gap] = x;

  对所有组的预排序的代码如下(这里取gap=3):

//排完gap组
int gap = 3;
for (int j = 0; j < gap; ++j)
{
	for (int i = j; i < n - gap; i += gap)
	{
		int end = i;
		int x = a[end + gap];
		while (end >= 0)
		{
			if (a[end] > x)
			{
				a[end + gap] = a[end];
				end -= gap;
			}
			else
			{
				break;
			}
		}
		a[end + gap] = x;
   }
}

  上面的代码虽然清楚,但是不够简洁,我们可以对多组同时进行预排序,就好像把多组同时一锅炖了一样。对单趟多组预排序的代码改造如下:

  for (int i = 0; i < n - gap; i++)
  {
  	int end = i;
  	int x = a[end + gap];
  	while (end >= 0)
  	{
  		if (a[end] > x)
  		{
  		//如果end所指的元素比x大
  		//就把end所指元素往后移动,空出一个格子
  			a[end + gap] = a[end];
  			end -= gap;
  		}
  		else
  		{
  		//否则就跳出去,
  		//这样可以同时处理end小于0的情况(插入在最头上的情况)
  			break;
  		}
  	}
  	a[end + gap] = x;
  }

讨论一下预排序的时间复杂度

  与直接插入排序类似,最好情况是已经有序的时候,是O(N)(遍历一遍就行了)

  最坏情况:每一组都是逆序的,每一组的元素个数是[N/gap],这样的总共需要的循环次数是:gap*(1+2+3+…+[N/gap]-1)(套用最糟糕情况直接插入排序的循环次数,gap组)。

  观察这个总共需要的循环次数的函数,发现:

  gap越大 预排越快(gap=N,O(N)) ,但是因为分的组数太多了,排完后越接近无序
  gap越小 预排越慢(gap=1,O(N^2)),分的组数少排完后越接近有序

  1. 多趟分组预排序与最后的直接插入排序

  为了让最后进行插入排序的时候数组能更接近有序一些,我们可以加一个循环控制gap不断变化进行多趟分组预排序,并且把gap=1时,也就是最终进行直接插入排序耦合到while循环里,代码如下:

void ShellSort(int* a, int n)
{
	int gap = n;
	//多次预排序(gap > 1)+直接插入排序(gap == 1)
	while (gap > 1)//gap进去以后才/ 所以大于1就行
    //等于1可能会死循环 一直是1出不去
	{
        //两种预排序方法:
		//gap = gap / 2;//一次跳一半
		gap = gap / 3 + 1;
        //加一是为了保证最后一次gap小于3的时候
        //能够有gap等于1来表示直接插入排序
        //多组同时搞:
		for (int i = 0; i < n - gap; i++)
		{
			int end = i;
			int x = a[end + gap];
			while (end >= 0)
			{
				if (a[end] > x)
				{
					a[end + gap] = a[end];
					end -= gap;
				}
				else
				{
					break;
				}
			}
			a[end + gap] = x;
		}
	}
}

可视化
请添加图片描述

3.对比希尔排序和直接插入排序的速度

  纸上得来终觉浅,我们这里使用随机数生成10w个数比较希尔排序和直接插入排序的速度。

void TestVel()
{
	srand(time(0));
	const int N = 100000;
	int* a1 = (int*)malloc(sizeof(int) * N);
	int* a2 = (int*)malloc(sizeof(int) * N);
	for (int i = 0; i < N; ++i)
	{
		a1[i] = rand();
		a2[i] = a1[i];
	}
	int start1 = clock();
	InsertSort(a1, N);
	int end1 = clock();
	int start2 = clock();
	ShellSort(a2, N);
	int end2 = clock();
	printf("InsertSort:%d\n", end1 - start1);
	printf("ShellSort:%d\n", end2 - start2);
	free(a1);
	free(a2);
}

在这里插入图片描述
100w个数

在这里插入图片描述

4.希尔排序时间复杂度分析

估算

  最外层的while循环logn次(每次除2或除3),进去的预排序,一开始n很大的时候,时间复杂度接近O(n),后来n很小的时候,由于前面的预排序已经让它基本有序了,时间复杂度也是是O(n),所以时间复杂度大概是O(nlogn)。

正式数学运算
  严格来讲,希尔排序的时间复杂度的计算是一件十分困难的事情,《数据结构—用面向对象方法与C++描述》中的说法如下:

在这里插入图片描述

  所以从记忆结论的角度上大概是Knuth的
O ( N 1.25 ) O(N^{1.25}) O(N1.25)

二、选择排序

  选择排序的思想是通过某种方法选出最大或最小元素,把他放到正确位置。

1.直接选择排序

  思想:遍历一遍选出最大的元素和最小的元素,分别与最后一个位置和第一个位置交换一下,然后从第二个元素到倒数第二个元素重新进行一次选择排序,直到区间长度小于等于1为止。

  可视化
请添加图片描述

  代码

void swap(int* px, int* py)
{
	int tmp = *px;
	*px = *py;
	*py = tmp;
}

//直接选择排序 时间复杂度
//最坏O(n^2)
//最好O(n^2)
//所以是整体而言最差的排序,因为无论什么情况都是N^2
void SelectSort(int* a, int n)
{
	int begin = 0;
	int end = n - 1;
	while (begin < end)
	{
		int mini = begin;
		int maxi = end;
		for (int i = begin; i <= end; i++)
		{
			if (a[i] < a[mini])
			{
				mini = i;
			}
			if (a[i] > a[maxi])
			{
				maxi = i;
			}
		}
		swap(&a[begin], &a[mini]);
		//如果begin和maxi重合了 maxi就被换走了
		//begin的元素换到mini那里去了
		//控制一下maxi=mini就行。
		if (begin == maxi)
		{
			maxi = mini;
		}
		swap(&a[end], &a[maxi]);
		++begin;
		--end;
	}
}

时间复杂度分析

不 管 最 好 最 坏 都 是 O ( n 2 ) 因 为 不 管 怎 么 样 都 会 遍 历 一 遍 选 最 小 最 大 长 度 减 小 2 再 遍 历 一 遍 求 和 求 起 来 最 高 次 项 就 是 O ( N 2 ) . 不管最好最坏都是O(n^{2})\\因为不管怎么样都会遍历一遍选最小最大\\长度减小2再遍历一遍\\求和求起来最高次项就是O(N^2). O(n2)2O(N2).

100w个数排序的速度

在这里插入图片描述

2.堆排序

  这里就不详细介绍了,详情参考我的有关特殊的完全二叉树——堆的文章
  代码

void AdjustDown(int* a, int n, int root)
{
	int parent = root;
	int child = root * 2 + 1;
	while (child < n)
	{
		if (child + 1 < n && a[child + 1] > a[child])
			child = child + 1;
		if (a[child] > a[parent])
		{
			swap(&a[child], &a[parent]);
			parent = child;
			child = 2 * parent + 1;
		}
		else
		{
			break;
		}
	}
}

void HeapSort(int* a, int n)
{
	assert(a);
	//向下调整建堆
	for (int i = (n - 1 - 1) / 2; i >= 0; i--)
	{
		AdjustDown(a, n, i);
	}
	//交换堆顶和最后一个元素,然后向下调整
	for (int i = n - 1; i > 0; i--)
	{
		swap(&a[0], &a[i]);
		AdjustDown(a, i, 0);
	}
}

void AdjustUp(int* a, int child)
{
	assert(a);
	int parent = (child - 1) / 2;
	while (child > 0)
	{
		if (a[child] > a[parent])
		{
			swap(&a[child], &a[parent]);
			child = parent;
			parent = (child - 1) / 2;
		}
		else
		{
			break;
		}
	}
}

void Heapsort(int* a, int n)
{
	assert(a);
	//用向上调整算法建堆
	//假插入的思想
	for (int i = 1; i < n; i++)
	{
		AdjustUp(a, i);
	}
	for (int i = n - 1; i > 0; i--)
	{
		swap(&a[0], &a[i]);
		AdjustDown(a, i, 0);
	}
}

  可视化
请添加图片描述

1kw个数堆排序的速度

在这里插入图片描述

时间复杂度分析

  建堆的时间复杂度是O(N),然后调整,最坏情况下每次堆顶被换了都要调整层数次,所以时间复杂度是O(N*logN)。

  用向上调整建堆速度确实比用向下调整建堆慢,这点也可以从这里的测试看出:

在这里插入图片描述

三、交换排序

1.冒泡排序

  思想:
  这个排序可以说是程序员的必修课了,思想就是一次单趟从头开始和自己相邻的数比较,如果比相邻的那个数大(排升序),就交换这两个数;这样的思想下,第一次单趟会把最大的数冒到最后,然后再重新从头开始,这次比较到倒数第二个数停,以此类推。
  针对有序和基本有序数组的优化:
  在冒泡排序中加入一个flag表示此次单趟交换的次数,如果某次单趟交换次数是0,表明此时已经有序了,就break出去就行,这个对有序数组和基本有序的数组都是有优化作用的,这样冒几次单趟就有序了就break了。
  可视化
请添加图片描述
  代码

void BubbleSort(int* a, int n)
{
	assert(a);
	for (int i = 0; i < n; i++)
	{
        int flag = 0;
		for (int j = 0; j < n - 1 - i; j++)
		{
			if (a[j] > a[j + 1])
			{
				swap(&a[j], &a[j + 1]);
                flag++;
			}
		}
        if (flag == 0)
            break;
	}
}

2.横向对比冒泡排序和直接选择排序和直接插入排序

  直接选择排序不论什么情况都是O(N^2),无法和另外两个比,另外两个中,冒泡排序和直接插入排序,最坏都是O(N^2),最好都是O(N).

  对于已经有序的数组,冒泡排序和直接插入排序,一样好都是O(N)。

  对接近有序的数组,插入排序更好,理由如下:

  如下面的数组 1 2 3 4 6 5

  冒泡排序:N-1+N-2(先遍历一趟把6放到应该放的位置,第二趟遍历确定有序了停下来)。

  直接插入排序:2插入,比1大,插入1后面:1次;3插入,比2大,插入2后面:2次;4插入,比3大,插入3后面:3次;6插入,比4大,插入4后面:4次;5插入,和6比一次,比6小,6往后移动,和4比, 比4大,5插在4后面:6次,归纳一下就是N次。

  综上所述,直接插入排序更好一些,直接插入排序应对于局部有序是很不错的排序。

3.快速排序

  快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。

单趟排序的目标

  快速排序单趟的目的是把基准值key放到一个正确的位置,这个位置左边元素小于等于它,右边元素大于等于它。

  先选一个基准值key,一般选最左边或者最右边的值做key。

  单趟排序的目标是排成:左边的值比key要小,右边的值比key大,一个单趟就把key放到了恰当的位置了。
  本文会介绍三种单趟排序的方法。

单趟排序之hoare版本

  思想:
  以最左边的值为key时,安排左右两个指针L和R,L找比key大的值,R找比key小的值,R先走找到了停下来,然后L再走找到了停下来,两个都找到了就让L和R指的值做一次交换,然后R再走。当L和R相遇的时跟key交换,返回相遇的位置表示这个位置的元素已经排好了。

  有小朋友可能会问了,如果相遇遇到的值比key大怎么办呢,这样交换不就把比key大的值放到左边去了嘛。

  所以有一个核心原则:

  • 选最左边的值做key,右边先走,可以达到左右相遇时比key小;

  • 选最右边的值做key,左边先走,可以达到左右相遇时比key大;

  以最左边的值做key的单趟可视化
请添加图片描述

  下图中:i是L,j是R。
请添加图片描述

  以左边做key时让左边先走为例,会出问题:

请添加图片描述
  如图,比key=6大的9跑到key左边去了。
  原理以右边做key时需要左边先走原理为例

  我们先说明为什么右边先走不行:如果在没相遇之前,其实哪边先走都一样,会保证L左边的值比key小,R右边的值比key大。
  这里相遇有两种情况,L撞R和R撞L。
  假设最后一次相遇前是R先走,R要找的是比key小的值,R没找到,R撞见了L停了,但是此时L的值是比R小的,如果和key交换了会导致key右边有比key小的值,这轮就失败了
  假设R先走R找到了比key小的值,L没有找到比key大的值,L撞见R停了,那此时相遇位置的值是比key小的,交换到key所在的右边,比key小的值放到了key右边,交换出问题了,这轮也会失败

  再说明为什么左边先走是可以的:所以如果右边做key左边先走的话,L找比key大的值没找到,撞到R停了,此时R指的值一定是比key大的,并且相遇位置左边是比key小的,右边是比key大的,所以交换key就会成功;
  假设L找到了比key大的值,R没找到比key小的值撞到L停了,相遇位置是比key大的值,并且相遇位置左边是比key小的值,右边是比key大的值,交换key和相遇位置的值此轮也是成功的。

  有了思想的铺垫,我们的单趟版本可以这样写:

int PartSort1(int* a, int left, int right)
{
	int keyi = left;
	while (left < right)
	{
		while (a[right] > a[keyi])
			--right;
		while (a[left] < a[keyi])
			++left;
		swap(&a[left], &a[right]);
	}
	swap(&a[left], &a[keyi]);
    return left;
}

  但是上面的代码在两种情况会有缺陷

缺陷1:全部都是相等的情况,right和left会动不了导致死循环。

在这里插入图片描述

  修改方法,右找小的,那相等的也放在右边把;左找大的,那相等的也放在左边吧。

  大于号改大于等于,小于号改小于等于。

  修改后右找的是严格比key大的,左找的是严格比key小的.

int PartSort1(int* a, int left, int right)
{
	int keyi = left;
	while (left < right)
	{
		while (a[right] >= a[keyi])
			--right;
		while (a[left] <= a[keyi])
			++left;
		swap(&a[left], &a[right]);
	}
	swap(&a[left], &a[keyi]);
    return left;
}

  但是仍然规避不了越界的情况。

情况2:

请添加图片描述

  因为我们在让right走的时候并没有控制right要大于left,所以可能会导致越界!

  所以必须每次比较前必须比较一下left是否小于right,只要left和right没错开,就不会出现越界的情况。

int PartSort1(int* a, int left, int right)
{
	int keyi = left;
	//左边做key
	while (left < right)
	{
		//右边先走 找小 控制不要错开不要越界
		while (left < right && a[right] >= a[keyi])
			--right;
		//左边再走 找大 控制不要错开不要越界
		while (left < right && a[left] <= a[keyi])
			++left;
		swap(&a[left], &a[right]);
	}
	swap(&a[left], &a[keyi]);
    return left;
}

  单趟排完,比key小的都到了左边,比key大的都在右边,如果左边有序,右边有序就完成了。

单趟排序之挖坑法

  挖坑法是单趟排序的hoare版本的一个变形,并没有实际上的效率优化,只是思想更好理解了一些。

  思想

  以最左边为key,把它取出放到一个临时变量tmp里头,最左边L就形成了一个坑,R先走找小,找到了就把数扔到坑里面,自己就形成了一个坑,然后L再走找大,找到了就把这个数扔到R所在的坑里边,一直到他们相遇,他俩相遇时一定是有一个是一个坑,把tmp放进来就行,最后返回坑的位置表示这个位置的元素已经放好了。

  挖坑法单趟排序可视化

请添加图片描述

  代码

int PartSort2(int* a, int left, int right)
{
	int ipit = left;
	int key = a[ipit];
	while (left < right)
	{
		//右边先走 找小 找到了放到左边的坑里面
		while (left < right && a[right] >= key)
			--right;
		a[ipit] = a[right];//放
		ipit = right;//自己就变成了坑
		//左边再走 找大 找到了放到右边的坑里边
		while (left < right && a[left] <= key)
			++left;
		a[ipit] = a[left];
		ipit = left;
	}
	//把key放到最后相遇的坑里面
	a[ipit] = key;
	return ipit;
}

单趟排序之前后指针法

  前后指针法是快排单趟最优雅的写法,不得不说发明这些算法的人真是大神。

  思想
  最左边做key,cur指key后面一个元素,prev指key。
  出发,cur先走,cur找小,找到小的停下来,然后prev走一步,++prev,然后交换cur和prev指向的值,然后重复上一轮;直到cur出去为止,最后交换key和prev所指向的值。

  这就像是把小的往左边甩,大的往右边甩的意思,prev要么紧跟着cur,要么紧跟着比key大的序列。
  可视化
请添加图片描述

  代码:

//左边做key
//右边做key会遇到意外 可能prev指的值比key小,那就++prev
int PartSort3low(int* a, int left, int right)
{
	int key = a[left];
	int keyi = left;
	int cur = left;
	int prev = cur + 1;
	while (cur <= right)
	{
		//cur找小
		while (cur <= right && a[cur] >= key)
			++cur;
		if (cur <= right)//防止越界
		{
			swap(&a[++prev], &a[cur]);
			//交换是prev的值还是比key小的
            //这样cur指的值仍然比key小 
            //在上面的while循环,cur会动不了
			//但其实这个点已经不用管了 
            //这个位置已经放是比key小的了
			++cur;
		}
	}
	swap(&a[prev], &a[keyi]);
	return prev;
}

  可以观察到不管是找到还是没找到,都要++cur,就算找到了交换过后cur的值可能会比key小,这样cur就无法通过最前面的while循环动起来了,并且当prev紧跟着cur的时候,cur和prev总是在自己交换自己,很呆。
  因此我们考虑不管找到还是没找到cur都++,如果cur找到了比key小的值并且cur不等于++prev的时候(这一步帮助我们把prev移动了),进行交换。

//更优质的写法
int PartSort3(int* a, int left, int right)
{
	assert(a);
	int keyi = left;
	int prev = left;
	int cur = prev + 1;
	while (cur <= right)
	{
		//相同就不交换了 减少消耗
		if (a[cur] < a[keyi] && ++prev != cur)
			swap(&a[cur], &a[prev]);
		//既然不管是交换还是不交换cur都得走 
        //不如直接拿出来走
		++cur;
	}
	swap(&a[keyi], &a[prev]);
	return prev;
}

  单趟排序的前后指针法的第二种写法非常简洁,因为它把思路弄得很清楚,并且这个思路是很好的思路,所以在写快速排序的时候,单趟排序我们尽量写第三种。

多趟排序递归写法

  我们用二叉树前序遍历的思想,现在key已经放在正确的位置上了,想让左区间有序,就对[left,keyi - 1]进行一次快排,想让右区间有序,就对[keyi + 1, right]进行一次快排,分解为最小问题时区间不存在时或区间长度等于1的时候,认为元素是有序的,就返回。

  递归图

在这里插入图片描述

  可视化
请添加图片描述

  代码

void QuickSort(int* a, int left, int right)
{
	assert(a);
	if (left >= right)
		return;
	int keyi = PartSort1(a, left, right);
	//keyi位置已经放了恰当的元素了
	//分成了[left, keyi-1] [keyi+1, right]
	QuickSort(a, left, keyi - 1);
	QuickSort(a, keyi + 1, right);
}

时间复杂度分析

  left=0,right = n-1的一次单趟,就是left、right往中间走,相当于一次遍历,时间复杂度O(N)。

  考察总的递归过程:

  如果每次都能差不多取到中位数的情况下,如下图:

在这里插入图片描述

  因此时间复杂度就是O(NlogN)。

  性能测试(1千万个数字)

在这里插入图片描述

递归写法的快排两个缺陷

  最坏情况:如果数组有序或基本有序的情况下,每次取得的左值就会放到最左边,分成的区间就是0和[1,n-1]、0和[2,n-1]…这种情况二叉树的深度会太深了,如下图:

在这里插入图片描述

  这时的时间复杂度是O(N^2)(1+2+3+…+N),时间复杂度变得非常糟糕,这是第一个缺陷。

  测试一下:

  让快排和堆排序都去排希尔排序已经排好的数组,

  10w个数的速度。

在这里插入图片描述

  另一个缺陷是递归层数太深甚至可能会导致栈溢出。

  这根本上是key选的不合理导致的,key应该尽量选择中间值以保证树的深度不大,这样递归次数少一点。

  如何解决快排面对有序的选key的问题:

  1. 随机选key,命运交给了随机,也不好。
  2. 三数取中。a[left]、a[right]、a[mid]中取不是最大也不是最小的那个做key,这样就规避了如果是有序的情况每次keyi取到了最左边导致时间复杂度骤升的情况,针对有序的情况下,每次取中就取到了中间,这样就是一下子从最坏变成了最好情况.
int GetMidIndex(int* a, int left, int right)
{
	//int mid = (left + right) / 2;
	//如果left和right超过int的一半会出问题
	//int mid = left + (right - left) / 2;
	//进一步修改 /是用减法实现的 减法是用加法实现的 效率比较低
	//除2相当于右移一位 右移效率相对高一点
	//注意优先级
	int mid = left + ((right - left) >> 1);
	if (a[mid] < a[left])
	{
		if (a[left] < a[right])
		{
			return left;
		}
		//a[left] > a[right] && a[mid] < a[left]
		//a[left]最大
		else if (a[mid] < a[right])
		{
			return right;
		}
		else
		{
			return mid;
		}
	}
	else //a[mid] > a[left]
	{
		if (a[left] > a[right])
		{
			return left;
		}
		//a[mid] > a[left] && a[left] < a[right]
		//a[left]最小
		else if (a[mid] < a[right])
		{
			return mid;
		}
		else
		{
			return right;
		}
	}
}

  为了保持主逻辑不变,我们先取得中的下标,然后把中的值和left的值换一下。

int PartSort1(int* a, int left, int right)
{
	int mini = GetMidIndex(a, left, right);
	swap(&a[left], &a[mini]);
	int keyi = left;
	//左边做key
	while (left < right)
	{
		//右边先走 找小 控制不要错开不要越界
		while (left < right && a[right] >= a[keyi])
			--right;
		//左边再走 找大 控制不要错开不要越界
		while (left < right && a[left] <= a[keyi])
			++left;
		swap(&a[left], &a[right]);
	}
	swap(&a[left], &a[keyi]);
	return left;
}

  已经用希尔排序排完了,有序的情况下。

  10w个数的速度:

在这里插入图片描述

  1kw个数的速度:

在这里插入图片描述

  有了三数取中,这样递归的深度都不会很大了,因为是接近完全二叉树的形态,栈溢出问题更难发生了,并且把最坏情况变成了最好情况,把时间复杂度控制在接近O(NlogN)。

快排无法解决的缺陷

  如果所有数据都相等,快速排序就会变得很糟糕。

  因为你所有数据都相等的时候(或者是23232323232323这种情况),三值取中取到的值还是最小的或次小的,快排就变成了上面提到的最坏情况(每次都是左边区间长度0,右边区间长度n-1个)。

  10w个相同的数:

在这里插入图片描述

  没有很好的办法解决这个问题。

小区间优化

  我们的快排写成递归的话,有以下缺陷。

  递归程序的缺陷:

  1. 相比循环程序,性能差。(针对早期编译器成立,因为对于递归调用,建立栈帧优化不大。现代编译器优化都很好,递归相比循环性能差不了多少,已经不是核心矛盾了)
  2. 递归深度太深时会导致栈溢出(Linux栈只有8M,只够几万层),核心矛盾。

  第一种解决方法是搞一个小区间优化,当区间长度很小的时候其实反而占的区间比较多(因为完全二叉树除最后一层外越深的结点越多),我们不如在这个时候使用一个别的排序的就行了。

void QuickSort(int* a, int left, int right)
{
	assert(a);
	if (left >= right)
		return;
	if (right - left + 1 < 10)
	//闭区间[left,right]有right - left + 1个元素
	{
		//小区间优化 当分割到小区间的时候 不再用分割让小区间有序
		//减少递归次数
		InsertSort(a + left, right - left + 1);//
		//a + left 起始位置
	}
	else {
		int keyi = PartSort3(a, left, right);
		//keyi位置已经放了恰当的元素了
		//分成了[left, keyi-1] [keyi+1, right]
		QuickSort(a, left, keyi - 1);
		QuickSort(a, keyi + 1, right);
	}
}

  1kw个数速度比较

无小区间优化

在这里插入图片描述

有小区间优化

在这里插入图片描述

  可以看出还是有一定程度的优化的,看起来不明显是因为release版本对递归优化了很多了。

快速排序非递归实现(栈模拟)

  另一种方法是实现所谓的非递归。

  思路

  把区间的左端点和右端点存到栈里头,出栈以后进行一次单趟排序,然后类似递归版本一样,分成左右两个区间再入栈,直到栈空为止。

  如果左区间想先处理,因为栈后进先出,所以先让右区间进去;左端点先进,右端点再进,同理,先出来的是右端点,再出来的是左端点。

  这里注意控制当区间长度大于1的时候才有入栈的必要

void QuickSortNonR(int* a, int left, int right)
{
	assert(a);
	Stack st;
	StackInit(&st);
	StackPush(&st, left);
	StackPush(&st, right);
	while (!StackEmpty(&st))
	{
		int end = StackTop(&st);
		StackPop(&st);
		int begin = StackTop(&st);
		StackPop(&st);
		int keyi = PartSort3(a, begin, end);
		//一次单趟让keyi放到正确的位置去了
		//[begin,keyi-1] [keyi + 1,end]
		if (keyi + 1 < end)
		{
			StackPush(&st, keyi + 1);
			StackPush(&st, end);
		}
		if (begin < keyi - 1)
		{
			StackPush(&st, begin);
			StackPush(&st, keyi - 1);
		}
	}
	Stackdestroy(&st);
}

四、归并排序

1 基本思想

  如果左区间有序,右区间也有序,我们用一个临时数组不断插入左右的最小元素,然后拷贝回原数组就行

  那怎么做到左右有序呢?

  与快速排序类似,借助递归的思想。注意到如果只有区间中只有两个元素的时候,我们可以很轻松的做到让区间有序,谁小谁先插入tmp数组,然后插另一个,这样这个区间就有序了。

  所以我们不断的把区间划分成一半一半,到最小规模的子问题即只有一个值或者不存在的区间的时候,这个区间可不就有序了吗,然后两个有序区间我们可以往回做归并,所以要把左边弄成有序,然后把右边弄成有序,然后再归并,类似后续遍历。

在这里插入图片描述

  可视化

请添加图片描述

2 递归版本

  先把左区间归并到有序,再把右区间归并到有序,对两个区间合成的区间进行一个归并,所以是一个类后续遍历。

代码:

void MergeSort(int* a, int n)
{
	assert(a);
	int* tmp = (int*)malloc(n * sizeof(int));
	if (tmp == NULL)
	{
		printf("malloc fail\n");
		exit(-1);
	}
	_MergeSort(a, 0, n - 1, tmp);//递归用的子函数
	free(tmp);
	tmp = NULL;
}
void _MergeSort(int* a, int left, int right, int* tmp)
{
	if (left >= right)
	//区间长度为0或者不存在这个区间的时候 返回
	{
		return;
	}
	int mid = left + (right - left) / 2;
	_MergeSort(a, left, mid, tmp);
	_MergeSort(a, mid + 1, right, tmp);
	//归并[left,mid] [mid+1, right]到临时数组tmp
	int begin1 = left;
	int end1 = mid;
	int begin2 = mid + 1;
	int end2 = right;
	int i = left;
	while (begin1 <= end1 && begin2 <= end2)
	{
		if (a[begin1] < a[begin2])
		{
			tmp[i++] = a[begin1++];
		}
		else
		{
			tmp[i++] = a[begin2++];
		}
	}
	while (begin1 <= end1)
	{
		tmp[i++] = a[begin1++];
	}
	while (begin2 <= end2)
	{
		tmp[i++] = a[begin2++];
	}
	//拷贝回原数组
	for (int j = left; j <= right; j++)
	{
		a[j] = tmp[j];
	}
}

1kw个数效率

在这里插入图片描述

3 非递归版本

  非递归可以用循环模拟,也可以用栈模拟,也可以用队列模拟。队列模拟类似层序遍历。

  总体思路框架:

  第一趟以间距为1,进行归并;第二趟以间距为2,进行归并…第i躺以间距为2^(i-1)进行归并,只要间距还小于n,就继续归并,类似一种层序遍历。

请添加图片描述

有问题的代码

void MergeSortNonR(int* a, int n)
{
	assert(a);
	int* tmp = (int*)malloc(sizeof(int) * n);
	if (tmp == NULL)
	{
		printf("malloc fail\n");
		exit(-1);
	}
	int gap = 1;
	while (gap < n)
	{
		for (int i = 0; i < n; i += 2 * gap)
		{
			//归并[i,i+gap - 1] [i + gap, i + 2 * gap - 1]
			int begin1 = i, end1 = i + gap - 1;
			int begin2 = i + gap, end2 = i + 2 * gap - 1;
			int j = i;
			while (begin1 <= end1 && begin2 <= end2)
			{
				if (a[begin1] < a[begin2])
				{
					tmp[j++] = a[begin1++];
				}
				else
				{
					tmp[j++] = a[begin2++];
				}
			}
			while (begin1 <= end1)
			{
				tmp[j++] = a[begin1++];
			}
			while (begin2 <= end2)
			{
				tmp[j++] = a[begin2++];
			}
		}
		//把当前tmp拷贝回去以应对下一次归并
		for (int k = 0; k < n; k++)
		{
			a[k] = tmp[k];
		}
		gap *= 2;
	}
}

  但是这么写有很大的问题,可能会导致越界问题,如下:

  测试用例

int a[] = { 2,56,9,3,5,1,-2,6,14,2,56,9,3,5,1,-2,6,14,2,56,9,3,5,1,-2,6,14,2,56,9,3,5,1,-2,6,14 };
//n = 35

在这里插入图片描述

  我们知道出现随机值一般都是越界的问题。

  加上每次归并的两个区间打印出来方便我们确定问题(我们这里的测试用例的n=35):

在这里插入图片描述

  通过观察可以分析出原因:begin1不可能越界(for循环的i控制的),但end1、begin2、end2都有可能越界,如上图。

技巧:

快捷键:VS拷贝快捷键ctrl+d 删除快捷键ctrl+shift+l

条件断点:

在这里插入图片描述

  经过分析这里有三种情况(n=35):

  1. end1越界了,后面的都越界了

在这里插入图片描述

  1. end1没越界,begin2越界了,end2因此也越界了

在这里插入图片描述

  1. 前面的没越界,end2越界了

在这里插入图片描述

  既然你的区间会越界,我们的思路就是是修正区间(因为我们归并的时候并不会要求归并的两个区间长度要相等,修正区间别让他越界就是),我们调整end1、begin2、end2。

if (end1 >= n)
{
	end1 = n - 1;
}
if (begin2 >= n)
{
	begin2 = n - 1;
}
if (end2 >= n)
{
	end2 = n - 1;
}

  但是这样修正后仍然存在另一个可能越界的地方:

在这里插入图片描述

  我们的测试用例是:

int a[] = { 10,6,7,1,8,9,4,2,5 };

在这里插入图片描述

  这个地方begin1,end1和begin2、end2都修正成了8的时候,先在上面的while循环中拷贝[begin2, end2]区间中的5后,j++,下来在begin1仍然小于end1,[begin1, end1]又会再拷贝一次,此时j=9,把tmp[9]改了就已经tmp越界了。

  这个地方修正方法思路一个是在下面再加一层条件防止它越界。

while (j < n && begin1 <= end1)
{
	tmp[j++] = a[begin1++];
}
while (j < n && begin2 <= end2)
{
	tmp[j++] = a[begin2++];
}

  或者这样修正,如果end1越界了,我们就让end2和begin2变成一个不存在的区间,begin2>end2就不会进下面的while循环。

  两种修正的思想都是在你要越界tmp的时候修正,让他不要越界。

if (end1 >= n)
{
	end1 = n - 1;
}
if (begin2 >= n)
{
	begin2 = n;
    end2 = n - 1;
}
if (end2 >= n)
{
	end2 = n - 1;
}

  总代码

void MergeSortNonR(int* a, int n)
{
	assert(a);
	int* tmp = (int*)malloc(sizeof(int) * n);
	if (tmp == NULL)
	{
		printf("malloc fail\n");
		exit(-1);
	}
	int gap = 1;
	while (gap < n)
	{
		//if (gap = 8)
		//{
		//	  int m = 0;
		//}
		for (int i = 0; i < n; i += 2 * gap)
		{
			//归并[i,i+gap - 1] [i + gap, i + 2 * gap - 1]
			int begin1 = i, end1 = i + gap - 1;
			int begin2 = i + gap, end2 = i + 2 * gap - 1;
			if (end1 >= n)
			{
				end1 = n - 1;
			}
			if (begin2 >= n)
			{
				begin2 = n;
				end2 = n - 1;
			}
			if (end2 >= n)
			{
				end2 = n - 1;
			}
			printf("[%d,%d] [%d,%d] ", 
			begin1, end1, begin2, end2);
			int j = i;
			if (j == 8)
			{
				int m = 3;
			}
			while (begin1 <= end1 && begin2 <= end2)
			{
				if (a[begin1] < a[begin2])
				{
					tmp[j++] = a[begin1++];
				}
				else
				{
					tmp[j++] = a[begin2++];
				}
			}
			
			while (begin1 <= end1)
			{
				tmp[j++] = a[begin1++];
			}
			while (begin2 <= end2)
			{
				tmp[j++] = a[begin2++];
			}
		}
		printf("\n");
		//把当前tmp拷贝回去以应对下一次归并
		for (int k = 0; k < n; k++)
		{
			a[k] = tmp[k];
		}
		gap *= 2;
	}
}

  另一种思路,我们可不可以把拷贝放到while里面来呢,也就是说,像递归那样,归并一部分拷贝一部分。

  这时,end1越界或begin2越界不需要处理,因为end1越界相当于第二个区间不存在,并且第一个区间已经经过上次归并且在while里头的拷贝后,已经在a中且有序了,只不过end1越出去了;begin2越界更好说了,第二个区间不存在,上次循环已经拷到原数组里头去了,不用归并了,所里这俩情况直接break就好。

  如果end2越界了,那说明其他的第二个区间里是有值的,因此要修正end2为n-1,走归并拷贝的逻辑。

  调整后的代码如下:

void MergeSortNonR(int* a, int n)
{
	assert(a);
	int* tmp = (int*)malloc(sizeof(int) * n);
	if (tmp == NULL)
	{
		printf("malloc fail\n");
		exit(-1);
	}
	int gap = 1;
	while (gap < n)
	{
		for (int i = 0; i < n; i += 2 * gap)
		{
			//归并[i,i+gap - 1] [i + gap, i + 2 * gap - 1]
			int begin1 = i, end1 = i + gap - 1;
			int begin2 = i + gap, end2 = i + 2 * gap - 1;

			if (end1 >= n || begin2 >= n)
			{
				break;
			}
			if (end2 >= n)
			{
				end2 = n - 1;
			}

			int j = i;
			while (begin1 <= end1 && begin2 <= end2)
			{
				if (a[begin1] < a[begin2])
				{
					tmp[j++] = a[begin1++];
				}
				else
				{
					tmp[j++] = a[begin2++];
				}
			}

			while (begin1 <= end1)
			{
				tmp[j++] = a[begin1++];
			}
			while (begin2 <= end2)
			{
				tmp[j++] = a[begin2++];
			}

			//归一部分 拷贝一部分
			for (int k = i; k <= end2; k++)
			{
				a[k] = tmp[k];
			}
		}	
		gap *= 2;
	}
}

4 时间复杂度

  与快速排序类似,归并排序也是不断地二分二分,每一层归并回去的效率是O(n),层数有logn层,时间复杂度就是O(nlogn)。

五、计数排序

  计数排序是一种非比较排序,它不依赖于比较大小得到顺序。

思想

  取当前数组的最大值和最小值,然后开一个最大值减最小值+1个大小的数组,初值赋0,遍历一遍原数组,如果某个元素出现了一次,就在元素-最小值(一种映射的思想)的位置++一次表示次数加1,最后按照开出来的数组的非零值,下标为i,对应值就是i+min,把原数组排序就行。

  不过当前的思想有一定的问题,如果最小值是负数,在下标是

时间复杂度

  遍历一次取最大最小,时间复杂度O(N),遍历一次记次数,O(N),遍历一次写回去O(max - min + 1)

  总时间复杂度O(N + max - min + 1)。

  计数排序数据分布表集中的数据,这样时间复杂度可以接近O(N),否则会造成空间和时间的浪费。

可视化

请添加图片描述

代码

void CountSort(int* a, int n)
{
	assert(a);
	int min = a[0], max = a[0];
	for (int i = 0; i < n; i++)
	{
		if (a[i] < min)
		{
			min = a[i];
		}
		if (a[i] > max)
		{
			max = a[i];
		}
	}
	int range = max - min + 1;
	int* count = (int*)calloc(range,sizeof(int));
	if (count == NULL)
	{
		printf("malloc fail\n");
		exit(-1);
	}
	for (int i = 0; i < n; i++)
	{
		count[a[i] - min]++;
	}
	int j = 0;
	for (int i = 0; i < range; i++)
	{
		while (count[i] > 0)
		{
			a[j++] = i + min;
			count[i]--;
		}
	}
}

1kw个随机数效率测试

在这里插入图片描述

适用范围

  适合对范围比较集中的整数数组进行排序。当范围较大,或者数据类型是浮点数和字符串就不合适。

六、各种排序的总结与排序的稳定性

1 各个排序的对比

排序
插入排序
直接插入排序
希尔排序
选择排序
直接选择排序
堆排序
交换排序
冒泡排序
快速排序
归并排序
归并排序
非比较排序
计数排序

在这里插入图片描述

2 稳定性

  以数组中相同的值在排序后位置是否变化来判断排序是否是稳定的,如果排序后位置可能会变化,就是不稳定的,如果排序后位置一定不变,就是稳定的。

  意义:对于稳定的排序,如果有以下场景:按成绩发奖金,先交卷的在原数组中排名在前,后交卷的在原数组中排名在后面,我给前三名法奖学金1000、800、600,如果前三名的成绩是100、99、99,如果我的排序算法是稳定的,那先交卷的就会排名在前(和原数组相比位置不变),这样就很好的满足了我们的规则。

直接插入排序是稳定的:控制只有比前面的值小才往前挪动,就可以做到稳定。

希尔排序是不稳定的:因为相同的值可能在预排序的时候分到不同的组,预判可能会让他们的位置变化。

直接选择排序是不稳定的:因为我们在交换值的时候可能把相等的值的顺序给变了

在这里插入图片描述

堆排序是不稳定的:如这个反例(堆顶的5原本在前面,换到后面去了)

在这里插入图片描述

冒泡排序是稳定的。

快速排序是不稳定的:因为单趟时和key相等的值可以在左边也可以在右边,然后你放key的时候位置就会变化。如5 1 5 5。

计数排序是不稳定的:因为计完数以后我并不知道原来的位置。

归并排序是稳定的:相等的时候下左边的。

  • 11
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 5
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值