LinkedListDeque & ArrayDeque

96 阅读7分钟

CS61B Project 1A

利用双向链表和循环数组的数据结构构造

LinkedListDeque

sentinel node无疑是重点,联系 DLList 内容,构建双向链表,其中 sentinel node 的环形链表是关键

单哨兵节点实现的环形列表图

​学习查找中 sentinel node中,查找的一些有益资料:

How does a sentinel node offer benefits over NULL? 说明了哨兵节点关于null的优势

Using sentinel nodes in Linked List operations 有关在Java中使用哨兵节点的 RemoveLinkedList 操作

哨兵节点:思想简单,效果很棒的的编程算法 - IOT物联网小镇的文章 - 知乎

关于 addFirstaddLastremoveFirstremoveLast 方法,核心在于理清单哨兵节点的环形列表逻辑,理清这个逻辑之后,实现就不算特别难,要理清楚知识点还是得在实践题目里练习啊,我最开始半懵办懂地入手被 sentinel node折磨红温,但真正理清楚了其实也就那样。

size:实现 size 需要花费恒定的时间,所以其时间复杂度为 O(1)。

DeepCopy深层复制 意味着不仅复制对象本身,还要复制对象内部所有引用的对象。在双向链表实现中,这意味着

要创建新的节点,而不是仅复制节点的引用。这样,当修改原始队列 other 时,新的队列不会受到影响。

在 Josh 的 视频引导 中提供了一种循环调用 addLast((T) other.get(i)); 的方法,将 other 队列中的每个元素添加到新队列中。

Josh 也在视频中说到这是一种比较容易但有点低效的方法,因为get(int index) 方法的时间复杂度为 O(n),在复制构造函数中,通过循环调用 get(i),整体时间复杂度为 O(n²),这样就使得效率更为低效。

ArrayDeque

(折磨啊,尤其是调整数组大小那一块,确实不容易,相当棘手 )

Circular ArrayDeque:

将数组的末尾和开头连接起来,形成一个环状结构,使得队列在数组的两端均可插入和删除,避免了浪费空间的情况。

Implementation of Deque using circular array

循环数组图示

Circular array implementation of Deque

Insert Elements at the Front end of Deque:

Delete Element From the Front end of Deque

resizing array

正确调整数组大小是解决数组队列的难点与重点,非常棘手。需要正确地调整数组大小(即 扩容 和 缩容),确保程序在任何给定时间使用的内存量与元素数量成正比。

使用系数(Usage Factor)

定义:使用系数 = size / length,即元素数量与数组容量的比值。

要求

  • 当数组长度 ≥ 16 时,使用系数应始终至少为 25%

  • 对于长度  < 16 的数组,使用系数可以任意低。

扩容——当数组已满,即 size == length 时,需要进行扩容。

扩容倍增策略:将数组容量扩大为原来的 2 倍

缩容——当使用系数 size / length < 0.25,且 length >= 16 时,需要进行缩容。

缩容减半策略:将数组容量缩小为原来的 1/2

最小容量限制:为了避免数组容量无限制地缩小,通常设定一个最小容量(例如 8)。当容量小于最小容量时,不进行缩容。

当数组需要 扩容 或 缩容 时,需要将元素从旧数组复制到新数组中。由于数组是环形的,元素在数组中的顺序可能不是连续的,因此在复制元素时,需要正确地处理索引,以确保元素按队列的顺序排列。

由于数组是环形的,front + i 可能超过数组的末尾,需要回绕到数组的开头。模运算(% length)的作用是确保索引始终在数组的有效范围内(0 到 length - 1),从而正确地访问元素。取模运算很巧妙,也很关键。

从 i = 0 到 i = size - 1,对于每个 i,计算旧数组中的索引 oldIndex = (front + i) % length,然后将 items[oldIndex] 复制到 newItems[i]

for (int i = 0; i < size; i++) {

    newItems[i] = items[(front + i) % length]

}

下面是对此模运算的具体分析——

