前端面试数据结构和算法

链表

1. 简单的反转链表

反转一个单链表。
示例 :
输入: 1->2->3->4->5->NULL
输出: 5->4->3->2->1->NULL

&循环解决方案

这道题是链表中的经典题目,充分体现链表这种数据结构 操作思路简单 , 但是 实现上 并没有那么简单的特
点。
那在实现上应该注意一些什么问题呢?
保存后续节点。作为新手来说,很容易将当前节点的 next 指针直接指向前一个节点,但其实当前节点
下一个节点 的指针也就丢失了。因此,需要在遍历的过程当中,先将下一个节点保存,然后再操作 next
指向。
链表结构声定义如下 :
function ListNode(val) {
this.val = val;
this.next = null;
}
实现如下 :
/**
* @param {ListNode} head
* @return {ListNode}
*/
let reverseList = (head) => {
if (!head)
return null;
let pre = null, cur = head;
while (cur) {
// 关键: 保存下一个节点的值
let next = cur.next;
cur.next = pre;
pre = cur;
cur = next;
}
return pre;
};
由于逻辑比较简单,代码直接一气呵成。不过仅仅写完还不够,对于链表问题,边界检查的习惯能帮助 我们进一步保证代码的质量。
但作为系统性的训练而言,单单让程序通过未免太草率了,我们后续会尽可能地用不同的方式去解决相 同的问题,达到融会贯通的效果,也是对自己思路的开拓,有时候或许能达到更优解。

递归解决方案

由于之前的思路已经介绍得非常清楚了,因此在这我们贴上代码,大家好好体会:
let reverseList = (head) => {
if (!head)
return null;
let pre = null, cur = head;
while (cur) {
// 关键: 保存下一个节点的值
let next = cur.next;
cur.next = pre;
pre = cur;
cur = next;
}
return pre;
};

2. 区间反转

反转从位置 m n 的链表。请使用一趟扫描完成反转。
说明 : 1 m n 链表长度。
示例 :
思路
这一题相比上一个整个链表反转的题,其实是换汤不换药。我们依然有两种类型的解法: 循环解法
归解法
需要注意的问题就是 前后节点 的保存 ( 或者记录 ) ,什么意思呢?看这张图你就明白了。
关于前节点和后节点的定义,大家在图上应该能看的比较清楚了,后面会经常用到。
反转操作上一题已经拆解过,这里不再赘述。值得注意的是反转后的工作,那么对于整个区间反转后的
工作,其实就是一个移花接木的过程,首先将 前节点 next 指向区间终点,然后将区间起点的 next
后节点 。因此这一题中有四个需要重视的节点 : 前节点 后节点 区间起点 区间终点 。接下来我们
开始实际的编码操作。

循环解法

let reverseList = ( head ) => {
let reverse = ( pre , cur ) => {
if ( ! cur ) return pre ;
// 保存 next 节点
let next = cur . next ;
cur . next = pre ;
return reverse ( cur , next );
}
return reverse ( null , head );
}
输入 : 1->2->3->4->5->NULL, m = 2, n = 4
输出 : 1->4->3->2->5->NULL
/**
* @param {ListNode} head
* @param {number} m
* @param {number} n * @return {ListNode}
*/
var reverseBetween = function ( head , m , n ) {
let count = n - m ;
let p = dummyHead = new ListNode ();
let pre , cur , start , tail ;
p . next = head ;
for ( let i = 0 ; i < m - 1 ; i ++ ) {
p = p . next ;
}
// 保存前节点
front = p ;
// 同时保存区间首节点
pre = tail = p . next ;
cur = pre . next ;
// 区间反转
for ( let i = 0 ; i < count ; i ++ ) {
let next = cur . next ;
cur . next = pre ;
pre = cur ;
cur = next ;
}
// 前节点的 next 指向区间末尾
front . next = pre ;
// 区间首节点的 next 指向后节点 ( 循环完后的 cur 就是区间后面第一个节点,即后节点 )
tail . next = cur ;
return dummyHead . next ;
};

递归解法

对于递归解法,唯一的不同就在于对于区间的处理,采用递归程序进行处理,大家也可以趁着复习一下
递归反转的实现。
var reverseBetween = function ( head , m , n ) {
// 递归反转函数
let reverse = ( pre , cur ) => {
if ( ! cur ) return pre ;
// 保存 next 节点
let next = cur . next ;
cur . next = pre ;
return reverse ( cur , next );
}
let p = dummyHead = new ListNode ();
dummyHead . next = head ;
let start , end ; // 区间首尾节点
let front , tail ; // 前节点和后节点
for ( let i = 0 ; i < m - 1 ; i ++ ) {
p = p . next ;
}
front = p ; // 保存前节点
start = front . next ;
for ( let i = m - 1 ; i < n ; i ++ ) {
p = p . next ;
}

3. 两个一组翻转链表

给定一个链表,两两交换其中相邻的节点,并返回交换后的链表。
你不能只是单纯的改变节点内部的值,而是需要实际的进行节点交换。
示例 :
思路
如图所示,我们首先建立一个虚拟头节点 (dummyHead) ,辅助我们分析。
首先让 p 处在 dummyHead 的位置,记录下 p.next p.next.next 的节点,也就是 node1
node2
随后让 node1.next = node2.next , 效果 :
然后让 node2.next = node1 , 效果 :
最后, dummyHead.next = node2 ,本次翻转完成。同时 p 指针指向 node1, 效果如下:
end = p ;
tail = end . next ; // 保存后节点
end . next = null ;
// 开始穿针引线啦,前节点指向区间首,区间首指向后节点
front . next = reverse ( null , start );
start . next = tail ;
return dummyHead . next ;
}
给定 1->2->3->4, 你应该返回 2->1->4->3. 依此循环,如果 p.next 或者 p.next.next 为空,也就是 找不到新的一组节点 了,循环结束。
循环解决
思路清楚了,其实实现还是比较容易的,代码如下 :
递归方式

4. K个一组翻转链表

