常见排序算法

文章目录

一、前言

1.1 什么是算法

算法是一个定义明确的计算过程,它接受一些值或值的集合作为输入,并产生一些值或值的集合作为输出。算法是为解决特定问题而制定的一系列执行步骤。

1.2 特性

确定性:算法的操作应该清晰明确,无歧义。

有限性:在有限的步骤后,算法必须终止。

有效性:算法中的每一步都是可行的,也就是说,每一步都可以在有限时间内完成。

输入和输出:每个算法都有指定的输入和输出。

二、排序的概念及其应用

1.1 排序概念

按照某种特定顺序(例如从小到大、从大到小或某种特定的字典序)重新排列数据

1.1.1 稳定性

如果两个元素的关键字相等,那么它们在排序后的序列中的相对顺序应与排序前的序列中的相对顺序相同

换句话说,稳定性确保了原始数据中相等关键字的元素的相对顺序不会因排序而改变。

考虑一个场景,我们有一组记录,每个记录包含一个名字和一个年龄。我们首先按照名字进行排序,然后我们再按照年龄进行排序。如果排序算法是稳定的,那么具有相同年龄的两个人的顺序将仍然是按他们的名字排序的。

1.2 常见的排序算法

冒泡排序 (Bubble Sort)

选择排序 (Selection Sort)

插入排序 (Insertion Sort)

快速排序 (Quick Sort)

归并排序 (Merge Sort)

希尔排序 (Shell Sort)

堆排序 (Heap Sort)

计数排序 (Counting Sort)

桶排序 (Bucket Sort)(后续哈希章节统一介绍)

1.3 应用

排序算法在计算机科学和日常生活中都有广泛的应用。

  1. 数据库查询优化:数据库使用排序算法对数据进行排序,以加快查询、删除和插入操作的速度。

  2. 搜索引擎:搜索引擎在处理和排列搜索结果时,会使用排序算法根据不同的标准(例如相关性、点击率)对网页进行排序。

  3. 电子商务网站:在网购时,用户可以选择按照价格、评分或其他标准对商品进行排序。

  4. 图形学:在计算机图形学中,要渲染透明物体时,可能需要根据深度对它们进行排序。

  5. 调度问题:在操作系统或生产线调度中,任务可能根据优先级或其他标准进行排序。

  6. 数据分析:在数据分析中,对数据集进行排序是一种常见的操作,可以帮助分析者更好地理解数据的分布和模式。

  7. 文件组织和管理:在文件系统中,文件和目录经常根据名称、大小、修改日期等进行排序。

  8. 数据压缩:某些数据压缩算法,如Burrows-Wheeler Transform(BWT),需要对数据块进行排序。

  9. 网络数据包排序:在计算机网络中,数据包可能会乱序到达。接收端需要对它们进行排序以确保数据的正确传输。

  10. 教育与测试:在学术界,教师可能需要对学生的成绩进行排序来进行评级。

以上只是一些常见的排序应用实例。实际上,排序在许多领域中都有应用,尤其是那些需要组织或处理数据的领域。

三、常见的排序算法实现

3.1 冒泡排序 (Bubble Sort)

3.1.1 原理

冒泡排序是一种简单的排序算法,它重复地遍历待排序的序列,比较每对相邻的项,若它们的顺序错误就把它们交换过来。过程重复直到没有再需要交换,即该序列已经排序完成。

3.1.2 实现

//升序
void BubbleSort(int arr[], int n) {
    for (int i = 0; i < n-1; i++) {          // 进行 n-1 趟遍历
        bool swapped = false;               // 用于优化,如果数组已经排序,则没有交换发生
        for (int j = 0; j < n-i-1; j++) {   // 最大的元素将在每趟的末尾确定其位置
            if (arr[j] > arr[j+1]) {        // 若前一个元素大于后一个元素
                swap(arr[j], arr[j+1]);     // 交换它们
                swapped = true;             
            }
        }
        if (!swapped) break;                // 若在某趟没有发生交换,则序列已排序完成
    }
}

