数据结构算法刷题模板

本文是个人观看哔站阿婆主“拉不拉东”东哥视频与相关公众号自己整理出来的模板和理解,更加简洁。如有侵权联系我删除。当然也推荐大家直接看东哥的公众号。

一、双指针

二分思想 - 二叉树中序遍历-双指针性

func mergeKLists(lists []*ListNode) *ListNode {
    length := len(lists)
    if length == 0{
        return nil
    }
    if length == 1{
        return lists[0]
    }
    mid:=length / 2
    a:=mergeKLists(lists[:mid])
    b:=mergeKLists(lists[mid:])
    return merge2Lists(a,b)
}

func merge2Lists(a,b *ListNode) *ListNode{
    // 合并两个链表
    return res.Next
}

先后指针 - 一遍找到链表倒数第n节点

// 返回链表的倒数第 k 个节点
ListNode findFromEnd(ListNode head, int k) {
    ListNode p1 = head;
    // p1 先走 k 步
    for (int i = 0; i < k; i++) {
        p1 = p1.next;
    }
    ListNode p2 = head;
    // p1 和 p2 同时走 n - k 步
    while (p1 != null) {
        p2 = p2.next;
        p1 = p1.next;
    }
    // p2 现在指向第 n - k + 1 个节点,即倒数第 k 个节点
    return p2;
}

快慢指针 - 找到链表中间节点

ListNode middleNode(ListNode head) {
    // 快慢指针初始化指向 head
    ListNode slow = head, fast = head;
    // 快指针走到末尾时停止
    while (fast != null && fast.next != null) {
        // 慢指针走一步,快指针走两步
        slow = slow.next;
        fast = fast.next.next;
    }
    // 慢指针指向中点
    return slow;
}

快慢指针 - 判断链表是否为环,及环起点

判断链表是否包含环属于经典问题了,解决方案也是用快慢指针:每当慢指针 slow 前进一步,快指针 fast 就前进两步。如果 fast 最终遇到空指针,说明链表中没有环;如果 fast 最终和 slow 相遇,那肯定是 fast 超过了 slow 一圈,说明链表中含有环。

boolean hasCycle(ListNode head) {
    // 快慢指针初始化指向 head
    ListNode slow = head, fast = head;
    // 快指针走到末尾时停止
    while (fast != null && fast.next != null) {
        // 慢指针走一步,快指针走两步
        slow = slow.next;
        fast = fast.next.next;
        // 快慢指针相遇,说明含有环
        if (slow == fast) {
            return true;
        }
    }
    // 不包含环
    return false;
}

当然,这个问题还有进阶版:如果链表中含有环,如何计算这个环的起点?

可以看到,当快慢指针相遇时,让其中任一个指针指向头节点,然后让它俩以相同速度前进,再次相遇时所在的节点位置就是环开始的位置。

我们假设快慢指针相遇时,慢指针 slow 走了 k 步,那么快指针 fast 一定走了 2k 步:

img

fast 一定比 slow 多走了 k 步,这多走的 k 步其实就是 fast 指针在环里转圈圈,所以 k 的值就是环长度的「整数倍」。

假设相遇点距环的起点的距离为 m,那么结合上图的 slow 指针,环的起点距头结点 head 的距离为 k - m,也就是说如果从 head 前进 k - m 步就能到达环起点。

巧的是,如果从相遇点继续前进 k - m 步,也恰好到达环起点。因为结合上图的 fast 指针,从相遇点开始走k步可以转回到相遇点,那走 k - m 步肯定就走到环起点了:

img

所以,只要我们把快慢指针中的任一个重新指向 head,然后两个指针同速前进,k - m 步后一定会相遇,相遇之处就是环的起点了。

ListNode detectCycle(ListNode head) {
    ListNode fast, slow;
    fast = slow = head;
    while (fast != null && fast.next != null) {
        fast = fast.next.next;
        slow = slow.next;
        if (fast == slow) break;
    }
    // 上面的代码类似 hasCycle 函数
    if (fast == null || fast.next == null) {
        // fast 遇到空指针说明没有环
        return null;
    }

    // 重新指向头结点
    slow = head;
    // 快慢指针同步前进,相交点就是环起点
    while (slow != fast) {
        fast = fast.next;
        slow = slow.next;
    }
    return slow;
}

数组快慢索引实现快慢指针去重复值

int removeDuplicates(vector<int>& nums) {
        if(nums.size() == 0){
            return 0;
        }
        int slow = 0;
        for(int fast = 0;fast < nums.size(); fast++) {
            if(nums[slow] != nums[fast]){
                slow++;
                nums[slow] = nums[fast];
            }
        }
        return slow + 1;
    }
img

双指针 - 凑两数之和

只要数组有序,就应该想到双指针技巧。这道题的解法有点类似二分查找,通过调节 leftright 就可以调整 sum 的大小:

img
int[] twoSum(int[] nums, int target) {
    // 一左一右两个指针相向而行
    int left = 0, right = nums.length - 1;
    while (left < right) {
        int sum = nums[left] + nums[right];
        if (sum == target) {
            // 题目要求的索引是从 1 开始的
            return new int[]{left + 1, right + 1};
        } else if (sum < target) {
            left++; // 让 sum 大一点
        } else if (sum > target) {
            right--; // 让 sum 小一点
        }
    }
    return new int[]{-1, -1};
}


双指针 - 中心扩展找回文串

找回文串的难点在于,回文串的的长度可能是奇数也可能是偶数,解决该问题的核心是从中心向两端扩散的双指针技巧

如果回文串的长度为奇数,则它有一个中心字符;如果回文串的长度为偶数,则可以认为它有两个中心字符。所以我们可以先实现这样一个函数

// 在 s 中寻找以 s[l] 和 s[r] 为中心的最长回文串
String palindrome(String s, int l, int r) {
    // 防止索引越界
    while (l >= 0 && r < s.length()
            && s.charAt(l) == s.charAt(r)) {
        // 双指针,向两边展开
        l--; r++;
    }
    // 返回以 s[l] 和 s[r] 为中心的最长回文串
    return s.substring(l + 1, r);
}

这样,如果输入相同的 lr,就相当于寻找长度为奇数的回文串,如果输入相邻的 lr,则相当于寻找长度为偶数的回文串。

那么回到最长回文串的问题,解法的大致思路就是:

for 0 <= i < len(s):
    找到以 s[i] 为中心的回文串
    找到以 s[i] 和 s[i+1] 为中心的回文串
    更新答案

能发现最长回文子串使用的左右指针和之前题目的左右指针有一些不同:之前的左右指针都是从两端向中间相向而行,而回文子串问题则是让左右指针从中心向两端扩展。不过这种情况也就回文串这类问题会遇到,所以也把它归为左右指针了。


二、二叉树框架

1、是否可以通过遍历一遍二叉树得到答案?如果可以,用一个 traverse 函数配合外部变量来实现,这叫「遍历」的思维模式。

2、是否可以定义一个递归函数,通过子问题(子树)的答案推导出原问题的答案?如果可以,写出这个递归函数的定义,并充分利用这个函数的返回值

3.如果单独抽出一个二叉树节点,它需要做什么事情?需要在什么时候(前/中/后序位置)做?其他的节点不用你操心,递归函数会帮你在所有节点上执行相同的操作

二叉树框架的重要应用

  • 快速排序 - 二叉树的前序遍历

快速排序的逻辑是,若要对 nums[lo..hi] 进行排序,我们先找一个分界点 p,通过交换元素使得 nums[lo..p-1] 都小于等于 nums[p],且 nums[p+1..hi] 都大于 nums[p],然后递归地去 nums[lo..p-1]nums[p+1..hi] 中寻找新的分界点,最后整个数组就被排序了。

void sort(int[] nums, int lo, int hi) {
    /****** 前序遍历位置 ******/
    // 通过交换元素构建分界点 p
    int p = partition(nums, lo, hi);
    /************************/

    sort(nums, lo, p - 1);
    sort(nums, p + 1, hi);
}

先构造分界点,然后去左右子数组构造分界点 => 二叉树的前序遍历

  • 归并排序 - 二叉树的后序遍历

归并排序的逻辑,若要对 nums[lo..hi] 进行排序,我们先对 nums[lo..mid] 排序,再对 nums[mid+1..hi] 排序,最后把这两个有序的子数组合并,整个数组就排好序了。

// 定义:排序 nums[lo..hi]
void sort(int[] nums, int lo, int hi) {
    int mid = (lo + hi) / 2;
    // 排序 nums[lo..mid]
    sort(nums, lo, mid);
    // 排序 nums[mid+1..hi]
    sort(nums, mid + 1, hi);

    /****** 后序位置 ******/
    // 合并 nums[lo..mid] 和 nums[mid+1..hi]
    merge(nums, lo, mid, hi);
    /*********************/
}

先对左右子数组排序,然后合并(类似合并有序链表的逻辑)=> 二叉树的后序遍历框架 => 分治算法

二叉树的算法思想的运用广泛,甚至可以说,只要涉及递归,都可以抽象成二叉树的问题。

  • 二叉树遍历框架

    void traverse(TreeNode root) {
        if (root == null) {
            return;
        }
        // 前序位置
        traverse(root.left);
        // 中序位置
        traverse(root.right);
        // 后序位置
    }
    

    只要是递归形式的遍历,都可以有前序位置和后序位置,分别在递归之前和递归之后。

    前中后序是遍历二叉树过程中处理每一个节点的三个特殊时间点,绝不仅仅是三个顺序不同的 List:

    • 前序位置的代码在刚刚进入一个二叉树节点的时候执行;
    • 后序位置的代码在将要离开一个二叉树节点的时候执行;
    • 中序位置的代码在一个二叉树节点左子树都遍历完,即将开始遍历右子树的时候执行。

后序遍历 - 倒序打印一条链表所有值

/* 递归遍历单链表,倒序打印链表元素 */
void traverse(ListNode head) {
    if (head == null) {
        return;
    }
    traverse(head.next);
    // 后序位置
    print(head.val);
}

本质上是利用递归的堆栈帮你实现了倒序遍历的效果。

img

二叉树题目的递归解法可以分两类思路,第一类是遍历一遍二叉树得出答案,第二类是通过分解问题计算出答案,这两类思路分别对应着 回溯算法核心框架动态规划核心框架


二叉树遍历 - 求二叉树最大深度

用一个外部变量记录每个节点所在的深度,取最大值就可以得到最大深度,这就是遍历二叉树计算答案的思路

int length = 0,max = 0;
void tarverse(TreeNode* node) {
    if(node == nullptr)return;
    length++;
    tarverse(node -> left);      
    max = max > length ? max : length;
    tarverse(node -> right);
    length--;
}
int maxDepth(TreeNode* root) {
    tarverse(root);
    return max;
}

为什么需要在前序位置增加 depth,在后序位置减小 depth

因为前序位置是进入一个节点的时候,后序位置是离开一个节点的时候,depth 记录当前递归到的节点深度,把 traverse 理解成在二叉树上游走的一个指针.

递归解法

一棵二叉树的最大深度可以通过子树的最大深度推导出来,这就是分解问题计算答案的思路

int maxDepth(TreeNode* root) {
        if(root == nullptr)
        return 0;
        int left = maxDepth(root -> left);
        int right = maxDepth(root -> right);
        return left > right ? left + 1 : right + 1;
    }

后序遍历的特殊 - 解决子树问题

前序位置的代码执行是自顶向下的,而后序位置的代码执行是自底向上的:

前序位置是刚刚进入节点的时刻,后序位置是即将离开节点的时刻。

img

意味着前序位置的代码只能从函数参数中获取父节点传递来的数据,而后序位置的代码不仅可以获取参数数据,还可以获取到子树通过函数返回值传递回来的数据

如何打印出每个节点的左右子树各有多少节点?

// 定义:输入一棵二叉树,返回这棵二叉树的节点总数
int count(TreeNode root) {
    if (root == null) {
        return 0;
    }
    int leftCount = count(root.left);
    int rightCount = count(root.right);
    // 后序位置
    printf("节点 %s 的左子树有 %d 个节点,右子树有 %d 个节点",
            root, leftCount, rightCount);

    return leftCount + rightCount + 1;
}

一个节点在第几层,从根节点遍历过来的过程就能顺带记录;而以一个节点为根的整棵子树有多少个节点,需要遍历完子树之后才能数清楚。结合这两个简单的问题,后序位置的特点,只有后序位置才能通过返回值获取子树的信息

