程序员不得不会的计算机科班知识——数据结构与算法篇(下)

2,062 阅读23分钟

计算机科班知识整理专栏系列文章:

【1】程序员不得不会的计算机科班知识——操作系统篇(上)
【2】程序员不得不会的计算机科班知识——操作系统篇(下)
【3】程序员不得不会的计算机科班知识——数据库原理篇(上)
【4】程序员不得不会的计算机科班知识——数据库原理篇(下)
【5】程序员不得不会的计算机科班知识——数据结构与算法篇(上)
【6】程序员不得不会的计算机科班知识——数据结构与算法篇(下)
【7】程序员不得不会的计算机科班知识——软件工程篇(上)
【8】程序员不得不会的计算机科班知识——软件工程篇(中)
【9】程序员不得不会的计算机科班知识——软件工程篇(下)
【10】程序员不得不会的计算机科班知识——编译原理篇(上)
【11】程序员不得不会的计算机科班知识——编译原理篇(中)
【12】程序员不得不会的计算机科班知识——编译原理篇(下)
【13】程序员不得不会的计算机科班知识——计算机网络篇(上)
【14】程序员不得不会的计算机科班知识——计算机网络篇(中)
【15】程序员不得不会的计算机科班知识——计算机网络篇(下)

第七章 图

7.1 概述

  • 图中节点之间的关系是任意的。
  • 有向图:<x,y>(x为弧尾tail,y为弧头head),无向图:(x,y)
  • 带边权的图叫做网。
  • 对于无向图而言,其邻接矩阵第i行元素之和就是图中第i个顶点的度
  • 对有向图而言:第i行:出度,第i列:入度。
  • 一个n个顶点的联通无向图,边最少情况为构成树时,n-1条边

7.2 图的遍历

7.2.1 深度优先dfs

  • 利用了递归,如果不满足条件则会回溯到另一条最近的分支进行遍历。(类似二叉树的先序遍历)
  • 例如:

// 二叉树的深度优先遍历--使用递归或栈
// 深度优先遍历(Depth First Search),简称DFS,其原则是,沿着一条路径一直找到最深的那个节点,当没有子节点的时候,返回上一级节点,寻找其另外的子节点,继续向下遍历,没有就向上返回一级,直到所有的节点都被遍历到,每个节点只能访问一次。

// 递归解法
function dfs(node) {
  if (!node) {
    return;
  }
  console.log(node.val);
  // 递归
  dfs(node.left);
  dfs(node.right);
}

dfs(tree);

// 栈解法,利用栈:将遍历到的结点都依次存入栈中,拿结果时从栈中访问
let dfs = function (nodes) {
  let result = [];
  let stack = [];
  stack.push(nodes);
  while(stack.length) { // 等同于 while(stack.length !== 0) 直到栈中的数据为空
    let node = stack.pop(); // 取的是栈中最后一个j
    result.push(node.value);
    if(node.right) stack.push(node.right); // 先压入右子树
    if(node.left) stack.push(node.left); // 后压入左子树
  }
  return result;
}
dfs(tree);

7.2.2 广度优先bfs

  • 不仅要有一个一维数组来存储该节点是否被访问过,还要借助队列来存储所有邻接节点。(类似二叉树的按层次遍历)
  • 例如:

// 二叉树的广度优先遍历--运用队列
// 广度优先遍历(Breadth First Search),简称BFS;广度优先遍历的原则就是对每一层的节点依次访问,一层访问结束后,进入下一层,直到最后一个节点,同样的,每个节点都只访问一次。

// 队列解法
function bfs(root) {
  //二叉树的层次遍历
  let res = [];
  let queue = [root];
  if (root === null) {
    return res;
  }
  while (queue.length !== 0) {
    let currentLevel = [];
    let len = queue.length;
    for (let i = 0; i < len; i++) {
      let node = queue.shift();
      currentLevel.push(node.val);
      console.log(node.val);
      if (node.left) queue.push(node.left);
      if (node.right) queue.push(node.right);
    }
    res.push(currentLevel);
  }
  return res;
};

let res = bfs(tree);
console.log(res);

7.3 构造最小生成树

7.3.1 Prim算法(普里姆算法)

  • 实质就是每次选择权最小的边加入,直至选择了n-1条边。
  • 时间复杂度为O(n^2),适用于稠密图,该边要和已选的顶点邻接,且不能构成回路
  • 例如:

7.3.2 Kruskal算法(克鲁斯卡尔算法)

  • 先将所有边按权排序,然后选边(向右选),若该边和已选出来的边构成回路,则舍去,最终得。
  • 由于边权可能相同,则可能导致最小生成树不唯一。
  • 时间复杂度为O(eloge),适用于边少的稀疏图。(e为图边数)
  • 例如:

7.4 拓扑排序

  • 在有向图中若以顶点表示活动,有边表示活动之间的有限关系,这样的图简称AOV网。(不能存在回路)

  • AOV图的拓扑排序不是唯一的。

  • 拓扑排序方法:

    1. 从有向图中选一个无前驱的顶点输出
    2. 从有向图中删去此顶点以及所有以它为起点的边
    3. 重复a、b,直到不存在无前驱的顶点
    4. 若此时输出的顶点数小于有向图中的顶点数,则说明有向图中存在回路,否则输出的顶点的顺序为一个拓扑排序。(因此拓扑排序可用于判断有无环,当然深优dfs也可以判断是否有环)
  • 例如:

7.5 AOE网

  • AOE网:顶点表示事件,边表示活动,边上权值表示活动的持续时间,AOE网常用来计算工程的最短完成时间。
  • AOE网的源点:唯一、入度为0;AOE网的汇点:唯一、出度为0。
  • 在AOE网中,有些活动可以并行地进行,最短完成时间应是从源点到汇点的最长路径长度(指的是路径上所有权值之和),称这样的路径为关键路径,关键路径上的活动叫做关键活动。
  • 例如:

事件最早发生时间ve(i):从源点到顶点vi的最长路径长度

事件最迟发生时间vl(i):先找出最长路径,然后减去汇点到该点的最长路径即可

活动最早开始时间ee(i+j) = ve(i)

活动最迟开始时间el(i,j) = vl(j) - 权(i,j)

el(i,j)-ee(i,j)为活动<vi,vj>的时间余量

将el(i,j) = ee(i,j)的活动称为关键活动。

例如:

7.6 最短路径算法

(计算机网络中的两种路由算法——链路状态算法(link state)与距离-向量路由算法(distance vector))

最短路径算法是一种计算网络中两个节点之间最短路径的方法。常用的最短路径算法有Dijkstra算法和Bellman-Ford算法。

  1. Dijkstra(迪杰斯特拉)算法:Dijkstra算法是基于图的广度优先搜索算法,它的核心思想是构造一张有向图,并计算从起点到其他节点的最短路径。具体步骤如下:(1)将起点设置为当前节点,并设置一个数组来存储起点到各个节点的距离;(2)遍历与当前节点相邻的所有节点,并计算从起点到这些节点的距离,并更新距离数组;(3)从未访问过的节点中选择距离最短的节点,将其设置为当前节点,并重复步骤2和3,直到所有节点都被访问为止。
  2. Bellman-Ford(距离向量)算法:Bellman-Ford算法是一种基于动态规划的解决方案,它可以处理带有负权边的图。具体步骤如下:(1)初始化起点到其他节点的距离为无穷大,起点到自身的距离为0;(2)遍历所有边,对于每一条边(u, v),如果起点u到源点的距离加上边权值w小于终点v到源点的距离,则更新终点v到源点的距离为起点u到源点的距离加上边权值w;(3)重复执行步骤2,直到所有节点都被更新为止。如果在这个过程中没有发现负权环,则算法结束。

最短路径算法实现过程中需要使用到数据结构,如队列、栈和数组等。其中优先队列是Dijkstra算法的核心数据结构,用于存储节点和距离信息,并按照距离的大小进行排序,以便优先选择距离最短的节点。而Bellman-Ford算法则通常采用动态规划技术,通过对每条边的松弛(relax)操作来更新节点之间的距离。

第八章 查找

8.1 概述

关键字是数据元素中的某个数据项

唯一标识的关键字=>主关键字

静态:

  • 顺序查找算法的平均查找长度(ASL)为(n+1)/2,表中元素可以任意存放
  • 折半查找(二分查找)只适用于对有序顺序表进行查找,ASL = log2(n+1)(可以构造一棵折半查找判定树,其中关键字比较次数不超过树的深度)

