【数据结构】详解快速排序(C语言)

前言

快速排序是霍尔(Hoare)于1962年提出的一种二叉树结构的交换排序方法,其基本思路为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。

[!Tips] 学习快速排序的建议:

  1. 画图理解单趟排序,画递归展开图
  2. 注意体会数据逐渐有序的过程(每经过一趟排序,该趟排序的基准值的位置是它最终的位置)

一、快速排序的单趟排序

快速排序将待排序序列逐步划分为较小的子序列,并对每个子序列再进行单趟排序。被嵌套调用的一系列单趟排序构成了完成的快速排序算法

在快速排序的单趟排序中,我们通过选择一个基准元素(代码中用Key表示,其下标用KeyIndex表示),并根据其值将序列进行划分。

我们提供霍尔法挖坑法前后指针法,3种实现方法。

1.1 霍尔法

霍尔(Hoare)是最初发现快速排序的人,它使用的单趟排序算法被称为霍尔法。

  • 步骤描述:
  1. 选择基准元素,通常选择序列的第一个元素作为基准元素。请添加图片描述

  2. 利用两个下标leftright分别指向待排数组的最左侧和最右侧,right指针找比key基准值小的数,left找比key基准值大的数。请添加图片描述

  3. right 先向左移动,直到找到第一个小于基准元素的值。在这里插入图片描述

  4. left 向右移动,直到找到第一个大于基准元素的值。请添加图片描述

  5. 找到之后交换这两个下标对应的数值,即交换a[left]a[right]在这里插入图片描述

  6. 重复步骤 3-5,直到 leftright相遇。在这里插入图片描述

  7. 最后,交换基准元素 a[KeyIndex]a[left],此时,基准元素所在位置就是它最终的位置。在这里插入图片描述

  8. 函数返回 leftleft标记了子序列的分界点)。

  • 用C语言代码实现:
// Hoare法单趟排序
int PartSort(int* a, int left, int right)
{
	// 选择基准元素的索引为 left
	int KeyIndex = left;
	while (left < right)
	{
		// right 从右侧开始,找到第一个小于基准元素的值
		while (left < right && a[right] >= a[KeyIndex])
		{
			--right;
		}
		// left 从左侧开始,找到第一个大于基准元素的值
		while (left < right && a[left] <= a[KeyIndex])
		{
			++left;
		}
		// 交换 left 和 right 的元素
		Swap(&a[left], &a[right]);
	}
	
	// 将基准元素放置在最终的位置
	Swap(&a[KeyIndex], &a[left]);
	return left;
}

[!Question] 为什么能够保证相遇位置比key小?
因为right先走,使得结局为以下两种:

  1. right停下之后,left向右靠近并与right相遇,由于right位置定在比key小的值上,所以最终leftright都在比key小的位置处。
  2. left停下之后,rightleft进行交换,交换后left指向的值比key小,此时right遇到left的位置一定比key

1.2 挖坑法

挖坑法是由 Tony Hoare(托尼·霍尔)在1960年提出的。他作为著名的计算机科学家,也是快速排序算法的发明者之一。

  • 步骤描述:
  1. 选择第一个元素作为基准元素,然后把基准元素用Key存放起来。请添加图片描述

  2. HoleIndex是坑的位置,初始位置为基准元素的位置。leftright分别指向待排数组的最左侧和最右侧,right找比key基准值小的数,left找比key基准值大的数。请添加图片描述

  3. right从右侧开始,寻找第一个小于基准元素的值。请添加图片描述

  4. 将该值移动到当前的坑中,该值原来位置成为新的坑位,因此更新 HoleIndex 为右侧坑的位置。请添加图片描述

  5. left从左侧开始,寻找第一个大于基准元素的值。请添加图片描述

  6. 将该值填充到右侧的坑中,该值原来位置成为新的坑位,因此更新 HoleIndex 为左侧坑的位置。请添加图片描述

  7. 重复步骤 3-6,直到左侧指针 left 和右侧指针 right 相遇。请添加图片描述

  8. 将基准元素放置到最后一个坑中,即填充到最终的位置。请添加图片描述

  9. 返回坑的位置 HoleIndex 作为划分子序列的分界点。

  • 用C语言代码实现:
