八大排序的思想讲解与排序算法可视化(未完待续)

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

  本文全部代码已上传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个数效率

在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/CS_COPy/article/details/121421113