news 2026/10/16 9:43:24

Android自定义View实现思维导图:递归布局与堆栈清除实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Android自定义View实现思维导图:递归布局与堆栈清除实战

简介:面向Android开发者的思维导图完整项目,巧妙结合递归算法与堆栈数据结构,解决多层级节点绘制、顺序遍历与清除回溯问题。工程从自定义视图重写绘制方法切入,涉及节点模型设计、触摸事件处理、展开折叠与增删节点等交互逻辑,覆盖布局管理、动画效果与性能优化等完整实现路径,适合想深入理解Android绘图机制和数据结构应用的开发者。压缩包共43个文件,以Java核心源码、XML界面布局、Gradle构建配置、PNG/JPG图片素材及项目说明文档为主,整体仅279KB,轻量紧凑。已有259人学习下载。项目代码结构清晰,可帮助读者快速掌握利用递归与堆栈处理树形界面的思路,并可直接参考其节点展开折叠、增删改动等交互设计,用于自己的项目改造或学习演练。同时,资源中附带的README和目录组织也为调试排错提供了便利。

1. 一个原生绘制的思维导图控件:递归布局加上堆栈清除

做笔记类App最头疼的事,是在“Android开发-其它控件”里挑一个不吃性能又有自由度的思维导图控件。列表能用RecyclerView,但多级节点的坐标换算、父子连线、子树删除,基础控件拼不出来。这个项目没引图表库,只用自定义View加一棵Node树:节点绘制走递归算法,节点清除改用堆栈做非递归遍历。同一棵树,画用递归、删用栈,恰好把“画得对”和“删得净”都解决了。

项目适合两类人:想在手写自定义View里看数据结构落地的开发者,以及做笔记、知识库工具、需要多层级自由布局又不想上重型依赖的你。工程拆开只有模型、测量、绘制、清除几条线,拿来即用,改编辑器也不难。

整体印象:根节点在最左,子节点逐级向右展开,贝塞尔曲线连接;间距、颜色、文本样式都走XML属性;删一个节点会连根拔掉整棵子树,对象池负责复用防内存抖动。

2. 架构与运行准备:从数据模型到首帧画面

2.1 工程结构与数据模型

加载完整工程后,核心代码集中在四个文件里:

app/src/main/java/... ├── model/Node.java // 树节点模型,保存坐标与测量缓存 ├── view/MindMapView.java // 自定义View,负责测量、布局、绘制与清除 ├── helper/LayoutCalculator.java // 测量与定位的两个递归入口 └── MainActivity.java // 接入示例,从JSON构建树并设置根节点

我的习惯是把测量和定位从View里抽出去,单独做成LayoutCalculator。这样后序遍历、前序遍历的逻辑可以单独做单元测试,不依赖Android的Looper和View系统。Node模型是整个控件的基石,字段不多但各司其职:

