大家好!在这篇文章中,我们要看一下算法,这是涉及到计算机科学和软件开发的一个关键话题。
算法是一个花哨的,有时令人生畏的,而且经常被误解的词。它听起来像是非常困难和复杂的东西,但实际上它不过是为了实现某个目标而必须采取的一系列步骤。
我想说的是,关于算法的基本知识主要包括两件事。
- 渐进式符号(我们用它来比较一种算法与另一种算法的性能)。
- 用于非常频繁的任务的经典算法的一般知识,如搜索、排序和遍历。
因此,这正是我们在这里要看到的。😉
我们走吧!
什么是算法?
如前所述,算法只是为了实现某个目标而需要采取的一系列步骤。
我发现,当人们第一次听到算法这个词时,他们会想象出这样的场景......
黑客帝国》或《机器人先生》中的一个场景
但实际上这种画面会更准确...
一本菜谱
一个算法就像一个食谱,在这个意义上,它将指出为了实现你的目标而需要遵循的必要步骤。
一个制作面包的食谱可以是。
1- Mix flower, salt, water and yeast
2- Let the dough rise
3- Put in the oven for 30'
4- Let chill and enjoy
侧面评论。我希望你能欣赏我同时教你如何编码和烹饪,而且是免费的。😜
识别一个词是否是重名的算法可以是。
function isPalindrome(word) {
// Step 1- Put a pointer at each extreme of the word
// Step 2 - Iterate the string "inwards"
// Step 3 - At each iteration, check if the pointers represent equal values
// If this condition isn't accomplished, the word isn't a palindrome
let left = 0
let right = word.length-1
while (left < right) {
if (word[left] !== word[right]) return false
left++
right--
}
return true
}
isPalindrome("neuquen") // true
isPalindrome("Buenos Aires") // false
和菜谱一样,在这个算法中,我们有具有一定目的的步骤,按照一定的顺序执行,以达到我们想要的结果。
按照维基百科的说法。
算法是一个由明确定义的指令组成的有限序列,通常用于解决一类特定问题或进行计算。
算法的复杂性
现在我们知道了什么是算法,让我们来学习如何将不同的算法相互比较。
假设我们遇到了这个问题。
写一个需要两个参数的函数。一个由不同整数组成的非空数组和一个代表目标和的整数。如果数组中的任何两个数字的总和等于目标总和,该函数应该在数组中返回它们。如果没有两个数字的总和达到目标总和,该函数应该返回一个空数组。
这可能是该问题的有效解决方案。
function twoNumberSum(array, targetSum) {
let result = []
// We use a nested loop to test every possible combination of numbers within the array
for (let i = 0; i < array.length; i++) {
for (let j = i+1; j < array.length; j++) {
// If we find the right combination, we push both values into the result array and return it
if (array[i] + array[j] === targetSum) {
result.push(array[i])
result.push(array[j])
return result
}
}
}
// Return the result array
return result
}
console.log(twoNumberSum([9,1,3,4,5], 6)) // [1,5]
console.log(twoNumberSum([1,2,3,4,5], 10)) // []
这可能是另一个有效的解决方案。
function twoNumberSum(array, targetSum) {
// Sort the array and iterate it with one pointer at each extreme
// At each iteration, check if the sum of the two pointers is bigger or smaller than the target
// If it's bigger, move the right pointer to the left
// If it's smaller, move the left pointer to the right
let sortedArray = array.sort((a,b) => a-b)
let leftLimit = 0
let rightLimit = sortedArray.length-1
while (leftLimit < rightLimit) {
const currentSum = sortedArray[leftLimit] + sortedArray[rightLimit]
if (currentSum === targetSum) return [sortedArray[leftLimit], sortedArray[rightLimit]]
else currentSum < targetSum ? leftLimit++ : rightLimit--
}
return []
}
console.log(twoNumberSum([9,1,3,4,5], 6)) // [1,5]
console.log(twoNumberSum([1,2,3,4,5], 10)) // []
这也可能是另一个有效的解决方案。
function twoNumberSum(array, targetSum) {
// Iterate over array once, and at each iteration
// check if the number you need to get to ther target exists in the array
// If it exists, return its index and the present number index
let result = []
for (let i = 0; i < array.length; i++) {
let desiredNumber = targetSum - array[i]
if (array.indexOf(desiredNumber) !== -1 && array.indexOf(desiredNumber) !== i) {
result.push(array[i])
result.push(array[array.indexOf(desiredNumber)])
break
}
}
return result
}
console.log(twoNumberSum([9,1,3,4,5], 6)) // [1,5]
console.log(twoNumberSum([1,2,3,4,5], 10)) // []
那么,我们如何比较哪个方案更好呢?他们都完成了自己的目标,对吗?
但除了有效性(目标是否实现),我们还应该从效率方面评估算法,也就是说,哪种算法在时间(处理时间)和空间(内存使用)方面使用最少的资源来解决问题。
在第一次思考这个问题时,一个自动出现的想法是,"只要测量算法运行的时间就可以了"。而这是有效的。
但问题是同样的算法在不同的计算机上可能需要更长或更短的时间,因为它的硬件和配置。甚至在同一台电脑上,考虑到你在那个特定时刻运行的背景任务,它可能需要更长或更短的时间来运行。
我们需要的是一种衡量算法性能的客观和不变的方法,而这正是渐近记号的作用。
渐近记数法(也叫大O记数法)是一个系统,它允许我们分析和比较一个算法在其输入增长时的性能。
大O是一种标准化的方法,用于分析和比较不同算法的复杂性(在运行时间和空间方面)。无论你在哪台电脑上 "计算",算法的大O复杂度都是一样的,因为复杂度是根据输入变化时算法的操作数如何变化来计算的,而且无论在什么环境下,这种关系总是保持不变的。
一个算法可以有很多不同的复杂度,但最常见的有以下几种。
- 常数--O(1)。当所需的操作/空间的数量总是相同的,与输入无关。以一个函数为例,它接受一个数字作为输入并返回该数字减去10。无论你给它100或1000000的输入,该函数将始终执行一个单一的操作(剩下10),所以复杂度是恒定的O(1)。
- 对数型 - O(log n)。当所需的操作/空间的数量与输入的增长相比,增长速度越来越慢。这种类型的复杂性经常出现在采取分而治之的算法或搜索算法中。典型的例子是二进制搜索,在这种情况下,你要经过的数据集不断地减半,直到达到最终的结果。
- 线性-O(n)。当所需的操作/空间数量以与输入相同的速度增长时。以一个打印数组中每一个值的循环为例。操作的数量会随着数组的长度而增长,所以复杂度是线性的O(n)。
- 二次方--O(n²)。当所需的操作/空间的数量与输入的数量成2次方增长时。嵌套循环是这一问题的典型例子。想象一下,我们有一个遍历数字数组的循环,而在这个循环中,我们有另一个循环,再次遍历整个数组。对于数组中的每一个值,我们都要在数组上迭代两次,所以复杂度是四次方的O(n²)。
经典算法复杂度的图形表示
请注意,在谈论时间和空间复杂性时,使用的是相同的符号。例如,我们有一个函数,无论它收到什么输入,都会用一个单一的值创建一个数组,那么空间复杂度将是常数O(1),其他复杂度类型也是如此。
为了更好地理解这一切,让我们回到我们的问题上,分析我们的解决方案的例子。
例1:
function twoNumberSum(array, targetSum) {
let result = []
// We use a nested loop to test every possible combination of numbers within the array
for (let i = 0; i < array.length; i++) {
for (let j = i+1; j < array.length; j++) {
// If we find the right combination, we push both values into the result array and return it
if (array[i] + array[j] === targetSum) {
result.push(array[i])
result.push(array[j])
return result
}
}
}
// Return the result array
return result
}
console.log(twoNumberSum([9,1,3,4,5], 6)) // [1,5]
console.log(twoNumberSum([1,2,3,4,5], 10)) // []
在这个例子中,我们对参数数组进行迭代,对于数组中的每一个值,我们都要对整个数组再次进行迭代,寻找一个与目标总和相加的数字。
每次迭代都算作一个任务。
- 如果我们在数组中有3个数字,我们将为每个数字迭代3次,再迭代9次(3次数组中的3个数字),共12个任务。
- 如果我们在数组中有4个数字,我们将为每个数字迭代4次,再迭代16次(数组中4个数字的4次),总共20个任务。
- 如果我们在数组中有5个数字,我们将为每个数字迭代5次,再迭代25次(数组中5个数字的5倍),共25项任务。
你可以看到这个算法中的任务数是如何以指数形式增长的,而且与输入不成比例。这个算法的复杂性是二次方的--O(n²)。
每当我们看到嵌套循环时,我们应该想到二次复杂度 => BAD => 可能有更好的方法来解决这个问题。
例2:
function twoNumberSum(array, targetSum) {
// Sort the array and iterate it with one pointer at each extreme
// At each iteration, check if the sum of the two pointers is bigger or smaller than the target
// If it's bigger, move the right pointer to the left
// If it's smaller, move the left pointer to the right
let sortedArray = array.sort((a,b) => a-b)
let leftLimit = 0
let rightLimit = sortedArray.length-1
while (leftLimit < rightLimit) {
const currentSum = sortedArray[leftLimit] + sortedArray[rightLimit]
if (currentSum === targetSum) return [sortedArray[leftLimit], sortedArray[rightLimit]]
else currentSum < targetSum ? leftLimit++ : rightLimit--
}
return []
}
console.log(twoNumberSum([9,1,3,4,5], 6)) // [1,5]
console.log(twoNumberSum([1,2,3,4,5], 10)) // []
这里我们在迭代之前对算法进行了排序。然后我们只迭代一次,在数组的每个极端使用一个指针,然后 "向内 "迭代。
这比之前的方案要好,因为我们只迭代了一次。但是我们仍然在对数组进行排序(这通常具有对数的复杂性),然后再迭代一次(这是线性的复杂性)。这个方案的算法复杂度是O(n log(n))。
例3:
function twoNumberSum(array, targetSum) {
// Iterate over array once, and at each iteration
// check if the number you need to get to ther target exists in the array
// If it exists, return its index and the present number index
let result = []
for (let i = 0; i < array.length; i++) {
let desiredNumber = targetSum - array[i]
if (array.indexOf(desiredNumber) !== -1 && array.indexOf(desiredNumber) !== i) {
result.push(array[i])
result.push(array[array.indexOf(desiredNumber)])
break
}
}
return result
}
console.log(twoNumberSum([9,1,3,4,5], 6)) // [1,5]
console.log(twoNumberSum([1,2,3,4,5], 10)) // []
在这最后一个例子中,我们只对数组进行一次迭代,之前没有做任何其他事情。这是最好的解决方案,因为我们要进行最少的操作。这种情况下的复杂性是线性的--O(n)。
这确实是算法背后最重要的概念。能够比较不同的实现方式,了解哪种方式更有效,以及为什么要这样做,这确实是一个重要的知识。所以,如果这个概念对你来说还不清楚,我鼓励你再读一遍例子,寻找其他资源,或者看看这个很棒的freeCodeCamp视频课程。
搜索算法
一旦你对算法复杂性有了很好的理解,接下来要知道的是用于解决非常常见的编程任务的流行算法。所以让我们从搜索开始。
当在一个数据结构中搜索一个值时,我们可以采取不同的方法。我们将看一下两个最常用的选项,并对它们进行比较。
线性搜索
线性搜索包括每次在数据结构上迭代一个值,并检查该值是否是我们要找的那个值。这可能是最直观的一种搜索方式,如果我们使用的数据结构不是有序的,这是我们能做的最好的搜索方式。
假设我们有一个数字数组,对于这个数组,我们想写一个函数,将一个数字作为输入,并返回该数字在数组中的索引。如果它在数组中不存在,它将返回-1。一个可能的方法是这样的。
const arr = [1,2,3,4,5,6,7,8,9,10]
const search = num => {
for (let i = 0; i < arr.length; i++) {
if (num === arr[i]) return i
}
return -1
}
console.log(search(6)) // 5
console.log(search(11)) // -1
由于数组不是有序的,我们没有办法知道每个值的大致位置,所以我们能做的就是一次检查一个值。这种算法的复杂性是线性的--O(n),因为在最坏的情况下,我们将不得不在整个数组上迭代一次以获得我们要找的值。
线性搜索是许多内置的JavaScript方法所使用的方法,如indexOf,includes, 和findIndex 。
二进制搜索
当我们有一个有序的数据结构时,我们可以采取一个更有效的方法,二进制搜索。我们在二进制搜索中所做的是以下几点。
- 选择我们数据结构的中间值,然后 "问",这是我们要找的值吗?
- 如果不是,我们就 "问 "我们要找的值是大于还是小于中间的值?
- 如果是大,我们就 "抛弃 "所有小于中间值的值。如果是小的,我们就 "丢弃 "所有大于中间值的值。
- 然后我们重复同样的操作,直到我们找到给定的值或者数据结构的剩余 "部分 "不能再被分割。
二进制搜索的图形表示
二进制搜索的酷之处在于,在每一次迭代中,我们都会丢弃大约一半的数据结构。这使得搜索变得非常快速和高效。👌
假设我们有相同的数组(有序的),我们想写一个和以前一样的函数,它接受一个数字作为输入并返回该数字在数组中的索引。如果它在数组中不存在,它将返回-1。二进制搜索的方法可以是下面这种。
const arr = [1,2,3,4,5,6,7,8,9,10]
const search = num => {
// We'll use three pointers.
// One at the start of the array, one at the end and another at the middle.
let start = 0
let end = arr.length-1
let middle = Math.floor((start+end)/2)
// While we haven't found the number and the start pointer is equal or smaller to the end pointer
while (arr[middle] !== num && start <= end) {
// If the desired number is smaller than the middle, discard the bigger half of the array
if (num < arr[middle]) end = middle - 1
// If the desired number is bigger than the middle, discard the smaller half of the array
else start = middle + 1
// Recalculate the middle value
middle = Math.floor((start+end)/2)
}
// If we've exited the loop it means we've either found the value or the array can't be devided further
return arr[middle] === num ? middle : -1
}
console.log(search(6)) // 5
console.log(search(11)) // -1
这种方法一开始可能看起来是 "更多的代码",但是潜在的迭代实际上比线性搜索要少得多,这是因为在每一次迭代中,我们大约要丢弃一半的数据结构。这种算法的复杂度是对数级的--O(log n)。
排序算法
当对数据结构进行排序时,我们可以采取许多可能的方法。让我们来看看一些最常用的选项,并对它们进行比较。
泡沫排序
泡沫排序在数据结构中进行迭代,每次比较一对数值。如果这些值的顺序不正确,它就交换其位置来纠正它。迭代重复进行,直到数据被排序。这种算法使较大的值 "冒 "到数组的末端。
这个算法有一个二次方--O(n²)的复杂度,因为它将把每个值与其余的值比较一次。
一个可能的实现可以是以下的。
const arr = [3,2,1,4,6,5,7,9,8,10]
const bubbleSort = arr => {
// set a flag variable
let noSwaps
// We will have a nested loop
// with a pointer iterating from right to left
for (let i = arr.length; i > 0; i--) {
noSwaps = true
// and another iterating from right to left
for (let j = 0; j < i-1; j++) {
// We compare the two pointers
if (arr[j] > arr[j+1]) {
let temp = arr[j]
arr[j] = arr[j+1]
arr[j+1] = temp
noSwaps = false
}
}
if (noSwaps) break
}
}
bubbleSort(arr)
console.log(arr) // [1,2,3,4,5,6,7,8,9,10]
选择排序
选择排序类似于冒泡排序,但它不是将较大的值放在数据结构的最后,而是将较小的值放在开头。它的步骤如下。
- 将数据结构的第一项存储为最小值。
- 遍历数据结构,将每个值与最小值进行比较。如果发现一个较小的值,它将这个值确定为新的最小值。
- 如果最小值不是数据结构的第一个值,它将最小值和第一个值的位置互换。
- 它重复这样的迭代,直到数据结构被排序。
这个算法有一个二次方--O(n²)的复杂性。
一个可能的实现可以是以下的。
const arr = [3,2,1,4,6,5,7,9,8,10]
const selectionSort = arr => {
for (let i = 0; i < arr.length; i++) {
let lowest = i
for (let j = i+1; j < arr.length; j++) {
if (arr[j] < arr[lowest]) {
lowest = j
}
}
if (i !== lowest) {
let temp = arr[i]
arr[i] = arr[lowest]
arr[lowest] = temp
}
}
}
selectionSort(arr)
console.log(arr) // [1,2,3,4,5,6,7,8,9,10]
插入式排序
插入排序通过创建一个总是正确排序的 "有序的一半 "来对数据结构进行排序,并在数据结构中迭代挑选每个值并将其准确地插入有序的一半中。
它的步骤如下。
- 它开始挑选数据结构中的第二个元素。
- 它将这个元素与之前的元素进行比较,如果有必要,将其位置调换。
- 它继续挑选下一个元素,如果它不在正确的位置,它就在 "有序的一半 "中反复寻找它的正确位置,并将它插入那里。
- 它重复同样的过程,直到数据结构被排序。
这个算法有一个二次方(O(n²))的复杂度。
一个可能的实现可以是以下方式。
const arr = [3,2,1,4,6,5,7,9,8,10]
const insertionSort = arr => {
let currentVal
for (let i = 0; i < arr.length; i++) {
currentVal = arr[i]
for (var j = i-1; j >= 0 && arr[j] > currentVal; j--) {
arr[j+1] = arr[j]
}
arr[j+1] = currentVal
}
return arr
}
insertionSort(arr)
console.log(arr) // [1,2,3,4,5,6,7,8,9,10]
冒泡排序、选择排序和插入排序的问题在于,这些算法的规模并不大。
当我们处理大数据集的时候,有更好的选择,我们可以选择。其中一些是合并排序、快速排序和径向排序。所以,现在让我们来看看这些算法吧!
合并排序
合并排序是一种递归地将数据结构分解为单个值的算法,然后以排序的方式再次进行组合。
它的步骤如下。
- 递归地将数据结构分解成两半,直到每个 "片 "只有一个值。
- 然后,以排序的方式递归合并这些碎片,直到它回到原始数据结构的长度。
这个算法的复杂度为O(n log n),因为它的分解部分的复杂度为log n,而它的比较部分的复杂度为n。
一个可能的实现可以是以下的。
const arr = [3,2,1,4,6,5,7,9,8,10]
// Merge function
const merge = (arr1, arr2) => {
const results = []
let i = 0
let j = 0
while (i < arr1.length && j < arr2.length) {
if (arr2[j] > arr1[i]) {
results.push(arr1[i])
i++
} else {
results.push(arr2[j])
j++
}
}
while (i < arr1.length) {
results.push(arr1[i])
i++
}
while (j < arr2.length) {
results.push(arr2[j])
j++
}
return results
}
const mergeSort = arr => {
if (arr.length <= 1) return arr
let mid = Math.floor(arr.length/2)
let left = mergeSort(arr.slice(0,mid))
let right = mergeSort(arr.slice(mid))
return merge(left, right)
}
console.log(mergeSort(arr)) // [1,2,3,4,5,6,7,8,9,10]
快速排序
快速排序的工作方式是选择一个元素(称为 "支点"),并找到支点在排序数组中的最终索引。
快速排序的运行时间部分取决于如何选择支点。理想情况下,它应该是被排序的数据集的大致中值。
该算法的步骤如下。
- 识别枢轴值,并将其放在它应该在的索引中。
- 在数据结构的每 "一半 "上递归执行相同的过程。
这个算法的复杂度为O(n log n)。
一个可能的实现可以是以下的。
const arr = [3,2,1,4,6,5,7,9,8,10]
const pivot = (arr, start = 0, end = arr.length - 1) => {
const swap = (arr, idx1, idx2) => [arr[idx1], arr[idx2]] = [arr[idx2], arr[idx1]]
let pivot = arr[start]
let swapIdx = start
for (let i = start+1; i <= end; i++) {
if (pivot > arr[i]) {
swapIdx++
swap(arr, swapIdx, i)
}
}
swap(arr, start, swapIdx)
return swapIdx
}
const quickSort = (arr, left = 0, right = arr.length - 1) => {
if (left < right) {
let pivotIndex = pivot(arr, left, right)
quickSort(arr, left, pivotIndex-1)
quickSort(arr, pivotIndex+1, right)
}
return arr
}
console.log(quickSort(arr)) // [1,2,3,4,5,6,7,8,9,10]
拉德克斯排序
Radix是一种算法,其工作方式与之前看到的算法不同,因为它不对数值进行比较。Radix是用来对数字列表进行排序的,为了做到这一点,它利用了这样一个事实:一个数字的大小是由它的数字数决定的(数字越多,数字越大)。
弧度所做的是按照数字的顺序对数值进行排序。它首先按第一个数字对所有数值进行排序,然后再按第二个数字排序,再按第三个数字排序......这个过程重复的次数与列表中最大数字的位数一样多。在这个过程结束时,该算法返回完全排序的列表。
它所采取的步骤如下。
- 计算最大的数字有多少位。
- 循环浏览列表,直到最大的数字。在每一次迭代中。
- 为每个数字(从0到9)创建 "桶",并根据被评估的数字将每个值放入其相应的桶中。
- 用在桶中排序的值替换现有的列表,从0开始,一直到9。
这个算法的复杂度是O(n*k),k是最大数字的位数。考虑到它并不互相比较数值,这个算法的运行时间比之前看到的算法要好,但只对数字列表起作用。
如果我们想要一个与数据无关的排序算法,我们可能会选择前面的任何一种算法。
一个可能的实现可以是以下几种。
const arr = [3,2,1,4,6,5,7,9,8,10]
const getDigit = (num, i) => Math.floor(Math.abs(num) / Math.pow(10, i)) % 10
const digitCount = num => {
if (num === 0) return 1
return Math.floor(Math.log10(Math.abs(num))) + 1
}
const mostDigits = nums => {
let maxDigits = 0
for (let i = 0; i < nums.length; i++) maxDigits = Math.max(maxDigits, digitCount(nums[i]))
return maxDigits
}
const radixSort = nums => {
let maxDigitCount = mostDigits(nums)
for (let k = 0; k < maxDigitCount; k++) {
let digitBuckets = Array.from({ length: 10 }, () => [])
for (let i = 0; i < nums.length; i++) {
let digit = getDigit(nums[i], k)
digitBuckets[digit].push(nums[i])
}
nums = [].concat(...digitBuckets)
}
return nums
}
console.log(radixSort(arr)) // [1,2,3,4,5,6,7,8,9,10]
遍历算法
我们要看的最后一种算法是遍历算法,它被用来遍历那些可以用不同方式遍历的数据结构(主要是树和图)。
当迭代像树这样的数据结构时,我们可以通过两种主要方式来确定迭代的优先次序,即广度或深度。
如果我们优先考虑深度,我们将通过树的每个分支 "下降",从头到每个分支的叶子。
深度第一
如果我们优先考虑广度,我们将水平地通过每一个树的 "层次",在 "下降 "到下一个层次之前迭代所有在同一层次上的节点。
广度优先
我们选择哪一种,主要取决于我们在迭代中寻找什么价值,以及我们的数据结构是如何建立的。
广度优先搜索(BFS)
所以我们先来分析一下BFS。如前所述,这种遍历方式将以 "水平方式 "遍历我们的数据结构。按照这个新的示例图片,这些值将按照以下顺序被遍历:[10, 6, 15, 3, 8, 20] 。
通常情况下,BFS算法所遵循的步骤如下。
- 创建一个队列和一个变量来存储被 "访问 "的节点
- 将根节点放在队列中
- 只要队列里有东西,就一直循环下去
- 从队列中取出一个节点,并将该节点的值推到存储被访问节点的变量中。
- 如果脱队的节点上有一个左属性,就把它添加到队列中。
- 如果去排队的节点上有一个右属性,就把它加到队列中。
一个可能的实现可以是以下的。
class Node {
constructor(value) {
this.value = value
this.left = null
this.right = null
}
}
class BinarySearchTree {
constructor(){ this.root = null; }
insert(value){
let newNode = new Node(value);
if(this.root === null){
this.root = newNode;
return this;
}
let current = this.root;
while(true){
if(value === current.value) return undefined;
if(value < current.value){
if(current.left === null){
current.left = newNode;
return this;
}
current = current.left;
} else {
if(current.right === null){
current.right = newNode;
return this;
}
current = current.right;
}
}
}
BFS(){
let node = this.root,
data = [],
queue = [];
queue.push(node);
while(queue.length){
node = queue.shift();
data.push(node.value);
if(node.left) queue.push(node.left);
if(node.right) queue.push(node.right);
}
return data;
}
}
const tree = new BinarySearchTree()
tree.insert(10)
tree.insert(6)
tree.insert(15)
tree.insert(3)
tree.insert(8)
tree.insert(20)
console.log(tree.BFS()) // [ 10, 6, 15, 3, 8, 20 ]
深度优先搜索(DFS)
DFS将以 "垂直方式 "遍历我们的数据结构。按照我们在BFS中使用的同样的例子,这些值将按以下顺序遍历:[10, 6, 3, 8, 15, 20] 。
这种做DFS的方式被称为 "预排序"。实际上有三种主要的DFS方式,每种方式都不同,只是改变了访问节点的顺序。
- **预先顺序。**先访问当前节点,然后是左边节点,再是右边节点。
- 后顺序。 在访问节点之前,探索左边的所有子节点,以及右边的所有子节点。
- 按顺序。 探索左边的所有孩子,访问当前节点,然后探索右边的所有孩子。
如果这听起来令人困惑,不要担心。它并不复杂,通过几个例子,它将在短时间内变得更加清晰。
前序DFS
在预排序DFS算法中,我们做了以下工作。
- 创建一个变量来存储所访问节点的值
- 将树的根存储在一个变量中
- 写一个辅助函数,接受一个节点作为参数
- 将节点的值推送到存储值的变量中
- 如果节点有一个左边的属性,调用以左边节点为参数的辅助函数
- 如果节点有右属性,则调用以左节点为参数的辅助函数
一个可能的实现可以是如下。
class Node {
constructor(value){
this.value = value;
this.left = null;
this.right = null;
}
}
class BinarySearchTree {
constructor(){
this.root = null;
}
insert(value){
var newNode = new Node(value);
if(this.root === null){
this.root = newNode;
return this;
}
var current = this.root;
while(true){
if(value === current.value) return undefined;
if(value < current.value){
if(current.left === null){
current.left = newNode;
return this;
}
current = current.left;
} else {
if(current.right === null){
current.right = newNode;
return this;
}
current = current.right;
}
}
}
DFSPreOrder(){
var data = [];
function traverse(node){
data.push(node.value);
if(node.left) traverse(node.left);
if(node.right) traverse(node.right);
}
traverse(this.root);
return data;
}
}
var tree = new BinarySearchTree()
tree.insert(10)
tree.insert(6)
tree.insert(15)
tree.insert(3)
tree.insert(8)
tree.insert(20)
console.log(tree.DFSPreOrder()) // [ 10, 6, 3, 8, 15, 20 ]
后序DFS
在后序DFS算法中,我们做了以下工作。
- 创建一个变量来存储被访问节点的值
- 将树的根存储在一个变量中
- 写一个辅助函数,接受一个节点作为参数
- 如果节点有一个左边的属性,就调用以左边节点为参数的辅助函数
- 如果节点有右属性,则调用以左节点为参数的辅助函数
- 调用以当前节点为参数的辅助函数
一个可能的实现可以是下面这样的。
class Node {
constructor(value){
this.value = value;
this.left = null;
this.right = null;
}
}
class BinarySearchTree {
constructor(){
this.root = null;
}
insert(value){
var newNode = new Node(value);
if(this.root === null){
this.root = newNode;
return this;
}
var current = this.root;
while(true){
if(value === current.value) return undefined;
if(value < current.value){
if(current.left === null){
current.left = newNode;
return this;
}
current = current.left;
} else {
if(current.right === null){
current.right = newNode;
return this;
}
current = current.right;
}
}
}
DFSPostOrder(){
var data = [];
function traverse(node){
if(node.left) traverse(node.left);
if(node.right) traverse(node.right);
data.push(node.value);
}
traverse(this.root);
return data;
}
}
var tree = new BinarySearchTree()
tree.insert(10)
tree.insert(6)
tree.insert(15)
tree.insert(3)
tree.insert(8)
tree.insert(20)
console.log(tree.DFSPostOrder()) // [ 3, 8, 6, 20, 15, 10 ]
按顺序DFS
在按顺序排列的DFS算法中,我们要做以下工作。
- 创建一个变量来存储所访问节点的值
- 将树的根存储在一个变量中
- 写一个辅助函数,接受一个节点作为参数
- 如果该节点有一个左边的属性,则以左边节点为参数调用辅助函数
- 将节点的值推送到存储值的变量中
- 如果节点有右边的属性,调用以左边节点为参数的辅助函数
- 调用以当前节点为参数的辅助函数
一个可能的实现可以是下面的。
class Node {
constructor(value){
this.value = value;
this.left = null;
this.right = null;
}
}
class BinarySearchTree {
constructor(){
this.root = null;
}
insert(value){
var newNode = new Node(value);
if(this.root === null){
this.root = newNode;
return this;
}
var current = this.root;
while(true){
if(value === current.value) return undefined;
if(value < current.value){
if(current.left === null){
current.left = newNode;
return this;
}
current = current.left;
} else {
if(current.right === null){
current.right = newNode;
return this;
}
current = current.right;
}
}
}
DFSInOrder(){
var data = [];
function traverse(node){
if(node.left) traverse(node.left);
data.push(node.value);
if(node.right) traverse(node.right);
}
traverse(this.root);
return data;
}
}
var tree = new BinarySearchTree()
tree.insert(10)
tree.insert(6)
tree.insert(15)
tree.insert(3)
tree.insert(8)
tree.insert(20)
console.log(tree.DFSInOrder()) // [ 3, 6, 8, 10, 15, 20 ]
你可能注意到了,前序、后序和依序的实现都非常相似,我们只是改变了节点被访问的顺序。每种实现方式所得到的遍历结果是完全不同的,有时一种方式可能比其他方式更有用。
关于何时使用BFS或DFS,正如我所说,这取决于我们的数据结构是如何组织的。
一般来说,如果我们有一个非常宽的树或图(意味着有很多站在同一水平线上的兄弟姐妹节点),我们应该优先考虑DFS。而如果我们处理的是一棵非常大的树或图,它的分支非常长,我们应该优先考虑BFS。
这两种算法的时间复杂度是一样的,因为我们总是只访问每个节点一次。但是空间复杂度会有所不同,这取决于每种实现方式需要在内存中存储多少个节点。因此,我们需要跟踪的节点越少越好。