一旦发现题目和子树有关,那大概率要给函数设置合理的定义和返回值,在后序位置写代码了

三、动态规划框架

动态规划问题的一般形式就是求最值

虽然动态规划的核心思想就是穷举求最值,但是问题可以千变万化,穷举所有可行解其实并不是一件容易的事,需要你熟练掌握递归思维,只有列出正确的「状态转移方程」,才能正确地穷举。而且,你需要判断算法问题是否具备「最优子结构」,是否能够通过子问题的最值得到原问题的最值。另外,动态规划问题存在「重叠子问题」,如果暴力穷举的话效率会很低,所以需要你使用「备忘录」或者「DP table」来优化穷举过程,避免不必要的计算。

  • 动态规划框架

明确 base case -> 明确「状态」-> 明确「选择」 -> 定义 dp 数组/函数的含义

  • 代码框架

    # 自顶向下递归的动态规划
    def dp(状态1, 状态2, ...):
        for 选择 in 所有可能的选择:
            # 此时的状态已经因为做了选择而改变
            result = 求最值(result, dp(状态1, 状态2, ...))
        return result
    
    # 自底向上迭代的动态规划
    # 初始化 base case
    dp[0][0][...] = base case
    # 进行状态转移
    for 状态1 in 状态1的所有取值:
        for 状态2 in 状态2的所有取值:
            for ...
                dp[状态1][状态2][...] = 求最值(选择1,选择2...)
    
    

以斐波那契数列了解动态规划

  • 递归解法

    int fib(int n) {
            if(n == 0 || n == 1)
            return n; 
            else return fib(n - 1) + fib(n - 2);
        }
    

这样写代码虽然简洁易懂,但是十分低效,会造成大量的重复计算,假设 n = 20,画出递归树:52

img

递归算法的时间复杂度怎么计算?就是用子问题个数乘以解决一个子问题需要的时间

计算子问题个数,即递归树中节点的总数。显然二叉树节点总数为指数级别,所以子问题个数为 O(2^n)。

然后计算解决一个子问题的时间,在本算法中,没有循环,只有 f(n - 1) + f(n - 2) 一个加法操作,时间为 O(1)。

所以,这个算法的时间复杂度为二者相乘,即 O(2^n),指数级别。

观察递归树,很明显发现了算法低效的原因:存在大量重复计算,比如 f(18) 被计算了两次,而且你可以看到,以 f(18) 为根的这个递归树体量巨大,多算一遍,会耗费巨大的时间。更何况,还不止 f(18) 这一个节点被重复计算,所以这个算法及其低效。

这就是动态规划问题的第一个性质:重叠子问题

  • 解决方法 - 备忘录

    可以造一个「备忘录」,每次算出某个子问题的答案后别急着返回,先记到「备忘录」里再返回;每次遇到一个子问题先去「备忘录」里查一查,如果发现之前已经解决过这个问题了,直接把答案拿出来用,不要再耗时去计算了。一般使用一个数组充当这个「备忘录」,当然也可以使用哈希表(字典),思想都是一样的。

int fib(int N) {
    // 备忘录全初始化为 0
    int[] memo = new int[N + 1];
    // 进行带备忘录的递归
    return helper(memo, N);
}

