排序之快速排序

概念:

快速排序是一种非常高效的排序算法,由C. A. R. Hoare在1960年提出。它采用了分治法(Divide and Conquer)的策略,通过递归将问题分解为更小的子问题来解决。

  • 分治法:将问题分解成多个小问题,递归解决小问题,然后将结果合并。
  • 基准元素(Pivot):选择数组中的一个元素作为基准,根据这个基准将数组分为两部分,一部分包含比基准小的元素,另一部分包含比基准大的元素。
  • 分区操作:根据基准元素将数组分为两个子数组的过程。

算法步骤:

思路:

hoare

  1. 选择基准:从数组中选择一个元素作为基准(key)。有多种选择方法,如选择第一个元素、最后一个元素、中间元素或随机元素。(关键)
  2. 分区:重新排列数组,所有比基准小的元素放在基准的左边,所有比基准大的元素放在基准的右边。这个过程称为分区(Partitioning)。
  3. 递归排序:递归地将上述步骤应用到基准左边和右边的子数组上。

在选择快速排序算法中的基准元素(Key)时,有几种不同的策略,每种策略都有其优缺点。以下是一些常见的选择基准的方法:

  •  首元素:选择数组的第一个元素作为基准。这种方法简单,但当输入数组已经部分排序时,可能会导致最坏情况的性能。(递归深度太大,导致栈溢出)

  • 末元素:选择数组的最后一个元素作为基准。这与首元素类似,但当数据集中存在多个重复元素时,可能会有不同的性能表现。

  • 中位数:选择数组中间位置的元素作为基准。这可以提供比首元素或末元素更好的平均性能。

  • 三数中位数:从数组的首元素、末元素和中间元素中选择中位数作为基准。这种方法旨在减少当数组已经部分排序时的不利影响。

  • 随机选择:随机选择数组中的一个元素作为基准。这可以避免最坏情况的发生,特别是在输入数据可能已经排序的情况下。

  • 尾递归优化:在递归调用中,选择较小的分区首先进行排序,这可以减少递归深度。

  • 荷兰国旗算法:这是一种多路快速排序算法,它使用三个指针将数组分为三部分:小于、等于和大于基准值的元素。

  • 第九个元素:选择数组长度的1/9、2/9、...、8/9位置的元素作为基准。这种方法在某些情况下可以提供更好的性能。

  • 平均值:取数组中若干个元素(例如三个)的平均值作为基准。这种方法可以减少选择极端值作为基准的可能性。

  • Bogdanov和Ducreux方法:这是一种基于统计学的方法,通过计算数组中元素的中位数来选择基准。 

选择基准的策略对快速排序的性能有重要影响。在实践中,随机选择通常被认为是一种很好的策略,因为它可以减少最坏情况发生的概率,并且对于各种类型的数据集都表现良好。然而,选择哪种策略可能取决于具体的应用场景和数据特性。

常见的,我们可以对快速排序进行以下优化:

优化:

  • 三数取中法:选择数组的第一个、中间的和最后一个元素的中位数作为基准,以提高基准选择的公平性。
// 选择三个元素的中位数
int medianOfThree(std::vector<int>& arr, int low, int high) {
    int mid = low + (high - low) / 2; // 中间的索引

    // 比较三个元素,确保arr[low] <= arr[mid] 和 arr[mid] <= arr[high]
    if (arr[low] > arr[mid]) {
        std::swap(arr[low], arr[mid]);
    }
    if (arr[low] > arr[high]) {
        std::swap(arr[low], arr[high]);
    }
    if (arr[mid] > arr[high]) {
        std::swap(arr[mid], arr[high]);
    }

    // 将中间值放到最左边
    std::swap(arr[mid], arr[low]);
    return arr[high];
}
  • 尾递归优化:在递归调用中,总是先处理较小的部分,这样可以减少递归栈的深度。
  • 小数组(区间)优化:当子数组的大小小于某个阈值(如10)时,使用插入排序,因为小数组上插入排序可能更快。
  • 并行化:快速排序可以并行处理,因为每个分区操作是独立的。
  • 随机化:随机选择基准元素,以减少最坏情况发生的概率。
#include <vector>
#include <cstdlib> // for std::rand and std::srand

