算法学习系列之排序算法:原理、应用场景与C++实现精解

文章目录

  • 前言
  • 原理和应用场景
  • 快速排序的实现
    • 一般的递归快速排序
    • 三点取中法
    • 单边递归快速排序
    • 无监督快速排序
  • 混合排序的实现
    • C++ 标准库sort算法
    • 我设计的混合排序算法
  • 堆排序的实现
    • 我的实现
    • 另一种实现
    • 解释
    • 特性
  • 其它c++案例实现
  • 总结


前言

在计算机科学领域,排序算法是最基础也是最关键的部分之一。它们不仅在理论上具有重要意义,也在实际应用中发挥着至关重要的作用。从经典的冒泡排序到更高效的快速排序,每种算法都有其独特之处和适用场景。本博客旨在深入探讨各种排序算法的内在原理、适用场景,并提供它们的C++实现。

原理和应用场景

计算机算法中,排序是一个基础且重要的领域。以下是一些关键的排序方法,每种方法都有其独特的特点和适用场景:

  1. 冒泡排序(Bubble Sort):通过重复交换相邻逆序的元素,使较大的元素“冒泡”到数组的一端。尽管简单,但效率较低,通常不用于大规模数据集。

    • 原理:通过比较相邻元素,如果它们的顺序错误就交换它们。操作像冒泡一样,重复遍历列表,直到没有更多交换。
    • 适用场景:由于其较低的效率,一般用于教学或处理小规模数据集。
  2. 选择排序(Selection Sort):重复地选择剩余元素中的最小者,放到已排序序列的末尾。这种方法也是效率较低的,适合小规模数据。

    • 原理:首先在未排序的序列中找到最小(或最大)元素,存放到排序序列的起始位置,然后再从剩余未排序元素中继续寻找最小(或最大)元素,放到已排序序列的末尾。
    • 适用场景:简单且容易实现,但效率低,适合小数据量排序。
  3. 插入排序(Insertion Sort):构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。适用于部分已排序的数据集。

    • 原理:构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。
    • 适用场景:适用于部分已排序的数据集和小规模数据集。
  4. 快速排序(Quick Sort):采用分治法策略,通过选取一个“基准”元素,将数组分为两部分,一部分小于基准,一部分大于基准,然后递归排序这两部分。适用于大规模数据集,是一种高效的排序方法。

    • 原理:选取一个元素作为“基准”,重新排列数组,所有比基准小的元素放在基准前面,所有比基准大的放后面。然后递归地在基准前后的子数组上重复这个过程。
    • 适用场景:适用于大规模数据集,是一种高效的排序方法,但对于小数据集或者数据已部分排序的情况效率不是最优。
  5. 归并排序(Merge Sort):也是一种分治法策略的应用,将数组分成若干个小块,先递归地对每个小块排序,然后将这些小块合并成一个大的有序数组。它是稳定的排序方法,适用于大规模数据集。

    • 原理:将数组分成两半,分别对它们排序,然后将结果合并。递归地将数组分成越来越小的半子表,再对子表合并成整个排序好的列表。
    • 适用场景:适合大规模数据集,对计算机内存使用较多。
  6. 堆排序(Heap Sort):利用堆这种数据结构所设计的一种排序算法。它将数组转换成一个最大堆,然后将堆顶的最大数取出,将剩余的堆继续调整为最大堆。适用于大规模数据集,效率较高。

    • 原理:将待排序的序列构建成一个大顶堆,此时,整个序列的最大值就是堆顶的根节点。将其与末尾元素进行交换,然后将剩余n-1个元素重新构造成一个堆,这样会得到n个元素的次小值。如此反复执行,便能得到一个有序序列。
    • 适用场景:适合大规模数据集,效率较高,但在最坏情况下性能不如快速排序和归并排序。
  7. 希尔排序(Shell Sort):是插入排序的一种更高效的改进版本。它通过比较距离一定间隔的元素来工作,间隔逐渐减少直到为1,使数组变为基本有序。

    • 原理:基于插入排序,将原始列表分成多个子序列,分别进行插入排序,随着序列越来越小,最后整体进行一次插入排序。
    • 适用场景:中等大小的数据集,比直接插入排序效率高,但比快速排序和归并排序慢。
  8. 计数排序(Counting Sort):适用于一定范围内的整数排序。通过计算每个元素的出现次数来确定每个元素的位置。

    • 原理:统计每个元素的出现次数,并用它来确定每个元素的位置。
    • 适用场景:适用于小范围整数的排序,如年龄排序、分数排序等。
  9. 基数排序(Radix Sort):按照低位先排序,然后收集;再按照高位排序,然后再收集,以此类推,直到最高位。适用于整数或某些类型的字符串排序。

    • 原理:按照低位先排序,然后收集;再按照高位排序,然后再收集,以此类推,直到最高位。
    • 适用场景:适用于需要按多个字段排序的数据,如电话号码、长整数列表等。
  10. 桶排序(Bucket Sort):将元素分布到有限数量的桶里,每个桶再分别排序(有可能再使用别的排序算法或是以递归方式继续使用桶排序进行排)。

    • 原理:将数组分到有限数量的桶里,每个桶再分别排序,最后合并。
    • 适用场景:适用于数据分布均匀的大范围数据集。

