当前位置:   article > 正文

WebGL2.0从入门到精通-2、数据结构与算法(三、双向循环链表的封装)

webgl2.0

三、双向循环链表的封装

JS/TS 内置了动态数组 Array 对象,随着元素的加入,它的内部机制会自行扩容空间以容纳新元素。JS/TS 又内置了静态类型数组(如 Float32Array 等),这些静态类型数组一旦在构造函数中设定元素的个数后,就不能改变了。

为了让静态类型数组像 JS/TS 动态数组 Array 一样能够自动扩容,我们封装了动态化类型数组(TypedArrayList),它的原理是:如果当前元素个数 length 大于容量 capacity,则重新翻倍地分配一块连续的内存区块,然后将旧元素原封不动地一一搬往新分配的内存区块中,最后回收原来的那块内存。

通过上述描述可以发现,不管是内置的 Array 对象,还是二次封装的动态类型数组,在使用的过程中,有非常大的可能存在浪费内存的现象。

假设当前有个 float32 的动态类型数组,其容量为 10 个元素,如果插入第 11 个元素时则会发生扩容,复制原来的 10 个元素到新的数组中并且插入第 11 个元素,而后续我们不在插入其他元素,则会有 9 个未使用的元素其内存浪费着。

如果要避免上述内存浪费的现象,可以使用另外一种数据结构:链表。

相对于动态数组而言,链表的最大好处是当每次插入或删除一个元素时,就会分配或释放一个元素的内存空间。因此链表这种数据结构对内存的运用非常精准,一点也不浪费。

同时与动态数组相比,对于任何位置的插入或删除,链表永远是常数时间。

当然,链表最大的缺点就是无法快速地随机访问某个索引处的元素。

由于 JS/TS 中并没有内置链表这种数据结构,因而需要自行封装实现链表结构。

双向循环列表是由一个 头部节点 + 实际数据节点 组成的,实际数据节点可以为空,即当初始化 LinkList 时,可以只有头部节点,当执行了 push (数据插入后)就有了实际数据节点,实际数据节点的最后一个节点指向头部节点,头部节点的 data 值 是 undefined。

  1. export class LinkNode<T> {
  2. public next: LinkNode<T> | null; // 指向下一个节点,自引用,指向当前 ListNode 的后驱
  3. public prev: LinkNode<T> | null; // 指向前一个节点,自引用,指向当前 ListNode 的前驱
  4. public data: T | undefined; // 当前节点实际存储的数据,ListNode 所持有的数据
  5. public constructor(data: T | undefined = undefined) {
  6. // 初始化时 当前 ListNode 的前驱和后驱都设置为 null
  7. this.next = this.prev = null; // next 、prev 指向的是当前整个节点 而不是当前整个节点(一个 LinkNode)的 prev 或 next
  8. this.data = data;
  9. }
  10. }
  11. export class LinkList<T> {
  12. private _header: LinkNode<T>; // 记录头节点 用头节点作为双向循环节点的开始(如从头节点开始进行数据的录入、删除等)
  13. private _length: number;
  14. public constructor() {
  15. this._header = new LinkNode<T>();
  16. this._header.next = this._header; // 初始化时 头节点指向自身
  17. this._header.prev = this._header; // 初始化时 头节点指向自身
  18. this._length = 0; // 初始化时元素个数为 0 ,头节点作为哨兵节点不计算在内
  19. }
  20. }

List 初始化时,头部节点状态图:

使用 push 方法后,即进行了数据的录入后,状态图为:

因此,双向循环链表的概念可以总结为:

  • 双向:是指每个ListNode的后驱指针next指向下一个ListNode,每个ListNode的前驱指针prev指向前一个ListNode。可以通过通过next和prev指针进行从前到后或者从后到前的双向遍历操作。

  • 循环:我们通过List的头节点和最后一个节点(数字为2的节点)可以了解到,头节点h的前驱指针指向最后一个节点(尾节点),而最后一个节点(尾节点)的后驱指针next指向头节点h,从而首尾相连,形成一个闭环。

  • 当初始化时,List中的头节点h的后驱指针next和前驱指针prev都指向头节点

