《数据结构与算法之美》笔记
1. 数组
数组(Array)是一种线性表数据结构。它用一组连续的内存空间,来存储一组具有相同类型的数据。
正是因为这两个限制,它才有了一个堪称“杀手锏”的特性:“随机访问”。但有利就有弊,这两个限制也让数组的很多操作变得非常低效,比如要想在数组中删除、插入一个数据,为了保证连续性,就需要做大量的数据搬移工作,平均情况时间复杂度为 O(n)。
说到数据的访问,那你知道数组是如何实现根据下标随机访问数组元素的吗?
我们知道,计算机会给每个内存单元分配一个地址,计算机通过地址来访问内存中的数据。当计算机需要随机访问数组中的某个元素时,它会首先通过下面的寻址公式,计算出该元素存储的内存地址:
a[i]_address = base_address + i * data_type_size
数组是适合查找操作,但是查找的时间复杂度并不为 O(1)。即便是排好序的数组,你用二分查找,时间复杂度也是 O(logn)。所以,正确的表述应该是,数组支持随机访问,根据下标随机访问的时间复杂度为 O(1)。
Python 实现:
class MyArray:
"""A simple wrapper around List.
You cannot have -1 in the array.
"""
def __init__(self, capacity: int):
self._data = []
self._capacity = capacity
def __getitem__(self, position: int):
return self._data[position]
def __setitem__(self, index: int, value: object):
self._data[index] = value
def __len__(self) -> int:
return len(self._data)
def __iter__(self):
for item in self._data:
yield item
def find(self, index: int) -> object:
try:
return self[index]
except IndexError:
return None
def delete(self, index: int) -> bool:
try:
self._data.pop(index)
return True
except IndexError:
return False
def insert(self, index: int, value: int) -> bool:
if len(self) >= self._capacity:
return False
else:
return self._data.insert(index, value)
def print_all(self):
for item in self:
print(item)
def test_myarray():
array = MyArray(5)
array.insert(0, 3)
array.insert(0, 4)
array.insert(1, 5)
array.insert(3, 9)
print(array.find(3))
2. 链表
对内存要求方面: 数组对内存的要求更高。因为数组需要一块连续内存空间来存放数据。(可能出现的问题就是:内存总的剩余空间足够,但是申请容量较大的数组时申请失败) 链表对内存的要求较低,是因为链表不需要连续的内存空间,只要内存剩余空间足够,无论是否连续,用链表来申请空间一定会成功。 但是要注意:链表虽然方便。但是内存开销比数组大了将近一倍,假设存储100个整数,数组400个字节的存储空间足够了。但是如果用链表存储100个整数,链表得需要800个字节的存储空间,因为链表中的每个节点不止要存储数据,还要存储地址,内存的利用率就比数组低太多了。 由此还可以得出:如果内存容量本身就很小,要存储的数据也比较多。选择数组来存储数据更好,如果内存空间充足,那我们在存储数据的时候到底选择链表还是数组。这个就视具体的业务场景而定了。
链表通过指针将一组零散的内存块串联在一起。其中,我们把内存块称为链表的“结点”。为了将所有的结点串起来,每个链表的结点除了存储数据之外,还需要记录链上的下一个结点的地址。如图所示,我们把这个记录下个结点地址的指针叫作后继指针 next。
与数组一样,链表也支持数据的查找、插入和删除操作。我们知道,在进行数组的插入、删除操作时,为了保持内存数据的连续性,需要做大量的数据搬移,所以时间复杂度是 O(n)。而在链表中插入或者删除一个数据,我们并不需要为了保持内存的连续性而搬移结点,因为链表的存储空间本身就不是连续的。所以,在链表中插入和删除一个数据是非常快速的。为了方便你理解,我画了一张图,从图中我们可以看出,针对链表的插入和删除操作,我们只需要考虑相邻结点的指针改变,所以对应的时间复杂度是 O(1)。
但是,有利就有弊。链表要想随机访问第 k 个元素,就没有数组那么高效了。因为链表中的数据并非连续存储的,所以无法像数组那样,根据首地址和下标,通过寻址公式就能直接计算出对应的内存地址,而是需要根据指针一个结点一个结点地依次遍历,直到找到相应的结点。你可以把链表想象成一个队伍,队伍中的每个人都只知道自己后面的人是谁,所以当我们希望知道排在第 k 位的人是谁的时候,我们就需要从第一个人开始,一个一个地往下数。所以,链表随机访问的性能没有数组好,需要 O(n) 的时间复杂度。
所以对于执行较慢的程序,可以通过消耗更多的内存(空间换时间)来进行优化;而消耗过多内存的程序,可以通过消耗更多的时间(时间换空间)来降低内存的消耗。
链表 VS 数组性能大比拼
数组简单易用,在实现上使用的是连续的内存空间,可以借助 CPU 的缓存机制,预读数组中的数据,所以访问效率更高。而链表在内存中并不是连续存储,所以对 CPU 缓存不友好,没办法有效预读。数组的缺点是大小固定,一经声明就要占用整块连续内存空间。如果声明的数组过大,系统可能没有足够的连续内存空间分配给它,导致“内存不足(out of memory)”。如果声明的数组过小,则可能出现不够用的情况。这时只能再申请一个更大的内存空间,把原数组拷贝进去,非常费时。链表本身没有大小的限制,天然地支持动态扩容,我觉得这也是它与数组最大的区别。
双向链表
单向链表只有一个方向,结点只有一个后继指针 next 指向后面的结点。而双向链表,顾名思义,它支持两个方向,每个结点不止有一个后继指针 next 指向后面的结点,还有一个前驱指针 prev 指向前面的结点。
在实际的软件开发中,从链表中删除一个数据无外乎这两种情况:
(1). 删除结点中“值等于某个给定值”的结点;
(2). 删除给定指针指向的结点。
对于第一种情况,不管是单链表还是双向链表,为了查找到值等于给定值的结点,都需要从头结点开始一个一个依次遍历对比,直到找到值等于给定值的结点,然后再通过我前面讲的指针操作将其删除。尽管单纯的删除操作时间复杂度是 O(1),但遍历查找的时间是主要的耗时点,对应的时间复杂度为 O(n)。根据时间复杂度分析中的加法法则,删除值等于给定值的结点对应的链表操作的总时间复杂度为 O(n)。对于第二种情况,我们已经找到了要删除的结点,但是删除某个结点 q 需要知道其前驱结点,而单链表并不支持直接获取前驱结点,所以,为了找到前驱结点,我们还是要从头结点开始遍历链表,直到 p->next=q,说明 p 是 q 的前驱结点。但是对于双向链表来说,这种情况就比较有优势了。因为双向链表中的结点已经保存了前驱结点的指针,不需要像单链表那样遍历。所以,针对第二种情况,单链表删除操作需要 O(n) 的时间复杂度,而双向链表只需要在 O(1) 的时间复杂度内就搞定了!
Python 代码实现:
class Node(object):
'''链表结构中的Node节点'''
def __init__(self, data, next_node=None):
'''Node节点的初始化
参数:
data:该Node节点存储的数据
next_node:下一个Node节点的引用地址
'''
self.__data = data
self.__next = next_node
@property
def data(self):
'''获取当前Node节点存储的数据
返回:
当前Node节点存储的数据
'''
return self.__data
@data.setter
def data(self, data):
'''当前Node节点存储数据的设置方法
参数:
data:新的存储数据
'''
self.__data = data
@property
def next_node(self):
'''获取当前Node节点的next指针值
返回:
当前Node节点的next指针值
'''
return self.__next
@next_node.setter
def next_node(self, next_node):
'''当前Node节点的next指针值的修改方法
参数:
next_node:新的下一个Node节点的引用
'''
self.__next = next_node
class SinglyLinkedList(object):
'''单向链表'''
def __init__(self):
'''单向链表的初始化方法'''
self.__head = None
def print_all(self):
"""打印当前链表所有节点数据."""
pos = self.__head
if pos == None:
print('当前链表没有数据')
else:
while pos.next_node is not None:
print(str(pos.data) + " --> ", end="")
pos = pos.next_node
print(str(pos.data))
def find_by_value(self, value):
"""按照数据值在单向列表中查找.
参数:
value:查找的数据
返回:
Node
"""
node = self.__head
while (node is not None) and (node.data != value):
node = node.next_node
return node
def find_by_index(self, index):
"""按照索引值在列表中查找.
参数:
index:索引值
返回:
Node
"""
node = self.__head
pos = 0
while (node is not None) and (pos != index):
node = node.next_node
pos += 1
return node
def insert_to_head(self, value):
"""在链表的头部插入一个存储value数值的Node节点.
参数:
value:将要存储的数据
"""
node = Node(value)
node.next_node = self.__head
self.__head = node
def insert_after(self, node, value):
"""在链表的某个指定Node节点之后插入一个存储value数据的Node节点.
参数:
node:指定的一个Node节点
value:将要存储在新Node节点中的数据
"""
if node is None:
return
else:
new_node = Node(value)
new_node.next_node = node.next_node
node.next_node = new_node
def insert_before(self, node, value):
"""在链表的某个指定Node节点之前插入一个存储value数据的Node节点.
参数:
node:指定的一个Node节点
value:将要存储在新的Node节点中的数据
"""
if (node is None) or (self.__head is None): # 如果指定在一个空节点之前或者空链表之前插入数据节点,则什么都不做
return
if node == self.__head: # 如果是在链表头之前插入数据节点,则直接插入
self.insert_to_head(value)
return
new_node = Node(value)
pre_node = self.__head
while (pre_node is not None) and (pre_node.next_node != node):
pre_node = pre_node.next_node
if pre_node:
pre_node.next_node = new_node
new_node.next_node = node
def delete_by_node(self, node):
"""在链表中删除指定Node的节点.
参数:
node:指定的Node节点
"""
if self.__head is None: # 如果链表是空的,则什么都不做
return
if node == self.__head: # 如果指定删除的Node节点是链表的头节点
self.__head = node.next_node
return
pre_node = self.__head
while (pre_node is not None) and (pre_node.next_node != node):
pre_node = pre_node.next_node
if pre_node:
pre_node.next_node = node.next_node
def delete_by_value(self, value):
"""在链表中删除指定存储数据的Node节点.
参数:
value:指定的存储数据
"""
if self.__head is None: # 如果链表是空的,则什么都不做
return
if self.__head.data == value: # 如果链表的头Node节点就是指定删除的Node节点
self.__head = self.__head.next_node
return
pre_node = self.__head
while (pre_node is not None) and (pre_node.next_node.data != value):
pre_node = pre_node.next_node
if pre_node:
pre_node.next_node = pre_node.next_node.next_node
def reverseList(self, head):
if not head:
return None
if not head.next_node:
self.__head = head
return head
self.reverseList(head.next_node)
head.next_node.next_node = head
head.next_node = None
#return headNode
l = SinglyLinkedList()
l.insert_to_head(6)
l.insert_to_head(2)
l.insert_to_head(8)
l.insert_to_head(9)
l.insert_to_head(3)
l.print_all()
head=l.find_by_index(0)
l.reverseList(head)
l.print_all()
3. 栈
后进者先出,先进者后出,这就是典型的“栈”结构。
- 栈在函数调用中的应用
我们知道,操作系统给每个线程分配了一块独立的内存空间,这块内存被组织成“栈”这种结构, 用来存储函数调用时的临时变量。每进入一个函数,就会将临时变量作为一个栈帧入栈,当被调用函数执行完成,返回之后,将这个函数对应的栈帧出栈。
警惕数组的访问越界问题
我们知道,操作系统给每个线程分配了一块独立的内存空间,这块内存被组织成“栈”这种结构, 用来存储函数调用时的临时变量。每进入一个函数,就会将临时变量作为一个栈帧入栈,当被调用函数执行完成,返回之后,将这个函数对应的栈帧出栈。 警惕数组的访问越界问题
这段代码的运行结果并非是打印三行“hello word”,而是会无限打印“hello world”,这是为什么呢?因为,数组大小为 3,a[0],a[1],a[2],而我们的代码因为书写错误,导致 for 循环的结束条件错写为了 i<=3 而非 i<3,所以当 i=3 时,数组 a[3]访问越界。我们知道,在 C 语言中,只要不是访问受限的内存,所有的内存空间都是可以自由访问的。根据我们前面讲的数组寻址公式,a[3]也会被定位到某块不属于数组的内存地址上,而这个地址正好是存储变量 i 的内存地址,那么 a[3]=0 就相当于 i=0,所以就会导致代码无限循环。数组越界在 C 语言中是一种未决行为,并没有规定数组访问越界时编译器应该如何处理。因为,访问数组的本质就是访问一段连续内存,只要数组通过偏移计算得到的内存地址是可用的,那么程序就可能不会报任何错误。
函数体内的局部变量存在栈上,且是连续压栈。在Linux进程的内存布局中,栈区在高地址空间,从高向低增长。变量i和arr在相邻地址,且i比arr的地址大,所以arr越界正好访问到i。当然,前提是i和arr元素同类型,否则那段代码仍是未决行为。
- 栈在表达式求值中的应用
其中一个保存操作数的栈,另一个是保存运算符的栈。我们从左向右遍历表达式,当遇到数字,我们就直接压入操作数栈;当遇到运算符,就与运算符栈的栈顶元素进行比较。如果比运算符栈顶元素的优先级高,就将当前运算符压入栈;如果比运算符栈顶元素的优先级低或者相同,从运算符栈中取栈顶运算符,从操作数栈的栈顶取 2 个操作数,然后进行计算,再把计算完的结果压入操作数栈,继续比较。
- 栈在括号匹配中的应用
我们假设表达式中只包含三种括号,圆括号 ()、方括号[]和花括号{},并且它们可以任意嵌套。比如,{[] ()[{}]}或[{()}([])]等都为合法格式,而{[}()]或[({)]为不合法的格式。那我现在给你一个包含三种括号的表达式字符串,如何检查它是否合法呢?
我们用栈来保存未匹配的左括号,从左到右依次扫描字符串。当扫描到左括号时,则将其压入栈中;当扫描到右括号时,从栈顶取出一个左括号。如果能够匹配,比如“(”跟“)”匹配,“[”跟“]”匹配,“{”跟“}”匹配,则继续扫描剩下的字符串。如果扫描的过程中,遇到不能配对的右括号,或者栈中没有数据,则说明为非法格式。
Python 代码实现:
from typing import Optional
class Node:
def __init__(self, data: int, next=None):
self._data = data
self._next = next
class LinkedStack:
"""A stack based upon singly-linked list.
"""
def __init__(self):
self._top: Node = None
def push(self, value: int):
new_top = Node(value)
new_top._next = self._top
self._top = new_top
def pop(self) -> Optional[int]:
if self._top:
value = self._top._data
self._top = self._top._next
return value
def is_empty(self) -> bool:
return not self._top
def __repr__(self) -> str:
current = self._top
nums = []
while current:
nums.append(current._data)
current = current._next
return " ".join(f"{num}]" for num in nums)
class Browser():
def __init__(self):
self.forward_stack = LinkedStack()
self.back_stack = LinkedStack()
def can_forward(self):
if self.back_stack.is_empty():
return False
return True
def can_back(self):
if self.forward_stack.is_empty():
return False
return True
def open(self, url):
print("Open new url %s" % url, end="\n")
self.forward_stack.push(url)
def back(self):
if self.forward_stack.is_empty():
return
top = self.forward_stack.pop()
self.back_stack.push(top)
print("back to %s" % top, end="\n")
def forward(self):
if self.back_stack.is_empty():
return
top = self.back_stack.pop()
self.forward_stack.push(top)
print("forward to %s" % top, end="\n")
if __name__ == "__main__":
stack = LinkedStack()
for i in range(9):
stack.push(i)
print(stack)
for _ in range(10):
stack.pop()
print(stack)
browser = Browser()
browser.open('a')
browser.open('b')
browser.open('c')
browser.back()
browser.back()
browser.back()
browser.back()
browser.forward()
4. 队列
先进者先出,这就是典型的“队列”。
栈只支持两个基本操作:入栈 push()和出栈 pop()。队列跟栈非常相似,支持的操作也很有限,最基本的操作也是两个:入队 enqueue(),放一个数据到队列尾部;出队 dequeue(),从队列头部取一个元素。
对于栈来说,我们只需要一个栈顶指针就可以了。但是队列需要两个指针:一个是 head 指针,指向队头;一个是 tail 指针,指向队尾元素的下一个位置。你可以结合下面这张图来理解。当 a、b、c、d 依次入队之后,队列中的 head 指针指向下标为 0 的位置,tail 指针指向下标为 4 的位置。
在用数组实现的非循环队列中,队满的判断条件是 tail == n,队空的判断条件是 head == tail。
队列可以应用在任何有限资源池中,用于排队请求,比如数据库连接池等。实际上,对于大部分资源有限的场景,当没有空闲资源时,基本上都可以通过“队列”这种数据结构来实现请求排队。
Python 代码实现:
class ArrayQueue():
def __init__(self, capacity: int):
self.items = []
self.capacity = capacity
self.head = 0
self.tail = 0
def enqueue(self, item) -> bool:
'''入队
参数:
item
返回:
bool
'''
# 判断队列是否已满
if self.tail == self.capacity:
# 队列里没有空余
if self.head == 0:
return False
# 还有空余
else:
# 数据搬移
for x in range(self.tail - self.head):
self.items[x] = self.items[x + self.head]
self.items = self.items[:self.tail - self.head]
# 重置 head, tail
self.tail = self.tail - self.head
self.head = 0
# 追加节点
self.items.insert(self.tail, item)
# tail 指针重置
self.tail += 1
return True
def dequeue(self):
if self.head != self.tail:
item = self.items[self.head]
self.head += 1
return item
else:
return None
def __repr__(self):
return '<-'.join(str(item) for item in self.items[self.head : self.tail])
if __name__ == "__main__":
queue = ArrayQueue(6)
for x in range(3):
queue.enqueue(x)
print(queue)
print(f'current head {queue.head}')
print(f'current tail {queue.tail}')
queue.dequeue()
queue.dequeue()
print(queue)
print(f'current head {queue.head}')
print(f'current tail {queue.tail}')
for x in range(6):
queue.enqueue(x)
print(queue)
print(f'current head {queue.head}')
print(f'current tail {queue.tail}')
5. 二叉查找树
二叉树的定义:
二叉树,顾名思义,每个节点最多有两个“叉”,也就是两个子节点,分别是左子节点和右子节点。
如图所示,叶子节点全都在最底层,除了叶子节点之外,每个节点都有左右两个子节点,这种二叉树就叫做满二叉树。
叶子节点都在最底下两层,最后一层的叶子节点都靠左排列,并且除了最后一层,其他层的节点个数都要达到最大,这种二叉树叫做完全二叉树。
想要存储一棵二叉树,我们有两种方法,一种是基于指针或者引用的二叉链式存储法,一种是基于数组的顺序存储法。
我们先来看比较简单、直观的链式存储法。从图中你应该可以很清楚地看到,每个节点有三个字段,其中一个存储数据,另外两个是指向左右子节点的指针。我们只要拎住根节点,就可以通过左右子节点的指针,把整棵树都串起来。这种存储方式我们比较常用。大部分二叉树代码都是通过这种结构来实现的。
我们再来看,基于数组的顺序存储法。我们把根节点存储在下标 i = 1 的位置,那左子节点存储在下标 2 * i = 2 的位置,右子节点存储在 2 * i + 1 = 3 的位置。以此类推,B 节点的左子节点存储在 2 * i = 2 * 2 = 4 的位置,右子节点存储在 2 * i + 1 = 2 * 2 + 1 = 5 的位置。
如果节点 X 存储在数组中下标为 i 的位置,下标为 2 * i 的位置存储的就是左子节点,下标为 2 * i + 1 的位置存储的就是右子节点。反过来,下标为 i/2 的位置存储就是它的父节点。通过这种方式,我们只要知道根节点存储的位置(一般情况下,为了方便计算子节点,根节点会存储在下标为 1 的位置),这样就可以通过下标计算,把整棵树都串起来。
所以,如果某棵二叉树是一棵完全二叉树,那用数组存储无疑是最节省内存的一种方式。因为数组的存储方式并不需要像链式存储法那样,要存储额外的左右子节点的指针。这也是为什么完全二叉树会单独拎出来的原因,也是为什么完全二叉树要求最后一层的子节点都靠左的原因。
二叉查找树:
二叉查找树要求,在树中的任意一个节点,其左子树中的每个节点的值,都要小于这个节点的值,而右子树节点的值都大于这个节点的值。
(1). 遍历
前序遍历是指,对于树中的任意节点来说,先打印这个节点,然后再打印它的左子树,最后打印它的右子树。中序遍历是指,对于树中的任意节点来说,先打印它的左子树,然后再打印它本身,最后打印它的右子树。后序遍历是指,对于树中的任意节点来说,先打印它的左子树,然后再打印它的右子树,最后打印这个节点本身。
(2). 查找
先取根节点,如果它等于我们要查找的数据,那就返回。如果要查找的数据比根节点的值小,那就在左子树中递归查找;如果要查找的数据比根节点的值大,那就在右子树中递归查找。
(3). 插入
如果要插入的数据比节点的数据大,并且节点的右子树为空,就将新数据直接插到右子节点的位置;如果不为空,就再递归遍历右子树,查找插入位置。同理,如果要插入的数据比节点数值小,并且节点的左子树为空,就将新数据插入到左子节点的位置;如果不为空,就再递归遍历左子树,查找插入位置。
(4). 删除
第一种情况是,如果要删除的节点没有子节点,我们只需要直接将父节点中,指向要删除节点的指针置为 null。比如图中的删除节点 55。
第二种情况是,如果要删除的节点只有一个子节点(只有左子节点或者右子节点),我们只需要更新父节点中,指向要删除节点的指针,让它指向要删除节点的子节点就可以了。比如图中的删除节点 13。
第三种情况是,如果要删除的节点有两个子节点,这就比较复杂了。我们需要找到这个节点的右子树中的最小节点,把它替换到要删除的节点上。然后再删除掉这个最小节点,因为最小节点肯定没有左子节点(如果有左子结点,那就不是最小节点了),所以,我们可以应用上面两条规则来删除这个最小节点。比如图中的删除节点 18。
Python 代码实现:
class TreeNode():
def __init__(self, value: int):
self.val = value
self.left = None
self.right = None
class BinarySearchTree():
def __init__(self):
self._root = None
def insert(self, value: int):
if not self._root:
self._root = TreeNode(value)
return
parent = None
node = self._root
while node:
parent = node
node = node.left if node.val > value else node.right
new_node = TreeNode(value)
if parent.val > value:
parent.left = new_node
else:
parent.right = new_node
def _in_order(self, node):
if node:
yield from self._in_order(node.left)
yield node.val
yield from self._in_order(node.right)
# 中序遍历
def in_order(self):
return self._in_order(self._root)
def find(self, value):
node = self._root
while node and node.val != value:
node = node.left if node.val > value else node.right
return node
def delete(self, value:int):
node = self._root
parent = None
while node and node.val != value:
parent = node
node = node.left if node.val > value else node.right
if not node:
return
# 要删除的节点有两个子节点
if node.left and node.right:
successor = node.right
successor_parent = node
while successor.left:
successor_parent = successor
successor = successor.left
node.val = successor.val
parent, node = successor_parent, successor
# 删除的节点是叶子节点或仅有一个子节点
child = node.left if node.left else node.right
if not parent:
self._root = child
elif parent.left == node:
parent.left = child
else:
parent.right = child
对于包含重复元素的二叉查找树: 1) 用链表,使节点可以存储多个相同值的对象 2) 将相同值的对象视作"大于"原节点值,插入右子节点,查找时,遇到相同对象不马上停,而是一直查到叶子节点为止,这样就可以把键值等于要查找值的所有节点都找出来。
时间复杂度分析:
(1). 中序遍历二叉查找树,可以输出有序的数据序列,时间复杂度是 O(n),非常高效。
(2). 在二叉查找树中,查找、插入、删除等很多操作的时间复杂度都跟树的高度成正比。一个极端情况的时间复杂度分别是 O(n),对应二叉树退化成链表的情况。
(3). 一个最理想的情况,二叉查找树是一棵完全二叉树(或满二叉树),不管操作是插入、删除还是查找,时间复杂度其实都跟树的高度成正比,也就是 O(height)。既然这样,现在问题就转变成另外一个了,也就是,如何求一棵包含 n 个节点的完全二叉树的高度?
树的高度就等于最大层数减一,为了方便计算,我们转换成层来表示。从图中可以看出,包含 n 个节点的完全二叉树中,第一层包含 1 个节点,第二层包含 2 个节点,第三层包含 4 个节点,依次类推,下面一层节点个数是上一层的 2 倍,第 K 层包含的节点个数就是 2^(K-1)。
不过,对于完全二叉树来说,最后一层的节点个数有点儿不遵守上面的规律了。它包含的节点个数在 1 个到 2^(L-1) 个之间(我们假设最大层数是 L)。如果我们把每一层的节点个数加起来就是总的节点个数 n。也就是说,如果节点的个数是 n,那么 n 满足这样一个关系:
n >= 1+2+4+8+...+2^(L-2)+1
n <= 1+2+4+8+...+2^(L-2)+2^(L-1)
借助等比数列的求和公式,我们可以计算出,L 的范围是[log2(n+1), log2n +1]。完全二叉树的层数小于等于 log2n +1,也就是说,完全二叉树的高度小于等于 log2n。
显然,极度不平衡的二叉查找树,它的查找性能肯定不能满足我们的需求。我们需要构建一种不管怎么删除、插入数据,在任何时候,都能保持任意节点左右子树都比较平衡的二叉查找树。
红黑树 VS 其他平衡二叉查找树
红黑树是一种平衡二叉查找树。它是为了解决普通二叉查找树在数据更新的过程中,复杂度退化的问题而产生的。红黑树的高度近似 logn,所以它是近似平衡,插入、删除、查找操作的时间复杂度都是 O(logn)。红黑树只是做到了近似平衡,并不是严格的平衡,所以在维护平衡的成本上,要比 AVL 树要低。
AVL 树是一种高度平衡的二叉查找树,所以查找的效率非常高,但是,有利就有弊,AVL 树为了维持这种高度的平衡,就要付出更多的代价。每次插入、删除都要做调整,就比较复杂、耗时。所以,对于有频繁的插入、删除操作的数据集合,使用 AVL 树的代价就有点高了。
6. 堆
堆是一种特殊的树,只要满足这两点,它就是一个堆。
(1). 堆是一个完全二叉树(完全二叉树要求,除了最后一层,其他层的节点个数都是满的,最后一层的节点都靠左排列。); (2). 堆中每一个节点的值都必须大于等于(或小于等于)其子树中每个节点的值。
对于每个节点的值都大于等于子树中每个节点值的堆,我们叫做“大顶堆”。对于每个节点的值都小于等于子树中每个节点值的堆,我们叫做“小顶堆”。
存储:
从图中可以看到,数组中下标为 i 的节点的左子节点,就是下标为 i∗2 的节点,右子节点就是下标为 i∗2+1 的节点,父节点就是下标为 i/2的节点。
如果从 0 开始存储,节点的下标是 i,那左子节点的下标就是 2∗i+1,右子节点的下标就是 2∗i+2,父节点的下标就是 (i−1)/2。
插入
堆中比较重要的两个操作是插入一个数据和删除堆顶元素。这两个操作都要用到堆化。以大顶堆为例:
插入一个数据的时候,我们把新插入的数据放到数组的最后,然后从下往上堆化。
删除
删除堆顶数据的时候,我们把数组中的最后一个元素放到堆顶,然后从上往下堆化。这两个操作时间复杂度都是 O(logn)。
堆排序
堆排序。堆排序包含两个过程,建堆和排序。我们将下标从 n//2 到 1 的节点(对应下标从1开始的情况),依次进行从上到下的堆化操作,然后就可以将数组中的数据组织成堆这种数据结构。如图所示:
接下来,我们迭代地将堆顶的元素放到堆的末尾,并将堆的大小减一,然后再堆化,重复这个过程,直到堆中只剩下一个元素,整个数组中的数据就都有序排列了。
整个堆排序的过程,都只需要极个别临时存储空间,所以堆排序是原地排序算法。堆排序包括建堆和排序两个操作,建堆过程的时间复杂度是 O(n),排序过程的时间复杂度是 O(nlogn),所以,堆排序整体的时间复杂度是 O(nlogn)。堆排序不是稳定的排序算法,因为在排序的过程,存在将堆的最后一个节点跟堆顶节点互换的操作,所以就有可能改变值相同数据的原始相对顺序。
应用
堆的应用之一:利用堆求 Top K
如何在一个包含 n 个数据的数组中,查找前 K 大数据呢?我们可以维护一个大小为 K 的小顶堆,顺序遍历数组,从数组中取出数据与堆顶元素比较。如果比堆顶元素大,我们就把堆顶元素删除,并且将这个元素插入到堆中;如果比堆顶元素小,则不做处理,继续遍历数组。这样等数组中的数据都遍历完之后,堆中的数据就是前 K 大数据了。
堆的应用之二:求中位数(百分位数)
我们需要维护两个堆,一个大顶堆,一个小顶堆。大顶堆中存储前半部分数据,小顶堆中存储后半部分数据,且小顶堆中的数据都大于大顶堆中的数据。也就是说,如果有 n 个数据,n 是偶数,我们从小到大排序,那前 n/2 个数据存储在大顶堆中,后 n/2 个数据存储在小顶堆中。这样,大顶堆中的堆顶元素就是我们要找的中位数。如果 n 是奇数,情况是类似的,大顶堆就存储 n/2+1 个数据,小顶堆中就存储 n/2 个数据。
如果新加入的数据小于等于大顶堆的堆顶元素,我们就将这个新数据插入到大顶堆;否则,我们就将这个新数据插入到小顶堆。这个时候就有可能出现,两个堆中的数据个数不符合前面约定的情况:如果 n 是偶数,两个堆中的数据个数都是 n/2;如果 n 是奇数,大顶堆有 n/2+1 个数据,小顶堆有 n/2 个数据。这个时候,我们可以从一个堆中不停地将堆顶元素移动到另一个堆,通过这样的调整,来让两个堆中的数据满足上面的约定。
于是,我们就可以利用两个堆,一个大顶堆、一个小顶堆,实现在动态数据集合中求中位数的操作。插入数据因为需要涉及堆化,所以时间复杂度变成了 O(logn),但是求中位数我们只需要返回大顶堆的堆顶元素就可以了,所以时间复杂度就是 O(1)。
Python 代码实现:
class Heap():
def __init__(self, nums=None, capacity=100):
self._data = []
self._capacity = capacity
if type(nums) == list and len(nums) <= self._capacity:
for n in nums:
assert type(n) is int
self._data.append(n)
self._length = len(self._data)
self._heapify()
def _heapify(self):
if self._length <= 1:
return
lp = (self._length - 2) // 2
for i in range(lp, -1, -1):
self._heap_down(i)
def _heap_down(self, idx):
pass
def insert(self, num):
pass
def get_top(self):
if self._length <= 0:
return None
return self._data[0]
def remove_top(self):
if self._length <= 0:
return None
self._data[0], self._data[-1] = self._data[-1], self._data[0]
top = self._data.pop()
self._length -= 1
self._heap_down(0)
return top
def get_data(self):
return self._data
class MaxHeap(Heap):
def _heap_down(self, idx):
while True:
max_pos = idx
if idx*2+1 <= self._length-1 and self._data[idx] < self._data[idx*2+1]:
max_pos = idx*2+1
if idx*2+2 <= self._length-1 and self._data[max_pos] < self._data[idx*2+2]:
max_pos = idx*2+2
if max_pos == idx:
break
self._data[idx], self._data[max_pos] = self._data[max_pos], self._data[idx]
idx = max_pos
def insert(self, num):
if self._length >= self._capacity:
return
self._data.append(num)
self._length += 1
pos = self._length - 1
while pos > 0:
parent = (pos-1) // 2
if self._data[pos] > self._data[parent]:
self._data[pos],self._data[parent] = self._data[parent],self._data[pos]
pos = parent
else:
break
def ascend(self):
i = -1
while self._length > 1:
i += 1
self._data[0], self._data[-1-i] = self._data[-1-i], self._data[0]
self._length -= 1
self._heap_down(0)
return self._data
class MinHeap(Heap):
def _heap_down(self, idx):
while True:
min_pos = idx
if idx*2+1 <= self._length-1 and self._data[idx] > self._data[idx*2+1]:
min_pos = idx*2+1
if idx*2+2 <= self._length-1 and self._data[min_pos] > self._data[idx*2+2]:
min_pos = idx*2+2
if min_pos == idx:
break
self._data[idx], self._data[min_pos] = self._data[min_pos], self._data[idx]
idx = min_pos
def insert(self, num):
if self._length >= self._capacity:
return
self._data.append(num)
self._length += 1
pos = self._length - 1
while pos > 0:
parent = (pos-1) // 2
if self._data[pos] < self._data[parent]:
self._data[pos],self._data[parent] = self._data[parent],self._data[pos]
pos = parent
else:
break
def top_k(nums, k):
"""
返回数组的前k大元素
"""
if len(nums) <= k:
return nums
min_h = MinHeap(nums[:k], k)
for i in range(k, len(nums)):
tmp = min_h.get_top()
if nums[i] > tmp:
min_h.remove_top()
min_h.insert(nums[i])
return min_h.get_data()
nums = [7,5,19,8,4,1,20,13,16]
max_h = MaxHeap(nums)
print(max_h.get_data())
print(max_h.ascend())
print(top_k([3,7,1,9,8,10,6,9], 3))
7. 图
图(Graph)和树比起来,是一种更加复杂的非线性表结构。
广度优先搜索(BFS)VS 深度优先搜索(DFS)
V 表示顶点的个数,E 表示边的个数。广度优先搜索和深度优先搜索的时间复杂度都可以简写为 O(E),空间复杂度都是 O(V)。
Python 实现:
from collections import deque
class Graph:
"""Undirected graph."""
def __init__(self, num_vertices: int):
self._num_vertices = num_vertices
self._adjacency = [[] for _ in range(num_vertices)]
def add_edge(self, s: int, t: int) -> None:
self._adjacency[s].append(t)
self._adjacency[t].append(s)
def _generate_path(self, s: int, t: int, prev):
if s != t:
yield from self._generate_path(s, prev[t], prev)
yield str(t)
def bfs(self, s: int, t: int):
"""Print out the path from Vertex s to Vertex t
using bfs.
"""
if s == t: return
visited = {s}
q = deque()
q.append(s)
prev = {}
while q:
v = q.popleft()
for neighbour in self._adjacency[v]:
if neighbour not in visited:
prev[neighbour] = v
if neighbour == t:
print("->".join(self._generate_path(s, t, prev)))
return
visited.add(neighbour)
q.append(neighbour)
def dfs(self, s: int, t: int) -> IO[str]:
"""Print out a path from Vertex s to Vertex t
using dfs.
"""
found = False
visited = set()
prev = {}
def _dfs(from_vertex: int):
nonlocal found
if found: return
visited.add(from_vertex)
if from_vertex == t:
found = True
return
for neighbour in self._adjacency[from_vertex]:
if neighbour not in visited:
prev[neighbour] = from_vertex
_dfs(neighbour)
_dfs(s)
if found:
print("->".join(self._generate_path(s, t, prev)))
8. Trie树
Trie 树,也叫“字典树”。顾名思义,它是一个树形结构。它是一种专门处理字符串匹配的数据结构,用来解决在一组字符串集合中快速查找某个字符串的问题。
假设有 6 个字符串,它们分别是:how,hi,her,hello,so,see。我们希望在里面多次查找某个字符串是否存在。如果每次查找,都是拿要查找的字符串跟这 6 个字符串依次进行字符串匹配,那效率就比较低,我们可以先对这 6 个字符串做一下预处理,组织成 Trie 树的结构,之后每次查找,都是在 Trie 树中进行匹配查找。Trie 树的本质,就是利用字符串之间的公共前缀,将重复的前缀合并在一起。最后构造出来的就是下面这个图中的样子。
构建 Trie 树的过程,需要扫描所有的字符串,时间复杂度是 O(n)(n 表示所有字符串的长度和)。但是一旦构建成功之后,后续的查询操作会非常高效。每次查询时,如果要查询的字符串长度是 k,那我们只需要比对大约 k 个节点,就能完成查询操作。跟原本那组字符串的长度和个数没有任何关系。所以说,构建好 Trie 树后,在其中查找字符串的时间复杂度是 O(k),k 表示要查找的字符串的长度。
如何利用 Trie 树,实现搜索关键词的提示功能?
我们假设关键词库由用户的热门搜索关键词组成。我们将这个词库构建成一个 Trie 树。当用户输入其中某个单词的时候,把这个词作为一个前缀子串在 Trie 树中匹配。为了讲解方便,我们假设词库里只有 hello、her、hi、how、so、see 这 6 个关键词。当用户输入了字母 h 的时候,我们就把以 h 为前缀的 hello、her、hi、how 展示在搜索提示框内。当用户继续键入字母 e 的时候,我们就把以 he 为前缀的 hello、her 展示在搜索提示框内。这就是搜索关键词提示的最基本的算法原理。
Python实现:
class TrieNode():
def __init__(self, data:str):
self._data = data
self._children = [None] * 26
self._is_ending_char = False
class Trie():
def __init__(self):
self._root = TrieNode('/')
def insert(self, text:str):
node = self._root
for ind, char in map(lambda x:(ord(x)-ord('a'),x), text):
if not node._children[ind]:
node._children[ind] = TrieNode(char)
node = node._children[ind]
node._is_ending_char = True
def find(self, pattern: str):
node = self._root
for ind in map(lambda x: ord(x)-ord('a'), pattern):
if not node._children[ind]:return False
node = node._children[ind]
return node._is_ending_char
def get_words(self, pattern:str):
node = self._root
words = []
for ind in map(lambda x: ord(x)-ord('a'), pattern):
if not node._children[ind]:return words
node = node._children[ind]
def _get_words(_node, _pattern):
if any(_node._children):
for child in _node._children:
if child:
char = child._data
if child._is_ending_char:
words.append(_pattern+char)
_get_words(child, _pattern+char)
_get_words(node, pattern)
return words
if __name__ == "__main__":
strs = ["how", "hi", "her", "hello", "so", "see"]
trie = Trie()
for s in strs:
trie.insert(s)
print(trie.find("her"))
print(trie.get_words("he"))