不同的排序算法有其特定的应用场景和优势。选择合适的排序方法取决于数据的特性(如数据大小、是否已部分排序等)和应用的需求(如对稳定性、时间复杂度、空间复杂度的考虑)。在实际应用中,选择最合适的排序算法对于优化性能至关重要。

快速排序的实现

从STL的代码中,我们可以学习快速排序的优化方法,主要有以下几点:

  • 单边递归法
  • 无监督Partition
  • 三点取中法
  • 适时放弃快排
  • 使用插入排序收尾
    这一部分我们重点介绍前三个,后两个在混合排序的实现部分说明。

一般的递归快速排序

// 递归快速排序
template <typename T>
void quickSort(vector<T>& arr, int start, int end) {
    if (start >= end) return ;
    int l = start, r = end;
    T pivot = arr[(start + end) / 2];
    while (l <= r) {
        while (l <= r && arr[l] < pivot) l++;
        while (l <= r && arr[r] > pivot) r--;
        if (l <= r) swap(arr[l++], arr[r--]);
    }
    quickSort(arr, start, r);
    quickSort(arr, l, end);
}

三点取中法

在快速排序中,“三点取中法”(Median-of-Three)是一种选择枢轴(pivot)的策略,旨在提高快速排序算法的效率,特别是在处理具有特定模式或几乎已排序的数组时。此方法通过考虑数组的首部、中部和尾部三个元素,并从这三个元素中选择中间值作为枢轴,从而提高枢轴选择的效果。
实现步骤
1. 选取三个点:从数组中选取三个点:起始位置(start),结束位置(end)和中间位置((start + end) / 2)。
1. 比较并排序这三个点:比较这三个位置的元素,并通过交换使得 arr[start] <= arr[mid] <= arr[end]。
1. 选择中间元素作为枢轴:选择中间位置的元素作为枢轴,并且可能将其与数组的某个位置(如 end-1)交换,从而在分区函数中使用。
1. 进行快速排序:以此枢轴进行常规的快速排序分区操作。

template <typename T>
int medianOfThree(std::vector<T>& arr, int start, int end) {
    int mid = start + (end - start) / 2;
    
    // 排序 start, mid, end
    if (arr[start] > arr[mid])
        std::swap(arr[start], arr[mid]);
    if (arr[start] > arr[end])
        std::swap(arr[start], arr[end]);
    if (arr[mid] > arr[end])
        std::swap(arr[mid], arr[end]);

    // 将中间值放在 end - 1
    std::swap(arr[mid], arr[end - 1]);
    return arr[end - 1];
}
#if 1
template <typename T>
int partition(std::vector<T>& arr, int start, int end) {
    T pivot = medianOfThree(arr, start, end);

    int i = start, j = end - 1;
    while (true) {
        while (arr[++i] < pivot) {}
        while (arr[--j] > pivot) {}
        if (i < j)
            std::swap(arr[i], arr[j]);
        else
            break;
    }
    std::swap(arr[i], arr[end - 1]); // 将枢轴放到正确的位置
    return i;
}
#else
template <typename T>
int partition(vector<T>& arr, int low, int high) {
    int pivotValue = arr[high];
    swap(arr, high, low);
    int i = low - 1;
    for (int j = low; j < high; j++) {
        if (arr[j] < pivotValue) {
            i++;
            swap(arr, i, j);
        }
    }
    swap(arr, i + 1, high);
    return i + 1;
}
#endif
template <typename T>
void quickSort(std::vector<T>& arr, int start, int end) {
    if (start < end) {
        int pivotIndex = partition(arr, start, end);
        quickSort(arr, start, pivotIndex - 1);
        quickSort(arr, pivotIndex + 1, end);
    }
}

