数据结构与算法之三(基础篇)

数组

数组是一种线性表数据结构,他是用一组连续内存空间,来存储相同类型的数据。
线性表就是数据排成像一条线一样的结构,比如数组、队列、栈。非线性表数据之间并不是前后关系,比如树、堆、图等。
因为数据是相同类型的,内存空间是连续的,使得数组可以随机访问,但也使数组不易插入和删除。
数组越界,假如你定义了一个数组长度为3,下标从0开始到下标为2,但你的条件语句写成了i<=3,说明数组到a[3]超出了定义范围就会报数组越界异常。访问数组的本质就是访问一段连续内存,只要数组通过偏移计算得到的内存地址是可用的,那么程序就可能不会报任何错误。
数组容器,针对数组类型Java提供了一个ArrayList容器,它的优势是可以将数组的操作细节封装起来,比如数组插入、删除数据时需要搬移其他数据等,另外还支持动态扩容。注意:动态扩容涉及内存申请和数据搬移,比较耗时,如果能事先确定内存大小,最好在创建ArrayList时就指定数据大小,这样可以节省内存申请和数据搬移。

链表

链表是将一组零散的内存块串联起来使用,我们把内存块称为链表的节点,为了将节点串联起来,还需记录下一节点地址即后继指针next。常见的链表结构有3种,单链表,双向链表,循环链表。
单链表的第一个节点称为头结点,最后一个为尾节点,头结点用来记录链表基地址,而尾节点指向的是空地址NULL。链表的插入和删除的时间复杂度为O(1),因为链表不像数组要为保证内存的连续性而搬移数据,只是单纯的插入和删除一个数据,但是查询数据要从头或尾开始查找比较费时时间复杂度为O(n)。
循环链表的尾节点指针指向头结点,形成一个环状链表。循环链表的优点是从链尾到链头比较方便。当要处理的数据具有环型结构特点时,就特别适合采用循环链表,比如著名的约瑟夫问题。
双向链表,它支持两个方向,每个结点不止有一个后继指针 next 指向后面的结点,还有一个前驱指针 prev 指向前面的结点。
在实际的软件开发中,从链表中删除一个数据无外乎这两种情况:

  • 删除结点中“值等于某个给定值”的结点;
  • 删除给定指针指向的结点。
    对于第一种情况,单纯的删除某个节点时间复杂度是O(1),但是找到要删除的节点得耗时O(n),根据时间复杂度加法法则时间复杂度为O(n)。对于第二种情况,我们已经找到了要删除的结点,但是删除某个结点 q 需要知道其前驱结点,而单链表并不支持直接获取前驱结点,所以,为了找到前驱结点,我们还是要从头结点开始遍历链表,直到 p->next=q,说明 p 是 q 的前驱结点。对于双向链表来说节点已经保存了前驱节点的指针,不需要再遍历,针对第二种情况,单链表删除操作需要 O(n) 的时间复杂度,而双向链表只需要在 O(1) 的时间复杂度内就搞定了。
    数据结构与算法之三(基础篇)_第1张图片

从栈的操作特性上来看,栈是一种“操作受限”的线性表,只允许在一端插入和删除数据。后进者先出,先进者后出,这就是典型的“栈”结构。这就跟洗碗放碟时一样,先放的在底部,后洗的放最上面,取碟时从上往下取。
栈既可以用数组来实现,也可以用链表来实现。用数组实现的栈,我们叫作顺序栈,用链表实现的栈,我们叫作链式栈。不管是顺序栈还是链式栈,存储数据只要大小为n的数组,因为出入栈需要一两个临时变量来存储数据,所以空间复杂度为O(1);因为出入栈只涉及栈顶个别数据操作,所以时间复杂度为O(1)。
当数组空间不够时,就申请一块更大的内存将原数组数据复制过去,这样就实现了一个支持动态扩容的数组。当出栈时不会涉及内存的从新申请和数据的搬移,所以时间复杂度为O(1),但是入栈时涉及数据的搬移时间复杂度为O(n)
函数调用栈,操作系统给每个线程分配了一块独立的内存空间,这块内存被组织成“栈”这种结构, 用来存储函数调用时的临时变量。每进入一个函数,就会将临时变量作为一个栈帧入栈,当被调用函数执行完成,返回之后,将这个函数对应的栈帧出栈。