给你一个链表,每 k 个节点一组进行翻转,请你返回翻转后的链表。
k 是一个正整数,它的值小于或等于链表的长度。
如果节点总数不是 k 的整数倍,那么请将最后剩余的节点保持原有顺序。
示例 :
var swapPairs = function ( head ) {
if ( head == null || head . next == null )
return head ;
let dummyHead = p = new ListNode ();
let node1 , node2 ;
dummyHead . next = head ;
while (( node1 = p . next ) && ( node2 = p . next . next )) {
node1 . next = node2 . next ;
node2 . next = node1 ;
p . next = node2 ;
p = node1 ;
}
return dummyHead . next ;
};
var swapPairs = function ( head ) {
if ( head == null || head . next == null )
return head ;
let node1 = head , node2 = head . next ;
node1 . next = swapPairs ( node2 . next );
node2 . next = node1 ;
return node2 ;
}; 给定这个链表: 1->2->3->4->5
k = 2 时,应当返回 : 2->1->4->3->5
k = 3 时,应当返回 : 3->2->1->4->5
说明 :
你的算法只能使用常数的额外空间。
你不能只是单纯的改变节点内部的值,而是需要实际的进行节点交换。
思路
思路类似 No.3 中的两个一组翻转。唯一的不同在于两个一组的情况下每一组只需要反转两个节点,而在
K 个一组的情况下对应的操作是将 K 个元素 的链表进行反转。
递归解法
以下代码的注释中 首节点 尾结点 等概念都是针对反转前的链表而言的。
/**
* @param {ListNode} head
* @param {number} k
* @return {ListNode}
*/
var reverseKGroup = function ( head , k ) {
let pre = null , cur = head ;
let p = head ;
// 下面的循环用来检查后面的元素是否能组成一组
for ( let i = 0 ; i < k ; i ++ ) {
if ( p == null ) return head ;
p = p . next ;
}
for ( let i = 0 ; i < k ; i ++ ){
let next = cur . next ;
cur . next = pre ;
pre = cur ;
cur = next ;
}
// pre 为本组最后一个节点, cur 为下一组的起点
head . next = reverseKGroup ( cur , k );
return pre ;
};
循环解法
重点都放在注释里面了。
var reverseKGroup = function ( head , k ) {
let count = 0 ;
// 看是否能构成一组,同时统计链表元素个数
for ( let p = head ; p != null ; p = p . next ) {
if ( p == null && i < k ) return head ;
count ++ ;

5. 如何检测链表形成环?

给定一个链表,判断链表中是否形成环。
思路
思路一 : 循环一遍,用 Set 数据结构保存节点,利用节点的内存地址来进行判重,如果同样的节点走过两
次,则表明已经形成了环。
思路二 : 利用快慢指针,快指针一次走两步,慢指针一次走一步,如果 两者相遇 ,则表明已经形成了环。
可能你会纳闷,为什么思路二用两个指针在环中一定会相遇呢?
其实很简单,如果有环,两者一定同时走到环中,那么在环中, 选慢指针为参考系 ,快指针每次 相对参
考系 向前走一步,终究会绕回原点,也就是回到慢指针的位置,从而让两者相遇。如果没有环,则两者
的相对距离越来越远,永远不会相遇。
接下来我们来编程实现。

方法一: Set 判重

}
let loopCount = Math . floor ( count / k );
let p = dummyHead = new ListNode ();
dummyHead . next = head ;
// 分成了 loopCount 组,对每一个组进行反转
for ( let i = 0 ; i < loopCount ; i ++ ) {
let pre = null , cur = p . next ;
for ( let j = 0 ; j < k ; j ++ ) {
let next = cur . next ;
cur . next = pre ;
pre = cur ;
cur = next ;
}
// 当前 pre 为该组的尾结点, cur 为下一组首节点
let start = p . next ; // start 是该组首节点
// 开始穿针引线!思路和 2 个一组的情况一模一样
p . next = pre ;
start . next = cur ;
p = start ;
}
return dummyHead . next ;
};
/**
* @param {ListNode} head
* @return {boolean}
*/
var hasCycle = ( head ) => {
let set = new Set ();
let p = head ;
while ( p ) {
// 同一个节点再次碰到,表示有环
if ( set . has ( p )) return true ;
set . add ( p );

方法二: 快慢指针

6. 如何找到环的起点

给定一个链表,返回链表开始入环的第一个节点。 如果链表无环,则返回 null
说明: 不允许修改给定的链表。
思路分析
刚刚已经判断了如何判断出现环,那如何找到环的节点呢?我们来分析一波。
p = p . next ;
}
return false ;
}
var hasCycle = function ( head ) {
let dummyHead = new ListNode ();
dummyHead . next = head ;
let fast = slow = dummyHead ;
// 零个结点或者一个结点,肯定无环
if ( fast . next == null || fast . next . next == null )
return false ;
while ( fast && fast . next ) {
fast = fast . next . next ;
slow = slow . next ;
// 两者相遇了
if ( fast == slow ) {
return true ;
}
}
return false ;
}; 看上去比较繁琐,我们把它做进一步的抽象 :
设快慢指针走了 x 秒,慢指针一秒走一次。
对快指针,有 : 2x - L = m * S + Y -----
对慢指针,有 : x - L = n * S + Y -----
其中, m n 均为自然数。
- * 2 :
L = (m - n) * S - Y -----
好,这是一个非常重要的等式。我们现在假设有一个新的指针在 L 段的最左端,慢指针现在还在相遇
处。
新指针 慢指针 都每次走一步,那么,当 新指针 走了 L 步之后 到达环起点 ,而与此同时,我们看看
慢指针情况如何
由③式,慢指针走了 (m - n) * S - Y 个单位,以环起点为参照物,相遇时的位置为 Y ,而现在由 Y +
(m - n) * S - Y (m - n) * S ,得知慢指针实际上参照环起点,走了整整 (m - n) 圈。也就是说,
指针此时也到达了环起点 :::tip 结论 现在的解法就很清晰了,当快慢指针相遇之后,让新指针从头出
发,和慢指针同时前进,且每次前进一步,两者相遇的地方,就是 环起点
编程实现
懂得原理之后,实现起来就容易很多了。
/**
* @param {ListNode} head
* @return {ListNode}
*/
var detectCycle = function ( head ) {
let dummyHead = new ListNode ();
dummyHead . next = head ;
let fast = slow = dummyHead ;
// 零个结点或者一个结点,肯定无环
if ( fast . next == null || fast . next . next == null )
return null ;
while ( fast && fast . next ) {
fast = fast . next . next ;
slow = slow . next ;

7. 合并两个有序链表

将两个有序链表合并为一个新的有序链表并返回。新链表是通过拼接给定的两个链表的所有节点组成
的。
示例 :
递归解法
递归解法更容易理解,我们先用递归来做一下 :
循环解法
if ( fast == slow ) {
let p = dummyHead ;
while ( p != slow ) {
p = p . next ;
slow = slow . next ;
}
return p ;
}
}
return null ;
};
输入: 1->2->4, 1->3->4
输出: 1->1->2->3->4->4
/**
* @param {ListNode} l1
* @param {ListNode} l2
* @return {ListNode}
*/
var mergeTwoLists = function ( l1 , l2 ) {
const merge = ( l1 , l2 ) => {
if ( l1 == null ) return l2 ;
if ( l2 == null ) return l1 ;
if ( l1 . val > l2 . val ) {
l2 . next = merge ( l1 , l2 . next );
return l2 ;
} else {
l1 . next = merge ( l1 . next , l2 );
return l1 ;
}
}
return merge ( l1 , l2 );
};
var mergeTwoLists = function ( l1 , l2 ) {
if ( l1 == null ) return l2 ;
if ( l2 == null ) return l1 ;

8. 合并 K 个有序链表

合并 k 个排序链表,返回合并后的排序链表。请分析和描述算法的复杂度。
示例 :
自上而下 ( 递归 ) 实现
let p = dummyHead = new ListNode ();
let p1 = l1 , p2 = l2 ;
while ( p1 && p2 ) {
if ( p1 . val > p2 . val ) {
p . next = p2 ;
p = p . next ;
p2 = p2 . next ;
} else {
p . next = p1 ;
p = p . next ;
p1 = p1 . next ;
}
}
// 循环完成后务必检查剩下的部分
if ( p1 ) p . next = p1 ;
else p . next = p2 ;
return dummyHead . next ;
};
输入 :
[
1->4->5,
1->3->4,
2->6
]
输出 : 1->1->2->3->4->4->5->6
/**
* @param {ListNode[]} lists
* @return {ListNode}
*/
var mergeKLists = function ( lists ) {
// 上面已经实现
var mergeTwoLists = function ( l1 , l2 ) { /* 上面已经实现 */ };
const _mergeLists = ( lists , start , end ) => {
if ( end - start < 0 ) return null ;
if ( end - start == 0 ) return lists [ end ];
let mid = Math . floor ( start + ( end - start ) / 2 );
return mergeTwoList ( _mergeLists ( lists , start , mid ), _mergeLists ( lists ,
mid + 1 , end ));
}
return _mergeLists ( lists , 0 , lists . length - 1 );
}; 自下而上实现
在自下而上的实现方式中,为每一个链表绑定了一个虚拟头指针 (dummyHead) ,为什么这么做?
这是为了方便链表的合并,比如 l1 l2 合并之后,合并后链表的头指针就直接是 l1
dummyHead.next 值,等于说两个链表都合并到了 l1 当中,方便了后续的合并操作。
多个链表的合并到这里就实现完成了,这种归并的方式同时也是对链表进行归并排序的核心代码。

9. 判断回文链表

请判断一个单链表是否为回文链表。
示例 1:
示例 2:
你能否用 O(n) 时间复杂度和 O(1) 空间复杂度解决此题?
思路分析
这一题如果不考虑性能的限制,其实是非常简单的。但考虑到 O(n) 时间复杂度和 O(1) 空间复杂度,恐
怕就值得停下来好好想想了。
题目的要求是单链表,没有办法访问前面的节点,那我们只得另辟蹊径 :
var mergeKLists = function ( lists ) {
var mergeTwoLists = function ( l1 , l2 ) { /* 上面已经实现 */ };
// 边界情况
if ( ! lists || ! lists . length ) return null ;
// 虚拟头指针集合
let dummyHeads = [];
// 初始化虚拟头指针
for ( let i = 0 ; i < lists . length ; i ++ ) {
let node = new ListNode ();
node . next = lists [ i ];
dummyHeads [ i ] = node ;
}
// 自底向上进行 merge
for ( let size = 1 ; size < lists . length ; size += size ){
for ( let i = 0 ; i + size < lists . length ; i += 2 * size ) {
dummyHeads [ i ]. next = mergeTwoLists ( dummyHeads [ i ]. next , dummyHeads [ i
+ size ]. next );
}
}
return dummyHeads [ 0 ]. next ;
};
输入 : 1->2
输出 : false
输入 : 1->2->2->1
输出 : true 找到链表中点,然后将后半部分反转,就可以依次比较得出结论了。下面我们来实现一波。
代码实现
其实关键部分的代码就是找中点了。先亮剑 :
let dummyHead = slow = fast = new ListNode ();
dummyHead . next = head ;
// 注意注意,来找中点了
while ( fast && fast . next ) {
slow = slow . next ;
fast = fast . next . next ;
}
为什么边界要设成这样?
不妨来分析一下,分链表节点个数为 奇数 偶数 的时候分别讨论。
当链表节点个数为奇数
试着模拟一下, fast 为空的时候,停止循环 , 状态如下 :
当链表节点个数为偶数 模拟走一遍,当 fast.next 为空的时候,停止循环,状态如下 :
对于 fast 为空 fast.next 为空 两个条件,在奇数的情况下,总是 fast 为空 先出现,偶数的情况
下,总是 fast.next 先出现 .
也就是说 : 一旦 fast 为空 , 链表节点个数一定为奇数,否则为偶数。因此两种情况可以合并来讨论,当
fast 为空或者 fast.next 为空,循环就可以终止了。
完整实现如下 :
/**
* @param {ListNode} head
* @return {boolean}
*/
var isPalindrome = function ( head ) {
let reverse = ( pre , cur ) => {
if ( ! cur ) return pre ;
let next = cur . next ;
cur . next = pre ;
return reverse ( cur , next );
}
let dummyHead = slow = fast = new ListNode ();
dummyHead . next = head ;
// 注意注意,来找中点了 , 黄金模板
while ( fast && fast . next ) {
slow = slow . next ;
fast = fast . next . next ;
}
let next = slow . next ;
slow . next = null ;
let newStart = reverse ( null , next );
for ( let p = head , newP = newStart ; newP != null ; p = p . next , newP =
newP . next ) {
if ( p . val != newP . val ) return false ;
}
return true ;
};

