21 KiB
数据结构原理及分析考点
线性数据结构
数组
-
数组是一种线性数据结构,可以容纳固定数量的元素。
-
数组中的每个元素都有固定的索引,这是访问数组元素的一种有效方式。
-
数组的大小在创建时确定,不能动态扩展或缩小。
-
数组在内存中是连续的,这使得某些操作(如随机访问)非常快速。
-
但是,如果需要动态调整大小,可能需要创建新的数组并复制数据,这是不高效的。
链表
- 链表是一种线性数据结构,可以动态添加或删除元素。
- 每个元素包含数据和一个指向下一个元素的指针。
- 链表不需要连续的内存空间,因此可以高效地进行插入和删除操作。
- 但是,由于需要额外存储指针信息,因此链表的存储空间利用率可能较低。
- 访问链表中的元素通常需要从头开始遍历,因此速度较慢。
(1)单链表
单链表单向链表只有一个方向,结点只有一个后继指针next指向后面的节点。我们习惯性地把第一个结点叫作头结点,链表通常有一个不保存任何值的head节点(头结点),通过头结点我们可以遍历整个链表。尾结点通常指向null。
(2)循环链表
循环链表其实是一种特殊的单链表,和单链表不同的是循环链表的尾结点不是指向null,而是指向链表的头结点。
(3)双向链表
双向链表包含两个指针,一个prev指向前一个节点,一个next指向后一个节点。
(4)双向循环链表
双向循环链表最后一个节点的next指向head,而head的prev指向最后一个节点,构成一个环。
队列
- 队列是一种先进先出(FIFO)的数据结构,通常用于管理需要按特定顺序处理的任务。
- 队列中的元素只能从一端(队尾)添加,从另一端(队首)删除。
- 队列的主要操作是入队(添加元素)和出队(删除元素),以及检查队列是否为空或已满。
- 队列通常在内部维护一个双向链表实现其操作。
队列是先进先出的线性表。在具体应用中通常用链表或者数组来实现,用数组实现的队列叫作顺序队列,用链表实现的队列叫作链式队列。队列只允许在后端(rear)进行插入操作也就是入队enqueue,在前端(front)进行删除操作也就是出队dequeue。
栈
- 栈是一种后进先出(LIFO)的数据结构,通常用于跟踪正在进行的任务或操作的层次结构。
- 栈中的元素只能从顶部添加和删除。
- 栈的主要操作是push(添加元素)和pop(删除元素),以及检查栈是否为空或已满。
- 栈也可以用数组或内部维护的链表来实现。
栈按照后进先出的原理运作。在栈中,push和pop的操作都发生在栈顶。 用数组实现的栈叫作顺序栈,用链表实现的栈叫作链式栈。
非线性数据结构
二维数组
在一维数组中,每个元素也是一个数组元素,这样的数组称为二维数组。也可以理解为,二维数组是一个特殊的一维数组,这个一维数组的每个元素都是一维数组
多维数组
多维数组:多维数组是指超过二维的数组,通常在数学和计算机科学中用于表示高维空间数据和算法
二叉树
图
堆
堆(heap)就是用数组实现的二叉树,它没有使用父指针或者子指针。
堆根据” 堆属性 “来排序,该属性决定了树中节点的位置。
堆的应用:
- 构建优先队列
- 堆排序
- 快速找到集合中的极值
什么是堆属性?
堆的定义是:一个数据集合 k={k0,k1,k2,...,kn},把它的所有元素按照完全二叉树的顺序储存方式存储在一个一维数组中,且满足下面的性质:
- 每个节点的值总是不大于或者不小于其父节点的值
- 是一棵完全二叉树
有两种情况:
- 父节点都不小于子节点的值,称为最大堆
- 父节点都不大于子节点的值,称为最小堆
注意,还需要是一棵完全二叉树,即除了最后一层节点的度不为 2,其余每个节点的度都是 2,如果最后一层的节点度不是 2,那么要求左满右不满。
下面举个例子,哪些是最大堆,哪些是最小堆。
也就是最大堆从上往下数值减小,最小堆从上往下是数值变大,但同一层的节点的数值没有固定顺序。
这个要与二叉搜索树区别开,二叉搜索树中,左节点必须小于父节点,右节点必须大于父节点。
根据最大堆和最小堆的属性,可以快速访问到最值。
如何理解:用数组实现的树
在堆的定义中,说到” 所有元素按照完全二叉树的顺序储存方式存放在一个一维数组中 “,这里的顺序存储是什么意思?
顺序存储在物理上就是一个数组,但在逻辑上,我们理解为一颗二叉树,准确地说,应该是完全二叉树。
我们前面说到,堆可以表示为完全二叉树,这是相当节省空间的,为什么不表示为普通二叉树呢?
上图说明,如果将数组表示为非完全二叉树,则顺序储存时会造成空间的浪费,有些地址没有值。这就是为什么堆需要完全二叉树。此外,完全二叉树表示为数组时,父节点总是在子节点前面。
所以,堆总是具有这样的形状:
在树中的每个节点都满足堆属性。
广义表
广义表也称列表(lists)是线性表的扩展,是一种可以嵌套的数据结构。广义表中的数据元素不仅有元素或者节点的名字,而且有元素或者节点的值,并且广义表可以共享,也就是说一个广义表可以被其他的广义表共享12。
广义表的定义和性质如下:
- 广义表的长度定义为最外层包含元素个数。
- 广义表的深度定义为该广义表展开后所含括号的重数。其中原子的深度为0,空表的深度为1。
- 广义表可以为其他表共享,被共享表的值可以不必列出,而是通过名称引用。
- 广义表可以是一个递归的表。递归表的深度是无穷的值,长度是有限值。
广义表(Lists,又称列表)是线性表的推广。广义表是n(n≥0)个元素a1,a2,a3,…,an的有限序列,其中ai或者是原子项,或者是一个广义表。若广义表LS(n>=1)非空,则a1是LS的表头,其余元素组成的表(a2,…an)称为LS的表尾。广义表的元素可以是广义表,也可以是原子,广义表的元素也可以为空。表尾是指除去表头后剩下的元素组成的表,表头可以为表或单元素值。所以表尾不可以是单个元素值。
例子:
A=()——A是一个空表,其长度为零。
B=(e)——表B只有一个原子e,B的长度为1。
C=(a,(b,c,d))——表C的长度为2,两个元素分别为原子a和子表(b,c,d)。
D=(A,B,C)——表D的长度为3,三个元素都是广义 表。显然,将子表的值代入后,则有D=(( ),(e),(a,(b,c,d)))。
E=(a,E)——这是一个递归的表,它的长度为2,E相当于一个无限的广义表E=(a,(a,(a,(a,…)))).
三个结论:
1.广义表的元素可以是子表,而子表的元素还可以是子表。由此,广义表是一个多层次的结构,可以用图形象地表示
2.广义表可为其它表所共享。例如在上述例4中,广义表A,B,C为D的子表,则在D中可以不必列出子表的值,而是通过子表的名称来引用。
3.广义表的递归性
考点:
1.广义表是0个或多个单因素或子表组成的有限序列,广义表可以是自身的子表,广义表的长度n>=0,所以可以为空表。广义表的同级元素(直属于同一个表中的各元素)具有线性关系
2.广义表的表头为空,并不代表该广义表为空表。广义表()和(())不同。前者是长度为0的空表,对其不能做求表头和表尾的运算;而后者是长度为l的非空表(只不过该表中惟一的一个元素是空表),对其可进行分解,得到的表头和表尾均是空表()
3.已知广义表LS=((a,b,c),(d,e,f)),运用head和tail函数取出LS中原子e的运算是head(tail(head(tail(LS)))。根据表头、表尾的定义可知:任何一个非空广义表的表头是表中第一个元素,它可以是原子,也可以是子表,而其表尾必定是子表。也就是说,广义表的head操作,取出的元素是什么,那么结果就是什么。但是tail操作取出的元素外必须加一个表——“()“。tail(LS)=((d,e,f));head(tail(LS))=(d,e,f);tail(head(tail(LS)))=(e,f);head(tail(head(tail(LS))))=e。
4.二维以上的数组其实是一种特殊的广义表
5.在(非空)广义表中:1、表头head可以是原子或者一个表 2、表尾tail一定是一个表 3.广义表难以用顺序存储结构 4.广义表可以是一个多层次的结构
排序
概念:
- 所谓排序,就是把一堆杂乱的数据,排成升序或降序 (递增 / 增减)。
- 排序稳定性: 假设一组数据 [1,2,9,5,5,6,8],进行升序排序后,两个 5 的相应位置不发生改变,即称为稳定的排序,否则就是不稳定排序。
- 内部排序: 数据元素全部放在内存中进行排序。
- 外部排序: 即将待排序的记录存储在外存中,排序时再把数据一部分一部分地调入内存进行排序,在排序过程中需要多次进行内存和外存之间地交换。
插入排序
思路:
- 默认为第一个元素自己是有序的,从第二个元素开始。
- 取出第二个元素 tmp,往前进行比较。
- 若该元素比 tmp 大,则将该元素往后移一位,直到找到比 tmp 小的。
- 找到比 tmp 小于等于的元素后,tmp 插入到该元素的下一位。
- 循环 2~4 步骤。
步骤具体实现:
- 定义下标 i ,遍历数组。默认 i 下标已经是有序的,把 i 下标元素存入 tmp。
- 定义 j,j 从 i-1 的位置,开始向前遍历,遇到比 tmp 大的元素,就把此时 j 下标的元素往后移一位,直到下标等于或小于 0 停止。
- j 下标元素小于 tmp,则把 tmp 元素插入该 j 下标的下一个位置。
时间复杂度: O(N^2);
空间复杂度: O(1);
稳定性: 稳定;
具体代码实现:
public static void insertSort(int[] array){
//1.遍历数组
for (int i = 0; i < array.length; i++) {
int tmp = array[i];
//2.往前遍历,进行插入
int j = i-1;
for ( ;j >=0 ; j--) {
if(array[j] > tmp){
int exchange = array[j];
array[j+1] = array[j];
array[j] = exchange;
}else{
//没有比tmp小的,退出循环
break;
}
}
//3.此时j下标元素比tmp小,tmp插入j下标的下一个位置
array[j+1] = tmp;
}
}
结论:
- 当数据趋于有序时,排序时间越快,最好的情况下时间复杂度为 O(N);
- 当把循环中
array[j] > tmp
的大于号改为大于等于,此时就不是稳定的排序了。
希尔排序
思路:
- 先将待排序列进行预排序,使得待排序列接近有序,此时进行插入排序。
- 把待排序的数据分为多个组,每组间隔为 5 或 3…。
- 若此组的第一个元素大于最后一个元素,将此组第一个元素和最后一个元素交换。
- 重复上述操作,直到每组间隔只有 1 时,所有数据都在统一组内进行排好序。
步骤具体实现:
- 定义 gap = 数组长度 \ 2。
- 把待排序列分为 gap 个组,每个组的第一个元素和最后一个元素进行比较交换。
- 重复上述操作
- 当 gap 为 1 时,进行插入排序。
时间复杂度: O(N^1.3);
空间复杂度: O(1);
稳定性: 不稳定。
具体代码实现:
public static void shell(int[] array){
int gap = array.length;
while(gap > 1){
gap /= 2;//分组
//1.每组头和尾进行比较交换
for (int i = 0; i < array.length; i++) {
int tmp = array[i];
//2.往前遍历,一次步长为gap
int j = i-gap;
for ( ;j >=0 ; j-=gap) {
if(array[j] > tmp){
int exchange = array[j];
array[j+gap] = array[j];
array[j] = exchange;
}else{
break;
}
}
array[j+gap] = tmp;
}
}
}
结论:
- 希尔排序是对插入排序的优化。
- 当 gap>1 时,都是预排序,目的是为了让数组更趋于有序,当 gap==1 时,数组已经接近有序,这样进行插入排序就会很快。
- 希尔排序的时间复杂度不容易计算,因为 gap 的取值方法很多,导致很难计算,因此我们按照 Knuth 提出的时间复杂度 O(N^1.3) 来算。
选择排序
思路:
每次从待排序列中选择一个最小值 (最大), 存放在序列的起始位置,直到全部待排序的数据排完。
步骤具体实现:
- 定义
i
,假设待排序列i
下标元素是最小值,用 min 记录当前i
下标。 - 定义 j,从
i
下一个位置开始往后遍历,遇到小于array[min]
时更新 min,min 指向每次遍历的最小值下标,直到遍历完一次数组。 - 一次遍历完后
array[i]
和 min 下标进行交换。 - 重复上述操作,直到待排序数据剩余 1 个元素。
时间复杂度: O(N^2);
空间复杂度: O(1);
稳定性: 不稳定;
具体代码实现:
public static void selectSort(int[] array){
for (int i = 0; i < array.length; i++) {
int min = i;
for (int j = i+1; j < array.length; j++) {
if(array[j] < array[min]){
//更新min的值
min = j;
}
}
if(min != i){
int tmp = array[i];
array[i] = array[min];
array[min] = tmp;
}
}
}
结论: 效率不是很好,实际中很少使用。
冒泡排序
思路: 根据序列中的两个记录键值比较结果来交换这两个记录在序列中的位置,交换排序的特点是:将键值较大的记录向序列的尾部移动,键值较小的向前部移动。
具体步骤实现:
- 定义
i
遍历数组,控制趟数,总体趟数比数组长度少 1; - 每趟让一个较大值移动到尾部。
- 定义
j
每次从 0 下标进行两两比较交换。
时间复杂度: O(N^2);
空间复杂度: O(1);
稳定性: 稳定;
具体代码实现:此处冒泡排序代码作了优化。
public static void bubbleSort(int[] array){
for (int i = 0; i < array.length-1; i++) {
boolean flag = false;//判断此趟排序有没有交换
for (int j = 0; j < array.length-1-i; j++) {
if (array[j] > array[j + 1]) {
int tmp = array[j];
array[j] = array[j + 1];
array[j + 1] = tmp;
flag = true;
}
}
//若flag一趟下来为false 说明数组已经有序
if(flag == false){
break;
}
}
}
快速排序
查找算法
线性查找
定义
线性查找「 Linear Search」是一种最基础的查找方法,其从数据结构的一端开始,依次访问每个元素,直到另一端后停止。线性查找实质上就是遍历数据结构 + 判断条件。比如,我们想要在数组nums
中查找目标元素target
的对应索引,那么可以在数组中进行线性查找。
代码实现
private static int sequenceSearch(int[] array,int target){
for(int i=0;i<array.length;i++){
if(target==array[i])
return i;
}
return -1;
}
复杂度分析
时间复杂度 O(n):其中 n 为数组或链表长度。
空间复杂度 O(1):无需使用额外空间。
二分查找
定义
二分查找 「Binary Search」利用数据的有序性,通过每轮缩小一半搜索区间来查找目标元素。
使用二分查找有两个前置条件:
- 要求输入数据是有序的,这样才能通过判断大小关系来排除一半的搜索区间;
- 二分查找仅适用于数组,而在链表中使用效率很低,因为其在循环中需要跳跃式(非连续地)访问元素。
代码实现
static int binarySearch1(int arr[],int len,int target){
/*初始化左右搜索边界*/
int left=0,right=len-1;
int mid;
while(left<=right){
/*中间位置:两边界元素之和/2向下取整*/
mid=(left+right)/2;
/*arr[mid]大于target,即要寻找的元素在左半边,所以需要设定右边界为mid-1,搜索左半边*/
if(target<arr[mid]){
right=mid-1;
/*arr[mid]小于target,即要寻找的元素在右半边,所以需要设定左边界为mid+1,搜索右半边*/
}else if(target>arr[mid]){
left=mid+1;
/*搜索到对应元素*/
}else if(target==arr[mid]){
return mid;
}
}
/*搜索不到返回-1*/
return -1;
}
复杂度分析
时间复杂度 O(logn):其中 n 为数组或链表长度;每轮排除一半的区间,因此循环轮数为 logn,使用 O(logn) 时间。
空间复杂度 O(1):指针i
,j
使用常数大小空间。
哈希查找
定义
「哈希查找 Hash Searching」借助一个哈希表来存储需要的「键值对 Key Value Pair」,我们可以在 O(1)时间下实现 “键→值” 映射查找,体现着 “以空间换时间” 的算法思想。
代码实现
public class HashSearch {
/*待查找序列*/
static int[] array = {13, 29, 27, 28, 26, 30, 38};
/* 初始化哈希表长度,此处哈希表容量设置的和array长度一样。
* 其实正常情况下,哈希表长度应该要长于array长度,因为使用
* 开放地址法时,可能会多使用一些空位置
*/
static int hashLength = 7;
static int[] hashTable = new int[hashLength];
public static void main(String[] args) {
/*将元素插入到哈希表中*/
for (int i = 0; i < array.length; i++) {
insertHashTable(hashTable, array[i]);
}
System.out.println("哈希表中的数据:");
printHashTable(hashTable);
int data = 28;
System.out.println("\n要查找的数据"+data);
int result = searchHashTable(hashTable, data);
if (result == -1) {
System.out.println("对不起,没有找到!");
} else {
System.out.println("在哈希表中的位置是:" + result);
}
}
/*将元素插入到哈希表中*/
public static void insertHashTable(int[] hashTable, int target) {
int hashAddress = hash(hashTable, target);
/*如果不为0,则说明发生冲突*/
while (hashTable[hashAddress] != 0) {
/*利用开放定址法解决冲突,即向后寻找新地址*/
hashAddress = (++hashAddress) % hashTable.length;
}
/*将元素插入到哈希表中*/
hashTable[hashAddress] = target;
}
public static int searchHashTable(int[] hashTable, int target) {
int hashAddress = hash(hashTable, target);
while (hashTable[hashAddress] != target) {
/*寻找原始地址后面的位置*/
hashAddress = (++hashAddress) % hashTable.length;
/*查找到开放单元(未存放元素的位置)或 循环回到原点,表示查找失败*/
if (hashTable[hashAddress] == 0 || hashAddress == hash(hashTable, target)) {
return -1;
}
}
return hashAddress;
}
/*用除留余数法计算要插入元素的地址*/
public static int hash(int[] hashTable, int data) {
return data % hashTable.length;
}
public static void printHashTable(int[] hashTable) {
for(int i=0;i<hashTable.length;i++)
System.out.print(hashTable[i]+" ");
}
}
复杂度分析
时间复杂度 O(1):哈希表的查找操作使用 O(1) 时间。
空间复杂度 O(n):其中 n 为数组或链表长度。