线性表的概述
线性表是具有相同数据类型的 n(n≥0)
个数据元素的有限序列,其中 n 为表长,当 n = 0 时线性表是一个空表。若用 L 命名线性表,则其一般表示为
L = (a1, a2, … , ai, ai+1, … , an)
-
ai 是线性表中的 “第i个” 元素线性表中的位序。
-
a1 是表头元素;an是表尾元素。
-
除第一个元素外,每个元素有且仅有一个直接前驱。
-
除最后一个元素外,每个元素有且仅有一个直接后继。
线性表的特点
- 表中元素的个数有限。
- 表中元素具有逻辑上的顺序性,表中元素有其先后次序。
- 表中元素都是数据元素,每个元素都是单个元素。
- 表中元素的数据类型都相同,这意味着每个元素占有相同大小的存储空间。
- 表中元素具有抽象性,即仅讨论元素间的逻辑关系,而不考虑元素究竟表示什么内容。
线性表的基本操作
一个数据结构的进本操作是指其最核心、最基本的操作。其他较复杂的操作可通过调用其基本操作来实现。
线性表的主要操作如下:
操作 | 描述 |
---|---|
InitList(&L) | 初始化表。构造一个空的线性表L,分配内存空间。 |
DestroyList(&L) | 销毁操作。销毁线性表,并释放线性表L所占用的内存空间。 |
ListInsert(&L,i,e) | 插入操作。在表L中的第i个位置上插入指定元素e。 |
ListDelete(&L,i,&e) | 删除操作。删除表L中第i个位置的元素,并用e返回删除元素的值。 |
LocateElem(L,e) | 按值查找操作。在表L中查找具有给定关键字值的元素。 |
GetElem(L,i) | 按位查找操作。获取表L中第i个位置的元素的值。 |
Length(L) | 求表长。返回线性表L的长度,即L中数据元素的个数。 |
PrintList(L) | 输出操作。按前后顺序输出线性表L的所有元素值。 |
Empty(L) | 判空操作。若L为空表,则返回true,否则返回false。 |
线性表的顺序表示
顺序表的定义
线性表的顺序存储又称顺序表。它是用一组地址连续的存储单元依次存储的数据元素,从而使得逻辑上相邻的两个元素在物理位置上也相邻。
第 1 个元素存储在线性表的起始位置,第 i 个元素的存储位置后面紧接着存储的是第 i+1 个元素,称 i 为元素 ai 在线性表中的位序。因此,顺序表的特点是表中元素的逻辑顺序与其物理顺序相同。
注意:线性表中元素的位序是从 1 开始的,而数组中元素的下标是从 0 开始的。
一维数组可以是静态分配的,也可以是动态分配的。
在静态分配时,由于数组的大小和空间事先已经固定,一旦空间占满,再加入新的数据将会产生溢出,进而导致程序崩溃。
而在动态分配时,存储数组的空间是在程序执行过程中通过动态存储分配语句分配的,一旦数据空间占满,就另外开辟一块更大的存储空间,用以替换原来的存储空间,从而达到扩充存储数组空间的目的,而不需要为线性表一次性划分所有空间。
注意:动态分配并不是链式存储,它同样属于顺序存储结构,物理结构没有变化,依然是随机存取方式,只是分配的空间大小可以在运行时决定。
顺序表的特点
- 随机访问:即可以在 O(1) 时间内找到第 i 个元素。
- 存储密度高,每个节点只存储数据元素。
- 拓展容量不方便(即便采用动态分配的方式实现,拓展长度的时间复杂度也比较高)。
- 插入、删除操作不方便,需要移动大量元素。
顺序表上基本操作的实现
Go 代码实现
package main
import "fmt"
const maxSize = 10
type SqList struct {
data [maxSize]int // 顺序表的元素
length int // 顺序表的当前长度
}
// 插入操作
func (sl *SqList) ListInsert(key int, val int) bool {
if key < 1 || key > sl.length+1 {
return false
}
if sl.length > maxSize {
return false
}
for i := sl.length; i >= key; i-- {
sl.data[i] = sl.data[i-1]
}
sl.data[key-1] = val
sl.length++
return true
}
// 删除操作
func (sl *SqList) ListDelete(key int) bool {
if key < 1 || key > sl.length {
return false
}
for i := key; i <= sl.length; i++ {
sl.data[i-1] = sl.data[i]
}
sl.length--
return true
}
// 按值查找
func (sl *SqList) locateElem(val int) int {
for i := 0; i < sl.length; i++ {
if sl.data[i] == val {
return i + 1
}
}
return 0
}
func main() {
sl := SqList{}
sl.ListInsert(1, 10)
sl.ListInsert(2, 20)
sl.ListInsert(3, 30)
sl.ListInsert(4, 40)
sl.ListInsert(5, 50)
fmt.Println(sl) // {[10 20 30 40 50 0 0 0 0 0] 5}
sl.ListInsert(3, 60)
fmt.Println(sl) // {[10 20 60 30 40 50 0 0 0 0] 6}
sl.ListDelete(4)
fmt.Println(sl) // {[10 20 60 40 50 0 0 0 0 0] 5}
res := sl.locateElem(40)
fmt.Println(res) // 4
}
线性表的链式表示
顺序表可以随时存取表中的任意一个元素,它的存储位置可以用一个简单直观的公式表示, 但插入和删除操作需要移动大量元素。链式存储线性表时,不需要使用地址连续的存储单元,即 不要求逻辑上相邻的元素在物理位置上也相邻,它通过 “链” 建立起数据元素之间的逻相关系, 因此插入和删除操作不需要移动元素,而只需修改指针,但也会失去顺序表可随机存取的优点。
单链表
线性表的链式存储又称单链表,它是指通过一组任意的存储单元来存储线性表中的数据元素为了建立数据元素之间的线性关系,对每个链表结点,除存放 元素自身的信息外,还需要存放一个指向其后继的指针。
利用单链表可以解决顺序表需要大量连续存储单元的缺点,但单链表附加指针域,也存在浪费存储空间的缺点。由于单链表的元素离散地分布在存储空间中,所以单链表是非随机存取的存储结构, 即不能直接找到表中某个特定的结点。查找某个特定的结点时,需要从表头开始遍历,依次查找。
通常用头指针来标识一个单链表,如单链表 L,头指针为 NULL 时表示一个空表。此外,为了操作上的方便,在单链表第一个结点之前附加一个结点,称为头结点。头结点的数据域可以不设任何信息,也可以记录表长等信息。头结点的指针域指向线性表的第一个元素结点,如图所示:
头结点和头指针的区分:不管带不带头结点,头指针始终指向链表的第一个结点,而头结点是带头结点的链表中的第一个结点,结点内通常不存储信息。
引入头结点后,可以带来两个优点:
- 由于第一个数据结点的位置被存放在头结点的指针域中,所以在链表的第一个位置上的 操作和在表的其他位置上的操作一致,无须进行特殊处理。
- 无论链表是否为空,其头指针都指向头结点的非空指针(空表中头结点的指针域为空),因此空表和非空表的处理也就得到了统一。
Go 代码实现
package main
import "fmt"
type Node struct {
id int
name string
next *Node // 指向下一个节点
}
// 往链表中插入一个节点
func InsertNode(headNode *Node, newNode *Node) {
// 创建一个辅助节点
temp := headNode
for {
if temp.next == nil {
break
}
temp = temp.next // 不断的指向下一个结点
}
temp.next = newNode
}
// 显示链表的所有结点信息
func ShowNode(headNode *Node) {
temp := headNode
// 判断该链表是不是一个空的链表
if temp.next == nil {
fmt.Println("空空如也...")
return
}
for {
fmt.Println(temp.next.id, temp.next.name)
// 判断是否到链表最后
temp = temp.next
if temp.next == nil {
break
}
}
}
// 删除链表指定的节点信息
func DelNode(headNode *Node, id int) {
temp := headNode
flag := false
for {
if temp.next == nil {
break
} else if temp.next.id == id {
flag = true
break
}
temp = temp.next
}
if flag {
temp.next = temp.next.next
} else {
fmt.Println("ID不存在")
}
}
func main() {
// 创建一个头结点
headNode := &Node{}
// 创建一个新的 Node
node1 := &Node{
id: 1,
name: "aa",
}
node2 := &Node{
id: 2,
name: "bb",
}
node3 := &Node{
id: 3,
name: "cc",
}
InsertNode(headNode, node1)
InsertNode(headNode, node2)
InsertNode(headNode, node3)
DelNode(headNode, 2) // 删除指定ID
ShowNode(headNode)
/*输出结果:
1 aa
3 cc*/
}
双链表
单链表结点中只有一个指向其后继的指针使得单链表只能从头结点依次顺序地向后遍历。要访问某个结点的前驱结点(插入、删除操作时),只能从头开始遍历,访问后继结点的时间复杂度为 O(1),访问前驱结点的时间复杂度为 O(n)。
为了克服单链表的上述缺点,引入了双链表,双链表结点中有两个指针 prior 和 next,分别指向其前驱结点和后继结点,如图所示:
Go 代码实现
package main
import "fmt"
type Node struct {
id int
name string
pre *Node // 指向前一个结点
next *Node // 指向下一个结点
}
// 往双向链表中插入一个节点
func InsertNode(headNode *Node, newHeroNode *Node) {
temp := headNode
for {
if temp.next == nil {
break
}
temp = temp.next
}
temp.next = newHeroNode
newHeroNode.pre = temp
}
// 往双向链表中插入一个节点(根据ID排序)
func InsertNodeOrderByID(headNode *Node, newNode *Node) {
// 创建一个辅助节点
temp := headNode
flag := true
for {
// 让插入的结点的 no 和 temp 的下一个结点的 no 比较
if temp.next == nil {
break
} else if temp.next.id > newNode.id {
break
} else if temp.next.id == newNode.id {
flag = false
break
}
temp = temp.next // 不断的指向下一个结点
}
if flag {
newNode.next = temp.next
newNode.pre = temp
if temp.next != nil {
temp.next.pre = newNode
}
temp.next = newNode
} else {
fmt.Println("ID已存在")
return
}
}
// 显示链表的所有结点信息
func ShowNode(headNode *Node) {
temp := headNode
// 判断该链表是不是一个空的链表
if temp.next == nil {
fmt.Println("空空如也...")
return
}
for {
fmt.Println(temp.next.id, temp.next.name)
// 判断是否到链表最后
temp = temp.next
if temp.next == nil {
break
}
}
}
// 删除双向链表指定的节点信息
func DelNode(headNode *Node, id int) {
temp := headNode
flag := false
for {
if temp.next == nil {
break
} else if temp.next.id == id {
flag = true
break
}
temp = temp.next
}
if flag {
temp.next = temp.next.next
if temp.next != nil {
temp.next.pre = temp
}
} else {
fmt.Println("ID不存在")
}
}
func main() {
// 创建一个头结点
headNode := &Node{}
// 创建一个新的 Node
node1 := &Node{
id: 1,
name: "aa",
}
node2 := &Node{
id: 2,
name: "bb",
}
node3 := &Node{
id: 3,
name: "cc",
}
InsertNodeOrderByID(headNode, node3)
InsertNodeOrderByID(headNode, node1)
InsertNodeOrderByID(headNode, node2)
DelNode(headNode, 2) // 删除指定ID
ShowNode(headNode)
/*输出结果:
1 aa
3 cc*/
}
循环链表
循环单链表
循环单链表和单链表的区别在于,表中最后一个结点的指针不是 NULL,而改为指向头结点,从而整个链表形成一个环,如图所示:
在循环单链表中,表尾结点 *r 的 next 域指向 L,故表中没有指针域为 NULL 的结点,因此,循环单链表的判空条件不是头结点的指针是否为空,而是它是否等于头指针。
循环单链表的插入、删除算法与单链表的几乎一样,所不同的是若操作是在表尾进行,则执行的操作不同,以让单链表继续保持循环的性质。当然,正是因为循环单链表是一个 “环”,因此在任何一个位置上的插入和删除操作都是等价的,无须判断是否是表尾。
在单链表中只能从表头结点开始往后顺序遍历整个链表,而循环单链表可以从表中的任意一个结点开始遍历整个链表。有时对单链表常做的操作是在表头和表尾进行的,此时对循环单链表不设头指针而仅设尾指针,从而使得操作效率更高。其原因是,若设的是头指针,对表尾进行操作需要 O(n) 的时间复杂度,而若设的是尾指针 r,r->next 即为头指针,对于表头与表尾进行操作都只需要 O(1) 的时间复杂度。
Go 代码实现
package main
import "fmt"
type Node struct {
id int
name string
next *Node
}
// 往环形链表中插入一个节点
func InsertNode(headNode *Node, newNode *Node) {
// 判断是否第一个节点
if headNode.next == nil {
headNode.id = newNode.id
headNode.name = newNode.name
headNode.next = headNode // 构成一个环状
return
}
// 定义一个临时变量,帮忙找到环形的最后节点
temp := headNode
for {
if temp.next == headNode {
break
}
temp = temp.next
}
// 加入到链表中
temp.next = newNode
newNode.next = headNode
}
// 显示链表的所有结点信息
func ShowNode(headNode *Node) {
temp := headNode
if temp.next == nil {
fmt.Println("空空如也...")
return
}
for {
fmt.Println(temp.id, temp.name)
if temp.next == headNode {
break
}
temp = temp.next
}
}
// 删除环形链表指定的节点信息
func DelNode(headNode *Node, id int) *Node {
temp := headNode
helper := headNode
if temp.next == nil {
fmt.Println("这是一个空的环形链表,不能删除")
return headNode
}
// 如果只有一个结点
if temp.next == headNode {
temp.next = nil
return headNode
}
// 将 helper 定位到链表最后
for {
if helper.next == headNode {
break
}
helper = helper.next
}
flag := true
for {
// 如果到这里,说明比较到最后一个(最后一个还没比较)
if temp.next == headNode {
break
}
if temp.id == id {
// 找到了可以直接删除
helper.next = temp.next
flag = false
}
temp = temp.next
helper = helper.next
}
if flag && temp.id == id {
helper.next = temp.next
fmt.Println("删除ID:", id)
} else {
fmt.Println("ID不存在")
}
return headNode
}
func main() {
// 创建一个头结点
headNode := &Node{}
// 创建一个新的 Node
node1 := &Node{
id: 1,
name: "aa",
}
node2 := &Node{
id: 2,
name: "bb",
}
node3 := &Node{
id: 3,
name: "cc",
}
InsertNode(headNode, node3)
InsertNode(headNode, node1)
InsertNode(headNode, node2)
DelNode(headNode, 2) // 删除指定ID
ShowNode(headNode)
/*输出结果:
删除ID: 2
3 cc
1 aa*/
}
循环双链表
由循环单链表的定义不难推出循环双链表。不同的是在循环双链表中,头结点的 prior 指针还要指向表尾结点,如图所示:
在循环双链表 L 中,某结点 *p 为尾结点时,p->next == L
;当循环双链表为空时,其头结点的 prior 域和 next 域都等于 L。
静态链表
静态链表借助数组来描述线性表的链式存储结构,结点也有数据域 data 和指针域 next,与前面所讲的链表中的指针不同的是,这里的指针是结点的相对地址(数组下标),又称游标。和顺序表一样,静态链表也要预先分配一块连续的内存空间。
静态链表和单链表的对应关系如图所示:
静态链表以 next==-1
作为其结束的标志。静态链表的插入、删除操作与动态链表的相同,只需要修改指针,而不需要移动元素。总体来说,静态链表没有单链表使用起来方便,但在一些不专持指针的高级语言(如Basic)中,这是一种非常巧妙的设计方法。
顺序表和链表的比较
1、存取(读写)方式
顺序表可以顺序存取,也可以随机存取,链表只能从表头顺序存取元素。例如在第 i 个位置上执行存或取的操作,顺序表仅需一次访问,而链表则需从表头开始依次访问 i 次。
2、逻辑结构与筋理结构
采用顺序存储时,逻辑上相邻的元素,对应的物理存储位置也相邻。而采用链式存储时,逻辑上相邻的元素,物理存储位置则不一定相邻,对应的逻辑关系是通过指针链接来表示的。
3、查找、插入和删除操作
对于按值查找,顺序表无序时,两者的时间豆杂度均为0(n);顺序表有序时,可采用折半查找,此时的时间复杂度为 O(log2n)。
对于按序号查找,顺序表支持随机访问,时间复杂度仅为 O(1),而链表的平均时间熨杂度为 O(n)。顺序表的插入、删除操作,平均需要移动半个表长的元素。链表的插入、删除操作,只需修改相关结点的指针域即可。由于链表的每个结点都带有指针域,故而存储密度不够大。
4、空间分配
顺序存储在静态存储分配情形下,一旦存储空间装满就不能扩充,若再加入新元素,则会出现内存溢出,因此需要预先分配足够大的存储空间。预先分配过大,可能会导致顺序表后部大量闲置;预先分配过小,又会造成溢出。动态存储分配虽然存储空间可以扩充,但需要移动大量元素,导致操作效率降低,而且若内存中没有更大块的连续存储空间,则会导致分配失败。链式存储的结点空间只在需要时申请分配,只要内存有空间就可以分配,操作灵活、高效。
在实际中应该怎样选取存储结构呢?
1、基于存储的考虑
难以估计线性表的长度或存储规模时,不宜采用顺序表;链表不用事先估计存储规模,但链表的存储密度较低,显然链式存储结构的存储密度是小于 1 的。
2、基于运算的考虑
在顺序表中按序号访问 ai 的时间第杂度为 O(1),而链表中按序号访问的时间复杂度为 O(n),因此若经常做的运算是按序号访问数据元素,则显然顺序表优于链表。在顺序表中进行插入、删除操作时,平均移动表中一半的元素,当数据元素的信息量较大且表较长时,这一点是不应忽视的;在链表中进行插入、删除操作时,虽然也要找插入位置,但操作主要是比较操作,从这个角度考虑显然后者优于前者。
3、基于环境的考虑
顺用表容易实现,任何高级语言中都有数组类型;链素的操作是基于指针的,相对来讲,前者实现较为简单,这也是用户考虑的一个因素。
总之,两种存储结构各有长短,选择哪一种由实际问题的主要因素决定。通常较稳定的线性表选择顺序存储,而频繁进行插入、删除掾作的线性表(即动态性较强)宜选择链式存储。