本文归纳数据结构中的七大排序,不说废话开始介绍排序
插入排序
直接插入排序
直插,将一个数插入到一个有序数组中。这样的操作是插入排序的单趟排序,插入排序完成后,该数就到了数组中正确的位置。一个长度为n的数组,从第二个数开始到最后一个数,将每个数前面所有数看成一个数组,依次将第二个数到最后一个数插入到前面的数组中,就完成了插入排序。
那单趟排序的操作要怎么实现?假设将4插入[2,9]这个数组中,将4与数组中最后一个比较,4小于9,将9赋值到数组的后一个位置,数组变成[2,9,9],将4与2比较,4大于2,将4放到2的后面,数组变成[2,4,9],单趟排序完成。
将tmp插入一个有序的升序数组,从数组中的最后一个数开始比较,若最后一个数大于tmp,把最后一个数移动到后一个位置。将tmp与该数的前一个数比较,若该数小于tmp,将tmp赋值到该数的后一个位置,循环结束。就是说要插入的数从数组的最后开始,找到第一个比它小的数,放到第一个比它小的数后面。至此一趟单趟排序完成。
void InsertSort(int* data, int n)
{
for (int i = 1; i < n; i++)
{
int tmp = data[i];
int j = 0;
for (j = i - 1; j >= 0; j--)
{
if (data[j] > tmp)
data[j + 1] = data[j];
else
break;
}
data[j + 1] = tmp;
}
}
外层循环控制循环次数的同时,保存要进行插入的数,内层循环负责遍历要插入的数的前面所有数,用于与要插入的数比较
最后对时间复杂度进行分析,直接插入排序要对n-1个数进行插入,每次要进行比较的数都是要插入的数前面的数,数量成等差数列,在最坏的情况下,每次都要与前面的所有数进行比较,所以比较的次数是1,2,3… n-1,根据等差数列求和公式,得到要比较的次数是n * (n - 1) / 2,用大O表示法,时间复杂度是O(n ^ 2);
而直接插入排序是在原数组的基础上进行排序,所以空间复杂度为O(1)。
直接插入排序总结:
1.数组越接近有序,直接排序就越快
2.时间复杂度O(n^2)
3.空间复杂度O(1)
希尔排序
若数组越接近有序,那么插入排序会越快(插入一个数,将其与它的前一个数进行比较,若数组接近有序,则在向前比较几个数后就能完成插入,不用再向前比较,就进行下一个数的插入)。根据这个结论,希尔排序对数组进行预排序,每进行一次预排序,数组就越接近有序。
如何做到预排序?将数组分割成gap个子数组,对子数组进行直接插入排序,这时的数组就比之前更接近有序。再将数组分成更多的子数组,再进行进行插入排序,当gap为1时,就是对整个数组进行插入排序,至此数组达到有序。 相隔的距离为gap的数之间为一组。对这样的一组数进行直接插入排序。
与直接插入排序一样,将要插入的数保存。例,要把5插入到9这个数组中时,将5保存,比较5与9(往前gap位置的数)的大小,9比5大,将9赋值到5的位置上(升序) 此时数组没有其他数,比较结束,将5放到原来是9的位置上。
之后要找的数不是7,而是和5间隔距离为3的数,将7插入[5,9]这个数组。所以循环变量一次要加gap,不是加1
void _ShellSort(int* data, int n)
{
int gap = 3;
for (int j = 0; j < gap; j++)
{
for (int i = 0; i < n - gap; i += gap)
{
int tmp = data[i + gap];
int pos = i;
while (pos >= 0)
[
if (tmp < data[pos])
{
data[pos + gap] = data[pos];
pos -= gap;
}
else
break;
]
data[pos + gap] = tmp;
}
}
}
单趟排序中有三层循环,看上面的图,最外层的循环控制的是不同颜色数组的更新;中间一层和最里面的一层循环控制同一颜色数字的排序;
可以将最外层的for和中间一层的for循环进行合并,但时间复杂度一样,至少写的更简略了。反正数组的所有数都要被遍历一次,那为什么要用两个变量遍历,所以改进的代码只用了一个变量来遍历数组。
void _ShellSort(int* data, int n)
{
int gap = 3;
for (int i = 0; i < n - gap; i++)
{
int pos = i;
int tmp = data[pos + gap];
while (pos >= 0)
{
if (data[pos] > tmp)
{
data[pos + gap] = data[pos];
pos -= gap;
}
else
break;
}
data[pos + gap] = tmp;
}
}
而单趟排序只是将数组中间隔为gap的数进行排序,gap是3,排完序后数组中间隔为3的数之间就是有序的,但间隔为2的数之间不是有序,所以要减小gap将数组分成更多的子数组进行单趟排序,直到gap为1,即对相邻的数之间进行插入排序,相当于直接插入排序,此后数组完全有序。
所以希尔排序的思想就是:
1.gap > 1,对数组进行预排序,使数组接近有序,越有序的数组用直接插入排序就越快
2.gap == 1,此时的排序就是直接插入排序
所以希尔排序是三层循环,最外层控制将数组分割成多少个子数组,中间一层控制子数组的更新,最内层控制子数组的插入排序。
void ShellSort(int* data, int n)
{
int gap = n;
while (gap > 1)
{
gap = gap / 3 + 1;
for (int i = 0; i < n - gap; i++)
{
int pos = i;
int tmp = data[pos + gap];
while (pos >= 0)
{
if (data[pos] > tmp)
{
data[pos + gap] = data[pos];
pos -= gap;
}
else
break;
}
data[pos + gap] = tmp;
}
}
}
选择排序
选择排序
对数组进行遍历,每遍历一遍都找出一个最小值,记录最小值的下标,将最小值与数组的第一个数交换。此时第一个数满足升序的性质,将除了第一个数之外的数看成数组,再从其中找出最小值的下标,将最小值与数组的第一个数交换,再把除了第一个数之外的数看成数组…直到数组的长度为1,排序完成
void SelectSort(int* data, int n)
{
for (int i = 0; i < n - 1; i++)
{
int mini = i;
for (int j = i + 1; j < n; j++)
{
if (data[j] < data[mini])
mini = j;
}
Swap(&data[mini], &data[i]);
}
}
选择排序还能一次选出最大与最小值,将时间减半
void SelectSort(int* data, int n)
{
int left = 0;
int right = n - 1;
while (left < right)
{
int maxi = left;
int mini = left;
for (int i = left; i <= right; i++)
{
if (data[i] > data[maxi])
maxi = i;
if (data[i] < data[mini])
mini = i;
}
Swap(&data[mini], &data[left]);
if (maxi == left)
maxi = mini;
Swap(&data[maxi], &data[right]);
left++;
right--;
}
}
选择排序很好理解,遍历一遍数组选出一个最小数,放到数组第一个位置。再将其他数看成一个数组,选最小。所以数组长度每次会减1,要遍历的次数是n,n-1,n-2… 2。一个等差数列的时间复杂度为O(n^2)。
堆排序
之前写过,这里就不写了
交换排序
冒泡排序
一趟单趟排序比较相邻的两个数,将一个最大的数排到最后,一次只能使一个数有序,所以冒泡排序要进行n - 1次单趟排序,n为数组长度。
所以冒泡由两层循环控制,外层控制单趟排序的次数,内层控制单趟排序。要注意的是,将最大的数排到数组的最后,再进行排序时的数组,应该是除了刚刚排完序的数之外的数组成的。因此单趟排序的数组长度要注意控制。
void BubbleSort(int* data, int n)
{
for (int i = 0; i < n - 1; i++)
{
for (int j = 0; j < n - i - 1; j++)
{
if (data[j] > data[j + 1])
Swap(&data[j], &data[j + 1]);
}
}
}
若在进行单趟排序时,没有数发生交换,即相邻的数之间都满足前一个数小于后一个数,此时的数组为有序数组,故不用再进行排序,所以能对冒牌排序进行优化。
void BubbleSort(int* data, int n)
{
for (int i = 0; i < n - 1; i++)
{
int flat = 0;
for (int j = 0; j < n - i - 1; j++)
{
if (data[j] > data[j + 1])
{
Swap(&data[j], &data[j + 1]);
flat = 1;
}
}
if (flat == 0)
break;
}
}
快速排序
先理解快速排序的单趟排序:从数组中选出一个key,一般是数组中的第一个数,要求排完序key的左边都是小于它的数,右边都是大于它的数。
单趟排序一共有三种实现方式:
1.hoare版本
一个left,一个right分别指向数组第一个数与最后一个数,若将数组第一个数当作key,right先判断指向的数是否小于key,若小于key,right不动,若大于key,right向左走一步。直到遇到比key小的数或者left停下来。right停下之后,判断left指向的数是否比key大,若比key大,left不动,比key小,left向右走一步,重复这样的操作,直到遇到比key大的数或者遇到right停下。两者停下后,交换两数。 交换后 当两数停下时 然后right找小,遇到3停下,left找大,先判断4是比key小的,left向右走遇到right,此时停下,循环结束。 将停下的位置与key交换。6作为key的单趟排序完成,6的左边都是比它小的数,右边是比6大的数。
void PartSort1(int* data, int left, int right)
{
int keyi = left;
while (left < right)
{
while (left < right && data[right] >= data[keyi])
right--;
while (left < right && data[left] <= data[keyi])
left++;
Swap(&data[left], &data[right]);
}
Swap(&data[left], &data[keyi]);
}
2.挖坑法
将数组第一个数选为key后,记录该位置为一个坑位,right先找大,找到后将找到的数填到坑位上,将此时right更新为新的坑位,left找小,找到后将该数填到坑位上,将此时的left更新为新的坑位。最后left与right相遇时,将key填到坑位上。
刚开始的数组
right找小 找到之后填到坑中,更新pit left找大,将找到的数填到坑中,更新pit 最后排完序的数组
void PartSort2(int* data, int left, int right)
{
int keyi = left;
int key = data[keyi];
int pit = keyi;
while (left < right)
{
while (left < right && data[right] >= key)
right--;
data[pit] = data[right];
pit = right;
while (left < right && data[left] <= key)
left++;
data[pit] = data[left];
pit = left;
}
data[pit] = key;
}
3.前后指针法
一个指针prev指向cur指针的前一个位置,cur指针在开始时指向数组的第一个数。同时将数组的第一个数视为key。 判断prev是否小于key,若prev小于key,将cur向前走一步,判断cur与prev是否相等,若不相等,交换两数。将prev向右走一步,判断prev是否小于key来确定cur是否要往右走…当prev遍历完数组时,将key与cur交换
第一步,prev为1,比6小,将cur向右走一步 此时1等于1,两者不交换。prev向右走一步,而2小于6,cur也向右走一步,两者还是相等,不交换。 prev向右走,7大于6,cur不动,prev再走,9大于6,cur还是不动,prev再走,3小于6,cur向右走,7不等于3,交换两数 prev向右走,4小于6,cur也走,不相等,交换两数… 当prev为7时,向右走一步后,10大于6,cur不动,prev再走,8还是大于6,再走遍历完整个数组,循环结束,将key6与cur5交换
void PartSort3(int* data, int left, int right)
{
int keyi = left;
int cur = left;
int prev = cur + 1;
while (prev <= right)
{
if (data[prev] < data[keyi])
cur++;
if (data[prev] != data[cur])
Swap(&data[prev], &data[cur]);
prev++;
}
Swap(&data[keyi], &data[cur]);
}
代码还能简化
void PartSort3(int* data, int left, int right)
{
int keyi = left;
int cur = left;
int prev = cur + 1;
while (prev <= right)
{
if (data[prev] < data[keyi] && data[++cur] != data[prev])
Swap(&data[cur], &data[prev]);
prev++;
}
Swap(&data[keyi], &data[cur]);
}
单趟排序会让一个key排到正确的位置,左边都是小于它的数,右边是大于它的数。当出现以下情况,则表示数组有序
1.key的左右两边都只有一个数
2.key的左右两边有一边没有数,一边有数
所以单趟排序后应该返回key在数组中的位置,以此判断这个数组是否有序。若数组无序,则将key左边的数视为一个数组,右边也视为一个数组,对这两个数组再进行 单趟排序。
所以要对这三个快排的单趟排序进行修改
1.1hoare版本
int PartSort1(int* data, int left, int right)
{
int keyi = left;
while (left < right)
{
while (left < right && data[right] >= data[keyi])
right--;
while (left < right && data[left] <= data[keyi])
left++;
Swap(&data[left], &data[right]);
}
Swap(&data[left], &data[keyi]);
return left;
}
2.1挖坑法
int PartSort2(int* data, int left, int right)
{
int keyi = left;
int key = data[keyi];
int pit = keyi;
while (left < right)
{
while (left < right && data[right] >= key)
right--;
data[pit] = data[right];
pit = right;
while (left < right && data[left] <= key)
left++;
data[pit] = data[left];
pit = left;
}
data[pit] = key;
return pit;
}
3.1前后指针法
int PartSort3(int* data, int left, int right)
{
int keyi = left;
int cur = left;
int prev = cur + 1;
while (prev <= right)
{
if (data[prev] < data[keyi] && data[++cur] != data[prev])
Swap(&data[cur], &data[prev]);
prev++;
}
Swap(&data[keyi], &data[cur]);
return cur;
}
可以将快排排序拆分成有关单趟排序的子问题,如果单趟排序后key的左边数组有序,右边数组有序,则该数组有序,若左右两边的数组无序,则将它们排成有序,继续对这两个数组进行单趟排序。
快排实现
void QuickSort(int* data, int begin, int end)
{
int keyi = PartSort2(data, begin, end);
if (begin < keyi - 1)
QuickSort(data, begin, keyi - 1);
if (keyi + 1 < end)
QuickSort(data, keyi + 1, end);
}
(非递归实现,手动创建栈,用循环模拟递归实现)
void QucikSortNonR(int* data, int begin, int end)
{
Stack st;
StackInit(&st);
StackPush(&st, being);
StackPush(&st, end);
while (!StackEmpty(&st))
{
int right = StackTop(&st);
StackPop(&st);
int left = StackTop(&st);
StackPop(&st);
int keyi = PartSort1(data, left, right);
if (left < keyi - 1);
{
StackPush(&st, left);
StackPush(&st, keyi - 1);
}
if (keyi + 1 < right)
{
StackPush(&st, keyi + 1);
StackPush(&st, right);
}
}
}
优化1:减少递归深度(效果不明显)
假设每次单趟排序后key的位置都在数组的中间,那么快排的单趟调用就像一颗满二叉树 而函数的递归调用是需要时间的,由于这样的递归到最后要创建很多的栈帧,所以当要进行排序的数组长度足够小时,可以用其他排序来减少栈帧创建的时间。
void QuickSort(int* data, int begin, int end)
{
if (end - begin + 1 < 8)
InsertSort(data + begin, end - begin + 1);
else
{
int keyi = PartSort(data, begin, end);
if (begin < keyi - 1)
QuickSort(data, begin, keyi - 1);
if (keyi + 1 < end)
QuickSort(data, keyi + 1, end);
}
}
优化2:三数取中(有效优化)
每次选的key都默认是数组的第一个数,假设一个极端的情况,当数组是有序且顺序的升序的,并且数组长度较大。这时每次递归调用数组只会调用key右边的数组,每次的数组长度只减少1。前面说数组长度较长,在这样的情况下,有几个数,就会创建几个函数栈帧,而一直开辟栈帧会导致栈溢出。所以针对这种最坏的情况,需要对如何选key进行优化,至少不能选到最小或是最大的那个数。
left为数组第一个数的位置,right为数组最后一个数的位置,选一个mid,为left + right再除以2,将三个数进行比较,得到大小为中数的那个数。选择中数为key,保证了key不是数组中最大或最小的一个数,防止栈溢出的问题
int GetMidIndex(int* data, int left, int right)
{
int mid = (left + right) / 2;
if (data[left] > data[mid])
{
if (data[mid] > data[right])
return mid;
else
{
if (data[left] < data[right])
return left;
else
return right;
}
}
else
{
if (data[left] < data[right])
return left; mid < left < right
else
{
if (data[left] > data[right])
return left;
else
return right;
}
}
}
在单趟排序选key时,将该函数的返回值作为key值,就能避免爆栈的出现
归并排序
思想讲解
将一个数组分成两个数组,假设这两个数组有序,用两个变量begin1和begin2遍历这两个数组,开始时都指向数组第一个数,谁指向的数小,谁就放到tmp(创建的一个临时数组)中,然后指向下一个数,直到一个数组遍历完,将另一个数组的数全接到tmp数组后面,再将tmp拷贝回原数组。
怎么确定由一个数组分成的两个数组是有序的?如果一个数组只有两个数,分成两个数组,分成的数组中只有一个数,那么就能将一个数看成是有序的。
所以归并排序对一个数组不断的进行二分,直到分成的数组只有一个数,再进行排序
红线以上将数组分割成一个数的过程称作归,将一个数不断的合并,直到数组有序的过程叫做并,这就是归并排序的大概思想。
那如何分割数组成了首要的问题。直到了数组的第一个数与最后一个数的下标,比如上面的数组,第一个数下标是0,最后一个数下标是7,将两者相加/2,得到3。
那么可以将0 ~ 3,4 ~ 7看成两个数组。所以将数组第一个数下标begin与最后一个数下标end相加除2,得到mid,将[begin, mid], [mid + 1, end]看成两个数组。begin和mid相等或者mid + 1与end相等,说明这两个数组只有一个数,接下来就不用分割了。因此分割的结束条件是begin > mid, mid + 1 > end。
代码实现
void _MergeSort(int* data, int begin, int end, int* tmp)
{
if (begin >= end)
return;
int mid = (begin + end) / 2;
int begin1 = begin;
int end1 = mid;
int begin2 = mid + 1;
int end2 = end;
int index = begin;
_MergeSort(data, begin1, end1, tmp);
_MergeSort(data, begin2, end2, tmp);
while (begin1 <= end1 && begin2 <= end2)
{
if (data[begin1] < data[begin2])
tmp[index++] = data[begin1++];
else
tmp[index++] = data[begin2++];
}
while (begin1 <= end1)
tmp[index++] = data[begin1++];
while (begin2 <= end2)
tmp[index++] = data[begin2++];
memcpy(data + begin, tmp + begin, sizeof(int) * (end - begin + 1));
}
void MergeSort(int* data, int n)
{
int begin = 0;
int end = n - 1;
int* tmp = (int*)malloc(sizeof(int) * n);
_MergeSort(data, begin, end, tmp);
free(tmp);
}
注意点1
要注意两个点,1.不能在MergeSort函数调用_MergeSort子函数后再将tmp的数据拷贝到原数组中。 比如将_MergeSort子函数将[10,6]归并成[6,10]之后,要将[6,10]正确的顺序拷贝到原数组中,否则在进行[6,10]与[1,7]这两组数的归并时,[6,10]变成了[10,6],[1,7]成了[7,1]和没有排序前一样,这时再进行归并排序得到的数组顺序是错误的。所以在tmp数组中归并得到正确的数时,要将正确的数据拷贝回原数组,不能在最后拷贝。
注意点2
2.拷贝数组时,原数组和目标数组的起始位置要格外注意。
之前写归并出现了错误,在调试时发现我将memcpy写错了 memcpy是将原数组的n个字节数的数据拷贝到目标数组。这样的描述太简单,举上面的例子,数组名tmp作为一个数组的首元素地址,data同样也是,memcpy将你给的原地址tmp处向后的n个字节数的数据依次拷贝到以data为起始地址往后的n个字节数地址处
所以这样写的memcpy每次只拷贝了tmp数组的前几个数到data数组中。而有时归并排序排的数是data数组中间或者最后的数,都拷贝到data的前面,使得函数出错。
非比较排序
计数排序
以上的排序都是比较排序,即通过两个数之间的比大小得到一个有序的数列。但有的排序却不需要通过比较。
计数排序:通过哈希定址法进行排序。计数排序的时间复杂度小,但其实是一组用空间换时间的排序
假设有一个数组data,先遍历一遍data,得到data数组的最大值与最小值,知道了数组的范围再开辟一个count计数数组,将数组全初始化为0。将data数组中的数作为count数组的下标,将该位置的数加1。所以在count数组中的数表示以该下标为值的数在data数组中出现的次数,而数组下标是天然有序的,所以遍历count数组,就能得到data数组排序的结果。
void CountSort(int* data, int n)
{
int max = data[0];
int min = data[0];
for (int i = 0; i < n; i++)
{
if (data[i] > max)
max = data[i];
if (data[i] < min)
min = data[i];
}
int range = max - min + 1;
int* count = (int*)malloc(sizeof(int) * range);
memset(count, 0, sizeof(int) * range);
for (int i = 0; i < n; i++)
{
count[data[i] - min]++;
}
int index = 0;
for (int i = 0; i < range; i++)
{
while (count[i]--)
data[index++] = i + min;
}
}
关于count[data[i] - min]++这行代码:为了减小空间的浪费,在开辟count数组时,不将data数组的最大数作为range开辟;如果数据范围在[5000, 10000]时,不开辟10000个int大小的数组,若开辟10000个int大小的数组将浪费前5000个int的空间。所以采用相对映射的方式,在得到data数组的范围range后,只开辟range个int大小的数组。这种方式使得数据映射时要减去最小值,假设现在有一个数5002,要映射到count数组中,将5002-5000得到2,将count下标为2的位置++,说明2出现了一次,但由于时相对映射,所以在拷贝回原数组data时,i下标要加上减去的min,所以要写data[index++] = i + min,而不是data[index++] = i。
|