队列

队列简单来讲就相当于排队买票,先来先买,也就是先进先出。队列跟栈非常相似,也是操作受限的线性表,最基本的操作也是两个:入队enqueue(),放一个数据到队列尾部;出队 dequeue(),从队列头部取一个元素。
用数组来实现的队列叫顺序队列,用链表实现的队列叫作链式队列。我们需要head指针指向队头,tail指针指向队尾。随着不停地进行入队、出队操作,head 和 tail 都会持续往后移动。当 tail 移动到最右边,即使数组中还有空闲空间,也无法继续往队列中添加数据了。我们可以采用数据搬移,但搬移数据的时间复杂度是O(n),还可以优化,在出队时不用搬移数据,如果没有空闲空间了,我们只需要在入队时,再集中触发一次数据的搬移操作。当队列的tail指针指向最右边,如果有新数据入队,我们可以将数据整体搬移到数组中的0到tail-head位置。出队操作的时间复杂度仍然是 O(1),但入队操作的时间复杂度还是 O(1)。
循环队列,数组的首尾相连形成一个环状。它避免了数据搬移操作,但也容易制造bug,需要注意队空和队满的判定条件。在数组实现的非循环队列中,队满的判定条件是tail==n,队空的判定条件tail ==head,循环队列队空也是tail ==head,队满判定条件,(tail+1)%n=head。
阻塞队列其实就是在队列基础上增加了阻塞操作。队列为空的时候,从对头取数据会被阻塞,因为这时队列还没有数据可取,直到队列有数据才会返回;如果队列已经满了,那么插入数据的操作就会被阻塞,直到队列中有空闲位置后再插入数据,然后返回。我们可以使用阻塞队列,轻松实现一个“生产者 - 消费者模型”,可以有效地协调生产和消费的速度。当“生产者”生产数据的速度过快,“消费者”来不及消费时,存储数据的队列很快就会满了。这个时候,生产者就阻塞等待,直到“消费者”消费了数据,“生产者”才会被唤醒继续“生产”,而且还可以协调“生产者”和“消费者”的个数来提高数据的处理效率,可以多配置几个“消费者”,来应对一个“生产者”。

递归

求n!就是用递归,要想知道n!就得先知道(n-1)!,(n-2)!···2!,1,再由1算到n!,这就是一个非常标准的递归求解问题的分解过程,去的过程叫“递”,回来的过程叫“归”。所有的递归问题都可以用递归公式来表示。递归需要满足的三个条件

  1. 一个问题的解可以分为几个子问题的解
  2. 这个问题与分解之后的子问题,除了数据规模不同,求解思路完全一样
  3. 存在递归终止条件
    写递归代码的关键就是找到如何将大问题分解为小问题的规律,并且基于此写出递推公式,然后再推敲终止条件,最后将递推公式和终止条件翻译成代码。
    写递归代码需注意堆栈溢出,重复计算。函数调用会使用栈来保存临时变量,每调用一个函数都会将临时变量封装为栈针压入内存栈,当函数执行完成返回时,才会出栈。如果递归规模很大,调用层次很深,一直压栈就会导致堆栈溢出。为了避免堆栈溢出,当到了一定层次不再递归直接返回报错。为了避免重复计算,我们可以通过一个数据结构(比如散列表)来保存已经求解过的 f(k)。当递归调用到 f(k) 时,先看下是否已经求解过了。如果是,则直接从散列表中取值返回,不需要重复计算。
    在时间效率上,递归代码里多了很多函数调用,当这些函数调用的数量较大时,就会积聚成一个可观的时间成本。在空间复杂度上,因为递归调用一次就会在内存栈中保存一次现场数据,所以在分析递归代码空间复杂度时,需要额外考虑这部分的开销。

你可能感兴趣的:(数据结构与算法,数据结构,链表,队列)