目录
这篇文章作者统一按照升序的排序方法来讲
直接插入排序
插入排序思想:直接插入排序是一种简单直观的排序算法,其基本思想是将一个待排序的序列分为已排序和未排序两部分,每次从未排序部分中取出一个元素,将其插入到已排序部分的适当位置,直到所有元素都被插入到已排序部分为止。
具体步骤如下:
- 将序列的第一个元素看作是已排序部分,其余元素看作是未排序部分。
- 从未排序部分中取出一个元素,将其与已排序部分从右往左比较,找到合适的位置插入。
- 将已排序部分中大于待插入元素的元素依次后移一位,为待插入元素腾出位置。
- 将待插入元素插入到合适的位置。
- 重复步骤2~4,直到未排序部分为空。
先看看单趟排序是怎么样的
//先把下一个要排序的数先保存起来
//因为如果这个数比上一个数小(大),就会因为挪动数据而被覆盖
int end = i
int tmp = a[end + 1];
//最坏的情况下end为-1,所以当end=0的情况可能还会要挪动数据
while(end >= 0)
{
if(tmp < a[end])
{
a[end+1] = a[end];
}
else
{
break;
}
end--;
}
a[end + 1] = tmp;
注意:上面代码中的else语句块里,不能直接插入tmp值,而是要跳出循环后才能插入tmp值。因为我们要想到最坏的情况,如果tmp值比排好序的最小的值还要小(可以联想上面的画图过程),那么挪动数据的时候end值就会一直减减到-1。如果此时我们在end位置插入tmp值,那么就会造成数组的越界了
直接插入排序完整代码
void InsertSort(int* a, int n)
{
//i<n-1 是因为要把最后一个未排序的数排序
//如果i<n 取tmp值的时候就会造成数组越界
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];
}
else
{
break;
}
end--;
}
a[end + 1] = tmp;
}
}
复杂度
时间复杂度:O(N^2)
空间复杂度:O(1)
稳定性:稳定
希尔排序
希尔排序思想:希尔排序可以说是一种改进的插入排序算法,也就是在直接插入排序的基础上多了增量(gap)这么一个概念,其基本思想是将待排序的序列按照一定的间隔分成若干个子序列,对每个子序列进行插入排序(让这组数据大的数更接近后面,让小的数更接近前面),然后缩小间隔,再次对子序列进行插入排序,直到间隔为1时完成最后一次插入排序(简单来说就是,用插入排序按照增量(gap)进行预排序让这组数据变得更接近有序后,在用插入排序进行整体排序,变成有序)。
具体步骤如下:
- 选择一个间隔序列,通常为希尔增量(gap值)序列,例如:n/2、n/4、n/8...直到间隔为1。
- 根据选择的间隔序列,将待排序序列分为若干个子序列。
- 对每个子序列进行插入排序,即将子序列中的元素按照间隔进行插入排序。
- 缩小间隔,继续对子序列进行插入排序,直到间隔为1。
- 最后一次插入排序完成后,整个序列就变成了有序序列。
总的来说,希尔排序就是预排序后,在直接插入排序
我们有这么一组数据 9 1 2 5 7 4 8 6 3 5 ,这里我们假设gap(增量)为3,按照gap=3,用插入排序进行预排序
void ShellSort(int* a, int n)
{
for(int i=0; i< n - gap; i += gap)
{
//对i< n - gap不懂,可以看一下上面的第一个图后面三个数字的下标
int end = i;
int tmp = a[end + gap];
while(end >= 0)
{
if(tmp <a[end]
{
a[end + gap] = a[end];
}
else
{
break;
}
end -= gap;
}
a[end + gap] = tmp;
//完成了预排序后
//调用插入排序函数
InsertSort();
}
}
预排序好了后,此时这组数据变成了5 1 2 5 6 3 8 7 4 9,相比于未排序之前 9 1 2 5 7 4 8 6 3 5,可以发现,预排序后,变得更接近有序了,尤其是把大的数放到了更后面,把小的数放到了更前面。最后我们在对这一组数据进行插入排序,就叫希尔排序
预排序的意义:大的数更快的到后面去,小的数更快的到前面去,gap越大跳得越快,越不接近有序,gap越小,跳的越慢,越接近有序,当gap == 1时,直接就是有序
完整代码
也可以同时三组进行预排序
void ShellSort(int* a, int n)
{
int gap = n;
while (gap > 1)
{
//gap > 1 时预排序,当gap == 1 时,就是有序了
gap = gap / 3 + 1; // +1是保持gap不为0
//也可以gap = gap / 2
//三组同时预排序
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];
}
else
{
break;
}
end -= gap;
}
a[end + gap] = tmp;
}
}
}
复杂度
希尔排序是对直接插入排序的优化
时间复杂度:O(N^1.3)
空间复杂度:O(1)
稳定性:不稳定 ps:相同的数据可能在分在不同的组
冒泡排序
冒泡排序是一种简单直观的排序算法,其核心思想是通过相邻元素之间的比较和交换来将最大(或最小)的元素逐步“冒泡”到数组的末尾。
具体步骤如下:
- 从数组的第一个元素开始,比较相邻的两个元素。
- 如果前一个元素大于后一个元素,则交换它们的位置,使较大的元素“冒泡”到后面。
- 继续比较下一个相邻的元素,重复步骤2,直到数组的末尾。
- 重复步骤1~3,每次都将最大的元素“冒泡”到数组的末尾。
- 重复上述步骤,直到所有元素都排好序。
比如我们有4个数组4 3 2 1,第一趟比较:3 2 1 4,第二趟比较:2 1 3 4,第三天比较:1 2 3 4
由此我们可以知道,有N个数据,那么就要进行N-1躺排序,第一躺排序都要进行N-1次两两比较,第二趟排序需要进行N-1-1次,也就是说我们在第几躺排序的时候就会少多少次两两比较,所以在第N躺排序需要N-1- i次两两比较
void BubbleSort(int* a, int n)
{
for (int j = 0; j < n - 1; j++)
{
int flag = 0;//用于标记是否有序
for (int i = 0; i < n-1-j; i++)
{
if (a[i - 1] > a[i])
{
int tmp = a[i];
a[i] = a[i - 1];
a[i - 1] = tmp;
flag = 1;
}
}
if (flag == 0)
break;
}
}
复杂度
时间复杂度:O(N^2)
空间复杂度:O(1)
稳定性:稳定
快速排序
快速排序思想:快速排序是一种分治的排序算法。它的基本思想是通过一趟排序将待排序的数据分割成独立的两部分,其中一部分的所有数据都比另一部分的所有数据小,然后再按照此方法对这两部分数据分别进行快速排序,整个过程递归进行,最终得到有序的结果。
具体的步骤如下:
- 选择一个基准元素(key),可以取数组的最左边的一个数作为key,也可以取数组最右边的数作为key.。将待排序序列分成两部分,左边的部分都比基准元素小,右边的部分都比基准元素大。
- 我们可以定义两个下标,left从左边走选取大于key的数,right从右边走选取小于Key的数,两边都找到了,那就进行交换,如果相遇了就把当前的最左边的key(最右边的key)与相遇点调换值,此时的相遇点作为新的key。需要注意的是:如果是选取最左边的数作为key,那么就让右边的right先走,如果选取最右边的数作为Key,那么就让最左边的left先走
- 对左右两部分分别进行快速排序,递归地重复步骤1,2。
- 当左右两部分都排序完成后,整个序列就是有序的了。
单趟排序Hoare左右指针法动态图
void QuickSort(int* a, int left, int right)
{
//区间不存在或者只有一个数
if (left >= right)
return;
int begin = left;
int end = right;
int keyi = left;
while (left < right)
{
//找小
while (left < right && a[right] >= a[keyi])
{
right--;
}
//找大
while (left < right && a[left] <= a[keyi])
{
left++;
}
//交换
Swap(&a[right], &a[left]);
}
Swap(&a[keyi], &a[left]);
keyi = left;
//有效区间[begin,keyi - 1] keyi [keyi + 1, end]
QuickSort(a, begin, keyi - 1);
QuickSort(a, keyi + 1, end);
}
现在我们思考一个问题:如果这个数组本来就是有序的,那这个快速排序的时间复杂度是多少?先看一下递归展开图
答案是O(N^2),因为如果这个数组本来就是有序的,那么key的值无论是在最左边还是在最右边每次的相遇点都只会在最左边或者最右边,而且在这种情况下,每次划分都将序列分成两部分,其中一部分为空,另一部分只比原序列少一个元素。就形成了一个等差数列,所以时间复杂度就是O(N^2)
那么怎么样才能避免这种情况发生呢?
Hoare不但发明了快速排序,他还优化了快速排序,他用三数取中这个技巧来避免这种情况发生,可以减少最坏情况的出现概率,提高快速排序的效率。
//三数取中优化,防止最坏情况
int GetMidi(int* a, int left, int right)
{
int mid = (left + right) / 2;
if (a[left] < a[mid])
{
if (a[mid] < a[right])
return mid;
else if (a[left] > a[right])
return left;
else
return right;
}
else //a[mid] > a[left]
{
if (a[mid] < a[right])
return mid;
else if (a[left] > a[right]) //mid最大
return left;
else
return right;
}
}
void QuickSort(int* a, int left, int right)
{
//区间不存在,或者只有一个数的时候,不用继续分割了
if (left >= right)
return;
//保留下标,因为一趟排序的适合下标会变化
int begin = left;
int end = right;
int keyi = GetMidi(a, 0, right);
while (left < right)
{
//找小
while (left < right && a[right] >= a[keyi])
{
right--;
}
//找大
while (left < right && a[left] <= a[keyi])
{
left++;
}
Swap(&a[right], &a[left]);
}
Swap(&a[keyi], &a[left]);
keyi = left;
QuickSort(a, begin, keyi - 1);
QuickSort(a, keyi + 1, end);
}
挖坑法
void QuickSort(int* a, int left, int right)
{
//区间不存在,或者只有一个数的时候就不用再分割了
if (left >= right)
return;
//保留下标,因为一趟排序用原有的下标遍历,会变化
int begin = left;
int end = right;
int mid = GetMidi(a, 0, right);
Swap(&a[left], &a[mid]);
//保存第一个坑的值
int key = a[left];
int hole = left;
while (left < right)
{
//找小
while (left < right && a[right] >= key)
{
right--;
}
a[hole] = a[right];
hole = right;
//找大
while (left < right && a[left] <= key)
{
left++;
}
a[hole] = a[left];
hole = left;
}
//把key的值放到最后相遇的坑位
a[hole] = key;
int keyi = hole;
QuickSort(a, begin, keyi - 1);
QuickSort(a, keyi + 1, end);
}
前后指针法
void QuickSort(int* a, int left, int right)
{
//区间不存在,或者只有一个数的时候不用再分割了
if (left >= right)
return;
//保留一份下标,因为在一趟排序的时候原来的下标会变化
int begin = left;
int end = right;
int mid = GetMidi(a, left, right);
Swap(&a[left], &a[mid]);
//保留key的下标
int key = left;
int prev = left;
//cur再prev的下一个
int cur = prev + 1;
while (cur <= right)
{
/*if (a[cur] < a[key])
{
Swap(&a[++prev], &a[cur]);
}*/
//重叠的不交唤
if (a[cur] < a[key] && ++prev != cur)
{
Swap(&a[cur], &a[prev]);
}
cur++;
}
//prev为新的key
Swap(&a[prev], &a[key]);
int keyi = prev;
QuickSort(a, begin, keyi - 1);
QuickSort(a, keyi + 1, end);
}
复杂度
快速排序整体的综合性能和使用场景都是比较好的,所以才敢叫快速排序
时间复杂度:O(N*Logn) ps:最坏情况下O(N^2)
空间复杂度:O(Logn)
稳定性:不稳定 ps当数组的数字是一样的时候
选择排序
选择排序的思想:每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的 数据元素排完 。
void SelectSort(int* a, int n)
{
/*for (int i = 0; i < n - 1; i++)
{
int tmp = 0;
//每一躺两两比较都把上一躺少一次两两比较
for (int j = i + 1; j < n; j++)
{
if (a[i] > a[j])
{
int tmp = a[i];
a[i] = a[j];
a[j] = tmp;
}
}
}*/
//优化
int begin = 0; int end = n - 1;
while (begin < end)
{
//找出次大的和次小的
int mini = begin;
int maxj = begin;
for (int i = begin + 1; i <= end; i++)
{
if (a[i] > a[maxj])
{
maxj = i;
}
if (a[i] < a[mini])
{
mini = i;
}
}
Swap(&a[begin], &a[mini]);
//9,1,2,5,7,4,8,6,3,5
//修正maxj的坐标
if (maxj == begin)
{
maxj = mini;
}
Swap(&a[end], &a[maxj]);
begin++;
end--;
}
}
复杂度
时间复杂度:O(N^2)
空间复杂度:O(1)
稳定性:不稳定 ps:3 3 1 4 5
堆排序
堆排序的思想:堆总是一颗完全二叉树结构。堆排序的基本思想是将待排序序列构造成一个小堆(大堆),此时,整个序列的最小值(最大值)就是堆顶的根节点。将其与末尾元素进行交换,此时末尾就为最大值。然后将剩余n-1个元素重新构造成一个堆,这样会得到n个元素的次大值,并与n-1位置的元素再进行交换,此时n-1位置就为次大值。如此反复执行,便能得到一个有序序列了。
注意:升序建大堆,降序建小堆
void Adjustdown(int* a, int n, int parent)
{
int child = parent * 2 + 1;
while (child < n)
{
//左孩子和右孩子比较
//因为堆是完全二叉树,不一定有右孩子,注意越界
if (child + 1 < n && a[child] > a[child + 1])
{
child++;
}
else
{
if (a[child] < a[parent])
{
Swap(&a[child], &a[parent]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
}
void HeapSort(int* a, int n)
{
//向下调整 建立堆
//O(N)
for (int i = (n - 1 - 1) / 2; i >= 0; i--)
{
Adjustdown(a, n, i);
}
//
int end = n - 1;
while (end)
{
//每一次都把最值放到最后,剩下n-1个数待排序
Swap(&a[0], &a[end]);
Adjustdown(a, end, 0);
end--;
}
}
复杂度
堆排序使用堆来选数,效率就高了很多。
时间复杂度:O(N*LogN)
空间复杂度:O(1)
稳定性:不稳定 ps:9 8 9 6 2 建大堆升序
归并排序
归并排序是一种分治算法,其核心思想是将一个大问题分解为小问题,逐步解决小问题并将结果合并起来,最终得到整个问题的解。
具体步骤如下:
- 将待排序的数组分成两个子数组,分别对两个子数组进行递归调用归并排序,直到子数组的长度为1或0,即已经有序。
- 将两个有序的子数组合并成一个有序的数组。合并的过程是通过比较两个子数组的元素,将较小的元素放入新的数组中,并将该元素所在的子数组的指针向后移动
void _MerGeSort(int* a, int* tmp, int left, int right)
{
if (left >= right)
{
return;
}
//分割
int mid = (left + right) / 2;
_MerGeSort(a, tmp, left, mid);
_MerGeSort(a, tmp, mid + 1, right);
//归并
int begin1 = left, end1 = mid;
int begin2 = mid + 1, end2 = right;
int tmpi = left;
//只要有一个数组为空那就停止
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] < a[begin2])
{
tmp[tmpi++] = a[begin1++];
}
else
{
tmp[tmpi++] = a[begin2++];
}
}
//此时不知道哪个数组为空,就两个挨着试一下
while (begin1 <= end1)
{
tmp[tmpi++] = a[begin1++];
}
while (begin2 <= end2)
{
tmp[tmpi++] = a[begin2++];
}
memcpy(a + left, tmp + left, (right - left + 1) * sizeof(int));
}
void MerGeSort(int* a, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("tmp false");
return;
}
_MerGeSort(a, tmp, 0, n - 1);
free(tmp);
}
复杂度
归并的缺点在于需要O(N)的空间复杂度,归并排序的思考更多的是解决在磁盘中的外排序问题。
时间复杂度:O(N*LogN)
空间复杂度:O(N)
稳定性:稳定
计数排序
计数排序是一种非比较排序算法,它的思想是通过统计每个元素出现的次数,然后根据元素的大小顺序将它们放到正确的位置上。
具体步骤如下:
- 找出待排序数组中的最大值max和最小值min。
- 创建一个长度为max-min+1的辅助数组count,并将每个元素初始化为0。
- 遍历待排序数组,统计每个元素出现的次数,即将元素值作为count数组的下标,出现的次数作为count数组的值。
- 根据count数组的值,重新排列待排序数组。具体做法是从count数组的第一个元素开始,将非零元素依次放入待排序数组中,并根据count数组的值进行重复放入。
void CountSort(int* a, int n)
{
//先找出最大值和最小值
int min = a[0];
int 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);
//printf("%d\n", range);
int* count = (int*)malloc(sizeof(int) * range);
if (count == NULL)
{
perror("false malloc");
return;
}
//把count数组的元素初始化为0
memset(count, 0, sizeof(int) * range);
//计数数字出现的次数
for (int i = 0; i < n; i++)
{
count[a[i] - min]++;
}
//排序
int j = 0;
for (int i = 0; i < range; i++)
{
while (count[i]--)
{
a[j++] = i + min;
}
}
free(count);
}
复杂度
计数排序在数据范围集中时,效率很高,但是适用范围及场景有限。
时间复杂度:O(N + rang)
空间复杂度:O(范围) rang