算法详细讲解:基础算法 - 位运算/双指针算法

位运算与双指针算法详解

位运算

讲解 - 位运算的两种常见操作

位运算只有两种常见的操作:

  • n的二进制中第k位是几:n >> k & 1

假设你有一个数字 n,你想知道它在二进制表示中的第 k 位是 0 还是 1。你可以通过两个步骤来实现:

  1. 把第 k 位移到最后一位:使用右移操作 >> 把第 k 位移动到最右边(也就是个位)。
  2. 查看个位是几:使用按位与操作 & 和 1 进行比较,看看个位是 0 还是 1。

具体步骤和例子

步骤 1: 右移操作 n >> k

假设 n = 15,它的二进制表示是 1111。我们想检查第 k 位的值,比如 k = 2

  • n 的二进制表示是 1111
  • 我们要检查第 2 位,所以进行右移操作 n >> 2。当我们进行右移操作 n >> k 时,实际上是把二进制数的所有位向移动,高位(左边)补0,低位(右边)被移出。
原始:   1  1  1  1
位数:   3  2  1  0
右移:   1  1  1  1 >> 2 = 0 0 1 1

可以看到,第 2 位(从右往左数,第 3 个位置)被移到了最右边。

步骤 2: 按位与操作 & 1

接下来,我们需要确定最右边的位是 0 还是 1。我们用 & 1 来做这个判断。

按位与(&)操作的规则是:

  • 0 & 0 = 0
  • 0 & 1 = 0
  • 1 & 0 = 0
  • 1 & 1 = 1
  0011
& 0001
------
  0001

结果是 1,说明原来第 2 位是 1

  • 返回n的最后一位1:lowbit(n) = n & -n

假设你有一个数字 n,你想知道它在二进制表示中最低位的1是几。你可以通过 n & -n 这个操作来实现。

为什么 -n 能帮到我们?

在计算机中,负数是以补码形式存储的。对于一个正数 n,它的负数 -n 的补码可以通过以下步骤得到:

  1. 先取反(每一位0变1,1变0)
  2. 再加1

举个例子,如果 n = 6,它的二进制表示是 0110,那么 -n 的计算过程如下:

  1. 取反:1001
  2. 加1:1010

所以 -6 在二进制中表示为 1010

具体步骤和例子

步骤 1: 计算 -n

假设 n = 6,上面已经演示过了,-6的二进制是1010

步骤 2: 按位与操作 n & -n

接下来,我们将 n-n 进行按位与操作:

  0110 (n)
& 1010 (-n)
------
  0010 (结果)

结果是 0010,也就是十进制的 2

总结为公式就是

x&−x=x&(∼x+1)

一个数 x 与上他本身的相反数,等于x与上本身取反后加一。

做题模板

运用位运算的两种常见操作,就可以得到这个数的二进制表示

模板题

801. 二进制中1的个数 - AcWing题库

#include<bits/stdc++.h>
using namespace std;

// 3.1 定义lowbit函数
// 提取 x 的二进制中最右边的 1 所代表的数值
int lowbit(int x) {
	// 利用补码性质
	// -x 是 ~x + 1,x & -x 得到最低位的1
	return x & -x;
}

int main() {
	int n;
	cin >> n;
	while(n--){
		int x;
		cin >> x;
		// 1.记录二进制中1的个数,初始为0
		int res = 0;
		// 2.循环:不断去掉x最右边的1,直到x变为0
		while(x){
			// 3.使用lowbit函数:每次减去x的最低位的1
			x -= lowbit(x);
			// 4.每去掉一个1,计数器加1
			res++;
		}
        cout << res << ' ';
	}
	return 0;
}

双指针算法

讲解

双指针算法的用途有非常多,KMP也算是双指针算法。双指针的核心思想就是优化。朴素的双重循环算法具有 O(n^2)的时间复杂度,而通过巧妙地使用双指针,可以将时间复杂度优化到 O(n)

// 朴素算法 O(n^2)
for (int i = 0; i < n; i++) {
    for (int j = 0; j < n; j++) {
        // 检查条件
    }
}