public class Node { public String id; // 节点唯一标识,用于命中检测 public String text; // 节点显示文本 public Node parent; // 父节点引用,根节点的 parent 为 null public List<Node> children = new ArrayList<>(); // 子节点列表 public int x, y; // 节点矩形左上角在画布坐标系中的位置 public int width, height; // 节点矩形尺寸,文本+内边距计算得出 public int level; // 层级深度,根为 0,用于颜色渐变 public int subWidth, subHeight; // 以该节点为根的子树横/纵向跨度,测量阶段缓存 public boolean recycled; // 标记已被回收,绘制时跳过 }

x、y是画布坐标系下的绝对坐标,不是相对父节点的偏移。因为onDraw阶段还需要用这两个值画连线的起止点,如果存的是相对偏移,画之前要先做累加换算,容易乱。subWidth和subHeight是测量阶段的输出,也是布局阶段的输入:先通过后序遍历算出整棵子树的占位尺寸,再在前序遍历时把每个节点摆到具体坐标上。recycled字段配合第4章的对象池使用,避免已经删除的节点被onDraw意外画出来。

2.2 一帧画面背后的三个步骤

从setRootNode到屏幕上出现第一帧,整个过程是固定的三步。

第一步,用户在Activity里把数据组装成Node树,调用mindMap.setRootNode(root)。第二步,MindMapView内部先调measure(root)做后序遍历,算出每个节点的文本宽度、节点高度,以及以它为根的子树占用的总宽总高。第三步,调layout(root, x, y)做前序遍历,从上到下、从左到右地为每个节点分配坐标,最后invalidate()触发系统在下一帧调用onDraw。

这三个步骤在代码里的入口长这样:

public void setRootNode(Node root) { this.root = root; if (root != null) { calculator.measure(root); calculator.layout(root, getPaddingLeft(), getPaddingTop()); requestLayout(); invalidate(); } }

requestLayout()触发布局系统重新计算View本身的位置,invalidate()触发重绘。如果你只在自定义View内部操作树结构,这两个调用缺一不可,只调invalidate()的话,控件尺寸变化时可能不会触发重新绘制。

整棵树的测量、布局、绘制完全由Java层递归完成,不依赖RecyclerView的Adapter,也没有Item布局和复用机制。这样做的好处是节点数量在几百个之内时绘制性能非常好,坏处是没有现成的滚动与回收机制,节点超多时需要自己套ScrollView或用平移手势。控件的定位就是轻量、可定制,适合做中大型知识结构展示,不适合做无限画布级的巨型导图。

2.3 接入配置与首次运行

把工程导入Android Studio后,先用模拟器或真机跑一遍,确认看到的初始demo是一棵三层的主题导图。控件在布局文件里这样声明:

<com.example.mindmap.view.MindMapView android:id="@+id/mindMap" android:layout_width="match_parent" android:layout_height="match_parent" app:nodeTextColor="#333333" app:nodeBgColor="#FFF3E0" app:levelSpace="50dp" app:nodePadding="10dp" app:lineColor="#CCBFA8" />

自定义属性在res/values/attrs.xml里定义,MindMapView构造时通过obtainStyledAttributes读取。nodeBgColor是节点圆角矩形的填充色,lineColor是贝塞尔连线的颜色,levelSpace控制相邻两级之间的水平距离,数值越大树越“宽”。这些属性都是运行时可变,后面二次开发做主题切换可以直接复用同一套解析逻辑。

MainActivity中的接入代码只需要三步:构建树、交给控件、让控件自己画。

MindMapView mindMap = findViewById(R.id.mindMap); Node root = buildTreeFromJson(jsonArray); // 解析实现见第6章 mindMap.setRootNode(root);

整个工程基于AndroidX,没有引用任何第三方图表库,依赖面极小。buildTreeFromJson是JSON解析节点树的入口,用系统自带的org.json就能跑通,不需要Gson。嫌JSON麻烦,也可以像demo里那样手写几个new Node()拼成树结构,数据量小时反而更直观。

这份资源里的完整工程包含上面列出的几个核心类、attrs.xml配置和一份可运行的MainActivity示例。拿到手后先跑通默认demo,再对照着改节点数据、调颜色和间距,很快就能看出每个属性对应的画面变化。

3. 核心绘制拆解:递归算法完成测量、定位与连线

3.1 后序遍历测量子树尺寸

画思维导图最忌讳边遍历边画。如果不知道子节点最终占多大空间,同级节点很可能会重叠,父节点也无法居中。所以第一步永远是用后序遍历把整棵树的尺寸算出来,自底向上汇总。LayoutCalculator构造时拿到textPaint和levelSpace,measure方法里直接用:

public void measure(Node node) { if (node == null || node.recycled) { return; } node.width = (int) textPaint.measureText(node.text) + node.padding * 2; node.height = NODE_HEIGHT; if (node.children.isEmpty()) { node.subWidth = node.width; node.subHeight = node.height; return; } int maxChildSubWidth = 0; int totalChildSubHeight = 0; for (Node child : node.children) { measure(child); // 先递归,保证孩子信息先算完 maxChildSubWidth = Math.max(maxChildSubWidth, child.subWidth); totalChildSubHeight += child.subHeight; } node.subWidth = Math.max(node.width, maxChildSubWidth + levelSpace); node.subHeight = Math.max(node.height, totalChildSubHeight); }

measure先递归处理所有子节点,再回头算父节点,这是后序思想。node.subWidth取“自身宽度”和“最宽的那条子分支横向跨度加一段水平间距”中的较大值。node.subHeight是子节点们纵向占用的总和。参数levelSpace对应布局文件里的levelSpace属性,它既要保证父子水平方向不重叠,也直接参与subWidth的计算,所以改这个参数不只是改间距,而是改整棵树的横向密度。

这里有个容易想当然的地方:子节点并不一定是同一高度。某个子节点如果带着三层孙节点,它的subHeight可能等于好几个叶子节点的高度之和。累加每个子节点的subHeight,才能保证第一棵子树和第二棵子树在垂直方向不会互相压住。如果写成children.size() * NODE_HEIGHT,层级一深就会重叠。

提示:若发现整棵树明显偏向右侧,优先检查根节点传入的起始坐标是否用了getPaddingLeft和getPaddingTop。

3.2 前序遍历分配坐标

测量完成后,每个节点的x、y还是脏数据,需要从根开始一层层往里填坐标。这一步用前序遍历,先给当前节点定位,再处理它的所有子节点:

public void layout(Node node, int x, int y) { node.x = x; node.y = y; if (node.children.isEmpty()) { return; } int childX = x + node.width + levelSpace; int childrenTotalHeight = 0; for (Node child : node.children) { childrenTotalHeight += child.subHeight; } // 让整棵子树垂直居中:第一个子节点上边缘的计算是关键 int cursorY = y + node.height / 2 - childrenTotalHeight / 2; for (Node child : node.children) { layout(child, childX, cursorY); cursorY += child.subHeight; } }

childX用当前节点矩形右边缘加上levelSpace得到,所有同级子节点共用这个x,形成清晰的纵向列。cursorY的初始值是这段代码里最容易翻车的地方:它不能直接等于y,而要用父节点高度的一半减去全部子节点总高度的一半,得到“让子节点整体在父节点周围垂直居中”的起始点。

layout函数的参数x、y是绝对画布坐标,不是偏移量。传入的第一个节点通常就是根,坐标来自setRootNode里的getPaddingLeft()和getPaddingTop(),让树从控件的左上内边距开始生长。之后每层递归都在父节点坐标基础上累加levelSpace和subHeight,整体结构始终以根为锚点向右侧展开。

3.3 递归绘制节点与贝塞尔连线

坐标算完,onDraw里做的事情就纯粹了:画线、画矩形、画文字,然后递归画子节点。

@Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); if (root == null) { return; } canvas.save(); canvas.translate(getScrollX(), getScrollY()); drawNode(canvas, root); canvas.restore(); } private void drawNode(Canvas canvas, Node node) { if (node == null || node.recycled) { return; } drawLineToParent(canvas, node); drawNodeShape(canvas, node); drawNodeText(canvas, node); for (Node child : node.children) { drawNode(canvas, child); } }

canvas先做一次translate,把画布原点平移到View的滚动偏移处,这样后面所有节点坐标都直接使用layout阶段算出的绝对坐标,逻辑统一。drawNode是典型的递归前序遍历,每进入一个节点,先画它通往父节点的连线,再画自身矩形和文字,最后遍历子节点。先画线再画矩形的好处是,连线终点会正好压在矩形边缘内,视觉上没有缝隙。

贝塞尔连线是思维导图最出效果的部分,代码也不复杂:

private void drawLineToParent(Canvas canvas, Node node) { if (node.parent == null) { return; } linePath.reset(); int startX = node.parent.x + node.parent.width; int startY = node.parent.y + node.parent.height / 2; int endX = node.x; int endY = node.y + node.height / 2; int ctrlX = (startX + endX) / 2; linePath.moveTo(startX, startY); linePath.cubicTo(ctrlX, startY, ctrlX, endY, endX, endY); canvas.drawPath(linePath, linePaint); }

startX取父节点矩形的右边缘,startY取父节点中心;endX取子节点左边缘,endY取子节点中心。控制点的x取起止点中点,画出来是一条平滑的S形曲线,符合多数思维导图软件的视觉习惯。linePath作为成员变量复用,每次绘制前reset,避免onDraw里频繁new对象。

4. 核心清除拆解:堆栈遍历实现节点删除与资源回收

4.1 递归删除为什么会翻车

绘制能用递归,是因为树的结构和调用栈天然同构。但删除子树时,很多人第一反应也是写递归remove,实际用起来会遇到几个头疼的问题。最典型的是深层级删除时直接StackOverflowError,尤其是用户把某棵子树拖到很深层级后再整体删除,调用栈跟着节点深度一路压下去,线程默认栈空间不够就直接崩。

另一个隐蔽问题是删除过程中容易触发业务回调的连锁反应。如果每个Node删掉时都发一个监听通知,递归删除一棵50个节点的树,onNodeRemoved会被调用50次,上层如果每次都刷新界面或写数据库,就是一场灾难。第三是资源回收时机不统一,递归删除只做了children.clear(),节点里可能持有的缓存对象没有被统一标记,下次新建节点时又new一批,内存水位一步步抬高。

所以清除这条链路上我没有抄绘制那套递归写法,而是压成一个显式的栈,做非递归遍历。绘制时用递归是享受语言天然栈,清除时用堆栈是主动控制遍历节奏,回收和通知的时机都握在自己手里。

4.2 用显式栈做非递归删除

clearSubTree方法负责把某个节点连同整棵子树从树中摘除,并完成内存回收:

public boolean clearSubTree(Node target) { if (target == null || target.recycled) { return false; } Node parent = target.parent; if (parent != null) { parent.children.remove(target); } target.parent = null; ArrayDeque<Node> stack = new ArrayDeque<>(); stack.push(target); while (!stack.isEmpty()) { Node current = stack.pop(); for (Node child : current.children) { stack.push(child); // 子节点全部入栈,下一轮继续处理 } current.children.clear(); // 清空引用,避免内存泄漏 current.parent = null; current.recycled = true; nodePool.offer(current); // 回收到对象池 } if (parent != null) { recalculateFrom(parent); // 只重排受影响的分支 } invalidate(); return true; }

这里栈的入栈顺序无所谓,因为不需要区分绘制顺序,只负责把每个节点都访问到。循环内先遍历children把子节点压栈,再清空children,顺序上不会产生漏删:子节点对象已经在栈里持有了引用,即使list被清空也不影响后续处理。

把target的parent直接置null,并从父节点children里remove掉,是从逻辑树上“摘除”的动作。如果不先摘除,后续recalculateFrom会把标记回收的节点重新测量进布局,屏幕上会出现鬼影节点。收回对象池用offer到ArrayDeque尾部,下一次创建节点时从头部poll,避免不断new Node造成GC压力。

这里用ArrayDeque而不是java.util.Stack。Stack继承Vector,方法加了同步锁,在UI线程的遍历删除中没必要;ArrayDeque是标准双端队列,push/pop语义完全够用。Android lint对Stack也有提醒,早换早省心。

4.3 被删节点去哪了:对象池与局部重排

删除不等于销毁。节点对象回收进nodePool后,创建新节点优先从池里取:

private Node obtainNode() { Node node = nodePool.poll(); return node != null ? node.reset() : new Node(); }

reset()方法在Node内部实现,把id、text、children、坐标、recycled标志全部重置。大多数思维导图交互场景里,节点创建和删除是高频操作,比如用户反复拖拽、复制、删除分支,没有对象池的话,GC在低端机上容易出现掉帧。节点对象本身很轻,但数量一多,每次创建都走内存分配,长时间操作会让堆水位居高不下。

recalculateFrom是局部重排的入口:

private void recalculateFrom(Node node) { calculator.measure(node); calculator.layout(node, node.x, node.y); }

删除的是某个父节点下的一棵子树,理论上只有这条水平分支的尺寸会变化,其余分支不受影响。如果删一次就整棵树重新measure和layout,节点较多时会有明显顿挫感。局部重排只处理被删子树的父节点及其兄弟分支,代价小很多。注意recalculateFrom里layout的起点用的是node.x和node.y,不能是0,否则被删节点的兄弟分支会全部跑回画布左上角。

4.4 清除动画:让删除不是瞬间消失

直接调clearSubTree,节点会瞬间消失,交互上很生硬。我习惯拆成两个动作:先detach再物理回收。detach把节点从逻辑树上摘掉,但对象还活着;视图层拿这个残留对象做一段淡出动画;动画结束后再走真正的clearSubTree做栈回收。

private void startRemoveAnimation(Node target, int duration) { detachNode(target); // 先摘逻辑引用 removeAnim = new RemoveAnim(target, SystemClock.uptimeMillis(), duration); postInvalidateOnAnimation(); }

onDraw里检查removeAnim,如果动画进行中就按透明度画出残留节点,动画结束时置空并调用clearSubTree。这样用户看到的是节点先淡出,随后内存里才真正回收。动画采用postInvalidateOnAnimation而不是invalidate,可以让动画帧跟随系统vsync信号,不会和屏幕刷新错位。

5. 避坑指南:节点重叠、栈溢出与坐标换算问题

5.1 同级节点垂直重叠

现象:跑出来的思维导图,第二层和第三层的节点在垂直方向上重叠,间距时大时小,看着像排版出错。

原因:测量阶段计算总高度时误用了children.size() * NODE_HEIGHT这个公式。每个子节点可能自带子树,实际占用的subHeight可能远高于单个NODE_HEIGHT。布局阶段又直接用父节点的y顺序累加,没有考虑让子树垂直居中。

解决:测量阶段统一改成后序遍历累加每个子节点的subHeight,并在布局阶段用node.height / 2 - childrenTotalHeight / 2算出子节点起排位置。改完验证一个三层树,观察叶层多而深的节点排布是否均匀。

5.2 树层级过深导致StackOverflowError

现象:用户在深层次结构上快速展开,或者从数据里解析出一棵几百层的树,绘制时崩溃,日志里能看到java.lang.StackOverflowError,栈顶落在drawNode或measure上。

原因:递归绘制和递归测量都使用JVM线程调用栈,每层递归占用栈帧。Java默认线程栈大小通常只容纳几千到上万层浅递归,树形结构一深,栈帧随层级线性增长,很容易触顶。

解决:做两级防护。第一级在数据入口做层级校验,超过预设最大深度直接抛异常,提示业务方调整数据结构。第二级如果确实要支持更深层级,把绘制链路的递归改成显式栈遍历,逻辑和clearSubTree的做法同构。

5.3 onDraw里频繁new对象导致掉帧

现象:节点数量200以上时滑动不跟手,用Profiler能看到内存抖动曲线呈锯齿状,GC频繁触发。

原因:最常见的写法是在onDraw或drawNode里写new Paint()、new Path()、new RectF()。这些对象每帧都会创建,每个节点又创建一遍,节点一多,分配速度远大于回收速度。

解决:把Paint、Path、RectF全部提升为View的成员变量,在初始化时配好,绘制前reset或复用。linePath按3.3的方式复用,RectF用set方法更新参数而不是重建。从那以后我写任何自定义View都强制走一遍这个检查:onDraw里不允许出现new关键字。

5.4 删除节点后界面不刷新

现象:clearSubTree执行完,返回true,但界面上被删的分支还在,点它也没有命中反应。

原因:一是删除后只改了Node树的数据结构,没有触发invalidate,画面自然不会重绘。二是删除过程中某个节点仍被父节点children引用,recycled标志又没及时置位,onDraw遍历时把它画了出来。

解决:clearSubTree方法末尾统一调invalidate,保证任何调用路径都以重绘收尾。删除循环里先置recycled再清children,遍历时用recycled做短路判断,双重保险。注意所有对Node树结构的改动都要发生在UI线程,不要在子线程里直接操作children,否则抛ConcurrentModificationException。

5.5 触摸坐标与画布坐标偏移

现象:把MindMapView放进ScrollView后,点击节点命中位置总是偏上一段距离,离根部越远偏移越大。

原因:onTouchEvent里直接用event.getX(),而onDraw里用canvas.translate(getScrollX(), getScrollY())做了坐标平移。两者坐标系不一致,触摸点比实际绘制起点少了scrollY这段偏移。

解决:在onTouchEvent入口统一做坐标映射,eventX = event.getX() + getScrollX(),eventY = event.getY() + getScrollY(),后续所有命中检测都用映射后的值。规则很简单:绘制和触摸必须使用同一套坐标系,要么都不translate,要么触摸做同样的反向平移。

6. 二次开发:把静态导图改造成可交互的编辑器

拿到这份资源后,最值得先做的改造是把“只展示”变成“可编辑”。我给三个优先级最高的方向。

第一个是展开收起。在Node上加一个boolean expanded字段,默认true。measure和layout遍历children前先判断expanded,false就跳过子节点,子树占位缩成当前节点自身;onDraw同样在expanded为false时不再递归子节点。这样用户点击节点时切换标志位再invalidate,整棵树的折叠与展开就完成了,改动只集中在measure和drawNode两个方法。

第二个是拖拽换父。手指按下时做一次矩形命中检测,命中后进入拖拽状态,滑到某个节点上方松手,就把当前节点从旧父节点children中移除,加入新父节点children并更新parent引用。注意新父节点不能是当前节点的后代,否则会成环,需要在拖拽结束时做一次向上回溯校验。

第三个是数据持久化,把Node树序列化成JSON存本地,下次启动再解析回来。解析用栈做非递归最容易写对:根节点先压栈,循环里pop一个节点时把它的children和属性读出来,再按顺序压栈,逐级扩展。这样深层次的树不会被递归解析撑爆,逻辑也和clearSubTree里那套遍历思想一致。

改造后的验证方法我是这样做的:LayoutCalculator是纯Java类,测量和布局方法不依赖View,可以直接写JUnit用例。构造一棵三层树,手动断言每个节点的x、y和subHeight,跑一次测试就知道布局有没有回归。UI部分再配合真机拖拽验证命中是否准确。前一阵我做知识库的目录视图时也踩了5.5那个坐标偏移的坑,后来任何自定义View都强制先统一画布坐标再写手势,再没翻过车。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 9:42:58

MySQL ODBC 环境搭建与 C++ 连接实战:从驱动安装到查询 Demo

简介&#xff1a;这是一份面向C开发者与数据库初学者的MySQL ODBC连接实践项目&#xff0c;重点演示如何通过ODBC API在C程序中完成与MySQL数据库的通信。内容涵盖ODBC数据源配置、环境与连接句柄初始化、SQL语句执行、结果集遍历以及连接释放等完整流程&#xff0c;并配有对话…

作者头像 李华
网站建设 2026/10/16 9:42:33

基于PyTorch LSTM的电价预测实战:未来24小时曲线构建解析

简介&#xff1a;面向电力市场分析与时间序列预测学习者的PyTorchLSTM电价预测项目&#xff0c;目标是根据丹麦市场历史特征与价格&#xff0c;滚动预测未来24小时电价。项目完整覆盖数据清洗、特征构建、趋势与周期性分析、LSTM模型搭建、训练评估等流程&#xff0c;适合具有P…

作者头像 李华
网站建设 2026/10/16 9:42:23

魔改Informer实战:滚动长期预测的误差卡点与高频改法全解析

简介&#xff1a;这是一个面向时间序列长期预测研究与论文写作的魔改版Informer模型资源&#xff0c;适合需要滚动多步预测、评估固定时段结果的科研人员。在官方模型基础上增加了滚动长期预测功能&#xff1a;先预测未来24个时间点&#xff0c;再自动补入数据并持续预测下一段…

作者头像 李华
网站建设 2026/10/16 9:41:57

files.tar.gz 完全指南:打包、解压、增量备份与排错

简介&#xff1a;面向Linux系统上实现BLE外围设备功能的开发资料包&#xff0c;对应标签“linux ble periph”&#xff0c;适合嵌入式或物联网开发者、以及需要让Linux设备通过Smart App接收BLE消息的工程师。内容围绕如何将Linux配置为蓝牙低功耗外设&#xff0c;涵盖配置、编…

作者头像 李华
网站建设 2026/10/16 9:41:36

贝叶斯优化调参指南:从盲调 to 高效搜索

简介&#xff1a;这份资源面向机器学习与深度学习方向的开发者、算法工程师及学生&#xff0c;聚焦超参数调优这一影响模型性能与训练效率的关键环节&#xff0c;以贝叶斯优化为核心策略&#xff0c;帮助读者摆脱手动试错的低效困境。压缩包共4个文件&#xff0c;约10.96MB&…

作者头像 李华
网站建设 2026/10/16 9:35:02

利用链表实现功能

链表寻找节点利用快慢指针解决问题中间节点快指针两步一走&#xff0c;慢指针一步一走快指针走到头停止的时候慢指针即走到中间因为快指针是两步一走&#xff0c;所以结束条件是对于奇数个数据&#xff1a;如果pNULL了&#xff0c;那就停止对于偶数个数据&#xff1a;如果p->…

作者头像 李华