动态:

  • 二叉排序树(中序遍历会从小到大排序,可以根据这个判定是否是二叉排序树)也称为二叉查找树(可以是一棵空树),若非空,则:

    1. 若左子树非空,则左子树上所有节点值均小于根节点值
    2. 若右子树非空,则右子树上所有节点值均小于根节点值
    3. 它的左右子树也分别为二叉排序树
  • 二叉排序树的ASL计算公式:ASL = ∑(本层高度*本层元素个数)/节点总数

  • 建立二叉排序树:若大于根节点,则插入右子树;若小于根节点,则插入左子树。

常见的查找时间复杂度:

  • 顺序查找:O(n^2)
  • 分块查找:O((n/s+1)/2+1)或O(log2(n/s+1)+s/2)
  • 折半查找:O(log2n)
  • 二叉排序树:随机:O(log2n),最坏:O(n^2)

8.2 二叉平衡树

8.2.1 二叉平衡树的概念

二叉平衡树(AVL树)是空树或者是具有以下性质的二叉排序树:

  • 左子树和右子树高度之差的绝对值小于等于1
  • 左子树和右子树也是AVL树
  • 保证了平衡二叉树的查找、插入和删除操作的时间复杂度都是O(logn)级别的。
  • 平衡二叉树的特点是,在插入或删除节点时,如果破坏了树的平衡性,需要通过旋转操作来重新平衡。旋转操作包括左旋、右旋、左右旋和右左旋四种,通过这些旋转操作可以保证树的平衡性。

平衡二叉树的实现有很多种,比如红黑树、AVL树、B树等,它们的实现方式不同,但都满足二叉搜索树的基本性质,即左子树的所有节点的值都小于根节点的值,右子树的所有节点的值都大于根节点的值。

8.2.2 平衡化旋转

(A指的是由插入节点向根节点进行搜索,第一个失去平衡的节点。)

  1. 右单旋转LL型,插入节点在A的左孩子的左子树上。

操作:(简单地说就是将中间节点往上提,然后将中间节点右子树成为A左子树)

  1. 使BR成为A的左子树
  2. 使A成为B的右孩子
  3. B变为重构树的根节点

图示:

例子:

  1. 左单旋转RR型,插入节点在A的右孩子的右子树上。

操作:(简单地说就是先将中间节点往上提,中间节点的左子树成为A的右子树)

  1. 使BL成为A的右子树
  2. A成为B的左孩子
  3. B变为重构树的根节点

图示:

例子:

  1. 左右双旋转LR型,插入节点在A的左孩子的右子树上。

操作:在节点B先做一次左旋转,再在A做一次右旋转。

图示:

  1. 右左双旋转RL型,插入节点在A的右孩子的左子树上。

操作:在节点B先做一次右旋转,再在A作一次左旋

图示:

构造二叉平衡树,在插入过程中,采用平衡旋转技术。

例如:

8.3 哈希冲突

8.3.1 概述

  • 散列表包括下标、比较次数、关键字三种元素
  • 散列表(哈希表)中,k1≠k2,但H(k1)=H(k2),这种现象称为冲突。
  • 哈希表的装填因子α定义:哈希表的元素个数n/哈希表长度m

8.3.2 处理冲突的方法

1、开方地址法(再散列法)

  • 实质就是通过Hi = (H(key)+di) % m 找另一个空的哈希地址。
  • di的两种取法:a:di = i(线性探测法) b:di = 随机数(随机探测法)
  • 1<=i<=m-1(i从1开始),m为散列表长度

例题:

用线性探测法将{100,20,21,35,3,78,99,10}依次存入散列表中,散列函数为H(key) = k%7。

解:

100:h = H(100) = 100%7 = 2,不冲突,放入,即ht[2] = 100;(比较次数1)

20:h = H(20) = 20%7 = 6,不冲突,放入,即ht[6] = 20;(比较次数1)

21:h = H(21) = 21%7 = 0,不冲突,放入,即ht[0] = 21;(比较次数1)

35:h = H(35) = 35%7 = 0,冲突,h1 = (H(35)+1)%8 = 1,不冲突,放入,即ht[1] = 35;(比较次数2)

3:h = H(3) = 3%7 = 3,不冲突,放入,即ht[3] = 3;(比较次数1)

