java 排序 复杂度_八大排序算法详解(动图演示 思路分析 实例代码java 复杂度分析 适用场景)...

一、分类

1.内部排序和外部排序

内部排序:待排序记录存放在计算机随机存储器中(说简单点,就是内存)进行的排序过程。

外部排序:待排序记录的数量很大,以致于内存不能一次容纳全部记录,所以在排序过程中需要对外存进行访问的排序过程。

60568e738247ee4359e4040bc3f6c420.png

2.比较类排序和非比较排序

比较类排序:通过比较来决定元素间的相对次序,由于其时间复杂度不能突破O(nlogn),因此也称为非线性时间比较类排序。

非比较类排序:不通过比较来决定元素间的相对次序,它可以突破基于比较排序的时间下界,以线性时间运行,因此也称为线性时间非比较类排序。

de5de9d6aad75ab2effe4b5a2fbc1029.png

二、复杂度分析,算法稳定性和适用场景

稳定:如果a原本在b前面,而a=b,排序之后a仍然在b的前面。

不稳定:如果a原本在b的前面,而a=b,排序之后 a 可能会出现在 b 的后面。

时间复杂度:对排序数据的总的操作次数。反映当n变化时,操作次数呈现什么规律。

空间复杂度:是指算法在计算机内执行时所需存储空间的度量,它也是数据规模n的函数。

1f123c58b50874fa19ae2ba55b0fad8b.png

三、八大排序算法详解

1.选择排序

1.1 动图演示

610994c6b85dd52f5bc1ec5814942763.gif

1.2 思路分析

1. 第一个跟后面的所有数相比,如果小于(或小于)第一个数的时候,暂存较小数的下标,第一趟结束后,将第一个数,与暂存的那个最小数进行交换,第一个数就是最小(或最大的数)

2. 下标移到第二位,第二个数跟后面的所有数相比,一趟下来,确定第二小(或第二大)的数

重复以上步骤

直到指针移到倒数第二位,确定倒数第二小(或倒数第二大)的数,那么最后一位也就确定了,排序完成。

1.3  复杂度分析

1. 不管原始数组是否有序,时间复杂度都是O(n2),

因为没一个数都要与其他数比较一次,(n-1)2次,分解:n2-2n+1,  去掉低次幂和常数,剩下n2,所以最后的时间复杂度是n2

2.  空间复杂度是O(1),因为只定义了两个辅助变量,与n的大小无关,所以空间复杂度为O(1)

1.4  Java 代码如下:

importjava.util.Arrays;public classMain {public static voidmain(String[] args) {int[] n = new int[]{1,6,3,8,33,27,66,9,7,88};int temp,index = -1;for (int i = 0; i < n.length-1; i++) {

index=i;//如果大于,暂存较小的数的下标

for (int j = i+1; j n[j]){

index=j;

}

}将一趟下来求出的最小数,与这个数交换

if(index>0){

temp=n[i];

n[i]=n[index];

n[index]=temp;

}

System.out.println(Arrays.toString(n));

}

System.out.println(Arrays.toString(n));

}

}

2. 冒泡排序

2.1  动图演示

aaf1cb710c2c4fb4cc5b586f78230edf.gif

2.2  思路分析

1. 相邻两个数两两相比,n[i]跟n[j+1]比,如果n[i]>n[j+1],则将连个数进行交换,

2.  j++, 重复以上步骤,第一趟结束后,最大数就会被确定在最后一位,这就是冒泡排序又称大(小)数沉底,

3.  i++,重复以上步骤,直到i=n-1结束,排序完成。

2.3  复杂度分析

1. 不管原始数组是否有序,时间复杂度都是O(n2),

因为没一个数都要与其他数比较一次,(n-1)2次,分解:n2+2n-1,  去掉低次幂和常数,剩下n2,所以最后的时间复杂度是n2

2.  空间复杂度是O(1),因为只定义了一个辅助变量,与n的大小无关,所以空间复杂度为O(1)

2.4  选择排序和冒泡排序的比较

1. 时间负责度都是O(n2)

2. 空间复杂度都是O(1)

3. 选择排序是从第一位开始确定最大或最小的数,保证前面的数都是有序的,且都比后面的数小或大,

