力扣1-200刷题总结(2/5)

本文精选LeetCode上的经典算法题目,包括直线上的最多点数、链表排序、单词拆分、回溯算法等问题,详细介绍了各种算法的实现原理及优化技巧。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

直线上最多的点数量

149题

 两点确定一条直线,如果前两个点分别与第三个点求斜率,若是相等则表明三个点都在一条直线上,运用此方法可以暴力求解

public int maxPoints(int[][] ps) {
        int n = ps.length;
        int ans = 1;
        for (int i = 0; i < n; i++) {
            int[] x = ps[i];//点1
            for (int j = i + 1; j < n; j++) {
                int[] y = ps[j];//点2
                int cnt = 2;//至少两个点可以确定一条直线
                for (int k = j + 1; k < n; k++) {
                    int[] p = ps[k];//测试其他的点有多少落在由xy确定的直线中
                    //判断 i 和 j 与第三个点 k 形成的两条直线斜率是否相等
                    int s1 = (y[1] - x[1]) * (p[0] - y[0]);
                    int s2 = (p[1] - y[1]) * (y[0] - x[0]);
                    if (s1 == s2) cnt++;
                }
                ans = Math.max(ans, cnt);
            }
        }
        return ans;
    }

基于此暴力求解方法可以采用Hash表进行进一步优化,需要注意的是由于哈希表存在精度问题,一般采用字符串作为key值,其中gcd算法用于计算a和b的最大公约数,因为在使用字符串作为key的时候要注意假分数问题

public int maxPoints(int[][] ps) {
        int n = ps.length;
        int ans = 1;
        for (int i = 0; i < n; i++) {
            Map<String, Integer> map = new HashMap<>();
            // 由当前点 i 发出的直线所经过的最多点数量
            int max = 0;
            for (int j = i + 1; j < n; j++) {
                int x1 = ps[i][0], y1 = ps[i][1], x2 = ps[j][0], y2 = ps[j][1];
                int a = x1 - x2, b = y1 - y2;
                int k = gcd(a, b);
                String key = (a / k) + "_" + (b / k);
                map.put(key, map.getOrDefault(key, 0) + 1);
                max = Math.max(max, map.get(key));
            }
            ans = Math.max(ans, max + 1);
        }
        return ans;
    }
    int gcd(int a, int b) {
        return b == 0 ? a : gcd(b, a % b);
    }

链表排列问题

147题--插入排序

public ListNode insertionSortList(ListNode head) {
        if (head == null) {
            return head;
        }
        ListNode dummyHead = new ListNode(0);
        dummyHead.next = head;
        ListNode lastSorted = head, curr = head.next;//维护lastSorted为链表已经排好序的最后一个节点
        while (curr != null) {
            if (lastSorted.val <= curr.val) {//说明curr应该位于lastSorted之后
                lastSorted = lastSorted.next;//将lastSorted后移一位,curr变成新的lastSorted
            } else {
                ListNode prev = dummyHead;//从链表头开始遍历 prev是插入节点curr位置的前一个节点
                while (prev.next.val <= curr.val) {//循环退出的条件是找到curr应该插入的位置
                    prev = prev.next;
                }
                //完成对curr的插入
                lastSorted.next = curr.next;
                curr.next = prev.next;
                prev.next = curr;
            }
            //此时 curr 为下一个待插入的元素
            curr = lastSorted.next;
        }
        return dummyHead.next;
    }

148题--归并排序

使用归并排序思想解决此题,其大体思路是,每次找到链表的中点,记录下要连接下一段链表随后断开链表,对左链表和右链表进行递归排序,最后合并两个链表

合并有序链表算法:每次连接两个链表中比较起来更小的值,然后后移链表指针和合并链表指针,需要注意的是该算法会漏掉最后一个节点,因为它的判断条件是两个链表都不为空,所以最后需要加上一个节点

