总结数据结构与算法相关知识

186 阅读26分钟

引入

编程的最终目的只有一个:对数据进行操作和处理

  • 评判编程能力、水平的高低,要看你是否可以更好的操作和处理数据

  • 无论是操作系统(Windows、Mac OS)本身,还是使用的编程语言(JavaScript、Java、C++、Python等等),还是在平时应用程序中用到的框架(Vue、React、Spring、Flask等等),它们的底层实现到处都是数据结构与算法

  • 以前端为例:框架中大量使用到了栈结构、队列结构等来解决问题(比之前看框架源码时经常看到这些数据结构,Vue源码、 React源码中可以看到队列、栈结构、树结构等,Webpack中还有图结构)

  • 这也是企业面试人员的重要标准,对于可以将数据结构与算法掌握很好的开发人员来说,通常对于业务的把握肯定是没有问题的

  • 学习数据结构与算法可以更好的锻炼我们的逻辑思维能力和代码编程能力,帮助我们平时在处理一些复杂数据时,可以更好的编 写代码,写出更高效的程序

  • 数据结构和语言无关,常见的编程语言都有直接或者间接的使用常见的数据结构,在这里我们都使用TypeScript实现,直接运行ts代码使用的是tsxnpm install -g tsx tsx filename.ts

数据结构

数据结构是计算机科学中的一个重要概念,它指的是组织、管理和存储数据的方式,以便于高效访问和修改。通过选择合适的数据结构,可以优化程序的运行效率,尤其是在大规模数据处理的场景下

定义

  • 数据结构是数据对象,以及存在于该对象的实例和 组成实例的数据元素之间的各种联系。这些联系可以通过定义相关的函数来给出 ---《数据结构、算法与应用》

  • 数据结构是ADT(抽象数据类型 Abstract Data Type)的物理实现 ---《数据结构与算法分析》

  • 数据结构(data structure)是计算机中存储、组织数据的方式。通常情况下,精心选择的数据结构可以带来最优效率的算法 --- 中文维基百科

线性结构

线性结构(Linear List)是由n(n≥0)个数据元素(结点)a[0],a[1],a[2]…,a[n-1]组成的有限序列,其中每个元素有且仅有一个前驱和一个后继(除第一个和最后一个元素外)

  • 数据元素的个数n定义为表的长度 = “list”.length(),(“list”.length() = 0(表里没有一个元素)时称为空表)

  • 将非空的线性表(n>=1)记作:a[0],a[1],a[2],…,a[n-1]

  • 数据元素a[i]0≤i≤n-1)只是个抽象符号,其具体含义在不同情况下可以不同

常见的线性结构:

  • 数组结构

  • 栈结构:受限的线性结构

  • 队列结构:受限的线性结构

  • 链表结构

数组结构(Array)

数组结构一个非常重要的数据结构,是几乎每个语言都会提供的一种原生数据结构,可以借数组实现其他的数据结构,比如栈、队列、堆

栈结构(Stack)

栈(stack),它是一种受限的线性结构,遵循“后进入的元素先被处理,先进入的元素后被处理”的原则。栈的插入和删除操作都只在栈顶进行,因此它适合用于需要“回退”或“撤销”操作的场景

  • 栈结构示意图如下: db7378f2499e466abb7c6244d0df1cf6~tplv-k3u1fbpfcp-zoom-in-crop-mark_1512_0_0_0.webp
  • 主要特点:
    • 单端操作:仅允许在表的一端(栈顶)进行插入和删除运算,另一端称为栈底

    • LIFO(last in first out)后进入的元素第一个弹出栈空间

    • 向一个栈插入新元素又称作进栈、入栈或压栈,它是把新元素放到栈顶元素的上面,使之成为新的栈顶元素

    • 从一个栈删除元素又称作出栈或退栈,它是把栈顶元素删除掉,使其相邻的元素成为新的栈顶元素

  • 基本操作:
    • push(element) :将一个元素放入栈顶

    • pop() :将栈顶的元素移除,并返回该元素

    • peek() :返回栈顶的元素,但不删除

    • isEmpty():检查栈是否为空

    • size():返回栈中元素的数量,想属性方式获取就写get size()

  • 应用场景:
    • 函数调用栈:在程序运行中,函数的调用过程使用栈来管理局部变量和函数返回地址

    • 表达式求值:如中缀表达式转后缀表达式、表达式求值等

    • 括号匹配:检查一组括号是否成对出现,例如编译器中的括号检查

    • 浏览器历史记录:当你按下“后退”时,可以通过栈来返回到之前浏览过的网页

    • 撤销操作:例如文本编辑器中的“撤销/重做”功能,可以通过栈实现撤销操作

实现