78:h = H(78) = 78%7 = 1,冲突,h1 = (H(78)+1)%8 = 2,冲突,h2 = (H(78)+2)%8 = 3,冲突,h2 = (H(78)+3)%8 = 4,不冲突,放入,即ht[4] = 78;(比较次数4)

99:同理得ht[5] = 99;(比较次数5)

10:同理得ht[7] = 10。(比较次数5)

其中,平均查找长度ASL = (41+2+4+52)/8 = 2.5

2、链地址法

  • 如果H(key1) = H(key2),带有关键字key1和关键字key2的数据元素被插入到相同链表。

例题:

采用链地址法处理上述例子:

其中,ASL = (51+32)/8 = 1.375

8.4 关于topk算法的思路

  • 排序法:对数组进行排序,然后取前k个或后k个数。时间复杂度为O(nlogn),空间复杂度为O(1)。
  • 堆法:维护一个大小为k的最小堆(求最大k个数)或最大堆(求最小k个数),遍历数组,如果当前元素比堆顶元素大(或小),则替换堆顶元素并调整堆,否则继续遍历。时间复杂度为O(nlogk),空间复杂度为O(k)。

堆法是一种利用堆这种数据结构来实现Topk问题的算法。堆是一种特殊的二叉树,它有以下两个特点:

1、堆是一个完全二叉树,即除了最后一层,其他层的节点都是满的,且最后一层的节点都靠左排列;

2、堆中每个节点的值都大于等于(或小于等于)其左右子节点的值,称为最大堆(或最小堆)。

堆法的基本思路是:

1、首先从数组中取出前k个元素,构建一个最小堆(求最大k个数)或最大堆(求最小k个数),这个过程的时间复杂度为O(k);

2、然后遍历数组中剩余的元素,如果当前元素比堆顶元素大(或小),则替换堆顶元素,并调整堆,使其仍然满足堆的性质,这个过程的时间复杂度为O(logk);

3、最后遍历完数组后,堆中的元素就是所求的Topk,可以直接返回或输出。

整个算法的时间复杂度为O(nlogk),空间复杂度为O(k)。

  • 快速选择法:借用快速排序的思想,每次划分后,如果划分点的位置等于k,则返回划分点及其左边的元素;如果划分点的位置小于k,则在右边部分继续划分;如果划分点的位置大于k,则在左边部分继续划分。时间复杂度为O(n),空间复杂度为O(1)。

快速选择法是一种借用快速排序的思想来实现Topk问题的算法。快速排序的基本思想是:

1、从数组中选择一个元素作为基准,将小于基准的元素放在左边,大于基准的元素放在右边;

2、对左右两边的子数组递归地进行快速排序,直到数组有序。

快速选择法的基本思想是:

1、从数组中选择一个元素作为基准,将小于基准的元素放在左边,大于基准的元素放在右边;

2、比较基准的位置和k的大小,如果相等,则返回基准及其左边的元素;如果小于k,则在右边部分继续进行快速选择;如果大于k,则在左边部分继续进行快速选择。

整个算法的时间复杂度为O(n),空间复杂度为O(1)。

第九章 排序

9.1 常见的排序方法

9.1.1 插入排序(简单插入排序、希尔排序)

  • 简单插入排序:它是一种简单的排序算法,通过不断将未排序的元素插入到已排序的部分中,最终得到一个有序的数组。时间复杂度为O(n^2)(最好情况为O(n)),空间复杂度为O(1)。稳定排序。
  • 希尔排序:它是一种基于插入排序的排序算法,通过将数组分成若干个子数组,并对子数组进行插入排序,最终将整个数组进行插入排序,得到一个有序的数组。时间复杂度为O(nlogn) ~ O(n^2),空间复杂度为O(1)。 不稳定排序。

希尔排序实质就是简单插入排序的升阶版(相当于1变成dk而已),每次将一组数分为若干组,每组进行插入排序操作,直至每组元素为1个。

//希尔排序函数(从小到大) 
//arr:需要排序的数组,n:数组元素的个数 
function ShellSort(arr, n) {
  for (let dk = Math.floor(n / 2); dk > 0; dk = Math.floor(dk / 2)) { //若干趟,控制增量每趟减半
    for (let i = dk; i < n; i++) { //一趟分为若干组,每组进行直接插入排序
      let temp = arr[i], j; //arr[i]是当前待插入元素
      for (j = i - dk; j >= 0 && temp < arr[j]; j -= dk) { //组内进行直接插入排序,寻找插入位置,每组数的相邻元素标号相差dk
        arr[j + dk] = arr[j]; //移动
      }
      arr[j + dk] = temp; //插入元素
    }
  }
}


