01栈&队列
🌐

01栈&队列

1.用栈实现队列

题目

请你仅使用两个栈实现先入先出队列。队列应当支持一般队列支持的所有操作(pushpoppeekempty):
实现 MyQueue 类:
  • void push(int x) 将元素 x 推到队列的末尾
  • int pop() 从队列的开头移除并返回元素
  • int peek() 返回队列开头的元素
  • boolean empty() 如果队列为空,返回 true ;否则,返回 false
说明:
  • 你 只能 使用标准的栈操作 —— 也就是只有 push to toppeek/pop from topsize, 和 is empty 操作是合法的。
  • 你所使用的语言也许不支持栈。你可以使用 list 或者 deque(双端队列)来模拟一个栈,只要是标准的栈操作即可。
示例 1:
输入: ["MyQueue", "push", "push", "peek", "pop", "empty"] [[], [1], [2], [], [], []] 输出: [null, null, null, 1, 1, false] 解释: MyQueue myQueue = new MyQueue(); myQueue.push(1); // queue is: [1] myQueue.push(2); // queue is: [1, 2] (leftmost is front of the queue) myQueue.peek(); // return 1 myQueue.pop(); // return 1, queue is [2] myQueue.empty(); // return false
提示:
  • 1 <= x <= 9
  • 最多调用 100 次 pushpoppeek 和 empty
  • 假设所有操作都是有效的 (例如,一个空的队列不会调用 pop 或者 peek 操作)
进阶:
  • 你能否实现每个操作均摊时间复杂度为 O(1) 的队列?换句话说,执行 n 个操作的总时间复杂度为 O(n) ,即使其中一个操作可能花费较长时间。

思路

  • 一个输入栈,一个输出栈
  • push入栈:只需要放入输入栈
  • pop出栈:
    • 如果输出栈为空,把进栈数据全部导入输出栈
    • 输出栈不为空,直接弹出
  • 判断栈空:两个栈都为空
notion image

Ts实现

class MyQueue { stackIn: number[]; stackOut: number[]; constructor() { this.stackIn = []; this.stackOut = []; } push(x: number): void { this.stackIn.push(x); } pop(): number { if (this.stackOut.length === 0) { while(this.stackIn.length) { this.stackOut.push(this.stackIn.pop()) } } return this.stackOut.pop(); } // peek(): number { // if (this.stackOut.length === 0) { // while(this.stackIn.length) { // this.stackOut.push(this.stackIn.pop()) // } // } // return this.stackOut[this.stackOut.length - 1] // } peek(): number { const top = this.pop(); // 注意这里stackOut this.stackOut.push(top); return top } empty(): boolean { return !this.stackIn.length && !this.stackOut.length } } /** * Your MyQueue object will be instantiated and called as such: * var obj = new MyQueue() * obj.push(x) * var param_2 = obj.pop() * var param_3 = obj.peek() * var param_4 = obj.empty() */
 

2.用队列实现栈

题目

请你仅使用两个队列实现一个后入先出(LIFO)的栈,并支持普通栈的全部四种操作(pushtoppop 和 empty)。
实现 MyStack 类:
  • void push(int x) 将元素 x 压入栈顶。
  • int pop() 移除并返回栈顶元素。
  • int top() 返回栈顶元素。
  • boolean empty() 如果栈是空的,返回 true ;否则,返回 false 。
注意:
  • 你只能使用队列的标准操作 —— 也就是 push to backpeek/pop from frontsize 和 is empty 这些操作。
  • 你所使用的语言也许不支持队列。 你可以使用 list (列表)或者 deque(双端队列)来模拟一个队列 , 只要是标准的队列操作即可。
示例:
输入: ["MyStack", "push", "push", "top", "pop", "empty"] [[], [1], [2], [], [], []] 输出: [null, null, null, 2, 2, false] 解释: MyStack myStack = new MyStack(); myStack.push(1); myStack.push(2); myStack.top(); // 返回 2 myStack.pop(); // 返回 2 myStack.empty(); // 返回 False
提示:
  • 1 <= x <= 9
  • 最多调用100 次 pushpoptop 和 empty
  • 每次调用 pop 和 top 都保证栈不为空
进阶:你能否仅用一个队列来实现栈。
notion image

思路1:两个队列实现

