关于我的仓库
- 这篇文章是我为面试准备的学习总结中的一篇
- 我将准备面试中找到的所有学习资料,写的Demo,写的博客都放在了这个仓库里iOS-Engineer-Interview
- 欢迎star
- 其中的博客在,CSDN都有发布
- 博客中提到的相关的代码Demo可以在仓库里相应的文件夹里找到
前言
- 该系列为学习《数据结构与算法之美》的系列学习笔记
- 总结规律为一周一更,内容包括其中的重要知识带你,以及课后题的解答
- 算法的学习学与刷题并进,希望能真正养成解算法题的思维
- LeetCode刷题仓库:LeetCode-All-In
- 多说无益,你应该开始打代码了
11讲排序(上):为什么插入排序比冒泡排序更受欢迎
- 开始进入排序章节了,专注于会,懂,好吧
- 敲之前我回我回,敲不出来我的我的
- GOGOGO
如何比较排序算法
-
执行效率
- 最好,最坏,平均时间复杂度
- 这样可以看出对于有序度比较高/低的测试数据效果如何
-
内存损耗
- 以空间复杂度衡量
- 这里显然原地排序算法比较屌,就是空间复杂度为O(1)的算法
-
稳定性
- 如果待排序的序列中存在值相等的元素,经过排序之后,相等元素之间原有的先后顺序不变
- 不变就是稳定排序算法,变就是稳定排序算法
- 这里对于数字看起来没有什么意义,但是如果我们比较的是一个对象,就可以在比较A的基础上,保证B的顺序不变
- 比如我们希望实现按金额排序订单,对于金额相同的订单又希望下单时间从早到晚有序
- 我们的做法其实就是先对下单时间排序,再对金额稳定排序:
冒泡排序(Bubble Sort)
- 冒泡就是对于相邻元素做比较,如果顺序不对就进行交换
原理
- 一次冒泡的详细过程:
- 完成排序就只要进行六次这样的操作:
- 进行优化就是,假如有一次没有任何交换,说明已经有序,可以终止排序了
代码
void bubbleSort(vector &arr) {
int arrLen = arr.size();
if (arrLen == 0) {
return ;
}
for (int i = 0; i < arrLen; i++) {
bool flag = false;
for (int j = 0; j < arrLen - i - 1; j++) {
if (arr[j] > arr[j + 1]) {
swap(arr[j], arr[j + 1]);
flag = true;
}
}
if (!flag) {
break;
}
}
return ;
}
特点分析
- 原地排序算法
- 由于我们设定了当相邻两个元素大小相等的时候,不做交换,所以冒泡是稳定的
- 时间复杂度为O(n2)
插入排序(Insertion Sort)
- 插入排序就是将待排序区间的插入到已排序区间即可
原理
- 将数据区域分成已排序区间和未排序区间,初始已排序区间只有一个元素就是第一个元素
代码
void insertionSort(vector &arr) {
int arrLen = arr.size();
if (arrLen == 0) {
return ;
}
for (int i = 1; i < arrLen; i++) {
int value = arr[i];
int j = i - 1;
for (; j >= 0; j--) {
if (arr[j] > value) {
arr[j + 1] = arr[j];
} else {
break;
}
}
arr[j + 1] = value;
}
}
特点分析
- 原地排序算法
- 我们可以选择将后面出现的元素,插入到前面的出现的元素后面【对于相同的元素】,所以是稳定的
- 时间复杂度为O(n2)
选择排序(Selection Sort)
- 选择排序本质就是从未排序区间中找到最小的元素,放到已排序区间的末尾
原理
- 将数据区域分成已排序区间和未排序区间,初始已排序区间只有一个元素就是第一个元素
代码
void selectionSort(vector &arr) {
int arrLen = arr.size();
if (arrLen == 0) {
return ;
}
for (int i = 0; i < arrLen; i++) {
int minNum = arr[i];
int minIndex = i;
for (int j = i; j < arrLen; j++) {
if (minNum > arr[j]) {
minNum = arr[j];
minIndex = j;
}
}
swap(arr[i], arr[minIndex]);
}
}
特点分析
- 原地排序算法
- 比如5,8,5,2,9这样一组数据,使用选择排序算法来排序的话,第一次找到最小元素2,与第一个5交换位置,那第一个5和中间的5顺序就变了,所以就不稳定了。正是因此,相对于冒泡排序和插入排序,选择排序就稍微逊色了。【不稳定】
- 时间复杂度为O(n2)
希尔排序(Shell Sort)
- 将需要排序的序列划分为若干个较小的序列,对这些序列进行直接插入排序,通过这样的操作可使需要排序的数列基本有序,最后再使用一次直接插入排序
原理
- 在希尔排序中首先要解决的是怎样划分序列,对于子序列的构成不是简单地分段,而是采取将相隔某个增量的数据组成一个序列。一般选择增量的规则是:取上一个增量的一半作为此次子序列划分的增量,一般初始值元素的总数量
代码
void shellSort(vector &arr) {
int arrLen = arr.size();
if (arrLen == 0) {
return ;
}
int d = arrLen / 2;
int x, j, k = 1;
while (d >= 1) {
for (int i = d; i < arrLen; i++) {
x = arr[i];
j = i - d;
// 直接插入排序,会向前找所适合的位置
while (j >= 0 && arr[j] > x) {
// 交换位置
arr[j + d] = arr[j];
j = j - d;
}
arr[j + d] = x;
}
d = d / 2;
}
}
特点分析
- 原地排序算法
- 不稳定
- 时间复杂度为O n的3/2次【比log(n)快】
总结
- 在真正地使用中,我们倾向于使用插入排序,因为不涉及交换,操作次数少,虽然它的时间复杂度和冒泡一样,而选择排序更是弟中弟
课后题:我们今天讲的几种排序算法,都是基于数组实现的。如果数据存储在链表中,这三种排序算法还能工作吗?如果能,那相应的时间、空间复杂度又是多少呢?
- 对于老师所提课后题,觉得应该有个前提,是否允许修改链表的节点value值,还是只能改变节点的位置。一般而言,考虑只能改变节点位置,冒泡排序相比于数组实现,比较次数一致,但交换时操作更复杂;插入排序,比较次数一致,不需要再有后移操作,找到位置后可以直接插入,但排序完毕后可能需要倒置链表;选择排序比较次数一致,交换操作同样比较麻烦。综上,时间复杂度和空间复杂度并无明显变化,若追求极致性能,冒泡排序的时间复杂度系数会变大,插入排序系数会减小,选择排序无明显变化。
12讲排序(下):如何用快排思想在O(n)内查找第K大元素
归并排序(Merge Sort)
- 如果要排序一个数组,我们先把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组就都有序了。
原理
- 先看一次分解图
这个的关键将在于merge函数,也就是将两个已经有序的子数组合并到一起应该怎么做
这里其实就和我们进行链表的插入一样,两个子数组同时遍历,比较,将小的跟在大的后面,这是这里我们不再是只要进行节点指向就可以解决问题了,而是需要使用辅助数组,在辅助数组里进行插入,在最后给原数组进行赋值
代码
void merge(vector &arr, int l, int mid, int r) {
int help[r - l + 1];
int lIndex = l;
int rIndex = mid + 1;
int i = 0;
while (lIndex <= mid && rIndex <= r) {
help[i++] = arr[lIndex] < arr[rIndex] ? arr[lIndex++] : arr[rIndex++];
}
while (lIndex <= mid) {
help[i++] = arr[lIndex++];
}
while (rIndex <= r) {
help[i++] = arr[rIndex++];
}
for (i = 0; i < r - l + 1; i++) {
arr[l + i] = help[i];
}
}
static void mergeSort(vector &arr, int l, int r) {
if (l == r) {
return;
}
int mid = (l + r) / 2;
mergeSort(arr, l, mid);
mergeSort(arr, mid + 1, r);
merge(arr, l, mid, r);
}
void mergeSort(vector &arr) {
int arrLen = arr.size();
if (arrLen == 0) {
return ;
}
mergeSort(arr, 0, arrLen - 1);
}
特点分析
- 非原地算法
- 在merge时,遇到相同的元素,我们可以保证先把前一个数组里的数据放入,这样就保证了不会错位,所以是稳定的
- 时间:O(nlogn) 空间:O(n)
快速排序(Quick Sort)
原理
- 如果要排序数组中下标从p到r之间的一组数据,我们选择p到r之间的任意一个数据作为pivot(分区点)
- 我们遍历p到r之间的数据,将小于pivot的放到左边,将大于pivot的放到右边,将pivot放到中间。经过这一步骤之后,数组p到r之间的数据就被分成了三个部分,前面p到q-1之间都是小于pivot的,中间是pivot,后面的q+1到r之间是大于pivot的
- 这里有个partition分区函数,就和上面的merge一样需要一波理解,我们需要把所有比游标小的数字放在左边,把比游标大的数字放在右边,返回游标的下标
- 具体操作如图:
代码
int partition(vector &arr, int p, int r) {
int pivot = arr[r];
int i = p;
for (int j = p; j < r; j++) {
if (arr[j] < pivot) {
swap(arr[i], arr[j]);
i++;
}
}
swap(arr[i], arr[r]);
return i;
}
static void quickSort(vector &arr, int p, int r) {
if (p >= r) {
return;
}
int q = partition(arr, p, r);
quickSort(arr, p, q - 1);
quickSort(arr, q + 1, r);
}
void quickSort(vector &arr) {
int arrLen = arr.size();
if (arrLen == 0) {
return ;
}
quickSort(arr, 0, arrLen - 1);
}
特点分析
- 原地算法
- 不稳定算法
- 时间:O(nlogn)
快速排序与归并排序的异同
- 两种排序很大一个区别就是归并排序是从下到上的,快速排序是从上到下的
如何用快排思想在O(n)内查找第K大元素
- 这个问题其实就是在快排的partition就可以找到,每次选择区分点后,把小的放前面,大的放后面
- 这样我们可以判断出我们要找的数字所在区间是哪一个,对其继续划分
实际题目LeetCode 215 数组中的第K个最大元素
在未排序的数组中找到第 k 个最大的元素。请注意,你需要找的是数组排序后的第 k 个最大的元素,而不是第 k 个不同的元素。
示例 1:
输入: [3,2,1,5,6,4] 和 k = 2
输出: 5
示例 2:
输入: [3,2,3,1,2,4,5,5,6] 和 k = 4
输出: 4
说明:
你可以假设 k 总是有效的,且 1 ≤ k ≤ 数组的长度。
题解
class Solution {
public:
int partition(vector &nums, int p, int r) {
if (p == r) {
return p;
}
int pivot = nums[r];
int i = p;
for (int j = p; j < r; j++) {
if (nums[j] > pivot) {
swap(nums[i], nums[j]);
i++;
}
}
swap(nums[i], nums[r]);
return i;
}
int findKthLargest(vector& nums, int k) {
int len = nums.size();
if (len == 0 || len < k) {
return 0;
}
int res = 0;
int p = 0;
int r = len;
while (1) {
int index = partition(nums, p, r - 1);
// res += index;
if ((index + 1) == k) {
return nums[index];
} else if ((index + 1) > k) {
r = index;
} else {
p = index + 1;
}
}
return -99;
}
};
课后题:现在你有10个接口访问日志文件,每个日志文件大小约300MB,每个文件里的日志都是按照时间戳从小到大排序的。你希望将这10个较小的日志文件,合并为1个日志文件,合并之后的日志仍然按照时间戳从小到大排列。如果处理上述排序任务的机器内存只有1GB,你有什么好的解决思路,能“快速”地将这10个日志文件合并吗?
- 每次从各个文件中取一条数据,在内存中根据数据时间戳构建一个最小堆,然后每次把最小值给写入新文件,同时将最小值来自的那个文件再出来一个数据,加入到最小堆中。这个空间复杂度为常数,但没能很好利用1g内存,而且磁盘单个读取比较慢,所以考虑每次读取一批数据,没了再从磁盘中取,时间复杂度还是一样O(n)。
- 先构建十条io流,分别指向十个文件,每条io流读取对应文件的第一条数据,然后比较时间戳,选择出时间戳最小的那条数据,将其写入一个新的文件,然后指向该时间戳的io流读取下一行数据,然后继续刚才的操作,比较选出最小的时间戳数据,写入新文件,io流读取下一行数据,以此类推,完成文件的合并, 这种处理方式,日志文件有n个数据就要比较n次,每次比较选出一条数据来写入,时间复杂度是O(n),空间复杂度是O(1),几乎不占用内存,这是我想出的认为最好的操作了,希望老师指出最佳的做法!!
13讲线性排序:如何根据年龄给100万用户数据排序
桶排序(Bucket Sort)
- 桶排序是大一进来学的第一个排序了,后面做了这么多题用到的场景其实也挺多的,对桶排序有特殊的情感,是咱们的老朋友
- 核心思想是将要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行排序。桶内排完序之后,再把每个桶里的数据按照顺序依次取出,组成的序列就是有序的了。
- 如果要排序的数据有n个,我们把它们均匀地划分到m个桶内,每个桶里就有k=n/m个元素。每个桶内部使用快速排序,时间复杂度为O(k * logk)。m个桶排序的时间复杂度就是O(m * k * logk),因为k=n/m,所以整个桶排序的时间复杂度就是O(n*log(n/m))。当桶的个数m接近数据个数n时,log(n/m)就是一个非常小的常量,这个时候桶排序的时间复杂度接近O(n)。
适用场景
- 桶排序比较适合用在外部排序中。所谓的外部排序就是数据存储在外部磁盘中,数据量比较大,内存有限,无法将数据全部加载到内存中。
- 将所有订单根据金额划分到100个桶里,第一个桶我们存储金额在1元到1000元之内的订单,第二桶存储金额在1001元到2000元之内的订单,以此类推。每一个桶对应一个文件,并且按照金额范围的大小顺序编号命名(00,01,02…99)。
计数排序(Counting Sort)
- 计数排序就是之前我理解的桶排序,一个桶里只存放一个数据的个数
- 这里额外讲到了怎么根据桶中的内容推算在有序数组中的位置
- 首先,进行顺序求和,结果就是小于等于k的个数【OS:这有啥难的呢】
- 之后就是遍历原数组和这个C数组来复原这个排序后的序列【这有啥用咧】
- 我们从后到前依次扫描数组A。比如,当扫描到3时,我们可以从数组C中取出下标为3的值7,也就是说,到目前为止,包括自己在内,分数小于等于3的考生有7个,也就是说3是数组R中的第7个元素(也就是数组R中下标为6的位置)。当3放入到数组R中后,小于等于3的元素就只剩下了6个了,所以相应的C[3]要减1,变成6。
基数排序(Radix Sort)
- 基数排序就很简单,只要是一位一位排序就行
- 就和第一次讲排序的时候提到的稳定排序一样,从后往前,保证稳定排序
- 另外对于长度不齐的可以通过补零来对齐
- 基数排序对要排序的数据是有要求的,需要可以分割出独立的“位”来比较,而且位之间有递进的关系,如果a数据的高位比b数据大,那剩下的低位就不用比较了。除此之外,每一位的数据范围不能太大,要可以用线性排序算法来排序,否则,基数排序的时间复杂度就无法做到O(n)了
课后题:假设我们现在需要对D,a,F,B,c,A,z这个字符串进行排序,要求将其中所有小写字母都排在大写字母的前面,但小写字母内部和大写字母内部不要求有序。比如经过排序之后为a,c,z,D,F,B,A,这个如何来实现呢?如果字符串中存储的不仅有大小写字母,还有数字。要将小写字母的放到前面,大写字母放在最后,数字放在中间,不用排序算法,又该怎么解决呢?
- 用两个指针a、b:a指针从头开始往后遍历,遇到大写字母就停下,b从后往前遍历,遇到小写字母就停下,交换a、b指针对应的元素;重复如上过程,直到a、b指针相交。
- 利用桶排序思想,弄小写,大写,数字三个桶,遍历一遍,都放进去,然后再从桶中取出来就行了。相当于遍历了两遍,复杂度O(n)
14讲排序优化:如何实现一个通用的、高性能的排序函数
前面讲的八种排序算法总结
- 这里要注意的是虽然归并排序看起来很爽,但是由于需要重开一块空间进行分区,所以空间复杂度太高,我们不使用
- 归并排序可以做到平均情况、最坏情况下的时间复杂度都是O(nlogn)
如何优化快速排序
- 当我们需要排序的序列本身就已经是接近有序的时候,我们的快速排序效率是最低的,因为每次选择的分区点都是最后一个数据,这样时间复杂度就会退化到O(n2)
- 理想的分区点应该是在左右两个分区中,数据的数量都差不多
- 这里介绍两种分区算法
三数取中法
- 选择第一个,中间一个,最后一个三个数中间那个作为分区点
- 如果要排序的数组比较大,那“三数取中”可能就不够了,可能要“五数取中”或者“十数取中”
随机法
- 机法就是每次从要排序的区间中,随机选择一个元素作为分区点。这种方法并不能保证每次分区点都选的比较好,但是从概率的角度来看,也不大可能会出现每次分区点都选的很差的情况,所以平均情况下,这样选的分区点是比较好的。时间复杂度退化为最糟糕的O(n2)的情况,出现的可能性不大。
Glibc中的qsort()函数
- ibc是GNU发布的libc库,即c运行库。glibc是linux系统中最底层的api,几乎其它任何运行库都会依赖于glibc
- 对于数据特别小的,会使用插入排序;较小的使用归并排序;特别大的是用快速排序
- 这很重要的一点是因为我们的时间复杂度代表的是一种上升趋势,对于数据在代入不同的值的时候要区别思考
课后题:在今天的内容中,我分析了C语言的中的qsort()的底层排序算法,你能否分析一下你所熟悉的语言中的排序函数都是用什么排序算法实现的呢?都有哪些优化技巧?
-
查看了下Arrays.sort的源码,主要采用TimSort算法, 大致思路是这样的:
1 元素个数 < 32, 采用二分查找插入排序(Binary Sort)
2 元素个数 >= 32, 采用归并排序,归并的核心是分区(Run)
3 找连续升或降的序列作为分区,分区最终被调整为升序后压入栈
4 如果分区长度太小,通过二分插入排序扩充分区长度到分区最小阙值
5 每次压入栈,都要检查栈内已存在的分区是否满足合并条件,满足则进行合并
6 最终栈内的分区被全部合并,得到一个排序好的数组Timsort的合并算法非常巧妙:
1 找出左分区最后一个元素(最大)及在右分区的位置
2 找出右分区第一个元素(最小)及在左分区的位置
3 仅对这两个位置之间的元素进行合并,之外的元素本身就是有序的
15讲二分查找(上):如何用最省内存的方式实现快速查找功能
- 二分查找虽然简单,但随着做的题目多了,发现后续的变化是真的多,甚至不需要一定使用有序的序列
导入:智力题,猜数字
- 二分查找针对的是一个有序的数据集合,查找思想有点类似分治思想。每次都通过跟区间的中间元素对比,将待查找的区间缩小为之前的一半,直到找到要查找的元素,或者区间被缩小为0
惊人的查找速度
- 我们的时间复杂度是O(logn),这是一个很牛掰的速度,就算我们需要查找的数据集大小为2的32次也就大约是42亿,也只需要查找32次就能出答案了
- 这里可以参考下阿基米德与国王下棋的故事参考下指数的强大
这是一个很著名的故事:阿基米德与国王下棋,国王输了,国王问阿基米德要什么奖赏?阿基米德对国王说:“我只要在棋盘上第一格放一粒米,第二格放二粒,第三格放四粒,第四格放十六粒…按这个方法放满整个棋盘就行.”国王以为要不了多少粮食,就随口答应了,结果国王输了
最后需要的大米数量为2的64次方-1
代码实现
非递归
- 学到了,使用mid = low+((high-low)>>1)
- 别问为什么,问就是学了
int BinarySearch(vector &arr, int value) {
int len = arr.size();
int left = 0;
int right = len - 1;
int mid = left + ((right - left) >> 1);
while (left <= right) {
int mid = left + ((right - left) >> 1);
if (arr[mid] == value) {
return mid;
} else if (arr[mid] < value) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return right;
}
- 这样子返回right,如果查找不到value,那么返回的就是在其中靠左的下标
- e.g. arr为{1, 3, 7, 9, 13},value为0返回-1,value为2返回0
递归
- 递归做法没得说,学学学
int bsearchInternally(vector &arr, int left, int right, int value) {
if (left > right) {
return -1;
}
int mid = left + ((right - left) >> 1);
if (arr[mid] == value) {
return mid;
} else if (arr[mid] < value) {
return bsearchInternally(arr, mid + 1, right, value);
} else {
return bsearchInternally(arr, left, mid - 1, value);
}
}
- 这个递归应该算是比较好理解的那种了
二分的局限性
- 首先,二分查找依赖的是顺序表结构,简单点说就是数组。
- 其次,二分查找针对的是有序数据。
- 再次,数据量太小不适合二分查找。
- 最后,数据量太大也不适合二分查找。
开篇的思考题:如何在1000万个整数中快速查找某个整数?
- 虽然大部分情况下,用二分查找可以解决的问题,用散列表、二叉树都可以解决。但是,我们后面会讲,不管是散列表还是二叉树,都会需要比较多的额外的内存空间。如果用散列表或者二叉树来存储这1000万的数据,用100MB的内存肯定是存不下的。而二分查找底层依赖的是数组,除了数据本身之外,不需要额外存储其他信息,是最省内存空间的存储方式,所以刚好能在限定的内存大小下解决这个问题。
课后题
如何编程实现“求一个数的平方根”?要求精确到小数点后6位。
- 根据x的值,判断求解值y的取值范围。假设求解值范围min < y < max。若0
1,则min=1,max=x;在确定了求解范围之后,利用二分法在求解值的范围中取一个中间值middle=(min+max)÷2,判断middle是否是x的平方根?若(middle+0.000001)(middle+0.000001)>x且(middle-0.000001)(middle-0.000001) middle > x,表示middle>实际求解值,max=middle; 若middlemiddle < x,表示middle<实际求解值,min =middle;之后递归求解!
备注:因为是保留6位小数,所以middle上下浮动0.000001用于介值定理的判断
我刚才说了,如果数据使用链表存储,二分查找的时间复杂就会变得很高,那查找的时间复杂度究竟是多少呢?如果你自己推导一下,你就会深刻地认识到,为何我们会选择用数组而不是链表来实现二分查找了。
-
说说第二题吧,感觉争议比较大:
假设链表长度为n,二分查找每次都要找到中间点(计算中忽略奇偶数差异):
第一次查找中间点,需要移动指针n/2次;
第二次,需要移动指针n/4次;
第三次需要移动指针n/8次;
......
以此类推,一直到1次为值总共指针移动次数(查找次数) = n/2 + n/4 + n/8 + ...+ 1,这显然是个等比数列,根据等比数列求和公式:Sum = n - 1.
最后算法时间复杂度是:O(n-1),忽略常数,记为O(n),时间复杂度和顺序查找时间复杂度相同
但是稍微思考下,在二分查找的时候,由于要进行多余的运算,严格来说,会比顺序查找时间慢