【数据结构详解】——快速排序(动图详解)

🕒 1. 快速排序

💡 算法思想:快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,通过一趟排序将待排记录分割成独立的两部分,其中一部分记录的关键字均比另一部分记录的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序的目的。

🕘 1.1 Hoare版本(左右指针法)

请添加图片描述

具体思路是:

  1. 选定一个基准值(pivot / key),最好选定最左边或者最右边
  2. 确定两个指针leftright分别从左边和右边向中间遍历数组;
  3. 如果选最右边为基准值,那么left指针先走,如果遇到大于基准值的数就停下来。这样能保证相遇位置比基准值大;
  4. 然后右边的指针再走,遇到小于基准值的数就停下来;
  5. 交换left和right指针对应位置的值;
  6. 重复以上步骤,直到left = right ,最后将基准值与left(right)位置的值交换,这便完成一趟排序。

请添加图片描述

这样基准值左边的所有数都比它小,而它右边的数都比它大,从而它所在的位置就是排序后的正确位置。之后再递归排以基准值为界限的左右两个区间中的数,当区间中没有元素时,排序完成。
请添加图片描述

// 快速排序hoare版本
int PartSort(int* a, int left, int right)
{
	int key = right;// 选定基准值
	while (left < right)
	{
		// 选右边为基准值,则左指针先走,从左往右找到第一个比基准值大的元素
        while (left < right && a[left] <= a[key])
		{
			left++;
		}
 
        // 右指针再走,从右往左找到第一个比基准值小的元素
        while (left < right && a[right] >= a[key])
		{
			right--;
		}
		Swap(&a[left], &a[right]);
	}
	Swap(&a[left], &a[key]);
	return left;
}
 
void QuickSort(int* a, int left, int right)
{
    assert(a);
	if (left >= right)
	{
		return;	// 如果左边界大于等于右边界,表示已经排序完成。
	}
 
	
	int keyi = PartSort(a, left, right);	// 对数组进行一次分割操作,获取基准值的最终位置
	QuickSort(a, left, keyi - 1);			// 对基准值左边的子数组进行递归排序
	QuickSort(a, keyi + 1, right);			// 对基准值右边的子数组进行递归排序
}

🕘 1.2 挖坑法

💡 算法思想:挖坑法与上面Hoare版本思想基本一致,都是利用递归(即分治的算法思想)去实现排序。但挖坑法的不同之处在于,当leftright这两个下标在遍历过程中找到满足条件的元素时(即元素值大于或小于基准值key),它们会立即将这些元素放置到合适的位置,这个过程可以描述为边查找边替换。相比之下,Hoare版本则是先记录下符合条件的元素下标,在遍历完成后再进行位置的交换。

请添加图片描述

具体思路是:

  1. 先将选定的基准值(最左边)直接取出,然后留下一个坑;
  2. 当右指针遇到小于基准值的数时,直接将该值放入坑中,而右指针指向的位置形成新的坑位;
  3. 然后左指针遇到大于基准值的数时,将该值放入坑中,左指针指向的位置形成坑位;
  4. 重复该步骤,直到左右指针相等。最后将基准值放入坑位之中。

注意,在操作过程中,我们并不是真正地创建一个新的坑位,而是在逻辑上将其视为坑位。实际上,坑位仍然占据着原来的元素,我们只是在操作时暂时将原元素存储起来,然后用新元素直接覆盖它。在leftright最终相遇的位置,就会形成一个坑位,恰好可以放置key。

// 快速排序挖坑法
int PartSort(int* a, int left, int right)
{
    int key = a[left]; // 取出基准值
    int hole = left; // 保存坑的位置

    while (left < right)
    {
        // 从右向左找第一个小于基准值的元素
        while (left < right && a[right] >= key)
        {
            right--;
        }
        // 将找到的小于基准值的元素填入左边的坑中
        a[hole] = a[right];
        hole = right; 

        // 从左向右找第一个大于基准值的元素
        while (left < right && a[left] <= key)
        {
            left++;
        }
        // 将找到的大于基准值的元素填入右边的坑中
        a[hole] = a[left];
        hole = left; 
    }

    // 将基准值填入最后一个坑中,此时左右两部分已经分好
    a[hole] = key;

    return hole; 
}

