Skip to content

Latest commit

 

History

History
1343 lines (784 loc) · 74.4 KB

File metadata and controls

1343 lines (784 loc) · 74.4 KB

profiling

profiling

说明

该文章一部分采用极客时间的算法训练营,一部分参阅了hello algo

what

definition

algorithm

「算法 algorithm」是在有限时间内解决特定问题的一组指令或操作步骤,它具有以下特性。

  • 问题是明确的,包含清晰的输入和输出定义。
  • 具有可行性,能够在有限步骤、时间和内存空间下完成。
  • 各步骤都有确定的含义,在相同的输入和运行条件下,输出始终相同。

https://www.hello-algo.com/chapter_introduction/what_is_dsa/#121

data structure

「数据结构 data structure」是计算机中组织和存储数据的方式,具有以下设计目标。

  • 空间占用尽量少,以节省计算机内存。
  • 数据操作尽可能快速,涵盖数据访问、添加、删除、更新等。
  • 提供简洁的数据表示和逻辑信息,以便算法高效运行。

数据结构设计是一个充满权衡的过程。如果想在某方面取得提升,往往需要在另一方面作出妥协。下面举两个例子。

  • 链表相较于数组,在数据添加和删除操作上更加便捷,但牺牲了数据访问速度。
  • 图相较于链表,提供了更丰富的逻辑信息,但需要占用更大的内存空间。

relation

数据结构与算法高度相关、紧密结合,具体表现在以下三个方面。

  • 数据结构是算法的基石。数据结构为算法提供了结构化存储的数据,以及操作数据的方法。
  • 算法是数据结构发挥作用的舞台。数据结构本身仅存储数据信息,结合算法才能解决特定问题。
  • 算法通常可以基于不同的数据结构实现,但执行效率可能相差很大,选择合适的数据结构是关键。

数据结构与算法的关系

算法效率评估

在算法设计中,我们先后追求以下两个层面的目标。

  1. 找到问题解法:算法需要在规定的输入范围内可靠地求得问题的正确解。
  2. 寻求最优解法:同一个问题可能存在多种解法,我们希望找到尽可能高效的算法。

也就是说,在能够解决问题的前提下,算法效率已成为衡量算法优劣的主要评价指标,它包括以下两个维度。

  • 时间效率:算法运行速度的快慢。
  • 空间效率:算法占用内存空间的大小。

简而言之,我们的目标是设计“既快又省”的数据结构与算法。而有效地评估算法效率至关重要,因为只有这样我们才能将各种算法进行对比,进而指导算法设计与优化过程。

复杂度

复杂度分析能够体现算法运行所需的时间和空间资源与输入数据大小之间的关系。它描述了随着输入数据大小的增加,算法执行所需时间和空间的增长趋势

  • “时间和空间资源”分别对应「时间复杂度 time complexity」和「空间复杂度 space complexity」。
  • “随着输入数据大小的增加”意味着复杂度反映了算法运行效率与输入数据体量之间的关系。
  • “时间和空间的增长趋势”表示复杂度分析关注的不是运行时间或占用空间的具体值,而是时间或空间增长的“快慢”。

一定要确定是趋势,不是具体值

recursion

「递归 recursion」是一种算法策略,通过函数调用自身来解决问题。它主要包含两个阶段。

  1. :程序不断深入地调用自身,通常传入更小或更简化的参数,直到达到“终止条件”。
  2. :触发“终止条件”后,程序从最深层的递归函数开始逐层返回,汇聚每一层的结果。

而从实现的角度看,递归代码主要包含三个要素。

  1. 终止条件:用于决定什么时候由“递”转“归”。
  2. 递归调用:对应“递”,函数调用自身,通常输入更小或更简化的参数。
  3. 返回结果:对应“归”,将当前递归层级的结果返回至上一层。
递归调用栈

递归函数每次调用自身时,系统都会为新开启的函数分配内存,以存储局部变量、调用地址和其他信息等。这将导致两方面的结果。

  • 函数的上下文数据都存储在称为“栈帧空间”的内存区域中,直至函数返回后才会被释放。因此,递归通常比迭代更加耗费内存空间
  • 递归调用函数会产生额外的开销。因此递归通常比循环的时间效率更低

时间效率和空间效率都会很低。

在实际中,编程语言允许的递归深度通常是有限的,过深的递归可能导致栈溢出错误。

同理编程语言允许的一般调用栈深度也是有限的。

推荐阅读:

js的尾调用优化:
尾递归

有趣的是,如果函数在返回前的最后一步才进行递归调用,则该函数可以被编译器或解释器优化,使其在空间效率上与迭代相当。这种情况被称为「尾递归 tail recursion」。

  • 普通递归:当函数返回到上一层级的函数后,需要继续执行代码,因此系统需要保存上一层调用的上下文。
  • 尾递归:递归调用是函数返回前的最后一个操作,这意味着函数返回到上一层级后,无须继续执行其他操作,因此系统无须保存上一层函数的上下文。
总结

从本质上看,递归体现了“将问题分解为更小子问题”的思维范式,这种分治策略至关重要。

  • 从算法角度看,搜索、排序、回溯、分治、动态规划等许多重要算法策略直接或间接地应用了这种思维方式。
  • 从数据结构角度看,递归天然适合处理链表、树和图的相关问题,因为它们非常适合用分治思想进行分析。

说白了还是分而治之

recursion vs iteration

迭代 递归
实现方式 循环结构 函数调用自身
时间效率 效率通常较高,无函数调用开销 每次函数调用都会产生开销
内存使用 通常使用固定大小的内存空间 累积函数调用可能使用大量的栈帧空间
适用问题 适用于简单循环任务,代码直观、可读性好 适用于子问题分解,如树、图、分治、回溯等,代码结构简洁、清晰

recursion vs stack

那么,迭代和递归具有什么内在联系呢?以上述递归函数为例,求和操作在递归的“归”阶段进行。这意味着最初被调用的函数实际上是最后完成其求和操作的,这种工作机制与栈的“先入后出”原则异曲同工

事实上,“调用栈”和“栈帧空间”这类递归术语已经暗示了递归与栈之间的密切关系。

  1. :当函数被调用时,系统会在“调用栈”上为该函数分配新的栈帧,用于存储函数的局部变量、参数、返回地址等数据。
  2. :当函数完成执行并返回时,对应的栈帧会被从“调用栈”上移除,恢复之前函数的执行环境。

递归调用和函数调用栈的联系已经表名了其和栈的关系,这种工作机制和栈的先入后出的原则异曲同工。

递归和迭代可以互相转换?

观察以上代码,当递归转化为迭代后,代码变得更加复杂了。尽管迭代和递归在很多情况下可以互相转化,但不一定值得这样做,有以下两点原因。

  • 转化后的代码可能更加难以理解,可读性更差。
  • 对于某些复杂问题,模拟系统调用栈的行为可能非常困难。

总之,选择迭代还是递归取决于特定问题的性质。在编程实践中,权衡两者的优劣并根据情境选择合适的方法至关重要。

https://www.hello-algo.com/chapter_computational_complexity/iteration_and_recursion/#223

complexity

commonly used(primary purpose)

  • 这里面有 10 个数据结构:数组、链表、栈、队列、散列表、二叉树、堆、跳表、图、Trie树;10 个算法:递归、排序、二分查找、搜索、哈希算法、贪心算法、分治算法、回溯算法、动态规划、字符串匹配算法。

why analyse complexity

  1. 测试结果非常依赖测试环境,和硬件机器有关联,不通用
  2. 测试结果受到数据规模的影响较大,数据规模小的排序,快排有可能比插入排序差,不通用。所以,我们需要一个不用具体的测试数据来测试,就可以粗略地估计算法的执行效率的方法。

但实际上,统计算法的运行时间既不合理也不现实。首先,我们不希望将预估时间和运行平台绑定,因为算法需要在各种不同的平台上运行。其次,我们很难获知每种操作的运行时间,这给预估过程带来了极大的难度。

统计时间增长趋势

时间复杂度分析统计的不是算法运行时间,而是算法运行时间随着数据量变大时的增长趋势

