【算法】《漫画算法:小灰的算法之旅》 学习笔记
漫画算法 小灰的算法之旅
前言
第1章 算法概述
算法:在计算机领域中,算法是一系列程序指令,用于处理特定的运算和逻辑问题。衡量算法优劣的主要标准是时间复杂度和空间复杂度。
数据结构:数据结构是数据的组织、管理和存储格式,其使用目的是为了高效地访问和修改数据。数据结构包含数组、链表这样的线性数据结构,也包含树、图这样的复杂数据结构。
时间复杂度:时间复杂度是对一个算法运行时间长短的度量,用大\(O\)表示,记作\(T(n)=O(f(n))\)。常见的时间复杂度按照从低到高的顺序,包括\(O(1)\)、\(O(\log n)\)、\(O(n)\)、\(O(n \log n)\)、\(O(n^2)\)
空间复杂度:空间复杂度是对一个算法在运行过程中临时占用存储空间大小的度量,用大\(O\)表示,记作\(S(n)=O(f(n))\)。常见的空间复杂度按照从低到高的顺序,包括\(O(1)\)、\(O(n)\)、\(O(n^2)\)
第2章 数据结构基础
数组
数组是由有限个相同类型的变量所组成的有序集合,它的物理存储方式是顺序存储,访问方式是随机访问。
利用下标查找数组元素的时间复杂度是\(O(1)\),中间插入、删除数组元素的时间复杂度是\(O(n)\)。
适合于读操作多、写操作少的场景
class My_Array
{
int * array; // 指向数组真实存储的内存区域
int size; // 数组当前的元素数量
int capacity; // 数组的最大容量
public:
My_Array()
{
array = new int [10];
capacity = 10;
size = 0;
}
My_Array(int capacity)
{
array = new int [capacity];
this->capacity = capacity;
size = 0;
}
~My_Array()
{
delete [] array;
}
// 增:在指定位置处添加一个元素
bool insert(int index, int value)
{
// 如果插入的下标无效,插入失败
if (index < 0 || index > size)
return false;
// 插入下标位置之后的元素,全部向后挪一位
for (int i = size - 1; i >= index; i--)
array[i + 1] = array[i];
// 将待插入插入到指定位置
array[index] = value;
size++;
resize();
return true;
}
// 删:删除指定位置的元素
int remove(int index)
{
// 判断下标范围是否有效
if (index < 0 || index >= size)
return -1;
// 将要删除的元素记录下来
int value = array[index];
// 将要删除位置之后的所有元素向前移一位
for (int i = index + 1; i < size; i++)
array[i - 1] = array[i];
size--;
resize();
return value;
}
// 改:修改指定位置的元素
bool modify(int index, int value)
{
// 判断下标是否有效
if (index < 0 || index >= size)
return false;
// 只修改对应位置的元素即可,总数量不发生改变,其余元素位置也不发生改变
array[index] = value;
return true;
}
// 查:查询指定位置的元素
int inquire(int index)
{
// 判断下标是否有效
if (index < 0 || index >= size)
return false;
return array[index];
}
// 调整数组大小
// 当数组内元素容量达到80%的时候进行扩容
// 当数组内元素容量小于30%的时候进行缩小
void resize()
{
if ((size < capacity * 0.8 && size > capacity * 0.3) ||
(size <= capacity * 0.3 && capacity <= 10))
return;
int * old = array;
if (size >= capacity * 0.8)
{
capacity = capacity * 2;
array = new int [capacity];
} else if (size <= capacity * 0.3)
{
capacity = capacity / 2;
array = new int [capacity];
}
for (int i = 0; i < size; i++)
array[i] = old[i];
delete [] old;
}
void display()
{
cout << "array capacity: " << capacity << ", and size: " << size << endl;
for (int i = 0; i < size; i++)
{
if (i == 0)
{
cout << array[i];
}
else
{
cout << ", " << array[i];
}
}
cout << endl;
}
};
链表
链表是一种链式数据结构,由若干节点组成,每个节点包含指向下一个节点的指针。链表的物理存储方式是随机存储,访问方式是顺序访问。
查找链表节点的时间复杂度是\(O(n)\),中间插入、删除节点的时间复杂度是\(O(1)\)。
适用于写操作多、读操作少的场景
栈
栈是一种线性的逻辑结构。遵循先入后出的原则(FILO)。包含入栈(push)和出栈(pop)操作。
最先进入的元素存储的位置叫做栈底(bottom),最后进入的元素的存储位置叫做栈顶(top)。
队列
队列也是一种线性逻辑结构。遵循先入先出的原则(FIFO)。包含入队(enqueue)和出队(dequeue)操作。
出口端称为队头(front),入口端称为队尾(rear)。
双端队列
双端队列在隐藏部分方法的情况下既可以作为队列使用,也可以作为栈使用。
散列表(哈希表)
散列表,是存储键值映射的集合。对于某一个键(key),散列表可以在接近\(O(1)\)的时间内进行读写操作。
散列表通过哈希函数实现键与数组下标的转换,通过开放寻址法和链表法来解决哈希冲突。
散列表在扩容的时候,首先进行物理扩容,扩大数组容量,之后再重新hash所有的内容,做出一个新的哈希表
第3章 树
树
n个节点的有限集(n=0时称之为空树)
有且只有一个根节点
根节点,父节点,孩子节点,兄弟节点,叶子节点,子树,高度/深度
二叉树
每个节点最多只能有两个子节点
满二叉树:所有非叶节点都有两个子节点,所有的叶节点都在同一层
完全二叉树:类似于堆的那种,除了最后一层,其它层都是满的,最后一层的节点都靠左边
二叉树的实现可以使用链表和数组,链表是常规的,数组就类似于堆那种表示法
二叉查找树:又叫二叉排序树,满足一定规则的二叉树,如左子树的所有节点一定小于等于当前节点,右子树的所有的节点一定大于当前节点,为了查询的时候更快速。
高级篇:二叉树的平衡、红黑树、AVL树、树堆等。。。
遍历
两大类共四种遍历:深度优先遍历,如前序遍历(根节点、左子树、右子树)、中序遍历(左子树、根节点、右子树)、后序遍历(左子树、右子树、根节点);广度优先遍历,如层序遍历。
深度优先遍历可以使用递归或者栈来实现,广度优先遍历可以使用队列来实现
二叉堆
本质上是一种完全二叉树,不过具有自己的性质。分为最大堆和最小堆。
最大堆,所有的父节点都大于等于它的左右子节点的值;最小堆则是小于等于。
二叉堆主要有三种操作:
- 插入节点:在堆的末尾插入一个新节点,然后不断向上,确定这个节点的最终位置
- 删除节点:按照堆的性质,删除的节点是根节点,将最后一个节点和根节点交换,然后将新的节点不断向下调整,确定这个节点的最终位置
- 构建二叉堆:本质就是让所有的非叶子节点依次下沉,从后往前,所有的非叶子节点依次下沉
优先级队列
出队的时候优先级最高的元素先出队,本质上就是使用了二叉堆的性质。
第4章 排序算法
时间复杂度为\(O(n^{2})\):冒泡排序、选择排序、插入排序、希尔排序
时间复杂度为\(O(n\log n)\):快速排序、归并排序、堆排序
时间复杂度为\(O(n)\):计数排序、桶排序、基数排序
冒泡排序
相邻元素两两比较、沉底或者冒泡,是一种稳定排序。优化时可以考虑有序后不再做无用功,最后一次交换时的有序区的界定,最后一位无序时的鸡尾酒排序
#include <iostream>
#include <vector>
using namespace std;
// 最普通版的冒泡排序
void bubble_sort(vector<int> &vec)
{
int temp;
for (int i = 0; i < vec.size() - 1; i++)
for (int j = 0; j < vec.size() - 1 - i; j++)
if (vec[j] > vec[j + 1])
{
temp = vec[j];
vec[j] = vec[j+1];
vec[j+1] = temp;
}
}
// 第一次优化的冒泡排序,如果已经有序,就不用再进行后续的步骤
void bubble_sort1(vector<int> &vec)
{
bool is_sorted = false;
int temp;
for (int i = 0; i < vec.size() - 1 && !is_sorted; i++)
{
// 假设已经有序了
is_sorted = true;
for (int j = 0; j < vec.size() - 1 - i; j++)
if (vec[j] > vec[j + 1])
{
is_sorted = false; // 有序假设失败
temp = vec[j];
vec[j] = vec[j+1];
vec[j+1] = temp;
}
}
}
// 第二次优化的冒泡排序,如果发现后面一部分元素已经有序了,而且最小值大于前面一部分元素的最大值
// 如: 4 3 2 1 5 6 7 8,其中 5 6 7 8 已经有序,那么判断时只需判断到5处就可以了
void bubble_sort2(vector<int> &vec)
{
int temp, sorted_border = vec.size() - 1, last_swap_index;
bool is_sorted = false;
for (int i = 0; i < vec.size() - 1 && !is_sorted; i++)
{
is_sorted = true;
for (int j = 0; j < sorted_border; j++)
{
if (vec[j] > vec[j + 1])
{
is_sorted = false;
temp = vec[j];
vec[j] = vec[j+1];
vec[j+1] = temp;
last_swap_index = j;
}
}
sorted_border = last_swap_index;
}
}
// 鸡尾酒排序,冒泡排序的变形,先正向排序,再反向排序
void cocktail_sort(vector<int> &vec)
{
bool is_sorted = false;
int temp;
for (int i = 0; i < vec.size() / 2 && !is_sorted; i++)
{
is_sorted = true;
// 正向排序
for (int j = i; j < vec.size() - i - 1; j++)
{
if (vec[j] > vec[j+1])
{
is_sorted = false;
temp = vec[j];
vec[j] = vec[j+1];
vec[j+1] = temp;
}
}
// 反向排序
for (int j = vec.size() - i - 3; j >= i; j--)
{
if (vec[j] > vec[j+1])
{
is_sorted = false;
temp = vec[j];
vec[j] = vec[j+1];
vec[j+1] = temp;
}
}
}
}
int main(void)
{
vector<int> vec {1, 6, 7, 3, 5, 8, 9, 2};
cocktail_sort(vec);
for (int x: vec)
cout << x << endl;
}
快速排序
使用基准元素将元素分为两组,大于自己的,小于等于自己的。基准元素的选择,以及元素交换是重点
#include <iostream>
#include <vector>
#include <stack>
using namespace std;
// 双边循环法 进行分割
int partition(vector<int> &vec, int left, int right)
{
int pivot = vec[left];
int p_left = left, p_right = right;
int temp;
while (p_left < p_right)
{
while (p_left <= right && vec[p_left] <= pivot) p_left++;
// 此时 p_left 是第一个大于基准点的元素的坐标(或者超界)
while (p_right >= left && vec[p_right] > pivot) p_right--;
// 此时 p_right 是从右向左第一个小于等于基准点的元素的坐标
if (p_left < p_right) {
temp = vec[p_left];
vec[p_left] = vec[p_right];
vec[p_right] = temp;
}
}
// p_right 一定是从右向左第一个小于等于基准点的元素的坐标
vec[left] = vec[p_right];
vec[p_right] = pivot;
return p_right;
}
// 单边循环法进行分割
int partition1(vector<int> &vec, int left, int right)
{
int pivot = vec[left];
int border = left; // 最后一个小于等于基准点的元素位置 默认是自己本身
for (int i = left + 1; i <= right; i++)
{
// 如果遇到一个小于等于基准点的元素,将该元素移动到最后一个小于等于基准点的元素的位置之后
if (vec[i] <= pivot) {
border++;
int temp = vec[i];
vec[i] = vec[border];
vec[border] = temp;
}
}
vec[left] = vec[border];
vec[border] = pivot;
return border;
}
// 非递归版本的快速排序
void quick_sort1(vector<int> &vec)
{
stack<pair<int, int> > sta;
sta.push(make_pair(0, vec.size() - 1));
while (!sta.empty())
{
pair<int, int> scope = sta.top();
sta.pop();
int left = scope.first;
int right = scope.second;
if (left >= right) continue;
int index = partition(vec, left, right);
sta.push(make_pair(index + 1, right));
sta.push(make_pair(left, index - 1));
}
}
void quick_sort(vector<int> &vec, int left, int right)
{
if (left >= right) return;
int index = partition1(vec, left, right);
quick_sort(vec, left, index - 1);
quick_sort(vec, index + 1, right);
}
void quick_sort(vector<int> &vec)
{
quick_sort(vec, 0, vec.size() - 1);
}
int main(void)
{
vector<int> vec {77, 5, 2, 15, 3, 8, 7, 9, 13, 22, 6, 1};
quick_sort1(vec);
for (int x: vec)
cout << x << endl;
}
堆排序
升序排列的话构建大根堆,降序排列的话构建小根堆
循环交换堆顶和末尾元素,然后调整堆
计数排序
统计一定范围内每个值出现的次数,然后根据新数组生成排序后的数组
#include <iostream>
#include <vector>
using namespace std;
void count_sort(vector<int> &vec) {
// 1. 找到最大值与最小值,并计算差值
int max_v = vec[0], min_v = vec[0];
for (int x: vec)
{
if (x > max_v) max_v = x;
if (x < min_v) min_v = x;
}
int gap = max_v - min_v;
// 2. 创建计数数组
vector<int> count_array(gap + 1);
// 3. 统计每个元素
for (int x: vec)
count_array[x-min_v]++;
// 4. 通过计数数组生成排序后的数组
vector<int> sorted_array;
for (int i = 0; i < count_array.size(); i++)
for (int j = 0; j < count_array[i]; j++)
sorted_array.push_back(i + min_v);
vec = sorted_array;
// 4-2. 如果需要稳定排序的话,需要将计数数组修改为小于等于当前值的元素数
// 并且在生成有序数组的时候,对原数组进行倒序统计
// for (int i = 1; i < count_array.size(); i++)
// count_array[i] = count_array[i - 1] + count_array[i];
// vector<int> sorted_array(vec.size());
// for (int i = vec.size() - 1; i >= 0; i--)
// {
// sorted_array[--count_array[vec[i] - min_v]] = vec[i];
// // 使用前缀递减的原因:放置一个元素后统计数量需要减一,而且统计数量与实际下标之间的差距也是1
// }
// vec = sorted_array;
}
int main(void)
{
vector<int> vec {7, 2, 5, 8, 9, 10, 3, 2, 5, 9, 3, 9, 2, 2, 4, 5, 1, 7, 6, 9};
count_sort(vec);
for (int x: vec)
cout << x << endl;
}
桶排序
每一个桶代表一个区间,可承载一个或多个元素
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
void bucket_sort(vector<double> &vec);
int main(void) {
vector<double> vec {6.65, 4.32, 7.99, 12.38, 2.32, 9.74, 1.35};
bucket_sort(vec);
for (double x: vec) {
cout << x << endl;
}
}
void bucket_sort(vector<double> &vec) {
// 1. 找到数组中的最小值和最大值
double min_v = vec[0], max_v = vec[0];
for (double x: vec) {
if (x > max_v) max_v = x;
if (x < min_v) min_v = x;
}
double gap = max_v - min_v;
// 2. 创建桶
vector<vector<double> > bucket(vec.size());
// 3. 将元素放入桶中
for (double x: vec) {
bucket[int ((x-min_v) / gap * (vec.size() - 1))].push_back(x);
// 元素的值减去最小值 然后除以范围长度 最后乘上桶的个数-1 保证范围在0~桶个数-1中,不会越界
}
// 4. 桶内部元素排序
for (vector<double> &x: bucket) {
if (x.size() < 2) continue;
sort(x.begin(), x.end());
}
// 5. 将排序结果输出
vector<double> sorted_array;
for (vector<double> x: bucket)
for (double y: x)
sorted_array.push_back(y);
vec = sorted_array;
}
第5章 面试中的算法
有环链表
判断一个单向链表中是否可能出现一个环,如下图

