从此不再无序:八大排序算法总结(附Java、C源码)

8 篇文章 0 订阅
5 篇文章 0 订阅

前言

大家好!今天小编整理一下面试官常考的一大热点题型:“排序”。下面的文章将重点的几大排序做了解析,我们从冒泡、选择、插入、归并、快速、堆、计数和基数这八大经典的排序算法讲起,比如:希尔排序,在插入排序的基础上做了优化,本文就不在讲解,博客网站上有很多文章!!!

大部分公司都会注重查找和排序算法。应聘者可以在了解各种查找和排序算法的基础上,重点掌握二分查找、归并排序和快速排序。 还要对各种排序算法的时间、空间复杂度烂熟于心,了解它的优缺点。

我参考的文章有:十大经典排序算法总结(Java实现+动画)十大经典排序算法动画 和《大话数据结构》

Java语言源码:GitHub

C语言源码:Github

开始之前,先简单认识一下排序和相关术语的概览吧。

排序:假设含有n个记录(数据元素)的序列为{R1,R2,……,Rn},其相应的关键字分别为{k1,k2,……,kn},需确定1,2,……,n的一种排列p1,p2,……,pn,使其相应的关键字满足Kp1 <= Kp2 <= …… <=

Kpn 非递减(非递增)关系,即使序列成为一个按关键字有序的序列,这样的操作称为排序。

稳定性: 假设Ki = Kj(i和j都在区间[1,n],且i 不等于j),并且在排序前的序列中Ri 领先于Rj(即i<j)。 如果排序后Ri仍然领先于Rj,则称所用的排序方法是稳定的;反之,则称所用的排序方法是不稳定的。

内排序与外排序内排序是在排序整个过程中,待排序的所有数据全部被放置在内存中。外排序是由于排序的数据个数太多不能同时放置在内存中,整个排序过程需要在内外存之间多次交换数据才能进行。

这里我们先把swap交换函数给实现了,等会一下的代码就直接调用这个函数。

void swap(int arr[], int L, int R) 
{
	int tmp = arr[L];
    arr[L] = arr[R];
    arr[R] = tmp;
}

一、冒泡排序 (Bubble Sort)

冒泡排序:两两比较相邻的数据,如果反序则交换,直到没有反序的数据为止。

在实现冒泡排序的细节上,我们分为两种冒泡排序:初级版与改进版。

动图演示:

//冒泡排序初级版
void BubbleSort(int arr[], int arrLength) 
{
    int i = 0;
    for (i = 0; i < arrLength - 1; i++) //循环的躺数
    {
        int j = 0;
        for (j = 0; j < arrLength - 1 -i; j++) //一趟需要交换数据的对数
        {
            if(arr[j] > arr[j+1]) //升序
                swap(arr, j, j+1);
        }
    }
}

解析:第5行的for循环,控制了这组数据需要进行多少趟,上面动图中,第一次循环“48”在前面的位置,最后来到倒数第二的位置上,这样一次,我们称为一趟排序。假设这个arr数组共有10个元素,则整个循环完成,只需要循环9趟即可完成排序。(arrLength - 1)

​ 第8行的for循环,控制了这一趟中,有多少对数据进行比较大小,例如:arr数组共有10个元素,第一趟我们只需要比较9对元素,就能让最大的元素来到数组的末尾;第二趟,此时最大的元素已经来到了数组的末尾,我们就不需要再对它进行判断大小了。所以,第二层循环是跟着第一层循环的改变而改变的。

第一趟: 比较9对元素

第二趟:比较8对元素

……

综上:第二层循环的停止条件为 (arrLength - 1 - i)。

初级版的冒泡我们就叙述忘了,大家是否能够发现一些问题???

例如: 待排序的元素为 {2,1,3,4,5,6,7,8,9,10},此时我需要对这个10个元素排为升序,我们观察发现,只需要2和1交换位置,整个数组就是升序了。但我们的初级版冒泡排序会怎么样?

当第一趟排序执行完后,数组的情况是{1,2,3,4,5,6,7,8,9,10}。此时程序会停下来吗???

当然不会,它还是会“傻傻”的一直在循环判断,此时我们的算法就不是那么的高效,所以我们在初级版冒泡排序的基础上,加上了一个bool值,flag。