  • 队列1:用来模拟操作
  • 队列2:用来备份,将队列1,除了栈底的元素不需要备份外,其他的pop出队,push到备份队列2中
  • 注意栈是先进后出,栈是先进先出,用数组描述的话,首尾的定义

TS实现:思路1

class MyStack { queueIn: number[]; backupsQueue: number[]; constructor() { this.queueIn = []; this.backupsQueue = []; } push(x: number): void { this.queueIn.unshift(x); } pop(): number { const size = this.queueIn.length; const top = this.queueIn.shift(); while (this.queueIn.length !== 0) { this.backupsQueue.push(this.queueIn.shift()) } const temp = this.queueIn; this.queueIn = this.backupsQueue; this.backupsQueue = temp; return top; } top(): number { console.log('this.queueIn',this.queueIn, this.backupsQueue) return this.queueIn[0]; } empty(): boolean { return !this.queueIn.length } } /** * Your MyStack object will be instantiated and called as such: * var obj = new MyStack() * obj.push(x) * var param_2 = obj.pop() * var param_3 = obj.top() * var param_4 = obj.empty() */

思路2:一个队列实现

notion image
  • 模拟栈弹出的时候,先将队头的元素出队,然后重新入队,此时弹出的即是栈顶元素

TS实现:思路2

class MyStack { queue: number[]; constructor() { this.queue = []; } push(x: number): void { this.queue.push(x); } pop(): number { if (this.queue.length === 1) { return this.queue.shift(); } for (let i = 0; i < this.queue.length - 1; i++) { this.queue.push(this.queue.shift()); } return this.queue.shift(); } top(): number { return this.queue[this.queue.length - 1]; } empty(): boolean { return this.queue.length === 0 } } /** * Your MyStack object will be instantiated and called as such: * var obj = new MyStack() * obj.push(x) * var param_2 = obj.pop() * var param_3 = obj.top() * var param_4 = obj.empty() */

3.匹配括号

题目

给定一个只包括 '('')''{''}''['']' 的字符串 s ,判断字符串是否有效。
有效字符串需满足:
  1. 左括号必须用相同类型的右括号闭合。
  1. 左括号必须以正确的顺序闭合。
  1. 每个右括号都有一个对应的相同类型的左括号。
示例 1:
输入:s = "()" 输出:true
示例 2:
输入:s = "()[]{}" 输出:true
示例 3:
输入:s = "(]" 输出:false
提示:
  • 1 <= s.length <= 104
  • s 仅由括号 '()[]{}' 组成

思路

  • 第一种情况【字符串遍历完了,左括号多了】:遍历完字符串,但是栈不为空
  • 第二种情况【遍历过程中,栈顶不匹配】:后续的右括号给的不对
  • 第三种情况【遍历过程中,栈空了】:说明右括号没有找到对应的左括号
notion image
notion image

TS实现

覆盖三种不匹配情况即可
function isValid(s: string): boolean { let stack: string[] = []; if (s.length === 1) return false; for (let i = 0; i < s.length; i++) { if (s[i] === '(') { stack.push(')'); } else if (s[i] === '{') { stack.push('}'); } else if (s[i] === '[') { stack.push(']'); } else if (s[i] !== stack[stack.length - 1]) { return false } else if (stack.length === 0) { return false } else if (stack[stack.length - 1] === s[i]) { stack.pop(); } } if (stack.length > 0) return false; return true };

4.逆波兰表达式(后缀表达式: 后续遍历,运算符为中间节点)

平时写的计算式是中缀表达式

题目

给你一个字符串数组 tokens ,表示一个根据 逆波兰表示法 表示的算术表达式。
请你计算该表达式。返回一个表示表达式值的整数。
注意:
  • 有效的算符为 '+''-''*' 和 '/' 。
  • 每个操作数(运算对象)都可以是一个整数或者另一个表达式。
  • 两个整数之间的除法总是 向零截断 。
  • 表达式中不含除零运算。
  • 输入是一个根据逆波兰表示法表示的算术表达式。
  • 答案及所有中间计算结果可以用 32 位 整数表示。
示例 1:
输入:tokens = ["2","1","+","3","*"] 输出:9 解释:该算式转化为常见的中缀算术表达式为:((2 + 1) * 3) = 9
示例 2:
输入:tokens = ["4","13","5","/","+"] 输出:6 解释:该算式转化为常见的中缀算术表达式为:(4 + (13 / 5)) = 6
示例 3:
输入:tokens = ["10","6","9","3","+","-11","*","/","*","17","+","5","+"] 输出:22 解释:该算式转化为常见的中缀算术表达式为: ((10 * (6 / ((9 + 3) * -11))) + 17) + 5 = ((10 * (6 / (12 * -11))) + 17) + 5 = ((10 * (6 / -132)) + 17) + 5 = ((10 * 0) + 17) + 5 = (0 + 17) + 5 = 17 + 5 = 22
提示:
  • 1 <= tokens.length <= 104
  • tokens[i] 是一个算符("+""-""*" 或 "/"),或是在范围 [-200, 200] 内的一个整数

思路

