概念与定义
空或者只有一个结点。或者
1、存在唯一的一个被称之为“第一个”的结点。
2、存在唯一的一个被称之为“最后一个”的结点。
3、除第一个结点之外,每个结点均只有一个前驱结点。
4、除最后一个结点之外,每个结点均只有一个后继结点。
线性表最重要的直觉是“元素之间只有一条主线”。无论底层使用数组还是链表,抽象层只关心第 i 个元素是谁、能不能在第 i 个位置插入或删除、能不能按顺序遍历。顺序表和链表的差异主要不在接口,而在“找到位置”和“移动元素”的成本不同。
抽象类
1 2 3 4 5 6 7 8 9 10 11 12 13
| template <class elemType> class list { public: virtual void clear() = 0; virtual int length() const = 0; virtual void insert(int i, const elemType &x) = 0; virtual void remove(int i) = 0; virtual int search(const elemType &x) const = 0; virtual elemType visit(int i) const = 0; virtual void traverse() const = 0; virtual ~list() {} };
|
这个抽象类只描述线性表应该能做什么,并不规定元素如何存放。seqList 会用连续数组保存元素,sLinkList 和 dLinkList 则用指针把结点串起来。后续代码中所有类都实现同一批接口,因此可以把重点放在:每种存储方式如何完成 insert、remove、search 这几个核心操作。
顺序表类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80
| template <class elemType> class seqList: public list<elemType> { private: elemType *data; int currentLength; int maxSize; void doubleSpace(); public: seqList(int initSize = 10); ~seqList() {delete [] data;} void clear() {currentLength = 0;} int length() const {return currentLength;} void insert(int i, const elemType &x); void remove(int i); int search(const elemType &x) const ; elemType visit(int i) const; void traverse() const ; };
template <class elemType> seqList<elemType>::seqList(int initSize) { data = new elemType[initSize]; maxSize = initSize; currentLength = 0; }
template <class elemType> int seqList<elemType>::search(const elemType &x) const { for (int i = 0; i < currentLength; i++) { if (data[i] == x) return i; } return -1; }
template <class elemType> void seqList<elemType>::traverse() const { for (int i = 0; i < currentLength; i++) cout << data[i] << " "; cout << endl; }
template <class elemType> void seqList<elemType>::insert(int i, const elemType &x) { if (i < 0 || i > currentLength) throw OutOfBound(); if (currentLength == maxSize) doubleSpace(); for (int j = currentLength; j > i; j--) data[j] = data[j - 1]; data[i] = x; ++currentLength; }
template <class elemType> void seqList<elemType>::doubleSpace() { elemType *tmp = data; data = new elemType[2 * maxSize]; for (int i = 0; i < currentLength; i++) data[i] = tmp[i]; maxSize *= 2; delete [] tmp; }
template <class elemType> void seqList<elemType>::remove(int i) { if (i < 0 || i >= currentLength) throw OutOfBound(); for (int j = i; j < currentLength - 1; j++) data[j] = data[j + 1]; --currentLength; }
|
顺序表的核心不变量是:有效元素始终连续存放在 data[0] 到 data[currentLength - 1] 中,currentLength 表示真实元素个数,maxSize 表示当前数组容量。插入时要先把位置 i 及其后面的元素整体后移一格,删除时则把位置 i 后面的元素整体前移一格,因此随机访问很快,但中间插入和删除会有移动成本。
doubleSpace() 是顺序表能动态增长的关键。它并不是在原地扩大数组,而是申请一块更大的新数组,把旧元素复制过去,再释放旧数组。因此扩容那一次操作较慢,但如果从多次插入的角度均摊,每个元素被搬移的次数有限,平均插入成本仍然可以看作常数级扩容开销加上必要的移动开销。
链式表类
链式表和顺序表的主要差异在存储方式:顺序表要求元素连续存放,链式表则用指针把分散的结点串起来。这样做的直接结果是,链表在已知位置附近插入或删除时不需要移动大量元素,只要修改局部指针;但它不能像数组那样通过下标立即访问第 i 个元素,查找位置通常要从头结点开始一步步走。
单链表类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80
| template <class elemType> class sLinkList: public list<elemType> { private: struct node { elemType data; node *next; node(const elemType &x, node *n = NULL) { data = x; next = n; } node( ): next(NULL) {} ~node() {} }; node *head; int currentLength; node *move(int i) const; public: sLinkList() ; ~sLinkList() {clear(); delete head; } void clear() ; int length() const {return currentLength;} void insert(int i, const elemType &x); void remove(int i); int search(const elemType &x) const ; elemType visit(int i) const; void traverse() const ; };
template <class elemType> sLinkList<elemType>::sLinkList() { head = new node; currentLength = 0; }
template <class elemType> void sLinkList<elemType>::clear() { node *p = head->next, *q; while (p != NULL) { q = p->next; delete p; p = q; } head->next = NULL; currentLength = 0; }
template <class elemType> sLinkList<elemType>::node *sLinkList<elemType>::move(int i) const { node* p = head; while (i-- >= 0) p = p->next; return p; }
template <class elemType> int sLinkList<elemType>::search(const elemType &x) const { node *p = head->next; int i = 0; while (p != NULL && p->data != x) { p = p->next; ++i; } if (p == NULL) return -1; else return i; }
template <class elemType> void sLinkList<elemType>::traverse() const { node *p = head->next; while (p != NULL) { cout << p->data << " "; p = p->next; } cout << endl; }
|
单链表的核心不变量是:head 是头结点,真正的数据从 head->next 开始;每个结点只知道自己的后继。头结点不存储有效数据,它的作用是统一“在第一个元素前插入/删除”和“在中间插入/删除”的写法,减少边界情况。
读链表代码时,最容易卡住的是 move(i)。它返回的是某个位置对应的结点地址,因此链表插入通常会先找到“插入位置的前驱结点”,再改两条指针:新结点指向原后继,前驱结点指向新结点。与顺序表相比,链表不需要整体搬移元素,但想访问第 i 个元素必须从头沿 next 逐个走过去。
双链表类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60
| template <class elemType> class dLinkList: public list<elemType> { private: struct node { elemType data; node *prev, *next; node(const elemType &x, node *p = NULL, node *n = NULL) { data = x; next = n; prev = p; } node( ):next(NULL), prev(NULL) {} ~node() {} }; node *head, *tail; int currentLength; node *move(int i) const; public: dLinkList() ; ~dLinkList() {clear(); delete head; delete tail;} void clear() ; int length() const {return currentLength;} void insert(int i, const elemType &x); void remove(int i); int search(const elemType &x) const ; elemType visit(int i) const; void traverse() const ; };
template <class elemType> dLinkList<elemType>::dLinkList() { head = new node; tail = new node; head->next = tail; tail->prev = head; currentLength = 0; }
template <class elemType> void dLinkList<elemType>::insert(int i, const elemType &x) { node *pos, *tmp; pos = move(i); tmp = new node(x, pos->prev, pos); pos->prev->next = tmp; pos->prev = tmp; ++currentLength; }
template <class elemType> void dLinkList<elemType>::remove(int i) { node *pos; pos = move(i); pos->prev->next = pos->next; pos->next->prev = pos->prev; delete pos; --currentLength; }
|
双链表比单链表多维护一条反向指针 prev,因此删除某个已知结点时,不需要再从头寻找它的前驱。插入和删除的本质都是维护局部四条连接关系:新结点或待删结点的前驱、后继必须互相重新接好。代码中的 head 和 tail 都是哨兵结点,它们让空表、表头插入、表尾插入这些边界情况都能被统一处理。
双向链表中查找、访问、遍历等操作与单链表类似,只是需要注意前驱和后继指针的使用,此处不再实现。
单循环链表类
- 一般单循环链表不带头结点。
- Head 为头指针。(头指针和头结点有区别)
- 插入操作时间复杂度为 O(1),删除操作时间复杂度为 O(n),查找操作时间复杂度为 O(n)。
单循环链表的最后一个结点不再指向 NULL,而是重新指向首元素,因此沿着 next 指针一直走会回到起点。它适合需要循环处理元素的场景,例如约瑟夫环或轮转调度。读这种结构时要特别注意终止条件:普通单链表可以用 p == NULL 停止,循环链表则通常要判断是否重新回到 head。
双循环链表类
- 首元素中 prior 字段给出尾元素的地址,尾元素中 next 字段给出首元素的地址。
- 一般也不设头尾结点(注意头/尾结点;头/尾指针;首/尾元素的区分)
- 插入操作时间复杂度为 O(1),删除操作时间复杂度为 O(n),查找操作时间复杂度为 O(n)。
双循环链表同时保留前驱和后继,并把首尾结点连成环。相比单循环链表,它可以从任意结点向前或向后移动;相比普通双链表,它没有天然的 NULL 边界。实现时通常需要非常明确地约定 head 是指向首元素还是哨兵结点,否则空表、只有一个元素、删除首尾元素这些边界情况很容易写错。