算法day12

  • 二叉树理论基础
  • 114 二叉树的前序遍历
  • 145 二叉树的后序遍历
  • 94 二叉树的中序遍历
  • 迭代法

二叉树理论基础

直接看代码随想录就完事了,之前考研也学过,大概都能理解
我这里就说说代码层面的。
二叉树的存储:
1、链式存储:这个就是我们平时用的左指针,右指针那种写法的二叉树存储方式。
2、顺序存储:这个就是利用数组来存二叉树,值得一提的是,结点与结点的孩子如何表示,这个是通过下标直接来表示的,如果父节点的数组下标是 i,那么它的左孩子就是 i * 2 + 1,右孩子就是 i * 2 + 2。

二叉树遍历
深度优先遍历
前序遍历(递归法,迭代法)
中序遍历(递归法,迭代法)
后序遍历(递归法,迭代法)
广度优先遍历
层次遍历(迭代法)
一个技巧:前中后这里指的是访问根节点的顺序。
前中后遍历的逻辑其实都可以借助栈使用递归的方式来实现。

二叉树的定义

type TreeNode struct {
    Val int
    Left *TreeNode
    Right *TreeNode
}

这简直和C++一个样。


二叉树的递归遍历

下面的题目都是涉及二叉树的递归遍历。递归的流程我觉得是很简单的,但是这个代码有时候容易搞混。所以这里对递归的写法做一个总结。

每次写递归都按照这三要素来写
1.确定递归函数的参数和返回值: 确定哪些参数是递归的过程中需要处理的,那么就在递归函数里加上这个参数, 并且还要明确每次递归的返回值是什么进而确定递归函数的返回类型
2.确定终止条件: 写完了递归算法, 运行的时候,经常会遇到栈溢出的错误,就是没写终止条件或者终止条件写的不对,操作系统也是用一个栈的结构来保存每一层递归的信息,如果递归没有终止,操作系统的内存栈必然就会溢出。
3.确定单层递归的逻辑: 确定每一层递归需要处理的信息。在这里也就会重复调用自己来实现递归的过程。


114二叉树的前序遍历

我知道逻辑就是先根后左右。c++能写但是go写不出来,看到go语言的代码我就傻掉了。
根本写不出
所以这里好好学学go语言怎么写的。
首先树结点的结构体这个必须要会写,就和c++的实现一样的。

func preorderTraversal(root *TreeNode) (res []int) {
    var traversal func(node *TreeNode)
    traversal = func(node *TreeNode) {
	if node == nil {
            return
	}
	res = append(res,node.Val)
	traversal(node.Left)
	traversal(node.Right)
    }
    traversal(root)
    return res
}

代码逻辑:
1.在这个函数中,先定义一个递归函数traversal ,注意这个函数是直接在内部定义的,这个语法我说实话我还是学的太少了,第一次见。这里把这个语法学了。
函数是可以在另一个函数的内部定义的,定义的方式如下:

traversal = func(node *TreeNode) {
	if node == nil {
            return
	}
	res = append(res,node.Val)
	traversal(node.Left)
	traversal(node.Right)
    }

我总结就是先声明然后进行实现,这个语法格式了解之后多写写就有记忆了。

这个题我只能说已经结束了,这其实就是利用了go语言的一个性质和leetcode题意要求,由于leetcode要求返回的是结果切片,所以这里我就要在这个函数里面调用一个函数,从而直接返回我要的结果。所以说这个题也并非要按题解这么写。这个函数我可以放到外面定义的,只是go语言有这个方便的性质。

这里我再写一个版本

func traversal(root *TreeNode,res *[]int){
    if root == nil {
        return 
    }

    *res = append(*res,root.Val)
    traversal(root.Left,res)
    traversal(root.Right,res)
}


func preorderTraversal(root *TreeNode) []int {
    res := []int{}
    traversal(root,&res)
    return res
}