3.1.3 复杂度分析

(1)时间复杂度
  • 最好情况:若输入数组已经完全排序,则只需进行一趟遍历,没有交换操作。此时时间复杂度为 O(n)。
  • 最坏情况:当输入数组完全逆序时,需要进行 (n(n-1)/2) 次比较和交换操作。因此,最坏情况下的时间复杂度为 O(n2)。
  • 平均情况:对于平均情况,时间复杂度也是 O(n2)。
(2)空间复杂度

原地排序算法,除了输入数组外,只需要一个小的额外空间来交换元素。因此,空间复杂度为 O(1)。

3.1.4 稳定性分析

冒泡排序是稳定的排序算法。在冒泡排序中,当两个元素相等时,我们不会交换它们,这确保了其稳定性。

3.2 选择排序 (Selection Sort)

3.2.1 原理

选择排序的基本思想是遍历数组多次,每次找到当前未排序部分的最小(或最大)元素,然后将其放到正确的位置。

  1. 在第一趟遍历中,找到整个数组的最小值,并将其与第一个元素交换。
  2. 在第二趟遍历中,找到从第二个元素开始的子数组的最小值,并将其与第二个元素交换。
  3. 重复上述过程,直到只剩下一个元素未被检查。

3.2.2 实现

void SelectSort(int arr[], int n) {
    for (int i = 0; i < n - 1; i++) {
        int minIdx = i;  // 假设当前位置是最小值
        for (int j = i + 1; j < n; j++) {
            if (arr[j] < arr[minIdx]) {
                minIdx = j;  // 更新最小值的位置
            }
        }
        if (minIdx != i) {
            swap(arr[i], arr[minIdx]);  // 将找到的最小值和当前位置的值进行交换
        }
    }
}

3.2.3 复杂度分析

(1)时间复杂度

最好情况最坏情况平均情况下的时间复杂度都为 O(n2)。

(2)空间复杂度

原地排序,选择排序的空间复杂度为 O(1)。

3.2.4 稳定性分析

选择排序是不稳定的排序算法。考虑一个简单的例子:序列 [4, 8, 3, 4,2],第一次选择最小元素 2 与第一个 4 交换位置,导致两个 4 的相对次序发生了变化。

3.3 插入排序 (Insertion Sort)

3.3.1 原理

插入排序的基本思想是将一个记录插入到已经排好序的有序表中,从而得到一个新的、记录数增1的有序表。即:开始时,第一个元素自然是有序的;然后,从第二个元素开始,依次将当前元素插入到前面已经排好序的子数组中,直到整个数组都排好序。

3.3.2 实现

void InsertSort(int arr[], int n) {
    for (int i = 1; i < n; i++) {
        int pivot = arr[i];
        int j = i - 1;
        // 将大于pivot的元素往后移动
        while (j >= 0 && arr[j] > pivot) {
            arr[j + 1] = arr[j];
            j--;
        }
        arr[j + 1] = pivot;  // 插入正确位置
    }
}

3.3.3 复杂度分析

(1)时间复杂度
  • 最好情况:输入数组已经是有序的,时间复杂度为 (O(n))。
  • 最坏情况:输入数组是逆序的,时间复杂度为 (O(n^2))。
  • 平均情况下的时间复杂度为 (O(n^2))。
(2)空间复杂度

插入排序是原地排序算法,其空间复杂度为 (O(1))。

3.3.4 稳定性分析

插入排序是稳定的排序算法。这是因为在排序过程中,相同的两个元素的相对位置不会发生改变。

3.4 快速排序 (Quick Sort)

3.4.1 原理

快速排序的基本思想是通过一个基准元素将数组分为两个子数组,左边的子数组元素都比基准小,右边的子数组元素都比基准大。这种操作被称为分区操作,然后递归地对两个子数组进行快速排序。

3.4.2 实现

根据分区操作的不同,有多种实现方法

