算法简介--附JavaScript实例

75 阅读11分钟

大家好!在这篇文章中,我们要看一下算法,这是涉及到计算机科学和软件开发的一个关键话题。

算法是一个花哨的,有时令人生畏的,而且经常被误解的词。它听起来像是非常困难和复杂的东西,但实际上它不过是为了实现某个目标而必须采取的一系列步骤。

我想说的是,关于算法的基本知识主要包括两件事。

  • 渐进式符号(我们用它来比较一种算法与另一种算法的性能)。
  • 用于非常频繁的任务的经典算法的一般知识,如搜索、排序和遍历。

因此,这正是我们在这里要看到的。😉
我们走吧!

什么是算法?

如前所述,算法只是为了实现某个目标而需要采取的一系列步骤。

我发现,当人们第一次听到算法这个词时,他们会想象出这样的场景......

markus-spiske-FXFz-sW0uwo-unsplash

黑客帝国》或《机器人先生》中的一个场景

但实际上这种画面会更准确...

frank-holleman-rN_RMqSXRKw-unsplash

一本菜谱

一个算法就像一个食谱,在这个意义上,它将指出为了实现你的目标而需要遵循的必要步骤。

一个制作面包的食谱可以是。

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²)。

2022-05-16_1232131236

经典算法复杂度的图形表示

请注意,在谈论时间和空间复杂性时,使用的是相同的符号。例如,我们有一个函数,无论它收到什么输入,都会用一个单一的值创建一个数组,那么空间复杂度将是常数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

二进制搜索

当我们有一个有序的数据结构时,我们可以采取一个更有效的方法,二进制搜索。我们在二进制搜索中所做的是以下几点。

  • 选择我们数据结构的中间值,然后 "问",这是我们要找的值吗?
  • 如果不是,我们就 "问 "我们要找的值是大于还是小于中间的值?
  • 如果是大,我们就 "抛弃 "所有小于中间值的值。如果是小的,我们就 "丢弃 "所有大于中间值的值。
  • 然后我们重复同样的操作,直到我们找到给定的值或者数据结构的剩余 "部分 "不能再被分割。

binary_search_1

二进制搜索的图形表示

二进制搜索的酷之处在于,在每一次迭代中,我们都会丢弃大约一半的数据结构。这使得搜索变得非常快速和高效。👌

假设我们有相同的数组(有序的),我们想写一个和以前一样的函数,它接受一个数字作为输入并返回该数字在数组中的索引。如果它在数组中不存在,它将返回-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²)的复杂度,因为它将把每个值与其余的值比较一次。

image.png

一个可能的实现可以是以下的。

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²)的复杂性。

image.png

一个可能的实现可以是以下的。

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²))的复杂度。

image.png

一个可能的实现可以是以下方式。

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。

image.png

一个可能的实现可以是以下的。

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)

image.png

一个可能的实现可以是以下的。

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是最大数字的位数。考虑到它并不互相比较数值,这个算法的运行时间比之前看到的算法要好,但只对数字列表起作用。

如果我们想要一个与数据无关的排序算法,我们可能会选择前面的任何一种算法。

image.png

image.png

一个可能的实现可以是以下几种。

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]

遍历算法

我们要看的最后一种算法是遍历算法,它被用来遍历那些可以用不同方式遍历的数据结构(主要是树和图)。

当迭代像树这样的数据结构时,我们可以通过两种主要方式来确定迭代的优先次序,即广度或深度。

如果我们优先考虑深度,我们将通过树的每个分支 "下降",从头到每个分支的叶子。

image-42

深度第一

如果我们优先考虑广度,我们将水平地通过每一个树的 "层次",在 "下降 "到下一个层次之前迭代所有在同一层次上的节点。

image-39

广度优先

我们选择哪一种,主要取决于我们在迭代中寻找什么价值,以及我们的数据结构是如何建立的。

广度优先搜索(BFS)

所以我们先来分析一下BFS。如前所述,这种遍历方式将以 "水平方式 "遍历我们的数据结构。按照这个新的示例图片,这些值将按照以下顺序被遍历:[10, 6, 15, 3, 8, 20]

image-40

通常情况下,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。

这两种算法的时间复杂度是一样的,因为我们总是只访问每个节点一次。但是空间复杂度会有所不同,这取决于每种实现方式需要在内存中存储多少个节点。因此,我们需要跟踪的节点越少越好。

总结

像往常一样,我希望你喜欢这篇文章,并学到一些新东西。如果你愿意,你也可以在LinkedInTwitter上关注我。