蓝桥杯算法精讲:双指针算法四大经典例题深度剖析

目录

  • 前言
  • 一、双指针
    • [1.1 唯一的雪花](#1.1 唯一的雪花)
    • [1.2 逛画展](#1.2 逛画展)
    • [1.3 字符串](#1.3 字符串)
    • [1.4 丢手绢](#1.4 丢手绢)
  • 结语

🎬 云泽Q个人主页
🔥 专栏传送入口 : 《C语言》《数据结构》《C++》《Linux》《蓝桥杯系列

⛺️遇见安然遇见你,不负代码不负卿~


前言

大家好啊,我是云泽Q,欢迎阅读我的文章,一名热爱计算机技术的在校大学生,喜欢在课余时间做一些计算机技术的总结性文章,希望我的文章能为你解答困惑~

一、双指针

双指针算法有时候也叫尺取法 或者滑动窗口 ,是一种优化暴力枚举策略的手段:

  • 当我们发现在两层for循环的暴力枚举过程中,两个指针是可以不回退的,此时我们就可以利用两个指针不回退的性质来优化时间复杂度。
  • 因为双指针算法中,两个指针是朝着同一个方向移动的,因此也叫做同向双指针

注意:希望大家在学习该算法的时候,不要只是去记忆模板,一定要学会如何从暴力解法优化成双指针算法。不然往后遇到类似题目,你可能压根都想不到用双指针去解决。

1.1 唯一的雪花

唯一的雪花

当我们暴力枚举 的过程中,固定一个起点位置left,然后right之后向后遍历时。当right第一次扫描到一个位置,使[left, right]这个区间出现重复字符,此时我们会发现:

  • right 无需再向后遍历,因为继续向后走也是不合法
  • left向后移动一格后,right指针也不用回退,因为我们已经维护出[left, right]这个区间的信息,并且left + 1为起点的最优解一定不会比left为起点的好

当我们发现暴力枚举的两个指针不回退 时,就可以用滑动窗口优化:

  • 进窗口:right位置元素记录到统计次数的哈希表中
  • 判断:当哈希表中right位置的值出现超过1次之后,窗口内子串不合法
  • 出窗口:让left所指位置的元素在哈希表中的次数减一
  • 更新结果:判断结束之后,窗口合法,此时更新窗口的大小
cpp 复制代码
#include<iostream>
#include<unordered_map>
using namespace std;

const int N = 1e6 + 10;
int a[N];
int n;

int main()
{
	int T; cin >> T;
	//这里多组测试数据不用清空,因为在读入新的一组数据时会把前一组数据覆盖掉
	while(T--)
	{
		cin >> n;
		for(int i = 1; i <= n; i++) cin >> a[i];
		int left = 1, right = 1;
		int ret = 0;
		//双关键字哈希表,统计窗口内每个元素出现的次数
		unordered_map<int, int> mp;
		//right所指的元素全部进入窗口 
		while(right <= n)
		{
			mp[a[right]]++;
			while(mp[a[right]] > 1)
			{
				mp[a[left]]--;
				left++;
			}
			ret = max(ret, right - left + 1);
			right++;
		}
		cout << ret << endl;
	}
	return 0;
}



时间复杂度分析:

  1. 基础框架 :测试用例循环外层循环执行 T 次,每次处理一个长度为 n 的数组,我们重点分析单组测试用例的复杂度即可。

  2. 滑动窗口的核心:双指针(left/right)的移动特性

    滑动窗口的关键优势是:left 和 right 指针都只会单向向右移动不会回退,这是时间复杂度为线性的核心原因。

  • right 指针:从 1 遍历到 n,每个元素仅被访问一次,总操作次数为 n 次。
  • left 指针:只会在窗口出现重复元素时向右收缩,整个过程中 left 最多从 1 移动到 n,总移动次数最多 n 次(不会超过 n,因为 left ≤ right ≤ n)。

因此,滑动窗口的两层 while 循环(外层扩展窗口、内层收缩窗口)总执行次数为 O (n),而非 O (n²)(暴力解法才是 O (n²))。

  1. 哈希表(unordered_map)的操作复杂度
    代码中 mp[a[right]]++、mp[a[left]]--、mp[a[right]] > 1 都是哈希表的核心操作:
  • 平均情况:unordered_map 的插入、修改、查询操作的时间复杂度为 O(1)(哈希函数均匀分布,无冲突);
  • 最坏情况:若哈希冲突极端严重(所有元素哈希值相同),复杂度会退化为 O(n),但这种情况在实际编程 / 竞赛中几乎不会出现,通常按 O(1) 计算。

因此,哈希表操作不会改变整体的线性复杂度。

  1. 其他辅助操作
  • 数组读入:for(int i=1; i<=n; i++) cin >> a[i] → O(n);
  • ret = max(ret, right-left+1) → 每次窗口调整后执行,总次数 O (n);

这些操作均为线性复杂度,不影响整体结论。

还要注意两个点:
一:

ret 若被定义为全局变量,用于记录最大包裹大小。在多组测试数据(T > 1)的情况下,每组测试用例开始前没有将 ret 重置为 0,导致后续测试用例的结果会被前一次的结果污染,输出错误。

例如:

第一组测试用例的最大长度为 3,ret 被更新为 3。

第二组测试用例的最大长度应为 1,但由于 ret 初始值仍为 3,最终输出仍为 3,与预期不符。

ret切记要移到while(T--)循环内部

二:

直接用静态数组代替哈希表是不可行的,不知道静态数组怎么代替哈希表的可以看下一道题

题目中明确说明,每片雪花的标记值是在 [0, 10^9] 内的整数:

  • 如果直接开数组 cnt[x] 来统计出现次数,数组大小需要达到 10^9 + 1
  • 每个 int 占 4 字节,总内存需求约为 4GB,这远远超过了程序可用的内存限制,会直接导致 内存溢出(MLE)

而题目中的雪花总数 n ≤ 10^6,这只是数组 a 的长度,不是元素取值范围,所以不能用它来决定数组大小。

若元素取值范围已知且不大(比如 0 ≤ 元素 ≤ 1e6 或 1 ≤ 元素 ≤ 1e6),可以使用静态数组模拟代替哈希表,如下题

1.2 逛画展

逛画展

代码中隐含的解决了若存在多组解,输出a最小的那组的情况,if(len <= ret)时,若又找到一个同长度的区间是会更新的,但是代码中写的是if(len < ret),就算又同等长度也不会更新

cpp 复制代码
#include <iostream>
using namespace std;

const int N = 1e6 + 10, M = 2e3 + 10;
int n, m;
int a[N];
int kind; // 窗口内有效元素的个数
int mp[M]; // 统计窗口内每个元素出现的次数

int main()
{
    cin >> n >> m;
    for (int i = 1; i <= n; i++) cin >> a[i];
    int left = 1, right = 1;
    int ret = n, begin = 1;
    while (right <= n)
    {
        // 进窗口
        if (mp[a[right]]++ == 0) kind++;
        // 判断
        while (kind == m)
        {
            // 更新结果
            int len = right - left + 1;
            if (len < ret)
            {
                ret = len;
                begin = left;
            }
            // 出窗口
            if (mp[a[left]]-- == 1) kind--;
            left++;
        }
        right++;
    }
    cout << begin << " " << begin + ret - 1 << endl;
    return 0;
}

再结合样例梳理一下代码

在这道题中,用数组模拟哈希(代码里的 mp[N])比 STL 的 unordered_map(哈希表)速度更快;只有当元素范围极大时,哈希表才是更优选择。

代码里的 mp[N] 本质是「静态哈希表」:

  • 题目中画家编号(数组 a[i] 的值)范围如果≤1e6,数组能直接覆盖所有可能的 key;
  • 访问 mp[a[right]] 就是直接取内存地址,比 unordered_map 少了大量「软开销」。

而蓝桥杯的评测机对「常数时间」非常敏感,n=1e6 时,unordered_map 的额外开销会被放大,甚至可能超时;而数组能稳稳跑过。

只有满足以下条件时,才考虑哈希表:

  1. 元素的取值范围极大(比如画家编号是 1e9 级别),数组根本开不下(1e9 大小的数组需要约 4GB 内存,直接超限制);
  2. 元素是离散的、无规律的(比如字符串、大整数),无法用数组映射。

这道题显然不满足这两个条件,所以数组是最优解。

补充一个该题目的要点,begin在这道题目的作用:
left 是动态变化的,begin 是 "快照"

  • left:是滑动窗口的当前左边界,在整个算法过程中会不断向右移动,它的值是实时变化的。
  • begin:是我们找到的最优窗口(最短且 x 最小)的起始位置,它是一个 "快照",只有当我们发现了更优的窗口时,才会更新它的值。

1.3 字符串

字符串

写法1:

cpp 复制代码
#include<iostream>
using namespace std;

string s;
int mp[26]; 
int kind;

int main()
{
    cin >> s;
    int n = s.size();
    //初始化为字符串最大可能长度
    int ret = n;  
    
    int left = 0, right = 0;
    while(right < n)
    {
    	//创建的是大小是26的数组,要将字符映射到数组
        if(mp[s[right] - 'a']++ == 0) kind++;
        while(kind == 26)
        {
            int len = right - left + 1;
            //更新最短长度
            if(len < ret)
            {
                ret = len;
            }
            if(mp[s[left] - 'a']-- == 1) kind--;
            left++;
        }
        right++;
    }
    cout << ret << endl;
    return 0;
}

写法2:

cpp 复制代码
#include <iostream>
using namespace std;

string s;
int mp[26]; // 统计每个小写字符出现的次数
int kind; // 窗口内小写字符的种类

int main()
{
    cin >> s;
    int n = s.size();
    int ret = n;
    // 初始化
    for(int left = 0, right = 0; right < n; right++)
    {
        // 进窗口
        if(mp[s[right] - 'a']++ == 0) kind++;
        // 判断
        while(kind == 26)
        {
            // 更新结果
            ret = min(ret, right - left + 1);
            // 出窗口
            if(mp[s[left] - 'a']-- == 1) kind--;
            left++;
        }
    }
    cout << ret << endl;
    return 0;
}

该题目和上一道题目几乎一样,这里就不作解释了,给出两种写法

1.4 丢手绢

丢手绢

假设此时是顺时针走,加上a1,a2,a3后,累加后的和用k表示,累加的过程中若k第一次出现>= sum(所有数字的总和,a1 + a2 + a3...) / 2的情况,假设现在累加到4这个位置(a1 + a2 + a3)出现了这种情况,就可以得出结论,3号小朋友就是顺时针距离1最远的那个小朋友

结论也是根据该题目的题意来的,因为计算1-3的距离的时候,是计算两个小朋友顺时针以及逆时针的最小值。顺时针累加的时候,发现加了a3就超过sum / 2,此时计算1-4之间的距离的时候就不能用顺时针计算了,只能用逆时针来计算,因为此时计算1-4顺时针相加的时候是一定大于逆时针总和的。因此有理由推断3号小朋友一定是顺时针所能走到的最远的小朋友,因为3号小朋友再加上a3的时候就会超过总和的一半,超过总和的一般就要用逆时针来计算了,所以对于1号来说,顺时针最远的就是3号小朋友,逆时针最远的就是4号小朋友,因为逆时针加的时候,a5 + a4是小于总和的一半的,但再加上a3之后,此时就会超过总和的一半,超过一半之后,计算1-3号之间的距离就应该计算顺时针的方向,所以逆时针走的最远的应该是4号位置。所以顺时针累加的时候我们应该关心第一次出现 k >= sum / 2的情况,当找到了这样一个位置,顺时针走的最远和逆时针走的最远的位置就全部可以得知,这些知道之后就可以进而得知针对第i个人,距离他最远的那个人离他的距离是多少。针对所有的小朋友都找到这样的位置的话,该问题就解决了

两层for循环,第一层for循环枚举起始位置left,第二层for循环枚举终止位置right,枚举的时候就从1号小朋友向后累加,k为[left, right]之间的距离,当right第一次扫描到k × 2 >= sum时,枚举的过程就停止,比如此时right指向4,之后计算顺时针的距离(a1 + a2 + a3),计算逆时针的距离(a4 + a5),在这两个距离之间取个max就是最终结果

数据范围1 ×10的5次方,两层for循环会超时,所以就要优化一下暴力解法,由于发现left向后移动一格之后,right指针也不用回退,因为我们已经维护出来[left, right]区间的信息,right回退也不是最优解

当我们发现暴力枚举的两个指针不回退 时,就可以用滑动窗口优化:

  • 进窗口:right位置与前一个位置的距离累加到k中;
  • 判断:k × 2 >= sum时,此时right不用前进,应该让left所指的元素出窗口
  • 出窗口:让left所指的位置与前一个位置的距离累减到k中

这道题目更新结果需要在两个地方更新:

  1. 在right向后累加的过程中,窗口合法的情况是需要更新结果的,因为要把这段距离统计到最终结果中,用k来更新结果
  2. 当窗口不合法的时候(2k >= sum),就要用逆时针的那段距离来当作最远距离,逆时针的那段距离就是sum - k,逆时针的这段距离也有可能是最终结果
cpp 复制代码
#include<iostream>
using namespace std;

const int N = 1e5 + 10;
typedef long long LL;
LL a[N];
int n;

int main()
{
	cin >> n;
	LL sum = 0;
	for(int i = 1; i <= n; i++)
	{
		cin >> a[i];
		sum += a[i];
	}
	int left = 1, right = 1;
	LL k = 0, ret = 0;
	while(right <= n)
	{
		//进窗口
		k += a[right];
		//判断
		while(2 * k >= sum)
		{
			//更新结果 
			ret = max(ret, sum - k);
			//出窗口 
			k -= a[left++];
		}
		ret = max(ret, k);
		right++; 
	}
	cout << ret << endl;
	return 0;
}

补充一个点:

题目中距离和小于等于2147483647,但是计算2k的时候就有可能超出int的范围,所以直接用long long比较保险,且该题目要尤其注意类型匹配,在用max这个函数的时候,括号内两个值的类型一定要一样


结语

相关推荐
小龙报2 小时前
【算法通关指南:算法基础篇】二分算法: 1.A-B 数对 2.烦恼的高考志愿
c语言·开发语言·数据结构·c++·vscode·算法·二分
yong99902 小时前
NNDA、PDA、JPDA、IMM数据关联算法MATLAB实现
开发语言·算法·matlab
罗湖老棍子2 小时前
加分二叉树(信息学奥赛一本通- P1580)(洛谷-P1040)
算法·区间dp·区间动态规划
再难也得平2 小时前
力扣54. 螺旋矩阵(Java解法)
算法·leetcode·矩阵
飞Link3 小时前
别再被异常数据骗了:深度解析 TSTD 异常检测中的重构模型(AutoEncoder 实战)
人工智能·算法·重构·回归
小曹要微笑3 小时前
C#中的各种数据类型
算法·c#·数据类型·c#数据类型
靠沿3 小时前
【优选算法】专题九——链表
数据结构·算法·链表
马士兵教育3 小时前
AI大模型的未来职业发展方向!
开发语言·人工智能·面试·职场和发展