(1)hoare
int Partition(int a[], int left, int right)
{
	int pivot = left;//基准元素
	while (left < right)
	{
		// 小于基准的元素
		while (left < right && a[right] >= a[pivot])
			--right;

		// 大于基准的元素
		while (left < right && a[left] <= a[pivot])
			++left;

		swap(a[left], a[right]);
	}
	swap(a[pivot], a[left]);
	return left;
}
void QuickSort(int arr[], int low, int right) {
    if (low < right) {
        int pivot = Partition(arr, low, right);
        QuickSort(arr, low, pivot - 1);
        QuickSort(arr, pivot + 1, right);
    }
}
(2)挖坑法
int Partition(int arr[], int left, int right) {
    int pivot = arr[left]; // 基准数据

    while (left < right) {
        // 从右往左找,找到比pivot小的数据填左边的坑
        while (left < right && arr[right] >= pivot)
            --right;
        arr[left] = arr[right]; // 填坑

        // 从左往右找,找到比pivot大的数据填右边的坑
        while (left < right && arr[left] <= pivot)
            ++left;
        arr[right] = arr[left]; // 填坑
    }

    arr[left] = pivot; // 基准数据放入最终位置
    return left; // 返回基准数据的位置
}
void QuickSort(int arr[], int left, int right) {
    if (left < right) {
        int pivot = Partition(arr, left, right);
        QuickSort(arr, left, pivot - 1);
        QuickSort(arr, pivot + 1, right);
    }
}
(3)Lomuto
int Partition(int arr[], int left, int right) {
    int pivot = arr[left]; // 选择最左侧元素作为基准
    int i = left ; 

    for (int j = left+1; j <= right; j++) {
        // 如果当前元素小于或等于基准
        if (arr[j] <= pivot) {
            i++; // 扩大小于基准的区域
            swap(arr[i], arr[j]); // 将当前元素放入小于基准的区域
        }
    }
    swap(arr[i], arr[left]); // 将基准放入其正确的位置
    return i; // 返回基准的位置
}
void QuickSort(int arr[], int left, int right) {
    if (left < right) {
        int pivot = Partition(arr, left, right);
        QuickSort(arr, left, pivot - 1);
        QuickSort(arr, pivot + 1, right);
    }
}
(4)快速排序的优化
1)三数取中法选pivot

基准元素的选择对快速排序的效率影响很大。如果始终选择最左边或最右边的元素作为基准,那么在对一个已经部分或完全有序的数组进行排序时,快速排序的效率会非常低。

从数组的左端、中部和右端分别取一个元素,然后选择这三个元素的中值作为基准。这种方法在某种程度上能够避免因基准选择不当导致的低效率问题。

int MedianOfThree(int arr[], int left, int right) {
    int mid = left + (right - left) / 2;
    //确保arr[left] <= arr[mid] <= arr[right]
    if (arr[left] > arr[right]) 
        swap(arr[left], arr[right]);
    if (arr[mid] > arr[right])
        swap(arr[mid], arr[right]);
    if (arr[left] > arr[mid])
        swap(arr[left], arr[mid]);
    return arr[mid];
}
2)递归到小的子区间时,可以考虑使用插入排序
  • 当待排序的子区间的大小减少到一定程度时(例如10个或以下的元素),快速排序不再是最有效的方法,因为递归的开销和基准的选择会使得效率降低。
  • 在这种情况下,我们可以考虑使用插入排序来对这些小的子区间进行排序,因为插入排序在小数组上非常高效。
(5)快速排序的非递归
//Lomuto
int partition(int arr[], int left, int right) {
    int pivot = arr[left];
    int i = left;
    for (int j = left + 1; j <= right; j++) {
        if (arr[j] < pivot) {
            i++;
            swap(arr[i], arr[j]);
        }
    }
    swap(arr[i], arr[left]);
    return i;
}

