LeetCode力扣刷题——一切皆可搜索

搜索


一、算法解释

        深度优先搜索和广度优先搜索是两种最常见的优先搜索方法,它们被广泛地运用在图和树等 结构中进行搜索。

二、经典问题

1. 深度优先搜索

        深度优先搜索(depth-first seach DFS )在搜索到一个新的节点时,立即对该新节点进行遍 历;因此遍历需要用先入后出的栈来实现,也可以通过与栈等价的递归来实现。对于树结构而言, 由于总是对新节点调用遍历,因此看起来是向着“深”的方向前进。
        考虑如下一颗简单的树。我们从 1 号节点开始遍历,假如遍历顺序是从左子节点到右子节点,那么按照优先向着“深”的方向前进的策略,假如我们使用递归实现,我们的遍历过程为 1 (起 始节点)->2 (遍历更深一层的左子节点) ->4 (遍历更深一层的左子节点) ->2 (无子节点,返回 父结点)->1 (子节点均已完成遍历,返回父结点) ->3 (遍历更深一层的右子节点) ->1 (无子节 点,返回父结点)-> 结束程序(子节点均已完成遍历)。如果我们使用栈实现,我们的栈顶元素 的变化过程为 1->2->4->3

        深度优先搜索也可以用来检测环路:记录每个遍历过的节点的父节点,若一个节点被再次遍 历且父节点不同,则说明有环。我们也可以用之后会讲到的拓扑排序判断是否有环路,若最后存 在入度不为零的点,则说明有环。
        有时我们可能会需要对已经搜索过的节点进行标记,以防止在遍历时重复搜索某个节点,这 种做法叫做状态记录记忆化(memoization )。

        给你一个大小为 m x n 的二进制矩阵 grid 。

        岛屿 是由一些相邻的 1 (代表土地) 构成的组合,这里的「相邻」要求两个 1 必须在 水平或者竖直的四个方向上 相邻。你可以假设 grid 的四个边缘都被 0(代表水)包围着。

        岛屿的面积是岛上值为 1 的单元格的数目。

        计算并返回 grid 中最大的岛屿面积。如果没有岛屿,则返回面积为 0 。

        此题是十分标准的搜索题,我们可以拿来练手深度优先搜索。一般来说,深度优先搜索类型的题可以分为主函数和辅函数,主函数用于遍历所有的搜索位置,判断是否可以开始索,如果可以即在辅函数进行搜索。辅函数则负责深度优先搜索的递归调用。当然,我们也可以使用栈(stack )实现深度优先搜索,但因为栈与递归的调用原理相同,而递归相对便于实现,因此刷题时笔者推荐使用递归式写法,同时也方便进行回溯(见下节)。不过在实际工程上,直接使用栈可能才是最好的选择,一是因为便于理解,二是更不易出现递归栈满的情况。
        这里我们使用了一个小技巧,对于四个方向的遍历,可以创造一个数组 [-1, 0, 1, 0, -1],每相邻两位即为上下左右四个方向之一。
        在辅函数里,一个一定要注意的点是辅函数内递归搜索时,边界条件的判定。边界判定一般有两种写法,一种是先判定是否越界,只有在合法的情况下才进行下一步搜索(即判断放在调用递归函数前);另一种是不管三七二十一先进行下一步搜索,待下一步搜索开始时再判断是否合法(即判断放在辅函数第一行)。
class Solution {
public: 
    vector<int> direction{-1, 0, 1, 0, -1};
    // 主函数
    int maxAreaOfIsland(vector<vector<int>>& grid) {
        if(grid.empty() || grid[0].empty())
            return 0;
        int max_area = 0;
        for(int i=0; i<grid.size(); ++i){
            for(int j=0; j<grid[0].size(); ++j){
                if(grid[i][j] == 1){
                    max_area = max(max_area, dfs(grid, i, j));
                }
            }
        }
        return max_area;
    }
    // 辅函数
    int dfs(vector<vector<int>>& grid, int r, int c){
        if(grid[r][c] == 0)
            return 0;
        grid[r][c] = 0;
        int x, y, area = 1;
        for(int i=0; i<4; ++i){
            x = r + direction[i], y = c + direction[i + 1];
            if(x >= 0 && x < grid.size() && y >= 0 && y < grid[0].size()){
                area += dfs(grid, x, y);
            }
        }
        return area;
    }
};

