Python数据结构之列表、栈、队列、链表、字典

什么是数据结构

简单来说,数据结构就是设计数据以何种方式组织并存储在计算机中。比如:列表、集合与字典等都是一种数据结构。

列表

python的列表在其他编程语言中称为“数组”,不过二者是有区别的。在C语言中,必须指定数组的长度,并且数组中的元素具有相同的数据类型。而python中则没有这些限制,这是因为二者在内存中存储方式不一样。

数组

我们知道,计算机将内存分割为字节,每个字节可以存储8位的二进制信息:

1.png

每个字节都具有唯一的地址。如果内存中有n个字节,那么可以把地址看作0 ~ n-1的数,并用指针变量来存储地址,也就说,指针就是地址

2.png

程序中的每个变量在内存中占有一个或多个字节,把第一个字节的地址称为是该变量的地址。

用C语言新建一个整型数组int a[4]={10, 12, 16, 33}每个数字用4个字节表示,就相当于在内存中开辟长度为20的连续空间,如图所示。那么数组名a,就是指向该数组第一个元素的指针,对数组元素的取值只需要对指针进行算术运算即可:& 取地址运算符,&a就是a在内存中的地址,2000;* 间接寻址运算符(访问指针所指向对象的内容),这里*a就表示指针a当前所指向的对象的值,也就是数组中第一个元素10;通过对指针a进行自增,就可访问到数组中的所有元素,*(a+1)就可以取到数组第二个元素的值,12:

4.png

a+i就等同于&a[i],两者都表示指向数组a中第i个元素的指针,并且*(a+1)等价于a[i],两者都表示第i个元素的值。因此,数组的取下标操作就是指针指针算术运算的一种形式

列表

python中的列表可以混合存储任意数据类型,那么它是如何实现的呢?因为列表中存的是元素的内存地址,而不是元素的值,比如有列表li = [1, True, 'hello', {'k': 'v'}]。li中的元素在内存中并不是连续存储的,因此li存储这些元素所在的内存地址,对元素取值时,先在列表中找到元素的内存地址,再通过内存地址找到元素:

3.png

因此,比起数组,对列表元素的取值要慢一些。

另外,列表没有长度限制是如何实现的呢?当我们通过append方法向列表中添加元素时,如果列表满了,那么新申请2倍于原来列表的内存地址,并将原列表的值拷贝到新的内存地址中。

列表的删除,插入的时间复杂度是O(N)

介绍

什么是栈

栈(Stack)是一个数据集合,可以理解为只能在一端进行插入或删除操作的列表。

栈的特点

后进先出(last-in, first-out)

栈的基本操作

进栈(压栈):Push;出栈:pop;查看栈顶:gettop

5.png

出栈次序问题

一个栈的进栈序列为1,2,3,问下面哪种出栈序列不可能出现?

123 (1进栈,1出栈;2进栈,2出栈;3进栈,3出栈,出栈序列:123)
132 (1 in, 1 out; 2 in, 3 in; 3 out, 2 out)
321  (1 in, 2 in, 3 in; 3 out, 2 out, 1 out)
231  (1 in, 2 in; 2 out, 3 in; 3 out, 1 out)
312 不可能出现
213  (1 in, 2 in; 2 out, 1 out; 3 in, 3 out)

栈的python实现

不需要自己定义,使用列表结构即可:

  1. 进栈:append
  2. 出栈:pop
  3. 查看栈顶:li[-1] 直接取索引

系统堆栈

系统堆栈比较复杂,这里只是简单体会一下,函数嵌套调用时的压栈和出栈:

def foo():
    bar()
    demo()
    print('print form foo')

def bar():
    demo()
    print('print from bar')

def demo():
    print('print from demo')

foo()

在IED的debug模式下调用foo函数,观察如下:

  1. 第13行,调用foo,foo入栈:[foo]
  2. 第2行,foo中调用bar,bar入栈:[foo, bar]
  3. 第7行,bar中调用demo, demo入栈:[foo, bar, demo]
  4. 第11行,demo打印完信息,执行完毕,demo出栈:[foo, bar]
  5. 第8行,bar打印完信息,执行完毕,bar出栈:[foo]
  6. 第3行,foo中调用demo,demo入栈:[foo, demo]
  7. 第11行,demo打印完信息,执行完毕,demo出栈:[foo]
  8. 第4行,foo打印完信息,执行完毕,foo出栈,栈空:[ ]