void QuickSort(int* a, int left, int right)
{
    assert(a);
	if (left >= right)
	{
		return;	// 如果左边界大于等于右边界,表示已经排序完成。
	}
 
	int keyi = PartSort(a, left, right);	// 对数组进行一次分割操作,获取基准值的最终位置
	QuickSort(a, left, keyi - 1);			// 对基准值左边的子数组进行递归排序
	QuickSort(a, keyi + 1, right);			// 对基准值右边的子数组进行递归排序
}

🕘 1.3 前后指针法

💡 算法思想:有两个指针:curprevcur的位置在prev的下一个位置),期间通过cur去遍历数组,将每个元素与key值比较大小,若发现有元素小于key,就让prev++(即走到下一个位置),再与cur此时对应的元素进行交换,通过递归的方式重复上述的步骤,即可实现完全排序。

简而言之:

  • prevkey初始位置之间的区间,是在维护一个所有元素都小于key的区域,目的是使key左侧的序列中的所有值都小于key
  • prev++cur之间的区间,则是在维护一个所有元素都大于key值的区域,以确保key右侧的序列中的所有值都大于key
  • cur不断寻找小于key的元素并与prev++后的位置交换,是为了将序列后方小于key的元素移动到key前方,保证key左侧子序列的值都小于key右侧子序列的值都大于key

具体思路是:

  1. 选定基准值,定义prev和cur指针(cur = prev + 1);
  2. cur先走,遇到小于基准值的数停下,然后将prev向后移动一个位置;
  3. 将prev对应值与cur对应值交换;
  4. 重复上面的步骤,直到cur走出数组范围;
  5. 最后将基准值与prev对应位置交换;
  6. 递归排序以基准值为界限的左右区间。
    请添加图片描述
// 快速排序前后指针法
int PartSort(int* a, int left, int right)
{
    // 选择基准值为数组的第一个元素(left)
    int keyi = left; // 基准值索引
    int prev = left; // 前指针初始化为基准值的位置
    int cur = left + 1; // 后指针初始化为基准值后一个位置

    // 遍历数组,将小于基准值的元素交换到前面
    while (cur <= right)
    {
        if (a[cur] < a[keyi] && ++prev != cur)
        {
            Swap(&a[prev], &a[cur]); // 如果当前元素小于基准值,交换到前面
        }
        cur++; 
    }

    Swap(&a[prev], &a[keyi]); // 将基准值交换到最终位置

    // 选择基准值为数组的最后一个元素(right)
    /*
    int keyi = right; // 基准值索引
    int prev = left - 1; // 前指针初始化为左边界的前一个位置
    int cur = prev + 1; // 后指针初始化为左边界

    // 遍历数组,将小于基准值的元素交换到前面
    while (cur <= right)
    {
        if (a[cur] < a[keyi] && ++prev != cur)
        {
            Swap(&a[cur], &a[prev]); // 如果当前元素小于基准值,交换到前面
        }
        cur++; 
    }

    Swap(&a[keyi], &a[++prev]); // 将基准值交换到最终位置
    */
    
    return prev; 
    
}

void QuickSort(int* a, int left, int right)
{
    assert(a);
	if (left >= right)
	{
		return;	// 如果左边界大于等于右边界,表示已经排序完成。
	}
 
	int keyi = PartSort(a, left, right);	// 对数组进行一次分割操作,获取基准值的最终位置
	QuickSort(a, left, keyi - 1);			// 对基准值左边的子数组进行递归排序
	QuickSort(a, keyi + 1, right);			// 对基准值右边的子数组进行递归排序
}

🕘 1.4 快速排序的优化

快速排序是一种高效的排序算法,但在某些情况下存在缺陷。例如,若基准值选为最小值,则会引发不必要的递归。此外,对于大量有序或近似有序的数据排序时,快速排序效率较为低下,甚至可能导致程序崩溃,因为过多的递归调用可能会造成栈溢出。为了提高效率并避免这些问题,可以采用以下两种优化策略:

  1. 三数取中法选基准值
  2. 递归到小的子区间时,可以考虑使用插入排序

🕤 1.4.1 三数取中

💡 算法思想:即在选择基准值时,不是简单地选择数组的第一个元素或者最后一个元素,而是从当前子数组的第一个元素、中间元素和最后一个元素中选择中间大小的元素作为基准值。这种方法的优势在于:

  • 降低最坏情况的发生概率: 如果每次选取的基准值都是当前子数组中的中间值,那么快速排序在大多数情况下会有较好的性能表现,因为这样可以避免极端情况下分割不均匀的问题。
  • 减少递归深度: 选取较为中间的元素作为基准值,可以更均匀地划分数组,避免出现极端的递归深度,从而减少排序的时间复杂度。