栈和队列

1. 有效括号

给定一个只包括 '(' ')' '{' '}' '[' ']' 的字符串,判断字符串是否有效。
有效字符串需满足:
左括号必须用相同类型的右括号闭合。 左括号必须以正确的顺序闭合。 注意空字符串可被认为是有效字
符串。
示例 :
代码实现

2. 多维数组 flatten

将多维数组转化为一维数组。
示例 :
代码实现
输入 : "()"
输出 : true
/**
* @param {string} s
* @return {boolean}
*/
var isValid = function ( s ) {
let stack = [];
for ( let i = 0 ; i < s . length ; i ++ ) {
let ch = s . charAt ( i );
if ( ch == '(' || ch == '[' || ch == '{' )
stack . push ( ch );
if ( ! stack . length ) return false ;
if ( ch == ')' && stack . pop () !== '(' ) return false ;
if ( ch == ']' && stack . pop () !== '[' ) return false ;
if ( ch == '}' && stack . pop () !== '{' ) return false ;
}
return stack . length === 0 ;
};
[1, [2, [3, [4, 5]]], 6] -> [1, 2, 3, 4, 5, 6]
/**
* @constructor
* @param {NestedInteger[]} nestedList
* @return {Integer[]}
*/ 同时可采用 reduce 的方式 , 一行就可以解决,非常简洁。

3. 普通的层次遍历

给定一个二叉树,返回其按层次遍历的节点值。 (即逐层地,从左到右访问所有节点)。
示例 :
结果应输出 :
实现
let flatten = ( nestedList ) => {
let result = [];
let fn = function ( target , ary ) {
for ( let i = 0 ; i < ary . length ; i ++ ) {
let item = ary [ i ];
if ( Array . isArray ( ary [ i ])) {
fn ( target , item );
} else {
target . push ( item );
}
}
}
fn ( result , nestedList )
return result ;
let flatten = ( nestedList ) => nestedList . reduce (( pre , cur ) =>
pre . concat ( Array . isArray ( cur ) ? flatten ( cur ): cur ), [])
3
/ \
9 20
/ \
15 7
[
[3],
[9,20],
[15,7]
]
/**
* @param {TreeNode} root
* @return {number[][]}
*/
var levelOrder = function ( root ) {
if ( ! root ) return [];
let queue = [];
let res = [];
let level = 0 ;
queue . push ( root );

4. 二叉树的锯齿形层次遍历

给定一个二叉树,返回其节点值的锯齿形层次遍历。(即先从左往右,再从右往左进行下一层遍历,以
此类推,层与层之间交替进行)。
例如:
给定二叉树 [3,9,20,null,null,15,7], 输出应如下 :
返回锯齿形层次遍历如下:
思路
这一题思路稍微不同,但如果把握住层次遍历的思路,就会非常简单。
代码实现
let temp ;
while ( queue . length ) {
res . push ([]);
let size = queue . length ;
// 注意一下 : size -- 在层次遍历中是一个非常重要的技巧
while ( size -- ) {
// 出队
let front = queue . shift ();
res [ level ]. push ( front . val );
// 入队
if ( front . left ) queue . push ( front . left );
if ( front . right ) queue . push ( front . right );
}
level ++ ;
}
return res ;
};
3
/ \
9 20
/ \
15 7
[
[3],
[20,9],
[15,7]
]
var zigzagLevelOrder = function ( root ) {
if ( ! root ) return [];
let queue = [];
let res = [];
let level = 0 ;

5. 二叉树的右视图

给定一棵二叉树,想象自己站在它的右侧,按照从顶部到底部的顺序,返回从右侧所能看到的节点值。
示例 :
思路
右视图?如果你以 DFS 即深度优先搜索的思路来想,你会感觉异常的痛苦。
但如果用广度优先搜索的思想,即用层序遍历的方式,求解这道题目也变得轻而易举。
代码实现
queue . push ( root );
let temp ;
while ( queue . length ) {
res . push ([]);
let size = queue . length ;
while ( size -- ) {
// 出队
let front = queue . shift ();
res [ level ]. push ( front . val );
if ( front . left ) queue . push ( front . left );
if ( front . right ) queue . push ( front . right );
}
// 仅仅增加下面一行代码即可
if ( level % 2 ) res [ level ]. reverse ();
level ++ ;
}
return res ;
};
输入 : [1,2,3,null,5,null,4]
输出 : [1, 3, 4]
解释 :
1 <---
/ \
2 3 <---
\ \
5 4 <---
/**
* @param {TreeNode} root
* @return {number[]}
*/
var rightSideView = function ( root ) {
if ( ! root ) return [];
let queue = [];
let res = [];
queue . push ( root );

6. 完全平方数

给定正整数 n ,找到若干个完全平方数(比如 1, 4, 9, 16, ... )使得它们的和等于 n 。你需要让组成和的
完全平方数的个数最少。
示例 :
思路
这一题其实最容易想到的思路是动态规划,我们放到后面专门来拆解。实际上用队列进行图的建模,也
是可以顺利地用广度优先遍历的方式解决的。
看到这个图,你可能会有点懵,我稍微解释一下你就明白了。
在这个无权图中,每一个点指向的都是它可能经过的上一个节点。举例来说,对 5 而言,可能是 4 加上
1 的平方 转换而来,也可能是 1 加上了 2 的平方 转换而来,因此跟 1 2 都有联系,依次类推。
那么我们现在要做了就是寻找到 n 转换到 0 最短的连线数
while ( queue . length ) {
res . push ( queue [ 0 ]. val );
let size = queue . length ;
while ( size -- ) {
// 一个 size 的循环就是一层的遍历,在这一层只拿最右边的结点
let front = queue . shift ();
if ( front . right ) queue . push ( front . right );
if ( front . left ) queue . push ( front . left );
}
}
return res ;
};
输入 : n = 12
输出 : 3
解释 : 12 = 4 + 4 + 4. 举个例子, n = 8 时,我们需要找到它的邻居节点 4 7 ,此时到达 4 和到达 7 的步数都为 1, 然后分别
4 7 出发, 4 找到邻居节点 3 0 ,达到 3 0 的步数都为 2 ,考虑到此时已经到达 0 ,遍历终
止,返回到达 0 的步数 2 即可。
Talk is cheap, show me your code. 我们接下来来一步步实现这个寻找的过程。
实现
接下来我们来实现第一版的代码。
/**
* @param {number} n
* @return {number}
*/
var numSquares = function ( n ) {
let queue = [];
queue . push ([ n , 0 ]);
while ( queue . length ) {
let [ num , step ] = queue . shift ();
for ( let i = 1 ; ; i ++ ) {
let nextNum = num - i * i ;
if ( nextNum < 0 ) break ;
// 还差最后一步就到了,直接返回 step + 1
if ( nextNum == 0 ) return step + 1 ;
queue . push ([ nextNum , step + 1 ]);
}
}
// 最后是不需要返回另外的值的,因为 1 也是完全平方数,所有的数都能用 1 来组合
};
这个解法从功能上来讲是没有问题的,但是其中隐藏了巨大的性能问题
那为什么会出现这样的问题?
出就出在这样一行代码 :
queue . push ([ nextNum , step + 1 ]);
只要是大于 0 的数,统统塞进队列。要知道 2 - 1 = 1 5 - 4 = 1 9 - 8 = 1 ...... 这样会重复非常多的 1 ,
依次类推,也会重复非常多的 2 , 3 等等等等。
这样大量的重复数字不仅仅消耗了更多的循环次数,同时也造成更加巨大的内存空间压力。
因此,我们需要对已经推入队列的数字进行标记,避免重复推入。改善代码如下 :
var numSquares = function ( n ) {
let map = new Map ();
let queue = [];
queue . push ([ n , 0 ]);
map . set ( n , true );
while ( queue . length ) {
let [ num , step ] = queue . shift ();
for ( let i = 1 ; ; i ++ ) {
let nextNum = num - i * i ;
if ( nextNum < 0 ) break ;
if ( nextNum == 0 ) return step + 1 ;

7. 单词接龙

给定两个单词( beginWord endWord )和一个字典,找到从 beginWord endWord 的最短转换序
列的长度。转换需遵循如下规则:
每次转换只能改变一个字母。
转换过程中的中间单词必须是字典中的单词。
说明 :
1 )如果不存在这样的转换序列,返回 0
2 )所有单词具有相同的长度。
3 )所有单词只由小写字母组成。
4 )字典中不存在重复的单词。
5 )你可以假设 beginWord endWord 是非空的,且二者不相同。
示例 :
思路
这一题是一个更加典型的用图建模的问题。如果每一个单词都是一个节点,那么只要和这个单词仅有一
个字母不同,那么就是它的相邻节点。
这里我们可以通过 BFS 的方式来进行遍历。实现如下 :
代码实现
// nextNum 未被访问过
if ( ! map . get ( nextNum )){
queue . push ([ nextNum , step + 1 ]);
// 标记已经访问过
map . set ( nextNum , true );
}
}
}
};
输入 :
beginWord = "hit",
endWord = "cog",
wordList = ["hot","dot","dog","lot","log","cog"]
输出 : 5
解释 : 一个最短转换序列是 "hit" -> "hot" -> "dot" -> "dog" -> "cog",
返回它的长度 5
/**
* @param {string} beginWord
* @param {string} endWord

8. 优先队列

所谓优先队列,就是一种特殊的 队列 , 其底层使用 的结构,使得每次添加或者删除,让队首元素始终是 优先级最高的。关于优先级通过什么字段、按照什么样的比较方式来设定,可以由我们自己来决定。
要实现优先队列,首先来实现一个堆的结构。
* @param {string[]} wordList
* @return {number}
*/
var ladderLength = function ( beginWord , endWord , wordList ) {
// 两个单词在图中是否相邻
const isSimilar = ( a , b ) => {
let diff = 0
for ( let i = 0 ; i < a . length ; i ++ ) {
if ( a . charAt ( i ) !== b . charAt ( i )) diff ++ ;
if ( diff > 1 ) return false ;
}
return true ;
}
let queue = [ beginWord ];
let index = wordList . indexOf ( beginWord );
if ( index !== - 1 ) wordList . splice ( index , 1 );
let res = 2 ;
while ( queue . length ) {
let size = queue . length ;
while ( size -- ) {
let front = queue . shift ();
for ( let i = 0 ; i < wordList . length ; i ++ ) {
if ( ! isSimilar ( front , wordList [ i ])) continue ;
// 找到了
if ( wordList [ i ] === endWord ) {
return res ;
}
else {
queue . push ( wordList [ i ]);
}
// wordList[i] 已经成功推入,现在不需要了,删除即可
// 这一步性能优化,相当关键,不然 100% 超时
wordList . splice ( i , 1 );
i -- ;
}
}
// 步数 +1
res += 1 ;
}
return 0 ;
};

