Problem: 2844. 生成特殊数字的最少操作
前言
难度分 1588,说明此题在一众中等题里也不算泛泛之辈。
可以参考一下和它近似分数的这道题:Problem: 2970. 统计移除递增子数组的数目 I
当然这是后话,我早上起来看这道题的时候并没有想这么多,也不会因为它的分数而望而生畏。
本篇题解可以说并不是典型意义上的题解,而是记录了我自己解题的全过程:
- 思路的从无到有
- 试错和优化
- 考虑技巧层面的提升【在此感谢灵神题解的点拨】

感兴趣的话,烦请继续往下看。
思路
还是和之前一样,我们面对一道新题的时候,想想办法让它和我们熟悉的领域靠近
题目名称里带有"最少操作",显而易见,这又是一道最值问题。
最少,最值问题,我们可以联想一下我们以前写过的类似的最值问题的解法,都有哪些可以解决的方式?
比如:
- 322.零钱兑换:让我们计算最小的硬币个数。这是利用动态规划去解决的。
- 209.长度最小的子数组:这是一道不定长滑动窗口的题目。
- 3040. 相同分数的最大操作数目 II:这是利用记忆化搜索,去求最大的操作数目。
等等....
由我上方举的例子,可以看到,解决最值问题是有挺多种方式的。因此针对本题,我们看看是否也可以让它往上面的几个例子靠一靠?
题目改造
👇我们仔细阅读一下题干信息👇
在一次操作中,您可以选择 num 的任意一位数字并将其删除。请注意,如果你删除 num 中的所有数字,则 num 变为 0。
返回最少需要多少次操作可以使 num 变成特殊数字。
我们可以任选一位数字删除,并且删除之后,还可以继续选。我们直接拿例子带入这个操作看看是什么样子:
比如示例一:num = "2245047"
我们可以选索引为0的数字删除

然后我们可以继续选索引为1的数字删除

此时我们不难发现,这个所谓的删除操作,实际上暴露了这样👇一条信息👇
每个数字只能选1次
而删除后还可以继续删除的继续操作,实际上暴露了这样👇一条信息👇
多个不同的数字可以组合
欸!发现没有,根据实际的操作和题干的信息,我们可以对这道题目做一个👇变式👇
即从给定的字符串中选择任意数量(最大不超过字符串的长度)的字符进行组合(即子序列),将这些字符删除后,看看剩余的字符串转成数字后能否被25整除。
这不就是一道枚举子序列的题么!
什么是子序列?
可以通过这一题回忆一下
还是拿示例一:num = "2245047"举例,它的子序列有这些:
-
长度为1:2,2,4,5,0,4,7
-
长度为2:
- 22 , 24 , 25 , 20 , 24 , 27;
- 24 , 25 , 20 , 24, 27;
- 45 , 40 , 44 , 47;
- 50 , 54 , 47
- 04 , 07
- 47
-
长度为3:等等,不再举例。
可以看到由于字符串里的字符并不是唯一的,可以重复的,因此有些索引不同的字符组合而成的子序列看起来却是相同的,比如:
- 索引
0和索引2组合的子序列:24 - 索引
0和索引5组合的子序列:24
因此我们在写dfs的时候,注意存的得是索引值,而不是字符。
我们很快可以写出这样的dfs代码
ini
/**
* @param {string} num
* @return {number}
*/
var minimumOperations = function (num) {
let nums = Array.from(num)
let count = 0;
// 如果num本身就可以被25整除了,那么直接return 0;
if (Number(num) % 25 === 0) {
return 0;
}
// count表示当前枚举的子序列的长度
count++;
// 用于存储索引值组合的数组
let temp = [];
// 用于判断在当前长度下的子序列的枚举中
// 是否存在1个子序列能够满足题目的要求
// 能,则直接return count,不再继续枚举。
let flag = false;
const dfs = (index) => {
// 递归结束的边界条件
// 即 索引值组合的数组长度 等于 子序列的长度
if (temp.length === count) {
let resNum = BigInt(nums.filter((item, index) => !temp.includes(index)).join(''))
if (resNum % BigInt(25) === BigInt(0)) {
flag = true;
}
return;
}
for (let i = index + 1; i < nums.length; i++) {
temp.push(i)
dfs(i)
temp.pop()
}
}
let res = 1;
while (count < num.length) {
for (let i = 0; i < nums.length; i++) {
temp.push(i);
dfs(i)
if (flag) {
return count;
}
temp.pop()
}
count++;
}
return count;
};
我们运行此程序,跑了几个用例,都过了,于是我们开心地点击提交


然后.........

靠,为啥?既然能通过500多个用例说明咱们的代码确实能够解决问题 ,但是为啥超时了?
我们查看一下题目给的信息:

再看一下我们的代码,我们根据字符串的子序列长度,从1到n要枚举每一位数字的子序列情况,即每次从n个元素中选取m(1 <= m <= n)个元素,我们可以写成这样的一个公式:

这也就意味着,咱们的时间复杂度是O(2^n)
而n最大可以是100
则咱们的时间复杂度就会变成O(2^100),这咋可能不超时啊???
从前面的分析来看,既然思路对了,为什么我们的解法会超时呢?

说明我们的思路并不对呀😭
由于这道题给的num的长度可以是100,我们枚举子序列的解法肯定会超时,那么是不是题干中还有一部分给的条件我们没有好好利用起来?