int helper(int[] memo, int n) {
    // base case
    if (n == 0 || n == 1) return n;
    // 已经计算过,不用再计算了
    if (memo[n] != 0) return memo[n];
    memo[n] = helper(memo, n - 1) + helper(memo, n - 2);
    return memo[n];
}

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-CXJcKsM3-1678434817094)(https://labuladong.gitee.io/algo/images/%e5%8a%a8%e6%80%81%e8%a7%84%e5%88%92%e8%af%a6%e8%a7%a3%e8%bf%9b%e9%98%b6/2.jpg)]

带「备忘录」的递归算法,把一棵存在巨量冗余的递归树通过「剪枝」,改造成了一幅不存在冗余的递归图,极大减少了子问题(即递归图中节点)的个数。

实际上,这种解法和常见的动态规划解法已经差不多了,只不过这种解法是「自顶向下」进行「递归」求解,我们更常见的动态规划代码是「自底向上」进行「递推」求解。

啥叫「自顶向下」?注意我们刚才画的递归树(或者说图),是从上向下延伸,都是从一个规模较大的原问题比如说 f(20),向下逐渐分解规模,直到 f(1)f(2) 这两个 base case,然后逐层返回答案,这就叫「自顶向下」。

啥叫「自底向上」?反过来,我们直接从最底下、最简单、问题规模最小、已知结果的 f(1)f(2)(base case)开始往上推,直到推到我们想要的答案 f(20)。这就是「递推」的思路,这也是动态规划一般都脱离了递归,而是由循环迭代完成计算的原因。

  • dp数组的循环迭代解法

可以把这个「备忘录」独立出来成为一张表,通常叫做 DP table,在这张表上完成「自底向上」的推算

int fib(int N) {
    if (N == 0) return 0;
    int[] dp = new int[N + 1];
    // base case
    dp[0] = 0; dp[1] = 1;
    // 状态转移
    for (int i = 2; i <= N; i++) {
        dp[i] = dp[i - 1] + dp[i - 2];
    }

    return dp[N];
}

引出「状态转移方程」这个名词,实际上就是描述问题结构的数学形式:

f(n) 的函数参数会不断变化,所以把参数 n 想做一个状态,这个状态 n 是由状态 n - 1 和状态 n - 2 转移(相加)而来,这就叫状态转移。

会发现,上面的几种解法中的所有操作,例如 return f(n - 1) + f(n - 2)dp[i] = dp[i - 1] + dp[i - 2],以及对备忘录或 DP table 的初始化操作,都是围绕这个方程式的不同表现形式。

可见列出「状态转移方程」的重要性,它是解决问题的核心,而且很容易发现,状态转移方程直接代表着暴力解法。

  • 最后再优化

根据斐波那契数列的状态转移方程,当前状态只和之前的两个状态有关,其实并不需要那么长的一个 DP table 来存储所有的状态,只要想办法存储之前的两个状态就行了。

所以,可以进一步优化,把空间复杂度降为 O(1)。这也就是我们最常见的计算斐波那契数的算法:

int fib(int n) {
    if (n == 0 || n == 1) {
        // base case
        return n;
    }
    // 分别代表 dp[i - 1] 和 dp[i - 2]
    int dp_i_1 = 1, dp_i_2 = 0;
    for (int i = 2; i <= n; i++) {
        // dp[i] = dp[i - 1] + dp[i - 2];
        int dp_i = dp_i_1 + dp_i_2;
        // 滚动更新
        dp_i_2 = dp_i_1;
        dp_i_1 = dp_i;
    }
    return dp_i_1;
}

这一般是动态规划问题的最后一步优化,如果我们发现每次状态转移只需要 DP table 中的一部分,那么可以尝试缩小 DP table 的大小,只记录必要的数据,从而降低空间复杂度。上述例子就相当于把DP table 的大小从 n 缩小到 2。


以凑零钱问题学习动态规划

零钱兑换

这个问题是动态规划问题,因为它具有「最优子结构」的。要符合「最优子结构」,子问题间必须互相独立

回到凑零钱问题,为什么说它符合最优子结构呢?假设你有面值为 1, 2, 5 的硬币,你想求 amount = 11 时的最少硬币数(原问题),如果你知道凑出 amount = 10, 9, 6 的最少硬币数(子问题),你只需要把子问题的答案加一(再选一枚面值为 1, 2, 5 的硬币),求个最小值,就是原问题的答案。因为硬币的数量是没有限制的,所以子问题之间没有相互制约,是互相独立的。

思考如何列出正确的状态转移方程?

1、确定 base case,这个很简单,显然目标金额 amount 为 0 时算法返回 0,因为不需要任何硬币就已经凑出目标金额了。

2、确定「状态」,也就是原问题和子问题中会变化的变量。由于硬币数量无限,硬币的面额也是题目给定的,只有目标金额会不断地向 base case 靠近,所以唯一的「状态」就是目标金额 amount

3、确定「选择」,也就是导致「状态」产生变化的行为。目标金额为什么变化呢,因为你在选择硬币,你每选择一枚硬币,就相当于减少了目标金额。所以说所有硬币的面值,就是你的「选择」。

4、明确 dp 函数/数组的定义。我们这里讲的是自顶向下的解法,所以会有一个递归的 dp 函数,一般来说函数的参数就是状态转移中会变化的量,也就是上面说到的「状态」;函数的返回值就是题目要求我们计算的量。就本题来说,状态只有一个,即「目标金额」,题目要求我们计算凑出目标金额所需的最少硬币数量。

  • 状态转移方程如下

在这里插入图片描述

int coinChange(int[] coins, int amount) {
    // 题目要求的最终结果是 dp(amount)
    return dp(coins, amount)
}

// 定义:要凑出金额 n,至少要 dp(coins, n) 个硬币
int dp(int[] coins, int amount) {
    // base case
    if (amount == 0) return 0;
    if (amount < 0) return -1;

    int res = Integer.MAX_VALUE;
    for (int coin : coins) {
        // 计算子问题的结果
        int subProblem = dp(coins, amount - coin);
        // 子问题无解则跳过
        if (subProblem == -1) continue;
        // 在子问题中选择最优解,然后加一
        res = Math.min(res, subProblem + 1);
    }
    return res == Integer.MAX_VALUE ? -1 : res;
}
  • 加备忘录去递归
int[] memo;

int coinChange(int[] coins, int amount) {
    memo = new int[amount + 1];
    // 备忘录初始化为一个不会被取到的特殊值,代表还未被计算
    Arrays.fill(memo, -666);

    return dp(coins, amount);
}

int dp(int[] coins, int amount) {
    if (amount == 0) return 0;
    if (amount < 0) return -1;
    // 查备忘录,防止重复计算
    if (memo[amount] != -666)
        return memo[amount];

    int res = Integer.MAX_VALUE;
    for (int coin : coins) {
        // 计算子问题的结果
        int subProblem = dp(coins, amount - coin);
        // 子问题无解则跳过
        if (subProblem == -1) continue;
        // 在子问题中选择最优解,然后加一
        res = Math.min(res, subProblem + 1);
    }
    // 把计算结果存入备忘录
    memo[amount] = (res == Integer.MAX_VALUE) ? -1 : res;
    return memo[amount];
}
  • dp 数组的迭代解法
int coinChange(int[] coins, int amount) {
    int[] dp = new int[amount + 1];
    // 数组大小为 amount + 1,初始值也为 amount + 1
    Arrays.fill(dp, amount + 1);

    // base case
    dp[0] = 0;
    // 外层 for 循环在遍历所有状态的所有取值
    for (int i = 0; i < dp.length; i++) {
        // 内层 for 循环在求所有选择的最小值
        for (int coin : coins) {
            // 子问题无解,跳过
            if (i - coin < 0) {
                continue;
            }
            dp[i] = Math.min(dp[i], 1 + dp[i - coin]);
        }
    }
    return (dp[amount] == amount + 1) ? -1 : dp[amount];
}

在这里插入图片描述

四、回溯算法框架

其实回溯算法和我们常说的 DFS 算法非常类似,本质上就是一种暴力穷举算法。回溯算法和 DFS 算法的细微差别是:回溯算法是在遍历「树枝」,DFS 算法是在遍历「节点」

解决一个回溯问题,实际上就是一个决策树的遍历过程,站在回溯树的一个节点上,你只需要思考 3 个问题:

1、路径:也就是已经做出的选择。

2、选择列表:也就是你当前可以做的选择。

3、结束条件:也就是到达决策树底层,无法再做选择的条件。

result = []
def backtrack(路径, 选择列表):
    if 满足结束条件:
        result.add(路径)
        return
    
    for 选择 in 选择列表:
        做选择
        backtrack(路径, 选择列表)
        撤销选择

其核心就是 for 循环里面的递归,在递归调用之前「做选择」,在递归调用之后「撤销选择」


全排列问题学习回溯算法

全排列

根据上面的框架写出代码,这里用到了vector数据结构。

vector的用法

	// 全局变量保存输出
	vector<vector<int>> res;
    vector<vector<int>> permute(vector<int>& nums) {
        // 每次遍历后的结果暂存器
        vector<int> re;
        // 每个元素是否已被使用的标识器
        vector<int> bol(nums.size(),0);
        // 遍历决策树
        terver(nums,re,bol);
        return res;
    }
    
    void terver(vector<int>& nums,vector<int>& re,vector<int>& bol) {
        // 若一个树枝遍历完了就保存遍历的结果到全局变量里
        if(re.size() == nums.size()) {
            res.push_back(re);
            return;
        }

        //在每层节点都遍历所有树枝
        for(int i = 0; i < nums.size(); i++) {
            // 根据是否使用标识判断该分支是否还能遍历
            if(bol[i] == 1)continue;
            // 遍历前把该元素标志已使用
            bol[i] = 1;
            // 将该元素存入暂存区
            re.push_back(nums[i]);
            // 遍历决策树
            terver(nums,re,bol);
            // 遍历后将该元素删去
            re.pop_back();
            // 重新标识未使用
            bol[i] = 0;
        }
    }

遍历树的过程如下

img

注意:这类解法不管怎么优化,都符合回溯框架,而且时间复杂度都不可能低于 O(N!),因为穷举整棵决策树是无法避免的。这也是回溯算法的一个特点,不像动态规划存在重叠子问题可以优化,回溯算法就是纯暴力穷举,复杂度一般都很高


回溯算法解决排列-组合-子集问题

无论是排列、组合还是子集问题,简单说无非就是让你从序列 nums 中以给定规则取若干元素,主要有以下几种变体:

  • 形式一、元素无重不可复选,即 nums 中的元素都是唯一的,每个元素最多只能被使用一次,这也是最基本的形式

以组合为例,如果输入 nums = [2,3,6,7],和为 7 的组合应该只有 [7]

  • 形式二、元素可重不可复选,即 nums 中的元素可以存在重复,每个元素最多只能被使用一次

以组合为例,如果输入 nums = [2,5,2,1,2],和为 7 的组合应该有两种 [2,2,2,1][5,2]

  • 形式三、元素无重可复选,即 nums 中的元素都是唯一的,每个元素可以被使用若干次

以组合为例,如果输入 nums = [2,3,6,7],和为 7 的组合应该有两种 [2,2,3][7]

当然,也可以说有第四种形式,即元素可重可复选。但既然元素可复选,那又何必存在重复元素呢?元素去重之后就等同于形式三,所以这种情况不用考虑。

住如下子集问题和排列问题的回溯树
在这里插入图片描述

在这里插入图片描述

1.回溯算法实现子集(元素无重不可复选)

子集

每次遍历节点不再遍历之前出现过的元素,实现时只需要保持元素相对顺序不变的前提下每次for循环的start都递增

在这里插入图片描述

	vector<vector<int>> res;
    vector<vector<int>> subsets(vector<int>& nums) {
        vector<int> re;
        terver(nums,re,0);
        return res;
    }
    void terver(vector<int>& nums,vector<int>& re,int start) {
        res.push_back(re);
        for(int i = start; i < nums.size(); i++) {
            re.push_back(nums[i]);
            terver(nums,re,i + 1);
            re.pop_back();
        }
    }

这里不按回溯算法的框架没加上base case,是因为最后for循环的start不断递增,最后for循环不会执行了,所以没有base case也不会死循环。


2.回溯算法解决组合(元素无重不可复选)

组合

组合操作就比子集多一点,首先元素无重不可复选的思路不能变。组合只是在上题的前提下多了一个base case;

在这里插入图片描述

    vector<vector<int>> res;
    vector<vector<int>> combine(int n, int k) {
        vector<int> re;
        terver(n,re,0,k);
        return res;
    }
    void terver(int n,vector<int>& re,int start,int size) {
        if(re.size() == size) {
            res.push_back(re);
            return;
        }
        for(int i = start; i < n; i++) {
            re.push_back(i + 1);
            terver(n,re,i + 1,size);
            re.pop_back();
        }
    }

3.回溯算法解决排列(元素无重不可复选)

上面的全排列问题学习回溯算法

提一下:由于遍历节点前的数据都不能再选择了,所以数据用过或未用的状态是遍历该节点前传入的参数。这里我们用了一个bol的int型向量作为状态标记器,值传递形式传给节点遍历函数。


4.回溯算法解决子集和组合(元素可重不可复选)

子集 II

在这里插入图片描述

按原来的思路遍历树会出现重复的遍历结果,因此主要是解决不可复选的问题。

  • 我的思路是利用数组来模拟map对记录出现的数字及其的次数。先记录,在判断该数字出现的次数是否仅为1?若为1就可继须遍历,不为1则continue;但是本题的数字范围>=-10并且=<10.不能用数组模拟map。C++的map又忘记咋用了。

  • 另一种解法是先对数据排序,遍历时判断此数字是否与之前的数字相同?相同就continue,不同就继续遍历。

	vector<vector<int>> res;
    vector<vector<int>> subsetsWithDup(vector<int>& nums) {
        vector<int> re;
        sort(nums.begin(),nums.end());
        terver(nums,re,0);
        return res;
    }
    void terver(vector<int>& nums,vector<int>& re,int start) {
        res.push_back(re);
        for(int i = start; i < nums.size(); i++) {
            if( i > start && nums[i] == nums[i - 1] ){
                continue;
            }
            re.push_back(nums[i]);
            terver(nums,re,i + 1);
            re.pop_back();
        }
    }

组合问题与子集问题是等价的,修改base case即可。

在这里插入图片描述

	vector<vector<int>> res;
    vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
        vector<int> re;
        sort(candidates.begin(),candidates.end());
        terver(candidates,re,0,0,target);
        return res;
    }
    void terver(vector<int>& nums,vector<int>& re,int start,int sum,int target) {
        if(sum == target){
            res.push_back(re);
            return;
        }
        if(sum > target)return;
        for(int i = start; i < nums.size(); i++) {
            if( i > start && nums[i] == nums[i - 1]){
                continue;
            }
            re.push_back(nums[i]);
            sum += nums[i];
            terver(nums,re,i + 1,sum,target);
            sum -= nums[i];
            re.pop_back();
        }
    }

