上一课我们学了冒泡、选择、插入,知道它们大多是
O(n²);
本课就要回答一个非常重要的问题:“有没有更快的排序和查找方法?”
第十一课 高效排序与二分查找
——为什么“每次砍掉一半”会这么快?
一、本课的学习目标
对于CSP-J 初赛复习,更重要的是做到:
第一层:认识
知道:
冒泡排序 O(n²) 选择排序 O(n²) 插入排序 O(n²) 快速排序 O(n log n) 归并排序 O(n log n) 堆排序 O(n log n) 二分查找 O(log n)第二层:理解
知道:
为什么二分查找这么快?
以及:
为什么快速排序、归并排序可以达到
O(n log n)?
第三层:程序阅读
熟练掌握二分查找代码:
mid = (l+r)/2;并可以判断:
答案在左边 还是右边并熟练掌握:
快速排序、归并排序 程序模版这是初赛常见的考点。
二、先复习上一课:冒泡排序为什么慢?
假设有:
100万个数字如果使用冒泡排序:
O(n²)那么工作量大约是:
n²也就是:
1,000,000² = 1,000,000,000,000一万亿级别。
这就很可怕了。
三、所以我们需要“聪明”的算法
上一课的排序思路基本是:
一个一个比较。
而这一课开始,我们学习一种非常重要的算法思想:
分而治之
也就是:
不要一次处理一个巨大的问题,而是把大问题变成几个小问题。
这也是快速排序、归并排序背后的重要思想。
四、二分查找
先不讲排序。
我们先来看一个生活中的问题。
老师有一本:
按学号排列的学生名单。
现在要找:
学号 537如果从第一个开始找:
1 2 3 4 5 …… 537这是:
顺序查找
最坏需要检查:
n个数据。
所以:
O(n)讲义也明确将顺序查找列为O(n)。
五、如果名单已经排好序呢?
假设:
1 2 3 4 5 ... 1000我要找:
537为什么一定要从1开始?
完全没必要!
我们直接看中间:
500发现:
537 > 500那么:
1 ~ 499全部不用看了!
只需要看:
501 ~ 1000一次就砍掉了一半。
六、再砍一半
现在范围:
501 ~ 1000中间大约:
750因为:
537 < 750所以:
751 ~ 1000又全部不用看。
现在只剩:
501 ~ 749七、继续砍
每一次:
剩余范围 ↓ 砍掉一半例如:
1000 ↓ 500 ↓ 250 ↓ 125 ↓ 62 ↓ 31 ↓ 16 ↓ 8 ↓ 4 ↓ 2 ↓ 1很快就能找到。
所以:
二分查找的核心思想
每次比较中间元素,然后把不可能的一半直接扔掉。
八、为什么叫“二分”?
因为:
二 + 分就是:
把问题分成两部分。
每次只保留其中一部分。
所以:
n ↓ n/2 ↓ n/4 ↓ n/8 ↓ …… ↓ 1九、二分查找为什么是 O(log n)?
这是第10课和第12课的重要连接。
假设:
n = 1024每次除以2:
1024 512 256 128 64 32 16 8 4 2 1需要:
10次因为:
2¹⁰ = 1024所以:
log₂1024 = 10因此:
二分查找 O(log₂n)
我们要特别强调:
二分查找要求数列有序。
十、为什么二分查找必须有序?
这是初赛经常考的知识点。
假设:
8 2 10 3 7 1 9我要找:
7我看中间:
3发现:
7 > 3能不能说:
“那我去右边找?”
不能。
因为右边:
7 1 9左边:
8 2 10两边都可能有7。
所以:
无序数据不能直接使用普通二分查找。
十一、有序为什么就可以?
例如:
1 2 3 4 5 6 7 8 9 10如果中间是:
5我要找:
8因为:
8 > 5所以左边:
1 2 3 4全部不可能。
这就是:
“有序”给了我们排除答案的依据。
十二、二分查找最简单的代码
先给同学们,写一个经典版本:
int l = 0; int r = n - 1; while(l <= r) { int mid = (l + r) / 2; if(a[mid] == x) { cout << "找到了"; break; } else if(a[mid] < x) { l = mid + 1; } else { r = mid - 1; } }这一段代码同学们要理解掌握。
十三、先看三个变量
int l = 0; int r = n - 1;它们表示:
l = 当前查找范围的左边界 r = 当前查找范围的右边界例如:
1 2 3 4 5 6 7 8 9如果:
l=0 r=8那么搜索:
[0,8]十四、mid是什么?
int mid=(l+r)/2;意思:
取当前区间的中间位置。
例如:
l=0 r=8那么:
mid=(0+8)/2 =4所以:
a[4]就是中间元素。
十五、如果a[mid] == x
直接:
if(a[mid] == x)说明:
找到了!
十六、如果a[mid] < x
例如:
a[mid] = 50 x = 80因为数组是升序:
左边 ≤ 50所以80不可能在左边。
因此:
l = mid + 1;把左边扔掉。
十七、如果a[mid] > x
例如:
a[mid] = 80 x = 50因为数组是升序:
右边 ≥ 80所以50不可能在右边。
因此:
r = mid - 1;把右边扔掉。
十八、给大家一个非常简单的口诀
二分查找一定要记住:
中间小,去右边;中间大,去左边;相等就找到了。
也就是:
a[mid] < x → l = mid + 1 a[mid] > x → r = mid - 1 a[mid] == x → 找到十九、模拟题
数组:
1 3 5 7 9 11 13 15 17寻找:
13下标:
0 1 2 3 4 5 6 7 8第一次:
l=0 r=8 mid=4所以:
a[4]=9因为:
9 < 13所以:
l=5第二次:
l=5 r=8 mid=6a[6]=13找到!
只用了:
2次。
二十、如果用顺序查找呢?
数组:
1 3 5 7 9 11 13寻找13:
1 3 5 7 9 11 13需要:
7次而二分:
9 13只需要:
2次这就是算法之间的巨大差别。
二十一、二分查找最大的价值
它告诉大家一个非常重要的算法思想:
不要总是“一个一个试”。
而要问:
能不能一次排除掉大量不可能的情况?
这是以后学习:
二分答案
搜索
贪心
动态规划
时都非常重要的思想。
二十二、接下来进入快速排序
二分查找解决的是:
快速寻找一个元素。
那么问题来了:
能不能也把排序做得更快?
可以。
一个非常经典的算法就是:
快速排序
讲义将快速排序列为O(n log n)的较快排序算法。
二十三、快速排序的核心思想
假设:
8 3 6 2 5 1 7 4我们先选择一个数字作为:
基准值 pivot
比如:
pivot = 5然后把数字分成两边:
比5小 比5大 2 3 1 4 8 6 7于是:
2 3 1 4 | 5 | 8 6 7现在我们发现:
原来的8个数字的大问题,被拆成了两个小问题。
二十四、然后怎么办?
对左边继续排序:
2 3 1 4对右边继续排序:
8 6 7这就是:
递归 + 分治。
所以快速排序和第7课:
递归
又联系起来了。
二十五、快速排序的思想图
可以画成:
8 3 6 2 5 1 7 4 │ pivot=5 / \ ↓ ↓ 2 3 1 4 8 6 7 │ │ 继续分 继续分 ↓ ↓ …… ……直到每一部分非常小。
二十六、为什么快速排序可能是 O(n log n)?
只要理解:
如果每一次都能比较均匀地分成两部分:
n ↓ n/2 + n/2 ↓ n/4 + n/4 + n/4 + n/4 ↓ ……大约需要:
log n层。
而每一层处理的数据总量大约是:
n所以:
n × log n得到:
O(n log n)
讲义将快速排序列为这一复杂度级别。
二十七、但是快速排序有一个问题
如果pivot选得非常糟糕呢?
例如:
1 2 3 4 5 6 7 8每次都选择:
1那么可能变成:
1 | 2 3 4 5 6 7 8然后:
1 | 2 | 3 4 5 6 7 8不断只分出一个元素。
这样就没有做到真正的“二分”。
所以:
快速排序的
O(n log n)是通常/理想划分情况下的复杂度描述;最坏情况复杂度会退化到O(n²)。
二十八、归并排序
第二个重要的高效排序:
归并排序。
它的思想非常漂亮:
先分,再排,再合。
二十九、例如:
8 3 6 2 5 1 7 4先分:
8 3 6 2 | 5 1 7 4继续分:
8 3 | 6 2 | 5 1 | 7 4继续:
8 | 3 | 6 | 2 | 5 | 1 | 7 | 4现在每一组只有一个元素。
一个元素:
天然有序。
三十、然后开始“合并”
例如:
8 3合并:
3 8再:
6 2合并:
2 6于是:
3 8 2 6继续合并:
2 3 6 8右边:
1 5 4 7最终:
1 2 3 4 5 6 7 8三十一、归并排序最重要的两个动作
分 ↓ 合所以叫:
归并排序
“归并”就是:
把几个已经有序的小部分合并成一个更大的有序部分。
三十二、快速排序和归并排序有什么区别?
可以用一句话记:
快速排序
选一个基准,把数据分到两边。
归并排序
先不断拆开,最后把有序部分合起来。
三十三、两种算法都使用了什么思想?
都是:
分治
即:
大问题 ↓ 小问题 ↓ 分别解决 ↓ 组合结果三十四、堆排序
快速排序 堆排序 归并排序都属于:
O(n log n)但是对于CSP-J初赛复习:
大纲要求,只了解堆排序概念,不深入实现。
三十五、速度排行榜
| 算法 | 时间复杂度 | 本课要求 |
|---|---|---|
| 二分查找 | O(log n) | ⭐⭐⭐ |
| 顺序查找 | O(n) | ⭐⭐⭐ |
| 快速排序 | O(n log n) | ⭐⭐ |
| 归并排序 | O(n log n) | ⭐⭐ |
| 堆排序 | O(n log n) | ⭐ |
| 冒泡排序 | O(n²) | ⭐⭐⭐ |
| 选择排序 | O(n²) | ⭐⭐⭐ |
| 插入排序 | O(n²) | ⭐⭐ |
三十六、时间复杂度的比较
假设:
n = 1,000,000那么:
O(n)
大约:
1,000,000O(log n)
大约:
20因为:
2²⁰ ≈ 1,000,000这就非常夸张了:
顺序查找: 约100万次 二分查找: 约20次同学们此时应该理解:
算法不是“小优化”,而可能是数量级的差别。
三十七、CSP-J经常考的陷阱
陷阱1:看到“二分”就认为一定是O(log n)
不够严谨。
必须满足:
能够有效地每次排除掉一半。
标准二分查找还要求:
数列有序。
我们明确要强调这一点。
陷阱2:不排序就二分
例如:
int a[5]={5,2,4,1,3};不能直接二分。
应该先:
sort(a,a+5);变成:
1 2 3 4 5然后才能进行普通二分查找。
所以经常出现:
排序 + 二分查找
这样的组合。
三十九、一个综合例题
int a[8]={8,3,6,2,5,1,7,4}; sort(a,a+8); int x=7; int l=0,r=7; while(l<=r) { int mid=(l+r)/2; if(a[mid]==x) { cout<<mid; break; } else if(a[mid]<x) l=mid+1; else r=mid-1; }问:
最后输出什么?
第一步:排序
原数组:
8 3 6 2 5 1 7 4排序:
1 2 3 4 5 6 7 8下标:
0 1 2 3 4 5 6 7第二步:二分
第一次:
l=0 r=7 mid=3a[3]=4因为:
4 < 7所以:
l=4第二次:
l=4 r=7 mid=5a[5]=6因为:
6 < 7所以:
l=6第三次:
l=6 r=7 mid=6a[6]=7找到。
输出:
6四十、就把前面的知识串起来:
数组 ↓ sort ↓ 排序 ↓ 下标 ↓ while ↓ 二分 ↓ l/r/mid ↓ 条件判断这正是CSP-J初赛程序阅读题的典型风格:
不是单独考某一个知识点,而是把多个基础知识拼在一起。
四十一、二分查找还有一个重要问题:边界
这一部分要易错点。
例如:
while(l<=r)什么时候停止?
当:
l > r说明:
查找区间已经空了。
所以没有找到。
四十二、为什么是mid+1?
如果:
a[mid] < x说明:
mid这个位置已经确定不是答案。
所以:
l=mid+1;不能写:
l=mid;否则可能一直卡在同一个位置。
四十三、为什么是r=mid-1?
同样:
a[mid] > x说明:
mid不是答案。
所以:
r=mid-1;而不是:
r=mid;四十四、给大家一个“边界口诀”
排除mid,就要跨过mid。
所以:
去右边: l = mid + 1 去左边: r = mid - 1这一句话以后做二分题会非常有用。
四十五、本课重要的思想
前面讲:
复杂度。
上节课讲:
基础排序。
本课真正要同学们学会的是:
“减少问题规模”。
例如:
顺序查找
n → n-1 → n-2 → n-3 → ...一次只减少一点。
二分查找
n → n/2 → n/4 → n/8 → ...每次砍掉一半。
所以:
O(n)和:
O(log n)差距巨大。
四十六、分治思想
快速排序:
一个大问题 ↓ 分成两个小问题 ↓ 再继续分 ↓ 越来越小归并排序:
一个大问题 ↓ 不断拆小 ↓ 小问题解决 ↓ 不断合并所以本课实际上是在给大家埋下一颗非常重要的种子:
分治思想。
四十七、本课知识地图
高效算法 │ ┌───────────┴───────────┐ ↓ ↓ 查找 排序 │ │ ┌────┴────┐ ┌──────┼──────┐ ↓ ↓ ↓ ↓ ↓ 顺序查找 二分查找 快排 归并 堆排 │ │ │ │ │ O(n) O(log n) O(nlogn) O(nlogn) │ ↓ 必须有序 │ ↓ 每次排除一半四十八、本课CSP-J必背清单
① 顺序查找
O(n)一个一个找。
② 二分查找
O(log n)核心:
每次排除一半。
条件:
数列有序。
③ 冒泡、选择、插入
O(n²)④ 快速排序
O(n log n)核心:
分治 + 基准值划分。
⑤ 归并排序
O(n log n)核心:
先分,再合并。
⑥ 堆排序
O(n log n)本课只要求了解。
四十九、课后练习(二分)
练习1
判断下面哪些数组可以直接使用二分查找:
① 1 2 3 4 5 ② 5 3 8 1 2 ③ 10 20 30 40 ④ 9 7 5 3 1注意:
降序数组也可以设计相应的二分查找,但代码中的判断方向必须跟着改变。
练习2
手算:
int a[]={1,3,5,7,9,11,13,15,17};寻找:
13要求写出每一次:
l r mid a[mid]练习3
判断复杂度:
for(int i=1;i<n;i*=2)答案:
O(log n)五十、课后练习(高效排序)
同学们来书写:
快速排序
选pivot ↓ 分左右 ↓ 递归归并排序
不断拆分 ↓ 单个元素 ↓ 有序合并要讲解给其他同学,自己为何这样写。
五十一、讲一个小故事
老师给两个同学同一道题:
从100万个已经排好序的数字里找一个数字。
小明:
1个 2个 3个 4个 ……一个一个找。
小华:
先看中间 ↓ 砍掉一半 ↓ 再看中间 ↓ 再砍一半 ↓ ……“你们两个,谁的方法更好一些?”
我们重要的不是比谁更聪明。
而是:
谁找到了更好的方法。
我们的算法竞赛真正训练的,不只是:
“你会不会写代码?”
而是:
面对一个很大的问题,你能不能找到一种方法,让计算机少做很多很多没有必要的事情。
这就是为什么我们要学习:
O(n) ↓ O(log n) O(n²) ↓ O(n log n)