怎么样统计时间复杂度

  • 时间复杂度的推算方法更简便。显然,运行平台和计算操作类型都与算法运行时间的增长趋势无关。因此在时间复杂度分析中,我们可以简单地将所有计算操作的执行时间视为相同的“单位时间”,从而将“计算操作运行时间统计”简化为“计算操作数量统计”,这样一来估算难度就大大降低了。

    忽略每个单位时间,那么我们只需要计算操作数量即可。

  • 时间复杂度也存在一定的局限性。例如,尽管算法 AC 的时间复杂度相同,但实际运行时间差别很大。同样,尽管算法 B 的时间复杂度比 C 高,但在输入数据大小 � 较小时,算法 B 明显优于算法 C 。在这些情况下,我们很难仅凭时间复杂度判断算法效率的高低。当然,尽管存在上述问题,复杂度分析仍然是评判算法效率最有效且常用的方法。

    实际也可以统计时间复杂度前面的常数n.

大 O 复杂度表示法

big-o notation

算法的执行效率,粗略地讲,就是算法代码执行的时间。

从 CPU 的角度来看,这段代码的每一行都执行着类似的操作:读数据-运算-写数据。尽管每行代码对应的 CPU 执行的个数、执行的时间都不一样,但是,我们这里只是粗略估计,所以可以==假设每行代码执行的时间都一样==,为 unit_time。

所有代码的执行时间 T(n) 与每行代码的执行次数成正比。

==所有代码的执行时间 T(n) 与每行代码的执行次数 n成正比==

T(n) = O(f(n))

  • T(n)表示代码执行时间;n表示数据规模的大小;f(n)表示每行代码执行的次数总和。因为这是一个公式,所以用 f(n)来表示。公式中的 O,表示代码的执行时间 T(n) 与 f(n) 表达式成正比。
  • 大 O 时间复杂度实际上并不具体表示代码真正的执行时间,==而是表示代码执行时间随数据规模增长的变化趋势==,所以,也叫作==渐进时间复杂度==(asymptotic time complexity),简称时间复杂度。因此常数复杂度,即使执行了 10000 次也是常量,都可以省略。

数学定义

时间复杂度分析本质上是计算“操作数量 T(n)”的渐近上界,它具有明确的数学定义。

若存在正实数 � 和实数 �0 ,使得对于所有的 �>�0 ,均有 �(�)≤�⋅�(�) ,则可认为 �(�) 给出了 �(�) 的一个渐近上界,记为 �(�)=�(�(�)) 。

  • 也就是一个函数渐进上界的概念

函数的渐近上界

分析时间复杂度

三个实用的方法

  1. 只关注循环执行次数最多的一段代码

    循环n次可以忽略执行一次的代码

  2. 加法法则:总复杂度等于量级最大的那段代码的复杂度

  3. 乘法法则:嵌套代码的复杂度等于嵌套内外代码复杂度的乘积

    T1(n)=O(f(n)), T2(n)=O(g(n))
    T(n)=T1(n)*T2(n)=O(f(n))*O(g(n))=O(f(n)*g(n))
    ∴
    T1(n)=O(n),T2(n)=O(n^2),则 T1(n)*T2(n)=O(n^3)
    

使用排列组合分析时间复杂度

https://www.jianshu.com/p/68593ea7f8f0

  • 需要使用上述的排列组合公司。

usually used complexity

常用的复杂度量级,我们可以粗略的分为两类,多项式量级和非多项式量级。

多项式量级

  • 常量阶 O(1)
  • 对数阶 O(logn)
  • 线性阶 O(n)
  • 线性对数阶 O(nlogn)
  • 平方阶 O(n^2)、立方阶 O(n^3)..... k 次方阶 O(n^k)

非多项式量级(NP)

  • O(2^n)
  • O(n!)

当数据规模 n 越来越大时,非多项式量级算法的执行时间会急剧增加,求解问题的执行时间会无限增长。所以,非多项式时间复杂度的算法其实是非常低效的算法。

1.O(1)

首先你必须明确一个概念,O(1) 只是常量级时间复杂度的一种表示方法,并不是指只执行了一行代码。比如这段代码,即便有 3 行,它的时间复杂度也是 O(1),而不是 O(3)

只要代码的执行时间不随 n 的增大而增长,这样代码的时间复杂度我们都记作 O(1)。或者说,一般情况下,只要算法中不存在循环语句、递归语句,即使有成千上万行的代码,其时间复杂度也是Ο(1)

2. O(logn)、O(nlogn)

代码

let i = 1;
while (i <= n) {
  i = i * 3
}
  • 分析上述代码

    执行次数设置为k,每一行时间unit_time。注意,所有的分析都是要求出执行次数,k

    1 1*3 1*3^2... 1*3^k=n

    k = log3(n)

    O(log(n))

  • O(nlogn) 则是一个O(n) 量级的算法乘以一个对数阶的算法

3. O(m+n)、O(m*n)

  • 无法事先评估的两个数据规模相加,就不能省略其中一个了

常见时间复杂度

指数阶 O(2^n)

指数阶增长非常迅速,在穷举法(暴力搜索、回溯等)中比较常见。对于数据规模较大的问题,指数阶是不可接受的,通常需要使用动态规划或贪心算法等来解决。

  • 常出现在一些暴力解法中,可以使用使用动态规划或贪心算法等来解决

spatial complexity

  • 空间复杂度: 渐进空间复杂度。表示算法的存储空间与数据规模之间的增长关系。
  • 我们常见的空间复杂度就是 O(1)、O(n)、O(n^2),像 O(logn)、O(nlogn) 这样的对数阶复杂度平时都用不到。空间复杂度分析比时间复杂度分析要简单很多。

粒子

function fn (n) {
  let i = 0
  let arr = []
  arr.length = n
  for (; i < arr.length; ++i) {
    arr[i] = i*i
  }
}
  • 代码的空间复杂度就是 O(n)
  • 计算的是为了得到结果==额外需要的内存==,而不是本身存储这个数据结构消耗的内存。时间复杂度也是一样的,本身存储的时候时间不计算,而是计算得到结果所需时间。说白了就是中间的变量,存储结果的数据结构也不算在内。

算法相关空间

算法在运行过程中使用的内存空间主要包括以下几种。

  • 输入空间:用于存储算法的输入数据。
  • 暂存空间:用于存储算法在运行过程中的变量、对象、函数上下文等数据。
  • 输出空间:用于存储算法的输出数据。

一般情况下,空间复杂度统计的范围是 暂存空间 + 输出空间

暂存空间可以进一步划分为三个部分。

  • 暂存数据:用于保存算法运行过程中的各种常量、变量、对象等。
  • 栈帧空间:用于保存调用函数的上下文数据。系统在每次调用函数时都会在栈顶部创建一个栈帧,函数返回后,栈帧空间会被释放。
  • 指令空间:用于保存编译后的程序指令,在实际统计中通常忽略不计。

在分析一段程序的空间复杂度时,我们通常统计暂存数据、栈帧空间和输出数据三部分

推算方法

  • 只需关注最差空间复杂度,因为空间复杂度是必须满足的

==权衡时间与空间==

降低时间复杂度通常需要以提升空间复杂度为代价,反之亦然。我们将牺牲内存空间来提升算法运行速度的思路称为“以空间换时间”;反之,则称为“以时间换空间”。

选择哪种思路取决于我们更看重哪个方面。在大多数情况下,时间比空间更宝贵,因此“以空间换时间”通常是更常用的策略。当然,在数据量很大的情况下,控制空间复杂度也非常重要。

  • 用时间换空间,或者用空间换时间

summary

  • 复杂度也叫渐进复杂度,包括时间复杂度和空间复杂度,用来分析算法执行效率与数据规模之间的增长关系。

complexity_compare

best case time complexity & worst case time complexity

  • 最好时间复杂度、最坏时间复杂度
  • 最好时间复杂度:在最理想的情况下,执行这段代码的时间复杂度
  • 最坏情况时间复杂度就是,在最糟糕的情况下,执行这段代码的时间复杂度

任何算法都要有最好时间复杂度的出口

最差、最佳、平均时间复杂度

“最差时间复杂度”对应函数渐近上界,使用大 � 记号表示。相应地,“最佳时间复杂度”对应函数渐近下界,用 Ω 记号表示:

从上述示例可以看出,最差时间复杂度和最佳时间复杂度只出现于“特殊的数据分布”,这些情况的出现概率可能很小,并不能真实地反映算法运行效率。相比之下,平均时间复杂度可以体现算法在随机输入数据下的运行效率,用 Θ 记号来表示。

一般不适用最差和最佳复杂度去衡量算法的优劣,只是一些特殊情况。