冒泡排序是从最后一位开始确定最大或最小的数,保证后面的数都是有序的且都大于或小于前面的数。

2.5  Java 代码如下

importjava.util.Arrays;public class冒泡 {public static voidmain(String[] args) {int[] n = new int[]{1,6,3,8,33,27,66,9,7,88};inttemp;for (int i = 0; i < n.length-1; i++) {for (int j = 0; j n[j+1]){

temp=n[j];

n[j]= n[j+1];

n[j+1] =temp;

}

}

}

System.out.println(Arrays.toString(n));

}

}

3. 直接插入排序

3.1  动图演示

4a33b09804a05e21f228a67b915e1bff.png

3.2  思路分析

例如从小到大排序:

1. 从第二位开始遍历,

2. 当前数(第一趟是第二位数)与前面的数依次比较,如果前面的数大于当前数,则将这个数放在当前数的位置上,当前数的下标-1,

3.  重复以上步骤,直到当前数不大于前面的某一个数为止,这时,将当前数,放到这个位置,

1-3步就是保证当前数的前面的数都是有序的,内层循环的目的就是将当前数插入到前面的有序序列里

4. 重复以上3步,直到遍历到最后一位数,并将最后一位数插入到合适的位置,插入排序结束。

根据思路分析,每一趟的执行流程如下图所示:

650aa1fb40a5a6d2915fcda445f88dac.png

3.3  复杂度分析

1.  时间复杂度:插入算法,就是保证前面的序列是有序的,只需要把当前数插入前面的某一个位置即可。

所以如果数组本来就是有序的,则数组的最好情况下时间复杂度为O(n)

如果数组恰好是倒=倒序,比如原始数组是5 4 3 2 1,想要排成从小到大,则每一趟前面的数都要往后移,一共要执行n-1 + n-2 + … + 2 + 1 = n * (n-1) / 2 =0.5 * n2 - 0.5 * n次,去掉低次幂及系数,所以最坏情况下时间复杂度为O(n2)

平均时间复杂度(n+n2 )/2,所以平均时间复杂度为O(n2)

2.  空间复杂度:插入排序算法,只需要两个变量暂存当前数,以及下标,与n的大小无关,所以空间复杂度为:O(1)

3.4  Java 代码如下

importjava.util.Arrays;public classinsertSort {public static voidmain(String[] args) {int[] n = new int[]{20,12,15,1,5,49,58,24,578,211,20,214,78,35,125,789,11};int temp = 0,j;for (int i = 1; i < n.length; i++) {

temp=n[i];for (j = i; j >0; j--) {//如果当前数前面的数大于当前数,则把前面的数向后移一个位置

if(n[j-1]>temp){

n[j]= n[j-1];//第一个数已经移到第二个数,将当前数放到第一个位置,这一趟结束

if(j==1){

n[j-1] =temp;break;

}

}else{//如果不大于,将当前数放到j的位置,这一趟结束

n[j]=temp;break;

}

}

System.out.println(Arrays.toString(n));

}

System.out.println(Arrays.toString(n));

}

}

4. 快速排序

4.1  动图演示

3665ecc521de0dad7bf8ca2f4fda2cd8.png

4.2  思路分析

快速排序的思想就是,选一个数作为基数(这里我选的是第一个数),大于这个基数的放到右边,小于这个基数的放到左边,等于这个基数的数可以放到左边或右边,看自己习惯,这里我是放到了左边,

一趟结束后,将基数放到中间分隔的位置,第二趟将数组从基数的位置分成两半,分割后的两个的数组继续重复以上步骤,选基数,将小数放在基数左边,将大数放到基数的右边,在分割数组,,,直到数组不能再分为止,排序结束。

例如从小到大排序:

1. 第一趟,第一个数为基数temp,设置两个指针left = 0,right = n.length,

①从right开始与基数temp比较,如果n[right]>基数temp,则right指针向前移一位,继续与基数temp比较,直到不满足n[right]>基数temp

②将n[right]赋给n[left]

③从left开始与基数temp比较,如果n[left]<=基数temp,则left指针向后移一位,继续与基数temp比较,直到不满足n[left]<=基数temp

④将n[left]赋给n[rigth]