备注:

  1. 这里的partition代码和快速排序的代码与我认为的标准快速排序不大一样,但我感觉有一番新意,放在这里用作参考。
  2. 三点取中法一般用于解决极端特殊情况,在一般情况下,取中的过程计算量还是有的,会减慢排序的速度。

单边递归快速排序

在单边递归版本中,我们只递归地对数组的一部分进行排序,而另一部分通过迭代完成。这样可以减少递归调用的深度,从而节省栈空间。

// 单边递归快速排序
template <typename T>
void quickSortSingleRecursion(vector<T>& arr, int start, int end) {
    while (start < end) {
        int l = start, r = end, pivot = arr[(start + end) / 2];
        while (l <= r) {
            while (l <= r && arr[l] < pivot) l++;
            while (l <=r && arr[r] > pivot) r--;
            if (l <= r) {
                swap(arr[l], arr[r]);
                l++; r--;
            }
        }
        quickSortSingleRecursion(arr, l, end);
        end = r;
    }
}

无监督快速排序

无监督快速排序(也称为Hoare分区方案的快速排序)不使用额外的检查来减少比较操作。它可能比上述版本稍快,但它的实现较为复杂,且对于初学者来说不那么直观。

// 无监督快速排序
template <typename T>
void quickSortUnwatched(vector<T>& arr, int start, int end) {
    while (start < end) {
        int l = start, r = end, pivot = arr[(start + end) / 2];
        do {
            while (arr[l] < pivot) l++;
            while (arr[r] > pivot) r--;
            if (l <= r) {
                swap(arr[l], arr[r]);
                l++; r--;
            }
        }while (l <= r);
        quickSortUnwatched(arr, l, end);
        end = r;
    }
}

注意:无监督版本的快速排序在处理等于枢纽值的元素时可能表现不如标准版本。在实际应用中,选择哪种快速排序版本取决于具体的应用场景和性能要求。

混合排序的实现

C++ 标准库sort算法

C++ 标准库中的 std::sort 算法实现通常是一种混合排序算法,它结合了快速排序、插入排序和堆排序的特点。这种混合排序算法被设计为在大多数情况下都能提供良好的性能。特别地,std::sort 在某些情况下会从快速排序切换到堆排序,以避免快速排序的最坏情况性能。
快速排序转换为堆排序的典型情况是当快速排序的递归深度过深时。这通常发生在以下情况:

  1. 枢轴选择不佳:如果在快速排序中重复选择到的枢轴不是很好(例如,经常选择到的是最小或最大的元素),这将导致分区不平衡,进而导致递归树变得非常深。
  2. 递归深度过大:为了避免快速排序在极端情况下的性能退化(如递归深度过大导致的栈溢出),std::sort 会设定一个递归深度的阈值。一旦超过这个阈值,算法就会切换到堆排序。这是因为堆排序的时间复杂度在最好、最坏和平均情况下都是 O(n log n),且它不是递归的,不会有栈溢出的风险。
  3. 恶化的时间复杂度:在某些极端情况下,快速排序的时间复杂度可能退化为 O(n²),此时切换到堆排序可以保证算法性能不会过于恶化。

我设计的混合排序算法

基于以上原理,我设计实现一个结合了快速排序、堆排序和插入排序的混合排序算法

  1. 首先,我们需要实现这三种排序方法的基础版本:
    • 插入排序:用于处理较小的数组,因为它在小数组上非常高效。
    • 堆排序:用作快速排序的后备,以避免快速排序的最坏情况性能。
    • 快速排序:作为主要的排序方法,但当递归深度过大时切换到堆排序。
  • 插入排序
// 插入排序
template <typename T>
void insertionSort(vector<T>& arr, int left, int right) {
    for (int i = left + 1, j = i; i <= right; i++, j = i)
        while (j > left && arr[j] < arr[j - 1]) swap(arr[j], arr[j--]);
}
  • 堆排序