547. 省份数量

        有 n 个城市,其中一些彼此相连,另一些没有相连。如果城市 a 与城市 b 直接相连,且城市 b 与城市 c 直接相连,那么城市 a 与城市 c 间接相连。

        省份 是一组直接或间接相连的城市,组内不含其他没有相连的城市。

        给你一个 n x n 的矩阵 isConnected ,其中 isConnected[i][j] = 1 表示第 i 个城市和第 j 个城市直接相连,而 isConnected[i][j] = 0 表示二者不直接相连。

        返回矩阵中 省份 的数量。

        对于题目 695 ,图的表示方法是,每个位置代表一个节点,每个节点与上下左右四个节点相邻。而在这一道题里面,每一行(列)表示一个节点,它的每列(行)表示是否存在一个相邻节点。因此题目 695 拥有 m × n 个节点,每个节点有 4 条边;而本题拥有 n 个节点,每个节点最多有 n 条边,表示和所有人都是朋友,最少可以有 1 条边,表示自己与自己相连。当清楚了图的表 示方法后,这道题与题目 695 本质上是同一道题:搜索省份 (岛屿)的个数(最大面积)。
class Solution {
public:
    // 主函数
    int findCircleNum(vector<vector<int>>& isConnected) {
        int n = isConnected.size(), count = 0;
        vector<bool> visited(n, false);
        for(int i=0; i<n; ++i){
            if(!visited[i]){
                dfs(isConnected, i, visited);
                ++count;
            }
        }
        return count;
    }
    // 辅函数
    void dfs(vector<vector<int>>& isConnected, int i, vector<bool>& visited){
        visited[i] = true;
        for(int k=0; k<isConnected.size(); ++k){
            if(isConnected[i][k] == 1 && !visited[k]){
                dfs(isConnected, k, visited);
            }
        }
    }
};

417. Pacific Atlantic Water Flow

        有一个 m × n 的矩形岛屿,与 太平洋 和 大西洋 相邻。 “太平洋” 处于大陆的左边界和上边界,而 “大西洋” 处于大陆的右边界和下边界。

        这个岛被分割成一个由若干方形单元格组成的网格。给定一个 m x n 的整数矩阵 heights , heights[r][c] 表示坐标 (r, c) 上单元格 高于海平面的高度 。

        岛上雨水较多,如果相邻单元格的高度 小于或等于 当前单元格的高度,雨水可以直接向北、南、东、西流向相邻单元格。水可以从海洋附近的任何单元格流入海洋。

        返回网格坐标 result 的 2D 列表 ,其中 result[i] = [ri, ci] 表示雨水从单元格 (ri, ci) 流动 既可流向太平洋也可流向大西洋 。

        虽然题目要求的是满足向下流能到达两个大洋的位置,如果我们对所有的位置进行搜索,那 么在不剪枝的情况下复杂度会很高。因此我们可以反过来想,从两个大洋开始向上流,这样我们 只需要对矩形四条边进行搜索。搜索完成后,只需遍历一遍矩阵,满足条件的位置即为两个大洋 向上流都能到达的位置。