// 随机化基准选择
int randomizedPivot(std::vector<int>& arr, int low, int high) {
    return low + std::rand() % (high - low);
}

实现:

在实现快速排序有以下三种方法:

hoare:

hoare

使用Hoare快速排序算法,并将基准(key)放在左侧的分区优化版本,可以通过以下步骤实现:

  1. 使用三数取中法选择一个好的基准。
  2. 利用Hoare分区方法将基准放到正确的位置,同时确保所有小于基准的元素都在它的左边。
  3. 递归地对基准左边和右边的子数组进行排序。

以下是使用Hoare快速排序算法的优化版本,并将基准放在左侧的C++代码示例:

#include <iostream>
#include <vector>

// 插入排序函数,用于小区间排序
void insertionSort(std::vector<int>& arr, int left, int right) {
    for (int i = left + 1; i <= right; ++i) {
        int key = arr[i];
        int j = i - 1;
        while (j >= left && arr[j] > key) {
            arr[j + 1] = arr[j];
            --j;
        }
        arr[j + 1] = key;
    }
}

// 三数取中法选择基准
void medianOfThree(std::vector<int>& arr, int low, int mid, int high) {
    if (arr[low] > arr[mid]) std::swap(arr[low], arr[mid]);
    if (arr[low] > arr[high]) std::swap(arr[low], arr[high]);
    if (arr[mid] > arr[high]) std::swap(arr[mid], arr[high]);
    std::swap(arr[mid], arr[low]);
}

// Hoare快速排序的分区函数,选择最左侧的元素作为基准
int hoarePartition(std::vector<int>& arr, int low, int high) {
    medianOfThree(arr, low, (low + high) / 2, high); // 选择三数中位数作为基准
    int pivot = arr[low]; // 将基准放到最左侧

    int left = low + 1;
    int right = high;

    while (left < right) {
        // 从右向左找,直到找到小于或等于基准的元素
        while (left < right && arr[right] >= pivot) {
            right--;
        }
        // 从左向右找,直到找到大于基准的元素
        while (left < right && arr[left] <= pivot) {
            left++;
        }
        // 如果left和right指向的元素都满足条件,则交换它们
        if (left < right) {
            std::swap(arr[left], arr[right]);
            left++;
            right--;
        }
    }

    // 将基准放到分区索引处
    std::swap(arr[low], arr[left]);
    return left; // 返回基准的最终位置
}

// Hoare快速排序递归函数
void hoareQuickSort(std::vector<int>& arr, int low, int high) {
    if (low >= high)
    {
        return;
    }
    // 小区间优化
    const int THRESHOLD = 10; // 阈值设为10
    if (low + THRESHOLD - 1 < high) { // 区间长度大于阈值
        int pi = hoarePartition(arr, low, high);

        hoareQuickSort(arr, low, pi - 1);
        hoareQuickSort(arr, pi + 1, high);
    }
    else {
        insertionSort(arr, low, high);
    }
}

Tip:

为什么相遇时,要与基准交换(或者说:基准位置的值一定比相遇位置的值大)?

针对上述代码:

左边做key(基准),右边先走,可以保证相遇位置比key小

相遇的场景:

L遇R:因为L是要找大,R要找小,R先走,停下来,R停下来的位置一定比key小,然而L要找大,找到就停下来了,但是没找到大的就遇到R,然后停下来了;

R遇L:R先走,找小,没有找到比key小的,直接跟L相遇了。L停留的位置是上一轮交换的位置,上一轮交换,把比key小的值换到L的位置;

总之:是右边先走的影响,左边先走就有影响了。我们可以推出:如果让右边做key,左边先走,可以保证相遇位置比key要大(升序:哪边做key,哪边先走)


在快速排序算法的递归实现中,通常不需要在每个递归调用中显式地写return语句,因为递归调用的返回值是通过函数调用栈隐式返回的。递归函数在完成其工作后,会自动返回到调用它的上一级函数。这是递归函数的一个基本特性。

在快速排序的递归函数quickSort中,递归调用quickSort(arr, low, pi - 1);quickSort(arr, pi + 1, high);不需要显式返回,因为它们的作用是将排序过程继续在子数组上执行,而不是返回一个值。排序的结果是通过原地(in-place)修改数组来实现的,而不是通过返回值。