⑤重复①-④步,直到left==right结束,将基数temp赋给n[left]

2. 第二趟,将数组从中间分隔,每个数组再进行第1步的操作,然后再将分隔后的数组进行分隔再快排,

3. 递归重复分隔快排,直到数组不能再分,也就是只剩下一个元素的时候,结束递归,排序完成

根据思路分析,第一趟的执行流程如下图所示:

6c812939a916cafd2108bb5de4ea4302.png

4.3  复杂度分析

1.  时间复杂度:

最坏情况就是每一次取到的元素就是数组中最小/最大的,这种情况其实就是冒泡排序了(每一次都排好一个元素的顺序)

这种情况时间复杂度就好计算了,就是冒泡排序的时间复杂度:T[n] = n * (n-1) = n^2 + n;

最好情况下是O(nlog2n),推导过程如下:

(递归算法的时间复杂度公式:T[n] = aT[n/b] + f(n) )

e811f0afc688c937168f65930c87319b.png

所以平均时间复杂度为O(nlog2n)

2.  空间复杂度:

快速排序使用的空间是O(1)的,也就是个常数级;而真正消耗空间的就是递归调用了,因为每次递归就要保持一些数据:

最优的情况下空间复杂度为:O(log2n);每一次都平分数组的情况

最差的情况下空间复杂度为:O( n );退化为冒泡排序的情况

所以平均空间复杂度为O(log2n)

4.4  Java 代码如下

importjava.util.Arrays;public classquick{public static voidmain(String[] args) {int[] arr = new int[]{10,6,3,8,33,27,66,9,7,88};//int[] arr = new int[]{1,3,2};

f(arr,0,arr.length-1);

System.out.println(Arrays.toString(arr));

}public static void f(int[] arr,int start,intend){//直到start>=end时结束递归

if(start

while(lefttemp){

right--;

}//右边的数字及下标小于或等于基本数,将右边的数放到左边

if(left

arr[left]=arr[right];

left++;

}左边的数字小于或等于标准数时,左边的数的位置不变,指针向右移一个位置

while(left

left++;

}//左边的数字大于基本数,将左边的数放到右边

arr[right] =arr[left];

}//一趟循环结束,此时left=right,将基数放到这个重合的位置,

arr[left] =temp;//将数组从left位置分为两半,继续递归下去进行排序

f(arr,start,left);

f(arr,left+1,end);

}

}

}

5. 归并排序

5.1  动图演示

ab8af6d5be4176a60ca86901c7375917.gif

5.2  思路分析

归并排序就是递归得将原始数组递归对半分隔,直到不能再分(只剩下一个元素)后,开始从最小的数组向上归并排序

1.  向上归并排序的时候,需要一个暂存数组用来排序,

2.  将待合并的两个数组,从第一位开始比较,小的放到暂存数组,指针向后移,

3.  直到一个数组空,这时,不用判断哪个数组空了,直接将两个数组剩下的元素追加到暂存数组里,

4.  再将暂存数组排序后的元素放到原数组里,两个数组合成一个,这一趟结束。

根据思路分析,每一趟的执行流程如下图所示:

ce54d9568f92e6f0ca0a78d8d0cdf80d.png

5.3  复杂度分析

1.  时间复杂度:递归算法的时间复杂度公式:T[n] = aT[n/b] + f(n)

e811f0afc688c937168f65930c87319b.png

无论原始数组是否是有序的,都要递归分隔并向上归并排序,所以时间复杂度始终是O(nlog2n)

2.  空间复杂度:

每次两个数组进行归并排序的时候,都会利用一个长度为n的数组作为辅助数组用于保存合并序列,所以空间复杂度为O(n)

5.4  Java 代码如下