// 挖坑法单趟排序
int PartSort2(int* a, int left, int right)
{
	// 选择基准元素为左侧第一个元素
	int Key = a[left];
	int HoleIndex = left; // 坑的下标,初始为基准元素位置
	
	while (left < right)
	{
		// 从右侧开始,找到第一个小于基准元素的值
		while (left < right && a[right] >= Key)
		{
			--right;
		}
		
		// 将右侧小于基准元素的值填充到左侧的坑中
		a[HoleIndex] = a[right];
		HoleIndex = right;
		
		// 从左侧开始,找到第一个大于基准元素的值
		while (left < right && a[left] <= Key)
		{
			++left;
		}
		
		// 将左侧大于基准元素的值填充到右侧的坑中
		a[HoleIndex] = a[left];
		HoleIndex = left;
	}
		
	// 将基准元素放置到最后一个坑中,即填充到最终的位置
	a[HoleIndex] = Key;
	return HoleIndex;
}

1.3 前后指针法

相比于霍尔法和挖坑法,前后指针法只进行元素的比较和少量交换操作,没有显式地创建临时变量来记录基准值。这样能够简化代码实现,并且在实际运行中减少了一些内存和计算开销。

  • 步骤描述:
  1. 基准值 key 为最左边元素,prev 指向基准值, cur 指向基准值下一个位置。请添加图片描述

  2. cur 开始遍历数组,直到找到第一个小于等于基准值的元素,则先将prev指针向后移动一位,再将cur交换到prev指针指向的位置。请添加图片描述

  3. 继续遍历数组,直到 cur 超过 right 为止。请添加图片描述

  4. 最后,将基准值交换到 prev 指向的位置,此时基准值左侧的元素均小于等于基准值,右侧的元素均大于基准值。请添加图片描述

  5. 返回 prevprev标记了子序列的分界点)。


  • 用C语言代码实现:
// 前后指针法
int PartSort3(int* arr, int left, int right)
{
    int key = left; // 将最左边的元素作为基准值
    int prev = left; // 后指针(动得慢)
    int cur = left + 1; // 前指针(动得快)
	
    while (cur <= right)
    {
        // 当cur遍历到的元素小于等于基准值时,先将prev指针向后移动一位,再将cur交换到prev指针指向的位置
        if (arr[cur] <= arr[key] && ++prev != cur)
        {
            Swap(&arr[cur], &arr[prev]);
        }
        cur++;
    }
	
    // 最后将基准值交换到prev指针指向的位置,以完成单趟排序
    Swap(&arr[key], &arr[prev]);
    return prev; // 返回基准值的位置,用于后续的划分
}

二、实现快速排序

2.1 排序步骤图

以一个数为基准,将数组分为两个子序列,左子序列放比基准数小的,右子序列放比基准数大的数,然后再将左右两段子序列以同样方式分割,数组有序。

![[霍尔法排序.png]]

2.2 快速排序代码

2.2.1 递归实现

  • C语言的递归实现:
// 快速排序的递归实现
void QuickSort(int* a, int begin, int end)
{
	//两种情况下直接返回:1. 区间只有一个值 2. 区间不存在
	if (begin >= end)
	{
		return;
	}
	
	// 使用单趟排序函数 PartSort 对序列进行划分,并获取基准元素的位置
	int KeyIndex = PartSort(a, begin, end);
	
	// 对基准元素左侧的子序列排序
	QuickSort(a, begin, KeyIndex - 1);
	
	// 对基准元素右侧的子序列排序
	QuickSort(a, KeyIndex + 1, end);
}

2.2.2 非递归实现

快速排序的非递归实现是使用栈存储每一次分割后的数组下标区间,以数组下标区间传入单趟排序,以实现对递归思想的模拟。

[!attention] 注意
​栈的特点是先进后出。
所以,获取区间以先右后左顺序时,就要以先左再右压栈。同样,如果要求左子列先出栈,右子列后出栈,那么必须先让右子列入栈,再让左子列入栈。

  • 栈的接口函数(实现部分附在文末)
//栈的接口函数
typedef int STDataType;
typedef struct Stack
{
	STDataType* a;
	int top;//标记栈顶
	int capacity;

}ST;