void quickSort(std::vector<int>& arr, int low, int high) {
    if (low < high) {
        // 分区操作,pi是分区索引
        int pi = partition(arr, low, high);

        // 递归地对基准左边的子数组进行排序
        quickSort(arr, low, pi - 1);

        // 递归地对基准右边的子数组进行排序
        quickSort(arr, pi + 1, high);
    }
    // 递归调用自动返回,不需要显式return语句
}

在上面的代码中,quickSort函数在每次递归调用之后不需要显式地写return语句。当递归到达数组的最底部(即low等于high时),递归开始逐步返回,此时数组的排序过程也随之完成。

然而,如果你想要显式地在递归函数中添加return语句,你可以在函数的最后添加return;,这在大多数情况下是多余的,但不会对函数的行为产生影响:

void quickSort(std::vector<int>& arr, int low, int high) {
    if (low < high) {
        // 分区和递归调用
        // ...

        // 最后一个递归调用之后
        return; // 这实际上是多余的
    }
}

 在实际编程中,通常遵循简洁性原则,避免添加不必要的代码,除非这样做可以提高代码的可读性或有其他明确的好处的呢。


递归本质上就是建立栈帧,在栈桢中重点存的核心是区间,我们就可以以非递归形式(用栈)实现快排:

#include <iostream>
#include <stack>
#include <vector>

void quickSort(std::vector<int>& arr, int low, int high) {
    std::stack<std::pair<int, int>> stack;
    stack.push({ low, high });

    while (!stack.empty()) {
        std::pair<int, int> range = stack.top();
        stack.pop();

        int start = range.first;
        int end = range.second;

        if (start >= end) {
            continue;
        }

        // 选择枢轴,这里简单选择区间的第一个元素
        int pivot = arr[start];
        int i = start;
        int j = end;

        while (i < j) {
            while (i < j && arr[j] >= pivot) {
                j--;
            }
            while (i < j && arr[i] <= pivot) {
                i++;
            }
            if (i < j) {
                std::swap(arr[i], arr[j]);
            }
        }

        // 将枢轴放到正确的位置
        arr[start] = arr[i];
        arr[i] = pivot;

        // 将新的区间压入栈中
        stack.push({ start, i - 1 });
        stack.push({ i + 1, end });
    }
}

int main() {
    std::vector<int> arr = { 10, 7, 8, 9, 1, 5 };
    int n = arr.size();

    quickSort(arr, 0, n - 1);

    for (int num : arr) {
        std::cout << num << " ";
    }

    return 0;
}

使用栈实现快速排序(或其他递归算法)的好处主要包括以下几点:

  1. 避免递归调用开销:递归调用可能会增加函数调用的开销,包括栈帧的创建和销毁。使用栈来模拟递归可以减少这种开销。

  2. 减少递归深度:在递归实现中,如果数据集很大,可能会导致递归深度过大,从而增加调用栈溢出的风险。使用栈可以更好地控制递归深度。

  3. 提高效率:通过迭代的方式,可以减少函数调用的开销,从而提高算法的效率。


挖坑法:

挖坑法
  1. 随机选择基准:避免选择数组两端的元素作为基准,减少已经排序数组的最坏情况。
  2. 挖坑法分区:使用挖坑法进行分区,将小于基准的元素移到数组的前端。
  3. 递归排序:递归地对基准元素左边和右边的子数组进行排序。
#include <iostream>
#include <vector>
#include <cstdlib> // For std::rand and std::srand

// 初始化随机数种子
void initRandomSeed() {
    std::srand(static_cast<unsigned int>(std::time(nullptr)));
}

// 随机选择基准元素的索引
int randomizedPivotIndex(const std::vector<int>& arr, int low, int high) {
    if (low == high) return low;
    return low + std::rand() % (high - low + 1);
}

// 挖坑法分区函数
int lomutoPartition(std::vector<int>& arr, int low, int high) {
    int pivotIndex = randomizedPivotIndex(arr, low, high);
    int pivot = arr[pivotIndex];
    std::swap(arr[pivotIndex], arr[high]); // 将基准移到末尾

    int i = low; // '坑'的位置
    for (int j = low; j < high; j++) {
        if (arr[j] < pivot) {
            std::swap(arr[i], arr[j]);
            i++;
        }
    }
    std::swap(arr[i], arr[high]); // 将基准移到'坑'的位置
    return i;
}

