Go 并发实战:使用通道检查二叉树等价性

前言

在大多数编程语言中,检查两棵二叉树是否存储相同的值序列是一个相当复杂的问题。通常需要编写递归算法来遍历两棵树,然后逐节点比较。但在 Go 语言中,我们可以利用其强大的并发原语和通道(channel)机制,写出简洁优雅的解决方案。

本文将通过 Go Tour 的一个经典练习,展示如何使用 Go 的并发特性来解决二叉树等价性问题。

问题描述

给定两棵二叉树,如何判断它们是否包含相同的值序列?注意,这里不要求树的结构完全相同,只要中序遍历的结果相同即可。

例如,以下两棵不同的二叉树都存储了序列 1, 1, 2, 3, 5, 8, 13:

    树1                    树2
     8                      5
    / \                    / \
   3   13                 2   8
  / \                    /   / \
 1   5                  1   3  13
/
1

传统的解决方案需要:
1. 分别对两棵树进行中序遍历,将结果存储到数组中
2. 比较两个数组是否相等

这种方法需要额外的存储空间,且代码较为冗长。让我们看看 Go 如何用更优雅的方式解决这个问题。

核心概念:Go 的通道(Channel)

在开始实现之前,先回顾一下 Go 通道的关键特性:

– 通道是类型安全的队列:chan int 表示一个整数类型的通道
– 发送和接收操作:ch <- value 发送值,value := <-ch 接收值 - 阻塞特性:发送和接收操作会阻塞,直到另一方准备好 - 可关闭:使用 close(ch) 关闭通道,接收方可以通过 value, ok := <-ch 检测通道是否关闭 - 与 goroutine 配合:通道是 goroutine 之间通信的主要方式 实现步骤 第一步:实现 Walk 函数 Walk 函数的任务是中序遍历二叉树,并将每个节点的值发送到通道中。

// Walk 中序遍历树 t,将所有值发送到通道 ch
func Walk(t *tree.Tree, ch chan int) {
	if t == nil {
		return
	}
	// 递归遍历左子树
	Walk(t.Left, ch)
	// 发送当前节点的值
	ch <- t.Value
	// 递归遍历右子树
	Walk(t.Right, ch)
}

// Walk 中序遍历树 t,将所有值发送到通道 ch
func Walk(t *tree.Tree, ch chan int) {
	if t == nil {
		return
	}
	// 递归遍历左子树
	Walk(t.Left, ch)
	// 发送当前节点的值
	ch <- t.Value
	// 递归遍历右子树
	Walk(t.Right, ch)
}

关键点解析:

1. 中序遍历:按照”左子树 → 根节点 → 右子树”的顺序遍历,这样对于排序二叉树,输出的值会是升序的
2. 递归结构:每次递归都会将值发送到同一个通道,保证值的顺序性
3. 阻塞发送:ch <- t.Value 是阻塞操作,只有当有接收方准备好接收时才会继续执行 第二步:测试 Walk 函数 在实现 Same 函数之前,我们先测试 Walk 是否正常工作。

func main() {
	// 测试 Walk 函数
	fmt.Println("测试 Walk 函数:")
	ch := make(chan int)
	go Walk(tree.New(1), ch)
	for i := 0; i < 10; i++ {
		fmt.Print(<-ch, " ")
	}
	fmt.Println()
}

说明:

– tree.New(k)创建一个随机结构的排序二叉树,包含值 k, 2k, 3k, …, 10k
– 使用 go Walk(…) 在独立的 goroutine 中执行遍历,避免阻塞主线程
– 从通道中读取 10 个值,应该输出 1 2 3 4 5 6 7 8 9 10

第三步:实现 Same 函数

这是整个练习的核心。我们需要利用 Walk 函数来判断两棵树是否等价。

// Same 判断树 t1 和 t2 是否包含相同的值
func Same(t1, t2 *tree.Tree) bool {
	// 创建两个通道
	ch1 := make(chan int)
	ch2 := make(chan int)

	// 启动两个 goroutine 分别遍历两棵树
	go func() {
		Walk(t1, ch1)
		close(ch1)
	}()
	go func() {
		Walk(t2, ch2)
		close(ch2)
	}()

	// 同时从两个通道读取值并比较
	for {
		v1, ok1 := <-ch1
		v2, ok2 := <-ch2

		// 如果两个通道都关闭了,说明遍历完成且所有值都相等
		if !ok1 && !ok2 {
			return true
		}

		// 如果一个通道关闭而另一个没有,或者值不相等,返回 false
		if !ok1 || !ok2 || v1 != v2 {
			return false
		}
	}
}

设计思路:

1. 并行遍历:启动两个 goroutine 分别遍历两棵树,充分利用并发能力
2. 及时关闭通道:每个 goroutine 在完成遍历后关闭自己的通道,让接收方知道遍历结束
3. 同步比较:在主 goroutine 中同时从两个通道读取值,实现”边遍历边比较”的效果
4. 提前终止:一旦发现不相等的值或长度不一致,立即返回 false,无需遍历完整棵树

比较逻辑详解:

v1, ok1 := <-ch1
v2, ok2 := <-ch2