新建了 LinkList.ts 文件,文件内容如下:

  1. export class LinkNode<T> {
  2. public next: LinkNode<T> | null; // 指向下一个节点,自引用,指向当前 ListNode 的后驱
  3. public prev: LinkNode<T> | null; // 指向前一个节点,自引用,指向当前 ListNode 的前驱
  4. public data: T | undefined; // 当前节点实际存储的数据,ListNode 所持有的数据
  5. public constructor(data: T | undefined = undefined) {
  6. // 初始化时 当前 ListNode 的前驱和后驱都设置为 null
  7. this.next = this.prev = null; // next 、prev 指向的是当前整个节点 而不是当前整个节点(一个 LinkNode)的 prev 或 next
  8. this.data = data;
  9. }
  10. }
  11. // 该结构表示一个双向循环链表,它包含了一个 ListNode<T> 的头节点(哨兵节点),该头节点(_header)在链表初始化时,其 next 和 prev 成员变量都指向自身(_header)
  12. // 头节点和最后一个节点:头节点 _header 的前驱指针 prev 指向最后一个节点(尾节点),而最后一个节点(尾节点)的后驱指针 next 指向头节点 _header
  13. // 从而首尾相连,形成一个闭环
  14. export class LinkList<T> {
  15. private _header: LinkNode<T>; // 记录头节点 用头节点作为双向循环节点的开始(如从头节点开始进行数据的录入、删除等)
  16. private _length: number;
  17. public constructor() {
  18. this._header = new LinkNode();
  19. this._header.next = this._header; // 初始化时 头节点指向自身
  20. this._header.prev = this._header; // 初始化时 头节点指向自身
  21. this._length = 0; // 初始化时元素个数为 0 ,头节点作为哨兵节点不计算在内
  22. }
  23. // 获取 List 元素个数
  24. public get length(): number {
  25. // 如果当前 List 的 length 为 0,则说明是个空链表
  26. return this._length;
  27. }
  28. // 判断是否为空链表(当 this._length = 0 时 也可判断为空链表)
  29. public empty(): boolean {
  30. // 当头节点的后驱指针 next 指针指向头节点本身,说明是空链表
  31. return this._header.next === this._header;
  32. }
  33. // 在第一个参数 node 前插入一个节点 数据插入操作(在某个节点之前,插入一个节点)
  34. // 实现步骤为:
  35. // 1、在 参考节点前先插入一个节点即插入一个新节点
  36. // 2、将新节点的后驱指向参考节点
  37. // 3、将新节点的前驱指向参考节点原来的前驱 此时新节点的前驱、后驱指向就明确了
  38. // 4、修改 参考节点的前驱,使 参考节点的前驱指向新节点,此时参考节点的前驱、后驱指向也就明确了
  39. // 5、修改插入之前,参考节点的前驱指向的节点(上一个节点)的后驱需要修改为指向新插入的节点
  40. // 6、至此,新插入的节点的前后两个节点(参考节点的上一个节点,参考节点),这三个节点的前驱、后驱指向就都明确了
  41. private insertBefore(node: LinkNode<T>, data: T | undefined): LinkNode<T> {
  42. // 新建一个要插入的新节点
  43. let ret: LinkNode<T> = new LinkNode<T>(data);
  44. // 设置新节点的后驱指针和前驱指针
  45. ret.next = node; // 新节点的后驱即为当前参考节点
  46. ret.prev = node.prev; // 新节点的前驱 即为 当前参考节点原来的前驱
  47. // 设置好新节点的前后驱指针后
  48. // 还要调整 参考节点 的前后驱指针
  49. // 将当前参考节点的前一个节点的 后驱改为指向新节点
  50. if (node.prev !== null) {
  51. node.prev.next = ret;
  52. }
  53. // 将当前参考节点的前驱指向 新节点
  54. node.prev = ret;
  55. // 插入成功后 元素计数加 1
  56. this._length++;
  57. // 返回新插入的那个节点
  58. return ret;
  59. }
  60. // 获取尾节点
  61. public begin(): LinkNode<T> {
  62. if (this._header.next === null) {
  63. throw new Error("头节点的 next 指针必须不为 null");
  64. }
  65. // 若是链表不为空,则返回第一个节点
  66. // 若是链表为空, next 指向头节点本身,因此返回头节点
  67. // 绝对不可能返回 null 值
  68. return this._header.next;
  69. }
  70. // 获取头节点
  71. public end(): LinkNode<T> {
  72. return this._header;
  73. }
  74. // 查询是否包含某个值
  75. public contains(data: T): boolean {
  76. for (
  77. let link: LinkNode<T> | null = this._header.next;
  78. link !== null && link !== this._header;
  79. link = link.next
  80. ) {
  81. if (link.data !== undefined) {
  82. if (link.data === data) {
  83. return true;
  84. }
  85. }
  86. }
  87. return false;
  88. }
  89. // 双向遍历操作
  90. public forNext(cb: (data: T) => void): void {
  91. for (
  92. let link: LinkNode<T> | null = this._header.next;
  93. link !== null && link !== this._header;
  94. link = link.next
  95. ) {
  96. if (link.data !== undefined) {
  97. cb(link.data); // 调用回调函数
  98. }
  99. }
  100. }
  101. // 双向遍历操作
  102. public forPrev(cb: (data: T) => void): void {
  103. for (
  104. let link: LinkNode<T> | null = this._header.prev;
  105. link !== null && link !== this._header;
  106. link = link.prev
  107. ) {
  108. if (link.data !== undefined) {
  109. cb(link.data); // 调用回调函数
  110. }
  111. }
  112. }
  113. // 删除操作 删除操作是插入操作的逆操作
  114. public remove(node: LinkNode<T>): void {
  115. // 获得要删除的 节点 的后驱指针指向的节点
  116. let next: LinkNode<T> | null = node.next;
  117. // 获得要删除的 节点 的前驱指针指向的节点
  118. let prev: LinkNode<T> | null = node.prev;
  119. if (prev !== null) {
  120. // 修改 删除节点的上一个节点的 后驱为删除节点的后驱
  121. prev.next = next;
  122. }
  123. if (next !== null) {
  124. // 修改 删除节点的下一个节点的 前驱为删除节点的前驱
  125. next.prev = prev;
  126. }
  127. this._length--;
  128. }
  129. // 通过 insertBefore 和 remove 方法,结合 begin 和 end 以获得头节点和尾节点的功能,可以实现头尾插入、头尾删除的操作
  130. // 在尾部添加
  131. public push(data: T): void {
  132. this.insertBefore(this.end(), data);
  133. }
  134. // 在尾部移除
  135. public pop(): T | undefined {
  136. let prev: LinkNode<T> | null = this.end().prev;
  137. if (prev !== null) {
  138. let ret: T | undefined = prev.data;
  139. this.remove(prev);
  140. return ret;
  141. }
  142. return undefined;
  143. }
  144. // 在头部添加
  145. public unshift(data: T): void {
  146. this.insertBefore(this.begin(), data);
  147. }
  148. // 在头部移除
  149. public shift(): T | undefined {
  150. let ret: T | undefined = this.begin().data;
  151. this.remove(this.begin());
  152. return ret;
  153. }
  154. }

