
算法原理/思维链路
全排列的题目,明显是递归加回溯。递归函数头给一个path表示当前已经探索的路径,如果路径等于nums的size说明此时已经找到一个排列了,加入到结果中。否则,从nums数组中找一个当前没有在path中使用的数添加到path中,并向下递归。撤销选择回到上一个状态。
代码实现
cpp
class Solution {
vector<vector<int>> ans;
int n;
public:
bool notused(int num,vector<int>& path)
{
//这个每次都是O(N)的不好
for(int i = 0;i < path.size();i++)
{
if(path[i] == num)
return false;
}
return true;
}
void backtrack(vector<int>& path,vector<int>& nums)
{
if(path.size() == n)
{
ans.push_back(path);
return;
}
for(int i =0;i < n;i++)
{
//如果在path里面没有用过那就是可选项
if(notused(nums[i],path))
{
path.push_back(nums[i]); //有经验了
backtrack(path,nums);
path.pop_back();
}
}
}
vector<vector<int>> permute(vector<int>& nums) {
//算法:递归+回溯解决排列问题
vector<int> path;
n = nums.size();
backtrack(path,nums); //path:路径 nums:选择choice
return ans;
}
};
优化空间(结果导向)
思考两个问题。一、为什么传参的时候传引用而不是传值?二、每次判断数字是否使用过的时候都是O(N)遍历,这个能否优化?
对于第一个问题来说,传引用使得每次递归的时候这个path都使用的是同一个空间,避免了频繁的拷贝所花费的时间,因此在测试的时候是比传值要快很多。但是我印象中是有一个题目是在递归的时候就已经帮助我们进行回溯,当时它使用传值的,有点记不清了~,感觉本质还是对递归的理解不到位吧,后面再慢慢学。
然后是对于第二个问题,我们现在是时间花费比较多,比较自然的一个想法就是用空间换时间,比如用一个vis数组记录当前已经使用的下标。
cpp
class Solution {
vector<vector<int>> ans;
int n;
public:
void backtrack(vector<int>& path,vector<int>& nums,vector<bool>& vis)
{
if(path.size() == n)
{
ans.push_back(path);
return;
}
for(int i =0;i < n;i++)
{
//如果在path里面没有用过那就是可选项
if(!vis[i])
{
path.push_back(nums[i]); //有经验了
vis[i] = true;
backtrack(path,nums,vis);
path.pop_back();
vis[i] = false;
}
}
}
vector<vector<int>> permute(vector<int>& nums) {
//算法:递归+回溯解决排列问题
vector<int> path;
n = nums.size();
vector<bool> vis(7,0);
backtrack(path,nums,vis); //path:路径 nums:选择choice
return ans;
}
};
但是为什么优化完之后显示速度还没不优化快(哭),可能是力扣的特色?也可能是有其他的开销我没考虑到?
观察到nums数组的元素个数其实是固定的,也就个位数,所以其实查找的速度接近于O(1);而我们用vis数组之后会产生一个后果是什么呢?其实我感觉没啥印象,数组索引下标访问也是O(1)所以影响不大,因此这题没有必要优化查找速度因为本来就已经很优秀了,在本题条件下~
动态规划
题目描述
https://leetcode.cn/problems/minimum-path-sum/
算法原理/思维链路
在写这一题的时候我潜意识犯了一个致命的错误,我误以为题目要的就是从右下角到左上角的一条和最大的路径,因此我的状态表示就是dp[i][j]表示从i,j位置到右下角的路径中的最大值。但是事实上我们在行走的过程中每一步都要保证血量大于等于1,也就是说即使后面的加的血量再多如果说这一步我的血量为0了还是不行!因此状态表示应该改为dp[i][j]表示从i,j位置到右下角的最少需要血量,这其实就是重复子问题~也是我们为什么采取动态规划的原因。
转移方程:dp[i][j] = max(1,min(dp[i+1][j],dp[i][j+1])-dungeon[i][j]);
至此我们可以写代码了,为了方便初始化我们可以给dp表加一行和一列。
代码实现
cpp
class Solution {
public:
int calculateMinimumHP(vector<vector<int>>& dungeon) {
//错误! 题目等价于从右下角开始向左或向上走,返回总和的最大值,需要的就是一个数,这个数加上最大值为1即可
//算法原理:动态规划
//状态表示
//dp[i][j]表示从i,j位置到右下角的最少需要血量
//转移方程 dp[i][j] = max(1,min(dp[i+1][j],dp[i][j+1])-dungeon[i][j]);
//逆向dp
int m = dungeon.size();
int n = dungeon[0].size();
vector<vector<int>> dp(m+1,vector<int>(n+1,INT_MAX));
dp[m][n-1] = 1;
dp[m-1][n] = 1; //离开终点时血量至少为1
for(int i = m-1;i>=0;i--)
{
for(int j = n-1;j>=0;j--)
{
dp[i][j] = max(1,min(dp[i+1][j],dp[i][j+1])-dungeon[i][j]);
}
}
return dp[0][0];
}
};
优化与反思
这题确实出的特别好,很容易让人想当然以为只是找一条最优路线而错误的定义状态表示,只能说还得多练习吧。优化暂时就不写了,这题目前的解法都已经够消化一会了~
贪心
题目链接
https://leetcode.cn/problems/minimum-operations-to-halve-array-sum/
算法原理
这题我一看到的时候想到的就是贪心,每次选择最大的 数减半即可。遇到的第一个问题是给我们的数组的类型是int的,我们减半之后的数都是double的,因此需要一个其他的容器来存储,这个我想到的就是堆,一方面可以存储double类型的数据,另一方面是可以建立最大堆来每次得到堆顶的数据就是最大值,是一个O(1)的时间,不过堆的调整需要O(nlogN),这是否能接受?如果不能该如何调整?如果可以的话为什么可以?(成就导向)
代码实现
cpp
class Solution {
public:
int halveArray(vector<int>& nums) {
//贪心+最大堆
double total = 0.0;
priority_queue<double> pq; //默认是最大堆
for(auto i : nums)
{
pq.push(i/1.0);
total += i;
}
int count = 0;
double lesstotal = 0.0;
while(1)
{
int top = pq.top();
pq.pop();
top /= 2;
lesstotal += top;
pq.push(top);
count++;
if(lesstotal >= total/2)
break;
}
return count;
}
};
测试用例能过,但是在提交的时候出现预期是36,结果输出是37。由于两者差别不大,因此我觉得是精度问题导致的,因为代码中有很多强制类型转换可能会丢失精度。通过问ai发现是我在拿堆顶的时候错误的用int来接受,应该改为double接受。至此代码通过~
优化与反思(成就导向)
时间复杂度:O(n log n + k log n) 是否可接受?
可以接受,而且很难优化,原因如下:
- n ≤ 10^5,log n ≈ 17,堆操作很快
- k 的理论上界:每次至少减少当前最大值的 1/2,最坏情况 k ≈ log₂(总和的初始值) ≤ 60
- 总操作数 ≈ 200 万次堆调整,完全可接受
如果非要优化(成就进阶):
- 用
<font style="color:rgb(15, 17, 21);">multiset</font>或手动维护大根堆?堆已经是最优了 - 能否 O(n)?不行,因为操作顺序依赖动态最大值