5.回溯算法解决排序(元素可重复不可复选)

全排列 II

可重复不可复选首先还是排序,再判断前一个和后一个是否相等,相等则剪枝。这里用了一个中间变量暂存前一个变量,更清晰。

在这里插入图片描述

vector<vector<int>> res;
    vector<int> re;
    vector<vector<int>> permuteUnique(vector<int>& nums) {
        sort(nums.begin(),nums.end());
        vector<bool> used(nums.size(),false);
        terver(nums,used);
        return res;
    }
    void terver(vector<int>& nums,vector<bool>& used) {
        if(re.size() == nums.size()) {
            res.push_back(re);
            return;
        }
        int ENV = 666;
        for(int i = 0; i < nums.size(); i++) {
            if(used[i]){
                continue;
            }
            if(nums[i] == ENV) {
                continue;
            }
            re.push_back(nums[i]);
            used[i] = true;
            ENV = nums[i];
            terver(nums,used);
            used[i] = false;
            re.pop_back();
        }
    }

6.回溯算法解决子集、组合(元素无重复可复选)

组合总和

  • 本人开始思路:原来是遍历的start递增就不会再遍历之前的元素,若让start等于0且不再变化,就可重复使用。但是此方法是错误的。此方法会导致出现有相同元素不同排列顺序的子集出现。因为集合的无序性,它们还是同一种结果。

  • 正确思路:把原来的start不再递增,下次遍历就会还能重复使用上一个元素。因为start还是变化的,所以也不会有以上问题出现。

