欢迎光临
我们一直在努力

三.循环链表

一、基础概念类

提问:循环链表和普通线性链表的核心区别?有什么优劣?适用场景?

回答:

可以把普通线性链表理解成直线跑道,有明确的起点和终点,跑到头就结束;循环链表是环形跑道,跑完一圈会回到起点,首尾相连。

核心优劣对比

维度普通线性链表循环链表
遍历逻辑 遇 null 终止,单向遍历 遇头节点终止,可循环遍历
首尾访问 单链表尾访问需 O (n) 遍历 带头哨兵可 O (1) 直接访问首尾
边界复杂度 空链表、头尾操作需单独判断 哨兵模式下无特殊边界,逻辑统一
内存开销 和对应单 / 双向链表一致 仅多 1 个哨兵节点,开销可忽略
适用场景 通用线性存储 环形遍历、周期调度、循环淘汰类场景

两类循环链表的分工

单向循环链表:适合仅需正向循环遍历、操作简单的场景(如约瑟夫环)

双向循环链表:工业级首选,兼顾双向遍历 + O (1) 首尾操作,绝大多数工程场景用这个

二、核心操作类

节点定义

// 单向循环链表
struct CListNode {
   int val;
   CListNode* next;
   CListNode(int v) : val(v), next(nullptr) {}
};

// 双向循环链表
struct CDListNode {
   int val;
   CDListNode* next;
   CDListNode* prev;
   CDListNode(int v) : val(v), next(nullptr), prev(nullptr) {}
};

初始化

DCListNode* initDCList() {
   DCListNode* head = new DCListNode(0);
   head->next = head;
   head->prev = head;
   return head;
}

头部插入节点(O (1))

void addToHead(DCListNode* head, DCListNode* node) {
   node->prev = head;
   node->next = head->next;
   
   head->next->prev = node;
   head->next = node;
}

尾部插入节点(O (1))

void addToTail(DCListNode* head, DCListNode node) {
   node->next = head;
   node->prev = head->prev;
   head->prev->next = node;
   head->prev = node;
}

删除指定节点(O (1))

void removeNode(DCListNode* node) {
   node->prev->next = node->next;
   node->next->prev = node->prev;
   
   node->next = nullptr;
   node->prev = nullptr;
}

遍历

void traverse(DCListNode* head) {
   DCListNode* curr = head->next;
   while(curr != head) {
       curr = curr->next;
  }
}

三、进阶理解类

提问:循环链表最容易踩的坑是什么?怎么规避?

回答:

最高频的两个坑:

  • 遍历死循环:忘记循环链表没有 null 尾节点,沿用普通链表的curr != nullptr做终止条件,直接陷入死循环。规避方法:统一用头哨兵做终止标记,写遍历前先明确结束条件。

  • 首尾节点指针维护遗漏:不带头哨兵的循环链表,插入 / 删除头 / 尾节点时,容易忘记修改尾节点的 next 指针,导致环断开。规避方法:工程实现一律加头哨兵,所有操作都等价于中间节点操作,逻辑完全统一。

  • 提问:约瑟夫环问题为什么首选循环链表实现?

    回答:

    约瑟夫环的核心逻辑是「环形循环计数 + 定点删除」,和循环链表的特性完全匹配:

    • 天然支持环形遍历,不用手动处理到头折返的逻辑

    • 删除节点只需修改指针,不用挪动数据,时间效率高

    • 双向循环链表可以 O (1) 删除当前节点,单向循环链表也只需找到前驱即可删除

    如果用数组实现,每次删除元素都要挪动后续数据,时间复杂度高很多;用普通链表要自己处理尾接头的环形逻辑,代码冗余。

    四、项目实战知识

    经典算法实战

    约瑟夫环(面试手写高频题)

    题目:n 个人围成一圈,从第 1 个人开始报数,报到 m 的人出列,求最后剩下的人的编号。

    // 单向循环链表实现
    int lastRemaining(int n, int m) {
       if(n == 0) return -1;
       
       // 1. 构建带哨兵的单向循环链表
       CListNode* head = new CListNode(0);  // 头哨兵
       CListNode* curr = head;
       for(int i = 1; i <= n; i ++ ) {
           curr->next = new CListNode(i);
           curr = curr->next;
      }
       curr->next = head;   // 尾节点接哨兵,完成成环
       
       CListNode* prev = head;  // 待删除节点的前驱
       int count = 0;
       while(head->next != head) {
           if(count == m – 1) {
               CListNode* del = prev->next;
               prev->next = del->next;
               delete del;
               count = 0;
          } else {
               prev = prev->next;
               count ++ ;
          }
      }
       int res = prev->val;
       delete head;
       return res;
    }

    提醒:链表版逻辑直观贴合考点,时间复杂度 O (n*m),适合 n 不大的场景;n 极大的场景可补充数学公式法做优化。

    核心项目应用:时间轮定时器

    场景说明:后端开发中高频使用的定时器组件(如 Netty、Kafka、Skynet、Redis 的定时任务),绝大多数基于循环链表实现的时间轮,是循环链表最核心的工业落地场景。

    实现原理

    • 用循环结构(数组模拟环形槽位,每个槽位挂一条定时任务链表)代表时间轮,每个槽位对应一个时间刻度

    • 指针按固定时间步长在环形槽位上循环移动,走到哪个槽位,就执行该槽位里的所有到期定时任务

    • 新增定时任务时,根据超时时间计算对应的槽位,挂到对应链表上

    为什么用循环链表结构?

    • 时间是周期性的,环形结构天然适配时间的循环特性,不用频繁重置指针

    • 新增 / 删除定时任务都是 O (1) 的链表操作,到期批量执行也是 O (k)(k 为当前槽任务数),性能远高于用堆实现的定时器

    • 高并发场景下,时间轮的精度和吞吐量都能满足服务端调度需求

    极简单层时间轮框架

    #include <list>
    #include <vector>
    #include <functional>
    using namespace std;

    class SimpleTimeWheel {
    private:
       int slotNum;          // 槽位总数,例如60个槽对应60秒
       int currentSlot;      // 当前指针指向的槽位
       vector<list<function<void()>>> slots;       // 每个槽位对应一条任务链表,数组索引模拟循环
       
    public:
       SimpleTimeWheel(int n) : slotNum(n), currentSlot(0) {
           slots.resize(slotNum);
      }
       
       // 添加定时任务,delay秒后执行
       void addTask(int delay, function<void()> task) {
           int targetSlot = (currentSlot + delay) % slotNum;
           slots[targetSlot].push_back(task);
      }
       
       // 每过1个时间刻度走一步,执行当前槽的到期任务
       void tick() {
           for(auto& task : slots[currentSlot]) {
               task();
          }
           slots[currentSlot].clear();
           currentSlot = (currentSlot + 1) % slotNum;
      }
    }

    其他常见落地场景

    操作系统时间片轮转调度:就绪进程排成双向循环链表,CPU 按时间片依次调度,跑完一圈回到队首

    环形消息队列:用双向循环链表实现无界环形队列,适配生产者消费者模型,头尾操作都是 O (1)

    游戏回合制战斗:角色排成循环链表,按顺序轮流行动,天然适配回合循环逻辑

    令牌桶限流:用循环结构维护令牌生成周期,循环补充令牌

    赞(0)
    未经允许不得转载:171主机测评 » 三.循环链表
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址