いくつかのステップを踏む、そして千マイルも-あなたはこれらのリンクリストの基本をマスターする必要があります

1はじめに

リンクリストは、物理ストレージユニット上の非連続および非順次のストレージ構造であり、データ要素の論理的な順序は、リンクリスト内のポインタのリンク順序によって実現されます。リンクリストは一連のノードで構成され(リンクリストの各要素はノードと呼ばれます)、ノードは実行時に動的に生成できます。リンクリストは連続した線形データ構造ではないため、リンクリストを使用するプログラムが比較的メモリの少ないマシンで実行される場合、配列よりも大きな利点があります。

2.定義

リンクリストの場合、このように定義することがよくあります。ここで、valueはリンクリストノードの値を表すために使用され、次のポインタは後続ノードを示すために使用されます(ノード値が保存され、次のノードにマークを付けることができます)、存在しない場合(null)は、リンクリストが終了することを意味します。

interface Node<T> {
    // 节点值
    value: T;
    // 下一个节点的引用
    next: Node<T> | null;
}
复制代码

3、リンクリストの操作

リンクリストの操作については、個人的には2つの最も重要な操作は合計だと感じています。LeetCodeの質問初始化遍历観点からすると、質問の半分以上はこのようなものです。リンクリストの操作については、頭の中に空のノードを置くのが好きな学生もいますが、これは個人的なプログラミングの習慣ですが、私は一般的にそれが好きではありません(メモリを節約するという考えからではありません、ハハハ)。あなたに合った習慣を選んでくださいそれだけです この記事のすべてのコード実装には、空のヘッダーノードが含まれていません。

3.1。初期化

最初に初期化について説明しましょう。つまり、リンクリストに大量のデータを作成します。リンクリストの初期化には、通常、との2つの操作があり头插法ます尾插法

ヘッド挿入方法:新しいノードがヘッドノードの前に挿入されるたびに、変数を使用して既存のリンクリストのヘッドノードをポイントし、この変数が新しく挿入されたノードをポイントするようにし头插法构建出的链表数据的顺序和输入的顺序是相反ます。

ヘッド挿入法によるノード挿入のプロセス:头插法.pngアルゴリズムは次のように実装されます。

/**
 * 以头插法初始化链表
 * @param {Array<number>} arr 用于初始化链表的数据
 */
function initialize(arr) {
  // 如果图中第一步
  let head = null;
  // 此例中无用,仅用于阐述问题
  let tail = null;
  arr.forEach((val) => {
    const node = createNode(val);
    // 如果是空链表的话,直接让头结点指针指向第一个节点
    if (head == null) {
      // 如图第二步  
      head = node;
      // 此例中无用,仅用于阐述问题
      tail = node;
    } else {
      //先让新来的节点指向head节点,如图第三步
      node.next = head;
      // 再让head指针指向最新的节点,如图第四步
      head = node;
    }
  });
  return head;
}
复制代码

テール挿入方法:新しいノードがテールノードの後に​​挿入されるたびに、変数を使用して既存のリンクリストのテールノードをポイントし、この変数が新しく挿入されたテールノードをポイントするようにし尾插法构建出的链表数据的顺序和输入的顺序是相同的ます。

テール挿入法によるノード挿入のプロセス:尾插法.pngアルゴリズムは次のように実装されます。

/**
 * 以尾插法初始化链表
 * @param {Array<number>} arr 用于初始化链表的数据
 */
function initialize(arr) {
  // 如图第一步
  let head = null;
  let tail = null;
  arr.forEach((val) => {
    const node = createNode(val);
    if (head == null) {
      // 如果是空链表的话,直接让头结点指针和尾结点指针指向第一个节点,对应图中第一步
      head = node;
      tail = node;
    } else {
      // 让尾结点的后继指针指向新来的节点, 如图第三步
      tail.next = node;
      // 让尾节点指针指向最后一个节点,如图第四步
      tail = node;
    }
  });
  return head;
}
复制代码

我个人编程习惯喜欢用第二种,但是即使是使用头插法,你也可以多用一个变量来记住链表的尾结点,这样的好处就是如果某个时刻你需要在尾部插入的话,可以直接用尾节点指针而不用再去遍历了。

3.2、链表的遍历

链表的遍历几乎可以说是一种标准范式了,这是对于链表一定得掌握的知识。对于单向链表,只要给头指针就可以完成遍历。

在遍历链表时,我们有时候会申明一个前驱节点,这样可以使得在遍历的过程中,既能找到当前节点,又可以找到当前节点的前驱节点,在某些时候非常好用,这也是一个必须掌握的编程技巧。