  • 遍历字符串,判断是否是加减乘除,如果是则弹出两个数运算,将计算的数推入栈中,继续遍历,重复

TS实现

function evalRPN(tokens: string[]): number { const stack: number[] = []; for (let i = 0; i < tokens.length; i++) { if (isNaN(Number(tokens[i]))) { const n2 = stack.pop(); const n1 = stack.pop(); switch (tokens[i]) { case '+': { stack.push(n1 + n2); break; } case '-': { stack.push(n1 - n2); break; } case '*': { stack.push(n1 * n2); break; } case '/': { stack.push(n1 / n2 | 0); break; } } } else { stack.push(Number(tokens[i])) } } return stack.pop() };

5.删除字符串中的所有相邻重复项

题目

给出由小写字母组成的字符串 S重复项删除操作会选择两个相邻且相同的字母,并删除它们。
在 S 上反复执行重复项删除操作,直到无法继续删除。
在完成所有重复项删除操作后返回最终的字符串。答案保证唯一。
示例:
输入:"abbaca" 输出:"ca" 解释: 例如,在 "abbaca" 中,我们可以删除 "bb" 由于两字母相邻且相同,这是此时唯一可以执行删除操作的重复项。之后我们得到字符串 "aaca",其中又只有 "aa" 可以执行重复项删除操作,所以最后的字符串为 "ca"。
提示:
  1. 1 <= S.length <= 20000
  1. S 仅由小写英文字母组成。

思路

  • 遍历字符串入栈,相同则出栈
notion image

TS实现

function removeDuplicates(s: string): string { const stack: string[] = []; let result = ''; for (let i = 0; i < s.length; i++) { if (s[i] === stack[stack.length - 1]) stack.pop(); else stack.push(s[i]); } for (let i = 0; i < stack.length; i++) { result += stack[i]; } return result };

6.滑动窗口最大值

题目

给你一个整数数组 nums,有一个大小为 k 的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k 个数字。滑动窗口每次只向右移动一位。
返回 滑动窗口中的最大值 
示例 1:
输入:nums = [1,3,-1,-3,5,3,6,7], k = 3 输出:[3,3,5,5,6,7] 解释: 滑动窗口的位置 最大值 --------------- ----- [1 3 -1] -3 5 3 6 73 1 [3 -1 -3] 5 3 6 73 1 3 [-1 -3 5] 3 6 7 5 1 3 -1 [-3 5 3] 6 75 1 3 -1 -3 [5 3 6] 76 1 3 -1 -3 5 [3 6 7]7
示例 2:
输入:nums = [1], k = 1 输出:[1]
提示:
  • 1 <= nums.length <= 105
  • 104 <= nums[i] <= 104
  • 1 <= k <= nums.length

思路