class Solution {
public:
    vector<int> direction{-1, 0, 1, 0, -1};
    vector<vector<int>> pacificAtlantic(vector<vector<int>>& heights) {
        if(heights.empty() || heights[0].empty()){
            return {};
        }
        vector<vector<int>> ans;
        int m = heights.size(), n = heights[0].size();
        vector<vector<bool>> can_reach_p(m, vector<bool>(n, false)); // 太平洋
        vector<vector<bool>> can_reach_a(m, vector<bool>(n, false)); // 大西洋
        // 左右两边开始往里流水
        for(int i=0; i<m; ++i){
            dfs(heights, can_reach_p, i, 0);
            dfs(heights, can_reach_a, i, n - 1);
        }
        // 上下两边开始往里流水
        for(int i=0; i<n; ++i){
            dfs(heights, can_reach_p, 0, i);
            dfs(heights, can_reach_a, m - 1, i);
        }
        for(int i=0; i<m; ++i){
            for(int j=0; j<n; ++j){
                if(can_reach_p[i][j] && can_reach_a[i][j]){
                    ans.push_back(vector<int>{i, j});
                }
            }
        }
        return ans;
    }
    void dfs(const vector<vector<int>>& heights, vector<vector<bool>>& can_reach, int r, int c){
        if(can_reach[r][c]){  //能反流到,则退出这次dfs
            return;
        }
        can_reach[r][c] = true;  //标记为能反流到
        int x, y;
        for(int i=0; i<4; ++i){
            x = r + direction[i], y = c + direction[i + 1];
            if(x >= 0 && x <= heights.size() - 1 && y >= 0 && y <= heights[0].size() - 1 && heights[r][c] <= heights[x][y]){
                dfs(heights, can_reach, x, y);
            }
        }
    }
};

2. 回溯法

        回溯法(backtracking )是优先搜索的一种特殊情况,又称为试探法,常用于需要记录节点状态的深度优先搜索。通常来说,排列、组合、选择类问题使用回溯法比较方便。
        顾名思义,回溯法的核心是回溯。在搜索到某一节点的时候,如果我们发现目前的节点(及 其子节点)并不是需求目标时,我们回退到原来的节点继续搜索,并且把在目前节点修改的状态 还原。这样的好处是我们可以始终只对图的总状态进行修改,而非每次遍历时新建一个图来储存 状态。在具体的写法上,它与普通的深度优先搜索一样,都有 [ 修改当前节点状态 ] [ 递归子节点] 的步骤,只是多了回溯的步骤,变成了 [ 修改当前节点状态 ] [ 递归子节点 ] [ 回改当前节点状态]
        没有接触过回溯法的读者可能会不明白我在讲什么,这也完全正常,希望以下几道题可以让 您理解回溯法。如果还是不明白,可以记住两个小诀窍,一是按引用传状态,二是所有的状态修 改在递归完成后回改
        回溯法修改一般有两种情况,一种是修改最后一位输出,比如排列组合;一种是修改访问标 记,比如矩阵里搜字符串。

        给定一个不含重复数字的数组 nums ,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。

        怎样输出所有的排列方式呢?对于每一个当前位置 i,我们可以将其于之后的任意位置交换,然后继续处理位置 i+1,直到处理到最后一位。为了防止我们每此遍历时都要新建一个子数组储存位置 i 之前已经交换好的数字,我们可以利用回溯法,只对原数组进行修改,在递归完成后再修改回来。

        我们以样例[1,2,3]为例,按照这种方法,我们输出的数组顺序为[[1,2,3], [1,3,2], [2,1,3], [2,3,1], [3,2,1], [3,1,2]],可以看到所有的排列在这个算法中都被考虑到了。

class Solution {
public:
    // 主函数
    vector<vector<int>> permute(vector<int>& nums) {
        vector<vector<int>> ans;
        backtracking(nums, 0, ans);
        return ans;
    }
    // 辅函数
    void backtracking(vector<int> &nums, int level, vector<vector<int>> &ans){
        if(level == nums.size() - 1){
            ans.push_back(nums);
            return;
        }
        for(int i=level; i<nums.size(); ++i){
            swap(nums[i], nums[level]); // 修改当前节点状态
            backtracking(nums, level + 1, ans); // 递归子节点
            swap(nums[i], nums[level]); // 回退到当前节点
        }
    }
};

77. 组合

77. Combinations

        给定两个整数 n 和 k,返回范围 [1, n] 中所有可能的 k 个数的组合。

        你可以按 任何顺序 返回答案。

        类似于排列问题,我们也可以进行回溯。排列回溯的是交换的位置,而组合回溯的是是否把当前的数字加入结果中。
