基础知识介绍
排序:排序是计算机内经常进行的一种操作,其目的是将一组“无序”的记录序列调整为“有序”的记录序列。
稳定性:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次 序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排 序算法是稳定的;否则称为不稳定的。
常见的排序分类
插入排序
直接插入排序
首先将arr[0]与arr[1]比较,让它们两个有序,在让arr[2]参与比较,如果大于arr[1]但小于arr[0]就排到arr[0]和arr[1]中间(arr[0]<arr[1]),后面的数据比较就重复之前的操作。当插入第i个元素时候,i之前的元素均已经有序,我们将第i个元素与arr[i-1]、arr[i-2]……等依次比较,最后让其位于正确的位置。
代码实现如下(所有代码实现默认升序):
void InsertSort(int* arr, int n)
{
for (int i = 1; i < n; i++)//i从第一个开始,因为我们默认一个数据就是已经排好的。
{
int temp = arr[i];//temp来记录需要排序的数,即第i个数。
int end = i - 1;//end记录的是排好序的数列的最后一个数
while (end >= 0)//从i-1到0依次进行比较
{
if (temp < arr[end])
{
arr[end + 1] = arr[end];
--end;
//如果temp小于end位置的数据那么就将end位置的数据往后挪动一个位置,直到temp不再小于arr[end]。
}
else
{
break;
}
}
arr[end + 1] = temp;
//这个地方为什么是end+1而不是end?
//因为在退出循环时候,end位置的数据小于temp,end+1的数据大于temp,并且已经往后移动一个位置,即end+1是为temp留的位置。
}
}
总结:
数据约接近有序,直接插入的时间效率越高。
例如数据本身就有序,只需要遍历一遍就行了。
时间复杂度:O(N^2)
时间复杂度最坏的情况就是目标是升序但是数据是降序排列,那么可以很轻易根据等差数列的知识算出时间复杂度应该在N^2这一个量级。
空间复杂度:O(1)
稳定性:稳定 因为可以在数据相同的时候将if条件判断为假,让相同数据保持原始的先后顺序。
希尔排序(缩小增量排序)
先选定一个整数(gap),将待排序的数列分为一组组数据,举例相隔gap的数据为一组,并对每一组的数据进行排序,然后gap减小,重复上述操作直至gap=1,此时数据有序。
代码实现如下;
void ShellSort(int* arr, int n)//现在比较主流的gap设置就是除以2或者除以3加1(保证最后gap为1)
{
//第一个思路:
//先排好自己的一组(相隔gap)再排序下一个
//对自己的一组排序的方法仿造插入排序。
int gap = n;
while (gap > 1)
{
gap /= 2;
for (int j = 0; j < gap; j++)
{
for (int i = gap; i < n; i += gap)
{
int temp = arr[i];
int end = i - gap;
while (end >= 0)
{
if (temp < arr[end])
{
arr[end + gap] = arr[end];
end -= gap;
}
else
{
break;
}
arr[end + gap] = temp;//和插入排序end+1同理
}
}
}
}
//第二个思路可以少写一个循环
//思路是一个接着一个在自己的组里排序
int gap = n;
while (gap > 1)
{
gap = gap / 3+1;
for (int i = 0; i < n-gap; i++)
{
int end = i;
int temp = arr[end + gap];
while (end >= 0)
{
if (temp < arr[end])
{
arr[end + gap] = arr[end];
end -= gap;
}
else
{
break;
}
arr[end + gap] = temp;
}
}
}
}
总结:
希尔排序是对插入排序算法的优化。
当gap>1时都是预排序,目标是让数据更加接近有序。当gap=1时,数组就已经比较接近有序了,这样时间就会大大减少。
时间复杂度:O(N^1.25) 希尔排序的时间复杂度难以确定,好像至今也只是确定了大致的范围。
稳定性: 不稳定 例如:两个相同数值被分在不同的gap,在gap1中,该值应该在比较靠前位置,在gap2中该值在比较靠后位置,这就可能导致其的前后位置发生改变。
选择排序
基本思想:每次从数组中选取最小的(最大的)元素放在起始位置,然后按照顺序依次选取第二小放在其后一个位置,并重复上述操作直至全部排好序。
直接选择排序
在数组中找出最小的元素和最大元素,如果最小元素不是数组的第一个元素,那么我们就将其与第一个元素交换位置,最大元素与最后一个元素交换位置。然后在剩余的元素中继续上述过程直到有序。
代码实现如下:
void Swap(int* x, int* y)
{
int temp = *x;
*x = *y;
*y = temp;
}
void SelectSort(int* arr, int n)
{
int left = 0;
int right = n - 1;
//left 和 right 两个下表依次从前后走
while (left < right)
{
int mini = left, maxi = left;
for (int i = left + 1; i <= right; i++)
{
if (arr[i] < arr[mini])
mini = i;
if (arr[i] > arr[maxi])
maxi = i;
}
Swap(&arr[left], &arr[mini]);
//如果left和maxi重叠,那么在left和mini交换后,最大值的位置将会改变,我们用这个if语句来纠正maxi的位置
if (maxi == left)
maxi = mini;
Swap(&arr[right], &arr[maxi]);
++left;
--right;
}
}
总结:直接选择排序理解难度低,但是效率及其低下,实用性极低。
时间复杂度:O(N^2) 无论你的数组是否有序均为该时间复杂度。
空间复杂度:O(1)
稳定性:不稳定 因为可能存在 2 2 1 1数组的情况,这样2的前后顺序会发生改变。
堆排序
堆排序是指利用堆积树(堆)这种数据结构所设计的一种排序算法,它是选择排序的一种。它是 通过堆来进行选择数据。需要注意的是排升序要建大堆,排降序建小堆。
思路:我们需要建立升序(用数组实现堆),那么就建立大堆,大的数据在顶部,小的数据在底部,然后将小的数据与大的数据交换位置,并将size减一。重复上述操作,直至size==0。
//注意这个堆的思路图是一个完全二叉树
void Swap(int* x, int* y)
{
int temp = *x;
*x = *y;
*y = temp;
}
void AdjustDown(int* a, int parent, int n)
{
int child = parent * 2 + 1;
while (child < n)
{
//这个if是用来改变child节点的,我们默认是左节点大于右节点,如果右边更大,该if语句执行
if (child + 1 < n && a[child + 1] > a[child])
{
child++;
}
if (a[parent] < a[child])
{
Swap(&a[parent], &a[child]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
void HeapSort(int* a, int n)
{
//n-1是最后一个元素的位置,再减一除2得到其父节点的位置
for (int i = (n - 1 - 1) / 2; i >= 0; --i)
{
AdjustDown(a, i, n);
}
//这一个for循环是用户来建立一个大堆
int end = n - 1;
//每次将最大的数和数组最后一个数字交换,然后将堆顶数据调整到相应位置。
while (end > 0)
{
Swap(&a[0], &a[end]);
AdjustDown(a, 0, end);
end--;
}
}
总结:
时间复杂度:O(N*logN) 首先建立大堆的时间复杂度为O(N)。计算如下
其次每次调整,数据从堆顶向下调整大约是log N次,因为尾部的数据是较小的,所以会到较下面的位置。 因此时间复杂度的量级应该是N*log N。
空间复杂度:O(1)
稳定性:不稳定 相同的数据在建堆和排序的时候并不能保证先后顺序,留给读者自行验证。
交换排序
基本思想:所谓交换,就是根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位置,交换排 序的特点是:将键值较大的记录向序列的尾部移动,键值较小的记录向序列的前部移动。
冒泡排序
通过比较相邻元素的位置并进行交换,使得较大的元素逐渐向后移动,较小的元素逐渐向前移动,最终达到排序的目的。
代码实现如下:
void BubbleSort(int* arr, int n)
{
for (int i = 0; i < n; i++)
{
bool exhchage = false;
//设置标志量,如果循环一次没有交换数据就推出循环
for (int j = 1; j < n-i; ++j)
{
if (arr[j - 1] < arr[j])
{
Swap(&arr[j - 1], &arr[j]);
exhchage = true;
}
}
if (exhchage == false)
break;
}
}
总结
冒泡排序和插入排序虽然最好最坏都相同,但是在局部有序的时候,插入的优势就体现出来了
冒泡教学意义大于实际意义 因为它容易理解,是很多新手接触的第一个排序。
时间复杂度:O(N^2)
空间复杂度:O(1)
稳定性:稳定
快速排序
快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法。
基本思想:任取待排序元素序列中 的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
hoare的方法
大量重复数据会导致该快排效率降低,可以使用三路递归改进。
key值选取有什么讲究?
key值如果只是选取左边或者右边的值,那么如果数列是升序或者降序那么会很吃亏,时间复杂度量级在O(N^2)。而且可能会导致栈溢出,需要创建N个栈帧 。
关于key值的选取,主流的大致就两种,一种是随机选择key,一种是三数取中。keyi越接近中间,时间复杂度越吻合N*log N。
为什么在选取left时候,一定要right先走?
相遇分为两种情况:1.R找小,L找大;R找到到小,但L没有找到,R、L相遇点必然是小于keyi。
2.R找小没找到,但R与L相遇了,不管L是否是最初始的位置还是交换过,L指向的数都是会小于或者等于keyi(相遇位置就是keyi位置)。
void Swap(int* x, int* y)
{
int temp = *x;
*x = *y;
*y = temp;
}
int GetMidNumi(int* arr, int left, int right)
{
int mid = (left + right) / 2;
if (arr[left] < arr[mid])
{
if (arr[mid] < arr[right])
{
return mid;
}
else if (arr[right] > arr[left])
{
return right;
}
else
{
return left;
}
}
else
{
if (arr[mid] > arr[right])
{
return mid;
}
else if (arr[left] < arr[right])
{
return left;
}
else
{
return right;
}
}
}
void QuickSort(int* arr, int left, int right)
{
if (left >= right)
return;
int begin = left, end = right;
//随机选择keyi
//int randi = left + (rand() % (right - left));
//Swap(&arr[left], &arr[randi]);
//三数取中
//left right 和mid 三个数取中间的
//完美规避 升序或者降序数列
int mid = GetMidNumi(arr, left, right);
if (mid != left)
Swap(&arr[left], &arr[mid]);
int keyi = left;
while (left < right)
{
//如果arr[keyi] <= arr[right]写为arr[keyi] < arr[right]那么遇到6、6、8、6、9、10这种
// keyi位置的值为6,但6与6之间有8,就无法将8放在正确位置
// 而且就算没有,终止条件也无法到达,成为死循环
// 相等的值放在左边或者右边都无所谓
// left < right这个条件携程left<=right也会导致排序出现问题
//右边找小
while (left < right && arr[keyi] <= arr[right])
--right;
//左边找大
while (left < right && arr[keyi] >= arr[left])
++left;
Swap(&arr[left], &arr[right]);
}
Swap(&arr[keyi], &arr[left]);
keyi = left;
QuickSort(arr, begin, keyi - 1);
QuickSort(arr, keyi + 1, end);
}
挖坑法
选left做key(key是单趟排序后能排到最后该待的位置的数据,left同时也作为第一个坑)right开始找,right遇到比key大或相等的就往左边走,遇到比key小的就停下,然后将right赋值给left(left = right)(填left坑),right则成为了新的坑。left开始找,left遇到比key小或相等的就往右边走,遇到比key大的就停下,然后将left赋值给right(right = left)(填right坑),left则成为了新的坑,进行第二步。若left超过了right,将key赋值给left。
void QuickSort(int* arr, int left, int right)
{
if (left >= right)
return;
int begin = left, end = right;
int mid = GetMidNumi(arr, left, right);
if (mid != left)
Swap(&arr[left], &arr[mid]);
int key = arr[left];//保存key值
int hole = left;
while (left < right)
{
while (left < right && arr[right] >= key)
--right;
arr[hole] = arr[right];
hole = right;
while (left < right && arr[left] <= key)
++left;
arr[hole] = arr[left];
hole = left;
}
arr[hole] = key;
QuickSort(arr, begin, hole - 1);
QuickSort(arr, hole + 1, end);
}
void Swap(int* x, int* y)
{
int temp = *x;
*x = *y;
*y = temp;
}
前后指针法
通过创建两个指针(prev和cur),prev指针指向数组的第一个数据,cur指向数组的第二个数据。如果cur指向的数据小于key,那么++prev,交换cur和prev(如果cur==prev就不交换),++cur;如果大于cur那么就只++cur。这样就可以保证prev指向的数一定小于key。(用cur指向数组尾部为结束条件)
void Swap(int* x, int* y)
{
int temp = *x;
*x = *y;
*y = temp;
}
int GetMidNumi(int* arr, int left, int right)
{
int mid = (left + right) / 2;
if (arr[left] < arr[mid])
{
if (arr[mid] < arr[right])
{
return mid;
}
else if (arr[right] > arr[left])
{
return right;
}
else
{
return left;
}
}
else
{
if (arr[mid] > arr[right])
{
return mid;
}
else if (arr[left] < arr[right])
{
return left;
}
else
{
return right;
}
}
}
void QuickSort(int* arr, int left, int right)
{
if (left >= right)
return;
int mid = GetMidNumi(arr, left, right);
if (mid != left)
Swap(&arr[left], &arr[mid]);
int keyi = arr[left];
int cur = left + 1, prev = left;
while (cur <= right)
{
if (arr[cur] < keyi && ++prev != cur)//小于keyi且不等于cur才交换,如果等于cur就只++prev
Swap(&arr[prev], &arr[cur]);
++cur;
}
Swap(&arr[left], &arr[prev]);
QuickSort(arr, left, prev-1);
QuickSort(arr, prev+1, right);
}
快速排序区间优化
我们可以在快速排序区间比较小的时候采取插入排序来代替递归,看上去可以优化许多,但其实效果并没有想象那么的好。
void QuickSort(int* arr, int left, int right)
{
if (left >= right)
return;
if ((right - left + 1) > 10)
{
int keyi = QuickSortPart(arr, left, right);
QuickSort(arr, left, keyi - 1);
QuickSort(arr, keyi + 1, right);;
}
else
{
InsertSort(arr + left, right-left + 1);
}
}
非递归实现快速排序
递归的问题
1.效率(影响不是很大)
2.栈溢出
递归改非递归
1.改循环(例如斐波拉契数列)
2.使用栈辅助改循环
思路:用Stack来存储排序的区间
void QuickSortNonR(int* arr, int left, int right)
{
Stack st;
StackInit(&st);
StackPush(&st, right);
StackPush(&st, left);
while (!StackEmpty(&st))
{
int begin = StackTop(&st);
StackPop(&st);
int end = StackTop(&st);
StackPop(&st);
int keyi = QuickSortPtr(arr, begin, end);
//[begin,keyi-1] keyi [keyi+1,end]
if (keyi + 1 < end)
{
StackPush(&st, end);
StackPush(&st, keyi + 1);
}
if (begin < keyi - 1)
{
StackPush(&st, keyi-1);
StackPush(&st, begin);
}
}
StackDestroy(&st);
}
总结:
快速排序整体的综合性能和使用场景都是比较好
时间复杂度:O(N*logN)
空间复杂度:O(logN)
稳定性:不稳定
归并排序
基本思想:归并排序(MERGE-SORT)是建立在归并操作上的一种有效的排序算法,该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有 序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。
归并排序
递归实现归并排序
基本思路:归并排序就是递归得将原始数组递归对半分隔,直到不能再分(只剩下一个元素)后,开始从最小的数组向上归并排序。向上归并的时候,我们需要开辟一个数组来存储我们排好序的数组。将两组数据(先开始每组就一个数据,默认一个数据本身就是有序的)比对,小的先放入数组,得到有序的(两个一组)数组,然后将其拷贝回原数组,之后继续递归。
void _MergeSort(int* arr, int begin, int end, int* temp)
{
if (begin >= end)
return;
int mid = (begin + end) / 2;
_MergeSort(arr, begin, mid, temp);
_MergeSort(arr, mid + 1, end, temp);
int begin1 = begin, end1 = mid;
int begin2 = mid + 1, end2 = end;
int i = begin;
while (begin1 <= end1 && begin2 <= end2)
{
if (arr[begin1] <= arr[begin2])
temp[i++] = arr[begin1++];
else
temp[i++] = arr[begin2++];
}
while (begin1 <= end1)
temp[i++] = arr[begin1++];
while (begin2 <= end2)
temp[i++] = arr[begin2++];
memcpy(arr + begin, temp + begin, sizeof(int)*(end - begin + 1));
}
void MergeSort(int* arr, int n)
{
int* temp = (int*)malloc(sizeof(int) * n);
if (temp == NULL)
{
perror("malloc fail");
return;
}
_MergeSort(arr, 0, n - 1, temp);
free(temp);
temp = NULL;
}
非递归实现归并排序
采用一个新数组和原数组配合实现,从递归的结束条件开始,即一个数为一组。然后向上一步步重复递归操作。
新数组整体拷贝写法
关于end1,begin2,end2越界的问题
begin1不可能越界,因为begin1是用 i 来赋值的,i 不可能等于n。
第一种end1越界了,那么我们直接把end1设置为n - 1,但要注意,我们一定要把begin2和end2设置为一个不存在的空间,不然最后一个数据将被拷贝两次。
第二种begin2越界,操作同上,把begin2和end2设置为一个不存在的空间,不然最后一个数据将被拷贝两次。
第三种end2越界,那么我们直接将end2设置为 n - 1即可。
void MergeSortNonR(int* arr, int n)
{
int* temp = (int*)malloc(sizeof(int) * n);
if (temp == NULL)
{
perror("malloc fail");
return;
}
int gap = 1;
while (gap < n)
{
for (int i = 0; i < n; i=i+2*gap)
{
int begin1 = i, end1 = i + gap - 1;
int begin2 = i + gap, end2 = i + 2 * gap - 1;
int count = i;
//开始判断是否存在越界并且纠正越界访问
if (end1 >= n)
{
end1 = n - 1;
begin2 = n;
end2 = n - 1;
}
if (begin2 >= n)
{
begin2 = n;
end2 = n - 1;
}
if (end2 >= n)
{
end2 = n - 1;
}
while (begin1 <= end1 && begin2 <= end2)
{
if (arr[begin1] <= arr[begin2])
temp[count++] = arr[begin1++];
else
temp[count++] = arr[begin2++];
}
while (begin1 <= end1)
temp[count++] = arr[begin1++];
while (begin2 <= end2)
temp[count++] = arr[begin2++];
}
memcpy(arr,temp,sizeof(int) * n);
gap *= 2;
}
free(temp);
temp = NULL;
}
新数组部分拷贝
排好一部分数据拷贝一部分回去原数组
void MergeSortNonR(int* arr, int n)
{
int* temp = (int*)malloc(sizeof(int) * n);
if (temp == NULL)
{
perror("malloc fail");
return;
}
int gap = 1;
while (gap < n)
{
for (int i = 0; i < n; i=i+2*gap)
{
int begin1 = i, end1 = i + gap - 1;
int begin2 = i + gap, end2 = i + 2 * gap - 1;
int count = i;
//开始判断是否存在越界并且纠正越界访问
if (end1 >= n || begin2 >= n)
break;
if (end2 >= n)
end2 = n - 1;
while (begin1 <= end1 && begin2 <= end2)
{
if (arr[begin1] <= arr[begin2])
temp[count++] = arr[begin1++];
else
temp[count++] = arr[begin2++];
}
while (begin1 <= end1)
temp[count++] = arr[begin1++];
while (begin2 <= end2)
temp[count++] = arr[begin2++];
memcpy(arr + i, temp + i, sizeof(int) * (end2 - i + 1));
}
gap *= 2;
}
free(temp);
temp = NULL;
}
总结:
归并的缺点在于需要O(N)的空间复杂度,归并排序的思考更多的是解决在磁盘中的外排序问题。 时间复杂度:O(N*logN)
空间复杂度:O(N)
稳定性:稳定 因为我们可以让相等的元素在前面的先进入新数组