文章目录
前言
最近在刷LeetCode的过程中,发现自己的算法基础比较薄弱,还是应该重新打一下基础,所以开始看《算法导论》。这里不会写流水账,作为读书笔记,只记录了我个人比较关心的知识点。如果您发现有错误,欢迎帮助指正!文中若有侵权,请麻烦联系我删除。
版本与涉及章节:《算法导论》 第三版 第一部分 第2章 《算法基础》
一、插入排序
1.介绍
插入排序是一种对于少量元素比较有效的算法。书中举的例子非常贴切,就像打扑克牌,在抓牌时候对手牌进行排序的过程。将这一流程抽象出来,就是插入排序了。

2.流程
可以回想一下抓牌的流程,进而抽象出算法的过程(这里以升序排序为例)。
- 抓第一张牌,此时不用排序
- 抓第二张牌,此时和第一张牌进行比较。此时有两种情况:
(1)大于等于手里那张,则放到手里那张的后面;
(2)小于手里那张,则放到手里那张的前面。 - 重复这些过程
- 抓第k+1张牌,此时手上已经有k张排好序的了,和手上最后一张牌(即第k张)进行比较。和第2步相同,此时有两种情况:
(1)大于等于第k张,则放在最后,流程停止,继续抓下一张牌
(2)小于第k张,则继续向前和第k-1张进行比较,直到刚找到比自己小的那张,然后放在那张后面。或者手里都比这张大,则这张放在第一位。
3.算法实现(C++)
根据上面抽象出来的流程,书中假设用数组实现,并对[5,2,4,6,1,3]进行排序,以体现用数组实现的上述过程。