//冒泡排序改进版
void BubbleSort(int arr[], int arrLength) 
{
    if (arr == NULL)
        return; //空指针,提前退出
    
    int i = 0;
    int flag = 0; //因为C语言没有bool类型,就以整形代替。 
    for (i = 0; i < arrLength - 1 && flag != 1; i++) //一次循环完后,flag还是1,则有序
    {
        int j = 0;
        flag = 1;
        for (j = 0; j < arrLength - 1 -i; j++)
        {
            if(arr[j] > arr[j+1]) //升序
            {
                swap(arr, j, j+1);
                flag = 0; //如果if语句进来后,则说明当前数组还是无序的
            }
        }
    }
}

二、选择排序 (Selection Sort)

选择排序:通过n-i次元素之间的比较,从n-i+1个元素中选出最小(最大)的元素,跟第i个元素交换。

简单来说:看图。

void selectSort(int arr[], int arrLength)
{
    if (arr == NULL)
        return; //空指针,提前退出
    
    int i,j,minIndex;
    for (i = 0; i < arrLength; i++)
    {
        minIndex = i; 
        for (j = i + 1; j < arrLength; j++)
        {
            if (arr[minIndex] < arr[j]) 
                minIndex = j; //保存最小值的下标
        }
        if (minIndex != i)
            swap(arr, minIndex, i); //如果minIndex不是i,说明有最小值
    }
}

三、插入排序 (Insert Sort)

插入排序:将一个数据插入到已经排好序的有序表中,从而得到一个新的、数据个数增1的有序表。

动图演示:

void insertSort(int arr[], int arrLength)
{
    if (arr == NULL)
        return; //空指针,提前退出
    
    int i = 0;
    int j = 0;
    for (i = 1; i < arrLength; i++)
    {
        int insertValue = arr[i];
        for (j = i - 1; j >= 0 && insertValue < arr[j]; j--) //insertValue < arr[j]
        {
            arr[j + 1] = arr[j]; //前一个数据往后移动
        }
       if (insertValue != arr[i]) //如果经过循环后,这两个不相等,说明循环里面移动过数据
          arr[++j] = insertValue; //这里值得注意的是,for循环停止时,j-- 已经自减了
    }
}

插入排序,就像我们过年时,几个小伙伴一起斗地主一样,每从桌上拿起一张牌,我们就会按照3 4 5 6 7……J Q K A,的顺序进行排列。插入的过程中,其余的牌就要整体移动,给插入的这张牌让一个位置。

四、归并排序 (Merger Sort)

归并排序:就是将一组数据进行二分拆开成左、右两个数组,分别使左右两个数组有序后,再合并到一起。

将大问题拆分为小问题,然而小问题也可以拆分为更小的问题,可以考虑递归函数。

先看动图:

//递归解法
#define MAXNUM 20  //数组最大元素个数
void mergerSort1(int arr[], int left, int right)
{
    if (arr == NULL || left == right)
        return;
    
    int mid = left + ((right - left) >> 1); //取中间数,也就是 (left + right)/2
    mergerSort(arr, left, mid); //递归调用左数组
    mergerSort(arr, mid+1, right); //递归调用右数组
    merger(arr, left, mid, right); //左右数组分别有序后,合并到一起
}

void merger(int arr[], int left, int mid, int right)
{
    int help[MAXNUM]= {0}; //用于临时存储两个数组合并时的有序数组
    int i = 0; //指向help数组
    int p1 = left; //指向左数组
    int p2 = mid + 1; //指向右数组
    while (p1 <= mid && p2 <= right)
        help[i++] = arr[p1] < arr[p2]? arr[p1++] : arr[p2++]; //谁更小,就放入help数组
    
    while (p1 <= mid)  //左边数组还有数据,就放入help
        help[i++] = arr[p1++];
    
    while (p2 <= right) //右边数组还有数据,就放入help
        help[i++] = arr[p2++];
    
    //将help数组的所有数据按照顺序放入原数组arr
    int j = 0;
    for (j = 0; j < i; j++)
        arr[left+j] = help[j]; //注意是从left位置处开始拷贝
}

