注意事项:
← 内容总览 02 / 24 下一节 →
强化讲义 · 原始转写 · 原书检索 · 文字用于定位,公式与图表请回原页核验 · 本次导入快照
## 注意事项:
快速排序不是万能的,有如下情况下不能使用:
题目明示或暗示不能改变顺序表元素的次序。
要采用稳定的排序算法。
如果题目需要得到降序,有两种方法:
方法1:修改快速排序代码(适合基础好的同学)。
方法2:快速排序代码不变,主过程段新增 for 循环将数组逆置。for (int i=0;i < n/2;
i++) swap(A[i], A[n-i-1]);
正常情况下快速排序代码可以直接默写,但如果数组的类型不是int 就需要修改。
<!-- page 12 -->
## 2.3 判断能否优化
写出暴力解后,下一步就是思考优化的可能性。从以下两个角度自问,往往能发现突破口:
1.“条件浪费”:题目中是否给了一些我没用上的重要条件?
例如:数组是“升序”的,但我却从头到尾线性扫描,浪费了有序性。
例如:元素值范围是“0到n-1”,但我却用了排序,浪费了数据范围的特性。
2.“目标超额”:我为了解决问题,是不是做了很多额外的不必要工作?
例如:题目只要求“找出最小值”,我却把整个数组都排序了。
例如:题目只要求“把小于k的放左边,大于等于k的放右边”,我却把左右两边的元素内部都排好序了。
最好的算法,就是把题目的所有条件都榨干,并且不做多余的操作。
## 思考:下面几种暴力解分别符合哪个标准
例题 2-1:对升序数组 A,求是否存在值为 x的元素,存在返回1,不存在返回-1。暴力解:枚举 A
例题 2-2:将两个升序数组 A 和 B 合并成一个新的升序数组 C。暴力解:快速排序
例题2-3:数组 A 中有 n 个非负元素,每个元素绝对值< n,其中只有一个数重复出现过,请输出这个数。暴力解:枚举
例题2-4:求含n个数(n为奇数)的数组A的中位数。暴力解:快速排序
例题 2-5:已知数组 A 含有n 个整数,现在要将小于 k的元素放在数组左侧,大于等于 k的元素放在右侧,如何实现这个功能。暴力解:快速排序
例题 2-6:已知数组 A 含有 n 个整数,现在要求另一个数组 res,res[i] 表示长度为 i+1 的连续子序列中元素和的最大值,求res数组。暴力解:枚举 人
<!-- page 13 -->
## 2.4 优化解
针对前文分析,我们介绍几种核心的优化思想。
## 2.4.1 折半查找
核心要素:
使用条件:有序的顺序表(数组)。
核心思想:通过不断与区间中点比较,每次都将查找范围缩小一半。
•复杂度:时间 O(logn),空间 O(1)。
当题目出现“有序数组”(且是顺序表,不是链表)时,你的脑海里应该立刻响起“折半查找”的警报,真题中频繁考察。 当计
## 两种经典模板
<table><tr><td rowspan=1 colspan=1>对比项</td><td rowspan=1 colspan=1>模板一:『等值判断』模板(优先使用)</td><td rowspan=1 colspan=1>模板二:『区间收敛』模板</td></tr><tr><td rowspan=1 colspan=1>适用场景</td><td rowspan=1 colspan=1>查找一个确切的值,通常适合元素不重复。</td><td rowspan=1 colspan=1>查找满足条件的第一个或最后一个元素,元素可以重复。</td></tr><tr><td rowspan=1 colspan=1>查找成功</td><td rowspan=1 colspan=1>A[mid] == x 判断。</td><td rowspan=1 colspan=1>区间缩小为只有一个元素,需额外判断。</td></tr><tr><td rowspan=1 colspan=1>查找失败</td><td rowspan=1 colspan=1>区间中没有元素</td><td rowspan=1 colspan=1>区间缩小为只有一个元素,需额外判断。</td></tr><tr><td rowspan=1 colspan=1>循环条件</td><td rowspan=1 colspan=1>while(L <= R)(区间没有元素时退出循环)</td><td rowspan=1 colspan=1>while (L < R) (区间收敛为一个元素时退出循环)</td></tr><tr><td rowspan=1 colspan=1>边界更新逻辑</td><td rowspan=1 colspan=1>R = mid - 1 或 L = mid+ 1 (直接排除 mid)</td><td rowspan=1 colspan=1>R = mid 或 L = mid + 1(可能不能排除 mid)</td></tr></table>
## 模板一:『等值判断』
这是最基础、最常用的模板,其核心是在循环内部通过 if(A[mid] == x)直接判断是否找到了目标值。
//折半查找
int B_Search ( int A [], int L , int R , int x )
{
int mid ;
while ( L & lt ; = R ) // 区间非空则继续
{
mid = ( R + L ) / 2 ; //求中点
if ( A [ mid ] == x )
return mid ; // 查找成功
else
if ( x & lt ; A [ mid ])
R = mid - 1 ; // x 在左半区间,L ~ mid-1
else L = mid + 1 ; // x 在右半区间, mid+1
}
return -1 ; //查找失败
}
<!-- page 14 -->
## 模板二:『区间收敛』
这个模板通过不断收敛(逼近)区间来锁定一个边界,专门用于解决边界查找问题。
// 折半查找第一个等于 x 元素的下标
int B_Search ( int A [], int L , int R , int x )
{
int mid ;
while ( L & lt ; R ) // 收敛至单元素
{
mid = ( R + L ) / 2 ;
if ( A [ mid ] & gt ; = x ) R = mid ; // x 在 L ∼ mid
else L = mid + 1 ; // x 在 mid+1 ∼ R
}
return L ; // 需额外判断是否等于
}
## 注意
循环条件与边界更新的匹配:L<= R和 L< R分别对应着不同的区间定义和边界更新逻辑,必须配套使用,否则容易导致死循环或漏解。
边界更新的细节:在模板二中,如果将 R = mid 误写为 R = mid - 1,若 A[mid] 正好是你要找的那个边界值时,就会把它错误地排除掉。
学会灵活应用:很多时候折半查找的代码并不能直接默写使用,而是需要根据题目要求来修改,例如题目中给的是降序序列。
例题2-1:对升序数组 A,求是否存在值为x的元素,存在返回 1,不存在返回-1。
## 暴力解:枚举
优化解:折半查找
解析:关键字“升序数组”,可使用折半查找优化。本题只是查找一个确切的值,使用最直观的模板一即可。 N
//折半查找模板一
int B_Search ( int A [], int L , int R , int x )
{
int mid ;
while ( L & lt ; = R ) // 区间非空则继续
{
mid = ( R + L ) / 2 ; //求中点
if ( A [ mid ] == x )
return mid ; //查找成功
else
if ( x & lt ; A [ mid ])
R = mid - 1 ; // x 在左半区间 ~ mid-1
else L = mid + 1 ; // x 在右半区间, mid+1 ∼ R
}
return -1 ; //查找失败
}
//在升序数组A中判断是否存在
int ans ( int A [], int n , int x )
{
// 直接调用折半查找函数
if ( B_Search ( A , 0 , n - 1 , x ) == -1 )
return -1 ;
else return 1 ;
}
<!-- page 15 -->
而如果改一下题目,就适合模板二了:在一个升序数组A中,查找第一个等于 x的元素的位置,如果存在则返回下标,如果不存在则返回-1。(数组中可能存在多个x)
解析:此题可能有重复元素,因此适用于模板二。因为数组中可能存在多个x(例如[1,2,3,3,3,4,4],查找 3),如果使用模板一,当 A[mid] == x时直接返回,可能会返回中间的那个 3,而不是第一个。我们需要找到满足 A[i] == x的最小的i。
这正是模板二的应用场景。我们使用模板二找到这个位置pos 后,还需要额外进行一次判断:检查 A[pos] 是否真的等于 x。如果等于,pos 就是答案;如果不等(或者pos 超出数组范围),则说明数组中不存在x。
//折半模板二
int B_Search ( int A [], int L , int R , int x )
{
int mid ;
while ( L & lt ; R )
{
mid = ( L + R ) / 2 ;
if ( A [ mid ] & gt ; = x ) R = mid ;
else L = mid + 1 ;
}
return L ;
}
//在升序数组 A 中查找第一个等于 x的元素的位置
int ans ( int A [], int n , int x )
{
int pos = B_Search ( A , 0 , n - 1 , x );
if ( A [ pos ] == x ) return pos ; //查找成功
else return -1 ; //查找失败
}
<!-- page 17 -->
## 2.4.2 散列表
使用条件:数据范围有限
使用条件:数据范围有限,可以用数组下标直接表示数据的值。
核心思想:用空间换时间。创建一个辅助数组 count[],count[i] 用于记录数值 i 出现的相关信息(如次数)。
复杂度:时间 O(n),空间 O(M),其中 M 是数据范围。
当题目明示或者通过分析确定“元素值为0到n-1”、“元素绝对值小于n”等等,此时我们要马上考虑使用散列表。
例题2-3:数组A 中有 n个非负元素,每个元素绝对值< n,其中只有一个数重复出现过,请输出这个数。
如:n=6, A={1, 2, 5, 2, 4, 3}
暴力解:枚举
优化解:散列表
解析:关键信息“绝对值<n”,可使用散列表
void Hash ( int A [], int n )
{
int count [ n ] = { 0 }; // 需要初始化
for ( int i = 0 ; i & lt ; n ; i ++ )
{
//A[i] 的值在0到n-1 之间,可以直接用作 count 数组的下标
if ( count [ A [ i ]] & gt ; 0 )
输出 A [ i ]; // 说明 A[i] 之前出现过
count [ A [ i ]] ++ ; // 标记 A[i] 出现
}
}
(13分)给定一个含n(n≥1)个整数的数组,请设计一个在时间上尽可能高效的算法,找出数组中未出现的最小正整数。例如,数组{-5, 3, 2,3} 中未出现的最小正整数是1;数组 {1,2,3} 中未出现的最小正整数是4。
解析:可以证明最小未出现的正整数t一定≤n+1(因为如果t>n+1,则1\~t-1都会出现一次,但只有n个整数,所以t-1≤n,即t≤n+1)。因此虽然题目中没有明确给出取值范围,但是通过我们的分析,只有0\~n+1这个范围的整数,所以可以使用散列表法。
int ans ( int A [], int n )
{
int count [ n + 2 ] = { 0 }; //0~n+1
for ( int i = 0 ; i & lt ; n ; i ++ ) // 扫描数组A
if ( A [ i ] & gt ; 0 & amp ; & amp ; A [ i ] & lt ; = n + 1 ) // 当0<A[i]<=n+1时才修改
count [ A [ i ]] ++ ;
for ( int j = 1 ; j & lt ; = n + 1 ; j ++ ) //扫描count数组
if ( count [ j ] == 0 ) //j未出现过,是最小未出现正整数
return j ; //输出j
}
<!-- page 18 -->
## 注意
使用散列表时一定要考虑 A[i] 的大小是否会超出 count 下标的范围。
新建辅助数组(散列表)需要初始化。
当题目限制元素的范围时可以考虑使用散列表,条件可以是直接给出也可能是暗示。
<!-- page 19 -->
## 2.4.3 预处理
使用条件:需要频繁计算区间和、积、最值等。
核心思想:类似散列表,以空间换时间。提前计算并存储一些结果,供后续快速查询。
考试中常见的预处理分为两类:求区间之和(前缀和)、求区间最值。
## 前缀和
如果需要多次查询不同区间的和,可以先用O(n)的时间预处理出前缀和数组 sum,其中sum[i] 表示原数组前1 个元素的和(下标 0~下标 i-1)。之后查询任意区间 [L,R] 的和,只需要O(1)的时间。 HN
如果数组 A下标从0 开始存放数据,
,A[L\~R] 之间所有元素之和 = sum[R+1] - sum[L]。
只需要提前处理一次 sum 数组,之后就能在 O(1) 的时间快速计算元素和。
例题 2-6:已知数组 A 含有 n 个整数,现在要求另一个数组 res,res[i] 表示长度为 i+1 的连续子序列中元素和的最大值,求 res 数组,函数原型为 void ans(int A, int n)。
例:n=4,A={1,-2, 3, 5}
<table><tr><td rowspan=1 colspan=1></td><td rowspan=1 colspan=1>连续子序列</td><td rowspan=1 colspan=1>元素和最大值</td></tr><tr><td rowspan=1 colspan=1>i=0</td><td rowspan=1 colspan=1>{1}、{-2}、{3}、{5}</td><td rowspan=1 colspan=1>{5}→5</td></tr><tr><td rowspan=1 colspan=1>i=1</td><td rowspan=1 colspan=1>{1, -2}、{-2, 3}、{3, 5}</td><td rowspan=1 colspan=1>{3, 5}→8</td></tr><tr><td rowspan=1 colspan=1>i=2</td><td rowspan=1 colspan=1>{1, -2, 3}、{-2, 3, 5}</td><td rowspan=1 colspan=1>{-2, 3, 5}→6</td></tr><tr><td rowspan=1 colspan=1>i=3</td><td rowspan=1 colspan=1>{1, -2, 3, 5}</td><td rowspan=1 colspan=1>{1, -2, 3, 5}→7</td></tr></table>
则处理之后的 res 数组为 {5, 8, 6, 7}
暴力解:枚举,时间复杂度O(n3)
void ans ( int A [], int n )
{
//创建将res数组初值设为最小负数
int res [ n ] = { 0 };
for ( int i = 0 ; i & lt ; n ; i ++ )
res [ i ] = INT_MIN ;
// 枚举每种A[i~j]的连续子序列
for ( int i = 0 ; i & lt ; n ; i ++ )
for ( int j = i ; j & lt ; n ; j ++ )
{
int temp = 0 ;
for ( int k = i ; k & lt ; = j ; k ++ )
temp += A [ k ];
if ( temp & gt ; res [ j - i ])
res [ j - i ] = temp ;
}
输出res数组
}
<!-- page 20 -->
优化解:预处理,时间复杂度O(n²)
解析:在枚举的过程中我们发现需要大量重复计算,可以预处理前缀和数组,然后每次计算子序列和的时间复杂度就变成了O(1)
void ans ( int A [], int n )
{
//创建并初始化前缀和数组
int res [ n ];
for ( int i = 0 ; i & lt ; n ; i ++ )
res [ i ] = INT_MIN ;
//创建并初始化前缀和数组
int sum [ n + 1 ] = { 0 };
for ( int i = 1 ; i & lt ; = n ; i ++ )
sum [ i ] = sum [ i -1 ] + A [ i -1 ];
//枚举每种A[i~j]的连续子序列
for ( int i = 0 ; i & lt ; n ; i ++ )
for ( int j = i ; j & lt ; n ; j ++ )
{
int temp = sum [ j + 1 ] - sum [ i ];
if ( temp & gt ; res [ j - i ])
res [ j - i ] = temp ;
}
输出res数组
}
当然本题还有其他写法,甚至有时间复杂度为O(n)的方法,但是考研可能用不到,所以此处跳过。 X算
<!-- page 21 -->
## 区间最值
如果要频繁求出数组某一区间内的最小值或最大值,也可以通过预处理来实现,比如说设置数组B,B[i]表示i下标及之前元素的最值,或者是下标及之后的最值,根据题意来决定。
例题 2-7:已知数组 A 含有 n 个正整数,现在要求另一个数组 res,计算 A[i]与 A](0 ≤i ≤j ≤ n-1)乘积的最大值,并将其保存到 res] 中。求 res 数组,函数原型为 void ans(int A,int n)。
若 A[] = {3, 1, 2, 7, 6},则得到 res[ = {9, 3, 6, 49, 42}。
暴力解:枚举,时间复杂度为O(n²)
void ans ( int A [], int n )
{
int res [ n ];
for ( int j = 0 ; j & lt ; n ; j ++ )
{
res [ j ] = INT_MIN ;
for ( int i = 0 ; i & lt ; = j ; i ++ )
if ( A [ i ] * A [ j ] & gt ; res [ j ]) res [ j ] = A [ i ] * A [ j ];
}
输出res数组
}
优化解:预处理,时间复杂度为O(n)
解析:要快速确定 A[i]与 A[]]的最大乘积,我们可以预处理,使用数组 B 提前维护,B]]表示 A[0\~j]之间的最大值,res[i]=B[j]×A[i]。
void ans ( int A [], int n )
{
int res [ n ], B [ n ];
int B [ 0 ] = A [ 0 ];
for ( int i = 1 ; i & lt ; n ; i ++ )
B [ i ] = max ( B [ i -1 ], A [ i ]);
for ( int j = 0 ; j & lt ; n ; j ++ )
res [ j ] = B [ j ] * A [ j ];
输出res数组
}
通过预处理每个位置之前的最大值,我们只需O(1)的时间即可确定当前位置的最值,使得整个算法的时间复杂度降为O(n)。
当然,细心的同学可能会发现,其实本题的预处理并不需要数组B,使用一个变量B,然后把两次循环合并即可。
void ans ( int A [], int n )
{
int res [ n ];
int B = A [ 0 ];
for ( int j = 0 ; j & lt ; n ; j ++ )
{
B = max ( B , A [ j ]);
res [ j ] = B * A [ j ];
}
输出res数组
}
<!-- page 23 -->
## 2.4.4 多指针法
适用条件:线性表(顺序表、链表),主要是有序线性表。
核心思想:在一个或多个线性表上设置两个或多个下标变量(指针),根据特定规则移动它们,协同完成任务。“规则”是多指针法的灵魂。它指的是根据题目要求和数据特性(如顺序性)制定出的指针移动策略。算法的每一步都依据这个规则来判断:应该移动哪个指针?移动后执行什么操作?一个设计精良的规则能确保在指针的移动过程中,我们总能做出局部最优决策,并最终导向全局最优解,同时避免了对无效状态的搜索。 人
多指针法可适用于以下两种场景:
多个线性表:每个线性表设置一个指针,如合并多个有序线性表。
单个线性表:在一个线性表中设置两个指针,例如一前一后或一快一慢,用于查找或调整元素顺序。
经典应用场景包括合并多个有序线性表、寻找第k个元素、归并排序等。注意:不可机械套用模板,应深入理解后灵活应用。
← 内容总览 02 / 24 下一节 →