大家好,我是梁唐。
今天给大家聊一个非常经典也非常简单的算法,学会了这个算法不说能够纵横leetcode,但可以解决非常多的问题。并且很多其他的算法也用到了类似的思想,非常有借鉴意义。
这个算法的名字叫做两指针算法,英文名是two pointers。
算法原理
既然算法叫做two pointers,那么顾名思义必然和两个指针有关。
首先声明一点,这里的指针并不是传统意义上的指针,可以理解成记录位置的变量或者是标记的意思。我们用两个变量记录一个线性表中的两个位置,维护这两个位置围成的区间。比如一个区间左侧的变量叫l,右侧的变量叫r,那么我们维护的就是[l, r]这个区间。
所以两个指针的目的是为了维护区间,这也是这个算法的核心目的。所以这个算法一般的应用场景就是寻找一个合法的最大区间的问题。当然实际的问题不会这么直白地告诉你我要求的是一个合法的区间,而是会做各种包装,玩各种花样,给你一些弯弯绕,需要你自己通过分析和理解get到题目的核心诉求。
理解了算法的核心目的之后,再来理解它的原理就容易多了,就只有一个问题需要解决,就是怎么样维护区间?
我们假设现在的l和r都停留在了一个合法的位置,我们把[l, r]理解成一个区间,那么l和r的变化都可以看成是区间的移动。
比如,l增大,就可以看成是区间的左侧在缩小。我们从[l, r]变成[l+1, r],意味着区间的左侧弹出了一个元素。反过来,如果r增大,则意味着区间的右侧再拓展,从[l, r]变成[l, r+1],意味着区间的右侧添加了一个新元素。所以我们控制l和r的增大,就相当于控制了区间添加和删除元素。
当我们要移动的时候,我们可以固定将r增大一位,也就是给区间添加一个元素。既然添加了新元素,就有可能导致区间的合法性被破坏。我们就需要做些什么来维护区间的合法性,比如我们可以移动左侧的l,让区间弹出元素,直到恢复合法性为止。
随着r一位一位地移动,我们就自然地遍历了所有合法的区间,想要找到其他最大或者最小的一个也就非常简单了。
可能光这么看文字会有些抽象,没关系,我们来看一道具体的例题,来套用一下刚学的这个算法。
例题
我们以leetcode第三题举例,这题当中需要我们在一个字符串当中找到一个最长的不包含重复字符的子串。
表面上来看这是一道字符串问题,很多人思考的角度估计都会围绕字符串展开。但实际上,我们只需要把寻找的子串看成是原字符串上的一段区间,那么这就是一个寻找最大合法区间的问题。
在这个问题当中,合法性指的是区间内的字符各不相同。
其实已经很明显了,我们只要套入一下two pointers算法就行了。首先,我们初始化一个合法区间,在这道题当中,很容易想到合法区间可以是[0, 0]。之后的每一步,我们都将r向右移动一位,也就是在区间里插入一个新字符。由于新字符的插入可能会引起区间的合法性遭到破坏,也就是使得某个字符重复了。
在这种情况下,我们就移动l指针,弹出区间内的元素,直到区间恢复合法性为止。为了判断区间的合法性是否回复,我们需要使用一个map来存储区间内每个元素的个数。当新插入的字符数量大于1的时候,说明合法性遭到了破坏,直到数量恢复成1为止。
代码
光看描述可能还有一些抽象,没关系,我们再来结合一下代码进行说明。
- class Solution {
- public:
- int lengthOfLongestSubstring(string s) {
- map<char, int> mp;
- if (s.size() == 0) return 0;
- int ret = 1;
- int l = 0;
- mp[s[0]] = 1;
- // 每次将r移动一位,插入元素
- for (int r = 1; r < s.size(); r++) {
- char c = s[r];
- // 将s[r]插入map
- if (mp.count(c)) mp[c]++;
- else mp[c] = 1;
- // 如果map中s[r]的数量大于1,说明引起冲突
- // 弹出左侧元素,直到合法性恢复
- while (mp[c] > 1) {
- mp[s[l++]]--;
- }
- ret = max(r - l + 1, ret);
- }
- return ret;
- }
- };
结合一下代码注释,整体逻辑还是比较清晰的。
就是一个右侧拓展,左侧收缩的过程,比较容易疑惑的点是为什么这样能找到最大的区间?其实这里面还蕴藏着贪心法的思想,只不过比较难想到。
可以用数学归纳法简单地进行一个证明,首先,很明显[0, 0]是以0为右端点能够找到的最大合法区间。
我们假设[l, r]是以r为右边界能够找到的最大合法区间,也就是说l是它能延伸到的最左侧的位置。那么当我们将r移动到r+1,以r+1为右侧边界,往左侧去寻找最大合法区间,找到的左侧边界,我们叫做l'。请问这个l'可能小于l吗?
很显然,不可能,因为如果l' < l,那么[l', r]必然也是合法的,就和我们假设的前提矛盾了。所以l'一定是大于等于l的。这就证明了,我们通过这样的递推算法找到的区间都是基于右侧端点的最大合法区间,我们基于每一个可能构成右侧端点的位置都寻找了最大合法区间,全局最大合法区间也必然在其中。
优化
如果能够写出或者理解上面的代码,那么对于two pointers算法的理解就算是勉强过关了,不过还没有结束。
因为如果对于它理解足够深入,就会发现这道题还有继续优化的空间,继续优化的前提依赖我们对算法的理解。
那么哪里还可以优化呢?其实很简单,我用红框标记一下就知道了。
我们在维护区间合法性的时候,使用了while循环弹出左侧的边界。仔细想,我们使用while循环的目的是什么?是移动区间的左侧边界l,移动l的目的是什么?是为了维护区间合法性,那维护区间合法性的核心在哪里?在于弹出那个和s[r]相同的字符。
重点来了,为了弹出和s[r]相同的字符。我们可以想到什么?既然本质目的是为了弹出这个引起冲突的字符,除了一位一位地移动,还有没有其他办法?我们既然已经用map了,使用一下map记录一下每个字符的位置行不行?完全可以!这样的话,我们就把循环的若干次执行替换成了一次查找,大大加快了速度。
如果再机灵一点,又可以想到,我们其实也没有必要使用map,因为我们记录的是字符的位置。字符的ascii码范围很小,我们完全可以用数组来存储,这样的话查找会更快,只有。
我们来看优化之后的代码:
- class Solution {
- public:
- int lengthOfLongestSubstring(string s) {
- int mp[128];
- memset(mp, -1, sizeof mp);
- if (s.size() == 0) return 0;
- int ret = 1;
- int tmp = 0;
- int l = 0;
- mp[s[0]] = 0;
- int n = s.size();
- for (int r = 1; r < n; r++) {
- char c = s[r];
- if (mp[c] >= l) l = mp[c]+1;
- mp[c] = r;
- ret = max(r - l + 1, ret);
- }
- return ret;
- }
- };
我们重点看下这个部分:
如果最近的一个s[r]在l的右侧,说明会构成冲突,那么我们直接把l移动到它的后一位即可,就代替了while循环一位一位移动l的操作,大大提升了运行速度。
实际上也的确如此,优化之前用了36ms,而优化之后只用了12ms,足足快了三倍。
这道例题非常经典,既有two pointers的应用,还可以基于它的理解进行进一步地优化,能把这道题吃透,就足够领会算法的精髓,并且它的难度还不是非常大,对新手足够友好。
如果之前没学过two pointers算法的话,可以多琢磨一下这道题,一定会有很大的收获。