平均时间复杂度

  • 简单:

    要查找的变量 x 在数组中的位置,有 n+1 种情况:在数组的 0~n-1 位置中和不在数组中。我们把每种情况下,查找需要遍历的元素个数累加起来,然后再除以 n+1,就可以得到需要遍历的元素个数的平均值,即:

    查找元素在各个位置所需的次数 / 所有情况个数

  • 引入概率

    我们知道,要查找的变量 x,要么在数组里,要么就不在数组里。这两种情况对应的概率统计起来很麻烦,为了方便你理解,我们假设在数组中与不在数组中的概率都为 1/2。另外,要查找的数据出现在 0~n-1 这 n 个位置的概率也是一样的,为 1/n。所以,根据概率乘法法则,要查找的数据出现在 0~n-1 中任意位置的概率就是 1/(2n)。

    1 * 1/2n + 2 * 1/2n + ... + n * 1/2n + n * 1/2 = (3n + 1)/4

    这个值就是概率论中的加权平均值,也叫作期望值,所以平均时间复杂度的全称应该叫加权平均时间复杂度或者期望时间复杂度。

均摊时间复杂度

 // array 表示一个长度为 n 的数组
 // 代码中的 array.length 就等于 n
 int[] array = new int[n];
 int count = 0;
 
 void insert(int val) {
    if (count == array.length) {
       int sum = 0;
       for (int i = 0; i < array.length; ++i) {
          sum = sum + array[i];
       }
       array[0] = sum;
       count = 1;
    }
 
    array[count] = val;
    ++count;
 }
  • 均摊时间复杂度出现的时候,都是有规律的出现,比如 n 个 O(1) 时间复杂度出现一个 O(n) 时间复杂度,这样才能进行均摊(摊还法)
  • 对一个数据结构进行一组连续操作中,大部分情况下时间复杂度都很低,只有个别情况下时间复杂度比较高,而且这些操作之间存在前后连贯的时序关系,这个时候,我们就可以将这一组操作放在一块儿分析,看是否能将较高时间复杂度那次操作的耗时,平摊到其他那些时间复杂度比较低的操作上。而且,==在能够应用均摊时间复杂度分析的场合,一般均摊时间复杂度就等于最好情况时间复杂度==。

dataStruct

定义

数据结构是在计算机中组织与存储数据的方式。这句话的主语是“结构”而非“数据”。

  • 组织与存储数据的方式

数据结构分类

常见的数据结构包括数组、链表、栈、队列、哈希表、树、堆、图,它们可以从“逻辑结构”和“物理结构”两个维度进行分类。

  • 逻辑结构 和 物理结构

逻辑结构分类

逻辑结构描述了数据元素之间的逻辑关系

分为:线性和非线性

  • 常见的逻辑结构包括线性、树状和网状等。通常我们根据逻辑结构将数据结构分为线性(数组、链表、栈、队列)和非线性(树、图、堆)两种。哈希表的实现可能同时包含线性数据结构和非线性数据结构。

物理结构分类

物理结构描述了数据在计算机内存中的存储方式。

分为:连续喝分散

  • 物理结构主要分为连续空间存储(数组)和分散空间存储(链表)。所有数据结构都是由数组、链表或两者的组合实现的。

这里也是所有数据结构的本质。

基本数据类型

基本数据类型是 CPU 可以直接进行运算的类型,在算法中直接被使用,主要包括以下几种。

  • 整数类型 byteshortintlong
  • 浮点数类型 floatdouble ,用于表示小数。
  • 字符类型 char ,用于表示各种语言的字母、标点符号甚至表情符号等。
  • 布尔类型 bool ,用于表示“是”与“否”判断。

在各大编程语言中都存在基本数据结构,CPU 可以直接进行运算。

基本数据类型以二进制的形式存储在计算机中。一个二进制位即为 1 比特。在绝大多数现代操作系统中,1 字节(byte)由 8 比特(bit)组成。

最小寻址内存单元

  • 即使表示布尔量仅需 1 位(0 或 1),它在内存中通常也存储为 1 字节。这是因为现代计算机 CPU 通常将 1 字节作为最小寻址内存单元。

基本数据类型和数据结构的关系

那么,基本数据类型与数据结构之间有什么联系呢?我们知道,数据结构是在计算机中组织与存储数据的方式。这句话的主语是“结构”而非“数据”。

如果想表示“一排数字”,我们自然会想到使用数组。这是因为数组的线性结构可以表示数字的相邻关系和顺序关系,但至于存储的内容是整数 int、小数 float 还是字符 char ,则与“数据结构”无关。

换句话说,基本数据类型提供了数据的“内容类型”,而数据结构提供了数据的“组织方式”

  • 数据结构存储和组织了基本数据类型,==可以理解为数据的结构,而不是数据本身!==

数字编码

首先需要指出,数字是以“补码”的形式存储在计算机中的

  • 非常重要,以补码的形式存储数字

浮点数编码

细心的你可能会发现:intfloat 长度相同,都是 4 字节 ,但为什么 float 的取值范围远大于 int ?这非常反直觉,因为按理说 float 需要表示小数,取值范围应该变小才对。

  • 浮点数取值返回大是因为其牺牲了精度

其由 符号位 + 指数位 + 分数为 组成

1 + 8 + 32

对比十进制

十进制也有精度问题,比如 1/3、1/7 就无法准确表示,和单精度是类似的。

字符编码

编程语言的字符编码

对于以往的大多数编程语言,程序运行中的字符串都采用 UTF-16 或 UTF-32 这类等长编码。在等长编码下,我们可以将字符串看作数组来处理,这种做法具有以下优点。

  • 随机访问:UTF-16 编码的字符串可以很容易地进行随机访问。UTF-8 是一种变长编码,要想找到第 � 个字符,我们需要从字符串的开始处遍历到第 � 个字符,这需要 �(�) 的时间。
  • 字符计数:与随机访问类似,计算 UTF-16 编码的字符串的长度也是 �(1) 的操作。但是,计算 UTF-8 编码的字符串的长度需要遍历整个字符串。
  • 字符串操作:在 UTF-16 编码的字符串上,很多字符串操作(如分割、连接、插入、删除等)更容易进行。在 UTF-8 编码的字符串上,进行这些操作通常需要额外的计算,以确保不会产生无效的 UTF-8 编码。

编程语言一般使用等长编码,虽然牺牲了内存空间,但是在进行一些字符串操作的使用时间复杂度都是 O(1)

  • js和Java的 string 类型使用 UTF-16 等长编码,也就是每个字符占用两个字节
  • 如果超出两个字节的字符需要使用代理对来表示

需要注意的是,以上讨论的都是字符串在编程语言中的存储方式,这和字符串如何在文件中存储或在网络中传输是不同的问题。在文件存储或网络传输中,我们通常会将字符串编码为 UTF-8 格式,以达到最优的兼容性和空间效率。

Array

数组(Array)是一种线性表数据结构。它用一组连续的内存空间,来存储一组具有相同类型的数据。

  • 具有查找优势,删除和插入劣势
  • 线性表数据结构

几个概念

  1. 第一是线性表(Linear List)。顾名思义,线性表就是数据排成像一条线一样的结构。每个线性表上的数据最多==只有前和后两个方向==。其实除了数组,链表、队列、栈等也是线性表结构。

linearList

  • 而与它相对立的概念是非线性表,比如二叉树、堆、图等。之所以叫非线性,是因为,在非线性表中,数据之间并不是简单的前后关系。

    noLinearList

  1. 第二个是连续的内存空间和相同类型的数据。正是因为这两个限制,它才有了一个堪称“杀手锏”的特性:“随机访问”。但有利就有弊,这两个限制也让数组的很多操作变得非常低效,比如要想在数组中删除、插入一个数据,为了保证连续性,就需要做大量的数据搬移工作。

数组是如何实现根据下标随机访问数组元素

  • 我们拿一个长度为 10 的 int 类型的数组 int[] a = new int[10] 来举例。在我画的这个图中,计算机给数组 a[10],分配了一块连续内存空间 1000~1039,其中,内存块的首地址为 base_address = 1000。

array-search

  • 通过寻址公式查找元素。我们知道,计算机会给每个内存单元分配一个地址,计算机通过地址来访问内存中的数据。当计算机需要随机访问数组中的某个元素时,它会首先通过下面的==寻址公式==,计算出该元素存储的内存地址:

    a[i]_address = base_address + i * data_type_size
  • 这里我要特别纠正一个“错误”。我在面试的时候,常常会问数组和链表的区别,很多人都回答说,“链表适合插入、删除,时间复杂度 O(1);数组适合查找,查找时间复杂度为 O(1)”。

    ==数组不是适合查找,准确的说是适合根据 索引 随机访问==。

    实际上,这种表述是不准确的。数组是适合查找操作,但是查找的时间复杂度并不为 O(1)。即便是排好序的数组,你用二分查找,时间复杂度也是 O(logn)。所以,正确的表述应该是,数组支持随机访问,根据下标随机访问的时间复杂度为 O(1)。