需要注意的是在遍历链表的过程中不要修改头指针,因为一旦修改了头指针就找不回来了,万一需要用到头指针,那代码又得重新设计。

链表的遍历过程: 链表的遍历.png

链表遍历的复杂度为O(N);

算法实现如下:

/**
 * 遍历链表
 * @param {Node<number>} head 链表头指针
 */
function traverse(head) {
  let node = head;
  // pre在本例中无用,仅用于说明这是一种编程技巧
  let pre = null;
  // 如果当前节点指向空 (对于空链表,开始就直接指向空)
  while (node) {
    console.log(node);
    // 让pre滞后,这样可以永远保证pre指向node的前一个节点(如果node是null,pre指向最后一个节点,如果node是第一个节点或者链表是空表,pre指向null)
    pre = node;
    node = node.next;
  }
}
复制代码

3.3、在指定位置插入节点

对于链表的操作操作一定要谨慎,否则容易丢失后继节点或者使得链表的节点指向表现非预期。

我们演示一下在表中部插入节点的场景,其流程如下: 插入1.png 插入2.png 伪代码描述即:newNode.next = node; pre.next = newNode; 这两行代码一定不能交换。

链表插入的平均时间复杂度为:O(N)

算法实现如下:

/**
 * 在链表中指定的K位置插入节点, 如果K小于1,则插在头部,如果K大于链表的长度,则直接插在尾部
 * @param {Node<number>} head 链表头
 * @param {number} val 节点值
 * @param {number} K 插入的位置,K为节点数,不是索引
 */
function insert(head, val, K) {
  const newNode = createNode(val);
  // 如果需要插在头部的话
  if (K < 1) {
    newNode.next = head;
    head = newNode;
    return head;
  }
  let node = head;
  // 申明一个空指针,因为其滞后node一个表结点,主要是用来记录上一个节点
  let pre = null;
  // 申明一个计数器,用于标记已经遍历的节点的个数
  let counter = 0;
  let inserted = false;
  while (node) {
    counter++;
    // 如果找到了合适的插入位置,插入完成以后就没有继续循环的必要了
    if (counter === K) {
      // 必须先用一个临时变量将其记住,否则会丢失后继节点
      let nextNode = node.next;
      // 插入新的节点
      node.next = newNode;
      newNode.next = nextNode;
      // 标记插入完成
      inserted = true;
      break;
    }
    // 先把当前这个节点记住,然后向后迭代
    pre = node;
    node = node.next;
  }
  // 如果已经插入了的哈,就不用再管什么事儿了
  if (inserted) {
    return head;
  }
  // 如果K大于等于链表的长度的话,就直接插在链表尾部即可
  if (counter < K && pre) {
    // 此刻的node已经是null了,而pre指针指向链表的最后一个节点
    pre.next = newNode;
  } else {
    // 如果链表是空表,之前的循环一次都没有执行的,那么直接让head指向新来的节点即可
    head = newNode;
  }
  return head;
}
复制代码

3.4、查找

查找主要分为按值查找或者按位置查找。 查找的思路和遍历类似,因此此处就不再赘述其算法流程。

查找的平均算法复杂度为O(N)。

算法实现如下:


/**
 * 根据索引查找链表节点
 * @param {Node<number>} head 链表头结点
 * @param {number} idx 目标索引
 */
function findIndex(head, idx) {
  let node = head;
  let counter = 0;
  // 找到表尾没有找到目标索引 或者 找到了目标索引 结束循环
  while (node && counter < idx) {
    counter++;
    node = node.next;
  }
  return counter === idx ? node : null;
}

/**
 * 根据节点值查找节点
 * @param {Node<number>} head 链表头结点
 * @param {number} val 目标节点值
 */
function find(head, val) {
  let node = head;
  // 找到节点值或遍历到链表结束,终止循环
  while (node && node.val !== val) {
    node = node.next;
  }
  return node;
}
复制代码

3.5、删除

链表的删除相对来说比较简单,直接拿掉特定节点即可,这一点,相对于数组来说有优势的,因为在数组删除元素后,需要把元素统统往前挪动一位,然后才能把size减少,当数据的每个单元是一个复杂结构的时候,这个时间的开销可是不能忽略的

链表节点的删除过程: 删除1.png 删除2.png 删除过程中还需要考虑一些边界情况,对于空表无序任何操作;如果删除头结点,需要修改链表头结点指针;

另外还需要注意的是,链表删除节点的时候,这些语句的顺序是不能交换顺序的,如果交换了顺序就不符合预期了,读者可以自行体会一下。