void quickSortIterative(int arr[], int left, int right) {
    if (left >= right) return;
    
    stack<pair<int, int>> s;
    s.push({left, right});
    
    while (!s.empty()) 
    {
        left = s.top().first;
        right = s.top().second;
        s.pop();
        
        int pivot = partition(arr, left, right);
        
        if (pivot - 1 > left) {
            s.push({left, pivot - 1});
        }
        
        if (pivot + 1 < right) {
            s.push({pivot + 1, right});
        }
    }
}

3.4.3 复杂度分析

(1)时间复杂度
  • 最好情况:如果每次都能均匀地划分数组,那么快速排序的递归深度为log(n),时间复杂度为O(nlogn)。
  • 最坏情况:如果每次都非常不均匀地划分数组,例如每次只能排定一个元素,那么快速排序的递归深度为n,时间复杂度为O(n^2)。这种情况一般出现在数组已经完全排序或完全逆序的情况下,但可以通过随机选取或“三数取中”等策略避免。
  • 平均情况:对于随机的输入数据,快速排序的平均时间复杂度为O(nlogn)。
(2)空间复杂度

因为快速排序是原地排序算法,不需要额外的存储空间,所以空间复杂度为O(1)。

但是因为快速排序是递归的,所以它需要额外的栈空间。在最好的情况下,栈深度为log(n),而在最坏的情况下,栈深度为n。

3.4.4 稳定性分析

快速排序不是稳定的排序算法。在排序过程中,相等的元素可能会交换它们的位置,导致它们的原始顺序发生变化。例如,在使用“Lomuto”分区策略时,当存在与基准相等的元素时,它们的相对顺序可能会发生变化。

3.5 归并排序 (Merge Sort)

3.5.1 原理

归并排序是采用分治策略的一种排序算法。它将一个数组分成两个等长(或几乎等长)的子数组,递归地将每个子数组排序,然后将两个已排序的子数组合并为一个整体来排序原数组。

3.5.2 实现

//两个已排序子数组的合并
void merge(int arr[], int left, int mid, int right) {
    int n1 = mid - left ;
    int n2 = right - mid-1;
    int L[n1], R[n2]; // 创建临时数组

    for (int i = 0; i < n1; i++)
        L[i] = arr[left + i];
    for (int j = 0; j < n2; j++)
        R[j] = arr[mid + 1 + j];

    int i = 0, j = 0, k = left;
    while (i < n1 && j < n2) {
        if (L[i] <= R[j]) {
            arr[k] = L[i];
            i++;
        } else {
            arr[k] = R[j];
            j++;
        }
        k++;
    }

    while (i < n1) {
        arr[k] = L[i];
        i++;
        k++;
    }

    while (j < n2) {
        arr[k] = R[j];
        j++;
        k++;
    }
}

void mergeSort(int arr[], int left, int right) {
    if (left < right) 
    {
        int mid = left + (right - left) / 2; // 计算中点
        mergeSort(arr, left, mid);
        mergeSort(arr, mid + 1, right);
        merge(arr, left, mid, right);    // 合并
    }
}

3.5.3 复杂度分析

(1)时间复杂度
  • 归并排序的核心是分而治之的策略。对于一个长度为 (n) 的数组,每次分裂都将数组长度减半,直到每个子数组的长度为 1。因此,可以进行 (log n) 次分裂操作。而在每一层的合并操作中,所有的元素都被处理一次,因此每一层的合并时间复杂度为 (O(n))。
  • 结合以上两个阶段,归并排序的时间复杂度为 (O(n log n)) 。
(2)空间复杂度

在合并过程中,归并排序需要额外的空间来存储两个子数组的临时副本。因此,归并排序的空间复杂度为 (O(n))。

3.5.4 稳定性分析

归并排序是稳定的排序算法。在合并两个已排序的子数组时,如果存在相等的元素,我们总是先从左侧的子数组中取元素,这确保了相等元素的原始相对顺序不会发生改变。

3.6 希尔排序 (Shell Sort)

3.6.1 原理

希尔排序是基于插入排序的一个变体,又被称为"缩小增量排序"。它的基本思想是将待排序的元素按照某个增量分为多个子序列,然后对子序列进行插入排序。随着增量逐渐减少,每个子序列都越来越有序。当增量减少到1时,整个序列基本上已经有序,此时再进行一次插入排序,即可得到完全有序的数组。