void STInit(ST* pst);               //初始化栈
void STDestroy(ST* pst);            //销毁栈
void STPush(ST* pst, STDataType x); //压栈
void STPop(ST* pst);                //出栈
STDataType STTop(ST* pst);          //取出栈顶元素
bool STEmpty(ST* pst);              //栈判空
int STSize(ST* pst);                //栈大小
  • 非递归实现:
// 非递归快速排序实现
void QuickSortNonR(int* a, int begin, int end)
{
	ST st; // 定义一个栈,用于模拟递归过程
	STInit(&st); // 初始化栈
	
	// 将初始的 begin 和 end 压入栈,相当于递归的入口
	STPush(&st, end);
	STPush(&st, begin);
	
	while (!STEmpty(&st))
	{
		// 从栈中取出当前处理的 begin 和 end
		int left = STTop(&st);
		STPop(&st);
		
		int right = STTop(&st);
		STPop(&st);
		
		// 调用 PartSort 函数对序列进行划分,并得到基准元素的位置
		int KeyIndex = PartSort(a, left, right);
		
		// 判断是否需要对右侧子序列进行排序,并将其入栈
		if (KeyIndex + 1 < right)
		{
			STPush(&st, right);
			STPush(&st, KeyIndex + 1);
		}
		
		// 判断是否需要对左侧子序列进行排序,并将其入栈
		if (left < KeyIndex - 1)
		{
			STPush(&st, KeyIndex - 1);
			STPush(&st, left);
		}
	}
	
	// 栈中所有任务处理完毕,排序完成
	STDestroy(&st);
}

[!Attention] 注意
快速排序无论采用哪种单趟排序策略,无论采用递归还是非递归

它的时间复杂度是:O(N*logN)
它的空间复杂度是:O(logN) ——logN也是递归层数,即创建的栈帧的数量


三、优化快速排序

3.1. 优化基准数key的选取

优化快速排序的基准数选择对算法的性能和稳定性有很大的影响。其中,三值取中法是一种常用的优化策略,它通过选取子序列中某三个元素的中位数作为基准数(一般选择首、中、尾三数),从而尽可能防止选择的基准数为待排数组的最大、最小值,而导致最坏情况出现。

[!Example] 最坏情况发生在以下两种情景下:

  1. 数组已经有序(升序或降序):当输入的数组是有序的时候,如果每次选择第一个或最后一个元素作为基准数,快速排序每次只能将数组划分为一个元素和剩余的元素,导致递归深度为 n(数组长度),时间复杂度为 O(n^2)。

  2. 数组具有相同元素:当输入的数组中所有元素都相同(例如,数组元素全部为2),无论选择哪个元素作为基准数,每次划分都只能将数组分为一个元素和一坨剩余的元素,导致递归深度为 n(数组长度),时间复杂度为 O(n^2)。

在这些最坏情况下,快速排序的性能大大降低,它退化成了一个效率较低的排序算法。为了避免最坏情况的发生,我们在选择基准数时更加谨慎,确保快速排序的平均性能得到保证。

3.1.3 三值取中法

  • 这里以三值取中选择基准值,霍尔法实现的单趟排序,和递归实现的快速排序为例。
// 三值取中法优化的快速排序递归实现
void QuickSortOptimized(int* a, int begin, int end)
{
    if (begin >= end)
    {
        return;
    }
	
    // 三值取中法选取基准数
    int mid = begin + (end - begin) / 2;
    if (a[begin] > a[mid])
    {
        Swap(&a[begin], &a[mid]);
    }
    if (a[mid] > a[end])
    {
        Swap(&a[mid], &a[end]);
    }
    if (a[begin] > a[mid])
    {
        Swap(&a[begin], &a[mid]);
    }
	
    // 将基准数交换到子序列的第二个位置
    Swap(&a[mid], &a[begin + 1]);
	
    // 使用霍尔法进行划分
    int KeyIndex = PartSort(a, begin, end);
	
    // 对左右子序列递归进行排序
    QuickSortOptimized(a, begin, KeyIndex - 1);
    QuickSortOptimized(a, KeyIndex + 1, end);
}

3.2 减少递归层数

3.2.1 小区间优化

