4.16
41. 缺失的第一个正数 - 力扣(LeetCode)
由于题目要求我们「只能使用常数级别的空间」,提示使用原地哈希。分析题意可知:
要找的数一定在 [1, N + 1] 左闭右闭(这里 N 是数组的长度)这个区间里
我们要找的数就在
[1, N + 1]里,最后N + 1这个元素我们不用找。因为在前面的N个元素都找不到的情况下,我们才返回N + 1;所以只用找(0,n]即可if(nums[i] <= 0 || nums[i] > nums.size()) break;我们可以采取这样的思路:就把 1 这个数放到下标为 0 的位置, 2 这个数放到下标为 1 的位置,按照这种思路整理一遍数组。即
nums[i] == nums[nums[i] - 1]然后我们再遍历一次数组,第一个遇到的它的值不等于下标的那个数,就是我们要找的缺失的第一个正数。
这个思想就相当于我们自己编写哈希函数,这个哈希函数的规则特别简单,那就是数值为 i 的数映射到下标为 i - 1 的位置。
class Solution {
public:
int firstMissingPositive(vector<int>& nums) {
for (int i = 0; i < nums.size(); i++) {
while(nums[i] != i + 1){
if(nums[i] <= 0 || nums[i] > nums.size() || nums[i] == nums[nums[i] - 1]) break;
swap(nums[i] , nums[nums[i]- 1]);
}
}
for (int i = 0; i < nums.size(); i++) {
if(nums[i] != i + 1) return i + 1;
}
return nums.size() + 1;
}
};
补充题5. 手撕归并排序
归并排序是一种常用且高效的排序算法,采用分治法的思想来对数组或列表进行排序。归并排序的基本思想是将数组分成较小的子数组,递归地对这些子数组进行排序,然后将它们合并在一起,产生最终的有序数组。
归并排序是一种递归算法,将输入数组不断地分割成较小的子数组,直到每个子数组只有一个元素,这一个元素是有序的。
然后,将排好序的子数组合并在一起,产生较大的有序子数组。
这个分割和合并的过程一直重复,直到整个数组都排序完毕。
举个例子
假设我们要对一个数组[7, 2, 5, 3, 9, 8, 6]进行归并排序,具体步骤如下:
- 将数组划分为左右两个子数组:[7, 2, 5, 3]和[9, 8, 6]。
- 对左右两个子数组分别进行归并排序。这里以左边的子数组为例,右边的子数组同理。
a. 将左边的子数组[7, 2, 5, 3]再次划分为左右两个子数组:[7, 2]和[5, 3]。
b. 对左右两个子数组分别进行归并排序。这里以左边的子数组为例,右边的子数组同理。
i. 将左边的子数组[7, 2]再次划分为左右两个子数组:[7]和[2]。
ii. 将左右两个子数组[7]和[2]进行归并,得到有序的子数组[2, 7]。
c. 将步骤2.b中得到的左右两个有序子数组[2, 7]和[3, 5]进行归并,得到有序的子数组[2, 3, 5, 7]。- 对右边的子数组[9, 8, 6]进行归并排序,得到有序的子数组[6, 8, 9]。
- 将步骤2中得到的左右两个有序子数组[2, 3, 5, 7]和[6, 8, 9]进行归并,得到最终的有序数组[2, 3, 5, 6, 7, 8, 9]。
如何用代码实现
定义一个数组a,用来保存原数组
定义一个数组temp,用作归并排序过程中的临时数组。
归并排序:
void merge_sort(int a[], int l, int r): 对数组a的,l-r 的范围中的元素进行排序,也就是对 a[l -r]进行归并排序
- 边界处理:当
l >= r的时候, 说明l-r这个范围内只有一个元素或没有元素。一个元素的数组,是有序的,递归结束(同快速排序)。- 如果
l > r,则进行归并排序。- 有数组 q, 左端点 l, 右端点 r
- 确定划分边界 mid = l + r >> 1
- 递归处理子问题 q[l..mid], q[mid+1..r]
- 合并子问题 当左右半边都排好序后,需要合并左右半边数组。合并数组用的是双指针法
主体合并。至少有一个小数组添加到 tmp 数组中
收尾。可能存在的剩下的一个小数组的尾部直接添加到 tmp 数组中
将临时数组中的数,拷贝回原数组,排序结束。tmp 数组覆盖原数组
class Solution { vector<int> tmp; void mergeSort(vector<int>& a , int l , int r){ if(l == r) return;//这里写l >=r 也没问题 //找中点,不断分割数组直到只有一个元素 int mid = l + ((r - l) >> 1); mergeSort(a , l , mid); mergeSort(a , mid + 1 , r); int k = 0; int i = l , j = mid + 1; //用临时数组记录较小的元素 while(i <= mid && j <= r){ if(a[i] < a[j]) tmp[k++] = a[i ++]; else tmp[k ++] = a[j ++]; } //把剩余元素复制进临时数组 while(i <= mid) tmp[k ++] = a[i ++]; while(j <= r) tmp[k ++] = a[j ++]; //此时临时数组是排序好的数组,复制回原数组 k = 0; for(int i = l ; i <= r ; i ++) a[i] = tmp[k ++]; } public: vector<int> sortArray(vector<int>& nums) { int n = nums.size(); tmp.resize(n); mergeSort(nums , 0 , n - 1); return nums; } };
时间复杂度为O(n log n),其中n是输入数组中的元素个数。
空间复杂度为O(n),其中n是输入数组中的元素个数。
数位DP:
2376. 统计特殊整数 - 力扣(LeetCode)
前置知识:位运算与集合论
集合可以用二进制表示,二进制从低到高第 i 位为 1 表示 i 在集合中,为 0 表示 i 不在集合中。例如集合 {0,2,3} 对应的二进制数为 1101(2)。
设集合对应的二进制数为 x。本题需要用到两个位运算操作:
- 判断元素 d 是否在集合中:
x >> d & 1可以取出 x 的第 d 个比特位,如果是 1 就说明 d 在集合中。- 把元素 d 添加到集合中:将
x更新为x | (1 << d)。更多位运算的知识点,请看 从集合论到位运算,常见位运算技巧分类总结!
思路
将 n 转换成字符串 s,定义
dfs(i,mask,isLimit,isNum)表示构造第 i 位及其之后数位的合法方案数,其余参数的含义为:
mask表示前面选过的数字集合,换句话说,第 i 位要选的数字不能在 mask 中。isLimit表示当前是否受到了 n 的约束(注意要构造的数字不能超过 n)。若为真,则第 i 位填入的数字至多为 s[i],否则可以是 9。如果在受到约束的情况下填了 s[i],那么后续填入的数字仍会受到 n 的约束。例如 n=123,如果 i=0 填的是 1 的话,i=1 的这一位至多填 2。如果 i=0 填的是 1,i=1 填的是 2,那么 i=2 的这一位至多填 3。isNum表示 i 前面的数位是否填了数字。若为假,则当前位可以跳过(不填数字),或者要填入的数字至少为 1;若为真,则要填入的数字可以从 0 开始。例如 n=123,在 i=0 时跳过的话,相当于后面要构造的是一个 99 以内的数字了,如果 i=1 不跳过,那么相当于构造一个 10 到 99 的两位数,如果 i=1 跳过,相当于构造的是一个 9 以内的数字。- 为什么要定义 isNum?因为 010 和 10 都是 10,如果认为第一个 0 和第三个 0 都是我们填入的数字,这就不符合题目要求了,但 10 显然是符合题目要求的。
实现细节
递归入口:
dfs(i,mask,isLimit,isNum): dfs(0,0,true,false),表示:
- 从 s[0] 开始枚举;
- 一开始集合中没有数字(空集);
- 一开始要受到 n 的约束(否则就可以随意填了,这肯定不行);
- 一开始没有填数字。
递归中:
- 如果 isNum 为假,说明前面没有填数字,那么当前也可以不填数字。一旦从这里递归下去,isLimit 就可以置为
false了,这是因为 s[0] 必然是大于 0 的「原数字没有前导0」,后面就不受到 n 的约束了。或者说,最高位不填数字,后面无论怎么填都比 n 小。- 如果 isNum 为真,那么当前必须填一个数字。枚举填入的数字,根据 isNum 和 isLimit 来决定填入数字的范围。
递归终点:当 i 等于 s 长度时,如果 isNum 为真,则表示得到了一个合法数字(因为不合法的数字不会递归到终点),返回 1,否则返回 0。
答疑
问:isNum 这个参数可以去掉吗?
答:本题由于 mask 中记录了数字,可以通过判断 mask 是否为 0(空集)来判断前面是否填了数字,所以对于本题来说,isNum 可以省略。
下面的代码保留了 isNum,主要是为了方便大家掌握这个模板。因为有些题目不需要 mask,但需要 isNum。
问:记忆化四个状态有点麻烦,能不能只记忆化 (i,mask) 这两个状态?
答:是可以的。比如 n = 234,第一位填 2,第二位填 3,后面无论怎么递归,都不会再次递归到第一位填 2,第二位填 3 的情况,所以不需要记录。(注:想象我们在写一个三重循环,枚举每一位填什么数字。第一位填 2,第二位填 3 已经是快要结束循环的情况了,不可能再次枚举到。)又比如,第一位不填,第二位也不填,后面无论怎么递归也不会再次递归到这种情况,所以也不需要记录。
根据这个例子,我们可以只记录不受到 isLimit 或 isNum 约束时的状态 (i,mask)。比如 n=234,第一位(最高位)填的 1,那么继续递归,后面就可以随便填,所以状态 (1,2) 就表示前面填了一个 1(对应的 mask=2),从第二位往后随便填的方案数。
相当于我们记忆化的是 (i,mask,false,true)。
问:能不能只记忆化 i?
答:这是不行的。想一想,我们为什么要用记忆化?如果递归到同一个状态时,计算出的结果是一样的,那么第二次递归到同一个状态,就可以直接返回第一次计算的结果了。通过保存第一次计算的结果,来优化时间复杂度。
由于前面选的数字会影响后面选的数字,两次递归到相同的 i,如果前面选的数字不一样,计算出的结果就可能是不一样的。如果只记忆化 i,就可能会算出错误的结果。
这段代码使用数位动态规划(记忆化搜索)来高效计算1到n中所有各位数字不同的数的个数。以下是详细的解释和示例说明:
方法思路
- 数位处理:将整数n转换为字符串,逐位处理每一位的可能取值。
- 记忆化搜索:通过缓存中间结果(
memo数组)避免重复计算,提升效率。- 状态参数:
i:当前处理到的位数。mask:位掩码,记录哪些数字已被使用(10位二进制,0-9)。is_limit:当前位的选择是否受限于n的对应位。is_num:表示是否已开始填数字(避免前导零)。- 递归枚举:对每一位枚举可能的数字,递归统计所有合法数字的数目。
代码解释
- 字符串转换:将n转为字符串以便逐位处理。
- 记忆化数组:
memo[i][mask]缓存处理到第i位且使用过的数字为mask的结果。- 递归函数
dfs:
- 终止条件:处理完所有位(
i == m),若已填过数字(is_num为真)则返回1。- 缓存检查:若当前不受限且已填数,直接返回缓存结果。
- 跳过当前位:若未开始填数,递归处理跳过的情况。
- 枚举当前位:根据是否受限确定最大值
up,从start开始枚举(避免前导零)。- 合法性检查:若数字
d未被使用(mask对应位为0),递归处理下一位并更新mask和限制状态。- 记忆化:缓存不受限且已填数的结果。
示例说明
以
n=13为例,有效数字为1-9、10、12、13,共12个:
- 一位数:1-9(共9个)。
- 通过跳过高位(i=0),直接填低位(i=1)生成。
- 两位数:10、12、13(共3个)。
- i=0填1,i=1填0/2/3(受限到s[1]=3,且数字不重复)。
通过递归和记忆化,代码高效遍历所有可能,确保每个数字的各位唯一,最终返回正确计数。
class Solution {
public:
int countSpecialNumbers(int n) {
string s = to_string(n);
int m = s.length();
//1 <= n <= 2 * 10^9,所以开10位
vector<vector<int>> memo(m, vector<int>(1 << 10, -1)); // -1 表示没有计算过
auto dfs = [&](auto&& dfs, int i, int mask, bool is_limit, bool is_num) -> int {
if (i == m) {
return is_num; // 终止条件:有效数字返回1,is_num 为 true 表示得到了一个合法数字
}
if (!is_limit && is_num && memo[i][mask] != -1) {
return memo[i][mask]; // 之前计算过,命中缓存
}
int res = 0;
if (!is_num) { // 可跳过当前位(保持未填状态)
res = dfs(dfs, i + 1, mask, false, false);
}
// 如果前面填的数字都和 n 的一样,那么这一位至多填数字 s[i](否则就超过 n 啦)
int up = is_limit ? s[i] - '0' : 9;
// 枚举要填入的数字 d
// 如果前面没有填数字,则必须从 1 开始(因为不能有前导零)
for (int d = is_num ? 0 : 1; d <= up; d++) {
if ((mask >> d & 1) == 0) { // d 不在 mask 中,说明之前没有填过 d
//mask | (1 << d) 为将d添加到mask中
res += dfs(dfs, i + 1, mask | (1 << d), is_limit && d == up, true);
}
}
if (!is_limit && is_num) {// 仅缓存不受限且已填数的状态
memo[i][mask] = res; // 记忆化
}
return res;
};
return dfs(dfs, 0, 0, true, false);// 初始状态:未填数,受限于n
}
};
复杂度分析
- 时间复杂度:O(mD2^D),其中 m 为 s 的长度,即 O(logn),D=10。由于每个状态只会计算一次,因此动态规划的时间复杂度 = 状态个数 × 单个状态的计算时间。本题状态个数为 O(m2^D),单个状态的计算时间为 O(D),因此时间复杂度为 O(m*D2^D)。
- 空间复杂度:O(m*2^D)。
233. 数字 1 的个数 - 力扣(LeetCode)
为方便计算,首先把 n 转换成字符串 s。
定义
dfs(i,cnt1,isLimit)表示在前 i 位有 cnt1 个 1 的前提下,我们能构造出的数中的 1 的个数总和。例如 n=9999,如果前三位我们都填了 1,那么填到最后一位,此时 dfs 计算的就是 1110,1111,1112,…,1119 这 10 个数中一共有多少个 1(一共有 31 个 1)。
dfs 中的 isLimit 表示当前是否受到了 n 的约束(我们要构造的数字不能超过 n)。若为真,则第 i 位填入的数字至多为 n[i],否则至多为 9,把这个上限记作 up。如果在受到约束的情况下填了数字 up,那么后续填入的数字仍会受到 n 的约束。例如 n=123,那么 i=0 填的是 1 的话,i=1 的这一位至多填 2。
递归边界:当所有数字填完时,返回 cnt1。
递归入口:dfs(0,0,true),一开始没有填数字,并且会受到 n 的约束。
答疑
问:记忆化三个状态有点麻烦,可以不记录 isLimit 吗?
答:可以。想一想 isLimit=true 只在什么情况下成立?只在我们填入的数字都等于 n 对应位置的数字时成立。比如 n=234,第一位填 2,第二位填 3,此时 isLimit=true。但我们不会再次递归到第一位填 2,第二位填 3 的情况,记忆化这样的状态是没有意义的。(注:想象我们在写一个三重循环,去枚举每一位填什么数字。第一位填 2,第二位填 3 已经是快要结束循环的情况了,不可能再次枚举到。)
根据这个例子,我们可以只记录不受到 n 约束时的状态 (i,cnt1)。相当于记忆化的(i,cnt1,false) 这个状态。比如 n=234,第一位填 1,第二位填 2,或者第一位填 2,第二位填 1,都会递归到 (2,1,false) 这个状态,那么第二次递归到这个状态时,就可以直接返回第一次递归记忆化的结果了。
方法思路
- 数位处理:将整数n转换为字符串,逐位处理每一位的可能取值。
- 记忆化搜索:通过缓存中间结果(memo数组)避免重复计算,提升效率。
- 状态参数:
i:当前处理到的位数。cnt1:当前已统计的1的个数。is_limit:当前位的选择是否受限于原数n的对应位。- 递归枚举:对每一位枚举可能的数字,递归统计所有可能组合中1的总数。
代码解释
- 字符串转换:将整数n转换为字符串以便逐位处理。
- 记忆化数组:
memo[i][cnt1]缓存第i位时已统计cnt1个1的结果。- 递归函数dfs:
- 终止条件:处理完所有位(i == m),返回当前统计的cnt1。
- 缓存检查:若当前不受限制且已缓存结果,直接返回。
- 枚举当前位:根据是否受限确定当前位最大值,递归处理每一位的可能取值。
- 更新缓存:仅缓存不受限制的情况,避免状态重复。
示例说明
以n=13为例,正确结果为6(1,10,11,12,13中的1,共6次):
- 十位处理:
- d=0:后续个位可取0-9,统计到1个1(个位为1的情况)。
- d=1:后续个位可取0-3,统计到5个1(十位1贡献4次,个位1贡献1次)。
- 总结果:0的情况贡献1次,1的情况贡献5次,合计6次。
通过逐位递归和记忆化优化,算法高效避免了重复计算,时间复杂度为O(m²),其中m为n的位数。
class Solution {
public:
int countDigitOne(int n) {
string s = to_string(n);
int m = s.size();
vector<vector<int>> memo(m, vector<int>(m, -1)); // 记忆化缓存
function<int(int, int, bool)> dfs = [&](int i, int cnt1, bool is_limit) -> int {
if (i == m) return cnt1; // 递归终止,返回当前统计的1的个数
if (!is_limit && memo[i][cnt1] != -1) return memo[i][cnt1]; // 使用缓存结果
int res = 0;
int up = is_limit ? s[i] - '0' : 9; // 当前位最大可选的数字
for (int d = 0; d <= up; ++d) { // 枚举当前位的所有可能数字
res += dfs(i + 1, cnt1 + (d == 1), is_limit && (d == up)); // 递归处理下一位
}
if (!is_limit) memo[i][cnt1] = res; // 仅缓存不受限制的情况
return res;
};
return dfs(0, 0, true); // 从最高位开始递归
}
};
复杂度分析
- 时间复杂度:O(m^2 * D),其中 m=O(logn),D=10。由于每个状态只会计算一次,动态规划的时间复杂度 = 状态个数 × 单个状态的计算时间。本题 i 和 cnt1 都有 O(m) 个,所以状态个数为 O(m^2),单个状态的计算时间为 O(D),所以动态规划的时间复杂度为 O(m^2 * D)。
- 空间复杂度:O(m^2)。即状态个数。
104. 二叉树的最大深度 - 力扣(LeetCode)
方法一:自底向上
相当于后序遍历,先递归到底,递归的过程中每层+1,再返回。
class Solution {
public:
int maxDepth(TreeNode* root) {
auto dfs = [&](this auto&& dfs ,TreeNode* node, int depth)->int{
if(!node) return depth;
return max(dfs(node->left ,depth + 1),dfs(node->right , depth + 1));
};
if(!root) return 0;
return dfs(root , 0);//为空节点的时候返回depth,多加了一次相当于根节点的深度从0开始
}
};
更简洁的写法,调用函数自身:
class Solution {
public:
int maxDepth(TreeNode* root) {
if(!root) return 0;
return max(maxDepth(root->left) , maxDepth(root->right)) + 1;
}
};
方法二:自顶向下
相当于中序遍历,先处理当前节点(depth ++ , 更新ans),再处理左右
class Solution {
public:
int maxDepth(TreeNode* root) {
int ans = 0;
auto dfs = [&](this auto&& dfs , TreeNode* node , int depth) ->void{
if(!node) return;
depth ++;
ans = max(ans , depth);
dfs(node->left , depth);
dfs(node->right , depth);
};
dfs(root , 0);
return ans;
}
};

浙公网安备 33010602011771号