importjava.util.Arrays;public classMain {public static voidmain(String[] args) {int[] arr = new int[]{3,6,4,7,5,2};

merge(arr,0,arr.length-1);

System.out.println(Arrays.toString(arr));

}//归并

public static void merge(int[] arr,int low,inthigh){int center = (high+low)/2;if(low

merge(arr,low,center);

merge(arr,center+1,high);//当数组不能再分,开始归并排序

mergeSort(arr,low,center,high);

System.out.println(Arrays.toString(arr));

}

}//排序

public static void mergeSort(int[] arr,int low,int center,inthigh){//用于暂存排序后的数组的临时数组

int[] tempArr = new int[arr.length];int i = low,j = center+1;//临时数组的下标

int index = 0;//循环遍历两个数组的数字,将小的插入到临时数组里

while(i<=center && j<=high){//左边数组的数小,插入到新数组

if(arr[i]

tempArr[index]=arr[i];

i++;

}else{//右边数组的数小,插入到新数组

tempArr[index] =arr[j];

j++;

}

index++;

}//处理左半边数组多余的数据,将左半边多余的数据直接追加的临时数组的后面

while(i<=center){

tempArr[index]=arr[i];

i++;

index++;

}//处理右半边数组多余的数据,将右半边多余的数据直接追加的临时数组的后面

while(j<=high){

tempArr[index]=arr[j];

j++;

index++;

}//将临时数组中的数据重新放进原数组

for (int k = 0; k < index; k++) {

arr[k+low] =tempArr[k];

}

}

}

6. 基数排序

6.1  动图演示

c8b96c93d3fa6137272b4734f3bddbee.png

6.2  思路分析

基数排序第i趟将待排数组里的每个数的i位数放到tempj(j=1-10)队列中,然后再从这十个队列中取出数据,重新放到原数组里,直到i大于待排数的最大位数。

1.数组里的数最大位数是n位,就需要排n趟,例如数组里最大的数是3位数,则需要排3趟。

2.若数组里共有m个数,则需要十个长度为m的数组tempj(j=0-9)用来暂存i位上数为j的数,例如,第1趟,各位数为0的会被分配到temp0数组里,各位数为1的会被分配到temp1数组里......

3.分配结束后,再依次从tempj数组中取出数据,遵循先进先进原则,例如对数组{1,11,2,44,4},进行第1趟分配后,temp1={1,11},temp2={2},temp4={44,4},依次取出元素后{1,11,2,44,4},第一趟结束

4.循环到n趟后结束,排序完成

根据思路分析,每一趟的执行流程如下图所示:

通过基数排序对数组{53, 3, 542, 748, 14, 214, 154, 63, 616}:

5c6f78a2fa3c7b8afb64e031dcf6be73.png

6.3  复杂度分析

1.  时间复杂度:

每一次关键字的桶分配都需要O(n)的时间复杂度,而且分配之后得到新的关键字序列又需要O(n)的时间复杂度。

假如待排数据可以分为d个关键字,则基数排序的时间复杂度将是O(d*2n) ,当然d要远远小于n,因此基本上还是线性级别的。

系数2可以省略,且无论数组是否有序,都需要从个位排到最大位数,所以时间复杂度始终为O(d*n) 。其中,n是数组长度,d是最大位数。

2.  空间复杂度:

基数排序的空间复杂度为O(n+k),其中k为桶的数量,需要分配n个数。

6.4  Java 代码如下