– ok1 和 ok2 表示通道是否仍然开放
– 如果 !ok1 && !ok2:两个通道都关闭,说明两棵树的节点数相同且所有值都相等
– 如果 !ok1 || !ok2:一个通道先关闭,说明两棵树的节点数不同
– 如果 v1 != v2:当前节点的值不相等

第四步:测试 Same 函数

func main() {
	// ... 前面的 Walk 测试代码 ...

	// 测试 Same 函数
	fmt.Println("\n测试 Same 函数:")
	fmt.Println("Same(tree.New(1), tree.New(1)) =", Same(tree.New(1), tree.New(1)))
	fmt.Println("Same(tree.New(1), tree.New(2)) =", Same(tree.New(1), tree.New(2)))
}

预期输出:

Same(tree.New(1), tree.New(1)) = true
Same(tree.New(1), tree.New(2)) = false

完整代码

package main

import (
	"fmt"

	"golang.org/x/tour/tree"
)

// Walk 中序遍历树 t,将所有值发送到通道 ch
func Walk(t *tree.Tree, ch chan int) {
	if t == nil {
		return
	}
	// 递归遍历左子树
	Walk(t.Left, ch)
	// 发送当前节点的值到通道
	ch <- t.Value
	// 递归遍历右子树
	Walk(t.Right, ch)
}

// Same 判断树 t1 和 t2 是否包含相同的值
func Same(t1, t2 *tree.Tree) bool {
	// 创建两个通道用于接收树的值
	ch1 := make(chan int)
	ch2 := make(chan int)

	// 启动两个 goroutine 分别遍历两棵树
	go func() {
		Walk(t1, ch1)
		close(ch1) // 关闭通道,表示遍历完成
	}()

	go func() {
		Walk(t2, ch2)
		close(ch2) // 关闭通道,表示遍历完成
	}()

	// 同时从两个通道读取值并比较
	for {
		v1, ok1 := <-ch1
		v2, ok2 := <-ch2

		// 如果两个通道都关闭了,说明遍历完成且所有值都相等
		if !ok1 && !ok2 {
			return true
		}

		// 如果一个通道关闭而另一个没有,或者值不相等,返回 false
		if ok1 != ok2 || v1 != v2 {
			return false
		}
	}
}

func main() {
	// 测试 Walk 函数
	fmt.Println("测试 Walk 函数:")
	ch := make(chan int)
	go Walk(tree.New(1), ch)
	for i := 0; i < 10; i++ {
		fmt.Print(<-ch, " ")
	}
	fmt.Println()

	// 测试 Same 函数
	fmt.Println("\n测试 Same 函数:")
	fmt.Println("Same(tree.New(1), tree.New(1)) =", Same(tree.New(1), tree.New(1)))
	fmt.Println("Same(tree.New(1), tree.New(2)) =", Same(tree.New(1), tree.New(2)))
}


为什么这个方案更优雅?

1. 无需额外存储

传统方法需要将遍历结果存储到数组中,而这个方案直接在遍历过程中进行比较,节省了内存空间。

2. 惰性求值

由于通道的阻塞特性,我们实际上是”按需”获取值。一旦发现问题就可以提前终止,不需要遍历整棵树。

3. 天然并行

两棵树的遍历可以同时进行,充分利用多核 CPU 的优势。

4. 代码简洁

整个解决方案只需要约 40 行代码,逻辑清晰,易于理解。

深入思考

性能分析

虽然这个方案使用了并发,但需要注意:

– 通道操作的开销:每次发送和接收都有同步开销
– goroutine 的创建成本:创建 goroutine 虽然轻量,但也有成本
– 适用场景:对于小型树,传统方法可能更快;对于大型树或需要流式处理的场景,这个方案更有优势

改进方向

1. 使用缓冲通道`make(chan int, bufferSize) 可以减少阻塞次数,提高性能
2. 添加超时机制:防止某棵树的遍历陷入死循环
3. 支持泛型:Go 1.18+ 可以使用泛型支持不同类型的树

总结

通过这个练习,我们学习了:

1. ✅ 如何使用通道在 goroutine 之间传递数据
2. ✅ 如何利用通道的阻塞特性实现同步
3. ✅ 如何通过关闭通道来信号化任务的完成
4. ✅ 如何将并发思维应用到算法设计中

Go 的并发模型(CSP – Communicating Sequential Processes)让我们能够以声明式的方式编写并发代码,避免了传统多线程编程中的锁、条件变量等复杂机制。”不要通过共享内存来通信,而要通过通信来共享内存” —— 这正是 Go 并发哲学的精髓。

我是拥有 15+ 年经验的 PHP / Go 后端工程师。如需以下服务,欢迎联系我(更多介绍请查看 关于我 & 合作):

  • ✅ PHP / Go 项目开发与维护
  • ✅ 系统架构设计与技术咨询
  • ✅ 网站性能优化与故障排查
  • ✅ Linux 服务器部署与运维
  • ✅ 网络环境优化与远程支持
  • ✅ 长期技术顾问合作

微信:13980074657
邮箱:shuijingwanwq@gmail.com
Telegram:@shuijingwan
GitHub:https://github.com/shuijingwan

评论

一条对“Go 并发实战:使用通道检查二叉树等价性”的回复

  1. 永夜 的头像
    永夜

    测试一下,缓存功能哈。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

这个站点使用 Akismet 来减少垃圾评论。了解你的评论数据如何被处理