在这里插入图片描述

	vector<vector<int>> res;
    int all = 0;
    vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
        vector<int> re;
        terver(candidates,re,target,0);
        return res;
    }
    void terver(vector<int>& nums,vector<int>& re,int target,int start) {
        if(all == target){
            res.push_back(re);
        }
        if(all > target)return;
        for(int i = start; i < nums.size(); i++) {
            re.push_back(nums[i]);
            all += nums[i];
            terver(nums,re,target,i);
            all -= nums[i];
            re.pop_back();
        }
    }

7.回溯算法解决排列(元素无重复可复选)

力扣上没有类似的题目,我们不妨先想一下,nums 数组中的元素无重复且可复选的情况下,会有哪些排列?

比如输入 nums = [1,2,3],那么这种条件下的全排列共有 3^3 = 27 种:

[
  [1,1,1],[1,1,2],[1,1,3],[1,2,1],[1,2,2],[1,2,3],[1,3,1],[1,3,2],[1,3,3],
  [2,1,1],[2,1,2],[2,1,3],[2,2,1],[2,2,2],[2,2,3],[2,3,1],[2,3,2],[2,3,3],
  [3,1,1],[3,1,2],[3,1,3],[3,2,1],[3,2,2],[3,2,3],[3,3,1],[3,3,2],[3,3,3]
]

这种是最简单的,就像上一部分里的我的第一种错误思路。即每次for循环都从0开始。即可以出现相同元素但顺序不同的结果。

	vector<vector<int>> res;
    vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
        vector<int> re;
        terver(candidates,re);
        return res;
    }
    void terver(vector<int>& nums,vector<int>& re,int target) {
        if(re.size() == nums.size()){
            res.push_back(re);
            return;
        }
        for(int i = 0; i < nums.size(); i++) {
            re.push_back(nums[i]);
            terver(nums,re,target,i);
            re.pop_back();
        }
    }

五、BFS广度优先遍历框架

DFS就是先前的回溯算法,一条条树枝优先从头遍历到尾。

BFS 的核心思想就是把一些问题抽象成图,从一个点开始,向四周开始扩散。一般来说,我们写 BFS 算法都是用「队列」这种数据结构,每次将一个节点周围的所有节点加入队列。

BFS 相对 DFS 的最主要的区别是:BFS 找到的路径一定是最短的,但代价就是空间复杂度可能比 DFS 大很多而DFS的复杂度更高,即要把所有的纸条遍历完并记录升读后再比较深度。形象点说,DFS 是线,BFS 是面。DFS 是单打独斗,BFS 是集体行动。

// 计算从起点 start 到终点 target 的最近距离
int BFS(Node start, Node target) {
    Queue<Node> q; // 核心数据结构
    Set<Node> visited; // 避免走回头路
    
    q.offer(start); // 将起点加入队列
    visited.add(start);
    int step = 0; // 记录扩散的步数

    while (q not empty) {
        int sz = q.size();
        /* 将当前队列中的所有节点向四周扩散 */
        for (int i = 0; i < sz; i++) {
            Node cur = q.poll();
            /* 划重点:这里判断是否到达终点 */
            if (cur is target)
                return step;
            /* 将 cur 的相邻节点加入队列 */
            for (Node x : cur.adj()) {
                if (x not in visited) {
                    q.offer(x);
                    visited.add(x);
                }
            }
        }
        /* 划重点:更新步数在这里 */
        step++;
    }
}

广度优先遍历解决二叉树最小高度

二叉树的最小深度

用到了c++的一个数据结构(queue)队列。队列的特性是先进先出。

queue用法

补充c++数据结构双端队列deque的用法,双向循环链表list的用法

class Solution {
public:
    int minDepth(TreeNode* root) {
        if (!root)  return 0;
        queue<TreeNode*> q; 
        q.push(root);
        int res = 1;

        // 层数从1开始,到了当前层找到叶子节点,那答案就是当前层数
        while(!q.empty()) {
            int sz = q.size();
            for (int i = 0; i < sz; i++) {
                auto t = q.front(); q.pop();
                if (!t->left && !t->right)   return res;
                if (t->left)    q.push(t->left);
                if (t->right)  q.push(t->right);
            }
            res++;
        }
        return res;
    }
};

六、二分搜索框架

int binarySearch(int[] nums, int target) {
    int left = 0, right = ...;

    while(...) {
        int mid = left + (right - left) / 2;
        if (nums[mid] == target) {
            ...
        } else if (nums[mid] < target) {
            left = ...
        } else if (nums[mid] > target) {
            right = ...
        }
    }
    return ...;
}

另外提前说明一下,计算 mid 时需要防止溢出,代码中 left + (right - left) / 2 就和 (left + right) / 2 的结果相同,但是有效防止了 leftright 太大,直接相加导致溢出的情况。

基本的二分查找


二分查找

即搜索一个数,如果存在,返回其索引,否则返回 -1。

int binarySearch(int[] nums, int target) {
    int left = 0; 
    int right = nums.length - 1; // 注意

    while(left <= right) {
        int mid = left + (right - left) / 2;
        if(nums[mid] == target)
            return mid; 
        else if (nums[mid] < target)
            left = mid + 1; // 注意
        else if (nums[mid] > target)
            right = mid - 1; // 注意
    }
    return -1;
}