可以基于链表也可以基于数组,还没有学链表,这里先基于数组实现

  • 基本实现:

    class ArrayStack {
      private data: any[] = [];
    
      push(item: any): void {
        this.data.push(item);
      }
      pop(): any {
        return this.data.pop();
      }
      peek(): any {
        return this.data[this.data.length - 1];
      }
      isEmpty(): boolean {
        return this.data.length === 0;
      }
      size(): number {
        return this.data.length;
      }
    }
    
    const stack1 = new ArrayStack();
    stack1.push("aaa");
    stack1.push("bbb");
    stack1.push("ccc");
    console.log(stack1.pop()); // ccc
    console.log(stack1.peek()); // bbb
    console.log(stack1.isEmpty()); // false
    console.log(stack1.size()); // 2
    
  • 用泛型优化:

    class ArrayStack<T> {
      private data: T[] = [];
    
      push(item: T): void {
        this.data.push(item);
      }
      pop(): T | undefined {
        return this.data.pop();
      }
      peek(): T | undefined {
        return this.data[this.data.length - 1];
      }
      isEmpty(): boolean {
        return this.data.length === 0;
      }
      size(): number {
        return this.data.length;
      }
    }
    
    const stack2 = new ArrayStack<string>();
    stack2.push("aaa");
    stack2.push("bbb");
    stack2.push("ccc");
    console.log(stack2.pop()); // ccc
    console.log(stack2.peek()); // bbb
    console.log(stack2.isEmpty()); // false
    console.log(stack2.size()); // 2
    
  • 用接口实现优化: 不管用数组或者链表实现,都要实现栈的操作,我们可以把这些操作定义个接口再去实现,提示也会更加智能,我们也可以修复问题点击实现接口 image.png

    interface IStack<T> {
      push(item: T): void;
      pop(): T | undefined;
      peek(): T | undefined;
      isEmpty(): boolean;
      size(): number;
    }
    
    class ArrayStack<T> implements IStack<T> {
      private data: T[] = [];
    
      push(item: T): void {
        this.data.push(item);
      }
      pop(): T | undefined {
        return this.data.pop();
      }
      peek(): T | undefined {
        return this.data[this.data.length - 1];
      }
      isEmpty(): boolean {
        return this.data.length === 0;
      }
      size(): number {
        return this.data.length;
      }
    }
    const stack3 = new ArrayStack<string>();
    stack3.push("aaa");
    stack3.push("bbb");
    stack3.push("ccc");
    console.log(stack3.pop()); // ccc
    console.log(stack3.peek()); // bbb
    console.log(stack3.isEmpty()); // false
    console.log(stack3.size()); // 2
    

面试题

  1. 有六个元素6、5、4、3、2、1,按顺序进栈,下面哪个不是合法的出栈顺序:

    A. 5 4 3 6 1 2 B. 4 5 3 2 1 6 C. 3 4 6 5 2 1 D. 2 3 4 1 5 6

    A合法解析:6 5进栈,5出栈,4进栈,4出栈,3进栈,3出栈6出栈,2 1进栈,1出栈2出栈

    B合法解析:6 5 4进栈,4出栈5出栈,3进栈,3出栈,2进栈,2出栈,1进栈,1出栈6出栈

    C不合法解析:6 5 4 3进栈,3出栈4出栈,这时6不可能出栈,必须要等5先出栈后6才能出

    D合法解析:6 5 4 3 2进栈,2出栈3出栈4出栈,1进栈,1出栈5出栈6出栈

  2. 十进制转二进制 image.png

    function decimalToBinary(decimal: number): string {
      // 1.创建一个栈, 用于存放余数
      const stack = new ArrayStack<number>()
    
      // 2.使用循环: 
      // while: 不确定次数, 只知道循环结束跳转 
      // for: 知道循环的次数时
      while (decimal > 0) {
        const result = decimal % 2
        stack.push(result)
    
        decimal = Math.floor(decimal / 2)
      }
    
      // 3.所有的余数都已经放在stack中, 以此取出即可
      let binary = ''
      while (!stack.isEmpty()) {
        binary += stack.pop()
      }
      return binary
    }
    
    console.log(decimalToBinary(35)) // 100011
    console.log('------')
    console.log(decimalToBinary(100)) // 1100100
    
  3. 给定一个只包括 '('')''{''}''['']' 的字符串 s ,判断字符串是否有效。Leetcode 20leetcode.cn/problems/va…

    • 有效字符串需满足:

      左括号必须用相同类型的右括号闭合

      左括号必须以正确的顺序闭合

      每个右括号都有一个对应的相同类型的左括号

    function isValid(s: string): boolean {
      // 1.创建栈结构
      const stack = new ArrayStack<number>()
    
      // 2.遍历s中的所有的括号
      for (let i = 0; i < s.length; i++) {
        const c = s[i]
        switch (c) {
          case "(":
            stack.push(")")
            break
          case "{":
            stack.push("}")
            break
          case "[":
            stack.push("]")
            break
          default:
            if (c !== stack.pop()) return false
            break
        }
      }
      return stack.size() === 0
    }
    console.log(valid("({})[]")); // true
    console.log(valid("[}")); // false
    console.log(valid("[()]")); // true
    

队列结构(Queue)

队列(Queue),是一种先进先出(FIFO, First In First Out)的线性数据结构