为了解决递归到较深处时,调用大量栈帧来实现短小的排序的“小题大做”问题,可以采用小区间优化的方法,即当待排序的子序列长度小于某个阈值时,不再使用递归方式进行排序,而是采用其他排序算法(如插入排序)对该子序列进行排序。通过直接对较短的子序列使用简单而高效的排序算法,避免了递归调用带来的开销,从而提高了排序的效率。

问题1:待排数组较时,此时再使用递归方式对短数组进行快速排序为什么会导致效率下降?(小区间优化的必要性)

  1. 当待排序的数组长度较短时,使用递归方式进行快速排序可能会导致效率下降的原因是递归的函数调用本身会带来一定的开销。每次递归调用都需要在内存中创建函数栈帧(包括参数、局部变量、返回地址等),并进行函数调用和返回的操作。数组越短,栈帧创建越多,导致效率下降,这是很不划算的事情。

问题2:为什么我们选择了“插入排序”进行“小区间优化”?

  1. 为了实现小区间优化,我们选择了插入排序作为替代算法。插入排序算法的特点是对几乎有序的序列具有较好的性能。在较短的子序列中,由于经过快速排序的初步划分,可能已经接近有序或完全有序。这种情况下,插入排序能够利用序列的局部有序性,通过较少的比较和交换操作来完成排序。

    相比于其他排序算法(如冒泡排序或选择排序),插入排序在局部有序性较好的情况下,具有更好的性能。它的时间复杂度为 O(n^2),但在较短的子序列中,由于交换的次数较少,实际的运行时间较快。因此,选择插入排序作为小区间优化的替代算法,能够有效地提高快速排序在短数组上的性能,进一步优化算法的效率。

[!Attention] 需要注意的是,当序列长度小于某个较小的固定值(如10或20)时,可以考虑应用小区间优化。

  • C语言代码示例
//插入排序
void InsertSort(int* a, int n)
{
	for (int i = 0; i < n-1; ++i)
	{
		// [0, end] 有序,插入tmp依旧有序
		int end = i;
		int tmp = a[i+1];
		while (end >= 0)
		{
			if (a[end] > tmp)
			{
				a[end + 1] = a[end];
				--end;
			}
			else
			{
				break;
			}
		}
		a[end + 1] = tmp;
	}
}
// 三数取中法优化的快速排序递归实现(带小区间优化)
void QuickSortOptimizedWithInsertion(int* a, int begin, int end)
{
    // 小区间优化:当待排序子序列的长度小于等于10时,使用插入排序
    if (end - begin + 1 <= 10)
    {
        InsertSort(a + begin, end - begin + 1);
        return;
    }
	
    // 三数取中法选取基准数
    int mid = begin + (end - begin) / 2;
    if (a[begin] > a[mid])
    {
        Swap(&a[begin], &a[mid]);
    }
    if (a[mid] > a[end])
    {
        Swap(&a[mid], &a[end]);
    }
    if (a[begin] > a[mid])
    {
        Swap(&a[begin], &a[mid]);
    }
	
    // 将基准数交换到子序列的第二个位置
    Swap(&a[mid], &a[begin + 1]);
	
    // 使用霍尔法进行划分
    int KeyIndex = PartSort(a, begin, end);
	
    // 对左右子序列递归进行排序
    QuickSortOptimizedWithInsertion(a, begin, KeyIndex - 1);
    QuickSortOptimizedWithInsertion(a, KeyIndex + 1, end);
}

四、时间复杂度和空间复杂度(后续更新)

五、完整代码和接口函数

  • 代码:
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include "Stack.h"

void PrintArray(int* a, int n)
{
	for (int i = 0; i < n; ++i)
	{
		printf("%d ", a[i]);
	}
	printf("\n");
}

void Swap(int* a, int* b)
{
	int tmp = *b;
	*b = *a;
	*a = tmp;
}

// Hoare法单趟排序
int PartSort(int* a, int left, int right)
{
	// 选择基准元素的索引为 left
	int KeyIndex = left;
	while (left < right)
	{
		// right 从右侧开始,找到第一个小于基准元素的值
		while (left < right && a[right] >= a[KeyIndex])
		{
			--right;
		}
		// left 从左侧开始,找到第一个大于基准元素的值
		while (left < right && a[left] <= a[KeyIndex])
		{
			++left;
		}
		// 交换 left 和 right 的元素
		Swap(&a[left], &a[right]);
	}

	// 将基准元素放置在最终的位置
	Swap(&a[KeyIndex], &a[left]);
	return left;
}