// 另一种形式
function ShellSort(arr, n) {
  for (let dk = Math.floor(n / 2); dk > 0; dk = Math.floor(dk / 2)) { //步长变化
    for (let i = dk; i < n; i += dk) {
      for (let j = i - dk; j >= 0; j -= dk) { //对每组数进行插入排序,每组数的相邻元素标号相差dk
        if (arr[j] > arr[j + dk]) { //交换
          let temp = arr[j];
          arr[j] = arr[j + dk];
          arr[j + dk] = temp;
        }
      }
    }
  }
}

9.1.2 交换排序(冒泡排序、快速排序)

  • 冒泡排序:它是一种简单的排序算法,通过不断交换相邻的元素,将较大的元素逐渐“冒泡”到数组的末尾。时间复杂度为O(n^2),空间复杂度为O(1)。稳定排序。
  • 快速排序:它是一种基于分治思想的排序算法,通过将数组分成两个子数组,分别对子数组进行排序,最终将子数组合并成一个有序的数组。快速排序的核心思想是选择一个基准元素,将数组分成两个部分,一部分是小于基准元素的元素,一部分是大于基准元素的元素。然后对这两部分分别进行快速排序,最终合并成一个有序的数组。快速排序的平均时间复杂度为O(nlogn),但是最坏情况下时间复杂度会退化到O(n^2)。不稳定排序,执行一遍后就会到位一个元素。

快速排序从两头开始比较,利用了递归:

  1. 哨兵j一步步地向左挪动(即j = j-1),直至找到一个小于基准数的数停下来。
  2. 接下来哨兵i再一步步地向右挪动(即i = i+1),直至找到一个大于基准数的数停下来。
  3. 然后交换这个哨兵对应的数。
  4. 然后哨兵重新这样移动直至两个哨兵相遇,将基准数与该数交换。
  5. 至此,一次“探测”结束。
function quickSort(arr, begin, end) {
  //如果区间不只一个数
  if (begin < end) {
    let temp = arr[begin]; //将区间的第一个数作为基准数
    let i = begin; //从左到右进行查找时的“指针”,指示当前左位置
    let j = end; //从右到左进行查找时的“指针”,指示当前右位置
    //不重复遍历
    while (i < j) {
      while (i < j && arr[j] > temp) //当右边的数大于基准数时,略过,继续向左查找
        j--; //不满足条件时跳出循环,此时的j对应的元素是小于基准元素的
      arr[i] = arr[j]; //将右边小于等于基准元素的数填入左边相应位置
      while (i < j && arr[i] <= temp) //当左边的数小于等于基准数时,略过,继续向右查找(重复的基准元素集合到左区间)
        i++; //不满足条件时跳出循环,此时的i对应的元素是大于等于基准元素的
      arr[j] = arr[i]; //将左边大于基准元素的数填入右边相应位置
    }
    arr[i] = temp; //将基准元素填入相应位置,此时的i即为基准元素的位置
    quickSort(arr, begin, i - 1); //对基准元素的左边子区间进行相似的快速排序
    quickSort(arr, i + 1, end); //对基准元素的右边子区间进行相似的快速排序
  }
    //如果区间只有一个数,则返回
  else return;
}

如果使用标准的快速排序算法来对逆序数组进行排序,那么时间复杂度最坏情况下会退化到O(n^2)。

这是因为在快速排序的过程中,每次选择的基准元素都是当前序列的最后一个元素,而逆序数组的最后一个元素是当前序列的最小值,因此在每次划分时都会出现最坏情况,导致时间复杂度退化。

为了避免这种情况,可以使用一些优化策略,比如随机化选择基准元素或者三数取中法来选择基准元素,这样可以降低最坏情况的发生概率,从而提高快速排序的效率。(三数取中法的原理是从数组的左端、右端和中间分别取出一个数,然后比较大小,取这三个数的中间值作为基准元素。这样可以避免在数组有序或逆序时,选取左端或右端作为基准元素导致的最坏情况。)