低效的“插入”和“删除”

如何优化:

  • 插入

    如果数组中的数据是有序的,我们在某个位置插入一个新的元素时,就必须按照刚才的方法搬移 k 之后的数据。但是,如果数组中存储的数据并没有任何规律,数组只是被当作一个存储数据的集合。在这种情况下,如果要将某个数组插入到第 k 个位置,为了避免大规模的数据搬移,我们还有一个简单的办法就是,直接将第 k 位的数据搬移到数组元素的最后,把新的元素直接放入第 k 个位置。

    不需要保持原来数组的顺序,只需要把指定的数据插入到指定的位置即可。

    利用这种处理技巧,在特定场景下,在第 k 个位置插入一个元素的时间复杂度就会降为 O(1)。这个处理思想在快排中也会用到,我会在排序那一节具体来讲,这里就说到这儿。

  • 删除

    和插入类似,如果删除数组末尾的数据,则最好情况时间复杂度为 O(1);如果删除开头的数据,则最坏情况时间复杂度为 O(n);平均情况时间复杂度也为 O(n)。

    我们继续来看例子。数组 a[10] 中存储了 8 个元素:a,b,c,d,e,f,g,h。现在,我们要依次删除 a,b,c 三个元素。

    del-array

    为了避免 d,e,f,g,h 这几个数据会被搬移三次,我们可以先记录下已经删除的数据。每次的删除操作并不是真正地搬移数据,只是记录数据已经被删除。当数组没有更多空间存储数据时,我们再触发执行一次真正的删除操作,这样就大大减少了删除操作导致的数据搬移。

    如果你了解 JVM,你会发现,这不就是 JVM 标记清除垃圾回收算法的核心思想吗?没错,数据结构和算法的魅力就在于此,很多时候我们并不是要去死记硬背某个数据结构或者算法,而是要学习它背后的思想和处理技巧,这些东西才是最有价值的。如果你细心留意,不管是在软件开发还是架构设计中,总能找到某些算法和数据结构的影子。

警惕数组的访问越界问题

int main(int argc, char* argv[]){
    int i = 0;
    int arr[3] = {0};
    for(; i<=3; i++){
        arr[i] = 0;
        printf("hello world\n");
    }
    return 0;
}

对文中示例的无限循环有疑问的同学,建议去查函数调用的栈桢结构细节(操作系统或计算机体系结构的教材应该会讲到)。

函数体内的局部变量存在栈上,且是连续压栈。在Linux进程的内存布局中,栈区在高地址空间,从高向低增长。变量i和arr在相邻地址,且i比arr的地址大,所以arr越界正好访问到i。

这段解释需要研究,只有在 c 语言中会出现

  • 根据数组的寻址公式,arr[3] 会直接访问某块不属于数组的内存地址上。而这个地址正好是存储变量 i 的内存地址,那么 a[3]=0 就相当于 i=0,所以就会导致代码无限循环。
  • 数组越界在 C 语言中是一种未决行为,并没有规定数组访问越界时编译器应该如何处理。因为,访问数组的本质就是访问一段连续内存,只要数组通过偏移计算得到的内存地址是可用的,那么程序就可能不会报任何错误。

javascript 中的数组

  • 更像是java中的容器(也就是所谓的列表),支持动态扩展长度,可以存储不同类型的数据

  • js中数组内存是否是连续性的呢?

    参考:https://juejin.cn/post/6844903888215080973

    • 如果相同类型的数据就是连续的内存
    • 如果是不同类型的数据就是不连续的内容

    那么也就要求我们在构造数组的时候类型尽量是相同的!

容器能否完全替代数组?

  • 容器完全不需要考虑数组的操作细节,比如说扩容等操作。
  • 以Java为例,ArrayList 如果使用 ArrayList,我们就完全不需要关心底层的扩容逻辑,ArrayList 已经帮我们实现好了。每次存储空间不够的时候,它都会将空间自动扩容为 1.5 倍大小。(JavaScript 是怎样实现的)
  • 不过,这里需要注意一点,因为扩容操作涉及内存申请和数据搬移,是比较耗时的。所以,如果事先能确定需要存储的数据大小,最好在创建 ArrayList 的时候事先指定数据大小

总结

对于业务开发,直接使用容器就足够了,省时省力。毕竟损耗一丢丢性能,完全不会影响到系统整体的性能。但如果你是做一些非常底层的开发,比如开发网络框架,性能的优化需要做到极致,这个时候数组就会优于容器,成为首选。

答疑

为什么数组的下标会从 0 开始

从数组存储的内存模型上来看,“下标”最确切的定义应该是“偏移(offset)”。前面也讲到,如果用 a 来表示数组的首地址,a[0] 就是偏移为 0 的位置,也就是首地址,a[k] 就表示偏移 k 个 type_size 的位置,所以计算 a[k] 的内存地址只需要用这个公式。

因为数组寻址应用到寻址公式,如果使用 1 为下标开始,数组每次的查找都需要多进行一步减法操作。

linked list

0. 引子:LRU 缓存淘汰算法

  • 缓存是一种提高数据读取性能的技术,在硬件设计、软件开发中都有着非常广泛的应用,比如常见的 CPU 缓存、数据库缓存、浏览器缓存等等。
  • 缓存的清理:缓存的大小有限,当缓存被用满时,哪些数据应该被清理出去,哪些数据应该被保留?这就需要缓存淘汰策略来决定。常见的策略有三种:先进先出策略 FIFO(First In,First Out)、最少使用策略 LFU(Least Frequently Used)、最近最少使用策略 LRU(Least Recently Used)。

1. 单链表

  • 链表通过指针将一组零散的内存块串联在一起。其中,我们把内存块称为链表的“结点”。为了将所有的结点串起来,每个链表的结点除了存储数据之外,还需要记录链上的下一个结点的地址。如图所示,我们把这个记录下个结点地址的指针叫作后继指针 next

single-linked-list

  • 与数组一样,链表也支持数据的查找、插入和删除操作。
  • 两个特殊的结点,第一个结点和最后一个结点。我们习惯性地把第一个结点叫作头结点,把最后一个结点叫作尾结点。其中,头结点用来记录链表的基地址。有了它,我们就可以遍历得到整条链表。而尾结点特殊的地方是:指针不是指向下一个结点,而是指向一个空地址 NULL,表示这是链表上最后一个结点。
  • 数组随机访问第 k 个元素,只需要执行寻址公式,找到对应的元素的地址即可。而链表随机访问一个结点,需要根据指针一个结点一个结点地依次遍历,直到找到相应的结点。所以,链表随机访问的性能没有数组好,需要 O(n) 的时间复杂度。

2. 循环链表

  • 循环链表是一种特殊的单链表

    它跟单链表唯一的区别就在尾结点。我们知道,单链表的尾结点指针指向空地址,表示这就是最后的结点了。而循环链表的尾结点指针是指向链表的头结点。从我画的循环链表图中,你应该可以看出来,它像一个环一样首尾相连,所以叫作“循环”链表。

3. 双向链表

  • 单向链表只有一个方向,结点只有一个后继指针 next 指向后面的结点。而双向链表,顾名思义,它支持两个方向,每个结点不止有一个后继指针 next 指向后面的结点,还有一个前驱指针 prev 指向前面的结点。

double-linked-list

  • 从我画的图中可以看出来,双向链表需要额外的两个空间来存储后继结点和前驱结点的地址。所以,如果存储同样多的数据,双向链表要比单链表占用更多的内存空间。虽然两个指针比较浪费存储空间,但可以支持双向遍历,这样也带来了双向链表操作的灵活性。
  • 从结构上来看,双向链表可以支持 O(1) 时间复杂度的情况下找到前驱结点,正是这样的特点,也使双向链表在某些情况下的插入、删除等操作都要比单链表简单、高效。
  • 你可能会说,我刚讲到单链表的插入、删除操作的时间复杂度已经是 O(1) 了,双向链表还能再怎么高效呢?别着急,刚刚的分析比较偏理论,很多数据结构和算法书籍中都会这么讲,但是这种说法实际上是不准确的,或者说是有先决条件的。我再来带你分析一下链表的两个操作。

例子

在实际的软件开发中,从链表中删除一个数据无外乎这两种情况:

  • 删除结点中“值等于某个给定值”的结点;
  • 删除给定指针指向的结点。