9. 关于堆的说明

可能你以前没有接触过 这种数据结构,但是其实是很简单的一种结构,其本质就是一棵二叉树。但是 这棵二叉树比较特殊,除了用数组来依次存储各个节点( 节点对应的数组下标和 层序遍历的序号 一致 ) 之外,它需要保证任何一个父节点的优先级大于子节点 ,这也是它最关键的性质,因为保证了根元素一定 是优先级最高的。
举一个例子 :
现在这个堆的数组就是 : [10, 7, 2, 5, 1]
因此也会产生两个非常关键的操作 —— siftUp siftDown
对于 siftUp 操作,我们试想一下现在有一个正常的堆,满足任何父元素优先级大于子元素,这时候向这 个堆的数组末尾又添加了一个元素,那现在可能就不符合 堆 的结构特点了。那么现在我将新增的节点和 其父节点进行比较,如果父节点优先级小于它,则两者交换,不断向上比较直到根节点为止,这样就保
证了 的正确结构。而这样的操作就是 siftUp
siftDown 是与其相反方向的操作,从上到下比较,原理相同,也是为了保证堆的正确结构。

10. 实现一个最大堆

最大堆,即堆顶元素为优先级最高的元素。
// 以最大堆为例来实现一波
/**
* @param {number[]} nums
* @param {number} k
* @return {number[]}
*/
class MaxHeap {
constructor ( arr = [], compare = null ) {
this . data = arr ;
this . size = arr . length ;
this . compare = compare ;
}
getSize () {
return this . size ;
}
isEmpty () {
return this . size === 0 ;
}
// 增加元素
add ( value ) {
this . data . push ( value );
this . size ++ ;
// 增加的时候把添加的元素进行 siftUp this . _siftUp ( this . getSize () - 1 );
}
// 找到优先级最高的元素
findMax () {
if ( this . getSize () === 0 )
return ;
return this . data [ 0 ];
}
// 让优先级最高的元素 ( 即队首元素 ) 出队
extractMax () {
// 1. 保存队首元素
let ret = this . findMax ();
// 2. 让队首和队尾元素交换位置
this . _swap ( 0 , this . getSize () - 1 );
// 3. 把队尾踢出去, size--
this . data . pop ();
this . size -- ;
// 4. 新的队首 siftDown
this . _siftDown ( 0 );
return ret ;
}
toString () {
console . log ( this . data );
}
_swap ( i , j ) {
[ this . data [ i ], this . data [ j ]] = [ this . data [ j ], this . data [ i ]];
}
_parent ( index ) {
return Math . floor (( index - 1 ) / 2 );
}
_leftChild ( index ) {
return 2 * index + 1 ;
}
_rightChild ( index ) {
return 2 * index + 2 ;
}
_siftUp ( k ) {
// 上浮操作,只要子元素优先级比父节点大,父子交换位置,一直向上直到根节点
while ( k > 0 && this . compare ( this . data [ k ], this . data [ this . _parent ( k )])) {
this . _swap ( k , this . _parent ( k ));
k = this . _parent ( k );
}
}
_siftDown ( k ) {
// 存在左孩子的时候
while ( this . _leftChild ( k ) < this . size ) {
let j = this . _leftChild ( k );
// 存在右孩子而且右孩子比左孩子大
if ( this . _rightChild ( k ) < this . size &&
this . compare ( this . data [ this . _rightChild ( k )], this . data [ j ])) {
j ++ ;
}
if ( this . compare ( this . data [ k ], this . data [ j ]))
return ;
// 父节点比子节点小,交换位置
this . _swap ( k , j );
// 继续下沉

11. 实现优先队列

有了最大堆作铺垫,实现优先队列就易如反掌,废话不多说,直接放上代码。
可能会有人问 : 你怎么保证这个优先队列是正确的呢 ?
我们不妨来做一下测试 :
k = j ;
}
}
}
class PriorityQueue {
// max 为优先队列的容量
constructor ( max , compare ) {
this . max = max ;
this . compare = compare ;
this . maxHeap = new MaxHeap ([], compare );
}
getSize () {
return this . maxHeap . getSize ();
}
isEmpty () {
return this . maxHeap . isEmpty ();
}
getFront () {
return this . maxHeap . findMax ();
}
enqueue ( e ) {
// 比当前最高的优先级的还要高,直接不处理
if ( this . getSize () === this . max ) {
if ( this . compare ( e , this . getFront ())) return ;
this . dequeue ();
}
return this . maxHeap . add ( e );
}
dequeue () {
if ( this . getSize () === 0 ) return null ;
return this . maxHeap . extractMax ();
}
} 结果如下 :
可见,这个优先队列的功能初步满足了我们的预期。

12. K 个高频元素

给定一个非空的整数数组,返回其中出现频率前 k 高的元素。
示例 :
说明 :
可以假设给定的 k 总是合理的,且 1 k 数组中不相同的元素的个数。
算法的时间复杂度必须优于 O(n log n) , n 是数组的大小。
思路
首先要做的肯定是统计频率,那之后如何来选取频率前 K 个元素同时又保证时间复杂度小于 O(n log n)
呢?
当然,这是一道典型的考察优先队列的题,利用容量为 K 的优先队列每次踢出不符合条件的值,那么最 后剩下的即为所求。整个时间复杂度成为 O n log K ),明显是小于 O(n log n) 的。
既然是优先队列,就涉及到如何来定义优先级的问题。
倘若我们以高频率为高优先级,那么队首始终是高频率的元素,因此每次出队是踢出出现频率最高的元 素,假设优先队列容量为 K ,那照这么做,剩下的是频率最低的 K 个元素,显然不符合题意。
因此,我们需要的是每次出队时踢出 频率最低的元素 ,这样最后剩下来的就是频率最高 K 个元素。
是不是我们为了踢出 频率最低的元素 ,还要重新写一个小顶堆的实现呢?
完全不需要!就像我刚才所说的,合理地定义这个优先级的比较逻辑即可。接下来我们来具体实现一
下。
let pq = new PriorityQueue ( 3 );
pq . enqueue ( 1 );
pq . enqueue ( 333 );
console . log ( pq . dequeue ());
console . log ( pq . dequeue ());
pq . enqueue ( 3 );
pq . enqueue ( 6 );
pq . enqueue ( 62 );
console . log ( pq . dequeue ());
console . log ( pq . dequeue ());
console . log ( pq . dequeue ());
333
1
62
6
3
输入 : nums = [1,1,1,2,2,3], k = 2
输出 : [1,2] 代码实现