class Solution {
public:
    // 主函数
    vector<vector<int>> combine(int n, int k) {
        vector<vector<int>> ans;
        vector<int> comb(k, 0);
        int count = 0;
        backtracking(ans, comb, count, 1, n, k);
        return ans;
    }
    // 辅函数
    void backtracking(vector<vector<int>>& ans, vector<int>& comb, int& count, int pos, int n, int k){
        if(count == k){
            ans.push_back(comb);
            return;
        }
        for(int i=pos; i<=n; ++i){
            comb[count++] = i; // 修改当前节点状态
            backtracking(ans, comb, count, i + 1, n, k); // 递归子节点
            --count; // 回退到当前节点状态
        }
    }
};

        给定一个 m x n 二维字符网格 board 和一个字符串单词 word 。如果 word 存在于网格中,返回 true ;否则,返回 false 。

        单词必须按照字母顺序,通过相邻的单元格内的字母构成,其中“相邻”单元格是那些水平相邻或垂直相邻的单元格。同一个单元格内的字母不允许被重复使用。

        不同于排列组合问题,本题采用的并不是修改输出方式,而是修改访问标记。在我们对任意位置进行深度优先搜索时,我们先标记当前位置为已访问,以避免重复遍历(如防止向右搜索后又向左返回);在所有的可能都搜索完成后,再回改当前位置为未访问,防止干扰其它位置搜索到当前位置。使用回溯法,我们可以只对一个二维的访问矩阵进行修改,而不用把每次的搜索状态作为一个新对象传入递归函数中。
class Solution {
public:
    // 主函数
    bool exist(vector<vector<char>>& board, string word) {
        if(board.empty())
            return false;
        int m = board.size(), n = board[0].size();
        vector<vector<bool>> visited(m, vector<bool>(n, false));
        bool find = false;
        for(int i=0; i<m; ++i){
            for(int j=0; j<n;++j){
                backtracking(i, j, board, word, find, visited, 0);
            }
        }
        return find;
    }
    // 辅函数
    void backtracking(int i, int j, vector<vector<char>>& board, string word, bool& find, vector<vector<bool>>& visited, int pos){
        if(i < 0 || i >= board.size() || j < 0 || j >= board[0].size()){
            return;
        }
        if(visited[i][j] || find || board[i][j] != word[pos]){
            return;
        }
        if(pos == word.size() - 1){
            find = true;
            return;
        }
        // 修改当前节点状态
        visited[i][j] = true;
        // 递归子节点
        backtracking(i + 1, j, board, word, find, visited, pos + 1);
        backtracking(i - 1, j, board, word, find, visited, pos + 1);
        backtracking(i, j + 1, board, word, find, visited, pos + 1);
        backtracking(i, j - 1, board, word, find, visited, pos + 1);
        // 回退当前节点状态
        visited[i][j] = false;
    }
};

51. N 皇后

        按照国际象棋的规则,皇后可以攻击与之处在同一行或同一列或同一斜线上的棋子。

        n 皇后问题 研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击。

        给你一个整数 n ,返回所有不同的 n 皇后问题 的解决方案。

        每一种解法包含一个不同的 n 皇后问题 的棋子放置方案,该方案中 'Q' 和 '.' 分别代表了皇后和空位。

        类似于在矩阵中寻找字符串,本题也是通过修改状态矩阵来进行回溯。不同的是,我们需要对每一行、列、左斜、右斜建立访问数组,来记录它们是否存在皇后。
        本题有一个隐藏的条件,即满足条件的结果中每一行或列有且仅有一个皇后。这是因为我们一共只有 n 行和 n 列。所以如果我们通过对每一行遍历来插入皇后,我们就不需要对行建立访问 数组了。
class Solution {
public:
    // 主函数
    vector<vector<string>> solveNQueens(int n) {
        vector<vector<string>> ans;
        if(n == 0)
            return ans;
        vector<string> board(n, string(n, '.'));
        vector<bool> column(n, false), ldiag(2 * n - 1, false), rdiag(2 * n - 1, false);
        backtracking(ans, board, column, ldiag, rdiag, 0, n);
        return ans;
    }
    // 辅函数
    void backtracking(vector<vector<string>>& ans, vector<string>& board, vector<bool>& column, vector<bool>& ldiag, vector<bool>& rdiag, int row, int n){
        if(row == n){
            ans.push_back(board);
            return;
        }
        // 在row行寻找合适位置放置皇后
        for(int i=0; i<n; ++i){
            if(column[i] || ldiag[n - row + i - 1] || rdiag[row + i]){
                continue;
            }
            // 修改当前节点状态
            board[row][i] = 'Q';
            column[i] = ldiag[n - row + i - 1] = rdiag[row + i] = true;
            // 递归子节点
            backtracking(ans, board, column, ldiag, rdiag, row + 1, n);
            //回退当前节点状态
            board[row][i] = '.';
            column[i] = ldiag[n - row + i - 1] = rdiag[row + i] = false;
        } 
    }
};

