目录
常见排序算法实现:
插入排序:直接插入排序,希尔排序
选择排序:选择排序,堆排序
交换排序:冒泡排序,快速排序
归并排序
一、插入排序
直接插入排序
直接插入就是插入一个数据,和之前的数据比较,保证顺序规则不变。我们需要事先存一下新数据,然后让新数据挨个和前面比较。
void InsertSort(int* a, int n)//时间复杂度如果是原本逆序,O(N^2), 如果是升序,O(N)
{
for (int i = 0; i < n - 1; ++i)
{
int end = i;
int tmp = a[end + 1];
while (end >= 0)
{
if (tmp < a[end])
{
a[end + 1] = a[end];
--end;
}
else
{
break;
}
}
a[end + 1] = tmp;
}
}
现在排的是升序,传进来一个数组和数组大小后,从头开始,第一个和第二个数据进行比较,如果第二个小于第一个,第二个就变成第一个的数据,但第二个数据并没有丢,因为事先已经存到了tmp,end继续--,再往前一个找数据比较大小;带入一个例子来看,假设5 3 1,这三个数字比较,当end = 0时,end是5, end + 1是3,tmp是3,小于,那么end + 1就变成了5,--end,end变成-1,不符合条件,这时候下标为end + 1,也就是0的位置换成tmp,也就是3,此时整个序列变成了3 5 1。
当end = 1时,end是5,end + 1就是1,tmp = 1,1比5小,1的位置换成5,这时候是3 5 5,--end,此时end是下标为0的位置,end符合条件,进入循环,tmp是1,end指向3,tmp小于end,那么end + 1变成3,此时就是3 3 5,--end,end为 -1,不符合条件,那么把end + 1,也就是0的位置换成tmp,这时候就是1 3 5了。
这样逐渐就把比tmp大的数据往后挪了一下,直到比首元素小,end再次--小于0后,就退出循环,那么end+1指向首元素,首元素就变成tmp。然后再次开启循环。
如代码里注释所写,最坏的情况就是原始数据是逆序,而我们要排升序。
希尔排序
直接插入排序确实有高效排序的时候,但逆序也确实低效。希尔排序会更好的处理逆序。希尔排序的思路先做一个预排序,再做插入排序。预排序是要排成一个近似成序的数组,对于逆序的数组,9要挪到最后需要好几步,为了减少步数,预排序中把数据分组,设定步长,让9一次就退后步长步,这样就能尽量减少逆序的时间消耗。
假设步长为3,那么第一个数据就后离它3步的数据比较。
9和6比较,再和3比较;8和5比较,再和2比较;7和4比较,再和1比较。可以看出,其实步长多少就可以分为多少组。放到具体下标上,也就是下标+3。所以tmp需要end + gap。以及挪动时也需要挪gap步。
现在看这段代码
void ShellSort(int* a, int n)
{
int gap = 3;
for (int i = 0; i < n - gap; i += gap)
{
int end = i;
int tmp = a[end + gap];
while (end >= 0)
{
if (tmp < a[end])
{
a[end + gap] = a[end];
end -= gap;
}
else
{
break;
}
}
a[end + gap] = tmp;
}
}
其实还是一样,只是可以把之前的代码看做步长为1。加上循环for (int i = 0; i < n - gap; i += gap)
这样就是一个个小组内排序,end = 0。9到6之间进行排序,排完后,i +3,end也 + 3,然后就是6到3的排序。i < n - gap ,这样a[end]就会最多指向4,避免越界,也确定了最后一个排序的开始位置。
但是现在这个代码只是走了一组,所以我们还需要j,走完所有组。
void ShellSort(int* a, int n)
{
int gap = 3;
for (int j = 0; j < gap; j++)
{
for (int i = j; i < n - gap; i += gap)
{
int end = i;
int tmp = a[end + gap];
while (end >= 0)
{
if (tmp < a[end])
{
a[end + gap] = a[end];
end -= gap;
}
else
{
break;
}
}
a[end + gap] = tmp;
}
}
}
i里面的循环里,一组有n / gap个数据,所以挪动1 到 n / gap次,总共有gap组,时间复杂度就O(N ^ 2) , O(N)。
简化一下代码
void ShellSort(int* a, int n)
{
int gap = 3;
for (int i = 0; i < n - gap; ++i)
{
int end = i;
int tmp = a[end + gap];
while (end >= 0)
{
if (tmp < a[end])
{
a[end + gap] = a[end];
end -= gap;
}
else
{
break;
}
}
a[end + gap] = tmp;
}
}
这个是怎么样呢?排完第一组第一个,再排第二组第一个,再排第三组第一个,然后又排第一组。
现在把gap定型
void ShellSort(int* a, int n)
{
// gap > 1 预排序
// gap == 1 直接插入排序
int gap = n;
while (gap > 1)
{
// gap = gap / 2;
// gap = gap / 3; // 9 8 不能保证最后一次一定是1
gap = gap / 3 + 1; // 9 8
for (int i = 0; i < n - gap; ++i)
{
int end = i;
int tmp = a[end + gap];
while (end >= 0)
{
if (tmp < a[end])
{
a[end + gap] = a[end];
end -= gap;
}
else
{
break;
}
}
a[end + gap] = tmp;
}
}
}
gap / 3也可。不过gap / 2可以保证gap最后一定为1,为1的时候就是直接插入排序。gap / 3 + 1就可。
对于预排序而言
gap越大,大的数越快排到后面,小的数越快排到前面,但越不接近有序。gap值越大,那么n-gap也就越小,我们能调的数据下标也越小了,自然也就越不接近有序。
gap越小,数据跳动越慢,越接近有序。
测试一下
void PrintArray(int* a, int n)
{
for (int i = 0; i < n; ++i)
{
printf("%d ", a[i]);
}
printf("\n");
}
void ShellSort(int* a, int n)
{
//gap > 1 预排序
//gap == 1 直接插入排序
int gap = n;
while (gap > 1)
{
gap = gap / 2;
//gap = gap / 3; // 9 8 不能保证最后一次一定是1
//gap = gap / 3 + 1; // 9 8
for (int i = 0; i < n - gap; ++i)
{
int end = i;
int tmp = a[end + gap];
while (end >= 0)
{
if (tmp < a[end])
{
a[end + gap] = a[end];
end -= gap;
}
else
{
break;
}
}
a[end + gap] = tmp;
}
}
}
void TestShellSort()
{
//int a[] = { 9, 1, 2, 5, 7, 4, 8, 6, 3, 5 };
int a[] = { 9,8,7,6,5,4,3,2,1,0,5,4,2,3,6,2,0,2,1,-1,-2,-1,-3 };
PrintArray(a, sizeof(a) / sizeof(int));
ShellSort(a, sizeof(a) / sizeof(int));
PrintArray(a, sizeof(a) / sizeof(int));
}
int main()
{
//TestInsertSort();
TestShellSort();
return 0;
}
每换完一遍打印一下。看结果可以发现,第一次换,几个比较大的数就已经来到后面了,然后形成一个前小后大的序,再直接插入排序,就排好了。
测试性能(加上堆排序)
测试性能的时候用Release模式。
void Test()
{
srand(time(0));
const int N = 1000;
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 begin1 = clock();
InsertSort(a1, N);
int end1 = clock();
int begin2 = clock();
ShellSort(a2, N);
int end2 = clock();
printf("InsertSort:%d\n", end1 - begin1);
printf("ShellSort:%d\n", end2 - begin2);
free(a1);
free(a2);
}
后面再写几个排序算法后,就加上a3,a4等。
1000个数据时,这两个没有多大差距,给上10万个。
816毫秒,7毫秒。
再把堆排序弄过来算一算。
void Test()
{
srand(time(0));
const int N = 1000;
int* a1 = (int*)malloc(sizeof(int) * N);
int* a2 = (int*)malloc(sizeof(int) * N);
int* a3 = (int*)malloc(sizeof(int) * N);
for (int i = 0; i < N; ++i)
{
a1[i] = rand();
a2[i] = a1[i];
a3[i] = a1[i];
}
int begin1 = clock();
InsertSort(a1, N);
int end1 = clock();
int begin2 = clock();
ShellSort(a2, N);
int end2 = clock();
int begin3 = clock();
HeapSort(a3, N);
int end3 = clock();
printf("InsertSort:%d\n", end1 - begin1);
printf("ShellSort:%d\n", end2 - begin2);
printf("HeapSort:%d\n", end3 - begin3);
free(a1);
free(a2);
free(a3);
}
其实来讲希尔排序和堆排序速度都很快,差别不多,到千万以上的数据时,两个排序都需要几百毫秒。有时候希尔排序会更快。当百万数据的时候再带上直接插入排序就会慢很多,Release模式也需要几十秒。所以后面只用十万个随机数。
希尔排序算法的时间复杂度是一个模糊的数据。真正求希尔排序的时间复杂度需要浑厚的数学功底,也不一定算得出来,希尔排序的时间复杂度仍然是一个难题。我们可以求一些最坏最好这样的情况,不过这里就不作分析了。现在普遍认可的数据是O(N^1.3)。
二、选择排序
选择排序
这个排序的思想很简单,从第一个开始遍历,遍历完所有的后,把最小的数选出来,放在前面。一次次遍历就结束了。
不过这个效率不高,我们不如在一次遍历中选出最大和最小的,放在右边和左边,然后再次遍历剩下的。
// O(N^2)
// 跟直接插入排序比较,谁更好 -- 插入
// 插入适应性很强,对于有序,局部有序,都能效率提升
// 任何情况都是O(N^2) 包括有序或接近有序
void SelectSort(int* a, int n)
{
int begin = 0, end = n - 1;
while (begin < end)
{
int mini = begin, maxi = begin;
for (int i = begin + 1; i <= end; ++i)
{
if (a[i] < a[mini])
{
mini = i;
}
if (a[i] > a[maxi])
{
maxi = i;
}
}
Swap(&a[begin], &a[mini]);
if (maxi == begin)
maxi = mini;
Swap(&a[end], &a[maxi]);
++begin;
--end;
}
}
选择排序需要注意的就是max和min的值变化,所以在交换时有做判断。
看一下十万个数的性能
因为选择排序,10万个数程序的运行就需要几秒时间。
而堆排序比它好的多的原因就是因为堆排序不需要每个都要比,堆排序借助二叉树结构,和堆顶比较即可,然后进行向下调整算法,logN的结果也不大。
三、交换排序
冒泡排序
冒泡排序是个耳熟能详的排序了。每一次把大的数据往后放。
做测试
还是10万个数据。再做1万个数据
百万数据就慢了,计算机需要一段时间才行。不过有时候会发现冒泡比选择还要慢,现在对冒泡做一下优化
//冒泡排序
void BubbleSort(int* a, int n)// O(N^2)
{
for (int j = 0; j < n; ++j)
{
int exchange = 0;
for (int i = 1; i < n - j; ++i)
{
if (a[i - 1] > a[i])
{
Swap(&a[i - 1], &a[i]);
exchange = 1;
}
}
if (exchange == 0)
break;
}
}
再走10万个数据
没有什么区别呀,这做不做优化,冒泡也还是要1000多毫秒啊。其实来讲是因为,冒泡排序并不能适应乱序。冒泡还是需要一个个比较,所以乱序情况下选择和冒泡并没有什么区别,只有在有序的情况下冒泡才会有优势。
到现在为止,会发现堆排和希尔都很快,插入排序有一定的价值,冒泡和选择都不算太好。
也可以对随机数做些更改,让其更随机。
接下来的博客看一个顾名思义的排序以及一样厉害的归并排序。
四、快速排序
既然叫快排,它既然是很快的。但是快排内容很多,需要慢慢理解。
举一个例子
数组 6 1 2 7 9 3 4 5 10 8。
设定key为6,然后一左L一右R走,右边先走,找到比key小的,找到后停下来,左边再走找到比key大的,找到后停下来,两者互换,小的就被调到左边去了,小的数就来到了L的位置,在它和key之间的数也一定小于key,然后R和L再重复之前操作,R到4这里停下,L到9这里停下,然后互换,最后LR都来到3,3和6互换。这样换完后还没结束,因为这时候以最后LR位置为轴,左右区间还不是有序,那么这时候左右区间就是一个子问题了,在两个区间里继续做上述操作,到实际代码上也就是递归做法。
具体地可以多画图,这里就不展示了。
规律:如果左边做key,右边就先走,能够保证相遇位置比key小;右边做key,左边就先走,能够保证相遇位置比key大。
实际上,相遇的情况有这两种,R停下,L遇到了R,相遇位置就是R停住的位置;或者L停下,R遇到了L,相遇位置就是L停住的位置。
key为6,第一遍走完后,L在7, R在5,交换,L位置是5, R位置是7,L位置小于key,假如9 3 4改为9 13 14 ,那么R就会一直往左走,直到遇见L,这也就是第二种情况,L停住的位置原本数据就是大于6的。
第一种情况就是按照这个数据 6 1 2 7 9 3 4 5 10 8。走,R会在3处停下,而L就会遇到R,而R停住的位置是一定小于key的。
当然也有极端情况,比如都比6大,R就一往无前地找到L,即使这样,相遇的位置是6,也<=key,所以第二种情况就是相遇位置 >= key,第一种情况则是 <= key。
代码实现
先写全部区间的遍历,这里要考虑到可能某个变量,另一个变量走全部。
以及还有一个情况,两边区间都有和key一样的值,这样即使停下来交换了,那么左右两边仍然动不了,所以要写>= 和 <=。
当停下后,交换key和停下位置的值,这时候分成两个区间,把停下位置的值给到key,然后分成[begin, key - 1] 和[key + 1, end]两个区间继续递归。
void QuickSort(int* a, int begin, int end)//用begin和end来表示区间
{
if (begin >= end)
{
return;
}
int left = begin, right = end;
int key = left;//最终要换的时候跟最左边的换,所以这里=left
while (left < right)
{
//右边先走,找小
while (left < right && a[right] >= a[key])
{
right--;
}
//左边再走,找大
while (left < right && a[left] <= a[key])
{
left++;
}
Swap(&a[left], &a[right]);
}
Swap(&a[left], &a[key]);
key = left;
//[begin, key - 1] [key + 1, end]
QuickSort(a, begin, key - 1);
QuickSort(a, key + 1, end);
}
再做性能测试。
这里就不弄选择和冒泡排序了。
百万数据
所以其实堆排和快排和希尔排都是差不多等级的。
下一篇继续优化快排以及写归并排序。
结束。