I 寻找最长“1...”串
问题描述:
给你一个长度为N的01字符串,定义答案=该串中最长的连续1的长度,现在你有至多K次机会,每次机会可以将串中的某个0改成1,现在问最大的可能答案。
样例:
对于字符串“1 0 0 1 0 1 0 1 0 1”,K = 2,得到的答案是5。比如,将最后2个0变成1就能得到长度为5的“1...”串。
事先声明,我参加了美团笔试,但是当时并没有想到思路(感谢赛码网的大牛的点拨)。所以,我不能确定自己的代码是否能够满足考试对空间和时间的需求。不过,算法的核心思想是没有问题的。下面我尽量以动态规划的一般步骤来分析问题。每个小标题就是步骤的总结。
判断该问题是否是动态规划并找到递归关系
我们首先想一种暴力破解方法:如果条件允许的话,我们可以遍历字符串,以每个字符作为起始点,记录下他们各自能够得到的“1...”串的长度,然后选出最大值就可以了。暴力解法虽然粗暴,但是他给我们指明了一条路:我们需要算出N个子串的长度才可能找出最大值。我们用str[i]表示以第i个字符打头的字符串,要想知道他在看k = K(我们用k来表示还剩下的改变的机会数)能得到的最长"1..."串有多长,可以求出str[i + 1]在k=K(第i个字符就是1,无需改变)或者在k= K - 1(第i个字符时0,需要改变1次)所能得到的最长"1..."串的长度再加上1。这样就有了原问题(str[i])和子问题(str[i + 1]),就可以考虑使用动态规划的方式来解决问题了。 这时候要你考虑计算从某个点开始能够得到的最长“1...”的长度的函数,他的样子可能是下面这样
// 以startIndex为起点,所能得到的最大长度
int lengthOf(int startIndex, ...) {
//一些终止条件
return lengthOf(startIndex + 1, ...) + 1;
}
也许你还会加个for循环来对0,...,N - 1都调用一遍lengthOf。尽管这样就相当于暴力破解了,但是,你也可以注意到每个字符既可以是其他的字符打头的字符串的一部分,也可以自己作为一个字符串的开头。这样,我们可以把遍历的任务放到函数里面来,即字符既计算以自己打头所能得到的“1...”串的长度, 也计算自己作为其他字符串的成员时,所能得到的“1...”串的长度。
// K是可以改变的总次数,str是将字符串转成的整型数组,leftChange是剩余的改变次数
int lengthOf(int startIndex, int leftChange, int *str, int K) {
// 终止条件1
//以startIndex打头的字符串
int result1;
if (str[startIndex] == 0) {
result1 = lengthOf(startIndex + 1, K - 1, str, K) + 1;
} else {
result1 = lengthOf(startIndex + 1, K, str, K) + 1;
}
// 记录result1到map中
// 终止条件2
//startIndex作为其他字符串的成员
if (str[startIndex] == 0) {
return lengthOf(startIndex + 1, leftChange - 1, str, K) + 1;
} else {
return lengthOf(startIndex + 1, leftChange, str, K) + 1;
}
}
函数lengthOf()中调用了两次自身,其中只返回startIndex作为其他字符串的成员的结果。而result1表示的是startIndex自身打头的字符串的“1...”串长度。我们将result1的结果保存起来,当函数从第一个字符开始运行完毕后,map中就记录了所有字符打头的长度。(从函数式编程的角度来看,lengthOf并非是纯函数,因为函数运行过程中修改了外部变量(非函数里的局部变量)的值(将result1保存到了map中,map是外部变量),这种现象也被称为side-effect)。
识别变化的参数,确定终止条件
变化的参数指的是在函数执行过程中,每次调用函数,传递的参数中值会发生变化的那些。从上面的分析可以很容易地得到变化的参数,也就是startIndex和 leftChange。递归的终止条件就是围绕变化的参数来确定的。
首先是startIndex,我们可以得到终止条件:
startIndex >= str.length() // 暂且用length()来表示str的长度
如果当前的元素已经超出了str的界线,很显然没有再执行下去的必要了。我们将这个判断条件放在终止条件1处。只要是startIndex还在str范围内,就可以计算以startIndex为开始的"1..."串的长度。
接着是leftChange,相应的终止条件是:
str[startIndex] == 0 && leftChange == 0
发生如上的情况就说明,当前位置的字符正好是0,但是所有的改变机会都已经用完了,这时候也没有必要继续往下算了。将这个条件放在终止条件2处。
存储中间数据(memorization)
几乎所有的动态规划问题都会使用这个技术,就是以空间换取时间,并且还能防止过深的调用栈。(python对递归支持地很不好,很容易就栈溢出,并且对尾递归也没有优化,这里用memorization技术就是为了缓解这个问题。当然就调用深度来说,最好的方法是你将递归算法转换成迭代算法,不过转换是有些难度的。)一般使用的数据结构都是像map这样的键值对。key的选择在我们分析出变化的参数后,也变得很容易。用变化的参数的组合作为key就行了。而对应的value就是在key的条件下的子串的长度。在文章最后,我会贴一个完整的代码。存储中间数据的结构如下:
map<pair<int, int>, int> memo;
pair<int, int>中的第一个int表示startIndex,第二个int表示leftChange。最后的int的含义就是在以startIndex打头,剩余修改次数是leftChange(不包括将startIndex从0修改到1的那一次)所能得到的“1...”串的最大长度。
代码
#include<bits/stdc++.h>
using namespace std;
map<pair<int, int>, int> memo;
map<int, int> results;
int f(int startIndex, int leftChange, int *numstr, int strLength, int K) {
// 终止条件
if (startIndex >= strLength) return 0;
// 从当前startIndex重新开始,作为字符串起点
// int origin_change = leftChange;
int new_change = K;
if (numstr[startIndex] == 0) {
new_change --;
}
int result1;
map<pair<int, int>, int>::iterator it = memo.find({startIndex, new_change});
if (it != memo.end()) {
result1 = it->second;
} else {
result1 = f(startIndex + 1, new_change, numstr, strLength, K) + 1;
// 保存当前起点对应的长度
results.insert({startIndex, result1});
// 保存结果
memo.insert({{startIndex, new_change}, result1});
}
// 继续沿着过去的添加字符进来
if (numstr[startIndex] == 0 && leftChange == 0) {
memo.insert({{startIndex, leftChange}, 0});
return 0;
}
if (numstr[startIndex] == 0) {
leftChange --;
}
int result2;
it = memo.find({startIndex, leftChange});
if(it != memo.end()) {
result2 = it->second;
} else {
result2 = f(startIndex + 1, leftChange, numstr, strLength, K) + 1;
memo.insert({{startIndex, leftChange}, result2});
}
return result2;
}
int main() {
int numstr[] = {1, 0, 0, 1, 0, 1, 0, 1, 0, 1, 1, 0, 0, 1, 1, 0, 1, 1, 1, 1, 1};
int K = 2;
int result = f(0, K, numstr, sizeof(numstr) / sizeof(numstr[0]), K);
results.insert({0, result});
for(map<int, int>::iterator it = results.begin(); it != results.end(); it++) {
cout << it->first << ": " << it->second << endl;
}
}
在main()中,我只把numstr中每个元素能对应的最长“1”串的长度给打印出来。
总结
遇到问题,一定不要慌。你可以先想想最直接的解法,试着从中确定有哪些是必须做的。比如这一题,每个字符作为起始点对应的长度就必须算出来。接着按照动态规划的原则,尝试着将问题分成子问题:一般就是当前的情况+下一步会发生的情况。 做完这一步,问题就已经很接近解了(尽管离代码成型还有差距,但是对于面试来说,你的思路已经出来了)。接下里就是常规的确定变化量, 找到终止条件,加一些memorization。简要的列出来就是:
- 想想看问题的一些暴力解
- 尝试用动态规划的思想去套,并找到递归关系
- 识别变化的参数,确定终止条件
- memorization
附上两个链接:http://blog.moertel.com/posts/2013-05-11-recursive-to-iterative.html,教你如何把recursion转为iteration。
https://medium.freecodecamp.org/follow-these-steps-to-solve-any-dynamic-programming-interview-problem-cc98e508cd0e,教你动态规划的解题套路。