普通队列

  • 结构示意图如下: 184071642-b49b9041-1afd-4ca7-8ea5-6eeeff7c923d.png

  • 主要特点:

    • 元素只能从一端入队(称为队尾),从另一端出队(称为队头

    • 它只允许在队列的前端(front)进行删除操作,在队列的后端(rear)进行插入操作

  • 基本操作:

    • enqueue(element):向队列尾部添加一个(或多个)新的项

    • dequeue():移除队列的第一(即排在队列最前面的)项,并返回被移除的元素

    • front/peek():返回队列中第一个元素——最先被添加,也将是最先被移除的元素。 队列不做任何变动(不移除元素,只返回元素信息——与Stack类的peek方法非常类似)

    • isEmpty():如果队列中不包含任何元素,返回true,否则返回false

    • size():返回队列包含的元素个数,与数组的length属性类似,想属性方式获取就写get size()

  • 应用场景:

    • 现实生活中处处都在排队,这都是队列的例子

    • 应用于任务调度

    • 应用于消息处理

实现
  • 基本实现:可以基于链表(更优)也可以基于数组,还没有学链表还是先基于数组实现

    interface IQueue<T> {
      enqueue(item: T): void;
      dequeue(): T | undefined;
      peek(): T | undefined;
      isEmpty(): boolean;
      size(): number;
    }
    
    class ArrayQueue<T> implements IQueue<T> {
      private data: T[] = [];
    
      enqueue(item: T): void {
        this.data.push(item);
      }
      dequeue(): T | undefined {
        return this.data.shift();
      }
      peek(): T | undefined {
        return this.data[0];
      }
      isEmpty(): boolean {
        return this.data.length === 0;
      }
      size(): number {
        return this.data.length;
      }
    }
    
    const queue1 = new ArrayQueue<number>();
    queue1.enqueue(111);
    queue1.enqueue(222);
    queue1.enqueue(333);
    console.log(queue1.dequeue()); // 111
    console.log(queue1.peek()); // 222
    console.log(queue1.isEmpty()); // false
    console.log(queue1.size()); // 2
    
  • 接口继承优化:我们发现定义的栈接口和队列接口,都要实现peekisEmptysize方法,我们可以抽取一个公共的接口,然后分别让栈接口和队列接口继承

    interface IStructure<T> {
      peek(): T | undefined;
      isEmpty(): boolean;
      size(): number;
    }
    
    interface IQueue<T> extends IStructure<T> {
      enqueue(item: T): void;
      dequeue(): T | undefined;
    }
    
    class ArrayQueue<T> implements IQueue<T> {
      private data: T[] = [];
    
      enqueue(item: T): void {
        this.data.push(item);
      }
      dequeue(): T | undefined {
        return this.data.shift();
      }
      peek(): T | undefined {
        return this.data[0];
      }
      isEmpty(): boolean {
        return this.data.length === 0;
      }
      size(): number {
        return this.data.length;
      }
    }
    
    const queue1 = new ArrayQueue<number>();
    queue1.enqueue(111);
    queue1.enqueue(222);
    queue1.enqueue(333);
    console.log(queue1.dequeue()); // 111
    console.log(queue1.peek()); // 222
    console.log(queue1.isEmpty()); // false
    console.log(queue1.size()); // 2
    
面试题
  • 破冰游戏: leetcode.cn/problems/yu… 后面会学习其他解法 image.png

    function iceBreakingGame(n: number, m: number) {
      // 1.创建队列
      const queue = new ArrayQueue<number>()
    
      // 2.将所有的数字加入到队列中
      for (let i = 0; i < n; i++) {
        queue.enqueue(i)
      }
    
      // 3.判断队列中是否还有数字
      while (queue.size() > 1) {
        for (let i = 1; i < m; i++) {
          queue.enqueue(queue.dequeue()!)
        }
        queue.dequeue()
      }
      return queue.dequeue()!
    }
    console.log(iceBreakingGame(7, 4)); // 1
    console.log(iceBreakingGame(12, 5)); // 0
    

双端队列

双端队列(Deque)在单向队列的基础上解除了一部分限制:允许在队列的两端添加(入队)和删除(出队)元素 Snipaste_2024-11-15_15-00-04.png

import { ArrayQueue } from "./ArrayQueue实现";

class ArrayDeque<T> extends ArrayQueue<T> {
  addFront(value: T) {
    this.data.unshift(value);
  }
  removeBack(): T | undefined {
    return this.data.pop();
  }
}
const deque = new ArrayDeque<string>();
deque.enqueue("aaa");
deque.enqueue("bbb");
deque.enqueue("ccc");
deque.addFront("abc");
deque.addFront("cba");
while (!deque.isEmpty()) {
  console.log(deque.removeBack());
}

优先级队列

优先级队列(Priority Queue)是一种比普通队列更加高效的数据结构

  • 它每次出队的元素都是具有最高优先级的,可以理解为元素按照关键字进行排序

  • 优先级队列可以用数组、链表等数据结构来实现,但是堆是最常用的实现方式

image.png

优先级队列的应用:

  • 机场登机的顺序:头等舱和商务舱乘客的优先级要高于经济舱乘客

  • 医院的(急诊科)候诊室:医生会优先处理病情比较严重的患者

  • 计算机中也可以通过优先级队列来重新排序队列中任务的顺序,比如每个线程处理的任务重要性不同可以通过优先级来决定该线程在队列中被处理的次序

优先级队列的实现:

  • 方式一:创建优先级的节点,保存在堆结构中
    import { Heap } from "../堆Heap/实现";
    
    class PriorityNode<T> {
      constructor(public value: T, public priorit: number) {}
      valueOf() {
        return this.priorit;
      }
    }
    
    class PriorityQueue<T> {
      private heap: Heap<PriorityNode<T>> = new Heap();
    
      enqueue(value: T, priorit: number): void {
        const node = new PriorityNode(value, priorit);
        this.heap.insert(node);
      }
      dequeue(): PriorityNode<T> | null {
        return this.heap.extract();
      }
      peek(): PriorityNode<T> | undefined {
        return this.heap.peek();
      }
      isEmpty(): boolean {
        return this.heap.isEmpty();
      }
      size(): number {
        return this.heap.size();
      }
    }
    
    const pQueue = new PriorityQueue<string>();
    pQueue.enqueue("why", 98);
    pQueue.enqueue("kobe", 90);
    pQueue.enqueue("james", 105);
    while (!pQueue.isEmpty()) {
      console.log(pQueue.dequeue());
      /* 
        PriorityNode { value: 'kobe', priorit: 90 }
        PriorityNode { value: 'why', priorit: 98 }
        PriorityNode { value: 'james', priorit: 105 }
      */
    }
    
  • 方式二:数据自身返回优先级的比较值
    import { Heap } from "../堆Heap/实现";
    
    class PriorityQueue1<T> {
      private heap: Heap<T> = new Heap();
    
      enqueue(node: T): void {
        this.heap.insert(node);
      }
      dequeue(): T | null {
        return this.heap.extract();
      }
      peek(): T | undefined {
        return this.heap.peek();
      }
      isEmpty(): boolean {
        return this.heap.isEmpty();
      }
      size(): number {
        return this.heap.size();
      }
    }
    
    class Person {
      constructor(public name: string, public age: number) {}
      valueOf() {
        return this.age;
      }
    }
    
    const p1 = new Person("kobe", 18);
    const p2 = new Person("lily", 28);
    const p3 = new Person("james", 8);
    
    const pQueue = new PriorityQueue1<Person>();
    pQueue.enqueue(p1);
    pQueue.enqueue(p2);
    pQueue.enqueue(p3);
    while (!pQueue.isEmpty()) {
      console.log(pQueue.dequeue());
      /* 
        Person { name: 'james', age: 8 }
        Person { name: 'kobe', age: 18 }
        Person { name: 'lily', age: 28 }
      */
    }
    

链表结构(LinkedList)

具体学习这篇文章:juejin.cn/post/743181…

哈希表结构(Hash)

具体学习这篇文章:juejin.cn/post/743433…

树结构(Tree)

具体学习这篇文章:juejin.cn/post/743584…

图结构(Graph)

图结构(Graph)是一种由节点(顶点)和边组成的数据结构,用于表示对象之间的关系。它广泛应用于计算机科学的许多领域,如社交网络、路径规划、网络路由等。图结构中的节点表示对象,边表示对象之间的连接或关系。

术语

图的术语其实非常多,如果你找一本专门讲图的各个方面的书籍,会发现只是术语就可以占据满满的一个章节,介绍几个比较常见的术语

  • 顶点:表示图中的一个节点,通常表示为 V

  • :连接两个顶点的关系,通常表示为 E,边可以是有方向的或无方向的

  • 相邻顶点:由一条边连接在一起的顶点称为相邻顶点

  • :一个顶点的度是相邻顶点的数量

  • 路径:路径是顶点v1,v2...,vn的一个连续序列

  • 简单路径:简单路径要求不包含重复的顶点

  • 回路:第一个顶点和最后一个顶点相同的路径称为回路

  • 无向图:所有的边都没有方向,节点之间是双向关系

  • 有向图:边有方向的图,通常用箭头表示连接方向,路径要根据方向来定

  • 权重:边上的数值,用于表示边的成本、距离或其他度量

  • 无权图:边没有携带权重,边没有任何意义

  • 带权图:带权图表示边有一定的权重,这里的权重可以是任意你希望表示的数据:比如距离或者花费的时间或者票价

表示

怎么在程序中表示图呢?一个图包含很多顶点,包含顶点和顶点之间的连线(边),这两个都是非常重要的图信息,因此都需要在程序中体现出来

  • 顶点的表示相对简单,顶点可以抽象成了1 2 3 4A B C D,可以使用一个数组来存储起来(存储所有的顶点)

  • 那么边怎么表示呢? 因为边是两个顶点之间的关系,所以表示起来会稍微麻烦一些,我们主要看下面两种方法

邻接矩阵

邻接矩阵让每个节点和一个整数项关联,该整数作为数组的下标值,用一个二维数组来表示顶点之间的连接

  • 在二维数组中,0表示没有连线(顶点到自己的连线也用0),1表示有连线
  • 通过二维数组,可以很快的找到一个顶点和哪些顶点有连线(比如顶点0,我们看第一列为1的有13

image.png

邻接矩阵的问题:如果图是一个稀疏图,那么矩阵中将存在大量的0,这意味着浪费了计算机存储空间来表示根本不存在的边

邻接表

邻接表由图中每个顶点以及和顶点相邻的顶点列表组成,这个列表可以用数组/链表/哈希表等来存储 image.png

邻接表的问题:计算出度(指向别人的数量)是比较简单的,如果需要计算有向图的入度(指向自己的数量)是非常麻烦的,它必须构造一个逆邻接表才能有效的计算入度,但是开发中入度相对用的比较少

实现

创建类

定义两个属性:

  • vertexes: 使用一个数组存储所有的顶点

  • adjListadjadjoin的缩写邻接的意思,adjList用于存储所有的边,采用邻接表的形式

class Graph<T> {
  vertexes: T[] = [];
  adjList: Map<T, T[]> = new Map();
}
addVertex/addEdge
addVertex(v: T) {
  this.vertexes.push(v);
  this.adjList.set(v, []);
}
addEdge(v: T, w: T) {
  this.adjList.get(v)?.push(w);
  this.adjList.get(w)?.push(v);
}
printEdges
printEdge() {
  this.vertexes.forEach((f) => {
    console.log(`${f} --> ${this.adjList.get(f)?.join(" ")}`);
  });
}
广度优先搜索

广度优先搜索(BFS, Breadth-First Search是一种用于遍历或搜索图结构的算法,特别适用于查找最短路径或探索所有节点。它使用队列数据结构来确保按照层级顺序逐层访问节点

image.png BFS的基本步骤: 会从指定的第一个顶点开始遍历图,先访问其所有的相邻点就像一次访问图的一层,就是先宽后深的访问顶点

  • 初始化:将起始节点(source node)加入队列,并标记为已访问

  • 遍历节点:从队列中移除队首节点,作为当前节点,访问当前节点的所有未访问邻居节点,将它们加入队列,并标记为已访问

  • 结束条件:当队列为空时,搜索结束,所有可达节点都已访问

bfs() {
  if(this.vertexes.length === 0) return

  const visited = new Set();
  visited.add(this.vertexes[0]);
  const queue = [this.vertexes[0]];

  while (queue.length) {
    const shift = queue.shift()!;
    console.log(shift)
      
    const adj = this.adjList.get(shift);
    if (!adj) continue;
    for (let key of adj) {
      if (!visited.has(key)) {
        visited.add(key);
        queue.push(key);
      }
    }
  }
}
深度优先搜索

深度优先搜索(DFS, Depth-First Search)是一种用于遍历或搜索图结构的算法,通常用于探索图的所有路径或查找某种结构

image.png DFS的基本步骤: 会从第一个指定的顶点开始遍历图,沿着路径直到这条路径被访问到最后了,接着原路回退并探索下一条路径,使用栈或递归实现

  • 初始化:将起始节点(source node)标记为已访问

  • 递归或迭代:访问每个邻居节点,若未访问则继续对其进行DFS

  • 结束条件:所有节点都访问完成,即表示遍历结束

dfs(startNode: T = this.vertexes[0], visited = new Set<T>()) {
  console.log(startNode); // 访问当前节点
  visited.add(startNode); // 标记为已访问

  // 递归访问每个邻居节点
  const adj = this.adjList.get(startNode) || [];
  for (const key of adj) {
    if (!visited.has(key)) {
      this.dfs(key, visited); // 递归调用DFS
    }
  }
}
全部代码
class Graph<T> {
  vertexes: T[] = [];
  adjList: Map<T, T[]> = new Map();

  addVertex(v: T) {
    this.vertexes.push(v);
    this.adjList.set(v, []);
  }
  addEdge(v: T, w: T) {
    this.adjList.get(v)?.push(w);
    this.adjList.get(w)?.push(v);
  }
  printEdge() {
    this.vertexes.forEach((f) => {
      console.log(`${f} -->  ${this.adjList.get(f)?.join(" ")}`);
    });
  }
  bfs() {
    if (this.vertexes.length === 0) return;

    const visited = new Set();
    visited.add(this.vertexes[0]);
    const queue = [this.vertexes[0]];

    while (queue.length) {
      const shift = queue.shift()!;
      console.log(shift);

      const adj = this.adjList.get(shift);
      if (!adj) continue;
      for (let key of adj) {
        if (!visited.has(key)) {
          visited.add(key);
          queue.push(key);
        }
      }
    }
  }
  dfs(startNode: T = this.vertexes[0], visited = new Set<T>()) {
    console.log(startNode); // 访问当前节点
    visited.add(startNode); // 标记为已访问

    // 递归访问每个邻居节点
    const adj = this.adjList.get(startNode) || [];
    for (const key of adj) {
      if (!visited.has(key)) {
        this.dfs(key, visited); // 递归调用DFS
      }
    }
  }
}

const graph = new Graph<string>();
graph.addVertex("A");
graph.addVertex("B");
graph.addVertex("C");
graph.addVertex("D");
graph.addVertex("E");
graph.addVertex("F");
graph.addVertex("G");
graph.addVertex("H");
graph.addVertex("I");

graph.addEdge("A", "B");
graph.addEdge("A", "C");
graph.addEdge("A", "D");
graph.addEdge("C", "D");
graph.addEdge("C", "G");
graph.addEdge("D", "G");
graph.addEdge("D", "H");
graph.addEdge("B", "E");
graph.addEdge("B", "F");
graph.addEdge("E", "I");

graph.printEdge();
/* 
  A -->  B C D
  B -->  A E F
  C -->  A D G
  D -->  A C G H
  E -->  B I
  F -->  B
  G -->  C D
  H -->  D
  I -->  E
*/

graph.bfs(); // A B C D E F G H I
graph.dfs(); // A B E I F C D G H

堆结构(Heap)

如果有一个集合,我们希望获取其中的最大值或者最小值,有哪些方案呢?

  • 数组/链表:获取最大或最小值是O(n)级别的,可以进行排序,排序本身就会消耗性能

  • 哈希表:他需要取出key对应的链表再去循环

  • 二叉搜索树:获取最大或最小值是O(logn)级别的,但是二叉搜索树操作较为复杂,并且还要维护树的平衡时才是O(logn)级别

这个时候需要一种数据结构来解决这个问题,就是堆结构,通常是用来解决Top K问题的

  • Top K问题是指在一组数据中,找出最前面的K个最大/最小的元素
  • 常用的解决方案有使用排序算法、快速选择算法、堆结构等

我们看下堆的结构:

  • 堆的本质是一种特殊的树形数据结构,使用完全二叉树来实现

  • 堆可以进行很多分类,但是平时使用的基本都是二叉堆,二叉堆用树形结构表示出来是一颗完全二叉树,通常在实现的时候我们底层会使用数组来实现

  • 二叉堆又可以划分为最大堆和最小堆

    • 最小堆:堆中每一个节点都小于等于(<=)它的子节点

    • 最大堆:堆中每一个节点都大于等于(>=)它的子节点

    image.png

性质

节点编号规律(即数组存储时的索引):节点编号可以用于定位树中每个节点的父节点和子节点位置,使得完全二叉树在实现堆等数据结构时特别高效 image.png

  • 根节点索引为 0:从根节点开始编号,根节点的索引是 0

  • 子节点的索引

    若节点索引为 i,左子节点的索引为 2i+1,右子节点的索引为 2i+2

    例如上图-索引为 4 的节点的左子节点索引为 9,右子节点索引为 10

  • 父节点的编号

    若节点编号为 i,父节点的编号为 floor( (i – 1) / 2 )

    例如上图-索引为 4 的节点,父节点索引为 floor(3/2) = 1

实现

常见的属性:

  • data:存储堆中的元素,通常使用数组来实现

  • size:堆中当前元素的数量

常见的方法:

  • insert(value):在堆中插入一个新元素

  • extract():从堆中提取最大/最小元素

  • build_heap(list):直接将一个列表数组变成堆结构

  • peek():返回堆中的最大/最小元素

  • isEmpty():判断堆是否为空

  • size():堆元素长度

insert插入

每次插入元素后,需要对堆进行重构来维护最大堆的性质,这种策略叫做上滤(percolate up Snipaste_2024-11-15_14-31-21.png

  • 将新插入的元素加入到最后

  • 对堆进行重构,拿新元素和父元素比较,比父元素大就交换位置

private swap(i: number, j: number) {
  const temp = this.data[i];
  this.data[i] = this.data[j];
  this.data[j] = temp;
}
insert(value: T) {
  this.data.push(value);
  this.percolateUp();
  this.size++;
}
private percolateUp() {
  const value = this.data[this.size];
  let i = this.size;
  while (i > 0) {
    const fi = Math.floor((i - 1) / 2);
    if (value > this.data[fi]) {
      this.swap(i, fi);
      i = fi;
    } else {
      break;
    }
  }
}
extract提取最大值

每次删除元素后,需要对堆进行重构来维护最大堆的性质,这种向下替换元素的策略叫作下滤(percolate down Snipaste_2024-11-15_14-43-01.png

  • 检查堆是否为空,如果是空堆则返回 null

  • 不为空时堆顶最大元素换为堆底元素,移除堆底的元素

  • 调用 percolateDown 方法以重新维护堆性质

    • 计算左子节点 li 和右子节点 ri 的索引

    • 如果没有左子节点,则直接返回(说明当前节点是叶节点,不需要下滤)

    • 确定要与当前节点交换的子节点 si,如果右子节点存在且大于左子节点,则选择右子节点

    • 如果子节点 si 的值大于当前节点的值,则交换两者并递归调用 percolateDown 继续下沉,以维护堆结构

  • 最后返回堆顶元素

extract(): T | null {
  if (this.length === 0) return null;
  const top = this.data[0];
  this.data[0] = this.data[this.length - 1];
  this.data.pop();
  this.length--;
  this.percolateDown(0);
  return top;
}
private percolateDown(i: number) {
  const li = 2 * i + 1; // 左节点index
  const ri = li + 1; // 右节点index
  if (li >= this.length) return; // 没有左子节点

  // 确定要交换的子节点(默认为左子节点)
  let si = li;
  if (ri < this.length && this.data[ri] > this.data[si]) {
    // 如果右子节点存在且大于左子节点,选择右子节点
    si = ri;
  }
  if (this.data[si] > this.data[i]) {
    // 有左子节点并且值大于节点值
    this.swap(i, si);
    this.percolateDown(si);
  }
}
原地建堆

原地建堆(In-place heap construction)是指建立堆的过程中,不使用额外的内存空间,直接将原有数组变成堆结构,我们使用的方式是自下而上的下滤,也可以使用自上而下的上滤,但是效率较低

  • 从数组的最后一个非叶节点开始对每个非叶节点下滤,将其与较大的子节点交换,直到堆结构完全建立
bulidHeap(array: T[]) {
  this.data = array;
  this.length = array.length;
  let i = Math.floor((this.length - 1) / 2); // 最后的非叶子结点的索引
  while (i >= 0) {
    this.percolateDown(i);
    i -= 1;
  }
}
全部代码

代码进行优化并同时实现最大最小堆

import { cbtPrint } from "hy-algokit";

export class Heap<T> {
  data: T[] = [];
  length: number = 0;
  constructor(list: T[] = [], public isMax: boolean = true) {
    this.isMax = isMax;
    list.length && this.bulidHeap(list);
  }

  private compare(i: number, j: number): boolean {
    if (this.isMax) {
      return this.data[i] > this.data[j];
    } else {
      return this.data[i] < this.data[j];
    }
  }

  private swap(i: number, j: number) {
    const temp = this.data[i];
    this.data[i] = this.data[j];
    this.data[j] = temp;
  }
  // 插入数据
  insert(value: T) {
    this.data.push(value);
    this.percolateUp();
    this.length++;
  }
  private percolateUp() {
    let i = this.length;
    while (i > 0) {
      const fi = Math.floor((i - 1) / 2);
      if (this.compare(i, fi)) {
        this.swap(i, fi);
        i = fi;
      } else {
        break;
      }
    }
  }

  // 提取出最大值
  extract(): T | null {
    if (this.length === 0) return null;
    const top = this.data[0];
    this.data[0] = this.data[this.length - 1];
    this.data.pop();
    this.length--;
    this.percolateDown(0);
    return top;
  }
  private percolateDown(i: number) {
    const li = 2 * i + 1; // 左节点index
    const ri = li + 1; // 右节点index
    if (li >= this.length) return; // 没有左子节点

    // 确定要交换的子节点(默认为左子节点)
    let si = li;
    if (ri < this.length && this.compare(ri, si)) {
      // 如果右子节点存在且大于左子节点,选择右子节点
      si = ri;
    }
    if (this.compare(si, i)) {
      // 有左子节点并且值大于节点值
      this.swap(i, si);
      this.percolateDown(si);
    }
  }

  // 原地建堆
  bulidHeap(array: T[]) {
    this.data = array;
    this.length = array.length;
    let i = Math.floor((this.length - 1) / 2); // 最后的非叶子结点的索引
    while (i >= 0) {
      this.percolateDown(i);
      i -= 1;
    }
  }

  print() {
    console.log(this.data);
    cbtPrint(this.data); // 树结构打印
  }

  /** 其他方法 */
  peek(): T | undefined {
    return this.data[0];
  }

  size() {
    return this.length;
  }

  isEmpty() {
    return this.length === 0;
  }
}

const arr = [19, 100, 36, 17, 3, 25, 1, 2];
const heap = new Heap(arr);
heap.print();
/* 
  [100, 19, 36, 17,3, 25, 1, 2]
                100
        ┌───────┴───────┐
       19              36
    ┌───┴───┐       ┌───┴───┐
   17       3      25       1
  ┌─┘
  2
*/

heap.insert(7);
heap.print();
/* 
  [100, 19, 36, 17,3, 25, 1, 2, 7]
                100
        ┌───────┴───────┐
       19              36
    ┌───┴───┐       ┌───┴───┐
   17       3      25       1
  ┌─┴─┐
  2   7
*/

console.log(heap.extract()); // 100
heap.print();
/* 
  [36, 19, 25, 17, 3, 7, 1, 2]
                36
         ┌───────┴───────┐
       19              25
    ┌───┴───┐       ┌───┴───┐
   17       3       7       1
  ┌─┘
  2
*/

console.log(heap.extract());
heap.print();
/* 
  [25, 19, 7, 17, 3, 2 1]
          25
      ┌───┴───┐
     19       7
    ┌─┴─┐   ┌─┴─┐
   17   3   2   1
*/

算法

算法是解决特定问题的一系列明确指令或步骤。它们被设计用来实现某种功能,执行一系列的操作。算法是计算机科学的核心,解决了许多复杂的现实世界问题。以下是一些常见的算法分类、概念以及具体算法

定义

Algorithm这个单词本意就是解决问题的办法/步骤逻辑,数据结构的实现,离不开算法

  • 一个有限指令集,每条指令的描述不依赖于语言

  • 接受一些输入(有些情况下不需要输入)

  • 产生输出

  • 一定在有限步骤之后终止

算法复杂度

算法复杂度主要衡量算法的性能与效率,包括时间复杂度和空间复杂度

大O表示法

大 O 表示法(Big O notation)是一种用于描述算法时间复杂度或空间复杂度的数学符号,表示当输入规模增大时,算法的执行时间或占用空间的增长趋势

  • O 表示法关注的是输入规模 n 趋于无穷大时,算法的增长趋势,它忽略常数和低阶项(因为当输入的n趋于无穷大时,常数和低阶项的影响会变得微不足道),专注于算法随着输入规模变化时的主导因素

  • 举个例子:解决一个规模为n的问题所花费的时间(或者所需步骤的数目)可以表示为:T(n)=4n²-2n+2n增大时,项开始占据主导地位,其他各项可以被忽略

  • 举例说明:当n=500

    • 4n²项是2n项的1000倍大,因此在大多数场合下,省略后者对表达式的值的影响将是可以忽略不计的

    • 进一步看如果我们与任一其他级的表达式比较,的系数也是无关紧要的

    • 因此我们取O(n²)

时间复杂度

时间复杂度是算法分析中的一个关键概念,用于描述算法执行所需的时间与输入数据规模之间的关系,通常使用大 O 表示法来表达时间复杂度,这样可以简化描述专注于算法在输入规模趋于无穷大时的增长趋势

  • 常见的时间复杂度 image.png 79640377-eba0a100-81c3-11ea-8fa4-2aec0e54d690.png

空间复杂度

空间复杂度用于描述算法在执行过程中需要的额外空间量,它主要关注内存的消耗情况,特别是与输入数据规模之间的关系,通常需要分析程序中需要额外分配的内存空间,如数组、变量、对象、递归调用等,空间复杂度通常也用大 O 表示法来表示

  • 举个例子

    • 对于一个简单的递归算法来说,每次调用都会在内存中分配新的栈帧,这些栈帧占用了额外的空间, 因此该算法的空间复杂度是O(n),其中n是递归深度

    • 对于迭代算法来说,在每次迭代中不需要分配额外的空间,因此其空间复杂度为O(1)

  • 常见的空间复杂度:当空间复杂度很大时,可能会导致内存不足,程序崩溃 image.png

  • 算法优化

    • 避免不必要的临时变量

    • 在合适的情况下复用已有变量

    • 减少递归深度(使用迭代替代递归)

    • 选择合适的数据结构,如避免使用不必要的大数组或嵌套结构

    • 特定情况下:使用空间换时间或使用时间换空间

查找算法

顺序查找

顺序查找(Linear Search 又称线性查找)是最简单的查找算法,直接从数据的第一个元素开始,逐一比较每个元素,直到找到目标元素或者遍历完整个集合

  • 在最坏情况下(目标元素在最后或者不存在),需要遍历所有元素,因此时间复杂度为 O(n)

  • 空间复杂度:O(1)

  • 代码实现

    function linearSearch<T>(array: T[], item: T) {
      for (let i = 0; i < array.length; i++) {
        if (array[i] === item) return i;
      }
      return -1;
    }
    console.log(linearSearch([1, 2, 3, 4, 5, 6, 7, 8, 9], 7)); // 6
    

二分查找

二分查找(Binary Search)是一种更高效的查找算法,但要求数据集合是有序的,它通过不断将搜索范围减半,快速定位目标元素

  • 工作原理

    1. 将集合分为左右两部分,取中间元素作为对比对象

    2. 如果中间元素等于目标元素,则查找成功。

    3. 如果中间元素大于目标元素,则将查找范围缩小到左半部分;如果小于目标元素,则缩小到右半部分。

    4. 重复上述过程,直到找到目标元素或者范围为空

  • 每次查找将范围缩小一半,最坏情况下需要 log₂(n) 次比较,因此时间复杂度为 O(log n)

  • 空间复杂度:O(1)

  • 代码实现

    function binarySearch<T>(array: T[], item: T) {
      let left = 0;
      let right = array.length - 1;
      while (right >= left) {
        const mid = Math.floor((left + right) / 2);
        if (array[mid] === item) {
          return mid;
        } else if (array[mid] > item) {
          right = mid - 1;
        } else {
          left = mid + 1;
        }
      }
      return -1;
    }
    console.log(binarySearch([1, 2, 3, 4, 5, 6, 7, 8, 9], 7)); // 6
    

比较总结

image.png

排序算法

具体学习这篇文章:待后面补充

动态规划

具体学习这篇文章:待后面补充