13. 合并 K 个排序链表

合并 k 个排序链表,返回合并后的排序链表。请分析和描述算法的复杂度。
示例 :
这一题我们之前在链表实现过,殊不知,它也可以利用优先队列完美解决。
var topKFrequent = function ( nums , k ) {
let map = {};
let pq = new PriorityQueue ( k , ( a , b ) => map [ a ] - map [ b ] < 0 );
for ( let i = 0 ; i < nums . length ; i ++ ) {
if ( ! map [ nums [ i ]]) map [ nums [ i ]] = 1 ;
else map [ nums [ i ]] = map [[ nums [ i ]]] + 1 ;
}
let arr = Array . from ( new Set ( nums ));
for ( let i = 0 ; i < arr . length ; i ++ ) {
pq . enqueue ( arr [ i ]);
}
return pq . maxHeap . data ;
};
输入 :
[
1->4->5,
1->3->4,
2->6
]
输出 : 1->1->2->3->4->4->5->6
/**
* @param {ListNode[]} lists
* @return {ListNode}
*/
var mergeKLists = function ( lists ) {
let dummyHead = p = new ListNode ();
// 定义优先级的函数,重要!
let pq = new PriorityQueue ( lists . length , ( a , b ) => a . val <= b . val );
// 将头结点推入优先队列
for ( let i = 0 ; i < lists . length ; i ++ )
if ( lists [ i ]) pq . enqueue ( lists [ i ]);
// 取出值最小的节点,如果 next 不为空,继续推入队列
while ( pq . getSize ()) {
let min = pq . dequeue ();
p . next = min ;
p = p . next ;
if ( min . next ) pq . enqueue ( min . next );
}
return dummyHead . next ;
};

14. 什么是双端队列?

双端队列是一种特殊的队列,首尾都可以添加或者删除元素,是一种加强版的队列。
JS 中的数组就是一种典型的双端队列。 push pop 方法分别从 尾部 添加和删除元素, unshift shift
法分别从 首部 添加和删除元素。

15. 滑动窗口最大值

给定一个数组 nums ,有一个大小为 k 的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到
在滑动窗口内的 k 个数字。滑动窗口每次只向右移动一位。
返回滑动窗口中的最大值。
示例 :
要求 : 时间复杂度应为线性。
思路
这是典型地使用双端队列求解的问题。
建立一个双端队列 window ,每次 push 进来一个新的值,就将 window 中目前 前面所有比它小的值 都删
除。利用双端队列的特性,可以从后往前遍历,遇到小的就删除之,否则停止。
这样可以保证队首始终是最大值,因此寻找最大值的时间复杂度可以降到 O(1) 。由于 window 中会有越
来越多的值被淘汰,因此整体的时间复杂度是线性的。
代码实现
代码非常的简洁,但是如果要写出 bug free 的代码还是有相当的难度的,希望你能自己独立实现一遍。
输入 : nums = [1,3,-1,-3,5,3,6,7], k = 3
输出 : [3,3,5,5,6,7]
解释 :
滑动窗口的位置 最大值
--------------- -----
[1 3 -1] -3 5 3 6 7 3
1 [3 -1 -3] 5 3 6 7 3
1 3 [-1 -3 5] 3 6 7 5
1 3 -1 [-3 5 3] 6 7 5
1 3 -1 -3 [5 3 6] 7 6
1 3 -1 -3 5 [3 6 7] 7
var maxSlidingWindow = function ( nums , k ) {
// 异常处理
if ( nums . length === 0 || ! k ) return [];
let window = [], res = [];
for ( let i = 0 ; i < nums . length ; i ++ ) {
// 先把滑动窗口之外的踢出
if ( window [ 0 ] !== undefined && window [ 0 ] <= i - k ) window . shift ();

16. 栈实现队列

使用栈实现队列的下列操作:
push(x) -- 将一个元素放入队列的尾部。 pop() -- 从队列首部移除元素。 peek() -- 返回队列首部的元
素。 empty() -- 返回队列是否为空。
示例 :
思路
既然栈是 先进后出 , 要想得到 先进先出 的效果,我们不妨用两个栈。
当进行 push 操作时, push stack1 ,而进行 pop peek 的操作时,我们通过 stack2
当然这其中有一个特殊情况,就是 stack2 是空,如何来进行 pop peek ? 很简单,把 stack1 中的元
素依次 pop 并推入 stack2 中,然后正常地操作 stack2 即可,如下图所示 :
// 保证队首是最大的
while ( nums [ window [ window . length - 1 ]] <= nums [ i ]) window . pop ();
window . push ( i );
if ( i >= k - 1 ) res . push ( nums [ window [ 0 ]])
}
return res ;
};
let queue = new MyQueue ();
queue . push ( 1 );
queue . push ( 2 );
queue . peek (); // 返回 1
queue . pop (); // 返回 1
queue . empty (); // 返回 fals 这就就能保证先入先出的效果了。
代码实现

17. 队列实现栈

