二叉树的层次遍历
82. 二叉树的右视图
解法:二叉树的层次遍历
对二叉树进行层次遍历,那么对于每层来说,最右边的结点一定是最后被遍历到的。二叉树的层次遍历可以用广度优先搜索实现。
算法
执行广度优先搜索,左结点排在右结点之前,这样,我们对每一层都从左到右访问。因此,只保留每个深度最后访问的结点,我们就可以在遍历完整棵树后得到每个深度最右的结点。层次队列一般用队列实现,就先记录每层的节点个数,然后将每层的节点的左右节点入队列,因此每次最后pop的节点是每层最右边的节点。
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode() : val(0), left(nullptr), right(nullptr) {}
* TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
* TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
* };
*/
class Solution {
public:
vector<int> rightSideView(TreeNode* root) {
if(root==nullptr)
return vector<int>{};
queue<TreeNode*>que;
que.push(root);
vector<int>result;
while(!que.empty()){
int sz=que.size();
while(sz>0){
TreeNode* node=que.front();
que.pop();
if(node->left)
que.push(node->left);
if(node->right)
que.push(node->right);
sz--;
if(sz==0){
result.emplace_back(node->val);
}
}
}
return result;
}
};
时间复杂度 : O(n)。 每个节点最多进队列一次,出队列一次,因此广度优先搜索的复杂度为线性。
空间复杂度 : O(n)。每个节点最多进队列一次,所以队列长度最大不不超过 n,所以这里的空间代价为 O(n)
83. 二叉树的层平均值
解法一:广度优先遍历
也可以使用广度优先搜索计算二叉树的层平均值。从根节点开始搜索,每一轮遍历同一层的全部节点,计算该层的节点数以及该层的节点值之和,然后计算该层的平均值。
如何确保每一轮遍历的是同一层的全部节点呢?我们可以借鉴层次遍历的做法,广度优先搜索使用队列存储待访问节点,只要确保在每一轮遍历时,队列中的节点是同一层的全部节点即可。具体做法如下:
- 初始时,将根节点加入队列;
- 每一轮遍历时,将队列中的节点全部取出,计算这些节点的数量以及它们的节点值之和,并计算这些节点的平均值,然后将这些节点的全部非空子节点加入队列,重复上述操作直到队列为空,遍历结束。
- 由于初始时队列中只有根节点,满足队列中的节点是同一层的全部节点,每一轮遍历时都会将队列中的当前层节点全部取出,并将下一层的全部节点加入队列,因此可以确保每一轮遍历的是同一层的全部节点。
- 具体实现方面,可以在每一轮遍历之前获得队列中的节点数量 size,遍历时只遍历 size 个节点,即可满足每一轮遍历的是同一层的全部节点。
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode() : val(0), left(nullptr), right(nullptr) {}
* TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
* TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
* };
*/
class Solution {
public:
vector<double> averageOfLevels(TreeNode* root) {
vector<double>result;
queue<TreeNode*>que;
que.push(root);
while(!que.empty()){
long long sum=0;
int len=que.size();
for(int i=0;i<len;i++){
TreeNode*tmp=que.front();
que.pop();
if(tmp->left)
que.push(tmp->left);
if(tmp->right)
que.push(tmp->right);
sum+=tmp->val;
}
double avg=sum*1.0/len;
result.emplace_back(avg);
}
return result;
}
};
时间复杂度:O(n),其中 n 是二叉树中的节点个数。
- 广度优先搜索需要对每个节点访问一次,时间复杂度是 O(n)。
需要对二叉树的每一层计算平均值,时间复杂度是 O(h),其中 h 是二叉树的高度,任何情况下都满足 h≤n。因此总时间复杂度是 O(n)。
空间复杂度:O(n),其中 n 是二叉树中的节点个数。空间复杂度取决于队列开销,队列中的节点个数不会超过 n。
解法二:深度优先遍历
84. 二叉树的层序遍历
解法:BFS层序遍历
I. 按层打印: 题目要求的二叉树的 从上至下 打印(即按层打印),又称为二叉树的 广度优先搜索(BFS)。BFS 通常借助 队列 的先入先出特性来实现。
II. 每层打印到一行: 将本层全部节点打印到一行,并将下一层全部节点加入队列,以此类推,即可分为多行打印。
算法流程:
- 特例处理: 当根节点为空,则返回空列表 [] 。
- 初始化: 打印结果列表 res = [] ,包含根节点的队列 queue = [root] 。
- BFS 循环: 当队列 queue 为空时跳出。
- 新建一个临时列表 tmpres ,用于存储当前层打印结果。
- 当前层打印循环: 循环次数为当前层节点数(即队列 queue 长度)。
- 出队: 队首元素出队,记为 node。
- 打印: 将 node.val 添加至 tmp 尾部。
- 添加子节点: 若 node 的左(右)子节点不为空,则将左(右)子节点加入队列 queue 。
将当前层结果 tmpres 添加入 res 。
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode() : val(0), left(nullptr), right(nullptr) {}
* TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
* TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
* };
*/
class Solution {
public:
vector<vector<int>> levelOrder(TreeNode* root) {
vector<vector<int>>res;
if(root==nullptr)
return res;
queue<TreeNode*>que;
que.push(root);
while(!que.empty()){
int sz=que.size();
vector<int>tmpres;
while(sz){
TreeNode* tmp=que.front();
tmpres.emplace_back(tmp->val);
que.pop();
sz--;
if(tmp->left)
que.push(tmp->left);
if(tmp->right)
que.push(tmp->right);
}
res.push_back(tmpres);
}
return res;
}
};
时间复杂度 O(N) : N 为二叉树的节点数量,即 BFS 需循环 N 次。
空间复杂度 O(N) : 最差情况下,即当树为平衡二叉树时,最多有 N/2个树节点同时在 queue 中,使用 O(N) 大小的额外空间。
85. 二叉树的锯齿形层序遍历
解法一:广度优先遍历
此题是「102. 二叉树的层序遍历」的变种,最后输出的要求有所变化,要求我们按层数的奇偶来决定每一层的输出顺序。规定二叉树的根节点为第 0 层,如果当前层数是偶数,从左至右输出当前层的节点值,否则,从右至左输出当前层的节点值。定义一个标志位,每次标志位置相反,我觉得可以不使用双端队列,直接如果是正方向,我们就顺序插入result结果,如果是逆方向就使用头插法即可
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode() : val(0), left(nullptr), right(nullptr) {}
* TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
* TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
* };
*/
class Solution {
public:
vector<vector<int>> zigzagLevelOrder(TreeNode* root) {
vector<vector<int>>result;
queue<TreeNode*>que;
que.push(root);
int direction=1;
if(root==nullptr)
return result;
while(!que.empty()){
int len=que.size();
vector<int>cur_result;
for(int i=0;i<len;i++){
TreeNode*tmp=que.front();
que.pop();
if(tmp->left!=nullptr){
que.push(tmp->left);
}
if(tmp->right!=nullptr){
que.push(tmp->right);
}
if(direction==1){
cur_result.emplace_back(tmp->val);
}
else{
cur_result.insert(cur_result.begin(),tmp->val);
}
}
result.push_back(cur_result);
direction=-direction;
}
return result;
}
};
时间复杂度:O(N),其中 N 为二叉树的节点数。每个节点会且仅会被遍历一次。
空间复杂度:O(N)。
二叉搜索树
86. 二叉搜索树的最小绝对差
解法一:中序遍历 考虑对升序数组 a 求任意两个元素之差的绝对值的最小值,答案一定为相邻两个元素之差的最小值,即a n s = m i n i = 0 n − 2 { a [ i + 1 ] − a [ i ] } ans=min_{i=0}^{n-2}\{a[i+1]-a[i]\} ans=mini=0n−2{a[i+1]−a[i]}
其中 n 为数组 a 的长度。其他任意间隔距离大于等于 2 的下标对 (i,j) 的元素之差一定大于下标对 (i,i+1) 的元素之差,故不需要再被考虑。
回到本题,本题要求二叉搜索树任意两节点差的绝对值的最小值,而我们知道二叉搜索树有个性质为二叉搜索树中序遍历得到的值序列是递增有序的,因此我们只要得到中序遍历后的值序列即能用上文提及的方法来解决。
朴素的方法是经过一次中序遍历将值保存在一个数组中再进行遍历求解,我们也可以在中序遍历的过程中用 pre 变量保存前驱节点的值,这样即能边遍历边更新答案,不再需要显式创建数组来保存,需要注意的是 pre 的初始值需要设置成任意负数标记开头,下文代码中设置为 −1。
二叉树的中序遍历有多种方式,包括递归、栈、Morris 遍历等,读者可选择自己最擅长的来实现。下文代码提供最普遍的递归方法来实现,其他遍历方法的介绍可以详细看「94. 二叉树的中序遍历的官方题解」,这里不再赘述。
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode() : val(0), left(nullptr), right(nullptr) {}
* TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
* TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
* };
*/
class Solution {
public:
void inOrder(TreeNode*root,int &pre,int&ans){
if(root==nullptr){
return;
}
inOrder(root->left,pre,ans);
if(pre==-1){
pre=root->val;
}
else{
ans=min(ans,root->val-pre);
pre=root->val;
}
inOrder(root->right,pre,ans);
}
int getMinimumDifference(TreeNode* root) {
int ans=INT_MAX,pre=-1;
inOrder(root,pre,ans);
return ans;
}
};
时间复杂度:O(n),其中 n 为二叉搜索树节点的个数。每个节点在中序遍历中都会被访问一次且只会被访问一次,因此总时间复杂度为 O(n)。
空间复杂度:O(n)。递归函数的空间复杂度取决于递归的栈深度,而栈深度在二叉搜索树为一条链的情况下会达到 O(n) 级别。
87. 二叉搜索树中第K 小的元素
解法一:普通解法,得到中序遍历的数组,返回其中的第k个
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode() : val(0), left(nullptr), right(nullptr) {}
* TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
* TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
* };
*/
class Solution {
public:
int kthSmallest(TreeNode* root, int k) {
vector<int>result;
preOrder(root,result);
return result[k-1];
}
void preOrder(TreeNode*root,vector<int>&result){
if(root==nullptr)
return;
preOrder(root->left,result);
result.emplace_back(root->val);
preOrder(root->right,result);
}
};
时间复杂度:O(N)
空间复杂度:O(N)
解法1.2:中序遍历的迭代方式,只需要计数到第k个,不需要遍历所有的元素
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode() : val(0), left(nullptr), right(nullptr) {}
* TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
* TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
* };
*/
class Solution {
public:
int kthSmallest(TreeNode* root, int k) {
stack<TreeNode*>stack;
while(root!=nullptr||stack.size()>0){
while(root!=nullptr){
stack.push(root);
root=root->left;
}
root=stack.top();
stack.pop();
--k;
if(k==0){
break;
}
root=root->right;
}
return root->val;
}
};
时间复杂度:O(H+k),其中 H 是树的高度。在开始遍历之前,我们需要 O(H) 到达叶结点。当树是平衡树时,时间复杂度取得最小值 O(logN+k;当树是线性树(树中每个结点都只有一个子结点或没有子结点)时,时间复杂度取得最大值 O(N+k)。
空间复杂度:O(H),栈中最多需要存储 H 个元素。当树是平衡树时,空间复杂度取得最小值 O(logN);当树是线性树时,空间复杂度取得最大值 O(N)
解法二:如果你需要频繁地查找第 k 小的值,你将如何优化算法?
在方法一中,我们之所以需要中序遍历前 k 个元素,是因为我们不知道子树的结点数量,不得不通过遍历子树的方式来获知。
因此,我们可以记录下以每个结点为根结点的子树的结点数,并在查找第 k 小的值时,使用如下方法搜索:
令 node 等于根结点,开始搜索。
对当前结点 node 进行如下操作:
- 如果 node 的左子树的结点数left 小于 k−1,则第 k 小的元素一定在node 的右子树中,令node 等于其的右子结点,k 等于 k−left−1,并继续搜索;
- 如果 node 的左子树的结点数 left 等于 k−1,则第 k小的元素即为 node ,结束搜索并返回 node 即可;
- 如果 node 的左子树的结点数 left 大于 k−1,则第 k 小的元素一定在 node 的左子树中,令node 等于其左子结点,并继续搜索。
class MyBst {
public:
MyBst(TreeNode *root) {
this->root = root;
countNodeNum(root);
}
// 返回二叉搜索树中第k小的元素
int kthSmallest(int k) {
TreeNode *node = root;
while (node != nullptr) {
int left = getNodeNum(node->left);
if (left < k - 1) {
node = node->right;
k -= left + 1;
} else if (left == k - 1) {
break;
} else {
node = node->left;
}
}
return node->val;
}
private:
TreeNode *root;
unordered_map<TreeNode *, int> nodeNum;
// 统计以node为根结点的子树的结点数
int countNodeNum(TreeNode * node) {
if (node == nullptr) {
return 0;
}
nodeNum[node] = 1 + countNodeNum(node->left) + countNodeNum(node->right);
return nodeNum[node];
}
// 获取以node为根结点的子树的结点数
int getNodeNum(TreeNode * node) {
if (node != nullptr && nodeNum.count(node)) {
return nodeNum[node];
}else{
return 0;
}
}
};
class Solution {
public:
int kthSmallest(TreeNode* root, int k) {
MyBst bst(root);
return bst.kthSmallest(k);
}
};
88. 验证二叉搜索树
解法一:二叉搜索树中序遍历递增,首先递归实现中序遍历,判断数组是否递增,如果递增则是二叉搜索树,否则不是
代码:
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode() {}
* TreeNode(int val) { this.val = val; }
* TreeNode(int val, TreeNode left, TreeNode right) {
* this.val = val;
* this.left = left;
* this.right = right;
* }
* }
*/
class Solution {
public:
vector<int>sortNum;
bool isValidBST(TreeNode* root) {
Inorder(root);
for(int i=0;i<sortNum.size()-1;i++){
if(sortNum[i]>=sortNum[i+1])
return false;
}
return true;
}
void Inorder(TreeNode*root){
if(root==nullptr)
return;
Inorder(root->left);
sortNum.emplace_back(root->val);
Inorder(root->right);
}
};
时间复杂度:O(n)
空间复杂度:O(n)
解法二:不使用数组,使用中序遍历的非递归算法
class Solution {
public:
bool isValidBST(TreeNode* root) {
stack<TreeNode*> stack;
long long inorder = (long long)INT_MIN - 1;
while (!stack.empty() || root != nullptr) {
while (root != nullptr) {
stack.push(root);
root = root -> left;
}
root = stack.top();
stack.pop();
// 如果中序遍历得到的节点的值小于等于前一个 inorder,说明不是二叉搜索树
if (root -> val <= inorder) {
return false;
}
inorder = root -> val;
root = root -> right;
}
return true;
}
};
解法二:递归
要解决这道题首先我们要了解二叉搜索树有什么性质可以给我们利用,由题目给出的信息我们可以知道:如果该二叉树的左子树不为空,则左子树上所有节点的值均小于它的根节点的值; 若它的右子树不空,则右子树上所有节点的值均大于它的根节点的值;它的左右子树也为二叉搜索树。
这启示我们设计一个递归函数 helper(root, lower, upper) 来递归判断,函数表示考虑以 root 为根的子树,判断子树中所有节点的值是否都在 (l,r)的范围内(注意是开区间)。如果 root 节点的值 val 不在 (l,r) 的范围内说明不满足条件直接返回,否则我们要继续递归调用检查它的左右子树是否满足,如果都满足才说明这是一棵二叉搜索树。
那么根据二叉搜索树的性质,在递归调用左子树时,我们需要把上界 upper 改为 root.val,即调用 helper(root.left, lower, root.val),因为左子树里所有节点的值均小于它的根节点的值。同理递归调用右子树时,我们需要把下界 lower 改为 root.val,即调用 helper(root.right, root.val, upper)。
函数递归调用的入口为 helper(root, -inf, +inf), inf 表示一个无穷大的值。
class Solution {
public:
bool helper(TreeNode* root, long long lower, long long upper) {
if (root == nullptr) {
return true;
}
if (root -> val <= lower || root -> val >= upper) {
return false;
}
return helper(root -> left, lower, root -> val) && helper(root -> right, root -> val, upper);
}
bool isValidBST(TreeNode* root) {
return helper(root, LONG_MIN, LONG_MAX);
}
};
时间复杂度:O(n),其中 n 为二叉树的节点个数。在递归调用的时候二叉树的每个节点最多被访问一次,因此时间复杂度为 O(n)。
空间复杂度:O(n),其中 n 为二叉树的节点个数。递归函数在递归过程中需要为每一层递归函数分配栈空间,所以这里需要额外的空间且该空间取决于递归的深度,即二叉树的高度。最坏情况下二叉树为一条链,树的高度为 n ,递归最深达到 n 层,故最坏情况下空间复杂度为 O(n) 。
图
89. 岛屿数量
解法:DFS
我们可以将二维网格看成一个无向图,竖直或水平相邻的 1 之间有边相连。
为了求出岛屿的数量,我们可以扫描整个二维网格。如果一个位置为 1,则以其为起始节点开始进行深度优先搜索。在深度优先搜索的过程中,使用visited标记每个节点是否访问,【也可以在grid数组上直接标记,每个搜索到的节每个搜索到的 1 都会被重新标记为 0】。
最终岛屿的数量就是我们进行深度优先搜索的次数。
class Solution {
public:
int count=0;
vector<vector<bool>>visited;
vector<vector<int>>direct{{1,0},{0,1},{-1,0},{0,-1}};
void dfs(vector<vector<char>>& grid,int r,int c){
if(r>=grid.size()||r<0||c>=grid[0].size()||c<0)
return;
if(grid[r][c]!='1'||visited[r][c])
return;
visited[r][c]=true;
for(int i=0;i<4;i++){
dfs(grid,r+direct[i][0],c+direct[i][1]);
}
}
int numIslands(vector<vector<char>>& grid) {
visited.resize(grid.size(), vector<bool>(grid[0].size(), false));
for(int i=0;i<grid.size();i++)
for(int j=0;j<grid[0].size();j++){
if(grid[i][j]=='1'&&!visited[i][j])
{
count++;
dfs(grid,i,j);
}
}
return count;
}
};
时间复杂度:O(MN),其中 M 和 N 分别为行数和列数。
空间复杂度:O(MN),在最坏情况下,整个网格均为陆地,深度优先搜索的深度达到 MN
解法二: 并查集和BFS
见200. 岛屿数量 - 力扣(LeetCode)
90. 被围绕的区域
解法一:深度优先搜索
本题给定的矩阵中有三种元素:
- 字母 X;
- 被字母 X 包围的字母 O;
- 没有被字母 X 包围的字母 O。
本题要求将所有被字母 X 包围的字母 O都变为字母 X ,但很难判断哪些 O 是被包围的,哪些 O 不是被包围的。
注意到题目解释中提到:任何边界上的 O 都不会被填充为 X。 我们可以想到,所有的不被包围的 O 都直接或间接与边界上的 O 相连。我们可以利用这个性质判断 O 是否在边界上,具体地说:
对于每一个边界上的 O,我们以它为起点,标记所有与它直接或间接相连的字母 O;
最后我们遍历这个矩阵,对于每一个字母:
- 如果该字母被标记过,则该字母为没有被字母 X 包围的字母 O,我们将其还原为字母 O;
- 如果该字母没有被标记过,则该字母为被字母 X 包围的字母 O,我们将其修改为字母 X。
其实和岛屿问题类似,采用逆向思维,如果边界的O能够到达的,就说明无法被围绕,所以只需要从边界位置进行深度优先搜索或者广度优先搜索,将搜索到的位置标记,这些标记位置最后都是字母O,其余的字母O改为X
class Solution {
public:
int row,col;
void dfs(vector<vector<char>>&board,int x,int y){
if(x<0||x>=row||y<0||y>=col||board[x][y]!='O')
return;
board[x][y]='#';
dfs(board,x+1,y);
dfs(board,x-1,y);
dfs(board,x,y+1);
dfs(board,x,y-1);
}
void solve(vector<vector<char>>& board) {
row=board.size();
if(row==0)
return;
col=board[0].size();
for(int i=0;i<row;i++)
{
dfs(board,i,0);
dfs(board,i,col-1);
}
for(int i=1;i<col-1;i++){
dfs(board,0,i);
dfs(board,row-1,i);
}
for(int i=0;i<row;i++){
for(int j=0;j<col;j++){
if(board[i][j]=='#'){
board[i][j]='O';
}
else if(board[i][j]=='O'){
board[i][j]='X';
}
}
}
}
};
时间复杂度:O(n×m),其中 n 和 m 分别为矩阵的行数和列数。深度优先搜索过程中,每一个点至多只会被标记一次。
空间复杂度:O(n×m),其中 n 和 m 分别为矩阵的行数和列数。主要为深度优先搜索的栈的开销。
解法二:广度优先搜索
91. 克隆图
解法一:广度优先遍历
- 使用一个哈希表 visited 存储所有已被访问和克隆的节点。哈希表中的 key 是原始图中的节点,value 是克隆图中的对应节点。
- 将题目给定的节点添加到队列。克隆该节点并存储到哈希表中。
- 每次从队列首部取出一个节点,遍历该节点的所有邻接点。如果某个邻接点已被访问,则该邻接点一定在 visited 中,那么从 visited 获得该邻接点,否则创建一个新的节点存储在 visited 中,并将邻接点添加到队列。将克隆的邻接点添加到克隆图对应节点的邻接表中。重复上述操作直到队列为空,则整个图遍历结束。
/*
// Definition for a Node.
class Node {
public:
int val;
vector<Node*> neighbors;
Node() {
val = 0;
neighbors = vector<Node*>();
}
Node(int _val) {
val = _val;
neighbors = vector<Node*>();
}
Node(int _val, vector<Node*> _neighbors) {
val = _val;
neighbors = _neighbors;
}
};
*/
class Solution {
public:
unordered_map <Node*,Node*>visited;
Node* cloneGraph(Node* node) {
if(node== nullptr)
return node;
queue<Node*>queue;
queue.push(node);
Node *copynode=new Node(node->val);
visited[node]=copynode;
while(!queue.empty())
{
Node* node=queue.front();
queue.pop();
for(auto neigh:node->neighbors)
{
if(visited.find(neigh)==visited.end())
{
Node * n=new Node(neigh->val);
visited[neigh]=n;
queue.push(neigh);
}
visited[node]->neighbors.push_back(visited[neigh]);
}
}
return visited[node];
}
};
时间复杂度:O(N),其中 N 表示节点数量。广度优先搜索遍历图的过程中每个节点只会被访问一次。
空间复杂度:O(N)。哈希表使用 O(N) 的空间。广度优先搜索中的队列在最坏情况下会达到 O(N) 的空间复杂度,因此总体空间复杂度为 O(N)。
解法二:深度优先遍历
对于本题而言,我们需要明确图的深拷贝是在做什么,对于一张图而言,它的深拷贝即构建一张与原图结构,值均一样的图,但是其中的节点不再是原来图节点的引用。因此,为了深拷贝出整张图,我们需要知道整张图的结构以及对应节点的值。
由于题目只给了我们一个节点的引用,因此为了知道整张图的结构以及对应节点的值,我们需要从给定的节点出发,进行「图的遍历」,并在遍历的过程中完成图的深拷贝。
为了避免在深拷贝时陷入死循环,我们需要理解图的结构。对于一张无向图,任何给定的无向边都可以表示为两个有向边,即如果节点 A 和节点 B 之间存在无向边,则表示该图具有从节点 A 到节点 B 的有向边和从节点 B 到节点 A 的有向边。
为了防止多次遍历同一个节点,陷入死循环,我们需要用一种数据结构记录已经被克隆过的节点。
算法
使用一个哈希表存储所有已被访问和克隆的节点。哈希表中的 key 是原始图中的节点,value 是克隆图中的对应节点。
从给定节点开始遍历图。如果某个节点已经被访问过,则返回其克隆图中的对应节点。
如下图,我们给定无向边边 A - B,表示 A 能连接到 B,且 B 能连接到 A。如果不对访问过的节点做标记,则会陷入死循环中。
如果当前访问的节点不在哈希表中,则创建它的克隆节点并存储在哈希表中。注意:在进入递归之前,必须先创建克隆节点并保存在哈希表中。如果不保证这种顺序,可能会在递归中再次遇到同一个节点,再次遍历该节点时,陷入死循环。
递归调用每个节点的邻接点。每个节点递归调用的次数等于邻接点的数量,每一次调用返回其对应邻接点的克隆节点,最终返回这些克隆邻接点的列表,将其放入对应克隆节点的邻接表中。这样就可以克隆给定的节点和其邻接点。
class Solution {
public:
unordered_map<Node*, Node*> visited;
Node* cloneGraph(Node* node) {
if (node == nullptr) {
return node;
}
// 如果该节点已经被访问过了,则直接从哈希表中取出对应的克隆节点返回
if (visited.find(node) != visited.end()) {
return visited[node];
}
// 克隆节点,注意到为了深拷贝我们不会克隆它的邻居的列表
Node* cloneNode = new Node(node->val);
// 哈希表存储
visited[node] = cloneNode;
// 遍历该节点的邻居并更新克隆节点的邻居列表
for (auto& neighbor: node->neighbors) {
cloneNode->neighbors.emplace_back(cloneGraph(neighbor));
}
return cloneNode;
}
};
时间复杂度:O(N),其中 N 表示节点数量。深度优先搜索遍历图的过程中每个节点只会被访问一次。
空间复杂度:O(N)。存储克隆节点和原节点的哈希表需要 O(N) 的空间,递归调用栈需要 O(H) 的空间,其中 H 是图的深度,经过放缩可以得到 O(H)=O(N),因此总体空间复杂度为 O(N)。
92. 除法求值
解法一:构建有项图+深搜/广搜
给你一个变量对数组 equations 和一个实数值数组 values 作为已知条件,其中 equations[i] = [Ai, Bi] 和 values[i] 共同表示等式 Ai / Bi = values[i] 。每个 Ai 或 Bi 是一个表示单个变量的字符串。
另有一些以数组 queries 表示的问题,其中 queries[j] = [Cj, Dj] 表示第 j 个问题,请你根据已知条件找出 Cj / Dj = ? 的结果作为答案。
返回 所有问题的答案 。如果存在某个无法确定的答案,则用 -1.0 替代这个答案。如果问题中出现了给定的已知条件中没有出现的字符串,也需要用 -1.0 替代这个答案。
注意:
- 输入总是有效的。你可以假设除法运算中不会出现除数为 0 的情况,且不存在任何矛盾的结果。
- 未在等式列表中出现的变量是未定义的,因此无法确定它们的答案。
题目分析
这道题我们需要根据已知的除法等式来计算待求解的等式。
假设我们已知 a / b = 3, b /c = 2,我们要求解 a / c。很明显我们并没有 a / c 的直接信息。但是我们可以通过已知信息 (a /b) * (b / c) 得出 a / c 的结果。
即我们通过 b 作为中间过渡变量,实现了从 a 到 c 计算。如果我们把每个变量 a, b, c 看成 图的节点,把每一个除法运算看成从被除数节点到除数节点的一条有向边且商为权重:
那么我们求解 a / c 相当于计算从节点 a 到 节点 c 的路径的权重乘积。
构图
根据上面的分析,我们可以根据输入 equations[i] = [Ai, Bi] 和 values[i] 共同表示等式 Ai / Bi = values[i] 进行构图:
构建一条从 Ai 节点 指向 Bi 节点,权重为 values[i] 的边;
构建一条从 Bi 节点 指向 Ai 节点,权重为 1 / values[i] 的边;【表示 Bi / Ai = values[i]】;
构建一条从 Ai 节点 指向 Ai 节点,权重为 1 的边;【表示 Ai / Ai = 1 】
构建一条从 Bi 节点 指向 Bi 节点,权重为 1 的边;【表示 Bi / Bi = 1】
即通过一组除法运算,我们可以构建四条边,保证只要等式数组中出现的变量都将构建相应的节点。
由于变量名 Ai 和 Bi 都是字符串,因此我们需要使用两重哈希表来存储图结构 graph,即:
第一层哈希表 graph 存储每个节点和它的邻节点信息表;
第二层哈希表 graph[s] 存储节点 s 的邻节点信息表,其中键 e 为 s 的邻节点,值 graph[s][e] 的值表示 s 节点到 e 节点的权重值。
graph = {a: {b: 3, …}, …}
广度优先搜索
根据上面的分析,我们对一个要求解的式子 C / D,就是找到图中 C 节点到 D节点的路径,并且计算这条路径上的权重积。
那么对路径的搜索我们有两种方式:深度优先搜索和广度优先搜索。这道题我觉得使用广度优先搜索会更优。因为广度优先搜索会找到一个节点到另一个节点的最短路径,那么我们就可以更快的找到目标节点。
因此对式子 C / D 的求解过程为:
- 首先判断求解的变量 C 和 D 是否都存在于图中;只要有一个变量不在图中,那一定是无法通过已有的变量计算得到的;
- 如果 C 和 D 都在图上,那么以 C 为搜索起点进行广度优先搜索;
- 如果无法到达终点,则该式子不可解;
- 否则,结果为到达终点时的路径权重积;
由于我们在进行广度优先搜索的过程中,不仅要找到下一个待搜索的节点【即当前节点的未处理邻节点】,还要得到到达这个待搜索节点时的权重积,因此我们对于搜索过程中的入队节点要存储节点变量名和权重积两个信息。而且从各种不同路径得到的答案应该是相同的,所以从源节点找到目标节点就可以跳出循环了。
class Solution {
public:
vector<double> calcEquation(vector<vector<string>>& equations, vector<double>& values, vector<vector<string>>& queries) {
//生成存储变量所构成的图结构
unordered_map<string,unordered_map<string,double>>graph;
int n=equations.size();
for(int i=0;i<n;i++){
string s=equations[i][0],t=equations[i][1];
double w=values[i];
graph[s][t]=w;
graph[t][s]=1/w;
graph[s][s]=1.0;
graph[t][t]=1.0;
}
queue<pair<string,double>>que;
int m=queries.size();
vector<double>ans(m,-1.0);
for(int i=0;i<m;i++){
string qx=queries[i][0],qy=queries[i][1];
if(graph.find(qx)==graph.end()||graph.find(qy)==graph.end())
continue;
que.emplace(qx,1.0);
unordered_set<string>visited{qx};//存储已经处理的节点
while(!que.empty()){
string node=que.front().first;
double mul=que.front().second;
que.pop();
for(auto neighbor:graph[node]){
string ngh=neighbor.first;
double weight=neighbor.second;
//枚举该节点的所有邻居节点
if(ngh==qy){
ans[i]=mul*weight;
break;
}
if(visited.find(ngh)==visited.end()){
visited.emplace(ngh);
que.emplace(ngh,mul*weight);
}
}
}
}
return ans;
}
};
时间复杂度:O(n)
空间复杂度:O(n)
93. 课程表
解法一:DFS
思路
我们可以将深度优先搜索的流程与拓扑排序的求解联系起来,用一个栈来存储所有已经搜索完成的节点。
对于一个节点 u,如果它的所有相邻节点都已经搜索完成,那么在搜索回溯到 u 的时候,u 本身也会变成一个已经搜索完成的节点。这里的「相邻节点」指的是从 u出发通过一条有向边可以到达的所有节点。
假设我们当前搜索到了节点 u,如果它的所有相邻节点都已经搜索完成,那么这些节点都已经在栈中了,此时我们就可以把 u 入栈。可以发现,如果我们从栈顶往栈底的顺序看,由于 u 处于栈顶的位置,那么 u 出现在所有 u的相邻节点的前面。因此对于 u这个节点而言,它是满足拓扑排序的要求的。
这样以来,我们对图进行一遍深度优先搜索。当每个节点进行回溯的时候,我们把该节点放入栈中。最终从栈顶到栈底的序列就是一种拓扑排序。
算法
对于图中的任意一个节点,它在搜索的过程中有三种状态,即:
- 「未搜索」:我们还没有搜索到这个节点;
- 「搜索中」:我们搜索过这个节点,但还没有回溯到该节点,即该节点还没有入栈,还有相邻的节点没有搜索完成);
- 「已完成」:我们搜索过并且回溯过这个节点,即该节点已经入栈,并且所有该节点的相邻节点都出现在栈的更底部的位置,满足拓扑排序的要求。
通过上述的三种状态,我们就可以给出使用深度优先搜索得到拓扑排序的算法流程,在每一轮的搜索搜索开始时,我们任取一个「未搜索」的节点开始进行深度优先搜索。
我们将当前搜索的节点 u 标记为「搜索中」,遍历该节点的每一个相邻节点 v:
- 如果 v为「未搜索」,那么我们开始搜索 v,待搜索完成回溯到 u;
- 如果 v 为「搜索中」,那么我们就找到了图中的一个环,因此是不存在拓扑排序的;
- 如果 v 为「已完成」,那么说明 v已经在栈中了,而 u 还不在栈中,因此 u 无论何时入栈都不会影响到 (u,v) 之前的拓扑关系,以及不用进行任何操作。
- 当 u 的所有相邻节点都为「已完成」时,我们将 u 放入栈中,并将其标记为「已完成」。
在整个深度优先搜索的过程结束后,如果我们没有找到图中的环,那么栈中存储这所有的 n 个节点,从栈顶到栈底的顺序即为一种拓扑排序。
代码:
class Solution {
public:
vector<vector<int>>edges;
vector<int>visited;
bool valid=true;
//0表示未搜索 1
void dfs(int u){
visited[u]=1;
for(int v:edges[u]){
if(visited[v]==0)
{
dfs(v);
if(!valid)
return;
}
else if(visited[v]==1){
valid=false;
return;
}
}
visited[u]=2;
}
bool canFinish(int numCourses, vector<vector<int>>& prerequisites) {
edges.resize(numCourses);
visited.resize(numCourses);
for(auto &info:prerequisites){
edges[info[1]].push_back(info[0]);
}
for(int i=0;i<numCourses&&valid;i++){
if(!visited[i]){
dfs(i);
}
}
return valid;
}
};
时间复杂度: O(n+m),其中 n 为课程数,m 为先修课程的要求数。这其实就是对图进行深度优先搜索的时间复杂度。
空间复杂度: O(n+m)。题目中是以列表形式给出的先修课程关系,为了对图进行深度优先搜索,我们需要存储成邻接表的形式,空间复杂度为 O(n+m)。在深度优先搜索的过程中,我们需要最多 O(n) 的栈空间(递归)进行深度优先搜索,因此总空间复杂度为 O(n+m))。
解法二:广度优先搜索
方法一的深度优先搜索是一种「逆向思维」:最先被放入栈中的节点是在拓扑排序中最后面的节点。我们也可以使用正向思维,顺序地生成拓扑排序,这种方法也更加直观。
我们考虑拓扑排序中最前面的节点,该节点一定不会有任何入边,也就是它没有任何的先修课程要求。当我们将一个节点加入答案中后,我们就可以移除它的所有出边,代表着它的相邻节点少了一门先修课程的要求。如果某个相邻节点变成了「没有任何入边的节点」,那么就代表着这门课可以开始学习了。按照这样的流程,我们不断地将没有入边的节点加入答案,直到答案中包含所有的节点(得到了一种拓扑排序)或者不存在没有入边的节点(图中包含环)。
上面的想法类似于广度优先搜索,因此我们可以将广度优先搜索的流程与拓扑排序的求解联系起来。
算法
我们使用一个队列来进行广度优先搜索。初始时,所有入度为 0 的节点都被放入队列中,它们就是可以作为拓扑排序最前面的节点,并且它们之间的相对顺序是无关紧要的。
在广度优先搜索的每一步中,我们取出队首的节点 u:
- 我们将 u 放入答案中;
- 我们移除 u 的所有出边,也就是将 u 的所有相邻节点的入度减少 1。如果某个相邻节点 v 的入度变为 0,那么我们就将 v放入队列中。
- 在广度优先搜索的过程结束后。如果答案中包含了这 nnn 个节点,那么我们就找到了一种拓扑排序,否则说明图中存在环,也就不存在拓扑排序了。
class Solution {
private:
vector<vector<int>> edges;
vector<int> indeg;
public:
bool canFinish(int numCourses, vector<vector<int>>& prerequisites) {
edges.resize(numCourses);
indeg.resize(numCourses);
for (const auto& info: prerequisites) {
edges[info[1]].push_back(info[0]);
++indeg[info[0]];
}
queue<int> q;
for (int i = 0; i < numCourses; ++i) {
if (indeg[i] == 0) {
q.push(i);
}
}
int visited = 0;
while (!q.empty()) {
++visited;
int u = q.front();
q.pop();
for (int v: edges[u]) {
--indeg[v];
if (indeg[v] == 0) {
q.push(v);
}
}
}
return visited == numCourses;
}
};
94. 课程表||
解法一:广度优先遍历+拓扑排序
思路
方法一的深度优先搜索是一种「逆向思维」:最先被放入栈中的节点是在拓扑排序中最后面的节点。我们也可以使用正向思维,顺序地生成拓扑排序,这种方法也更加直观。
我们考虑拓扑排序中最前面的节点,该节点一定不会有任何入边,也就是它没有任何的先修课程要求。当我们将一个节点加入答案中后,我们就可以移除它的所有出边,代表着它的相邻节点少了一门先修课程的要求。如果某个相邻节点变成了「没有任何入边的节点」,那么就代表着这门课可以开始学习了。按照这样的流程,我们不断地将没有入边的节点加入答案,直到答案中包含所有的节点(得到了一种拓扑排序)或者不存在没有入边的节点(图中包含环)。
上面的想法类似于广度优先搜索,因此我们可以将广度优先搜索的流程与拓扑排序的求解联系起来。
算法
我们使用一个队列来进行广度优先搜索。开始时,所有入度为 0 的节点都被放入队列中,它们就是可以作为拓扑排序最前面的节点,并且它们之间的相对顺序是无关紧要的。
在广度优先搜索的每一步中,我们取出队首的节点 u:
我们将 u 放入答案中;
我们移除 u 的所有出边,也就是将 u 的所有相邻节点的入度减少 1。如果某个相邻节点 v 的入度变为 0,那么我们就将 v 放入队列中。
在广度优先搜索的过程结束后。如果答案中包含了这 n 个节点,那么我们就找到了一种拓扑排序,否则说明图中存在环,也就不存在拓扑排序了。
class Solution {
public:
vector<vector<int>>graph;
vector<int>indeg;
vector<int> findOrder(int numCourses, vector<vector<int>>& prerequisites) {
vector<int>result;
graph.resize(numCourses);
indeg.resize(numCourses);
for(auto &pr:prerequisites){
graph[pr[1]].push_back(pr[0]);
++indeg[pr[0]];
}
queue<int>que;
for(int i=0;i<numCourses;i++){
if(indeg[i]==0){
que.push(i);
}
}
while(!que.empty()){
int u=que.front();
que.pop();
result.push_back(u);
for(int v:graph[u]){
--indeg[v];
if(indeg[v]==0){
que.push(v);
}
}
}
if(result.size()!=numCourses)
return {};
return result;
}
};
时间复杂度: O(n+m),其中 n 为课程数,m 为先修课程的要求数。这其实就是对图进行广度优先搜索的时间复杂度。
空间复杂度: O(n+m)。题目中是以列表形式给出的先修课程关系,为了对图进行广度优先搜索,我们需要存储成邻接表的形式,空间复杂度为 O(n+m)。在广度优先搜索的过程中,我们需要最多 O(n) 的队列空间(迭代)进行广度优先搜索,并且还需要若干个 O(n) 的空间存储节点入度、最终答案等
图的广度优先遍历
95. 蛇梯棋
解法一:BFS
我们可以将棋盘抽象成一个包含
N
2
N^2
N2个节点的有向图,对于每个节点 x,若 x+i (1≤i≤6) 上没有蛇或梯子,则连一条从 x 到 x+i 的有向边;否则记蛇梯的目的地为 y,连一条从 x 到 y 的有向边。如此转换后,原问题等价于在这张有向图上求出从 1 到
N
2
N^2
N2的最短路长度。
对于该问题,我们可以使用广度优先搜索。将节点编号和到达该节点的移动次数作为搜索状态,顺着该节点的出边扩展新状态,直至到达终点
N
2
N^2
N2
,返回此时的移动次数。若无法到达终点则返回 −1。
代码实现时,我们可以用一个队列来存储搜索状态,初始时将起点状态 (1,0) 加入队列,表示当前位于起点 1,移动次数为 0。然后不断取出队首,每次取出队首元素时扩展新状态,即遍历该节点的出边,若出边对应节点未被访问,则将该节点和移动次数加一的结果作为新状态,加入队列。如此循环直至到达终点或队列为空。
此外,我们需要计算出编号在棋盘中的对应行列,以便从 board 中得到目的地。设编号为 id,由于每行有 n 个数字,其位于棋盘从下往上数的第 [ i d − 1 n ] [\frac{id-1}{n}] [nid−1] 行,记作 r。由于棋盘的每一行会交替方向,若 r 为偶数,则编号方向从左向右,列号为$ (id−1)\ mod\ n$;若 r 为奇数,则编号方向从右向左,列号为 n − 1 − ( ( i d − 1 ) m o d n ) n−1−((id−1)\ mod\ n) n−1−((id−1) mod n)。
class Solution {
public:
pair<int,int>id2rc(int id,int n){
int r=(id-1)/n,c=(id-1)%n;
if(r%2==1){
c=n-1-c;
}
//因为图中id=1原本应该在第一行,但是实际在最后一行
return{n-1-r,c};
}
int snakesAndLadders(vector<vector<int>>& board) {
int n=board.size();
vector<int>vis(n*n+1);
queue<pair<int,int>>que;
//记录起始点和移动次数
que.emplace(1,0);
while(!que.empty()){
auto p=que.front();
que.pop();
for(int i=1;i<=6;++i){
int nx_id=p.first+i;
if(nx_id>n*n){
break;
}
auto rc=id2rc(nx_id,n);
if(board[rc.first][rc.second]>0){
//存在蛇或者梯子,直接传递,不走回头路
nx_id=board[rc.first][rc.second];
}
if(nx_id==n*n){
return p.second+1;
}
if(!vis[nx_id]){
vis[nx_id]=true;
que.emplace(nx_id,p.second+1);
}
}
}
return -1;
}
};
时间复杂度:,其中 N 为棋盘 board 的边长。棋盘的每个格子至多入队一次,因此时间复杂度为
O
(
N
2
)
O(N^2)
O(N2)。
空间复杂度:
O
(
N
2
)
O(N^2)
O(N2) 。我们需要
O
(
N
2
)
O(N^2)
O(N2) 的空间来存储每个格子是否被访问过。
解法二:将二维转成一维再进行bfs
96. 最小基因变化
解法一:广度优先搜索
思路与算法
经过分析可知,题目要求将一个基因序列 A 变化至另一个基因序列 B,需要满足以下条件:
- 序列 A 与 序列 B 之间只有一个字符不同;
- 变化字符只能从 ‘A’, ‘C’, ‘G’, ‘T’ 中进行选择;
- 变换后的序列 B 一定要在字符串数组 bank 中。
- 根据以上变换规则,我们可以进行尝试所有合法的基因变化,并找到最小的变换次数即可。步骤如下:
如果 start 与 end 相等,此时直接返回 0;如果最终的基因序列不在 bank 中,则此时按照题意要求,无法生成,直接返回 −1;
首先我们将可能变换的基因 s 从队列中取出,按照上述的变换规则,尝试所有可能的变化后的基因,比如一个 AACCGGTA,我们依次尝试改变基因 s 的一个字符,并尝试所有可能的基因变化序列 s 0 , s 1 , s 2 , . . . , s i , . . , s 23 s_0,s_1,s_2,...,s_i,..,s_{23} s0,s1,s2,...,si,..,s23 ,变化一次最多可能会生成 3×8=24 种不同的基因序列。
我们需要检测当前生成的基因序列的合法性 s i s_i si ,首先利用哈希表检测 s i s_i si 是否在数组 bank 中,如果是则认为该基因合法,否则该变化非法直接丢弃;其次我们还需要用哈希表记录已经遍历过的基因序列,如果该基因序列已经遍历过,则此时直接跳过;如果合法且未遍历过的基因序列,则我们将其加入到队列中。
如果当前变换后的基因序列与 end 相等,则此时我们直接返回最小的变化次数即可;如果队列中所有的元素都已经遍历完成还无法变成 end,则此时无法实现目标变化,返回 −1。
class Solution {
public:
char keys[4]={'A','C','G','T'};
int minMutation(string startGene, string endGene, vector<string>& bank) {
if(startGene==endGene)
return 0;
unordered_set<string>wordSet(bank.begin(),bank.end());
unordered_map<string,int>visitMap;//<word,查询到这个 word的路径长度>
if (wordSet.find(endGene) == wordSet.end()) return -1;
queue<string>que;
que.push(startGene);
visitMap.insert(pair<string,int>(startGene,0));
while(!que.empty()){
string s=que.front();
que.pop();
int word_cnt=visitMap[s];
for(int i=0;i<s.size();i++){
for(int j=0;j<4;j++){
string tmp=s;
if(keys[j]==s[i])
continue;
tmp[i]=keys[j];
if(tmp==endGene)
return word_cnt+1;
if(wordSet.count(tmp)==1&& visitMap.count(tmp)==0){
visitMap.insert(pair<string, int>(tmp, word_cnt + 1));
que.push(tmp);
}
}
}
}
return -1;
}
};
时间复杂度:
O
(
m
×
n
2
)
O(m×n^2)
O(m×n2)
空间复杂度:
O
(
n
2
)
O(n^2)
O(n2)
97. 单词接龙
解法一:BFS
按照最小基因变化的相似方法,进行bfs操作,下个遍历的单词需要在wordlist里,其实对于单词长度n的所有单词,有n*26中不同的单词,然后查看是否在wordlist中,然后遍历。
class Solution {
public:
int ladderLength(string beginWord, string endWord, vector<string>& wordList) {
int ret=0;
queue<pair<string,int>>que;
unordered_map<string,int>hash;
for(auto str:wordList){
hash[str]=1;//初始化列表
}
if(beginWord==endWord)
{
if(!hash.count(beginWord)){
return 0;
}
return 1;
}
que.push(make_pair(beginWord,1));
hash[beginWord]=2;
while(!que.empty()){
auto item=que.front();
que.pop();
string start=item.first;
int count=item.second;
int n=start.size();
for(int i=0;i<n;i++)
{
for(int j=0;j<26;j++){
string next=start;
if(next[i]!='a'+j){
next[i]='a'+j;
}
if(hash.count(next)&&next==endWord){
return count+1;
}
if(hash.count(next)&&hash[next]==1){
//在列表中但是并没有访问过
que.push(make_pair(next,count+1));
hash[next]=2;
}
}
}
}
return 0;
}
};
解法二:虚拟节点+BFS
以及解法二优化:虚拟节点+BFS+双向
字典树
98. 实现Trie(前缀树)
解法:前缀树结构模拟
字典树
Trie,又称前缀树或字典树,是一棵有根树,其每个节点包含以下字段:
指向子节点的指针数组 children。对于本题而言,数组长度为 262626,即小写英文字母的数量。此时 children[0]对应小写字母 a,children[1] 对应小写字母 b,…,children[25] 对应小写字母 z。
布尔字段isEnd,表示该节点是否为字符串的结尾。
插入字符串
我们从字典树的根开始,插入字符串。对于当前字符对应的子节点,有两种情况:
- 子节点存在。沿着指针移动到子节点,继续处理下一个字符。
- 子节点不存在。创建一个新的子节点,记录在 children 数组的对应位置上,然后沿着指针移动到子节点,继续搜索下一个字符。
- 重复以上步骤,直到处理字符串的最后一个字符,然后将当前节点标记为字符串的结尾。
查找前缀
我们从字典树的根开始,查找前缀。对于当前字符对应的子节点,有两种情况:
- 子节点存在。沿着指针移动到子节点,继续搜索下一个字符。
- 子节点不存在。说明字典树中不包含该前缀,返回空指针。
- 重复以上步骤,直到返回空指针或搜索完前缀的最后一个字符。
- 若搜索到了前缀的末尾,就说明字典树中存在该前缀。此外,若前缀末尾对应节点的 isEnd 为真,则说明字典树中存在该字符串。
代码:
class Trie {
public:
vector<Trie*>children;
bool isEnd;
Trie() :children(26),isEnd(false){ }
void insert(string word) {
Trie*node=this;
for(char ch:word){
ch-='a';
if(node->children[ch]==nullptr){
node->children[ch]=new Trie();
}
node=node->children[ch];
}
node->isEnd=true;
}
Trie* searchPrefix(string word){
Trie*node=this;
for(char ch:word){
ch-='a';
if(node->children[ch]==nullptr){
return nullptr;
}
node=node->children[ch];
}
return node;
}
bool search(string word) {
Trie*node=this->searchPrefix(word);
return node!=nullptr&&node->isEnd;
}
bool startsWith(string prefix) {
return searchPrefix(prefix)!=nullptr;
}
};
/**
* Your Trie object will be instantiated and called as such:
* Trie* obj = new Trie();
* obj->insert(word);
* bool param_2 = obj->search(word);
* bool param_3 = obj->startsWith(prefix);
*/
时间复杂度:初始化为 O(1),其余操作为 O(∣S∣),其中 ∣S∣是每次插入或查询的字符串的长度。
空间复杂度:
O
(
∣
T
∣
⋅
Σ
)
O(|T|\cdot\Sigma)
O(∣T∣⋅Σ),其中 ∣T∣ 为所有插入字符串的长度之和,Σ 为字符集的大小,本题 Σ=26。
99. 添加与搜索单词-数据结构设计
解法:字典树
预备知识
字典树(前缀树)是一种树形数据结构,用于高效地存储和检索字符串数据集中的键。前缀树可以用 O(∣S∣) 的时间复杂度完成如下操作,其中 ∣S∣ 是插入字符串或查询前缀的长度:
- 向字典树中插入字符串 word;
- 查询字符串 word 是否已经插入到字典树中。
- 字典树的实现可以参考「208. 实现 Trie (前缀树) 的官方题解」。
思路和算法
根据题意,WordDictionary 类需要支持添加单词和搜索单词的操作,可以使用字典树实现。
对于添加单词,将单词添加到字典树中即可。
对于搜索单词,从字典树的根结点开始搜索。由于待搜索的单词可能包含点号,因此在搜索过程中需要考虑点号的处理。对于当前字符是字母和点号的情况,分别按照如下方式处理:
- 如果当前字符是字母,则判断当前字符对应的子结点是否存在,如果子结点存在则移动到子结点,继续搜索下一个字符,如果子结点不存在则说明单词不存在,返回 false;
- 如果当前字符是点号,由于点号可以表示任何字母,因此需要对当前结点的所有非空子结点继续搜索下一个字符。
- 重复上述步骤,直到返回 false 或搜索完给定单词的最后一个字符。
- 如果搜索完给定的单词的最后一个字符,则当搜索到的最后一个结点的 isEnd 为 true 时,给定的单词存在。
特别地,当搜索到点号时,只要存在一个非空子结点可以搜索到给定的单词,即返回 true。
struct TrieNode{
vector<TrieNode*>child;
bool isEnd;
TrieNode(){
this->child=vector<TrieNode*>(26,nullptr);
this->isEnd=false;
}
void insert(const string&word){
TrieNode*node=this;
for(auto c:word){
if(node->child[c-'a']==nullptr){
node->child[c-'a']=new TrieNode();
}
node=node->child[c-'a'];
}
node->isEnd=true;
}
};
class WordDictionary {
public:
WordDictionary() {
trie=new TrieNode();
}
void addWord(string word) {
trie->insert(word);
}
bool search(string word) {
return dfs(word,0,trie);
}
private:
bool dfs(const string &word,int index,TrieNode*node){
if(index==word.size()){
return node->isEnd;
}
char c=word[index];
if(c>='a'&&c<='z'){
TrieNode* child=node->child[c-'a'];
if(child!=nullptr&&dfs(word,index+1,child)){
return true;
}
}
else if(c=='.'){
for(int i=0;i<26;i++){
TrieNode*child=node->child[i];
if(child!=nullptr&&dfs(word,index+1,child)){
return true;
}
}
}
return false;
}
TrieNode*trie;
};
/**
* Your WordDictionary object will be instantiated and called as such:
* WordDictionary* obj = new WordDictionary();
* obj->addWord(word);
* bool param_2 = obj->search(word);
*/
时间复杂度:初始化为 O(1),添加单词为 O(∣S∣),搜索单词为
O
(
∣
Σ
∣
∣
S
∣
)
O(∣Σ∣^{∣S∣})
O(∣Σ∣∣S∣),其中 ∣S∣ 是每次添加或搜索的单词的长度,Σ 是字符集,这道题中的字符集为全部小写英语字母,∣Σ∣=26。
最坏情况下,待搜索的单词中的每个字符都是点号,则每个字符都有 ∣Σ∣ 种可能。
空间复杂度:O(∣T∣⋅∣Σ∣),其中 ∣T∣ 是所有添加的单词的长度之和,Σ 是字符集,这道题中的字符集为全部小写英语字母,∣Σ∣=26。
100. 单词搜索||
解法:回溯 + 字典树
预备知识
前缀树(字典树)是一种树形数据结构,用于高效地存储和检索字符串数据集中的键。前缀树可以用 O(∣S∣) 的时间复杂度完成如下操作,其中 ∣S∣ 是插入字符串或查询前缀的长度:
- 向前缀树中插入字符串 word;
- 查询前缀串 prefix 是否为已经插入到前缀树中的任意一个字符串 word 的前缀;
- 前缀树的实现可以参考「208. 实现 Trie (前缀树) 的官方题解」。
思路和算法
根据题意,我们需要逐个遍历二维网格中的每一个单元格;然后搜索从该单元格出发的所有路径,找到其中对应 words 中的单词的路径。因为这是一个回溯的过程,所以我们有如下算法:
- 遍历二维网格中的所有单元格。
- 深度优先搜索所有从当前正在遍历的单元格出发的、由相邻且不重复的单元格组成的路径。因为题目要求同一个单元格内的字母在一个单词中不能被重复使用;所以我们在深度优先搜索的过程中,每经过一个单元格,都将该单元格的字母临时修改为特殊字符(例如 #),以避免再次经过该单元格。
- 如果当前路径是 words 中的单词,则将其添加到结果集中。如果当前路径是 words 中任意一个单词的前缀,则继续搜索;反之,如果当前路径不是 words 中任意一个单词的前缀,则剪枝。我们可以将 words 中的所有字符串先添加到前缀树中,而后用 O(∣S∣) 的时间复杂度查询当前路径是否为 words 中任意一个单词的前缀。
在具体实现中,我们需要注意如下情况:
因为同一个单词可能在多个不同的路径中出现,所以我们需要使用哈希集合对结果集去重。
在回溯的过程中,我们不需要每一步都判断完整的当前路径是否是 words 中任意一个单词的前缀;而是可以记录下路径中每个单元格所对应的前缀树结点,每次只需要判断新增单元格的字母是否是上一个单元格对应前缀树结点的子结点即可。
struct TrieNode{
string word;//类似isEnd的作用
unordered_map<char,TrieNode*>children;
TrieNode(){
this->word="";
}
void insert(const string&word){
TrieNode*node=this;
for(char c:word){
if(!node->children.count(c)){
node->children[c]=new TrieNode();
}
node=node->children[c];
}
node->word=word;
}
};
class Solution {
public:
int direct[4][2]={{1,0},{-1,0},{0,1},{0,-1}};
bool dfs(vector<vector<char>>& board,int x,int y,TrieNode*root,set<string>&res){
char c=board[x][y];
if(!root->children.count(c)){
return false;
}
root=root->children[c];
if(root->word.size()>0){
res.insert(root->word);
}
board[x][y]='#';
for(int i=0;i<4;i++){
int nx=x+direct[i][0];
int ny=y+direct[i][1];
if((nx>=0&&nx<board.size())&&(ny>=0&&ny<board[0].size())){
if(board[nx][ny]!='#'){
dfs(board,nx,ny,root,res);
}
}
}
board[x][y]=c;
return true;
}
vector<string> findWords(vector<vector<char>>& board, vector<string>& words) {
TrieNode*root=new TrieNode();
set<string>res;
vector<string>ans;
for(auto &word:words){
root->insert(word);
}
for(int i=0;i<board.size();i++){
for(int j=0;j<board[0].size();j++){
dfs(board,i,j,root,res);
}
}
for(auto &word:res){
ans.emplace_back(word);
}
return ans;
}
};
时间复杂度:
O
(
m
×
n
×
3
l
−
1
)
O(m×n×3^{l−1})
O(m×n×3l−1),其中 m 是二维网格的高度,n 是二维网格的宽度,l 是最长单词的长度。我们需要遍历 m×n 个单元格,每个单元格最多需要遍历
4
×
3
l
−
1
4×3^{l−1}
4×3l−1条路径。
空间复杂度:
O
(
k
×
l
)
O(k×l)
O(k×l),其中 k 是 words 的长度,l 是最长单词的长度。最坏情况下,我们需要
O
(
k
×
l
)
O(k×l)
O(k×l) 用于存储前缀树
回溯
101. 电话号码的字母组合
解法:dfs回溯
首先使用哈希表存储每个数字对应的所有可能的字母,然后进行回溯操作。
回溯过程中维护一个字符串,表示已有的字母排列(如果未遍历完电话号码的所有数字,则已有的字母排列是不完整的)。该字符串初始为空。每次取电话号码的一位数字,从哈希表中获得该数字对应的所有可能的字母,并将其中的一个字母插入到已有的字母排列后面,然后继续处理电话号码的后一位数字,直到处理完电话号码中的所有数字,即得到一个完整的字母排列。然后进行回退操作,遍历其余的字母排列。
回溯算法用于寻找所有的可行解,如果发现一个解不可行,则会舍弃不可行的解。回溯的过程其实就是DFS后依照条件退出递归的过程,很明显,上述退出递归的过程是取到电话的最后一个数字。
在这道题中,由于每个数字对应的每个字母都可能进入字母组合,因此不存在不可行的解,直接穷举所有的解即可。
代码:
class Solution {
public:
vector<string>numStr;
int dight_depth;
void dfs(vector<string>&result,int depth,string &digits,string res){
if(depth==dight_depth){
result.push_back(res);
return;
}
depth++;
string tmp=numStr[digits[depth-1]-'0'];
for(int i=0;i<tmp.size();i++){
string next_res=res+tmp[i];
//注意c++ string具有pop和push的功能,也可以不新增next_res对象
/**
res.push_back(tmp[i]);
dfs(result,depth,digits,next_res);
res.pop_back();
**/
dfs(result,depth,digits,next_res);
}
}
vector<string> letterCombinations(string digits) {
if(digits.empty())
{
return vector<string>{};
}
numStr.resize(10);
numStr[2]="abc";
numStr[3]="def";
numStr[4]="ghi";
numStr[5]="jkl";
numStr[6]="mno";
numStr[7]="pqrs";
numStr[8]="tuv";
numStr[9]="wxyz";
vector<string>result;
string res="";
dight_depth=digits.size();
string tmp=numStr[digits[0]-'0'];
for(int i=0;i<tmp.size();i++){
res=tmp[i];
dfs(result,1,digits,res);
}
return result;
}
};
时间复杂度: O ( 3 m × 4 n ) O(3^m \times 4^n) O(3m×4n),其中 m 是输入中对应 3 个字母的数字个数(包括数字 2、3、4、5、6、8),n 是输入中对应 4 个字母的数字个数(包括数字 7、9),m+n是输入数字的总个数。当输入包含 m 个对应 3 个字母的数字和 n 个对应 44个字母的数字时,不同的字母组合一共有 3 m × 4 n 3^m \times 4^n 3m×4n 种,需要遍历每一种字母组合。
空间复杂度:O(m+n),其中 m 是输入中对应 3 个字母的数字个数,nnn 是输入中对应 4 个字母的数字个数,m+n是输入数字的总个数。除了返回值以外,空间复杂度主要取决于哈希表以及回溯过程中的递归调用层数,哈希表的大小与输入无关,可以看成常数,递归调用层数最大为 m+n。
102. 组合
解法一:递归
从 n 个当中选 k 个的所有方案对应的枚举是组合型枚举。在「方法一」中我们用递归来实现组合型枚举。
首先我们先回忆一下如何用递归实现二进制枚举(子集枚举),假设我们需要找到一个长度为 n 的序列 a 的所有子序列,代码框架是这样的:
vector<int> temp;
void dfs(int cur, int n) {
if (cur == n + 1) {
// 记录答案
// ...
return;
}
// 考虑选择当前位置
temp.push_back(cur);
dfs(cur + 1, n, k);
temp.pop_back();
// 考虑不选择当前位置
dfs(cur + 1, n, k);
}
上面的代码中,dfs(cur,n) 参数表示当前位置是 cur,原序列总长度为 n。原序列的每个位置在答案序列种的状态有被选中和不被选中两种,我们用 temp 数组存放已经被选出的数字。在进入 dfs(cur,n) 之前 [1,cur−1] 位置的状态是确定的,而 [cur,n] 内位置的状态是不确定的,dfs(cur,n) 需要确定 cur 位置的状态,然后求解子问题 dfs(cur+1,n)。对于 cur 位置,我们需要考虑 a[cur] 取或者不取,如果取,我们需要把 a[cur] 放入一个临时的答案数组中(即上面代码中的 temp),再执行 dfs(cur+1,n),执行结束后需要对 temp 进行回溯;如果不取,则直接执行 dfs(cur+1,n)。在整个递归调用的过程中,cur 是从小到大递增的,当 cur 增加到 n+1 的时候,记录答案并终止递归。可以看出二进制枚举的时间复杂度是 O ( 2 n ) O(2^n) O(2n)。
vector<int> temp;
void dfs(int cur, int n) {
// 记录合法的答案
if (temp.size() == k) {
ans.push_back(temp);
return;
}
// cur == n + 1 的时候结束递归
if (cur == n + 1) {
return;
}
// 考虑选择当前位置
temp.push_back(cur);
dfs(cur + 1, n, k);
temp.pop_back();
// 考虑不选择当前位置
dfs(cur + 1, n, k);
}
这个时候我们可以做一个剪枝,如果当前 temp 的大小为 s,未确定状态的区间 [cur,n] 的长度为 t,如果 s+t<k,那么即使 t 个都被选中,也不可能构造出一个长度为 k 的序列,故这种情况就没有必要继续向下递归,即我们可以在每次递归开始的时候做一次这样的判断:
if (temp.size() + (n - cur + 1) < k) {
return;
}
至此,其实我们已经得到了一个时间复杂度为 O ( ( k n ) ) O((^n _k )) O((kn)) 的组合枚举,由于每次记录答案的复杂度为 O(k),故这里的时间复杂度为 O ( ( k n ) ) O((^n _k )) O((kn)) ,但是我们还可以进一步优化代码。在上面这份代码中有三个 if 判断,其实第三处的 if 是可以被删除的。因为:
首先,cur=n+1 的时候,一定不可能出现 s>k(s 是前文中定义的 temp 的大小),因为自始至终 s 绝不可能大于 k,它等于 k 的时候就会被第二处 if 记录答案并返回;
如果 cur=n+1 的时候 s=k,它也会被第二处 if 记录答案并返回;
如果 cur=n+1 的时候 s<k,一定会在 cur<n+1 的某个位置的时候发现 s+t<k,它也会被第一处 if 剪枝。
因此,第三处 if 可以删除。最终我们得到了如下的代码。
代码:
class Solution {
public:
void dfs(int cur,int k,int n,vector<vector<int>>&res,vector<int>&cur_res){
if(cur_res.size()+(n-cur+1)<k){
return;
}
if(cur_res.size()==k){
res.push_back(cur_res);
return;
}
//考虑当前位置
cur_res.push_back(cur);
dfs(cur+1,k,n,res,cur_res);
cur_res.pop_back();
//不考虑当前位置
dfs(cur+1,k,n,res,cur_res);
}
vector<vector<int>> combine(int n, int k) {
vector<vector<int>>res;
vector<int>cur_res;
dfs(1,k,n,res,cur_res);
return res;
}
};
时间复杂度:
O
(
(
k
n
)
×
k
)
O((^n_k)×k)
O((kn)×k),分析见「思路」部分。
空间复杂度:O(n+k)=O(n),即递归使用栈空间的空间代价和临时数组 cur_res 的空间代价。
解法二:非递归(字典序法)实现组合型枚举
103. 全排列
解法一:DFS递归,使用visited数组记录该元素是否被访问
class Solution {
public:
vector<vector<int>>res;
void dfs(vector<int>cur_res,vector<int>visited,vector<int>& nums,int i){
cur_res.emplace_back(nums[i]);
visited[i]=true;\
if(cur_res.size()==visited.size()){
res.push_back(cur_res);
return;
}
for(int i=0;i<nums.size();i++){
if(!visited[i]){
dfs(cur_res,visited,nums,i);
}
}
}
vector<vector<int>> permute(vector<int>& nums) {
vector<int>visited(nums.size(),false);
for(int i=0;i<nums.size();i++)
{
vector<int>cur_res;
dfs(cur_res,visited,nums,i);
}
return res;
}
};
解法二:DFS的递归优化方法,不使用visited数组,使用标记的方法
104. 组合总和
解法:DFS搜索+回溯
思路与算法
对于这类寻找所有可行解的题,我们都可以尝试用「搜索回溯」的方法来解决。
回到本题,我们定义递归函数 dfs(target,cur_res,idx), 表示当前在 candidates数组的第 idx 位,还剩 target 要组合,已经组合的列表为 cur_res。递归的终止条件为 target≤0 或者 candidates 数组被全部用完。那么在当前的函数中,每次我们可以选择跳过不用第idx 个数,即执行 dfs(target,combine,idx+1)。也可以选择使用第 idx 个数,即执行 dfs(target−candidates[idx],cur_res,idx),注意到每个数字可以被无限制重复选取,因此搜索的下标仍为 idx。
更形象化地说,如果我们将整个搜索过程用一个树来表达,即如下图呈现,每次的搜索都会延伸出两个分叉,直到递归的终止条件,这样我们就能不重复且不遗漏地找到所有可行解:
class Solution {
public:
vector<vector<int>>res;
void dfs(int target,vector<int>& candidates,vector<int>&cur_res,int idx){
if(idx==candidates.size())
{
return;
}
if(target==0)
{
res.push_back(cur_res);
return;
}
//直接跳过
dfs(target,candidates,cur_res,idx+1);
if(target-candidates[idx]>=0){
cur_res.push_back(candidates[idx]);
dfs(target-candidates[idx],candidates,cur_res,idx);
cur_res.pop_back();
}
}
vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
vector<int>cur_res;
dfs(target,candidates,cur_res,0);
return res;
}
};
时间复杂度:O(S),其中 S 为所有可行解的长度之和。从分析给出的搜索树我们可以看出时间复杂度取决于搜索树所有叶子节点的深度之和,即所有可行解的长度之和。在这题中,我们很难给出一个比较紧的上界,我们知道
O
(
n
×
2
n
)
O(n \times 2^n)
O(n×2n) 是一个比较松的上界,即在这份代码中,n 个位置每次考虑选或者不选,如果符合条件,就加入答案的时间代价。但是实际运行的时候,因为不可能所有的解都满足条件,递归的时候我们还会用
target
−
candidates
[
idx
]
≥
0
\textit{target} - \textit{candidates}[\textit{idx}] \ge 0
target−candidates[idx]≥0 进行剪枝,所以实际运行情况是远远小于这个上界的。
空间复杂度:
O
(
target
)
O(\textit{target})
O(target)。除答案数组外,空间复杂度取决于递归的栈深度,在最差情况下需要递归
O
(
target
)
O(\textit{target})
O(target)层。
105. N皇后||
解法一:回溯计算个数,与「51. N 皇后」做法相同,得到所有可能的解
解法二:位运算
视频题解:leetcode52 位运算解决N皇后问题_哔哩哔哩_bilibili
官方题解:即在递归的过程中,通过位运算计算下一层可能放置的位置
代码:
class Solution {
public:
int ans=0;
int totalNQueens(int n) {
dfs(n,0,0,0,0);
return ans;
}
void dfs(int n,int row,int columns,int diagonals1,int diagonals2){
if(row==n)
{
ans++;
return;
}
else{
//计算还可以放置的位置
int availblePostions=((1<<n)-1)&(~(columns|diagonals1|diagonals2));
while(availblePostions!=0){
int position=availblePostions&(-availblePostions);
availblePostions=availblePostions&(availblePostions-1);
dfs(n,row+1,columns|position,(diagonals1|position)<<1,(diagonals2|position)>>1);
}
}
}
};
时间复杂度:O(N!),其中 N 是皇后数量。
空间复杂度:O(N),其中 N 是皇后数量。由于使用位运算表示,因此存储皇后信息的空间复杂度是 O(1),空间复杂度主要取决于递归调用层数,递归调用层数不会超过 N。
106. 括号生成
解法一:DFS深搜+剪枝
这一类问题是在一棵隐式的树上求解,可以用深度优先遍历,也可以用广度优先遍历。
一般用深度优先遍历。原因是:代码好写,使用递归的方法,直接借助系统栈完成状态的转移;广度优先遍历得自己编写结点类和借助队列。
这里的「状态」是指程序执行到 隐式树 的某个结点的语言描述,在程序中用不同的 变量 加以区分。
以n=2为例,画出树形结构图,方法是记录左括号和右括号剩余的次数
可以得出结论:
- 当前左右括号都有大于 0 个可以使用的时候,才产生分支;
- 产生左分支的时候,只看当前是否还有左括号可以使用;
- 产生右分支的时候,还受到左分支的限制,右边剩余可以使用的括号数量一定得在严格大于左边剩余的数量的时候,才可以产生分支;
- 在左边和右边剩余的括号数都等于 0 的时候结算。
class Solution {
public:
void dfs(string curStr,int left,int right,vector<string>&res){
if(left==0&&right==0){
res.push_back(curStr);
return;
}
if(left>right)
{
return;
}
if(left>0){
dfs(curStr+"(",left-1,right,res);
}
if(right>0){
dfs(curStr+")",left,right-1,res);
}
}
vector<string> generateParenthesis(int n) {
vector<string>res;
dfs("",n,n,res);
return res;
}
};
我们的复杂度分析依赖于理解
generateParenthesis
(
n
)
\textit{generateParenthesis}(n)
generateParenthesis(n)中有多少个元素。这个分析超出了本文的范畴,但事实证明这是第 n 个卡特兰数 1n+1(2nn)
1
n
+
1
(
2
n
n
)
\frac{1}{n+1}\dbinom{2n}{n}
n+11(n2n) ,这是由
4
n
n
n
\dfrac{4^n}{n\sqrt{n}}
nn4n 渐近界定的。
时间复杂度:
O
(
4
n
n
)
O(\dfrac{4^n}{\sqrt{n}})
O(n4n),在回溯过程中,每个答案需要 O(n) 的时间复制到答案数组中。
空间复杂度:O(n),除了答案数组之外,我们所需要的空间取决于递归栈的深度,每一层递归函数需要 O(1)的空间,最多递归 2n 层,因此空间复杂度为 O(n)。
107. 单词搜索
建立四叉树
解法:递归
思路与算法
我们可以使用递归的方法构建出四叉树。
具体地,我们用递归函数
d
f
s
(
r
0
,
c
0
,
r
1
,
c
1
)
dfs(r_0,c_0,r_1,c_1)
dfs(r0,c0,r1,c1)处理给定的矩阵 grid 从
r
0
r_0
r0行开始到 $r_1 −1 $行,从
c
0
c_0
c0和
c
1
−
1
c_1-1
c1−1列的部分。我们首先判定这一部分是否均为 0 或 1,如果是,那么这一部分对应的是一个叶节点,我们构造出对应的叶节点并结束递归;如果不是,那么这一部分对应的是一个非叶节点,我们需要将其分成四个部分:行的分界线为
(
r
0
+
r
1
)
/
2
(r_0+r_1)/2
(r0+r1)/2,列的分界线为
(
c
0
+
c
1
)
/
2
(c_0+c_1)/2
(c0+c1)/2,根据这两条分界线递归地调用 dfs 函数得到四个部分对应的树,再将它们对应地挂在非叶节点的四个子节点上。
/*
// Definition for a QuadTree node.
class Node {
public:
bool val;
bool isLeaf;
Node* topLeft;
Node* topRight;
Node* bottomLeft;
Node* bottomRight;
Node() {
val = false;
isLeaf = false;
topLeft = NULL;
topRight = NULL;
bottomLeft = NULL;
bottomRight = NULL;
}
Node(bool _val, bool _isLeaf) {
val = _val;
isLeaf = _isLeaf;
topLeft = NULL;
topRight = NULL;
bottomLeft = NULL;
bottomRight = NULL;
}
Node(bool _val, bool _isLeaf, Node* _topLeft, Node* _topRight, Node* _bottomLeft, Node* _bottomRight) {
val = _val;
isLeaf = _isLeaf;
topLeft = _topLeft;
topRight = _topRight;
bottomLeft = _bottomLeft;
bottomRight = _bottomRight;
}
};
*/
class Solution {
public:
Node* construct(vector<vector<int>>& grid) {
return dfs(0,0,grid.size()-1,grid[0].size()-1,grid);
}
Node* dfs(int a,int b,int c,int d,vector<vector<int>>&grid){
bool ok=true;
int t=grid[a][b];
for(int i=a;i<=c&&ok;i++)
{
for(int j=b;j<=d&&ok;j++){
if(t!=grid[i][j])
ok=false;
}
}
if(ok)
return new Node(t==1,true);
Node*root=new Node(t==1,false);
int dx=c-a+1,dy=d-b+1;
root->topLeft=dfs(a,b,a+dx/2-1,b+dy/2-1,grid);
root->topRight=dfs(a,b+dy/2,a+dx/2-1,d,grid);
root->bottomLeft=dfs(a+dx/2,b,c,b+dy/2-1,grid);
root->bottomRight=dfs(a+dx/2,b+dy/2,c,d,grid);
return root;
}
};
时间复杂度: O ( n 2 l o g n ) O(n^2logn) O(n2logn)。这里给出一个较为宽松的时间复杂度上界。记 T(n) 为边长为 n 的数组需要的时间复杂度,那么「判定这一部分是否均为 0 或 1」需要的时间为 O ( n 2 ) O(n^2) O(n2),在这之后会递归调用 4 规模为 n/2 的子问题,那么有:
T
(
n
)
=
4
T
(
n
/
2
)
+
O
(
n
2
)
T(n)=4T(n/2)+O(n^2)
T(n)=4T(n/2)+O(n2)
以及:
T
(
1
)
=
O
(
1
)
T(1)=O(1)
T(1)=O(1)
根据主定理,可以得到
T
(
n
)
=
O
(
n
2
l
o
g
n
)
T(n)=O(n^2logn)
T(n)=O(n2logn)。但如果判定需要的时间达到了渐近紧界
Θ
(
n
2
)
Θ(n^2)
Θ(n2),那么说明这一部分包含的元素大部分都是相同的,也就是说,有很大概率在深入递归时遇到元素完全相同的一部分,从而提前结束递归。因此
O
(
n
2
l
o
g
n
)
O(n^2logn)
O(n2logn) 的时间复杂度是很宽松的,实际运行过程中可以跑出与方法二
O
(
n
2
)
O(n2)
O(n2) 时间复杂度代码相似的速度。
空间复杂度:O(logn),即为递归需要使用的栈空间。
解法二:递归 + 二维前缀和优化
分治
108. 将有序数组转换为二叉搜索树
二叉搜索树的中序遍历是升序序列,题目给定的数组是按照升序排序的有序数组,因此可以确保数组是二叉搜索树的中序遍历序列。
给定二叉搜索树的中序遍历,是否可以唯一地确定二叉搜索树?答案是否定的。如果没有要求二叉搜索树的高度平衡,则任何一个数字都可以作为二叉搜索树的根节点,因此可能的二叉搜索树有多个。
如果增加一个限制条件,即要求二叉搜索树的高度平衡,是否可以唯一地确定二叉搜索树?答案仍然是否定的。
直观地看,我们可以选择中间数字作为二叉搜索树的根节点,这样分给左右子树的数字个数相同或只相差 1,可以使得树保持平衡。如果数组长度是奇数,则根节点的选择是唯一的,如果数组长度是偶数,则可以选择中间位置左边的数字作为根节点或者选择中间位置右边的数字作为根节点,选择不同的数字作为根节点则创建的平衡二叉搜索树也是不同的。
确定平衡二叉搜索树的根节点之后,其余的数字分别位于平衡二叉搜索树的左子树和右子树中,左子树和右子树分别也是平衡二叉搜索树,因此可以通过递归的方式创建平衡二叉搜索树。
当然,这只是我们直观的想法,为什么这么建树一定能保证是「平衡」的呢?这里可以参考「1382. 将二叉搜索树变平衡」,这两道题的构造方法完全相同,这种方法是正确的,1382 题解中给出了这个方法的正确性证明:1382 官方题解,感兴趣的同学可以戳进去参考。
递归的基准情形是平衡二叉搜索树不包含任何数字,此时平衡二叉搜索树为空。
在给定中序遍历序列数组的情况下,每一个子树中的数字在数组中一定是连续的,因此可以通过数组下标范围确定子树包含的数字,下标范围记为 [left,right]。对于整个中序遍历序列,下标范围从 left=0 到 right=nums.length−1。当 left>right 时,平衡二叉搜索树为空。
以下三种方法中,方法一总是选择中间位置左边的数字作为根节点,方法二总是选择中间位置右边的数字作为根节点,方法三是方法一和方法二的结合,选择任意一个中间位置数字作为根节点。
解法一:中序遍历,总是选择中间位置左边的数字作为根节点
选择中间位置左边的数字作为根节点,则根节点的下标为 mid=(left+right)/2,此处的除法为整数除法。
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode() : val(0), left(nullptr), right(nullptr) {}
* TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
* TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
* };
*/
class Solution {
public:
TreeNode* sortedArrayToBST(vector<int>& nums) {
return dfsTree(nums,0,nums.size()-1);
}
TreeNode* dfsTree(const vector<int>&nums,int left,int right){
if(left>right)
return nullptr;
//总是选择中间位置左边的节点作为根节点
int mid=(left+right)/2;
TreeNode* root=new TreeNode(nums[mid]);
root->left=dfsTree(nums,left,mid-1);
root->right=dfsTree(nums,mid+1,right);
return root;
}
};
时间复杂度:O(n),其中 n 是数组的长度。每个数字只访问一次。
空间复杂度:O(logn),其中 n 是数组的长度。空间复杂度不考虑返回值,因此空间复杂度主要取决于递归栈的深度,递归栈的深度是 O(logn)。
109. 排序链表
解法一:利用插入排序,[147. 对链表进行插入排序 ](###14. 147 对链表进行插入排序) 超出时间限制
解法二:归并排序+递归
题目要求时间空间复杂度分别为 O(nlogn) 和 O(1),根据时间复杂度我们自然想到二分法,从而联想到归并排序;
对数组做归并排序的空间复杂度为 O(n),分别由新开辟数组 O(n) 和递归函数调用 O(logn)组成,而根据链表特性:
数组额外空间:链表可以通过修改引用来更改节点顺序,无需像数组一样开辟额外空间;
递归额外空间:递归调用函数将带来 O(logn)的空间复杂度,因此若希望达到 O(1) 空间复杂度,则不能使用递归。
通过递归实现链表归并排序,有以下两个环节:
分割 cut 环节: 找到当前链表 中点,并从 中点 将链表断开(以便在下次递归 cut 时,链表片段拥有正确边界);
- 我们使用 fast,slow 快慢双指针法,奇数个节点找到中点,偶数个节点找到中心左边的节点。
- 找到中点 slow 后,执行 slow.next = None 将链表切断。
- 递归分割时,输入当前链表左端点 head 和中心节点 slow 的下一个节点 tmp(因为链表是从 slow 切断的)。
- cut 递归终止条件: 当 head.next == None 时,说明只有一个节点了,直接返回此节点。
合并 merge 环节: 将两个排序链表合并,转化为一个排序链表。
- 双指针法合并,建立辅助 ListNode* h 作为头部。
- 设置两指针 left, right 分别指向两链表头部,比较两指针处节点值大小,由小到大加入合并链表头部,指针交替前进,直至添加完两个链表。
- 返回辅助ListNode h 作为头部的下个节点 h.next。
- 时间复杂度 O(l + r),l, r 分别代表两个链表长度。
- 当题目输入的 head == None 时,直接返回 None。
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode() : val(0), next(nullptr) {}
* ListNode(int x) : val(x), next(nullptr) {}
* ListNode(int x, ListNode *next) : val(x), next(next) {}
* };
*/
class Solution {
public:
ListNode* sortList(ListNode* head) {
if(head==nullptr||head->next==nullptr){
return head;
}
ListNode* fast=head->next;
ListNode* slow=head;
while(fast!=nullptr&&fast->next!=nullptr){
slow=slow->next;
fast=fast->next->next;
}
ListNode*tmp=slow->next;
slow->next=nullptr;
ListNode* left=sortList(head);
ListNode* right=sortList(tmp);
ListNode* h=new ListNode(0);
ListNode* res=h;
while(left!=nullptr&&right!=nullptr){
if(left->val<right->val){
h->next=left;
left=left->next;
}
else{
h->next=right;
right=right->next;
}
h=h->next;
}
h->next=left!=nullptr?left:right;
return res->next;
}
};
时间复杂度:O(nlogn)
空间复杂度:O(1)
110. 建立四叉树
解法一:递归
我们可以使用递归的方法构建出四叉树。
具体地,我们用递归函数 d f s ( r 0 , c 0 , r 1 , c 1 ) dfs(r_0,c_0,r_1,c_1 ) dfs(r0,c0,r1,c1) 处理给定的矩阵 grid 从 r 0 r_0 r0行开始到 r 1 − 1 r_1-1 r1−1行,从 c 0 c_0 c0和列 c 1 − 1 c_1-1 c1−1的部分。我们首先判定这一部分是否均为 0 或 1,如果是,那么这一部分对应的是一个叶节点,我们构造出对应的叶节点并结束递归;如果不是,那么这一部分对应的是一个非叶节点,我们需要将其分成四个部分:行的分界线为$ \frac{r_0+r_1}{2} ,列的分界线为 ,列的分界线为 ,列的分界线为\frac{c_0+c_1}{2}$,根据这两条分界线递归地调用 dfs 函数得到四个部分对应的树,再将它们对应地挂在非叶节点的四个子节点上。
假定我们存在函数 Node dfs(int a, int b, int c, int d),其能够返回「以 (a,b) 为左上角,(c,d) 为右下角」所代表的矩阵的根节点。
那么最终答案为 dfs(0, 0, n-1, n-1),不失一般性考虑「以 (a,b) 为左上角,(c,d) 为右下角」时如何计算:
- 判断该矩阵是否为全 0 或全 1:
- 如果是则直接创建根节点(该节点四个子节点属性均为空)并进行返回;
- 如果不是则创建根节点,递归创建四个子节点并进行赋值,利用左上角 (a,b) 和右下角 (c,d) 可算的横纵坐标的长度为 c−a+1 和 d−b+1,从而计算出将当前矩阵四等分所得到的子矩阵的左上角和右下角坐标。
- 由于矩阵大小最多为 2 6 = 64 2^6=64 26=64 ,因此判断某个子矩阵是否为全 0 或全 1 的操作用「前缀和」或者是「暴力」来做都可以。
/*
// Definition for a QuadTree node.
class Node {
public:
bool val;
bool isLeaf;
Node* topLeft;
Node* topRight;
Node* bottomLeft;
Node* bottomRight;
Node() {
val = false;
isLeaf = false;
topLeft = NULL;
topRight = NULL;
bottomLeft = NULL;
bottomRight = NULL;
}
Node(bool _val, bool _isLeaf) {
val = _val;
isLeaf = _isLeaf;
topLeft = NULL;
topRight = NULL;
bottomLeft = NULL;
bottomRight = NULL;
}
Node(bool _val, bool _isLeaf, Node* _topLeft, Node* _topRight, Node* _bottomLeft, Node* _bottomRight) {
val = _val;
isLeaf = _isLeaf;
topLeft = _topLeft;
topRight = _topRight;
bottomLeft = _bottomLeft;
bottomRight = _bottomRight;
}
};
*/
class Solution {
public:
Node* construct(vector<vector<int>>& grid) {
return dfs(0,0,grid.size()-1,grid[0].size()-1,grid);
}
Node* dfs(int a,int b,int c,int d,vector<vector<int>>&grid){
bool ok=true;
int t=grid[a][b];
for(int i=a;i<=c&&ok;i++)
{
for(int j=b;j<=d&&ok;j++){
if(t!=grid[i][j])
ok=false;
}
}
if(ok)
return new Node(t==1,true);
Node*root=new Node(t==1,false);
int dx=c-a+1,dy=d-b+1;
root->topLeft=dfs(a,b,a+dx/2-1,b+dy/2-1,grid);
root->topRight=dfs(a,b+dy/2,a+dx/2-1,d,grid);
root->bottomLeft=dfs(a+dx/2,b,c,b+dy/2-1,grid);
root->bottomRight=dfs(a+dx/2,b+dy/2,c,d,grid);
return root;
}
};
时间复杂度: O ( n 2 l o g n ) O(n^2logn) O(n2logn)。这里给出一个较为宽松的时间复杂度上界。记 T(n) 为边长为 n 的数组需要的时间复杂度,那么「判定这一部分是否均为 0 或 1」需要的时间为 O ( n 2 ) O(n^2) O(n2),在这之后会递归调用 4 规模为 n/2 的子问题,那么有:
T
(
n
)
=
4
T
(
n
/
2
)
+
O
(
n
2
)
T(n)=4T(n/2)+O(n^2)
T(n)=4T(n/2)+O(n2)
以及:
T
(
1
)
=
O
(
1
)
T(1)=O(1)
T(1)=O(1)
根据主定理,可以得到
T
(
n
)
=
O
(
n
2
l
o
g
n
)
T(n)=O(n^2logn)
T(n)=O(n2logn)。但如果判定需要的时间达到了渐近紧界
Θ
(
n
2
)
Θ(n2)
Θ(n2),那么说明这一部分包含的元素大部分都是相同的,也就是说,有很大概率在深入递归时遇到元素完全相同的一部分,从而提前结束递归。因此
O
(
n
2
l
o
g
n
)
O(n^2logn)
O(n2logn) 的时间复杂度是很宽松的,实际运行过程中可以跑出
O
(
n
2
)
O(n^2)
O(n2) 时间复杂度代码相似的速度。
空间复杂度:O(logn),即为递归需要使用的栈空间。
111. 合并K 个升序链表
解法一:分治合并
思路
考虑优化方法一,用分治的方法进行合并。
将 k 个链表配对并将同一对中的链表合并;
第一轮合并以后, kkk 个链表被合并成了
k
2
\frac{k}{2}
2k 个链表,平均长度为
2
n
k
\frac{2n}{k}
k2n,然后是
k
4
\frac{k}{4}
4k 个链表,
k
8
\frac{k}{8}
8k 个链表等等;
重复这一过程,直到我们得到了最终的有序链表。
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode() : val(0), next(nullptr) {}
* ListNode(int x) : val(x), next(nullptr) {}
* ListNode(int x, ListNode *next) : val(x), next(next) {}
* };
*/
class Solution {
public:
ListNode* mergeTwoLists(ListNode* list1, ListNode* list2) {
ListNode * dump=new ListNode();
ListNode*first=list1;
ListNode*second=list2;
ListNode*cur=dump;
while(first!=nullptr&&second!=nullptr){
if(first->val<second->val){
ListNode*tmp=first;
first=first->next;
cur->next=tmp;
cur=cur->next;
}
else if(first->val>second->val){
ListNode*tmp=second;
second=second->next;
cur->next=tmp;
cur=cur->next;
}
else{
ListNode*tmp1=first;
first=first->next;
ListNode*tmp2=second;
second=second->next;
cur->next=tmp1;
cur=cur->next;
cur->next=tmp2;
cur=cur->next;
}
}
if(first==nullptr&&second!=nullptr){
cur->next=second;
}
else {
cur->next=first;
}
return dump->next;
}
ListNode*merge(vector<ListNode*>& lists,int l,int r){
if(l==r)
return lists[l];
if(l>r)
return nullptr;
int mid=(l+r)/2;
return mergeTwoLists(merge(lists,l,mid),merge(lists,mid+1,r));
}
ListNode* mergeKLists(vector<ListNode*>& lists) {
return merge(lists,0,lists.size()-1);
}
};
时间复杂度:考虑递归「向上回升」的过程——第一轮合并
k
2
\frac{k}{2}
2k 组链表,每一组的时间代价是 O(2n);第二轮合并
k
4
\frac{k}{4}
4k 组链表,每一组的时间代价是 O(4n)…所以总的时间代价是
O
(
∑
i
=
1
∞
k
2
i
×
2
i
n
)
=
O
(
k
n
×
log
k
)
O(\sum_{i = 1}^{\infty} \frac{k}{2^i} \times 2^i n) = O(kn \times \log k)
O(∑i=1∞2ik×2in)=O(kn×logk),故渐进时间复杂度为
O
(
k
n
×
log
k
)
O(kn \times \log k)
O(kn×logk)。
空间复杂度:递归会使用到
O
(
log
k
)
O(\log k)
O(logk) 空间代价的栈空间。
解法二:顺序合并
解法三:优先队列合并
见:23. 合并 K 个升序链表 - 力扣(LeetCode)
Kadane 算法
112. 最大子数组和
解法一:动态规划
步骤
- 定义dp[i]表示数组中前i+1(注意这里的i是从0开始的)个元素构成的连续子数组的最大和。
- 如果要计算前i+1个元素构成的连续子数组的最大和,也就是计算dp[i],只需要判断dp[i-1]+num[i]和num[i]哪个大,如果dp[i-1]+num[i]大,则dp[i]=dp[i-1]+num[i],否则令dp[i]=nums[i]。
- 在递推过程中,可以设置一个值max,用来保存子序列的最大值,最后返回即可。
- 转移方程:dp[i]=Math.max(nums[i], nums[i]+dp[i-1]);
- 边界条件判断,当i等于0的时候,也就是前1个元素,他能构成的最大和也就是他自己,所以dp[0]=num[0];
代码:
class Solution {
public:
int maxSubArray(vector<int>& nums) {
int res=nums[0];
int n=nums.size();
vector<int>dp(nums);
for(int i=1;i<n;i++){
dp[i]=max(dp[i],dp[i-1]+nums[i]);
res=max(res,dp[i]);
}
return res;
}
};
时间复杂度:O(n),其中 n 为 nums 数组的长度。我们只需要遍历一遍数组即可求得答案。
空间复杂度:O(1)。我们只需要常数空间存放若干变量。
解法二:分治法
分治法的思路是这样的,其实也是分类讨论。
连续子序列的最大和主要由这三部分子区间里元素的最大和得到:
第 1 部分:子区间 [left, mid];
第 2 部分:子区间 [mid + 1, right];
第 3 部分:包含子区间 [mid , mid + 1] 的子区间,即 nums[mid] 与 nums[mid + 1] 一定会被选取。
对这三个部分求最大值即可。
说明:考虑第 3 部分跨越两个区间的连续子数组的时候,由于 nums[mid] 与 nums[mid + 1] 一定会被选取,可以从中间向两边扩散,扩散到底 选出最大值。
代码:
class Solution {
public:
int maxSubArray(vector<int>& nums) {
int len=nums.size();
if(len==0)
return 0;
return maxSubArraySum(nums,0,len-1);
}
int maxCrossingSum(vector<int>& nums,int left,int mid,int right){
//一定包含nums[mid]元素
int sum=0;
int leftSum=-1e6;
//左半边包含nums[mid]元素,最多可以到达的地方
for(int i=mid;i>=left;i--){
sum+=nums[i];
if(sum>leftSum){
leftSum=sum;
}
}
sum=0;
int rightsum=-1e6;
for(int i=mid+1;i<=right;i++){
sum+=nums[i];
if(sum>rightsum){
rightsum=sum;
}
}
return leftSum+rightsum;
}
int maxSubArraySum(vector<int>& nums,int left,int right){
if(left==right)
return nums[left];
int mid=left+(right-left)/2;
return max3(maxSubArraySum(nums,left,mid),maxSubArraySum(nums,mid+1,right),maxCrossingSum(nums,left,mid,right));
}
int max3(int num1,int num2,int num3){
return max(num1,max(num2,num3));
}
};
时间复杂度:O(NlogN),这里递归的深度是对数级别的,每一层需要遍历一遍数组(或者数组的一半、四分之一);
空间复杂度:O(logN),需要常数个变量用于选取最大值,需要使用的空间取决于递归栈的深度.
113. 环形子数组的最大和
解法一:动态规划
思路与算法
本题为「53. 最大子数组和」的进阶版,建议读者先完成该题之后,再尝试解决本题。
求解普通数组的最大子数组和是求解环形数组的最大子数组和问题的子集。设数组长度为 n,下标从 0 开始,在环形情况中,答案可能包括以下两种情况:
- 构成最大子数组和的子数组为 nums[i:j],包括 nums[i] 到 nums[j−1] 共 j−i 个元素,其中 0≤i<j≤n。
- 构成最大子数组和的子数组为 nums[0:i] 和 nums[j:n],其中 0<i<j<n。
第一种情况的求解方法与求解普通数组的最大子数组和方法完全相同,读者可以参考 53 号题目的题解:最大子序和。
第二种情况中,答案可以分为两部分,nums[0:i] 为数组的某一前缀,nums[j:n] 为数组的某一后缀。求解时,我们可以枚举 j,固定 sum(nums[j:n]) 的值,然后找到右端点坐标范围在 [0,j−1] 的最大前缀和,将它们相加更新答案。
右端点坐标范围在 [0,i] 的最大前缀和可以用 leftMax[i] 表示,递推方程为:
leftMax[i]=max(leftMax[i−1],sum(nums[0:i])
代码:
class Solution {
public:
int maxSubarraySumCircular(vector<int>& nums) {
int n=nums.size();
vector<int>dp(n);
dp[0]=nums[0];
//求出情况 1 的最大子数组和
int res=nums[0];
for(int i=1;i<n;i++){
if(dp[i-1]>0){
dp[i]=nums[i]+dp[i-1];
}
else{
dp[i]=nums[i];
}
res=max(res,dp[i]);
}
//求出情况 2 的最大子数组和
vector<int>leftMax(n);
leftMax[0]=nums[0];
int leftSum=nums[0];
for(int i=1;i<n;i++){
leftSum+=nums[i];
leftMax[i]=max(leftSum,leftMax[i-1]);
}
int rightSum=0;
for(int j=n-1;j>=1;j--){
rightSum+=nums[j];
res=max(res,rightSum+leftMax[j-1]);
}
return res;
}
};
时间复杂度:O(n),其中 n 是 nums 的长度。求解第一种情况的时间复杂度为 O(n),求解 leftMax 数组和枚举后缀的时间复杂度为 O(n),因此总的时间复杂度为 O(n)。
空间复杂度:O(n),其中 n 是 nums 的长度。过程中我们使用 leftMax 来存放最大前缀和。
二分查找
114. 搜索插入位置
解法:二分搜索
class Solution {
public:
int searchInsert(vector<int>& nums, int target) {
int left=0;
int right=nums.size()-1;
while(left<=right){
int mid=left+(right-left)/2;
if(nums[mid]==target)
return mid;
else if(nums[mid]>target){
right=mid-1;
}
else{
left=mid+1;
}
}
return left;
}
};
时间复杂度:
O
(
log
n
)
O(\log n)
O(logn)),其中 n 为数组的长度。二分查找所需的时间复杂度为
O
(
log
n
)
O(\log n)
O(logn)。
空间复杂度:O(1)。我们只需要常数空间存放若干变量。
115. 搜索二维矩阵
解法一:从右上角开始搜索,每次淘汰一整行或者一整列,因为根据描述
如果>target,则往左移一列,如果<target则往下移动一行
class Solution {
public:
bool searchMatrix(vector<vector<int>>& matrix, int target) {
int n=matrix.size();
int m=matrix[0].size();
int row=0;
int col=m-1;
// cout<<1<<endl;
while(row<n&&col>=0){
if(matrix[row][col]==target)
return true;
if(matrix[row][col]>target){
col--;
}
else{
row++;
}
}
return false;
}
};
时间复杂度:O(mn)
空间复杂度:O(1)
解法二:一次二分查找
74. 搜索二维矩阵 - 力扣(LeetCode)
116. 寻找峰值
解法一:遍历数组,找到第一个峰值,注意,如果为[1,2]这种数组,峰值是 2,就是说明峰值的右边没有数据也是默认的峰值。
为了方便遍历,在nums 数组的前后都加一个最小值,这样返回的索引就是找到的索引i-1
class Solution {
public:
int findPeakElement(vector<int>& nums) {
nums.insert(nums.begin(),INT_MIN);
nums.push_back(INT_MIN);
int n=nums.size();
for(int i=1;i<n-1;i++)
{
if(nums[i]>nums[i+1]&&nums[i]>nums[i-1])
{
return i-1;
}
}
return 0;
}
};
- 时间复杂度:O(n),其中 n 是数组 nums 的长度。
- 空间复杂度:O(1)。
解法二:二分法
首先要注意题目条件,在题目描述中出现了 nums[-1] = nums[n] = -∞,这就代表着 只要数组中存在一个元素比相邻元素大,那么沿着它一定可以找到一个峰值
根据上述结论,我们就可以使用二分查找找到峰值
查找时,左指针 l,右指针 r,以其保持左右顺序为循环条件
根据左右指针计算中间位置 m,并比较 m 与 m+1 的值,如果 m 较大,则左侧存在峰值,r = m,如果 m + 1 较大,则右侧存在峰值,l = m + 1
class Solution {
public:
int findPeakElement(vector<int>& nums) {
int n = nums.size();
int left = 0, right = n - 1, ans = -1;
while (left <right) {
int mid = left+(right-left)/2;
if (nums[mid] < nums[mid+1]) {
left = mid + 1;
}
else {
right = mid;
}
}
return left;
}
};
- 时间复杂度:O(logn),其中 n 是数组 nums 的长度。
- 空间复杂度:O(1)。
很多人有一个思维误区,就是一次走一半难道不怕错过了一些峰值吗,但是其实这不重要,题目只是要求求出一个峰值,因此只要右边存在答案就可以把左边都抛弃掉,然后只要一直确保这个区间存在答案,当区间长度为1时,答案就出来了。
117. 搜索旋转排序数组
解法:二分查找
对于有序数组,可以使用二分查找的方法查找元素。
但是这道题中,数组本身不是有序的,进行旋转后只保证了数组的局部是有序的,这还能进行二分查找吗?答案是可以的。
可以发现的是,我们将数组从中间分开成左右两部分的时候,一定有一部分的数组是有序的。拿示例来看,我们从 6 这个位置分开以后数组变成了 [4, 5, 6] 和 [7, 0, 1, 2] 两个部分,其中左边 [4, 5, 6] 这个部分的数组是有序的,其他也是如此。
这启示我们可以在常规二分查找的时候查看当前 mid 为分割位置分割出来的两个部分 [l, mid] 和 [mid + 1, r] 哪个部分是有序的,并根据有序的那个部分确定我们该如何改变二分查找的上下界,因为我们能够根据有序的那部分判断出 target 在不在这个部分:
- 如果 [l, mid - 1] 是有序数组,且 target 的大小满足 [nums[l],nums[mid]),则我们应该将搜索范围缩小至 [l, mid - 1],否则在 [mid + 1, r] 中寻找。
- 如果 [mid, r] 是有序数组,且 target 的大小满足 (nums[mid+1],则我们应该将搜索范围缩小至 [mid + 1, r],否则在 [l, mid - 1] 中寻找。
需要注意的是,二分的写法有很多种,所以在判断 target
大小与有序部分的关系的时候可能会出现细节上的差别。
class Solution {
public:
int search(vector<int>& nums, int target) {
int n = (int)nums.size();
if (!n) {
return -1;
}
if (n == 1) {
return nums[0] == target ? 0 : -1;
}
int l = 0, r = n - 1;
while (l <= r) {
int mid = (l + r) / 2;
if (nums[mid] == target) return mid;
if (nums[0] <= nums[mid]) {
if (nums[0] <= target && target < nums[mid]) {
r = mid - 1;
} else {
l = mid + 1;
}
} else {
if (nums[mid] < target && target <= nums[n - 1]) {
l = mid + 1;
} else {
r = mid - 1;
}
}
}
return -1;
}
};
时间复杂度: O(logn),其中 n 为 nums 数组的大小。整个算法时间复杂度即为二分查找的时间复杂度 O(logn)。
空间复杂度: O(1)。我们只需要常数级别的空间存放变量。
118. 在排序数组中查找元素的第一个和最后一个位置
解法一:二分查找
直观的思路肯定是从前往后遍历一遍。用两个变量记录第一次和最后一次遇见 target\textit{target}target 的下标,但这个方法的时间复杂度为 O(n)O(n)O(n),没有利用到数组升序排列的条件。
由于数组已经排序,因此整个数组是单调递增的,我们可以利用二分法来加速查找的过程。
考虑 target 开始和结束位置,其实我们要找的就是数组中「第一个等于 target的位置」(记为 leftIdx)和「第一个大于 target 的位置减一」(记为 rightIdx)。
二分查找中,寻找 leftIdx 即为在数组中寻找第一个大于等于 target 的下标,寻找 rightIdx即为在数组中寻找第一个大于 target 的下标,然后将下标减一。两者的判断条件不同,为了代码的复用,我们定义 binarySearch(nums, target, lower) 表示在 nums数组中二分查找 target 的位置,如果 lower 为 true,则查找第一个大于等于 target 的下标,否则查找第一个大于 target 的下标。
最后,因为 target 可能不存在数组中,因此我们需要重新校验我们得到的两个下标 leftIdx 和 rightIdx,看是否符合条件,如果符合条件就返回 [leftIdx,rightIdx],不符合就返回[−1,−1]。
class Solution {
public:
int binarySearch(vector<int>&nums,int target,bool lower){
int left=0,right=nums.size()-1;
int ans=nums.size();
while(left<=right){
int mid=left+(right-left)/2;
if(nums[mid]>target||(lower&&nums[mid]>=target)){
right=mid-1;
ans=mid;
}
else{
left=mid+1;
}
}
return ans;
}
vector<int> searchRange(vector<int>& nums, int target) {
int leftIdx = binarySearch(nums, target, true);
int rightIdx = binarySearch(nums, target, false) - 1;
if (leftIdx <= rightIdx && rightIdx < nums.size() && nums[leftIdx] == target && nums[rightIdx] == target) {
return vector<int>{leftIdx, rightIdx};
}
return vector<int>{-1, -1};
}
};
时间复杂度: O(logn) ,其中 n 为数组的长度。二分查找的时间复杂度为 O(logn),一共会执行两次,因此总时间复杂度为 O(logn)。
空间复杂度:O(1)。只需要常数空间存放若干变量
119. 寻找旋转排序数组中的最小值
解法一:二分查找
思路与算法
一个不包含重复元素的升序数组在经过旋转之后,可以得到下面可视化的折线图:
其中横轴表示数组元素的下标,纵轴表示数组元素的值。图中标出了最小值的位置,是我们需要查找的目标。
我们考虑数组中的最后一个元素 x:在最小值右侧的元素(不包括最后一个元素本身),它们的值一定都严格小于 x;而在最小值左侧的元素,它们的值一定都严格大于 x。因此,我们可以根据这一条性质,通过二分查找的方法找出最小值。
在二分查找的每一步中,左边界为wlow,右边界为 high,区间的中点为 pivot,最小值就在该区间内。我们将中轴元素 nums[pivot]与右边界元素 nums[high]进行比较,可能会有以下的三种情况:
第一种情况是 nums[pivot]<nums[high]。如下图所示,这说明 nums[pivot] 是最小值右侧的元素,因此我们可以忽略二分查找区间的右半部分。
因为此时的pivot是最小的可能性无法排除,所以high=pivot;
第二种情况是 nums[pivot]>nums[high]。如下图所示,这说明 nums[pivot]是最小值左侧的元素,因此我们可以忽略二分查找区间的左半部分。
此时已知nums[pivot]至少比nums[high]打,因此已经不是最小的了,所以low=pivot+1
由于数组不包含重复元素,并且只要当前的区间长度不为 1,pivot 就不会与 high重合;而如果当前的区间长度为 1,这说明我们已经可以结束二分查找了。因此不会存在 nums[pivot]=nums[high]的情况。
当二分查找结束时,我们就得到了最小值所在的位置。
class Solution {
public:
int findMin(vector<int>& nums) {
int low=0;
int high=nums.size()-1;
while(low<high){
int mid=low+(high-low)/2;
if(nums[mid]<nums[high])
high=mid;
else
low=mid+1;
}
return nums[low];
}
};
时间复杂度:时间复杂度为 O(logn),其中 n 是数组 nums 的长度。在二分查找的过程中,每一步会忽略一半的区间,因此时间复杂度为 O(logn)
空间复杂度:O(1)。
120. 寻找两个正序数组的中位数
解法: 如果题目没有要求时间复杂度是O(log(m + n)),则可以采用暴力解法,使用归并排序,将两个有序数组合并为一个有序数组,然后找到这个有序数组的中位数即可。但是题目规定时间复杂度是log级别,可以想到要用二分法去筛选。
中位数有这样的特点,它的左边的数一定都是比它小的数,它的右边的数一定都是比他大的数。因此,如果每次能筛选一半的数,那个就能达到这个时间复杂度。
而找到第k个位置的数时,设置n=k/2,因为nums1和nums2都是有序的,所以如果nums1的第n个位置的数,小于nums2的第n个位置的数,那么可以确定,nums1的前n个数一定不是第k个位置的数,就把这n个数排除了,然后num1从排除的数之后那个数作为起点,求第k个数就变成求第(k-n)位的数,然后继续采用相同的方法,将其除以2,再排除。这明显是一个重复的过程,因此用递归来完成,递归的出口:其中一个数组为空,或者所求的第k和位置的数经过不断除以2,最后变成第1个位置,只要返回两个数组第一个位置中较小的数即可。(其中要注意数组的下标时从0开始的)
此时如果求第4位置的数,将4/2=2,num1[2-1]=2,num2[2-1]=5,显然2<5,我们就可以舍弃数组1中的1和2,求第4位置的数,就变成求新的两个数组中第4-2=2个位置的数,2/2=1,num1[1-1]=3.num2[1-1]=4,3<4,因此,舍弃数组1中的3,此时数组1为空。又变成求新的两个数组中的第2-1=1个位置的数.因为数组1为空,就是求数组二的第一个数,为4.
用递归可以求得两个数组中第k个位置的数,那么怎么求中位数呢?需要分成数组长度是奇数或者偶数来讨论吗?其实需要,假设数组num1的长度是len1,num2的长度是len2,假如len1+len2为偶数,那么其中位数为这两个数组形成的有序数列的(len1+len2+1)/2和(len1+len2+2)/2的位置上的和的值除以2。
但是同时可以观察到如果len1+len2为奇数的话,(len1+len2+1)/2和(len1+len2+2)/2这两个表达式的值是相同的,因此,我们可以不用讨论,直接使用总长度为偶数时使用的式子,而达到相同的效果。
class Solution {
public:
double findMedianSortedArrays(vector<int>& nums1, vector<int>& nums2) {
int len1=nums1.size();
int len2=nums2.size();
int midl=(len1+len2+1)/2;
int midr=(len1+len2+2)/2;
return (getMid(nums1,0,len1-1,nums2,0,len2-1,midl)+getMid(nums1,0,len1-1,nums2,0,len2-1,midr))/2.0;
}
int getMid(const vector<int>& nums1,int start1,int end1,const vector<int>& nums2,int start2,int end2,int mid){
int l1=end1-start1+1;
int l2=end2-start2+1;
if(l1==0)
return nums2[start2+mid-1];
else if(l2==0)
return nums1[start1+mid-1];
else if(mid==1)
return min(nums1[start1],nums2[start2]);//返回第一小的
else{
//递归
int num=mid/2;
int n1=start1+min(l1,num)-1;
int n2=start2+min(l2,num)-1;
if(nums1[n1]<nums2[n2]){
return getMid(nums1,n1+1,end1,nums2,start2,end2,mid-(n1-start1+1));
}
else{
return getMid(nums1,start1,end1,nums2,n2+1,end2,mid-(n2-start2+1));
}
}
}
};
时间复杂度:O(log(m+n)),其中 m 和 n 分别是数组 nums1和 nums2的长度。初始时有 k=(m+n)/2 或 k=(m+n)/2+1,每一轮循环可以将查找范围减少一半,因此时间复杂度是 O(log(m+n))。
空间复杂度:O(1)。
堆
121. 数组中的第K 个最大元素
解法:改进版的快排
我们可以用快速排序来解决这个问题,先对原数组排序,再返回倒数第 k 个位置,这样平均时间复杂度是 O(nlogn),但其实我们可以做的更快。
首先我们来回顾一下快速排序,这是一个典型的分治算法。我们对数组 a[l⋯r] 做快速排序的过程是(参考《算法导论》):
分解: 将数组 a[l⋯r]「划分」成两个子数组 a[l⋯q−1]、a[q+1⋯r],使得 a[l⋯q−1] 中的每个元素小于等于 a[q],且 a[q] 小于等于 a[q+1⋯r] 中的每个元素。其中,计算下标 q 也是「划分」过程的一部分。
解决: 通过递归调用快速排序,对子数组 a[l⋯q−1]和 a[q+1⋯r]进行排序。
合并: 因为子数组都是原址排序的,所以不需要进行合并操作,a[l⋯r]已经有序。
上文中提到的 「划分」 过程是:从子数组 a[l⋯r]中选择任意一个元素 x作为主元,调整子数组的元素使得左边的元素都小于等于它,右边的元素都大于等于它, x 的最终位置就是 q。
由此可以发现每次经过「划分」操作后,我们一定可以确定一个元素的最终位置,即 x的最终位置为 q,并且保证 a[l⋯q−1]中的每个元素小于等于 a[q],且 a[q] 小于等于 a[q+1⋯r]中的每个元素。所以只要某次划分的 q 为第 k-1 个下标的时候,我们就已经找到了答案。 我们只关心这一点,至于 a[l⋯q−1]和 a[q+1⋯r]是否是有序的,我们不关心。
因此我们可以改进快速排序算法来解决这个问题:在分解的过程当中,我们会对子数组进行划分,如果划分得到的 q 正好就是我们需要的下标,就直接返回 a[q];否则,如果 q 比目标下标大,就递归左子区间,否则递归右子区间。这样就可以把原来递归两个区间变成只递归一个区间,提高了时间效率。这就是「快速选择」算法。
我们知道快速排序的性能和「划分」出的子数组的长度密切相关。直观地理解如果每次规模为 n 的问题我们都划分成 1 和 n−1,每次递归的时候又向 n−1的集合中递归,这种情况是最坏的,时间代价是 O ( n 2 ) O(n ^ 2) O(n2)。我们可以引入随机化来加速这个过程,它的时间代价的期望是 O(n),证明过程可以参考「《算法导论》9.2:期望为线性的选择算法」。需要注意的是,这个时间复杂度只有在 随机数据 下才成立,而对于精心构造的数据则可能表现不佳。因此我们这里并没有真正地使用随机数,而是使用双指针的方法,这种方法能够较好地应对各种数据。
注意,我最初使用填坑法来实现快排的时候,基准值选择的是最左边的值,这样对有非常多的重复元素的情况下超时,如示例40
代码:
class Solution {
public:
int quickSelect(vector<int>&nums,int left,int right,int k){
int pivotIndex = left + std::rand() % (right - left + 1); // 随机选择枢轴
int pivotValue = nums[pivotIndex];
std::swap(nums[pivotIndex], nums[left]);
int i=left,j=right;
while(i<j){
while(i<j&&nums[j]<=pivotValue)
j--;
nums[i]=nums[j];
while(i<j&&nums[i]>pivotValue)
i++;
nums[j]=nums[i];
}
nums[i]=pivotValue;
if(i==k-1)
return nums[i];
else if(i<k-1) return quickSelect(nums,i+1,right,k);
else return quickSelect(nums,left,i-1,k);
}
int findKthLargest(vector<int>& nums, int k) {
return quickSelect(nums,0,nums.size()-1,k);
}
};
时间复杂度:O(n)
空间复杂度:O(logn),递归使用栈空间的空间代价的期望为 O(logn)。
解法二:小根堆
c++的stl库中的priority_queue可以实现小根队 【或者自己实现】
class Solution {
public:
int findKthLargest(vector<int>& nums, int k) {
priority_queue<int,vector<int>,greater<int>>que;
for(auto n:nums){
que.push(n);
if(que.size()>k){
que.pop();
}
}
return que.top();
}
};
自己实现一个大根队,做k-1次删除操作之后,就是第k大的
class Solution {
public:
void maxHeapify(vector<int>& a, int i, int heapSize) {
int l = i * 2 + 1, r = i * 2 + 2, largest = i;
if (l < heapSize && a[l] > a[largest]) {
largest = l;
}
if (r < heapSize && a[r] > a[largest]) {
largest = r;
}
if (largest != i) {
swap(a[i], a[largest]);
maxHeapify(a, largest, heapSize);
}
}
void buildMaxHeap(vector<int>& a, int heapSize) {
for (int i = heapSize / 2; i >= 0; --i) {
maxHeapify(a, i, heapSize);
}
}
int findKthLargest(vector<int>& nums, int k) {
int heapSize = nums.size();
buildMaxHeap(nums, heapSize);
for (int i = nums.size() - 1; i >= nums.size() - k + 1; --i) {
swap(nums[0], nums[i]);
--heapSize;
maxHeapify(nums, 0, heapSize);
}
return nums[0];
}
};
时间复杂度:O(nlogn)
空间复杂度:O(n)
122. 查找和最小的K 对数字
解法:优先队列+多路归并
多路归并
令 nums1 的长度为 n,nums2 的长度为 m,所有的点对数量为 n×m。
其中每个 nums1[i] 参与所组成的点序列为:
[(nums1[0],nums2[0]),(nums1[0],nums2[1]),…,(nums1[0],nums2[m−1])]
[(nums1[1],nums2[0]),(nums1[1],nums2[1]),…,(nums1[1],nums2[m−1])]
[(nums1[n−1],nums2[0]),(nums1[n−1],nums2[1]),…,(nums1[n−1],nums2[m−1])]
由于 nums1 和 nums2 均已按升序排序,因此每个 nums1[i] 参与构成的点序列也为升序排序,这引导我们使用「多路归并」来进行求解。
具体的,起始我们将这 n 个序列的首位元素(点对)以二元组 (i,j) 放入优先队列(小根堆),其中 i 为该点对中 nums1[i] 的下标,j 为该点对中 nums2[j] 的下标,这步操作的复杂度为 O(nlogn)。这里也可以得出一个小优化是:我们始终确保 nums1 为两数组中长度较少的那个,然后通过标识位来记录是否发生过交换,确保答案的点顺序的正确性。
每次从优先队列(堆)中取出堆顶元素(含义为当前未被加入到答案的所有点对中的最小值),加入答案,并将该点对所在序列的下一位(如果有)加入优先队列中。
举个 例子,首次取出的二元组为 (0,0),即点对 (nums1[0],nums2[0]),取完后将序列的下一位点对 (nums1[0],nums2[1]) 以二元组 (0,1) 形式放入优先队列。
可通过「反证法」证明,每次这样的「取当前,放入下一位」的操作,可以确保当前未被加入答案的所有点对的最小值必然在优先队列(堆)中,即前 k 个出堆的元素必然是所有点对的前 k 小的值。
class Solution {
public:
bool flag=true;
vector<vector<int>> kSmallestPairs(vector<int>& nums1, vector<int>& nums2, int k) {
vector<vector<int>>ans;
int n=nums1.size();
int m=nums2.size();
if(n>m){
swap(nums1,nums2);
swap(n,m);
flag=false;
}
auto cmp=[&](const auto &a,const auto &b){
return nums1[a.first]+nums2[a.second]>nums1[b.first]+nums2[b.second];
};
priority_queue<pair<int,int>,vector<pair<int,int>>,decltype(cmp)>que(cmp);
for(int i=0;i<min(n,k);i++){
que.push({i,0});
}
while(ans.size()<k&&que.size()>0){
auto [a,b]=que.top();
que.pop();
flag?ans.push_back({nums1[a],nums2[b]}):ans.push_back({nums2[b],nums1[a]});
if(b+1<m)
que.push({a,b+1});
}
return ans;
}
};
- 时间复杂度:令 M 为 n、m 和 k 三者中的最小值,复杂度为 O(M+k)logM)
- 空间复杂度:O(M)
123. IPO
解法一:堆的贪心算法
我们首先思考,如果不限制次数下我们可以获取的最大利润,我们应该如何处理?我们只需将所有的项目按照资本的大小进行排序,依次购入项目 i,同时手中持有的资本增加 profits[i],直到手中的持有的资本无法启动当前的项目为止。
如果初始资本 w≥max(capital),我们直接返回利润中的 k 个最大元素的和即可。
当前的题目中限定了可以选择的次数最多为 k 次,这就意味着我们应该贪心地保证选择每次投资的项目获取的利润最大,这样就能保持选择 k 次后获取的利润最大。
我们首先将项目按照所需资本的从小到大进行排序,每次进行选择时,假设当前手中持有的资本为 w,我们应该从所有投入资本小于等于 w 的项目中选择利润最大的项目 j,然后此时我们更新手中持有的资本为 w+profits[j],同时再从所有花费资本小于等于 w+profits[j] 的项目中选择,我们按照上述规则不断选择 k 次即可。
我们利用大根堆的特性,我们将所有能够投资的项目的利润全部压入到堆中,每次从堆中取出最大值,然后更新手中持有的资本,同时将待选的项目利润进入堆,不断重复上述操作。
如果当前的堆为空,则此时我们应当直接返回。
class Solution {
public:
int findMaximizedCapital(int k, int w, vector<int>& profits, vector<int>& capital) {
int n=profits.size();
int cur=0;
priority_queue<int,vector<int>,less<int>>que;
vector<pair<int,int>>arr;
for(int i=0;i<n;i++){
arr.push_back({capital[i],profits[i]});
}
sort(arr.begin(),arr.end());
for(int i=0;i<k;i++){
while(cur<n&&arr[cur].first<=w){
que.push(arr[cur].second);
cur++;
}
if(!que.empty()){
w+=que.top();
que.pop();
}
else{
break;
}
}
return w;
}
};
时间复杂度:O((n+k)logn),其中 n 是数组 profits 和 capital 的长度,k 表示最多的选择数目。我们需要 O(nlogn) 的时间复杂度来来创建和排序项目,往堆中添加元素的时间不超过 O(nlogn),每次从堆中取出最大值并更新资本的时间为 O(klogn),因此总的时间复杂度为 O(nlogn+nlogn+klogn)=O((n+k)logn)。
空间复杂度:O(n),其中 n 是数组 profits 和 capital 的长度。空间复杂度主要取决于创建用于排序的数组和大根堆。
124. 数据流中的中位数
解法一:大根堆+小根堆
建立一个 小顶堆 A 和 大顶堆 B ,各保存列表的一半元素,且规定:
- A 保存 较大 的一半,长度为 N 2 \frac{N}{2} 2N( N 为偶数)或 N + 1 2 \frac{N+1}{2} 2N+1 (N 为奇数)。
- B保存 较小 的一半,长度为 N 2 \frac{N}{2} 2N( N 为偶数)或 N − 1 2 \frac{N-1}{2} 2N−1 ( N 为奇数)。
随后,中位数可仅根据 A,B 的堆顶元素计算得到。
算法流程:
设元素总数为 N=m+n ,其中 m 和 n 分别为 A 和 B 中的元素个数。
函数 addNum(num) :
-
当 m=n(即 N 为 偶数):需向 A 添加一个元素。实现方法:将新元素 num 插入至 B ,再将 B 堆顶元素插入至 A 。
-
当 m ≠ n m\neq n m=n(即 N 为 奇数):需向 B 添加一个元素。实现方法:将新元素 num 插入至 A ,再将 A 堆顶元素插入至 B 。
假设插入数字 num 遇到情况 1. 。由于 num 可能属于 “较小的一半” (即属于 B ),因此不能将 nums 直接插入至 A 。而应先将 num 插入至 B ,再将 B 堆顶元素插入至 A 。这样就可以始终保持 A 保存较大一半、 B 保存较小一半。
函数 findMedian() :
- 当 m=n( N 为 偶数):则中位数为 ( A 的堆顶元素 + B 的堆顶元素 )/2。
- 当 m ≠ n m \neq n m=n( N 为 奇数):则中位数为 A 的堆顶元素。
class MedianFinder {
public:
priority_queue<int,vector<int>,greater<int>>Left;//小顶堆记录较大的
priority_queue<int,vector<int>,less<int>>Right;//大顶堆记录较小的
MedianFinder() {
}
void addNum(int num) {
if(Left.size()!=Right.size()){
Left.push(num);
Right.push(Left.top());
Left.pop();
}
else{
Right.push(num);
Left.push(Right.top());
Right.pop();
}
}
double findMedian() {
return Left.size()!=Right.size()?Left.top():(Left.top()+Right.top())/2.0;
}
};
/**
* Your MedianFinder object will be instantiated and called as such:
* MedianFinder* obj = new MedianFinder();
* obj->addNum(num);
* double param_2 = obj->findMedian();
*/
时间复杂度 O(logN) :查找中位数 O(1) : 获取堆顶元素使用 O(1) 时间。添加数字 O(logN) : 堆的插入和弹出操作使用 O(logN) 时间。
空间复杂度 O(N) : 其中 N 为数据流中的元素数量,小顶堆 A 和大顶堆 B 最多同时保存 N 个元素。
位运算
125. 二进制求和
将string 转为int,之后计算完再转为string 的做法,不适合大数的运算,为了支持长度无限长的字符串计算,应该使用其他更健壮的算法。
解法一:模拟
我们可以借鉴「列竖式」的方法,末尾对齐,逐位相加。在十进制的计算中「逢十进一」,二进制中我们需要「逢二进一」。
具体的,我们可以取 n = m a x ∣ a ∣ , ∣ b ∣ n=max{∣a∣,∣b∣} n=max∣a∣,∣b∣,循环 n 次,从最低位开始遍历。我们使用一个变量 carry 表示上一个位置的进位,初始值为 0。记当前位置对其的两个位为 a i a_i ai和 b i b_i bi,则每一位的答案为 ( c a r r y + a i + b i ) m o d 2 (carry+a_i+b_i)mod2 (carry+ai+bi)mod2,下一位的进位为 ⌊ c a r r y + a i + b i 2 ⌋ ⌊\frac{carry+a_i+b_i }{2}⌋ ⌊2carry+ai+bi⌋ 。重复上述步骤,直到数字 a 和 b 的每一位计算完毕。最后如果 carry 的最高位不为 0,则将最高位添加到计算结果的末尾。
注意,为了让各个位置对齐,你可以先反转这个代表二进制数字的字符串,然后低下标对应低位,高下标对应高位。当然你也可以直接把 a 和 b 中短的那一个补 0 直到和长的那个一样长,然后从高位向低位遍历,对应位置的答案按照顺序存入答案字符串内,最终将答案串反转。这里的代码给出第一种的实现。
class Solution {
public:
string addBinary(string a, string b) {
string ans;
reverse(a.begin(),a.end());
reverse(b.begin(),b.end());
int n=max(a.size(),b.size()),carry=0;
for(int i=0;i<n;i++){
carry+=i<a.size()?a[i]=='1':0;
carry+=i<b.size()?b[i]=='1':0;
if(carry%2==0){
ans.push_back('0');
}
else{
ans.push_back('1');
}
carry/=2;
}
if(carry!=0){
ans.push_back('1');
}
reverse(ans.begin(),ans.end());
return ans;
}
};
假设
n
=
m
a
x
∣
a
∣
,
∣
b
∣
n=max{∣a∣,∣b∣}
n=max∣a∣,∣b∣。
时间复杂度:O(n),这里的时间复杂度来源于顺序遍历 a 和 b。
空间复杂度:O(1),除去答案所占用的空间,这里使用了常数个临时变量。
解法二:位运算
126. 颠倒二进制位
解法 1:逐位颠倒
将 n 视作一个长为 32 的二进制串,从低位往高位枚举 n 的每一位,将其倒序添加到翻转结果 rev 中。
代码实现中,每枚举一位就将 n 右移一位,这样当前 n 的最低位就是我们要枚举的比特位。当 n 为 0 时即可结束循环。
需要注意的是,在某些语言(如 Java)中,没有无符号整数类型,因此对 n 的右移操作应使用逻辑右移。
class Solution {
public:
uint32_t reverseBits(uint32_t n) {
uint32_t res=0;
for(int i=0;i<32&&n>0;++i){
res|=(n&1)<<(31-i);
n=n>>1;
}
return res;
}
};
- 时间复杂度:O(logn)。
- 空间复杂度:O(1)。
解法二:位运算分治
有另外一种不使用循环的做法,类似于归并排序。
其思想是分而治之,把数字分为两半,然后交换这两半的顺序;然后把前后两个半段都再分成两半,交换内部顺序……直至最后交换顺序的时候,交换的数字只有 1 位。
class Solution {
public:
uint32_t reverseBits(uint32_t n) {
n = (n >> 16) | (n << 16);
n = ((n & 0xff00ff00) >> 8) | ((n & 0x00ff00ff) << 8);
n = ((n & 0xf0f0f0f0) >> 4) | ((n & 0x0f0f0f0f) << 4);
n = ((n & 0xcccccccc) >> 2) | ((n & 0x33333333) << 2);
n = ((n & 0xaaaaaaaa) >> 1) | ((n & 0x55555555) << 1);
return n;
}
};
- 时间复杂度:O(1)
- 空间复杂度:O(1)
127. 位 1 的个数
解法一:循环检查二进制位
我们可以直接循环检查给定整数 n 的二进制位的每一位是否为 1。
具体代码中,当检查第 i 位时,我们可以让 n 与
2
i
2_i
2i进行与运算,当且仅当 n 的第 i 位为 1 时,运算结果不为 0。
class Solution {
public:
int hammingWeight(int n) {
int res=0;
for(int i=0;i<32;i++){
if(n&(1<<i)){
res++;
}
}
return res;
}
};
时间复杂度:O(k),其中 k 是 int 型的二进制位数,k=32。我们需要检查 n 的二进制位的每一位,一共需要检查 32 位。
空间复杂度:O(1),我们只需要常数的空间保存若干变量。
解法二:位运算优化
观察这个运算:n & (n−1),其运算结果恰为把 n 的二进制位中的最低位的 1 变为 0 之后的结果。如:6&(6−1)=4 6 = ( 110 ) 2 6=(110)_2 6=(110)2 , 4 = ( 100 ) 2 4=(100)_2 4=(100)2 ,运算结果 4 即为把 6 的二进制位中的最低位的 1 变为 0 之后的结果。
这样我们可以利用这个位运算的性质加速我们的检查过程,在实际代码中,我们不断让当前的 n 与 n−1 做与运算,直到 n 变为 0 即可。因为每次运算会使得 n 的最低位的 1 被翻转,因此运算次数就等于 n 的二进制位中 1 的个数。
class Solution {
public:
int hammingWeight(int n) {
int res=0;
while(n){
n&=n-1;
res++;
}
return res;
}
};
时间复杂度:O(logn)。循环次数等于 n 的二进制位中 1 的个数,最坏情况下 n 的二进制位全部为 1。我们需要循环 logn 次。
空间复杂度:O(1),我们只需要常数的空间保存若干变量。
128. 只出现一次的数字
解法:位运算
如果不考虑时间复杂度和空间复杂度的限制,这道题有很多种解法,可能的解法有如下几种。
使用集合存储数字。遍历数组中的每个数字,如果集合中没有该数字,则将该数字加入集合,如果集合中已经有该数字,则将该数字从集合中删除,最后剩下的数字就是只出现一次的数字。
使用哈希表存储每个数字和该数字出现的次数。遍历数组即可得到每个数字出现的次数,并更新哈希表,最后遍历哈希表,得到只出现一次的数字。
使用集合存储数组中出现的所有数字,并计算数组中的元素之和。由于集合保证元素无重复,因此计算集合中的所有元素之和的两倍,即为每个元素出现两次的情况下的元素之和。由于数组中只有一个元素出现一次,其余元素都出现两次,因此用集合中的元素之和的两倍减去数组中的元素之和,剩下的数就是数组中只出现一次的数字。
上述三种解法都需要额外使用 O(n)的空间,其中 n 是数组长度。
如何才能做到线性时间复杂度和常数空间复杂度呢?
答案是使用位运算。对于这道题,可使用异或运算 ⊕ \oplus ⊕。异或运算有以下三个性质。
任何数和 0 做异或运算,结果仍然是原来的数,即
a
⊕
0
=
a
a \oplus 0=a
a⊕0=a。
任何数和其自身做异或运算,结果是 0,即
a
⊕
a
=
0
a \oplus a=0
a⊕a=0。
异或运算满足交换律和结合律,即
a
⊕
b
⊕
a
=
b
⊕
a
⊕
a
=
b
⊕
(
a
⊕
a
)
=
b
⊕
0
=
b
a \oplus b \oplus a=b \oplus a \oplus a=b \oplus (a \oplus a)=b \oplus0=b
a⊕b⊕a=b⊕a⊕a=b⊕(a⊕a)=b⊕0=b。
假设数组中有 2m+1 个数,其中有 m 个数各出现两次,一个数出现一次。令 a 1 a_{1} a1 、 a 2 a_{2} a2、…、 a m a_{m} am为出现两次的 m 个数, a m + 1 a_{m+1} am+1为出现一次的数。根据性质 3,数组中的全部元素的异或运算结果总是可以写成如下形式:
( a 1 ⊕ a 1 ) ⊕ ( a 2 ⊕ a 2 ) ⊕ ⋯ ⊕ ( a m ⊕ a m ) ⊕ a m + 1 (a_{1} \oplus a_{1}) \oplus (a_{2} \oplus a_{2}) \oplus \cdots \oplus (a_{m} \oplus a_{m}) \oplus a_{m+1} (a1⊕a1)⊕(a2⊕a2)⊕⋯⊕(am⊕am)⊕am+1
根据性质 2 和性质 1,上式可化简和计算得到如下结果:
0 ⊕ 0 ⊕ ⋯ ⊕ 0 ⊕ a m + 1 = a m + 1 0 \oplus 0 \oplus \cdots \oplus 0 \oplus a_{m+1}=a_{m+1} 0⊕0⊕⋯⊕0⊕am+1=am+1
因此,数组中的全部元素的异或运算结果即为数组中只出现一次的数字。
class Solution {
public:
int singleNumber(vector<int>& nums) {
int ret=0;
for(auto e:nums){
ret^=e;
}
return ret;
}
};
- 时间复杂度:O(n),其中 n 是数组长度。只需要对数组遍历一次。
- 空间复杂度:O(1)。
129. 只出现一次的数字II
解法一:哈希表,即统计每个数字出现的次数,统计完成之后遍历一次哈希表,但是这么做是一个o(n)的空间复杂度
解法二:依次确定每一个二进制位
为了方便叙述,我们称「只出现了一次的元素」为「答案」。
由于数组中的元素都在 int(即 32 位整数)范围内,因此我们可以依次计算答案的每一个二进制位是 0 还是 1。
具体地,考虑答案的第 i 个二进制位(i 从 0 开始编号),它可能为 0 或 1。对于数组中非答案的元素,每一个元素都出现了 3 次,对应着第 i 个二进制位的 3 个 0 或 3 个 1,无论是哪一种情况,它们的和都是 3 的倍数(即和为 0 或 3)。因此:
答案的第 i 个二进制位就是数组中所有元素的第 i 个二进制位之和除以 3 的余数。
这样一来,对于数组中的每一个元素 x,我们使用位运算 (x >> i) & 1 得到 x 的第 i 个二进制位,并将它们相加再对 3 取余,得到的结果一定为 0 或 1,即为答案的第 i 个二进制位。
需要注意的是,如果使用的语言对「有符号整数类型」和「无符号整数类型」没有区分,那么可能会得到错误的答案。这是因为「有符号整数类型」(即 int 类型)的第 31 个二进制位(即最高位)是补码意义下的符号位,对应着 − 2 31 −2^{31} −231,而「无符号整数类型」由于没有符号,第 31 个二进制位对应着 2 31 2^{31} 231 。因此在某些语言(例如 Python)中需要对最高位进行特殊判断。
class Solution {
public:
int singleNumber(vector<int>& nums) {
int ans=0;
for(int i=0;i<32;++i){
int tmp=0;
for(int num:nums){
tmp+=((num>>i)&1);
}
if(tmp%3){
ans|=(1<<i);
}
}
return ans;
}
};
时间复杂度:O(nlogC),其中 n 是数组的长度,C 是元素的数据范围,在本题中 l o g C = l o g 2 32 = 32 logC=log2^{32}=32 logC=log232=32,也就是我们需要遍历第 0∼31 个二进制位。
空间复杂度:O(1)
解法三:数字电路设计
130. 数字范围按位与
解法一:Brian Kernighan 算法
最直观的解决方案就是迭代范围内的每个数字,依次执行按位与运算,得到最终的结果,但此方法在 [m,n] 范围较大的测试用例中会因超出时间限制而无法通过,因此我们需要另寻他路。
我们观察按位与运算的性质。对于一系列的位,例如 [1,1,0,1,1],只要有一个零值的位,那么这一系列位的按位与运算结果都将为零。
回到本题,首先我们可以对范围内的每个数字用二进制的字符串表示,例如 9 = 00001001 ( 2 ) 9=00001001(2) 9=00001001(2) ,然后我们将每个二进制字符串的位置对齐。
在上图的例子中,我们可以发现,对所有数字执行按位与运算的结果是所有对应二进制字符串的公共前缀再用零补上后面的剩余位。
证明:
那么这个规律是否正确呢?我们可以进行简单的证明。假设对于所有这些二进制串,前 i 位均相同,第 i+1 位开始不同,由于 [m,n] 连续,所以第 i+1 位在 [m,n] 的数字范围从小到大列举出来一定是前面全部是 0,后面全部是 1,在上图中对应 [9,11] 均为 0,[12,12] 均为 1。并且一定存在连续的两个数 x 和 x+1,满足 x 的第 i+1 位为 0,后面全为 1,x+1 的第 i+1 位为 1,后面全为 0,对应上图中的例子即为 11 和 12。这种形如 0111… 和 1000… 的二进制串的按位与的结果一定为 0000…,因此第 i+1 位开始的剩余位均为 0,前 i 位由于均相同,因此按位与结果不变。最后的答案即为二进制字符串的公共前缀再用零补上后面的剩余位。
进一步来说,所有这些二进制字符串的公共前缀也即指定范围的起始和结束数字 m 和 n 的公共前缀(即在上面的示例中分别为 9 和 12)。
因此,最终我们可以将问题重新表述为:给定两个整数,我们要找到它们对应的二进制字符串的公共前缀。
还有一个位移相关的算法叫做「Brian Kernighan 算法」,它用于清除二进制串中最右边的 1。
Brian Kernighan 算法的关键在于我们每次对 number 和 number−1 之间进行按位与运算后,number 中最右边的 1 会被抹去变成 0。
基于上述技巧,我们可以用它来计算两个二进制字符串的公共前缀。
其思想是,对于给定的范围 [m,n](m<n),我们可以对数字 n 迭代地应用上述技巧,清除最右边的 1,直到它小于或等于 m,此时非公共前缀部分的 1 均被消去。因此最后我们返回 n 即可。
class Solution {
public:
int rangeBitwiseAnd(int left, int right) {
while(left<right){
right=right&(right-1);
}
return right;
}
};
时间复杂度:O(logn)。和位移方法类似,算法的时间复杂度取决于 m 和 n 二进制展开的位数。尽管和位移方法具有相同的渐近复杂度,但 Brian Kernighan 的算法需要的迭代次数会更少,因为它跳过了两个数字之间的所有零位。
空间复杂度:O(1)。我们只需要常数空间存放若干变量。
数学
131. 回文数
解法一:翻转一半的数字
思路
映入脑海的第一个想法是将数字转换为字符串,并检查字符串是否为回文。但是,这需要额外的非常量空间来创建问题描述中所不允许的字符串。
第二个想法是将数字本身反转,然后将反转后的数字与原始数字进行比较,如果它们是相同的,那么这个数字就是回文。
但是,如果反转后的数字大于 int.MAX,我们将遇到整数溢出问题。
按照第二个想法,为了避免数字反转可能导致的溢出问题,为什么不考虑只反转 int 数字的一半?毕竟,如果该数字是回文,其后半部分反转后应该与原始数字的前半部分相同。
例如,输入 1221,我们可以将数字 “1221” 的后半部分从 “21” 反转为 “12”,并将其与前半部分 “12” 进行比较,因为二者相同,我们得知数字 1221 是回文。
算法
首先,我们应该处理一些临界情况。所有负数都不可能是回文,例如:-123 不是回文,因为 - 不等于 3。所以我们可以对所有负数返回 false。除了 0 以外,所有个位是 0 的数字不可能是回文,因为最高位不等于 0。所以我们可以对所有大于 0 且个位是 0 的数字返回 false。
现在,让我们来考虑如何反转后半部分的数字。
对于数字 1221,如果执行 1221 % 10,我们将得到最后一位数字 1,要得到倒数第二位数字,我们可以先通过除以 10 把最后一位数字从 1221 中移除,1221 / 10 = 122,再求出上一步结果除以 10 的余数,122 % 10 = 2,就可以得到倒数第二位数字。如果我们把最后一位数字乘以 10,再加上倒数第二位数字,1 * 10 + 2 = 12,就得到了我们想要的反转后的数字。如果继续这个过程,我们将得到更多位数的反转数字。
现在的问题是,我们如何知道反转数字的位数已经达到原始数字位数的一半?
由于整个过程我们不断将原始数字除以 10,然后给反转后的数字乘上 10,所以,当原始数字小于或等于反转后的数字时,就意味着我们已经处理了一半位数的数字了。
class Solution {
public:
bool isPalindrome(int x) {
// 特殊情况:
// 如上所述,当 x < 0 时,x 不是回文数。
// 同样地,如果数字的最后一位是 0,为了使该数字为回文,
// 则其第一位数字也应该是 0
// 只有 0 满足这一属性
if (x < 0 || (x % 10 == 0 && x != 0)) {
return false;
}
int revertedNumber = 0;
while (x > revertedNumber) {
revertedNumber = revertedNumber * 10 + x % 10;
x /= 10;
}
// 当数字长度为奇数时,我们可以通过 revertedNumber/10 去除处于中位的数字。
// 例如,当输入为 12321 时,在 while 循环的末尾我们可以得到 x = 12,revertedNumber = 123,
// 由于处于中位的数字不影响回文(它总是与自己相等),所以我们可以简单地将其去除。
return x == revertedNumber || x == revertedNumber / 10;
}
};
- 时间复杂度:O(logn),对于每次迭代,我们会将输入除以 10,因此时间复杂度为 O(logn)。
- 空间复杂度:O(1)。我们只需要常数空间存放若干变量。
132. 加一
解法一:简单模拟
将digits 数组反转,那么0 就是最低位,从最低位开始遍历,idx 是遍历的索引,如果idx+1 等于 10,则进位继续遍历,否则直接退出,如果idx=n,说明数组需要加一个最高位,在dight 末尾加入,最后反转dight,并且返回dight.
class Solution {
public:
vector<int> plusOne(vector<int>& digits) {
int idx=0;
int n=digits.size();
reverse(digits.begin(),digits.end());
while(idx!=n){
if(digits[idx]+1==10){
digits[idx]=0;
idx++;
}
else{
digits[idx]++;
break;
}
}
if(idx==n){
digits.push_back(1);
}
reverse(digits.begin(),digits.end());
return digits;
}
};
- 时间复杂度:O(n),其中 n 是数组 digits 的长度。
- 空间复杂度:O(1)。返回值不计入空间复杂度。
解法二:当我们对数组 digits 加一时,我们只需要关注 digits 的末尾出现了多少个 9 即可。
133. 阶乘后的零
解法一:数学解法
n! 尾零的数量即为 n! 中因子 10 的个数,而 10=2×5,因此转换成求 n! 中质因子 2 的个数和质因子 5 的个数的较小值。由于质因子 5 的个数不会大于质因子 2 的个数,我们可以仅考虑质因子 5 的个数。而 n! 中质因子 5 的个数等于 [1,n] 的每个数的质因子 5 的个数之和,我们可以通过遍历 [1,n] 的所有 5 的倍数求出。
class Solution {
public:
int trailingZeroes(int n) {
int ans = 0;
for (int i = 5; i <= n; i += 5) {
for (int x = i; x % 5 == 0; x /= 5) {
++ans;
}
}
return ans;
}
}
- 时间复杂度:O(n)。n! 中因子 5 的个数为 O(n)。
- 空间复杂度:O(1)
解法二:优化对数的时间复杂度
找规律:
先以n=7
举个例子,看一下7!末尾有几个0。
我们把7!展开来看下:
7 ! = 1 ∗ 2 ∗ 3 ∗ 4 ∗ 5 ∗ 6 ∗ 7 7!=1∗2∗3∗4∗5∗6∗7 7!=1∗2∗3∗4∗5∗6∗7
我们知道,只有2∗5才可以得到一个0,那我们只需要看7!可以分解为多少个2∗5就可以。
2出现的频率肯定是高于5的,因为:
- 每隔 2 个数就会包含因子2,比如2,4,6,…,
- 而每个 5 个数才会出现一个包含因子5的数,比如5,10,15,…
那我们的题目就可以转换为,n!最多可以分解出多少个因子5。
对于n!,5 的因子一定是每隔 5 个数出现一次,也就是下边的样子。
n ! = 1 ∗ 2 ∗ 3 ∗ 4 ∗ ( 1 ∗ 5 ) ∗ . . . ∗ ( 2 ∗ 5 ) ∗ . . . ∗ ( 3 ∗ 5 ) ∗ . . . ∗ n ∗ n!=1∗2∗3∗4∗(1∗5)∗...∗(2∗5)∗...∗(3∗5)∗...*n* n!=1∗2∗3∗4∗(1∗5)∗...∗(2∗5)∗...∗(3∗5)∗...∗n∗
但我们还会发现,每隔 25(5*5) 个数字,出现的是 2 个 5,如下:
. . . ∗ ( 1 ∗ 5 ) ∗ . . . ∗ ( 1 ∗ 5 ∗ 5 ) ∗ . . . ∗ ( 2 ∗ 5 ∗ 5 ) ∗ . . . ∗ ( 3 ∗ 5 ∗ 5 ) ∗ . . . ∗ ∗ n ∗ ...∗(1∗5)∗...∗(1∗5∗5)∗...∗(2∗5∗5)∗...∗(3∗5∗5)∗...∗*n* ...∗(1∗5)∗...∗(1∗5∗5)∗...∗(2∗5∗5)∗...∗(3∗5∗5)∗...∗∗n∗
比如1∗5∗5,2∗5∗5,里面包含了 2 个 5。
同理,每隔 125 ( 5 ∗ 5 ∗ 5 ) 125(5*5*5) 125(5∗5∗5)个数字,出现的是 3 个 5,如下:
. . . ∗ ( 1 ∗ 5 ) ∗ . . . ∗ ( 1 ∗ 5 ∗ 5 ∗ 5 ) ∗ . . . ∗ ( 2 ∗ 5 ∗ 5 ∗ 5 ) ∗ . . . ∗ ( 3 ∗ 5 ∗ 5 ∗ 5 ) ∗ . . . ∗ n ∗ ...∗(1∗5)∗...∗(1∗5∗5∗5)∗...∗(2∗5∗5∗5)∗...∗(3∗5∗5∗5)∗...∗n* ...∗(1∗5)∗...∗(1∗5∗5∗5)∗...∗(2∗5∗5∗5)∗...∗(3∗5∗5∗5)∗...∗n∗
那么,我们要计算n!
中一共有多少个因子5
的话,计算方法方式就应该是:
每隔5个数出现一次的因子5的次数+每隔25个数出现一次的因子5的次数+…
也就是:
n ∗ / 5 + ∗ n ∗ / ( 5 ∗ 5 ) + ∗ n ∗ / ( 5 ∗ 5 ∗ 5 ) + . . . n*/5+*n*/(5∗5)+*n*/(5∗5∗5)+... n∗/5+∗n∗/(5∗5)+∗n∗/(5∗5∗5)+...
134. X 的平方根
解法一:二分查找
由于 x 平方根的整数部分 ans 是满足 k 2 ≤ x k^2≤x k2≤x 的最大 k 值,因此我们可以对 k 进行二分查找,从而得到答案。
二分查找的下界为 0,上界可以粗略地设定为 x。在二分查找的每一步中,我们只需要比较中间元素 mid 的平方与 x 的大小关系,并通过比较的结果调整上下界的范围。由于我们所有的运算都是整数运算,不会存在误差,因此在得到最终的答案 ans 后,也就不需要再去尝试 ans+1 了。
class Solution {
public:
int mySqrt(int x) {
int l=0,r=x,ans=-1;
while(l<=r){
int mid=l+(r-l)/2;
if((long long)mid*mid<=x){
ans=mid;
l=mid+1;
}
else{
r=mid-1;
}
}
return ans;
}
};
- 时间复杂度:O(logx),即为二分查找需要的次数。
- 空间复杂度:O(1)。
解法二:对数换底
解法三:牛顿迭代
见:https://leetcode.cn/problems/sqrtx/?envType=study-plan-v2&envId=top-interview-150
135. Pow(x,n)
解法一:快速幂
求
x
n
x^n
xn 最简单的方法是通过循环将 n 个 x 乘起来,依次求
x
1
,
x
2
,
.
.
.
,
x
n
−
1
,
x
n
x^1,x^2,...,x^{n-1},x^n
x1,x2,...,xn−1,xn ,时间复杂度为 O(n) 。
快速幂法 可将时间复杂度降低至 O(logn) ,从二进制角度解析快速幂法。
快速幂解析(二进制角度):
利用十进制数字 n 的二进制表示,可对快速幂进行数学化解释。
对于任何十进制正整数 n ,设其二进制为
b
m
.
.
.
b
3
b
2
b
1
b_m...b_3b_2b_1
bm...b3b2b1 为二进制某位值,i∈[1,m] ),则有:
二进制转十进制:
n
=
1
b
1
+
2
b
2
+
4
b
3
+
2
m
−
1
b
m
n=1b_1+2b_2+4b_3+2^{m-1}b_m
n=1b1+2b2+4b3+2m−1bm(即二进制转十进制公式) ;
幂的二进制展开:
x n = x 1 b 1 + 2 b 2 + 4 b 3 + . . . + 2 m − 1 b m x^n=x^{1b_1+2b_2+4b_3+...+2^{m-1}b_m} xn=x1b1+2b2+4b3+...+2m−1bm
根据以上推导,可把计算x^n转化为解决以下两个问题:
计算
x
1
,
x
2
,
x
4
,
.
.
.
,
x
2
m
−
1
x^1,x^2,x^4,...,x^{2^{m-1}}
x1,x2,x4,...,x2m−1的值: 循环赋值操作
x
=
x
2
x=x^2
x=x2即可;
获取二进制各位
b
1
,
b
2
,
b
3
,
.
.
.
,
b
m
b_1,b_2,b_3,...,b_m
b1,b2,b3,...,bm 的值: 循环执行以下操作即可。
n&1 (与操作): 判断 n 二进制最右一位是否为 1 ;
n>>1 (移位操作): n 右移一位(可理解为删除最后一位)。
因此,应用以上操作,可在循环中依次计算
x
2
0
b
1
,
x
2
1
b
2
,
x
2
2
b
3
.
.
.
,
x
2
m
−
1
b
,
x^{2^0b_1},x^{2^1b_2},x^{2^2b_3}...,x^{2^{m-1}b_,}
x20b1,x21b2,x22b3...,x2m−1b,的值,并将所有
x
2
i
−
1
b
i
x^{2^{i-1}b_i}
x2i−1bi累计相乘即可,其中:
x 2 i − 1 b i = { 1 , b i = 0 x 2 i − 1 , b i = 1 x^{2^{i-1} b_i} = \begin{cases} 1, & b_i = 0 \\ x^{2^{i-1}}, & b_i = 1 \end{cases} x2i−1bi={1,x2i−1,bi=0bi=1
class Solution {
public:
double myPow(double x, int n) {
if(x==0.0)
return 0.0;
long b=n;
double res=1.0;
if(b<0){
x=1/x;
b=-b;
}
while(b>0){
if((b&1)==1)
res*=x;
x*=x;
b>>=1;
}
return res;
}
};
- 时间复杂度 O(logn) :二分的时间复杂度为对数级别。
- 空间复杂度 O(1) : res, b 等变量占用常数大小额外空间。
136. 直线上最多的点数
解法一:枚举直线+枚举统计
我们知道,两点可以确定一条线。
一个朴素的做法是先枚举两点(确定一条线),然后检查其余点是否落在该线中。
为避免除法精度问题,当我们枚举两个点 x 和 y 时,不直接计算其对应直线的 斜率和 截距。
而是通过判断 x 和 y 与第三个点 p 形成的两条直线斜率是否相等,来得知点 ppp 是否落在该直线上。
斜率相等的两条直线要么平行,要么重合。
平行需要 4个点来唯一确定,我们只有 3 个点,因此直接判定两条直线是否重合即可。
详细说,当给定两个点 (x1,y1) 和 (x2,y2)时,对应斜率
y
2
−
y
1
x
2
−
x
1
\frac{y_2 - y_1}{x_2 - x_1}
x2−x1y2−y1 。
为避免计算机除法的精度问题,我们将「判定$\frac{a_y - b_y}{a_x - b_x} = \frac{b_y - c_y}{b_x - c_x} $是否成立」改为「判定
(
a
y
−
b
y
)
×
(
b
x
−
c
x
)
=
(
a
x
−
b
x
)
×
(
b
y
−
c
y
)
(a_y - b_y) \times (b_x - c_x) = (a_x - b_x) \times (b_y - c_y)
(ay−by)×(bx−cx)=(ax−bx)×(by−cy)( 是否成立」。
将存在精度问题的「除法判定」巧妙转为「乘法判定」。
class Solution {
public:
int maxPoints(vector<vector<int>>& points) {
int n = points.size(), ans = 1;
for (int i = 0; i < n; i++) {
vector<int> x = points[i];
for (int j = i + 1; j < n; j++) {
vector<int> y = points[j];
// 枚举点对 (i,j) 并统计有多少点在该线上, 起始 cnt = 2 代表只有 i 和 j 两个点在此线上
int cnt = 2;
for (int k = j + 1; k < n; k++) {
vector<int> p = points[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 = max(ans, cnt);
}
}
return ans;
}
};
- 时间复杂度:O(n3)
- 空间复杂度:O(1)
解法二:枚举直线+哈希表
枚举直线 + 哈希表统计
根据「朴素解法」的思路,枚举所有直线的过程不可避免,但统计点数的过程可以优化。
具体的,我们可以先枚举所有可能出现的 直线斜率(根据两点确定一条直线,即枚举所有的「点对」),使用「哈希表」统计所有 斜率 对应的点的数量,在所有值中取个 max即是答案。
class Solution {
public:
int maxPoints(vector<vector<int>>& points) {
int n=points.size();
int ans=1;
for(int i=0;i<n;i++)
{
unordered_map<string,int>map;
int maxv=0;
for(int j=i+1;j<n;j++){
int x1=points[i][0],y1=points[i][1];
int x2=points[j][0],y2=points[j][1];
int a=x1-x2;
int b=y1-y2;
int k=gcd(a,b);
string key=to_string(a/k)+"_"+to_string(b/k);
map[key]++;
maxv=max(maxv,map[key]);
}
ans=max(ans,maxv+1);
}
return ans;
}
int gcd(int a,int b){
return b==0?a:gcd(b,a%b);
}
};
时间复杂度:枚举所有直线的复杂度为
O
(
n
2
)
O(n^2)
O(n2);令坐标值的最大差值为 m,gcd 复杂度为 O(logm)。整体复杂度为
O
(
n
2
×
log
m
)
O(n^2 \times \log{m})
O(n2×logm)
空间复杂度:O(n)
一维动态规划
137. 爬楼梯
解法:动态规划
我们用 f(x) 表示爬到第 x 级台阶的方案数,考虑最后一步可能跨了一级台阶,也可能跨了两级台阶,所以我们可以列出如下式子:
f ( x ) = f ( x − 1 ) + f ( x − 2 ) f(x)=f(x−1)+f(x−2) f(x)=f(x−1)+f(x−2)
它意味着爬到第 x 级台阶的方案数是爬到第 x−1 级台阶的方案数和爬到第 x−2 级台阶的方案数的和。很好理解,因为每次只能爬 1 级或 2 级,所以 f(x) 只能从 f(x−1)和 f(x−2)转移过来,而这里要统计方案总数,我们就需要对这两项的贡献求和。
以上是动态规划的转移方程,下面我们来讨论边界条件。我们是从第 000 级开始爬的,所以从第 0 级爬到第 0 级我们可以看作只有一种方案,即 f(0)=1;从第 0 级到第 1 级也只有一种方案,即爬一级,f(1)=1。这两个作为边界条件就可以继续向后推导出第 nnn 级的正确结果。我们不妨写几项来验证一下,根据转移方程得到 f(2)=2,f(3)=3,f(4)=5,……,我们把这些情况都枚举出来,发现计算的结果是正确的。
我们不难通过转移方程和边界条件给出一个时间复杂度和空间复杂度都是 O(n) 的实现,使用dp数组记录,dp[n]表示爬到第n阶需要的次数。
class Solution {
public:
int climbStairs(int n) {
vector<int>dp(n+1,0);
dp[0]=1;
dp[1]=1;
for(int i=2;i<=n;i++)
{
dp[i]=dp[i-1]+dp[i-2];
}
return dp[n];
}
};
时间复杂度:循环执行 n 次,每次花费常数的时间代价,故渐进时间复杂度为 O(n)。
空间复杂度:这里只用了常数个变量作为辅助空间,故渐进空间复杂度为 O(1)。
但是由于这里的 f(x)只和 f(x−1) 与 f(x−2)有关,所以我们可以用「滚动数组思想」把空间复杂度优化成 O(1)。下面的代码中给出的就是这种实现。
class Solution {
public:
int climbStairs(int n) {
int p = 0, q = 0, r = 1;
for (int i = 1; i <= n; ++i) {
p = q;
q = r;
r = p + q;
}
return r;
}
};
138. 打家劫舍
解法:动态规划
首先考虑最简单的情况。如果只有一间房屋,则偷窃该房屋,可以偷窃到最高总金额。如果只有两间房屋,则由于两间房屋相邻,不能同时偷窃,只能偷窃其中的一间房屋,因此选择其中金额较高的房屋进行偷窃,可以偷窃到最高总金额。
如果房屋数量大于两间,应该如何计算能够偷窃到的最高总金额呢?对于第 k 间房屋,有两个选项:
-
偷窃第 k间房屋,那么就不能偷窃第 k−1间房屋,偷窃总金额为前 k−2 间房屋的最高总金额与第 k 间房屋的金额之和。
-
不偷窃第 k 间房屋,偷窃总金额为前 k−1 间房屋的最高总金额。
在两个选项中选择偷窃总金额较大的选项,该选项对应的偷窃总金额即为前 kkk 间房屋能偷窃到的最高总金额。
用dp[i] 表示前 i间房屋能偷窃到的最高总金额,那么就有如下的状态转移方程:
d
p
[
i
]
=
m
a
x
(
d
p
[
i
−
2
]
+
n
u
m
s
[
i
]
,
d
p
[
i
−
1
]
)
dp[i]=max(dp[i−2]+nums[i],dp[i−1])
dp[i]=max(dp[i−2]+nums[i],dp[i−1])
边界条件为:
{ dp [ 0 ] = nums [ 0 ] 只有一间房屋,则偷窃该房屋 dp [ 1 ] = max ( nums [ 0 ] , nums [ 1 ] ) 只有两间房屋,选择其中金额较高的房屋进行偷窃 \begin{cases} \textit{dp}[0] = \textit{nums}[0] & 只有一间房屋,则偷窃该房屋 \\ \textit{dp}[1] = \max(\textit{nums}[0], \textit{nums}[1]) & 只有两间房屋,选择其中金额较高的房屋进行偷窃 \end{cases} {dp[0]=nums[0]dp[1]=max(nums[0],nums[1])只有一间房屋,则偷窃该房屋只有两间房屋,选择其中金额较高的房屋进行偷窃
最终的答案即为 dp[n−1],其中 n 是数组的长度。
class Solution {
public:
int rob(vector<int>& nums) {
int n=nums.size();
if(n==1)
return nums[0];
if(n==2)
return max(nums[0],nums[1]);
vector<int>dp(n,0);
dp[0]=nums[0];
dp[1]=max(nums[0],nums[1]);
for(int i=2;i<n;i++){
dp[i]=max(dp[i-2]+nums[i],dp[i-1]);
}
return dp[n-1];
}
};
时间复杂度:O(n),其中 n 是数组长度。只需要对数组遍历一次。
空间复杂度:O(n)。
使用滚动数组,可以只存储前两间房屋的最高总金额,而不需要存储整个数组的结果,因此空间复杂度是 O(1)。
class Solution {
public:
int rob(vector<int>& nums) {
if (nums.empty()) {
return 0;
}
int size = nums.size();
if (size == 1) {
return nums[0];
}
int first = nums[0], second = max(nums[0], nums[1]);
for (int i = 2; i < size; i++) {
int temp = second;
second = max(first + nums[i], second);
first = temp;
}
return second;
}
};
139. 单词拆分
解法:动态规划
我们定义 dp[i]表示字符串 s 前 i 个字符组成的字符串 s[0…i−1]s[0…i-1]s[0…i−1] 是否能被空格拆分成若干个字典中出现的单词。从前往后计算考虑转移方程,每次转移的时候我们需要枚举包含位置 i−1i-1i−1 的最后一个单词,看它是否出现在字典中以及除去这部分的字符串是否合法即可。公式化来说,我们需要枚举 s[0…i−1] 中的分割点 j ,看 s[0…j−1] 组成的字符串 s 1 s_1 s1(默认 j=0时 s 1 s_1 s1为空串)和 s[j…i−1]] 组成的字符串 $s_2$2
是否都合法,如果两个字符串均合法,那么按照定义 s 1 s_1 s1和 s 2 s_2 s2拼接成的字符串也同样合法。由于计算到 dp [ i ] \textit{dp}[i] dp[i] 时我们已经计算出了 t e x t i t d p [ 0.. i − 1 ] textit{dp}[0..i-1] textitdp[0..i−1]的值,因此字符串 s 1 s_1 s1是否合法可以直接由 dp[j] 得知,剩下的我们只需要看 s 2 s_2 s2是否合法即可,因此我们可以得出如下转移方程:
dp
[
i
]
=
dp
[
j
]
&
&
check
(
s
[
j
.
.
i
−
1
]
)
\textit{dp}[i]=\textit{dp}[j]\ \&\&\ \textit{check}(s[j..i-1])
dp[i]=dp[j] && check(s[j..i−1])
其中
check
(
s
[
j
.
.
i
−
1
]
)
\textit{check}(s[j..i-1])
check(s[j..i−1])c表示子串 s[j…i−1]是否出现在字典中。
对于检查一个字符串是否出现在给定的字符串列表里一般可以考虑哈希表来快速判断,同时也可以做一些简单的剪枝,枚举分割点的时候倒着枚举,如果分割点 j 到 i的长度已经大于字典列表里最长的单词的长度,那么就结束枚举,但是需要注意的是下面的代码给出的是不带剪枝的写法。
对于边界条件,我们定义 dp [ 0 ] = t r u e \textit{dp}[0]=true dp[0]=true表示空串且合法。
class Solution {
public:
bool wordBreak(string s, vector<string>& wordDict) {
auto wordDictSet=unordered_set<string>();
for(auto word:wordDict){
wordDictSet.insert(word);
}
auto dp=vector<bool>(s.size()+1);
dp[0]=true;
for(int i=1;i<=s.size();i++){
for(int j=0;j<i;j++){
if(dp[j]&&wordDictSet.find(s.substr(j,i-j))!=wordDictSet.end()){
dp[i]=true;
break;
}
}
}
return dp[s.size()];
}
};
时间复杂度: O ( n 2 ) O(n^2) O(n2) ,其中 n 为字符串 s 的长度。我们一共有 O(n)个状态需要计算,每次计算需要枚举 O(n)O(n)O(n) 个分割点,哈希表判断一个字符串是否出现在给定的字符串列表需要 O(1) 的时间,因此总时间复杂度为 O ( n 2 ) O(n^2) O(n2)。
空间复杂度:O(n) ,其中 n 为字符串 s 的长度。我们需要 O(n)的空间存放 dp 值以及哈希表亦需要 O(n) 的空间复杂度,因此总空间复杂度为 O(n)
140. 零钱兑换
解法:动态规划
我们采用自下而上的方式进行思考。仍定义 F(i) 为组成金额 i 所需最少的硬币数量,假设在计算 F(i) 之前,我们已经计算出 F(0)−F(i−1)的答案。 则 F(i)F(i)F(i) 对应的转移方程应为
F ( i ) = min j = 0 … n − 1 F ( i − c j ) + 1 F(i)=\min_{j=0 \ldots n-1}{F(i -c_j)} + 1 F(i)=minj=0…n−1F(i−cj)+1
其中 c j c_j cj 代表的是第 j枚硬币的面值,即我们枚举最后一枚硬币面额是 c j c_j cj ,那么需要从 i − c j i-c_j i−cj 这个金额的状态 F ( i − c j ) F(i-c_j) F(i−cj) 转移过来,再算上枚举的这枚硬币数量 1 的贡献,由于要硬币数量最少,所以 F(i)为前面能转移过来的状态的最小值加上枚举的硬币数量 1 。
例子1:假设
c
o
i
n
s
=
[
1
,
2
,
5
]
,
a
m
o
u
n
t
=
11
coins = [1, 2, 5], amount = 11
coins=[1,2,5],amount=11
则,当 i==0时无法用硬币组成,为 0 。当 i<0i时,忽略 F(i)
例子2:假设
c o i n s = [ 1 , 2 , 3 ] , a m o u n t = 6 coins = [1, 2, 3], amount = 6 coins=[1,2,3],amount=6
在上图中,可以看到:
F ( 3 ) = min ( F ( 3 − c 1 ) , F ( 3 − c 2 ) , F ( 3 − c 3 ) ) + 1 = min ( F ( 3 − 1 ) , F ( 3 − 2 ) , F ( 3 − 3 ) ) + 1 = min ( F ( 2 ) , F ( 1 ) , F ( 0 ) ) + 1 = min ( 1 , 1 , 0 ) + 1 = 1 \begin{aligned} F(3) &= \min({F(3- c_1), F(3-c_2), F(3-c_3)}) + 1 \\ &= \min({F(3- 1), F(3-2), F(3-3)}) + 1 \\ &= \min({F(2), F(1), F(0)}) + 1 \\ &= \min({1, 1, 0}) + 1 \\ &= 1 \end{aligned} F(3)=min(F(3−c1),F(3−c2),F(3−c3))+1=min(F(3−1),F(3−2),F(3−3))+1=min(F(2),F(1),F(0))+1=min(1,1,0)+1=1
class Solution {
public:
const int MAX=10e5;
int coinChange(vector<int>& coins, int amount) {
vector<int>dp(amount+1,MAX);
dp[0]=0;
for(int i=1;i<=amount;i++){
for(int j=0;j<coins.size();j++){
if(coins[j]<=i){
dp[i]=min(dp[i],dp[i-coins[j]]+1);
}
}
}
return dp[amount]==MAX?-1:dp[amount];
}
};
时间复杂度:O(Sn),其中 S是金额,n 是面额数。我们一共需要计算 O(S) 个状态,S 为题目所给的总金额。对于每个状态,每次需要枚举 n 个面额来转移状态,所以一共需要 O(Sn) 的时间复杂度。
空间复杂度:O(S)。数组 dp 需要开长度为总金额 S 的空间。
解法二:记忆化搜索
我们能改进上面的指数时间复杂度的解吗?当然可以,利用动态规划,我们可以在多项式的时间范围内求解。首先,我们定义:
F(S):组成金额 S 所需的最少硬币数量
[ c 0 … c n − 1 ] [c_{0}\ldots c_{n-1}] [c0…cn−1]:可选的 n 枚硬币面额值
我们注意到这个问题有一个最优的子结构性质,这是解决动态规划问题的关键。最优解可以从其子问题的最优解构造出来。如何将问题分解成子问题?假设我们知道 F(S),即组成金额 S 最少的硬币数,最后一枚硬币的面值是 C。那么由于问题的最优子结构,转移方程应为:
F
(
S
)
=
F
(
S
−
C
)
+
1
F(S)=F(S−C)+1
F(S)=F(S−C)+1
但我们不知道最后一枚硬币的面值是多少,所以我们需要枚举每个硬币面额值
c
0
,
c
1
,
c
2
…
c
n
−
1
c_0, c_1, c_2 \ldots c_{n -1}
c0,c1,c2…cn−1并选择其中的最小值。下列递推关系成立:
F
(
S
)
=
min
i
=
0...
n
−
1
F
(
S
−
c
i
)
+
1
subject to
S
−
c
i
≥
0
F(S) = \min_{i=0 ... n-1}{ F(S - c_i) } + 1 \ \text{subject to} \ \ S-c_i \geq 0
F(S)=mini=0...n−1F(S−ci)+1 subject to S−ci≥0
F
(
S
)
=
0
,
when
S
=
0
F(S) = 0 \ , \text{when} \ S = 0
F(S)=0 ,when S=0
F
(
S
)
=
−
1
,
when
n
=
0
F(S) = -1 \ , \text{when} \ n = 0
F(S)=−1 ,when n=0
在上面的递归树中,我们可以看到许多子问题被多次计算。例如,F(1)F(1)F(1) 被计算了 131 次。为了避免重复的计算,我们将每个子问题的答案存在一个数组中进行记忆化,如果下次还要计算这个问题的值直接从数组中取出返回即可,这样能保证每个子问题最多只被计算一次。
class Solution {
vector<int>count;
int dp(vector<int>& coins, int rem) {
if (rem < 0) return -1;
if (rem == 0) return 0;
if (count[rem - 1] != 0) return count[rem - 1];
int Min = INT_MAX;
for (int coin:coins) {
int res = dp(coins, rem - coin);
if (res >= 0 && res < Min) {
Min = res + 1;
}
}
count[rem - 1] = Min == INT_MAX ? -1 : Min;
return count[rem - 1];
}
public:
int coinChange(vector<int>& coins, int amount) {
if (amount < 1) return 0;
count.resize(amount);
return dp(coins, amount);
}
};
141. 最长递增子序列
解法:动态规划
定义 dp[i] 为考虑前 i个元素,以第 i 个数字结尾的最长上升子序列的长度,注意 nums[i] 必须被选取。
我们从小到大计算 dp 数组的值,在计算 dp[i] 之前,我们已经计算出 dp[0…i−1]的值,则状态转移方程为:
dp[i]=max(dp[j])+1,其中 0≤j<i 且 num[j]<num[i]
即考虑往 dp[0…i−1]中最长的上升子序列后面再加一个 nums[i]。由于 dp[j] 代表 nums[0…j]中以 nums[j] 结尾的最长上升子序列,所以如果能从 dp[j]这个状态转移过来,那么 nums[i] 必然要大于nums[j],才能将 nums[i] 放在 nums[j] 后面以形成更长的上升子序列。
最后,整个数组的最长上升子序列即所有 dp[i] 中的最大值。
LIS length = max ( dp [ i ] ) , 其中 0 ≤ i < n \text{LIS}_{\textit{length}}= \max(\textit{dp}[i]), \text{其中} \, 0\leq i < n LISlength=max(dp[i]),其中0≤i<n
class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
int n=nums.size();
if(n==0){
return 0;
}
vector<int>dp(n,1);
int result=0;
for(int i=0;i<n;i++)
{
for(int j=0;j<i;j++){
if(nums[j]<nums[i]){
dp[i]=max(dp[i],dp[j]+1);
}
}
result=max(result,dp[i]);
}
return result;
}
};
时间复杂度: O ( n 2 ) O(n^2) O(n2),其中 n 为数组 nums 的长度。动态规划的状态数为 n,计算状态 dp[i] 时,需要 O(n) 的时间遍历 dp[0…i−1]的所有状态,所以总时间复杂度为 O ( n 2 ) O(n^2) O(n2)。
空间复杂度:O(n),需要额外使用长度为 n 的 dp数组。
解法二:动态规划+二分查找
解题思路:
降低复杂度切入点: 解法一中,遍历计算 dp列表需 O(N),计算每个 dp[k] 需 O(N)。
动态规划中,通过线性遍历来计算 dp的复杂度无法降低;
每轮计算中,需要通过线性遍历 [0,k)区间元素来得到 dp[k] 。我们考虑:是否可以通过重新设计状态定义,使整个 dp 为一个排序列表;这样在计算每个 dp[k] 时,就可以通过二分法遍历 [0,k) 区间元素,将此部分复杂度由 O(N)降至 O(logN。
设计思路:
新的状态定义:
我们考虑维护一个列表 tails个元素 tails[k]的值代表 长度为 k+1 的子序列尾部元素的值。
如 [1,4,6] 序列,长度为 1,2,3的子序列尾部元素值分别为 tails=[1,4,6]。
状态转移设计:
设常量数字 N,和随机数字 x,我们可以容易推出:当 N 越小时,N<x的几率越大。例如: N=0肯定比 N=1000更可能满足 N<x。
在遍历计算每个 tails[k],不断更新长度为 [1,k] 的子序列尾部元素值,始终保持每个尾部元素值最小 (例如 [1,5,3]), 遍历到元素 5时,长度为 2 的子序列尾部元素值为 5;当遍历到元素 3 时,尾部元素值应更新至 3,因为 3 遇到比它大的数字的几率更大)。
tails 列表一定是严格递增的: 即当尽可能使每个子序列尾部元素值最小的前提下,子序列越长,其序列尾部元素值一定更大。
反证法证明: 当 k<i,若 tails[k]>=tails[i],代表较短子序列的尾部元素的值 >较长子序列的尾部元素的值。这是不可能的,因为从长度为 iii 的子序列尾部倒序删除 i−1 个元素,剩下的为长度为 k 的子序列,设此序列尾部元素值为 v,则一定有 v<tails[i] (即长度为 k 的子序列尾部元素值一定更小), 这和 tails[k]>=tails[i]矛盾。
既然严格递增,每轮计算 tails[k] 时就可以使用二分法查找需要更新的尾部元素值的对应索引 i。
算法流程:
状态定义:
tails[k] 的值代表 长度为 k+1子序列 的尾部元素值。
转移方程: 设 res为 tails当前长度,代表直到当前的最长上升子序列长度。设 j∈[0,res),考虑每轮遍历 nums[k]时,通过二分法遍历 [0,res) 列表区间,找出 nums[k]的大小分界点,会出现两种情况:
区间中存在 tails[i]>nums[k]: 将第一个满足 tails[i]>nums[k] 执行 tails[i]=nums[k];因为更小的 nums[k] 后更可能接一个比它大的数字(前面分析过)。
区间中不存在 tails[i]>nums[k]: 意味着 nums[k]nums[k]nums[k] 可以接在前面所有长度的子序列之后,因此肯定是接到最长的后面(长度为 res ),新子序列长度为 res+1。
初始状态:
令 tails 列表所有值 =0。
返回值:返回 res ,即最长上升子子序列长度。
class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
int n=nums.size();
vector<int>tail(n,0);
int res=0;
for(int num:nums){
int i=0,j=res;
while(i<j){
int mid=(i+j)/2;
if(tail[mid]<num)
i=mid+1;
else
j=mid;
}
//找到了tail[mid]>=num的i的位置
tail[i]=num;
if(res==j)
res++;
}
return res;
}
};
时间复杂度 O(NlogN) : 遍历 nums 列表需 O(N),在每个 nums[i]二分法需 O(logN)。
空间复杂度 O(N) : tails 列表占用线性大小额外空间。
多维动态规划
142. 三角形最小路径和
解法一:动态规划
前言
本题是一道非常经典且历史悠久的动态规划题,其作为算法题出现,最早可以追溯到 1994 年的 IOI(国际信息学奥林匹克竞赛)的 The Triangle。时光飞逝,经过 20 多年的沉淀,往日的国际竞赛题如今已经变成了动态规划的入门必做题,不断督促着我们学习和巩固算法。
在本题中,给定的三角形的行数为 n,并且第 i 行(从 0 开始编号)包含了 i+1 个数。如果将每一行的左端对齐,那么会形成一个等腰直角三角形,如下所示:
[2]
[3,4]
[6,5,7]
[4,1,8,3]
思路与算法
我们用 f [ i ] [ j ] f[i][j] f[i][j] 表示从三角形顶部走到位置 (i,j) 的最小路径和。这里的位置 (i,j) 指的是三角形中第 i 行第 j 列(均从 0 开始编号)的位置。
由于每一步只能移动到下一行「相邻的节点」上,因此要想走到位置 (i,j),上一步就只能在位置 (i−1,j−1) 或者位置 (i−1,j)。我们在这两个位置中选择一个路径和较小的来进行转移,状态转移方程为:
f
[
i
]
[
j
]
=
m
i
n
(
f
[
i
−
1
]
[
j
−
1
]
,
f
[
i
−
1
]
[
j
]
)
+
c
[
i
]
[
j
]
f[i][j]=min(f[i−1][j−1],f[i−1][j])+c[i][j]
f[i][j]=min(f[i−1][j−1],f[i−1][j])+c[i][j]
其中
c
[
i
]
[
j
]
c[i][j]
c[i][j] 表示位置 (i,j) 对应的元素值。
注意第 i 行有 i+1 个元素,它们对应的 j 的范围为 [0,i]。当 j=0 或 j=i 时,上述状态转移方程中有一些项是没有意义的。例如当 j=0 时, f [ i − 1 ] [ j − 1 ] f[i−1][j−1] f[i−1][j−1] 没有意义,因此状态转移方程为:
f
[
i
]
[
0
]
=
f
[
i
−
1
]
[
0
]
+
c
[
i
]
[
0
]
f[i][0]=f[i−1][0]+c[i][0]
f[i][0]=f[i−1][0]+c[i][0]
即当我们在第 i 行的最左侧时,我们只能从第 i−1 行的最左侧移动过来。当 j=i 时,f[i−1][j] 没有意义,因此状态转移方程为:
f
[
i
]
[
i
]
=
f
[
i
−
1
]
[
i
−
1
]
+
c
[
i
]
[
i
]
f[i][i]=f[i−1][i−1]+c[i][i]
f[i][i]=f[i−1][i−1]+c[i][i]
即当我们在第 i 行的最右侧时,我们只能从第 i−1 行的最右侧移动过来。
最终的答案即为 f [ n − 1 ] [ 0 ] f[n−1][0] f[n−1][0] 到 f [ n − 1 ] [ n − 1 ] f[n−1][n−1] f[n−1][n−1] 中的最小值,其中 n 是三角形的行数。
细节
状态转移方程的边界条件是什么?由于我们已经去除了所有「没有意义」的状态,因此边界条件可以定为:
f
[
0
]
[
0
]
=
c
[
0
]
[
0
]
f[0][0]=c[0][0]
f[0][0]=c[0][0]
即在三角形的顶部时,最小路径和就等于对应位置的元素值。这样一来,我们从 1 开始递增地枚举 i,并在 [0,i] 的范围内递增地枚举 j,就可以完成所有状态的计算。
class Solution {
public:
int minimumTotal(vector<vector<int>>& triangle) {
int n=triangle.size();
vector<vector<int>>dp(n,vector<int>(n));
dp[0][0]=triangle[0][0];
for(int i=1;i<n;i++){
dp[i][0]=dp[i-1][0]+triangle[i][0];
for(int j=1;j<i;j++){
dp[i][j]=min(dp[i-1][j-1],dp[i-1][j])+triangle[i][j];
}
dp[i][i]=dp[i-1][i-1]+triangle[i][i];
}
int min_res=INT_MAX;
for(int j=0;j<n;j++){
min_res=min(min_res,dp[n-1][j]);
}
return min_res;
}
};
- 时间复杂度: O ( n 2 ) O(n^2) O(n2),其中 n 是三角形的行数。
- 空间复杂度: O ( n 2 ) O(n^2) O(n2)。我们需要一个 n ∗ n n*n n∗n 的二维数组存放所有的状态。
解法二:动态规划+空间优化
思路与算法
在题目描述中的「进阶」部分,提到了可以将空间复杂度优化至 O(n)。
我们回顾方法一中的状态转移方程:
f
[
i
]
[
j
]
=
{
f
[
i
−
1
]
[
0
]
+
c
[
i
]
[
0
]
,
j
=
0
f
[
i
−
1
]
[
i
−
1
]
+
c
[
i
]
[
i
]
,
j
=
i
min
(
f
[
i
−
1
]
[
j
−
1
]
,
f
[
i
−
1
]
[
j
]
)
+
c
[
i
]
[
j
]
,
otherwise
f[i][j] = \begin{cases} f[i-1][0] + c[i][0], & j = 0 \\ f[i-1][i-1] + c[i][i], & j = i \\ \min(f[i-1][j-1], f[i-1][j]) + c[i][j], & \text{otherwise} \end{cases}
f[i][j]=⎩
⎨
⎧f[i−1][0]+c[i][0],f[i−1][i−1]+c[i][i],min(f[i−1][j−1],f[i−1][j])+c[i][j],j=0j=iotherwise
可以发现,
f
[
i
]
[
j
]
f[i][j]
f[i][j] 只与
f
[
i
−
1
]
[
.
.
]
f[i−1][..]
f[i−1][..] 有关,而与
f
[
i
−
2
]
[
.
.
]
f[i−2][..]
f[i−2][..] 及之前的状态无关,因此我们不必存储这些无关的状态。具体地,我们使用两个长度为 n 的一维数组进行转移,将 i 根据奇偶性映射到其中一个一维数组,那么 i−1 就映射到了另一个一维数组。这样我们使用这两个一维数组,交替地进行状态转移。
class Solution {
public:
int minimumTotal(vector<vector<int>>& triangle) {
int n = triangle.size();
vector<vector<int>> f(2, vector<int>(n));
f[0][0] = triangle[0][0];
for (int i = 1; i < n; ++i) {
int curr = i % 2;
int prev = 1 - curr;
f[curr][0] = f[prev][0] + triangle[i][0];
for (int j = 1; j < i; ++j) {
f[curr][j] = min(f[prev][j - 1], f[prev][j]) + triangle[i][j];
}
f[curr][i] = f[prev][i - 1] + triangle[i][i];
}
return *min_element(f[(n - 1) % 2].begin(), f[(n - 1) % 2].end());
}
};
上述方法的空间复杂度为 O(n),使用了 2n 的空间存储状态。我们还可以继续进行优化吗?
答案是可以的。我们从 i 到 0 递减地枚举 j,这样我们只需要一个长度为 n 的一维数组 f,就可以完成状态转移。
为什么只有在递减地枚举 j 时,才能省去一个一维数组?当我们在计算位置 (i,j) 时,f[j+1] 到 f[i] 已经是第 i 行的值,而 f[0] 到 f[j] 仍然是第 i−1 行的值。此时我们直接通过
f[j]=min(f[j−1],f[j])+c[i][j]
进行转移,恰好就是在 (i−1,j−1) 和 (i−1,j) 中进行选择。但如果我们递增地枚举 j,那么在计算位置 (i,j) 时,f[0] 到 f[j−1] 已经是第 i 行的值。如果我们仍然使用上述状态转移方程,那么是在 (i,j−1) 和 (i−1,j) 中进行选择,就产生了错误。
这样虽然空间复杂度仍然为 O(n),但我们只使用了 n 的空间存储状态,减少了一半的空间消耗。
class Solution {
public:
int minimumTotal(vector<vector<int>>& triangle) {
int n = triangle.size();
vector<int> f(n);
f[0] = triangle[0][0];
for (int i = 1; i < n; ++i) {
f[i] = f[i - 1] + triangle[i][i];
for (int j = i - 1; j > 0; --j) {
f[j] = min(f[j - 1], f[j]) + triangle[i][j];
}
f[0] += triangle[i][0];
}
return *min_element(f.begin(), f.end());
}
};
- 时间复杂度: O ( n 2 ) O(n^2) O(n2),其中 n 是三角形的行数。
- 空间复杂度: O ( n ) O(n) O(n)。
143. 最小路径和
解法:动态规划
由于路径的方向只能是向下或向右,因此网格的第一行的每个元素只能从左上角元素开始向右移动到达,网格的第一列的每个元素只能从左上角元素开始向下移动到达,此时的路径是唯一的,因此每个元素对应的最小路径和即为对应的路径上的数字总和。
对于不在第一行和第一列的元素,可以从其上方相邻元素向下移动一步到达,或者从其左方相邻元素向右移动一步到达,元素对应的最小路径和等于其上方相邻元素与其左方相邻元素两者对应的最小路径和中的最小值加上当前元素的值。由于每个元素对应的最小路径和与其相邻元素对应的最小路径和有关,因此可以使用动态规划求解。
创建二维数组dp ,与原始网格的大小相同, dp [ i ] [ j ] \textit{dp}[i][j] dp[i][j]表示从左上角出发到 (i,j)位置的最小路径和。显然, dp [ 0 ] [ 0 ] = grid [ 0 ] [ 0 ] \textit{dp}[0][0]=\textit{grid}[0][0] dp[0][0]=grid[0][0][0]。对于dp 中的其余元素,通过以下状态转移方程计算元素值。
1.当 i>0 且 j=0时, dp [ i ] [ 0 ] = dp [ i − 1 ] [ 0 ] + grid [ i ] [ 0 ] \textit{dp}[i][0]=\textit{dp}[i-1][0]+\textit{grid}[i][0] dp[i][0]=dp[i−1][0]+grid[i][0]。
2.当 i=0 且 j>0时, dp [ 0 ] [ j ] = dp [ 0 ] [ j − 1 ] + grid [ 0 ] [ j ] \textit{dp}[0][j]=\textit{dp}[0][j-1]+\textit{grid}[0][j] dp[0][j]=dp[0][j−1]+grid[0][j]。
3.当 i>0且 j>0 时, dp [ i ] [ j ] = min ( dp [ i − 1 ] [ j ] , dp [ i ] [ j − 1 ] ) + grid [ i ] [ j ] \textit{dp}[i][j]=\min(\textit{dp}[i-1][j],\textit{dp}[i][j-1])+\textit{grid}[i][j] dp[i][j]=min(dp[i−1][j],dp[i][j−1])+grid[i][j]
最后得到 dp [ m − 1 ] [ n − 1 ] \textit{dp}[m-1][n-1] dp[m−1][n−1]的值即为从网格左上角到网格右下角的最小路径和。
注意情况1和情况2可以包含和情况3采用相同的写法,即将dp全部初始化为一个非常大的值即可
class Solution {
public:
int minPathSum(vector<vector<int>>& grid) {
int m=grid.size();
int n=grid[0].size();
vector<vector<int>>dp(m,vector<int>(n,1000));
dp[0][0]=grid[0][0];
for(int i=0;i<m;i++)
for(int j=0;j<n;j++){
if(i-1>=0){
dp[i][j]=min(dp[i][j],dp[i-1][j]+grid[i][j]);
}
if(j-1>=0)
{
dp[i][j]=min(dp[i][j],dp[i][j-1]+grid[i][j]);
}
}
return dp[m-1][n-1];
}
};
时间复杂度:O(mn),其中 m 和 n 分别是网格的行数和列数。需要对整个网格遍历一次,计算 dp 的每个元素的值。
空间复杂度:O(mn),其中 m 和 n 分别是网格的行数和列数。创建一个二维数组 dp,和网格大小相同。
144. 不同路径||
解法一:动态规划(二维数组)
我们用 f(i,j) 来表示从坐标 (0,0) 到坐标 (i,j) 的路径总数,u(i,j) 表示坐标 (i,j) 是否可行,如果坐标 (i,j) 有障碍物,u(i,j)=0,否则 u(i,j)=1。
因为「机器人每次只能向下或者向右移动一步」,所以从坐标 (0,0) 到坐标 (i,j) 的路径总数的值只取决于从坐标 (0,0) 到坐标 (i−1,j) 的路径总数和从坐标 (0,0) 到坐标 (i,j−1) 的路径总数,即 f(i,j) 只能通过 f(i−1,j) 和 f(i,j−1) 转移得到。当坐标 (i,j) 本身有障碍的时候,任何路径都到到不了 f(i,j),此时 f(i,j)=0;下面我们来讨论坐标 (i,j) 没有障碍的情况:如果坐标 (i−1,j) 没有障碍,那么就意味着从坐标 (i−1,j) 可以走到 (i,j),即 (i−1,j) 位置对 f(i,j) 的贡献为 f(i−1,j),同理,当坐标 (i,j−1) 没有障碍的时候,(i,j−1) 位置对 f(i,j) 的贡献为 f(i,j−1)。综上所述,我们可以得到这样的动态规划转移方程:
f [ i ] [ j ] = { 0 , u ( i , j ) = 0 f [ i − 1 ] [ j ] + f [ i ] [ j − 1 ] , u [ i ] [ j ] ≠ 0 f[i][j] =\begin{cases} 0, &u(i,j) = 0 \\ f[i-1][j] + f[i][j-1], & u[i][j] \neq 0 \end{cases} f[i][j]={0,f[i−1][j]+f[i][j−1],u(i,j)=0u[i][j]=0
注意:这时候要关注到 f [ i − 1 ] [ j ] f[i-1][j] f[i−1][j]和 f [ i ] [ j − 1 ] f[i][j-1] f[i][j−1]的边界条件,在计算 f [ i ] [ j ] f[i][j] f[i][j] 时,需要对边界情况进行处理。例如:
• 如果 i == 0,则 f [ i − 1 ] [ j ] f[i-1][j] f[i−1][j] 不存在;
• 如果 j == 0,则 $f[i][j-1] $不存在。
所以应初始化第一行和第一列:
• 如果某格是障碍物,后续的格子路径数应为 0。
• 如果某格不是障碍物,路径数依赖前一格的值。
class Solution {
public:
int uniquePathsWithObstacles(vector<vector<int>>& obstacleGrid) {
int n=obstacleGrid.size();
int m=obstacleGrid[0].size();
vector<vector<int>>dp(n,vector<int>(m,0));
dp[0][0]=(obstacleGrid[0][0]==0);
//初始化第一列
for (int i = 1; i < n; i++) {
dp[i][0] = (obstacleGrid[i][0] == 0 && dp[i-1][0] == 1) ? 1 : 0;
}
//初始化第一行
for (int j = 1; j < m; j++) {
dp[0][j] = (obstacleGrid[0][j] == 0 && dp[0][j-1] == 1) ? 1 : 0;
}
for(int i=1;i<n;i++)
for(int j=1;j<m;j++){
if(obstacleGrid[i][j]==1){
dp[i][j]=0;
}
else{
dp[i][j]=dp[i-1][j]+dp[i][j-1];
}
}
return dp[n-1][m-1];
}
};
时间复杂度:O(nm)
空间复杂度:O(nm)
解法二:动态规划+一维数组
在原二维动态规划解法中,dp[i][j] 表示从起点 (0, 0) 到达网格 (i, j) 的路径数量。状态转移方程是:
• 如果 (i, j) 有障碍物,则路径数为 0:
d p [ i ] [ j ] = 0 if obstacleGrid [ i ] [ j ] = 1 dp[i][j] = 0 \quad \text{if } \text{obstacleGrid}[i][j] = 1 dp[i][j]=0if obstacleGrid[i][j]=1
• 如果没有障碍物:
d p [ i ] [ j ] = d p [ i − 1 ] [ j ] + d p [ i ] [ j − 1 ] dp[i][j] = dp[i-1][j] + dp[i][j-1] dp[i][j]=dp[i−1][j]+dp[i][j−1]
然而,二维数组在每次计算 d p [ i ] [ j ] dp[i][j] dp[i][j] 时,只需要用到当前行和上一行的值。因此,可以将 dp 优化为一维数组,只保留上一行的状态。
优化为一维数组的思路
我们用一维数组 dp[j] 来表示到达当前行的第 j 列的路径数量。具体来说:
• 遍历到第 i 行时,dp[j] 会保存上一行的路径数量(即 d p [ i − 1 ] [ j ] dp[i-1][j] dp[i−1][j])。
• 同时,dp[j] 会被更新为当前行的值(即 d p [ i ] [ j ] dp[i][j] dp[i][j])。
状态更新细节
对于状态转移方程:
d p [ i ] [ j ] = d p [ i − 1 ] [ j ] + d p [ i ] [ j − 1 ] dp[i][j] = dp[i-1][j] + dp[i][j-1] dp[i][j]=dp[i−1][j]+dp[i][j−1]
在一维数组中可以表示为:
d p [ j ] = d p [ j ] + d p [ j − 1 ] dp[j] = dp[j] + dp[j-1] dp[j]=dp[j]+dp[j−1]
其中:
• dp[j] 表示上一行的 d p [ i − 1 ] [ j ] dp[i-1][j] dp[i−1][j]。
• dp[j-1] 表示当前行左侧的 d p [ i ] [ j − 1 ] dp[i][j-1] dp[i][j−1]。
注意:需要从左到右更新 dp[j],因为 dp[j-1] 在当前行计算时仍然需要是上一行的值。
class Solution {
public:
int uniquePathsWithObstacles(vector<vector<int>>& obstacleGrid) {
int n = obstacleGrid.size();
int m = obstacleGrid[0].size();
vector<int> dp(m, 0);
dp[0] = (obstacleGrid[0][0] == 0);
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
if (obstacleGrid[i][j] == 1) {
dp[j] = 0;
} else if (j > 0) {
dp[j] += dp[j-1];
}
}
}
return dp[m-1];
}
};
时间复杂度:O(nm),其中 n 为网格的行数,m 为网格的列数。我们只需要遍历所有网格一次即可。
空间复杂度:O(m)。利用滚动数组优化,我们可以只用 O(m) 大小的空间来记录当前行的 f 值。
145. 最长回文子串
解法一:动态规划
第 1 步:定义状态
d p [ i ] [ j ] dp[i][j] dp[i][j] 表示子串 s[i…j] 是否为回文子串,这里子串 s[i…j] 定义为左闭右闭区间,可以取到 s[i] 和 s[j]。
第 2 步:思考状态转移方程
在这一步分类讨论(根据头尾字符是否相等),根据上面的分析得到:
d p [ i ] [ j ] = ( s [ i ] = = s [ j ] ) a n d d p [ i + 1 ] [ j − 1 ] dp[i][j] = (s[i] == s[j])\ and\ dp[i + 1][j - 1] dp[i][j]=(s[i]==s[j]) and dp[i+1][j−1]
说明:
「动态规划」事实上是在填一张二维表格,由于构成子串,因此 i 和 j 的关系是 i <= j ,因此,只需要填这张表格对角线以上的部分。
看到 d p [ i + 1 ] [ j − 1 ] dp[i + 1][j - 1] dp[i+1][j−1] 就得考虑边界情况。
边界条件是:表达式 [i + 1, j - 1] 不构成区间,即长度严格小于 2,即 j - 1 - (i + 1) + 1 < 2 ,整理得 j - i < 3。
这个结论很显然:j - i < 3 等价于 j - i + 1 < 4,即当子串 s[i…j] 的长度等于 2 或者等于 3 的时候,其实只需要判断一下头尾两个字符是否相等就可以直接下结论了。
如果子串 s[i + 1…j - 1] 只有 1 个字符,即去掉两头,剩下中间部分只有 1 个字符,显然是回文;
如果子串 s[i + 1…j - 1] 为空串,那么子串 s[i, j] 一定是回文子串。
因此,在 s[i] == s[j] 成立和 j - i < 3 的前提下,直接可以下结论, d p [ i ] [ j ] = t r u e dp[i][j] = true dp[i][j]=true,否则才执行状态转移。
第 3 步:考虑初始化
初始化的时候,单个字符一定是回文串,因此把对角线先初始化为 true,即 d p [ i ] [ i ] = t r u e dp[i][i] = true dp[i][i]=true。
事实上,初始化的部分都可以省去。因为只有一个字符的时候一定是回文,dp[i][i] 根本不会被其它状态值所参考。
第 4 步:考虑输出
只要一得到 d p [ i ] [ j ] = t r u e dp[i][j] = true dp[i][j]=true,就记录子串的长度和起始位置,没有必要截取,这是因为截取字符串也要消耗性能,记录此时的回文子串的「起始位置」和「回文长度」即可。
例如字符串"abaca"
0 | 1 | 2 | 3 | 4 | |
---|---|---|---|---|---|
0 | T | F | T | F | F |
1 | T | F | F | F | |
2 | T | F | F | ||
3 | T | F | |||
4 | T |
代码:
class Solution {
public:
string longestPalindrome(string s) {
vector<vector<bool>>a(s.size(),vector<bool>(s.size()));
int max=1;
int left=0;
if(s.size()<2)
return s;
for(int i=0;i<s.size();i++){
a[i][i]=true;
}
for(int j=1;j<s.size();j++){
for(int i=0;i<j;i++){
if(s[i]!=s[j])
a[i][j]=false;
else{
if(j-i<3)
a[i][j]=true;
else
a[i][j]=a[i+1][j-1];
}
if(a[i][j]==true&&(j-i+1)>max){
max=j-i+1;
left=i;
}
}
}
return s.substr(left,max);
}
};
时间复杂度: O ( n 2 ) O(n^2) O(n2),其中 n是字符串的长度。动态规划的状态总数为 O ( n 2 ) O(n^2) O(n2),对于每个状态,我们需要转移的时间为 O(1)。
空间复杂度: O ( n 2 ) O(n^2) O(n2),即存储动态规划状态需要的空间
解法二:中心扩散法
除了枚举字符串的左右边界以外,比较容易想到的是枚举可能出现的回文子串的“中心位置”,从“中心位置”尝试尽可能扩散出去,得到一个回文串。
因此中心扩散法的思路是:遍历每一个索引,以这个索引为中心,利用“回文串”中心对称的特点,往两边扩散,看最多能扩散多远
在这里要注意一个细节:回文串在长度为奇数和偶数的时候,“回文中心”的形式是不一样的。
奇数回文串的“中心”是一个具体的字符,例如:回文串 “aba” 的中心是字符 “b”;
偶数回文串的“中心”是位于中间的两个字符的“空隙”,例如:回文串串 “abba” 的中心是两个 “b” 中间的那个“空隙”。
我们看一下一个字符串可能的回文子串的中心在哪里?
class Solution {
public:
string longestPalindrome(string s) {
int len=s.size();
if(len<2)
return s;
int max_len=1;
string res=s.substr(0,1);
for(int i=0;i<len-1;i++){
string oddStr=centerSpread(s,i,i);
string evenStr=centerSpread(s,i,i+1);
string maxLenstr=oddStr.size()>evenStr.size()?oddStr:evenStr;
if(maxLenstr.size()>max_len){
max_len=maxLenstr.size();
res=maxLenstr;
}
}
return res;
}
string centerSpread(const string &s,int left,int right){
//left==right 回文中心是字符,长度为奇数
//right=left+1 此时回文中心是一个空隙,回文串的长度是偶数
int len=s.size();
int i=left;
int j=right;
while(i>=0&&j<len){
if(s[i]==s[j]){
i--;
j++;
}
else{
break;
}
}
return s.substr(i+1,j-i-1);
}
};
时间复杂度: O ( n 2 ) O(n^2) O(n2),其中 n 是字符串的长度。长度为 1 和 2 的回文中心分别有 n 和 n−1 个,每个回文中心最多会向外扩展 O(n)次。
空间复杂度:O(1)。
146. 交错字符串
解法一:动态规划+二维数组
首先如果
∣
s
1
∣
+
∣
s
2
∣
≠
∣
s
3
∣
|s_1|+|s_2| \neq |s_3|
∣s1∣+∣s2∣=∣s3∣ ,那
s
3
s_3
s3必然不可能由
s
1
s_1
s1和
s
2
s_2
s2交错组成。在
∣
s
1
∣
+
∣
s
2
∣
s
3
∣
|s_1|+|s_2 |s_3|
∣s1∣+∣s2∣s3∣时,我们可以用动态规划来求解。我们定义 f(i,j) 表示
s
1
s_1
s1 的前 i 个元素和
s
2
s_2
s2的前 j 个元素是否能交错组成
s
3
s_3
s3的前 i+j 个元素。如果
s
1
s_1
s1的第 i 个元素和
s
3
s_3
s3的第 i+j 个元素相等,那么
s
1
s_1
s1的前 i 个元素和
s
2
s_2
s2的前 j 个元素是否能交错组成
s
3
s_3
s3的前 i+j 个元素取决于
s
1
s_1
s1的前 i−1 个元素和
s
2
s_2
s2的前 j 个元素是否能交错组成
s
3
s_3
s3 的前 i+j−1 个元素,即此时 f(i,j) 取决于 f(i−1,j),在此情况下如果 f(i−1,j) 为真,则 f(i,j) 也为真。同样的,如果
s
2
s_2
s2的第 j 个元素和
s
3
s_3
s3的第 i+j 个元素相等并且 f(i,j−1) 为真,则 f(i,j) 也为真。于是我们可以推导出这样的动态规划转移方程:
f ( i , j ) = [ f ( i − 1 , j ) a n d s 1 ( i − 1 ) = s 3 ( p ) ] o r [ f ( i , j − 1 ) a n d s 2 ( j − 1 ) = s 3 ( p ) ] f(i,j)=[f(i−1,j)\ and\ s_1(i−1)=s3(p)]\ or\ [f(i,j−1)and\ s_2(j−1)=s_3(p)] f(i,j)=[f(i−1,j) and s1(i−1)=s3(p)] or [f(i,j−1)and s2(j−1)=s3(p)]
其中 p=i+j−1。边界条件为 f(0,0)=True。
class Solution {
public:
bool isInterleave(string s1, string s2, string s3) {
vector<vector<int>>f(s1.size()+1,vector<int>(s2.size()+1,false));
int n=s1.size(),m=s2.size(),t=s3.size();
if(n+m!=t){
return false;
}
f[0][0]=true;
for(int i=0;i<=n;i++)
for(int j=0;j<=m;j++){
int p=i+j-1;
if(i>0){
f[i][j]|=(f[i-1][j]&&s1[i-1]==s3[p]);
}
if(j>0){
f[i][j]|=(f[i][j-1]&&s2[j-1]==s3[p]);
}
}
return f[n][m];
}
};
时间复杂度:O(nm)
空间复杂度:O(nm)
解法二:动态规划+一维数组
147. 编辑距离
解法:动态规划
我们可以对任意一个单词进行三种操作:
- 插入一个字符;
- 删除一个字符;
- 替换一个字符。
题目给定了两个单词,设为 A 和 B,这样我们就能够六种操作方法。
但我们可以发现,如果我们有单词 A 和单词 B:
- 对单词 A 删除一个字符和对单词 B 插入一个字符是等价的。例如当单词 A 为 doge,单词 B 为 dog 时,我们既可以删除单词 A 的最后一个字符 e,得到相同的 dog,也可以在单词 B 末尾添加一个字符 e,得到相同的 doge;
- 同理,对单词 B 删除一个字符和对单词 A 插入一个字符也是等价的;
- 对单词 A 替换一个字符和对单词 B 替换一个字符是等价的。例如当单词 A 为 bat,单词 B 为 cat 时,我们修改单词 A 的第一个字母 b -> c,和修改单词 B 的第一个字母 c -> b 是等价的。
这样以来,本质不同的操作实际上只有三种:
- 在单词 A 中插入一个字符;
- 在单词 B 中插入一个字符;
- 修改单词 A 的一个字符。
这样以来,我们就可以把原问题转化为规模较小的子问题。我们用 A = horse,B = ros 作为例子,来看一看是如何把这个问题转化为规模较小的若干子问题的。
-
在单词 A 中插入一个字符:如果我们知道 horse 到 ro 的编辑距离为 a,那么显然 horse 到 ros 的编辑距离不会超过 a + 1。这是因为我们可以在 a 次操作后将 horse 和 ro 变为相同的字符串,只需要额外的 1 次操作,在单词 A 的末尾添加字符 s,就能在 a + 1 次操作后将 horse 和 ro 变为相同的字符串;
-
在单词 B 中插入一个字符:如果我们知道 hors 到 ros 的编辑距离为 b,那么显然 horse 到 ros 的编辑距离不会超过 b + 1,原因同上;
-
修改单词 A 的一个字符:如果我们知道 hors 到 ro 的编辑距离为 c,那么显然 horse 到 ros 的编辑距离不会超过 c + 1,原因同上。
那么从 horse 变成 ros 的编辑距离应该为 min(a + 1, b + 1, c + 1)。
**注意:**为什么我们总是在单词 A 和 B 的末尾插入或者修改字符,能不能在其它的地方进行操作呢?答案是可以的,但是我们知道,操作的顺序是不影响最终的结果的。例如对于单词 cat,我们希望在 c 和 a 之间添加字符 d 并且将字符 t 修改为字符 b,那么这两个操作无论为什么顺序,都会得到最终的结果 cdab。
你可能觉得 horse 到 ro 这个问题也很难解决。但是没关系,我们可以继续用上面的方法拆分这个问题,对于这个问题拆分出来的所有子问题,我们也可以继续拆分,直到:
- 字符串 A 为空,如从 转换到 ro,显然编辑距离为字符串 B 的长度,这里是 2;
- 字符串 B 为空,如从 horse 转换到 ,显然编辑距离为字符串 A 的长度,这里是 5。
因此,我们就可以使用动态规划来解决这个问题了。我们用 D [ i ] [ j ] D[i][j] D[i][j] 表示 A 的前 i 个字母和 B 的前 j 个字母之间的编辑距离。
如上所述,当我们获得 D [ i ] [ j − 1 ] D[i][j-1] D[i][j−1],$D[i-1][j] $和 D [ i − 1 ] [ j − 1 ] D[i-1][j-1] D[i−1][j−1] 的值之后就可以计算出 D [ i ] [ j ] D[i][j] D[i][j]。
-
D [ i ] [ j − 1 ] D[i][j-1] D[i][j−1] 为 A 的前 i 个字符和 B 的前 j - 1 个字符编辑距离的子问题。即对于 B 的第 j 个字符,我们在 A 的末尾添加了一个相同的字符,那么 D [ i ] [ j ] D[i][j] D[i][j] 最小可以为 D [ i ] [ j − 1 ] + 1 D[i][j-1] + 1 D[i][j−1]+1;
-
D [ i − 1 ] [ j ] D[i-1][j] D[i−1][j] 为 A 的前 i - 1 个字符和 B 的前 j 个字符编辑距离的子问题。即对于 A 的第 i 个字符,我们在 B 的末尾添加了一个相同的字符,那么 D [ i ] [ j ] D[i][j] D[i][j] 最小可以为 D [ i − 1 ] [ j ] + 1 D[i-1][j] + 1 D[i−1][j]+1;
-
D [ i − 1 ] [ j − 1 ] D[i-1][j-1] D[i−1][j−1] 为 A 前 i - 1 个字符和 B 的前 j - 1 个字符编辑距离的子问题。即对于 B 的第 j 个字符,我们修改 A 的第 i 个字符使它们相同,那么 $D[i][j] $最小可以为 D [ i − 1 ] [ j − 1 ] + 1 D[i-1][j-1] + 1 D[i−1][j−1]+1。特别地,如果 A 的第 i 个字符和 B 的第 j 个字符原本就相同,那么我们实际上不需要进行修改操作。在这种情况下, D [ i ] [ j ] D[i][j] D[i][j] 最小可以为 D [ i − 1 ] [ j − 1 ] D[i-1][j-1] D[i−1][j−1]。
那么我们可以写出如下的状态转移方程:
若 A 和 B 的最后一个字母相同:
D [ i ] [ j ] = min ( D [ i ] [ j − 1 ] + 1 , D [ i − 1 ] [ j ] + 1 , D [ i − 1 ] [ j − 1 ] ) = 1 + min ( D [ i ] [ j − 1 ] , D [ i − 1 ] [ j ] , D [ i − 1 ] [ j − 1 ] − 1 ) \begin{aligned} D[i][j] &= \min(D[i][j - 1] + 1, D[i - 1][j]+1, D[i - 1][j - 1])\\ &= 1 + \min(D[i][j - 1], D[i - 1][j], D[i - 1][j - 1] - 1) \end{aligned} D[i][j]=min(D[i][j−1]+1,D[i−1][j]+1,D[i−1][j−1])=1+min(D[i][j−1],D[i−1][j],D[i−1][j−1]−1)
若 A 和 B 的最后一个字母不同:
D
[
i
]
[
j
]
=
1
+
min
(
D
[
i
]
[
j
−
1
]
,
D
[
i
−
1
]
[
j
]
,
D
[
i
−
1
]
[
j
−
1
]
)
D[i][j] = 1 + \min(D[i][j - 1], D[i - 1][j], D[i - 1][j - 1])
D[i][j]=1+min(D[i][j−1],D[i−1][j],D[i−1][j−1])
所以每一步结果都将基于上一步的计算结果,示意如下:
对于边界情况,一个空串和一个非空串的编辑距离为 D [ i ] [ 0 ] = i D[i][0] = i D[i][0]=i 和 D [ 0 ] [ j ] = j D[0][j] = j D[0][j]=j, D [ i ] [ 0 ] D[i][0] D[i][0] 相当于对 word1 执行 i 次删除操作, D [ 0 ] [ j ] D[0][j] D[0][j] 相当于对 word1执行 j 次插入操作。
class Solution {
public:
int minDistance(string word1, string word2) {
int n=word1.size();
int m=word2.size();
//空串
if(n*m==0)return n+m;
vector<vector<int>>dp(n+1,vector<int>(m+1));
for(int i=0;i<n+1;i++)
dp[i][0]=i;
for(int j=0;j<m+1;j++)
dp[0][j]=j;
for(int i=1;i<n+1;i++)
for(int j=1;j<m+1;j++){
int left=dp[i-1][j]+1;
int down=dp[i][j-1]+1;
int left_down=dp[i-1][j-1];
if(word1[i-1]!=word2[j-1])
left_down+=1;
dp[i][j]=min(left,min(down,left_down));
}
return dp[n][m];
}
};
时间复杂度 :O(mn),其中 m 为 word1 的长度,n 为 word2 的长度。
空间复杂度 :O(mn),我们需要大小为 O(mn)的 D数组来记录状态值。
148. 买卖股票的最佳时机III
解法一:动态规划
由于我们最多可以完成两笔交易,因此在任意一天结束之后,我们会处于以下五个状态中的一种:
- 未进行过任何操作;
- 只进行过一次买操作;
- 进行了一次买操作和一次卖操作,即完成了一笔交易;
- 在完成了一笔交易的前提下,进行了第二次买操作;
- 完成了全部两笔交易。
由于第一个状态的利润显然为 0,因此我们可以不用将其记录。对于剩下的四个状态,我们分别将它们的最大利润记为 b u y 1 buy_1 buy1 , s e l l 1 sell_1 sell1,
b u y 2 buy_2 buy2,以及 s e l l 2 sell_2 sell2。
如果我们知道了第 i−1 天结束后的这四个状态,那么如何通过状态转移方程得到第 i 天结束后的这四个状态呢?
对于 b u y 1 buy_1 buy1而言,在第 i 天我们可以不进行任何操作,保持不变,也可以在未进行任何操作的前提下以 prices[i] 的价格买入股票,那么 b u y 1 buy_1 buy1的状态转移方程即为:
b
u
y
1
=
m
a
x
b
u
y
1
′
,
−
p
r
i
c
e
s
[
i
]
buy_1=max{buy_1^′,−prices[i]}
buy1=maxbuy1′,−prices[i]
这里我们用
b
u
y
1
′
buy_1^′
buy1′表示第 i−1 天的状态,以便于和第 i 天的状态
b
u
y
1
buy_1
buy1进行区分。
对于 s e l l 1 sell_1 sell1而言,在第 i 天我们可以不进行任何操作,保持不变,也可以在只进行过一次买操作的前提下以 prices[i] 的价格卖出股票,那么 s e l l 1 sell_1 sell1
的状态转移方程即为:
s
e
l
l
1
=
m
a
x
s
e
l
l
1
′
,
b
u
y
1
′
+
p
r
i
c
e
s
[
i
]
sell_1=max{sell_1^′,buy_1^′+prices[i]}
sell1=maxsell1′,buy1′+prices[i]
同理我们可以得到
b
u
y
2
buy_2
buy2和
s
e
l
l
2
sell_2
sell2对应的状态转移方程:
b
u
y
2
=
m
a
x
b
u
y
2
′
,
s
e
l
l
1
′
−
p
r
i
c
e
s
[
i
]
buy_2=max{buy_2^′,sell_1^′−prices[i]}
buy2=maxbuy2′,sell1′−prices[i]
s
e
l
l
2
=
m
a
x
s
e
l
l
2
′
,
b
u
y
2
′
+
p
r
i
c
e
s
[
i
]
sell_2=max{sell_2^′,buy_2^′+prices[i]}
sell2=maxsell2′,buy2′+prices[i]
在考虑边界条件时,我们需要理解下面的这个事实:
无论题目中是否允许「在同一天买入并且卖出」这一操作,最终的答案都不会受到影响,这是因为这一操作带来的收益为零。
因此,在状态转移时,我们可以直接写成:
{ b u y 1 = max { b u y 1 , − p r i c e s [ i ] } s e l l 1 = max { s e l l 1 , b u y 1 + p r i c e s [ i ] } b u y 2 = max { b u y 2 , s e l l 1 − p r i c e s [ i ] } s e l l 2 = max { s e l l 2 , b u y 2 + p r i c e s [ i ] } \begin{cases} buy_1 = \max\{buy_1, -prices[i]\} \\ sell_1 = \max\{sell_1, buy_1 + prices[i]\} \\ buy_2 = \max\{buy_2, sell_1 - prices[i]\} \\ sell_2 = \max\{sell_2, buy_2 + prices[i]\} \end{cases} ⎩ ⎨ ⎧buy1=max{buy1,−prices[i]}sell1=max{sell1,buy1+prices[i]}buy2=max{buy2,sell1−prices[i]}sell2=max{sell2,buy2+prices[i]}
例如在计算 s e l l 1 sell_1 sell1时,我们直接使用 b u y 1 buy_1 buy1而不是 b u y 1 ′ buy_1^′ buy1′进行转移。 b u y 1 buy_1 buy1比 b u y 1 ′ buy_1^′ buy1′多考虑的是在第 i 天买入股票的情况,而转移到 s e l l 1 sell_1 sell1
时,考虑的是在第 i 天卖出股票的情况,这样在同一天买入并且卖出收益为零,不会对答案产生影响。同理对于 b u y 2 buy_2 buy2以及 s e l l 2 sell_2 sell2
,我们同样可以直接根据第 i 天计算出的值来进行状态转移。
那么对于边界条件,我们考虑第 i=0 天时的四个状态: b u y 1 buy_1 buy1即为以 prices[0] 的价格买入股票,因此 b u y 1 = − p r i c e s [ 0 ] buy_1=−prices[0] buy1=−prices[0]; s e l l 1 sell_1 sell1
即为在同一天买入并且卖出,因此 s e l l 1 = 0 sell_1=0 sell1=0; b u y 2 buy_2 buy2即为在同一天买入并且卖出后再以 prices[0] 的价格买入股票,因此 b u y 2 = − p r i c e s [ 0 ] buy_2 =−prices[0] buy2=−prices[0];同理可得 s e l l 2 = 0 sell_2=0 sell2=0。我们将这四个状态作为边界条件,从 i=1 开始进行动态规划,即可得到答案。
在动态规划结束后,由于我们可以进行不超过两笔交易,因此最终的答案在 0, s e l l 1 sell_1 sell1, s e l l 2 sell_2 sell2中,且为三者中的最大值。然而我们可以发现,由于在边界条件中 s e l l 1 sell_1 sell1和 s e l l 2 sell_2 sell2的值已经为 0,并且在状态转移的过程中我们维护的是最大值,因此 s e l l 1 sell_1 sell1和 s e l l 2 sell_2 sell2最终一定大于等于 0。同时,如果最优的情况对应的是恰好一笔交易,那么它也会因为我们在转移时允许在同一天买入并且卖出这一宽松的条件,从 s e l l 1 sell_1 sell1转移至 s e l l 2 sell_2 sell2 ,因此最终的答案即为 s e l l 2 sell_2 sell2。
class Solution {
public:
int maxProfit(vector<int>& prices) {
int n=prices.size();
int buy1=-prices[0],sell1=0;
int buy2=-prices[0],sell2=0;
for(int i=1;i<n;i++){
buy1=max(buy1,-prices[i]);
sell1=max(sell1,buy1+prices[i]);
buy2=max(buy2,sell1-prices[i]);
sell2=max(sell2,buy2+prices[i]);
}
return sell2;
}
};
- 时间复杂度:O(n),其中 n 是数组 prices 的长度。
- 空间复杂度:O(1)。
149. 买卖股票的最佳时机IV
与其余的股票问题类似,我们使用一系列变量存储「买入」的状态,再用一系列变量存储「卖出」的状态,通过动态规划的方法即可解决本题。
我们用 buy[i][j] 表示对于数组 prices[0…i] 中的价格而言,进行恰好 j 笔交易,并且当前手上持有一支股票,这种情况下的最大利润;用 sell[i][j] 表示恰好进行 j 笔交易,并且当前手上不持有股票,这种情况下的最大利润。
那么我们可以对状态转移方程进行推导。对于 b u y [ i ] [ j ] buy[i][j] buy[i][j],我们考虑当前手上持有的股票是否是在第 i 天买入的。如果是第 i 天买入的,那么在第 i−1 天时,我们手上不持有股票,对应状态 s e l l [ i − 1 ] [ j ] sell[i−1][j] sell[i−1][j],并且需要扣除 prices[i] 的买入花费;如果不是第 i 天买入的,那么在第 i−1 天时,我们手上持有股票,对应状态 b u y [ i − 1 ] [ j ] buy[i−1][j] buy[i−1][j]。那么我们可以得到状态转移方程:
b
u
y
[
i
]
[
j
]
=
m
a
x
b
u
y
[
i
−
1
]
[
j
]
,
s
e
l
l
[
i
−
1
]
[
j
]
−
p
r
i
c
e
[
i
]
buy[i][j]=max\ {buy[i−1][j],sell[i−1][j]−price[i]}
buy[i][j]=max buy[i−1][j],sell[i−1][j]−price[i]
同理对于
s
e
l
l
[
i
]
[
j
]
sell[i][j]
sell[i][j],如果是第 i 天卖出的,那么在第 i−1 天时,我们手上持有股票,对应状态
b
u
y
[
i
−
1
]
[
j
−
1
]
buy[i−1][j−1]
buy[i−1][j−1],并且需要增加 prices[i] 的卖出收益;如果不是第 i 天卖出的,那么在第 i−1 天时,我们手上不持有股票,对应状态
s
e
l
l
[
i
−
1
]
[
j
]
sell[i−1][j]
sell[i−1][j]。那么我们可以得到状态转移方程:
s
e
l
l
[
i
]
[
j
]
=
m
a
x
s
e
l
l
[
i
−
1
]
[
j
]
,
b
u
y
[
i
−
1
]
[
j
−
1
]
+
p
r
i
c
e
[
i
]
sell[i][j]=max\ {sell[i−1][j],buy[i−1][j−1]+price[i]}
sell[i][j]=max sell[i−1][j],buy[i−1][j−1]+price[i]
由于在所有的 n 天结束后,手上不持有股票对应的最大利润一定是严格由于手上持有股票对应的最大利润的,然而完成的交易数并不是越多越好(例如数组 prices 单调递减,我们不进行任何交易才是最优的),因此最终的答案即为
s
e
l
l
[
n
−
1
]
[
0..
k
]
sell[n−1][0..k]
sell[n−1][0..k] 中的最大值。
细节
在上述的状态转移方程中,确定边界条件是非常重要的步骤。我们可以考虑将所有的$ buy[0][0…k] 以及 以及 以及 sell[0][0…k] $设置为边界。
对于 b u y [ 0 ] [ 0.. k ] buy[0][0..k] buy[0][0..k],由于只有 prices[0] 唯一的股价,因此我们不可能进行过任何交易,那么我们可以将所有的 b u y [ 0 ] [ 1.. k ] buy[0][1..k] buy[0][1..k] 设置为一个非常小的值,表示不合法的状态。而对于 b u y [ 0 ] [ 0 ] buy[0][0] buy[0][0],它的值为 −prices[0],即「我们在第 0 天以 prices[0] 的价格买入股票」是唯一满足手上持有股票的方法。
对于 s e l l [ 0 ] [ 0.. k ] sell[0][0..k] sell[0][0..k],同理我们可以将所有的$ sell[0][1…k]$ 设置为一个非常小的值,表示不合法的状态。而对于 s e l l [ 0 ] [ 0 ] sell[0][0] sell[0][0],它的值为 0,即「我们在第 0 天不做任何事」是唯一满足手上不持有股票的方法。
在设置完边界之后,我们就可以使用二重循环,在 i∈[1,n),j∈[0,k] 的范围内进行状态转移。需要注意的是, s e l l [ i ] [ j ] sell[i][j] sell[i][j] 的状态转移方程中包含 b u y [ i − 1 ] [ j − 1 ] buy[i−1][j−1] buy[i−1][j−1],在 j=0 时其表示不合法的状态,因此在 j=0 时,我们无需对 s e l l [ i ] [ j ] sell[i][j] sell[i][j] 进行转移,让其保持值为 0 即可。
最后需要注意的是,本题中 k 的最大值可以达到 1 0 9 10^9 109,然而这是毫无意义的,因为 n 天最多只能进行 ⌊ n 2 ⌋ ⌊\frac{n}{2}⌋ ⌊2n⌋笔交易,其中 ⌊x⌋ 表示对 x 向下取整。因此我们可以将 k 对 ⌊ n 2 ⌋ ⌊\frac{n}{2}⌋ ⌊2n⌋ 取较小值之后再进行动态规划。
class Solution {
public:
int maxProfit(int k, vector<int>& prices) {
if (prices.empty()) {
return 0;
}
int n = prices.size();
k = min(k, n / 2);
vector<vector<int>> buy(n, vector<int>(k + 1));
vector<vector<int>> sell(n, vector<int>(k + 1));
buy[0][0] = -prices[0];
sell[0][0] = 0;
for (int i = 1; i <= k; ++i) {
buy[0][i] = sell[0][i] = INT_MIN / 2;
}
for (int i = 1; i < n; ++i) {
buy[i][0] = max(buy[i - 1][0], sell[i - 1][0] - prices[i]);
for (int j = 1; j <= k; ++j) {
buy[i][j] = max(buy[i - 1][j], sell[i - 1][j] - prices[i]);
sell[i][j] = max(sell[i - 1][j], buy[i - 1][j - 1] + prices[i]);
}
}
return *max_element(sell[n - 1].begin(), sell[n - 1].end());
}
};
时间复杂度:O(nm)
空间复杂度:O(nm)
解法二:动态规划+一维数组
150. 最大正方形
解法一:暴力
- 遍历矩阵中的每个元素,每次 遇到 1,则将该元素作为正方形的左上角;
- 确定正方形的左上角之后,根据左上角所在的航河流计算可能的最长正方形的边长,在该变长范围内寻找只包含 1 的最大正方形
- 每次在下方新增一行以及右方新增一列,判断新增的行和列是否满足所有元素都是 1
class Solution {
public:
int maximalSquare(vector<vector<char>>& matrix) {
if (matrix.size() == 0 || matrix[0].size() == 0) {
return 0;
}
int maxSide = 0;
int rows = matrix.size(), columns = matrix[0].size();
for (int i = 0; i < rows; i++) {
for (int j = 0; j < columns; j++) {
if (matrix[i][j] == '1') {
// 遇到一个 1 作为正方形的左上角
maxSide = max(maxSide, 1);
// 计算可能的最大正方形边长
int currentMaxSide = min(rows - i, columns - j);
for (int k = 1; k < currentMaxSide; k++) {
// 判断新增的一行一列是否均为 1
bool flag = true;
if (matrix[i + k][j + k] == '0') {
break;
}
for (int m = 0; m < k; m++) {
if (matrix[i + k][j + m] == '0' || matrix[i + m][j + k] == '0') {
flag = false;
break;
}
}
if (flag) {
maxSide = max(maxSide, k + 1);
} else {
break;
}
}
}
}
}
int maxSquare = maxSide * maxSide;
return maxSquare;
}
};
时间复杂度: O ( m n m i n ( m , n ) 2 ) O(mnmin(m,n)^2) O(mnmin(m,n)2),其中 m 和 n 是矩阵的行数和列数。
- 需要遍历整个矩阵寻找每个 1,遍历矩阵的时间复杂度是 O(mn)。
- 对于每个可能的正方形,其边长不超过 m 和 n 中的最小值,需要遍历该正方形中的每个元素判断是不是只包含 1,遍历正方形时间复杂度是
O
(
m
i
n
(
m
,
n
)
2
)
O(min(m,n)^2)
O(min(m,n)2)。
总时间复杂度是 O ( m n m i n ( m , n ) 2 ) O(mnmin(m,n)^2) O(mnmin(m,n)2)。
空间复杂度:O(1)。额外使用的空间复杂度为常数。
解法二:动态规划
用 dp(i,j) 表示以 (i,j) 为右下角,且只包含 1 的正方形的边长最大值。如果我们能计算出所有 dp(i,j) 的值,那么其中的最大值即为矩阵中只包含 1 的正方形的边长最大值,其平方即为最大正方形的面积。
那么如何计算 dp 中的每个元素值呢?对于每个位置 (i,j),检查在矩阵中该位置的值:
如果该位置的值是 0,则 dp(i,j)=0,因为当前位置不可能在由 1 组成的正方形中;
如果该位置的值是 1,则 dp(i,j) 的值由其上方、左方和左上方的三个相邻位置的 dp 值决定。具体而言,当前位置的元素值等于三个相邻位置的元素中的最小值加 1,状态转移方程如下:
d p ( i , j ) = m i n ( d p ( i − 1 , j ) , d p ( i − 1 , j − 1 ) , d p ( i , j − 1 ) ) + 1 dp(i,j)=min(dp(i−1,j),dp(i−1,j−1),dp(i,j−1))+1 dp(i,j)=min(dp(i−1,j),dp(i−1,j−1),dp(i,j−1))+1
此外,还需要考虑边界条件。如果 i 和 j 中至少有一个为 0,则以位置 (i,j) 为右下角的最大正方形的边长只能是 1,因此 dp(i,j)=1。
以下用一个例子具体说明。原始矩阵如下。
0 1 1 1 0
1 1 1 1 0
0 1 1 1 1
0 1 1 1 1
0 0 1 1 1
对应的 dp 值如下
0 1 1 1 0
1 1 2 2 0
0 1 2 3 1
0 1 2 3 2
0 0 1 2 3
下图也给出了计算 dp 值的过程
class Solution {
public:
int maximalSquare(vector<vector<char>>& matrix) {
if (matrix.size() == 0 || matrix[0].size() == 0) {
return 0;
}
int maxSide = 0;
int rows = matrix.size(), columns = matrix[0].size();
vector<vector<int>> dp(rows, vector<int>(columns));
for (int i = 0; i < rows; i++) {
for (int j = 0; j < columns; j++) {
if (matrix[i][j] == '1') {
if (i == 0 || j == 0) {
dp[i][j] = 1;
} else {
dp[i][j] = min(min(dp[i - 1][j], dp[i][j - 1]), dp[i - 1][j - 1]) + 1;
}
maxSide = max(maxSide, dp[i][j]);
}
}
}
int maxSquare = maxSide * maxSide;
return maxSquare;
}
};
时间复杂度:O(mn),其中 m 和 n 是矩阵的行数和列数。需要遍历原始矩阵中的每个元素计算 dp 的值。
空间复杂度:O(mn),其中 m 和 n 是矩阵的行数和列数。创建了一个和原始矩阵大小相同的矩阵 dp。由于状态转移方程中的 dp(i,j) 由其上方、左方和左上方的三个相邻位置的 dp 值决定,因此可以使用两个一维数组进行状态转移,空间复杂度优化至 O(n)。