数据结构与算法(Python)——常见数据结构Part1(数组和链表)
Posted The fool
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了数据结构与算法(Python)——常见数据结构Part1(数组和链表)相关的知识,希望对你有一定的参考价值。
写在前面
本节对常见数据结构做一个预览,我们的目的是快速了解他们,对于它们涉及到的复杂的数据结构和算法,在这里并不全部展开,留在后期详述。
1.数组
数组是我们要学习的第一个线性结构(Linear structure),所谓线性结构,指的是在数据有限集合中,每个数据元素都有一个确定的位置,例如 a0 a 0 是第一个元素, an−1 a n − 1 是最后一个元素, ai a i 是第i个元素;元素之间相邻,例如 ai a i 前驱为 ai−1 a i − 1 , ai a i 后继为 ai+1 a i + 1 。其中i被称之为在线性表中的位序。
数组中存放的一般是同类型元素的集合,在物理存储上它们连续存放,通过索引来访问数组中的每个元素,数组可以是一维,二维和其他多维形式。
常见的一维数组形式表示为:
二维数组表示为:
访问一维数组我们只需要一个索引,而访问多维数组则需要多个多个数组组合的索引。例如存储(10, 20, 30, 40, 50)数据的数组表示为:
访问第一个元素,我们通过可以通过索引0表示为:numbers[0],它的值为10。
1.1 数组的特性
数组的实现可以分为固定大小的,也称之为静态数组(static array);另外一种是大小随着需要能够动态增长的,称之为动态数组(dynamic array)。静态数组,需要提前预知数组的大小,这种预判在某些情况下可能会偏大,从而导致存储空间的浪费,同时如果预估比实际需要少了,则容易发生数据溢出。动态数组可以根据需要动态的增长空间,内存安全的语言例如Java/C#之类的会自动管理内存,但是像C++中动态分配数组则需要用户自行管理,相比较而言容易导致错误。Python语言会自行分配和回收内存,数组天生就是动态数组。
在数组中访问元素,通过索引即可找到,时间复杂度为O(1)。
插入元素时,如果是尾部插入,则时间复杂度为O(1),如果是其他位置插入则需要移动其他元素,来保持数组的线性特性,假若数组长度为n,插入位置概率平均分布,则插入元素时移动元素的平均量为:
∑ni=0n=n+(n−1)+...+1n=1+n2
∑
i
=
0
n
n
=
n
+
(
n
−
1
)
+
.
.
.
+
1
n
=
1
+
n
2
则平均时间复杂度为O(n)。同理,删除元素时,删除最后一个元素,则不需要移动元素,删除其余元素则需要移动其他元素,时间复杂为O(n)。
1.2 Python中的数组
在Python中,使用list来表示数组,list对象实现为一个动态数组,也就是长度根据需要自动调整的数组,这个数组支持不同类型元素存储。另外array模块也提供了接近C语言数组的只支持同类型元素的数组。
在Python中使用list存储上面数字:
numbers = [10, 20, 30, 40, 50]
print("array length:", len(numbers))
print("first element:", numbers[0])
print("last element:", numbers[-1])
#('array length:', 5)
#('first element:', 10)
#('last element:', 50)
使用Array模块存储为:
import array
# 使用l表示为int数组
numbers = array.array("l", [10, 20, 30, 40, 50])
print("array length:", len(numbers))
print("first element:", numbers[0])
print("last element:", numbers[-1])
Python中list支持多种操作,例如添加元素append,合并两个表使用extend操作,感兴趣地可以查看Python list。
1.3数组的应用
数组一般用来存放一组相关的数据,可以是简单的整数,也可以是复杂的对象;数组还可以同来实现其他数据结构,例如使用数组实现栈、队列等数据结构。
2.链表
链表是我们要学习的第二种线性结构。与数组不同的时,链表中每个元素也称之为节点(Node),它的物理位置并不一定与它的前驱或者后继相邻,可以在其他任意位置,只要有一种线索将每个元素串起来,形成这个线性结构即可。这个线索,就是每个节点的地址,习惯上称之为指针。
2.1 不同类型的链表
例如如下图所示为有一个指向后继节点指针的单链表(single linked list):
单链表中有一个头指针(Head)指向链表中第一个节点,最后一个节点没有后继节点了,因此它的指针指向空即NULL。
通过上面的观察,我们可以得出单链表对应的节点结构,可以表达为:
class SingleLinkListNode(object):
"""
单链表结点类
"""
def __init__(self, data=None, next_node=None):
self.data = data # 数据域
self.next = next_node # 指向下一个结点的指针(也即是地址)
def __str__(self):
return str(self.data)
在上述单链表中,如果我们已经找到了第i个节点,能不能找到位于i之前的节点呢? 这里需要在尾部节点上做一个修改,使它的指针指向链表头,这样构成了一个环形结构,这样的链表称之为循环链表(Circular linked list)。在循环链表中,可以通过任意节点找到链表中的其他节点。
另外一个问题是,如果我们已经找到了第i个节点,想知道它的前驱怎么办?我们可以在遍历过程中,用一个临时变量记住它的前驱节点。另一种方法是,在节点结构中提供两个指针,一个指向后继,一个指向前驱,这种链表称之为双向链表(Double linked list),它的节点结构声明为:
class DoubleLinkListNode(object):
"""
双链表结点类
"""
def __init__(self, data=None, prev_node=None,next_node=None):
self.data = data # 数据域
self.prev = prev_node # 指向前驱节点
self.next = next_node # 指向后继节点
def __str__(self):
return str(self.data)
当然也可以在双链表中,加入循环机制,构成循环双链表。上述3种链表,对比如下图所示:
2.2 链表特性
以单链表为例进行说明。
在上面的链表中,我们可以看出,如果需要访问数组中第i个元素,则需要从头开始遍历链表,除此之外别无它法(花费额外空间保存第i个元素地址除外),因此链表中访问元素时间复杂度为O(n)。
插入元素时,头部插入则仅仅需要重新调整头指针和插入节点的指针,时间复杂度为O(1)。尾部插入时,首先需要找到尾部节点,这个遍历过程时间复杂度为O(n),插入时间复杂度为O(1),因此总的时间复杂度为O(n);注意,如果链表中保存了尾部指针位置,则尾部插入时时间复杂度也能达到O(1)。
删除元素时,我们首先需要定位到指定节点,然后删除,则时间复杂度为O(n)。
2.3 Python中链表
Python中并没有提供链表的实现,但是list结构本身是支持动态增长的数组,且数组元素是引用类型,因此使用list类型即可满足编程需求。这里自己动手实现一个上面描述的单链表:
class LinkListNode(object):
"""
链表结点
"""
def __init__(self, data=None, next_node=None):
self.data = data
self.next = next_node
def __str__(self):
return str(self.data)
class LinkList(object):
"""
单链表类
"""
def __init__(self):
self.head = LinkListNode() # 表头的空结点指针 Dummy node
self.tail = self.head
def __init__(self, data_array):
if type(data_array) is not list:
raise ValueError("init with data array only.")
self.head = LinkListNode()
cur_node = self.head
for x in data_array:
cur_node.next = LinkListNode(x)
cur_node = cur_node.next
self.tail = cur_node
def __str__(self):
head = self.head.next
output_str = "LinkList["
while head:
output_str += str(head.data)+", "
head = head.next
output_str += "]"
return output_str
def push_back(self, data):
self.tail.next = LinkListNode(data)
self.tail = self.tail.next
def back(self):
if self.tail:
return self.tail.data
else:
return None
def pop_back(self):
if self.tail != self.head:
node_to_remove = self.head.next
prev_node = self.head
while node_to_remove and node_to_remove.next:
prev_node = node_to_remove
node_to_remove = node_to_remove.next
prev_node.next = None
self.tail = prev_node
del node_to_remove
def front(self):
if self.head.next:
return self.head.next.data
else:
return None
def push_front(self, data):
self.head.next = LinkListNode(data, self.head.next)
if self.tail == self.head:
self.tail = self.head.next
def pop_front(self):
if self.head != self.tail:
first_node = self.head.next
self.head.next = first_node.next
if not self.head.next:
self.tail = self.head
del first_node
def remove_at(self, index):
if index < 0 or self.tail == self.head:
return False
node_to_remove = self.head.next
prev_node = self.head
for i in range(index):
prev_node = node_to_remove
node_to_remove = node_to_remove.next
if not node_to_remove:
return False
prev_node.next = node_to_remove.next
if not prev_node.next:
self.tail = prev_node
del node_to_remove
return True
def is_empty(self):
return self.tail == self.head
def size(self):
count = 0
cur_node = self.head.next
while cur_node:
count += 1
cur_node = cur_node.next
return count
@staticmethod
def print_list(list_head):
"""
打印链表
:param list_head: 链表头结点
:return:None
"""
output_str = str(list_head)
print(output_str)
注意上述实现的几点:
提供了一个head指向一个空的头结点,一个tail指针指向尾部节点。
提供了额外的tail指针,虽然方便了尾部插入;但使得在进行某些操作时,总是得考虑尾部指针的更新,例如删除节点时如果删除了尾部节点则应更新tail。可见增加一个指针成员将使得编码变得复杂了。
上面实现中提供了两个初始化方法,可以构建一个空的链表,也可以从一个数组构造链表。
上述实现中,提供了类的静态打印数据方法,这个方法重写了
__str__
方法。
为上述单链表类编写一个测试用例:
if __name__ == "__main__":
link_list = LinkList([1, 2, 3, 4])
LinkList.print_list(link_list)
i = 0
while not link_list.is_empty():
if i % 2:
link_list.pop_back()
else:
link_list.pop_front()
i += 1
LinkList.print_list(link_list)
输出:
LinkList[1, 2, 3, 4, ]
LinkList[2, 3, 4, ]
LinkList[2, 3, ]
LinkList[3, ]
LinkList[]
链表实现的时候,测试用例的要考虑的情形还是挺多的,在编写的时候尤其要考虑到特殊情况,例如删除空表中元素等。双链表的实现可以自行实现,也可以参考Linked Lists in Python。
3.数组和链表的比较
数组的实现要比链表简单,但是为什么还是要引入链表呢?通过下面这个表格的对比,我们就能回答这个问题(整理自Linked Lists)。
比较项目 | Array | LinkList |
---|---|---|
元素访问 | O(1) 随机访问 | O(n) 需要遍历 |
头部添加/删除 | O(n) 因为需要移动其他元素 | O(1) 修改指针即可 |
尾部添加/删除 | O(1)-尾部添加时摊销O(1) | O(1)-有尾指针的链表中O(1) |
任意位置添加/删除 | O(n)-移动元素开销 | O(n)-遍历指针开销 |
额外空间 | O(n)-数组空间预分配,浪费的空间在于没有实际使用的空间 | O(n)-存储指针而消耗的空间 |
空间限制 | 连续的空间,特殊情况下容易不足 | 不需要连续空间 |
总结一下链表的优势在于:
- 1)不用提前估算空间,空间也不需要预分配
- 2)在删除结点和添加结点时比数组快(删除元素时,数组需要移动元素,这开销远比链表遍历指针大,虽然二者复杂度都为O(n))。
链表的劣势在于:
- 1)不能随机访问,除了头尾结点外只能顺序遍历。
- 2) 存储指针也将带来额外开销。
数组的优势在于:支持随机访问 可以使用索引访问;劣势在于要求实现预分配空间,通常分配的空间比使用的多,而且在内存碎片情况下,可能无法继续扩大。
关于两者的比较,感兴趣地还可以参考:
4. Python中list实现说明
Python中list实现为动态数组,而不是链表,可以参考其结构定义:
typedef struct
PyObject_HEAD
Py_ssize_t ob_size;
/* Vector of pointers to list elements. list[0] is ob_item[0], etc. */
PyObject **ob_item;
/* ob_item contains space for 'allocated' elements. The number
* currently in use is ob_size.
* Invariants:
* 0 <= ob_size <= allocated
* len(list) == ob_size
* ob_item == NULL implies ob_size == allocated == 0
*/
Py_ssize_t allocated;
PyListObject;
这是一个预分配的动态数组,真实大小为ob_size,分配大小为allocated,其中ob_item是一个指针数组,每个元素指向PyObject对象。感兴趣地可以参考SO。
也可以通过下面的图来理解Python中list的实现:
本部分介绍了常见数据结构中的数组和链表,要想较好地掌握本节内容,更多的练习必不可少,这里推荐leetcode linkedlist训练的习题。
以上是关于数据结构与算法(Python)——常见数据结构Part1(数组和链表)的主要内容,如果未能解决你的问题,请参考以下文章
数据结构与算法(Python)——常见数据结构Part3(队列和循环队列)
数据结构与算法(Python)——常见数据结构Part3(队列和循环队列)
数据结构与算法(Python)——常见数据结构Part1(数组和链表)