给定一个数组 nums 和滑动窗口的大小 k,请找出所有滑动窗口里的最大值。
示例:
输入: nums = [1,3,-1,-3,5,3,6,7], 和 k = 3
输出: [3,3,5,5,6,7]
解释:
滑动窗口的位置 最大值
--------------- -----
[1 3 -1] -3 5 3 6 7 3
1 [3 -1 -3] 5 3 6 7 3
1 3 [-1 -3 5] 3 6 7 5
1 3 -1 [-3 5 3] 6 7 5
1 3 -1 -3 [5 3 6] 7 6
1 3 -1 -3 5 [3 6 7] 7
提示:
你可以假设 k 总是有效的,在输入数组不为空的情况下,1 ≤ k ≤ 输入数组的大小。
方法:单调队列
class MonoQueue {
public:
deque<int> q;
MonoQueue() {};
void push(int x) {
while (!q.empty() && q.back() < x) {
q.pop_back();
}
q.push_back(x);
}
int _max() {return q.front();}
void pop(int x) {
if (!q.empty() && q.front() == x) q.pop_front();
}
};
vector<int> maxSlidingWindow(vector<int>& nums, int k) {
if (nums.size() == 0) return {};
MonoQueue q;
vector<int> res;
for (int i = 0; i < k; ++i) q.push(nums[i]);
res.push_back(q._max());
for (int i = k; i < nums.size(); ++i) {
q.pop(nums[i-k]); //
q.push(nums[i]);
res.push_back(q._max());
}
return res;
}
注意pop时,应当pop窗口的左侧。
复杂度:
时间 O(n), 遍历nums中的元素
空间 O(k),
请定义一个队列并实现函数 max_value 得到队列里的最大值,要求函数max_value、push_back 和 pop_front 的均摊时间复杂度都是O(1)。
若队列为空,pop_front 和 max_value 需要返回 -1
示例 1:
输入:
["MaxQueue","push_back","push_back","max_value","pop_front","max_value"]
[[],[1],[2],[],[],[]]
输出: [null,null,null,2,1,2]
示例 2:
输入:
["MaxQueue","pop_front","max_value"]
[[],[],[]]
输出: [null,-1,-1]
限制:
1 <= push_back,pop_front,max_value的总操作数 <= 10000
1 <= value <= 10^5
我的思路:一个queue,一个辅助deque
假设我们要push_back的数列为:2,3,7,5,6
则我们需要一个辅助deque,存入 2,3, 7,5, 6
当要queue pop的值等于7时,辅助deque才pop
class MaxQueue {
public:
queue<int> q;
deque<int> q_fz;
MaxQueue() {}
int max_value() {
return q_fz.empty()? -1:q_fz.front();
}
void push_back(int value) {
q.push(value);
while (!q_fz.empty() && q_fz.back()<value) q_fz.pop_back();
q_fz.push_back(value);// 注意每次比较时,比的是back(),插入的也是back()
}
int pop_front() {
if (q.size()==0) return -1;
int tmp = q.front();
q.pop();
if (tmp == q_fz.front()) q_fz.pop_front();
return tmp;
}
};
复杂度分析
输入某二叉树的前序遍历和中序遍历的结果,请构建该二叉树并返回其根节点。
假设输入的前序遍历和中序遍历的结果中都不含重复的数字。
示例 1:
Input: preorder = [3,9,20,15,7], inorder = [9,3,15,20,7]
Output: [3,9,20,null,null,15,7]
示例 2:
Input: preorder = [-1], inorder = [-1]
Output: [-1]
限制:
0 <= 节点个数 <= 5000
我的方法:利用性质,递归
int find_int(const vector<int>& v, int& target) {
for (int i = 0; i < v.size(); ++i) {
if (v[i] == target) return i;
}
return -1;
}
TreeNode* buildTree(vector<int>& preorder, vector<int>& inorder) {
int num = preorder.size();
if (num == 0) return nullptr;
TreeNode* root = new TreeNode(preorder[0]);
int inorder_idx = find_int(inorder, root->val);
vector<int> right_inorder, left_inorder;
if (inorder_idx == inorder.size()-1) {
right_inorder = {};
left_inorder = vector<int>(inorder.begin(), --inorder.end());
}
else if (inorder_idx == 0) {
left_inorder = {};
right_inorder = vector<int>(inorder.begin()+1, inorder.end());
}
else {
right_inorder = vector<int>(inorder.begin() + inorder_idx + 1, inorder.end());
left_inorder = vector<int>(inorder.begin(), inorder.begin()+inorder_idx);
}
int left_len = left_inorder.size();
int right_len = right_inorder.size();
vector<int> left_preorder, right_preorder;
if (right_len == inorder.size()-1) {
left_preorder = {};
right_preorder = vector<int>(++preorder.begin(), preorder.end());
}
else if (right_len == 0) {
left_preorder = vector<int>(++preorder.begin(), preorder.end());
right_preorder = {};
}
else
{
left_preorder = vector<int>(++preorder.begin(), preorder.begin() + left_len +1);
right_preorder = vector<int>(preorder.begin() + left_len + 1, preorder.end());
}
root->left = buildTree(left_preorder, left_inorder);
root->right = buildTree(right_preorder, right_inorder);
return root;
}
复杂度:
时间:每次递归根节点时,都将遍历一遍前序,故为O(nlogn)
空间:O(n)
改进
1、上面的解法每次都要生成四个vector,很费空间,实际上我们直接在原vector中找根节点就可以了
2、每次都需要循环去找一个数,这里我们可以用一个哈希表去记录位置,以空间换时间
unordered_map<int, int> index;
TreeNode* myBuildTree(const vector<int>& preorder, const vector<int>& inorder,
int preorder_left, int preorder_right, int inorder_left, int inorder_right) {
if (preorder_left > preorder_right) {
return nullptr;
}
// 前序遍历中的第一个节点就是根节点
int preorder_root = preorder_left;
// 在中序遍历中定位根节点
int inorder_root = index[preorder[preorder_root]];
// 先把根节点建立出来
TreeNode* root = new TreeNode(preorder[preorder_root]);
// 得到左子树中的节点数目
int size_left_subtree = inorder_root - inorder_left;
// 递归地构造左子树,并连接到根节点
// 先序遍历中「从 左边界+1 开始的 size_left_subtree」个元素就对应了中序遍历中「从 左边界 开始到 根节点定位-1」的元素
root->left = myBuildTree(preorder, inorder, preorder_left + 1, preorder_left + size_left_subtree, inorder_left, inorder_root - 1);
// 递归地构造右子树,并连接到根节点
// 先序遍历中「从 左边界+1+左子树节点数目 开始到 右边界」的元素就对应了中序遍历中「从 根节点定位+1 到 右边界」的元素
root->right = myBuildTree(preorder, inorder, preorder_left + size_left_subtree + 1, preorder_right, inorder_root + 1, inorder_right);
return root;
}
TreeNode* buildTree(vector<int>& preorder, vector<int>& inorder) {
int n = preorder.size();
// 构造哈希映射,帮助我们快速定位根节点
for (int i = 0; i < n; ++i) {
index[inorder[i]] = i;
}
return myBuildTree(preorder, inorder, 0, n - 1, 0, n - 1);
}
复杂度分析
时间复杂度:O(n),其中 n 是树中的节点个数。
空间复杂度:O(n),除去返回的答案需要的 O(n) 空间之外,我们还需要使用 O(n) 的空间存储哈希映射,以及 O(h)(其中 h 是树的高度)的空间表示递归时栈空间。这里 h < n,所以总空间复杂度为 O(n)。
给你一个二叉树的根节点 root ,判断其是否是一个有效的二叉搜索树。
有效 二叉搜索树定义如下:
节点的左子树只包含 小于 当前节点的数。
节点的右子树只包含 大于 当前节点的数。
所有左子树和右子树自身必须也是二叉搜索树。
示例 1:
输入:root = [2,1,3]
输出:true
示例 2:
输入:root = [5,1,4,null,null,3,6]
输出:false
解释:根节点的值是 5 ,但是右子节点的值是 4 。
提示:
树中节点数目范围在[1, 104] 内
-231 <= Node.val <= 231 - 1
思路,利用BST的性质
class Solution:
def __init__(self):
self.vec = []
def isValidBST(self, root: Optional[TreeNode]) -> bool:
if not root:
return True
left = self.isValidBST(root.left)
if len(self.vec)>0 and self.vec[-1] >= root.val: # 判断有序
return False
self.vec.append(root.val)
right = self.isValidBST(root.right)
return left and right
输入一个整数数组,判断该数组是不是某二叉搜索树的后序遍历结果。如果是则返回 true,否则返回 false。假设输入的数组的任意两个数字都互不相同。
参考以下这颗二叉搜索树:
5
/ \
2 6
/ \
1 3
示例 1:
输入: [1,6,3,2,5]
输出: false
示例 2:
输入: [1,3,2,6,5]
输出: true
提示:
数组长度 <= 1000
思路:BST 的性质 + 后序遍历规律
left < root < right
class Solution:
def verifyPostorder(self, postorder: List[int]) -> bool:
if len(postorder)<2:
return True
root = postorder[-1]
idx = None
for i, v in enumerate(postorder[:-1]): # 判断该数组是BST
if v>root:
if idx is None:
idx = i
elif v==root:
return False
else:
if idx is not None:
return False
if idx is None or idx==0: # 没有分割点,说明没有分支
return self.verifyPostorder(postorder[:-1])
# 有分割点,分别判断左、右子树
return self.verifyPostorder(postorder[:idx]) and self.verifyPostorder(postorder[idx:-1])
剑指 Offer 37. 序列化二叉树
请实现两个函数,分别用来序列化和反序列化二叉树。
你需要设计一个算法来实现二叉树的序列化与反序列化。这里不限定你的序列 / 反序列化算法执行逻辑,你只需要保证一个二叉树可以被序列化为一个字符串并且将这个字符串反序列化为原始的树结构。
提示:输入输出格式与 LeetCode 目前使用的方式一致,详情请参阅 LeetCode 序列化二叉树的格式。你并非必须采取这种方式,你也可以采用其他的方法解决这个问题。
示例:
输入:root = [1,2,3,null,null,4,5]
输出:[1,2,3,null,null,4,5]
我的思路:bfs
serialize方法
: bfs✔
deserialize方法
:(vector存储,再用里面的元素生成树) ❌
**原因:**使用了二叉堆那样的索引去记录父节点、左右子节点。
int parent(int root) {return root/2};
int left(int root) {return root*2};
int right(int root) {return root*2+1};
这是不对的,因为提供的输入并不是那样的规律
例如:[1,2,3,null,null,4,5,6,7]
这样会导致上述方式失效
改进:
int pos = 1;
for (int i = 0; i < v.size(); ++i) {
if (!v[i]) continue;
if (pos == v.size()) break;
v[i]->left = v[pos++];
v[i]->right = v[pos++];
}
return v[0];
完整代码如下:
class Codec {
public:
// Encodes a tree to a single string.
string serialize(TreeNode* root) {
if (!root) return "null";
string res;
queue<TreeNode* > q;
q.push(root);
TreeNode* node = root;
while (!q.empty()) {
int len = q.size();
for (int i = 0; i < len; ++i) {
node = q.front(); q.pop();
if (node) {
res += to_string(node->val);
res += ',';
q.push(node->left);
q.push(node->right);
}
else res += "null,";
}
}
return res;
}
// Decodes your encoded data to tree.
TreeNode* deserialize(string data) {
int left = 0, right = 0;
vector<TreeNode*> v;
v.push_back(nullptr);
for (; right < data.size(); right++) {
if (data[right] == ',') {
left = right + 1;
continue;
}
else if (data[right] == 'n') {
v.push_back(nullptr);
right = right + 3;
}
else {
int tmp = right+1;
while (tmp < data.size() && data[tmp] != ',') {
right++;
tmp++;
}
string str = data.substr(left, right - left + 1);
int num = atoi(str.c_str());
v.push_back(new TreeNode(num));
}
}
// for (int i = 1; i * 2 + 1 <= v.size(); ++i) { // ❌
// int pa = i/2, l = i*2, r = i*2+1;
// if (l <= v.size() && v[i]) v[i]->left = v[l];
// if (r < v.size() && v[i]) v[i]->right = v[r];
// }
// return v[1];
int pos = 1;
for (int i = 0; i < v.size(); ++i) {
if (!v[i]) continue;
if (pos == v.size()) break;
v[i]->left = v[pos++];
v[i]->right = v[pos++];
}
return v[0];
}
};
复杂度分析:
serilize
时间复杂度 O(N): N 为二叉树的节点数,层序遍历需要访问所有节点,最差情况下需要访问 N + 1个 null ,总体复杂度为 O(2N + 1) = O(N) 。
空间复杂度 O(N) : 最差情况下,队列 queue 同时存储 N + 1 2 \frac{N + 1}{2} 2N+1个节点(或 N+1 个 null ),使用 O(N) ;列表 res 使用 O(N) 。
deserilize
时间复杂度 O(N) : N 为二叉树的节点数,按层构建二叉树需要遍历整个 vals ,其长度最大为 2N+1 。
空间复杂度 O(N) : 最差情况下,队列 queue 同时存储 N + 1 2 \frac{N + 1}{2} 2N+1个节点,因此使用 O(N) 额外空间。
给定一个不含重复数字的数组 nums ,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。
示例 1:
输入:nums = [1,2,3]
输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
示例 2:
输入:nums = [0,1]
输出:[[0,1],[1,0]]
示例 3:
输入:nums = [1]
输出:[[1]]
提示:
1 <= nums.length <= 6
-10 <= nums[i] <= 10
nums 中的所有整数 互不相同
我的思路:回溯
vector<vector<int>> res_permute;
vector<vector<int>> permute(vector<int>& nums) {
vector<int> track;
traverse_int(nums, track);
return res_permute;
}
void traverse_int(vector<int>& nums, vector<int>& track) {
if (track.size() == nums.size()) {
res_permute.emplace_back(track);
return;
}
for (int i = 0; i < nums.size(); ++i) {
if (find(track.begin(), track.end(), nums[i]) != track.end()) continue;//
track.emplace_back(nums[i]);
traverse_int(nums, track);
track.pop_back();
}
return;
}
注意处的判断条件,每次我们都需要遍历track,很浪费时间。
改进:加一个vector 去判断是否访问过
vector<vector<int>> res_permute;
vector<vector<int>> permute(vector<int>& nums) {
vector<int> track;
vector<bool> visited(nums.size(), false); //
traverse_int(nums, track, visited);
return res_permute;
}
void traverse_int(vector<int>& nums, vector<int>& track, vector<bool> &visited) {
if (track.size() == nums.size()) {
res_permute.emplace_back(track);
return;
}
for (int i = 0; i < nums.size(); ++i) {
if (visited[i]) continue;
track.emplace_back(nums[i]);
visited[i] = true; //
traverse_int(nums, track, visited);
visited[i] = false; //
track.pop_back();
}
return;
}
复杂度分析
时间复杂度: O ( n × n ! ) O(n \times n!) O(n×n!),其中 n 为序列的长度。
算法的复杂度首先受 backtrack 的调用次数制约,backtrack 的调用次数为 ∑ k = 1 n P ( n , k ) \sum_{k = 1}^{n}{P(n, k)} ∑k=1nP(n,k)
P(n,k) 次,其中 P ( n , k ) = n ! ( n − k ) ! = n ( n − 1 ) . . . ( n − k + 1 ) P(n, k) = \frac{n!}{(n - k)!} = n (n - 1) ... (n - k + 1) P(n,k)=(n−k)!n!=n(n−1)...(n−k+1),该式被称作 n 的 k - 排列,或者部分排列。
而 ∑ k = 1 n P ( n , k ) = n ! + n ! 1 ! + n ! 2 ! + n ! 3 ! + . . . + n ! ( n − 1 ) ! < 2 n ! + n ! 2 + n ! 2 2 + n ! 2 n − 2 < 3 n ! \sum_{k = 1}^{n}{P(n, k)} = n! + \frac{n!}{1!} + \frac{n!}{2!} + \frac{n!}{3!} + ... + \frac{n!}{(n-1)!} < 2n! + \frac{n!}{2} + \frac{n!}{2^2} + \frac{n!}{2^{n-2}} < 3n! ∑k=1nP(n,k)=n!+1!n!+2!n!+3!n!+...+(n−1)!n!<2n!+2n!+22n!+2n−2n!<3n!
这说明 backtrack 的调用次数是 O(n!) 的。
而对于 backtrack 调用的每个叶结点(共 n! 个),我们需要将当前答案使用 O(n) 的时间复制到答案数组中,相乘得时间复杂度为 O ( n × n ! ) O(n \times n!) O(n×n!)。
因此时间复杂度为 O ( n × n ! ) O(n \times n!) O(n×n!)。
空间复杂度:O(n),其中 n 为序列的长度。除答案数组以外,递归函数在递归过程中需要为每一层递归函数分配栈空间,所以这里需要额外的空间且该空间取决于递归的深度,这里可知递归调用深度为 O(n)。
输入一个字符串,打印出该字符串中字符的所有排列。
你可以以任意顺序返回这个字符串数组,但里面不能有重复元素。
示例:
输入:s = "abc"
输出:["abc","acb","bac","bca","cab","cba"]
限制:
1 <= s 的长度 <= 8
我的思路:
基于回溯法的框架,见上一题46. 全排列
, 同时引入一个unordered_set
用于记录符合条件的字符串,回溯时查询该set,如结果已存在,则跳过该次回溯。
缺点:尽管加入set查询,时间成本依旧很高
vector<string> res_permutation;
unordered_set<string> count_permutation;
void traverse_s(string& s, string& target, vector<bool> visited) {
if (s.size() == target.size()) {
res_permutation.push_back(target);
count_permutation.insert(target);
return;
}
for (int i = 0; i < s.size(); ++i) {
if (visited[i]) continue;
target += s[i];
if (count_permutation.count(target)) {
target.pop_back();
continue;
}
visited[i] = true;
traverse_s(s, target, visited);
visited[i] = false;
target.pop_back();
}
return;
}
vector<string> permutation(string s) {
string target;
unordered_map<char, int> m;
for (int i = 0; i < s.size(); ++i) {
++m[s[i]];
}
vector<bool> visited(s.size(), false);
traverse_s(s, target, visited);
return res_permutation;
}
改进:方法二:回溯
在重复的字符较多的情况下,该递归函数会生成大量重复的排列。对于任意一个空位,如果存在重复的字符,该递归函数会将它们重复填上去并继续尝试导致最后答案的重复。我们只要在递归函数中设定一个规则,保证在填每一个空位的时候重复字符只会被填入一次。
具体地,我们首先对原字符串排序,【巧妙!】,保证相同的字符都相邻,在递归函数中,我们限制每次填入的字符一定是这个字符所在重复字符集合中「从左往右第一个未被填入的字符」,即如下的判断条件:
if (vis[j] || (j > 0 && !vis[j - 1] && s[j - 1] == s[j])) {//
continue; // s[j - 1] == s[j]表示上一个字符跟当前字符一样
}
class Solution {
public:
vector<string> rec;
vector<int> vis;
void backtrack(const string& s, int i, int n, string& perm) {
if (i == n) {
rec.push_back(perm);
return;
}
for (int j = 0; j < n; j++) {
if (vis[j] || (j > 0 && !vis[j - 1] && s[j - 1] == s[j])) {//
continue;
}
vis[j] = true;
perm.push_back(s[j]);
backtrack(s, i + 1, n, perm);
perm.pop_back();
vis[j] = false;
}
}
vector<string> permutation(string s) {
int n = s.size();
vis.resize(n);
sort(s.begin(), s.end()); //
string perm;
backtrack(s, 0, n, perm);
return rec;
}
};
复杂度分析
方法三:下一个排列
我们可以这样思考:当我们已知了当前的一个排列,我们能不能快速得到字典序中下一个更大的排列呢?
答案是肯定的,参见下一题「31. 下一个排列的官方题解」,当我们已知了当前的一个排列,我们可以在 O ( n ) O(n) O(n) 的时间内计算出字典序下一个中更大的排列。这与 C++ \texttt{C++} C++ 中的 next_permutation \texttt{next\_permutation} next_permutation 函数功能相同。
具体地,我们首先对给定的字符串中的字符进行排序,即可得到当前字符串的第一个排列,然后我们不断地计算当前字符串的字典序中下一个更大的排列,直到不存在更大的排列为止即可。
这个方案的优秀之处在于,我们得到的所有排列都不可能重复,这样我们就无需进行去重的操作。同时因为无需使用回溯法,没有栈的开销,算法时间复杂度的常数较小。
class Solution {
public:
bool nextPermutation(string& s) {
int i = s.size() - 2;
while (i >= 0 && s[i] >= s[i + 1]) { // 找到一个位置i,满足后一个位置i+1的值大于i位置的值
i--;
}
if (i < 0) {
return false;
}
int j = s.size() - 1;
while (j >= 0 && s[i] >= s[j]) { // 找到
j--;
}
swap(s[i], s[j]);
reverse(s.begin() + i + 1, s.end());
return true;
}
vector<string> permutation(string s) {
vector<string> ret;
sort(s.begin(), s.end());
do {
ret.push_back(s);
} while (nextPermutation(s));
return ret;
}
};
复杂度分析
时间复杂度: O ( n × n ! ) O(n \times n!) O(n×n!),其中 n 为给定字符串的长度。我们需要 O ( n log n ) O(n \log n) O(nlogn) 的时间得到第一个排列, nextPermutation \texttt{nextPermutation} nextPermutation 函数的时间复杂度为 O ( n ) O(n) O(n),我们至多执行该函数 O ( n ! ) O(n!) O(n!)次,因此总时间复杂度为 O ( n × n ! + n log n ) = O ( n × n ! ) O(n \times n! + n \log n) = O(n \times n!) O(n×n!+nlogn)=O(n×n!)。
空间复杂度: O(1)。注意返回值不计入空间复杂度。
整数数组的一个 排列 就是将其所有成员以序列或线性顺序排列。
例如,arr = [1,2,3] ,以下这些都可以视作 arr 的排列:[1,2,3]、[1,3,2]、[3,1,2]、[2,3,1] 。
整数数组的 下一个排列 是指其整数的下一个字典序更大的排列。更正式地,如果数组的所有排列根据其字典顺序从小到大排列在一个容器中,那么数组的 下一个排列 就是在这个有序容器中排在它后面的那个排列。如果不存在下一个更大的排列,那么这个数组必须重排为字典序最小的排列(即,其元素按升序排列)。
例如,arr = [1,2,3] 的下一个排列是 [1,3,2] 。
类似地,arr = [2,3,1] 的下一个排列是 [3,1,2] 。
而 arr = [3,2,1] 的下一个排列是 [1,2,3] ,因为 [3,2,1] 不存在一个字典序更大的排列。
给你一个整数数组 nums ,找出 nums 的下一个排列。
必须 原地 修改,只允许使用额外常数空间。
示例 1:
输入:nums = [1,2,3]
输出:[1,3,2]
示例 2:
输入:nums = [3,2,1]
输出:[1,2,3]
示例 3:
输入:nums = [1,1,5]
输出:[1,5,1]
提示:
1 <= nums.length <= 100
0 <= nums[i] <= 100
我的思路:单调栈找 右侧值最接近自己 且 大于 自己的元素索引 + 倒序
注意到下一个排列总是比当前排列要大,除非该排列已经是最大的排列。我们希望找到一种方法,能够找到一个大于当前序列的新序列,且变大的幅度尽可能小。具体地:
def nextPermutation(self, nums: List[int]) -> None:
"""
Do not return anything, modify nums in-place instead.
"""
n = len(nums)
def swap(i, j):
nums[i], nums[j] = nums[j], nums[i]
def reverse(i, j):
while i<j:
swap(i, j)
i += 1
j -= 1
stk = []
right = [-1]*n # 储存右侧最接近且大于自己的元素索引
for i in range(n):
while stk and nums[stk[-1]]>=nums[i]:
stk.pop()
if stk:
right[stk[-1]] = i
stk.append(i)
for i in range(n-1, -1, -1):
if right[i]!=-1:
swap(i, right[i])
reverse(i+1, n-1)
break
else:
reverse(0, n-1)
解法:固定套路
void nextPermutation(vector<int>& nums) {
if (nums.size() == 1) return;
int i = nums.size() - 2;
while (i >= 0 && nums[i] >= nums[i + 1]) i--;//从后往前找升序的i
if (i > -1) { // 当i=-1时,说明nums已经成降序(最大)排列了,直接进行reverse得到最小排列即可
int j = nums.size() - 1;
while (j >= 0 && nums[i] >= nums[j]) j--;//从后往前找比nums[i]大的位置j
swap(nums[i], nums[j]); // 此时,从i+1往后,均为降序
}
reverse(nums.begin() + i + 1, nums.end());//从i+1的位置往后翻转
// 此过程如下所示:
// [3(i), 4, 2(j), 1] ->swap-> [4(i), 3(i+1), 2, 1] ->reverse-> [4, 1, 2, 3]
return;
}
复杂度分析
我们把只包含质因子 2、3 和 5 的数称作丑数(Ugly Number)。求按从小到大的顺序的第 n 个丑数。
示例:
输入: n = 10
输出: 12
解释: 1, 2, 3, 4, 5, 6, 8, 9, 10, 12 是前 10 个丑数。
说明:
1 是丑数。
n 不超过1690。
我的方法:暴力枚举
利用set自动排序的性质
int nthUglyNumber(int n) {
set<long long int> s1;
s1.insert(1);
vector<int> multiply{2, 3, 5};
while(s1.size() < n){
for(auto num : s1){
for(auto factor : multiply){
s1.insert(factor*num);
}
if (s1.size() > 2*n) break;
}
}
auto it = s1.begin();
n = n-1;
while (n--) {
it++;
}
return *(it);
}
复杂度:O(n^2) O(n)
方法一:最小堆
要得到从小到大的第 n 个丑数,可以使用最小堆实现。
初始时堆为空。首先将最小的丑数 1 加入堆。
每次取出堆顶元素 x,则 x 是堆中最小的丑数,由于 2x, 3x, 5x 也是丑数,因此将 2x, 3x, 5x 加入堆。
上述做法会导致堆中出现重复元素的情况。为了避免重复元素,可以使用哈希集合去重,避免相同元素多次加入堆。
在排除重复元素的情况下,第 n 次从最小堆中取出的元素即为第 n 个丑数。
int nthUglyNumber(int n) {
vector<int> factors = { 2, 3, 5 };
unordered_set<long> seen;
priority_queue<long, vector<long>, greater<long>> heap;
seen.insert(1L);
heap.push(1L);
int ugly = 0;
for (int i = 0; i < n; i++) {
long curr = heap.top();
heap.pop();
ugly = (int)curr;
for (int factor : factors) {
long next = curr * factor;
if (!seen.count(next)) {
seen.insert(next);
heap.push(next);
}
}
}
return ugly;
复杂度分析
时间复杂度: O ( n log n ) O(n \log n) O(nlogn)。得到第 n 个丑数需要进行 nn 次循环,每次循环都要从最小堆中取出 1 个元素以及向最小堆中加入最多 3 个元素,因此每次循环的时间复杂度是 O ( log n + log 3 n ) = O ( log n ) O(\log n+\log 3n)=O(\log n) O(logn+log3n)=O(logn),总时间复杂度是 O ( n log n ) O(n \log n) O(nlogn)。
空间复杂度:O(n)。空间复杂度主要取决于最小堆和哈希集合的大小,最小堆和哈希集合的大小都不会超过 3n。
方法二:动态规划
方法一使用最小堆,会预先存储较多的丑数,导致空间复杂度较高,维护最小堆的过程也导致时间复杂度较高。可以使用动态规划的方法进行优化。
定义数组 dp \textit{dp} dp,其中 dp [ i ] \textit{dp}[i] dp[i] 表示第 i 个丑数,第 n 个丑数即为 dp [ n ] \textit{dp}[n] dp[n]。
由于最小的丑数是 1,因此 dp [ 1 ] = 1 \textit{dp}[1]=1 dp[1]=1。
如何得到其余的丑数呢?定义三个指针 p 2 , p 3 , p 5 p_2,p_3,p_5 p2,p3,p5 ,表示下一个丑数是当前指针指向的丑数乘以对应的质因数。初始时,三个指针的值都是 1。
当 2 ≤ i ≤ n 2 \le i \le n 2≤i≤n 时,令 dp [ i ] = min ( dp [ p 2 ] × 2 , dp [ p 3 ] × 3 , dp [ p 5 ] × 5 ) \textit{dp}[i]=\min(\textit{dp}[p_2] \times 2, \textit{dp}[p_3] \times 3, \textit{dp}[p_5] \times 5) dp[i]=min(dp[p2]×2,dp[p3]×3,dp[p5]×5),然后分别比较 dp [ i ] d p [ i ] 和 dp [ p 2 ] , dp [ p 3 ] , dp \textit{dp}[i]dp[i] 和 \textit{dp}[p_2],\textit{dp}[p_3],\textit{dp} dp[i]dp[i]和dp[p2],dp[p3],dp 是否相等,如果相等则将对应的指针加 1。
白话解释
首先一定要知道,后面的丑数一定由前面的丑数乘以2,或者乘以3,或者乘以5得来。例如,8,9,10,12一定是1, 2, 3, 4, 5, 6乘以2,3,5三个质数中的某一个得到。
这样的话我们的解题思路就是:从第一个丑数开始,一个个数丑数,并确保数出来的丑数是递增的,直到数到第n个丑数,得到答案。那么问题就是如何递增地数丑数?
观察上面的例子,假如我们用1, 2, 3, 4, 5, 6去形成后面的丑数,我们可以将1, 2, 3, 4, 5, 6分别乘以2, 3, 5,这样得到一共6*3=18个新丑数。也就是说1, 2, 3, 4, 5, 6中的每一个丑数都有一次机会与2相乘,一次机会与3相乘,一次机会与5相乘(一共有18次机会形成18个新丑数),来得到更大的一个丑数。
这样就可以用三个指针,
pointer2, 指向1, 2, 3, 4, 5, 6中,还没使用乘2机会的丑数的位置。该指针的前一位已经使用完了乘以2的机会。
pointer3, 指向1, 2, 3, 4, 5, 6中,还没使用乘3机会的丑数的位置。该指针的前一位已经使用完了乘以3的机会。
pointer5, 指向1, 2, 3, 4, 5, 6中,还没使用乘5机会的丑数的位置。该指针的前一位已经使用完了乘以5的机会。
下一次寻找丑数时,则对这三个位置分别尝试使用一次乘2机会,乘3机会,乘5机会,看看哪个最小,最小的那个就是下一个丑数。最后,那个得到下一个丑数的指针位置加一,因为它对应的那次乘法使用完了。
正确性证明
对于 i > 1 i>1 i>1,在计算 dp [ i ] \textit{dp}[i] dp[i] 时,指针 p x ( x ∈ { 2 , 3 , 5 } ) p_x(x \in \{2,3,5\}) px(x∈{2,3,5})的含义是使得 dp [ j ] × x > dp [ i − 1 ] \textit{dp}[j] \times x>\textit{dp}[i-1] dp[j]×x>dp[i−1] 的最小的下标 j j j,即当 j ≥ p x j \ge p_x j≥px时 dp [ j ] × x > dp [ i − 1 ] \textit{dp}[j] \times x>\textit{dp}[i-1] dp[j]×x>dp[i−1],当 j < p x j
因此,对于 i > 1 i>1 i>1,在计算 dp [ i ] \textit{dp}[i] dp[i] 时, dp [ p 2 ] × 2 , dp [ p 3 ] × 3 , dp [ p 5 ] × 5 \textit{dp}[p_2] \times 2,\textit{dp}[p_3] \times 3,\textit{dp}[p_5] \times 5 dp[p2]×2,dp[p3]×3,dp[p5]×5都大于 dp [ i − 1 ] d p [ i − 1 ] , dp [ p 2 − 1 ] × 2 , dp [ p 3 − 1 ] × 3 , dp [ p 5 − 1 ] × 5 \textit{dp}[i-1]dp[i−1],\textit{dp}[p_2-1] \times 2,\textit{dp}[p_3-1] \times 3,\textit{dp}[p_5-1] \times 5 dp[i−1]dp[i−1],dp[p2−1]×2,dp[p3−1]×3,dp[p5−1]×5 都小于或等于 dp [ i − 1 ] \textit{dp}[i-1] dp[i−1]。令 dp [ i ] = min ( dp [ p 2 ] × 2 , dp [ p 3 ] × 3 , dp [ p 5 ] × 5 ) \textit{dp}[i]=\min(\textit{dp}[p_2] \times 2, \textit{dp}[p_3] \times 3, \textit{dp}[p_5] \times 5) dp[i]=min(dp[p2]×2,dp[p3]×3,dp[p5]×5),则 dp [ i ] > dp [ i − 1 ] \textit{dp}[i]>\textit{dp}[i-1] dp[i]>dp[i−1] 且 dp [ i ] \textit{dp}[i] dp[i] 是大于 dp [ i − 1 ] \textit{dp}[i-1] dp[i−1] 的最小的丑数。
在计算 dp [ i ] \textit{dp}[i] dp[i] 之后,会更新三个指针 p 2 , p 3 , p 5 p_2,p_3,p_5 p2,p3,p5,更新之后的指针将用于计算 dp [ i + 1 ] \textit{dp}[i+1] dp[i+1],同样满足 dp [ i + 1 ] > dp [ i ] \textit{dp}[i+1]>\textit{dp}[i] dp[i+1]>dp[i] 且 dp [ i + 1 ] \textit{dp}[i+1] dp[i+1] 是大于 dp [ i ] \textit{dp}[i] dp[i] 的最小的丑数。
int nthUglyNumber(int n) {
vector<int> dp(n + 1);
dp[1] = 1;
int p2 = 1, p3 = 1, p5 = 1;
for (int i = 2; i <= n; i++) {
int num2 = dp[p2] * 2, num3 = dp[p3] * 3, num5 = dp[p5] * 5;
dp[i] = min(min(num2, num3), num5);
if (dp[i] == num2) {
p2++;
}
if (dp[i] == num3) {
p3++;
}
if (dp[i] == num5) {
p5++;
}
}
return dp[n];
}
复杂度分析
把n个骰子扔在地上,所有骰子朝上一面的点数之和为s。输入n,打印出s的所有可能的值出现的概率。
你需要用一个浮点数数组返回答案,其中第 i 个元素代表这 n 个骰子所能掷出的点数集合中第 i 小的那个的概率。
示例 1:
输入: 1
输出: [0.16667,0.16667,0.16667,0.16667,0.16667,0.16667]
示例 2:
输入: 2
输出: [0.02778,0.05556,0.08333,0.11111,0.13889,0.16667,0.13889,0.11111,0.08333,0.05556,0.02778]
限制:
1 <= n <= 11
我的方法:动态规划
给定n,返回的数组大小一定为(6n-n+1=5n+1) 个
设定dp表为vector
已知dp[i-1]
相当于我们知道了上一次获得的点数的所有概率,则dp[i]
我们可由 d p [ i − 1 ] 中 的 每 一 个 值 ✖ 1 6 dp[i-1]中的每一个值✖\frac{1}{6} dp[i−1]中的每一个值✖61得到
dp还可优化为 vector
循环中令dp = tmp
vector<double> dicesProbability(int n) {
vector<vector<double>> dp(n + 1); // vector dp(n + 1);
dp[1] = vector<double>(6, 1.0/6); // dp = vector(6, 1.0/6)
for (int i = 2; i < n+1; ++i) {
vector<double> tmp = vector<double>(5 * i + 1);
for (int j = 0; j < dp[i-1].size(); ++j) { // dp[i-1] => dp
for (int factor = 0; factor < 6; factor++) {
int pos = (j + i) + factor - i;
tmp[pos] += dp[i-1][j] * 1.0/6; // dp[i-1] => dp
}
}
dp[i] = tmp; // dp = tmp
}
return dp[n]; // return dp;
}
复杂度分析:
输入数字 n,按顺序打印出从 1 到最大的 n 位十进制数。比如输入 3,则打印出 1、2、3 一直到最大的 3 位数 999。
示例 1:
输入: n = 1
输出: [1,2,3,4,5,6,7,8,9]
说明:
用返回一个整数列表来代替打印
n 为正整数
大数打印解法:
实际上,本题的主要考点是大数越界情况下的打印。需要解决以下三个问题:
1.表示大数的变量类型:
无论是 short / int / long … 任意变量类型,数字的取值范围都是有限的。因此,大数的表示应用字符串 String 类型。
2.生成数字的字符串集:
"9999"
至 "10000"
需要从个位到千位循环判断,进位 4 次。3.递归生成全排列:
基于分治算法的思想,先固定高位,向低位递归,当个位已被固定时,添加数字的字符串。例如当 n = 2 时(数字范围 1 - 99 ),固定十位为 0 - 9 ,按顺序依次开启递归,固定个位 0 - 9 ,终止递归并添加数字字符串。
char num_pn[10] = { '0', '1', '2', '3' , '4' , '5' , '6' , '7' , '8' , '9' };
void dfs_pn(int n, vector<string>& res, string& tmp) {
if (n == 0) {
while (!tmp.empty() && tmp.front() == '0') {
tmp.erase(tmp.begin()); // 移除首位为0的字符
}
if (!tmp.empty()) {
res.push_back(tmp);
tmp.erase(--tmp.end());
}
return;
}
for (auto it : num_pn) {
tmp.push_back(it);
dfs_pn(n - 1, res, tmp);
}
if (!tmp.empty()) tmp.erase(--tmp.end());// 回退时,需要清掉当前父节点
}
vector<string> printNumbers(int n) {
vector<string> res;
string tmp;
dfs_pn(n, res, tmp);
return res;
}
在数组中的两个数字,如果前面一个数字大于后面的数字,则这两个数字组成一个逆序对。输入一个数组,求出这个数组中的逆序对的总数。
数组中的逆序对
示例 1:
输入: [7,5,6,4]
输出: 5
限制:
0 <= 数组长度 <= 50000
我的方法: 暴力遍历 超时
方法一:归并排序
「归并排序」是分治思想的典型应用,它包含这样三个步骤:
分解: 待排序的区间为 [ l , r ] [l, r] [l,r],令 m = ⌊ l + r 2 ⌋ m = \lfloor \frac{l + r}{2} \rfloor m=⌊2l+r⌋,我们把 [ l , r ] [l, r] [l,r] 分成 [ l , m ] [l, m] [l,m] 和 [ m + 1 , r ] [m + 1, r] [m+1,r]
解决: 使用归并排序递归地排序两个子序列
合并: 把两个已经排好序的子序列 [ l , m ] [l, m] [l,m]和 [ m + 1 , r ] [m + 1, r] [m+1,r]合并起来
在待排序序列长度为 1 的时候,递归开始「回升」,因为我们默认长度为 1 的序列是排好序的。
思路
那么求逆序对和归并排序又有什么关系呢?关键就在于「归并」当中「并」的过程。我们通过一个实例来看看。假设我们有两个已排序的序列等待合并,分别是 L = { 8 , 12 , 16 , 22 , 100 } L = \{ 8, 12, 16, 22, 100 \} L={8,12,16,22,100} 和 R = { 9 , 26 , 55 , 64 , 91 } R = \{ 9, 26, 55, 64, 91 \} R={9,26,55,64,91} 。一开始我们用指针 l P t r = 0 lPtr = 0 lPtr=0 指向 L 的首部, r P t r = 0 rPtr = 0 rPtr=0 指向 R 的头部。记已经合并好的部分为 M。
L = [8, 12, 16, 22, 100] R = [9, 26, 55, 64, 91] M = []
| |
lPtr rPtr
我们发现 l P t r lPtr lPtr 指向的元素小于 r P t r rPtr rPtr 指向的元素,于是把 l P t r lPtr lPtr 指向的元素放入答案,并把 l P t r lPtr lPtr 后移一位。
L = [8, 12, 16, 22, 100] R = [9, 26, 55, 64, 91] M = [8]
| |
lPtr rPtr
这个时候我们把左边的 8 加入了答案,我们发现右边没有数比 8 小,所以 8 对逆序对总数的「贡献」为 0。
接着我们继续合并,把 9 加入了答案,此时 l P t r lPtr lPtr 指向 12 12 12, r P t r rPtr rPtr指向 26 26 26。
L = [8, 12, 16, 22, 100] R = [9, 26, 55, 64, 91] M = [8, 9]
| |
lPtr rPtr
此时 lPtr 比 rPtr 小,把 lPtr 对应的数加入答案,并考虑它对逆序对总数的贡献为 rPtr 相对 R 首位置的偏移 1(即右边只有一个数比 12 小,所以只有它和 12 构成逆序对),以此类推。
我们发现用这种「算贡献」的思想在合并的过程中计算逆序对的数量的时候,只在 lPtr 右移的时候计算,是基于这样的事实:当前 lPtr 指向的数字比 rPtr 小,但是比 R 中 [0 … rPtr - 1] 的其他数字大,[0 … rPtr - 1] 的其他数字本应当排在 lPtr 对应数字的左边,但是它排在了右边,所以这里就贡献了 rPtr 个逆序对。
利用这个思路,我们可以写出如下代码。
class Solution {
public:
int mergeSort(vector<int>& nums, vector<int>& tmp, int l, int r) {
if (l >= r) {
return 0;
}
int mid = (l + r) / 2;
int inv_count = mergeSort(nums, tmp, l, mid) + mergeSort(nums, tmp, mid + 1, r);
int i = l, j = mid + 1, pos = l;
while (i <= mid && j <= r) {
if (nums[i] <= nums[j]) {
tmp[pos] = nums[i];
++i;
inv_count += (j - (mid + 1));
}
else {
tmp[pos] = nums[j];
++j;
}
++pos;
}
for (int k = i; k <= mid; ++k) {
tmp[pos++] = nums[k];
inv_count += (j - (mid + 1));
}
for (int k = j; k <= r; ++k) {
tmp[pos++] = nums[k];
}
copy(tmp.begin() + l, tmp.begin() + r + 1, nums.begin() + l);
return inv_count;
}
int reversePairs(vector<int>& nums) {
int n = nums.size();
vector<int> tmp(n);
return mergeSort(nums, tmp, 0, n - 1);
}
};
复杂度分析
记序列长度为 n。
输入一个整数 n ,求1~n这n个整数的十进制表示中1出现的次数。
例如,输入12,1~12这些整数中包含1 的数字有1、10、11和12,1一共出现了5次。
示例 1:
输入:n = 12
输出:5
示例 2:
输入:n = 13
输出:6
限制:
1 <= n < 2^31
我的解法:dp+递归
int countDigitOne(int n) {
if (n == 0) return 0;
if (n < 10) return 1;
int supbit = n, cnt = 0;
while (supbit >= 10) {
supbit /= 10;
cnt++;
}
int dp0 = 0, dp1 = 1;
for (int i = 1; i < cnt; i++) { // 计算次高位的0-(9...9)中1的个数,
dp0 = dp1;
dp1 = pow(10, i) + dp0 * 10;// 例如n=139,则dp1 = 0-99中1的个数
}
int res = 0;
for (int i = 0; i < supbit; i++)
res += dp1; // 例如n=354,则我们将答案加2次dp1的值,表征了0-99,0-99(百位先不算),200-299
if (supbit > 1) {
res += pow(10, cnt); // 如果最高位大于1,我们把百位的1补上(all)
return res + countDigitOne(n - supbit * pow(10, cnt)); //返回res + 300-354中1的个数
}
else if (supbit == 1) { // 如果最高位等于1,我们补充百位的1的个数(部分)
int plus = n - supbit * pow(10, cnt) + 1; // 计算部分的个数
return res + plus + countDigitOne(n - supbit * pow(10, cnt));
}
return 0;
}
复杂度分析:设输入n的位数为m~log(n)
时间: O ( l o g ( n ) 2 ) O(log(n)^2) O(log(n)2)
空间: O ( m ) O(m) O(m)栈
可优化,例如dp数组
并不需要每次递归都进行,放在递归外面更好
方法一:枚举每一数位上 1 的个数
int countDigitOne(int n) {
// mulk 表示 10^k
// 在下面的代码中,可以发现 k 并没有被直接使用到(都是使用 10^k)
// 但为了让代码看起来更加直观,这里保留了 k
long long mulk = 1;
int ans = 0;
for (int k = 0; n >= mulk; ++k) {
ans += (n / (mulk * 10)) * mulk + min(max(n % (mulk * 10) - mulk + 1, 0LL), mulk);
mulk *= 10;
}
return ans;
}
复杂度分析
时间复杂度: O ( log n ) O(\log n) O(logn)。n 包含的数位个数与 n 呈对数关系。
空间复杂度:O(1) 。
数字以0123456789101112131415…的格式序列化到一个字符序列中。在这个序列中,第5位(从下标0开始计数)是5,第13位是1,第19位是4,等等。
请写一个函数,求任意第n位对应的数字。
剑指 Offer 44. 数字序列中某一位的数字]
示例 1:
输入:n = 3
输出:3
示例 2:
输入:n = 11
输出:0
限制:
0 <= n < 2^31
给你一根长度为 n 的绳子,请把绳子剪成整数长度的 m 段(m、n都是整数,n>1并且m>1),每段绳子的长度记为 k[0],k[1]…k[m - 1] 。请问 k[0]k[1]…*k[m - 1] 可能的最大乘积是多少?例如,当绳子的长度是8时,我们把它剪成长度分别为2、3、3的三段,此时得到的最大乘积是18。
答案需要取模 1e9+7(1000000007),如计算初始结果为:1000000008,请返回 1。
剑指 Offer 14- II. 剪绳子 II
示例 1:
输入: 2
输出: 1
解释: 2 = 1 + 1, 1 × 1 = 1
示例 2:
输入: 10
输出: 36
解释: 10 = 3 + 3 + 4, 3 × 3 × 4 = 36
提示:
2 <= n <= 1000