一、前言
收到读者私信说:为什么你的算法越讲越简单了?
我告诉他:因为你越来越聪明了!
今天要讲的算法,《算法导论》书上是看不到的,因为无论是思考过程还是代码实现上都是非常容易理解的,所以各大算法书上都不屑将它归为算法,但是它却作为职场面试,省赛水题的绝佳选择,它有一个比较优雅的名字叫 尺取法,英文把它叫 “two pointers”,也就是 “双指针” 的意思。
现在几点来着?四点?哈哈,早睡早起,方能养生!
二、最长不重复子串
- 接下来,以一个非常经典的面试题【最长不重复子串】为例,展开今天算法的讲解。
【例题1】给定一个长度为 的字符串 ,求一个最长的满足所有字符不重复的子串。
1、初步分析
- 首先我们分析一下这个问题的关键词,主要有以下几个:
- 1);
- 2)最长;
- 3)所有字符不重复;
- 4)子串;
- 根据以上的几个关键词,我们可以得出一些结论。首先,根据 的范围已经能够大致确认这是一个需要 或者 的算法才能解决的问题;其次,”最长” 这个词告诉我们,可能是一个动态规划问题或者贪心问题,也有可能是搜索,所以这个关键词给我们的信息用处不大;而判断字符是否重复可以用 哈希表 在 的时间内判断;最后,枚举所有 “子串” 的时间复杂度是 的。
2、朴素算法
- 由以上分析,我们可以发现第(1)个 和 第(4)个关键词给我们得出的结论是矛盾的,那么,我们可以先尝试减小 的范围,如果 时,怎么解决这个问题呢?
- 因为最后求的是满足条件的最长子串,所以我们如果能够枚举所有子串,那么选择长度最长的满足条件的子串就是答案了(这里的条件是指子串中所有字符都不同)。
用 记录我们需要求的最大不重复子串的长度,用一个哈希表 来代表某个字符是否出现过,算法描述如下:
1)枚举子串的左端点 ;
2)清空哈希表 ;
3)枚举子串的右端点 ,如果当前这个字符 出现过(即 ),则跳出 的循环;否则,令 ,并且用当前长度去更新 (即 );
4)回到 2);
- c++ 代码实现如下:
int getmaxlen(int n, char *str, int& l, int& r) {
int ans = 0, i, j, len;
memset(h, 0, sizeof(h));
for(i = 0; i < n; ++i) { // 1)
j = i;
memset(h, false, sizeof(h)); // 2)
while(j < n && !h[str[j]]) {
h[ str[j] ] = true; // 3)
len = j - i + 1;
if(len > ans)
ans = len, l = i, r = j;
++j;
}
}
return ans;
}
复制代码
- 1)这一步枚举对应子串的左端点 ;
- 2)这一步用于清空哈希表 ,其中 代表原字符串的第 个字符 是否出现在以第 个字符为左端点的子串中;
- 3)而第三步可以这么理解:如果字符串 中已经出现重复的字符,那么 必然会有重复字符,所以这里不需要继续往下枚举,直接跳出第二层循环即可。
- 这个算法执行完毕, 就是我们要求的最长不重复子串的长度, 代表了最长不重复子串在原字符串的区间。正确性毋庸置疑,因为已经枚举了所有子串的情况,如果字符集的个数 ,算法的时间复杂度就是 。
- 最后奉上一张动图,代表了上述朴素算法的求解过程,如图二-2-1所示:
图二-2-1
- 字符串下标从 0 开始,最长无重复子串为:,长度为 5。
- 由于是字符串,字符集的个数 最多 256,所以时间复杂度基本就是 ,当 时,这个时间复杂度是无法接受的,需要想办法优化。
3、优化算法
- 如果仔细思考上面朴素算法的求解过程,就会发现:枚举子串的时候有很多区间是重叠的,所以必然存在许多没有必要的重复计算。
- 我们考虑一个子串以 为左端点, 为右端点,且 中不存在重复字符, 中存在重复字符(换言之, 和 中某个字符相同)。如图二-3-1所示:
图二-3-1
- 那么我们没必要再去检测 ,, 这几个字符串的合法性,因为当前情况 是非法的,而这些字符串是完全包含 的,所以它们必然也是不合法的。
- 那么我们可以把枚举的左端点自增,即: ,这时,按照朴素算法的实现,右端点需要重置,即 ,实际上这里的右端点可以不动。
- 可以这么考虑,由于 这个字符和 中的字符产生了重复,假设这个重复的字符的下标为 ,那么 必须满足 ,换言之, 可以一直自增,直到 ,如图二-3-2所示:
图二-3-2
- 利用上述思路,我们重新实现 最长不重复子串 的算法, c++ 代码实现如下:
int getmaxlen(int n, char *str, int& l, int& r) {
int ans = 0, i = 0, j = -1, len; // 1)
memset(h, 0, sizeof(h)); // 2)
while (j++ < n - 1) { // 3)
++h[ str[j] ]; // 4)
while (h[ str[j] ] > 1) { // 5)
--h[ str[i] ];
++i;
}
len = j - i + 1;
if(len > ans) // 6)
ans = len, l = i, r = j;
}
return ans;
}
复制代码
- 1)初始化
i = 0, j = -1
,代表 为一个空串,从空串开始枚举; - 2)同样需要维护一个哈希表,哈希表记录的是当前枚举的区间 中每个字符的个数;
- 3)只推进子串的右端点;
- 4)在哈希表中记录字符的个数;
- 5)当
h[ str[j] ] > 1
满足时,代表出现了重复字符,这时候左端点 推进,直到没有重复字符为止; - 6)记录当前最优解
j-i+1
; - 这个算法执行完毕,我们就可以得到最长不重复子串的长度为 ,并且 和 这两个指针分别只自增 次,两者自增相互独立,是一个相加而非相乘的关系,所以这个算法的时间复杂度为 。
- 利用该优化算法优化后的最长不重复子串的求解过程如图二-3-3所示:
图二-3-3
- 参考这个图,一个比较通俗易懂的解释:当区间 中存在重复(红色)字符时,左指针 自增;否则,右指针 自增。
三、尺取法
1、算法定义
- 如上文所述,这种利用问题特性,通过两个指针,不断调整区间,从而求出问题最优解的算法就叫 “尺取法”,由于利用的是两个指针,所以又叫 “双指针” 算法。
- 这里 “尺” 的含义,主要还是因为这类问题,最终要求解的都是连续的序列(子串),就好比一把尺子一样,故而得名。
2、算法描述
算法描述如下:
1)初始化 , ,代表一开始 “尺子” 的长度为 0;
2)增加 “尺子” 的长度,即 ;
3)判断当前这把 “尺子” 是否满足题目给出的条件:
3.a)如果不满足,则减小 “尺子” 长度,即 ,回到 3);
3.b)如果满足,记录最优解,回到 2);
- 上面这段文字描述的比较官方,其实这个算法的核心,只有一句话:满足条件时,++;不满足条件时,++;
- 如图三-2-1 所示,当区间 满足条件时,用蓝色表示,此时 自增;反之闪红,此时 自增。
图三-2-1
3、条件
- 这里所说的条件比较模糊,对于【例题1】来说,条件就是 “字符不重复”,当然也可以是 “每个字符重复次数不超过 次”,”至少包含 种字符”,”求和不大于 ” 等等,因题而异。
- 然而,无论问题怎么变,这里的条件都需要满足以下两点:
1)单调性
- 所谓单调性,就是说:任意一个指针的增加,条件满足与否只会出现两种情况,即 : 【满足 -> 不满足】或者 【不满足 -> 满足】,不会出现 【满足 -> 不满足 -> 满足】这样的情况。
2)时效性
- 所谓时效性,就是说:必须在 或者 的时间内,求出当前区间 是否满足既定条件,否则无法用尺取法求解。
四、尺取法的应用
1、前缀和问题
【例题2】给定 个正数 和一个正数 。找到一个最短的连续子序列,满足它的和 。
- 对于一个连续子序列 ,它的所有数之和为 ,如果我们已经知道 ,那么就没必要再去求 ,因为题目要求一个最短的连续子序列,所以基于这点,它满足单调性。
- 并且,我们可以定义前缀和 ,这样就可以通过 的时间计算出连续子序列的和:
- c++ 代码实现如下:
int getminlen(int n, int *sum) {
int len = n + 1, i = 1, j = 0;
while (j++ < n) {
while (sum[j] - sum[i - 1] >= p) {
len = min(len, j - i + 1);
++i;
}
}
if (len == n + 1) len = 0;
return len;
}
复制代码
2、哈希问题
【例题3】对于一个字符串,如果它的每个字符数都 ,则称其为 串。给定一个长度为 且只包含小写字母的字符串,求它的 串的数量。
- 如果某个子串 是 串,而 不是,那么可以得出:以 为右端点的情况下,左端点只要大于等于 ,那么这个串一定是 串,所以以 为右端点,满足条件的个数为 ;
- 所以整个调整过程就是: 自增的过程,如果满足每个字符数都 的情况下,累加 ;否则,自增 ,继续判断可行性。每个字符的个数统计就用到了哈希。
- c++ 代码实现如下:
int has[256];
ll getcount(int n, char* str, int k) {
ll ans = 0;
int s = 0, i = 0, j = -1;
memset(has, 0, sizeof(has));
while (j++ < n - 1) {
if (++has[str[j]] > k)
s = 1;
while (s) {
if (--has[str[i]] == k)
s = 0;
++i;
}
ans += (j - i + 1);
}
return ans;
}
复制代码
3、K 大数问题
【例题4】给定 个数 ,以及一个数 ,求第 大的数 的区间的个数。
- 可以这么考虑:如果一个区间里面 的数不小于 个,那么第 大的也一定满足 。
- 于是我们将原数组作如下处理:将所有 的数变成 1,其它为 0,然后求出前缀和 。
- 那么对于某个区间 ,如果 ,则所有区间 都满足 “第 大的数 “。所有以 为左端点的满足条件区间数就是 个;累加到答案。
- 并且满足 时,左指针 自增。
- c++ 代码实现如下:
#define ll long long
ll getcount(int n, int k) {
ll ans = 0;
int i = 1, j = 0;
while (j++ < n) {
while (sum[j] - sum[i - 1] >= k) {
ans += n - j + 1;
++i;
}
}
return ans;
}
复制代码
- 关于 尺取法 的内容到这里就全部结束了,如果还有不懂的问题可以留言告诉作者或者添加作者的微信公众号。
- 本文所有示例代码均可在以下 github 上找到:github.com/WhereIsHero…
五、尺取法相关题集整理
题目链接 | 难度 | 解法 |
---|---|---|
HDU 2668 Daydream | ★★☆☆☆ | 【例题1】哈希 + 最长不重复子串 |
NC41 最长无重复子串 | ★★☆☆☆ | 哈希 + 最长不重复子串 |
PKU 2100 Graveyard Design | ★☆☆☆☆ | 平方和单调性 |
PKU 3061 Subsequence | ★☆☆☆☆ | 【例题2】求和单调性 |
PKU 2739 Sum of Consecutive Prime Numbers | ★☆☆☆☆ | 素数筛选 + 求和单调性 |
PKU 3320 Jessica’s Reading Problem | ★★☆☆☆ | 离散化 / 哈希 + 全集合连续子序列 |
HDU 2158 最短区间版大家来找碴 | ★★☆☆☆ | 哈希 + 全集合连续子序列 |
HDU 2369 Broken Keyboard | ★★☆☆☆ | 哈希 + 最长 m 子串 |
HDU 5672 String | ★★☆☆☆ | 哈希 + m 子串方案数 |
HDU 5056 Boring count | ★★☆☆☆ | 哈希 + m 子串方案数 |
HDU 6205 card card card | ★★☆☆☆ | 前缀和 + 求和单调性 |
HDU 5178 pairs | ★★☆☆☆ | 前缀和 + 求和单调性 |
HDU 6103 Kirinriki | ★★★☆☆ | 枚举 + 求和单调性 |
HDU 6119 小小粉丝度度熊 | ★★★☆☆ | 区间合并 + 前缀和 |
HDU 1937 Finding Seats | ★★★☆☆ | 矩阵前缀和 + 求和单调性 |
PKU 2566 Bound Found | ★★★☆☆ | 思维转换 + 求和单调性 |
HDU 5806 NanoApe Loves Sequence Ⅱ | ★★★☆☆ | 【例题4】思维转换 + 求和单调性 |
© 版权声明
文章版权归作者所有,未经允许请勿转载。
THE END