获得中点算法:快慢指针,如果想在1234中获得2的位置,fast指针初始化为head.next.next,如果想获得3的位置fast指针初始化为head;

 public ListNode sortList(ListNode head) {
        //递归结束条件
        if (head == null || head.next == null) return head;
        //找到链表的中间点并且断开链表,递归下探
        ListNode mid = getMidNode(head);
        ListNode rightHead = mid.next;
        //断开链表
        mid.next = null;
        //递归
        ListNode left = sortList(head);
        ListNode right = sortList(rightHead);
        //合并有序链表
        return mergeTwoList(left, right);
    }

    //合并两个有序链表
    private ListNode mergeTwoList(ListNode l1, ListNode l2) {
        ListNode dummy = new ListNode(0);
        ListNode cur = dummy;
        while (l1 != null && l2 != null) {
            if (l1.val < l2.val) {
                cur.next = l1;
                l1 = l1.next;//后移了l1
            } else {
                cur.next = l2;
                l2 = l2.next;//后移l2
            }
            cur=cur.next;//连接完毕后后移cur
        }
        cur.next=l1!=null?l1:l2;//连接最后一个节点
        return  dummy.next;

    }

    private ListNode getMidNode(ListNode head) {
        if (head == null || head.next == null) return head;
        ListNode slow = head;
        ListNode fast = head.next.next;//如果在偶数的时候想返回第一个节点需要head.next.next否则直接是head
        while (fast != null && fast.next != null) {
            slow = slow.next;
            fast = fast.next.next;
        }
        return slow;
    }

143题--重排链表

算法步骤:123456=>123与654=>162534

翻转链表算法:只需要cur的指针指向前一个然后不断的往后更新位置就行

 public void reorderList(ListNode head) {
        if (head == null || head.next == null || head.next.next == null) {
            return;
        }
        //找中点,链表分成两个
        ListNode slow = head;
        ListNode fast = head;
        while (fast.next != null && fast.next.next != null) {
            slow = slow.next;
            fast = fast.next.next;
        }

        ListNode newHead = slow.next;
        slow.next = null;

        //第二个链表倒置
        newHead = reverseList(newHead);

       //链表节点依次连接
        while (newHead != null) {
            ListNode next=head.next;
            ListNode nNext = newHead.next;
            head.next = newHead;
            newHead.next=next;
            //更新位置
            newHead=nNext;
            head=next;
        }
    }

    private ListNode reverseList(ListNode head) {
        if (head == null) {
            return null;
        }
        ListNode pre = null;
        ListNode cur = head;
        while (cur!=null){
            ListNode next=cur.next;
            cur.next=pre;
            pre=cur;
            cur=next;
        }
        return pre;
    }

单词拆分

139--单词拆分1

动态规划方法其中dp[i]表示从0开始到i-1的单词能否被分割,算法开始使用set的集合构造器拿到所有wordDict里的单词,其中dp[0]需要为true,因为需要用这个状态来推导其他的状态

对0-i这个状态做分析,0<=j<i,如果dp[j]为true并且set中含有j-i的字符串就说明0-i是可以被分割的

 public boolean wordBreak(String s, List<String> wordDict) {
        Set<String> set = new HashSet<>(wordDict);
        int n=s.length();
        //表示从0-i的单词能否被分割
        boolean[] dp = new boolean[n+1];
        dp[0]=true;
        for (int i = 1; i <= n; i++) {
            for (int j = 0; j < i; j++) {
                if(dp[j]&&set.contains(s.substring(j,i))){
                    dp[i]=true;
                    break;
                }
            }
        }
        return dp[s.length()];
    }

 140--单词拆分2

基于上一题的算法,我们已经可以知道每个dp[i]的状态了,现在需要使用这些状态来构造句子,这就引申出了回溯算法,回溯也是一种深度优先的算法,虽然此题最后要返回的是一个String的List但是为了方便还是使用List<String>来封装每个结果

由于dp[i]只能得到0-i的字符串是否能被分割的结果,所以需要传入一个哈希表来确定此字符串的后缀是否能被分割,然而通过哈希表只能确定一个单词,但是dp[i]可以确定多个单词,所以使用从后向前遍历的方法收集字符串,在收集到一个指定的字符串之后将len改为i继续向前递归,以此让哈希表继续发挥作用

