拒绝模板套壳,从零手写最干净的双向链表 —数据结构肆

拒绝模板套壳,从零手写最干净的双向链表 —数据结构肆 你好我是林森lsjs我的Github 地址sqyCoder (Qiyang) · GitHub以博文记录成长用心打磨代码与思维目录结点类DLinkedNode双向链表主体类MyDLinkedList方法1addFirst(int val) 头部插入元素方法2addLast(int val) 尾部插入元素方法3toString() 正向反向双向打印调试核心方法方法4size() 统计链表结点个数方法5add(int index, int val) 指定下标插入结点方法6contains(int value) 判断元素是否存在方法7indexOf(int value) 查询目标数值对应的下标方法8removeFirst() 删除头部结点方法9removeLast() 删除尾部结点方法10remove(int index) 根据下标删除结点方法11removeByValue(int value) 删除第一个匹配数值的结点main测试入口总结前置核心认知单向链表结点结构val next只能向后遍历prev前驱引用记录左边相邻结点地址next后继引用记录右边相邻结点地址双向链表任意结点既能向前找前驱又能向后找后继。⚠️ 本代码特点没有dummy傀儡头结点、没有tail尾指针只有head头指针。head直接指向第一个有效数据结点链表为空时head null所有尾部操作必须从头遍历找到尾结点。双向链表最大难点插入、删除操作必须同时维护 prev、next 双向两条链路漏写任意一条都会链表断裂。结点类DLinkedNodeclass DLinkedNode { public int val; public DLinkedNode prev; public DLinkedNode next; public DLinkedNode(int val) { this.val val; this.prev null; this.next null; } }逐行拆解public int val结点存储的数据当前存储整数public DLinkedNode prev前驱引用保存上一个结点地址第一个结点的 prev 永远为 nullpublic DLinkedNode next后继引用保存下一个结点地址最后一个结点的 next 永远为 null构造方法DLinkedNode(int val)新建结点时传入数据this.val val把传入数值存入结点this.prev null新结点刚创建没有前驱初始置空this.next null新结点刚创建没有后继初始置空 新建出来的结点是孤立结点不与链表任何结点建立联系。双向链表主体类MyDLinkedListpublic class MyDLinkedList { private DLinkedNode head; // private DLinkedNode tail;private DLinkedNode head链表头引用永远指向链表第一个有效结点链表为空 →head null链表存在元素 → head指向首结点注释掉的tail尾指针如果开启尾插、尾删不需要遍历当前代码不使用所有尾部操作必须循环遍历。方法1addFirst(int val) 头部插入元素public void addFirst(int val) { DLinkedNode newNode new DLinkedNode(val); if (head null) { head newNode; return; } newNode.next head; head.prev newNode; head newNode; }逐行详细讲解DLinkedNode newNode new DLinkedNode(val);创建一个全新孤立结点数据为valprevnull、nextnull。if (head null)判断场景链表是空链表不存在任何结点。满足条件执行head newNode;让head直接指向新结点return结束方法。图示空链表头插head → null 执行后head ──▶ [val | prev:null | next:null]链表不为空存在原有结点newNode.next head;新结点的后继指向原来的头结点建立新结点向右的连接。head.prev newNode;原头结点的前驱指向新结点建立原头结点向左的连接。head newNode;更新headhead指向新结点新结点成为链表新表头。示例原链表head→[1] ←→ [2] ←→ [3]头插入5执行流程newNode(5).next [1][1].prev newNode(5)head newNode(5)最终head→[5] ←→ [1] ←→ [2] ←→ [3]⚠️致命易错点不能先执行head newNode一旦先修改head原有整条链表的引用直接丢失造成内存泄漏必须先建立双向连接再移动head指针。方法2addLast(int val) 尾部插入元素public void addLast(int val) { DLinkedNode newNode new DLinkedNode(val); if (head null) { head newNode; return; } DLinkedNode cur head; while (cur.next ! null) { cur cur.next; } cur.next newNode; newNode.prev cur; }逐行详细讲解DLinkedNode newNode new DLinkedNode(val);创建孤立新结点。if (head null)空链表场景直接让head指向新结点方法结束。DLinkedNode cur head;定义遍历指针cur从头结点开始向后查找。while (cur.next ! null) { cur cur.next; }循环条件解释只要当前结点还有下一个结点代表cur还不是最后一个结点指针持续后移循环终止条件cur.next null→ cur指向链表最后一个结点。cur.next newNode;原尾结点的后继指向新结点向右建立连接newNode.prev cur;新结点的前驱指向原尾结点向左建立连接示例原链表[1] ←→ [2] ←→ [3]尾插入4循环结束cur指向[3]cur.next [4][4].prev [3]最终链表[1] ←→ [2] ←→ [3] ←→ [4]缺点代码无tail尾指针每次尾插都需要遍历整条链表时间复杂度O(n)。方法3toString() 正向反向双向打印调试核心方法Override public String toString() { StringBuilder stringBuilder new StringBuilder(); stringBuilder.append([); DLinkedNode tail null; for (DLinkedNode cur head; cur ! null; cur cur.next) { tail cur; stringBuilder.append(cur.val); if (cur.next ! null) { stringBuilder.append(,); } } stringBuilder.append(] | [); for (DLinkedNode cur tail; cur ! null; cur cur.prev) { stringBuilder.append(cur.val); if (cur.prev ! null) { stringBuilder.append(,); } } stringBuilder.append(]); return stringBuilder.toString(); }逐行详细讲解StringBuilder stringBuilder new StringBuilder();字符串拼接容器循环拼接字符串性能远高于直接拼接。stringBuilder.append([);拼接字符串开头左中括号。DLinkedNode tail null;临时变量用来保存链表最后一个结点。第一层for循环正向遍历链表cur head从头出发循环条件cur ! null每次cur cur.next依靠后继指针向后走。每一轮执行tail cur;循环全部结束后tail一定保存链表最后一个结点。拼接当前结点数值if(cur.next ! null)判断不是最后一个结点追加逗号避免字符串末尾多出逗号。stringBuilder.append(] | [);分隔正向遍历结果和反向遍历结果。第二层for循环反向遍历链表双向链表独有功能单向链表无法实现cur tail从尾部结点出发依靠cur cur.prev前驱指针向前遍历。拼接右中括号返回最终字符串。输出样例[1,2,3,4] | [4,3,2,1]可以直观验证prev、next双向指针是否正常绑定。方法4size() 统计链表结点个数public int size() { int size 0; for (DLinkedNode cur head; cur ! null; cur cur.next) { size; } return size; }逐行详细讲解int size 0;结点计数器初始化为0循环从头正向遍历链表每访问到一个结点计数器size自增遍历结束返回链表总结点数量。⚠️缺陷每次获取长度必须遍历链表优化方案增加成员变量size每次add/remove时手动±1不需要循环。方法5add(int index, int val) 指定下标插入结点public void add(int index, int val) { int size size(); if (index 0 || index size) { throw new IndexOutOfBoundsException(下标越界: index index); } if (index 0) { addFirst(val); return; } if (index size) { addLast(val); return; } DLinkedNode cur head; for (int i 0; i index; i) { cur cur.next; } DLinkedNode prev cur.prev; DLinkedNode newNode new DLinkedNode(val); prev.next newNode; newNode.prev prev; newNode.next cur; cur.prev newNode; }逐行详细讲解int size size();获取当前链表结点总数。if (index 0 || index size)下标合法性校验。链表插入合法区间0 ≤ index ≤ sizeindex size等价于尾部插入。下标非法直接抛出越界异常。index 0代表头部插入直接调用已经写好的addFirst复用代码index size代表尾部插入直接调用addLast。剩下场景在链表中间位置插入结点DLinkedNode cur head;for (int i 0; i index; i) { cur cur.next; }循环结束后cur指向原本占据index下标位置的结点。DLinkedNode prev cur.prev;prev就是index位置结点的前一个结点。创建新结点newNode。prev.next newNode; newNode.prev prev;绑定前驱结点 ↔ 新结点newNode.next cur; cur.prev newNode;绑定新结点 ↔ 原index位置结点举例链表[1,2,4]index2插入数值3循环结束cur指向结点4prev指向结点2建立双向链接后链表变为[1,2,3,4]。方法6contains(int value) 判断元素是否存在public boolean contains(int value) { for (DLinkedNode cur head; cur ! null; cur cur.next) { if (cur.val value) { return true; } } return false; }正向遍历链表只要找到结点数值和目标value相等立刻返回true遍历完整链表依旧没有匹配返回false。方法7indexOf(int value) 查询目标数值对应的下标public int indexOf(int value) { int index 0; for (DLinkedNode cur head; cur ! null; cur cur.next) { if (cur.val value) { return index; } index; } return -1; }int index 0下标计数器从头遍历链表匹配成功直接返回当前下标遍历结束没有找到目标约定返回-1。方法8removeFirst() 删除头部结点public void removeFirst() { if (head null) { return; } if (head.next null) { head null; return; } head head.next; head.prev null; }三种场景拆分讲解场景1head null空链表无结点可以删除直接return场景2head.next null链表只有唯一一个结点执行head null链表直接清空场景3链表拥有多个结点head head.next;head指针移动到第二个结点head.prev null;切断新头结点向前指向旧头结点的引用。示例原链表[1] ←→ [2] ←→ [3]head原本指向结点1head移动到结点2结点2.prev null最终链表[2] ←→ [3]。⚠️重点区别单向链表单向链表头删不需要head.prev null双向链表必须清除前驱引用否则新旧结点依旧相连。方法9removeLast() 删除尾部结点public void removeLast() { if (head null) { return; } if (head.next null) { head null; return; } DLinkedNode tail head; while (tail.next ! null) { tail tail.next; } DLinkedNode prev tail.prev; prev.next null; }场景1空链表直接return场景2链表只有一个结点head置空清空链表场景3多个结点循环遍历找到最后一个结点tailDLinkedNode prev tail.prev;prev代表倒数第二个结点prev.next null;切断倒数第二个结点指向尾结点的引用尾结点脱离链表等待垃圾回收。方法10remove(int index) 根据下标删除结点public void remove(int index) { int size size(); if (index 0 || index size) { throw new IndexOutOfBoundsException(下标越界: index index); } if (index 0) { removeFirst(); return; } if (index size - 1) { removeLast(); return; } DLinkedNode prev head; for (int i 0; i index - 1; i) { prev prev.next; } DLinkedNode toDelete prev.next; DLinkedNode next toDelete.next; prev.next next; next.prev prev; }逐行讲解获取链表长度校验下标删除合法区间0 ≤ index size。index0 调用头删index size-1代表删除最后一个结点调用尾删。中间结点删除循环走到index-1位置prev指向待删除结点的前一个结点。toDelete prev.next待删除结点next toDelete.next待删除结点的后一个结点prev.next next; next.prev prev;直接绕过待删除结点把前后两个结点双向相连。待删除结点没有任何外部引用自动被GC回收。注意这里提前处理了头删、尾删所以prev、next一定不会为null不需要额外判空。方法11removeByValue(int value) 删除第一个匹配数值的结点public void removeByValue(int value) { if (head null) { return; } if (head.val value) { removeFirst(); return; } DLinkedNode cur head; for (; cur ! null; cur cur.next) { if (cur.val value) { break; } } if (cur null) { return; } DLinkedNode prev cur.prev; DLinkedNode next cur.next; if (prev ! null) { prev.next next; } if (next ! null) { next.prev prev; } }逐行讲解head null空链表直接返回if (head.val value)要删除的结点是头结点直接调用removeFirst循环遍历链表寻找val匹配的结点cur循环结束后cur null代表找不到目标元素直接return找到目标结点cur获取前驱prev、后继nextif (prev ! null)和if (next ! null)必须做判空代码只提前特殊处理了头结点cur有可能是链表最后一个结点此时nextnull。如果直接执行next.prev prev会触发空指针异常。对比remove(index)remove(index)提前把头尾场景过滤不需要判空本方法只过滤头结点必须增加非空判断。main测试入口public static void main(String[] args) { MyDLinkedList list new MyDLinkedList(); list.addLast(1); list.addLast(2); list.addLast(3); list.addLast(4); list.removeByValue(3); list.removeByValue(100); System.out.println(list); }执行流程依次尾插1、2、3、4删除值为3的结点尝试删除不存在的100无任何操作打印链表。预期输出[1,2,4] | [4,2,1]总结双向链表增删操作prev、next两条指针都要维护少一条链表直接断裂当前实现无dummy傀儡头结点操作头部结点时需要单独处理head引用当前无tail尾指针尾部操作需要O(n)遍历工程优化建议增加tail成员变量访问结点引用如node.prev、node.next前警惕空指针异常区分是否已经排除头尾场景双向链表最大优势已知任意结点可以O(1)获取前驱单向链表无法直接获取前驱。今天的数据结构讲解就到这了我们下期再见诸位共勉