插入方法的原理及示例过程:

假如已经有了 List,如下图:

现在要增加一个新节点3,让其添加到节点1的前面,效果如下图所示:

当调用 push 或 unshift 的 insertBefore 方法后,获得的效果如下图:

该方法实现的步骤可以拆分为以下几步:

(1)调整新创建的node3的后驱指针和前驱指针,代码如下:

  1. // 设置新节点node3的后驱指针和前驱指针
  2. node3.next = node1; // node3.next设置为node1
  3. node3.prev = node1.prev; // node1.prev是node0,因此node.prev被设置为
  4. node0了

(2)调整node0的后驱指针next,让其指向新创建的node3,代码如下:

  1. // 设置好新节点的前后驱指针后
  2. // 还要调整参考节点的前后驱指针
  3. if ( node1.prev ! == null )
  4. {
  5. // node1.prev在未修正前,是指向node0的,再用node0.next指针指向新创建的node3
  6. node1.prev.next = node3;
  7. }

(3)调整node1的前驱指针prev,让其指向新创建的node3。

    node1.prev = node3;

demo 运行效果如下:

项目代码版本对应地址

本章参考如下:

《TypeScript 图形渲染实战——基于WebGL的3D架构与实现》

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/Gausst松鼠会/article/detail/727252
推荐阅读
相关标签
  

闽ICP备14008679号