跳到主要内容

6 篇博文 含有标签「Algorithm」

查看所有标签

· 阅读需 2 分钟

前言

在本文中,我们将探讨 JavaScript 中一些缺失的数学方法,以及我们如何为它们编写函数。

JavaScript Math 对象包含一些非常有用且功能强大的数学运算,可用于 Web 开发,但它缺少大多数其他语言提供的许多重要运算(例如 Haskell,其中有大量运算)。

JavaScript 中缺少数学方法:求和

你可能记得在学校里,“sum”是“add”的同义词。例如,如果我们将数字 1、2 和 3 相加,它实际上意味着1 + 2 + 3。

我们的sum函数将涉及对数组中的所有值求和。

编写这个函数有两种方法:我们可以使用for循环,或者我们可以使用reduce函数。如果您想重新熟悉该reduce函数,可以阅读有关在 JavaScript 中使用 map()reduce() 的信息。

使用for循环:

function sum(array){
let total = 0
for(let count = 0; count < array.length; count++){
total = total + array[count]
}
return total
}

· 阅读需 13 分钟

前言

之前我们已经研究了树数据结构的实现以及它的一些变体,例如 trie。在这篇文章中,我们将深入研究堆。它也称为优先级队列。

什么是堆?

堆是树数据结构的一种变体,具有两个附加属性: 1.它是一棵完全二叉树:一棵完全二叉树的每一层都包含最大数量的节点,可能最后一层除外,它必须从左到右填充。完整的二叉树总是通过它的定义来平衡的。作为参考下图显示了什么时候可以将树称为完全二叉树: 2.每个节点都满足“堆属性”:堆属性本质上意味着对于任何给定的节点 C,如果 P 是 C 的父节点,则:

  • 对于最大堆:P 的键应该大于或等于 C 的键。
  • 对于最小堆:P 的键应该小于或等于 C 的键。

如何表示堆?

我们通常从节点的类表示开始实现,然后将其与实际数据结构本身的类表示联系起来。我们也可以对堆做同样的事情。但是,有一种更简单的方法可以解决此问题,这是因为所有堆都必须遵守以下两个属性之一:

所有堆必须是完全二叉树 由于所有堆都必须是完全二叉树,并且我们知道完全二叉树中除最后一层外的所有级别都必须完全填充。此外,对于最后一层,所有子项必须从左到右方向填充,没有任何间隙。这个定义确保了一个由 n 个节点组成的完全二叉树只能有 1 种可能的形状。反过来,它允许我们使用数组来表示完整的二叉树。这意味着,堆也可以使用数组来表示。例如,我们可以将一个简单的堆表示为一个数组,如下图所示: 这里要注意的关键是父节点和子节点之间的关系。如果仔细观察上图,我们可以推断出以下内容:

  1. 如果节点位于数组中的索引 i 处,则假设结果索引位于数组的长度内:
  • 它的左孩子将在第 (2i+1) 个位置
  • 左孩子将在 (2i+2) 位置
  1. 如果一个节点被放置在数组中的索引 i 处,它的父节点将位于第 ((i-1)/2) 个索引处。 下图可以更轻松地使用上述信息:

具体实现

注意:在整个实现过程中,我们只会讨论最小堆。稍后我们将看到如何将相同的想法轻松扩展到最大堆。

在我们已经涵盖了表示的细节,让我们想出一个使用数据结构的接口。在堆数据结构的帮助下,我们希望能够实现三个关键点:

  • 向堆中添加一个新键
  • 从堆中删除最大或最小键(取决于它是最小堆还是最大堆)
  • 从堆中获取最小键的最大值(取决于是最小堆还是最大堆)

第三个操作很简单。我们知道对于最小堆,数组中的第一项将是最小键,同样对于最大堆,数组中的第一项将是最大键。所以我们剩下两个操作的实现:

// adds the provided newKey into the min-heap named "heap"
function heappush(heap, newKey){}

// removes the smallest key from the min-heap named "heap"
function heappop(heap){}

实现heappush()

我们如何向堆中添加一个新键?假设我们首先将新键推送到数组中。推送新键仍然让我们遵守堆的第一个要求,即它必须是一个完整的二叉树。但是,我们需要确保它也遵守“堆属性”。 我们可以通过将push的项与其父项进行比较来做到这一点。如果父项大于的push的项,那么我们知道堆属性被违反,因此我们可以交换。我们可以继续进行这种交换,直到找到一个合法的父节点或者我们已经到达堆的顶部。 代码如下

