2.3 判断能否优化
← 内容总览 02 / 24 下一节 →
原始讲义 · 非增强校订版 · 原书检索 · 文字用于定位,公式与图表请回原页核验 · 本次导入快照
## 2.3 判断能否优化
写出暴力解后,下一步就是思考优化的可能性。从以下两个角度自问,往往能发现突破口:
1.“条件浪费”:题目中是否给了一些我没用上的重要条件?
。例如:数组是“升序”的,但我却从头到尾线性扫描,浪费了有序性。
。例如:元素值范围是“0到n-1”,但我却用了排序,浪费了数据范围的特性。
2.“目标超额”:我为了解决问题,是不是做了很多额外的不必要工作?
。例如:题目只要求“找出最小值”,我却把整个数组都排序了。
。例如:题目只要求“把小于k的放左边,大于等于k的放右边”,我却把左右两边的元素内部都排好序了。
最好的算法,就是把题目的所有条件都榨干,并且不做多余的操作。
思考:下面几种暴力解分别符合哪个标准
• 例题2-1:对升序数组A,求是否存在值为x的元素,存在返回1,不存在返回-1。
暴力解:枚举
• 例题 2-2:将两个升序数组A 和 B 合并成一个新的升序数组C。
暴力解:快速排序
• 例题2-3:数组A 中有n 个非负元素,每个元素绝对值<n,其中只有一个数重复出现过,请输出这个数。
暴力解:枚举
•例题2-4:求含n 个数(n为奇数)的数组A的中位数。
暴力解:快速排序
<!-- page 10 -->
• 例题2-5:已知数组A含有n 个整数,现在要将小于k的元素放在数组左侧,大于等于k的元素放在右侧,如何实现这个功能。 ka
暴力解:快速排序
• 例题 2-6:已知数组 A 含有 n 个整数,现在要求另一个数组 res,res[i] 表示长度为 i+1 的连续子序列中元素和的最大值,求 res 数组。
暴力解:枚举
## 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=2 colspan=1>边界更新逻辑</td><td rowspan=1 colspan=1>R = mid - 1 或 L = mid + 1 (直接排</td><td rowspan=2 colspan=1>R = mid 或 L = mid + 1 (可能不能排除 mid)</td></tr><tr><td rowspan=1 colspan=1>除mid)</td></tr></table>
## 模板一:『等值判断』
这是最基础、最常用的模板,其核心是在循环内部通过 if (A[mid] == x) 直接判断是否找到了目标值。
<!-- verified-by-hand -->
> [!success] 已结合原文第 10 页及 Markdown 上下文人工校对
> 结合同讲义后文模板逐行校正:补回 while 循环体花括号,拆开被串行的 mid 赋值和 if,将 return -1 放回循环之后
// 折半查找
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 在左半区间
else
L = mid + 1 ; // x 在右半区间
}
return -1 ; // 查找失败
}
//只要区间不为空就继续
<!-- page 11 -->
## 模板二:『区间收敛』
这个模板通过不断收敛(逼近)区间来锁定一个边界,专门用于解决边界查找问题。
// 折半查找第一个等于 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。
优化解:折半查找
解析:关键字“升序数组”,可使用折半查找优化。本题只是查找一个确切的值,使用最直观的模板一即可。
//折半查找模板一
int B_Search ( int A [], int L , int R , int x )
{
int mid ;
while ( L & lt ; = R ) //只要区间不为空就继续
{
mid = ( R + L ) / 2 ; //求中点
}
}
<!-- page 12 -->
if ( A [ mid ] == x )
else return mid ; //查找成功
if ( x & lt ; A [ mid ])
R = mid - 1 ; // x 在左半区间,L ~ mid-1
else L = mid + 1 ; // x 在右半区间,mid+1 ~ R
}
return -1 ; //查找失败
// 在升序数组 A 中判断是否存在 x
int ans ( int A [], int n , int x )
{
//直接调用折半查找函数
if ( B_Search ( A , 0 , n - 1 , x ) == -1 )
return -1 ;
else return 1 ;
而如果改一下题目,就适合模板二了:在一个升序数组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 ; Ww w
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 13 -->
## 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 )
{
//辅助数组充当散列表,大小为 n,下标 0 ~ n-1
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] 出现
}
} 专研
## 2018年41题
(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 14 -->
## 注意
• 使用散列表时一定要考虑 A[i] 的大小是否会超出 count 下标的范围。
• 新建辅助数组(散列表)需要初始化。
• 当题目限制元素的范围时可以考虑使用散列表,条件可以是直接给出也可能是暗示。
## 2.4.3 预处理
• 使用条件:需要频繁计算区间和、积、最值等。
核心思想:类似散列表,以空间换时间。提前计算并存储一些结果,供后续快速查询。
考试中常见的预处理分为两类:求区间之和(前缀和)、求区间最值。
## 前缀和
如果需要多次查询不同区间的和,可以先用 O(n)的时间预处理出前缀和数组 sum,其中 sum[i] 表示原数组前 i个元素的和(下标0\~下标i-1)。之后查询任意区间[L,R]的和,只需要O(1)的时间。
只需要提前处理一次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 o m</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(n³)
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 15 -->
优化解:预处理,时间复杂度 O(n²)
解析:在枚举的过程中我们发现需要大量重复计算,可以预处理前缀和数组,然后每次计算子序列和的时间复杂度就变成了0(1) ETA
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)的方法,但是考研可能用不到,所以此处跳过。
## 区间最值
如果要频繁求出数组某一区间内的最小值或最大值,也可以通过预处理来实现,比如说设置数组B,B[i]表示i下标及之前元素的最值,或者是i下标及之后的最值,根据题意来决定。
例题 2-7:已知数组 A 含有 n 个正整数,现在要求另一个数组 res,计算 A[i]与 A[j](0 ≤i ≤ j ≤ n-1)乘积的最大值,并将其保存到 res[j] 中。求 res 数组,函数原型为 void ans(int A☑, int n)。
若 A[] = {3, 1, 2, 7, 6},则得到 res[] = {9, 3, 6, 49, 42}。
暴力解:枚举,时间复杂度为O(n²)
<!-- page 16 -->
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数组 m
优化解:预处理,时间复杂度为O(n)
解析:要快速确定 A[i]与 A[j]的最大乘积,我们可以预处理,使用数组 B 提前维护,B[j] 表示 A[0\~j]之间的最大值,res[j]=B[j]×A[j]。1 CS
void ans ( int A [], int n )
{
int res [ n ], B [ n ];
int B [ O ] = INT_MIN ;
for ( int i = 0 ; 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 = INT_MIN ;
for ( int j = 0 ; j & lt ; n ; j ++ )
{
B = max ( B , A [ j ]);
res [ j ] = B * A [ j ];
}
输出res数组
}
<!-- page 17 -->
## 2.4.4 多指针法
适用条件:线性表(顺序表、链表),主要是有序线性表。
核心思想:在一个或多个线性表上设置两个或多个下标变量(指针),根据特定规则移动它们,协同完成任务。确定“规则”是多指针法的灵魂。它指的是根据题目要求和数据特性(如顺序性)制定出的指针移动策略。算法的每一步都依据这个规则来判断:应该移动哪个指针?移动后执行什么操作?一个设计精良的规则能确保在指针的移动过程中,我们总能做出局部最优决策,并最终导向全局最优解,同时避免了对无效状态的搜索。
多指针法可适用于以下两种场景:
• 多个线性表:每个线性表设置一个指针,如合并多个有序线性表。
• 单个线性表:在一个线性表中设置两个指针,例如一前一后或一快一慢,用于查找或调整元素顺序。
经典应用场景包括合并多个有序线性表、寻找第k个元素、归并排序等。注意:不可机械套用模板,应深入理解后灵活应用。 -n
对比枚举法,多指针法的精髓在于其高效的“剪枝”能力,而这种能力完全来自于指针移动的“规则”。这个规则通常基于数组的有序性等已知条件。例如,在合并两个升序数组时,规则是“取较小值,移动其指针”。这个简单的规则保证了我们每次选择的都是当前所有未合并元素中的最小值,从而无需比较所有未合并的元素。我们之所以敢于只移动一个指针,是因为我们确信被“跳过”的那个数组中的其他元素(在该指针之后的元素)必然比当前选中的元素更大,不可能成为下一个合并的元素。这种基于规则的确定性移动,让我们一次性地排除了大量的比较,实现了从O((m+n)²)到O(m+n)的飞跃。可以说,找到并证明这个“规则”的正确性,是使用多指针法解决问题的关键。
多指针法考虑三个要点:指针起始位置、移动规则、停止处理。
← 内容总览 02 / 24 下一节 →