3. 广度优先搜索

        广度优先搜索(breadth-fifirst search BFS )不同与深度优先搜索,它是一层层进行遍历的,因此需要用先入先出的队列而非先入后出的栈进行遍历。由于是按层次进行遍历,广度优先搜索时按照“广”的方向进行遍历的,也常常用来处理最短路径等问题。
        考虑如下一颗简单的树。我们从 1 号节点开始遍历,假如遍历顺序是从左子节点到右子节点,那么按照优先向着“广”的方向前进的策略,队列顶端的元素变化过程为 [1]->[2->3]->[4] ,其中方括号代表每一层的元素。

         这里要注意,深度优先搜索和广度优先搜索都可以处理可达性问题,即从一个节点开始是否能达到另一个节点。因为深度优先搜索可以利用递归快速实现,很多人会习惯使用深度优先搜索刷此类题目。实际软件工程中,笔者很少见到递归的写法,因为一方面难以理解,另一方面可能产生栈溢出的情况;而用栈实现的深度优先搜索和用队列实现的广度优先搜索在写法上并没有太大差异,因此使用哪一种搜索方式需要根据实际的功能需求来判断。

934. 最短的桥

934. Shortest Bridge

        给你一个大小为 n x n 的二元矩阵 grid ,其中 1 表示陆地,0 表示水域。

        岛 是由四面相连的 1 形成的一个最大组,即不会与非组内的任何其他 1 相连。grid 中 恰好存在两座岛 。

        你可以将任意数量的 0 变为 1 ,以使两座岛连接起来,变成 一座岛 。

        返回必须翻转的 0 的最小数目。

        本题实际上是求两个岛屿间的最短距离,因此我们可以先通过任意搜索方法找到其中一个岛屿,然后利用广度优先搜索,查找其与另一个岛屿的最短距离。
class Solution {
public:
    vector<int> direction{-1, 0, 1, 0, -1};
    // 主函数
    int shortestBridge(vector<vector<int>>& grid) {
        int m = grid.size(), n = grid[0].size();
        queue<pair<int, int>> points;
        // dfs寻找第一个岛屿,并把1全部赋值为2
        bool flipped = false;
        for(int i=0; i<m; ++i){
            if(flipped)
                break;
            for(int j=0; j<n; ++j){
                if(grid[i][j] == 1){
                    dfs(points, grid, m, n, i, j);
                    flipped = true;
                    break;
                }
            }
        }
        // bfs寻找第二个岛屿,并把过程中经过的0赋值为2
        int x, y;
        int level = 0;
        while(!points.empty()){
            ++level;
            int n_points = points.size();
            while(n_points--){
                auto [r, c] = points.front();
                grid[r][c] = 2;
                points.pop();
                for(int k=0; k<4; ++k){
                    x = r + direction[k], y = c + direction[k + 1];
                    if(x >= 0 && y >= 0 && x < m && y < n){
                        if(grid[x][y] == 2){
                            continue;
                        }
                        if(grid[x][y] == 1){
                            return level;
                        }
                        points.push({x, y});
                        grid[x][y] = 2;
                    }
                }
            }
        }
        return 0;
    }
    // 辅函数
    void dfs(queue<pair<int, int>>& points, vector<vector<int>>& grid, int m, int n, int i, int j){
        if(i < 0 || j < 0 || i >= m || j >= n || grid[i][j] == 2){
            return;
        }
        if(grid[i][j] == 0){
            points.push({i, j});
            return;
        }
        grid[i][j] = 2;
        dfs(points, grid, m, n, i - 1, j);
        dfs(points, grid, m, n, i + 1, j);
        dfs(points, grid, m, n, i, j - 1);
        dfs(points, grid, m, n, i, j + 1);
    }
};

