2.5 链表其实也可以用数组模拟
在C或者C++语言中有“指针”的概念。因为这个概念,链表在编程语言中能够非常方便地得以发挥作用,但并不是在所有的编程语言中都有指针这个概念,比如Java,虽然没有“指针”的概念,但是Java有“引用”的概念,类似于指针,可用于完成链表的实现,这在前面已经有所介绍了。
但若有的编程语言没有指针怎么办?如果你的常用语言没有指针,那么就用数组模拟吧。
2.5.1 静态链表
链表有两种:静态链表和动态链表。动态链表其实就是我们在前面提到的链表,空间都是在需要时动态生成的;而静态链表一般是使用数组来描述的链表,多数用于为一些没有指针的高级编程语言实现链表。
2.5.2 静态链表的实现
一般来说,静态链表的实现就是使用一段固定长度的数组,其中的每个元素需要由两部分组成:一个是data,用于记录数据;一个是cur,用于记录指向下一个节点的位置。在C语言中一般使用结构体,在Java中一般使用对象。如果不使用这种组合方式,那么也可以使用两个数组:一个数组存data,一个数组存cur,让同一个元素的data和cur的下标保持一致。
其实静态链表也可以模拟双向链表,只需要再增加一个部分,用于记录前面一个节点的位置即可,但是这个静态链表的维护成本更高。
这里采用Java中的对象的方式进行讲解,类似地,也可用于理解C语言中的实现,对于两个数组的实现方式也可以轻松地类推。下面我们先来看看静态链表中一个元素的结构代码。
package com.example.demo.base.link.extend; public class Element { private int data; private int cur; public int getData() { return data; } public void setData(int data) { this.data = data; } public int getCur() { return cur; } public void setCur(int cur) { this.cur = cur; } }
动态链表主要包含创建、插入、删除、遍历操作;静态链表是使用数组对动态链表进行模拟,当然也可以实现这些操作。
下面来看看使用数组模拟的链表是如何完成这蚱操作的。
1. 创建静态链表
首先要创建链表。对于动态链表来说,创建操作并不复杂;但是对于静态链表来说,创建操作会复杂一些。
首先需要三个标记,为了方便,我们直接采用三个变量来记录,分别是头指针标记、尾指针标记和未使用链表的头指针标记。
未使用链表的头指针标记的作用是什么?由于使用数组作为链表的存储空间,所以链表的元素肯定会分布到数组的一些元素上去,但是对链表进行插入、删除操作时,会出现链表的顺序和数组的下标顺序不一致的情况,可能还会导致数组中一些的连续空间为空,即未被使用,可能是这里的元索在之前被删了。
所以我们需要把链表中未使用的空间通过一个链表串起来,这样在需要分配空间时,就可以直接把未使用表的头指针指向的元素给我们真正使用的链表了。如图2-20所示。
这个未使用的链表一般叫作备用链表,用于串起那些没有被使用的数组元素,为接下来在链表中插入的操作使用,而在链表中删除元素时,需要及时把要删除的元素加入备用链表的头部记录下来。
所以在创建一个链表时,我们需要把这个备用链表串一下。
下面看看创建静态链表的代码实现。
public StaticLinkedList(int capacity) { elements = new Element[capacity]; unUsed = 0; for (int i = 0; i < capacity - 1; i++) { elements[i] = new Element(); elements[i].setCur(i + 1); } elements[capacity - 1] = new Element(); elements[capacity - 1].setCur(-1); }
创建静态链表时,需要把数组的所有元素遍历一下,用备用链表串起来。我们在这里对除最后一个元素外的所有元素进行循环赋值,对最后一个元素需要赋不一样的值,即把它的指针值赋值为-1,用于说明没有下一个元素了。
2. 插入操作
静态链表的头插入需要进行如下操作。
首先从备用链表的头中拿出一个元素,把备用链表的头标记指向备用链表的第2个元素的数组下标,然后把这个被拿出的元素的cur设为链表头标记的位置,即当前链表中第1个元素的数组下标,接着把头元素的标记指向这个新数组的元素下标。如此便完成了对链表的头插入。
静态链表的尾插入类似,首先是从备用链表的头拿出一个元素,把备用链表的头标记指向备用链表的第2个元索的数组下标,因为要作为链表的最后一个元素,因此把这个元素的cur设为空。接着把真实链表的尾指针指向的数组元素cur设为这个被拿出来的元素的下标,接着把尾指针标记的值也设为这个元素的数组下标,这样就完成了表的尾插入。
链表的中间插入则需要对静态链表进行遍历,在遍历到指定位置之后进行操作。备用链表同样将头元素作为链表的插入元素空间。而在链表遍历到要插入元素的位置的前一个元素之后,把这个元素的cur设为新拿出来的备用元素的下标。而这个新拿出来的备用元素的cur同样需要设为前一个元素的原本cur的值(也就是本该是新插入这个元素的下一个元素的数组下标),这样就完成了链表的中间插入。
其实静态链表的插入操作和动态链表的原理一样,只是改变了一些操作步骤:一个需要处理静态链表;一个是没有指针,所以需要修改cur值为指定元素的数组下标。
3. 删除操作
静态链表的删除操作的原理类似于动态链表,需要改变前后元素的指针指向,同时把当前元素移出(在静态链表中,就是在备用链表中进行头插入)。
头删除时,需要把头指针的值(head)设为原本链表的第2个元素的数组下标,同时需要把这个被删除元素的cur设为备用链表的头元素(unUsedHead)数组下标,然后修改备用链表头标记的值为这个元素的数组下标。即删除静态链表时,除了需要把链表cur的关系设定好,还需要把这个被删除的元素归还到备用链表里,以备以后使用。
尾删除时,需要把尾指针(tail)前移,但由于不知道前一个元素的下标是什么(除非使用双向链表),所以尾删除和中间删除一样,都需要进行遍历。在遍历到要删除的元素的前一个元素时,把这个元素的cur设为要删除的元素的后一个元素的数组下标(如果没有,则设置为空,这时删除的这个元素肯定是链表的最后一个元素)。如果要删除的元素是最后一个元素,那么需要修改尾元素标记(tail)的值。
4. 遍历操作
插入和删除操作,有时需要遍历到链表的指定位置。遍历操作时不需要理会备用链表,只需要从头标记(head)的值开始,找到元素数组的下标,再根据每个元素的cur去找下一个元素的数组下标,直到cur为空为止,则说明遍历完成。当我们需要遍历到指定的位置时,只需要一个计数器来记录我们遍历了多少个元素。
静态表不管是插入还是删除,其操作步骤都与动态链表类似,唯一需要额外处理的就是对备用链表的操作。进行插入操作时,需要对备用链表进行头删除;而进行删除操作时,则需要对备用链表进行头插入,这是需要额外维护的工作。
下面来看看静态链表的实现代码。
package com.example.demo.base.link.extend; public class StaticLinkedList { private Element[] elements; private int head; private int tail; private int unUsed; private int size; /** * 初始化操作 * * @param capacity */ public StaticLinkedList(int capacity) { elements = new Element[capacity]; unUsed = 0; for (int i = 0; i < capacity - 1; i++) { elements[i] = new Element(); elements[i].setCur(i + 1); } elements[capacity - 1] = new Element(); elements[capacity - 1].setCur(-1); } /** * 在链表指定位置的后面插入 * * @param data 要插入的值 * @param index 链表位置(不是数组下标) */ public void insert(int data, int index) { if (index == 0) { insertFirst(data); } else if (index == size) { insertLast(data); } else { checkFull(); // 获取要插入的元素的前一个元素 Element preElement = get(index); // 获取一个未被使用的元素作为要插入的元素 Element unUsedElement = elements[unUsed]; // 记录要插入元素的数组下标 int temp = unUsed; // 将从备用链表中拿出来的元素的下一个元素的数组下标设为备用链表头 unUsed = unUsedElement.getCur(); // 将要插入元素的指针设为原本前一个元素的指针指向的下标值(链表插入操作) unUsedElement.setCur(preElement.getCur()); // 将前一个元素的指针指向插入的元素下标 preElement.setCur(temp); // 赋值 unUsedElement.setData(data); // 链表长度+1 size++; } } /** * 链表前端插入 * * @param data */ public void insertFirst(int data) { checkFull(); Element unUsedElement = elements[unUsed]; int temp = unUsed; unUsed = unUsedElement.getCur(); unUsedElement.setCur(head); unUsedElement.setData(data); head = temp; size++; } /** * 链表尾插入 * * @param data */ public void insertLast(int data) { checkFull(); Element unUsedElement = elements[unUsed]; int temp = unUsed; unUsed = unUsedElement.getCur(); elements[tail].setCur(temp); unUsedElement.setData(data); tail = temp; size++; } /** * 链表头删除 */ public void deleteFirst() { checkEmpty(); Element deleteElement = elements[head]; int temp = head; head = deleteElement.getCur(); deleteElement.setCur(unUsed); unUsed = temp; size--; } /** * 链表尾删除 */ public void deleteLast() { delete(size - 1); } /** * 删除指定位置元素 * * @param index 链表中的第几个元素(不是数组下标) */ public void delete(int index) { if (index == 0) { deleteFirst(); } else { checkEmpty(); Element pre = get(index - 1); int del = pre.getCur();// 这是数组的下标 Element deleteElement = elements[del]; pre.setCur(deleteElement.getCur()); if (index == size - 1) { tail = index - 1; } deleteElement.setCur(unUsed); unUsed = del; size--; } } /** * 获取链表元素 * * @param index 链表的第几个元素(不是数组下标) * @return */ public Element get(int index) { checkEmpty(); Element element = elements[head]; for (int i = 0; i < index; i++) { element = elements[element.getCur()]; } return element; } /** * 按顺序打印所有元素的值 */ public void printAll() { Element element = elements[head]; System.out.println(element.getData()); for (int i = 1; i < size; i++) { element = elements[element.getCur()]; System.out.println(element.getData()); } } public int size() { return size; } private void checkFull() { if (size == elements.length) { throw new IndexOutOfBoundsException("数组不够长了"); } } private void checkEmpty() { if (size == 0) { throw new IndexOutOfBoundsException("链表为空"); } } }
下面是测试代码。
package com.example.demo.base.link.extend; public class TestStaticLinkedList { public static void main(String[] args) { StaticLinkedList link = new StaticLinkedList(10); link.insertFirst(2); link.insertFirst(1); link.insertLast(4); link.insertLast(5); link.insert(3, 1);// 在下标为1的元素后插入元素 link.printAll();// 1,2,3,4,5 link.deleteFirst(); link.deleteLast(); link.delete(1); link.printAll();// 在移除头、尾后,剩下3个元素,移除下标为1的元素,只剩下两个元素,即2和4 System.out.println(link.get(1).getData()); link.deleteFirst(); link.deleteFirst(); System.out.println(link.size());// 从头部全部移出 } }
2.5.3 静态链表的特点
静态链表在大多数情况下和动态链表比较相似,但是静态链表的实现方式导致链表失去了原有的优势,而且操作变得更复杂了,主要体现为以下几点。
(1)空间需要连续申请,而且空间是有限的。
由于静态链表是用数组模拟的,所以空间是连续的,虽然链表在数组中可以不按顺序排列,但是对于整个存储空间来说,还是需要连续的。
另外,由于用到数组模拟,所以我们在创建数组时需要初始化数组的长度,链表本身的一个优点是可以随时动态添加,但是静态链表没有办法这样添加。当链表的长度需要大于数组的长度时,就无法实现用数组模拟了,除非复制一个更长的新数组,那时就要考虑更多问题,比如串连备用链表、更高性能消耗等。
(2)查找元素需要遍历查找。
这一点和动态链表相似,在找一个元素时需要对链表进行遍历。
(3)操作更复杂。
由于执行操作时需要额外维护一个备用链表,所以无论是插入还是删除,都需要额外关心操作元素的动向,所以静态链表的操作比动态链表更复杂。
由于存在以上问题,所以静态链表除了有助于我们分析问题,在多数语言中并不常用,尤其是现在不支持指针或者引用的高级编程语言越来越少的情况下。但是,我们在学习算法时,还是需要掌握静态链表的。
2.6 再谈汉诺塔
栈的数据结构非常适合使用汉诺塔来解释,因为二者的操作原理一样,由此也衍生了针对汉诺塔的一些算法。其中一个就是三根柱子的汉诺塔的移动步驟。要求写算法实现,给定初始的一根柱子上的圆盘个数,打印完成汉诺塔移动的实现步骤(例如A到B、B到C)。
2.6.1 汉诺塔的移动原理
这里我们再详细地介绍一下汉诺塔的移动原理,假设三根柱子分别是A、B、C,一开始A上有N个圆盘,从小到大、从上到下分别是1、2...N-1、N,我们要把A上的N个圆盘全部移动到C上面,且每次只能移动每根柱子最上面的一个圆盘。
我们可以反过来考虑一下,若要把A上的圆盘全部移动到C上,那么需要把前N-1个圆盘按顺序落在B上了,最后的第N个圆盘才可以直接从A移动到C上,从而完成第N个圆盘的移动。之后把B上的N-2个圆盘移动到A上,再把B上剩下的最后一个圆盘,也就是第N-1个圆盘直接移动到C上,这时就已经完成了最下面两个圆盘的移动。继续这样的移动,直到完成游戏。
经过上述对汉诺塔移动的详细分析,大家可以先思考和尝试一下如何写这个算法的实现代码。
2.6.2 汉诺塔的递归实现
上面的步骤在代码上使用递归是最好实现的。要使用递归,就要考虑到需要进行递归的是哪部分。我们先看一下代码,然后结合代码具体分析这样写的原因。
package com.example.demo.base.link.extend; public class HanoiTest { public static void hanoi(int n, char A, char B, char C) { if (n == 1) { // 只有一个圆盘需要移动时,直接移动即可结束操作 move(A, C); return; } // 先把A上n-1个圆盘移动到B上 hanoi(n - 1, A, C, B); // 把A上最后一个圆盘移动到C上 move(A, C); // 接下来递归,把B上的n-1个圆盘移动到C上 hanoi(n - 1, B, A, C); } /** * 把A最上面的圆盘移动到C上去 * * @param A * @param C */ private static void move(char A, char C) { System.out.println(A + "-->" + C); } public static void main(String[] args) { hanoi(3, 'A', 'B', 'C'); } }
运行结果。
A-->C A-->B C-->B A-->C B-->A B-->C A-->C
现在有了汉诺塔递归实现的具体代码,我们来分析一下。
hanoi函数的第1个参数是柱子上需要移动的圆盘的个数,后三个参数分别为三根柱子的标识。首先当n为1时,需要移动的圆盘只有一个,直接把A上的圆盘移动到C上就可以了,同时代码结束,因为已经没有需要移动的圆盘了。
接下来是汉诺塔实现的关键,即把A上所有的圆盘移动到C上,需要先把A最上面的1个圆盘移动到B上,于是有了“hanoi(n-1,A,C,B);”这样一行递归调用,接下来只需要把A上最后剩下的最大的圆盘移动到C上。
现在B上有小1个圆盘,C上有一个最大的圆盘,接下来把B上这n-1个圆盘也移动到C上。此时把B想象成之前的A,有一堆待移动的圆盘;把A想象成之前的B,是空的柱了,这时我们只需要把调用方式变为“hanoi(n-1,B,A,C);”,就可以完成移动,这就是递归调用的思想所在了。
在递归调用中会继续执行该方法,改变参数的值,继续打印,这样每次调用会减少n的值,直到n最后变为1之后,结束递归调用,最终完成汉诺塔移动。
转变一下思想,就能够理解从B上移动n-1个圆盘到C上其实和从A上移动圆盘到C上一样,我们会发现汉诺塔的递归调用非常简单。