将整个数组分成小块,将每个小块的数据变为有序后,再合并起来,这就是归并。

想要写降序,第20行的三目操作符修改一下就可以!

还有就是第7行的取中间数,为什么要这样写??两点原因

  1. 位运算的速度远快于普通的加减乘除!
  2. 考虑溢出的情况,例如int最大的32亿左右,如果此时我的left是19亿,right是19亿,此时二者相加就溢出了整形的范围。
//非递归解法
void mergerSort2(int arr[],int arrLength)
{
    int mergerSize = 1; //表示左数组的元素个数,最开始时,左边元素个数1个,右边也为1个
    while (mergerSize < arrLength)
    {
        int L = 0;
        while (L < arrLength) //一趟
        {
            int M = L + mergerSize - 1; //取中间值
            if(M >= arrLength)
                break; //如果中间值大于等于数组的长度,则提前退出
            
            //取右边数组的范围
            int R = (M + mergerSize) < (arrLength - 1)? M+mergerSize:arrLength-1;
       		merger(arr,L,M,R); //还是调用归并函数
            L = R + 1;
        }
        
        if(mergerSize > arrLength / 2)
            return; //防止整形溢出,提前判断一下
        
        mergerSize <<= 1; //乘2     mergerSize = mergerSize * 2;
    }
}

void merger(int arr[], int left, int mid, int right)
{
    int help[MAXNUM]= {0}; //用于临时存储两个数组合并时的有序数组
    int i = 0; //指向help数组
    int p1 = left; //指向左数组
    int p2 = mid + 1; //指向右数组
    while (p1 <= mid && p2 <= right)
        help[i++] = arr[p1] < arr[p2]? arr[p1++] : arr[p2++]; //谁更小,就放入help数组
    
    while (p1 <= mid)  //左边数组还有数据,就放入help
        help[i++] = arr[p1++];
    
    while (p2 <= right) //右边数组还有数据,就放入help
        help[i++] = arr[p2++];
    
    //将help数组的所有数据按照顺序放入原数组arr
    int j = 0;
    for (j = 0; j < i; j++)
        arr[left+j] = help[j]; //注意是从left位置处开始拷贝
}

非递归与递归二者的区别?

当我们递归调用到数据的最底层,最先移动的数据还是下标为0和下标为1的数据,这二者进行比较,此时,其他的参数在栈区里面保存着,当这两个数据操作完成之后,才返回函数,去进入下一个下标2、3的数据进行比较和排序。

反观非递归的解法,则直接定义最开始时,左右数组的元素个数(mergerSize),当下标为0、1的数据操作之后,L直接跳到2、3的位置,一趟排序之后,此时数组中每两个数据是有序的; 此时扩大mergerSize为2,即就是左右数组大小各为2,再次重复上面的操作。

五、快速排序 (Quick Sort)

在讲解快速排序问题之前,我们先了解一下“荷兰国旗问题”。

荷兰国旗问题

从上面的这个题目,我们可以提取出一些算法思想。

假设待排序的数组是{10, 30, 50, 40, 20, 60, 40},我们将数组的最后一个元素40作为“中心点”,将数组中的所有数据都跟“中心点”(40)做比较,比中心点小的,放到数组的前面,比中心点大的放到数组的后面,等于中心点的放到中间。 这样,我们将整个数组分为了三个区域:< 区、= 区、> 区

经过上面的步骤得到以下数组:

  • {10,30,20,40,60,50,40}; 此时蓝色区域就是 < 区,绿色区域就是 >区。
  • 此时将数组最后一个元素40绿色区域的第一个元素交换位置
  • 得到{10,30,20 ,40,40, 50,60};

现在看上去,整体从左到右就是一个升序,至于 <区 和 >区 再重复以上步骤就能使其有序。又是递归函数

画图理解一下其中的算法思想,这样才更容易下面的代码!!!

void quickSort(int arr[], int arrLength)
{
    if (arr == NULL || arrLength < 2)
        return;
    process(arr, 0, arrLength - 1);
}

