• 数据结构与算法001


                          《数组》

    例题1:假设数组的长度为 n,现在,如果我们需要将一个数据插入到数组中的第 k 个位置?(插入)   ——JVM 的标记清除垃圾回收算法的核心理念

    数组是无序的:

      解决方法:将第K位的数据移动到最后一位,也就是O(n-k+1),再将新数插入到第k位,也就是O(1),这样时间复杂度,就是O(1)

      如果是删除一个数据也是一样的,将最后一个数组赋值到删除位置,再将最后一个数据删除。

    数组和ArrayList的区别:

      1.Java ArrayList 无法存储基本类型,比如 int、long,需要封装为 Integer、Long 类,而 Autoboxing、Unboxing 则有一定的性能消耗,

      所以如果特别关注性能,或者希望使用基 本类型,就可以选用数组。

      2. 如果数据大小事先已知,并且对数据的操作非常简单,用不到 ArrayList 提供的大部分方 法,也可以直接使用数组。

      3. 还有一个是我个人的喜好,当要表示多维数组时,用数组往往会更加直观。比如 Object[][] array;而用容器的话则需要这样定义:ArrayList array。 

    思考1:为什么大多数编程语言中,数组要从 0 开始编号,而不是从 1 开始呢?

      从数组存储的内存模型上来看,“下标”最确切的定义应该是“偏移(offset)”。前面也 讲到,如果用 a 来表示数组的首地址,

      a[0] 就是偏移为 0 的位置,也就是首地址,a[k] 就 表示偏移 k 个 type_size 的位置,所以计算 a[k] 的内存地址只需要用这个公式:

        a[k]_address = base_address + k * type_size

      但是,如果数组从 1 开始计数,那我们计算数组元素 a[k] 的内存地址就会变为:

        a[k]_address = base_address + (k-1)*type_size

      对比两个公式,我们不难发现,从 1 开始编号,每次随机访问数组元素都多了一次减法运 算,对于 CPU 来说,就是多了一次减法指令。

      数组作为非常基础的数据结构,通过下标随机访问数组元素又是其非常基础的编程操作,效 率的优化就要尽可能做到极致。

      所以为了减少一次减法操作,数组选择了从 0 开始编号, 而不是从 1 开始。

    ***数组越界会导致无线循环访问的原因:

      函数体内的局部变量存在栈上,且是连续压栈。在Linux进程的内存布局中,栈区在高地址 空间,从高向低增长。变量i和arr在相邻地址,且i比arr的地址大,所以arr越界正好访问

      例子中死循环的问题跟编译器分配内存和字节对齐有关 数组3个元素 加上一个变量a 。4个 整数刚好能满足8字节对齐 所以i的地址恰好跟着a2后面 导致死循环。

      如果数组本身有4 个元素 则这里不会出现死循环。。因为编译器64位操作系统下 默认会进行8字节对齐 变量 i的地址就不紧跟着数组后面了。

      1. 我认为文中更准确的说法可能是标记-整理垃圾回收算法。标记-清除算法在垃圾收集时 会先标记出需要回收的对象,标记完成后统一回收所有被标记的对象。清除之后会产生大 量不连续的内存碎片。标记-整理垃圾回收算法在标记完成之后让所有存活的对象都向一端 移动,然后直接清理掉边界以外的内存。

      https://www.ibm.com/developerworks/cn/linux/l-cn-gccstack/index.html


                              《链表》

      链表应用场景,那就是 LRU 缓存淘汰算法。

      缓存是一种提高数据读取性能的技术,在硬件设计、软件开发中都有着非常广泛的应用,比 如常见的 CPU 缓存、数据库缓存、浏览器缓存等等。

     缓存的大小有限,当缓存被用满时,哪些数据应该被清理出去,哪些数据应该被保留?这就 需要缓存淘汰策略来决定。常见的策略有三种:先进先出策略 FIFO(First In,First Out)、最少使用策略 LFU(Least Frequently Used)、最近最少使用策略 LRU(Least Recently Used)。

    假如说,你买了很多本技术书,但有 一天你发现,这些书太多了,太占书房空间了,你要做个大扫除,扔掉一些书籍。那这个时 候,你会选择扔掉哪些书呢?

    对应一下,你的选择标准是不是和上面的三种策略神似呢?

    好了,回到正题,我们今天的开篇问题就是:如何用链表来实现 LRU 缓存淘汰策略呢? 带 着这个问题,我们开始今天的内容吧! 五花八门的链表结构 相比数组,链表是一种稍微复杂一点的数据结构。

    对于初学者来说,掌握起来也要比数组稍 难一些。这两个非常基础、非常常用的数据结构,我们常常将会放到一块儿来比较。所以我 们先来看,这两者有什么区别。 我们先从底层的存储结构上来看一看。

    为了直观地对比,我画了一张图。从图中我们看到,数组需要一块连续的内存空间来存储, 对内存的要求比较高。如果我们申请一个 100MB 大小的数组,当内存中没有连续的、足够 大的存储空间时,即便内存的剩余总可用空间大于 100MB,仍然会申请失败。 而链表恰恰相反,它并不需要一块连续的内存空间,它通过“指针”将一组零散的内存块串 联起来使用,所以如果我们申请的是 100MB 大小的链表,根本不会有问题。

    链表结构五花八门,今天我重点给你介绍三种最常见的链表结构,它们分别是:单链表、双 向链表和循环链表。

    我们首先来看最简单、最常用的单链表

    我们刚刚讲到,链表通过指针将一组零散的内存块串联在一起。其中,我们把内存块称为链 表的“结点”。为了将所有的结点串起来,每个链表的结点除了存储数据之外,

    还需要记录 链上的下一个结点的地址。如图所示,我们把这个记录下个结点地址的指针叫作后继指针 next。

     从我画的单链表图中,你应该可以发现,其中有两个结点是比较特殊的,它们分别是第一个 结点和最后一个结点。我们习惯性地把第一个结点叫作头结点,把最后一个结点叫作尾结 点。

    其中,头结点用来记录链表的基地址。有了它,我们就可以遍历得到整条链表。而尾结 点特殊的地方是:指针不是指向下一个结点,而是指向一个空地址 NULL,表示这是链表 上最后一个结点。

    但是,有利就有弊。链表要想随机访问第 k 个元素,就没有数组那么高效了。因为链表中 的数据并非连续存储的,所以无法像数组那样,

    根据首地址和下标,通过寻址公式就能直接 计算出对应的内存地址,而是需要根据指针一个结点一个结点地依次遍历,直到找到相应的 结点。

    你可以把链表想象成一个队伍,队伍中的每个人都只知道自己后面的人是谁,所以当我们希 望知道排在第 k 位的人是谁的时候,我们就需要从第一个人开始,一个一个地往下数。所 以,链表随机访问的性能没有数组好,需要 O(n) 的时间复杂度。好了,单链表我们就简单介绍完了,接着来看另外两个复杂的升级版,循环链表和双向链 表。

    循环链表是一种特殊的单链表。实际上,循环链表也很简单。它跟单链表唯一的区别就在尾 结点。我们知道,单链表的尾结点指针指向空地址,表示这就是最后的结点了。而循环链表 的尾结点指针是指向链表的头结点。从我画的循环链表图中,你应该可以看出来,它像一个 环一样首尾相连,所以叫作“循环”链表。

    和单链表相比,循环链表的优点是从链尾到链头比较方便。当要处理的数据具有环型结构特 点时,就特别适合采用循环链表。比如著名的约瑟夫问题。尽管用单链表也可以实现,但是 用循环链表实现的话,代码就会简洁很多。

    单链表和循环链表是不是都不难?接下来我们再来看一个稍微复杂的,在实际的软件开发 中,也更加常用的链表结构:双向链表

    单向链表只有一个方向,结点只有一个后继指针 next 指向后面的结点。而双向链表,顾名 思义,它支持两个方向,每个结点不止有一个后继指针 next 指向后面的结点,还有一个前 驱指针 prev 指向前面的结点。

    从我画的图中可以看出来,双向链表需要额外的两个空间来存储后继结点和前驱结点的地 址。所以,如果存储同样多的数据,双向链表要比单链表占用更多的内存空间。虽然两个指 针比较浪费存储空间,但可以支持双向遍历,这样也带来了双向链表操作的灵活性。那相比 单链表,双向链表适合解决哪种问题呢?

    从结构上来看,双向链表可以支持 O(1) 时间复杂度的情况下找到前驱结点,正是这样的特 点,也使双向链表在某些情况下的插入、删除等操作都要比单链表简单、高效。

    你可能会说,我刚讲到单链表的插入、删除操作的时间复杂度已经是 O(1) 了,双向链表还 能再怎么高效呢?别着急,刚刚的分析比较偏理论,很多数据结构和算法书籍中都会这么 讲,但是这种说法实际上是不准确的,或者说是有先决条件的。我再来带你分析一下链表的 两个操作。

    我们先来看删除操作。 在实际的软件开发中,从链表中删除一个数据无外乎这两种情况:

      删除结点中“值等于某个给定值”的结点;

      删除给定指针指向的结点。

    对于第一种情况,不管是单链表还是双向链表,为了查找到值等于给定值的结点,都需要从 头结点开始一个一个依次遍历对比,直到找到值等于给定值的结点,然后再通过我前面讲的 指针操作将其删除。

    尽管单纯的删除操作时间复杂度是 O(1),但遍历查找的时间是主要的耗时点,对应的时间 复杂度为 O(n)。根据时间复杂度分析中的加法法则,删除值等于给定值的结点对应的链表操作的总时间复杂度为 O(n)。

    对于第二种情况,我们已经找到了要删除的结点,但是删除某个结点 q 需要知道其前驱结 点,而单链表并不支持直接获取前驱结点,所以,为了找到前驱结点,我们还是要从头结点 开始遍历链表,直到 p->next=q,说明 p 是 q 的前驱结点。(这里也就是说,我要删除某个结点,先删除某结点对它得指向,再将该结点对其他结点得指向设为Null,所以还要再找出它的前面一个结点对他的指向)

    但是对于双向链表来说,这种情况就比较有优势了。因为双向链表中的结点已经保存了前驱 结点的指针,不需要像单链表那样遍历。所以,针对第二种情况,单链表删除操作需要 O(n) 的时间复杂度,而双向链表只需要在 O(1) 的时间复杂度内就搞定了!

    同理,如果我们希望在链表的某个指定结点前面插入一个结点,双向链表比单链表有很大的 优势。双向链表可以在 O(1) 时间复杂度搞定,而单向链表需要 O(n) 的时间复杂度。你可 以参照我刚刚讲过的删除操作自己分析一下。  

    除了插入、删除操作有优势之外,对于一个有序链表,双向链表的按值查询的效率也要比单 链表高一些。因为,我们可以记录上次查找的位置 p,每次查询时,根据要查找的值与 p 的大小关系,决定是往前还是往后查找,所以平均只需要查找一半的数据。

    现在,你有没有觉得双向链表要比单链表更加高效呢?这就是为什么在实际的软件开发中, 双向链表尽管比较费内存,但还是比单链表的应用更加广泛的原因。如果你熟悉 Java 语 言,你肯定用过 LinkedHashMap 这个容器。如果你深入研究 LinkedHashMap 的实现原 理,就会发现其中就用到了双向链表这种数据结构。

    实际上,这里有一个更加重要的知识点需要你掌握,那就是用空间换时间的设计思想。当内 存空间充足的时候,如果我们更加追求代码的执行速度,我们就可以选择空间复杂度相对较 高、但时间复杂度相对很低的算法或者数据结构。相反,如果内存比较紧缺,比如代码跑在 手机或者单片机上,这个时候,就要反过来用时间换空间的设计思路。

    还是开篇缓存的例子。缓存实际上就是利用了空间换时间的设计思想。如果我们把数据存储 在硬盘上,会比较节省内存,但每次查找数据都要询问一次硬盘,会比较慢。但如果我们通 过缓存技术,事先将数据加载在内存中,虽然会比较耗费内存空间,但是每次数据查询的速 度就大大提高了

    所以我总结一下,对于执行较慢的程序,可以通过消耗更多的内存(空间换时间)来进行优 化;而消耗过多内存的程序,可以通过消耗更多的时间(时间换空间)来降低内存的消耗。

    了解了循环链表和双向链表,如果把这两种链表整合在一起就是一个新的版本:双向循环链 表。我想不用我多讲,你应该知道双向循环链表长什么样子了吧?你可以自己试着在纸上画 一画。

     

    链表 VS 数组性能大比拼

     

     不过,数组和链表的对比,并不能局限于时间复杂度。而且,在实际的软件开发中,不能仅 仅利用复杂度分析就决定使用哪个数据结构来存储数据。

    数组简单易用,在实现上使用的是连续的内存空间,可以借助 CPU 的缓存机制,预读数组 中的数据,所以访问效率更高。而链表在内存中并不是连续存储,所以对 CPU 缓存不友 好,没办法有效预读。

    数组的缺点是大小固定,一经声明就要占用整块连续内存空间。如果声明的数组过大,系统 可能没有足够的连续内存空间分配给它,导致“内存不足(out of memory)”。如果声 明的数组过小,则可能出现不够用的情况。这时只能再申请一个更大的内存空间,把原数组 拷贝进去,非常费时。链表本身没有大小的限制,天然地支持动态扩容,我觉得这也是它与 数组最大的区别。

    你可能会说,我们 Java 中的 ArrayList 容器,也可以支持动态扩容啊?我们上一节课讲 过,当我们往支持动态扩容的数组中插入一个数据时,如果数组中没有空闲空间了,就会申 请一个更大的空间,将数据拷贝过去,而数据拷贝的操作是非常耗时的。

    我举一个稍微极端的例子。如果我们用 ArrayList 存储了 1GB 大小的数据,这个时候已 经没有空闲空间了,当我们再插入数据的时候,ArrayList 会申请一个 1.5GB 大小的存储空 间,并且把原来那 1GB 的数据拷贝到新申请的空间上。听起来是不是就很耗时?

     除此之外,如果你的代码对内存的使用非常苛刻,那数组就更适合你。因为链表中的每个结 点都需要消耗额外的存储空间去存储一份指向下一个结点的指针,所以内存消耗会翻倍。而 且,对链表进行频繁的插入、删除操作,还会导致频繁的内存申请和释放,容易造成内存碎 片,如果是 Java 语言,就有可能会导致频繁的 GC(Garbage Collection,垃圾回收)。

    好了,关于链表的知识我们就讲完了。我们现在回过头来看下开篇留给你的思考题。如何基 于链表实现 LRU 缓存淘汰算法?

    我的思路是这样的:我们维护一个有序单链表,越靠近链表尾部的结点是越早之前访问的。 当有一个新的数据被访问时,我们从链表头开始顺序遍历链表。

        


  • 相关阅读:
    3.10_eclipse的使用和Object类型
    团队项目推荐:礼物推荐 —— by Xizhou
    团队项目推荐:不一样的旅游——by Lijun
    团队项目推荐:自动化卡通人脸生成——by Feng Nie
    团队项目推荐:手机社交照片管理软件——by Zhaofan
    Team Project Proposal:App:等车时间预测(重写) -- by DengPan
    对MSRA-USTC 2011的 meng-meng(萌萌) 的team project的学习和评价
    Team Project Proposal: All-in-one多信息源整合的移动应用[Revised in 8th Oct]——by Yuchan
    Team Project Proposal:App:等车时间预测 -- by DengPan
    Team Project Proposal:手机音频美化APP——by Kun Qian
  • 原文地址:https://www.cnblogs.com/su-ke/p/13503632.html
Copyright © 2020-2023  润新知