这个我感觉更适合我理解的写法。
但是这个写法我自己写的时候也遇到问题:
1.我第一次写的时候没有意思到,res切片是引用类型,所以我在函数调用的时候,如果要对res产生影响,那么我就必须要传指针,所以在定义函数的时候我要传的是*[]int,然后传参的时候传的肯定是&res。
2.还有一个同样的问题发生在append,我在对res调用append的时候,这个res必须是引用类型,所以我就要加一个取值符号*,对里面的参数一样如此。

总结:注意引用类型。

自己写了这个版本之后我才知道题解这么用有啥好处,题解这么写还用到了一个语法:闭包

这里通过这个题再学学闭包:
闭包是一种特殊的函数,它可以捕获其创建时所在的作用域中的变量。在go语言中,闭包是通过定义在另一个函数内部的匿名函数实现的。这些匿名函数能够访问并操作其外部函数的变量。

我相信看完这个闭包的定义,肯定有很大的疑惑,我这个代码里面不是有名字吗?这里其实很多教程又省略了,我又去查了资料。

这里我建议看我理解的这个:
上面代码中,严格来说我实现闭包的这个函数并不是匿名函数,然而,即使它被命名了,它仍然可以访问并修改它的外部作用域(PreorferTraversal函数)中的变量res。它仍然符合闭包的行为特征。

所以这里就要进行更正:
闭包的本质
闭包的核心特征是它能够捕获并使用其外部作用域中的变量。**在go语言中,这通常是通过在一个函数内部定义另一个函数实现的。**这个内部函数可以是匿名的,也可以是命名的。关键是前面加粗的这句话。

题解的做法完全就是利用了闭包的性质。


后面两个题很简单

145二叉树的后续遍历

func postorderTraversal(root *TreeNode) []int {
    res := []int{}
    var traversal func(root *TreeNode)
    traversal = func(root *TreeNode){
        if root == nil {
            return 
        }
        
        traversal(root.Left)
        traversal(root.Right)
        res = append(res,root.Val)
    }
    traversal(root)
    return res
}

就是左右根


94 二叉树的中序遍历

左根右

func inorderTraversal(root *TreeNode) []int {
    res := []int{}
    var traversal func(root *TreeNode)
    traversal = func(root *TreeNode){
        if root == nil {
            return 
        }
        
        traversal(root.Left)
        res = append(res,root.Val)
        traversal(root.Right)
    }
    traversal(root)
    return res
}

迭代法(非递归实现遍历)

思路:非递归的实现其实就是用栈来模拟递归,因为递归的实现就是每一次递归调用都会把函数的局部变量、参数值和返回地址等压入调用栈中,然后递归返回的时候,从栈顶弹出上一次递归的各项参数,所以这就是递归为什么可以返回上一层位置的原因。
所以非递归的代码实现就是用栈来实现。所以下面的代码我都会先开一个栈来辅助。

前序遍历

思路:前序遍历是中左右,每次先处理中间结点,那么就先将根结点放入栈中,然后将右孩子加入栈,再加入左孩子(有人可能觉得这里我搞返了)。
为什么要先加入右孩子再加入左孩子?因为这样出栈的时候才是中左右的顺序。
可以看看这个动画模拟实现的过程,看看过程中遍历到不同结点时,栈的变化
算法day12-LMLPHP
我对这个过程解读以下:
一开始5先入栈,5出栈,5的右孩子(6)入栈,然后左孩子(4)入栈。
此时栈的状态的结果 前序遍历输出5 栈中 6 4.
4出栈,然后4的右孩子2入栈,左孩子1入栈,此时 前序遍历输出 5 4 ,栈中6 2 1
1 出栈,由于1没有左右孩子,那继续2出栈,2也没有左右孩子,然后6出栈,6也没有左右孩子,所以这个时候栈为空,结束。

代码逻辑:
1.既然要用到栈,那就先把栈实现了,这里要注意这栈的元素是什么,是值?还是树结点?回答是树结点,这里需要注意。在上面的图中可能显得稍微简化了一点,但实际上是把树结点加入栈中。就像我们写递归写法一样,我们进行递归下一层同样是对结点操作。
2.实现前序遍历的非递归解法:
1.先创建一个刚刚实现的空栈,再创建一个结果集装结果。
2.先将根结点root先入栈,然后开始循环
3.for 循环是针对这个栈的,只要栈不空就不会停止。
将栈顶元素出栈,然后把这个栈顶元素的值加入结果集。然后先加入右结点,再加入左结点

