%% # 纲要 > 主干纲要、Hint/线索/路标 # Q&A #### 已明确 ❓ <font color="#c00000">双层 `while` 循环,什么时候 `i` 和 `j` 的更新放前面,什么时候 `i` 和 `j` 的更新放后面?</font> 取决于 i 和 j 的含义: - 如果 `i` 或 `j` 指向 "**当前待处理位置**",那么在循环体的尾部,完成对当前位置的处理后,就需要移动 i 和 j,使其指向下一个待处理位置; - 如果 `i` 或 `j` 表示 "**当前待处理区间的左开/右开边界**",例如 `[l, r)` 或者 `(l, r]`,则作为 "**开区间的边界**" 时其本身就已经指向了 "待处理位置",因此在循环体的末尾通常不需要再移动。 <font color="#c00000">❓滑动窗口和双指针有什么关系?</font> - 滑动窗口是**双指针法**的一种情况,**两指针代表左右边界**,包括: - 定长滑动窗口(窗口大小、即两指针相对距离不变) - **变长滑动窗口**(在单调性序列上,**枚举各个位置作为区间==右边界==**) #### 待明确 > 当下仍存有的疑惑 **❓<font color="#c0504d"> 有什么问题?</font>** ❓ <font color="#c00000">那些变长滑动窗口的模版题,如果按 "枚举左边界" 去写是否好理解?</font> 不好理解。 # Buffer ## 闪念 > sudden idea ## 候选资料 > Read it later %% # 双指针模版 参见 [[00-数据结构与算法笔记/专题总结/双指针模版.canvas|双指针模版]] ![[00-数据结构与算法笔记/专题总结/双指针模版.canvas|双指针模版]] # 双指针(尺取法)总结 > 双指针法,也称 "**尺取法**"。 两个指针的组合实际上有 $n^2$ 种情况,双指针核心思想是**在扫描上做优化**,将**两层循环优化到只需 $O(2n)$ 的遍历**。 "双指针" 法是一个统称,**在不同场景下具体应用方式不同**,常见的包括: - 双指针——**位置处理**:每次 **==提取==**/处理 **==指针所指的两个位置==**; - 双指针——**区间提取/区间确认**:每次提取/处理**一个或两个区间**; - 双指针——**主次指针(辅助指针)**:"主指针" **枚举/遍历**各个位置,**==辅助指针==** 视情况移动; - 双指针——**滑动窗口**:本质上是在 **==单调性==序列**上 **==枚举==各个==右边界==** 对应的窗口情况 - 双指针——**快慢指针**:两个指针**步数不同**,常用于 "环" 相关问题; <br> ### 双指针「位置处理」、「确认区间」、 「滑动窗口」的区别 #### 找位置 ![[Excalidraw-Solution/双指针_0.excalidraw.svg|400]] %% [[Excalidraw-Solution/双指针_0.excalidraw.md|🖋 Edit in Excalidraw]] %% #### 找区间 「**找区间/确认区间**」是每次要搜索/确定一个 "**==最大化==的、==右边界不可再延伸==**" **的目标区间段**。 - **不同区间之间通常是 ==无重叠的==**,没有 "滑动" 的过程,每确定一段**极大区间**后才进行处理,而后从 **==剩余未搜索区域==** 中找下一个所需区间; - 外层循环逻辑:从**剩余未搜索区域**中找到**满足条件的起始点**作为 **==左边界==** 固定, 而后**扩展==右边界==** 直到**不满足条件**,从而确定一个**目标区间**。 ![[Excalidraw-Solution/双指针_1.excalidraw.svg|400]] %%[[Excalidraw-Solution/双指针_1.excalidraw.md|🖋 Edit in Excalidraw]]%% #### 滑动窗口 「**滑动窗口**」本质上是枚举所有位置作为 "右边界" 的情况, 因此**窗口右边界每次==只移动一步==**, "**整个窗口**" 通过 **==滑动 or 扩展/收缩==** 进行搜索,**窗口在变化前后==具有重叠的覆盖区域**。 ![[Excalidraw-Solution/双指针_2.excalidraw.svg|700]] %%[[Excalidraw-Solution/双指针_2.excalidraw.md|🖋 Edit in Excalidraw]]%% --- <br> <br> # 双指针——主次指针(辅助指针) 通常**外层采用 `for` 循环**的写法,是对**主指针 `j`** 的移动,循环内**视==情况/条件==移动左指针**。 - **主指针 `j`** 指向**剩余==待搜索区间的头部==**,即**当前待判断元素**。 - **次指针/==辅助指针==** `i` 指向已得到**序列==尾部的后一位==**,即 **==待插入下一个有效元素==** 的位置,`i` 的值即为当前有效元素长度。 - `j` 每次移动一步,逐个扫描元素,若 `i` 和 `j` 满足条件,则并后移 `i`:`++i` 。 ```cpp int n = seq.size(); // 循环不变量: j指向剩余待搜索区间的头部, i指向已处理区间尾部的"后一位". int i = 0; for (int j = 0; j < n && i < m; ++j) { if (satisfy(seq, i, j)) { // do something... process_pos(seq, i, j); ++i; // 移动辅助指针 } } ``` ### 扩展:荷兰国旗算法——三指针(一个主指针,两个辅助指针) - **主指针 `i`** 指向**剩余==待搜索区间的头部==**,即**当前待判断元素**。 - **两个辅助指针`j`、`k`** 分别**指向==两段序列==各自的==尾部的后一位==**,即 **==待插入下一个有效元素==** 的位置。 ###### 示例一:荷兰国旗问题(Dutch National Flag Algorithm) 将只含有 **0、1、2 三值**的数组**重排列**,使得相同值的元素相邻,同时整体分成 0、1、2 三段) ###### 示例二:将区间`[l, r]`内元素依次分成 `<val`、`==val`、`>val` 的三段 **循环不变量**: - 主指针 `i` 指向 **==剩余待处理区间==的头部**,即**当前待处理元素**; - 指针 `lt` 指向 `<val` 的**序列尾部的后一位**,即 "**==待插入==下一个 `<val` 的元素的位置**"; - 指针 `gt` 指向 `>val` 的**序列头部的前一位**,即 "**==待插入==下一个 `>val` 的元素的位置**"。 - 区间划分: - `[l, lt)` 为 `< val` 的序列; - `[lt, gt]` 为 `==val` 的序列; - `(gt, r]` 为 `> val` 的序列; - `[i, gt]` 为 "**剩余待处理区间**",因此循环条件为 `i <= gt`,即 **==剩余待搜索区间非空==** ```cpp // 将区间arr[l, r]内的元素划分成`<pivot`, `==pivot`, `>pivot`三段. void Partition(vector<int> &arr, int l, int r, int pivot) { int lt = l; int gt = r; int i = l; while (i <= gt) { // 区间[i, gt]为剩余待搜索区间. 循环条件即为该区间非空. if (arr[i] < pivot) { // 由于[lt, i)区间全为==val的元素, 所以交换到位置i的就是val值. // 因此同时后移 lt 和 i. swap(arr[i++], arr[lt++]); } else if (arr[i] > pivot) { swap(arr[i], arr[gt--]); } else { // arr[i] == pivot ++i; } } } ``` <br><br> # 双指针——位置处理 双指针 `i` 和 `j` 每次用于 **==提取==**/处理 **==两个位置==**。 即**两个指针至少有一方的位置是不确定**的,每次都需要**先移动两个指针直至指向目标位置**,从而**提取两个元素进行处理**。 通常有两种等价的写法:**单层 for 循环**、**双层 while 循环**; ## 双指针指向同一序列 #### 单层 for 循环的写法 ```cpp int j = 0; // 逐位检查左边界, 为每一个有效左边界,找到满足条件的最大右边界. // 与下面的双层while循环本质上相同. for (int i = 0; i < n; ++i) { if (satisfy_pos(seq, i)) { // 可作为左边界 j = min(i + 1, j); while (j < n && !satisfy_pos(seq, j)) ++j; // 找到满足条件的右边界. if (j == n) break; // 处理位置i和j process_pos(seq, i, j); ++j; } } ``` #### 双层 while 循环的写法 > while 双层循环的写法,双指针 `i` 和 `j` 都指向当前**待处理位置**,外层循环的逻辑是 "每次**处理两个指针所指的一对位置 `i`, `j`**" ##### 同向移动 ```cpp int n = seq.size(); // 循环不变量: i指向"i对应的剩余待处理区间的头部", j指向"j对应的剩余待处理区间的头部" int i = 0, j = 0; while (j < n) { // 或者 `i <= j`, 取决于具体场景 // 外层循环的逻辑: 每次尝试令指针i和j指向两个"有效位置", 取两指针所指元素进行处理. // 1. 移动i直到下一个有效位置 while (i < n && !satisfy(seq, i)) ++i; // 2. 保证j在i右侧, 并且移动j直到指向下一个有效位置 j = max(i + 1, j); // 如果允许i和j重合, 则这里取`j = max(i, j);`. while (j < n && !satisfy(seq, j)) ++j; // 处理位置i和j if (i < n && j < n) { process_pos(seq, i, j); } // 当前位置已处理完, 后移i和j, 使其分别指向下一个待处理位置. ++i; ++j; } ``` ##### 首尾相向移动 ```cpp int n = seq1.size(); // 循环不变量: i、j分别"剩余待处理区间的头部、尾部" int i = 0, j = n - 1; while (i < j) { // 或者 `i <= j`, 取决于具体场景 // 外层循环的逻辑: 每次尝试令指针i和j指向两个"有效位置", 取两指针所指元素进行处理. // 1. 确定左、右指针指向有效位置, 或越界 while (i < j && ...) ++i; // 如果每次固定移动1位, 则省略while循环 while (i < j && ...) --j; // 如果每次固定移动1位, 则省略while循环 // 处理位置i和j. if (i < j) process_pos(i, j); // 当前位置i和j已完成处理, 后移i和j, 使其指向"待搜索区间", 保证循环不变量成立 ++i; --j; } // 退出循环时, i >= j. ``` <br> ## 双指针指向两个序列 > while 双层循环的写法,双指针 `i` 和 `j` 都指向当前**待处理位置**,外层循环的逻辑是 "每次**处理两个指针所指的一对位置 `i`, `j`**" ```cpp title:two_pointers_to_two_sequence.cpp int n = seq1.size(), m = seq2.size(); // 循环不变量: i、j均指向"剩余待处理区间的首个元素" int i = 0, j = 0; while (i < n || j < m) { // 或者`i < n && j < m`, 或者单一条件`j < m`等, 取决于具体场景 // 外层循环的逻辑: 每次尝试令指针i和j指向两个"有效位置", 取两指针所指元素进行处理. // 1. 确定左指针指向有效位置, 或越界 while (i < n && ...) ++i; // 如果是每次固定移动一位, 则省略此处while循环 // 2. 确定右指针指向有效位置, 或越界 while (j < m && ...) ++j; // 如果是每次固定移动一位, 则省略此处while循环 // 处理位置i和j. if (i < n && j < m) { // 两指针位置有效 // ... process_pos(i, j); } else if (i < n || j < m) { // 仅其中一个指针位置有效, 另一指针越界 // ... } // 当前位置i和j已完成处理, 后移i和j, 使其指向"待搜索区间", 保证循环不变量成立 ++i; ++j; } // 退出循环时, 两个指针均越界. // do something... ``` <br><br><br> # 双指针——区间确认/区间提取 通过**两个指针**来标记区间的**左**、**右边界**,每次确定一个「 **==最大化的、边界不可再扩张的目标区间段==** 」后对该区间段进行处理, 随后再去 **==剩余待搜索区域==** 中**找下一个目标区间段**。 双指针用于在 **一般序列 / 单调性序列** 上提取/确认区间时,**写法不同**: - 对于 "**==一般序列==**":目的在于 "**==提取==** 一个极大目标区间" 进行处理; - 通常包括两种写法: 1. 方式一:**单层 `for` 循环**写法 2. 方式二:**双层 `while` 循环**写法 - 对于 "**==单调性序列==**":目的在于 **==枚举==** 每个位置作为**左边界或右边界**,扩展/收缩得到对应的"极大右边界/极小左边界",取得**极大/极小的目标区间**。 - 写法通常为 "**外层 `for` 循环** + **内层 `while` 循环**" : - 外层 `for` 循环 **==枚举==各个位置**作为**左边界或右边界**; - 内层 `while` 循环用于==**扩展==极大右边界** 或 **==收缩==左边界**; 无论采用哪种写法,都推荐以 "**左开右闭区间 `[l, r)` 表示当前极大区间**" 作为 **==循环不变量==**,令 `r` 始终指向 "**剩余待搜索区间的头部**"。 ## (1)"一般序列"上提取区间 有两种等价的常见写法:1)单层 `for` 循环写法; 2)双层 `while` 循环的写法; #### 单层 for 循环写法:逐位遍历 > 逻辑:逐位遍历各个位置,检查是否可作为**左边界**,或者**右边界**。 1. **方式一**:适用于**必须**要得到一段 "**==最大化、不可再扩张的完整区间==**" 后才能进行 **==记录、处理==** 的情况,例如**获取一段完整目标字符串**后才进行哈希映射。 1. 若当前位置满足条件,则右边界后移; 2. 若当前位置不满足条件(包括 **==越界==** 时),则"**记录/处理前一段区间**",<br>然后**重置左边界**为当前位置; 3. 需要在循环外 **==单独处理最后一个区间==**。 2. **方式二**:每次得到一个**合法区间**(不一样是最大化的,或许仍能继续扩张)就进行**记录** 1. 若当前位置满足条件, 则"**记录当前区间长度**",**右边界后移**; 2. 若当前位置不满足条件, 则**重置左边界**为当前位置; > [!caution] > - 如果是在**不合法位置**才处理,就要在**循环外单独处理越界**的情况。 > - 如果是在**满足条件的合法位置边界处**进行处理,在处理完合法位置(例如添加答案等)的最后,就需要移动指针,指向剩余待处理位置。 ##### 标准模版 写法一:**只在 "==当前位置满足条件==" 时进行处理,并==检查后一位==是否无效;** ❇️(推荐) ```cpp // 写法一:只对满足条件的位置进行处理, 并且检查后一位是否无效. int l = -1; for (int r = 0; r < n; ++r) { if (satisfy(r)) { // 1.纳入当前位置, 更新区间状态. include(r); update(state); // 2.检查是否可作为左边界 if (l == -1) l = r; // 3.检查是否为右边界 if (r == n - 1 || !satisfy(r + 1)) { process_sec(seq, l, r); // 获取一段极大区间[l, r] // 重置区间状态, 重置左边界. reset(state); l = -1; } } } ``` 写法二:对每个位置分两个逻辑:**满足条件时如何处理,不满足条件时如何处理** ```cpp // 写法二: 不满足条件时, 若得到一段极大区间, 则进行处理; int l = -1; for (int r = 0; r < n; ++r) { if (satisfy(r)) { // 1.检查是否作为左边界 if (l == -1) l = r; // 2.纳入当前位置, 更新区间状态. include(r); update(state); } } else { // !satisfy(r) if (l != -1) { // 得到一段极大区间[l, r] process_seq(seq, l, r); reset(state); l = -1; } } } // 需要处理最后一段极大区间[l, n-1] if (l < n) process_sec(seq, l, n-1); ``` ##### 简化模版 ```cpp int n = seq.size(); // 循环不变量: [l, r) 表示当前待处理区间. r指向剩余待搜索区间头部. int l = 0; for (int r = 0; r < n; ++r) { // 省略了`satisfy(seq, r)`成立的处理逻辑 if (r == n - 1 || !satisfy(seq, r + 1)) { // 得到一段"最大化" process_sec(seq, l, r); l = r + 1; // 更新l指向当前r的后一位置. } } // 如果for循环中没有处理`r` == n-1时的情况, 则这里必须如下做额外处理. // if (l < n) process(l, n - 1); ``` #### while 双层循环写法:每次获取一段完整区间 ❇️ > 本质:外层循环逻辑为 "**每次==确认/提==取一段完整的目标区间 `[l, r)`(极大右边界,不可再扩展)并进行处理**"。 - **外层循环每轮完成:==找到/确定一个目标区间== & ==处理该区间==**,步骤为: 1. 确定**区间==左边界==** 2. 确定**区间==右边界==** 3. 处理**区间** - **内层循环用于上述步骤中,确定"区间==左、右边界=="**; ```cpp int l = 0, r = 0; // 循环不变量: [l, r)表示当前有效区间, r指向剩余待搜索区间的头部. // 外层循环每次确认/提取一段完整的目标区间 `[l, r)`(极大右边界,不可再扩展)并进行处理 while (r < n) { // 1.确定左边界: 跳过不满足的位置, 直到首个满足条件的位置. while (r < n && !satisfy(seq, r)) ++r; if(r == n) break; // 不存在有效区间 l = r; // 明确左边界存在. // 2.确定右边界: 跳过满足条件的位置, 直至首个不满足条件的位置. ++r; // 如果要保证 r 始终在l右侧, 则这里需要先后移一步. 如果r和l可重叠, 允许区间[l, r)长度为0, 则省略此行. while (r < n && staisfy(seq, r)) { // do something... ++r; } // 3. 得到有效区间[l, r) 上面已判断左边界存在, 因此区间长度至少为1. process_sec(seq, l, r-1); } // 其实 l 可以直接放在循环内定义, 每次确定起点后才需要l int r = 0; while (r < n) { while (r < n && !satisfy(seq, r)) ++r; if (r == n) break; int l = r; // 确定起点 // ... 其余同上 } ``` ##### 示例 ###### 找出所有连续相同内容的区间 ```cpp // 循环不变量: r指向剩余待处理区间的头部 // [l, r)为上一个已处理区间. l初始值是多少都无所谓. int l = 0, r = 0; while (r < n) { // j指向剩余待处理区间 // 确定左边界, 找出连续相同字符的最大区间[l, r) l = r; while (r < n && nums[r] == nums[l]) { // do something. ++r; } // 处理区间[l, r) process(l, r - 1); } ``` <br><br> ## (2)"==单调性==序列"上搜索区间 > 目的在于寻找合法的 "最大**左右边界**",要求序列本身**具有==单调性==**,**保证两个指针移动后都==不需要回头==**。 ### 枚举左边界,扩展最大右边界 > 本质:**枚举以每一个位置 `l` 作为==左边界==的情况**,在满足条件或不满足条件为其 **==扩展==得到最大右边界 `r`**,构成**有效区间 `[l, r)`** ```cpp int n = seq.size(); // 循环不变量: [l, r) 表示当前区间. r指向剩余待搜索区间的头部. int r = 0; // 枚举以每一个位置 `l` 为左边界的情况, 每次移动一步左边界l. // 为每个seq[l]找到极大有效区间[l, r). for (int l = 0; l < n; ++l) { // 对每个seq[l], 扩展得到相应的最大右边界r, 构成区间[l, r). r = max(l + 1, r); // 保证j在i右侧. 若l,r可重叠, 即允许区间[l,r)长度为0, 则取 `r = max(l, r);` // 若去掉上一行, 则下面循环应当为`while (r <= l || r < n && satisfy(seq, r))` while (r < n && satisfy(seq, r)) ++r; // 得到i对应的最大有效区间[l, r) process_sec(l, r-1); } ``` > 扩展情况:为每个位置 `i` 的 `seq[i]` 找到的有效区间 `[l, r)`; ```cpp // 扩展情况: // 为每个seq[i]找到的有效区间[l, r). int l = 0, r = 0; for (int i = 0; i < n; ++i) { // 对每个seq[i]: // 1. 不满足条件时扩展右边界j, 得到有效区间的起始左边界k. r = max(l + 1, r); // 保证j在i右侧. 若l,r可重叠, 即允许区间[l,r)长度为0, 则取 `r = max(l, r);` // 若去掉上一行, 则下面循环应当为`while (r <= l || r < n && satisfy(seq, r))` while (r < n && !satisfy(seq, r)) ++r; if (r == n) break; // 不存在有效区间. // 2. 满足条件时继续扩展右边界, 得到有效区间的最大右边界r-1. l = r; // k是有效区间的起始左边界. while (r < n && satisfy(seq, r)) ++r; // 得到i对应的最大有效区间[l, j). 上面已经判断r未越界, 因此这里区间大小至少为1. process_sec(seq, l, r-1); } ``` ### 枚举右边界,收缩左边界(变长 ==滑动窗口== ) > 本质:**枚举以每一个位置 `j` 作为==右边界==的情况**,在满足条件或不满足条件时 **==收缩==得到最右左边界 `i`**,构成**有效区间 `[i, j) ```cpp int n = seq.size(); // 循环不变量: [l, r) 表示当前区间. r指向剩余待搜索区间的头部. int l = 0; // 枚举以每一个位置`r`作为右边界的情况, 每次移动一步右边界r. for (int r = 0; r < n; ++r) { // 右边界右移一位, 纳入新位置seq[r]. include(seq, r); // 检查是否需要收缩左边界 while (l <= r && check_seq(seq, l, r)) { // 满足条件时收缩, 或者不满足条件时收缩 // 处理当前窗口 process_sec(seq, l, r); // 如果收缩条件是"窗口即为所需窗口时收缩", 则每次收缩前都要先记录当前窗口状态. // 收缩左边界. exclude(seq, l); ++l; } // 以当前j为右边界的"最小窗口"[i, j]. process_sec(seq, l, r); // 如果收缩条件是"窗口非所需窗口时收缩", 则最终收缩为目标窗口后, 再记录窗口状态 } ``` # 双指针——区间扩展 ![[00-数据结构与算法笔记/循环不变量#(3)从某一位置起扩展得到满足条件的最大区间|循环不变量]] <br><br> # 参考资料 # Footnotes