126. 单词接龙 II

126. Word Ladder II

        按字典 wordList 完成从单词 beginWord 到单词 endWord 转化,一个表示此过程的 转换序列 是形式上像 beginWord -> s1 -> s2 -> ... -> sk 这样的单词序列,并满足:

        每对相邻的单词之间仅有单个字母不同。
        转换过程中的每个单词 si(1 <= i <= k)必须是字典 wordList 中的单词。注意,beginWord 不必是字典 wordList 中的单词。
        sk == endWord
        给你两个单词 beginWord 和 endWord ,以及一个字典 wordList 。请你找出并返回所有从 beginWord 到 endWord 的 最短转换序列 ,如果不存在这样的转换序列,返回一个空列表。每个序列都应该以单词列表 [beginWord, s1, s2, ..., sk] 的形式返回。

        我们可以把起始字符串、终止字符串、以及单词表里所有的字符串想象成节点。若两个字符串只有一个字符不同,那么它们相连。因为题目需要输出修改次数最少的所有修改方式,因此我 们可以使用广度优先搜索,求得起始节点到终止节点的最短距离。
        我们同时还使用了一个小技巧:我们并不是直接从起始节点进行广度优先搜索,直到找到终止节点为止;而是从起始节点和终止节点分别进行广度优先搜索,每次只延展当前层节点数最少的那一端,这样我们可以减少搜索的总结点数。举例来说,假设最短距离为 4 ,如果我们只从一 端搜索 4 层,总遍历节点数最多是 1 + 2 + 4 + 8 + 16 = 31 ;而如果我们从两端各搜索两层,总遍 历节点数最多只有 2 × ( 1 + 2 + 4 ) = 14
        在搜索结束后,我们还需要通过回溯法来重建所有可能的路径。
class Solution {
public:
    vector<vector<string>> findLadders(string beginWord, string endWord, vector<string>& wordList) {
        vector<vector<string>> ans;
        unordered_set<string> dict;
        for(const auto &w: wordList){
            dict.insert(w);
        }
        if(!dict.count(endWord)) {
            return ans;
        }
        dict.erase(beginWord);
        dict.erase(endWord);
        unordered_set<string> q1{beginWord}, q2{endWord};
        unordered_map<string, vector<string>> next;
        bool reversed = false, found = false;
        while(!q1.empty()){
            unordered_set<string> q;
            for(const auto &w: q1){
                string s = w;
                for(size_t i=0; i<s.size(); i++){
                    char ch = s[i];
                    for(int j=0; j<26; j++){
                        s[i] = j + 'a';
                        if(q2.count(s)){
                            reversed? next[s].push_back(w): next[w].push_back(s);
                            found = true;
                        }
                        if(dict.count(s)){
                            reversed? next[s].push_back(w): next[w].push_back(s);
                            q.insert(s);
                        }
                    }
                    s[i] = ch;
                }
            }
            if(found)
                break;
            for(const auto &w:q)
                dict.erase(w);
            if(q.size() <= q2.size()){
                q1 = q;
            }else{
                reversed = !reversed;
                q1 = q2;
                q2 = q;
            }
        }
        if(found){
            vector<string> path = {beginWord};
            backtracking(beginWord, endWord, next, path, ans);
        }
        return ans;
    }
    
    void backtracking(const string &src, const string &dst, unordered_map<string,
    vector<string>> &next, vector<string> &path, vector<vector<string>> &ans) {
        if (src == dst) {
            ans.push_back(path);
            return; 
        }
        for (const auto &s: next[src]) {
            path.push_back(s);
            backtracking(s, dst, next, path, ans);
            path.pop_back();
        } 
    }
};

三、巩固练习

130. 被围绕的区域

130. Surrounded Regions

257. 二叉树的所有路径

257. Binary Tree Paths

47. 全排列 II

47. Permutations II

40. 组合总和 II

40. Combination Sum II

37. 解数独

37. Sudoku Solver

310. 最小高度树

310. Minimum Height Trees


欢迎大家共同学习和纠正指教

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值