重温:数据结构与算法 - 04链表(一)

xwzz.jpg

重温:数据结构与算法 - 开篇
重温:数据结构与算法 - 复杂度分析(一)
重温:数据结构与算法 - 复杂度分析(二)
重温:数据结构与算法 - 数组

前章提到链表也属于线性表结构
可想而知,链表在内存中存储的形式也是类似于一条直线进行延伸的。

链表内存结构.png

从内存图中可看出,链表数据结构的内存分配情况并不连续,作为线性表结构的关键就在于每个数据结点都是由 数据块指针块 组成。

Node结点.png
  • 数据块 : 用于存储真正的数据
  • 指针块 : 用于存储下一个结点的内存首地址

例如上图Node1结点中的指针块存储的就是Node2的首地址1003(这里假设一个结点仅占用1个字节)

单向链表

内存分配举例.png

单链表的方向是固定的,指针始终指向下一个结点,当没有结点时,最后一个结点称之尾结点,它的指针块会存储null;而第一个结点称之首结点,只要知道首结点,就可以获取到该链表中任意结点数据。

链表的插入与删除

与数组一样,链表也支持插入、删除、查询操作。我们知道数组的插入、删除操作需要大量的数据迁移工作,时间复杂度为:O(n)。而在链表中插入和删除结点就非常简单。

插入操作

如果需要在Node1和Node2中间插入Node8结点,只需要两步:

  • ①修改Node1的指针块存储Node8的首地址;
  • ②再让Node8的指针块存储原Node1指针块中取出的地址。

通过上面操作,在没有进行位移数据的情况下, 插入操作的时间复杂度为:O(1)

插入操作.png

删除操作

同理,删除Node2结点也只需两步:

  • ①修改Node1的指针块存储Node3的首地址;
  • ②清空Node2的指针块。
删除操作.png

So. 删除操作的时间复杂度也是:O(1)

查询操作

有利就有弊,与数组恰恰相反,链表因为不是连续的内存块,就无法通过寻址公式计算出每个结点的首地址,所以查询一个结点就必须从链表的首结点挨个查询下去,直到找到为止,即时间复杂度为:O(n)

查询操作.png

循环链表

循环列表是一种特殊单向链表,它的尾结点指针块不再存储null,而是指向首结点的首地址。

循环链表.png

这种链表的优点在处理环型数据时更加合适,比如著名的:约瑟夫问题

约瑟夫环

N个人围成一圈,从第一个开始报数,第M个将被杀掉,最后剩下一个人时游戏结束,求人员被杀掉的顺序。

例如:N=6,M=5,被杀掉的顺序是:5 -> 4 -> 6 -> 2 -> 3 , 最终幸运者: 1。

首先,创建一个Node类,该类的数据块存储人员编号,next指向下一个人员结点;

class Node {

    public int data;     // 人员编号
    public Node next;    // 下一个人结点

    public Node(int data) {
        this.data = data;
    }

}

再创建人员,并组成环;

    // 1、创建结点,并组成环
    Node firstNode = new Node(1);
    Node preNode = firstNode;
    for (int i = 1; i < n; i++) {
        Node temp = new Node(i + 1);
        preNode.next = temp;
        preNode = temp;
    }
    preNode.next = firstNode;

最后开始报数,只要报到m-1的人,它的下一位就出局,代码如下:

    // 2、开始报数
    Node node = firstNode;
    while (node != node.next) {
        for (int i = 1; i < m-1; i++) {
            node = node.next;
        }
        //当报数到m-1时,下一个人员就是出局者
        Node outNode = node.next;
        System.out.println("出局:" + outNode.data);

        //报数m-1的人指向 m+1人员
        node.next = outNode.next;
        outNode.next = null;

        //准备下一轮
        node = node.next;
    }
    System.out.println("幸运者:" + node.data);

输出:

  出局:5
  出局:4
  出局:6
  出局:2
  出局:3
  幸运者:1

时间复杂度:O(m * n)

双向链表

和单向链表不同,双向链表有两个指针块,一个和单向链表一样指向下一个结点的next指针,一个是指向上一个结点的pre指针。

双向链表.png

了解单向链表的数据结构后,双向链表就不难理解,与单项链表相比,双向链表的优缺点如下:

缺点

首先,由于每个结点比单向链表都多一个指针块,内存占用肯定大于单向链表。

优点

前驱指针的存在,当需要获取前结点需求时,时间复杂度就仅仅为O(1),而单向链表就不得不重新从首结点遍历,时间复杂度为O(n),这种设计我们称之为:用空间换时间

关于实际情况中的双链表操作的优点

  • 之前提到单向链表的删除操作时间复杂度是O(1),但实际开发中的删除需求可能会是删除某个结点(Node对象)。这时,单链表就无法将上结点和下结点直接连接起来(没有前驱指针),就必须重头遍历链表找到上结点,单向链表删除操作时间复杂度就变为:O(n);而双向链表就不存在这个问题,依旧可以保持高效的时间复杂度:O(1)
  • 同理,当要插入结点在某个结点前时,单向链表依旧无法获取上结点进行连接,所以插入操作时间复杂度变为:O(n);而双向链表依旧为:O(1)

  • 如果链表数据是有序的,在查询上虽然两者时间复杂度都是O(n),但实际上双向链表可以支持更快查询。双向链表可以把每次查询到的结点记录下来,当下次查询值大于当前就可以继续向后查询,反之向前查询,从而节省大部分时间。