9.1.3 选择排序(简单选择排序、堆排序)

  • 简单选择排序:它是一种简单的排序算法,通过不断选择未排序部分的最小元素,并将其放置在已排序部分的末尾,最终得到一个有序的数组。时间复杂度为O(n^2),空间复杂度为O(1)。不稳定排序。(与冒泡排序的区别:简单选择排序是先找出min/max,每轮只需要交换一次位置)
  • 堆排序:它是一种基于堆的排序算法,通过将数组构建成一个堆,不断将堆顶元素与堆中的最后一个元素交换,并调整堆,最终得到一个有序的数组。时间复杂度为O(nlogn),空间复杂度为O(1)。 不稳定排序。(无论对整棵树还是子树,都满足根节点最大/最小,是一棵完全二叉树)

堆排序需解决:

  1. 如何建立一个无序列表的堆
  2. 输出堆顶元素后,将剩余元素转化为一个新的堆

例题:

微信图片_20230602211221.jpg

核心思想:插入节点大于/小于其父母节点,则交换。

堆排序的应用场景:

  • 游戏排行榜:由于游戏中的玩家不断地进入和离开服务器,所以需要一个能够快速找到最大或最小值的数据结构,而堆就可以满足这个需求。
  • 优先队列:优先队列是一种按照优先级出队的数据结构,它可以用堆来实现,从而保证每次出队的都是优先级最高或最低的元素。
  • 多路归并排序:多路归并排序是一种将多个有序序列合并成一个有序序列的算法,它可以用堆来维护每个序列当前未处理的元素中最小或最大的那个。
  • 求解Topk问题(详细见上)
  • 求解中位数问题:中位数使用堆排序的思路是维护一个大顶堆和一个小顶堆,分别存储序列的前半部分和后半部分。具体步骤如下:

1、首先,将第一个元素加入大顶堆。

2、然后,每次加入新元素时,先判断它是否小于等于大顶堆的堆顶元素,如果是,则加入大顶堆;否则,加入小顶堆。

3、接着,调整两个堆的大小,使得它们的元素个数之差不超过1。如果大顶堆比小顶堆多两个或以上元素,则将大顶堆的堆顶元素移除并加入小顶堆;如果小顶堆比大顶堆多两个或以上元素,则将小顶堆的堆顶元素移除并加入大顶堆。

4、最后,根据两个堆的大小判断中位数。如果两个堆的大小相等,则中位数是它们各自的堆顶元素的平均值;如果大顶堆比小顶堆多一个元素,则中位数是大顶 堆的 堆 项 元 素;

9.1.4 归并排序

  • 归并排序:它是一种基于分治思想的排序算法,通过将数组分成两个子数组,分别对子数组进行排序,最终将子数组合并成一个有序的数组。归并排序的核心思想是将数组分成两个子数组,分别进行排序,最后将两个有序子数组合并成一个有序数组。归并排序的时间复杂度为O(nlogn),但是空间复杂度较高,需要额外的O(n)空间来存储临时数组。稳定排序。(所需辅助量是众多排序中最多的)

例题:

9.2 关于各种排序方法的其它说明

  • 在文件局部有序或文件长度较小时,最佳选择是直接插入排序
  • 利用比较的方法进行排序,能达到的最好时间复杂度为O(nlogn):

原因:

对于n个待排序元素,在未比较时,可能的正确结果有n!种。

在经过一次比较后,其中两个元素的顺序被确定,所以可能的正确结果剩余n!/2种。

依次类推,直到经过m次比较,剩余可能性n!/(2^m)种。

直到n!/(2^m)<=1时,结果只剩余一种。此时的比较次数m为o(nlogn)次。

所以基于排序的比较算法,最优情况下,复杂度是o(nlogn)的。

  • 关于空间复杂度:

    • 所有的简单排序方法(包括直接插入排序、冒泡排序、简单选择排序)和堆排序的空间复杂度都为O(1)
    • 快排所需的空间复杂度为O(logn),为栈所需的辅助空间
    • 归并排序所需的辅助空间最多,为O(n)
  • 一趟结束后就能选出一个元素在其最终的位置上的排序只有:简单选择排序、堆排序、冒泡排序、快速排序。(这里面只有冒泡排序是稳定的)