根据上述图片,有以下算法:
- 第一张直接拿在手里,所以i是从1开始的。
- 抓第n张牌,此时牌是vec[i],需要依次和i-1及之前的手牌进行比较。这里使用调换来模拟插入,所以依次向前比较,碰到大于自己的,就交换,表示插入到这个元素前面;一直到碰到小于自己的,就停止交换,表示插入到这个元素后面,这样就实现了插入过程。
void InsertSort(vector<int>& vec) {
int size = vec.size();
for (int i = 1; i < size; ++i) {
for (int j = i; j >= 1 && vec[j - 1] > vec[j]; --j) {
swap(vec[j - 1], vec[j]);
}
}
}
代码实现和流程完全一致。此处用i表示此时拿的是第i张牌,j表示此牌向前面交换的过程。
4.分析复杂度
1. 时间复杂度
书中这部分计算十分严谨,我将其做了简化处理。下面是我个人的理解。
对于n张牌的排序来说,需要抽n-1次。
(1)如果按最好情况考虑,如果是已经排好序的,则只会进行外层for循环,即执行n-1次,其时间复杂度为Θ(n)。
(2)如果按反向抽牌这种最坏情况考虑,如果每次手牌都比当前牌大,则从第二张牌开始,每次都需要与前面的进行比较,执行次数为
1
+
2
+
⋯
+
k
−
1
+
⋯
+
n
−
1
=
∑
k
=
1
n
−
1
k
=
1
+
n
−
1
2
×
(
n
−
1
)
1+2+\cdots+k-1+\cdots+n-1=\sum_{k=1}^{n-1}k=\frac{1+n-1}{2}\times (n-1)
1+2+⋯+k−1+⋯+n−1=k=1∑n−1k=21+n−1×(n−1)
次,故时间复杂度为Θ(n2)。
时间复杂度的计算一般按最坏情况考虑,故插入排序的时间复杂度为Θ(n2)。
注:《算法导论》中给出了为什么要按照最坏情况分析复杂度,原因如下:
- 一个算法的最坏情况运行时间给出了任何输入的运行时间的一个上界。直到了这个界,就能确保该算法绝不需要更长的时间。我们不必对运行时间做某种复杂的猜测并可以期望它不会变得更坏。
- 对某些算法,最坏情况经常出现。例如,当在数据库中检索一条特定信息时,若该信息不在数据库中出现,则检索算法得最坏情况会经常出现。在某些应用中,对缺失信息得检索可能是频繁的。
- “平均情况”往往与最坏情况大致一样差。假定随机选择n个数并应用插入排序……平均来说,插入一个数时已排好序的部分一半元素会小于它,而另一半会大于它。而Θ(n2)就算除以2,其时间复杂度也是Θ(n2),同样是输入规模的一个二次函数。
2. 空间复杂度
算法是原地排序,所以不需要占用额外空间,空间复杂度为Θ(1)。
二、选择排序
1.介绍
《算法导论》在插入排序这一节的后面的练习2-2中提出了选择排序题目。
考虑排序存储在数组A中的n个数:首先找出A中的最小元素并将其与A[0]中的元素进行交换。接着,找出A中的次最小元素并将其与A[1]中的元素进行交换。对A中前n-1个元素按该方式继续上述流程。该算法称为选择算法。
2.流程
就像介绍中说明的那样,流程应该为:
- 找出A[0]到A[n - 1]中最小元素,与A[0]元素交换;
- 找出A[1]到A[n - 1]中最小元素,与A[1]元素交换;
- 重复上述过程,直到 找出A[n-2]到A[n - 1]中最小元素,与A[n-2]元素交换;
- 仅剩A[n-1]元素自己,无需再排序。
3.算法实现(C++)
根据上述流程设计,有以下算法:
void SelectSort(vector<int>& vec) {
int size = vec.size();
for (int i = 0; i + 1 < size; ++i) {
int minInt = i;
for (int j = i + 1; j < size; ++j) {
if (vec[minInt] > vec[j])
minInt = j;
}
swap(vec[i], vec[minInt]);
}
}
4.分析复杂度
1. 时间复杂度
对于n个数据的数组来说,不论数组本身是正序还是倒序,其工作流程都要从头到尾选择一个最小的数,所以时间复杂度稳定不变。由于要进行两层循环,其时间复杂度为Θ(n2)。
2. 空间复杂度
由于算法仅需要常数空间记录最小数的下标,所以空间复杂度为Θ(1)。
三、归并排序
1.介绍
归并排序的方法基于递归实现,是分治模式的典型思想。具体是将原问题分解为几个规模较小但类似于原问题的子问题,递归地求解这些子问题,然后再合并这些子问题的解来建立原问题的解。
2.流程
分治模式在每层递归时都有三个步骤:
(1)分解原问题为若干子问题,这些子问题是原问题的规模较小的实例;
(2)解决这些子问题,递归地求解各子问题。然而,若子问题的规模足够小,则直接求解。
(3)合并这些子问题的解成原问题的解。
归并排序算法完全遵循分治模式,直观上其操作如下:
(1)分解:分解待排序的n个元素的序列成各具n/2个元素的两个子序列;
(2)解决:使用归并排序递归地排序两个子序列。当待排序的序列长度为1时,算是“子问题的规模足够小”,直接求解;
(3)合并:合并两个已排序的子序列以产生已排序的答案。
3. 浅谈“合并”
根据上述流程,分解与解决其实都不需要进行太多操作,唯一需要注意的就是合并。当归并排序递归地排序好了两个子序列之后,只要将其“合并”,即将两个有序子序列合成一个有序子序列,就算完成归并排序中的“合并”流程。在进行归并排序的合并之前,可以先了解一下LeetCode上与此步骤相关的题目,进行一下热身。
a. leetcode21 合并两个有序链表
题目链接:合并两个有序链表
基本思想很简单,就是比较两个有序链表的头部,然后比较大小,将小的先合并即可。唯一可能需要注意的就是链表首尾的处理。有很多种方法,这里采用哨兵+每一步判断是否为空来进行处理。这样处理的好处就是流程模板化,不需要在循环结束后再进行其他判断了。
参考答案
ListNode* mergeTwoLists(ListNode* l1, ListNode* l2) {
ListNode hair(-1);
ListNode* cur = &hair, *left = l1, *right = l2;
while (left || right) {
if (!right || (left && left->val <= right->val)) {
cur->next = left;
left = left->next;
}
else {
cur->next = right;
right = right->next;
}
cur = cur->next;
}
return (&hair)->next;
}
b. leetcode88 合并两个有序数组
题目链接:合并两个有序数组
基本思想和链表的差不多,唯一不同的就是它进行了原地合并,为了避免出现从头开始归并导致原数据被覆盖的情况,这里采用了从后往前的合并方式。可以看到,两道题if中的判断方式基本都相同。
参考答案
void merge(vector<int>& nums1, int m, vector<int>& nums2, int n) {
int left = m - 1, right = n - 1;
for (int i = m + n - 1; i >= 0; --i) {
if (right < 0 || (left >= 0 && nums1[left] >= nums2[right])) {
nums1[i] = nums1[left--];
}
else {
nums1[i] = nums2[right--];
}
}
}
4.算法实现(C++)
根据上述流程,我们先实现合并逻辑。书中给出了一个例子:

大意是说,将要合并的两个部分首先分别保存在其它数组中,然后再向原数组合并。
于是,排序的整体流程是,每次都取数组中点位置,将数组分为两部分,递归解决后再将得到的两个有序数组合并。就有了如下实现:
void helper(vector<int>& vec, int left, int right) {
if (left >= right) return; // 此处即“子问题的规模足够小”
int mid = left + (right - left) / 2; // 分解
helper(vec, left, mid); // 递归
helper(vec, mid + 1, right);
// 下面是MERGE合并过程
vector<int> temp1(vec.begin() + left, vec.begin() + mid + 1), temp2(vec.begin() + mid + 1, vec.begin() + right + 1);
int idx1 = 0, idx2 = 0, cur = left, size1 = mid - left + 1, size2 = right - mid;
while (idx1 < size1 || idx2 < size2) {
if (idx2 >= size2 || (idx1 < size1 && temp1[idx1] < temp2[idx2]))
vec[cur++] = temp1[idx1++];
else
vec[cur++] = temp2[idx2++];
}
}
void MergeSort(vector<int>& vec) {
if (vec.size() < 2) return;
helper(vec, 0, vec.size() - 1);
}
注:使用vector构造函数来复制vector中某些元素时,第二个参数要指向需要复制的元素最后位置再向后一位,可以参考全部复制是vector<int> temp(vec.begin(), vec.end());而end()指向的是最后一个元素的下个位置。
5.分析复杂度
1. 时间复杂度
下面按照最坏情况考虑时间复杂度。假设最坏运行时间为T(n),当有n>1个元素时,我们分解运行时间如下:
分解:分解步骤仅计算子数组的中间位置,需要常量时间Θ(1);
解决:我们递归地求解两个规模均为T(n/2)的子问题;
合并:在一个具有n个元素的子数组上进行MERGE(合并)需要Θ(n)的时间(因为需要所有元素都遍历一次)。
我们忽略常数项时间,仅计算子问题与MERGE的时间。书上将这一过程画为一个显而易见的分解过程,如下图。

其中,n为元素个数,即上图递归树最后一行的叶节点数目。而显然该树为1层时叶节点个数为20,2层时个数为21,……,i层时为n=2i-1。故i=lgn+1。所以,每一层的时间复杂度为Θ(n),共lgn+1层,故时间复杂度共Θ(n(lgn+1)),即归并排序的时间复杂度为Θ(nlgn)。
2. 空间复杂度
此处部分借鉴时间复杂度&空间复杂度分析内容
归并的空间复杂度遵循递归的时间复杂度计算公式,具体为
递 归 深 度 × 每 次 递 归 所 需 要 的 辅 助 空 间 递归深度\times每次递归所需要的辅助空间 递归深度×每次递归所需要的辅助空间
由空间复杂度分析可知,递归共进行lgn次,而压栈时还未开辟辅助空间,可知每次递归所需要的辅助空间为常数级别,所以这部分空间复杂度为Θ(lgn)。但是,在递归出栈的时候,由于MERGE归并过程,还需要最大n个位置的额外空间,所以两者为相加关系,取大,空间复杂度为Θ(n)。
四、冒泡排序
1.介绍
《算法导论》在归并排序后面的思考题2-2中提出了冒泡排序题目。
冒泡排序是一种流行但低效的排序算法,它的作用是反复交换相邻的未按次序排列的元素。
2.流程
冒泡排序的流程就和水泡浮起一样,将最大(以升序为例)元素从数组中浮起来。流程如下:
- 比较第1个和第2个元素,将大的元素放在后面;
- 比较第2个和第3个元素,同样将大的元素放在后面;
- 重复上述过程,直到 比较到倒数第二个和倒数第一个元素,将较大的放在后面。这样,元素中最大的就会是队列中的最后一个;
- 重新进行第1、2、3过程,唯一的不同是现在最大的已经比较完毕,所以只需要进行到倒数第二个位置即可;
- 重复4过程,直到只剩一个元素,即为元素中最小值。此时排列完毕。
整个过程剩下元素中最大的就会逐渐“浮”到最后面,冒泡排序由此得名。
3.算法实现(C++)
根据上述流程,有以下算法:
void BubbleSort(vector<int>& vec) {
if (vec.empty()) return;
int size = vec.size();
for (int i = size - 1; i > 0; --i) {
for (int j = 1; j <= i; ++j) {
if (vec[j - 1] > vec[j])
swap(vec[j - 1], vec[j]);
}
}
}
4.分析复杂度
1. 时间复杂度
对于n个数据的数组来说,整个流程需要进行两层循环,故其时间复杂度为Θ(n2)。
2. 空间复杂度
算法是原地排序,所以不需要占用额外空间,空间复杂度为Θ(1)。
这篇博客是《算法导论》第二章的读书笔记,详细介绍了插入排序、选择排序、归并排序和冒泡排序的原理、流程、C++实现以及时间复杂度和空间复杂度分析。通过举例和LeetCode题目加深理解,如合并两个有序链表(leetcode21)和合并两个有序数组(leetcode88)。

2189

被折叠的 条评论
为什么被折叠?



