插入排序
基本思想:将一个元素逐步插入到已经排好序的部分数组中,从而逐步构建有序序列。插入排序适用于小型数据集,尤其是当数据集已经接近有序时效果更好。
基本概念:插入排序从数组的第二个元素开始,将数组分为已排序部分和未排序部分。初始时,第一个元素被视为已排序。逐个将未排序元素插入已排序部分,对于未排序部分中的每个元素,从右向左逐个比较它与已排序部分中的元素,找到合适的位置将其插入。当找到适合的插入位置时,将未排序元素插入到已排序部分,同时可能需要将已排序部分中的一些元素后移以腾出位置。重复步骤直至所有元素有序。
插入排序的时间复杂度取决于输入数据的初始顺序。在最佳情况下,即输入数据已经几乎有序时,插入排序的时间复杂度可以达到 O(n),其中 n 是元素的数量。但在最坏情况下,即输入数据逆序排列时,插入排序的时间复杂度为 O(n^2)。
(1)直接插入排序
基本概念:插入排序从数组的第二个元素开始,将数组分为已排序部分和未排序部分。初始时,第一个元素被视为已排序。逐个将未排序元素插入已排序部分,对于未排序部分中的每个元素,从右向左逐个比较它与已排序部分中的元素,找到合适的位置将其插入。当找到适合的插入位置时,将未排序元素插入到已排序部分,同时可能需要将已排序部分中的一些元素后移以腾出位置。重复步骤直至所有元素有序。
typedef int Keytype;
typedef struct{
Keytype key;
InfoType otherinfo;
}Redtype;
typedef struct{
Redtype r[20+1];
int length;
}SqList;
void InsertSort(SqList &L){
int i,j;
for(i = 2; i < L.length; i++){
if(L.r[i].key < L.r[i-1].key){
L.r[0] = L.r[i];
L.r[i] = L.r[i-1];
//使用顺序查找法将待插入元素与有序列表对比
for(j=i-2;L.r[j].key>L.r[0].key;j--)
L.r[j+1] = L.r[j];
L.r[j+1] = L.r[0];
}
}
}
(2)折半插入排序
直接插入排序采用顺序查找法查找当前记录在有序列表中的插入位置,将查找法替换为折半查找实现为折半插入排序。
void BInsertSort(Sqlist& L){
int i, j;
for(i = 2;i < L.length;i++){
L.r[0] = L.r[i];
int low = 1; high = i - 1;
while(low <= high){
m = (low + high)/2;
if(L.r[0]>=L.r[m]) low = m + 1;
else high = m - 1;
}
for(j = i - 1; j >= high + 1; j--)
L.r[j+1] = L.r[j]; //腾出插入位置
L.r[high] = L.r[0];
}
}
(3)希尔排序
希尔排序是一种改进的插入排序算法,也称缩小增量排序。通过将数组分成多个子序列进行插入排序,逐步缩小子序列的间隔,从而提高了插入排序的效率。
基本思想:
确定增量序列: 选择一个增量序列,该序列决定了子序列的划分方式。常用的增量序列有希尔增量、Hibbard增量等。
分组插入排序: 根据增量序列将数组划分成多个子序列,然后对每个子序列进行插入排序。这里的子序列不是连续的,而是跳跃式的。
逐步减小增量: 通过不断缩小增量,重复进行分组插入排序。每次减小增量,子序列的数量变少,而每个子序列中的元素数量逐渐增加。
最后一步常规插入排序: 当增量缩小至1时,整个数组被分为一个子序列,这时进行一次常规的插入排序。
算法特点:
记录跳跃式移动导致排序不稳定,且只能用于顺序表,不能链式结构。
增量序列有多重取法,但增量中的值不能有除1之外的公因子,且最后一个增量值必须为1。
总的比较次数和移动次数比直接插入少,n越大越明显,适用于无序、n较大的情况。
//希尔排序就是将相隔dk的俩个元素对比排序,随着增量的不断减小,将趋于有序。
void ShellInsert(SqList &L, int dk){
//对顺序表L做一趟增量为dk的希尔插入排序
for(int i=dk+1;i<=L.length;i++)
if(L.r[i].key<L.r[i-dk].key){
L.r[0] = L.r[i];
//将相隔dk个数的俩个元素进行对比若r[i]<r[i-dk]则互相交换,
//j-=dk小于0时将跳出for循环。
for(j=i-dk;j>0&&L.r[0].key<L.r[j].key;j-=dk)
L.r[j+dk] = L.r[j];
L.r[j+dk] = L.r[0];
}
}
void ShellSort(SqList &L,int dt[],int t){
//按增量序列dt[0...t-1]对顺序表L做t趟希尔排序
for(int k=0;k<t;k++)
ShellInsert(L,dt[k]);
}
//方法2:数组从0开始,另设sum暂存交换值。
#include <vector>
std::vector<int> arr = {12, 34, 5, 23, 67, 1, 45, 9, 20};
void shellSort(std::vector<int> &arr) {
int n = arr.size();
int gap = n / 2; // 初始增量,可以根据需要调整
while (gap > 0) {
for (int i = gap; i < n; i++) {
int temp = arr[i];
int j = i;
while (j >= gap && arr[j - gap] > temp) {
arr[j] = arr[j - gap];
j -= gap;
}
arr[j] = temp;
}
gap /= 2; // 缩小增量
}
}
交换排序
基本思想:
俩俩比较待排序记录的关键字,一旦发现俩记录不满足次序要求就交换。直到整个序列满足要求。
(1)冒泡排序
算法思想:
冒泡排序是一种最简单的交换排序算法,通过俩俩比较相邻的关键字,有逆序则进行交换,从而使关键字小的记录像气泡一样向上"漂浮"(左移)。
算法步骤:
比较相邻元素:从第一个元素(r[1])开始比较相邻的两个元素,顺序不正确则交换它们的位置。
一轮冒泡: 在第一轮比较后,最大的元素已经“冒泡”到数组的末尾位置。然后,对除最后一个元素外的所有元素重复步骤1,直到第二大的元素冒泡到正确的位置。
多轮冒泡: 继续进行多轮的比较和交换,每轮将会确定一个未排序部分中的一个最大元素的位置。每次冒泡都会减少未排序部分的大小。
重复直至排序完成:重复进行步骤1和步骤2,直到整个数组排序完成。在每一轮冒泡中,已排序部分会逐渐扩大,未排序部分会逐渐缩小,直至所有元素都处于正确的位置。
算法特点:
稳定排序、可用于链式存储结构
移动记录次数较多,算法平均时间性能比直接插入排序差,当初始列表无序,n较大时不宜采用该算法。
void BubbleDort(SqList &L)
{
int m = L.length - 1;
flag = 1; // flag用来标记某一趟排序是否发生交换
while(m > 0 && flag == 1){
flag = 0; //flag置0,若本趟排序未发生交换,则不会执行下一趟交换
for(int j = 1; j <= m; j++)
if(L.r[j].key>L.r[j+1].key){
flag = 1; //发生交换
t = L.r[j];
L.r[j] = L.r[j+1];
L.r[j+1] = t;
}
--m;
}
}
//时间复杂度: O(n^2)
//空间复杂度: O(1)
(2)快速排序
算法概念:
冒泡排序是对相邻的俩个记录进行比较,每次只能消除一个逆序,而快速排序则通过俩不相邻记录的一次交换消除多个逆序。
算法步骤:
选择基准元素: 从待排序的数组任选一个元素作为基准。通常选择数组中的第一个或最后一个元素。
分区操作: (第一趟排序)将数组元素与基准比较可分为两个子数组:小于基准的大于基准的子数组。这个过程称为分区操作。
具体步骤:
定义两个指针,一个指向子数组的起始位置(通常是基准的下一个位置),另一个指向子数组的末尾位置。
移动第一个指针,直到找到一个大于等于基准的元素。
移动第二个指针,直到找到一个小于基准的元素。
如果第一个指针小于等于第二个指针,交换这两个元素,然后继续移动指针直到相遇。
将基准元素与第二个指针指向的元素交换,这样基准元素就被放置在了正确的位置,使得它左边的元素都小于它,右边的元素都大于它。
递归排序: 分别对基准元素左边的子数组和右边的子数组进行递归排序,重复执行步骤1和步骤2,直到每个子数组只有一个元素或为空。
合并: 递归排序完成后,数组中的元素就变得有序。因为在分区操作中,每次都会将一个元素放置在正确的位置上。
整个过程不断地将数组分割为较小的子数组,然后通过递归地对子数组进行排序,最终实现整个数组的有序排列。
需要注意的是,快速排序的性能与基准的选择以及分区操作的实现方式有关。优化的快速排序算法会考虑基准选择的策略,以及如何有效地进行分区操作,以减少不必要的比较和交换次数。
void QuickSort(SqList& L)
{
QSort(L, 1, L.length());//对顺序表做快速排序
}
void QSort(SqList &L,int low,int high)
{
low = 1; high = L.length();//调用前置初值
//对顺序表L的子序列L.r[low,high]做快速排序
if(low < high){ //长度大于1
int pivotloc = Partition(L, low, high);
QSort(L,low,pivotloc - 1);
QSort(L,pivotloc + 1, high);
}
}
//对L.r[low..high]一分为二,返回枢轴位置
int Partition(SqList& L, int low, int high)
{
L.r[0] = L.r[low];//将子序列的第一个元素作为枢轴进行对比划分大小两块子序列
int pivotkey = L.r[low].key;
while(low < high){
while(low<high && L.r[high].key >= pivotkey)
--high;
L.r[low] = L.r[high];
while(low<high && L.r[low].key <= pivotkey)
++low;
L.r[high] = L.r[low];
}
L.r[low] = L.r[0];
return low;
}
合理的选择枢轴可避免递归树为单支树(有序序列)的情况下需要n-1趟比较才能定位记录的现象。此时快速牌序退化到简单牌序的水平。
三者取中规则:比较当前表中第一个,最后一个和中间一个记录的关键字,取关键字居中的记录作为枢轴记录,事先调用到第一个记录的位置。
时间复杂度:平均情况下为O(nlog2N);快速排序的趟数取决于递归树的深度。
空间复杂度:快速排序是递归的执行时需要一个栈存放相应数据,最大递归调用次数与递归树深度一致,最好:O(log2N);最坏:O(n);
算法特点:
记录非顺次移动导致排序方法不是稳定的。
排序过程中需要定位表的上下界,所以适用于顺序结构,很难用于链式结构。
n较大时,平均情况下快速排序是所有非内部排序方法中速度最快的一种,适用于无序,n较大的情况。
选择排序
基本思想:
每一趟从待排序的记录中选出关键字最小的记录,按顺序放在已排序的队列后面,直到全部有序。
(1)简单选择排序
简单选择排序也叫直接选择排序,实际上就是用俩个for循环依次将队列中最小的关键字放到队列前面,直到遍历所有。
void selectSort(SqList& L){
for(int i=1; i<L.length,i++){
int min = i;
for(int j=i+1; j<L.length; j++){
if(L.r[j].key < L.r[i].key)
min = j;
if(min != i){
L.r[0] = L.r[i];
L.r[i] = L.r[min];
L.r[min] = L.r[0];
}
}
}
}
时间复杂度: O(N^2) 空间复杂度: O(1)。可用于链式存储结构
(2)树形选择排序
又叫锦标赛排序,首先对n个元素进行比较,然后在其中的n/2个较小者之间再俩俩比较,直到选出最小的元素为止。该过程可用一颗有n个叶子结点的完全二叉树表示。
选出最小值,并修改叶子结点为∞,直到所有叶子结点为∞,排序完成。
树形选择排序时间复杂度为O(nlog2 n),但需要的辅助存储空间较多、还有和最大值进行多余比较等缺点,改进算法为:堆排序。
(3)堆排序
算法思想:
堆排序也是一种树形选择排序,在排序过程中将待排序列表看成是一颗完全二叉树的顺序存储结构,利用完全二叉树的双亲节点和孩子节点之间的内在关系,在无序序列中选择关键字最大、最小的记录。
堆定义:
n个元素序列{k1,k2…kn}称之为堆,当且仅当满足:
K(i)>=K(2i)且K(i)>=K(2i+1) 或 K(i)<=K(2i)且K(i)<=K(2i+1) (1<= i <= (n/2(向下取整)))。
若将此序列对应的一维数组看成为一个完全二叉树,则堆实质上是一个这样的二叉树:树中所有的非终端节点的值均不大于(不小于)其左右节点的值。
算法步骤:
构建最大堆: 从数组中构建最大堆,可以使用自底向上的方法。从最后一个非叶子节点开始,对每个节点进行"下沉"操作,使得当前子树满足最大堆性质。
交换堆顶和末尾元素: 将最大堆的堆顶元素(数组的第一个元素)与数组的最后一个元素交换。此时,最大值已经在正确的位置。
调整堆: 由于交换了堆顶元素,破坏了最大堆的性质,需要对堆顶元素进行"下沉"操作,将其下沉到合适的位置,以恢复最大堆的性质。
重复交换和调整: 重复执行步骤2和步骤3,每次都将堆中的最大元素交换到数组末尾,并重新调整堆,直到堆为空,即所有元素都被取出,同时数组也就变成有序的。
//建初堆 : 将无序序列建立为大根堆/小根堆
/*一个具有n个元素的完全二叉树的最后一个非叶子节点的索引是n/2-1(从下标0开始)。*/
void CreatHeap(SqList& L){
int length = L.length;
for(int i = length/2; i > 0; i--) //下标0不用
HeapAdjust(L, i, length);
}
//筛选法调整堆
void HeapAdjust(SqList& L, int s, int n) {
L.r[0] = L.r[s];
for(int i = 2*s; j <= n; j*=2){
if(j<m && L.r[j].key > L.r[j+1].key) j++;
if(L.r[0].key >= L.r[j].key) break;
L.r[s] = L.r[j];
s = j; //将调换过的孩子再对比一遍,防止孩子的孩子出现更大的情况。
}
L.r[s] = L.r[0]; //将小值还给孩子
}
//堆排序
void HeapSort(SqList& L){
CreatHeap(L); //把无序序列建为大根堆
for(int i = L.length; i > 1; i--){
L.r[0] = L.r[1];
L.r[1] = L.r[i];
L.r[i] = L.r[0];
HeapAdjust(L,i,i-1);
}
}
堆排序的时间复杂度主要耗费在建初堆和调整堆时进行的反复筛选上,为O(nlog2n),虽然在最坏情况下也是O(nlog2n),但是由于堆排序不像快速排序那样对数据的随机性要求高,因此在实际中它具有一定的稳定性。然而,堆排序通常比一些其他排序算法(快速排序和归并排序)的性能稍差,因为在交换和调整堆的过程中涉及到较多的指针操作。
算法特点
不稳定排序 只能用于顺序结构 初始建堆所需的比较次数较多,因此记录数较少时不宜采用。
归并排序
归并排序就是将俩个或俩个以上的有序表合并为一个有序表的过程,俩个有序表合并:2-路归并。
算法思想:
假设初始序列含有n个记录,则可以看成n个有序的子序列,每个子序列长度为1,然后俩俩归并,得到n/2(向上取整)个长度为2或1的有序子序列。再俩俩归并,重复得到一个长度为n的有序序列为止。
//相邻俩个有序子列表的合并
void Merge(Redtype R[], Redtype T[], int low, int mid, int high){
int i = low, j = mid + 1, k = low;
while(i <= mid && j <= high){
if(R[i].key <= R[j].key) T[k++] = R[i++];
else T[k++] = R[j++];
}
while(i <= mid) T[k++] = R[i++];
while(j <= high) T[k++] =R[j++];
}
//归并排序
void MSort(Redtype R[], Redtype T[], int low, int high){
Redtype S[high];
//R[low..high]归并排序后放入T[low..high]中
if(low == high) T[low] = R[high];
else{
int mid = (high + low) / 2; //将当前序列一分为2,求出分裂点mid
MSort(R, S, low, mid); //对子序列R[low-mid]进行递归归并排序,结果放入S[low-mid]中。
MSort(R, S, mid+1, high);//同上,对子序列T进行递归归并排序。
Merge(S, T, low, mid, high);//将S和T归并到T[low-high]
}
}
//对外接口
void MergeSort(SqList& L){
//对顺序表L做归并排序
MSort(L.r, L.r, 1, L.length);
}
时间复杂度:当有n个记录时,需要进行log2 n(向上取整)趟归并排序,每一趟归并其关键字比较次数不超过n,元素移动次数为n,因此归并排序的时间复杂度为O(nlog2 n);
空间复杂度:用顺序表实现归并排序需要n个辅助空间,所以为O(n);
算法特点:
稳定排序,可用于链式结构,且不需要附加存储空间,但递归实现时仍然需要开辟相应的递归栈。