链表删除时我们若不考虑查找的时间复杂度的话,其时间复杂度为O(1)。

算法实现如下:

/**
 * 从链表中删除值为val的节点
 * @param {Node<number>} head 链表的头结点
 * @param {number} val 待删除的值
 */
function remove(head, val) {
  if (!head) {
    console.warn("can not remove element from empty linked list");
    return;
  }
  let node = head;
  let pre = null;
  while (node) {
    // 找到了目标节点,需要结束循环
    if (node.value != val) {
      break;
    }
    pre = node;
    node = node.next;
  }
  // 如果pre存在的话,说明用户删除的不是头结点
  if (pre) {
    // 如图第二步
    pre.next = node.next;
    // 如图第三步
    node.next = null;
    node = null;
  } else {
    // 删除头结点时,首先得用临时变量把第二个节点先记下来(哪怕它不存在)
    let nextHead = head.next;
    // 解除头结点对第二个节点的引用
    head.next = null;
    // 让头结点指针指向下一个节点
    head = nextHead;
  }
  return head;
}
复制代码

4、扩展

本文不阐述双向链表和循环链表,如果你能够掌握上述知识的话,对于双向链表和循环链表也不在话下,有兴趣的读者可以自行查阅资料。

本节选取一些高频面试题向大家分析思路及解法。

4.1、反转链表

206题。

这道题其实考察你对头插法和尾插法的理解,可以在时间复杂度为O(N)的情况下实现。

我们在遍历链表的过程中,使用头插法构建新的链表,当原始链表遍历完成的时候,新链表也构建完成,即完成链表反转。

算法实现如下:

/**
 * 对指定链表进行反转
 * @param {Node<number>} head 待反转链表头节点
 */
function reverseList(head) {
  let reverseList = null;
  let node = head;
  while (node) {
    let nextNode = node.next;
    node.next = null;
    if (reverseList === null) {
      reverseList = node;
    } else {
      node.next = reverseList;
      reverseList = node;
    }
    node = nextNode;
  }
  return reverseList;
}
复制代码

4.2、对链表进行插入排序

147

对于数组的排序因为我们可以直接通过下标访问到数组元素,链表相对数组来说,就稍微要复杂一些了。本文以插入排序简单阐述一下链表排序的一种实现方式。

插入排序的思路跟我们打扑克很相似,在摸牌的过程中,我们第一张牌摸上来的时候,是可以直接插入的,也就是说第一张牌自认为有序,后面我们每摸上来一张牌,我们都需要从最后一张牌开始比较,如果待插入的牌比当前选择的牌小的话,说明这个牌还不能插在这个位置,我们得把当前选择的牌往后错一位,然后继续向前比,重复这个过程,直到找到合适的位置。算法流程可以参考这里

但是对于链表来说,这个过程是反的。

首先,对链表进行遍历这是肯定必不可少的,那么先拿出我们的链表遍历范式代码。

一边遍历原链表,一边构建新链表。

因为链表节点的插入,不像数组那么麻烦,数组需要错位,而链表是可以直接插入的,但需要在新表中找到合适的插入位置,这必须要从新表的头开始找。

这个算法的时间复杂度和数组的插入排序平均时间复杂度是一样的,即O(N²)。

这个算法过程本文就不给出执行的过程图了,如果有任何疑问可以直接查看LeetCode的官方讲解或者联系作者。

算法实现如下:

/**
 * Definition for singly-linked list.
 * function ListNode(val, next) {
 *     this.val = (val===undefined ? 0 : val)
 *     this.next = (next===undefined ? null : next)
 * }
 */
/**
 * 对链表进行直接插入排序   
 * @param {ListNode} head
 * @return {ListNode}
 */
function insertionSortList(head) {
  if (!head) {
    return head;
  }
  // 待构建的新表
  let newHead = null;
  let node = head;
  while (node) {
    let nextNode = node.next;
    node.next = null;
    if (newHead === null) {
      newHead = node;
    } else {
      let sortNode = newHead;
      let preSortNode = null;
      // 在新表中找到合适的插入位置
      while (sortNode && sortNode.val <= node.val) {
        preSortNode = sortNode;
        sortNode = sortNode.next;
      }
      // 如果插在第一个节点上的话
      if (preSortNode === null) {
        node.next = newHead;
        newHead = node;
      } else {
        let preNext = preSortNode.next;
        preSortNode.next = node;
        node.next = preNext;
      }
    }
    node = nextNode;
  }
  // 最后返回新表即可完成排序
  return newHead;
}
复制代码

