以下正文部分摘录于《算法之美》
按排序的策略不同将内部排序方法进行分类,大致分为五类:
1、插入排序法
基本思想:每次将一个待排序数据对象按关键码大小插入一个有序的数据序列中,得到一个新的容量加1的数据序列,如此往复直到全部对象插入完毕为止。
(1)直接插入排序
基本思想:数据对象在顺序表中存储,当插入第 i 个对象时,前面的 i-1 个元素已经排好序列,用第 i 个对象的关键码同已经存在的 i-1 个对象的关键码从后往前进行比较,找到合适的位置以后就将V[i]插入,而插入点以后的元素都随之向后移动一位。
直接插入是由两层嵌套循环组成的。外层循环标识并决定待比较的数值。内层循环为待比较数值确定最终位置。直接插入排序是将待比较值与它的前一个数值进行比较,所以外层循环是从第2个数值开始的。当前一数值比待比较数值大的情况下继续循环比较,直到找到比待比较数值小的并将待比较数值置入其后一位置,结束该次循环。
void arr_Insert(int* arr)
{
int i, j, insertval;
for (i = 1; i < N; i++)
{
insertval = arr[i];
for (j = i - 1; j >= 0; j--)
{
if (arr[j] > insertval)
{
arr[j + 1] = arr[j];
}
else {
break;
}
}
arr[j + 1] = insertval;
}
}
对于i的取值从第二个元素开始,即最外层循环i的取值为(1-N-1),然后采用变量insertval来记录每次准备插入的 i 的数值。内层循环的作用是为准备插入的元素确定位置,所以对于内层循环的 j 的取值从 i-1 开始,从后往前进行比较,因为需要考虑插入的元素可能是第一个元素,即数组下标为0,所以 j 的取值范围为(i-1-0)。第二层循环内部的逻辑为,当前一数值比所需插入的数值大的情况下继续循环比较,并将当前数值向后赋值移动一个位置。循环直到找到比所需插入数值小的位置,然后break,将所需插入数值置入其后一位置,结束该次循环。
(2)二分插入排序
二分插入排序同直接插入排序一样,是每次将一个数据对象插入已经排序好序的顺序表中。不同点在于,二分插入排序是用折半查找法寻找所需要插入数据V[i]的位置,然后再插入这个对象。最外层循环同样需要 n-1 的比较。折半查找法的使用是二分插入排序与直接插入排序的区别。
void Binary_Insert_sort(int* arr)
{
int middle;
for (int i = 1; i < N; i++)
{
int insertval = arr[i];
int left = 0;
int right = i - 1;
while (left <= right)
{
middle = (left + right) / 2;
if (insertval > arr[middle])
{
left = middle + 1;
}
else {
right = middle - 1;
}
}
for (int j = i; j > left; j--)
{
arr[j] = arr[j - 1];
}
arr[left] = insertval;
}
}
二分查找比顺序查找快,所以二分插入排序就平均性能来说比直接插入排序要快。它的算法复杂度为nlogn。可见当n较大时,二分查找算法的关键码比较次数比直接插入排序在完全逆序下的情况要好,比在完全有序的情况要差。所以,应该根据不同的情况选用这两种方法。
(3)希尔排序
希尔排序又称为缩小增量排序,该算法先取一个小于数据表中元素个数n的整数gap,并以此作为第一个间隔,将数据表分为gap个子序列,所有距离为gap的对象放在同一个子序列中。也就是把数据表中的全部元素分成了gap个组。而所有距离为gap的倍数的记录会被放在同一个组中。分组确定后,就在每一个小组中分别进行直接插入排序。局部完成后就缩小间隔gap,并重复上述步骤,直至取到gap = 1时,完成最后一次直接插入排序。
void shell_sort(int* arr)
{
int gap, i, j;
int insertval;
for (gap = N >> 1; gap > 0; gap >>= 1)
{
for (i = gap; i < N; i++)
{
insertval = arr[i];
for (j = i - gap; j >= 0; j -= gap)
{
if (arr[j] > insertval)
{
arr[j + gap] = arr[j];
}
else {
break;
}
}
arr[j + gap] = insertval;
}
}
}
希尔排序(经验推算的时间复杂度为O(n^1.3))比插入排序要快,甚至在小数组中比快速
排序和堆排序还快,但是在涉及大量数据时希尔排序还是比快速排序慢,并且希尔排序是一个不稳定的排序方法。
2、交换排序法
交换排序的基本思想是两两比较排序对象的关键码。如果发生逆序(即排序顺序与期望的相反)就交换。直到所有对象都排序完毕。
(1)冒泡排序
冒泡排序的外层循环用来控制无序数的数目,因为冒泡排序是不断比较相邻元素的大小,即V[j]和V[j+1]的大小,所以 j 的最大取值为N-2,i 的最大取值为N-1。若将一组数据从小到大进行排序,则外层循环N-1次,每次循环的结果是将下标为 i 的位置放置该组排序数据中的最大值。
void arr_bubble(int* arr)
{
int i, j;
for (i = N - 1; i > 0; i--)
{
for (j = 0; j < i; j++)
{
if (arr[j] > arr[j + 1])
{
SWAP(arr[j], arr[j + 1]);
}
}
}
}
(2)快速排序
快速排序也称为分区排序,该算法采用了一种分治的策略,其基本思想是取待排序对象序列中的某个对象为基准(比如第一个对象)。按照该关键码的大小,将整个对象序列划分为左右两个子序列:左侧子序列中所有对象的关键码都小于或等于基准对象的关键码,右侧子序列中所有对象的关键码都大于基准对象的关键码,基准对象排在这两个子序列中间,然后分别对这两个子序列重复实施上述方法,直到排序完成为止。
数组分区、递归求解是快速排序的核心思想,数组的两个分区具有下面的属性,S1分区的所有项都小于基准项p,而S2分区的所有项都大于等于p。这个属性说明,即选定基准元素后,虽然从位置first到middle-1的元素的相对位置可能会变化,但变化不会超过middleElement。同样,选择基准元素后,虽然从位置middle+1到last的元素的相对位置可能变化,但依然在middle+1到last的范围内。由于在最终的有序数组中,基准元素的位置保持不变,因此也可以将它作为基准项。
int partition(int* arr, int left, int right)
{
int i;
int k = left; //k和i初始都指向数组最左端
for (i = left; i < right; i++) //使用数组最右边的值作为分割点
{
if (arr[i] < arr[right]) //当数组下标i的值小于分割点的值时
{
SWAP(arr[k], arr[i]); //交换k和i位置的值,并递增K的值
k++;
} //k和i之间存在的数组数值都是大于分割点的值(包括K)
}
SWAP(arr[right], arr[k]); //循环结束后,下标k之前的数组数值都小于分割点的值
return k; //下标k以及之后的数组数值都大于分割点的值,swap后返回k
}
void arr_quick(int* arr, int left, int right)
{
int pivot; //基准值
if (left < right)
{
pivot = partition(arr, left, right); //该函数处理数组并返回基准值
arr_quick(arr, left, pivot - 1);
arr_quick(arr, pivot + 1, right);
}
}
快速排序算法的平均排序时间是O(nlogn),最理想情况下快速排序所需的存储开销为O(logn)。
3、选择排序法
选择排序的基本思想是:在排序时每次选择最小项或最大项,将其放入适当位置。
(1)直接选择排序
它的基本步骤是(以升序为例):首先在一组数据对象对象V[i]……V[N-1]中选择具有最小关键码的对象。若它不是对象序列V[i]……V[N-1]中的第一个对象,则将它与这组对象中的第一个对象对调,然后在这组对象中除去具有最小关键码的对象。在剩下的V[i+1]……V[N-1]中重复执行上述步骤,直到剩余对象只有一个为止。在这个过程中,具有相同关键码的对象可能会颠倒次序,因此直接选择排序法是一种不稳定的排序方法。
void arr_select(int* arr)
{
int i, j, min_pos;
for (i = 0; i < N - 1; i++)
{
min_pos = i;
for (j = i + 1; j < N; j++)
{
if (arr[min_pos] > arr[j])
{
min_pos = j;
}
}
SWAP(arr[i], arr[min_pos]);
}
}
(2)堆排序
如果将堆结构中的数据看作是一颗完全二叉树的各个节点,那么堆的性质上就是满足如下性质的完全二叉树:树中任意非叶子节点的关键字均不大于或不小于其左右孩子节点的关键字。利用堆的有序性及其运算,可以容易的实现选择排序的方法称为堆排序。
假设欲对含有n个对象的序列v[0]、v[1]、v[n-1]进行堆排序,算法主要分为两个步骤:一是根据初始输入数据利用堆的调整算法形成初始堆,二是通过一系列的对象交换和重新调整对堆进行排序。具体的过程是在首次获得最大堆之后,将关键码最大的元素v[0] (即堆顶)和无序区的最后一个元素v[n-1]进行交换。由此得到新的无序区v[0]、v[1]、v[n-2]和有序区v[n-1],且满足v[0……n-2]中元素的关键码不大于v[n-1]的关键码。由于交换后新的根v[0]可能违反堆的性质,因此再次利用堆的调整算法对无序区v[0]、v[1]、v[n-2]进行调整。调整完成后将关键码最大的元素v[0]和无序区的最后一个元素v[n-2]交换,并再次利用堆的调整算法对无序区进行调整,这个过程直到无序区中仅剩一个元素为止。
/*堆排是从底向上的调整方式
最后一个父亲节点的位置是 N / 2 - 1(数组是从0开始的)
假设数组中有10个元素
将最大的元素放在数组的最后
这时候树中需要排序的数量减1,剩下9个元素
在这9个元素中,只有刚才顶部交换的元素不符合大顶堆,其他元素都符合大顶堆
再对现在的顶部元素的位置调用刚才的函数接口adjust_max_heap
该函数接口运行一次只调整一个节点。
调整后,再将该元素和数组中的倒数第二个元素进行交换,数组的len会不断变小
堆排的所有时间复杂度都是nlog2n*/
void adjust_Max_Heap(int* arr, int adjustPos, int arrLen)
{
int dad = adjustPos; //adjustPos是要调整的节点位置
int son = 2 * dad + 1;
while (son < arrLen) //arrLen代表数组的长度
{
if (son + 1 < arrLen && arr[son] < arr[son + 1]) //比较左孩子和右孩子,如果右孩子大于左孩子,将son加1,从而下一步拿右孩子与父亲比较
{
son++;
}
if (arr[dad] < arr[son])
{
SWAP(arr[dad], arr[son]);
dad = son;
son = 2 * dad + 1;
}
else
{
break;
}
}
}
void arr_Heap(int* arr)
{
int i;
//将数组调整为大根堆
for (i = N / 2 - 1; i >= 0; i--) //从数组中最后一个dad节点进行调整
{
adjust_Max_Heap(arr, i, N);
}
SWAP(arr[0], arr[N - 1]);//交换顶部与最后一个元素
for (i = N - 1; i > 1; i--)
{
adjust_Max_Heap(arr, 0, i); //将剩余9个元素再次调整为大根堆,不断交换根部元素与数组尾部元素,然后调整的堆元素个数减一
SWAP(arr[0], arr[i - 1]);//交换顶部元素与堆中最后一个元素,已经在尾部排好的不算堆中元素
}
}
4、归并排序法
归并排序和快速排序一样,是使用分治策略实现的排序算法,算法可采用递归实现。归并,就是将两个或两个以上的有序数据序列合并成一个新的有序数据序列。
有序数组的线性归并方法。基本的归并算法接受两个输入数组A和B,并输出结果数组C。这个过程中存在3个计数器A_counter、B_counter、C_counter。在算法开始时,它们都位于各自数组的始端。算法的实施过程是将A[A_counter]和B[B_counter]中的元素进行比较,并将较小的那个元素续填到数组C,使其计数器递增。一旦A或B中任意一个数组中的元素被耗尽,则将另一个数组中的剩余元素全部续填到数组C,整个归并过程完成。
归并排序是通过将数组不断划分的递归过程完成排序的(第一次分成两份、第二次分成四份……)。划分的过程直到得到仅有一个元素的数组返回为止。因为一个元素是有序的,那么就能够将两个数据项归并到含有两个元素的有序数组中,再次返回,将这一对两个元素的数组归并到一个四个元素的数组中。返回最外层的时候,这个数组将会有两个分别有序的子数组,再次归并则完成排序。
void Merge(ElemType A[], int low, int mid, int high) //二路归并排序
{
ElemType B[N]; //辅助数组,将A中元素放入B中,对B数组中元素进行排序比较,结果放会A数组中
int i, j, k;
for (k = low; k <= high; k++) //将A中的元素全部复制元素到B中
{
B[k] = A[k];
}
for (i = low, j = mid + 1, k = i; i <= mid && j <= high; k++) //合并两个有序数组
{
if (B[i] <= B[j]) //第一个有序数组的下标i的范围为low - mid
{
A[k] = B[i++];
}
else
{
A[k] = B[j++]; //第二个有序数组的下标j的范围为mid+1 - high
}
}
while (i <= mid) //如果有剩余元素,接着放入A数组就完成了归并排序
{
A[k++] = B[i++];
}
while (j <= high)
{
A[k++] = B[j++];
}
}
void MergeSort(ElemType A[], int low, int high) //递归分割然后归并
{
if (low < high)
{
int mid = (low + high) / 2;
MergeSort(A, low, mid);
MergeSort(A, mid + 1, high);
Merge(A, low, mid, high);
}
}
对n个记录的序列进行归并排序时,必须进行logn次的归并,而每次归并操作的时间复杂度为O(n),所以二路归并的时间复杂度为O(nlogn)。与快速排序和堆排序相比,归并排序的最大优点是,它是一种稳定的排序方法,但一般情况下,很少利用二路归并进行内部排序。
5、计数排序法
计数排序法是一个非基于比较的线性时间排序算法。计数排序对输入的数据有附加的限制条件。需要额外的空间来存储数据的数值变化范围。
计数排序的时间复杂度为O(n+m),但这个复杂度仅在限制条件下成立。计数排序算法没有用到元素间的比较,而是利用元素的实际值来确定它们在输出数组中的位置。
void arrCount(int* arr)
{
int count[M] = { 0 };
int i, j, k;
//遍历需要进行排序的数组,将数组中每个元素出现的次数进行统计
for (i = 0; i < N; i++)
{
count[arr[i]]++;
}
k = 0; //k来记录数组中哪些元素已经填入值
for (i = 0; i < M; i++) //外层控制数值变化
{
for (j = 0; j < count[i]; j++) //将计数数组中每个数值,依次填入原有数组
{
arr[k++] = i; //count[i]中i的值即为未排序前arr[i]的值
}
}
}
对于上述代码的案例解释,如果N为1一亿个数,而M为10000,即数组arr[N]中所有元素的取值都在10000以内。我们对这一亿个数进行遍历,统计重复出现的数字的个树。比如,一亿个数中,元素值1出现了8888次,9999出现了100000次……。统计完成次数后,需要将按照0 - 9999依次将这些数据有序的存放会数组arr。所以for循环中,i为数据的值,count[i]为该数据出现的次数,从1一直循环至9999。k为数组arr中元素的计数器。