// 挖坑法单趟排序
int PartSort2(int* a, int left, int right)
{
	// 选择基准元素为左侧第一个元素
	int Key = a[left];
	int HoleIndex = left; // 坑的下标,初始为基准元素位置

	while (left < right)
	{
		// 从右侧开始,找到第一个小于基准元素的值
		while (left < right && a[right] >= Key)
		{
			--right;
		}

		// 将右侧小于基准元素的值填充到左侧的坑中
		a[HoleIndex] = a[right];
		HoleIndex = right;

		// 从左侧开始,找到第一个大于基准元素的值
		while (left < right && a[left] <= Key)
		{
			++left;
		}

		// 将左侧大于基准元素的值填充到右侧的坑中
		a[HoleIndex] = a[left];
		HoleIndex = left;
	}

	// 将基准元素放置到最后一个坑中,即填充到最终的位置
	a[HoleIndex] = Key;
	return HoleIndex;
}

// 前后指针法
int PartSort3(int* arr, int left, int right)
{
	int key = left; // 将最左边的元素作为基准值
	int prev = left; // 后指针(动得慢)
	int cur = left + 1; // 前指针(动得快)

	while (cur <= right)
	{
		// 当cur遍历到的元素小于等于基准值时,先将prev指针向后移动一位,再将cur交换到prev指针指向的位置
		if (arr[cur] <= arr[key] && ++prev != cur)
		{
			Swap(&arr[cur], &arr[prev]);
		}
		cur++;
	}

	// 最后将基准值交换到prev指针指向的位置,以完成单趟排序
	Swap(&arr[key], &arr[prev]);
	return prev; // 返回基准值的位置,用于后续的划分
}

// 快速排序的递归实现
void QuickSort(int* a, int begin, int end)
{
	//两种情况下直接返回:1. 区间只有一个值 2. 区间不存在
	if (begin >= end)
	{
		return;
	}

	// 使用单趟排序函数 PartSort 对序列进行划分,并获取基准元素的位置
	int KeyIndex = PartSort(a, begin, end);

	// 对基准元素左侧的子序列排序
	QuickSort(a, begin, KeyIndex - 1);

	// 对基准元素右侧的子序列排序
	QuickSort(a, KeyIndex + 1, end);
}

// 非递归快速排序实现
void QuickSortNonR(int* a, int begin, int end)
{
	ST st; // 定义一个栈,用于模拟递归过程
	STInit(&st); // 初始化栈

	// 将初始的 begin 和 end 压入栈,相当于递归的入口
	STPush(&st, end);
	STPush(&st, begin);

	while (!STEmpty(&st))
	{
		// 从栈中取出当前处理的 begin 和 end
		int left = STTop(&st);
		STPop(&st);

		int right = STTop(&st);
		STPop(&st);

		// 调用 PartSort 函数对序列进行划分,并得到基准元素的位置
		int KeyIndex = PartSort(a, left, right);

		// 判断是否需要对右侧子序列进行排序,并将其入栈
		if (KeyIndex + 1 < right)
		{
			STPush(&st, right);
			STPush(&st, KeyIndex + 1);
		}

		// 判断是否需要对左侧子序列进行排序,并将其入栈
		if (left < KeyIndex - 1)
		{
			STPush(&st, KeyIndex - 1);
			STPush(&st, left);
		}
	}

	// 栈中所有任务处理完毕,排序完成
	STDestroy(&st);
}