为什么 while 循环的条件中是 <=,而不是 <

答:因为初始化 right 的赋值是 nums.length - 1,即最后一个元素的索引,而不是 nums.length

while(left <= right) 的终止条件是 left == right + 1,写成区间的形式就是 [right + 1, right],或者带个具体的数字进去 [3, 2],可见这时候区间为空,因为没有数字既大于等于 3 又小于等于 2 的吧。所以这时候 while 循环终止是正确的,直接返回 -1 即可。

while(left < right) 的终止条件是 left == right,写成区间的形式就是 [right, right],或者带个具体的数字进去 [2, 2]这时候区间非空,还有一个数 2,但此时 while 循环终止了。也就是说这区间 [2, 2] 被漏掉了,索引 2 没有被搜索,如果这时候直接返回 -1 就是错误的。

此算法有什么缺陷

答:至此,你应该已经掌握了该算法的所有细节,以及这样处理的原因。但是,这个算法存在局限性。

比如说给你有序数组 nums = [1,2,2,2,3]target 为 2,此算法返回的索引是 2,没错。但是如果我想得到 target 的左侧边界,即索引 1,或者我想得到 target 的右侧边界,即索引 3,这样的话此算法是无法处理的。

这样的需求很常见,你也许会说,找到一个 target,然后向左或向右线性搜索不行吗?可以,但是不好,因为这样难以保证二分查找对数级的复杂度了


寻找左侧边界的二分搜索

int left_bound(int[] nums, int target) {
    int left = 0, right = nums.length - 1;
    // 搜索区间为 [left, right]
    while (left <= right) {
        int mid = left + (right - left) / 2;
        if (nums[mid] < target) {
            // 搜索区间变为 [mid+1, right]
            left = mid + 1;
        } else if (nums[mid] > target) {
            // 搜索区间变为 [left, mid-1]
            right = mid - 1;
        } else if (nums[mid] == target) {
            // 找到target时不立即返回而继续收缩右侧边界
            right = mid - 1;
        }
    }
    // 判断 target 是否存在于 nums 中
    // 此时 target 比所有数都大,返回 -1
    if (left == nums.length) return -1;
    // 判断一下 nums[left] 是不是 target
    return nums[left] == target ? left : -1;
}

寻找右侧边界的二分查找

int right_bound(int[] nums, int target) {
    int left = 0, right = nums.length - 1;
    while (left <= right) {
        int mid = left + (right - left) / 2;
        if (nums[mid] < target) {
            left = mid + 1;
        } else if (nums[mid] > target) {
            right = mid - 1;
        } else if (nums[mid] == target) {
            // 这里改成收缩左侧边界即可
            left = mid + 1;
        }
    }
    // 最后改成返回 right
    if (right < 0) return -1;
    return nums[right] == target ? (right) : -1;
}

二分思维的精髓就是:通过已知信息尽可能多地收缩(折半)搜索空间,从而增加穷举效率,快速找到目标。


七、滑动窗口框架

滑动窗口大致思路如下

int left = 0, right = 0;

while (right < s.size()) {
    // 增大窗口
    window.add(s[right]);
    right++;
    
    while (window needs shrink) {
        // 缩小窗口
        window.remove(s[left]);
        left++;
    }
}

代码框架

/* 滑动窗口算法框架 */
void slidingWindow(string s) {
    unordered_map<char, int> window;
    
    int left = 0, right = 0;
    while (right < s.size()) {
        // c 是将移入窗口的字符
        char c = s[right];
        // 增大窗口
        right++;
        // 进行窗口内数据的一系列更新
        ...

        /*** debug 输出的位置 ***/
        // 注意在最终的解法代码中不要 print
        // 因为 IO 操作很耗时,可能导致超时
        printf("window: [%d, %d)\n", left, right);
        /********************/
        
        // 判断左侧窗口是否要收缩
        while (window needs shrink) {
            // d 是将移出窗口的字符
            char d = s[left];
            // 缩小窗口
            left++;
            // 进行窗口内数据的一系列更新
            ...
        }
    }
}

其中两处 ... 表示的更新窗口数据的地方,到时候你直接往里面填就行了

而且,这两个 ... 处的操作分别是扩大和缩小窗口的更新操作,等会你会发现它们操作是完全对称的。

unordered_map 就是哈希表(字典),相当于 Java 的 HashMap,它的一个方法 count(key) 相当于 Java 的 containsKey(key) 可以判断键 key 是否存在。

可以使用方括号访问键对应的值 map[key]。需要注意的是,如果该 key 不存在,C++ 会自动创建这个 key,并把 map[key] 赋值为 0。所以代码中多次出现的 map[key]++ 相当于 Java 的 map.put(key, map.getOrDefault(key, 0) + 1)


滑动窗口解决滑动窗口

最小覆盖子串

string minWindow(string s, string t) {
    unordered_map<char, int> need, window;
    for (char c : t) need[c]++;

    int left = 0, right = 0;
    int valid = 0;
    // 记录最小覆盖子串的起始索引及长度
    int start = 0, len = INT_MAX;
    while (right < s.size()) {
        // c 是将移入窗口的字符
        char c = s[right];
        // 扩大窗口
        right++;
        // 进行窗口内数据的一系列更新
        if (need.count(c)) {
            window[c]++;
            if (window[c] == need[c])
                valid++;
        }

        // 判断左侧窗口是否要收缩
        while (valid == need.size()) {
            // 在这里更新最小覆盖子串
            if (right - left < len) {
                start = left;
                len = right - left;
            }
            // d 是将移出窗口的字符
            char d = s[left];
            // 缩小窗口
            left++;
            // 进行窗口内数据的一系列更新
            if (need.count(d)) {
                if (window[d] == need[d])
                    valid--;
                window[d]--;
            }                    
        }
    }
    // 返回最小覆盖子串
    return len == INT_MAX ?
        "" : s.substr(start, len);
}

滑动窗口解决字符串排列匹配

字符串的排列

class Solution {
public:
    bool checkInclusion(string s1, string s2) {
        map<char,int> window,need;
        for(char c : s1)need[c]++;
        int left = 0,right = 0,start = 0,len = 666666,valid = 0;
        while(right < s2.size()) {
            char last = s2[right];
            right++;
            if(need.count(last)) {
                window[last]++;
                if(window[last] == need[last]) {
                    valid++;
                }
            }
            while(right - left >= s1.size()) {
                if(valid == need.size()) return true;
                char first = s2[left];
                left++;
                if(need.count(first)) {
                    if(window[first] == need[first]) valid--;
                    window[first]--;
                }
            }
        }
        return false;
    }
};