// 快速排序递归函数
void quickSort(std::vector<int>& arr, int low, int high) {
    while (low < high) {
        // 挖坑法分区
        int pivotIndex = lomutoPartition(arr, low, high);

        // 递归地对基准左边的子数组进行排序
        if (pivotIndex - low < high - pivotIndex) {
            quickSort(arr, low, pivotIndex - 1);
            low = pivotIndex + 1;
        } else {
            quickSort(arr, pivotIndex + 1, high);
            high = pivotIndex - 1;
        }
    }
}

前后指针:

前后指针
  1. 选择两个基准:通常选择数组的首元素、中间元素和尾元素,然后确定两个基准,使得一个基准小于或等于另一个。

  2. 初始化三个指针low指针、mid指针和high指针分别指向数组的起始位置、中间位置和结束位置。

  3. 使用双向指针进行分区

    • 两个ij指针分别从low+1high-2开始,向中间移动。
    • 所有小于第一个基准的元素交换到low指针的右侧。
    • 所有在两个基准之间的元素保持在两个ij指针之间。
    • 所有大于第二个基准的元素交换到high指针的左侧。
  4. 递归排序:对三个分区进行递归排序。

#include <iostream>
#include <vector>
#include <algorithm> // For std::swap

// 选择两个基准的中位数
int choosePivot(std::vector<int>& arr, int l, int m, int r) {
    if (arr[l] > arr[m]) std::swap(arr[l], arr[m]);
    if (arr[m] > arr[r]) std::swap(arr[m], arr[r]);
    if (arr[l] > arr[m]) std::swap(arr[l], arr[m]);
    return m;
}

// 双路快速排序的分区函数
void dualPivotPartition(std::vector<int>& arr, int low, int high) {
    if (high - low <= 10) { // 对小数组使用插入排序
        std::sort(arr.begin() + low, arr.begin() + high + 1);
        return;
    }
    
    int p1 = low, p2 = high;
    int pivot1 = arr[choosePivot(arr, low, low + (high - low) / 2, high)];
    int pivot2 = arr[high];
    int i = low, j = high;

    while (i < j) {
        while (i < p2 && arr[i] <= pivot1) i++;
        while (j > p1 && arr[j] >= pivot2) j--;
        if (i < j) {
            if (arr[i] < pivot2) {
                std::swap(arr[i++], arr[j--]);
            } else if (arr[j] <= pivot1) {
                std::swap(arr[i], arr[j--]);
            } else {
                i++;
            }
        }
    }

    std::swap(arr[i], arr[high]); // 将最大的元素放到其最终位置
    p2 = i - 1; // 左边分区的上界
    i = low;
    j = high;

    while (i < j) {
        while (i < p1 && arr[i] < pivot1) i++;
        while (j > p2 && arr[j] > pivot1) j--;
        if (i < j) {
            std::swap(arr[i++], arr[j--]);
        } else if (i == j) {
            std::swap(arr[i], arr[j]);
            break;
        }
    }

    // 递归地对三个分区进行排序
    dualPivotPartition(arr, low, i - 1);
    dualPivotPartition(arr, i, p2);
    dualPivotPartition(arr, p2 + 1, high);
}

特性:

  • 时间复杂度为 O(nlog⁡n)、非自适应排序:在平均情况下,哨兵划分的递归层数为 log⁡n ,每层中的总循环数为 n ,总体使用 O(nlog⁡n) 时间。在最差情况下,每轮哨兵划分操作都将长度为 n 的数组划分为长度为 0 和 n−1 的两个子数组,此时递归层数达到 n ,每层中的循环数为 n ,总体使用 O(n2) 时间。
  • 空间复杂度为 O(n)、原地排序:在输入数组完全倒序的情况下,达到最差递归深度 n ,使用 O(n) 栈帧空间。排序操作是在原数组上进行的,未借助额外数组。
  • 非稳定排序:在哨兵划分的最后一步,基准数可能会被交换至相等元素的右侧。

  • 23
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值