public List<String> wordBreak(String s, List<String> wordDict) {
        Set<String> wordSet=new HashSet<>(wordDict);
        int len=s.length();
        //动态规划计算是否有解
        boolean[] dp=new boolean[len+1];
        dp[0]=true;//这个值会被后面的状态值参考
        for (int i = 1; i <=len ; i++) {
            for (int j = 0; j <i ; j++) {
                if(dp[j]&&wordSet.contains(s.substring(j,i))){
                    dp[i]=true;
                    break;
                }
            }
        }
        //回溯找到所有复合条件的解
        List<String> res=new ArrayList<>();
        if(dp[len]){
            Deque<String> path=new ArrayDeque<>();
            dfs(s,len,wordSet,dp,path,res);
        }
        return res;
    }
/**
 * s[0:len) 如果可以拆分成 wordSet 中的单词,把递归求解的结果加入 res 中
 *
 * @param s
 * @param len     长度为 len 的 s 的前缀子串
 * @param wordSet 单词集合,已经加入哈希表
 * @param dp      预处理得到的 dp 数组
 * @param path    从叶子结点到根结点的路径
 * @param res     保存所有结果的变量
 */
    private void dfs(String s, int len, Set<String> wordSet, boolean[] dp, Deque<String> path, List<String> res) {
        if(len==0)res.add(String.join(" ",path));
        // 可以拆分的左边界从 len - 1 依次枚举到 0
        for (int i = len - 1; i >= 0; i--) {
            String suffix = s.substring(i, len);
            if (wordSet.contains(suffix) && dp[i]) {
                path.addFirst(suffix);//从后往前,所以是addFirst
                dfs(s, i, wordSet, dp, path, res);
                path.removeFirst();
            }
        }
    }

回溯算法

131-分割回文串

预先使用动态规划判断是否是回文串dp[i][j]表示s的字串i-j是否是回文串,注意left==right的时候也要加入判断,因为单个字符肯定是回文串需要把dp[i][j]变为true

判断回文串的条件为首先需要最左和最右的字符相等再之需要上一个状态是回文串或者当前字串数量<=2

在dfs中,添加结果时的条件是起始索引等于长度的时候,只要index-i是回文串就将其加入path,而后将起始索引调整至i+1

 public List<List<String>> partition(String s) {
        int len = s.length();
        List<List<String>> res = new ArrayList<>();
        if (len == 0) {
            return res;
        }

        char[] charArray = s.toCharArray();
        // 预处理
        // 状态:dp[i][j] 表示 s[i][j] 是否是回文
        boolean[][] dp = new boolean[len][len];
        // 状态转移方程:在 s[i] == s[j] 的时候,dp[i][j] 参考 dp[i + 1][j - 1]
        for (int right = 0; right < len; right++) {
            // 注意:left <= right 取等号表示 1 个字符的时候也需要判断
            for (int left = 0; left <= right; left++) {
                //当字符串小于等于2或者上一个字符串是回文串的时候 两个字符相等则认为它是回文串
                if (charArray[left] == charArray[right] && (right - left <= 2 || dp[left + 1][right - 1])) {
                    dp[left][right] = true;
                }
            }
        }

        Deque<String> stack = new ArrayDeque<>();
        dfs(s, 0, len, dp, stack, res);
        return res;
    }
/**
 * @param index     起始字符的索引
 * @param len       字符串 s 的长度,可以设置为全局变量
 * @param path      记录从根结点到叶子结点的路径
 * @param res       记录所有的结果
 */
    private void dfs(String s, int index, int len, boolean[][] dp, Deque<String> path, List<List<String>> res) {
        if (index == len) {
            res.add(new ArrayList<>(path));
            return;
        }

        for (int i = index; i < len; i++) {
            if (dp[index][i]) {
                path.addLast(s.substring(index, i + 1));
                dfs(s, i + 1, len, dp, path, res);
                path.removeLast();
            }
        }
    }