为什么一定是整除25?为什么一定是25这个数字,它特殊在哪里?
学习技巧
于是我点开题解,打算一探究竟,毕竟哥们知道25它特殊,但是真不知道特殊在哪里

学习了一波灵神的题解后,我明白了,只有数字末尾是00、25、50、75的数才能被25整除(再加上一个特殊的数字,0)
转变思路
错在哪里
我们不应该去枚举长度为(1~n)的子序列,然后去看在哪个长度能够满足题目条件。
这样的枚举做了太多无用功 了!!!!
还是拿示例一:num = "2245047"举例
我们从索引0的2开始做的枚举情况,是不是通通都是无用的?白白浪费时间?
对于从索引0开始的枚举情况,只有把全部数字都删除了,得到0,才能被25整除。
当子序列长度为2时,只有我们枚举到索引5和索引6组合的情况,此时得到的数才能被25整除,即,索引0、索引1、索引2、索引3、索引4的全部枚举都是在浪费时间。
怎么改
由于我们通过学习技巧,知道了只有末尾是00、25、50、75的数才能被25整除,因此,我们应该枚举的是末尾。
我们从后往前 去遍历这个字符串,看看什么时候这个字符串能以00、25、50、75中的第二项结尾,即末尾是0或5
找到之后,从找到的地方的索引值开始,再去往前 找00、25、50、75中的第一项出现的位置,即0、2、5、7出现的索引值。

如果我们使用的是js语言的话,可以直接用实例方法lastIndexOf快速找到指定字符最后出现的索引值。
根据上面的分析,我们可以写出这样的函数代码
js
const nlen = num.length;
const suffixPairs = ['00', '50', '25', '75'];
const findSuffix = (pair) => {
const [pre, post] = pair;
const postIndex = num.lastIndexOf(post);
if (postIndex <= 0) {
return nlen;
}
const preIndex = num.lastIndexOf(pre, postIndex - 1);
// 2245047
// 找'50'
// 0 的index是4
// 5 的index是3
// 那留下的字符数量是 224 再加上 50,即 224:( 3 ),50:( + 2)
// 被删除的字符数量就是 47,即 47:(7 - (3 + 2)) = 7 - 3 - 2 = 2
// 也就是 nlen - preIndex - 2
return preIndex === -1 ? nlen : nlen - preIndex - 2;
}
return Math.min(findSuffix(suffixPairs[0]), findSuffix(suffixPairs[1]), findSuffix(suffixPairs[2]), findSuffix(suffixPairs[3]));
不过这样写还没完哦,用这个代码过用例的话,用例: 10就过不去了,因为会输出2,而实际上的答案是1。
因为上面咱们也讨论过了,除了4种后缀的情况,我们还得考虑0这个数字。
即把num变成只剩一个0时,要删除的字符总数。
而这个字符总数,也要加入到Math.min的比较中去。
于是我们得到了最终版的代码
js
/**
* @param {string} num
* @return {number}
*/
var minimumOperations = function (num) {
const nlen = num.length;
const suffixPairs = ['00', '50', '25', '75']
const findSuffix = (pair) => {
const [pre, post] = pair;
const postIndex = num.lastIndexOf(post)
if (postIndex <= 0) {
return nlen;
}
// 第2个参数(postIndex - 1)表示反向搜索的起始位置
const preIndex = num.lastIndexOf(pre, postIndex - 1)
// 2245047
// 找'50'
// 0 的index是4
// 5 的index是3
// 那留下的字符数量是 224 再加上 50,即 224:( 3 ),50:( + 2)
// 被删除的字符数量就是 47,即 47:(7 - (3 + 2)) = 7 - 3 - 2 = 2
// 也就是 nlen - preIndex - 2
return preIndex === -1 ? nlen : nlen - preIndex - 2
}
const onlyZero = nlen - (num.includes('0') ? 1 : 0)
return Math.min(onlyZero, findSuffix(suffixPairs[0]), findSuffix(suffixPairs[1]), findSuffix(suffixPairs[2]), findSuffix(suffixPairs[3]))
};
复杂度
- 时间复杂度: O(N)
- 空间复杂度: O(1)
Code
js
/**
* @param {string} num
* @return {number}
*/
var minimumOperations = function (num) {
const nlen = num.length;
const suffixPairs = ['00', '50', '25', '75']
const findSuffix = (pair) => {
const [pre, post] = pair;
const postIndex = num.lastIndexOf(post)
if (postIndex <= 0) {
return nlen;
}
// 第2个参数(postIndex - 1)表示反向搜索的起始位置
const preIndex = num.lastIndexOf(pre, postIndex - 1)
// 2245047
// 找'50'
// 0 的index是4
// 5 的index是3
// 那留下的字符数量是 224 再加上 50,即 224:( 3 ),50:( + 2)
// 被删除的字符数量就是 47,即 47:(7 - (3 + 2)) = 7 - 3 - 2 = 2
// 也就是 nlen - preIndex - 2
return preIndex === -1 ? nlen : nlen - preIndex - 2
}
const onlyZero = nlen - (num.includes('0') ? 1 : 0)
return Math.min(onlyZero, findSuffix(suffixPairs[0]), findSuffix(suffixPairs[1]), findSuffix(suffixPairs[2]), findSuffix(suffixPairs[3]))
};