void process(int arr[], int left, int right)
{
    if (left >= right)
        return;

    //随机数srand放入主函数
    swap(arr, left + rand() % (right - left + 1), right); //从数组中随机抽取一个元素与数组最后的元素交换位置
                                                        //这里也是快排时间复杂度变为O(N logN)的原因

    int LMid, RMid; //三个区中,等于区的第一个元素下标,和最后一个元素下标----也就是上面文字解释中的{40,40}的下标
    netherlandsFlag(arr, left, right, &LMid, &RMid); //就是上面解释的,将数组分为三个区
    process(arr, left, LMid - 1); //递归调用 <区的数据,上面文字解释中的 {10,30,20}
    process(arr, RMid + 1, right); //递归调用 >区的数据,上面文字解释中的 {50,60}
}

void netherlandsFlag(int arr[], int left, int right, int* LMid, int* RMid)
{
    if (left > right)
    {
        *LMid = *RMid = -1;
        return;
    }
    if (left == right)
    {
        *LMid = *RMid = left;
        return;
    }

    int minRange = left - 1; //<区范围
    int maxRange = right; //>区范围,将最后一个元素先放到>区范围,总共整体排序后,与>区的第一个元素交换
    int index = left; //循环判断的索引值

    while (index < maxRange) //索引值不跟>区范围遇到,循环继续
    {
        if (arr[index] < arr[right])
            swap(arr, index++, ++minRange); //放入<区,过后,索引值index++
        else if (arr[index] > arr[right])
            swap(arr, index, --maxRange); //放入>区,过后,索引值不变,因为从>区过来的值还不知道是大是小,所以还需要判断
        else
            index++; //相等的话,不交换,索引值index++即可
    }

    swap(arr, maxRange, right); //>区的第一个元素与数组的最后一个元素交换
    *LMid = minRange + 1; //等于区的第一个元素
    *RMid = maxRange; //等于区的最后一个元素
}

快速排序的时间复杂度能优化到O(N logN),最为关键的就是第14行的交换函数!!!

只有当“中心点”取到数组正中间时,此时的时间复杂度才是最好的,当取到数组的两边时,时间复杂度就很高了。所以加了随机数,使之“中心点”在数组中的出现是等概率的,具体的证明方式,就得看数学功底了!!!

六、堆排序 (Heap Sort)

将堆排序之前,我们先来了解了解“完全二叉树”是个什么!!!

如上图所示

左边是完全二叉树,判断的理由是: 整棵树从上到下,从左到右,没有缺失结点。

右边则不是完全二叉树:理由是: 整颗树从上到下,从左到右,在第三层第二个结点处断了,而第三个结点又还在,形成了一个“空位”,则不是完全二叉树。若将第三层的第三个结点移动到这一层第第二个结点处,则就会变为完全二叉树,如下图:

了解了完全二叉树。 我们就直接进入正题。以大根堆为例,小根堆就是改一下条件即可!

大根堆:顾名思义,就是将数值大的作为根,有以下性质:

  1. 左孩子结点 小于或等于 根结点。
  2. 右孩子结点 大于或等于 根结点。

将整个数组变为大根堆之后,此时根结点当前数组中最大的数值,我们就把他取出来,与数组的最后一个元素进行交换即可。

这里需要处理的问题就是:

  • 取出根结点的值后,如何让整颗树还是保持大根堆的形式??

带着这个问题,我们来看代码。

void heapSort(int arr[], int arrLength)
{
    if(arr == NULL || arrLength < 2)
        return;
    
    int i = 0;
    for (i = 0; i < arrLength; i++)
        heapInsert(arr, i); //大根堆的形式插入
    
    //经过上面的循环之后,形成了大根堆。此时就将根结点的数据与数组中最后一个元素进行交换
    //交换之后,根结点的数据并不是此时这颗树的最大值,所有将这个结点的数据往下层移动
    int heapSize = arrLength;
     swap(arr,0,--heapSize);
    while (heapSize > 0)
    {
        heapify(arr,0,heapSize); //判断左右孩子结点和根结点的关系,条件成立,就往下层移动
        swap(arr,0,--heapSize);
    }
}

void heapInsert(int arr[], int index)
{
    //数组是以下标为0处开始放入数据的
    //则根结点和左右孩子有以下关系
    //index 的根结点index/2, index的 左孩子为index*2+1,右孩子就是index*2+2
    //新插入的结点,插入到叶子结点处,然后去寻找父节点,判断二者之间的大小,比父节点大的话,就要往上移动
    while (arr[index] > arr[(index-1) / 2])
    {
        swap(arr,index,(index-1) / 2);
        index = (index-1) / 2;
    }
}