// 双指针算法 O(n)
for (int i = 0, j = 0; i < n; i++) {
    while (j < i && check(i, j)) j++;
    // 每道题目的具体逻辑
}

第一类:指向两个序列

这类双指针算法通常用于处理两个独立的序列,比如归并排序中的合并过程。

示例:归并排序

在归并排序中,我们需要将两个有序序列合并成一个有序序列。此时可以使用两个指针分别指向两个序列的起始位置,逐个比较并合并。

第二类:指向一个序列(区间)

这类双指针算法通常用于在一个序列中寻找满足特定条件的子区间。快速排序中的分区操作就是一个典型例子。

示例:快速排序

在快速排序的分区过程中,我们需要找到一个基准值,并将小于基准值的元素放在左边,大于基准值的元素放在右边。此时可以使用两个指针分别从两端向中间移动,交换不符合条件的元素。

两种都可以见博客:算法详细讲解- 快速排序与归并排序-优快云博客

统一模板

所有的双指针算法都可以归类为一个统一的模板,如下所示:

for (int i = 0, j = 0; i < n; i++) {
    while (j < i && check(i, j)) j++;
    // 每道题目的具体逻辑
}
  • 外层循环for (int i = 0, j = 0; i < n; i++),控制一个指针 i 从左到右遍历整个序列。
  • 内层循环while (j < i && check(i, j)) j++;,控制另一个指针 j 根据特定条件进行移动。

举一个例子:

#include<bits/stdc++.h>
using namespace std;

// 例子:输入abc def ghi,每个单词输出并空格隔开

char str[1000];

int main() {
	gets(str);
	int n = strlen(str);
	// 移动i指针
	for (int i = 0; i < n; i++) {
		int j = i;
		// 移动j指针,遇到空格就停下
		while(j < n && str[j] !=  ' ') j++;
		// 这道题的具体逻辑
		for (int k = i; k < j; k++) cout << str[k];
		cout << endl;
		i = j;
	}
	return 0;
}

做题模板

1. 朴素做法(双重循环)

  • 时间复杂度为 O(n2)O(n2)。
  • 使用两个嵌套循环遍历数组中的所有子数组,并检查每个子数组是否满足条件。
  • 对于每个右端点 i,从左端点 j = 0 开始逐个检查到 j = i
// 朴素做法:O(n^2)
for (int i = 0; i < n; i++) {
    for (int j = 0; j <= i; j++) {
        if (check(j, i)) { // 检查子数组 [j, i] 是否满足条件
            res = max(res, i - j + 1); // 更新结果
        }
    }
}

2. 双指针算法

  • 时间复杂度为 O(n)O(n)。
  • 使用两个指针 i 和 j 遍历数组,通过移动指针来动态调整子数组范围。
  • 当子数组 [j, i] 不满足条件时,移动左指针 j,直到子数组满足条件。
// 双指针算法:O(n)
for (int i = 0, j = 0; i < n; i++) {
    while (j <= i && check(j, i)) j++; // 移动左指针 j,直到不满足条件
    res = max(res, i - j + 1); // 更新结果
}
  • 优化关键:在朴素做法中,每次右端点 i 固定时,需要从左端点 j = 0 开始逐个检查到 j = i。而在双指针算法中,左指针 j 只需要根据条件进行相应的调整,避免了重复计算。
  • 动态调整:当右指针 i 向右移动时,左指针 j 可以直接跳过已经检查过的部分,从而将时间复杂度从 O(n2)O(n2) 优化到 O(n)O(n)。

模板题

799. 最长连续不重复子序列 - AcWing题库

给定一个长度为 n 的整数序列,请找出最长的不包含重复的数连续区间,输出它的长度。

#include<bits/stdc++.h>
using namespace std;

const int N = 100010;
int n; // 存储序列长度
int a[N]; // 存储输入的整数序列
int s[N]; // 频次数组:s[x] 表示数字 x 在当前区间 [j, i] 中出现的次数

