1、基本概念
- 数据结构是计算机存储、组织数据的方式。
- 算法是特定问题求解步骤的描述,在计算机中表现为指令的有限序列,是独立存在的一种解决问题的方法和思想。
- 两者区别:算法是为解决实际问题而设计的;数据结构是算法需要处理问题的载体;数据结构只是静态描述数据元素之间的关系,高效的程序需要在数据结构的基础上设计和选择算法,数据结构与算法相辅相成。
2、算法应用
2.1 栈的应用
2.1.1 就近匹配——检测字符串中的括号是否成对出现
字符串: 5+5*(6)+9/3*1)-(1+3(
- 算法思路如下:
- 从第一个字符开始扫描
- 当遇见普通字符时忽略,
- 当遇见左符号时压入栈中,
- 当遇见右符号时从栈中弹出栈顶符号,并进行匹配
4.1 匹配成功:继续读入下一个字符
4.2 匹配失败:立即停止,并报错 - 扫描结束:
5.1 成功: 所有字符扫描完毕,且栈为空
5.2 失败:匹配失败或所有字符扫描完毕但栈非空
2.1.2 中缀表达式与后缀表达式 —— 四则运算
- 实例
5 + 4 => 5 4 +
1 + 2 * 3 => 1 2 3 * +
8 + ( 3 – 1 ) * 5 => 8 3 1 – 5 * + - 中缀转后缀算法:
遍历中缀表达式中的数字和符号:
对于数字,直接输出;
对于符号:1)左括号:进栈;
2)运算符:a)若栈顶符号优先级低,此运算符进栈;
b)若栈顶符号优先级不低,将栈顶符号弹出,然后此运算符进栈。
3)右括号:将栈顶符号弹出,直到匹配左括号。
遍历结束后,将栈中所有符号弹出。 (备注:左括号的优先级最低)
2.1.3 后缀表达式的计算
例如:8 3 1 – 5 * +
- 计算规则如下:
遍历后缀表达式中的数字和符号:
对于数字,进栈
对于符号:从栈中弹出右操作数,从栈中弹出左操作数,根据符号进行运算,将运算结果 入栈。
遍历结束:栈中的唯一数字为最终计算结果。
2.2 堆的应用
堆(Heap)是一种满足特定条件的完全二叉树,主要分为两种类型:
- 小顶堆(min heap):任意节点的值 <= 其子节点的值。
- 大顶堆(max heap):任意节点的值 >= 其子节点的值。
vector<int> input{1,2,3,4,5};
//大顶堆
priority_queue<int, vector<int>, less<int>>maxHeap(input.begin(),input.end());
//小顶堆
priority_queue<int, vector<int>, greater<int>>minHeap;
minHeap.push(1);
2.2.1 Top-k问题
基于堆更加高效地解决 Top-k 问题,流程如下所示。
- 初始化一个小顶堆,其堆顶元素最小。
- 先将数组的前 k 个元素依次入堆。
- 从第 k+1 个元素开始,若当前元素大于堆顶元素,则将堆顶元素出堆,并将当前元素入堆。
- 遍历完成后,堆中保存的就是最大的 k个元素。
/* 基于堆查找数组中最大的 k 个元素 */
priority_queue<int, vector<int>, greater<int>> topKHeap(vector<int> &nums, int k) {
// 初始化小顶堆
priority_queue<int, vector<int>, greater<int>> heap;
// 将数组的前 k 个元素入堆
for (int i = 0; i < k; i++) {
heap.push(nums[i]);
}
// 从第 k+1 个元素开始,保持堆的长度为 k
for (int i = k; i < nums.size(); i++) {
// 若当前元素大于堆顶元素,则将堆顶元素出堆、当前元素入堆
if (nums[i] > heap.top()) {
heap.pop();
heap.push(nums[i]);
}
}
return heap;
}
2.3 搜索
2.3.1 二分查找法(binary search)
(时间复杂度O(),空间复杂度O(1))
/* 二分查找(双闭区间) */
int binarySearch(vector<int> &nums, int target) {
// 初始化双闭区间 [0, n-1] ,即 i, j 分别指向数组首元素、尾元素
int i = 0, j = nums.size() - 1;
// 循环,当搜索区间为空时跳出(当 i > j 时为空)
while (i <= j) {
int m = i + (j - i) / 2; // 计算中点索引 m
if (nums[m] < target) // 此情况说明 target 在区间 [m+1, j] 中
i = m + 1;
else if (nums[m] > target) // 此情况说明 target 在区间 [i, m-1] 中
j = m - 1;
else // 找到目标元素,返回其索引
return m;
}
// 未找到目标元素,返回 -1
return -1;
}
2.3.2 哈希优化策略
通过将线性查找替换为哈希查找来降低算法的时间复杂度。
Q: 给定一个整数数组 nums
和一个目标元素 target
,请在数组中搜索“和”为 target
的两个元素,并返回它们的数组索引。返回任意一个解即可。
A1: 线性查找:以时间换空间(时间复杂度O(),空间复杂度O(1))
/* 方法一:暴力枚举 */
vector<int> twoSumBruteForce(vector<int> &nums, int target) {
int size = nums.size();
// 两层循环,时间复杂度为 O(n^2)
for (int i = 0; i < size - 1; i++) {
for (int j = i + 1; j < size; j++) {
if (nums[i] + nums[j] == target)
return {i, j};
}
}
return {};
}
A2:哈希查找:以空间换时间(时间复杂度O(),空间复杂度O(
))
/* 方法二:辅助哈希表 */
vector<int> twoSumHashTable(vector<int> &nums, int target) {
int size = nums.size();
// 辅助哈希表,空间复杂度为 O(n)
unordered_map<int, int> dic;
// 单层循环,时间复杂度为 O(n)
for (int i = 0; i < size; i++) {
if (dic.find(target - nums[i]) != dic.end()) {
return {dic[target - nums[i]], i};
}
dic.emplace(nums[i], i);
}
return {};
}
2.4 排序
2.4.1 选择排序(selection sort)
工作原理:开启一个循环,每轮从未排序区间选择最小的元素,将其放到已排序区间的末尾。 (时间复杂度O(),空间复杂度O(1))
/* 选择排序 */
void selectionSort(vector<int> &nums) {
int n = nums.size();
// 外循环:未排序区间为 [i, n-1]
for (int i = 0; i < n - 1; i++) {
// 内循环:找到未排序区间内的最小元素
int k = i;
for (int j = i + 1; j < n; j++) {
if (nums[j] < nums[k])
k = j; // 记录最小元素的索引
}
// 将该最小元素与未排序区间的首个元素交换
swap(nums[i], nums[k]);
}
}
2.4.2 冒泡排序(bubble sort)
通过连续地比较与交换相邻元素实现排序。冒泡过程可以利用元素交换操作来模拟:从数组最左端开始向右遍历,依次比较相邻元素大小,如果“左元素 > 右元素”就交换二者。遍历完成后,最大的元素会被移动到数组的最右端。 (时间复杂度O(),空间复杂度O(1))
/* 冒泡排序 */
void bubbleSort(vector<int> &nums) {
// 外循环:未排序区间为 [0, i]
for (int i = nums.size() - 1; i > 0; i--) {
// 内循环:将未排序区间 [0, i] 中的最大元素交换至该区间的最右端
for (int j = 0; j < i; j++) {
if (nums[j] > nums[j + 1]) {
swap(nums[j], nums[j + 1]);
}
}
}
}
2.4.3 插入排序(insertion sort)
在未排序区间选择一个基准元素,将该元素与其左侧已排序区间的元素逐一比较大小,并将该元素插入到正确的位置。 (时间复杂度O(),空间复杂度O(1))
/* 插入排序 */
void insertionSort(vector<int> &nums) {
// 外循环:已排序元素数量为 1, 2, ..., n
for (int i = 1; i < nums.size(); i++) {
int base = nums[i], j = i - 1;
// 内循环:将 base 插入到已排序部分的正确位置
while (j >= 0 && nums[j] > base) {
nums[j + 1] = nums[j]; // 将 nums[j] 向右移动一位
j--;
}
nums[j + 1] = base; // 将 base 赋值到正确位置
}
}
2.4.4 快速排序(quick sort)
基于分治策略,快速排序的核心操作是“哨兵划分”,其目标是:选择数组中的某个元素作为“基准数”,将所有小于基准数的元素移到其左侧,而大于基准数的元素移到其右侧。
(时间复杂度O(),空间复杂度O(
))
/* 快速排序 */
void quickSort(vector<int> &nums, int left, int right) {
// 子数组长度为 1 时终止递归
if (left >= right)
return;
// 哨兵划分
int pivot = partition(nums, left, right);
// 递归左子数组、右子数组
quickSort(nums, left, pivot - 1);
quickSort(nums, pivot + 1, right);
}
/* 哨兵划分 */
int partition(vector<int> &nums, int left, int right) {
// 以 nums[left] 为基准数
int i = left, j = right;
while (i < j) {
while (i < j && nums[j] >= nums[left])
j--; // 从右向左找首个小于基准数的元素
while (i < j && nums[i] <= nums[left])
i++; // 从左向右找首个大于基准数的元素
swap(nums[i], nums[j]); // 交换这两个元素
}
swap(nums[i], nums[left]); // 将基准数交换至两子数组的分界线
return i; // 返回基准数的索引
}
2.4.5 归并排序(merge sort)
基于分治策略,包含如下两个阶段:
- 划分阶段:通过递归不断地将数组从中点处分开,将长数组排序问题转换为短数组排序问题。
- 合并阶段:当子数组长度为 1 时终止划分,开始合并,持续地将左右两个较短的有序数组合并为一个较长的有序数组,直至结束。
(时间复杂度O(),空间复杂度O(
))
/* 归并排序 */
void mergeSort(vector<int> &nums, int left, int right) {
// 终止条件
if (left >= right)
return; // 当子数组长度为 1 时终止递归
// 划分阶段
int mid = (left + right) / 2; // 计算中点
mergeSort(nums, left, mid); // 递归左子数组
mergeSort(nums, mid + 1, right); // 递归右子数组
// 合并阶段
merge(nums, left, mid, right);
}
/* 合并左子数组和右子数组 */
void merge(vector<int> &nums, int left, int mid, int right) {
// 左子数组区间为 [left, mid], 右子数组区间为 [mid+1, right]
// 创建一个临时数组 tmp ,用于存放合并后的结果
vector<int> tmp(right - left + 1);
// 初始化左子数组和右子数组的起始索引
int i = left, j = mid + 1, k = 0;
// 当左右子数组都还有元素时,进行比较并将较小的元素复制到临时数组中
while (i <= mid && j <= right) {
if (nums[i] <= nums[j])
tmp[k++] = nums[i++];
else
tmp[k++] = nums[j++];
}
// 将左子数组和右子数组的剩余元素复制到临时数组中
while (i <= mid) {
tmp[k++] = nums[i++];
}
while (j <= right) {
tmp[k++] = nums[j++];
}
// 将临时数组 tmp 中的元素复制回原数组 nums 的对应区间
for (k = 0; k < tmp.size(); k++) {
nums[left + k] = tmp[k];
}
}
2.4.6 堆排序(heap sort)
基于堆数据结构实现。(priority_queue<int, vector<int>, greater<int>>minHeap)
可以利用“建堆操作”和“元素出堆操作”实现堆排序。
- 输入数组并建立小顶堆,此时最小元素位于堆顶。
- 不断执行出堆操作,依次记录出堆元素,即可得到从小到大排序的序列。
以上方法虽然可行,但需要借助一个额外数组来保存弹出的元素,比较浪费空间。在实际中,我们通常使用一种更加优雅的实现方式。
2.4.7 桶排序(bucket sort)
基于分治策略,通过设置一些具有大小顺序的桶,每个桶对应一个数据范围,将数据平均分配到各个桶中;然后,在每个桶内部分别执行排序;最终按照桶的顺序将所有数据合并。
考虑一个长度为 的数组,其元素是范围 [0,1) 内的浮点数。桶排序的流程如下所示:
- 初始化
个桶,将
个元素分配到
个桶中。
- 对每个桶分别执行排序(采用内置排序函数sort)。
- 按照桶从小到大的顺序合并结果。
(时间复杂度O(),空间复杂度O(
))
/* 桶排序 */
void bucketSort(vector<float> &nums) {
// 初始化 k = n/2 个桶,预期向每个桶分配 2 个元素
int k = nums.size() / 2;
vector<vector<float>> buckets(k);
// 1. 将数组元素分配到各个桶中
for (float num : nums) {
// 输入数据范围为 [0, 1),使用 num * k 映射到索引范围 [0, k-1]
int i = num * k;
// 将 num 添加进桶 bucket_idx
buckets[i].push_back(num);
}
// 2. 对各个桶执行排序
for (vector<float> &bucket : buckets) {
// 使用内置排序函数,也可以替换成其他排序算法
sort(bucket.begin(), bucket.end());
}
// 3. 遍历桶合并结果
int i = 0;
for (vector<float> &bucket : buckets) {
for (float num : bucket) {
nums[i++] = num;
}
}
}
2.4.8 计数排序(counting sort)
2.4.9 基数排序(radix sort)