void heapify(int arr[], int index, int heapSize)
{
    int leftChild = index * 2 + 1; //拿到左孩子的结点下标
    while (leftChild < heapSize) 
    {
        int maxChild = leftChild + 1 < heapSize && arr[leftChild] < arr[leftChild + 1]?
            		leftChild + 1 : leftChild; //判断左右孩子的大小
        maxChild = arr[index] > arr[maxChild]? index: maxChild; //判断根结点与左右孩子中最大的 那个孩子的  大小

        if(maxChild == index) //如果最大数值下标就是index,说明不需要向下层移动
			break;
        swap(arr,index, maxChild);
        index = maxChild;
        leftChild = index * 2 + 1;
    }
}

七、计数排序 (Counting Sort)

计数排序:统计这组数据中所有元素出现的次数。

既然要统计所有元素出现的次数,所以对数据本身的要求就很高!!!

例如: 要统计幼儿园所有小盆友的年龄情况,假设年龄最小到几个月,最大的也不过10岁左右,所以我们只需要开辟一个11个单位空间的数组,下标分别表示年龄,每个单位空间里记录的是这个年龄的有多少小盆友。例如 :下标为2的空间,有20个小盆友是2岁,那这块空间的内容就是20.

//计数排序
void countSort(int arr[], int length)
{
    int countAge[10] = { 0 }; //需要对数据本身的范围,来创建数组的大小
    int i = 0;
    int j = 0;
    for (i = 0; i < length; i++)
        countAge[arr[i]]++;

    //将countAge的数据全部放回arr数组
    for (i = 0; i < length; i++)
        while (countAge[i]--)
            arr[j++] = i;
}

时间复杂度

计数排序很明显是一种通过空间来换时间的算法,因为我们可以很明显的看到计数排序需要三次遍历,两次遍历我们的原序列,第三次是遍历我们的区间数组.那么很明显时间复杂度一定是线性级别的但是因为第三次遍历的并不是我们的原序列,而是我们的区间数组,所以时间复杂度并不是我们的平常的O(n),而是应该加上我们遍历区间数组的时间,假设我们的区间数组长度为k的话,那么我们的时间复杂度就是O(n)

空间复杂度

上面我们已经说过了,计数排序本身就是一个通过空间来换取时间的算法,所以很明显他的空间复杂度就会很高.并且这个空间复杂度主要就取决于我们区间数组的长度,所以假设我们的区间数组长度为k的话,那么我们的空间复杂度就为O(k)

八、基数排序 (Radix Sort)

**基数排序(**Radix Sort)是桶排序的扩展,它是这样实现的:将整数按位数切割成不同的数字,然后按每个位数分别比较

桶排序他不是一种具体的排序算法,他只是一种思想,计数排序和基数排序都是桶排序的一种实现。