与此题相似的132题也是分割回文串,但是求的是分割次数,因此与回溯无关,可以通过两次dp来解决

 第一次dp求出某个段是否是回文串,第二次dp求出[0,i]所需要的最小分割次数

    //对于长度为 n 的字符串,我们使用 [1,n]进行表示,其目的是为了减少边界判断
    public int minCut(String s) {
        int len=s.length();
        //表示i,j这一段是否是回文串
        boolean[][] dp = new boolean[len][len];
        // 状态转移方程:在 s[i] == s[j] 的时候,dp[i][j] 参考 dp[i + 1][j - 1]
        for (int right = 0; right < len; right++) {
            // 注意:left <= right 取等号表示 1 个字符的时候也需要判断
            for (int left = 0; left <= right; left++) {
                //当字符串小于等于2或者上一个字符串是回文串的时候 两个字符相等则认为它是回文串
                if (s.charAt(left) == s.charAt(right) && (right - left <= 2 || dp[left + 1][right - 1])) {
                    dp[left][right] = true;
                }
            }
        }
        //代表分割[0,i]这一段所需要的最小分割次数
        int[] fdp=new int[len];
        for (int i = 0; i < len; i++) {
            //满足回文串不需要分割
            if(dp[0][i]){
                fdp[i]=0;
            }else{
                fdp[i]=i;//预设的最大分割次数
                //左端往右压缩搜索,看是否有回文串出现,注意这里要搜索到自己,因为如果类似abbac的情况搜索到c的时候就可以查到fdp[j-1]+1
                for (int j = 0; j <=i ; j++) {
                    if(dp[j][i])fdp[i]=Math.min(fdp[i],fdp[j-1]+1);//上一个字符的分割次数+1
                }
            }
        }
        return fdp[len-1];
    }

113-路径总和

 该回溯的停止条件是根节点为null的时候,当且仅当在叶子节点并且targetSum已经被削减到叶子节点值的时候增加结果

 public List<List<Integer>> pathSum(TreeNode root, int targetSum) {
        List<List<Integer>> res=new ArrayList<>();
        Deque<Integer> path=new ArrayDeque<>();
        dfs(root,targetSum,path,res);
        return res;
    }

    private void dfs(TreeNode root, int targetSum, Deque<Integer> path, List<List<Integer>> res) {
        if(root==null)return;
        path.add(root.val);
        if(root.left==null&root.right==null){
            if(targetSum==root.val){
                res.add(new ArrayList<>(path));
            }
        }
        dfs(root.left,targetSum-root.val,path,res);
        dfs(root.right,targetSum-root.val,path,res);
        path.removeLast();
    }

93--复原可能产生的ip地址

 judge函数判断是否是一个合法的ip地址,dfs的添加结束条件是begin等于len的时候,与其他回溯有所不同的是dfs里记录了residue:还有多少段没有被分割,这是由于ip地址只能被分割为4段导致的;

(residue*3<len-i)表示后面的ip段即使全部是3位,s还是会有剩下的,因此剪枝;每次dfs只判断3位数字,在for循环中更新begin为最新的i+1,并且减少residue

public List<String> restoreIpAddresses(String s) {
        List<String> res=new ArrayList<>();
        int len=s.length();
        if(len>12||len<4)return res;
        Deque<String> path=new ArrayDeque<>(4);
        dfs(s,len,0,4,path,res);
        return res;
    }
    //residue记录还有多少段没有被分割
    private void dfs(String s, int len, int begin, int residue, Deque<String> path, List<String> res) {
        if(begin==len){
            //使用指定的分隔符分割集合中的string
            if(residue==0)res.add(String.join(".",path));
            return;
        }
        for (int i=begin;i<begin+3;i++){
            if(i>=len)break;
            //剩下的长度太长,继续往后走,直到能分割的时候
            if(residue*3<len-i)continue;
            if(judge(s,begin,i)){
                String cur=s.substring(begin,i+1);
                path.addLast(cur);
                dfs(s,len,i+1,residue-1,path,res);
                path.removeLast();
            }
        }
    }
    //看字符串是否合理
    private boolean judge(String s, int left, int right) {
        int len=right-left+1;
        //前导为0
        if(len>1&&s.charAt(left)=='0')return false;
        int res=0;
        while (left<=right){
            res=res*10+s.charAt(left)-'0';
            left++;
        }
        return res>=0&&res<=255;
    }

90-子集

 此类子集问题在回溯前优先排序,因为可以达到剪枝的目的;

dfs算法一开始就加入path为结果,这是因为空集是所有集合的子集,后续在起始索引超过数组长度的时候return;循环里的剪枝条件是当两个前后数字相等并且上一个数字并没有被使用的时候(前期我们做了排序处理,因此数组是升序的)