int main() {
	// 读入序列长度 n
	cin >> n;  
	// 读入 n 个整数到数组 a 中
	for (int i = 0; i < n; i++) {
		cin >> a[i];
	}
	// 用于记录最长不重复区间的长度,初始为 0
	int res = 0;
	// 双指针算法主循环
	// i 是右指针,j 是左指针,共同维护一个不包含重复数字的滑动窗口 [j, i]
	for (int i = 0, j = 0; i < n; i++) {
		// 第一步:将右端点 a[i] 加入当前区间
		s[a[i]]++;  // a[i] 的出现次数加 1
		
		// 第二步:如果 a[i] 重复了(出现次数 > 1),就要移动左指针 j,缩小窗口
		// 直到 a[i] 的频次降为 1,即区间内不再有重复
		while (s[a[i]] > 1) {
			s[a[j]]--;  // 把左端点 a[j] 移出区间,它的频次减 1
			j++;        // 左指针右移
		}
		
		// 第三步:此时区间 [j, i] 是合法的(无重复),更新最大长度
		res = max(res, i - j + 1);  // 当前区间长度为 i - j + 1
	}
	
	cout << res << endl;
	return 0;
}

800. 数组元素的目标和 - AcWing题库

给定两个升序排序的有序数组 A 和 B,以及一个目标值 x。请你求出满足 A[i]+B[j]=x 的数对 (i,j)。

#include <bits/stdc++.h>
using namespace std;

const int N = 100010;

int n, m, x, a[N], b[N];

int main() {
	scanf("%d%d%d", &n, &m, &x);
	
	for (int i = 0; i < n; i++) {
		scanf("%d", &a[i]);
	}
	
	for (int i = 0; i < m; i++) {
		scanf("%d", &b[i]);
	}
	
	// 双指针法求两数之和(适用于两个有序数组)
	// 思路:
	//   - 指针 i 从数组 A 的起始位置(0)开始,向右移动(从小到大)
	//   - 指针 j 从数组 B 的末尾位置(m-1)开始,向左移动(从大到小)
	//   - 利用两个数组有序的性质,动态调整 j 的位置,使得 a[i] + b[j] 接近 x
	
	// 初始化:i 从 0 开始,j 从 B 的最后一个元素开始
	for (int i = 0, j = m - 1; i < n; i++) {
		// 当前 a[i] 固定时,如果 a[i] + b[j] 太大,说明 b[j] 太大
		// 因为 b 是升序的,所以 j 要左移(j--),尝试更小的 b[j]
		// 循环条件:j >= 0 保证不越界,且 a[i] + b[j] > x 时需要减小和
		while (j >= 0 && a[i] + b[j] > x) {
			j--;
		}
		
		// 退出 while 后,有两种可能:
		//   1. j < 0:说明 B 中所有元素都太小,无法与 a[i] 配对达到 x(但题目保证有唯一解,所以不会发生)
		//   2. a[i] + b[j] <= x,此时我们检查是否正好等于 x
		if (j >= 0 && a[i] + b[j] == x) {  // 注意:j 可能已变为 -1,需判断
			// 找到唯一解:输出下标 i 和 j
			printf("%d %d\n", i, j);
			break;  // 题目保证有唯一解,找到后即可退出
		}
		
		// 如果 a[i] + b[j] < x,说明 a[i] 太小,进入下一轮循环,i++,尝试更大的 a[i]
		// 此时 j 保持不变或已经调整过,继续用于下一个 i
	}
	return 0;
}

练习题

128. 最长连续序列 - 力扣(LeetCode)

这道题与上面唯一不同的是序列是未排序,而且是需要去重的,题解如下:

class Solution {
public:
    int longestConsecutive(vector<int>& nums) {
        if (nums.empty()) return 0;
        
        sort(nums.begin(), nums.end());
        nums.erase(unique(nums.begin(), nums.end()), nums.end());

        int res = 1, len = 1;
        for (int i = 1; i < nums.size(); i++) {
            if (nums[i] == nums[i-1] + 1) {
                len++;
            } else {
                len = 1;
            }
            res = max(res, len);
        }
        return res;
    }
};

在这里,去掉重复元素的统一固定做法是:

 nums.erase(unique(nums.begin(), nums.end()), nums.end());

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值