和上一题的效果刚好相反,用队列 先进先出 的方式来实现 先进后出 的效果。
思路
以上面的队列为例, push 操作好说,直接从在队列末尾推入。但 pop peek 呢?
回到我们的目标,我们的目标是拿到队尾的值,也就是 3 。这就好办了,我们让前面的元素统统出队,
只留队尾元素即可,剩下的元素让另外一个队列保存。
var MyQueue = function () {
this . stack1 = [];
this . stack2 = [];
};
MyQueue . prototype . push = function ( x ) {
this . stack1 . push ( x );
};
// stack1 的元素转移到 stack2
MyQueue . prototype . transform = function () {
while ( this . stack1 . length ) {
this . stack2 . push ( this . stack1 . pop ());
}
}
MyQueue . prototype . pop = function () {
if ( ! this . stack2 . length ) this . transform ();
return this . stack2 . pop ();
};
MyQueue . prototype . peek = function () {
if ( ! this . stack2 . length ) this . transform ();
return this . stack2 [ this . stack2 . length - 1 ];
};
MyQueue . prototype . empty = function () {
return ! this . stack1 . length && ! this . stack2 . length ;
}; 代码实现
实现过程中,值得注意的一点是, queue1 始终保存前面的元素, queue2 始终保存队尾元素(即栈顶元素
但是当 push 的时候有一个陷阱,就是当 queue2 已经有元素的时候,不能将新值 push queue1 ,因
为此时的 栈顶元素 应该更新。此时对于新的值来说,应先 push queue2 , 然后将 旧的栈顶 queue2
出队,推入 queue1 ,这样就实现了 更新栈顶 的操作。
var MyStack = function () {
this . queue1 = [];
this . queue2 = [];
};
MyStack . prototype . push = function ( x ) {
if ( ! this . queue2 . length ) this . queue1 . push ( x );
else {
// queue2 已经有值
this . queue2 . push ( x );
// 旧的栈顶移到 queue1
this . queue1 . push ( this . queue2 . shift ());
}
};
MyStack . prototype . transform = function () {
while ( this . queue1 . length !== 1 ) {
this . queue2 . push ( this . queue1 . shift ())
}
// queue2 保存了前面的元素
// queue1 queue2 交换
// 现在 queue1 包含前面的元素, queue2 里面就只包含队尾的元素
let tmp = this . queue1 ;
this . queue1 = this . queue2 ;
this . queue2 = tmp ;
}
MyStack . prototype . pop = function () {
if ( ! this . queue2 . length ) this . transform ();
return this . queue2 . shift ();
};
MyStack . prototype . top = function () {
if ( ! this . queue2 . length ) this . transform ();
return this . queue2 [ 0 ];
};

二叉树

1. 前序遍历

示例 :
递归方式
非递归方式
MyStack . prototype . empty = function () {
return ! this . queue1 . length && ! this . queue2 . length ;
};
示例 :
输入 : [1,null,2,3]
1
\
2
/
3
输出 : [1,2,3]
/**
* @param {TreeNode} root
* @return {number[]}
*/
var preorderTraversal = function ( root ) {
let arr = [];
let traverse = ( root ) => {
if ( root == null ) return ;
arr . push ( root . val );
traverse ( root . left );
traverse ( root . right );
}
traverse ( root );
return arr ;
};

2. 中序遍历

给定一个二叉树,返回它的中序 遍历。
示例 :
递归方式 :
非递归方式
var preorderTraversal = function ( root ) {
if ( root == null ) return [];
let stack = [], res = [];
stack . push ( root );
while ( stack . length ) {
let node = stack . pop ();
res . push ( node . val );
// 左孩子后进先出,进行先左后右的深度优先遍历
if ( node . right ) stack . push ( node . right );
if ( node . left ) stack . push ( node . left );
}
return res ;
};
输入 : [1,null,2,3]
1
\
2
/
3
输出 : [1,3,2]
/**
* @param {TreeNode} root
* @return {number[]}
*/
var inorderTraversal = function ( root ) {
let arr = [];
let traverse = ( root ) => {
if ( root == null ) return ;
traverse ( root . left );
arr . push ( root . val );
traverse ( root . right );
}
traverse ( root );
return arr ;
};
var inorderTraversal = function ( root ) {
if ( root == null ) return [];

3. 后序遍历

给定一个二叉树,返回它的 后序 遍历。
示例 :
递归方式
非递归方式
let stack = [], res = [];
let p = root ;
while ( stack . length || p ) {
while ( p ) {
stack . push ( p );
p = p . left ;
}
let node = stack . pop ();
res . push ( node . val );
p = node . right ;
}
return res ;
};
输入 : [1,null,2,3]
1
\
2
/
3
输出 : [3,2,1]
/**
* @param {TreeNode} root
* @return {number[]}
*/
var postorderTraversal = function ( root ) {
let arr = [];
let traverse = ( root ) => {
if ( root == null ) return ;
traverse ( root . left );
traverse ( root . right );
arr . push ( root . val );
}
traverse ( root );
return arr
};
var postorderTraversal = function ( root ) {

4. 最大深度

给定一个二叉树,找出其最大深度。
二叉树的深度为根节点到最远叶子节点的最长路径上的节点数。
说明 : 叶子节点是指没有子节点的节点。
示例: 给定二叉树 [3,9,20,null,null,15,7]
递归实现
实现非常简单,直接贴出代码 :
if ( root == null ) return [];
let stack = [], res = [];
let visited = new Set ();
let p = root ;
while ( stack . length || p ) {
while ( p ) {
stack . push ( p );
p = p . left ;
}
let node = stack [ stack . length - 1 ];
// 如果右孩子存在,而且右孩子未被访问
if ( node . right && ! visited . has ( node . right )) {
p = node . right ;
visited . add ( node . right );
} else {
res . push ( node . val );
stack . pop ();
}
}
return res ;
};
3
/ \
9 20
/ \
15 7
/**
* @param {TreeNode} root
* @return {number}
*/
var maxDepth = function ( root ) {
// 递归终止条件
if ( root == null ) return 0 ;
return Math . max ( maxDepth ( root . left ) + 1 , maxDepth ( root . right ) + 1 );
}; 非递归实现
采用层序遍历的方式,非常好理解。

5. 最小深度

给定一个二叉树,找出其最小深度。
最小深度是从根节点到最近叶子节点的最短路径上的节点数量。
说明 : 叶子节点是指没有子节点的节点。
示例 :
给定二叉树 [3,9,20,null,null,15,7]:
返回它的最小深度 2.
递归实现
在实现的过程中,如果按照最大深度的方式来做会出现一个陷阱,即 :
var maxDepth = function ( root ) {
if ( root == null ) return 0 ;
let queue = [ root ];
let level = 0 ;
while ( queue . length ) {
let size = queue . length ;
while ( size -- ) {
let front = queue . shift ();
if ( front . left ) queue . push ( front . left );
if ( front . right ) queue . push ( front . right );
}
// level ++ 后的值代表着现在已经处理完了几层节点
level ++ ;
}
return level ;
};
3
/ \
9 20
/ \
15 7
/**
* @param {TreeNode} root
* @return {number}
*/
var minDepth = function ( root ) {
// 递归终止条件
if ( root == null ) return 0 ;
return Math . min ( minDepth ( root . left ) + 1 , minDepth ( root . right ) + 1 );
}; root 节点有一个孩子为空的时候,此时返回的是 1 , 但这是不对的,最小高度指的是 根节点到最近叶
子节点 的最小路径,而不是到一个空节点的路径。
因此我们需要做如下的调整 :
这样程序便能正常工作了。
非递归实现
类似于 最大高度 问题,采用了层序遍历的方式,很容易理解。

6. 对称二叉树

给定一个二叉树,检查它是否是镜像对称的。
例如,二叉树 [1,2,2,3,4,4,3] 是对称的。
var minDepth = function ( root ) {
if ( root == null ) return 0 ;
// 左右孩子都不为空才能像刚才那样调用
if ( root . left && root . right )
return Math . min ( minDepth ( root . left ), minDepth ( root . right )) + 1 ;
// 右孩子为空了,直接忽略之
else if ( root . left )
return minDepth ( root . left ) + 1 ;
// 左孩子为空,忽略
else if ( root . right )
return minDepth ( root . right ) + 1 ;
// 两个孩子都为空,说明到达了叶子节点,返回 1
else return 1 ;
};
var minDepth = function ( root ) {
if ( root == null ) return 0 ;
let queue = [ root ];
let level = 0 ;
while ( queue . length ) {
let size = queue . length ;
while ( size -- ) {
let front = queue . shift ();
// 找到叶子节点
if ( ! front . left && ! front . right ) return level + 1 ;
if ( front . left ) queue . push ( front . left );
if ( front . right ) queue . push ( front . right );
}
// level ++ 后的值代表着现在已经处理完了几层节点
level ++ ;
}
return level ;
}; 1
/ \
2 2
/ \ / \
3 4 4 3
但是下面这个 [1,2,2,null,3,null,3] 则不是镜像对称的 :
1
/ \
2 2
\ \
3 3
递归实现
递归方式的代码是非常干练和优雅的,希望你先自己实现一遍,然后对比改进。
/**
* @param {TreeNode} root
* @return {boolean}
*/
var isSymmetric = function ( root ) {
let help = ( node1 , node2 ) => {
// 都为空
if ( ! node1 && ! node2 ) return true ;
// 一个为空一个不为空,或者两个节点值不相等
if ( ! node1 || ! node2 || node1 . val !== node2 . val ) return false ;
return help ( node1 . left , node2 . right ) && help ( node1 . right , node2 . left );
}
if ( root == null ) return true ;
return help ( root . left , root . right );
};
非递归实现
用一个队列保存访问过的节点,每次取出两个节点,进行比较。
var isSymmetric = function ( root ) {
if ( root == null ) return true ;
let queue = [ root . left , root . right ];
let node1 , node2 ;
while ( queue . length ) {
node1 = queue . shift ();
node2 = queue . shift ();
// 两节点均为空
if ( ! node1 && ! node2 ) continue ;
// 一个为空一个不为空,或者两个节点值不相等
if ( ! node1 || ! node2 || node1 . val !== node2 . val ) return false ;
queue . push ( node1 . left );
queue . push ( node2 . right );
queue . push ( node1 . right );

7. 二叉树的最近公共祖先

对于一个普通的二叉树 : root = [3,5,1,6,2,0,8,null,null,7,4]
思路分析
思路一 : 首先遍历一遍二叉树,记录下每个节点的父节点。然后对于题目给的 p 节点,根据这个记录表
不断的找 p 的上层节点,直到根,记录下 p 上层节点集合 。然后对于 q 节点,根据记录不断向上找它
的上层节点,在寻找的过程中一旦发现 这个上层节点已经包含在刚刚的集合 中,说明发现了最近公共祖
先,直接返回。
思路二 : 深度优先遍历二叉树,如果当前节点为 p 或者 q ,直接返回这个节点,否则查看左右孩子,左孩
子中不包含 p 或者 q 则去找右孩子,右孩子不包含 p 或者 q 就去找左孩子,剩下的情况就是 左右孩子中
都存在 p 或者 q , 那么此时直接返回这个节点。
祖先节点集合法
queue . push ( node2 . left );
}
return true ;
};
输入 : root = [3,5,1,6,2,0,8,null,null,7,4], p = 5, q = 1
输出 : 3
解释 : 节点 5 和节点 1 的最近公共祖先是节点 3
/**
* @param {TreeNode} root
* @param {TreeNode} p
* @param {TreeNode} q
* @return {TreeNode}
*/
var lowestCommonAncestor = function ( root , p , q ) {
if ( root == null || root == p || root == q ) return root ;
let set = new Set ();
let map = new WeakMap ();
let queue = [];
queue . push ( root );
// 层序遍历 可以看到整棵二叉树遍历了一遍,时间复杂度大致是 O(n) ,但是由于哈希表的存在,空间复杂度比较
高,接下来我们来用另一种遍历的方式,可以大大减少空间的开销。
深度优先遍历法
代码非常简洁、美观,不过更重要的是体会其中递归调用的过程,代码是自顶向下执行的,我建议大家
自底向上 的方式来理解它,即从最左下的节点开始分析,相信你会很好的理解整个过程。

8. 二叉搜索树的最近公共祖先

给定如下二叉搜索树 : root = [6,2,8,0,4,7,9,null,null,3,5]
while ( queue . length ) {
let size = queue . length ;
while ( size -- ) {
let front = queue . shift ();
if ( front . left ) {
queue . push ( front . left );
// 记录父亲节点
map . set ( front . left , front );
}
if ( front . right ) {
queue . push ( front . right );
// 记录父亲节点
map . set ( front . right , front );
}
}
}
// 构造 p 的上层节点集合
while ( p ) {
set . add ( p );
p = map . get ( p );
}
while ( q ) {
// 一旦发现公共节点重合,直接返回
if ( set . has ( q )) return q ;
q = map . get ( q );
}
};
var lowestCommonAncestor = function ( root , p , q ) {
if ( root == null || root == p || root == q ) return root ;
let left = lowestCommonAncestor ( root . left , p , q );
let right = lowestCommonAncestor ( root . right , p , q );
if ( left == null ) return right ;
else if ( right == null ) return left ;
return root ;
};
输入 : root = [6,2,8,0,4,7,9,null,null,3,5], p = 2, q = 8
输出 : 6
解释 : 节点 2 和节点 8 的最近公共祖先是 6 实现
二叉搜索树作为一种特殊的二叉树,当然是可以用上述的两种方式来实现的。
不过借助二叉搜索树有序的特性,我们也可以写出另外一个版本的深度优化遍历。
同时也可以采用非递归的方式 :
是不是被二叉树精简而优雅的代码惊艳到了呢?希望你能好好体会其中遍历的过程,然后务必自己独立
实现一遍,保证对这种数据结构足够熟悉,增强自己的编程内力。

9. 二叉树的直径

给定一棵二叉树,你需要计算它的直径长度。一棵二叉树的直径长度是任意两个结点路径长度中的最大
值。这条路径可能穿过根结点。
示例 : 给定二叉树
/**
* @param {TreeNode} root
* @param {TreeNode} p
* @param {TreeNode} q
* @return {TreeNode}
*/
var lowestCommonAncestor = function ( root , p , q ) {
if ( root == null || root == p || root == q ) return root ;
// root.val p q 都大,找左孩子
if ( root . val > p . val && root . val > q . val )
return lowestCommonAncestor ( root . left , p , q );
// root.val p q 都小,找右孩子
if ( root . val < p . val && root . val < q . val )
return lowestCommonAncestor ( root . right , p , q );
else
return root ;
};
var lowestCommonAncestor = function ( root , p , q ) {
let node = root ;
while ( node ) {
if ( p . val > node . val && q . val > node . val )
node = node . right ;
else if ( p . val < node . val && q . val < node . val )
node = node . left ;
else return node ;
}
};
1
/ \
2 3
/ \
4 5 返回 3, 它的长度是路径 [4,2,1,3] 或者 [5,2,1,3]
注意:两结点之间的路径长度是以它们之间边的数目表示。
思路
所谓的 求直径 , 本质上是求树中节点左右子树 高度和的最大值
注意,这里我说的是 树中节点 , 并非根节点。因为会有这样一种情况 :
1
/
2
/ \
4 5
/ \
8 6
\
7
那这个时候,直径最大的路径是 : 8 -> 4 -> 2-> 5 -> 6 -> 7 。交界的元素并不是根节点。这是这个问题特
别需要注意的地方,不然无解。
初步求解
目标已经确定了,求树中节点左右子树 高度和的最大值 。开干!
/**
* @param {TreeNode} root
* @return {number}
*/
var diameterOfBinaryTree = function ( root ) {
// 求最大深度
let maxDepth = ( node ) => {
if ( node == null ) return 0 ;
return Math . max ( maxDepth ( node . left ) + 1 , maxDepth ( node . right ) + 1 );
}
let help = ( node ) => {
if ( node == null ) return 0 ;
let rootSum = maxDepth ( node . left ) + maxDepth ( node . right );
let childSum = Math . max ( help ( node . left ), help ( node . right ));
return Math . max ( rootSum , childSum );
}
if ( root == null ) return 0 ;
return help ( root );
};
这样一段代码放到 LeetCode 是可以通过,但时间上却不让人很满意,为什么呢?
因为在反复调用 maxDepth 的过程,对树中的一些节点增加了很多不必要的访问。比如: 我们看什么时候访问节点 8 maxDepth( 节点 2) 的时候访问, maxDepth( 节点 4) 的时候又会访问,如
果节点层级更高,重复访问的次数更加频繁,剩下的节点 6 、节点 7 都是同理。每一个节点访问的次数
大概是 O(logK)( 设当前节点在第 K ) 。那能不能把这个频率降到 O(1) 呢?
答案是肯定的,接下来我们来优化这个算法。
优化解法
在这个过程中设置了一个 max 全局变量,深度优先遍历这棵树,每遍历完一个节点就更新 max ,并通过
返回值的方式 自底向上 把当前节点左右子树的最大高度传给父函数使用,使得每个节点只需访问一次即
可。
现在提交我们优化后的代码,时间消耗明显降低。

10. 二叉树的所有路径

给定一个二叉树,返回所有从根节点到叶子节点的路径。
说明 : 叶子节点是指没有子节点的节点。
示例 :
1
/
2
/ \
4 5
/ \
8 6
\
7
var diameterOfBinaryTree = function ( root ) {
let help = ( node ) => {
if ( node == null ) return 0 ;
let left = node . left ? help ( node . left ) + 1 : 0 ;
let right = node . right ? help ( node . right ) + 1 : 0 ;
let cur = left + right ;
if ( cur > max ) max = cur ;
// 这个返回的操作相当关键
return Math . max ( left , right );
}
let max = 0 ;
if ( root == null ) return 0 ;
help ( root );
return max ;
}; 输入 :
1
/ \
2 3
\
5
输出 : ["1->2->5", "1->3"]
解释 : 所有根节点到叶子节点的路径为 : 1->2->5, 1->3
递归解法
利用 DFS( 深度优先遍历 ) 的方式进行遍历。
/**
* @param {TreeNode} root
* @return {string[]}
*/
var binaryTreePaths = function ( root ) {
let path = [];
let res = [];
let dfs = ( node ) => {
if ( node == null ) return ;
path . push ( node );
dfs ( node . left );
dfs ( node . right );
if ( ! node . left && ! node . right )
res . push ( path . map ( item => item . val ). join ( '->' ));
// 注意每访问完一个节点记得把它从 path 中删除,达到回溯效果
path . pop ();
}
dfs ( root );
return res ;
};
非递归解法
接下来我们通过 非递归的后序遍历 的方式来实现一下 , 顺便复习一下后序遍历的实现。
var binaryTreePaths = function ( root ) {
if ( root == null ) return [];
let stack = [];
let p = root ;
let set = new Set ();
res = [];
while ( stack . length || p ) {
while ( p ) {
stack . push ( p );
p = p . left ;
}
let node = stack [ stack . length - 1 ];

11. 二叉树的最大路径和

给定一个非空二叉树,返回其最大路径和。
本题中,路径被定义为一条从树中任意节点出发,达到任意节点的序列。该路径至少包含一个节点,且
不一定经过根节点。
示例 :
递归解
// 叶子节点
if ( ! node . right && ! node . left ) {
res . push ( stack . map ( item => item . val ). join ( '->' ));
}
// 右孩子存在,且右孩子未被访问
if ( node . right && ! set . has ( node . right )) {
p = node . right ;
set . add ( node . right );
} else {
stack . pop ();
}
}
return res ;
};
输入 : [-10,9,20,null,null,15,7]
-10
/ \
9 20
/ \
15 7
输出 : 42
/**
* @param {TreeNode} root
* @return {number}
*/
var maxPathSum = function ( root ) {
let help = ( node ) => {
if ( node == null ) return 0 ;
let left = Math . max ( help ( node . left ), 0 );
let right = Math . max ( help ( node . right ), 0 );
let cur = left + node . val + right ;
// 如果发现某一个节点上的路径值比 max 还大,则更新 max
if ( cur > max ) max = cur ;
// left right 永远是 " 一根筋 " ,中间不会有转折
return Math . max ( left , right ) + node . val ;
}
let max = Number . MIN_SAFE_INTEGER ;
help ( root );

12. 验证二叉搜索树

给定一个二叉树,判断其是否是一个有效的二叉搜索树。
假设一个二叉搜索树具有如下特征:
节点的左子树只包含小于当前节点的数。 节点的右子树只包含大于当前节点的数。 所有左子树和右子树
自身必须也是二叉搜索树。
示例 1 :
方法一 : 中序遍历
通过中序遍历,保存前一个节点的值,扫描到当前节点时,和前一个节点的值比较,如果大于前一个节
点,则满足条件,否则不是二叉搜索树。
方法二 : 限定上下界进行 DFS
二叉搜索树每一个节点的值,都有一个 上界和下界 ,深度优先遍历的过程中,如果访问左孩子,则通过
当前节点的值来 更新左孩子节点的上界 ,同时访问右孩子,则 更新右孩子的下界 ,只要出现节点值越界
的情况,则不满足二叉搜索树的条件。
return max ;
};
输入 :
2
/ \
1 3
输出 : true
/**
* @param {TreeNode} root
* @return {boolean}
*/
var isValidBST = function ( root ) {
let prev = null ;
const help = ( node ) => {
if ( node == null ) return true ;
if ( ! help ( node . left )) return false ;
if ( prev !== null && prev >= node . val ) return false ;
// 保存当前节点,为下一个节点的遍历做准备
prev = node . val ;
return help ( node . right );
}
return help ( root );
};
parent
/ \
left right 假设这是一棵巨大的二叉树的一个部分 (parent left right 都是实实在在的节点 ) ,那么全部的节点排完
序一定是这样 :
...left, parent, right...
可以看到左孩子的 最严格的上界 是该节点 , 同时 , 右孩子的 最严格的下界 也是该节点。我们按照这样的规
则来进行更新上下界。
递归实现 :
var isValidBST = function ( root ) {
const help = ( node , max , min ) => {
if ( node == null ) return true ;
if ( node . val >= max || node . val <= min ) return false ;
// 左孩子更新上界,右孩子更新下界,相当于边界要求越来越苛刻
return help ( node . left , node . val , min )
&& help ( node . right , max , node . val );
}
return help ( root , Number . MAX_SAFE_INTEGER , Number . MIN_SAFE_INTEGER );
};
非递归实现 :
var isValidBST = function ( root ) {
if ( root == null ) return true ;
let stack = [ root ];
let min = Number . MIN_SAFE_INTEGER ;
let max = Number . MAX_SAFE_INTEGER ;
root . max = max ; root . min = min ;
while ( stack . length ) {
let node = stack . pop ();
if ( node . val <= node . min || node . val >= node . max )
return false ;
if ( node . left ) {
stack . push ( node . left );
// 更新上下界
node . left . max = node . val ;
node . left . min = node . min ;
}
if ( node . right ) {
stack . push ( node . right );
// 更新上下界
node . right . max = node . max ;
node . right . min = node . val ;
}
}
return true ;
};

13. 将有序数组转换为二叉搜索树

将一个按照升序排列的有序数组,转换为一棵高度平衡二叉搜索树。
本题中,一个高度平衡二叉树是指一个二叉树每个节点 的左右两个子树的高度差的绝对值不超过 1
示例 :
递归实现
递归程序比较好理解,不断地调用 help 完成整棵树树的构建。那如何用非递归来解决呢?我觉得这是一
个非常值得大家思考的问题。希望你能动手试一试,如果实在想不出来,可以参考下面我写的非递归版
本。
其实思路跟递归的版本是一样的,只不过实现起来是用栈来实现 DFS 的效果。
给定有序数组 : [-10,-3,0,5,9],
一个可能的答案是: [0,-3,9,-10,null,5] ,它可以表示下面这个高度平衡二叉搜索树:
0
/ \
-3 9
/ /
-10 5
/**
* @param {number[]} nums
* @return {TreeNode}
*/
var sortedArrayToBST = function ( nums ) {
let help = ( start , end ) => {
if ( start > end ) return null ;
if ( start === end ) return new TreeNode ( nums [ start ]);
let mid = Math . floor (( start + end ) / 2 );
// 找出中点建立节点
let node = new TreeNode ( nums [ mid ]);
node . left = help ( start , mid - 1 );
node . right = help ( mid + 1 , end );
return node ;
}
return help ( 0 , nums . length - 1 );
};
/**
* @param {number[]} nums
* @return {TreeNode}
*/
var sortedArrayToBST = function ( nums ) {
if ( nums . length === 0 ) return null ;
let mid = Math . floor ( nums . length / 2 );
let root = new TreeNode ( nums [ mid ]);
// 说明 : 1. index 指的是当前元素在数组中的索引
// 2. 每一个节点的值都是区间中点,那么 start 属性就是这个区间的起点, end 为其终点
root . index = mid ; root . start = 0 ; root . end = nums . length - 1 ;

14. 二叉树展开为链表

给定一个二叉 ( 搜索 ) 树,原地将它展开为链表。
例如,给定二叉树
将其展开为:
let stack = [ root ];
while ( stack . length ) {
let node = stack . pop ();
// node 出来了,它本身包含了一个区间, [start, ..., index, ... end]
// 下面判断 [node.start, node.index - 1] 之间是否还有开发的余地
if ( node . index - 1 >= node . start ) {
let leftMid = Math . floor (( node . start + node . index ) / 2 );
let leftNode = new TreeNode ( nums [ leftMid ]);
node . left = leftNode ;
// 初始化新节点的区间起点、终点和索引
leftNode . start = node . start ;
leftNode . end = node . index - 1 ;
leftNode . index = leftMid ;
stack . push ( leftNode );
}
// 中间夹着 node.index, 已经有元素了,这个位置不能再开发
// 下面判断 [node.index + 1, node.end] 之间是否还有开发的余地
if ( node . end >= node . index + 1 ) {
let rightMid = Math . floor (( node . index + 1 + node . end ) / 2 );
let rightNode = new TreeNode ( nums [ rightMid ]);
node . right = rightNode ;
// 初始化新节点的区间起点、终点和索引
rightNode . start = node . index + 1 ;
rightNode . end = node . end ;
rightNode . index = rightMid ;
stack . push ( rightNode );
}
}
return root ;
};
1
/ \
2 5
/ \ \
3 4 6 1
\
2
\
3
\
4
\
5
\
6
递归方式
采用后序遍历,遍历完左右孩子我们要做些什么呢?用下面的图来演示一下:
/**
* @param {TreeNode} root
* @return {void} Do not return anything, modify root in-place instead.
*/
var flatten = function ( root ) {
if ( root == null ) return ;
flatten ( root . left );
flatten ( root . right );
if ( root . left ) {
let p = root . left ;
while ( p . right ) {
p = p . right ;
}
p . right = root . right ;
root . right = root . left ;
root . left = null ;
}
};
非递归方式
采用非递归的后序遍历方式,思路跟之前的完全一样。

15. 不同的二叉搜索树II

给定一个整数 n ,生成所有由 1 ... n 为节点所组成的二叉搜索树。
示例 :
var flatten = function ( root ) {
if ( root == null ) return ;
let stack = [];
let visited = new Set ();
let p = root ;
// 开始后序遍历
while ( stack . length || p ) {
while ( p ) {
stack . push ( p );
p = p . left ;
}
let node = stack [ stack . length - 1 ];
// 如果右孩子存在,而且右孩子未被访问
if ( node . right && ! visited . has ( node . right )) {
p = node . right ;
visited . add ( node . right );
} else {
// 以下为思路图中关键逻辑
if ( node . left ) {
let p = node . left ;
while ( p . right ) {
p = p . right ;
}
p . right = node . right ;
node . right = node . left ;
node . left = null ;
}
stack . pop ();
}
}
};
输入 : 3
输出 :
[
[1,null,3,2],
[3,2,null,1],
[3,1,null,null,2],
[2,1,3],
[1,null,2,null,3]
]
解释 :
以上的输出对应以下 5 种不同结构的二叉搜索树:
1 3 3 2 1
\ / / / \ \
3 2 1 1 3 2
/ / \ \
2 1 2 3 递归解法
递归创建子树
/**
* @param {number} n
* @return {TreeNode[]}
*/
var generateTrees = function ( n ) {
let help = ( start , end ) => {
if ( start > end ) return [ null ];
if ( start === end ) return [ new TreeNode ( start )];
let res = [];
for ( let i = start ; i <= end ; i ++ ) {
// 左孩子集
let leftNodes = help ( start , i - 1 );
// 右孩子集
let rightNodes = help ( i + 1 , end );
for ( let j = 0 ; j < leftNodes . length ; j ++ ) {
for ( let k = 0 ; k < rightNodes . length ; k ++ ) {
let root = new TreeNode ( i );
root . left = leftNodes [ j ];
root . right = rightNodes [ k ];
res . push ( root );
}
}
}
return res ;
}
if ( n == 0 ) return [];
return help ( 1 , n );
};
非递归解法
var generateTrees = function ( n ) {
let clone = ( node , offset ) => {
if ( node == null ) return null ;
let newnode = new TreeNode ( node . val + offset );
newnode . left = clone ( node . left , offset );
newnode . right = clone ( node . right , offset );
return newnode ;
}
if ( n == 0 ) return [];
let dp = [];
dp [ 0 ] = [ null ];
// i 是子问题中的节点个数,子问题 : [1], [1,2], [1,2,3]... 逐步递增,直到 [1,2,3...,n]
for ( let i = 1 ; i <= n ; i ++ ) {
dp [ i ] = [];
for ( let j = 1 ; j <= i ; j ++ ) {
// 左子树集
for ( let leftNode of dp [ j - 1 ]) {
// 右子树集 for ( let rightNode of dp [ i - j ]) {
let node = new TreeNode ( j );
// 左子树结构共享
node . left = leftNode ;
// 右子树无法共享,但可以借用节点个数相同的树,每个节点增加一个偏移量
node . right = clone ( rightNode , j );
dp [ i ]. push ( node );
}
}
}
}
return dp [ n ];
};
  • 4
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值