对于第一种情况,不管是单链表还是双向链表,为了查找到值等于给定值的结点,都需要从头结点开始一个一个依次遍历对比,直到找到值等于给定值的结点,然后再通过我前面讲的指针操作将其删除。(节点值互换,节点 next 指针互换)

尽管单纯的删除操作时间复杂度是 O(1),但遍历查找的时间是主要的耗时点,对应的时间复杂度为 O(n)。根据时间复杂度分析中的加法法则,删除值等于给定值的结点对应的链表操作的总时间复杂度为 O(n)。

对于第二种情况,我们已经找到了要删除的结点,但是删除某个结点 q 需要知道其前驱结点,而单链表并不支持直接获取前驱结点,所以,为了找到前驱结点,我们还是要从头结点开始遍历链表,直到 p->next=q,说明 p 是 q 的前驱结点。

但是对于双向链表来说,这种情况就比较有优势了。因为双向链表中的结点已经保存了前驱结点的指针,不需要像单链表那样遍历。所以,针对第二种情况,==单链表删除操作需要 O(n) 的时间复杂度,而双向链表只需要在 O(1) 的时间复杂度内就搞定了!==

同理,如果我们希望在链表的某个指定结点前面插入一个结点,双向链表比单链表有很大的优势。双向链表可以在 O(1) 时间复杂度搞定,而单向链表需要 O(n) 的时间复杂度。你可以参照我刚刚讲过的删除操作自己分析一下。

  • 因为删除和插入都需要依赖前驱结点。

除了插入、删除操作有优势之外,对于一个有序链表,双向链表的按值查询的效率也要比单链表高一些。因为,我们可以记录上次查找的位置 p,每次查询时,根据要查找的值与 p 的大小关系,决定是往前还是往后查找,所以平均只需要查找一半的数据。

  • 随机查找有序链表,需要判断是往左还是往右走,类似于二分法。

实际上,这里有一个更加重要的知识点需要你掌握,那就是用空间换时间的设计思想。当内存空间充足的时候,如果我们更加追求代码的执行速度,我们就可以选择空间复杂度相对较高、但时间复杂度相对很低的算法或者数据结构。相反,如果内存比较紧缺,比如代码跑在手机或者单片机上,这个时候,就要反过来用时间换空间的设计思路。

还是开篇缓存的例子。==缓存实际上就是利用了空间换时间的设计思想==。如果我们把数据存储在硬盘上,会比较节省内存,但每次查找数据都要询问一次硬盘,会比较慢。但如果我们通过缓存技术,事先将数据加载在内存中,虽然会比较耗费内存空间,但是每次数据查询的速度就大大提高了。

实现 LRU 缓存淘汰算法

我的思路是这样的:我们维护一个有序单链表,越靠近链表尾部的结点是越早之前访问的。当有一个新的数据被访问时,我们从链表头开始顺序遍历链表。

  1. 如果此数据之前已经被缓存在链表中了,我们遍历得到这个数据对应的结点,并将其从原来的位置删除,然后再插入到链表的头部。

  2. 如果此数据没有在缓存链表中,又可以分为两种情况:

  • 如果此时缓存未满,则将此结点直接插入到链表的头部;
  • 如果此时缓存已满,则链表尾结点删除,将新的数据结点插入链表的头部。

现在我们来看下 m 缓存访问的时间复杂度是多少。因为不管缓存有没有满,我们都需要遍历一遍链表,所以这种基于链表的实现思路,缓存访问的时间复杂度为 O(n)。

实际上,我们可以继续优化这个实现思路,比如引入散列表(Hash table)来记录每个数据的位置,将缓存访问的时间复杂度降到 O(1)。因为要涉及我们还没有讲到的数据结构,所以这个优化方案,我现在就不详细说了,等讲到散列表的时候,我会再拿出来讲。

硬盘、内存与缓存

参考:https://www.hello-algo.com/chapter_array_and_linkedlist/ram_and_cache/#443

计算机存储系统

在当前技术下,多层级的缓存结构是容量、速度和成本之间的最佳平衡点。

  • 依次是 硬盘、内存、缓存三级结构,是容量、速度和成本之间的最佳平衡点。

计算机的存储设备

硬盘 内存 缓存
用途 长期存储数据,包括操作系统、程序、文件等 临时存储当前运行的程序和正在处理的数据 存储经常访问的数据和指令,减少 CPU 访问内存的次数
易失性 断电后数据不会丢失 断电后数据会丢失 断电后数据会丢失
容量 较大,TB 级别 较小,GB 级别 非常小,MB 级别
速度 较慢,几百到几千 MB/s 较快,几十 GB/s 非常快,几十到几百 GB/s
价格 较便宜,几毛到几元 / GB 较贵,几十到几百元 / GB 非常贵,随 CPU 打包计价

数据结构的缓存效率

缓存虽然在空间容量上远小于内存,但它比内存快得多,在程序执行速度上起着至关重要的作用。由于缓存的容量有限,只能存储一小部分频繁访问的数据,因此当 CPU 尝试访问的数据不在缓存中时,就会发生缓存未命中(cache miss),此时 CPU 不得不从速度较慢的内存中加载所需数据。

显然,“缓存未命中”越少,CPU 读写数据的效率就越高,程序性能也就越好。我们将 CPU 从缓存中成功获取数据的比例称为缓存命中率(cache hit rate),这个指标通常用来衡量缓存效率。

为了尽可能达到更高的效率,缓存会采取以下数据加载机制。

  • 缓存行:缓存不是单个字节地存储与加载数据,而是以缓存行为单位。相比于单个字节的传输,缓存行的传输形式更加高效。
  • 预取机制:处理器会尝试预测数据访问模式(例如顺序访问、固定步长跳跃访问等),并根据特定模式将数据加载至缓存之中,从而提升命中率。
  • 空间局部性:如果一个数据被访问,那么它附近的数据可能近期也会被访问。因此,缓存在加载某一数据时,也会加载其附近的数据,以提高命中率。
  • 时间局部性:如果一个数据被访问,那么它在不久的将来很可能再次被访问。缓存利用这一原理,通过保留最近访问过的数据来提高命中率。

linked list vs Array

  • 通过前面内容的学习,你应该已经知道,数组和链表是两种截然不同的内存组织方式。正是因为内存存储的区别,它们插入、删除、随机访问操作的时间复杂度正好相反。

    ![linked list vs Array](./imgs/linked list vs Array.jpeg)

总结

  • 不过,数组和链表的对比,并不能局限于时间复杂度。而且,在实际的软件开发中,不能仅仅利用复杂度分析就决定使用哪个数据结构来存储数据。

    数组简单易用,在实现上使用的是连续的内存空间,可以借助 CPU 的缓存机制1,预读数组中的数据,所以访问效率更高。而链表在内存中并不是连续存储,所以对 CPU 缓存不友好,没办法有效预读。

  • 数组的缺点是大小固定,一经声明就要占用整块连续内存空间。如果声明的数组过大,系统可能没有足够的连续内存空间分配给它,导致“内存不足(out of memory)”。如果声明的数组过小,则可能出现不够用的情况。这时只能再申请一个更大的内存空间,把原数组拷贝进去,非常费时。链表本身没有大小的限制,天然地支持动态扩容,我觉得这也是它与数组最大的区别。
  • 除此之外,如果你的代码对内存的使用非常苛刻,那数组就更适合你。因为链表中的每个结点都需要消耗额外的存储空间去存储一份指向下一个结点的指针,所以内存消耗会翻倍。而且,对链表进行频繁的插入、删除操作,还会导致频繁的内存申请和释放,容易造成内存碎片,如果是 Java 语言,就有可能会导致频繁的 GC(Garbage Collection,垃圾回收)。

Tree(树)

为我们展示了数据分而治之的形态(子树的治理?)

二叉树(binary tree)

「二叉树 binary tree」是一种非线性数据结构,代表“祖先”与“后代”之间的派生关系,体现了“一分为二”的分治逻辑.

常见术语

  • 「根节点 root node」:位于二叉树顶层的节点,没有父节点。
  • 「叶节点 leaf node」:没有子节点的节点,其两个指针均指向 None
  • 「边 edge」:连接两个节点的线段,即节点引用(指针)。
  • 节点所在的「层 level」:从顶至底递增,根节点所在层为 1 。
  • 节点的「度 degree」:节点的子节点的数量。在二叉树中,度的取值范围是 0、1、2 。
  • 二叉树的「高度 height」:从根节点到最远叶节点所经过的边的数量。
  • 节点的「深度 depth」:从根节点到该节点所经过的边的数量。
  • 节点的「高度 height」:从距离该节点最远的叶节点到该节点所经过的边的数量。