// 堆排序
// 堆排序的辅助函数
template <typename T>
void heapify(std::vector<T>& 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) {
        std::swap(arr[i], arr[largest]);
        heapify(arr, n, largest);
    }
}

// 堆排序
template <typename T>
void heapSort(std::vector<T>& arr, int start, int end) {
    int n = end - start + 1; // 计算堆的大小

    // 构建最大堆
    for (int i = n / 2 - 1; i >= 0; --i)
        heapify(arr, n, i);

    // 一个个从堆顶取出元素,然后重新调整堆
    for (int i = n - 1; i >= 0; --i) {
        std::swap(arr[0], arr[i]); // 将当前最大元素移到数组末尾
        heapify(arr, i, 0); // 调整剩余数组,使其成为最大堆
    }
}
  • 快速排序
// 快速排序
// 分区函数
template <typename T>
int partition(std::vector<T>& arr, int start, int end) {
    T pivot = arr[(start + end) / 2]; // 选择中间的元素作为枢轴
    int l = start, r = end;

    while (l <= r) {
        while (arr[l] < pivot) l++;  // 从左侧找到第一个不小于枢轴的元素
        while (arr[r] > pivot) r--;  // 从右侧找到第一个不大于枢轴的元素

        if (l <= r) {
            std::swap(arr[l], arr[r]);  // 交换这两个元素
            l++;
            r--;
        }
    }

    return l; // 返回分区后的分界点
}

// 快速排序
template <typename T>
void quickSort(std::vector<T>& arr, int start, int end, int depthLimit) {
    if (start < end) {
        if (end - start < 16) { // 小数组使用插入排序
            insertionSort(arr, start, end);
        } else if (depthLimit == 0) { // 深度限制达到时使用堆排序
            heapSort(arr, start, end);
        } else {
            int l = partition(arr, start, end); // 快速排序的分区操作
            quickSort(arr, start, l - 1, depthLimit - 1);
            quickSort(arr, l, end, depthLimit - 1);
        }
    }
}

  1. 然后将三者结合起来
// 组合排序
template <typename T>
void hybridSort(std::vector<T>& arr) {
    int maxDepth = log(arr.size()) * 2;
    quickSort(arr, 0, arr.size() - 1, maxDepth);
}

  1. 最后是一个用来测试的main函数
int main() {
    // 生成随机数
    srand(static_cast<unsigned int>(time(0))); // 初始化随机数生成器
    std::vector<int> testArray;
    for (int i = 0; i < 20; ++i) {
        testArray.push_back(rand() % 100); // 生成0到99之间的随机数
    }

    // 打印未排序的数组
    std::cout << "Original array:\n";
    for (int i : testArray) {
        std::cout << i << " ";
    }
    std::cout << "\n";

    // 使用混合排序
    hybridSort(testArray);

    // 打印排序后的数组
    std::cout << "Sorted array:\n";
    for (int i : testArray) {
        std::cout << i << " ";
    }
    std::cout << "\n";

    return 0;
}

这段代码首先生成一个包含20个随机整数的 vector,然后调用 hybridSort 函数对其进行排序,最后输出排序前后的数组。您可以运行这段代码来验证排序算法的有效性。如果需要测试更大的数组或不同类型的数据,只需相应地调整 testArray。

以上的代码提供了一种基础的混合排序算法实现。它在小数组上使用插入排序,使用快速排序作为主要的排序算法,但当递归深度变得过深时(为了避免快速排序的最坏情况性能),它会切换到堆排序。

请注意,具体的阈值(如这里的数组大小16用于切换到插入排序)和深度限制可以根据具体的应用场景进行调整。此外,快速排序的分区策略也可以根据需要进行修改。

堆排序的实现

我的实现

我的这段代码实现了堆排序算法。堆排序是一种利用堆数据结构的排序算法,分为两个主要阶段:构建最大堆(make_heap)和执行排序(pop_heap)。下面是对您的代码的详细解释和注释。

