《数据结构与算法核心精要:从基础到实战应用》
<|begin▁of▁sentence|># 1. 什么是数据结构
数据结构是计算机存储、组织数据的方式。数据结构是指相互之间存在一种或多种特定关系的数据元素的集合。通常情况下,精心选择的数据结构可以带来更高的运行或者存储效率。数据结构往往同高效的检索算法和索引技术有关。
# 2. 什么是算法
算法(Algorithm)是指解题方案的准确而完整的描述,是一系列解决问题的清晰指令,算法代表着用系统的方法描述解决问题的策略机制。也就是说,能够对一定规范的输入,在有限时间内获得所要求的输出。如果一个算法有缺陷,或不适合于某个问题,执行这个算法将不会解决这个问题。不同的算法可能用不同的时间、空间或效率来完成同样的任务。一个算法的优劣可以用空间复杂度与时间复杂度来衡量。
# 3. 数据结构和算法的关系
- 数据结构是为算法服务的,算法要作用在特定的数据结构之上。
- 数据结构是静态的,它只是组织数据的一种方式。如果不在它的基础上操作、构建算法,孤立存在的数据结构就是没用的。
# 4. 学习重点
- 复杂度分析
- 最常用、最基础的数据结构:数组、链表、栈、队列、散列表、二叉树、堆、跳表、图、Trie树
- 最常用、最基础的算法:递归、排序、二分查找、搜索、哈希算法、贪心算法、分治算法、回溯算法、动态规划、字符串匹配算法
# 5. 复杂度分析
## 5.1. 大O复杂度表示法
大O时间复杂度实际上并不具体表示代码真正的执行时间,而是表示代码执行时间随数据规模增长的变化趋势,所以,也叫作渐进时间复杂度(asymptotic time complexity),简称时间复杂度。
## 5.2. 时间复杂度分析
- 只关注循环执行次数最多的一段代码
- 加法法则:总复杂度等于量级最大的那段代码的复杂度
- 乘法法则:嵌套代码的复杂度等于嵌套内外代码复杂度的乘积
## 5.3. 常见时间复杂度实例分析
- 常量阶 O(1)
- 对数阶 O(logn)
- 线性阶 O(n)
- 线性对数阶 O(nlogn)
- 平方阶 O(n^2)、立方阶 O(n^3) ... k次方阶 O(n^k)
- 指数阶 O(2^n)
- 阶乘阶 O(n!)
## 5.4. 空间复杂度分析
空间复杂度全称就是渐进空间复杂度(asymptotic space complexity),表示算法的存储空间与数据规模之间的增长关系。
# 6. 数组
数组(Array)是一种线性表数据结构。它用一组连续的内存空间,来存储一组具有相同类型的数据。
## 6.1. 线性表
线性表就是数据排成像一条线一样的结构。每个线性表上的数据最多只有前和后两个方向。其实除了数组,链表、队列、栈等也是线性表结构。
## 6.2. 非线性表
比如二叉树、堆、图等。之所以叫非线性,是因为,在非线性表中,数据之间并不是简单的前后关系。
## 6.3. 数组的优缺点
- 优点:支持随机访问,根据下标随机访问的时间复杂度为O(1)
- 缺点:插入、删除操作效率低,平均情况时间复杂度为O(n)
## 6.4. 数组的插入操作
- 如果数组是有序的,那么插入操作需要移动元素,平均情况时间复杂度为O(n)
- 如果数组是无序的,那么插入操作可以直接将新元素放到数组的末尾,时间复杂度为O(1)
## 6.5. 数组的删除操作
- 删除操作需要移动元素,平均情况时间复杂度为O(n)
- 在某些特殊情况下,我们并不一定非得追求数组中数据的连续性。我们可以将多次删除操作集中在一起执行,这样可以提高删除的效率。
## 6.6. 数组的访问越界问题
在C语言中,数组的访问越界是一种未定义行为,可能会导致程序崩溃。在Java中,数组的访问越界会抛出ArrayIndexOutOfBoundsException异常。
## 6.7. 容器能否完全替代数组?
- 容器类(如Java中的ArrayList)的优势:支持动态扩容,提供了很多便利的API
- 数组的优势:性能更好,支持基本数据类型,内存使用更高效
- 选择:如果数据大小事先已知,并且对数据的操作非常简单,用不到ArrayList提供的大部分方法,也可以直接使用数组。或者表示多维数组时,用数组往往会更加直观。对于业务开发,直接使用容器就足够了,省时省力。毕竟损耗一丢丢性能,完全不会影响到系统整体的性能。但如果你是做一些非常底层的开发,比如开发网络框架,性能的优化需要做到极致,这个时候数组就会优于容器,成为首选。
# 7. 链表
链表通过指针将一组零散的内存块串联起来使用。
## 7.1. 单链表
- 每个节点除了存储数据之外,还需要记录链上的下一个节点的地址。
- 头节点:链表的第一个节点,用来记录链表的基地址。
- 尾节点:链表的最后一个节点,指向一个空地址NULL。
## 7.2. 循环链表
循环链表是一种特殊的单链表,它的尾节点指向头节点,形成一个环。
## 7.3. 双向链表
双向链表支持两个方向,每个节点有一个后继指针next指向后面的节点,还有一个前驱指针prev指向前面的节点。
## 7.4. 双向循环链表
双向循环链表是循环链表和双向链表的结合体。
## 7.5. 链表的优缺点
- 优点:插入、删除操作效率高,时间复杂度为O(1)
- 缺点:随机访问效率低,时间复杂度为O(n)
## 7.6. 链表 vs 数组
- 数组简单易用,在实现上使用的是连续的内存空间,可以借助CPU的缓存机制,预读数组中的数据,所以访问效率更高。而链表在内存中并不是连续存储,所以对CPU缓存不友好,没办法有效预读。
- 数组的缺点是大小固定,一经声明就要占用整块连续内存空间。如果声明的数组过大,系统可能没有足够的连续内存空间分配给它,导致“内存不足(out of memory)”。如果声明的数组过小,则可能出现不够用的情况。这时只能再申请一个更大的内存空间,把原数组拷贝进去,非常费时。链表本身没有大小的限制,天然地支持动态扩容,我觉得这也是它与数组最大的区别。
## 7.7. 如何基于链表实现LRU缓存淘汰算法?
- 思路:我们维护一个有序单链表,越靠近链表尾部的节点是越早之前访问的。当有一个新的数据被访问时,我们从链表头开始顺序遍历链表。
- 如果此数据之前已经被缓存在链表中了,我们遍历得到这个数据对应的节点,并将其从原来的位置删除,然后再插入到链表的头部。
- 如果此数据没有在缓存链表中,又可以分为两种情况:
- 如果此时缓存未满,则将此节点直接插入到链表的头部;
- 如果此时缓存已满,则链表尾节点删除,将新的数据节点插入链表的头部。
## 7.8. 如何轻松写出正确的链表代码?
- 理解指针或引用的含义
- 警惕指针丢失和内存泄漏
- 利用哨兵简化实现难度
- 重点留意边界条件处理
- 举例画图,辅助思考
- 多写多练,没有捷径
# 8. 栈
栈是一种“操作受限”的线性表,只允许在一端插入和删除数据。
## 8.1. 栈的实现
- 顺序栈:用数组实现的栈
- 链式栈:用链表实现的栈
## 8.2. 栈的应用
- 函数调用栈
- 表达式求值
- 括号匹配
- 浏览器前进后退功能
## 8.3. 栈的复杂度分析
- 入栈、出栈操作的时间复杂度都是O(1)
- 空间复杂度是O(n)
# 9. 队列
队列是一种“操作受限”的线性表,只允许在一端插入数据,在另一端删除数据。
## 9.1. 队列的实现
- 顺序队列:用数组实现的队列
- 链式队列:用链表实现的队列
## 9.2. 循环队列
循环队列可以解决顺序队列的“假溢出”问题。
## 9.3. 阻塞队列和并发队列
- 阻塞队列:在队列为空的时候,从队头取数据会被阻塞。如果队列已经满了,那么插入数据的操作就会被阻塞,直到队列中有空闲位置后再插入数据。
- 并发队列:线程安全的队列。
## 9.4. 队列的应用
- 线程池
- 消息队列
- 广度优先搜索
## 9.5. 队列的复杂度分析
- 入队、出队操作的时间复杂度都是O(1)
- 空间复杂度是O(n)
# 10. 递归
递归是一种应用非常广泛的算法(或者编程技巧)。很多数据结构和算法的编码实现都要用到递归,比如DFS深度优先搜索、前中后序二叉树遍历等等。
## 10.1. 递归需要满足的三个条件
- 一个问题的解可以分解为几个子问题的解
- 这个问题与分解之后的子问题,除了数据规模不同,求解思路完全一样
- 存在递归终止条件
## 10.2. 如何编写递归代码?
写递归代码的关键就是找到如何将大问题分解为小问题的规律,并且基于此写出递推公式,然后再推敲终止条件,最后将递推公式和终止条件翻译成代码。
## 10.3. 递归代码要警惕堆栈溢出
可以通过限制递归深度来解决。
## 10.4. 递归代码要警惕重复计算
可以通过散列表来保存已经求解过的值。
## 10.5. 怎么将递归代码改写为非递归代码?
递归本身就是借助栈来实现的,所以我们可以用栈来模拟递归的过程。
## 10.6. 递归的优缺点
- 优点:代码简洁、清晰
- 缺点:空间复杂度高、有堆栈溢出的风险、存在重复计算、过多的函数调用会耗时较多
# 11. 排序
排序算法有很多种,最常用的有:冒泡排序、插入排序、选择排序、归并排序、快速排序、计数排序、基数排序、桶排序。
## 11.1. 排序算法的执行效率
- 最好情况、最坏情况、平均情况时间复杂度
- 时间复杂度的系数、常数 、低阶
- 比较次数和交换(或移动)次数
## 11.2. 排序算法的内存消耗
- 原地排序(In-place):特指空间复杂度是O(1)的排序算法。
- 非原地排序:空间复杂度不是O(1)的排序算法。
## 11.3. 排序算法的稳定性
- 稳定性:如果待排序的序列中存在值相等的元素,经过排序之后,相等元素之间原有的先后顺序不变。
- 稳定排序算法:冒泡排序、插入排序、归并排序、计数排序、基数排序、桶排序。
- 不稳定排序算法:选择排序、快速排序、堆排序。
## 11.4. 冒泡排序(Bubble Sort)
- 思路:每次冒泡操作都会对相邻的两个元素进行比较,看是否满足大小关系要求。如果不满足就让它俩互换。一次冒泡会让至少一个元素移动到它应该在的位置,重复n次,就完成了n个数据的排序工作。
- 优化:当某次冒泡操作已经没有数据交换时,说明已经达到完全有序,不用再继续执行后续的冒泡操作。
- 代码实现:
```java
public void bubbleSort(int[] a, int n) {
if (n <= 1) return;
for (int i = 0; i < n; ++i) {
// 提前退出冒泡循环的标志位
boolean flag = false;
for (int j = 0; j < n - i - 1; ++j) {
if (a[j] > a[j+1]) { // 交换
int tmp = a[j];
a[j] = a[j+1];
a[j+1] = tmp;
flag = true; // 表示有数据交换
}
}
if (!flag) break; // 没有数据交换,提前退出
}
}
```
- 分析:
- 原地排序:是
- 稳定排序:是
- 时间复杂度:最好O(n),最坏O(n^2),平均O(n^2)
## 11.5. 插入排序(Insertion Sort)
- 思路:我们将数组中的数据分为两个区间,已排序区间和未排序区间。初始已排序区间只有一个元素,就是数组的第一个元素。插入算法的核心思想是取未排序区间中的元素,在已排序区间中找到合适的插入位置将其插入,并保证已排序区间数据一直有序。重复这个过程,直到未排序区间中元素为空,算法结束。
- 代码实现:
```java
public void insertionSort(int[] a, int n) {
if (n <= 1) return;
for (int i = 1; i < n; ++i) {
int value = a[i];
int j = i - 1;
// 查找插入的位置
for (; j >= 0; --j) {
if (a[j] > value) {
a[j+1] = a[j]; // 数据移动
} else {
break;
}
}
a[j+1] = value; // 插入数据
}
}
```
- 分析:
- 原地排序:是
- 稳定排序:是
- 时间复杂度:最好O(n),最坏O(n^2),平均O(n^2)
## 11.6. 选择排序(Selection Sort)
- 思路:选择排序算法的实现思路有点类似插入排序,也分已排序区间和未排序区间。但是选择排序每次会从未排序区间中找到最小的元素,将其放到已排序区间的末尾。
- 代码实现:
```java
public void selectionSort(int[] a, int n) {
if (n <= 1) return;
for (int i = 0; i < n - 1; ++i) {
// 查找最小值
int minIndex = i;
for (int j = i + 1; j < n; ++j) {
if (a[j] < a[minIndex]) {
minIndex = j;
}
}
// 交换
int tmp = a[i];
a[i] = a[minIndex];
a[minIndex] = tmp;
}
}
```
- 分析:
- 原地排序:是
- 稳定排序:否
- 时间复杂度:最好O(n^2),最坏O(n^2),平均O(n^2)
## 11.7. 归并排序(Merge Sort)
- 思路:归并排序的核心思想还是蛮简单的。如果要排序一个数组,我们先把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组就都有序了。
- 代码实现:
```java
public void mergeSort(int[] a, int n) {
mergeSortInternally(a, 0, n-1);
}
private void mergeSortInternally(int[] a, int p, int r) {
if (p >= r) return;
int q = p + (r - p)/2;
// 分治递归
mergeSortInternally(a, p, q);
mergeSortInternally(a, q+1, r);
// 将A[p...q]和A[q+1...r]合并为A[p...r]
merge(a, p, q, r);
}
private void merge(int[] a, int p, int q, int r) {
int i = p;
int j = q+1;
int k = 0; // 初始化变量i, j, k
int[] tmp = new int[r-p+1]; // 申请一个大小跟a[p...r]一样的临时数组
while (i<=q && j<=r) {
if (a[i] <= a[j]) {
tmp[k++] = a[i++];
} else {
tmp[k++] = a[j++];
}
}
// 判断哪个子数组中有剩余的数据
int start = i;
int end = q;
if (j <= r) {
start = j;
end = r;
}
// 将剩余的数据拷贝到临时数组tmp
while (start <= end) {
tmp[k++] = a[start++];
}
// 将tmp中的数组拷贝回a[p...r]
for (i = 0; i <= r-p; ++i) {
a[p+i] = tmp[i];
}
}
```
- 分析:
- 原地排序:否
- 稳定排序:是
- 时间复杂度:最好O(nlogn),最坏O(nlogn),平均O(nlogn)
- 空间复杂度:O(n)
## 11.8. 快速排序(Quick Sort)
- 思路:快排的思想是这样的:如果要排序数组中下标从p到r之间的一组数据,我们选择p到r之间的任意一个数据作为pivot(分区点)。我们遍历p到r之间的数据,将小于pivot的放到左边,将大于pivot的放到右边,将pivot放到中间。经过这一步骤之后,数组p到r之间的数据就被分成了三个部分,前面p到q-1之间都是小于pivot的,中间是pivot,后面的q+1到r之间是大于pivot的。根据分治、递归的处理思想,我们可以用递归排序下标从p到q-1之间的数据和下标从q+1到r之间的数据,直到区间缩小为1,就说明所有的数据都有序了。
- 代码实现:
```java
public void quickSort(int[] a, int n) {
quickSortInternally(a, 0, n-1);
}
private void quickSortInternally(int[] a, int p, int r) {
if (p >= r) return;
int q = partition(a, p, r); // 获取分区点
quickSortInternally(a, p, q-1);
quickSortInternally(a, q+1, r);
}
private int partition(int[] a, int p, int r) {
int pivot = a[r];
int i = p;
for(int j = p; j < r; ++j) {
if (a[j] < pivot) {
if (i == j) {
++i;
} else {
int tmp = a[i];
a[i
最新文章
- 行车安全必知:如何正确保持安全车距与制动距离
- 动力电池与电驱动系统引领新能源汽车核心技术突破
- 液冷技术革新汽车散热:效率提升40%,电池寿命延长30%
- 新能源与智能网联技术引领未来出行革命
- 汽车发动机号码全解析:位置、作用与常见问题
- 汽车散热系统高效冷却引擎确保稳定运行
- 定期检查机油更换轮胎保养发动机
- 发动机高效运转汽车性能卓越
- 毫米波雷达:智能汽车的“透视眼”与安全守护神
- 800V高压平台:电动汽车技术升级的三大核心优势与选购指南
- 电动汽车革命:固态电池与高压快充技术引领未来出行
- 电动汽车三大核心技术:电池、电机、电控发展趋势解析
- ESP与AEB:汽车安全双保险的工作原理与性能对比
- 充电桩建设提速:2025年车桩比1:1目标下的新能源发展新机遇
- 车况检测保障行车安全
- 激光雷达:智能汽车自动驾驶的'火眼金睛'技术解析
- 汽车蓄电池极板硫化:原因、危害及预防处理全解析
- 轮胎磨损类型解析:从胎面厚度到四轮定位的全面指南
- 汽车防盗报警器实时监控安全防护
- 电动汽车革命:固态电池与800V快充引领未来出行