  • 单调队列
  • 不能用大顶堆,因为无法移除最大值之外的数
  • 我们需要一个队列,这个队列呢,放进去窗口里的元素,然后随着窗口的移动,队列也一进一出,每次移动之后,队列告诉我们里面的最大值是什么
  • 队列里的元素一定是要排序的,而且要最大值放在出队口,要不然怎么知道最大值呢
  • 其实队列没有必要维护窗口里的所有元素,只需要维护有可能成为窗口里最大值的元素就可以了,同时保证队列里的元素数值是由大到小的。
  • 单调递减的队列就叫做单调队列
notion image
class MyQueue { public: void pop(int value) { } void push(int value) { } int front() { return que.front(); } };
 

细节

设计单调队列的时候,pop,和push操作要保持如下规则:
  1. pop(value):如果窗口移除的元素value等于单调队列的出口元素,那么队列弹出元素,否则不用任何操作
    1. 窗口移除元素 === 出口元素,弹出
  1. push(value):如果push的元素value大于入口元素的数值,那么就将队列入口的元素弹出,直到push元素的数值小于等于队列入口元素的数值为止
    1. push的元素 > 入口元素,则弹出入口元素,直到push的元素 ≤ 入口元素
notion image

TS实现

function maxSlidingWindow(nums: number[], k: number): number[] { class MonotonousQueue { queue: number[]; constructor() { this.queue = []; } // 出队:如果当前遍历到的元素===队列出口元素,则pop,考虑队列为空的情况 dequeue(value: number) { const top = this.top(); if (value === top && top !== undefined) { this.queue.shift(); } } // 入队:如果当前加入的元素大于队尾值,则弹出队尾值,直到小于队尾值,考虑队列为空的情况 enqueue(value: number) { let back = this.queue[this.queue.length - 1]; while (value > back && back !== undefined) { this.queue.pop(); back = this.queue[this.queue.length - 1]; } this.queue.push(value) } top() { return this.queue[0]; } } const helperQueue: MonotonousQueue = new MonotonousQueue(); let i = 0, j = 0; const resArr: number[] = []; while(j < k) { helperQueue.enqueue(nums[j++]) } // 滑动窗口最大值记录 resArr.push(helperQueue.top()); while(j < nums.length) { // 窗口移动 // 删除元素 helperQueue.dequeue(nums[i]); // 增加元素 helperQueue.enqueue(nums[j]); // 滑动窗口最大值记录 resArr.push(helperQueue.top()); i++; j++; } return resArr; };

7.前 K 个高频元素

题目

给你一个整数数组 nums 和一个整数 k ,请你返回其中出现频率前 k 高的元素。你可以按 任意顺序 返回答案。
示例 1:
输入:nums = [1,1,1,2,2,3], k = 2 输出:[1,2]
示例 2:
输入:nums = [1], k = 1 输出:[1]
提示:
  • 1 <= nums.length <= 105
  • k 的取值范围是 [1, 数组中不相同的元素的个数]
  • 题目数据保证答案唯一,换句话说,数组中前 k 个高频元素的集合是唯一的
进阶:你所设计算法的时间复杂度 必须 优于 O(n log n) ,其中 n 是数组大小。

思路

  • 统计元素出现频率:用map统计出现的概率
  • 对频率排序:
    • 什么是优先级队列呢?
      • 其实就是一个披着队列外衣的堆,因为优先级队列对外接口只是从队头取元素,从队尾添加元素,再无其他取元素的方式,看起来就是一个队列。
    • 什么是堆呢
      • 堆是一棵完全二叉树,树中每个结点的值都不小于(或不大于)其左右孩子的值。 如果父亲结点是大于等于左右孩子就是大顶堆,小于等于左右孩子就是小顶堆。
    • 为何不使用大顶堆:
      • 那么问题来了,定义一个大小为k的大顶堆,在每次移动更新大顶堆的时候,每次弹出都把最大的元素弹出去了,那么怎么保留下来前K个高频元素呢。
      • 而且使用大顶堆就要把所有元素都进行排序,那能不能只排序k个元素呢?
      • 所以我们要用小顶堆,因为要统计最大前k个元素,只有小顶堆每次将最小的元素弹出,最后小顶堆里积累的才是前k个最大元素。
  • 找出前K个高频元素:最后小顶堆里积累的才是前k个最大元素。
notion image

TS实现

内置实现

function topKFrequent(nums: number[], k: number): number[] { const numMap = new Map(); const result: number[] = []; for (let i = 0; i < nums.length; i++) { if (numMap.has(nums[i])) { numMap.set(nums[i], numMap.get(nums[i]) + 1); } else { numMap.set(nums[i], 1); } } //创建小顶堆 const heap = new PriorityQueue({ compare: (a, b) => a.value - b.value }) for (const [key, value] of numMap) { heap.enqueue({ key, value }); if (heap.size() > k) heap.dequeue(); } //处理输出 while (heap.size()) result.push(heap.dequeue().key); return result; };

手写实现优先队列

class PriorityQueue { queueArray: QueueElement[]; compareFn: (a: QueueElement, b: QueueElement) => number; constructor(compareFn: (a: QueueElement, b: QueueElement) => number) { this.compareFn = compareFn; this.queueArray = []; } dequeue(): QueueElement | undefined { // 如果队列中只有一个值 if (this.size() <= 1) { return this.queueArray.pop(); } // 弹出最大值 const out = this.queueArray[0]; // 将最后一个放到第一个准备下沉操作 this.queueArray[0] = this.queueArray.pop()!; // 从头部向下沉,下沉操作 let index = 0; // 当前索引0-根节点 let left = 1; // 左孩子 // 获取左右两个子节点中最小的一个 let miniSearchChild = this.compare(left, left + 1) > 0 ? left + 1 : left; // 如果[当前节点] < [最小的子节点] while (this.compare(index, miniSearchChild) > 0) { // 交换节点,把小的放到当前index [this.queueArray[index], this.queueArray[miniSearchChild]] = [this.queueArray[miniSearchChild], this.queueArray[index]]; // 当前索引移动到[最小的子节点] index = miniSearchChild; // 继续计算[最小的子节点] left = 2 * index + 1; miniSearchChild = this.compare(left, left + 1) > 0 ? left + 1 : left; } return out; } enqueue(value: QueueElement) { // 推到队列中 this.queueArray.push(value); // 上浮操作 // 尾部向上浮动 let index = this.size() - 1; let parent = Math.floor((index - 1) / 2); // 比较父级和当前节点,如果小于则交换 while (parent >= 0 && this.compare(parent, index) > 0) { [this.queueArray[index], this.queueArray[parent]] = [this.queueArray[parent], this.queueArray[index]]; // 当前索引,和父级 index = parent; parent = Math.floor((index - 1) / 2); } } size(): number { return this.queueArray.length; } // 比较大小 compare(index1: number, index2: number): number { if (this.queueArray[index1] === undefined) return 1; // a > b if (this.queueArray[index2] === undefined) return -1; // a < b return this.compareFn(this.queueArray[index1], this.queueArray[index2]); } }
 
这个 Heap 类的实现是一个基于数组的二叉堆,常用于实现优先级队列。在你的例子中,二叉堆被用来解决 “前 K 个高频元素” 的问题。我们来详细解释一下这个堆的实现原理:

8.实现模拟堆的结构

  • 堆是一种完全二叉树,通常有两种类型:大顶堆(最大堆)和 小顶堆(最小堆)。
  • 在大顶堆中,父节点的值总是大于或等于子节点的值;而在小顶堆中,父节点的值总是小于或等于子节点的值。
  • 通过数组表示堆时,父节点和子节点的下标关系是:
    • 父节点下标:parent = Math.floor((index - 1) / 2)
    • 左子节点下标:left = 2 * index + 1
    • 右子节点下标:right = 2 * index + 2

堆的操作

在这个堆实现中,主要涉及两个操作:插入元素(push)移除堆顶元素(pop)

1. 插入元素(push)

当我们向堆中插入元素时,首先将元素添加到数组的末尾,然后需要通过 上浮操作 来保持堆的性质:
  • 初始位置是数组的最后一个元素(index = this.size() - 1)。
  • 计算父节点的下标:parent = Math.floor((index - 1) / 2)
  • 如果新元素比父节点小(对于小顶堆),交换新元素和父节点的位置,并更新 indexparent,继续比较,直到满足堆的性质为止。
这个过程的时间复杂度为 O(log n)

2. 移除堆顶元素(pop)

移除堆顶元素的步骤是:
  • 用数组的最后一个元素填补堆顶。
  • 然后需要通过 下沉操作 来保持堆的性质:
    • 初始位置是数组的第一个元素(index = 0)。
    • 计算左子节点的下标:left = 2 * index + 1,右子节点为 left + 1
    • 找到左右子节点中较小的那个(对于小顶堆)。
    • 如果子节点比当前节点小,交换它们的位置,并更新 indexleft,继续比较,直到满足堆的性质为止。
这个过程的时间复杂度也是 O(log n)

3. 比较函数(compare)

compare 方法用于根据传入的比较函数 compareFn 来比较两个元素:
  • 通过下标 index1index2 取出对应位置的元素,然后传入 compareFn 进行比较。
  • 通过 compareFn 的结果来决定是否需要交换元素。

4. 在 topKFrequent 问题中的应用

topKFrequent 函数中,堆用于求解前 K 个高频元素:
  • 首先用一个哈希表 map 记录每个元素出现的频率。
  • 然后将 map 中的每个键值对(entry)推入堆中。
  • 当堆的大小超过 K 时,移除堆顶元素(即最小频率的元素)。这样,堆中最终保留的就是频率最高的 K 个元素。
由于使用的是小顶堆,堆顶元素总是频率最小的元素,所以可以通过不断移除堆顶来确保只保留前 K 个高频元素。
最终,将堆中的元素逐个弹出,并将它们的键(即原数组中的元素)加入结果数组 res 中,得到最终答案。

总结

  • 这个实现利用了堆的插入(上浮)和删除(下沉)操作,保证在动态维护数据时可以快速获取频率最小的元素。
  • 时间复杂度是 O(N log K),其中 N 是数组长度,K 是前 K 个高频元素。

数组表示堆时,父节点和子节点的下标关系是

通过数组表示堆时,父节点和子节点的下标关系是基于二叉树结构的性质推导出来的。
假设在数组中存储堆的数据,节点在数组中的下标为 index,则:

1. 子节点的下标

  • 左子节点 的下标:left = 2 * index + 1
  • 右子节点 的下标:right = 2 * index + 2
这是因为在二叉树中,如果一个节点的下标是 index,它的子节点下标按如下方式计算:
  • 左子节点的位置总是父节点位置的两倍再加 1,即 2 * index + 1
  • 右子节点的位置总是父节点位置的两倍再加 2,即 2 * index + 2

2. 父节点的下标

  • 父节点 的下标:parent = Math.floor((index - 1) / 2)
这可以通过反向推导子节点的公式得到:
  • 给定子节点的下标 index,它的父节点下标可以通过 (index - 1) / 2 得到(取整以确保是整数),即 parent = Math.floor((index - 1) / 2)
  1. 左子节点的推导:
      • 假设当前节点的下标是 index
      • 它的左子节点下标为 2 * index + 1
  1. 右子节点的推导:
      • 同样,假设当前节点的下标是 index
      • 它的右子节点下标为 2 * index + 2
  1. 父节点的推导:
      • 反过来推导,给定子节点的下标 index
        • 如果 index 是左子节点(即 index = 2 * parent + 1),则父节点 parent = Math.floor((index - 1) / 2)
        • 如果 index 是右子节点(即 index = 2 * parent + 2),同样父节点 parent = Math.floor((index - 1) / 2)

举例说明

考虑一个完全二叉树,通过数组表示如下:
0 / \ 1 2 / \ / \ 3 4 5 6
对应的数组表示为:[0, 1, 2, 3, 4, 5, 6]
  • 对于下标 1 的节点,left = 2 * 1 + 1 = 3right = 2 * 1 + 2 = 4
  • 对于下标 2 的节点,left = 2 * 2 + 1 = 5right = 2 * 2 + 2 = 6
  • 对于下标 3 的节点,父节点 parent = Math.floor((3 - 1) / 2) = 1
通过这个关系,可以快速找到堆中父节点与子节点的位置,从而实现堆的插入和删除操作。

为什么是两倍再加 1

为什么在通过数组表示堆时,左子节点的下标是父节点下标的两倍再加 1(2 * index + 1)呢?这是由二叉树的结构和数组的存储方式决定的。我们来详细解释下其中的原因。

完全二叉树的结构

堆通常用 完全二叉树 来实现,完全二叉树是指所有层都被填满,且最后一层的节点尽可能靠左排列。
在这种结构中,从上到下、从左到右 依次填充节点。这种顺序映射到数组中时,自然形成了以下关系:

数组的存储方式

在数组中存储二叉树时,节点的排列顺序如下:
  • 根节点位于数组的第 0 位(下标为 0)。
  • 其左子节点位于第 1 位,右子节点位于第 2 位。
  • 继续往下,左子节点的左子节点位于第 3 位,右子节点位于第 4 位,依此类推。
假设节点在数组中的下标为 index,那么根据完全二叉树的结构:
  • 左子节点 位于 2 * index + 1
  • 右子节点 位于 2 * index + 2

推导过程

假设有一个二叉树节点的下标是 index,来推导它的子节点的下标:
  1. 左子节点的推导
      • 在二叉树中,左子节点总是紧挨着父节点之后。因此,如果父节点的下标是 index,那么左子节点的下标自然是父节点的两倍再加 1,即 2 * index + 1
  1. 右子节点的推导
      • 右子节点紧跟在左子节点之后,因此右子节点的下标是父节点的两倍再加 2,即 2 * index + 2

举例说明

考虑如下的完全二叉树:
0 / \ 1 2 / \ / \ 3 4 5 6
这个二叉树对应的数组为 [0, 1, 2, 3, 4, 5, 6]
  • 对于下标为 0 的节点:
    • 它的左子节点是下标 12 * 0 + 1 = 1),右子节点是下标 22 * 0 + 2 = 2)。
  • 对于下标为 1 的节点:
    • 它的左子节点是下标 32 * 1 + 1 = 3),右子节点是下标 42 * 1 + 2 = 4)。
  • 对于下标为 2 的节点:
    • 它的左子节点是下标 52 * 2 + 1 = 5),右子节点是下标 62 * 2 + 2 = 6)。