例如: 数组{34,56,72,88,90,64;对这个数组进行基数排序,就是将每个数值的“个位”先排序,我们先准备10个“”,分别对应的值是0~9,第一个元素“34”,个位是4,所以我们将34放入4号桶,第二个元素是“56”,个位是6,就将这个数放入6号桶,后面依次这样放入,如下图:

现在对个位进行了排序,然后像队列一样(先进先出),按照从0号桶到9号桶的顺序,从下面弹出元素:{90, 72, 34, 64, 56, 88};

然后循环往复,对十位、百位都进行这样的操作,很显然,基数排序的数据越小,越容易排序,当数据越大时,需要进行的操作就大。

//基数排序
#define MAXNUM 20
void radixSort(int arr[], int arrLength, int digit)
{
    int help[MAXNUM] = { 0 }; //有多少个数,就开辟多大的内存空间
    int d, i, j;

    for (d = 1; d <= digit; d++) //有几位数,就循环几次
    {
        int count[10] = { 0 }; //10个桶
        for (i = 0; i < arrLength; i++) //得到count数组
        {
            j = getDigit(arr[i], d); //得到这个数字,在d位上的数值。例如 个位是4
            count[j]++; //对应的桶个数值加一
        }

        for (i = 1; i < 10; i++) //得到count' 数组
            count[i] = count[i] + count[i - 1];

        for (i = arrLength - 1; i >= 0; i--) //从数组的最后一个元素开始遍历,例如64,一定是4号桶的最后一个进入的元素
        {
            j = getDigit(arr[i], d); //拿到数值d位上的数,例如 个位是4 
            help[count[j] - 1] = arr[i];
            count[j]--;
        }

        for (i = 0, j = 0; i < arrLength; i++, j++)
            arr[i] = help[j];      
    }
}
int getDigit(int num, int d) //计算相应位置上的素数值
{
    while (--d)
        num /= 10;
    return num % 10;
}

解释上诉代码:

这个代码,读起来似乎跟我上面的说的基数排序,好像并不沾边!!!这也是这个排序算法有点难的原因。。。

首先最外层第8行的循环,是对数组中最大元素的位数,假设这个数组中最大的元素是1000,则digit就是4,外层循环就是循环4次。。

然后就是第10行~29行,这四个for循环分别的作用是 计算第一次count数值计算第二次count数组将arr中的数据放入help数组将help数组元素重新放回arr数组

  1. 第一次count数组: 将所有数据的每一位的数值,分别放到0~9号桶中。
  2. 第二次count数组: 根据第一次count数组的数据,计算第二次count数组。例如:第一次count数组的情况如下:
  1. 从原数组arr的最后一个元素开始取出,假设数组的最后一个元素就是上图中的64,个位是4,4号桶里面的内容是4,说明个位数小于等于4的有4个(90,72,34,64),而从数组的最后一个元素开始取出,是64,说明,64是在下标为**(4 - 1)** 的位置上的。
  2. 第三步的循环走完之后,help数组里面的元素,是按个位的顺序进行排序的,此时相对于个位数来说,已经有序,此时将help数组的数据全部重新放回arr原数组,然后进入第二次的外层循环(十位)…… 循环往复。。。。

最简单的就是输入10个 小于10的数,例如:{2,1,3,6,5,8,7,9,0,9},一步一步去执行,观察变化,即可理解其中的奥秘。

总结

时间复杂度空间复杂度稳定性
冒泡排序O(N2)O(1)
选择排序O(N2)O(1)没有
插入排序O(N2)O(1)
归并排序O(N log2N)O(N)
快速排序O(N log2N)O(log2N)没有
堆排序O(N log2N)O(1)没有
计数排序O(N)O(K)
基数排序O(N)O(N)

上诉的8种排序算法,稳定的有:冒泡排序、插入排序、归并排序,计数与基数排序; 不稳定就有选择排序、随机快速排序、堆排序。

冒泡、插入、归并三种排序方法,稳不稳定,还得看实现源码在判断是否需要交换两个数据时的判断条件,例如:{1,41,3,5,42};当在判断数据 “41”与“42”的大小关系时,他两个相等,你还是把他们交换了,那这个排序就不在有稳定性。

常见的坑:

  1. 归并排序的空间复杂度可以变为O(1),但是将变得不再稳定。 具体的帖子: “归并排序 内部缓存法‘,只需要了解即可。
  2. 快速排序稳定性改进,但是会对样本数据要求过高。具体帖子: “01 stable sort”,只需要了解即可。

工程上对排序算法的优化: 对于一个工程来说,需要处理的数据量是非常大的,此时如果只有单一的一种排序算法,并不能达到最优的时间和空间上追求。此时我们就可以使用 “组合排序”,也就是说在原本数据量很大的时候,我们使用快速排序,利用快速排序的时间复杂度为O(N logN)的优势,当处理过后的数据不是很多的时候,我们就使用冒泡排序等 ,来优化了空间复杂度,具体的怎么是搭配适用,还是得看你具体的应用场景和追求的是什么!!!

每一种排序算法,都有它的应用场景,具体该使用什么排序算法,还是得看你追求的是什么。八大排序算法就介绍到此了,深入理解这八大排序算法,我相信你会得到面试官的青睐的!!!

下期见!

评论 11
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

听雨7x

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

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

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

打赏作者

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

抵扣说明:

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

余额充值