排序算法章节在校招方面考察是相对比较频繁的,所以本章中所学习的所有排序算法需要引起很大的重视。需要掌握各种排序算法的时间复杂度,注意事项
思想:对于一个数组而言,假定第一个元素默认是有序的,从第二个位置的元素开始,每次插入之后与前面的数据进行比较,假如排成升序,如果插入的数据比前面的数据小,则需要将前面的数据进行挪动,直到找到前面的数据是小于等于插入的数据,那么再将此插入的数据放到该数据之后。具体代码如下:
// 插入排序
void InsertSort(int* a, int n)
{
assert(a);
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;
}
}
// 循环走到这里是有两种情况的:
// 1.循环条件结束
// 2.break出来的
a[end + 1] = tmp;
}
}
插入排序中需要注意的点:
- 第一个for循环中,第一次循环end是从第一个位置开始的,然后每次比较后面的数和end位置的大小关系,最后一次循环end是在倒数第二个位置上,也就是相当于插入最后一个数据时,前面的数据已经有序,那么此时再比较最后一个数据和前面数据的大小关系即可。
- 在while循环中,主要是找到a[end+1]的合适位置,不符合时就依次挪动数据,符合数据时退出循环,相当于找到了a[end+1]的合适位置。
- 跳出循环有两种情况:
- 循环条件结束(循环条件不满足了),此时end在下标为-1的位置
- break出来的(a[end+1]找到了合适的位置),此时end还没有发生越界
时间复杂度的分析
插入排序中,最坏的情况就是数据刚好是逆序的,此时每次插入数据都需要移动对应的数据,第一个数据默认有序的话,那么插入第二个数据需要移动1次,第三个数据需要移动2次,第四个数据需要移动3次…第n个数据需要移动n-1次,显然,这是一个等差数列的求和公式,因此时间复杂度为O(N^2)。最好的情况就是有序时,其实本质只需要执行外层的for循环,因此只需要执行n-1,因为当数据有序时,相当于每次插入的数据都是在合适的位置,不需要另找位置,也就不需要挪动数据的位置,因此这种情况的时间复杂度为O(N)。
综上,插入排序的时间复杂度为O(N^2)。
思想:冒泡排序本质上是属于一种交换排序,冒泡排序算法中需要使用到交换函数,其实就是一个交换的过程,假如排成升序,每轮冒泡排序就是将数据中的最大值交换到最后的位置,然后不考虑该最大值,剩余数据继续进行冒泡排序。代码实现如下:
// 冒泡排序
void BubbleSort(int* a, int n)
{
assert(a);
for (int j = 0; j < n - 1; j++)
{
for (int i = 0; i < n-j-1; i++)
{
if (a[i] > a[i + 1])
{
Swap(&a[i], &a[i + 1]);
}
}
}
}
分析:
外层循环主要是控制冒泡排序的趟数,10个数其实我们只需要进行9趟就可以了,因为最后一个就默认是最小的,不需要进行冒泡,因此n个数据就是需要进行n趟冒泡排序。内层循环控制的是每一趟冒泡排序控制的数据范围,第一趟冒泡排序控制的数据范围是整个数据,第二趟得将最大值去掉得到剩余的数据,第三趟冒泡排序需要去掉第二趟中的最大值得到第三趟的冒泡排序的数据,依此类推。假如排成升序,每一趟冒泡排序需要做的事情就是将该趟冒泡排序控制的数据的最大值冒泡到数据的结尾,因此最终可以排成升序。
注意事项:
上述的代码其实不是冒泡排序的最优解,因为上述的冒泡排序中,无法判断数据有序的情况,当数据已经有序时,该排序算法仍然会进行无脑的冒泡,因此效率较低,下面将给出优化方案:增加一个标志位,标志上一趟冒泡排序中是否发生交换,如果发生交换,则说明上一趟的数据仍然不是有序的,需要进行冒泡,如果上一趟没有发生交换,则说明数据已经达到有序,此时只需要退出循环即可,不需要再进行冒泡排序。代码实现如下:
// 冒泡排序
void BubbleSort(int* a, int n)
{
assert(a);
for (int j = 0; j < n - 1; j++)
{
int exchange = 0;
for (int i = 0; i < n-j-1; i++)
{
if (a[i] > a[i + 1])
{
// 如果发生交换,修改标志位
exchange = 1;
Swap(&a[i], &a[i + 1]);
}
}
if (exchange == 0)
{
// 说明上一趟冒泡排序中没有发生交换,数据已经达到有序
break;
}
}
}
思想:堆排序本质是在一棵完全二叉树中对数据进行选择排序,该排序中需要使用向上调整算法和向下调整算法。堆排序的步骤:建堆,如果排成升序则建大堆,排成降序,则建小堆,然后每次将第一个位置的元素和最后一个元素的值进行交换,因为堆顶存放的是最值,所以每次交换之后,对剩余的数据进行向下调整,使剩余的数据仍然能够满足堆的性质。
// 向上调整算法
void AdjustUp(int* a, int child)
{
// child表示调整的起始位置
int parent = (child - 1) / 2;
while (child > 0)
{
// 调成大堆
if (a[parent] < a[child])
{
Swap(&a[child], &a[parent]);
child = parent;
parent = (child - 1) / 2;
}
else
{
break;
}
}
}
// 向下调整算法
void AdjustDown(int* a, int n, int root)
{
// root表示从根节点开始进行调整
int parent = root;
int child = parent * 2 + 1;
while (child < n)
{
// 调成大堆
if (child + 1 < n && a[child + 1] > a[child])
{
child++;
}
if (a[child] > a[parent])
{
// 需要进行交换
Swap(&a[child], &a[parent]);
parent = child;
child = parent * 2 + 1;
}
else
{
// 半途不需要进行调整了
break;
}
}
}
void HeapSort(int* a, int n)
{
for (int i = (n - 1 - 1) / 2; i >= 0; i--)
{
// 使用向下调整算法进行建堆:升序建大堆
AdjustDown(a, n, i);
}
int end = n - 1;
while (end > 0)
{
Swap(&a[0], &a[end]);
AdjustDown(a, end, 0);
end--;
}
}
堆排序的本质就是将堆顶数放到最后一个位置,然后再对剩余的数据进行调整,使剩余的数据仍然满足堆的性质。
注意:
- 如果排成升序,则需要建大堆,因为大堆的堆顶存放的是数据的最大值,所以每次将堆顶数据放到数据的最后一个位置,就可以将数据排成升序。
- 如果排成降序,则需要建小堆,因为小堆的堆顶存放的是数据的最小值,所以每次将堆顶数据放到数据的最后一个位置,就可以将数据排成降序。
思想:我们需要两个下标,分别称为左右下标,左下标是作为左区间,右下标作为右区间,每次遍历该区间,选择出区间中的最小值和最大值,分别放到左边和右边,再缩小区间范围,即左下标向前走一步,右下标向后走一步,当左右下标相遇时循环结束。代码实现如下:
// 直接选择排序
void SelectSort(int* a, int n)
{
assert(a);
int left = 0;
int right = n - 1;
while (left < right)
{
int mini = left;
int maxi = left;
for (int i = left; i <= right; i++)
{
if (a[i] < a[mini])
{
mini = i;
}
if (a[i] > a[maxi])
{
maxi = i;
}
}
Swap(&a[mini], &a[left]);
Swap(&a[maxi], &a[right]);
left++;
right--;
}
}
注意:
上面的代码是存在一定的问题的,在一些极端场景中,上面的代码就会出现问题,如果在遍历的时候,最大值的下标和左下标是一样,相当于就是说,交换前,最大值在数据的最左边,那么如果我们将最小值和最左边的数据进行交换的话,显然会将最大值换到了原来最小值的地方,所以此时我们需要更新最大值的下标,也就是交换前最小值的下标。处理后的代码如下:
// 直接选择排序
void SelectSort(int* a, int n)
{
assert(a);
int left = 0;
int right = n - 1;
while (left < right)
{
int mini = left;
int maxi = left;
for (int i = left; i <= right; i++)
{
if (a[i] < a[mini])
{
mini = i;
}
if (a[i] > a[maxi])
{
maxi = i;
}
}
Swap(&a[mini], &a[left]);
if (maxi == left)
{
maxi = mini;
}
Swap(&a[maxi], &a[right]);
left++;
right--;
}
}
思想:希尔排序是在直接插入排序的基础上进行改造的,插入排序中,当数据出现逆序时,时间复杂度为O(N^2),效率比较低,所以希尔排序针对直接插入排序中存在的这样的问题进行优化。在希尔排序中,首先会对数据进行预排序,预排序的结果就是使数据接近有序,而不至于逆序,当数据接近有序时,此时在使用直接插入排序算法对数据进行排序,效率就会比较高。希尔排序中需要确定一个间隔gap,gap越大,表明大的数越快到达后面,小的数越快到达前面,但是数据越不接近有序,当gap越小时,预排序之后数据越接近有序。代码实现如下:
// 希尔排序
void ShellSort(int* a, int n)
{
assert(a);
// 预排序
int gap = n;
while (gap > 1)
{
gap = gap / 3 + 1;
for (int i = 0; i + gap < n; 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一般建议是数据个数的三分之一左右,再加1能够保证最后一次的gap一定是1,表示直接插入排序,注意每次是将
a[end]
和a[end+gap]
的数据进行比较,而不是a[end]
和a[end+1]
的数据进行比较,这就是希尔排序和直接插入排序的区别之一。