总结

通过数组存储堆时,左子节点的下标是 2 * index + 1,右子节点的下标是 2 * index + 2。这是因为在完全二叉树中,节点是从上到下、从左到右依次排列的,因此子节点的位置可以通过这个简单的公式推导出来。

parent = Math.floor((index - 1) / 2)用右节点算不就是-2?

你提到的 parent = Math.floor((index - 1) / 2) 用右节点来计算的确需要仔细理解。这个公式在任何情况下都能正确计算父节点的下标,无论你是从左子节点还是右子节点来计算。

公式推导及说明

  1. 左子节点下标left = 2 * parent + 1
  1. 右子节点下标right = 2 * parent + 2
根据上面的公式,我们反推父节点下标:
  • 对于左子节点
    • index = 2 * parent + 1
    • 反推父节点:parent = Math.floor((index - 1) / 2)
  • 对于右子节点
    • index = 2 * parent + 2
    • 反推父节点:parent = Math.floor((index - 2) / 2),但计算时仍然可以使用统一公式 parent = Math.floor((index - 1) / 2)

为什么统一使用 Math.floor((index - 1) / 2)

尽管右子节点的公式是 index - 2,但由于取整的缘故,Math.floor((index - 1) / 2) 仍然给出了正确的父节点下标。我们可以举几个例子来验证这一点:

举例验证

假设我们有一个完全二叉树,数组表示如下:
0 / \ 1 2 / \ / \ 3 4 5 6
数组表示为 [0, 1, 2, 3, 4, 5, 6]
  1. 计算下标 2 的父节点(右子节点):
      • index = 2
      • parent = Math.floor((2 - 1) / 2) = Math.floor(1 / 2) = 0
      • 父节点下标为 0,对应的值为 0,正确。
  1. 计算下标 5 的父节点(左子节点):
      • index = 5
      • parent = Math.floor((5 - 1) / 2) = Math.floor(4 / 2) = 2
      • 父节点下标为 2,对应的值为 2,正确。
  1. 计算下标 6 的父节点(右子节点):
      • index = 6
      • parent = Math.floor((6 - 1) / 2) = Math.floor(5 / 2) = 2
      • 父节点下标为 2,对应的值为 2,正确。

结论

无论是左子节点还是右子节点,统一使用 parent = Math.floor((index - 1) / 2) 都能得到正确的父节点下标。这是因为在整数除法中,Math.floor 对于正数始终向下取整,使得公式对两种子节点都适用。

上浮操作(在插入新元素时)的原理

为什么如果新元素比父节点小(对于小顶堆),交换新元素和父节点的位置,并更新 index 和 parent,继续比较,直到满足堆的性质为止。,这样能满足堆的性质?
这个问题涉及堆的调整过程,特别是 上浮操作(在插入新元素时)的原理。我们来详细说明为什么上浮操作能够保证堆的性质。