解决方案:
- 双重遍历:对于第i个节点,从头开始,一直遍历至当前节点的前一个节点,看是否存在重复节点。时间\(O(n^{2})\) 空间\(O(1)\)
- 哈希表:对于每个经历过的节点,存储到哈希表中,如果发现某个节点在哈希表中存在,那么存在环。时间\(O(n)\) 空间\(O(n)\)
- 双指针:创建两个指针指向头结点,然后按照不同的步长前进,p1以步长1前进,p2以步长2前进,如果两个指针指向同一位置,那么证明有环(追及问题)
#include <iostream>
using namespace std;
struct Node {
int val;
Node * next;
Node(int v = 0): val(v), next(nullptr) {}
};
bool is_cycle(Node * head) {
Node *p1 = head, *p2 = head;
while (p2 != nullptr && p2->next != nullptr) {
p1 = p1->next;
p2 = p2->next->next;
if (p1 == p2) {
return true;
}
}
return false;
}
int main()
{
Node node1(5);
Node node2(3);
Node node3(7);
Node node4(2);
Node node5(6);
Node node6(8);
Node node7(1);
node1.next = &node2;
node2.next = &node3;
node3.next = &node4;
node4.next = &node5;
node5.next = &node6;
node6.next = &node7;
node7.next = &node4;
cout.setf(ios_base::boolalpha);
cout << is_cycle(&node1) << endl;
}
拓展问题:
求一个有环链表的环长:追及问题,p1步长为1,p2步长为2,从第一次相遇到第二次相遇,p1走过的长度就是环长
求一个有环链表的入环点:如图,设A为起始点,B为入环点,C为第一次相遇点。第一次相遇时p1走过的距离为AB+BC,p2走过的距离为AB+BC+CB+BC,p2的步长是2,p1的步长是1,p2走过的距离是p1的两倍,2(AB+BC)=AB+BC+CB+BC,得到AB=CB,也就是说从起始点到入环点的距离等于从第一次相遇点到入环点的距离,设置两个指针,分别指向起始点和第一次相遇点,同时前进,相等时的点便是入环点