该去重去除的是同一层树上的重复元素而非同一个树枝上的重复元素所以需要加上!used[i-1]的条件

90.å­éII.png

List<List<Integer>> result = new ArrayList<>();// 存放符合条件结果的集合
    LinkedList<Integer> path = new LinkedList<>();// 用来存放符合条件结果
    boolean[] used;
    public List<List<Integer>> subsetsWithDup(int[] nums) {
        if (nums.length == 0){
            result.add(path);
            return result;
        }
        //排序
        Arrays.sort(nums);
        used = new boolean[nums.length];
        subsetsWithDupHelper(nums, 0);
        return result;
    }

    private void subsetsWithDupHelper(int[] nums, int startIndex){
        result.add(new ArrayList<>(path));
        if (startIndex >= nums.length){
            return;
        }
        for (int i = startIndex; i < nums.length; i++){
            if (i > 0 && nums[i] == nums[i - 1] && !used[i - 1]){
                continue;
            }
            //add方法是在后面追加,push方法是在前面追加(相当于addFirst)
            path.add(nums[i]);
            used[i] = true;
            subsetsWithDupHelper(nums, i + 1);
            path.removeLast();
            used[i] = false;
        }
    }

60--排列序列

与其他回溯题不同的是,这题不需要枚举所有的分支,只需要找到第k个结果存在的那个分支,我们初始化一个factory数组用于记录每个剩下可以使用的数字可以进行排列组合的总数,当这个总数小于k的时候,说明还没有进入想要的分支;在k<cnt的时候说明已经进入了结果分支,所以当前的i是要加入结果中的,继续对下一个数字进行dfs,此题不需要回溯变量,因为只需要找到一个结果就行了,所以严格意义上不能算是回溯算法

最后的return是需要加的,因为既然到了这个地方说明该层的数据已经不需要去计算了

/**
     * 记录数字是否使用过
     */
    private boolean[] used;

    /**
     * 阶乘数组
     */
    private int[] factorial;

    private int n;
    private int k;

    public String getPermutation(int n, int k) {
        this.n = n;
        this.k = k;
        calculateFactorial(n);

        // 查找全排列需要的布尔数组
        used = new boolean[n + 1];
        Arrays.fill(used, false);

        StringBuilder path = new StringBuilder();
        dfs(0, path);
        return path.toString();
    }


    /**
     * @param index 在这一步之前已经选择了几个数字,其值恰好等于这一步需要确定的下标位置
     * @param path
     */
    private void dfs(int index, StringBuilder path) {
        if (index == n) {
            return;
        }

        // n-1-index表示还没有参与计算的数字的个数,factorial[n - 1 - index]也就是剩余的叶子节点个数
        int cnt = factorial[n - 1 - index];
        for (int i = 1; i <= n; i++) {
            if (used[i]) {
                continue;
            }
            //当剩余叶子节点个数小于k的时候,我们直接减去,也不用进去计算,因为反转结果不在这个小分支里面
            if (cnt < k) {
                k -= cnt;
                continue;
            }
            //进到这里说明我们已经准备开始计算真正的结果了,现在进入了我们想要的分支,添加当前分支的路径,进入更小的分支
            path.append(i);
            used[i] = true;
            //index+1表示我已经拿到了一个数字,剩下的排列组合产生的结果会更小
            dfs(index + 1, path);//通过递归找到最小的分支

            // 注意 1:不可以回溯(重置变量),算法设计是「一下子来到叶子结点」,没有回头的过程
            // 注意 2:这里要加 return,后面的数没有必要遍历去尝试了
            return;
        }
    }

    //阶乘数组
    private void calculateFactorial(int n) {
        factorial = new int[n + 1];
        factorial[0] = 1;
        for (int i = 1; i <= n; i++) {
            factorial[i] = factorial[i - 1] * i;
        }
    }

