数据结构与算法学习笔记系列文章目录
基本概念篇
1. 入门概述
2. 复杂度
3. 数组&链表
4. 栈&堆
5. 排序算法
6. 查找算法
编程思想篇
实际问题篇
1. 约瑟夫环
文章目录
一、前言
本节介绍一下最基础的两个数据结构:数组、链表。
后续的其他数据结构都是可以使用它们来进行表示的。
二、前置条件
C语言基础
三、本文参考资料
《大话数据结构》
《数据结构与算法之美》
百度
《果果带你写链表,小学生看了都说好!》
四、正文部分
4.1 数组
4.1.1 数组的基本概念
数组(Array)是一种线性表数据结构。它用一组连续的内存空间,来存储一组具有相同类型的数据。
- 优点:“随机访问”。
- 缺点:数组中删除、插入一个数据,为了保证连续性,就需要做大量的数据搬移工作,非常低效
- 注意事项:
数组支持随机访问,根据下标随机访问的时间复杂度为 O(1)
数组是适合查找操作,但是查找的时间复杂度并不为 O(1)。
即便是排好序的数组,你用二分查找,时间复杂度也是 O(logn)。
4.1.2 数组的寻址公式
我们拿一个长度为 10 的 int 类型的数组 int[] a = new int[10]来举例。
图中,计算机给数组 a[10],分配了一块连续内存空间 1000~1039,其中,内存块的首地址为 base_address = 1000。
计算机会给每个内存单元分配一个地址,计算机通过地址来访问内存中的数据。
当计算机需要随机访问数组中的某个元素时,它会首先通过下面的寻址公式,计算出该元素存储的内存地址:
a[i]_address = base_address + i * data_type_size
其中 data_type_size 表示数组中每个元素的大小。
我们举的这个例子里,数组中存储的是 int 类型数据,所以 data_type_size 就为 4 个字节。
这里也同样解释了为什么数组下标需要从0开始而不是从1开始:
-
历史原因
C 语言设计者用 0 开始计数数组下标,之后的 Java、JavaScript 等高级语言都效仿了 C 语言,或者说,为了在一定程度上减少 C 语言程序员学习 Java 的学习成本,因此继续沿用了从 0 开始计数的习惯。
实际上,很多语言中数组也并不是从 0 开始计数的,比如 Matlab。甚至还有一些语言支持负数下标,比如 Python。 -
从 1 开始编号,每次随机访问数组元素都多了一次减法运算,对于 CPU 来说,就是多了一次减法指令。
a[k]_address = base_address + (k-1)*type_size
4.1.3 数组的相关操作
-
插入
如果在数组的末尾插入元素,那就不需要移动数据了,这时的时间复杂度为 O(1)。
但如果在数组的开头插入元素,那所有的数据都需要依次往后移动一位,所以最坏时间复杂度是 O(n)。
因为我们在每个位置插入元素的概率是一样的,所以平均情况时间复杂度为 (1+2+…n)/n=O(n)。在特定场景下(如无序时插入),在第 k 个位置插入一个元素的时间复杂度(直接将原位置的值与插入的值交换)就会降为 O(1)。
-
删除
如果删除数组末尾的数据,则最好情况时间复杂度为 O(1);
如果删除开头的数据,则最坏情况时间复杂度为 O(n);
平均情况时间复杂度也为 O(n)。我们可以先记录下已经删除的数据。
每次的删除操作并不是真正地搬移数据,只是记录数据已经被删除。
当数组没有更多空间存储数据时,我们再触发执行一次真正的删除操作,这样就大大减少了删除操作导致的数据搬移。
4.1.4 数组的访问越界问题
实际操作时发现不同编译器的允许得到的结果不一样
4.1.5 二维数组内存寻址公式
a[i][j]
address = base_address + ( i * n + j) * type_size
a[0][0] a[0][1] ... a[0][j-1]
a[1][0] a[1][1] ...
...
a[j][0] a[j][1] ... a[i-1/**8][j-1]
4.1.6 ArrayList
ArrayList 最大的优势就是可以将很多数组操作的细节封装起来。
比如前面提到的数组插入、删除数据时需要搬移其他数据等。
另外,它还有一个优势,就是支持动态扩容。
如果使用 ArrayList,我们就完全不需要关心底层的扩容逻辑,ArrayList 已经帮我们实现好了。
每次存储空间不够的时候,它都会将空间自动扩容为 1.5 倍大小。
因为扩容操作涉及内存申请和数据搬移,是比较耗时的。
所以,如果事先能确定需要存储的数据大小,最好在创建 ArrayList 的时候事先指定数据大小。
-
Java ArrayList 无法存储基本类型,
比如 int、long,需要封装为 Integer、Long 类,而 Autoboxing、Unboxing 则有一定的性能消耗,
所以如果特别关注性能,或者希望使用基本类型,就可以选用数组。 -
如果数据大小事先已知,并且对数据的操作非常简单,用不到 ArrayList 提供的大部分方法,也可以直接使用数组。
-
当要表示多维数组时,用数组往往会更加直观。比如 Object[][] array;而用容器的话则需要这样定义:ArrayList<arraylist>
4.2 链表
4.2.1 链表的基本概念
链表并不需要一块连续的内存空间,它通过“指针”将一组零散的内存块串联起来使用,所以如果我们申请的是 100MB 大小的链表,根本不会有问题。
-
单链表
我们习惯性地把第一个结点叫作头结点,把最后一个结点叫作尾结点。
其中,头结点用来记录链表的基地址。有了它,我们就可以遍历得到整条链表。
而尾结点特殊的地方是:指针不是指向下一个结点,而是指向一个空地址 NULL,表示这是链表上最后一个结点。
在链表中插入或者删除一个数据,我们并不需要为了保持内存的连续性而搬移结点,因为链表的存储空间本身就不是连续的。
所以,在链表中插入和删除一个数据是非常快速的。
从图中我们可以看出,针对链表的插入和删除操作,我们只需要考虑相邻结点的指针改变,所以对应的时间复杂度是 O(1)。
链表要想随机访问第 k 个元素,就没有数组那么高效了。
因为链表中的数据并非连续存储的,所以无法像数组那样,根据首地址和下标,通过寻址公式就能直接计算出对应的内存地址,而是需要根据指针一个结点一个结点地依次遍历,直到找到相应的结点。
所以,链表随机访问的性能没有数组好,需要 O(n) 的时间复杂度。 -
循环链表
循环链表的优点是从链尾到链头比较方便。
当要处理的数据具有环型结构特点时,就特别适合采用循环链表。比如著名的约瑟夫问题。
尽管用单链表也可以实现,但是用循环链表实现的话,代码就会简洁很多。 -
双向链表
双向链表需要额外的两个空间来存储后继结点和前驱结点的地址。
所以,如果存储同样多的数据,双向链表要比单链表占用更多的内存空间。
虽然两个指针比较浪费存储空间,但可以支持双向遍历,这样也带来了双向链表操作的灵活性。从结构上来看,双向链表可以支持 O(1) 时间复杂度的情况下找到前驱结点,正是这样的特点,也使双向链表在某些情况下的插入、删除等操作都要比单链表简单、高效。
-
删除给定指针指向的结点
已经找到了要删除的结点,但是删除某个结点 q 需要知道其前驱结点,而单链表并不支持直接获取前驱结点,所以,为了找到前驱结点,我们还是要从头结点开始遍历链表,直到 p->next=q,说明 p 是 q 的前驱结点。
但是对于双向链表来说,这种情况就比较有优势了。因为双向链表中的结点已经保存了前驱结点的指针,不需要像单链表那样遍历。
所以,单链表删除操作需要 O(n) 的时间复杂度,而双向链表只需要在 O(1) 的时间复杂度内就搞定了! -
在链表的某个指定结点前面插入一个结点
双向链表可以在 O(1) 时间复杂度搞定,而单向链表需要 O(n) 的时间复杂度。 -
按值查询
可以记录上次查找的位置 p,每次查询时,根据要查找的值与 p 的大小关系,决定是往前还是往后查找,所以平均只需要查找一半的数据。
-
-
双向循环链表
对于执行较慢的程序,可以通过消耗更多的内存(空间换时间)来进行优化;
而消耗过多内存的程序,可以通过消耗更多的时间(时间换空间)来降低内存的消耗。
4.2.2 链表操作
- 核心知识:
这个链表结构体里面有一个指针,这个指针,等于其他结构体的地址。即结构体A里面的某一个指针,指向结构体B。
4.2.2.1 初始化
4.2.2.2 插入
可以在链表的头部插入新的节点,也可以在列表的尾部插入新的节点。
-
在头部插入
void InsertNodeToHead(spy newNode) { newNode->next_addr = pHead;//新插入节点的下一个节点为空 pHead = newNode; //头结点指向新插入的节点 }
-
在尾部插入
tmp假设是最后一个元素,B是新元素tmp->next.addr=&B; B.next addr = NULL;
找出最后一个元素
void InsertNodeToTail(p_spy newNode) { p_spy temp;//定义一个临时节点 if(pHead == NULL) { //如果链表为空 pHead = newNode; newNode->next = NULL; } else { temp= pHead ;//让头结点指向临时节点 while (temp) { if (temp->next == NULL) //找到了最后一个节点 temp就是最后一个节点 break; else temp= temp->next; } temp->next = newNode;//最后一个节点插入新节点 newNode->next = NULL;//新节点的下一个节点为空,这样就将新节点插入最后了 } }
4.2.2.3 删除
关键在于我们怎么找到前面的人:temp
如果我的下一项就等于你的话,我就是你的前一个。找到之后,就执行这条指令:temp->next=后面的人=needDeleteNode->next
void RemoveNode(p_spy needDeleteNode)
{
p_spy temp;//定义一个临时的节点,用来遍历
if (pHead == needDeleteNode)//如果被删除的节点正好是头结点指向的那一个节点
{
pHead = needDeleteNode->next;//直接让头节点指向被删除节点的下一个节点
return;//返回
}
else
{
/* 找出NeedDeleteNode的上一个节点 */
temp = pHead;//让头结点指向临时节点temp
while(temp)
{
if (temp->next == needDeleteNode)//找到了,要删除的节点就是temp的下一个节点
break;
else
temp= temp->next;//继续找
}
if (temp)
{
//让被删除的上一个节点temp的下一个节点指向被删除节点的下一个节点,那么就把原来的要删除的节点给从链表中删除了。
temp->next = needDeleteNode-> next;
}
}
}
4.2.2.4 链表的使用
/*------------------------1.链表和结点的定义----------------------------*/
/*结点结构体*/
typedef struct LIST_NODE {
int data; /*用于存放结点数据*/
struct LIST_NODE *pxNext; /*用于指向下一个结点*/
struct LIST_NODE *pxPrevious; /*用于指向上一个结点*/
}ListNode;
/*链表结构体*/
typedef struct LIST {
unsigned int NumberOfNodes; /*用于记录链表结点数量*/
ListNode RootNode; /*用于作为循环链表的参考点*/
}List;
/*------------------------2.链表和结点的初始化---------------------------*/
/*结点初始化*/
void ListInitialiseItem(ListNode *pxListNode, int value)
{
pxListNode->data = value; /*结点数据赋值*/
}
/*链表初始化*/
void ListInitialise(List *pxList)
{
pxList->RootNode.pxNext = &(pxList->RootNode); /*由于此时链表中没有结点,第一个结点指向自己*/
pxList->RootNode.pxPrevious = &(pxList->RootNode); /*由于此时链表中没有结点,第一个结点指向自己*/
pxList->NumberOfNodes = 1; /*链表结点计数初始化为1,也就是只有一个根结点*/
}
/*------------------------3.1结点插入链表---------------------------*/
void ListInsertEnd(List *pxList, ListNode *pxInsertListNode)
{
ListNode *pxNextNode = &(pxList->RootNode); /*插入结点的后结点*/
ListNode *pxPreviosNode = pxList->RootNode.pxPrevious; /*插入结点的前结点*/
pxInsertListNode->pxNext = pxNextNode; /*插入结点指向后结点*/
pxInsertListNode->pxPrevious = pxPreviosNode; /*插入结点指向前结点*/
pxPreviosNode->pxNext = pxInsertListNode; /*前结点指向插入结点*/
pxNextNode->pxPrevious = pxInsertListNode; /*后结点指向插入结点*/
(pxList->NumberOfNodes)++; /*链表结点计数加1*/
}
/*------------------------3.2链表删除结点---------------------------*/
void ListRemove(List *pxList, ListNode *pxIListToRemove)
{
ListNode *pxPreviosNode = pxIListToRemove->pxPrevious; /*删除结点的前结点*/
ListNode *pxNextNode = pxIListToRemove->pxNext; /*删除结点的后结点*/
pxNextNode->pxPrevious = pxPreviosNode; /*后结点指向前结点*/
pxPreviosNode->pxNext = pxNextNode; /*前结点指向后结点*/
(pxList->NumberOfNodes)--; /*链表结点计数减1*/
}
int main(void)
{
/*1.定义链表、结点*/
List list; //定义链表
ListNode list_node1; //定义结点1
ListNode list_node2; //定义结点2
/*2.初始化链表、结点*/
ListInitialise(&list);
ListInitialiseItem(&list_node1, 100);
ListInitialiseItem(&list_node2, 200);
/*3.插入链表*/
ListInsertEnd(&list, &list_node1);
ListInsertEnd(&list, &list_node2);
/*4.删除结点*/
ListRemove(&list, &list_node1);
return 0;
}
4.2.3 链表编码技巧
-
理解指针或引用的含义
将某个变量赋值给指针,实际上就是将这个变量的地址赋值给指针,
或者反过来说,指针中存储了这个变量的内存地址,指向了这个变量,通过指针就能找到这个变量。p->next=q。这行代码是说,p 结点中的 next 指针存储了 q 结点的内存地址。
-
警惕指针丢失和内存泄漏
插入结点时,一定要注意操作的顺序,要先将结点 x 的 next 指针指向结点 b,再把结点 a 的 next 指针指向结点 x,这样才不会丢失指针,导致内存泄漏。
删除链表结点时,也一定要记得手动释放内存空间 -
利用哨兵简化实现难度
head=null 表示链表中没有结点了。其中 head 表示头结点指针,指向链表中的第一个结点。如果我们引入哨兵结点,在任何时候,不管链表是不是空,head 指针都会一直指向这个哨兵结点。
我们也把这种有哨兵结点的链表叫带头链表。相反,没有哨兵结点的链表就叫作不带头链表。
-
重点留意边界条件处理
-
举例画图,辅助思考
4.3 性能比较
数组简单易用,在实现上使用的是连续的内存空间,可以借助 CPU 的缓存机制,预读数组中的数据,所以访问效率更高。
而链表在内存中并不是连续存储,所以对 CPU 缓存不友好,没办法有效预读。
数组的缺点是大小固定,一经声明就要占用整块连续内存空间。
- 如果声明的数组过大,系统可能没有足够的连续内存空间分配给它,导致“内存不足(out of memory)”。
- 如果声明的数组过小,则可能出现不够用的情况。这时只能再申请一个更大的内存空间,把原数组拷贝进去,非常费时。
链表本身没有大小的限制,天然地支持动态扩容。
如果代码对内存的使用非常苛刻,那数组就更适合你。
因为链表中的每个结点都需要消耗额外的存储空间去存储一份指向下一个结点的指针,所以内存消耗会翻倍。
而且,对链表进行频繁的插入、删除操作,还会导致频繁的内存申请和释放,容易造成内存碎片,如果是 Java 语言,就有可能会导致频繁的 GC(Garbage Collection,垃圾回收)。
五、总结
数组主要用于固定大小的数据结构,其特点是支持随机访问与基于下标的查找,但插入与删除较为耗时
链表主要用于不定长的数据结构,其特点是支持没有大小限制,只要内存够,可以无限增长,插入删除较为方便,但要注意前驱节点的操作,与数组相反,查找时则需要遍历链表才能进行访问。
双向链表提供了一种利用空间去交换时间的技巧。
数组在使用时还需要额外注意内存的使用,不得越界访问别的空间,否则会出现意想不到的错误,且这些错误都是无法被编译器发现的。