[算法系列] - 常用数据结构及Python实现

219 阅读18分钟

《数据结构与算法之美》笔记

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. 栈

后进者先出,先进者后出,这就是典型的“栈”结构。

  1. 栈在函数调用中的应用

我们知道,操作系统给每个线程分配了一块独立的内存空间,这块内存被组织成“栈”这种结构, 用来存储函数调用时的临时变量。每进入一个函数,就会将临时变量作为一个栈帧入栈,当被调用函数执行完成,返回之后,将这个函数对应的栈帧出栈。

警惕数组的访问越界问题

我们知道,操作系统给每个线程分配了一块独立的内存空间,这块内存被组织成“栈”这种结构, 用来存储函数调用时的临时变量。每进入一个函数,就会将临时变量作为一个栈帧入栈,当被调用函数执行完成,返回之后,将这个函数对应的栈帧出栈。 警惕数组的访问越界问题

这段代码的运行结果并非是打印三行“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元素同类型,否则那段代码仍是未决行为。

  1. 栈在表达式求值中的应用

其中一个保存操作数的栈,另一个是保存运算符的栈。我们从左向右遍历表达式,当遇到数字,我们就直接压入操作数栈;当遇到运算符,就与运算符栈的栈顶元素进行比较。如果比运算符栈顶元素的优先级高,就将当前运算符压入栈;如果比运算符栈顶元素的优先级低或者相同,从运算符栈中取栈顶运算符,从操作数栈的栈顶取 2 个操作数,然后进行计算,再把计算完的结果压入操作数栈,继续比较。

  1. 栈在括号匹配中的应用

我们假设表达式中只包含三种括号,圆括号 ()、方括号[]和花括号{},并且它们可以任意嵌套。比如,{[] ()[{}]}或[{()}([])]等都为合法格式,而{[}()]或[({)]为不合法的格式。那我现在给你一个包含三种括号的表达式字符串,如何检查它是否合法呢?

我们用栈来保存未匹配的左括号,从左到右依次扫描字符串。当扫描到左括号时,则将其压入栈中;当扫描到右括号时,从栈顶取出一个左括号。如果能够匹配,比如“(”跟“)”匹配,“[”跟“]”匹配,“{”跟“}”匹配,则继续扫描剩下的字符串。如果扫描的过程中,遇到不能配对的右括号,或者栈中没有数据,则说明为非法格式。

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"))