最小栈
实现一个栈,有三种方法,出栈、入栈、取最小元素,并且每个方法的时间复杂度都是\(O(1)\)
栈本来就具有入栈和出栈方法,且满足时间复杂度的要求。
如果只使用一个值来表示最小的元素的话,出栈时如果恰好最小元素出栈,那么最小元素下标将会无效
额外下标,在入栈的同时,将当前最小元素对应的下标也压入栈中,缺点是下标需要依赖数组版本实现,而且最小元素对应的下标可能会重复多次
额外的最小元素栈,创建一个额外的栈来存储最小元素,入栈时如果最小元素大于等于入栈元素,最小元素栈中压入入栈元素,出栈时,如果出栈元素等于最小元素,最小元素栈弹出最小元素
#include <iostream>
#include <stack>
using namespace std;
class my_stack {
private:
stack<int> st;
stack<int> minSt;
public:
void push(int n)
{
if (st.empty()) {
st.push(n);
minSt.push(n);
return;
}
if (n <= minSt.top())
minSt.push(n);
st.push(n);
}
void pop()
{
if (st.empty()) return;
if (st.top() == minSt.top())
minSt.pop();
st.pop();
}
int getMin() { return minSt.top(); }
};
int main() {
my_stack ms;
ms.push(15);
ms.push(22);
ms.push(13);
cout << ms.getMin() << endl;
ms.push(16);
ms.push(12);
cout << ms.getMin() << endl;
ms.pop();
cout << ms.getMin() << endl;
ms.pop();
ms.pop();
cout << ms.getMin() << endl;
}
求最大公约数
暴力法:从大到小依次递减,找到第一个整除他们的值
辗转相除法:两个正整数a和b(a>b),它们的最大公约数等于a除以b的余数c和b之间的最大公约数 gcd(a, b) = gcd(big % small, small)
更相减损术:两个正整数a和b(a>b),它们的最大公约数等于a减去b的差值c和b之间的最大公约数 gcd(a, b) = gcd(big - small, small)
更相减损术结合移位运算:
- 如果a,b为偶数:
gcd(a, b) = 2 * gcd(a/2, b/2) = gcd(a>>1, b>>1)<<1 - 如果a为偶数,b为奇数:
gcd(a, b) = gcd(a/2, b) = gcd(a>>1, b)b为偶数,a为奇数同理 - 如果a,b为奇数:
gcd(a, b) = gcd(big - small, small)两个奇数的差为偶数,又可以使用上面的规则
#include <iostream>
using namespace std;
int gcd(int a, int b) {
int big, small, diff, factor = 1;
big = a > b ? a : b;
small = a < b ? a : b;
diff = big - small;
while (diff != 0) {
if ((a&1) == 0 && (b&1) == 0) {
factor *= 2;
a >>= 1;
b >>= 1;
} else if ((a&1) == 0) {
a >>= 1;
} else if ((b&1) == 0) {
b >>= 1;
} else {
big = a > b ? a : b;
small = a < b ? a : b;
diff = big - small;
a = small;
b = diff;
}
}
return factor * small;
}
int main() {
cout << gcd(15, 20) << endl;
cout << gcd(99, 127) << endl;
cout << gcd(867, 432) << endl;
}
是否为2的整数次幂
判断一个正整数是否为2的整数次幂
2的整数次幂的二进制表示下只有一个1,对其减一得到的值与其按位与的结果为0,如16是10000B,减一是01111B,按位与为0。12是1100,减一是1011,按位与是1000,不是0
#include <iostream>
using namespace std;
bool is_power_of_2(int x) {
return ((x&(x-1))==0);
}
int main() {
cout.setf(ios_base::boolalpha);
cout << is_power_of_2(15) << endl;
cout << is_power_of_2(16) << endl;
cout << is_power_of_2(128) << endl;
cout << is_power_of_2(129) << endl;
}
无序数组求有序状态下的最大相邻差
理解\(O(n)\)的排序思想,利用桶排序,按最大、最小范围性的分为n个桶,每个桶只用来记录最大最小值,最后比较所有桶之间的最大相邻值
只记录最大、最小值,是因为如果某个桶里存在两个元素,必然存在一个桶中没有元素,这两个元素之间的差值肯定没有空桶左右的元素差值大
#include <iostream>
#include <vector>
using namespace std;
#define mark -1
struct Bucket {
int min;
int max;
Bucket() {
min=mark;
max=mark;
}
};
int max_adjacent_difference(vector<int> & vec) {
if (vec.empty()) return 0;
int min_value = vec[0], max_value = vec[0], difference;
for (int x: vec) {
min_value = min_value < x ? min_value : x;
max_value = max_value > x ? max_value : x;
}
difference = max_value - min_value;
vector<Bucket> bucket(vec.size());
for (int x: vec) {
int index = (x-min_value)*(vec.size()-1)/difference;
if (bucket[index].min == mark || bucket[index].min > x) bucket[index].min = x;
if (bucket[index].max == mark || bucket[index].max < x) bucket[index].max = x;
}
int left_max = bucket[0].max;
int result = 0;
for (int i = 1; i < bucket.size(); i++) {
if (bucket[i].min == mark) continue;
if (bucket[i].min - left_max > result) result = bucket[i].min - left_max;
left_max = bucket[i].max;
}
return result;
}
int main() {
vector<int> vec = {2, 6, 3, 4, 5, 10, 9};
cout << max_adjacent_difference(vec) << endl;
}
用栈实现队列
使用两个栈实现队列的,一个栈做入队操作,一个栈做出队操作。如果出队栈为空,将入队栈的所有元素弹出压入到出队栈中
#include <iostream>
#include <stack>
using namespace std;
class My_queue {
private:
stack<int> st_in, st_out;
public:
void push(int x) {
st_in.push(x);
}
int pop() {
if (st_out.empty()) {
while (!st_in.empty()) {
st_out.push(st_in.top());
st_in.pop();
}
}
int value = st_out.top();
st_out.pop();
return value;
}
};
int main() {
My_queue mq;
mq.push(15);
mq.push(23);
mq.push(43);
cout << mq.pop() << " " << mq.pop() << endl;
mq.push(22);
cout << mq.pop() << " " << mq.pop() << endl;
}
寻找全排列的下一个数
- 自右向左寻找第一个逆序点,如
12345的5,51243的4 - 将逆序点的前一位与逆序点后大于它的最小值交换 如
51243变成51342 - 将逆序点至末尾的数逆向排列 如
51342变成51324
删去k个数字字后的最小值
对于高位,每次删除使其不满足升序性质的第一个数之前的数,如2375,其中5是第一个不满足升序性质的数,所以删除掉5之前的数,也就是7得到了235
实现时,可以利用栈的性质,用数组实现,使其内部元素满足升序性质
大整数相加
大整数相加可以按位相加并进位,以字符串或者int数组作为输入,长m位的一个数和长n位的一个数,和不大于max(m, n) + 1,可以通过对原数组进行逆序排列,然后补零,计算得到的结果再次逆向,去除掉头部的零
0-1背包(动态规划)
寻找缺失的整数
- 缺少一个整数
- 出现了奇数次的整数
- 2个出现了奇数次的整数

浙公网安备 33010602011771号