template <typename T>
void heapSort(vector<T>& arr, int start, int end) {
    // 构建最大堆(make_heap)
    // 在这一阶段,从数组的中间位置开始向前遍历,处理每个非叶子节点。对于每个节点,如果它大于其父节点,则将其与父节点交换,直至满足最大堆的性质
    int mid = start + ((end - start + 1) >> 1);
    for (int i = end; i >= mid; i--) {
        int ind = i, parent = start + ((ind - start - 1) >> 1);
        while (ind > start && parent >= start) {
            if (arr[parent] < arr[ind]) swap(arr[parent], arr[ind]);
            ind = parent, parent = start + ((ind - start - 1) >> 1);
        }
    }
    // 执行排序(pop_heap)
    // 在排序阶段,不断将堆顶元素(即当前最大元素)与堆的最后一个元素交换,并减少堆的大小。每次交换后,它重新调整剩余的堆,以确保堆顶元素是最大的。
    while (end > start) {
        swap(arr[start], arr[end--]); // pop
        int ind = start, child = start + ((ind - start) << 1) + 1;
        while (child <= end) {
            if (child < end && arr[child] < arr[child + 1]) child++;
            if (arr[ind] < arr[child]) swap(arr[ind], arr[child]);
            else break;
            ind = child, child = start + ((ind - start) << 1) + 1;
        }
    }
}

优化和注意事项

  • 代码在构建最大堆时只考虑了非叶子节点,这是一个有效的优化,因为所有叶子节点已经是最大堆。
  • 使用位运算符 >> 1 和 << 1 分别代表除以2和乘以2,这在性能上稍有优势。
  • 代码中有一些重复的计算,比如 start + ((ind - start - 1) >> 1) 和 start + ((ind - start) << 1) + 1,这些可以在必要时进行优化,以提高代码的清晰度和可能的性能。

整体来说,这是一个有效且清晰的堆排序实现,正确地应用了堆的性质来进行排序。

另一种实现

以下是另一种堆排序算法在 C++ 中的实现:

// 堆排序的辅助函数
template <typename T>
void heapify(std::vector<T>& 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) {
        std::swap(arr[i], arr[largest]);
        heapify(arr, n, largest);
    }
}

// 堆排序
template <typename T>
void heapSort(std::vector<T>& arr, int start, int end) {
    int n = end - start + 1; // 计算堆的大小

    // 构建最大堆
    for (int i = n / 2 - 1; i >= 0; --i)
        heapify(arr, n, i);

    // 一个个从堆顶取出元素,然后重新调整堆
    for (int i = n - 1; i >= 0; --i) {
        std::swap(arr[0], arr[i]); // 将当前最大元素移到数组末尾
        heapify(arr, i, 0); // 调整剩余数组,使其成为最大堆
    }
}

这个代码和我之前实现的不太一样,感觉别有一番新意,供大家参考。

解释

  1. heapify 函数:此函数是堆排序的核心。它确保以索引 i 为根的子树符合最大堆的性质。如果子节点的值大于其父节点的值,该函数会递归地调整堆。
  2. heapSort 函数:这个函数首先通过调用 heapify 函数从无序数组创建最大堆。一旦堆构建完成,它将堆的根(即最大元素)与堆的最后一个元素交换,并减少堆的大小,然后再次调用 heapify 来恢复最大堆的性质。这个过程重复进行,直到堆的大小为 1。
  3. 构建最大堆:开始时,heapSort 从最后一个非叶子节点开始向上构建最大堆。
  4. 排序:每次循环都将当前最大元素(堆顶元素)移动到数组末尾(即堆的外部),然后在减小的堆上重新运行 heapify,以确保最大元素总是位于堆顶。

特性

  1. 堆排序是一种不稳定的排序算法。
  2. 它的时间复杂度为 O(n log n),在所有情况下均是这样。
  3. 堆排序在实践中通常比快速排序和归并排序慢,但它的最坏情况性能优于快速排序。

其它c++案例实现

这块我就不详细写代码了,参考我的github,有上面所有排序算法的实现细节,多数是经过我仔细优化过的,比如插入排序等,比网上一般的实现要简单。

总结

通过本博客的介绍,我们不仅理解了各种排序算法的基本原理和它们的适用场景,还学习了如何用C++实现这些算法。每种排序方法都有其独特的优势和局限性,理解这些差异对于选择最合适的排序策略至关重要。无论是在学术研究还是实际应用中,这些排序算法都是解决问题和优化性能的重要工具。

你可能感兴趣的:(c++,算法,算法,排序算法,学习)