// 三值取中法优化的快速排序递归实现
void QuickSortOptimized(int* a, int begin, int end)
{
	if (begin >= end)
	{
		return;
	}

	// 三值取中法选取基准数
	int mid = begin + (end - begin) / 2;
	if (a[begin] > a[mid])
	{
		Swap(&a[begin], &a[mid]);
	}
	if (a[mid] > a[end])
	{
		Swap(&a[mid], &a[end]);
	}
	if (a[begin] > a[mid])
	{
		Swap(&a[begin], &a[mid]);
	}

	// 将基准数交换到子序列的第二个位置
	Swap(&a[mid], &a[begin + 1]);

	// 使用霍尔法进行划分
	int KeyIndex = PartSort(a, begin, end);

	// 对左右子序列递归进行排序
	QuickSortOptimized(a, begin, KeyIndex - 1);
	QuickSortOptimized(a, KeyIndex + 1, end);
}

//插入排序
void InsertSort(int* a, int n)
{
	for (int i = 0; i < n - 1; ++i)
	{
		// [0, end] 有序,插入tmp依旧有序
		int end = i;
		int tmp = a[i + 1];
		while (end >= 0)
		{
			if (a[end] > tmp)
			{
				a[end + 1] = a[end];
				--end;
			}
			else
			{
				break;
			}
		}
		a[end + 1] = tmp;
	}
}

// 三数取中法优化的快速排序递归实现(带小区间优化)
void QuickSortOptimizedWithInsertion(int* a, int begin, int end)
{
	// 小区间优化:当待排序子序列的长度小于等于10时,使用插入排序
	if (end - begin + 1 <= 10)
	{
		InsertSort(a + begin, end - begin + 1);
		return;
	}

	// 三数取中法选取基准数
	int mid = begin + (end - begin) / 2;
	if (a[begin] > a[mid])
	{
		Swap(&a[begin], &a[mid]);
	}
	if (a[mid] > a[end])
	{
		Swap(&a[mid], &a[end]);
	}
	if (a[begin] > a[mid])
	{
		Swap(&a[begin], &a[mid]);
	}

	// 将基准数交换到子序列的第二个位置
	Swap(&a[mid], &a[begin + 1]);

	// 使用霍尔法进行划分
	int KeyIndex = PartSort(a, begin, end);

	// 对左右子序列递归进行排序
	QuickSortOptimizedWithInsertion(a, begin, KeyIndex - 1);
	QuickSortOptimizedWithInsertion(a, KeyIndex + 1, end);
}

int main()
{
	int arr[8] = { 55,74,32,67,12,23,49,87 };
	printf("Before:\n");
	PrintArray(arr, (sizeof(arr) / sizeof(int)));
	printf("After:\n");
	QuickSortOptimizedWithInsertion(arr, 0, (sizeof(arr) / sizeof(int))-1);
	PrintArray(arr, (sizeof(arr) / sizeof(int)));

	return 0;
}
  • 栈的接口函数:
void STInit(ST* pst)
{
	assert(pst);
	pst->a = NULL;
	pst->top = 0;      //top是栈顶的下一个位置的下标
	pst->capacity = 0;
}

void STDestroy(ST* pst)
{
	assert(pst);
	free(pst->a);
	pst->a = NULL;
	pst->capacity = pst->top = 0;
}

void STPush(ST* pst, STDataType x)
{
	if (pst->top == pst->capacity)
	{
		int newCapacity = pst->capacity == 0 ? 4 : pst->capacity*2;
		STDataType* tmp = realloc(pst->a, newCapacity*sizeof(STDataType));
		if (tmp == NULL)
		{
			perror("realloc fail");
			return;
		}
		pst->a = tmp;
		pst->capacity = newCapacity;
	}

	pst->a[pst->top] = x;
	pst->top++;
}

void STPop(ST* pst)
{
	assert(pst);
	assert(!STEmpty(pst));
	pst->top--;
}

STDataType STTop(ST* pst)
{
	assert(pst);
	assert(!STEmpty(pst));

	return pst->a[pst->top - 1];
}

int STSize(ST* pst)
{
	assert(pst);

	return pst->top;
}

bool STEmpty(ST* pst)
{
	assert(pst);

	if (pst->top == 0)
	{
		return true;
	}
	else
	{
		return false;
	}

}

六、参考

本文参考了:

  • 书籍:
    《大话数据结构》- 程杰
  • 博客:
    • CSDN博主「手眼通天王水水」的原创文章http://t.csdn.cn/2ZpHx
      遵循CC 4.0 BY-SA版权协议
  • 14
    点赞
  • 27
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 10
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 10
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

_宁清

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值