算法基础课03:双指针、位运算

1.双指针算法

1.1概念

双指针算法是一种通过设置两个指针不断进行单向移动来解决问题的算法。

双指针算法常见问题可分为两种情况:

(1)双指针指向不同序列

之前介绍的归并排序就是一个典型的例子。 

 (2)双指针指向同一个序列,两个指针共同维护一个区间:

一个典型的例子是快速排序。 

1.2核心思想

双指针算法其核心思想即优化时间复杂度。

在朴素的算法中,两个指针最多有n^2种组合方式,即时间复杂度为 T = O(n^2)

双指针算法,虽然用两个指针扫描一个序列,看起来是2重循环,但是两个指针只能进行单向移动,每一个指针在所有的循环里移动次数不会超过n,两个指针移动次数不会超过2n,因此时间复杂度为 T = O(n)

因此,对于这样一种优化算法,我们通常的做法是先想一个朴素的、复杂度为O(n^2)的算法:

for(int i = 0; i < n; i ++)
    for(int j = 0; j <= i; j ++)
        if(check(i , j))
        {
            res = max(res , i - j + 1);
        }

再想一下怎么优化(本质上是找 i 和 j 有什么规律)。其实就是利用 i 和 j 的单调性

① j 表示 i 往左最远能走到多远;

② i 的枚举思路和朴素做法中一样,即枚举每一个 i ,观察以每一个 i 为右端点的区间,其左端点 j 离它最远在什么位置,使得【j ,i】下标范围内的元素无重复。

1.3模板

通用模板(一般写法)

//常规模板
for( i = 0 , j = 0 ; i < n ; i ++ )
{
	while( j < i && check( i , j ) )	//check(i,j)即检查i , j是否符合某种性质
        j ++;

	//之后按每道题的具体逻辑......
}

1.4举例

下面举几个例子用以说明双指针算法。 

1.4.1例题1:输入一个字符串,输出每个单词,单词之间由且仅由一个空格隔开。

这是双指针算法最简单的一个应用。我们可以设置两个指针,其中一个指向单词的首字母,另一个指针用于遍历单词,寻找单词最后一个位置。

初始时 i 和 j 都指向字符串中某单词的首字母,i 在本轮循环中始终指向该单词首字母,j 则不断向后走,直到遇到空格。此时,从 i 到 j - 1 之间的所有字母即为当前单词的所有字母:

 当前这一轮循环结束后,进入下一轮循环前,需要让 i 指向 j ,即 i 也指向 j 所指向的空格,这样在下一轮循环开始前,i 的自增操作会使得 i 指向下一个单词的首字母,如此反复。

代码实现如下:

#include<iostream>
#include<string.h>

using namespace std;

int main(){
	char str[1000];
	
	gets(str);												//输入字符串
	
	int n = strlen(str);									//求字符串长度,作为循环判断条件
	
	for(int i = 0; i < n; i ++){							
		int j = i;											//j 从 i 开始向后遍历,
		while( j < n && str[j] != ' ' )						//直到遇到空格
			j ++;
		
		//这道题的具体逻辑
		for(int k = i; k < j; k ++)				//此时从 i 到 j - 1 所有字母即当前单词所有字母
			cout << str[k];
		
		cout << endl;
		
		i = j;				//当前这次 i 循环结束后让 i 指向 j , 即 i 会指向 j 指向的空格
	}						//下一轮 i 循环开始前 i 要先自增(i ++),就会指向下一个单词首字母
	return 0;
}

1.4.2例题2:AcWing 799. 最长连续不重复子序列

 注意到这道题要求的是最长连续不重复。因此在样例【1 2 2 3 5】中能够完全满足要求的子序列只有【2 3 5】,因此区间长度为 3。

我们可以画个图理解求解过程,我们设置两个指针 i 和 j 分别用于枚举终点和起点:

①初始时 i 指向第一个数,j 也指向第一个数;

② i 指向第二个数,j 指向第一个数;

③ i 指向第三个数,j 也指向第三个数。因为 i 指向第三个数时,j 不管指向第一个数还是第二个数,【j ,i】下标间元素都会有重复数字,因此 i 、j 只能指向同一个元素;

 

 

 ④ i 指向第四个数,j 指向第三个数,因为两个指针之间无重复数字;

⑤ i 指向第五个数,j 可指向第三个数字,同样是因为二者间无重复数字。

我们可以看到,每次都是 i 向后移动一位,再求新的 i 对应的 j ,看看 j 最靠左可在什么位置。 

通过这样一种方式,我们可以确保 j 和 i 之间的元素构成的子序列是一个当前已知的最长连续不重复子序列的候选。(至于是不是最终解还无法确定,因为 i 和 j 的移动都还没有结束)。

这里有一个重要的性质:随着 i 不断后移,j 只能向后走或停在原位而不会向前走。 即前面所强调的“单调性”

对于 j 的单调性可以用反证法证明:

设 i 后移时 j 可前移,我们不妨设移动前的 i 、j 为“旧 i ”和“旧 j ”,移动后的 i 、j 为“新 i ”和“新 j ”。由定义可知,新 i新 j 之间无重复元素。那么旧 i新 j 之间也无重复元素,因为一个无重复元素的区间内部的所有子区间内均无重复元素。那么再往前,j 不应该移动到旧 j 的位置以确保无重复数,而是停留在当前新 j 的位置即可,即【旧 j旧 i】间不能作为最长连续不重复子序列的备选,矛盾。因此假设不成立。所以 i 后移 j 也只可后移或者停在原位而不能前移,具有单调性.□

由此可见,要想使用双指针,必须先证明单调性!!!

我们可以对朴素算法进行优化,只需枚举 i 即可,j 每次根据情况判断要不要往后走:

for(int i = 0, j = 0; i < n; i ++)
{
    while(j <= i && check(j , i))    //判断【j , i】间有无重复元素
        j ++;                        //若有则 j ++
    
    res = max(res , i - j + 1);
}

代码实现如下:

#include <iostream>

using namespace std;

const int N = 100010;

int a[N];			//a数组用于存放当前数组每一个数,
int s[N];			//s数组用于存放当前数组中每一个数出现的次数

int main(){
	int n;
	cin >> n;
	
	for(int i = 0; i < n; i ++)
		cin >> a[i];
		
	int res = 0;			//res表示答案
	
	for(int i = 0 , j = 0; i < n; i ++){
		s[a[i]] ++;			//统计a[i]对应的数字出现的次数
		
	//不必写 j<= i,虽然按模板这是一个判断条件。因为若 j > i 说明区间内没有数,一定是满足要求的
		while(s[a[i]] > 1)			//因为新加的数是 a[i],因此若有重复也是 a[i] 这个数重复了
		{
			s[a[j]] --;					// j 指针向后移,则当前指向的数出现的次数 -1
			j ++;
		}
		
		res = max(res , i - j + 1);
	}
	cout << res ;
	return 0;
}

两个指针 i 、j 共走 2n 步(i 走 n 步,j 走 n 步),因此时间复杂度可优化为O(n)。

2.位运算

位运算最常用2种操作:求整数 n 的二进制表示中的第 k 位;返回二进制数 x 最后一位 1。

2.1求整数 n 的二进制表示中第 k 位 

如 (10)_{10} = (1010)_{2}

十进制 10 的二进制表示中第 1 位是 1。 (注意二进制最低位表示第 0 位

2.1.1思路

(1)先把第 k 位移动到最后一位:n >> k(向右移动 k 位,注意二进制最低位表示第 0 位);

(2)看看个位是什么:x & 1(x 表示个位上的数字。因为1和任何数做与运算,都能得到它本身);

将(1)(2)结合一下:( n >> k ) & 1

2.1.2 例:输入一个十进制数 x  = 10, 表示其二进制形式

代码如下: 

#include<iostream>
using namespace std;

int main(){
    int x;
    cin >> x;
    
    for(int k = 3; k >= 0; k --)
        cout << (x >> k & 1) << ' ';
        
    return 0;
}

 输入:10

输出:1 0 1 0

 2.2 lowbit(x):返回十进制数 x 的二进制形式的最后一位1

如 

十进制形式二进制形式lowbit(x)
10101010
401010001000

10 的二进制形式,最后一个 1 表示 10 ,即十进制中的 2;

40 的二进制形式,最后一个 1 表示 1000,即十进制中的 8。

2.2.1 表达式

x & -x

原因是 C++ 中 -x 的二进制表示等价于 x 取反后加1。即 -x = ~x + 1 。 

所以 x & -x = x & (~x + 1) 。

x =1010......100......0
~x =0101......011......1
~x + 1 =0101......100......0
x & (~x + 1) =0000......100......0

x 同 (~x + 1) 最末尾的若干个 0 做与运算 & 后仍是0,x 同 (~x + 1)最后一个 1 相与 & 后仍是1。而x 同和(~x + 1)最后一个 1 之前的所有数正好相反,做与运算 & 后为0。综上,求 x 的二进制形式的最后一位 1 是什么可以用 x & -x 来做。

2.2.2 应用

常用于统计十进制数 x 的二进制形式中 1 的个数。思想就是每次减掉 x 中最后一个1,当 x 减到 0 时,x 中就没有 1 了。减了多少次就说明 x 有多少个 1。

2.2.3 例:ACWing 801. 二进制中1的个数

 代码如下:

#include<iostream>
using namespace std;

int lowbit(int x){
    return x & -x;
}

int main(){
    int n;
    cin >> n;
    while(n --){
        int x;
        int res = 0;
        cin >> x;
        while(x){				//只要x不为0
            x -= lowbit(x);		//每次减去x最后一位1
            res ++;
        }
        cout << res << ' ';
    }
    return 0;
}

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值