堆的性质

对于小顶堆(最小堆),堆的性质要求:每个节点的值都小于或等于其子节点的值。即,堆中最小的元素总是位于根节点。

插入新元素时的调整过程(上浮)

当我们向堆中插入一个新元素时,初始情况下,这个新元素被添加到堆的末尾(数组的最后一位)。但是,此时它的位置可能不满足小顶堆的性质,因此需要通过上浮操作来恢复堆的性质。

上浮操作的原理

上浮的思路是:如果新插入的元素比其父节点更小,就交换它们的位置,并不断重复这个过程,直到满足堆的性质(新元素不再比父节点小,或者它已经成为根节点)。

具体步骤如下:

  1. 将新元素插入堆的末尾(数组的最后)。
  1. 比较新元素与其父节点:
      • 如果新元素小于父节点,交换它们的位置。
      • 更新新元素的位置为父节点的位置,并继续比较新的父节点。
  1. 重复这个过程,直到新元素不再比父节点小,或者它成为根节点。

为什么上浮能恢复堆的性质?

上浮操作是基于以下事实:在小顶堆中,父节点的值总是小于等于子节点的值。如果新插入的元素小于父节点,那么这个父节点违反了小顶堆的性质,因为它不再是比子节点更小的元素。因此,交换新元素和父节点的位置将有助于恢复局部的堆性质。
通过递归地上浮,我们可以保证从新元素的位置到根节点的所有路径上都满足堆的性质:
  • 如果上浮到根节点,说明新元素是整个堆中最小的元素,堆的性质被完全恢复。
  • 如果在中途停止上浮,说明堆的性质已经恢复,因为新元素找到了一个位置,使得它的父节点小于等于它,而它又小于等于它的子节点。

举例说明

假设当前堆为:
3 / \ 5 4 / \ 8
数组表示为 [3, 5, 4, 8, 6]
如果我们插入一个新元素 2,插入后的堆为:
3 / \ 5 4 / \ / 8 6 2
数组表示为 [3, 5, 4, 8, 6, 2]
此时新元素 2 不满足堆的性质,因为它比父节点 4 更小。我们需要上浮:
  1. 2 与父节点 4 交换位置:
3 / \ 5 2 / \ / 8 6 4
数组变为 [3, 5, 2, 8, 6, 4]
  1. 2 继续与父节点 3 比较,发现比 3 还小,继续上浮,交换位置:
2 / \ 5 3 / \ / 8 6 4
数组变为 [2, 5, 3, 8, 6, 4]

总结

上浮操作之所以能恢复堆的性质,是因为它通过逐层向上比较和交换,确保从插入位置到根节点的路径上,父节点总是小于等于子节点。通过这个过程,堆的整体结构维持了最小堆的性质,使得每个父节点都比它的子节点小或相等。

9.接雨水