解释器分配程序运行的堆和栈的比例,程序中的变量保存在堆中,内存溢出指的就是堆溢出。栈用来保存函数调用上下文,一般程序嵌套层不会太多,因此堆的比例远大于栈的比例。当递归层数过多(死递归),超出栈的大小时,就会出现栈溢出。

栈的应用

括号匹配问题

给一个字符串,其中包含小括号、中括号、大括号,求该字符串中的括号是否匹配。比如:()()[]{}匹配,([{()}])匹配,[](不匹配,[(])不匹配:

def check(exp):
    stack = [] # 创建栈
    for char in exp:
        if char in ['(', '[', '{']:
            stack.append(char)  # 压入栈
        elif char == ')':
            if stack and stack[-1] == '(':
                stack.pop()  # 弹出栈
            else:
                return False
        elif char == ']':
            if stack and stack[-1] == '[':
                stack.pop()
            else:
                return False
        elif char == '}':
            if stack and stack[-1] == '{':
                stack.pop()
            else:
                return False

    if not stack: #如果栈为空,说明括号全部匹配
        return True
    return False

迷宫问题

给一个二维列表,表示迷宫(0表示通道,1表示围墙)。给出算法,求一条走出迷宫的路径。

maze = [
    [1,1,1,1,1,1,1,1,1,1],
    [1,0,0,1,0,0,0,1,0,1],
    # ...
    [1,1,1,1,1,1,1,1,1,1]
]

6.png

思路分析

  1. 在一个迷宫节点(x, y)上,可以进行四个方向的探查:上maze[x-1][y], 下maze[x+1][y], 左maze[x][y-1], 右maze[x][y+1]
  2. 从一个节点开始,依次从四个方向,找下一个能走的点,当找不到能走的点时,退回上一个点寻找是否有其他方向的点。
  3. 创建一个空栈,首先将入口位置进栈。当栈不为空时循环:获取栈顶元素(当前位置),寻找下一个可走的相邻方块,如果找不到可走的相邻方块,说明当前位置是死胡同,进行回溯(当前节点位置出栈,看前面的节点点是否还有别的出路)
maze = [[1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
        [1, 0, 0, 1, 0, 0, 0, 1, 0, 1],
        [1, 0, 0, 1, 0, 0, 0, 1, 0, 1],
        [1, 0, 0, 0, 0, 1, 1, 0, 0, 1],
        [1, 0, 1, 1, 1, 0, 0, 0, 0, 1],
        [1, 0, 0, 0, 1, 0, 0, 0, 0, 1],
        [1, 0, 1, 0, 0, 0, 1, 0, 0, 1],
        [1, 0, 1, 1, 1, 0, 1, 1, 0, 1],
        [1, 1, 0, 0, 0, 0, 0, 0, 0, 1],
        [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]]
# 对于maze中的元素,可以用maze[x][y]取值,x是行坐标,y就是列坐标
# 约定按四个方向探寻:
dirs = [lambda x, y: (x - 1, y),  # 上
        lambda x, y: (x + 1, y),  # 下
        lambda x, y: (x, y - 1),  # 左
        lambda x, y: (x, y + 1)]  # 右


def maze_path(x1, y1, x2, y2):
    """
    :param x1: 起点 行坐标
    :param y1: 起点 列坐标
    :param x2: 终点 行坐标
    :param y2: 终点 列坐标
    :return: 
    """
    stack = []
    stack.append((x1, y1))  # 起点位置入栈
    while stack:  # 栈不为空时循环;栈空代表死迷宫(找遍所有的路,直到回退到起点位置,继续回退(出栈),栈空)
        curNode = stack[-1]  # 栈顶,表示当前位置
        if curNode[0] == x2 and curNode[1] == y2:  # 到达终点,栈中的元素就是最终路径
            for node in stack:  # 打印路径
                print(node)
            return True
        for dir in dirs:  # 依次进行4个方向的探寻
            nextNode = dir(*curNode)  # 下一个位置坐标
            if maze[nextNode[0]][nextNode[1]] == 0:  # 0代表通道,说明找到下一个方块
                stack.append(nextNode)  # 添加到栈顶,更新当前位置
                maze[nextNode[0]][nextNode[1]] = -1  # 将0赋值为-1,标记为已经走过,防止死循环
                break  # 找到一个通道,退出
        else:  # 四个方向都找完,没有通道,那么开始回退
            maze[curNode[0]][curNode[1]] = -1  # 标记死路
            stack.pop()  # 弹出栈顶,回退
    print('死迷宫')
    return False  # 死迷宫

maze_path(1, 1, 8, 8)

通过栈解决迷宫问题,用的是回溯法(一条路走到黑,走不通则回退),是深度优先的模式。

队列

概念

队列(Queue)是一个数据集合,仅允许在列表的一端进行插入,另一端进行删除。

进行插入的一端称为队尾(rear),插入动作称为进队或入队。

进行删除的一端称为队头(front),删除动作称为出队。

队列的性质:先进先出(First-in, First-out)。

双向队列:队列的两端都允许进行进队和出队操作。

7.png

队列的实现

用列表可以简单的模拟队列:li= []

  1. 进队:li.append
  2. 出队:li.pop(0)

不过通过python列表模拟队列,效率太低了,时间复杂度是O(n)。下面我们看一下队列的底层实现原理。

实现思路

  1. 初步设想:数组 + 两个下标指针
  2. 创建一个数组和两个指针变量,front指向队首,rear指向队尾。初始时,front和rear都为0。
  3. 进队操作:元素写到li[rear]的位置,rear自增1
  4. 出队操作:返回li[front]的元素,front自减1

8.png

虽然这样的时间复杂读是O(1),但是这种实现有一个问题:

数组长度有限,当元素都出队后,rear和front值相同时,新元素无法入队了,即便是将列表开很大,但是front前面的空间都浪费了。解决方案:环形队列,将数组首尾逻辑上连接起来:

9.png

随着元素A, B, C, D,…的依次进队和出队,rear++和front++往后移,队列的长度是12,当有新元素依次进来时,超出队列长度的,只要让它想办法进入0的位置,就可以了。当队尾指针front == Maxsize + 1时,再前进一个位置就自动到0,实现方式是求余数运算

队首指针前进1:front = (front + 1) % MaxSize

队尾指针前进1:rear = (rear + 1) % MaxSize

队空条件:rear == front

队满条件:(rear + 1) % MaxSize == front

对于队满的情况,需要额外处理,中间空一个,以区分rear和front。

deque

Python的deque模块可以实现队列,并且支持双向队列:

from collections import deque

li = [1,2,3,4]
queue = deque(li) # 从列表创建双向队列(也可以直接创建)
queue.append(5)  # deque([1, 2, 3, 4, 5])
queue.popleft()  # deque([2, 3, 4, 5])

# 双向队列对首进队,队尾出对
queue.appendleft('a')  # deque(['a', 2, 3, 4, 5])
queue.pop()  # deque(['a', 2, 3, 4])

另外,线程中的那个queue强调的是线程安全,与这个不一样。

用队列解决迷宫问题

还是上面的迷宫问题,如果用队列来实现,就是广度优先。

思路:从一个节点开始,寻找所有下面能继续走的节点。继续寻找,直到找到出口。

方法:

  1. 创建一个空队列,将起点位置进队。在队列不为空时循环:出队一次。如果当前位置为出口,则结束算法;否则找出当前方块的4个相邻方块中可走的方块,全部进队。
  2. 用一个二维数据结构path记录每一步是从哪一步来的(索引)。用-1 记录走过的路径 或 起始地方。每探寻一个节点,该节点出队,放到 path中,直到探寻到终点。最终通过索引,将路径串起来。

图示:

10.png

11.png

12.png

通过这种队列实现的广度优先寻找,最终路径一定是最短的。而之前的深度优先搜索,其结果具有一定的随机性,取决于探寻方向的顺序。

代码实现:

maze = [[1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
        [1, 0, 0, 1, 0, 0, 0, 1, 0, 1],
        [1, 0, 0, 1, 0, 0, 0, 1, 0, 1],
        [1, 0, 0, 0, 0, 1, 1, 0, 0, 1],
        [1, 0, 1, 1, 1, 0, 0, 0, 0, 1],
        [1, 0, 0, 0, 1, 0, 0, 0, 0, 1],
        [1, 0, 1, 0, 0, 0, 1, 0, 0, 1],
        [1, 0, 1, 1, 1, 0, 1, 1, 0, 1],
        [1, 1, 0, 0, 0, 0, 0, 1, 0, 1],
        [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]]

from collections import deque


def maze_path2(x1, y1, x2, y2):
    queue = deque()
    path = []  # 存放走过的
    queue.append((x1, y1, -1))
    # queue中元素:(1, 1, -1),前两位是坐标,第三位是其上一步在path中的索引。这里用-1标记起点。
    maze[x1][y1] = -1
    while queue:
        curNode = queue.popleft()
        path.append(curNode)
        if curNode[0] == x2 and curNode[1] == y2:
            print_path(path)
            return True
        for dir in dirs:
            nextNode = dir(curNode[0], curNode[1])
            if maze[nextNode[0]][nextNode[1]] == 0:
                queue.append((*nextNode, len(path) - 1))  # len(path)-1) 记录来源索引
                maze[nextNode[0]][nextNode[1]] = -1
    return False


def print_path(path):
    '''提取路径'''
    curNode = path[-1]
    realpath = []
    print('迷宫路径为:')
    while curNode[2] != -1:
        realpath.append(curNode[0:2])
        curNode = path[curNode[2]] # 通过索引找到上一步节点
    realpath.append(curNode[0:2]) # 加入起点
    realpath.reverse()
    print(realpath)


maze_path2(1, 1, 8, 8)

链表

概念

链表中每一个元素都是一个对象,每个对象称为一个节点,包含有数据域key和指向下一个节点的指针next。通过各个节点之间的相互连接,最终串联成一个链表。

区别与数组/列表:数组和列表是连续存储的,删除和插入元素,其它元素需要补过去或者后移,时间复杂度是O(n);而链表则不会这样,它的时间复杂度是O(1)

定义节点和构造链表

# 定义节点类
class Node:
    def __init__(self, val):
        self.val = val
        self.next = None

# 实例化节点
a = Node(1)
b = Node(2)
c = Node(3)

# 构造链表
a.next = b
b.next = c

# 取节点c的值
a.next.next.val #3

头节点

13.png

一般头不存元素(可以存链表的长度)

节点的遍历,插入和删除

遍历

def traversal(head):
    curNode = head
    while curNode is not None:
        print(curNode.val)
        curNode = curNode.next

插入

p = Node(5)

p.next = curNode.next # 先将插入节点与当前节点的下一个节点连起来
curNode.next = p # 再将当前节点与插入节点连起来

删除

P = curNode.next  # 定位删除节点

curNode.next = curNode.next.next  # 将当前节点与删除节点的下一个节点连起来
del p  # 删除节点

建立链表

头插法

16.png

def create_link_head(li):
    """头插法:每个新节点,都插到头节点的后面"""
    head = Node() 
    for num in li:
        s = Node(num)
        s.next = head.next
        head.next = s
    return head

head = create_link_head([1,2,3,4])
traversal(head)
""" 遍历链表
None
4
3
2
1
"""

尾插法

17.png

def create_link_tail(li):
    """尾插法:每个新节点,都插到最后一个节点后面"""
    head = Node()
    tail = head  # tail指向尾节点
    for num in li:
        s = Node(num)
        tail.next = s
        tail = s
    return head

head = create_link_tail([1,2,3,4])
traversal(head)
""" 遍历链表
None
1
2
3
4
"""

双链表

双链表中每个节点有两个指针:一个指向后面节点、一个指向前面节点。

18.png

定义节点

class Node:
    def __init__(self, val=None):
        self.val = val
        self.next = None
        self.prev = None

双链表节点的插入和删除

19.png

插入

p = Node(2)
# step1: 将插入节点与当前节点的下一个节点连起来
p.next = curNode.next  
curNode.next.prev = p
# step2: 将插入节点与当前节点连起来
p.prev = curNode
curNode.next = p

删除

p = curNode.next
# step1: 当前节点与删除节点的下一个节点连起来
curNode.next = p.next
p.next.prev = curNode
# step2: del
del p

建立双链表

def create_dlink_tail(li):
    head = Node()
    tail = head
    for num in li:
        s = Node(num)
        s.prev = tail
        tail.next = s
        tail = s
    return head, tail

# 双向链表可以从两个方向遍历
def traversal_head(head):
    curNode = head
    while curNode is not None:
        print(curNode.val)
        curNode = curNode.next

def traversal_tail(tail):
    curNode = tail
    while curNode is not None:
        print(curNode.val)
        curNode = curNode.prev

head, tail = create_dlink_tail([1,2,3,4])
traversal_head(head) # None 1 2 3 4
traversal_tail(tail) # 4 3 2 1 None

二叉数就是链式存储

这里推荐一个网站:https://visualgo.net/zh 数据结构和算法动态可视化。

20.png

该图可以用如下代码表示

class BTNode:
    """二叉树(binary tree)节点类"""
    def __init__(self, val):
        self.val = val
        self.lchild = None
        self.rchild = None

a = BTNode(1)
b = BTNode(2)
c = BTNode(3)
d = BTNode(4)

a.lchild = b
b.rchild = c
b.lchild = d

二叉搜索树

也叫二叉查找树(Binary Search Tree)是一种特殊的二叉树,它改善了二叉树节点查找的效率。满足以下两个特征的就是二叉搜索树

对于任意一个节点 n:

  1. 其左子树(left subtree)下的每个后代节点(descendant node)的值都小于节点 n 的值;
  2. 其右子树(right subtree)下的每个后代节点的值都大于节点 n 的值。

比如:

21.png

二叉搜索树的查找复杂度是O(N),每次查找规模小一半。

二叉树遍历

二叉树遍历有三种方式:前序,中序,后序,也称为前根序遍历,中根序遍历,后根序遍历(这样可能还好理解一点,前中后本来就是相对于根结点来说的)

22.png

  1. 前根序遍历:先遍历根结点,然后遍历左子树,最后遍历右子树:根 > 左 > 右

    ABDHECFG

  2. 中根序遍历:先遍历左子树,然后遍历根结点,最后遍历右子树: 左 > 根 >右

    HDBEAFCG

  3. 后根序遍历:先遍历左子树,然后遍历右子树,最后遍历根节点:左 > 右 > 根

    HDEBFGCA

注意:遍历是递归的,只要是有子节点的,就作为根对待,依据上述方式遍历。

二叉搜索树查找用的是中根序遍历。

这一部分参考了:http://blog.csdn.net/prince_jun/article/details/7699024

python中的集合与字典

哈希表

哈希表(Hash Table,又称为散列表),是一种线性的存储结构(类似于数组),根据关键码的值(key, value)直接进行访问。将对象的Key通过一个哈希函数hash(key)换成一个整型数字,然后将该数字对数组长度进行取余,取余结果就当作数组的下标,将对象的值value存储在以该数字为下标的数组空间里。(哈希函数又称为散列函数)

哈希函数的种类很多,这里不作研究。

例如:

数据集合{1,6,7,9},假设存在哈希函数hash(x)使得hash(1) = 0, hash(6) = 2, hash(7) = 4, hash(9) = 5,那么这个哈希表被存储为[1,None, 6, None, 7, 9]。当我们查找6所在的位置时,通过哈希函数获得该元素在哈希表中的下标(hash(6)=2),然后在下标为2的位置取出该元素。

哈希表查询速度非常的快,几乎是O(1)的时间复杂度。

哈希冲突

由于哈希表的下标范围是有限的,而元素关键字的值是接近无限的,因此可能会出现h(102) = 56, h(2003) = 56这种情况。此时,两个元素映射到同一个下标处,造成哈希冲突。

解决方案:

  1. 拉链法:将所有冲突的元素用链表连接

    23.png

    左边个数组,数组的每个成员包括一个指针,指向一个链表的头,当然这个链表可能为空,也可能元素很多。我们根据元素的一些特征把元素分配到不同的链表中去,也是根据这些特征,找到正确的链表,再从链表中找出这个元素。

  2. 开放寻址法:通过哈希冲突函数得到新的地址。在此不讨论。

python中的字典

在Python中,给出一个字典:d = {'name': 'lena', 'age': '18', 'hobby': '瓜子'},使用哈希表存储字典,通过哈希函数将字典中元素的key映射为数组/列表下标。假如hash(‘name’)=3, hash(‘age’)=1, h(‘hobby’)=4, 则哈希表存储为[None, 18, None, 'Lena', '瓜子']

在字典键值对数量不多的情况下,几乎不会发生哈希冲突,此时查找一个元素的时间复杂度为O(1)。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值