importjava.util.Arrays;public classMain {public static voidmain(String[] args) {int[] arr = new int[]{10,6,3,8,33,27,66,9,7,88};

radixSort(arr);

}private static void radixSort(int[] arr) {//求出待排数的最大数

int maxLength=0;for (int i = 0; i < arr.length; i++) {if(maxLength

maxLength=arr[i];

}//根据最大数求最大长度

maxLength = (maxLength+"").length();//用于暂存数据的数组

int[][] temp = new int[10][arr.length];//用于记录temp数组中每个桶内存的数据的数量

int[] counts = new int[10];//用于记录每个数的i位数

int num = 0;//用于取的元素需要放的位置

int index = 0;//根据最大长度决定排序的次数

for (int i = 0,n=1; i < maxLength; i++,n*=10) {for (int j = 0; j < arr.length; j++) {

num= arr[j]/n%10;

temp[num][counts[num]]=arr[j];

counts[num]++;

}//从temp中取元素重新放到arr数组中

for (int j = 0; j < counts.length; j++) {for (int j2 = 0; j2 < counts[j]; j2++) {

arr[index]=temp[j][j2];

index++;

}

counts[j]=0;

}

index=0;

}

System.out.println(Arrays.toString(arr));

}

}

7. 希尔(shell)排序

7.1  动图演示

9b2d1b9b2a7c5eae4db42d94d3cfb748.gif

7.2  思路分析

希尔排序是把记录按下标的一定增量分组,对每组使用直接插入排序算法排序;随着增量逐渐减少,每组包含的关键词越来越多,当增量减至1时,整个文件恰被分成一组,算法便终止。

简单插入排序很循规蹈矩,不管数组分布是怎么样的,依然一步一步的对元素进行比较,移动,插入,比如[5,4,3,2,1,0]这种倒序序列,数组末端的0要回到首位置很是费劲,比较和移动元素均需n-1次。

而希尔排序在数组中采用跳跃式分组的策略,通过某个增量将数组元素划分为若干组,然后分组进行插入排序,随后逐步缩小增量,继续按组进行插入排序操作,直至增量为1。希尔排序通过这种策略使得整个数组在初始阶段达到从宏观上看基本有序,小的基本在前,大的基本在后。然后缩小增量,到增量为1时,其实多数情况下只需微调即可,不会涉及过多的数据移动。

来看下希尔排序的基本步骤,在此选择增量gap=length/2,缩小增量继续以gap = gap/2的方式,这种增量选择可以用一个序列来表示,{n/2,(n/2)/2...1},称为增量序列。希尔排序的增量序列的选择与证明是个数学难题,选择的这个增量序列是比较常用的,也是希尔建议的增量,称为希尔增量,但其实这个增量序列不是最优的。此处做示例使用希尔增量。

4c9155e58850003f7231ab545bc294ed.png

7.3  复杂度分析

1.  时间复杂度:最坏情况下,每两个数都要比较并交换一次,则最坏情况下的时间复杂度为O(n2), 最好情况下,数组是有序的,不需要交换,只需要比较,则最好情况下的时间复杂度为O(n)。

经大量人研究,希尔排序的平均时间复杂度为O(n1.3)(这个我也不知道咋来的,书上和博客上都这样说,也没找到个具体的依据,,,)。

2.  空间复杂度:希尔排序,只需要一个变量用于两数交换,与n的大小无关,所以空间复杂度为:O(1)。

7.4  Java 代码如下

importjava.util.Arrays;public classshell {public static voidmain(String[] args) {int[] arr = new int[]{10,6,3,8,33,27,66,9,7,88};

shellSort(arr);

System.out.println(Arrays.toString(arr));

}private static void shellSort(int[] arr) {inttemp;//控制增量序列,增量序列为1的时候为最后一趟

for (int i = arr.length/2; i >0; i/=2) {//根据增量序列,找到每组比较序列的最后一个数的位置

for (int j = i; j < arr.length; j++) {//根据该比较序列的最后一个数的位置,依次向前执行插入排序

for (int k = j-i; k >=0; k-=i) {if(arr[k]>arr[k+i]){

temp=arr[k];

arr[k]= arr[k+i];

arr[k+i] =temp;

}

}

}

}

}

}

8. 堆排序

8.1  动图演示

7606e2242d636c790a9df456f12e9cd3.gif

8.2  思路分析

先来了解下堆的相关概念:堆是具有以下性质的完全二叉树:每个结点的值都大于或等于其左右孩子结点的值,称为大顶堆;或者每个结点的值都小于或等于其左右孩子结点的值,称为小顶堆。如下图:

b6e2792364135f0f2777ea56aad94091.png

同时,我们对堆中的结点按层进行编号,将这种逻辑结构映射到数组中就是下面这个样子

09b854abfe37b10f61075c66bd5f57f7.png

该数组从逻辑上讲就是一个堆结构,我们用简单的公式来描述一下堆的定义就是:

大顶堆:arr[i] >= arr[2i+1] && arr[i] >= arr[2i+2]

小顶堆:arr[i] <= arr[2i+1] && arr[i] <= arr[2i+2]

了解了这些定义。接下来看看堆排序的基本思想及基本步骤:

堆排序基本思想及步骤

堆排序的基本思想是:将待排序序列构造成一个大顶堆,此时,整个序列的最大值就是堆顶的根节点。将其与末尾元素进行交换,此时末尾就为最大值。然后将剩余n-1个元素重新构造成一个堆,这样会得到n个元素的次小值。如此反复执行,便能得到一个有序序列了

步骤一 构造初始堆。将给定无序序列构造成一个大顶堆(一般升序采用大顶堆,降序采用小顶堆)。

a.假设给定无序序列结构如下

744334a139238e014db8fdcb8bd44d8b.png

2.此时我们从最后一个非叶子结点开始(叶结点自然不用调整,第一个非叶子结点 arr.length/2-1=5/2-1=1,也就是下面的6结点),从左至右,从下至上进行调整。

90874934f35dfc6b8eecb27974ea168a.png

4.找到第二个非叶节点4,由于[4,9,8]中9元素最大,4和9交换。

a437ae91cf14a73b951452b33de54979.png

这时,交换导致了子根[4,5,6]结构混乱,继续调整,[4,5,6]中6最大,交换4和6。

21c41bb03f9c94bacfd57b7475ad7379.png

此时,我们就将一个无需序列构造成了一个大顶堆。

步骤二 将堆顶元素与末尾元素进行交换,使末尾元素最大。然后继续调整堆,再将堆顶元素与末尾元素交换,得到第二大元素。如此反复进行交换、重建、交换。

a.将堆顶元素9和末尾元素4进行交换

fcf8b930fdb270e622fc0737562d2ef7.png

b.重新调整结构,使其继续满足堆定义

335036054285f3dbfe8eb1ef5ef0fe7e.png

c.再将堆顶元素8与末尾元素5进行交换,得到第二大元素8.

8668ae61674a5abb882f44fbcd599edf.png

后续过程,继续进行调整,交换,如此反复进行,最终使得整个序列有序

0a0040282352027a234e45b22d24fc7a.png

再简单总结下堆排序的基本思路:

a.将无序序列构建成一个堆,根据升序降序需求选择大顶堆或小顶堆;

b.将堆顶元素与末尾元素交换,将最大元素"沉"到数组末端;

c.重新调整结构,使其满足堆定义,然后继续交换堆顶元素与当前末尾元素,反复执行调整+交换步骤,直到整个序列有序。

8.3  复杂度分析

1.  时间复杂度:堆排序是一种选择排序,整体主要由构建初始堆+交换堆顶元素和末尾元素并重建堆两部分组成。其中构建初始堆经推导复杂度为O(n),在交换并重建堆的过程中,需交换n-1次,而重建堆的过程中,根据完全二叉树的性质,[log2(n-1),log2(n-2)...1]逐步递减,近似为nlogn。所以堆排序时间复杂度最好和最坏情况下都是O(nlogn)级。

2.  空间复杂度:堆排序不要任何辅助数组,只需要一个辅助变量,所占空间是常数与n无关,所以空间复杂度为O(1)。

8.4  Java 代码如下

importjava.util.Arrays;public classduipaixu {public static voidmain(String[] args) {int[] arr = new int[]{4,6,8,5,9};int length =arr.length;//从最后一个非叶节点开始构建大顶堆

for (int i = arr.length/2-1; i >=0; i--) {

maximumHeap(i,arr,length);

}//从最小的叶子节点开始与根节点进行交换并重新构建大顶堆

for (int i = arr.length-1; i >=0; i--) {//System.out.println(Arrays.toString(arr));

swap(arr,0,i);

length--;

maximumHeap(0,arr,length);

}

System.out.println(Arrays.toString(arr));

}//构建大顶堆

public static void maximumHeap(int i,int[] arr,intlength){int temp =arr[i];for (int j = i*2+1; j < length; j=j*2+1) {//如果右孩子大于做孩子,则指向右孩子

if(j+1arr[j]){

j++;

}//如果最大的孩子大于当前节点,则将大孩子赋给当前节点,修改当前节点为其大孩子节点,再向下走。

if(arr[j]>temp){

arr[i]=arr[j];

i=j;

}else{break;

}

}//将temp放到最终位置

arr[i] =temp;

}//交换

public static void swap(int[] arr,int i,intj){int temp =arr[i];

arr[i]=arr[j];

arr[j]=temp;

}

}

初入职场热爱分享的打工人一枚,请大家多多指教~~

700174aa1adb687f212f31bc991aaca1.png

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值