length(旧数组容量)为 8; front 为 6; size 为 5

这意味着队列中有 5 个元素,front 指向索引 6

旧数组状态:

索引:  0   1   2   3   4   5   6   7

元素: [C] [D] [E] [ ] [ ] [ ] [A] [B]

                              ^
                            front

我们需要按照队列的顺序,将元素复制到新数组中,使它们在新数组中从索引 0 开始连续排列。

例如:i = 0

  • 旧数组索引:(front + 0) % length = (6 + 0) % 8 = 6
  • newItems[0] = items[6] = A

i = 2

  • 旧数组索引:(front + 2) % length = (6 + 2) % 8 = 0(注意这里超过了数组末尾,回到了索引 0
  • newItems[2] = items[0] = C

新数组状态则变更为:

索引:  0   1   2   3   4   5   6   7   ...

元素: [A] [B] [C] [D] [E] [ ] [ ] [ ] ...

在完成元素复制后,指针需要重置,front 重置为 0rear 重置为 size(即 5)。

在进行添加操作addFirst 和 addLast 方法前,检查是否需要扩容(扩容操作需要在更新 front 或 rear 指针之前进行)

if (size == length) {

    resize(length * 2);

}

在进行删除操作removeFirst 和 removeLast 方法前,检查是否需要缩容(缩容操作需要在更新 front 或 rear 指针和 size 之后进行)

if (length >= 16 && size < length / 4) {

    resize(length / 2);

}

removeLast 需要先更新 rear 指针再进行后续操作,因为rear 指针指向下一个可插入的位置,而不是当前最后一个元素的位置,因此,要移除最后一个元素,需要先将 rear 指针向前移动一个位置,指向最后一个实际存在的元素。

resizie 方法的两大重点:

环绕逻辑:使用模运算 (front + i) % length 确保即使队列元素在原数组中是环绕排列的,也能正确地复制到新数组中。

指针重置:复制完成后,将 front 重置为 0rear 设置为 size,以确保新数组中的指针位置一致且直观。

最后,写了一点GTP助教 prompt 帮助学习,O1-Preview的助教表现还是相当不错,但是有使用限额,所以也用了 O1-mini,作为自学者,没有 UCB 这种顶级大学的助教资源,但好在互联网和 AI 的发展让普通人学习知识的门槛进一步降低了。

GPT 助教 Prompt

写了一段 prompt 来利用 GPT 扮演 CS61B 中的助教角色——

我现在将要进行 CS61B 课程中一个project,关于 双向链表 的数据结构项目练习,我需要你作为我的助教角色,来帮助我解答我在做此项目中的疑问与困惑,作为助教,以下几点要求请你铭记并执行:

  1. 由我独自编写:构建项目中,我提交的所有项目代码(骨架代码除外)应由我单独编写,解决微小子问题的小片段除外。
  2. 禁止为我直接提供完善的代码答案:这是第一条规则的延伸,因为学术不诚实的黄金法则是,你不应该声称对不属于你的工作负责。所以你必须作为助教帮助我独立完成构建此项目,而不是作为工具为我提供违反我独立完成的作弊代码!
  3. 引用来源:当从其他地方(网站/大模型/他人GitHUb仓库)那里获得有关代码作业的重要帮助时,应该在源代码中的某个位置在注释中使用@source标签引用该帮助。 引用示例如下——
 // @source This code was generated by ChatGPT. It reads and parses
 // all integers from the file, which I then pass into my computeSum method.
 ...

4. 允许执行:讨论解决问题的方法;针对问题解决方案的重要概念性想法;讨论代码中的特定语法问题和错误;使用在网上找到的小代码片段来解决小问题(例如,谷歌搜索“大写字符串java”可能会引导找到一些示例代码,可以将其复制并粘贴到解决方案中),此类用法应在项目代码中作为注释引用!

  1. CheckStyle: coding-style非常重要,在你担任我的助教途中,请时刻提醒检查完善我的 coding-style。