常见二叉树

  • 完美二叉树
  • 完全二叉树
  • 完满二叉树:所有节点的度都为 0 或 2
  • 平衡二叉树:「平衡二叉树 balanced binary tree」中任意节点的左子树和右子树的高度之差的绝对值不超过 1

二叉树的退化

当二叉树的每层节点都被填满时,达到“完美二叉树”;而当所有节点都偏向一侧时,二叉树退化为“链表”。

  • 完美二叉树是理想情况,可以充分发挥二叉树“分治”的优势。
  • 链表则是另一个极端,各项操作都变为线性操作,时间复杂度退化至 �(�) 。

二叉树的最佳结构与最差结构

用数组表示二叉树

  • 完全二叉树:值得说明的是,完全二叉树非常适合使用数组来表示

    完全二叉树非常适合用数组表示。

  • 其他类型的二叉树:只需要把缺口补 null 就能保持其树的完整性

优缺点

二叉树的数组表示主要有以下优点。

  • 数组存储在连续的内存空间中,对缓存友好,访问与遍历速度较快。
  • 不需要存储指针,比较节省空间。
  • 允许随机访问节点。

然而,数组表示也存在一些局限性。

  • 数组存储需要连续内存空间,因此不适合存储数据量过大的树。
  • 增删节点需要通过数组插入与删除操作实现,效率较低。
  • 当二叉树中存在大量 None 时,数组中包含的节点数据比重较低,空间利用率较低。

Heap

完全二叉树

堆作为完全二叉树的一个特例,具有以下特性。

  • 最底层节点靠左填充,其他层的节点都被填满。
  • 我们将二叉树的根节点称为“堆顶”,将底层最靠右的节点称为“堆底”。==完全二叉树非常适合用数组来表示,堆顶和堆底对应数组的第一个和最后一个元素==
  • 对于大顶堆(小顶堆),堆顶元素(根节点)的值是最大(最小)的。

桶结构

https://blog.csdn.net/aiscong/article/details/130883118

bucket

桶结构是计算机科学中常见的一种数据结构,主要用来存储元素数量不确定的数据集合。 数据被分配到一个或多个桶中,每个桶通常具有相同的容量和大小。 桶结构可以被视为一种哈希表的变体,它通过将数据映射到桶的索引来快速访问和操作数据,有效提高数据的访问效率和处理速度。

图(graph、G)

如果将顶点看作节点,将边看作连接各个节点的引用(指针),我们就可以将图看作一种从链表拓展而来的数据结构。如图所示,相较于线性关系(链表)和分治关系(树),网络关系(图)的自由度更高,因而更为复杂。

  • 一定要想清楚,从链表到树再到图是递进演化的关系!!!

链表、树、图之间的关系

图常见类型与术语

无向图和有向图

根据边是否具有方向,可分为「无向图 undirected graph」和「有向图 directed graph」,如图 9-2 所示。

  • 在无向图中,边表示两顶点之间的“双向”连接关系,例如微信或 QQ 中的“好友关系”。
  • 在有向图中,边具有方向性,即 �→� 和 �←� 两个方向的边是相互独立的,例如微博或抖音上的“关注”与“被关注”关系。

有向图与无向图

连通图和非连通图

根据所有顶点是否连通,可分为「连通图 connected graph」和「非连通图 disconnected graph」,如图 9-3 所示。

  • 对于连通图,从某个顶点出发,可以到达其余任意顶点。
  • 对于非连通图,从某个顶点出发,至少有一个顶点无法到达。

连通图与非连通图

有权图

我们还可以为边添加“权重”变量,从而得到如图 9-4 所示的「有权图 weighted graph」。例如在《王者荣耀》等手游中,系统会根据共同游戏时间来计算玩家之间的“亲密度”,这种亲密度网络就可以用有权图来表示。

  • 一般图所有边等价;有权图的边具有权重属性。

有权图与无权图

术语

  • 「邻接 adjacency」:当两顶点之间存在边相连时,称这两顶点“邻接”。
  • 「路径 path」:从顶点 A 到顶点 B 经过的边构成的序列被称为从 A 到 B 的“路径”。路径可能是多条的。
  • 度(degree):一个顶点拥有的边数。对于有向图,「入度 in-degree」表示有多少条边指向该顶点,「出度 out-degree」表示有多少条边从该顶点指出。

图的表示

图的常用表示方式包括“邻接矩阵”和“邻接表”。

==注意:图的任何操作都需要考虑顶点和边的操作!!!==因此,在计算时间复杂度的时候要考虑顶点和边的操作!

邻接矩阵

一个二维数组表示,每一个元素都是一行数组!

邻接表

  • 为了方便添加与删除顶点,以及简化代码,我们使用列表(动态数组)来代替链表。
  • 使用哈希表来存储邻接表,key 为顶点实例,value 为该顶点的邻接顶点列表(链表)。

图的遍历

树代表的是“一对多”的关系,而图则具有更高的自由度,可以表示任意的“多对多”关系。因此,我们可以把树看作图的一种特例。显然,树的遍历操作也是图的遍历操作的一种特例

图和树都需要应用搜索算法来实现遍历操作。图的遍历方式也可分为两种:「广度优先遍历」和「深度优先遍历」。

  • 和树一样,都需要应用搜索算法来实现遍历操作。

深度优先遍历

深度优先遍历是一种优先走到底、无路可走再回头的遍历方式。树和图都是一样的,走到底然后再回头走,一般都需要使用递归实现!

这种“走到尽头再返回”的算法范式通常基于递归来实现。

algorithm

搜索

二分查找

二分查找插入点

总的来看,二分查找无非就是给指针 � 和 � 分别设定搜索目标,目标可能是一个具体的元素(例如 target ),也可能是一个元素范围(例如小于 target 的元素)。

在不断的循环二分中,指针 � 和 � 都逐渐逼近预先设定的目标。最终,它们或是成功找到答案,或是越过边界后停止。

选择合适的搜索算法

  • 实际中,我们需要对数据体量、搜索性能要求、数据查询和更新频率等因素进行具体分析,从而选择合适的搜索方法。

  • 线性搜索适用于小型或频繁更新的数据;二分查找适用于大型、排序的数据;哈希查找适用于对查询效率要求较高且无须范围查询的数据;树查找适用于需要维护顺序和支持范围查询的大型动态数据。

分治

「分治 divide and conquer」,全称分而治之,是一种非常重要且常见的算法策略。==分治通常基于递归实现==,包括“分”和“治”两个步骤。

  1. 分(划分阶段):递归地将原问题分解为两个或多个子问题,直至到达最小子问题时终止。
  2. 治(合并阶段):从已知解的最小子问题开始,从底至顶地将子问题的解进行合并,从而构建出原问题的解。

分而治之分为 分 和 治 两阶段,划分阶段 和 治理阶段。

如何判断是分治问题

一个问题是否适合使用分治解决,通常可以参考以下几个判断依据。

  1. 问题可以分解:原问题可以分解成规模更小、类似的子问题,以及能够以相同方式递归地进行划分。
  2. 子问题是独立的:子问题之间没有重叠,互不依赖,可以独立解决。
  3. 子问题的解可以合并:原问题的解通过合并子问题的解得来。
  • 算法一般是递归
  • 套路一般是问题的分解,这里指的是步骤的分解

常见应用-todo

  • 寻找最近点对:该算法首先将点集分成两部分,然后分别找出两部分中的最近点对,最后找出跨越两部分的最近点对。
  • 大整数乘法:例如 Karatsuba 算法,它将大整数乘法分解为几个较小的整数的乘法和加法。
  • 矩阵乘法:例如 Strassen 算法,它将大矩阵乘法分解为多个小矩阵的乘法和加法。
  • 汉诺塔问题:汉诺塔问题可以通过递归解决,这是典型的分治策略应用。
  • 求解逆序对:在一个序列中,如果前面的数字大于后面的数字,那么这两个数字构成一个逆序对。求解逆序对问题可以利用分治的思想,借助归并排序进行求解。

分治搜索策略

二分查找的分治策略如下所示,因此二分查找也能够通过分治来实现。

  • 问题可以分解:二分查找递归地将原问题(在数组中进行查找)分解为子问题(在数组的一半中进行查找),这是通过比较中间元素和目标元素来实现的。
  • 子问题是独立的:在二分查找中,每轮只处理一个子问题,它不受其他子问题的影响。
  • 子问题的解无须合并:二分查找旨在查找一个特定元素,因此不需要将子问题的解进行合并。当子问题得到解决时,原问题也会同时得到解决。