八、股票买卖问题解题框架

用状态机的技巧来解决,可以全部提交通过。实际上就是 DP table。

买卖股票的最佳时机 IV

只看「持有状态」,可以画个状态转移图:

在这里插入图片描述

通过这个图可以很清楚地看到,每种状态(0 和 1)是如何转移而来的。根据这个图,我们来写一下状态转移方程:

dp[i][k][0] = max(dp[i-1][k][0], dp[i-1][k][1] + prices[i])
              max( 今天选择 rest,        今天选择 sell       )

解释:今天我没有持有股票,有两种可能,我从这两种可能中求最大利润:

1、我昨天就没有持有,且截至昨天最大交易次数限制为 k;然后我今天选择 rest,所以我今天还是没有持有,最大交易次数限制依然为 k

2、我昨天持有股票,且截至昨天最大交易次数限制为 k;但是今天我 sell 了,所以我今天没有持有股票了,最大交易次数限制依然为 k

dp[i][k][1] = max(dp[i-1][k][1], dp[i-1][k-1][0] - prices[i])
              max( 今天选择 rest,         今天选择 buy         )

解释:今天我持有着股票,最大交易次数限制为 k,那么对于昨天来说,有两种可能,我从这两种可能中求最大利润:

1、我昨天就持有着股票,且截至昨天最大交易次数限制为 k;然后今天选择 rest,所以我今天还持有着股票,最大交易次数限制依然为 k

2、我昨天本没有持有,且截至昨天最大交易次数限制为 k - 1;但今天我选择 buy,所以今天我就持有股票了,最大交易次数限制为 k

这里着重提醒一下,时刻牢记「状态」的定义,状态 k 的定义并不是「已进行的交易次数」,而是「最大交易次数的上限限制」。如果确定今天进行一次交易,且要保证截至今天最大交易次数上限为 k,那么昨天的最大交易次数上限必须是 k - 1

这个解释应该很清楚了,如果 buy,就要从利润中减去 prices[i],如果 sell,就要给利润增加 prices[i]。今天的最大利润就是这两种可能选择中较大的那个。

注意 k 的限制,在选择 buy 的时候相当于开启了一次交易,那么对于昨天来说,交易次数的上限 k 应该减小 1。

定义 base case,即最简单的情况。

dp[-1][...][0] = 0
解释:因为 i 是从 0 开始的,所以 i = -1 意味着还没有开始,这时候的利润当然是 0。

dp[-1][...][1] = -infinity
解释:还没开始的时候,是不可能持有股票的。
因为我们的算法要求一个最大值,所以初始值设为一个最小值,方便取最大值。

dp[...][0][0] = 0
解释:因为 k 是从 1 开始的,所以 k = 0 意味着根本不允许交易,这时候利润当然是 0。

dp[...][0][1] = -infinity
解释:不允许交易的情况下,是不可能持有股票的。
因为我们的算法要求一个最大值,所以初始值设为一个最小值,方便取最大值。

把上面的状态转移方程总结一下:

base case:
dp[-1][...][0] = dp[...][0][0] = 0
dp[-1][...][1] = dp[...][0][1] = -infinity

状态转移方程:
dp[i][k][0] = max(dp[i-1][k][0], dp[i-1][k][1] + prices[i])
dp[i][k][1] = max(dp[i-1][k][1], dp[i-1][k-1][0] - prices[i])

就没有持有,且截至昨天最大交易次数限制为 k;然后我今天选择 rest,所以我今天还是没有持有,最大交易次数限制依然为 k

2、我昨天持有股票,且截至昨天最大交易次数限制为 k;但是今天我 sell 了,所以我今天没有持有股票了,最大交易次数限制依然为 k

dp[i][k][1] = max(dp[i-1][k][1], dp[i-1][k-1][0] - prices[i])
              max( 今天选择 rest,         今天选择 buy         )

解释:今天我持有着股票,最大交易次数限制为 k,那么对于昨天来说,有两种可能,我从这两种可能中求最大利润:

1、我昨天就持有着股票,且截至昨天最大交易次数限制为 k;然后今天选择 rest,所以我今天还持有着股票,最大交易次数限制依然为 k

2、我昨天本没有持有,且截至昨天最大交易次数限制为 k - 1;但今天我选择 buy,所以今天我就持有股票了,最大交易次数限制为 k

这里着重提醒一下,时刻牢记「状态」的定义,状态 k 的定义并不是「已进行的交易次数」,而是「最大交易次数的上限限制」。如果确定今天进行一次交易,且要保证截至今天最大交易次数上限为 k,那么昨天的最大交易次数上限必须是 k - 1

这个解释应该很清楚了,如果 buy,就要从利润中减去 prices[i],如果 sell,就要给利润增加 prices[i]。今天的最大利润就是这两种可能选择中较大的那个。

注意 k 的限制,在选择 buy 的时候相当于开启了一次交易,那么对于昨天来说,交易次数的上限 k 应该减小 1。

定义 base case,即最简单的情况。

dp[-1][...][0] = 0
解释:因为 i 是从 0 开始的,所以 i = -1 意味着还没有开始,这时候的利润当然是 0。

dp[-1][...][1] = -infinity
解释:还没开始的时候,是不可能持有股票的。
因为我们的算法要求一个最大值,所以初始值设为一个最小值,方便取最大值。

dp[...][0][0] = 0
解释:因为 k 是从 1 开始的,所以 k = 0 意味着根本不允许交易,这时候利润当然是 0。

dp[...][0][1] = -infinity
解释:不允许交易的情况下,是不可能持有股票的。
因为我们的算法要求一个最大值,所以初始值设为一个最小值,方便取最大值。

把上面的状态转移方程总结一下:

base case:
dp[-1][...][0] = dp[...][0][0] = 0
dp[-1][...][1] = dp[...][0][1] = -infinity

状态转移方程:
dp[i][k][0] = max(dp[i-1][k][0], dp[i-1][k][1] + prices[i])
dp[i][k][1] = max(dp[i-1][k][1], dp[i-1][k-1][0] - prices[i])

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值