4.3、K 个一组翻转链表

25

据说这道题是某一年微软的面试原题。可以看到,这是一道困难难度的题,千万别被吓破了胆,当你真正的理解了链表,困难其实也就那样,哈哈哈(膨胀)。

这道题同样还是考查链表的插入操作,但是难度就在于引入了翻转条件。我们先考虑思考一些边界条件,首先:如果传入的链表是空的或者以K=1为一组进行翻转的话,是啥事儿也不用做。如果翻转完n组件之后,还剩下一些节点,按题目要求原样输出即可。

接着考虑一下实现思路,对链表的遍历是必不可少的,我们需要在遍历的时候增加一个计数器(记为groupCounter),当计数器为0的时候,我们需要初始化一个变量(记为groupStartNode)以后方便记住开始反转的链表头节点,如果计数器在某个时刻等于K的话,说明这个时刻,从groupStartNode到当前这个节点的链表都可以进行反转,我们已经知道如何反转一个链表,为了简化问题,先把当前节点的后继节点断开,当然,为了防止后继节点丢失,断开之前得先用一个变量nextNode记住它,反转完了之后,再通过它给接回来,然后还要记得把groupCountergroupStartNode初始化,这样一组反转就完成了。如果链表已经遍历完成了,groupCounter还是小于K, 即一组也不需要反转,也符合题意。

算法流程如下: K-链表反转0.png K-链表反转1.png K-链表反转2.png

算法实现如下:

/**
 * 以K为一组将链表翻转
 * @param {Node<number>} head 待翻转的链表表头
 * @param {number} k 翻转的每组大小
 * @returns {Node<number>} 被翻转的链表
 */
var reverseKGroup = function(head, k) {
  // 链表如果是空 或者 如果 反转节点是1 自然是不需要翻转的
  if (!head || k == 1) {
    return head;
  }
  let newHead = null;
  let pre = null;
  let node = head;
  let groupHead = head;
  let groupCounter = 0;
  // 开始对链表进行遍历
  while (node != null) {
    groupCounter++;
    // 说明当前满足K个一组了,需要对其进行反转了。
    if (groupCounter === k) {
      let nextNode = node.next;
      node.next = null;
      // 转化为和题目1完全一样的过程
      const reverse = reverseList(groupHead);
      // 是否是第一组翻转
      if (!newHead) {
        newHead = reverse;
      } else {
        pre.next = reverse;
      }
      // 因为翻转完成之后,pre刚好就是新链表的最后一个节点,即剩余待翻转链表的前一个节点
      pre = groupHead;
      // 之前为了简化问题,之前我们有断开的操作,现在可以把它接回来了。
      groupHead.next = nextNode;
      // 下一个节点即是下一轮开始翻转的节点
      groupHead = nextNode;
      // 因为已经完成了翻转,计数器需要归零
      groupCounter = 0;
      node = nextNode;
    } else {
      node = node.next;
    }
  }
  return newHead || head;
};

/**
 * 反转链表
 * @param {Node<number>} head 链表表头
 * @returns 
 */
function reverseList(head) {
  let reverseHead = null;
  while (head != null) {
    let next = head.next;
    head.next = reverseHead;
    reverseHead = head;
    head = next;
  }
  return reverseHead;
}

复制代码

总结

链表相对于数组还有一个优势是在内存足够的前提下,其长度是可以无限增长的。链表在初始化的的时候无需知道表长,而数组必须确定表长(此性质不考虑JavaScript语言),对于其它语言来书(C#,Java等)数组的扩容代价相对较大,首先需要向系统申请更长的连续空间(在某些情况下是可能申请不到的),然后需要把旧数据拷贝到新数组里面去,然后再将原来的数组释放,而在每个数据项比较大的情况下,这个拷贝时间是不能被忽略的

链表平均算法复杂度与数组的比较如下:

操作 数组 链表
随机访问 O(1) O(N)
插入 O(N) O(N)
查找 O(N) O(N)
删除(不考虑前置查找) O(N) O(1)

虽然文中大部分都在谈链表相对于数组的优势,但是如果需要对其数据进行排序的话,有些排序算法是不能直接用的,因此在实际开发中我们需要根据需求决定选择用链表还是数据存储数据。

由于笔者水平有限,写作过程中难免出现错误,若有纰漏,请各位读者指正,请联系作者本人,邮箱[email protected],你们的意见将会帮助我更好的进步。本文乃作者原创,若转载请联系作者本人。

おすすめ

転載: juejin.im/post/7079356715472257031