3.6.2 实现

void shellSort(int arr[], int n) 
{
    for (int gap = n / 2; gap > 0; gap /= 2) 
    {
        for (int i = gap; i < n; i++) 
        {
            int temp = arr[i];
            int j=i-gap
            for (j >= 0 && arr[j ] > temp; j -= gap) 
            {
                arr[j+gap] = arr[j];
            }
            arr[j+gap] = temp;
        }
    }
}

3.6.3 复杂度分析

(1)时间复杂度

与增量的选取有关,但当数据规模很大时,希尔排序的最坏情况时间复杂度一般被认为是 (O(n^{1.3})),但确切的时间复杂度依赖于增量序列的选择。

(2)空间复杂度

希尔排序是原地排序算法,其空间复杂度为 (O(1))。

3.6.4 稳定性分析

希尔排序是不稳定的排序算法。由于多次插入排序是跳跃式的,相同的元素可能会被交换位置。

3.7 堆排序 (Heap Sort)

3.7.1 原理

堆排序是基于二叉堆数据结构的一种排序方法。它首先将数组转换为一个最大堆(或最小堆),然后交换堆顶元素(最大或最小)与数组最后一个元素,然后对剩余的元素重新建堆。此过程重复,直到整个数组都被排序。

3.7.2 实现

void Heapify(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);
    }
}

void HeapSort(int arr[], int n) {
    // 构建最大堆
    for (int i = n / 2 - 1; i >= 0; i--)
        Heapify(arr, n, i);

    // 一个个提取元素从堆中
    for (int i = n - 1; i >= 0; i--) {
        swap(arr[0], arr[i]);
        Heapify(arr, i, 0);
    }
}

3.7.3 复杂度分析

(1)时间复杂度

(O(n logn))。

(2)空间复杂度

堆排序是原地排序算法,其空间复杂度为 (O(1))。

3.7.4 稳定性分析

堆排序通常被认为是不稳定的排序算法,因为元素之间的相对次序可能会在排序过程中发生改变。

3.8 计数排序

3.8.1 原理

计数排序是一个非基于比较的排序算法,适用于对一定范围内的整数进行排序。它的工作原理是通过统计每个数值在输入中出现的次数,然后利用这些统计数据构建输出的排序数组。

步骤:

  1. 找到输入数组中的最大和最小值。
  2. 根据最大和最小值的范围,创建一个计数数组,初始化为零。
  3. 遍历输入数组,对于每个元素,增加其对应的计数数组的计数。
  4. 通过累积计数数组中的值,确定每个值在输出数组中的位置。
  5. 使用计数数组和输入数组,构建输出的排序数组。

3.8.2 实现

void countingSort(int arr[], int n) {
    int maxVal = *max_element(arr, arr + n);
    int minVal = *min_element(arr, arr + n);
    
    int range = maxVal - minVal + 1;
    vector<int> count(range, 0), output(n);

    // 统计元素出现的次数
    for (int i = 0; i < n; i++)
        count[arr[i] - minVal]++;
    
    // 累积计数
    for (int i = 1; i < range; i++)
        count[i] += count[i - 1];
    
    // 构建输出数组
    for (int i = n - 1; i >= 0; i--) {
        output[count[arr[i] - minVal] - 1] = arr[i];
        count[arr[i] - minVal]--;
    }
    
    // 复制排序后的数组到原数组
    for (int i = 0; i < n; i++)
        arr[i] = output[i];
}

3.8.3 复杂度分析

(1)时间复杂度

(O(n + k)),其中 n 是数组的长度,k 是数组中的数值范围。

(2)空间复杂度

计数排序需要额外的空间来存储计数数组和输出数组,因此其空间复杂度为 (O(n + k))。

3.8.4 稳定性分析

如果实现得当,计数排序是稳定的

四、常见排序算法综合比较

在这里插入图片描述

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值