冒泡排序
介绍
冒泡排序(Bubble Sort)是一种简单的比较排序算法,其工作原理类似于气泡在水中上升的过程。它通过重复遍历要排序的列表,比较相邻的两个元素,如果它们的顺序不正确就交换它们的位置。这个过程会持续进行,直到整个列表没有需要交换的元素为止。
步骤(以升序排序为例)
- 从列表的第一个元素开始,比较相邻的两个元素,如果前者比后者大,则交换它们的位置。
- 继续这个过程,直到遍历完整个列表。此时,最大的元素会“冒泡”到列表的末尾。
- 重复以上过程,每次遍历的范围逐渐减少(因为每次遍历都会把最大的元素放在正确的位置),直到整个列表有序。
算法实现
void bubbleSort(vector<int> &arr){
int n=arr.size();//复习size()
for(int i=0;i<n-1;i++){//控制比较次数
for(int j=0;j<n-1-i;j++){//控制相邻元素的比较和交换,上层循环完一次,最大的数会到最右边固定住,所以循环次数是n-1-i
if(arr[j]>arr[j+1]{
swap(arr[j],arr[j+1]);
}
}
}
}
复杂度分析
时间复杂度
(1)最好情况:当输入数组已经是有序的时候,冒泡排序只需要遍历一次数组即可完成排序。时间复杂度为 O(n)。
(2)最坏情况:当输入数组完全倒序时,每两个相邻元素都需要进行比较和交换,时间复杂度为 O(n^2)。
(3)平均情况:对于一个随机排列的数组,需要进行多次比较和交换操作,时间复杂度为 O(n^2)。
空间复杂度
冒泡排序是一种原地排序算法,它只需要一个额外的临时变量用于交换元素,不需要额外的空间。因此,其空间复杂度为 O(1)。
是否为稳定排序:是
稳定排序的定义
如果一个排序算法能够保证在排序后,数据中相同元素之间的相对顺序与排序前相同,那么这个算法就是稳定排序算法。
在冒泡排序中,当比较和交换相邻元素时,只有当前者大于后者时才会交换它们的位置。如果两个元素相等,它们不会被交换,因此它们的相对顺序得以保持不变。
选择排序
介绍
选择排序(Selection Sort)工作原理是:每次从未排序的部分中找到最小(或最大)的元素,然后将其交换到未排序部分的起始位置,直到整个数组排序完成。
步骤(以升序排序为例)
1.从数组的第 i 个元素开始,找到从第 i 到第 n−1 个元素中最小的元素。
2. 将找到的最小元素与第 i 个元素交换位置。
3. 重复步骤 1 和 2,每次从下一个位置开始,直到整个数组有序。
算法实现
void selectionSort(vector<int> &arr){
int n=arr.size();//复习size()
for(int i=0;i<n-1;i++){//对应步骤1的话
int minIndex=i;// 当前最小元素的索引
for(int j=i+1;j<n;j++){
if(arr[j]<arr[minIndex]){
minIndex=j;
}
}
// 将找到的最小元素与第 i 个元素交换
if(minIndex!=i)
swap(arr[minIndex],arr[i]);
}
}
复杂度分析
时间复杂度
(1)最好情况:即使数组已经完全有序,选择排序仍然需要遍历整个数组来寻找最小元素。因此,最好情况下的时间复杂度为O(n^2)。
(2)最坏情况:数组完全倒序时,同样需要遍历整个数组,且每次都需要交换元素。最坏情况下的时间复杂度为 O(n^2)。
(3)平均情况:对于一个随机排列的数组,需要进行多次比较和交换操作,时间复杂度为 O(n^2)。
空间复杂度
选择排序是一种原地排序算法,它只需要一个额外的临时变量用于交换元素,不需要额外的空间。因此,其空间复杂度为 O(1)。
是否为稳定排序:否
选择排序不是稳定排序算法。在选择排序中,当找到一个更小的元素并与前面的元素交换时,可能会改变相同元素之间的相对顺序。
举个栗子!
假设有一个数组 [3a, 3b, 2],其中 3a 和 3b 是两个值相同的元素,但它们的相对顺序在排序前是 3a 在前,3b 在后。我们的目标是通过选择排序对这个数组进行升序排序。
(1)第一轮:
- 初始数组:[3a, 3b, 2]
- 从索引 0 开始,寻找数组中最小的元素。
- 比较 3a、3b 和 2,发现最小元素是 2,位于索引 2。
- 将 2 与 3a 交换位置。
- 结果数组:[2, 3b, 3a]
(2)第二轮: - 数组状态:[2, 3b, 3a]
- 从索引 1 开始,寻找子数组 [3b, 3a] 中的最小元素。 比较 3b 和3a,它们的值相同,因此最小元素可以认为是任意一个。 由于 3b 在索引 1,而 3a 在索引 2,但它们的值相同,所以不需要进行交换。
- 结果数组:[2, 3b, 3a]
(3)第三轮: - 数组状态:[2, 3b, 3a]
- 从索引 2 开始,子数组只剩下 [3a],已经有序,无需操作。
- 最终数组:[2, 3b, 3a]
可以发现:排序后的数组是 [2, 3b, 3a]。虽然数组已经按值正确排序,但原来的 3a 和 3b 的相对顺序发生了变化。
so,不稳定!
插入排序
介绍
插入排序(Insertion Sort)工作原理是:每次从未排序部分选取第一个元素,将其与已排序部分的元素进行比较,并插入到已排序部分的合适位置,直到整个数组排序完成。
步骤(以升序排序为例)
1.从数组的第 1 个元素开始,将其与前面已排序部分的元素进行比较。
2. 如果当前元素小于已排序部分的最后一个元素,则将已排序部分的元素依次向后移动,为当前元素腾出插入位置。
3. 将当前元素插入到合适的位置
4. 重复步骤 1 - 3,直到整个数组有序。
算法实现
void insertSort(vector<int> &arr){
int n=arr.size();
for(int i=0;i<n-1;i++){
int cur=arr[i];//当前要插入的元素
int j=i-1;
while(arr[j]>arr[i]&&j>=0){
arr[j+1]=arr[j];
j--;
}
arr[j+1]=cur;//插入
}
}
复杂度分析
时间复杂度
(1)最好情况:当输入数组已经是有序的时候,每次插入元素时无需移动已排序部分的元素,时间复杂度为 O(n)。
(2)最坏情况:当输入数组完全倒序时,每次插入元素都需要与已排序部分的所有元素进行比较并移动,时间复杂度为 O(n²)。
(3)平均情况:对于一个随机排列的数组,需要进行多次比较和交换操作,时间复杂度为 O(n²)。
空间复杂度
插入排序是一种原地排序算法,它只需要一个额外的临时变量用于交换元素,不需要额外的空间。因此,其空间复杂度为 O(1)。
是否为稳定排序:是
插入排序是稳定排序算法。在插入排序中,当比较和移动元素时,只有当前元素小于已排序部分的元素时才会进行移动操作。如果两个元素相等,它们的相对顺序保持不变。
希尔排序
介绍
希尔排序(Shell Sort)是插入排序的一种高效改进版本。它的工作原理是:先将整个待排序的记录序列分割成若干子序列分别进行插入排序,然后依次缩减间隔(步长),直至步长为 1 时进行最后一次插入排序,使得整个序列有序。
步骤(以升序排序为例)
1.确定初始步长 :选择一个增量序列,例如初始步长可以取数组长度的一半,并依次减半,直到步长为 1。
2. 分组与排序 :按照步长将数组分成若干组,每组包含间隔为步长的元素。对每一组内的元素进行插入排序。eg:[5,1,2,34,53,2,1].我选择步长为3,那么分组是[5,34,1],[1,53],[2,2]
4. 缩减步长并重复 :减小步长,重复步骤 2 的分组和排序过程,直到步长缩减为 1。此时,整个数组经过多次局部有序的调整,最终通过一次完整的插入排序得到有序序列。
算法实现
void shellSort(vector<int> &arr){
int n=arr.size();
int gap=n/2;//初始步长
while(gap>0){
//对每个步长gap的分组进行插入排序
for(int i=gap;i<n;i++){
int temp=arr[i];
int j=i-gap;//注意这里!间隔不是1是步长啊!
while(arr[j]>temp&&j>=0){
arr[j+gap]=arr[j];
j-=gap;
}
arr[j+gap]=temp;
gap/=2;//减少步长
}
}
}
跟插入排序蛮像的哈!插入排序步长是1
复杂度分析
时间复杂度
(1)最好情况:当输入数组已经是有序的时候,希尔排序的时间复杂度接近 O(n),因为每次插入元素时无需移动已排序部分的元素,此时步长缩减过程主要是进行少量的比较操作。
(2)最坏情况:希尔排序的最坏情况时间复杂度取决于所采用的步长序列。对于最简单的步长序列(如每次减半),最坏情况下的时间复杂度为 O(n²)。但有一些更好的步长序列(如 Sedgewick 提出的序列),可以将时间复杂度降低到 O(n^1.25) 或更优。
(3)平均情况:希尔排序的平均时间复杂度介于 O(n^1.25) 和 O(n²) 之间,具体取决于步长序列的选择。
空间复杂度
希尔排序是一种原地排序算法,它只需要一个额外的临时变量用于交换元素,不需要额外的空间。因此,其空间复杂度为 O(1)。
是否为稳定排序:否
希尔排序不是稳定排序算法。在希尔排序中,由于元素可能会在不同组之间进行移动,导致相同元素之间的相对顺序发生改变。
归并排序
介绍
归并排序(Merge Sort)是一种经典的分治算法,其基本思想是将数组分成两部分,分别对这两部分进行排序,然后再将排序后的两部分合并成一个有序的整体。
步骤(以升序排序为例)
1.分解:将数组不断分成两半,直到每个子数组只有一个元素(此时该子数组自然是有序的)。
2.合并:将两个相邻的有序子数组合并成一个有序的数组。
Part 1 递归实现:
void merge(vector<int>&arr,int left,int mid,int right){
int i=left,j=mid+1;
vector<int>temp;// 临时数组,用于存放合并后的结果
while(i<=mid&&j<=right){
if(arr[i]<=arr[j]){
temp.push_back(arr[i]);
i++;
}else{
temp.push_back(arr[j]);
j++
}
}
// 将剩余的元素添加到临时数组中
while(i<=mid){
temp.push_back(arr[i]);
i++
}
while(j<=right){
temp.push_back(arr[j]);
j--;
}
// 将临时数组中的元素复制回原数组
for(int k=left;k<=right;k++){
arr[k]=temp[k-left];
}
}
void mergeSort(vector<int> &arr,int left,int right){
if(left<right){
int mid=(left+right)/2+left;
mergeSort(arr,left,mid);// 对左半部分进行归并排序
mergeSort(arr,mid+1,right);// 对右半部分进行归并排序
merge(arr,left,mid,right);// 合并两个有序的子数组
}
}
复杂度分析
时间复杂度
(1)最好情况 :每次分解都将数组均匀分成两半,并且合并操作的时间复杂度为 O(n),所以最好情况下的时间复杂度为 O(n log n)。
(2)最坏情况 :无论数组的初始状态如何,归并排序都需要进行 log n 层的分解和合并操作,每层操作的时间复杂度为 O(n),所以最坏情况下的时间复杂度为 O(n log n)。
(3)对于一个随机排列的数组,归并排序的时间复杂度为 O(n log n)。
空间复杂度
归并排序需要额外的存储空间来保存临时数组,空间复杂度为 O(n)。
是否为稳定排序:是
归并排序是稳定排序算法。在合并两个有序子数组时,如果两个元素相等,会优先将前面的子数组中的元素添加到临时数组中,从而保证了相同元素的相对顺序不变。
Part 2 非递归实现:
//这部分内容不变
void merge(vector<int>&arr,int left,int mid,int right){
int i=left,j=mid+1;
vector<int>temp;// 临时数组,用于存放合并后的结果
while(i<=mid&&j<=right){
if(arr[i]<=arr[j]){
temp.push_back(arr[i]);
i++;
}else{
temp.push_back(arr[j]);
j++
}
}
// 将剩余的元素添加到临时数组中
while(i<=mid){
temp.push_back(arr[i]);
i++
}
while(j<=right){
temp.push_back(arr[j]);
j--;
}
// 将临时数组中的元素复制回原数组
for(int k=left;k<=right;k++){
arr[k]=temp[k-left];
}
}
void mergeSort(vector<int> &arr,int left,int right){
int n=arr.size();
vector<int>temp(n);
// 子数组长度从1开始,每次翻倍
for(int sublength=1;sublength<n;sublength<<1){
// 遍历数组,合并相邻的两个子数组
for(int left=0;left<n;left+=sublength*2){
int mid=left+sublength-1;
int right=min(left+sublength*2-1,n-1);
merge(arr,left,mid,right);
}
}
}
复杂度分析
时间复杂度
O(n log n):非递归归并排序的时间复杂度与递归版本相同,均为 O(n log n),其中 n 是数组的长度。这是因为算法将数组分成 log n 层,每层处理大约 n 个元素。
空间复杂度
O(n):由于在合并过程中使用了一个临时数组来存储合并后的结果,所以空间复杂度为 O(n),其中 n 是数组的长度。
快速排序
介绍
快速排序(Quick Sort)是一种高效的分治排序算法(复习:归并排序是不是分治排序??是的!),其基本思想是通过选择一个基准元素,将数组分为两部分:一部分包含小于或等于基准的元素,另一部分包含大于基准的元素,然后递归地对这两部分进行快速排序。
步骤(以升序排序为例)
1.选择基准元素 :从数组中选择一个元素作为基准(pivot)。常见的选择方法包括选择第一个元素、最后一个元素、中间元素或随机元素。
2.分区操作 :将数组中的元素重新排列,使得所有小于或等于基准的元素都移到基准的左边,所有大于基准的元素都移到基准的右边。基准元素最终会位于其正确的位置。(其他元素不一定在正确位置哦!每次都是为了把基准元素排对位置~)
3.递归排序 :对基准左边和右边的两个子数组分别递归地进行快速排序,直到子数组的大小为 0 或 1,此时整个数组有序。
实现:
int partition(vector<int> &arr,int low,int high){
int pivot=arr[high];//选择最后一个元素作为基准
int i=low-1;//i是小于等于基准部分的最后一个元素的索引
for(int j=low;j<high;j++){
if(arr[j]<=pivot){
i++;
swap(arr[i],arr[j]);//把比基准元素大的数放在基准元素左边
}
//比基准元素大的数就不变动位置
//这里不要想太多:我们只是需要把比基准元素小的数字放左边,大的数放右边,他们的位置对不对不重要,重要的是基准元素要放对位置!
}
//经过上面的for循环,我们已经把所有小于基准元素的数放在基准元素左边了,i+1就是正确位置
swap(arr[i+1],arr[high]);// 将基准元素放到正确的位置
return i+1;//返回基准元素的位置
}
void quickSort(vector<int> &arr,int low,int high){
if(low<high){
int pivotIndex=partiton(arr,low,high);//分区操作
quickSort(arr,low,pivotIndex-1);//对左半部分进行快速排序
quickSort(arr,pivotIndex+1,high);// 对右半部分进行快速排序
}
}
int main()
{
quickSort(arr,0,arr.size()-1);
}
复杂度分析
时间复杂度
(1)最好情况 :当每次分区操作都能将数组均匀分成两部分,此时递归树的深度为 log n,每层的时间复杂度为 O(n),所以最好情况下的时间复杂度为 O(n log n)。
(2)最坏情况 :当数组已经有序或逆序时,每次分区操作都只会将数组分成一个大小为 n - 1 和一个大小为 0 的子数组,此时递归树的深度为 n,时间复杂度为 O(n²)。
(3)平均情况:对于随机排列的数组,快速排序的平均时间复杂度为 O(n log n)。快速排序在实际应用中通常被认为是最快的通用排序算法之一。
空间复杂度
快速排序的空间复杂度主要取决于递归调用所需的栈空间。在最好情况下,递归树的深度为 log n,因此空间复杂度为 O(log n)。在最坏情况下,空间复杂度为 O(n)。
是否为稳定排序:否
快速排序不是稳定排序算法。在分区过程中,相同元素的相对顺序可能会因为交换操作而发生改变。
堆排序
介绍
堆排序(Heap Sort)是一种基于堆数据结构的排序算法。它的工作原理是将数组构造成一个堆,然后不断取出堆顶元素并重新调整堆,从而实现排序。堆是一种特殊的完全二叉树,其中父节点的值总是**大于或等于(大顶堆)或小于或等于(小顶堆)**其子节点的值。堆排序利用了堆的性质来高效地找到数组中的最大或最小元素。
步骤(以升序排序为例)
- 构建大顶堆:将数组构造成一个大顶堆,此时堆顶元素是数组中的最大值。
- 交换堆顶与末尾元素:将堆顶元素(最大值)与数组末尾元素交换,此时最大值位于数组的正确位置。
- 重新调整堆:排除已排序的末尾元素,对剩余元素重新调整堆结构,使其再次成为大顶堆。
重复步骤 2 和 3,直到所有元素都排序完成。
堆算法详细图解见大大link
实现:
void heapify(vector<int>&arr,int n,int i){
//递归版本
int largest=i;//初始化最大值为当前节点
int left=2*i+1;//左子节点
int right=2*i+2;//右子节点
if(left<n&&arr[left]>arr[largest]){
largest=left;
}
if(right<n&&arr[right]>arr[largest]){
largest=right;
}
if(largest!=i){
swap(arr[i],arr[largest]);
heapify(arr,n,largest);
}
//非递归版本
int temp=arr[i];//当前元素i看出成最大值
for(int j=2*i+1;j<n;j=2*j+1){
if(j+1<n&&arr[j]<arr[j+1]){
如果左子结点小于右子结点,j指向右子结点
j++;
}
if(arr[j]>temp){
arr[i]=arr[j];
i=j;
}else{
break;
}
}
arr[i]=temp;
}
void heapSort(vector<int>& arr){
int n=arr.size();
//构造大顶堆
//n/2-1代表最后一个非叶子节点
//从最后一个非叶子结点开始调整
for(int i=n/2-1;i>=0;i--){
heapify(arr,n,i);
}
//逐个取出堆顶元素并调整堆
for(int i=n-1;i>=0;i--){
swap(arr[i],arr[0]);// 将堆顶元素(最大值)移到数组末尾
heapify(arr,i,0);//调整剩余元素为大顶堆
}
}
复杂度分析
时间复杂度
构建堆的时间复杂度为 O(n),每次调整堆的时间复杂度为 O(log n),需要进行 n 次调整,因此总时间复杂度为 O(n log n)。堆排序的时间复杂度在最好、最坏和平均情况下均为 O(n log n)。
空间复杂度
堆排序的空间复杂度为 O(1),因为它只使用了少量的额外空间
是否为稳定排序:否
堆排序不是稳定排序算法。在堆排序过程中,相同元素的相对顺序可能会因为交换操作而发生改变。
计数排序
介绍
计数排序(Counting Sort)是一种非比较排序算法,它的工作原理是统计数组中每个元素出现的次数,然后根据统计结果确定每个元素的正确位置。计数排序适用于整数排序,尤其是当输入数据范围较小时。
步骤(以升序排序为例)
1.找到数组中的最大值和最小值,确定数据范围。
2. 创建一个计数数组,用于统计每个元素出现的次数。
3. 计算每个元素的累计次数,确定元素的正确位置。
4. 根据累计次数将元素放入输出数组。。
实现:
void countingSort(vector<int>& arr){
int min_val=max_val=arr[0];
for (int i = 1; i < array.length; i++) {
if (array[i] > max_val) {
max_val = array[i];
}
if(array[i] < min_val) {
min_val = array[i];
}
}
int range=max_val-min_val+1;//数据范围
vector<int>count(range,0);//计数数组
vector<int>output(arr.size());//输出数组
//统计每个元素出现的次数
for(int num:arr){
count[num-min_val]++;
}
//计算累计次数
for(int i=1;i<range;i++){
count[i]+=count[i-1];
}
//根据累计次数将元素放入输出数组
for(int i=arr.size()-1;i>=0;i--){
output[count[arr[i]]-min_val-1]=arr[i];
count[arr[i]-min_val]--;//
}
return output;
}
复杂度分析
时间复杂度
计数排序的时间复杂度为 O(n + k),其中 n 是数组长度,k 是数据范围(最大值减最小值加 1)。它适用于数据范围较小的情况。
空间复杂度
计数排序的空间复杂度为 O(n + k),需要额外的计数数组和输出数组。
是否为稳定排序:是
计数排序是稳定排序算法。在计数排序过程中,相同元素的相对顺序得以保持。
桶排序
介绍
桶排序(Bucket Sort)是一种分布排序算法,其基本思想是将数组元素分散到有限数量的桶中,每个桶再单独排序,最后将各个桶中的元素按顺序合并。桶排序适用于均匀分布在某一范围内的实数。
步骤(以升序排序为例)
- 确定数据范围,将数据划分到若干个桶中。
- 将数组中的元素放入对应的桶。
- 分别对每个桶内的元素进行排序(可使用插入排序、快排等)。
- 按顺序将每个桶中的元素合并,构成最终的排序结果。
实现:
void bucketSort(vector<float>& arr){
int n=arr.size();
vector<vector<float>>buckets(n);
int max_val=min_val=arr[0];
for (int i = 1; i < n; i++) {
if (array[i] > max_val) {
max_val = array[i];
}
if(array[i] < min_val) {
min_val = array[i];
}
}
int range = max_val - min_val + 1;
int bucket_size = max(1, range/n + 1); // 每个桶的大小
// 初始化桶
vector<vector<int>> buckets(bucket_count);
// 将元素分配到桶中
for (int num:arr) {
int idx = (num-min_val) / bucket_size;
buckets[idx].push_back(num);
}
// 对每个桶排序并合并结果
arr.clear();
for (auto& bucket : buckets) {
sort(bucket.begin(), bucket.end());
for (int num:bucket) {
arr.push_back(num);
}
}
}
复杂度分析
时间复杂度
平均情况:O(n + k),k为桶数。
最坏情况(所有数据集中一个桶):O(n²)。
空间复杂度
O(n + k),需要额外空间存储桶。
是否为稳定排序:取决于桶内排序方式
若桶内使用稳定排序算法(如插入排序),则整体为稳定排序。
基数排序
介绍
基数排序(Radix Sort)是一种非比较型整数排序算法,按照数字的每一位(从最低位到最高位或反过来)依次排序。通常用于整数或字符串等可分解为位的离散值类型。适合排序长度一致的整数或字符串。
步骤(以升序排序为例,最低位优先)
- 找出最大元素,确定最大位数。
- 从最低位到最高位,对每一位执行一次稳定排序(如计数排序)。
- 多轮排序完成后,整体有序。
实现:
//找最大值
int getMax(const vector<int>& arr){
int mx=arr[0];
for(int i=1;i<arr.size();i++){
if(arr[i]>mx){
mx=arr[i];
}
}
return mx;
}
void countingSortByDigit(vector<int>& arr, int exp) {
int n=arr.size();
vector<int>output(n);
vector<int>count(10,0);
//统计当前位数的频率
for(int i=0;i<n;i++){
count[(arr[i]/exp)%10]++;
}
//计算累计频率
for(int i=1;i<10;i++){
count[i]+=count[i-1];
}
//构建输出数组(从后往前保证稳定性)
for(int i=n-1;i>=0;i--){
int digit=(arr[i]/exp)%10;
output[count[digit]-1]=arr[i];
count[digit]--;
}
//拷贝回原数组
for(int i=0;i<n;i++){
arr[i]=output[i];
}
}
void radixSort(vector<int>& arr){
int max_val=getMax(arr);
for(int exp=1;max_val/exp>0;exp*=10){
countingSortByDigit(arr,exp);
}
}
复杂度分析
时间复杂度
O(d × (n + k)),其中 d 是最大数的位数,n 是元素数量,k 是每位上的可能数值(通常为10)。
空间复杂度
O(n + k),使用额外计数数组和输出数组。
是否为稳定排序:是
基数排序在每个位上使用稳定排序,因此整体是稳定排序。