LinkedList底层就是通过双向链表实现。

双向循环链表

跟循环链表一样,把双向链表的首尾相连起来,将双向和循环的优缺点结合起来就是:双向循环链表

双向循环链表.png

链表 VS 数组

时间复杂度对比

  • 数组 插入 O(n)
  • 链表 插入 O(1)
  • 数组 删除 O(n)
  • 链表 删除 O(1)
  • 数组 查询 O(1)
  • 链表 查询 O(n)

链表和数组的对比不能局限于时间复杂度,在特定的场景下选择适合的数据结构,才能编写出高效的算法。

数组的缺点是内存大小连续且固定,比如在内存仅剩100K,但并不连续,那么在创建一个100K的数组时就会导致“内存不足(out of memory)”,而链表就可以正常创建。

反过来说,正因为数组是连续的内存区域,有利于CPU的缓存机制,可以提前预读到CPU中,提高访问效率;而链表中的数据由于都是单内存区,就无法提前预读。并且如果过多的对链表结点进行插入、删除操作,可能会导致JVM频繁的GC,产生更多的内存碎片。

LRU淘汰算法

LRU淘汰算法属于缓存策略一种,一般缓存策略分为三种:

  • 1、先进先出策略 FIFO
  • 2、最少使用策略 LFU
  • 3、最近最少使用策略 LRU

了解过链表后,可以很容易的使用双向链表实现一个最近最少使用策略的LRU缓存机制,步奏如下:

  • 我们将需缓存的数据倒序存入链表中(先进的在末尾)
  • 当有新数据到来时,遍历缓存数据:
    • 已在缓存中: 就将该结点在原先位置删除,再插入到首结点
    • 不在缓存中:
      • 缓存大小充足,就将该数据插入首结点
      • 缓存大小不充足,删除尾结点,再插入首结点

这样设计的缓存策略,由于每次存储数据都要检查结点是否存在,所以时间复杂度为O(n)。

以下为缓存Data类型数据的LRU淘汰算法实现:

// Data 数据类
class Data {

    public String id;
    public int data;

    public Data(String id, int data) {
        this.id = id;
        this.data = data;
    }

    public void print() {
        System.out.print("{id : '" + id + "' , data : " + data + "}");
    }
}

双向链表:

 // 结点类
 class Node {

    public Node pre;
    public Data data;
    public Node next;

    public Node(Data data) {
        this.data = data;
    }

}

LruCache实现类:

 // Lru缓存策略容器
class LruCache {

    private Node first;
    private int size;
    private int maxSize;

    public LruCache(int maxSize) {
        this.maxSize = maxSize;
    }

    public void add(Data data) {
        //校验数据是否合法
        if (data.id == null) return;

        //查询结点是否已经缓存
        Node tempNode = first;
        Node last = null;
        while (tempNode != null) {
            if (tempNode.data.id.equals(data.id)) {
                if (tempNode.pre != null) {
                    tempNode.pre.next = tempNode.next;
                }
                //已缓存,移动到头部位置
                tempNode.next = first;
                first.pre = tempNode;
                first = tempNode;
                first.pre = null;
                return;
            } else {
                //未缓存,记录尾结点
                if (tempNode.next != null) {
                    tempNode = tempNode.next;
                } else {
                    last = tempNode;
                    break;
                }
            }
        }

        //超过初始化大小,删除尾结点
        if (size + 1 > maxSize && last != null) {
            last.pre.next = null;
            size--;
        }

        //添加新结点到头部
        Node newNode = new Node(data);
        newNode.pre = null;
        newNode.next = first;
        if (first != null) {
            first.pre = newNode;
        }
        first = newNode;
        size++;
    }

    public Data get(String id) {
        Node tempNode = first;
        while (tempNode != null) {
            if (tempNode.data.id.equals(id)) {
                return tempNode.data;
            }
            tempNode = tempNode.next;
        }
        return null;
    }

    public void print() {
        Node tempNode = first;
        while (tempNode != null) {
            System.out.print(tempNode.data.data + "\t");
            tempNode = tempNode.next;
        }
        System.out.println();
    }

}

测试:创建一个大小为3的缓存空间,并存入一组数据,查看存储情况

    LruCache cache = new LruCache(3);
    
    Data data1 = new Data("1", 111);
    Data data2 = new Data("2", 222);
    Data data3 = new Data("3", 333);
    Data data4 = new Data("4", 444);

    cache.add(data1);
    cache.add(data2);
    cache.add(data3);
    // 数据空间已满,添加data4会移除data1
    cache.add(data4);
    // 重复添加data2,data2将移至首结点
    cache.add(data2);
    cache.print();

    Data data = cache.get("3");
    data.print();

输出:

222 444 333 
{id : '3' , data : 333}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,524评论 5 460
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,869评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,813评论 0 320
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,210评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,085评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,117评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,533评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,219评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,487评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,582评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,362评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,218评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,589评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,899评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,176评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,503评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,707评论 2 335

推荐阅读更多精彩内容