目录
Hello,各位未来的高级程序员们,大家好,今天我就来为大家讲解一下有关排序的内容,我们常见的排序就是我们接下来要讲的这八个排序,我们平常所说的排序有十大排序,我们这里的八大排序是我们生活中最为常见的八大排序,而剩下的两个排序是桶排序和基数排序,这两个排序我们不做具体的精彩讲解,因为这两个排序属于是又复杂又没用的排序,因此,我们在这里不做具体的介绍,只介绍剩余的八大排序,OK,话不多说,我们现在开讲。
首先,我们这里先写一下八大排序中可能会用到的一些辅助函数:
1.交换函数:
这个函数的功能就是交换两个数据。
void Swap(int* p1, int* p2)
{
int tmp = 0;
tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
2.三数取中:
这个函数主要是用于在写快速排序的优化操作时会使用到,是为了得到某个数组中第一个,最后一个和最中间这三个数中不大不小的那个数据。
int GetMid(int* arr, int left, int right)
{
int mid = (left + right) / 2;//这里是为了找到最中间的那个数据。
if (arr[left] < arr[mid])
{
if (arr[left] > arr[right])//arr[right] < arr[left] < arr[mid]
{
return left;
}
else if(arr[mid] < arr[right])//arr[left] < arr[mid] < arr[right]
{
return mid;
}
else//arr[left] < arr[right] < arr[mid]
{
return right;
}
}
else
{
if (arr[left] < arr[right])//arr[mid] < arr[left] < arr[right]
{
return left;
}
else if (arr[mid] > arr[right])//arr[right] < arr[mid] < arr[left]
{
return mid;
}
else//arr[mid] < arr[right] < arr[lreft]
{
return right;
}
}
}
那么,接下来,我们就要开始正式的讲解有关排序的内容了:
一.插入排序:
插入排序就是有一个有序的数组,将一个数据插入到这个有序数组中,接下来,我们先来画图分析一下这个插入排序的过程。
如上图所示的那样,我们这里的解析过程以上面的图中的那个数组为例来做出解释,若上图所示,我们的目的是想要将数组中的第end+1这个元素插入到[0,end]这个有序的数组序列中去,我们这里先将arr[end+1]这个元素单独从数组中拿出来,将其放到tmp这个变量空间中,然后让这个元素一一与[0,end]这个有序的数组序列所有元素去进行比较(比较时,我们这里从arr[end]开始,依次往前去进行比较),若tmp比arr[end]这个元素小的话,则先将arr[end]这个元素往后移动一位,然后再让end--,进行下一轮的比较,如果tmp比arr[end]这个元素大的话,则说明arr[end]这个元素之前的所有元素均比tmp小,因此,不需要往后面移动,直接将tmp这个值放在arr[end+1]的这个位置上就可以了。
void InsertSort(int* arr, int sz)//这个sz指的是要进行插入排序的数组的下标的最大值。
{
for (int i = 0; i < sz; i++)//这里的i<sz是因为我们在这里这个i表示的是的end,而我们插入排序的目的是将arr[end+1]这个元素插入到[0,end]这个有序的数组序列中去,因此,我们要保证arr[end+1]这个元素必须在有效的数组范围内才可以,因此,i不能大于sz,否则,会造成越界访问的问题。
{
int end = i;//这里确定有序序列的范围,就是得到上面解析中所说的end。
int tmp = arr[end + 1];//根据上面我们所写的解析,我们确定好end之后,将arr[end + 1]这个元素插入到[0,end]这个有序的数组序列中。
while (end >= 0)//这里我们以最坏的情况为循环的结束条件,插入排序的最坏情况就是arr[end + 1]这个元素比[0,end]这个有序的数组序列中的任何一个元素都要小。
{
if (arr[end] > tmp)//如果tmp比arr[end]这个元素小的话,则先将arr[end]这个元素往后移动一位,然后再让end--,进行下一轮的比较。
{
arr[end + 1] = arr[end];
end--;
}
else//如果tmp比arr[end]这个元素大的话,则说明arr[end]这个元素之前的所有元素均比tmp小,直接就不用再进行循环比较了,直接出循环。
{
break;
}
}
arr[end + 1] = tmp;//将tmp这个值放在arr[end+1]的这个位置上就可以了。
}
}
二.希尔排序:
这里我们接下来要讲的希尔排序其实就是对刚刚上面写过的插入排序的一种优化,我们在进行插入排序的时候(这里我们假设在进行插入排序的时候是将数组中的元素从小到大排),最害怕的一种情况就是数组中的元素是按照从大到小的顺序排列的,因为这样的话,它的时间复杂度就是O(N^2),时间复杂度有点太高了,这种情况下,希尔排序就出现了,希尔排序其实主要分为两步:(1).预排序,(2).插入排序,(预排序是让是让数组接近有序,也就是再对数组进行一次插入排序,我们我们在经过一次预排序之后,那么,这样的话,他的时间复制度就被降下来了),我们以如下数组为例,来为大家讲解一下希尔排序的具体过程:
如上图所示的那样,我们定义了一个数组,我们将这个数组中的元素分成gap个组,分别是1组,2族和3组(因为我们上面定义的gap是3,所以我们在这里将数组中的元素全部分成3组即可),分好组后,接下来我们就要开始去进行预排序的过程了,预排序就是对我们上面分好的3组元素,每一组都进行一次插入排序,当我们的3个组均进行完插入排序之后,那么,此时的数组中的元素就十分接近与有序了,这时,我们再统一地将整个数组中的元素进行一遍插入排序就好了,这样的话,当我们遇到上面我们所说的那种情况时,时间复杂度也不会变的很高了。
当我们将gap组的元素全部使用插入排序排完时,预排序环节就结束了,那么,我们现在看一下,现在数组中的元素排序相较于之前的数组中的元素排序,很明显,变得更加接近有序了,最后,我们再进行一次插入排序的操作,那么,数组中的元素就变得彻底有序了,那这个方法对于上面我们所说的那种最害怕的情况,这种方法对于上面那种情况的时间复杂度就大大降低了,看到这里,大家是不是有一种醍醐灌顶的感觉呢?希尔排序的分析其实到这里还没有完,上面的解析中,我们假设的gap是一个固定的值,但是,在我们的生活中,我们是有可能不知道数组的具体长度的,比如说某个数组中由1000000000个元素,此时我们如果让gap继续等于3的话,是不是有点太慢了,因此,我们再进行希尔排序的时候,我们这里的gap是一个变化的值,也就是说,我们这里要将一个数组中的元素分成好多组(gap=3,gap=7,gap=10...),进行好多次预排序。
void ShellSort(int* arr, int sz)
{
int gap = sz;//这里我们先定义一个gap,让它的初始值为数组的原长度的大小。
while (gap > 1)
{
gap = gap / 3 + 1;这里我们是要进行预排序的过程,当gap<=1时,我们这里就不再进行预排序了,为了保证gap的值时不断变化的,这里之所以要在后面+1,是为了保证最后一次的gap为1,当gap为1的时候,就相当于是将一整个数组进行了一次插入排序,这样的话我们就不用再在后面写一个插入排序的代码了(最后一次插入排序)。
for (int i = 0; i < sz - gap; i++)//我们这里使用一个for循环来实现多次的插入排序这里的i之所以要小于sz-gap的原因和上面的插入排序中i < sz的原因是一样的,不同的是,我们在希尔排序这里是将arr[end+gap]这个元素插入到前面(同一个组中,因为我们这里将一个数组分成了gap个组)。
{
int end = i;//定义一个end。
int tmp = arr[end + gap];//找到要进行插入的元素,我们这里是对一个组中的元素进行排序操作,而一个组中的每一个元素之间都相互间隔gap个,就如下面那图所示的那样,我们这里假设先对1组进行排序操作(当数组中含有一个元素时,我们就认为这个数组是有序的),我们这里将1组中的第一个元素定义为end,那么我们要插入的同一个组中的元素就是arr[end+gap]这个元素,也就是5这个元素。
while (end >= 0)//下面进行的操作就是我们上面进行一次插入排序的操作。这里我们以最坏的情况来作为一次插入排序的条件,最坏的条件就是end>=0。
{
if (arr[end] > tmp)//下面就不解释了,如果非要进行解释的话,那么这些解释其实就是上面的插入排序的那些解释(把插入排序的代码中所有的1全部换成gap就可以了)。
{
arr[end + gap] = arr[end];
end -= gap;
}
else
{
break;
}
}
arr[end + gap] = tmp;
}
}
}
三.选择排序:
选择排序大致上和冒泡排序是一样的,这个排序它也需要我们去遍历数组元素,只不过不同的是,冒泡排序是在遍历的过程中,两个元素(一前一后地两个元素进行相互比较)进行比较,而选择排序是在每次遍历数组地过程中,选择出一大一小两个元素,选出这两个元素以后,接下来,我们能就将小的那个元素放到数组的第一个元素的位置,将大的元素放到数组的最后一个元素的位置上,以此类推,我们就用下面这幅图来详细地给大家介绍一下吧:
通过上面的这幅图我们可以得知,我们需要在每次找到当前数组中的最大值和最小值后,并且将其给挪到相应的位置上后,就必须得重新确定一下范围,跳过确定好位置的两个元素(就是我们在这一趟遍历数组中确定好的最大的元素和最小的元素这两个元素)。
void SelectSort(int* arr, int sz)//sz指的是我们要进行排序的数组中最大的那个下标。
{
int begin = 0, end = sz;//定义两个指针,确定每次遍历的范围。
while (begin <= end)//接下来我们就要开始进行选择排序了,选择排序结束的条件是begin <= end。
{
int max = begin, min = end;//通过上面的我们的描述,我们需要定义两个变量来存饭这趟遍历数组中找到的最大值和最小值,这里我们为了方便,我们定义一个max变量用来存放最大值,定义一个min变量来存放最小值,这两个元素的初始值随便赋,只要是在这个[begin,end]范围之内就可以,因为我们这里找到最大和最小的元素之后,要进行交换,所以我们这里使用指针(也就是数组元素的下标)。
for (int i = begin; i <= end; i++)//我们开始遍历数组(必须是在[begin,end]这个范围之内)。
{
if (arr[i] < arr[min])//找最小的元素。
{
min = i;
}
if (arr[i] > arr[max])//找最大的元素。
{
max = i;
}
}
Swap(&arr[max],&arr[end]);//当程序运行到这里的时候,就说明找到了数组中[begin,end]这个范围内的最大的元素和最小的元素,按照我们上面所讲述的方法,我们这里要将最大的元素放到[begin,end]这个范围中的begin这个位置上,将最小的元素放到[begin,end]这个范围中的end这个位置上。
if (min == end)//这一步的原因我们在下面有讲到(画图的方式)。
{
min = max;
}
Swap(&arr[min], &arr[begin]);
begin++;//重新确定下一次要遍历的数组的范围。
end--;
}
}
我们现在来解释一下上面我们遗留的那个问题:我们就以图中的那个数组为例来解释一下这个问题,就上图而言,我们在先进行最大的元素交换之后,9这个元素确实是被我们挪动到本次遍历的数组的范围的最后一个位置上了,但是,别忘了,在未交换之前,min这个指针指向的位置就是本次遍历的数组的范围最后一个位置,这时我们如果执意要进行将最小的那个元素挪动到本次遍历的数组的范围的第一个位置上的话,就会将9这个元素挪动到第一个位置上,这样的话,结果就会出错,因此,我们在这里需要判断一下,就是判断一下min这个指针是否指向的是本次遍历的数组的范围的第一个位置,若是的话,就需要让这个指针重新指向max这个指针指向的位置。
四.快速排序:
1.霍尔法(递归版):
(1).我们在这里先讲一下原始的快速排序,当原始的快速排序我们解决了之后,我们就来看一下我们优化的快速排序的方法。
这里我们先来写一下实现的思路(我们这里的快速排序采用的是递归地思想来解决问题的):快速排序其实就是定义两个指针,让这两个指针分别从数组中的第一个元素和最后一个元素,让这两个指针去遍历数组(我们这里将这两个定义的指针分别命名为left指针和right指针,right指针指向的是数组中最后一个元素的位置,left指针指向的是数组中的第一个元素的位置),然后我们再定义一个key这个变量,这个key这个变量中存放的是数组中的第一个元素(key中存放的数据只不一定只能是数组中的第一个元素,它还可以是数组中的最后一个元素,就只能是数组中第一个元素或者是数组中的最后一个元素这两个元素中的其中一个元素,其余均不行,我们这里让key取数组中的第一个元素为例来为大家展开解释),当我们将所有的变量全部都定义好后,接下来就可以走程序了,right指针从后往前走,在遍历的过程中寻找比key小的元素,如果遇到并且left指针和right指针还未相遇的话,指针就停下来,当right指针停下来的时候,我们的left指针就可以开始走了,而left指针是从前往后走,left指针在遍历数组的过程中,left指针寻找的是比key这个元素大的元素,如果遇到并且left指针和right指针还未相遇的话,那么left指针就停下来,不走了,当这两个指针全部都停下来时,就将这两个指针所指向的位置的元素进行交换即可,就这样一直执行上面的这个操作,直到left指针遇到right指针的时候整个循环就可以停下来了,这时就将key指针指向的元素(也就是第一个元素)与left指针和right指针相遇的那个位置中所存储的元素进行交换操作,这样的话,那么最后的结果就是left指针和right指针相遇的那个位置中所存储的元素的前面的所有的元素均比这个位置的元素小,而这个元素后面的所有元素均比这个元素大,这样的话,我们就相当于确定了一个元素的位置了(当我们这个一轮程序结束的时候,这是的left指针和right指针所指向的那个位置的元素就是当程序全部排列好后的位置,也就是说,我们每一轮程序结束之后,就会确定一个元素的位置),上面这是进行一次程序所得到的结果,接下来来我们用下图来为大家展示一下整个的所有过程。
我们用下图来为大家具体地解析一下,我们这里先来讲解一下进行一次操作的实现过程。
OK,如果大家没有看懂我上面所写的关于快速排序的文字解析的话,大家可以尝试去看一下我上面画的这副图,可以有一个清楚且直观的体验。
其实上面的这一个操作就是我们一次递归的过程,那么,接下来,我就来为大家写一下整个快速排序的过程。
OK,这里的递归的实现的过程正如上面的那幅图一样,当我们进行完上面的上面那一幅图中的过程之后,我们就要对原数组进行一次分割操作(这个文字解析我们仍然用上面的那副图中的那个数组为例来展开讲解),将6这个元素前面的原数组区间看成是一个新的数组,将6这个元素后面的原数组区间看成是一个新的数组,然后进行递归操作,对6这个元素前面的数组区间和6这个元素后面的数组区间再次进行上面的上面那一幅图中的过程,这样的话,再这一趟递归过程执行结束之后,我们就又确定了两个元素的位置(也就是上副图中的3这个元素和9这个元素的位置),再继续分割......直到将数组中的元素分割到最后只剩下了一个元素或者分割好后的数组中没有元素(换种说法,就是分割好后的新数组不存在),那么,此时我们就可以停止进行递归的操作了,开始返回。我们最终往mian函数中返回的时候,这届返回原数组就可以了,因为我们这里的整个递归过程它都是在原数组中进行的,也就是说,我们在执行完一次递归操作后确定的某个元素的位置,其实就是在原数组中找到了这个元素的位置。
void QuickSort(int* arr, int left, int right)
{
if (left >= right)//因为我们这里的快速排序是用递归的方法来实现的,因此,我们就必须得设置一下递归的结束条件,left==right是当数组中含有一个元素的时候,而left>right是我们分割的这个数组不存在的时候(等一下在这个代码的下面会为大家讲述这个问题的,就是left>right这种情况)。
{
return;
}
int tmp = left;//定义一个变量,存放数组中的第一个元素(这里我们定义的tmp就是上面的解析中的key)。
int begin = left;//由于我们到后面要对原数组进行分割操作(就是重新划分一个区间),因此,我们这里需要原数组的一个区间,begin和end要对数组进行遍历操作。
int end = right;
while (begin < end)//循环的结束条件是begin和end不可以相遇。
{
while (begin < end && arr[end] > arr[tmp])//我们在这里先让end指针走,找比首元素小的元素,当然,这里还需要有一个必不可少的因素,就是必须要限制begin < end,这里如果不加上这个限制的话,就可能会导致出错,按照我们的逻辑来说,当我们程序结束后,我们会确定某一个元素的位置,这个元素之前的所有元素均比它小,这个元素之前的所有元素均比它大,如果不加begin < end这个限制条件的话,最后造成的结果就是确定好的某一个元素的位置,这个元素之前的所有元素均比它大,这个元素之前的所有元素均比它小,违背了我们上面所说的逻辑,并且会有越界的风险,这个很危险。
{
end--;
}
while (begin < end && arr[begin] < arr[tmp])//end找到比tmp这个元素小的元素之后,停下来之后,begin开始往后去找比tmp大的元素。
{
begin++;
}
Swap(&arr[begin], &arr[end]);//两个指针都找到了对应要寻找的元素之后,就将这两个元素进行交换操作。
}
Swap(&arr[begin], &arr[tmp]);//交换这两个指针相遇位置的那个元素和tmp指向的那个元素。
tmp = begin;//这一步可以写也可以不写,我这里写它是为了方便下面的分割数组。
//[left,tmp-1] tmp [tmp+1,right]如果没有加上tmp = begin;这一步的话,这个分割就要分成下面这样[left,begin-1] begin [begin+1,right]或[left,end-1] end [end+1,right]
QuickSort(arr, left, tmp - 1);//递归,先递归左边。
QuickSort(arr, tmp + 1, right);
}
现在我们来解决一下上面出现的那种情况,就是为什么会出现left>right这种情况,这里我就下面的这幅图来为大家分析一下这中情况的出现原因吧:
好的,我们从上面的这副图中我们我们可以知道如果按照我们的程序走的话,上面的数组会被我们分割出一个不存在的数组下标范围来,那么这个数组下标所代表的数组就是一个不存在的数组,因此,我们在递归结束的条件中也要加入这个影响因素。
(2).我们接下来再来看一下优化的快速排序,这里我们先来说一下为什么要进行优化操作呢,或者说要怎么进行优化操作呢?
大家先看上面一段数组,假如说我要对上面的一组数组进行快速排序操作呢?这样的话,它的时间复杂度就会很高,为了避免时间复杂度很高的这种情况的出现,我们在这里需要进行一个三数取中的操作,就是我们取这个数组中第一个元素,最后一个元素和最中间的的那个元素,让这三个元素进行比较,我们取到这三个数中不大也不小的那个元素,让这个元素做key,这样就可以有效降低时间复杂度了。
要进行优化的不止这一个地方,还有一个地方我们要对其进行优化操作,就是我们这里使用的是递归的思想,既然有递归,那么自然就会有溢出的风险,我们在这里要避免这种风险的出现,因为我们这里是用递归的思想有点类似与二叉树,我们下面就是用二叉树的相关知识来解析。
通过上图我们可知,二叉树在递归时越往下面递归,递归的次数就越多,就更容易出现溢出的风险,因此,我们这就是说当递归到最后4次时,就不递归了,就改用插入排序就可以了(当分割后新数组的的元素剩10个元素的啥时候,就说明递归剩余4次了)。
这是改进之后的代码:
void QuickSort(int* arr, int left, int right)
{
if (left >= right)
{
return;
}
if ((right - left + 1) < 10)//判断递归是否进行到只剩最后4次了。
{
InsertSort(arr + left, (right - left + 1) - 1);
}
else
{
int mid = GetMid(arr, left, right);//执行三数取中操作。
Swap(&arr[mid], &arr[left]);//这里之所以要进行交换,是因为我们找到的那个不大也不小的元素可能不在数组的首位,为了迎合我们这里的逻辑,因此,要将我们找到的那个不大也不小的元素放到数组的首位。
int tmp = left;
int begin = left;
int end = right;
while (begin < end)
{
while (begin < end && arr[end] > arr[tmp])
{
end--;
}
while (begin < end && arr[begin] < arr[tmp])
{
begin++;
}
Swap(&arr[begin], &arr[end]);
}
Swap(&arr[begin], &arr[tmp]);
tmp = begin;
QuickSort(arr, left, tmp - 1);
QuickSort(arr, tmp + 1, right);
}
}
好的,同志们,以上就是我们的快速排序相关的代码了,当我们大家看到这里以后,相信大家肯定是已经对快速排序的一些知识掌握了,那么,我们有对代码感兴趣的小伙伴就可以试着去开始编写代码了,我这里再提醒一下大家,就是在编写代码的时候需要注意的一个坑,就是我们的left指针和right指针在去进行遍历数组的时候,如果你选择的是以数组中第一个元素作为key的话,那么,就要让right指针先开始去遍历数组,如果你是以数组中的最后一个元素作为key的话,那么,你在遍历数组的时候就需要先让left指针开始遍历,说到这里,可能有的同学会比较蒙,那么,我还是来为大家解释一下吧,解析如下所示:
其实关于这个问题的解释还可以去解答另一个问题,就是为什么我们在排的时候,begin和end相遇的时候共同指向的那个元素它就一定是比数组中的第一个元素小呢?为了解决这个问题,我们就相遇问题来探讨一下:
1>.left遇right:right先走,它遇到比key小的值就会停下来,所以right停下的位置上的元素就一定比key小,停下之后,left开始走,left遇到比它大的元素才会停下来,若left和right相遇,则说明left没有找到比key大的元素,遇到right就停下来了,那么停下来的那个位置就是right停下来的那个位置,而right此时所指向的那个位置的元素比key小。
2>.right遇left:right先走,它遇到比key小的值就会停下来,所以right停下的位置上的元素就一定比key小,停下之后,left开始走,left遇到比它大的元素才会停下来,若left和right同时停下,且满足left<right后,left和right所指向的位置的元素进行交换,交换后,那么此时left指针所指向的位置的元素就比key小了,此时right再走,right遇见left,说明right没有找到比key小的元素,right遇到left就停下来了,那么停下来的那个位置就是left的那个位置,而left指针所指向的那个位置的元素比key小。
综上所述,我们可以得出如下结论(这个结论很重要):
左边做key,右边先走,可以保证相遇位置比key小。
右边做key,左边先走,可以保证相遇位置比key大。
2.挖坑法(递归版):
这里的挖坑法和上面的霍尔法逻辑基本一致,我将代码给大家,由于时间关系,就不给大家一一解释了,如果有兴趣,可以自行画图看看,如果有哪个地方看不懂,可以在评论区回复我,我一定会抽时间回复大家的。
void QuickSortDigHole(int* arr, int left, int right)
{
if (left >= right)
{
return;
}
if ((right - left + 1) < 10)
{
InsertSort(arr + left, (right - left + 1) - 1);
}
else
{
int mid = GetMid(arr, left, right);
Swap(&arr[mid], &arr[left]);
int dig = arr[left];
int begin = left, end = right;
int hole = 0;
while (begin < end)
{
while (end > begin && arr[end] > dig)
{
end--;
}
hole = end;
arr[begin] = arr[hole];
while (end > begin && arr[begin] < dig)
{
begin++;
}
hole = begin;
arr[end] = arr[hole];
}
arr[begin] = dig;
QuickSortDigHole(arr, left, begin - 1);
QuickSortDigHole(arr, begin + 1, right);
}
}
3.双指针法(递归版):
这里的双指针法和上面的霍尔法逻辑基本一致,我将代码给大家,由于时间关系,就不给大家一一解释了,如果有兴趣,可以自行画图看看,如果有哪个地方看不懂,可以在评论区回复我,我一定会抽时间回复大家的。
void QuickSortPointer(int* arr, int left, int right)
{
if (left >= right)
{
return;
}
if ((right - left + 1) < 10)
{
InsertSort(arr + left, (right - left + 1) - 1);
}
else
{
int mid = GetMid(arr, left, right);
Swap(&arr[mid], &arr[left]);
int keyi = left;
int prev = left, cur = prev + 1;
int begin = left;
int end = right;
while (cur <= right)
{
if (arr[cur] < arr[keyi])
{
prev++;
Swap(&arr[cur], &arr[prev]);
}
cur++;
}
Swap(&arr[keyi], &arr[prev]);
keyi = prev;
QuickSortPointer(arr, begin, keyi-1);
QuickSortPointer(arr, keyi + 1, end);
}
}
4.非递归版:
好的,以上三中方法全部都是使用递归的思想来实现的,接下来,我们使用非递归的方法来实现一下这个快速排序:
我们现在先来讲解一下这个非递归版本的思路,我们这里要想实现这个非递归,就必须要借助一个数据结构的知识,就是栈有关的知识,我们大家先来想一下,就是我们上面所讲述的思路中递归实现的方法最为主要的一个思路是什么,如果我们大家仔细看的话,会发现,其实上面的递归操作中最为主要且重要的就是对于数组区间的一个分割,当我们有了这个分割后的数组区间后,我们才可以进行递归操作,由此,我们不难可以发现,快速排序中最重要的就是那个数组分割后的区间,那么,换到我们这里的非递归的话,就是将我们分割好后的区间放的栈中去,上面是递归实现的,我们这里可以使用一个循环来搞定快速排序的相关内容,接下来我就用下面这幅图来为大家展示一下具体的操作过程:
大家这里可以参考一下上面的这幅图,通过我们上面的递归的方法我们可以知道。递归中最为重要的就是我们对于数组的分割,换句话说,也就是区间的划分,因此,我们这里将数组的下标区间放在我们创建好的栈中,我们刚开始将最原数组的下标范围放在栈中,然后选出key,选好后,将我们刚刚放进去的数组下标范围从栈中拿出来,然后分割数组(我们这里的访问逻辑也与递归的逻辑保持一致),分割好后,先访问key左边区间,再访问key右边的区间,但是我们根据栈的特点来想的话,我们先将分割好后的后面的那个区间放到栈中,然后我们再将分割好后的前面的那个区间放到栈中,这样我们每次取区间的时候就是先访问key左边的区间,再访问key右边的区间,当我们被分割的数组中只剩下一个元素或者是分割后的数组不存在时,就不再将该区间放到栈中去了,直至栈为空时,就结束排序了。
void QuickSortNonR(int* arr, int left, int right)
{
Stack st;//定义一个栈。
StackInit(&st);//对栈进行初始化操作。
StackPush(&st, right);//由于我们的栈中不能存放某一个范围,因此,我们这里往栈中存放范围的极值(我们这里统一先存范围的最大值,然后存放范围的最小值,这样的话,我们可以保证每一次是先拿到范围的最小值,再拿到范围的最大值)。
StackPush(&st, left);//存范围的最小值。
while (!StackEmpty(&st))//通过上面的那幅图我们可以得知,排序结束的条件是栈为空的时候。
{
STDataType begin1 = StackTop(&st);//取出范围。
StackPop(&st);//将其Pop掉。
STDataType end1 = StackTop(&st);
StackPop(&st);
int mid = GetMid(arr, begin1, end1);//利用三数取中法找到那个不大不小的元素来作为key。
Swap(&arr[mid], &arr[begin1]);//这里之所以要进行交换,是因为我们找到的那个不大也不小的元素可能不在数组的首位,为了迎合我们这里的逻辑,因此,要将我们找到的那个不大也不小的元素放到数组的首位。
int tmp = begin1;//从这里开始我们往下的所有代码就与前面都是相同的,解释上面都有,大家有不会的可以参考前面的解释。
int begin = begin1;
int end = end1;
while (begin < end)
{
while (begin < end && arr[end] >= arr[tmp])
{
end--;
}
while (begin < end && arr[begin] <= arr[tmp])
{
begin++;
}
Swap(&arr[begin], &arr[end]);
}
Swap(&arr[begin], &arr[tmp]);
tmp = begin;
if (tmp + 1 < end1)//将分割好后的数组范围放到栈中,这里使用了两个if语句,是为了不让被分割后只剩一个元素的数组范围入栈和防止不存在的数组的下标范围入栈。
{
StackPush(&st, end1);
StackPush(&st, tmp+1);
}
if (begin1 < tmp - 1)
{
StackPush(&st, tmp - 1);
StackPush(&st, begin1);
}
}
StackDestroy(&st);//销毁掉栈。
}
五.归并排序:
1.递归版:
这里我们先来了解一下归并排序的思路:我们在进行归并排序的时候,还需要再次借助一个数组来完成我们这个归并操作,我们将这个数组起名为叫tmp数组,这个数组的长度就是要进行合并的两个数组的总长度,接下来我们就要开始进行讲解了。当然,在讲解之前,我们先来了解一下什么是归并,简单来讲,就是归并的含义是什么,(这里我们以数组为例来做一个解释),归并就是将两段有序的数组(两个数组中的元素是均按有序排列好的),合并成一个有序的数组,具体的做法就是:定义两个指针,让这两个指针分别指向两个数组的首元素,开始从头进行比较,比较选出最小的那个元素,将其放到tmp数组的第一个元素的位置上,再往后面继续去找,找到两个元素中小的那个元素,找到之后,就将这个小的元素放到tmp数组中的下一个位置上,直到将两个数组全部比较完就可以了,将两个数组中的所有的元素按照元素的大小顺序在tmp数组中全部排列好,其实,我们这里要进行的归并排序就是上面的那个思路,和上面的那个思路是一模一样的,接下来我们就通过下面的这副图来具体的看一下这个归并排序的过程。
我们这里就使用上面那幅图中的那个数组来进行讲解,经过我们上面的对于归并排序的分析,我们知道了要进行归并的条件就是必须要保证两个待归并的数组是有序数组,只有这样,我们才能保证我们合并后的数组中的元素排列是有序排列的,因此,我们这里所采用的方法是在进行归并之前,我们先将要合并的数组利用递归操作将数组给分解,直到被分解后的数组中只含有一个元素时,我们就可以停止进行递归操作了,这里有同学就可能要问了,为什么当被分解后的数组中只含有一个元素时,我们就可以停止进行递归操作了,大家不妨在这里想一想,当数组中含有一个元素的时候,那么,我们是不是就可以认为这个数组是有序数组,当然,是可以的,因此,我们这里将数组分解成数组中含有一个元素,那么,这个时候,递归就可以停止了。
OK,说到这里,我们就可以开始进行合并操作了,看下图:
当我们分解好后,那么,我们至少就能保证接下来的排序操作中我们要进行合并的两个数组是有序的,既然是有序的,那我们现在就可以开始进行合并操作了,就如上面的那副图中所示的那样,定义两个指针,在刚开始的时候分别指向两个要合并的数组的首元素的位置上,然后对两个位置上所存储的元素进行比较,选出小的那个元素,将那个小的元素放在tmp数组中,然后继续往后继续比较,再选出比较小的那个元素,再接着继续往tmp数组的下一个位置放这个刚才选出的较小的元素,当我们将要合并的数组中的所有的元素全部都合并完后,我们还要进行一步,就是将tmp数组中的元素将其整体复制到原数组中(位置要与原来一样),这里之所以要进行这一步操作,主要还是基于我们这里的归并的特点,就是这里的归并要求的是待合并的两个数组必须要是有序的,如果不进行复制这一步操作的话,我们就相当于只有tmp数组中的元素是有序的,原数组中的对应的位置上的元素还是原来的,并没有变成一个有序的元素序列,那么,这样的话,我们在递归回去的时候,要进行下一个数组合并的时候,就无法保证这两个数组是有序的数组。
这里我们再来说一下这个位置要一样,位置要一样就是比如说我们这里要将上图中的这个数组中的只含有8这个元素的数组(假设这是已经分割好后的数组)和只含有2这个元素的数组进行合并的话,(8这个元素在原数组中的位置下标为0的位置,而2这个元素在原数组中的位置是下标为1的这个位置)在tmp数组中的位置也是下标为0和下标为1的位置,那么,复制到原数组中也是下标为0和下标为1的位置,对了,我们在这个归并排序这里还必须注意一个非常重要的点,就是我们这里在进行合并的时候,是两个相邻的数组进行的合并操作(简而言之,就是我们这里是靠数组的下标访问来确定范围,而我们确定的这个范围我们在逻辑上将其看成一个新的数组),这一点非常重要。
void MergeSort(int* arr, int* tmp, int begin, int end)
{
if (begin >= end)//因为我们这里的归并排序是靠递归思想来实现的,那么,既然是递归,就一定少不了结束条件,通过我们上面的分析可知,这里进行的递归结束的条件就是当数组中只剩下1个元素的时候,或者说,当我们分割好后的这个数组不存在的时候,那么,我们就可以说递归结束了。
{
return;
}
int mid = (begin + end) / 2;//之所以加上这一步,是因为我们想对要进行排序的数组进行分割操作,找到数组的中间的那个元素,方便分割数组。
MergeSort(arr, tmp, begin, mid);//通过上面的两幅图我们可以知道,我们这里的操作是先对数组进行一个分割,将这个数组最后分成一个数组中只含有一个元素的数组,我们通过递归操作来实现这里的分割,先分割左边的。
MergeSort(arr, tmp, mid + 1, end);//再分割右边的。
int begin1 = begin, end1 = mid;//我们这一句代码可以写,当然,也可以不写,我这里使用下角标1和下角标2更容易区分要进行合并的两个有序数组(下角标1代表的是要合并的第1个有序数组,下角标2代表的是要合并的第2个有序数组),这里我们的逻辑上是在进行数组之间的合并,但是在物理上是在原数组中进行的合并操作,因此,这里得用到范围,换句话说,范围中的数组元素是有序的,将这个范围中的有序元素重新看成一个新的数组。
int begin2 = mid + 1, end2 = end;
int i = begin1;//定义一个指针,这个指针的作用就是将每次比较选出来的较小的那个元素放到tmp数组中(在tmp数组中的位置也要与在原数组中的位置一模一样,换句话说,就是,比如说:要排序的两个数组在原数组中的下标范围是[0,4]那么,相应的,在tmp数组中的位置也得是[0,4]这个范围)。
while (begin1 <= end1 && begin2 <= end2)//开始合并了。
{
if (arr[begin1] <= arr[begin2])//选出两个比较的元素中小的那个元素。
{
tmp[i++] = arr[begin1++];//放到tmp数组中。
}
else
{
tmp[i++] = arr[begin2++];
}
}
while (begin1 <= end1)//这里之所以要加上这一句代码,是因为有一种情况,我们必须要考虑到,就是要合并的两个数组中有一个数组中的所有的元素均比另一个数组中的某一个元素小,那么这样话,在执行完上面的那个while循环后,另一个数组还没有被访问完,因为我们合并的目的就是要将两个待合并的数组中的所有元素均进行合并操作,因此,我们得将另一个没有访问完的数组中的剩余的元素给放到tmp数组中去。
{
tmp[i++] = arr[begin1++];
}
while (begin2 <= end2)
{
tmp[i++] = arr[begin2++];
}
memcpy(arr + begin, tmp + begin, (end - begin + 1) * sizeof(int));//将tmp数组中的所有元素全部复制到原数组中的相应位置上去,然后递归回去,执行下一次的合并操作。
}
2.非递归版:
这里的非递归版的思路其实和递归般的思路是差不多的,都是先分割数组,当我们将数组分割到数组中只剩下一个元素的时候,或者是分割好后的数组不存在的时候,就不会再继续分割了。
我们的这个非递归它在分割数组数组的时候不需要就是通过递归的思路来达到分割数组的目的,
具体的思路这里文字说不清,我们通过下面的这副图来给大家分析一下吧:
OK,正如上面的这幅图中所展示的那样,我们就直接在原数组中进行合并操作就可以了,我们先将一个元素看成是一个数组,然后两个两个数组进行合并操作,合并完后,我们的原数组就变成2中的那个数组了,将两个元素看成是一个数组,然后两个两个数组进行合并操作,合并后,我们的原数组就变成3中的那个数组了,然后将四个元素看成是一个数组,然后两个两个数组进行合并操作,合并后,我们的原数组就变成4中的那个数组了,这样的话,我们的数组就被排序好了,接下来,上代码:
void MergeSortNonR(int* arr, int* tmp, int sz)
{
int gap = 1;//这里我们定义一个gap值,这个gap的意思就是要合并的数组中的元素个数(是要合并的两个数组中的一个数组中的个数,因为合并的两个数组中的元素个数不一定是都是相同的)。
while (gap <= sz)//我们要合并的数组中的元素个数必须比原数组中的元素少或者是一样多。
{
for (int j = 0; j <= sz; j += 2 * gap)//通过我们上面的讲解,我们这里是使用for循环来解决上面的合并的问题,j每次在+的时候都是+2*gap个元素的位置,2*gap个元素个数是我们每次将两个待合并的数组合并后的元素个数,就拿上面那副图中的1这个数组来说的话,我们是将只含有8这个元素的数组和只含有2这个元素的数组进行合并操作,当这两个数组进行完合并之后,那么,下一次要进行合并的数组就是只含有4这个元素的数组和只含有7这个元素的数组,在这次的合并中,gap为1,只含有8这个元素的数组和只含有4这个元素的数组之间相差的元素个数是2*gap个元素。
{
int begin1 = j, end1 = j + gap - 1;//接下来的操作就和上面的递归的操作是一样的。
int begin2 = j + gap, end2 = j + 2 * gap - 1;
if (begin2 > sz)//下面我们将会讲到这里为什么这里要加入这两步if语句的代码。
{
break;
}
if (end2 > sz)
{
end2 = sz;
}
int i = begin1;
while (begin1 <= end1 && begin2 <= end2)
{
if (arr[begin1] <= arr[begin2])
{
tmp[i++] = arr[begin1++];
}
else
{
tmp[i++] = arr[begin2++];
}
}
while (begin1 <= end1)
{
tmp[i++] = arr[begin1++];
}
while (begin2 <= end2)
{
tmp[i++] = arr[begin2++];
}
memcpy(arr + j, tmp + j, (end2 - j + 1) * sizeof(int));//这里的memcpy,也就是上面的思路中所说的复制的这一步骤,只能放在一次合并的后面,也就是,当我们一次合并结束之后,我们就要马上进行复制的这一步操作,这一步操作不可以等到一轮合并都结束了,才开始往原数组中进行复制操作的这一过程,下面我们会讲到这一个问题。
}
gap *= 2;//我们在进行一轮合并之后,根据我们上面的思路,我们接下来合并的数组中的元素是刚刚合并的两个数组中的元素总和。
}
}
好了,以上的代码就是我们的归并排序非递归版的代码了,那么,我们接下来就为大家讲一下在上述代码的编写中,我们为什么要加入那两句if语句呢?这里先来给大家解释一下这两句if语句的作用是什么,其实,这两句if语句的作用就是判断要进行合并的两个数组存不存在,接下来,就请看下图:
从上图我们可以看到,当我们对只含有1个元素的数组进行合并的时候,这一次的合并是没有问题的,但是当我们进行下一步的合并操作时,也就是对只含有2个元素的数组进行合并的时候,问题就出现了,我们对下标范围为[0,1]和下标范围为[2,3]这两个数组合并是没有问题的,但是对[4,5]这个数组合并的话就有问题了,按照我们的分法,与这个数组合并的数组的下标范围是[6,7],但是这个范围的数组不存在呀,因此,就会有越界的风险。好的,通过分析,有一下3种情况可能会引发越界:
我们现在知道了越界的情况,那么,我们就要去防止这种情况的出现,大家看一下,第2种情况和第3种情况,这两种情况我们是不是就都不用排了,也就是说,要排序的两个数组中,有一个数组不存在,那么,这组就不需要进行排序了,因此,直接跳出本趟排序的过程即可,接着,我们再看第1种情况,这种情况就只是end2越界,其余均没有越界,那么,我们将end2重新将最初的那个数组的最后一个元素的下标赋给end2就可以了,好了,以上的所有就是对我们的那两句if语句的解释。
到这里,我们就来说一说为什么这个复制这一步操作只能放在一次合并的后面,而不是等到一轮合并都结束了,才开始整体地往原数组中进行复制这一操作:对于这个问题,我们就以上面那副图的上面那一幅图中的22合并这一步来展开讲解,[0,1]和[2,3]这两个数组进行合并了,当[4,5]这个数组准备和[6,7]这个数组进行合并的时候,[6,7]这个数组它不存在呀,那么,这样的话,这一组就不会进行排序操作,那么,[4,5]这个数组中的所有元素就没有写入到tmp中,假设我们的复制这一步操作,是等到一轮合并都结束的情况下,才整体地往进行复制这步操作的话,由于[4,5]这个数组中的所有元素就没有写入到tmp中,那么复制到原数组中地相应位置上,原数组中的在这个区间的元素就会被一些别的数值(每一个数组在被我们创建之后,如果我们没有对它进行赋值操作的话,系统就会自己对其赋一些数值)给替代,那么,原来在这个区间的元素我们就找不到了,会造成数据的丢失。
以上就是我们的有关归并排序的所有相关内容了,如果大家又哪里看不懂的,可以随时在评论区问我。
六.冒泡排序:
OK,相信大家对于这个排序并不会感到陌生,因为这个排序我们大家在刚刚开始学习语言的时候就基本已经接触过了,所以这个排序对于大家来说很熟悉,那我在这里就不多说废话了,我们在这里就过的会稍微快一点,冒泡排序其实也就是去遍历数组,它的遍历是每相邻的两个元素进行比较,找出两个元素中较大的那个元素,将他换到靠后的位置上,换好后,再去进行下一趟比较,我们在进行了每一轮的比较后,都会选出最大的一个元素,将它放到本次排序的数组中末位的位置。
void BubbleSort(int* arr, int sz)
{
for (int i = 0; i <= sz; i++)
{
for (int j = 0; j < sz - i; j++)
{
if (arr[j] > arr[j + 1])
{
Swap(&arr[j], &arr[j + 1]);
}
}
}
}
七.堆排序:
关于我们这里的这个堆排序,我们在之前的数和二叉树有关的博客中我们就已经说到过了,所以,我们就不在这里多说了,大家对于代码有什么疑问的话可以去看我之前写的博客。
void HeapSort(int* arr, int sz)
{
for (int i = (sz - 1) / 2; i >= 0; i--)
{
HeapAdjustDown(arr, i, sz);
}
int j = sz;
for (int i = 0; i <= sz; i++)
{
Swap(&arr[0], &arr[j]);
printf("%d ", arr[j]);
j--;
HeapAdjustDown(arr, 0, j);
}
}
void HeapAdjustDown(int* arr, int parent, int sz)
{
int child = parent * 2 + 1;
while (child <= sz)
{
if (child + 1 <= sz && arr[child] > arr[child + 1])
{
child++;
}
if (arr[child] < arr[parent])
{
Swap(&arr[child], &arr[parent]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
八.计数排序:
我们这里的计数排序就是统计要排序的数组中各个元素的个数,在统计之前我们这里还需要借助一个第三方数组的帮助,我们在这里为这个第三方数组起名为是叫tmp数组,该数组的空间大小就是我们这个数组的最大值+1,接下来,我们通过下面的这幅图来具体的为大家解析一下这个计数排序的精彩过程:
我们这里通过arr这个数组来具体的分析一下这个过程,arr数组正如上图中所示的那样,通过我们上面的解析部分我们可知,我们在这里要创建一个第三方数组tmp数组,我们这里开创的tmp数组的大小是10个空间的大小,我们这里为了方便观察,我们将tmp数组的下标写出来,接下来我们就来数arr数组中各元素的个数,arr数组中元素大小为6的元素有1个元素,因此,我们在tmp数组中下标为6的空间赋值为1,接着往下数,arr数组中元素大小为1的元素有2个元素,因此,我们在tmp数组中下标为1的空间赋值为2,接着往下数,arr数组中元素大小为2的元素有1个元素,因此,我们在tmp数组中下标为2的空间赋值为1,接着往下数,arr数组中元素大小为9的元素有1个元素,因此,我们在tmp数组中下标为9的空间赋值为1,接着往下数,arr数组中元素大小为4的元素有3个元素,因此,我们在tmp数组中下标为4的空间赋值为3,接着往下数,arr数组中元素大小为3的元素有1个元素,因此,我们在tmp数组中下标为3的空间赋值为1,接着往下数,当我们将数组整个遍历了一遍之后,我们就找出了arr数组中所有的元素在arr数组中的个数,既然我们已经全部找到了,那么,我们下一步就该进行排序操作了,具体过程请看下图:
好的,我们可以通过tmp数组就可以直接排序好了,我们来讲一下具体的过程:tmp数组中的元素代表的意思就是出现的次数,tmp数组中的下标对应的就是arr数组中的元素,而tmp数组中的元素就是对应的下标在arr数组中出现的次数,而tmp数组的下标就是从小到大排列的,因此,我们就看tmp数组中的元素(这里我们使用上面的那幅图来解释),tmp数组中0下标对应的元素大小是0,说明arr数组中没有0这个元素,我们再往后遍历tmp数组,tmp数组中1下标对应的元素大小是2,说明arr数组中有两个大小为1的元素,因此,arr数组中的前两个元素我们将其赋值为1,我们再往后遍历tmp数组,tmp数组中2下标对应的元素大小是1,说明arr数组中有一个大小为2的元素,因此,arr数组中的下一个元素我们将其赋值为2,我们再往后遍历tmp数组.............直到我们这里tmp数组全部遍历结束,那么,这个计数排序就可以结束了。
当我们大家看到这里是,就说明我们大家已经将计数排序的原理已经理解的八九不离十了,现在,给大家说一下这个排序存在的弊端,就是如果我们要排序的数组中的那个最大值非常大的话,那就相当于我们是在这里是要开创一个非常大的数组空间,比如说,我要排序的数组中的元素大小介于105~110之间的话,那么,按照我们上面的那个方法,要开创一个含有111个空间大小的数组,并且,我们这里只会使用到tmp数组的下标为105~110的空间,这样的话,就会造成空间的浪费相关的一些问题,因此,我们在这里将其给改进升级一下,我们在开始排序之前,我们先遍历一遍数组,选出最大的元素和最小的元素这两个元素,我们建立的tmp数组的空间大小就是最大的元素和最小的元素这两个元素之间的元素个数,在查找arr数组中各元素的个数的时候,我们可以采用映射的方法来实现,且看下图:
这里的映射的方法去解决就是说我们的tmp数组中含有6个空间大小,由于我们上面说过,tmp数组中的下标代表的是arr数组中的元素的大小,我们这里在得出arr数组中含有105这个元素有2个,我们让105这个arr中的元素减去刚刚选出来的最小值,也就是105,是0,就将arr数组中含有105这个元素的个数,也就是2放到tmp数组中下标为0的这个位置上,就按这样的这个操作,一直将arr数组全部遍历完就可以了,然后就是排序了,我们在往arr数组中重新进行赋值操作时,要加上刚刚选出来的最小值,也就是105,比如说,tmp数组中下标为0的元大小为2,就说明arr数组中含有2个元素大小为0+105的元素,tmp数组中下标为1的元大小为2,就说明arr数组中含有2个元素大小为1+105的元素,以此类推。
void CountSort(int* arr, int sz)
{
int max = arr[0], min = arr[0];//定义两个变量,一个变量存放要排序的数组的最大值,一个变量存放要排序的数组的最小值。
for (int i = 0; i <= sz; i++)//遍历数组,选出最大值和最小值。
{
if (arr[i] > max)
{
max = arr[i];
}
if (arr[i] < min)
{
min = arr[i];
}
}
int count = max - min + 1;//确定tmp数组要开创的空间的大小。
int* tmp = (int*)calloc(count, sizeof(int) * count);//开创空间,我们这里想将开创的数组空间全部都初始化为0,因此,为了方便,我们这里直接选用calloc函数来进行数组空间的开创。
if (tmp == NULL)//判断是否开创成功。
{
perror("calloc fail");
return;
}
for (int i = 0; i <= sz; i++)//遍历数组,找出arr数组中的各个相同元素的个数,并将该个数存放到tmp数组中的相应的位置上。
{
tmp[arr[i] - min]++;//这里我们采用的是刚刚上面说的映射的方法。
}
int j = 0;//再定义一个变量,这次定义的这个变量是为了重新向arr数组中赋值(通过tmp数组)。
for (int i = 0; i < count; i++)//通过for循环来进行排序。
{
while (tmp[i]--)//tmp数组中的元素是该下标在arr数组中的个数,因此通过--这种方式来将这个元素全部重新写到arr数组中去。
{
arr[j++] = i + min;//下标要加最小值。
}
}
free(tmp);//用完tmp数组后,要将他释放掉。
tmp = NULL;//将其置空。
}
好了,以上就是我们计数排序的内容了。
———————————————————————————————————————————
我们这里再来一个补充的内容,就是稳定性。
稳定性:相同的值在进行完排序过后相对的位置顺序不会发生改变。
接下来,我们来对以上的八大排序进行一个完整的总结,如下图所示:
直接插入排序 | 希尔排序 | 选择排序 | 堆排序 | 冒泡排序 | 快速排序 | 归并排序 | |
时间复杂度 | O(N^2) | O()N^1.3 | O(N^2) | O(NlogN) | O(N^2) | O(NlogN) | O(NlogN) |
空间复杂度 | O(1) | O(1) | O(1) | O(1) | O(1) | O(logN) | O(N) |
稳定性 | 稳定 | 不稳定 | 不稳定 | 不稳定 | 稳定 | 不稳定 | 稳定 |