多指针法
← 内容总览 03 / 24 下一节 →
强化讲义 · 原始转写 · 原书检索 · 文字用于定位,公式与图表请回原页核验 · 本次导入快照
## 多指针法
对比枚举法,多指针法的精髓在于其高效的“剪枝”能力,而这种能力完全来自于指针移动的“规则”。这个规则通常基于数组的有序性等已知条件。例如,在合并两个升序数组时,规则是“取较小值,移动其指针”。这个简单的规则保证了我们每次选择的都是当前所有未合并元素中的最小值,从而无需比较所有未合并的元素。我们之所以敢于只移动一个指针,是因为我们确信被“跳过”的那个数组中的其他元素(在该指针之后的元素)必然比当前选中的元素更大,不可能成为下一个合并的元素。这种基于规则的确定性移动,让我们一次性地排除了大量的比较,实现了从O((m+n)^2)到O(m+n)的飞跃。可以说,找到并证明这个“规则”的正确性,是使用多指针法解决问题的关键。
多指针法考虑三个要点:指针起始位置、移动规则、停止处理。
## 类型1:多个有序顺序表
这是多指针法最经典的应用场景。通过在每个有序表上设置一个指针(可能在头或尾),并根据特定规则移动它们,可以高效地解决多种问题。我们从以下题目中感受:
例题2-2:给定升序数组 A、B,长度分别为 n、m,合并成一个新的升序数组C 并输出。
例如:A = {1, 2, 7},B = {3,4, 5}
枚举法:最暴力的枚举是,为了确定新数组C的下一个元素,我们需要比较A和B中还剩下的所有元素,然后找出最小的。确定第二个元素时,又要重复这个过程。这显然是低效的,时间复杂度为 O((m + n)2)。
<!-- page 24 -->
多指针法:在枚举法的基础上,发现升序条件没有用到,数组A、B、C都会是升序数组,因此数组C的下一个元素一定是数组A中最小元素及数组B 中最小元素其中较小的那个。最小元素在升序数组中是最左侧元素,所以设置两个指针分别指向A、B现存最小元素,每次比较指针所指元素并移动即可,时间复杂度 O(m+n)。
分析:数组C需要是升序,元素是从小到大存放,下一个元素一定是数组A中最小元素及数组B中最小元素其中较小的那个。所以每个数组的指针都应该分别指向他们还未处理的最小元素(即最左侧的下标)。 道
起始位置:数组 A、B、C的指针都是从左侧开始向右移动。
规则:比较数组A、B对应指针所指的元素,将较小的元素复制到结果数组中,然后将该较小元素所在的数组的指针、最终数组的指针各移动一位。不断重复这个过程。这个规则保证了结果数组始终是升序的,因为我们每次都选取了当前所有待选元素中的最小值。
停止处理:当其中一个数组的指针移出数组(即遍历完该数组的所有元素)时,退出循环。此时,另一个数组中可能还有剩余的元素,由于原数组是有序的,这些剩余元素必然都大于等于已合并的所有元素。因此,只需将这个数组的剩余部分依次复制到结果数组的末尾即可。 道
void Merge ( int A [], int n , int B [], int m )
{
int C [ n + m ];
int i = 0 , j = 0 , k = 0 ;
//直到有一个数组遍历完退出循环
while ( i & lt ; n & amp ; & amp ; j & lt ; m )
if ( A [ i ] & lt ; B [ j ])
C [ k ++ ] = A [ i ++ ];
else
C [ k ++ ] = B [ j ++ ];
//复制A 中剩余的元素
while ( i & lt ; n )
C [ k ++ ] = A [ i ++ ];
//复制B 中剩余的元素
while ( j & lt ; m )
C [ k ++ ] = B [ j ++ ];
从左到右输出数组C中的元素
}
例题 2-8 改编:给定升序数组 A、B,数组 A 中都是负整数,数组B 中都是非负整数,将所有元素平方后按降序排序,存入新数组C并输出。
<!-- code-audit: false_positive -->
> [!info] 已人工审读:这是正文中的集合示例,不是代码块;checker 将数学示例误判为 C 代码。
例如 : A = { -4 , -2 } B = { 0 , 1 , 3 } → C = { 16 , 9 , 4 , 1 , 0 } 。
暴力解:将所有元素平方后存入数组 C,对数组C 快速排序,时间复杂度 O((m+n)^2)。
优化解:多指针法,时间复杂度 O(m+n)。
<!-- page 25 -->
分析:目标数组C 需要是降序,元素应从大到小存放。C 的下一个元素必然是 A和B当前剩余元素平方后的较大者。 A
对于 A(负数升序),其绝对值是降序的,所以平方后的值也是降序((—4)²>(-2)²)。最大值在 A[0]。
对于B(非负数升序),其绝对值是升序的,所以平方后的值是升序(0²< 1² < 3²)。最大值在B的末尾。
起始位置:数组A、C的指针都是从左侧开始向右移动,数组B的指针是从右向左移动。
规则:比较|A[i]|和 |B[j]|,将绝对值较大者的平方值放入C[k],然后移动被选中的指针(i 向右或j 向左)和结果指针k。 X
停止处理:当其中一个指针(i或j)越界时,主循环停止。此时,将另一个数组中所有剩余元素的平方值依次复制到C的末尾。 人
<!-- verified-by-hand -->
> [!success] 已结合原文第 25 页及 Markdown 上下文人工校对
> 对照原文第25页校正:三个 while 是平级的,OCR 把「复制B」的循环嵌进了「复制A」的循环体里并丢了花括号;全角连字符改 ASCII
void mergeAndSquare ( int A [], int n , int B [], int m )
{
int C [ n + m ];
int i = 0 ; // A 的指针,从左向右
int j = m - 1 ; // B 的指针,从右向左
int k = 0 ; // C 的指针,从左向右
// 直到有一个数组遍历完退出循环
while ( i & lt ; n & amp ; & amp ; j & gt ; = 0 )
if ( abs ( A [ i ]) & gt ; abs ( B [ j ]))
{
C [ k ++ ] = A [ i ] * A [ i ];
i ++ ;
}
else
{
C [ k ++ ] = B [ j ] * B [ j ];
j -- ;
}
// 复制 A 中剩余元素的平方
while ( i & lt ; n )
{
C [ k ++ ] = A [ i ] * A [ i ];
i ++ ;
}
// 复制 B 中剩余元素的平方
while ( j & gt ; = 0 )
{
C [ k ++ ] = B [ j ] * B [ j ];
j -- ;
}
// 输出 C...
}
<!-- page 26 -->
## 类型2:单个有序顺序表
在处理单个有序顺序表时,我们常常可以根据题目的特点,将这个单一的表看作是从某个“切点”分成的两个逻辑上的有序子序列。指针则分别置于这些逻辑子序列的端点,然后像处理多个有序表一样进行操作。首尾双指针(相向而行)就是这种思想最常见的体现,首尾的指针最终会在中间相遇。
例题2-8:升序数组 A 中包含n 个整数,将其元素平方后按降序排序,存入新数组 B 并输出。 人
例如:A = {-4,-2,0, 1,3} → B = {16,9,4,1,0}。
暴力解:平方后快速排序,时间复杂度 O(nlogn)。
优化解:多指针法,时间复杂度 O(n)。
思路分析:对于升序序列,平方后的最大值一定产生于原数组的“两端”(绝对值最大的地
方)。因此,可以用两个指针 i,j 分别指向 A 的头和尾,比较 |A[i]| 和 |A[j]|,将较
大的平方值从后往前放入结果数组B。【
● 分析:对于一个包含正负数的升序序列,其平方后的最大值一定产生于原数组的“两端”(绝对值最大的地方)。这相当于将原数组以0 为“切点”,分成了两个逻辑序列:一个负数序列(绝对值降序)和一个正数序列(绝对值升序)。我们可以设置两个指针,一个在数组头,一个在数组尾,它们分别指向这两个逻辑序列中绝对值最大的元素。
起始位置:头指针 i 指向 A[0],尾指针 j 指向 A[n-1]。结果数组的指针 k 应指向B的首位B[0]。
规则:比较|A[i]| 和|A[j]|,将绝对值较大者的平方值放入B[k],然后移动相应的指针(i 向右或 j向左) 和结果指针k(向右)。 A
停止处理:当头尾指针相遇或交错(i > j)时,循环结束。
有的同学会疑惑为什么这里的判断条件只是i<=j,而不需要考虑正负发生变化时的情况,这里可以证明:当有正负发生变化时,那剩下的所有元素正负都相同,也就是说剩下的元素都是单调的,那在单调的情况下继续比较头尾一定能够保证只有一端移动。所以这种情况下仍然是可以继续执行直到i<=j才结束,并且没有元素剩下 道
void squareAndSort ( int A [], int n )
{
int B [ n ];
int i = 0 , j = n - 1 ; // 头尾指针
int k = 0 ; // B 指针,从后往前
//直到指针相遇退出循环
while ( i & lt ; = j )
{
if ( abs ( A [ i ]) & gt ; abs ( A [ j ]))
{
B [ k ++ ] = A [ i ] * A [ i ];
i ++ ;
}
else
{
B [ k ++ ] = A [ j ] * A [ j ];
j -- ;
}
}
// 输出 B...
}
<!-- page 28 -->
## \*类型3:单个无序顺序表
无序情况下的多指针法稍复杂,考察频率较低。学有余力的同学可以试着学习本节,掌握单个无序顺序表的处理即可,该方法常用于实现划分(Partition) 操作。
关于划分(Partition)操作,请读者回忆快速排序的过程,本节的方法都是基于快速排序的思路。 C 道
例题 2-9:给定数组 A 含有 n 个互异整数,以 A[0] 为基准,将小于 A[0]的元素放左侧,大于A[0]的元素放右侧。 K
例如:A = {2,5,3,-1, -2} → 可能结果为 {-2,-1,2,3,5}或{-1,-2,2,3,5}或{-2, -1, 2, 5, 3}或{-1, -2, 2, 5, 3}。
暴力解:快速排序,时间复杂度 O(nlogn)。
优化解:多指针法,时间复杂度 O(n)。
思路分析:使用快速排序的单次划分思想,两个指针分别从数组两端向中间移动,分别找到大于和小于基准的元素进行交换,直到相遇后再将基准元素放入正确位置。
void ans ( int A [], int n )
{
int i = 0 , j = n - 1 ;
int pivot = A [ 0 ];
while ( i & lt ; j )
{
while ( i & lt ; j & amp ; & amp ; A [ i ] & lt ; = pivot ) i ++ ;
if ( i & lt ; j ) swap ( A [ i ], A [ j ]); // A[j]<pivot<A[i],交换
}
swap ( A [ 0 ], A [ i ]); //基准归位
}
例题2-10:给定数组A 含有n个互异整数,将较小的一半元素放左侧,较大的一半放右侧。
例如: A = {2, 5,−1, −2} → 可能结果为 {-2, -1,2,5}或{-1, -2,2,5}或{-2, −1,5, 2}或{-1, -2, 5, 2}。。
暴力解:快速排序,时间复杂度 O(nlogn)。
优化解:多指针法,时间复杂度 O(n)。
思路分析:在上一道例题的划分基础上改进,上一题会划分成左区间、x、右区间,x所在下标是i。但是本题要求两个区间各占一半,若i==|n/2|,则说明正好平分区间,如果小于则说明右边的区间过大应该继续处理右边的区间,而如果大于则说明左边的区间过大应该继续处理左边的区间。
void ans ( int A [], int n )
{
int L = 0 , R = n - 1 ; // L和R是当前处理区间的边界
while ( true )
{
int i = L , j = R ;
int pivot = A [ L ];
while ( i & lt ; j )
{
while ( i & lt ; j & amp ; & amp ; A [ j ] & gt ; = pivot ) j -- ;
if ( i & lt ; j ) swap ( A [ i ], A [ j ]);
}
swap ( A [ L ], A [ i ]);
if ( i == n / 2 ) return ;
if ( i & lt ; n / 2 ) L = i + 1 ; // 继续处理右区间
else R = i - 1 ; //继续处理左区间
}
}
<!-- page 29 -->
例题2-9(按 A[0]划分)的解法就是快排的单趟Partition
例题2-10(划分为大小两半)的解法,则是在Partition思想的基础上,不断调整,直到基准pivot的最终位置恰好是n/2。这被称为 快速选择(Quick Select)算法。
## 快速排序 (Quick Sort) vs. 快速选择 (Quick Select)
目标不同:快排的目标是整个数组有序;快选的目标是找到第k小的元素。
递归方式不同:
快排:划分后,递归处理左右两个子区间。T(n) = 2T(n/2) + O(n) -> O(nlogn)
• 快选:划分后,根据划分点的位置,只递归处理其中一个子区间。T(n) = T(n/2)+ O(n) -> O(n)
这就是为什么“找中位数”比“完整排序”更快的原因。
<!-- page 30 -->
## 2.5 章节练习
## 2.5.1 习题
【\*习题2-1】(13 分)已知升序数组A含有n个整数,求是否存在值为x的元素,存在返回数组下标,不存在则返回-1。设计一个在时间上尽可能高效的算法实现该功能。例:A={1,2,3,7, 8,11,15},n = 7,x = 8,则返回数组下标为 4。(1)给出算法的基本设计思想。
(2)根据设计思想,采用C、C++或Java 语言描述算法,关键之处给出注释。(3)说明你所设计算法的时间复杂度和空间复杂度。
<!-- page 31 -->
【\*习题2-2】(13 分)已知两个整数升序数组 A和B,他们的元素数分别为 n 和 m,请将他们合并成一个新的升序数组C,并输出C数组。设计一个尽可能高效的算法实现该功能。例: A = {1, 2, 7}, n = 3, B = {3, 4, 5, 10}, m = 4, 则 C = {1, 2, 3, 4, 5, 7, 10}。 (1) 给出算法的基本设计思想。(2)根据设计思想,采用C、C++或Java 语言描述算法,关键之处给出注释。(3)说明你所设计算法的时间复杂度和空间复杂度。
<!-- page 32 -->
【\*习题2-3】(15 分)已知数组A中有n个非负元素,每个元素绝对值< n,其中只有一个数重复出现过,请输出这个数,否则输出-1。设计一个时间上尽可能高效的算法实现该功能。例:n = 6,A = {1, 2, 5, 2, 4, 3},则 2 重复出现,输出2。 (1)给出算法的基本设计思想。
(2)根据设计思想,采用C、C++或Java 语言描述算法,关键之处给出注释。(3)说明你所设计算法的时间复杂度和空间复杂度。
<!-- page 33 -->
【习题2-4】(15 分)已知整数数组A含有n 个不相等的整数(n为奇数,且数组A 中每个元素的绝对值≤n)。设计一个时间上尽可能高效的算法,求数组A的中位数。例:n=5,A={-3,0,4,-2,3},则中位数为0。(1)给出算法的基本设计思想。(2)根据设计思想,采用C、C++或Java 语言描述算法,关键之处给出注释。(3)说明你所设计算法的时间复杂度和空间复杂度。
<!-- page 34 -->
【习题2-5】(15 分)已知数组A含有n个整数,现在要将小于k的元素放在数组右侧,大于等于k的元素放在左侧,设计一个时间上尽可能高效的算法实现这个功能。例:n=7,A={4, 5, 9, 3, 7, 10,1},k = 6,则处理之后的数组可以为 {10,7, 9, 3, 5, 4,1},其中 10、7、9比6大,在数组左侧;而3、5、4、1小于等于6,在数组右侧。(1)给出算法的基本设计思想。(2)根据设计思想,采用C、C++或Java 语言描述算法,关键之处给出注释。(3)
<!-- page 35 -->
【习题2-6】(13 分)有一个数组A由n个互不相同的正整数组成(n为奇数),该序列中的数先减后增,中位数是最小值,输入一个整数key。请设计一个时间和空间上尽可能高效的算法,判断key是否在该数组中,如果是则返回数组下标(下标从0开始),否则返回-1。例如对应 A = {10, 7, 5, 2, 6, 13, 15},key = 5 时返回下标 2,若 key = 8 则返回 -1。(1) 给出算法的基本设计思想。(2)根据设计思想,采用C 或C++语言描述算法,关键之处给出注释。(3)说明你所设计算法的时间复杂度和空间复杂度。
<!-- page 36 -->
【习题 2-7】 (13 分)定义距离d = x- yl,给定 2 个长度为 n的升序数组 a、b,x是数组a中的某个元素,y是数组b中的某个元素。请设计一个尽可能高效的算法,计算并输出所有可能的最小距离 d。例如 数组 a = {-16,-8, 5, 8, 13},b ={-2,0, 2, 6, 10},则最小距离为 1,相应的 x=5, y=6, d= |5- 6|=1。(1)给出算法的基本设计思想。(2)根据设计思想,采用C 或C++语言描述算法,关键之处给出注释。(3) 说明你所设计算法的时间复杂度和空间复杂度。 当计
<!-- page 37 -->
【习题2-8】(15分)已知升序数组A含有n个非负整数,没有重复元素,设计一个时间上尽可能高效的算法求是否存在两个数和为k(k>0),如果存在则返回1,不存在则返回0。例:A={1,2,3,7,8,11,15},k=18,则 7+11=18,返回 1。(1)给出算法的基本设计思想。
(2)根据设计思想,采用C、C++或Java 语言描述算法,关键之处给出注释。(3)说明你所设计算法的时间复杂度和空间复杂度。
<!-- page 38 -->
【习题2-9】(13 分)已知升序数组(无重复元素)A、B都含有n个整数,设计一个时间上尽可能高效的算法求新的数组C 使得C中只存在A、B中都包含的元素,输出数组C。
(1)给出算法的基本设计思想。(2)根据设计思想,采用C、C++或Java 语言描述算法,关键之处给出注释。(3)说明你所设计算法的时间复杂度和空间复杂度。
<!-- page 39 -->
【习题2-10】(13分)将一个数组最开始的若于元素搬到数组的末尾,称之为数组的旋转。已知数组A为某个有序数组旋转后的结果(无重复值),设计一个时间上尽可能高效的算法求该旋转数组的最小元素。例:数组 A = {3, 4, 5,6, 1, 2} 为有序数组 {1,2, 3, 4,5, 6} 的—个旋转数组,该数组的最小值为1。(1)给出算法的基本设计思想。(2)根据设计思想,采用C、C++或Java语言描述算法,关键之处给出注释。(3)说明你所设计算法的时间复杂度和空间复杂度。 当订
<!-- page 40 -->
【习题 2-11】 (15 分)已知—个整数升序序列 A = {a0, a1,, an−1},其中 0 ≤ ai < n(0 ≤ i< n)。连续子序列是从原序列中选取的一段连续的元素构成的序列(至少包含一个元素),如{a0, a1}、{a0}都是连续子序列。设计一个在时间上尽可能高效的算法,输出元素之和<=k的连续子序列个数。例:A = {1, 2, 3, 4, 5},n = 5,k = 6,则符合要求的连续子序列为 {1}、{2}、{3}、{4}、{5}、{1,2}、{2,3}、{1,2,3},共有 8 个,返回8。(1)给出算法的基本设计思想。(2)根据设计思想,采用C、C++或Java 语言描述算法,关键之处给出注释。(3)说明你所设计算法的时间复杂度和空间复杂度。
<!-- page 41 -->
【习题 2-12】 (13 分)已知两个非负整数序列 A = {a0, a1, , an−1}、B = {b0, b1, ... , bn−1},求出有多少个数对{ai, bi}使得ai+bj <= 5n,设计一个在时间上尽可能高效的算法,有多少个这样的数对。例:A= <2,10,8>,B = <8,12,1>,n =3,<2,8>、<2,12>、<2,1>、<10,1>、<8,1>共满足要求的数对,返回5。(1)给出算法的基本设计思想。(2)根据设计思想,采用C、C++或Java语言描述算法,关键之处给出注释。(3)说明你所设计算法的时间复杂度和空间复杂度。
<!-- page 42 -->
【\*习题2-13】 (13 分)已知数组 A 中有 n 个非负元素,每个元素 Ai] < 2n。请设计一个时间上尽可能高效的算法,求出数组 A 中第 k 小的元素。例:A = {1, 2, 1, 2, 3, 3},n = 6,k =3,按照升序得到{1,1,2,2,3,3},所以第k小的元素是2(第3、4大元素都是2),输出2。(1)给出算法的基本设计思想。(2)根据设计思想,采用C、C++或Java 语言描述算法,关键之处给出注释。(3)说明你所设计算法的时间复杂度和空间复杂度。
<!-- page 43 -->
【习题2-14】 (13 分)已知数组 A 中有 n 个元素(可能重复),每个元素2n ≤ A[i] < 3n。请设计一个尽可能高效的算法,从小到大输出A数组中的元素(重复元素只输出一次)。例:A= {13,17,16,14,13,13},n = 6,出现的元素是13、17、16、14,所以从小到大依次输出:13、14、16、17。(1)给出算法的基本设计思想。(2)根据设计思想,采用C、C++或Java语言描述算法,关键之处给出注释。(3)说明你所设计算法的时间复杂度和空间复杂度。 当计
<!-- page 44 -->
【习题 2-15】 (15 分)已知—个整数序列 A = (a0, a1, .. . , an−1),其中 0 ≤ ai < n(0 ≤i <n)。从数组中选择一些数,要求选择的数中任意两数差的绝对值<=k。设计一个在时间上尽可能高效的算法输出最多可能选择的元素个数。要求:(1)给出算法的基本设计思想。
(2)根据设计思想,采用C、C++或Java 语言描述算法,关键之处给出注释。(3)说明你所设计算法的时间复杂度和空间复杂度。 Xy
<!-- page 45 -->
【习题2-16】(15 分)在数组中,某个数字减去它右边的数字得到一个数对之差。求所有数对之差的最大值。例如,在数组 {2, 4,1,16, 7,5, 11, 9} 中,数对之差的最大值是11,是16减去5的结果。设计一个在时间上尽可能高效的算法输出所有数对之差的最大值。(1)给出算法的基本设计思想。(2)根据设计思想,采用C或C++语言描述算法,关键之处给出注
<!-- page 46 -->
【习题2-17】(15 分)已知一个整数升序序列 A = (a0, a1,, an-1)。设计一个尽可能高效的算法返回整数 x 出现的次数。函数原型为:int countOccurrences(int A[], int n, int x)。(1)给出算法的基本设计思想。(2)根据设计思想,采用C语言描述算法,关键之处给出注释。(3)说明你所设计算法的时间复杂度和空间复杂度。
<!-- page 47 -->
← 内容总览 03 / 24 下一节 →