代码实现

type Stack []*TreeNode

func (s *Stack) Push(node *TreeNode) {
    *s = append(*s, node)
}

func (s *Stack) Pop() *TreeNode {
    if s.IsEmpty() {
        return nil
    }
    index := len(*s) - 1
    element := (*s)[index]
    *s = (*s)[:index]
    return element
}

func (s *Stack) IsEmpty() bool {
    return len(*s) == 0
}

func preorderTraversal(root *TreeNode) []int {
    if root == nil {
        return nil
    }

    stack := Stack{}
    stack.Push(root)
    result := []int{}

    for !stack.IsEmpty() {
        node := stack.Pop()
        result = append(result, node.Val)

        if node.Right != nil {
            stack.Push(node.Right)
        }
        if node.Left != nil {
            stack.Push(node.Left)
        }
    }

    return result
}

中序遍历迭代法

注意有人可能想像递归写法一样,我在前序遍历非递归的写法,改以下就得到中序遍历迭代法。这是实现不了的,因为不是一个逻辑。前序遍历的逻辑无法直接用到中序遍历上。
为什么中序写法和前序的写法不通用?
因为前序的遍历顺序是根左右,先访问的元素是中间结点,而且要处理的元素也是中间结点,所以才能写出相对简洁的代吗,因为要访问的元素和要处理的元素顺序是一致的,都是中间节点。
再看看中序:中序是左根右,先访问的是二叉树顶部的结点然后一层一层的往下层访问,直到到达树的最左下,然后才开始处理结点(也就是访问结点),这就造成了处理顺序和访问顺序是不一致的。

那么在使用迭代法写中序遍历,就需要借用指针的遍历来帮助访问节点,栈则用来处理节点上的元素。

思路:
中序遍历的顺序是左中右
1.初始化栈和当前结点:创建一个存待访问的结点,然后设置当前结点为根结点。

看懂这个就看懂了这个题的逻辑:
什么是当前结点当前结点是正在处理的元素,为什么需要当前结点,在非递归的实现中,需要一个指针或引用来追踪当前正在处理的结点,这个指针就被称为当前结点。
当前结点如何用? 遍历时不断地讲当前结点移动到其左子节点,直到到达最左侧的结点。这个过程中,沿途的结点都被推入栈中,以备后续访问。
当到达最左侧结点且该结点没有左子结点时,开始从栈中弹出结点,每次从栈中弹出一个结点,这个结点就称为新的”当前结点”,然后处理这个结点,并将当前结点移动到其右子结点。

2.遍历左子树:当前节点非空时,将其推入栈中,并将当前节点设置为其左子节点。这个过程一直持续到最左端,即没有左子节点为止。

3.访问节点和遍历右子树:当前节点为空时(表示到达最左端),从栈中弹出一个元素(这是当前最左侧的节点),访问这个节点(将节点值加入结果数组),将“当前节点”设置为弹出节点的右子节点(开始处理右子树)

4.重复过程:重复上述过程,直到栈为空且当前节点也为空。这表示所有节点都已按中序遍历的顺序访问完毕。

核心:
1.栈的使用: 栈用来暂存还未访问的节点。由于栈的后进先出特性,它能够保证在处理完左子树后能够回到相应的父节点,再去处理右子树。

2.遍历到最左侧: 一开始,需要遍历到树的最左侧,这是中序遍历开始访问节点的地方。

3.从栈中弹出节点: 在没有左子节点可访问时,从栈中弹出节点,表示该节点的左子树已经处理完毕,可以访问该节点了。

4.转向右子树: 访问完节点后,转向处理右子树,即将当前节点设置为右子节点。

代码:

func inorderTraversal(root *TreeNode) []int {
    var result []int
    stack := []*TreeNode{}
    currentNode := root

    for currentNode != nil || len(stack) > 0 {
        // 遍历到最左节点
        for currentNode != nil {
            stack = append(stack, currentNode)
            currentNode = currentNode.Left
        }

        // 当前节点为空,说明左边遍历到底了,开始处理栈顶节点
        currentNode = stack[len(stack)-1]
        stack = stack[:len(stack)-1]  // 弹出栈顶节点
        result = append(result, currentNode.Val)  // 添加到结果中

        // 转向右子树
        currentNode = currentNode.Right
    }

    return result
}

后序遍历非递归实现:

后序遍历只需要在前序遍历的基础是做调整就可以实现,为什么?
先序遍历是根左右,后续遍历是左右根,那么我们只需要调整一下先序遍历的代码顺序,就变成中右左的遍历顺序,然后在反转result数组,输出的结果顺序就是左右中了。

可能这样看着还是有点不太懂。先看代码逻辑,我来解读:


func postorderTraversal(root *TreeNode) []int {
    if root == nil {
        return nil
    }

    stack := []*TreeNode{root} // 创建一个栈并初始化为包含根节点
    result := []int{} // 结果数组,用于存储遍历结果

    // 继续遍历直到栈为空
    for len(stack) > 0 {
        node := stack[len(stack)-1] // 取出栈顶元素
        stack = stack[:len(stack)-1] // 弹出栈顶元素

        // 将当前节点的值添加到结果数组的前面
        // 这样做是为了在最后反转遍历的顺序
        result = append([]int{node.Val}, result...)

        // 如果存在左子节点,将其推入栈中
        // 注意这里先处理左子节点,因为我们希望它在右子节点之后被处理
        if node.Left != nil {
            stack = append(stack, node.Left)
        }

        // 如果存在右子节点,将其推入栈中
        if node.Right != nil {
            stack = append(stack, node.Right)
        }
    }

    // 返回结果数组,这个数组是“左-右-根”的顺序
    return result
}

1.栈还是和前序一样的用法,用于存储待访问的解读,首先将根节点压入栈中。
2.根-右-左的遍历顺序,我们从栈中弹出一个节点,首先访问这个节点(根),然后,如果存在左子节点,我们将其压入栈中,接下来,如果存在右子节点,我们也将其压入栈中。由于栈是后进先出的,这意味着右子节点将在左子节点之前被处理。
3.结果数组的构建:每次从栈中弹出一个节点时,我们将其值插入到结果数组的前端,这样做实际上是在创建一个“根-右-左”的顺序的数组。但是,因为我们总是在数组的前端插入新元素,所以最终的数组实际上是“左-右-根”的顺序。

为什么这样做可以得到后序遍历的结果?
在后序遍历中,我们希望遵循“左-右-根”的顺序。由于我们是在结果数组的前端插入每个新访问的节点,所以最后访问的节点(根节点)将出现在数组的最前面,而第一个访问的节点(最左侧的节点)将出现在数组的最后面。这样,即使我们的遍历顺序是“根-右-左”,数组中元素的顺序却是“左-右-根”。

递归写法总结:算法实现还是非常的好理解的
主要还是对go语言的语法学习。今天这个语法我也是头一次体会到了闭包的作用。当时学语法的时候由于没有使用场景,所以学起来还是有很大差别的。

应用场景总结:
在函数调用的时候,对于一个引用类型,如果你想通过函数调用对这个引用类型产生影响,这个时候要么传指针,要么用闭包。而且对于有些函数调用,比如append,我还需要对传递的指针通过取值符号*进行取值后才能调用这个函数。

递归写法和非递归写法哪个比较好?好在哪里?
递归写法:
优点:代码好写,对于要解决的问题来说,这种写法与问题直接的关系就显得很直观。
缺点:递归可能会导致调用堆栈过深,尤其是处理大规模数据的时候,可能会出现堆栈溢出。而且每次递归调用都会增加额外的堆栈帧开销,影响性能。

非递归写法:
优点:通常比递归写法有更好的性能表现,而且无堆栈溢出风险。
缺点:代码不好写,代码的可读性下降,设计比较困难。

总结:递归好写性能差,非递归难写性能好。

02-07 12:58