面试中算法总结

本次Chat,主要从知名互联网公司在面试中喜欢提问的算法入手,给大家详细阐述讲解面试中的高频率算法题。涉及到的算法题主要包括:排序和查找、链表、二叉树、队列、堆栈、字符串以及数组等方面。如果你想在来年的校园招聘中拿下一线互联网的Offer,那么本次Chat将助你玩转算法面试~

面试,是大家从学校走向社会的第一步。大型互联网公司的校园招聘,从形式上说,面试一般分为2-3轮技术面试+1轮HR面试。但是一些公司确实是没有HR面试的,直接就是三轮技术面。技术面试中,面试官一般会先就你所应聘的岗位进行相关知识的考察,也叫基础知识和业务逻辑面试。只要你回答的不是特别特别差,面试官通常会说:“咱们写个代码吧”,这个时候就开始了算法面试。也就是说,一轮技术面试=基础知识和业务逻辑面试+算法面试

那么算法面试重要还是基础知识和业务逻辑面试重要?大家可能会有这个疑惑,而我要说的是,如果真要对这两个进行排序,那么我会把算法面试放在优先级更高的位置。曾经问过百度的一位面试官,为什么你们面试都喜欢考算法?回答是:“首先,算法是一种通用的考察点,不管哪个技术岗都可以进行考察;其次,算法包含了太多的逻辑思维,可以考察应聘者思考问题的逻辑和解决问题的能力;最后,连这么有难度的算法题你都可以搞定,那么其他只需要看看写写用用就可以掌握的基础知识和相关技术框架还怕学不会吗?”所以说,我认为算法的重要性相当高。

推荐几个不错的学习测试算法的工具吧。首先是Leetcode,这是一个美国的在线编程网站,上面主要收集了各大IT公司的笔试面试题,对于应届毕业生找工作是一个不可多得的好帮手。Leetcode的主要特点就是按照难易将题目分为了easy、medium和hard三种,并且在leetcode上将题目进行了分类。在自己练习通过之后可以打开讨论区,看看别人是怎么思考解决该问题的。其次是牛客网,这是一个专注于程序员的学习和成长的专业平台,集笔面试系统、课程教育、社群交流、招聘内推于一体。我们可以在在线编程模块进行算法题的练习。

接下来我们正式开始常见算法题的介绍,主要内容包括以下七个小节:

  • 排序和查找算法
  • 单链表
  • 二叉树
  • 队列和栈
  • 字符串
  • 数组
  • 其它算法

enter image description here

第一节:排序和查找算法

在排序中,90%的概率会考察快速排序算法,所以我们需要准备一个没有任何bug的快速排序算法。当面试官让我们写一个快排的时候,我们可以毫不犹豫的写出来。在一些简单的时候,面试官可能会要求你写一个二分查找算法。

enter image description here

快速排序:是一种分区交换排序算法。采用分治策略对两个子序列再分别进行快速排序,是一种递归算法。

法描述:在数据序列中选择一个元素作为基准值,每趟从数据序列的两端开始交替进行,将小于基准值的元素交换到序列前端,将大于基准值的元素交换到序列后端,介于两者之间的位置则成为了基准值的最终位置。同时,序列被划分成两个子序列,再分别对两个子序列进行快速排序,直到子序列的长度为1,则完成排序。

举例:假设要排序的数组是a[6],长度为7,首先任意选取一个数据(通常选用第一个数据)作为关键数据,然后将所有比它的数都放到它前面,所有比它大的数都放到它后面,这个过程称为一躺快速排序。一躺快速排序的算法是:

  1. 设置两个变量i,j,排序开始的时候i=0;j=6;
  2. 以第一个数组元素作为关键数据,赋值给key,即key=a[0];
  3. 从j开始向前搜索,即由后开始向前搜索(j--),找到第一个小于key的值,两者交换;
  4. 从i开始向后搜索,即由前开始向后搜索(i++),找到第一个大于key的值,两者交换;
  5. 重复第3、4步,直到i=j;此时将key赋值给a[i];

例如:待排序的数组a的值分别是:(初始关键数据key=49)

enter image description here