分治能够提升搜索效率,本质上是因为暴力搜索每轮只能排除一个选项,而分治搜索每轮可以排除一半选项

==子问题是独立的,子问题的解无需合并==

  • 很多树的问题都可以通过分治解决,树天然就是一个分治的数据结构。

总结

  • 分治既可以解决许多算法问题,也广泛应用于数据结构与算法设计中,处处可见其身影。

回溯算法

「回溯算法 backtracking algorithm」是一种通过穷举来解决问题的方法,它的核心思想是从一个初始状态出发,暴力搜索所有可能的解决方案,当遇到正确的解则将其记录,直到找到解或者尝试了所有可能的选择都无法找到解为止。

  • 暴力搜索所有可能的方案,直接无法找到为之,看起来一般都是那种找到所有的可能的场景。

回溯算法通常采用“深度优先搜索”来遍历解空间。在“二叉树”章节中,我们提到前序、中序和后序遍历都属于深度优先搜索。接下来,我们利用前序遍历构造一个回溯问题,逐步了解回溯算法的工作原理。

  • 深度优先遍历

决策树模型

适合用回溯解决的问题通常满足“决策树模型”,这种问题可以使用树形结构来描述,其中每一个节点代表一个决策,每一条路径代表一个决策序列。

  • 也就是每一个选择就是一个树的子节点

尝试与回退

之所以称之为回溯算法,是因为该算法在搜索解空间时会采用“尝试”与“回退”的策略。当算法在搜索过程中遇到某个状态无法继续前进或无法得到满足条件的解时,它会撤销上一步的选择,退回到之前的状态,并尝试其他可能的选择。

  • 这个也是回溯算法的核心,在函数执行完成之前需要回退。
  • 在回溯的过程中,我们并不需要手机递归的返回值,而是需要使用一个传入的容器进行收集。

剪枝

复杂的回溯问题通常包含一个或多个约束条件,约束条件通常可用于“剪枝”

  • 比如说在递归的过程中,包含其他约束条件,需要在符合约束条件的情况下提前返回,避免继续递归下去浪费时间。

“剪枝”是一个非常形象的名词。如图所示,在搜索过程中,我们“剪掉”了不满足约束条件的搜索分支,避免许多无意义的尝试,从而提高了搜索效率。

根据约束条件剪枝

框架代码

/* 回溯算法框架 */
function backtrack(state: State, choices: Choice[], res: State[]): void {
    // 判断是否为解
    if (isSolution(state)) {
        // 记录解
        recordSolution(state, res);
        // 不再继续搜索
        return;
    }
    // 遍历所有选择
    for (let choice of choices) {
        // 剪枝:判断选择是否合法
        if (isValid(state, choice)) {
          	// 符合之后再进行尝试!!!
            // 尝试:做出选择,更新状态
            makeChoice(state, choice);
          	// 每次都要缩小选择范围!
            backtrack(state, choices, res);
            // 回退:撤销选择,恢复到之前的状态
            undoChoice(state, choice);
        }
    }
}

优点和局限性

  • 优点:就是改方案的通用性

  • 缺点,效率偏低

    然而,在处理大规模或者复杂问题时,回溯算法的运行效率可能难以接受

    • 时间:回溯算法通常需要遍历状态空间的所有可能,时间复杂度可以达到指数阶或阶乘阶。
    • 空间:在递归调用中需要保存当前的状态(例如路径、用于剪枝的辅助变量等),当深度很大时,空间需求可能会变得很大。

回溯的经典例题 —— todo

回溯算法可用于解决许多搜索问题、约束满足问题和组合优化问题。

  • 全排列-重复元素处理 todo

搜索问题:这类问题的目标是找到满足特定条件的解决方案。

  • 全排列问题:给定一个集合,求出其所有可能的排列组合。
  • 子集和问题:给定一个集合和一个目标和,找到集合中所有和为目标和的子集。
  • 汉诺塔问题:给定三根柱子和一系列大小不同的圆盘,要求将所有圆盘从一根柱子移动到另一根柱子,每次只能移动一个圆盘,且不能将大圆盘放在小圆盘上。

约束满足问题:这类问题的目标是找到满足所有约束条件的解。

  • � 皇后:在 �×� 的棋盘上放置 � 个皇后,使得它们互不攻击。
  • 数独:在 9×9 的网格中填入数字 1 ~ 9 ,使得每行、每列和每个 3×3 子网格中的数字不重复。
  • 图着色问题:给定一个无向图,用最少的颜色给图的每个顶点着色,使得相邻顶点颜色不同。

组合优化问题:这类问题的目标是在一个组合空间中找到满足某些条件的最优解。

  • 0-1 背包问题:给定一组物品和一个背包,每个物品有一定的价值和重量,要求在背包容量限制内,选择物品使得总价值最大。
  • 旅行商问题:在一个图中,从一个点出发,访问所有其他点恰好一次后返回起点,求最短路径。
  • 最大团问题:给定一个无向图,找到最大的完全子图,即子图中的任意两个顶点之间都有边相连。

请注意,对于许多组合优化问题,回溯不是最优解决方案。

  • 0-1 背包问题通常使用动态规划解决,以达到更高的时间效率。
  • 旅行商是一个著名的 NP-Hard 问题,常用解法有遗传算法和蚁群算法等。
  • 最大团问题是图论中的一个经典问题,可用贪心算法等启发式算法来解决。

优化效率

回溯算法效率并不高。

即便如此,回溯算法仍然是某些搜索问题和约束满足问题的最佳解决方案。对于这些问题,由于无法预测哪些选择可生成有效的解,因此我们必须对所有可能的选择进行遍历。在这种情况下,关键是如何优化效率,常见的效率优化方法有两种。

  • 剪枝:避免搜索那些肯定不会产生解的路径,从而节省时间和空间。
  • 启发式搜索:在搜索过程中引入一些策略或者估计值,从而优先搜索最有可能产生有效解的路径。
  • 启发式搜索 todo,将在后面章节看到。

动态规划

「动态规划 dynamic programming」是一个重要的算法范式,它将一个问题分解为一系列更小的子问题,并通过存储子问题的解来避免重复计算,从而大幅提升时间效率。

  • 一个重要的特征就是原问题的解可以由子问题的解构建而得。

  • 动态规划是一种“从底至顶”的方法:从最小子问题的解开始,迭代地构建更大子问题的解,直至得到原问题的解。

    从底到顶的过程,从最小的子问题的解开始,通过迭代构建出更大子问题的解,直至得到原问题的解。

技巧

  • 由于动态规划不包含回溯过程,因此只需使用循环迭代实现,无须使用递归。

==注意:动态规划本身是存在递归暴力解法和迭代解法的!!!一定要兼容的想到解法。==

术语

  • 将数组 dp 称为「dp 表」,dp[i] 表示状态 i 对应子问题的解。
  • 将最小子问题对应的状态(第 1 阶和第 2 阶楼梯)称为「初始状态」。
  • 将递推公式 dp[i]=dp[i−1]+do[i−2] 称为「状态转移方程」。

DP 问题特性

  • 分治算法递归地将原问题划分为多个相互独立的子问题,直至最小子问题,并在回溯中合并子问题的解,最终得到原问题的解。
  • 动态规划也对问题进行递归分解,但与分治算法的主要区别是,动态规划中的子问题是相互依赖的,在分解过程中会出现许多重叠子问题。
  • 回溯算法在尝试和回退中穷举所有可能的解,并通过剪枝避免不必要的搜索分支。原问题的解由一系列决策步骤构成,我们可以将每个决策步骤之前的子序列看作一个子问题。
  • 与分治算法的主要区别:动态规划中的子问题是相互依赖的,在分解过程中会出现许多重叠子问题。

    个人觉得动态规划是从底到顶的方法,这个和分治那种从顶到底的方式还是有区别的。

适用

实际上,动态规划常用来求解最优化问题,它们不仅包含重叠子问题,还具有另外两大特性:最优子结构、无后效性。

  • 常用来求解最优化问题

最优子结构

这便可以引出最优子结构的含义:原问题的最优解是从子问题的最优解构建得来的

  • 最优解是从子问题的最优解构建而来的

  • 最优子结构的解释方式比较灵活,在不同问题中会有不同的含义。

    只可意会!

无后效性

无后效性是动态规划能够有效解决问题的重要特性之一,其定义为:给定一个确定的状态,它的未来发展只与当前状态有关,而与过去经历的所有状态无关

  • 也就是说如果解决一个 i 问题,可能会发展出状态 i + 1 或者 i + 2,无需要考虑 i 之前的状态,他们对 i 的未来没有影响,也就意味着状态转移方程不能出现其他非 i 状态。