// 三数取中法
int MidIndex(int* a, int left, int right)
{
    int mid = (left + right) / 2;	// 计算中间元素的索引
    // 若要防止整数溢出可以使用以下方式计算mid
    // int mid = left + (right - left) / 2;

    // 比较三个关键位置的元素,选出中间值的索引作为基准值
    if (a[left] < a[right])
    {
        if (a[mid] < a[left])
        {
            return left;
        }
        else if (a[mid] > a[right])
        {
            return right;
        }
        else
        {
            return mid;
        }
    }
    else
    {
        if (a[mid] > a[left])
        {
            return left;
        }
        else if (a[mid] < a[right])
        {
            return right;
        }
        else
        {
            return mid;
        }
    }
}
// 快速排序前后指针法优化
int PartSort(int* a, int left, int right)
{
	int mid = MidIndex(a, left, right);
	// 将基准位置调整至最左边
	Swap(&a[mid], &a[left]);

    // 选择基准值为left
    int keyi = left; 
    int prev = left; 
    int cur = left + 1; 
    ......
}

🕤 1.4.2 小区间优化排序

💡 算法思想:优化排序的时间复杂度,减少不必要的调用递归次数

具体来说,就是在完成一次快速排序递归后,将数据分为左右两个子序列。如果这两个子序列的数据量都较小,就可以直接采用更适合小数据集的排序方法,如插入排序,而不必再进行快速排序的递归操作。这样不仅提高了算法的效率,也优化了递归层次,特别是在数据量较小的情况下更为明显。

// 快速排序小区间优化
void QuickSort(int* a, int left, int right)
{
    assert(a);
	if (left >= right)
	{
		return;	
	}

	// 小区间优化,减少递归次数
	if (right - left + 1 < 10)
	{
		InsertSort(a + left, right -left + 1);
	}
	else
	{
		int keyi = PartSort(a, left, right);
		QuickSort(a, left, keyi - 1);
		QuickSort(a, keyi + 1, right);
	}
}

🕘 1.5 快速排序的非递归实现

💡 算法思想:快速排序的非递归实现依赖于来存储待排序的子区间。这种方法不仅避免了递归可能导致的栈溢出问题,而且其核心思想与递归实现是相似的。

具体思路是:

  1. 将数组左右下标入栈;
  2. 若栈不为空,两次取出栈顶元素,分别为闭区间的左右界限;
  3. 将区间中的元素按照上述任意一种方法得到基准值的位置;
  4. 再以基准值为界限,若基准值左右区间中有元素,则将区间入栈;
  5. 重复上述步骤直到栈为空。
void QuickSortNonR(int* a, int left, int right)
{
    // 创建栈
    Stack st;
    StackInit(&st);
 
    // 原始数组区间入栈
    StackPush(&st, right);  // 先将右边界入栈
    StackPush(&st, left);   // 再将左边界入栈
 
    // 将栈中区间排序
    while (!StackEmpty(&st))
    {
        // 弹出栈顶的左右边界
        left = StackTop(&st);
        StackPop(&st);
        right = StackTop(&st);
        StackPop(&st);
        
        // 对当前区间进行划分,得到基准值的位置
        int mid = PartSort(a, left, right);
 
        // 将基准值两侧的子区间入栈,准备下一轮排序
        if (right > mid + 1)
        {
            StackPush(&st, right);  // 右边界入栈
            StackPush(&st, mid + 1);  // 右侧子区间的左边界入栈
        }
        if (left < mid - 1)
        {
            StackPush(&st, mid - 1);  // 左侧子区间的右边界入栈
            StackPush(&st, left);  // 左边界入栈
        }
    }
    
    // 销毁栈
    StackDestroy(&st);
}

快速排序的特性总结:

  1. 快速排序整体的综合性能和使用场景都是比较好的,所以才叫快速排序。
  2. 时间复杂度:最好:O(N*logN) ; 最坏:O(N2)
  3. 空间复杂度:O(logN) ~ O(N)
  4. 稳定性:不稳定

❗ 转载请注明出处
作者:HinsCoder
博客链接:🔎 作者博客主页

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值