47--全排列2

 经典的先sort再去重操作,和之前的求子集题目很像,但是它需要返回完整的路径

 //记录哪些已经被操作过了,下次遇到就直接跳过
    boolean[] used;
    public List<List<Integer>> permuteUnique(int[] nums) {
        int len = nums.length;
        List<List<Integer>> res = new ArrayList<>();
        if (len == 0) return res;
        //排序是剪枝的前提
        Arrays.sort(nums);
        used = new boolean[len];
        Deque<Integer> path=new ArrayDeque<>(len);
        dfs(nums,len,0,path,res);
        return res;
    }

    private void dfs(int[] nums, int len, int begin, Deque<Integer> path, List<List<Integer>> res) {
        if(begin==len){
            res.add(new ArrayList<>(path));
            return;
        }
        for (int i = 0; i < len; i++) {
            if(used[i])continue;
            //i > 0 是为了保证 nums[i - 1] 有意义
            //!used[i - 1] 是因为 nums[i - 1] 在深度优先遍历的过程中刚刚被撤销选择
            if(i>0&&nums[i]==nums[i-1]&&!used[i-1])continue;
            path.addLast(nums[i]);
            used[i]=true;
            dfs(nums,len,begin+1,path,res);
            used[i]=false;
            path.removeLast();
        }
    }

39--组合总数1

 对数字回溯类的题目先进行排序再进行回溯往往可以达到剪枝的效果,此题排序后如果target减去一个数已经是负数,对于升序的数组来说往后已经没有结果了

//回溯+减枝
    public List<List<Integer>> combinationSum(int[] candidates, int target) {
        int len = candidates.length;
        List<List<Integer>> res = new ArrayList<>();
        if (len == 0) {
            return res;
        }
        //如果 target 减去一个数得到负数,那么减去一个更大的树依然是负数,同样搜索不到结果。基于这个想法,我们可以对输入数组进行排序,添加相关逻辑达到进一步剪枝的目的
        //要想减枝,先进行排序
        Arrays.sort(candidates);

        Deque<Integer> path = new ArrayDeque<>();
        dfs(candidates, 0, len, target, path, res);
        return res;
    }

    /**
     * @param candidates 候选数组
     * @param begin      搜索起点
     * @param len        冗余变量,是 candidates 里的属性,可以不传
     * @param target     每减去一个元素,目标值变小
     * @param path       从根结点到叶子结点的路径,是一个栈
     * @param res        结果集列表
     */
    private void dfs(int[] candidates, int begin, int len, int target, Deque<Integer> path, List<List<Integer>> res) {
        if (target == 0) {
            res.add(new ArrayList<>(path));
            return;
        }

        // 重点理解这里从 begin 开始搜索的语意
        for (int i = begin; i < len; i++) {
            //减枝操作
            if (target - candidates[i] < 0) {
                break;
            }
            //尝试着把当前节点加入path
            path.addLast(candidates[i]);

            // 注意:由于每一个元素可以重复使用,下一轮搜索的起点依然是 i,这里非常容易弄错
            dfs(candidates, i, len, target - candidates[i], path, res);

            // 状态重置
            path.removeLast();
        }
    }

40--组合总数2

与上题不同的是,这次的数组每个数只能用一次,因此在下一层的dfs中应该不包括当前层的索引,虽然数字不能重复,但是数组中如果存在相同数字,可以进行小剪枝

public List<List<Integer>> combinationSum2(int[] candidates, int target) {
        int len = candidates.length;
        List<List<Integer>> res = new ArrayList<>();
        if (len == 0) {
            return res;
        }

        // 关键步骤
        Arrays.sort(candidates);

        Deque<Integer> path = new ArrayDeque<>(len);
        dfs(candidates, len, 0, target, path, res);
        return res;
    }
    //从begin开始搜索
    private void dfs(int[] candidates, int len, int begin, int target, Deque<Integer> path, List<List<Integer>> res) {
        if(target==0){
            res.add(new ArrayList<>(path));
            return;
        }

        for (int i = begin; i <len ; i++) {
            //大剪枝
            if(target-candidates[i]<0)break;
            //小剪枝 针对同一层相同数值的节点 同一层数值相同的结点第 222、333 ... 个结点,因为数值相同的第 111 个结点已经搜索出了包含了这个数值的全部结果
            if(i>begin&&candidates[i]==candidates[i-1])continue;
            path.addLast(candidates[i]);
            //元素不可重复
            dfs(candidates,len,i+1,target-candidates[i],path,res);
            path.removeLast();
        }
    }
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值