问题判断-区分动态规划和回溯问题

总的来说,如果一个问题包含重叠子问题、最优子结构,并满足无后效性,那么它通常适合用动态规划求解。

然而,我们很难从问题描述中直接提取出这些特性。因此我们通常会放宽条件,先观察问题是否适合使用回溯(穷举)解决

适合用回溯解决的问题通常满足“决策树模型”,这种问题可以使用树形结构来描述,其中每一个节点代表一个决策,每一条路径代表一个决策序列。

  • 先判断是否符合回溯算法 -> 是否满足决策树模型

换句话说,如果问题包含明确的决策概念,并且解是通过一系列决策产生的,那么它就满足决策树模型,通常可以使用回溯来解决。

判断加分项:(佐助我们判断动态规划问题)

  • 最优结构:相比回溯问题,包含最大或最多等优化描述;
  • 问题状态能使用一个列表、多为矩阵或树来表示,并且一个状态与其周围的状态存在递推关系,也就是能够写出状态方程。
    • 能描述出改状态
    • 能写出状态转移方程

减分项:

  • 问题的目标是找出所有可能的解决方案,而不是找出最优解。

    找到所有的可能,而不是最优解。

  • 问题描述中有明显的排列组合的特征,需要返回具体的多个方案。

问题求解步骤

动态规划的解题流程会因问题的性质和难度而有所不同,但通常遵循以下步骤:描述决策,定义状态,建立 dp 表,推导状态转移方程,确定边界条件等。

  • 描述决策:也就是从回溯思想推到出来的,应该怎么去进行尝试(如何进行下一步),其实和回溯是差不多的,无非就是限制较多
  • 定义状态:这个很重要,最好能把该阶段所有的状态记录下来(因为无后效性影响),可用数组、二维数组或者树来记录。
  • 状态转移方程:这个也是重点,动态规划的流程就是从底到顶,这个流程就是流向
  • 确定边界条件:这个也是难度重点!

==代码参考:knapsack.ts!!!==

暴力解法——非常重要!

  • 不考虑时间的前提下,所有动态规划问题都可以用回溯(暴力搜索)进行求解,但递归树中存在大量的重叠子问题,效率极低。通过引入记忆化列表,可以存储所有计算过的子问题的解,从而保证重叠子问题只被计算一次。

    这里的回溯算法仅仅是因为它使用了深度优先遍历,而不是广义的回溯算法范式,直接套用上面的范式可能不是很好理解。

这个也是动态规划的基础写法,直觉写法。

  • ==一般情况下,只要能写出状态转移方程的动态规划问题,都能够通过从顶向下(DFS)的暴力解法搞定。==

迭代法

  1. 找到状态定义,定义 dp 表

    状态定义大概率是一些变量,主要是看哪些变量需要被记录,目前来看一般情况下都是二维数组,不行就硬套!

    变量确定后,变量保存的状态很有可能是最优值(或最优结构),比如定义一个状态 dp[i,c] 就是最优值。

  2. 根据最优子结构,推导出状态转移方程

    这个选择有可能是一个非常简单的选择,就算是一个放入和不放入的选择也算是一个决策过程,符合决策树模型。

    存储状态的dp表设计

  3. 第三步:确定边界条件和状态转移顺序

注意事项

贪心

向日葵朝着太阳转动,时刻追求自身成长的最大可能。

贪心策略在一轮轮的简单选择中,逐步导向最佳答案。

特性——找到符合贪心的问题

相较于动态规划,贪心算法的使用条件更加苛刻,其主要关注问题的两个性质。

  • 贪心选择性质:只有当局部最优选择始终可以导致全局最优解时,贪心算法才能保证得到最优解。

    ​ 这个也是最难验证的,可能需要数学归纳法或者反证法来进行证明。

  • 最优子结构:原问题的最优解包含子问题的最优解。

贪心存在的问题

  • 一般情况下,都要求数据有效性,方便进行局部最优解的计算

经典例题-todo

  • 硬币找零问题:在某些硬币组合下,贪心算法总是可以得到最优解。
  • 区间调度问题:假设你有一些任务,每个任务在一段时间内进行,你的目标是完成尽可能多的任务。如果每次都选择结束时间最早的任务,那么贪心算法就可以得到最优解。
  • 分数背包问题:给定一组物品和一个载重量,你的目标是选择一组物品,使得总重量不超过载重量,且总价值最大。如果每次都选择性价比最高(价值 / 重量)的物品,那么贪心算法在一些情况下可以得到最优解。
  • 股票买卖问题:给定一组股票的历史价格,你可以进行多次买卖,但如果你已经持有股票,那么在卖出之前不能再买,目标是获取最大利润。
  • 霍夫曼编码:霍夫曼编码是一种用于无损数据压缩的贪心算法。通过构建霍夫曼树,每次选择出现频率最低的两个节点合并,最后得到的霍夫曼树的带权路径长度(编码长度)最小。
  • Dijkstra 算法:它是一种解决给定源顶点到其余各顶点的最短路径问题的贪心算法。

解题步骤

  • 求解贪心问题主要分为三步:问题分析、确定贪心策略、正确性证明。其中,确定贪心策略是核心步骤,正确性证明往往是难点。

algorithm skill summary

1. 快慢指针

  • 需要找出一个线性数据结构的中点的时候,可以使用快慢指针,一个指针一次走一格,一个指针一次走两格。

2. 哨兵

  • 如果每次遍历都需要两个判断条件,可以将其中一个==边界条件==换成一个哨兵,(相当于简化判断条件,因为判断条件也跟随着循环执行。)哨兵一般就是用来执行边界问题的。
// 在数组 a 中,查找 key,返回 key 所在的位置
// 其中,n 表示数组 a 的长度
int find(char* a, int n, char key) {
  // 边界条件处理,如果 a 为空,或者 n<=0,说明数组中没有数据,就不用 while 循环比较了
  if(a == null || n <= 0) {
    return -1;
  }
  
  int i = 0;
  // 这里有两个比较操作:i<n 和 a[i]==key.
  while (i < n) {
    if (a[i] == key) {
      return i;
    }
    ++i;
  }
  
  return -1;
}
// 在数组 a 中,查找 key,返回 key 所在的位置
// 其中,n 表示数组 a 的长度
// 我举 2 个例子,你可以拿例子走一下代码
// a = {4, 2, 3, 5, 9, 6}  n=6 key = 7
// a = {4, 2, 3, 5, 9, 6}  n=6 key = 6
int find(char* a, int n, char key) {
  if(a == null || n <= 0) {
    return -1;
  }
  
  // 这里因为要将 a[n-1] 的值替换成 key,所以要特殊处理这个值
  if (a[n-1] == key) {
    return n-1;
  }
  
  // 把 a[n-1] 的值临时保存在变量 tmp 中,以便之后恢复。tmp=6。
  // 之所以这样做的目的是:希望 find() 代码不要改变 a 数组中的内容
  char tmp = a[n-1];
  // 把 key 的值放到 a[n-1] 中,此时 a = {4, 2, 3, 5, 9, 7}
  a[n-1] = key;
  
  int i = 0;
  // while 循环比起代码一,少了 i<n 这个比较操作
  while (a[i] != key) {
    ++i;
  }
  
  // 恢复 a[n-1] 原来的值, 此时 a= {4, 2, 3, 5, 9, 6}
  a[n-1] = tmp;
  
  if (i == n-1) {
    // 如果 i == n-1 说明,在 0...n-2 之间都没有 key,所以返回 -1
    return -1;
  } else {
    // 否则,返回 i,就是等于 key 值的元素的下标
    return i;
  }
}
  • 上边例子中的 i < n 条件不跟随循环去执行,节省了执行时间。

Footnotes

  1. CPU在从内存读取数据的时候,会先把读取到的数据加载到CPU的缓存中。而CPU每次从内存读取数据并不是只读取那个特定要访问的地址,而是==读取一个数据块==(这个大小我不太确定。。)并保存到CPU缓存中,然后下次访问内存数据的时候就会先从CPU缓存开始查找,如果找到就不需要再从内存中取。这样就实现了比内存访问速度更快的机制,也就是CPU缓存存在的意义:为了弥补内存访问速度过慢与CPU执行速度快之间的差异而引入。 对于数组来说,存储空间是连续的,所以在加载某个下标的时候可以把以后的几个下标元素也加载到CPU缓存这样执行速度会快于存储空间不连续的链表存储。