文章内容摘取自公众号:程序员与机器学习。公众号会不定期更新程序猿技术类文章以及机器学习方向知名论文的解析分享。由于排版原因,一些内容没有完全拷贝过来,想看完整内容的同学可扫描文末的二维码关注公众号,获取更多的姿势。
阅读提示:通读本文将大约花费15分钟的时间
继本系列上一篇文章手撸数据结构系列-1-单链表之后,本篇文章要讲的循环链表也是线性数据结构中比较简单的一种。
ps:本文所讲的循环链表是基于单链表的循环链表,后文将不再单独说明。
循环链表和单链表的区别在于但不限于以下几点:
1.结构不同:循环链表首尾相连,单链表则不
2.结束标志不同:单链表结束标志是next结点为null,而循环链表可以说没有结束标志(如果非要说那就是next结点指向head结点吧)
3.遍历方式不同:单链表的每一次遍历都必须从头结点出发,而循环链表可以从任意节点出发将指针向后移动即可完成遍历
4.操作方式不同:相比于单链表,循环链表对结点的顺序以及头结点位置并不是那么关注,这给编程带来了不小的便利(这一点在后文coding中会有所体现)。5.应用场景不同:循环队列对具有环状特性的数据(如:约瑟夫问题)处理有一针见血的效果;而单链表则适合传统队列特性的数据处理(如:排队候车)
当然,他们也有很多共同点,至少在实现上除了循环链表的尾结点的后继结点指向头结点外,其他的实现还是有很多共通的地方的。
对于循环链表来说,核心的数据结构依然是同单链表一样,一个结点对象和一个头指针,咱们通过图来看一下单链表和循环链表在结构上的一致和区别:
在coding之前,我们先考虑一下几件事:
1. 循环链表不关注结点顺序,那在新增结点时,我们就不必寻找最后一个结点,而是直接跟在头结点后(如果链表为空,那新结点就是头结点),其实这就相当于一个插入新结点的操作。而又因为循环链表的循环特性,我们无需关注边界值,无论在什么位置,都使用一样的插入流程。
2. 循环链表不关注头结点的位置,这在理解上带来了不小的阻碍,因为我们知道如果一个链表没有头结点(头结点不固定),那我们应该从什么地方开始操作呢?其实这也正是循环链表的灵活之处,我们可以根据数据场景需求,将我们认为最方便后续操作的结点定义为头结点。
3. 由于循环链表的头结点可以根据我们自己的喜好随意设定,那必然会导致A线程(头结点在a)正在工作时,B线程突然把头结点的位置(头结点被改为了b)更改了,此时A线程的操作必将出错,这个是我们在编程时需要去考虑的点。
4. 综上,我们不难看出,循环链表的新增和插入其实是一回事,所以相比于单链表我们的接口将减少一个;对于获取链表结点的操作,因为头结点并不固定,所以我们很难根据元素下标找到我们想要的元素,因此我对获取结点的机制进行了调整,改为随机获取一个结点;同时由于循环链表的后一个操作比较依赖前一个操作的操作结果(需要知道循环链表跑到哪个位置了,否则每次新操作都要再一次从头结点开始,那和单链表就没什么区别了),因此我们在设计返回值的时候将返回值设置为该操作的特征结点。这里说循环结点不关注结点顺序、不关注头结点位置纯属个人理解,我认为循环链表的特点就在于循环可以让我们在coding的时候脱离更多束缚,如果关注点太多,反而把循环链表的优势整没了。关于这一点如果你持有不同的观点,那也是没有任何问题的,循环链表只是一种数据结构,其实现思路和方案有非常多。
特征结点:关于特征节点?好吧,我又乱起名字了。实际上我的意思是指一个具有代表性的结点,比如新增,就返回被新增的结点,删除则返回被删除的结点,诸如此类。
有了上面的思考,我们就开始coding吧。
循环链表的基本骨架
import java.util.Random;
public class CircularLinkList<E> {
public static Random RANDOM = new Random();
/**
* 头指针
*/
public MyNode<E> head;
/**
* 链表大小
*/
public int size = 0;
public String outOfBoundsMsg(int index) {
return "Index: " + index + ", Size: " + size;
}
/**
* 结点定义
*
* @param <E>
*/
class MyNode<E> {
/**
* 数据域内容
*/
E data;
/**
* 指针域内容
*/
MyNode<E> next;
public MyNode(E data) {
this.data = data;
}
}
}
循环链表的新增插入
动图
代码
/**
* 新增一个结点
* 头结点之后新增的结点直接跟在头结点后
*
* @param e 结点数据
* @return 新增结点
*/
public MyNode<E> add(E e) {
MyNode<E> newNode = new MyNode<E>(e);
if (isEmpty()) {
// 插入首个元素
head = newNode;
newNode.next = head;
} else {
newNode.next = head.next;
head.next = newNode;
}
size++;
return newNode;
}
为了满足顺序加入结点的需求,我又增加了一个插入方法,该方法可实现在指定结点后插入新结点 ↓
/**
* 在某个结点后插入新的结点
*
* @param node 基准结点
* @param e 新结点数据
* @return 新增结点
*/
public MyNode<E> add(MyNode<E> node, E e) {
MyNode<E> newNode = new MyNode<E>(e);
if (node == null) {
throw new IllegalArgumentException("Base node can not be null.");
} else {
newNode.next = node.next;
node.next = newNode;
}
size++;
return newNode;
}
循环链表的删除
动图
代码
可以看到remove操作会更改头结点的位置为被删除结点的后一个结点,为了保证我在remove的过程中掺和进来新的结点捣乱,我不允许新增结点改变头指针指向(要不然会出现前脚把头指针指向A,还没来得及开始下一轮,新增一个元素把头指针指向B了[虽然实际场景不会允许这么操作],这就麻烦了),所以你会看到在新增操作中不会对头结点位置进行改变。
/**
* 删除某结点起往后的第index个结点.
* 当删除该结点后,头指针指向被删结点的后一个结点.
*
* @param node
* @param index
* @return 被删除结点
*/
public MyNode<E> remove(MyNode<E> node, int index) {
if (isEmpty()) {
throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
}
MyNode<E> del;
MyNode<E> curr = node;
int count = 1;
while (count < index + size - 1) {
count++;
curr = curr.next;
}
del = curr.next;
curr.next = curr.next.next;
// 重置头结点
head = curr.next;
size--;
return del;
}
循环链表的获取
动图
代码
/**
* 随机获取一个结点
*/
public MyNode<E> get() {
int index = RANDOM.nextInt(size);
MyNode<E> curr = head;
int count = 0;
while (count < index) {
curr = curr.next;
count++;
}
return curr;
}
循环链表的遍历
循环链表的遍历和单链表遍历有所不同,单链表只需要从头结点向后遍历至next结点为NULL即可完成,但是循环链表的结束条件则是从头结点开始,遍历至next结点为头结点(绕了一圈)为结束,代码如下:
@Override
public String toString() {
StringBuilder str = new StringBuilder();
if (isEmpty()) {
return "";
}
str.append(head.data.toString());
str.append(" ");
MyNode<E> curr = head.next;
while (curr != head) {
str.append(curr.data.toString());
str.append(" ");
curr = curr.next;
}
return str.toString();
}
以上便是整个循环链表的设计,我们来看一下循环链表能解决什么样场景的问题吧。
约瑟夫环(丢手绢问题)
有N个人围成一个圈,每个人手中拿着一个随机的密码。最开始的时候有一个初始密码M,第一个人开始往后数M个数,被数到的人淘汰,并且留下自己的密码当做新的M值,然后从被淘汰的人的下一个人又开始数。如此重复,直到N个人只剩下一个人。
约瑟夫问题(有时也称为约瑟夫斯置换,是一个出现在计算机科学和数学中的问题。在计算机编程的算法中,类似问题又称为约瑟夫环。又称“丢手绢问题”.)
PS:本文只是写出循环链表可以作为约瑟夫问题的解决方案之一,实际上你还可以使用更多更加简单的方案实现。
这个场景是约瑟夫问题的一种场景,需要一个初始结点用于开始,同时结点元素将变成一个对象,对象包含名字和密码两个属性。淘汰操作我们使用remove方法,通过remove方法返回被淘汰这个人的结点信息(获取他的密码);该人被淘汰后,头指针正好指向他的下一个人,那么下一轮我们直接从头结点开始数即可。
先定义一个People类作为每个结点的数据域内容:
private static class People {
private String name;
private int key;
public People(String name, int key) {
this.name = name;
this.key = key;
}
@Override
public String toString() {
return name + ":" + key;
}
}
接着就用呗!看下循环链表在约瑟夫环的问题上的处理效果:
为了稍后图解方便,我们将人数N设置为5,初始密码M为3,从最开始插入链表的人开始计数,被M点中的人被remove:
public static void main(String[] args) {
CircularLinkList<People> linkList = new CircularLinkList<People>();
// 5个人站成一圈,每个人手上拿着一个密码值
linkList.add(new People("A", 3));
linkList.add(new People("B", 4));
linkList.add(new People("C", 4));
linkList.add(new People("D", 1));
linkList.add(new People("E", 2));
System.out.println("初始顺序:" + linkList.toString());
StringBuilder str = new StringBuilder();
str.append("淘汰顺序:");
CircularLinkList<People>.MyNode<People> curr = linkList.head;
// 初始密码为3
int m = 3;
while (linkList.size > 1) {
curr = linkList.remove(linkList.head, m);
m = curr.data.key;
str.append(curr.data.toString()).append(" ");
}
System.out.println(str);
System.out.println("剩下:" + linkList.toString());
}
结果:
初始顺序:A(3) E(2) D(1) C(4) B(4)
淘汰顺序:D(1) C(4) B(4) E(2)
剩下:A(3)
看一下图解:
下一篇文章将介绍双向链表的coding实现和动图解析。
扫描二维码或者搜索公众号“程序员与机器学习”关注获取更多文章,还有我在开发生涯中使用到的黑科技推荐