此时完成了一趟循环,将49赋值给a[3],数据分为三组,分别为{27,38,13}{49}{76,96,65},利用递归,分别对第一组和第三组进行排序,则可得到一个有序序列,这就是快速排序算法。

快速排序代码如下:

public void sort(int a[], int low, int high){    
    int i=low;    
    int j=high;    
    int key=a[low];    

    if (low < high){    
        while(i<j){ // 此处的while循环结束,则完成了元素key的位置调整    
            while(i<j&&key<=a[j]){    
                j--;    
            }    
            a[i]=a[j];    
            while(i<j&&key>=a[i]){    
                i++;    
            }    
            a[j]=a[i];    
            a[i]=key;  //此处不可遗漏  
        }     
        sort(a,low,i-1);    
        sort(a,i+1,high);    
    }    
}

二分查找又称折半查找,优点是比较次数少,查找速度快,平均性能好;其缺点是要求待查表为有序表,且插入删除困难。因此,折半查找方法适用于不经常变动而查找频繁的有序列表。

步骤:首先,假设表中元素是按升序排列,将表中间位置记录的关键字与查找关键字比较,如果两者相等,则查找成功;否则利用中间位置记录将表分成前、后两个子表,如果中间位置记录的关键字大于查找关键字,则进一步查找前一子表,否则进一步查找后一子表。重复以上过程,直到找到满足条件的记录,使查找成功,或直到子表不存在为止,此时查找不成功。

算法前提:必须采用顺序存储结构;必须按关键字大小有序排列。

实现方式:包含递归实现和非递归实现两种方式。二分查找的递归代码实现如下:

private  int halfSearch(int[] a,int left,int right,int target) {  
     int mid=(left+right)/2;  
     int midValue=a[mid];  
     if(left<=right){  
         if(midValue>target){  
             return halfSearch(a, left, mid-1, target);  
         }else if(midValue<target) {  
             return halfSearch(a, mid+1, right, target);  
         }else {  
             return mid;  
         }  
    }  
    return -1;  
}

非递归实现代码如下:

private  int halfSearch(int[] a,int target){  
    int i=0;  
    int j=a.length-1;  
    while(i<=j){  
        int mid=(i+j)/2;  
        int midValue=a[mid];  
        if(midValue>target){  
            j=mid-1;  
        }else if(midValue<target){  
            i=mid+1;  
        }else {  
            return mid;  
        }  
    }  
    return -1;  
}

篇幅有限,更多排序查找算法,请参考我的博客:

第二节 单链表相关算法

对链表的操作由于涉及到指针,所以链表是一种极其常见的算法考题。常见的链表题有:单链表反转、合并有序单链表、求单链表的中间节点、判断单链表相交或者有环、求出进入环的第一个节点、求单链表相交的第一个节点等。

我们首先给出单链表的定义:用代码实现。

class Node{  
    int val;  
    Node next;  
    public Node(int val){  
         this.val=val;  
    }
}  

enter image description here

单链表反转:比如1→2→3→4→5,反转之后返回5→4→3→2→1

步骤:

  1. 从头到尾遍历原链表,每遍历一个结点
  2. 将其摘下放在新链表的最前端。
  3. 注意链表为空和只有一个结点的情况。

代码实现如下:

public static Node reverseNode(Node head){  
      // 如果链表为空或只有一个节点,无需反转,直接返回原链表表头  
      if(head == null || head.next == null)  
          return head;  

      Node reHead = null;  
      Node cur = head;  
      while(cur!=null){  
          Node reCur = cur;      // 用reCur保存住对要处理节点的引用  
          cur = cur.next;        // cur更新到下一个节点  
          reCur.next = reHead;   // 更新要处理节点的next引用  
          reHead = reCur;        // reHead指向要处理节点的前一个节点  
      }  
      return reHead;  
 }

合并有序的单链表:给出两个分别有序的单链表,将其合并成一条新的有序单链表。
举例:1→3→5和2→4→6合并之后为1→2→3→4→5→6 步骤:首先,我们通过比较确定新链表的头节点,然后移动链表1或者链表2的头指针。然后通过递归来得到新的链表头结点的next 代码实现如下:

public static Node mergeList(Node list1 , Node list2){  
    if(list1==null)  
        return list2;  
    if(list2==null)  
        return list1;  
    Node resultNode;  
    if(list1.val<list2.val){ // 通过比较大小,得到新的节点 
        resultNode = list1;  
        list1 = list1.next;  
    }else{  
        resultNode = list2;  
        list2 = list2.next;  
    }  
    // 递归得到next
    resultNode.next = mergeList(list1, list2);  
    return resultNode;  
}

篇幅有限,更多链表相关算法请参考我的博客:

第三节 二叉树相关算法

二叉树相比单链表,会有更多的指针操作,如果面试官想进一步考察应聘者指针操作,那么二叉树无疑是理想的考题。二叉树常见的考题包括:分层遍历(宽度优先遍历. 前序遍历、中序遍历、后序遍历以及求二叉树中两个节点的最低公共祖先节点。

我们首先定义二叉树这种数据结构,代码实现如下:

class TreeNode {
    int val;
    TreeNode left;
    TreeNode right;
    public TreeNode(int val) {
        this.val = val;
    }
}

面试中,对分层遍历二叉树考察最多。举例如下,针对如下所示的二叉树。

enter image description here

其分层遍历(宽度优先遍历)序列为:1,2,3,4,5,6,7,8,9,10 可以看出,这好像符合一种先进先出的规律,我先遍历到某个节点,就将其输出。想到用过队列Queue来实现分层遍历。

步骤:队列初始化,将根节点压入队列。当队列不为空,进行如下操作:弹出一个节点,访问,若左子节点或右子节点不为空,将其压入队列 。

代码实现如下:

public static void levelTraversal(TreeNode root){  
    if(root==null)  
        return ;  
    LinkedList<TreeNode> queue = new LinkedList<TreeNode>();  
    queue.add(root);  // 队列初始化,将根节点加入队列
    while(!queue.isEmpty()){  
        TreeNode cur = queue.remove();  
        System.out.print(cur.val+" ");  
        if(cur.left!=null)  
            queue.add(cur.left);  
        if(cur.right!=null)  
            queue.add(cur.right);  
    }  
}

说完了宽度优先遍历二叉树,我们再来一个深度优先遍历二叉树,也就是二叉树的前序遍历序列。

enter image description here

这颗二叉树的前序遍历序列为:abdefgc。前序遍历中,遍历顺序为根左右,也就是左节点在右节点之前,我们考虑使用堆栈这种后进先出的数据结构来实现。

步骤:使用一个辅助堆栈,初始时将根节点加入堆栈,当堆栈不为空时,弹出一个节点,分别将其不为空的右子节点和左子节点加入堆栈。 代码实现如下:

public static void preorderTraversal(TreeNode root){  
    if(root==null)  
        return ;  
    Stack<TreeNode> stack = new Stack<TreeNode>(); // 辅助stack  
    stack.push(root);  
    while(!stack.isEmpty()){  
        TreeNode cur = stack.pop();  // 出栈栈顶元素   
        System.out.print(cur.val+" ");  
        // 关键点:要先压入右孩子,再压入左孩子,这样在出栈时会先打印左孩子再打印右孩子  
        if(cur.right!=null)  
             stack.push(cur.right);  
        if(cur.left!=null)  
             stack.push(cur.left);  
    }  
}

篇幅有限,更多关于二叉树的常考算法题,请参考我的博客:

第四节 队列和堆栈相关算法

队列和堆栈通常在算法题的考察中会作为一种辅助的数据结构出现。分别利用其先进先出和后进先出的特性。

但是,有时候会单纯的考察队列和堆栈的相关知识,常见的算法题包括:包含min函数的堆栈、两个栈实现队列以及自定义堆栈的实现等。

enter image description here

enter image description here

题目:包含min函数的栈

定义栈的数据结构,请在该类型中实现一个能够得到栈最小元素的min函数。在该栈中,调用min、push和pop方法。要求的时间复杂度均为O(1)

思路:题目要求我们的各个方法均为O(1)复杂度,则我们考虑增加辅助空间来实现,即增加一个专门用来存储min值的辅助栈。

比如,data中依次入栈,5, 4, 3, 8, 10, 11, 12, 1。则辅助栈依次入栈, 5, 4, 3,no,no, no, no, 1。no代表此次不如栈。即,每次入栈的时候,如果入栈的元素比min中的栈顶元素小或等于则入栈,否则不入栈。

代码实现如下:

import java.util.Stack;   
public class Main {  

    Stack<Integer> stack = new Stack<>();  
    Stack<Integer> minStack = new Stack<>();  


    public void push(int node) {  
        stack.push(node);  
        if(minStack.isEmpty()||minStack.peek()>=node)  
            minStack.push(node);  
    }  

/** 
 * 首先需要对stack执行出栈操作, 
 * 判断minStack中是否需要出栈操作 
 */  
    public void pop() {  
        stack.pop();  
        if(stack.peek()==minStack.peek()){  
            minStack.pop();  
        }  
    }  

    public int top() {  
        return stack.peek();  
    }  

/** 
 * 直接peek minStack 
 * @return 
 */  
    public int min() {  
        return minStack.peek();  
    }  
}

该实现算法中,在push和pop操作中,均有判断,判断值相等一定要用peek方法而不是pop!!!切记切记,关键点。

篇幅有限,更多关于队列和堆栈的常考算法题,请参考我的博客:

第五节 字符串相关算法

字符串是一种非常常见的数据类型,我们应当对API中常用的函数进行一定的学习和了解。在字符串的考察中,比较经典的是自定义一个函数实现字符串转整数的功能;加大难度之后,会考察以下常见的5种关于字符串中“最长”问题:

  1. 最长公共子序列
  2. 最长公共子串
  3. 最长递增子序列
  4. 最长公共前缀
  5. 最长不含重复元素的子串

来看自定义一个函数,实现字符串转换成整数。当面试官说出这个题时,他肯定会避免和你说太多,只说出了最简单的要求。如果你立马动笔刷刷刷的用了三分钟就说写好了。

enter image description here

那么,对不起,我敢以人格担保,你这轮面试绝对死翘翘了。。。这道题看似简单,而且面试官也没有提醒你应该注意哪些。但是!!,这道题的目的就是为了考察你的特殊情况处理能力,你能不能想到会有哪些特殊情况或者边界处理,这才是本题的重点。

enter image description here

那么,遇到这道题,我们应该如何面对?我们要不慌不乱的和面试官亲切交谈,制定该函数的一些规则,即如何处理异常输入等,之后,再遍历数组,根据需求进行相应的异常处理哦~

首先,我们应该要想到本题的一些特殊情况。

本题的特殊情况如下:

  1. 能够排除首部的空格,从第一个非空字符开始计算
  2. 允许数字以正负号(+-)开头
  3. 遇到非法字符便停止转换,返回当前已经转换的值,如果开头就是非 法字符则返回0
  4. 在转换结果溢出时返回特定值,这里是最大/最小整数

我们需要针对以上的特殊情况和面试官亲切交流,询问如果有特殊情况该如何处理。

其次,我们要想到一些测试用例,根据测试用例来询问面试官输出是否应该为XX。

先来几组测试用例:

"    010"
"    +004500"
"  -001+2a42"
"   +0 123"
"-2147483648"
"2147483648"
"   - 321"
"      -11919730356x"
"9223372036854775809"

以上的测试用例对应的正确输出如下:

10
4500
-1
0
-2147483648
2147483647
0
-2147483648
2147483647

如果你能想到这些特殊情况和测试用例,那么恭喜你,你已经成功了90%,面试官会从心底里开始欣赏你。

最后,代码的编写(so easy)

enter image description here

代码如下:

public static int myAtoi(String str) {  
    if(str==null||str.length()==0)  
        return 0;  
    char[] array = str.toCharArray();  
    long result = 0;  // 要返回的结果result  
    int count = 0;  // 记录‘+’或者‘-’出现的次数  
    int num = 0;   // 判断空格出现的位置  
    int flag = 1; // 正数还是负数  
    for (int i = 0; i < array.length; i++) {  
        Character c = array[i];  
        if(c>='0'&&c<='9'){  
            result = result*10+c-'0';  
            // 判断是否溢出  
            if(flag==1&&result>Integer.MAX_VALUE){  
                return Integer.MAX_VALUE;  
            }else if(flag==-1&&-result<Integer.MIN_VALUE)  
                return Integer.MIN_VALUE;  
            num++;  
        }else if(c==' '&&num==0&&count==0)  
            continue;  
        else if(c=='+'&&count==0){  
            count = 1;  
        }  
        else if(c=='-'&&count==0){  
            flag = -1;  
            count = 1;  
        }  
        else{  
            return (int) (flag*result);  

        }  
    }  
    return (int) (flag*result);  
}  

篇幅有限,更多字符串相关常考算法,请参考我的博客:

第六节 数组相关算法

数组也是一种极其常见的数据结构,在面试中和数组相关的算法题出现频率贼高。对数组的操作,一般会要求时间复杂度和空间复杂度。所以,最常用的方法就是设置两个指针,分别指向不同的位置,不断调整指针指向来实现O(N)时间复杂度内实现算法。常见的面试题有:拼接一个最大/小的数字、合并两个有序数组、调整数组顺序使奇数位于偶数前面、查找多数元素、数组中的重复元素

题目一:查找多数元素

找出一个数组中占50%以上的元素,即寻找多数元素,并且多数元素是一定存在的假设。

思路1:将数组排序,则中间的那个元素一定是多数元素

public int majorityElement(int[] nums) {  
    Arrays.sort(nums);  
    return nums[nums.length/2];  
}  

该代码的时间复杂度为O(NlogN),面试官会问你能不能进行优化时间复杂度?

思路2:利用HashMap来记录每个元素的出现次数

public int majorityElement(int[] nums) {  
    HashMap<Integer, Integer> map = new HashMap<>();  

    for (int i = 0; i < nums.length; i++) {  
        if(!map.containsKey(nums[i])){  
            map.put(nums[i], 1);  
        }else {  
            int values = map.get(nums[i]);  
            map.put(nums[i], ++values);  
        }  
    }  
    int n = nums.length/2;  
    Set<Integer> keySet = map.keySet();  
    Iterator<Integer> iterator = keySet.iterator();  
    while(iterator.hasNext()){  
        int key = iterator.next();  
        int value = map.get(key);  
        if (value>n) {  
            return key;  
        }  
    }  
    return 0;  

}  

该代码的时间复杂度为O(N),空间复杂度为O(N)。面试官还不满意,问你能不能用O(N)+O(1)实现该算法?

思路3Moore voting algorithm--每找出两个不同的element,就成对删除即count--,最终剩下的一定就是所求的。

public int majorityElement(int[] nums) {  
      int elem = 0;  
      int count = 0;   
      for(int i = 0; i < nums.length; i++)  {      
         if(count == 0)  {  
             elem = nums[i];  
             count = 1;  
         }  
         else    {  
             if(elem == nums[i])  
                 count++;  
             else  
                 count--;  
         }  

     }  
     return elem;       
}  

完美,这才是本题的正确最优解。

enter image description here

题目二:把数组排成最小的数

输入一个正整数数组,把数组里所有数字拼接起来排成一个数,打印能拼接出的所有数字中最小的一个。例如输入数组{3,32,321},则打印出这三个数字能排成的最小数字为321323。

思路:我们需要定义一种新的比较大小规则,数组根据这个规则可以排成一个最小的数字。

排序规则:两个数字m和n,我们比较mn和nm的大小,来确定在新的比较规则下n和m的大小关系,来确定哪个应该排在前面

步骤:将整型数组转换为String数组。在新的规则下对数组进行排序(本例使用了选择排序)。

public String PrintMinNumber(int [] num) {  
    if(num==null||num.length==0)  
        return "";  
    int len = num.length;  
    String[] str = new String[len];  
    for(int i = 0; i < len; i++){  
        str[i] = String.valueOf(num[i]);  
    }  
    for (int i = 0; i < str.length; i++) {  
        for (int j = i+1; j < str.length; j++) {  
            if(compare(str[i], str[j])){  
                String temp = str[j];  
                str[j] = str[i];  
                str[i] = temp;  
            }  
        }  
    }  
    StringBuilder sb = new StringBuilder();  
    for(int i = 0;i<str.length;i++){  
        sb = sb.append(str[i]);  
    }  
    return sb.toString();  

}  
private boolean compare(String s1,String s2){  
    int len = s1.length()+s2.length();  
    String str1 = s1+s2;  
    String str2 = s2+s1;  
    for (int i = 0; i < len; i++) {  
        if(Integer.parseInt(str1.substring(i,i+1))>Integer.parseInt(str2.substring(i,i+1)))  
            return true;  
        if(Integer.parseInt(str1.substring(i,i+1))<Integer.parseInt(str2.substring(i,i+1)))  
            return false;  
    }  
    return false;    
}

篇幅有限,更多数组相关算法,请参考我的博客:

第七节 Others Algorithm

其它常见的算法题还有青蛙跳台阶问题。

enter image description here

题目一:青蛙跳台阶问题

一只青蛙一次可以跳上1级台阶,也可以跳上2级台阶。求该青蛙跳上一个n级台阶总共有多少种跳法?

分析:当n = 1, 只有1中跳法;当n = 2时,有2种跳法;当n = 3 时,有3种跳法;当n = 4时,有5种跳法;当n = 5时,有8种跳法;.......规律类似于Fibonacci数列:

enter image description here

递归实现的代码如下:

public int Fibonacci(int n){  
    if(n<=2)  
        return n;  
    return Fibonacci(n-1)+Fibonacci(n-2);  
}  

当我们写出递归代码时,面试官应该会建议我们对递归代码进行优化,因为递归代码中有太多的重复运算。所以,我们考虑使用使用变量保存住中间结果。 代码实现如下:

public int jumpFloor(int number) {  
    if(number<=2)  
        return number;  
    int jumpone=2; // 离所求的number的距离为1步的情况,有多少种跳法  
    int jumptwo=1; // 离所求的number的距离为2步的情况,有多少种跳法  
    int sum=0;  
    for(int i=3;i<=number;i++){  
        sum=jumptwo+jumpone;  
        jumptwo=jumpone;  
        jumpone=sum;  
    }  
    return sum;  
}  

题目二:青蛙变态跳台阶问题

一只青蛙一次可以跳上1级台阶,也可以跳上2级……它也可以跳上n级。求该青蛙跳上一个n级的台阶总共有多少种跳法。

解题思路:

  • 先跳到n-1级,再一步跳到n级,有f(n-1)种;
  • 先跳到n-2级,再一步跳到n级,有f(n-2)种;
  • 先跳到n-3级,再一步跳到n级,有f(n-3)种;
  • 。。。。。。
  • 先跳到第1级,再一步跳到n级,有f(1)种;
  • 所以:
  • f(n)=f(n-1)+f(n-2)+f(n-3)+···+f(1)
  • f(n-1)=f(n-2)+f(n-3)+···+f(1)
  • 推出f(n)=2*f(n-1)

代码实现如下:

public int jumpFloor2(int num) {  
    if(num<=2)  
        return num;  
    int jumpone=2; // 前面一级台阶的总跳法数  
    int sum=0;  
    for(int i=3;i<=num;i++){  
        sum = 2*jumpone;  
        jumpone = sum;  
    }  
    return sum;  
} 

结束语

当各位同学读到此处时,你可能只是仅仅对文章内容进行了一次简单的浏览,还来不及细细消化品味文中算法的乐趣。诚然,这是一篇特别长的文章。因为,我的本意是将每一小节都写成一个单独的课时,这样方便读者阅读,不至于产生疲惫感。但是,我后来才知道,达人课才能那么写,普通的单场Chat就是一篇文章搞定。所以,我只能尽可能的精简篇幅,以使读者不至于阅读疲惫。

本文中所述算法均为典型案例,也是我亲身经历过得面试算法题。文中链接中的算法题也是非常常见,希望大家可以熟练掌握,细细体会其中的奥妙。

本次Chat结束之后,我会针对自己所应聘的Java后台开发方向,发布一场新的Chat,重点阐述Java开发岗在面试中都考察哪些方面的知识和技能,欢迎大家关注^_^

注:以上算法题,仅仅是作者认为大家在面试前必须要牢牢掌握的高频率算法题。当你特别熟练的回答了之后,可能面试官会加大算法难度,想看看应聘者的极限在哪里^_^

(作者水平有限,文中如有错误之处,烦请各位指出,我们一起进步。)


本文首发于GitChat,未经授权不得转载,转载需与GitChat联系。

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值