function heappush(heap, newKey){
// push the new key
heap.push(newKey);

// get the current index of pushed key
let curr = heap.length-1;

// keep comparing till root is reached or we terminate in middle
while(curr > 0){
let parent = Math.floor((curr-1)/2)
if( heap[curr] < heap[parent] ){
// quick swap
[ heap[curr], heap[parent] ] = [ heap[parent], heap[curr] ]
// update the index of newKey
curr = parent
} else{
// if no swap, break, since we heap is stable now
break
}
}
}

实现 heappop()

使用 heappop() 我们需要删除堆的最顶层元素。意思是,对于最小堆,最小键将被删除,而对于最大堆,最大键将被删除。从数组的角度来看,它只是意味着我们应该删除数组的第一项。但是哪个节点应该成为根?如果我们随机选择被移除节点的左孩子或右孩子作为新的根节点,则不能保证遵循堆属性。我们可以按照以下步骤(对于最小堆):

  1. 将根节点与最后一个节点交换(数组中的第一个元素与最后一个元素)
  2. 通过从数组中弹出最后一项来删除根节点
  3. 将新根节点的键与其子节点进行比较:
  • 如果键小于它的两个子键,则堆是稳定的
  • 否则,将Key与较小的子Key交换
  1. 重复步骤 3,直到到达最后一个子节点或建立堆属性。

本质上,我们遵循与 heappush() 类似的过程,除了我们试图以从上到下的方式建立堆属性,即从根开始并一直持续到最后一个孩子。在 heappush() 中,我们遵循相反的顺序,即从最后一个孩子开始,一直到根。 代码实现:

function heappop(heap){
// swap root with last node
const n = heap.length;
[heap[0], heap[n-1]] = [ heap[n-1], heap[0]]

// remove the root i.e. the last item (because of swap)
const removedKey = heap.pop();

let curr = 0;

// keep going till atleast left child is possible for current node
while(2*curr + 1 < heap.length){
const leftIndex = 2*curr+1;
const rightIndex = 2*curr+2;
const minChildIndex = (rightIndex < heap.length && heap[rightIndex] < heap[leftIndex] ) ? rightIndex :leftIndex;
if(heap[minChildIndex] < heap[curr]){
// quick swap, if smaller of two children is smaller than the parent (min-heap)
[heap[minChildIndex], heap[curr]] = [heap[curr], heap[minChildIndex]]
curr = minChildIndex
} else {
break
}
}

// finally return the removed key
return removedKey;
}

使用现有数组创建堆

从现有数组创建堆看起来非常简单。只需创建一个空堆,然后遍历数组的所有项并执行 heappush():

function heapify(arr){
const heap = []
for(let item of arr){
heappush(heap, item)
}
return heap;
}

但是我们可以在这里做得更好吗?是的。首先,我们可以完全避免为新堆使用额外的空间。为什么不重新排列数组本身的元素,使其满足堆属性?为此,我们可以遵循与堆弹出类似的逻辑。我们可以查看第一个节点并与它的子节点进行比较,看看它是否是最小的节点,如果不是与较小的子节点交换。下面我们来实现一下:

// follows pretty much the same logic as heappush, except minor modifications
function percolateDown(heap, index){
let curr = index;
// keep going down till heap property is established
while(2*curr + 1 < heap.length){
const leftIndex = 2*curr+1;
const rightIndex = 2*curr+2;
const minChildIndex = (rightIndex < heap.length && heap[rightIndex] < heap[leftIndex] ) ? rightIndex :leftIndex;
if(heap[minChildIndex] < heap[curr]){
// quick swap, if smaller of two children is smaller than the parent (min-heap)
[heap[minChildIndex], heap[curr]] = [heap[curr], heap[minChildIndex]]
curr = minChildIndex
} else {
break
}
}

我们可以对数组中的所有元素使用 percolateDown() 函数,按照堆属性将所有元素按正确顺序排列:

function heapify(heap){
for(let i in heap){
percolateDown(heap, i)
}
return heap
}

这样就为我们节省了一个额外的数组。但是我们能做些什么来改善所花费的时间吗?是的。如果你仔细观察,我们实际上是在做一些重复的工作。假设堆中有 n 个节点,其中 x 是叶节点,那么这意味着我们只需要对 n-x 个节点执行 percolateDown(),因为到那时最后 x 个节点将在正确的位置。

那么在堆的数组表示中,我们应该执行 percolateDown() 操作到哪个索引? 直到最后一个节点的父节点所在的索引。因为一旦最后一个节点的父节点被过滤掉,它也会处理最后一个节点。所以:

  • 如果数组长度为 n
  • 最后一个节点的索引是:n-1
  • 它的父节点的索引是:Math.floor((n-1) - 1 / 2) = Math.floor(n/2 - 1)
function heapify(heap){
const last = Math.floor(heap.length/2 - 1);
for(let i = 0; i <= last; i++){
percolateDown(heap, i)
}
return heap
}

时间和空间复杂度

查看 heappush() 和 heapop() 操作,很明显我们在尝试添加或删除键时正在遍历树的高度。由于堆是一棵平衡树,因此高度是 log(n),其中 n 是节点总数。因此,对于堆的推送和弹出操作,时间复杂度为 O(log(n))。 heapify() 操作的时间复杂度可能看起来像 Onlog(n),因为每次调用都需要 O(log(n))。这个观察结果对于推导 heapify() 的时间复杂度的上限是正确的,但是,渐近(平均)时间复杂度为 O(n)。更多细节在这里。就空间复杂度而言,它是恒定的,因为额外的空间仅被诸如 curr、leftIndex 等大小恒定的变量占用。

最大堆

如果我们有 minHeap 的实现,我们也可以轻松地将它用作最大堆。我们只需要确保在向堆添加值时我们插入键的负数。它将确保堆充当所有键的负数的最小堆,这相当于所有实际键的最大堆。例如:

  • 假设我们有一个数组 const x = [23, 454, 54, 29];
  • 可以使用以下方法创建最小堆:
const heap = [];
for(let el of x) heappush(heap, el);
// min value
const min = heappop(heap)

最大堆可以使用

const heap = [];
for(let el of x) heappush(heap, -el);

// max value const max = -heappop(heap)


· 阅读需 8 分钟

前言

我们已经在三篇文章中介绍了树数据结构的基础知识。如果你还没有读过这些,我强烈建议先阅读前三篇文章:

介绍

Trie 是树数据结构的一种变体。它也被称为前缀树或搜索树的变体。就像 n 叉树数据结构一样,trie 可以有 n 个来自单亲的孩子。通常,trie 中的所有节点都会存储一些字符。假设我们只处理英语单词,下面是一个简单的 trie 可能看起来像: 需要注意的事项:

  1. 我们正在尝试使用树来尽可能高效地表示英语单词。

  2. 在上图中,从根节点到任何绿色节点的路径表示一个英文单词。例如:

    • NULL->C->A->T: CAT
    • NULL->D->O: DO
    • NULL->D->O->G: DOG
    • NULL->D->A->R->K: DARK
    • NULL->A: A
    • NULL->A->N: AN
  3. 每个节点最多可以有 26 个子节点(如果我们只处理英文字母)。我们有一个 NULL 节点作为根节点,因为一个单词可以以 26 个字母中的任何一个开头,因此我们需要一个虚拟节点,它可以将任何潜在的第一个字母作为子节点。

  4. 绿色节点,本质上代表“词尾”,同时从根遍历到该节点。

实现节点

现在,让我们尝试提出 Trie 节点的表示。回到树节点,这就是我们呈现它的方式:

function Node(value){
this.value = value
this.left = null
this.right = null
}

因此,我们可以对 Trie 遵循类似的想法,同时确保它满足我们在介绍部分讨论的要求。要了解 Trie 节点的要求,让我们放大任何节点: 所以现在更有意义了。这是最终的代码:

function Node(value){
this.value = value
this.isEndOfWord = false // false by default, a green node means this flag is true
this.children = {} // children are stored as Map, where key is the letter and value is a TrieNode for that letter
}

实现 Trie 数据结构

我们可以使用一个简单的 ES6 类来表示:

class Trie{
constructor(){
this.root = new Node(null)
}

insert(word){
// TODO
}

search(word){
// TODO
}

}

所以我们已经准备好了大概。作为初始化的一部分,每个trie 都会创建它自己的根节点(NULL)。那么我们可以实现这两个方法如下:

  • insert(word):我们可以将单词拆分为字母,并为每个字母创建一个 Node()。然后我们可以开始将这些 Trie 节点中的每一个链接到根节点,以插入单词。最后,我们将最后插入的节点的 isEndOfWord 属性标记为 true。
  • search(word):我们可以将单词拆分为字母。然后我们可以从根开始一个一个地寻找这些字母中的每一个。如果我们能够按顺序找到所有字母,那么我们可以返回 true 否则 false。

让我们直观地理解这两个操作以获得更好的上下文:

  • 首先insert(CAR)然后insert(CAN):
  • 首先search(CAR)然后search(CAN):

实现如下:

class Trie{
constructor(){
this.root = new Node(null)
}

insert(word){
let current = this.root
// iterate through all the characters of word
for(let character of word){
// if node doesn't have the current character as child, insert it
if(current.children[character] === undefined){
current.children[character] = new Node(character)
}
// move down, to insert next character
current = current.children[character]
}
// mark the last inserted character as end of the word
current.isEndOfWord = true
}

search(word){
let current = this.root
// iterate through all the characters of word
for(let character of word){
if(current.children[character] === undefined){
// could not find this character in sequence, return false
return false
}
// move down, to match next character
current = current.children[character]
}
// found all characters, return true if last character is end of a word
return current.isEndOfWord
}
}

使用 Trie

const trie = new Trie();

// insert few words
trie.insert("CAT");
trie.insert("DOG");

// search something
trie.search("MAT") // false
trie.search("DOG") // true

空间复杂度

在最坏的情况下,所有插入单词的每个字符都可以占用 Trie 中的单个节点。所以这意味着最坏的空间复杂度可以是 (W*n),其中 W 是每个单词的平均字符数,n 是 Trie 中的单词总数。

时间复杂度

  • 插入:插入一个有n个字符的单词,只需要遍历n个字符,所以时间复杂度为O(n)
  • 搜索:与插入类似,我们只需要遍历单词的所有字符即可进行搜索。所以时间复杂度是 O(n),其中 n 是单词中的字符数。

现在,想一想,你还能如何在庞大的单词列表中搜索某个单词? -可能使用数组?时间复杂度为 O(m),其中 m 是单词总数,这很糟糕。

  • 如何使用Map(或 JavaScript 中的对象)?这会将时间复杂度降低到 O(1),但是找到具有特定前缀的单词列表有多快?它将是 O(m)。

Trie 不仅将时间复杂度降低到 O(n)(n = 单词中的字符数),而且您还可以有效地搜索具有前缀的单词列表,这对于任何以上两种方法。

应用

  • 自动完成和预先输入:如果您在文本框中键入内容,并且看到具有相同前缀的潜在搜索列表,即自动完成小部件,那么这可能是由后台的 Trie 处理的。同样,Typeahead 也可以使用 Trie 来实现。
  • 拼写检查器:我们可以使用 trie 创建拼写检查器,即给定一个单词列表,我们可以检查给定单词的拼写是否正确。
  • IP 路由(最长前缀匹配):Internet 由多个路由器节点组成,它们决定应该发送的目标数据包。 Internet 上的每个路由器都需要将数据包发送到由给定 IP 目的地决定的适当目标节点。但是每个路由器如何使用给定的 IP 地址决定下一个目标路由器呢?这个问题可以使用IP路由来解决。这是一篇深入探讨这个主题的好文章

· 阅读需 7 分钟

前言

到目前为止,我们已经了解了一些二叉树遍历的方法:

  • 使用递归和迭代算法遍历二叉树
  • 使用父指针遍历二叉树 在本文中,我们将把这些知识用于 n 叉树,即 DOM。我们将看到如何使用各种 CSS 选择器定位 DOM 元素,而无需使用内置 API,如 getElementById、getElementsByClassname 或 querySelector/querySelectorAll。因此,本文将阐明这些 API 可能如何在幕后工作。

DOM 遍历

借用 使用递归和迭代算法遍历二叉树的思路,我们来得出DOM的前序遍历算法:

function walkPreOrder(node){
if(!node) return

// do something here
console.log(node)

for(let child of node.children){
walkPreOrder(child)
}
}

我们可以修改这个算法使之来返回一个迭代器:

function* walkPreOrder(node){
if(!node) return

// do something here
yield node
for(let child of node.children){
yield* walkPreOrder(child)
}
}

// USAGE
for(let node of walkPreOrder(root)){
console.log(node)
}

我们可以使用任何广度优先或深度优先算法(在之前的文章中讨论过)来遍历 DOM。 我们还假设正在处理具有以下 HTML 的文档:

<html>
<head>
<title>DOM selection algorithm</title>
</head>
<body>

<div class="container">
<div class="body">
<div class="row">
<img id="profile" src="xyz.jpg" alt="">
</div>
<div class="row"></div>
<div class="row"></div>
</div>
</div>

</body>
</html>

通过 ID 定位节点

function locateById(nodeId){
// iterate through all nodes in depth first (preOrder) fashion
// return the node as soon as it's found
for(let node of walkPreOrder(document.body)){
if(node.id === nodeId){
return node
}
}
return null
}

我们可以使用 locateById() 函数如下:

const img = locateById('profile')
// returns the image node

通过ClassName 定位节点

浏览器提供 document.getElementsByClassName() API 来实现此结果。我们如何实现类似的东西:

function locateAllByClassName(className){
const result = []
for(let node of walkPreOrder(document.body)){
if(node.classList.contains(className)){
result.push(node)
}
}
return result
}

// USAGE
const elements = locateAllByClassName('row')

浏览器如何优化选择查询

选择 DOM 节点是 Web 应用程序相当常见的操作。为同一个选择器多次遍历树似乎不是最佳选择。浏览器通过使用记忆优化选择。 查看 mozilla 解析器的源代码,即函数 startTag 的摘录:

 // ID uniqueness
@IdType String id = attributes.getId();
if (id != null) {
LocatorImpl oldLoc = idLocations.get(id);
if (oldLoc != null) {
err("Duplicate ID \u201C" + id + "\u201D.");
errorHandler.warning(new SAXParseException(
"The first occurrence of ID \u201C" + id
+ "\u201D was here.", oldLoc));
} else {
idLocations.put(id, new LocatorImpl(tokenizer));
}
}

我们可以看到这些节点 ID 保存在一个简单的哈希映射中。我们可以使用类似的方法来确保对同一 ID 的重复查询不需要完全遍历,相反,我们可以从 hashMap 中查找并返回它。 以下是我们的解决方案:

function getSelectors(){
const idLocations = {}
const classLocations = {}

// updated selector functions
function locateById(nodeId){
if(idLocations.hasOwnProperty(nodeId))
return idLocations[nodeId]

for(let node of walkPreOrder(document.body)){
if(node.id === nodeId){
idLocations[nodeId]= node //memoize
return node
}
}
idLocations[nodeId]= null // memoize
return null
}

function locateAllByClassName(className){
if(classLocations.hasOwnProperty(className))
return classLocations[className]

const result = []
for(let node of walkPreOrder(document.body)){
if(node.classList.contains(className)){
result.push(node)
}
}
classLocations[nodeId]= result
return result
}

return {
locateById,
locateAllByClassName
}

}

// USAGE
const {locateById, locateAllByClassName} = getSelectors();
const result = locateAllByClassName('row') // returns array of elements
const img = locateById('profile') // returns an element, if found

处理更复杂的选择器

让我们尝试实现类似 element.querySelector 的方法。以下是 MDN 的描述:

The querySelector() method of the Element interface returns the first element that is a descendant of the element on which it is invoked that matches the specified group of selectors.

样例

const firstRow = document.querySelector('.container .row:first-child')

在这种情况下,我们可以将任何 CSS 选择器传递给函数,它应该能够遍历 DOM 为我们找到该元素。让我们看看它是如何实现的:

// given a selector and root node, find that selector within the root node
function select(selector, root){
for(let node of walkPreOrder(root)){
if(node.matches(selector)){
return node
}
}
return null;
}

function myQuerySelector(path, node){ // if path is empty, nothing to find if(path.length === 0) return null;

// if node is not provided, let's assume user wants to search within document.body let root = node || document.body;
const selector = path[0];

// if there's only one selector in the path, just traverse using select function above if(path.length === 1) return select(selector, root);

// else, either the current node matches the first selector in path or not // if first selector matches with current node, look through it's children for subsequent selectors only // else, look through it's children for the whole path const newPath = root.matches(selector) ? path.slice(1): path; for(let child of root.children){ const ans = myQuerySelector(newPath, child); if(ans) return ans }

// nothing found return null; }

// USAGE: const firstRow = myQuerySelector([".container", ".row"])

myQuerySelectorAll 的实现(类似于 element.querySelectorAll)也遵循相同的方法,稍作修改:
```javascript
function selectAll(selector, root){
let result = []
for(let node of walkPreOrder(root)){
if(node.matches(selector)){
result.push(node)
}
}
return result;
}

function myQuerySelectorAll(path, node){
let result = [];
if(path.length === 0) return result;

let root = node || document.body;
const selector = path[0];

if(path.length === 1) return selectAll(selector, root);

const newPath = root.matches(selector) ? path.slice(1): path;
for(let child of root.children){
result = [...result, ...myQuerySelectorAll(newPath, child)]

}

return result;
}

进阶

我们可以使用本文开头描述的递归前序遍历方法来克隆任何树。让我们看看我们如何使用它来克隆任何 DOM 树,类似于 element.cloneNode(true) 所做的:

  • 通过创建具有相同 tagName 的新节点然后复制属性来创建源节点的克隆。
  • 对源节点的所有子节点递归调用 cloneTree 方法,并将返回的节点作为子节点附加到克隆节点。
function cloneTree(node){
if(!node) return

const clonedNode = document.createElement(node.tagName.toLowerCase())
const attributes = node.getAttributeNames()

attributes.forEach(attribute => {
clonedNode.setAttribute(attribute, node.getAttribute(attribute))
})

for(const child of node.children){
clonedNode.append(cloneTree(child))
}

return clonedNode
}

· 阅读需 9 分钟

前言

在本系列的第一部分中,我们研究了遍历二叉树的递归和迭代方法。 在实际应用中,树节点有一个父节点是很常见的:一个指向父节点的指针,因此也称为父指针。让我们以浏览器中的 DOM 为例。假设我们使用以下命令选择任何节点:

const element = document.querySelector("#id")

在本文中,我们将研究如何使用这些父指针来提高遍历效率。我稍后会解释我所说的“更高效”是什么意思。在下一篇文章中,我们还将了解如何使用此处学到的经验教训从头开始创建 myQuery 库。

更新节点定义

首先,我们需要更新Node 函数

function Node(value){
this.value = value
this.left = null
this.right = null
this.parent = null // added parent field
}

现在让我们看看如何使用这个新的 Node 定义来创建一个类似的树,就像我们在上一篇文章中所做的那样。

const root = new Node(2)
const left = new Node(1)
root.left = left
left.parent = root

const right = new Node(3)
root.right = right
right.parent = root

我们只需要确保父指针指向父节点。这是我们使用上述代码获得的最终树的视觉参考:

##寻找后继节点

前序后继

假设每个节点都有一个parent指针,如何找出二叉树中任何节点的 前序 后继? 让我们试着分析一下这个问题:

  1. 首先,我们在这里处理前序,这意味着我们正在寻找以下顺序:
root -> left -> right
  1. 这意味着如果我们已经在当前节点,我们想寻找左子节点作为后继节点。
  2. 如果根本没有左子节点怎么办?那么在这种情况下,我们会寻找合适的节点,如果在有左子节点,那就是后继节点。
  3. 如果没有左子节点或右子节点,那么我们需要回溯(继续向上走向父节点)。我们一直回溯,直到通过它的右子节点到达父级(因为这意味着 前序 对于父级下的整个子树是完整的,根据 #1 的定义)。

最终算法实现就是这样:

function preOrderSuccessor(node){
if(!node) return

if(node.left) return node.left
if(node.right) return node.right

let parent = node.parent

while(parent && parent.right === node) {
node = node.parent
parent = parent.parent
}

if(!parent) return null // we backtracked till root, so no successor

return parent.right
}

可以根据下图更好的理解

中序后继

  1. 首先,我们在这里处理中序遍历,这意味着我们正在寻找以下顺序:
root -> left -> right
  1. 如果我们在当前节点,并且它右边有右子节点,那么我们可以通过在右子树上找到最左边的节点来获得后继节点。
  2. 如果没有右子节点,那么我们需要回溯(向上移动)。我们一直向上移动,直到通过它的右子节点到达父节点,因为这意味着已经遍历了整个子树(根据 #1 中的定义)。
  3. 一旦我们找到最近的父节点,它是通过它的左子节点找到的,它就会作为后继节点返回。为什么?因为这意味着它是一个已经探索了左树的节点,所以根据 #1 中的定义,节点本身现在是后继节点。 实现如下:
function inOrderSuccessor(node){
if(!node) return

if(node.right){
let current = node.right
while(current && current.left) current = current.left
return current
}

let parent = node.parent

while(parent && parent.right === node) {
root = node.parent
parent = parent.parent
}

if(!parent) return null

return parent
}

后序后继

  1. 首先,我们在这里处理后序遍历,这意味着我们正在寻找以下顺序:
left -> right -> root
  1. 所以,如果我们在任何节点上,就意味着它的左右子树已经被访问过了。这意味着我们需要查看父级的继任者。
  2. 如果我们从它的右子节点到达父母,这意味着父母本身就是继任者,根据#1 中的定义
  3. 如果我们从它的左子节点到达父母,这意味着接下来要探索父母的右子节点(根据#1 中的定义)。所以现在我们需要简单地返回父节点右子节点中最左边的节点作为后继节点。 实现如下:
function postOrderSuccessor(node){
if(!node) return

let parent = node.parent
if(!parent) return null

if(parent.right === node). return parent

let current = parent.right
while(current && (current.left || current.right)){
current = (current.left || current.right)
}

return current
}

使用后继算法更好地遍历

为什么我们需要使用父指针来提出遍历算法呢?这是一个值得思考的问题,因为我们已经提出了遍历树的递归和迭代方法,而且不需要父指针。

我们这样做的原因是因为我们之前的方法增加了空间复杂性。如果你还记得上一篇文章中我们需要使用一个或两个堆栈(取决于遍历方法)来使任何遍历算法工作。即使在递归方法中,虽然我们不直接使用堆栈,但递归本身基于调用堆栈,因此那里也使用了隐藏的内存中堆栈。问题是这个堆栈的大小会随着我们树的深度而增加,因此这不是最好的解决方案,因为我们有办法在花费更少空间的情况下完成相同的任务。通过使用父指针,我们可以完全摆脱这些堆栈,为我们节省大量空间,即从 O(logN) 的空间复杂度(其中 N 表示平衡树的大小)到 O(1)。让我们看看如何实现。

前序遍历

对于 前序遍历,我们从树的根部开始。之后,我们可以使用上面的算法继续获取前序后继以遍历整棵树:

function preOrder(root){
// first node
console.log(root.value);

let current = root
while(true){
const next = preOrderSuccessor(current)
if(!next) break

// do something
console.log(next.value)

current = next
}
}

中序遍历

对于 中序遍历,起始节点将是树的最左侧节点。此后,我们可以使用上述算法继续获取后继以遍历整棵树:

function inOrder(root){
// start at the left most node
while(root && root.left){
root = root.left
}

// first node
console.log(root.value);

let current = node
while(true){
const next = inOrderSuccessor(current)
if(!next) break

// do something
console.log(current.value)

current = next
}
}

后序遍历

非常类似于上面的中序遍历的方法:

function postOrder(root){
// start at the left most node
while(root && root.left){
root = root.left
}

// first node
console.log(root.value);

let current = node
while(true){
const next = postOrderSuccessor(current)
if(!next) break

// do something
console.log(current.value)

current = next
}
}

进阶

如果每个节点都有一个父指针,你能想出算法来寻找前任(inOrder、preOrder 和 postOrder)吗?

· 阅读需 9 分钟

前言

Tree是一种有趣的数据结构。它在各个领域都有广泛的应用。例如:

  • DOM 是一种Tree状数据结构
  • 我们操作系统中的目录和文件可以表示为Tree
  • 家庭层次结构可以表示为Tree。 Tree的许多变体(如堆、BST 等)可用于解决与调度、图像处理、数据库等相关的问题。许多复杂的问题乍一看似乎与Tree无关,但可以被表示为一个Tree的问题。我们也会(在本系列的后面部分)解决这些问题,从而了解Tree如何使看似复杂的问题更容易理解和解决。

简介

二叉树实现节点非常简单

function Node(value){
this.value = value
this.left = null
this.right = null
}
// usage
const root = new Node(2)
root.left = new Node(1)
root.right = new Node(3)

所以这几行代码将为我们创建一个二叉树,如下所示:

           2  
/ \
/ \
1 3
/ \ / \
null null null null

遍历

让我们从尝试遍历这些连接的树节点(或一棵树)开始。正如我们可以遍历数组一样,如果我们也可以“遍历”树节点。然而,树不是像数组那样的线性数据结构,所以遍历这些的方法不止一种。我们可以将遍历方法大致分为以下几类:

  • 广度优先遍历
  • 深度优先遍历

广度优先遍历(BFS)

在这种方法中,我们逐层遍历树。我们将从根开始,然后覆盖它的所有子级,然后覆盖所有 2 级子级,依此类推。例如对于上面的树,遍历会导致这样的结果:

           2  
/ \
/ \
1 3
/ \ / \
null null null null
2,1,3

下面是一个稍微复杂的树的插图,使这更容易理解:

为了实现这种形式的遍历,我们可以使用队列(先进先出)数据结构。以下是整个算法的过程:

  1. 初始化一个包含 root 的队列
  2. 从队列中删除第一项
  3. 将弹出项的左右节点推入队列
  4. 重复步骤 2 和 3,直到队列为空 下面是这个算法在实现后的样子:
function walkBFS(root){
if(root === null) return

const queue = [root]
while(queue.length){
const item = queue.shift()
// do something
console.log(item)

if(item.left) queue.push(item.left)
if(item.right) queue.push(item.right)
}
}

我们可以稍微修改上面的算法实现为:

function walkBFS(root){
if(root === null) return

const queue = [root], ans = []

while(queue.length){
const len = queue.length, level = []
for(let i = 0; i < len; i++){
const item = queue.shift()
level.push(item)
if(item.left) queue.push(item.left)
if(item.right) queue.push(item.right)
}
ans.push(level)
}
return ans
}

深度优先遍历(DFS)

在 DFS 中,我们取一个节点并继续探索它的子节点,直到深度耗尽为止。它可以通过以下方式之一完成:

 root node -> left node -> right node // pre-order traversal
left node -> root node -> right node // in-order traversal
left node -> right node -> root node // post-order traversal

所有这些遍历技术都可以递归和迭代实现。让我们进入实现细节:

前序遍历(Pre-Order traversal)

分析
 root node -> left node -> right node

技巧:

我们可以使用这个简单的技巧来手动找出任何树的前序遍历:从根节点开始遍历整棵树,保持自己在左边。

实现
  • 递归
function walkPreOrder(root){
if(root === null) return

// do something here
console.log(root.val)

// recurse through child nodes
if(root.left) walkPreOrder(root.left)
if(root.right) walkPreOrder(root.right)
}

  • 迭代 前序遍历的迭代方法与 BFS 非常相似,不同之处在于我们使用堆栈而不是队列,并且我们首先将右节点推入堆栈:
function walkPreOrder(root){
if(root === null) return

const stack = [root]
while(stack.length){
const item = stack.pop()

// do something
console.log(item)

// Left child is pushed after right one, since we want to print left child first hence it must be above right child in the stack
if(item.right) stack.push(item.right)
if(item.left) stack.push(item.left)
}
}

中序遍历(In-Order traversal)

分析

下面是一棵树的中序遍历的过程:

left node -> root node -> right node

技巧

我们可以使用这个简单的技巧来手动找出任何树的中序遍历:在树的底部水平放置一个平面镜,并获取所有节点的投影

实现
  • 递归

    function walkInOrder(root){
    if(root === null) return

    if(root.left) walkInOrder(root.left)

    // do something here
    console.log(root.val)

    if(root.right) walkInOrder(root.right)
    }
  • 迭代 这个算法乍一看可能有点神秘。但它相当直观。让我们这样看:在中序遍历中,最左边的孩子节点首先被打印,然后是根,然后是孩子节点。所以首先想到的是想出这样的东西:

    const curr = root

while(curr){ while(curr.left){ curr = curr.left // get to leftmost child }

console.log(curr) // print it

curr = curr.right // now move to right child }

在上述方法中,我们无法回溯,即返回导致最左侧节点的父节点。所以我们需要一个堆栈来记录这些。因此,我们修订后的方法可能如下所示:
```javascript
const stack = []
const curr = root

while(stack.length || curr){
while(curr){
stack.push(curr) // keep recording the trail, to backtrack
curr = curr.left // get to leftmost child
}
const leftMost = stack.pop()
console.log(leftMost) // print it

curr = leftMost.right // now move to right child
}

现在我们可以使用上面的方法来制定最终的迭代算法:

function walkInOrder(root){
if(root === null) return

const stack = []
let current = root

while(stack.length || current){
while(current){
stack.push(current)
current = current.left
}
const last = stack.pop()

// do something
console.log(last)

current = last.right
}
}

后序遍历(Post-Order traversal)

分析

下面是一棵树的中序遍历的过程:

 left node -> right node -> root node

技巧

对于任何树的快速手动后序遍历:一个接一个地提取所有最左边的孩子节点。

实现

让我们深入研究这种遍历的实际实现。

  • 递归

      function walkPostOrder(root){
    if(root === null) return

    if(root.left) walkPostOrder(root.left)
    if(root.right) walkPostOrder(root.right)

    // do something here
    console.log(root.val)

    }
  • 迭代 我们已经有了用于前序遍历的迭代算法。我们可以用那个吗?因为后序遍历似乎只是前序遍历的反向。让我们来看看:

// PreOrder:
root -> left -> right

// Reverse of PreOrder:
right -> left -> root

// But PostOrder is:
left -> right -> root

从上面分析可见有细微的差别。我们可以通过稍微修改我们的 前序遍历算法然后反转它应该给出 后序遍历结果来适应这一点。总体算法将是:

// record result using 
root -> right -> left

// reverse result
left -> right -> root
  • 使用与上述迭代前序遍历算法类似的方法,使用临时堆栈。
    • 唯一的区别是 root -> right -> left 而不是 root -> left -> right
  • 结果将遍历序列记录在一个array
  • 结果的反转就是后序遍历
function walkPostOrder(root){
if(root === null) return []

const tempStack = [root], result = []

while(tempStack.length){
const last = tempStack.pop()

result.push(last)

if(last.left) tempStack.push(last.left)
if(last.right) tempStack.push(last.right)
}

return result.reverse()
}