算法

作者: shaokang 时间: February 23, 2022字数:16842

引言

最近在看一本算法书《labuladong 的算法小抄》,作者对各种算法问题提炼出了基础的解法框架,能够帮助读者快速理解各算法中的精髓。这里我将此记录下来,帮助自己日后快速回顾。

回溯算法

核心框架

result = [];
def backtrack( 路径选择列表 ):
    if 满足结束条件 :
        result.add( 路径);
        return;
    for 选择 in 选择列表 
        做选择
        backtrack( 路径选择列表 )
        撤销选择

经典例子:全排列问题

let permute = function (nums) {
    const result = [];
    backtrack(nums, arr, result);
    return result;
};

let backtrack = function (nums, track, result) {
    if (track.length === nums.length) {
        result.push([...track]);
        return;
    }

    for (let i = 0; i < nums.length; i++) {
        if (track.includes(nums[i])) {
            continue;
        }

        track.push(nums[i]);
        backtrack(nums, track, result);
        track.pop();
    }
};

广度优先遍历框架

核心框架:

int BFS(Node start, Node target) {
    Queue<Node> q; // 核心数据结构
    Set<Node> visited; // 避免走回头路

    q.offer(start); // 将起点加入队列
    visited.add(start);

    while (q not empty) {
        int sz = q.size();
        /* 将当前队列中的所有节点向四周扩散 */
        for (int i = 0; i < sz; i++) {
            Node cur = q.poll();
            /* 划重点,这里判断是否到达终点 */
            if (cur is target) {
                return step;
            }

            /* 将 cur 的相邻节点加入队列 */
            for (Node x : cur.adj()) {
                if (x not in visited) {
                    q.offer(x);
                    visited.add(x);
                }
            }
        }
        /* 划重点,在这里更新步数 */
        step++;
    }

}

经典问题:二叉树的最小高度

var BFS = function (root) {
    if (root === null) return 0;
    const queue = [root];
    let depth = 1;
    while (queue.length !== 0) {
        const sz = queue.length;
        for (var i = 0; i < sz; i++) {
            const cur = queue.shift();
            if (cur.left === null && cur.right === null) {
                return depth;
            }

            if (cur.left !== null) {
                queue.push(cur.left);
            }

            if (cur.right !== null) {
                queue.push(cur.right);
            }
        }
        depth++;
    }
};

深度优先遍历框架

深度优先遍历其实就是递归的过程 例:二叉树的先序遍历

递归

void traverse(TreeNode root) {
    if (root === null) return;

    /* 前序遍历的代码 */
    print(root.val);

    traverse(root.left);
    traverse(root.right);
}

遍历

void traverse(TreeNode root) {
    if (root === null) return;

    Stack<Node> stack;
    stack.push(root);

    while (!stack.empty()) {
        Node node = stack.top();

        stack.pop();

        if (node.right != null) {
            stack.push(node.right);
        }

        if (node.left != null) {
            stack.push(node.left);
        }

        /* 输出 */
        print(node.val);
    }
}

排序算法

快排

function quickSort(arr) {
    // 终止条件。arr 数组小于等于 1
    if (arr.length <= 1) {
        return arr;
    }
    var pivotIndex = Math.floor(arr.length / 2);
    // splice 返回的是一个数组
    var pivot = arr.splice(pivotIndex, 1)[0];
    var left = [],
        right = [];
    for (var i = 0; i < arr.length; i++) {
        if (arr[i] < pivot) {
            left.push(arr[i]);
        } else {
            right.push(arr[i]);
        }
    }
    return quickSort(left).concat([pivot], quickSort(right));
}

优化版:

① 左边界作为基准值

function quickSortInPlace(arr, left = 0, right = arr.length - 1) {
    if (left >= right) {
        return;
    }

    const pivotIndex = partition(arr, left, right);
    quickSortInPlace(arr, left, pivotIndex - 1);
    quickSortInPlace(arr, pivotIndex + 1, right);
}

function partition(arr, left, right) {
    const pivot = arr[left];
    let i = left + 1;
    let j = right;

    while (i <= j) {
        while (i <= j && arr[i] <= pivot) {
            i++;
        }
        while (i <= j && arr[j] >= pivot) {
            j--;
        }
        if (i < j) {
            [arr[i], arr[j]] = [arr[j], arr[i]];
        }
    }

    [arr[left], arr[j]] = [arr[j], arr[left]];
    return j;
}

② 右边界作为基准值

function quickSortInPlace(arr, left = 0, right = arr.length - 1) {
    if (left >= right) {
        return;
    }

    const pivotIndex = partition(arr, left, right);
    quickSortInPlace(arr, left, pivotIndex - 1);
    quickSortInPlace(arr, pivotIndex + 1, right);
}

function partition(arr, left, right) {
    const pivotIndex = right;
    const pivot = arr[right];
    let partitionIndex = left;
    for (let i = left; i < right; i++) {
        if (arr[i] < pivot) {
            [arr[partitionIndex], arr[i]] = [arr[i], arr[partitionIndex]];
            partitionIndex++;
        }
    }
    [arr[partitionIndex], arr[pivotIndex]] = [
        arr[pivotIndex],
        arr[partitionIndex],
    ];
    return partitionIndex;
}

归并排序

function mergeSort(arr) {
    if (arr.length <= 1) {
        return arr;
    }

    let idx = Math.floor(arr.length / 2);
    return merge(mergeSort(arr.slice(0, idx)), mergeSort(arr.slice(idx)));
}

function merge(left, right) {
    let res = [];
    while (left.length && right.length) {
        if (left[0] < right[0]) {
            res.push(left.shift());
        } else {
            res.push(right.shift());
        }
    }

    return res.concat(left).concat(right);
}

堆排序

  1. 建立大顶堆:最大元素位于堆顶。
  2. 将堆顶元素(第一个元素)与堆底元素(最后一个元素)交换。此时数组最后一个元素最大,再排序剩余的元素。
  3. 从堆顶元素开始,从顶到底执行堆化操作(sift down)。完成堆化后,堆的性质得到修复。
  4. 重复上述步骤,循环 n - 1 轮后,即可完成数组排序。
/* 堆的长度为 n ,从节点 i 开始,从顶至底堆化 */
function siftDown(nums, n, i) {
    while (true) {
        // 判断节点 i, l, r 中值最大的节点,记为 ma
        let l = 2 * i + 1;
        let r = 2 * i + 2;
        let ma = i;
        if (l < n && nums[l] > nums[ma]) {
            ma = l;
        }
        if (r < n && nums[r] > nums[ma]) {
            ma = r;
        }
        // 若节点 i 最大或索引 l, r 越界,则无须继续堆化,跳出
        if (ma === i) {
            break;
        }
        // 交换两节点
        [nums[i], nums[ma]] = [nums[ma], nums[i]];
        // 循环向下堆化
        i = ma;
    }
}

/* 堆排序 */
function heapSort(nums) {
    // 建堆操作:堆化除叶节点以外的其他所有节点
    for (let i = Math.floor(nums.length / 2) - 1; i >= 0; i--) {
        siftDown(nums, nums.length, i);
    }
    // 从堆中提取最大元素,循环 n-1 轮
    for (let i = nums.length - 1; i > 0; i--) {
        // 交换根节点与最右叶节点(交换首元素与尾元素)
        [nums[0], nums[i]] = [nums[i], nums[0]];
        // 以根节点为起点,从顶至底进行堆化
        siftDown(nums, i, 0);
    }
}

滑动窗口

核心框架

let left = 0, right = 0;

while (right < s.length) {
    window.add(s[right]);
    right++;

    while (window needs shrink) {
        window.remove(s[left]);
        left++;
    }
}

时间复杂度为 O(N)

const slidingWindow = function (s: string, t: string) {
    const need: Record<string, number> = {};
    for (let c of t) {
        need[c] = (need[c] || 0) + 1;
    }
    const window: Record<string, number> = {};

    let left = 0, right = 0;
    let valid = 0;

    while (right < s.length) {
        // c 是移入窗口的字符
        let c = s[right];
        // 右移窗口
        right++;
        // 进行窗口内数据的一系列更新
        ...


        while (window needs shrink) {
            // d 是将移出窗口的字符
            let d = s[left];
            left++;
            // 进行窗口内数据的一系列更新
            ...
        }
    }
}

经典问题:字符串的排列

判断 s1 的排列之一是 s2 的 子串。

输入:s1 = "ab" s2 = "eidbaooo"
输出:true
解释:s2 包含 s1 的排列之一 ("ba").
const checkInclusion = function (s1, s2) {
    if (s1.length > s2.length) {
        return false;
    }

    let left = 0;
    let right = 0;
    let valid = 0;
    const window = {};
    const need = {};
    for (let c of t) {
        need[c] = (need[c] || 0) + 1;
    }

    while (right < s2.length) {
        const c = s2[right];
        right++;

        if (need[c]) {
            window[c] = (window[c] || 0) + 1;
            if (window[c] === need[c]) {
                valid++;
            }
        }

        while (right - left >= s1.length) {
            if (valid === Object.keys(need).length) {
                return true;
            }

            const d = s2[left];
            left++;
            if (need[d]) {
                if (window[d] === need[d]) {
                    valid--;
                }
                window[d] -= 1;
            }
        }
    }
    return false;
};

动态规划

动态规划分为以下几步:找到 “状态” 和 “选择” => 明确 dp 数组的定义 => 寻找 “状态” 之间的关系。

通用技巧:数学归纳法

经典问题:最长递增子序列

定义 dp,dp[i] 表示以 nums[i] 这个数结尾的最长递增子序列的长度。

base case: dp[i] 初始值为 1。

假设已经知道 dp[0..4]的所有结果,如何通过这些已知结果推出 dp[5] 呢?
只需要找到结尾小于 num[5] 的子序列,将 num[5] 接到后面,看最后那个子序列长度最大。

for (let i = 0; i < nums.length; i++) {
    for (let j = 0; j < i; j++) {
        if (nums[i] > nums[j]) {
            dp[i] = Math.max(dp[i], dp[j] + 1);
        }
    }
}

经典问题:最长回文子串

dp[i][j] = dp[i + 1][j - 1] && s[i] === s[j];

因为求 i,j 需要知道 i + 1, j - 1,所以 i 从 len 开始递减。并且 i 肯定是小于等于 j 的(这样才能构成字符串),j 一开始等于 i 就可以了。

最后我们就计算出了 dp table(长方形)左下一半的值.

const n = s.length;
const dp = new Array(n).fill(false).map(() => new Array(n).fill(false));
let maxLen = 0;
let start = 0;
let end = 0;

for (let i = n - 1; i >= 0; i--) {
    for (let j = i; j < n; j++) {
        if (s[i] === s[j]) {
            if (j - i < 2) {
                dp[i][j] = true;
            } else if (dp[i + 1][j - 1]) {
                dp[i][j] = true;
            }
        }

        if (dp[i][j]) {
            if (j - i + 1 > maxLen) {
                maxLen = j - i + 1;
                start = i;
                end = j;
            }
        }
    }
}

return s.substr(start, end - start + 1);

循环的条件也可以改一下

// 遍历所有的长度
for (let len = 1; len <= length; len++)
    for (let i = 0; i < length; i++) {
        let j = len + i - 1;
        // 如果右边界越界,就可以退出当前循环
        if (j >= s.length) {
            break;
        }

        if (s[i] != s[j]) {
            dp[i][j] = false;
        } else {
            if (j - i < 3) {
                dp[i][j] = true;
            } else {
                dp[i][j] = dp[i + 1][j - 1];
            }
        }
    }

常见的动态规划状态转移方程

以下是几个常见的动态规划问题和它们对应的状态转移方程示例:

斐波那契数列(Fibonacci Sequence)

dp[i] = dp[i-1] + dp[i-2],其中 dp[i] 表示第 i 个斐波那契数。

爬楼梯问题(Climbing Stairs)

dp[i] = dp[i-1] + dp[i-2],其中 dp[i] 表示爬到第 i 级楼梯的方法数。

背包问题(Knapsack Problem)

dp[i][j] = max(dp[i-1][j], dp[i-1][j-weight[i]] + value[i]),其中 dp[i][j] 表示在前 i 个物品中选择总重量不超过 j 的最大价值,weight[i] 表示第 i 个物品的重量,value[i] 表示第 i 个物品的价值。

最长递增子序列(Longest Increasing Subsequence)

dp[i] = max(dp[j] + 1, dp[i]),其中 dp[i] 表示以第 i 个元素结尾的最长递增子序列的长度,j 为 0 到 i-1 的索引,且 nums[i] > nums[j]。

最大子数组和(Maximum Subarray Sum)

dp[i] = max(nums[i], nums[i] + dp[i-1]),其中 dp[i] 表示以第 i 个元素结尾的最大子数组和。

最长公共子序列(Longest Common Subsequence)

如果 str1[i] 等于 str2[j],则 dp[i][j] = dp[i-1][j-1] + 1

否则,dp[i][j] = max(dp[i-1][j], dp[i][j-1]),其中 dp[i][j] 表示 str1 的前 i 个字符和 str2 的前 j 个字符的最长公共子序列的长度。

编辑距离(Edit Distance

如果 word1[i] 等于 word2[j],则 dp[i][j] = dp[i-1][j-1]

否则,dp[i][j] = min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) + 1,其中 dp[i][j] 表示将 word1 的前 i 个字符转换为 word2 的前 j 个字符所需的最少操作次数。

打家劫舍(House Robber)

dp[i] = max(dp[i-1], dp[i-2] + nums[i]),其中 dp[i] 表示前 i 个房屋能够获得的最大金额,nums[i] 表示第 i 个房屋中的金额。

最大正方形(Maximal Square)

如果 matrix[i][j] 等于 1,则 dp[i][j] = min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) + 1

否则,dp[i][j] = 0,其中 dp[i][j] 表示以 matrix[i][j] 为右下角的最大正方形的边长。

二分查找

经典框架

function binarySearch(nums, target) {
    while (left <= right) {
        mid = left + (right - left) / 2;

        if (nums[mid] === target) {
            return mid;
        } else if (nums[mid] < target) {
            left = mid + 1;
        } else {
            right = mid - 1;
        }
    }
}

经典二分查找

给定一个 n 个元素有序的(升序)整型数组 nums 和一个目标值 target,写一个函数搜索 nums 中的 target,如果目标值存在返回下标,否则返回 -1。

var search = function (nums, target) {
    let left = 0;
    let right = nums.length - 1;

    while (left <= right) {
        const mid = Math.floor((left + right) / 2);
        if (nums[mid] === target) {
            return mid;
        } else if (nums[mid] < target) {
            left = mid + 1;
        } else {
            right = mid - 1;
        }
    }
    return -1;
};

寻找左边界的二分查找

nums 数组中可能有重复元素,找出最左侧的 target

var search = function (nums, target) {
    let left = 0;
    let right = nums.length - 1;

    while (left <= right) {
        const mid = Math.floor((left + right) / 2);
        // 防止两个数值过大造成溢出
        // const mid = left + Math.floor((left - right) / 2);
        if (nums[mid] < target) {
            left = mid + 1;
        } else {
            // 因为是要找最左侧的数,所以 = target 的时候还是要收紧边界
            // nums[mid] <= target 收缩右侧边界
            right = mid - 1;
        }
    }

    if (left >= nums.length || nums[left] !== target) {
        return -1;
    }

    return left;
};

寻找右边界的二分查找

var search = function (nums, target) {
    let left = 0;
    let right = nums.length - 1;

    while (left <= right) {
        const mid = Math.floor((left + right) / 2);
        if (nums[mid] > target) {
            right = mid - 1;
        } else {
            // nums[mid] <= target 收缩左侧边界
            left = mid + 1;
        }
    }

    if (right < 0 || nums[right] !== target) {
        return -1;
    }

    return right;
};

二叉树

二叉树的遍历

二叉树遍历框架:

var traverse = function (root) {
    if (root === null) {
        return;
    }
    // 前序位置
    traverse(root.left);
    // 中序位置
    traverse(root.right);
    // 后序位置
};

前序位置的代码在刚刚进入一个二叉树节点的时候执行;

后序位置的代码在将要离开一个二叉树节点的时候执行;

中序位置的代码在一个二叉树节点左子树都遍历完,即将开始遍历右子树的时候执行。

二叉树的解法框架

二叉树题目的递归解法可以分两类思路,第一类是遍历一遍二叉树得出答案,第二类是通过分解问题计算出答案,这两类思路分别对应着 回溯算法核心框架 和 动态规划核心框架。

以求二叉树的最大深度为例:

① 遍历一遍二叉树,得到答案

var res = 0;
// 记录遍历到的节点的深度
var depth = 0;

// 主函数
function maxDepth(root) {
    traverse(root);
    return res;
}

// 二叉树遍历框架
function traverse(root) {
    if (root == null) {
        return;
    }
    // 前序位置
    depth++;
    if (root.left == null && root.right == null) {
        // 到达叶子节点,更新最大深度
        res = Math.max(res, depth);
    }
    traverse(root.left);
    traverse(root.right);
    // 后序位置
    depth--;
}

② 通过分解问题计算出答案

var maxDepth = function (root) {
    if (root == null) {
        return 0;
    }
    // 利用定义,计算左右子树的最大深度
    var leftMax = maxDepth(root.left);
    var rightMax = maxDepth(root.right);
    // 整棵树的最大深度等于左右子树的最大深度取最大值,
    // 然后再加上根节点自己
    var res = Math.max(leftMax, rightMax) + 1;

    return res;
};

实现一个最小堆

class MinHeap {
    constructor() {
        this.heap = [];
    }

    swap(i, j) {
        const temp = this.heap[i];
        this.heap[i] = this.heap[j];
        this.heap[j] = temp;
    }

    getParentIndex(i) {
        return Math.floor((i - 1) / 2);
    }

    getLeftChildIndex(i) {
        return i * 2 + 1;
    }

    getRightChildIndex(i) {
        return i * 2 + 2;
    }

    insert(value) {
        this.heap.push(value);
        this.heapifyUp();
    }

    heapifyUp() {
        let index = this.heap.length - 1;

        while (index > 0) {
            const parentIndex = this.getParentIndex(index);
            // 最小堆:父节点值小于子节点值
            if (this.heap[index] >= this.heap[parentIdx]) {
                break;
            }
            this.swap(index, parentIndex);
            index = parentIdx;
        }
    }

    remove() {
        if (this.heap.length === 0) {
            return;
        }

        const root = this.heap[0];
        const end = this.heap.pop();
        this.heap[0] = end;
        this.heapifyDown();
        return root;
    }

    heapifyDown() {
        let index = 0;
        const length = this.heap.length;
        while (this.getLeftChildIndex(index) < length) {
            let minIndex = this.getLeftChildIndex(index);
            const rightIndex = this.getRightChildIndex(index);
            if (
                rightIndex < length &&
                this.heap[rightIndex] < this.heap[minIndex]
            ) {
                minIndex = rightIndex;
            }

            if (this.heap[index] < this.heap[minIndex]) {
                break;
            }
            this.swap(minIndex, index);
            index = minIndex;
        }
    }
}

实现一个堆


const defaultCmp = (x, y) => x > y; // 默认是最大堆

const swap = (arr, i, j) => ([arr[i], arr[j]] = [arr[j], arr[i]]);

class Heap {
    /**
     * 默认是最大堆
     * @param {Function} cmp
     */
    constructor(cmp = defaultCmp) {
        this.container = [];
        this.cmp = cmp;
    }

    insert(data) {
        const { container, cmp } = this;

        container.push(data);
        let index = container.length - 1;
        while (index) {
            let parent = Math.floor((index - 1) / 2);
            if (!cmp(container[index], container[parent])) {
                return;
            }
            swap(container, index, parent);
            index = parent;
        }
    }

    extract() {
        const { container, cmp } = this;
        if (!container.length) {
            return null;
        }

        swap(container, 0, container.length - 1);
        const res = container.pop();
        const length = container.length;
        let index = 0,
            exchange = index * 2 + 1;

        while (exchange < length) {
            // // 以最大堆的情况来说:如果有右节点,并且右节点的值大于左节点的值
            let right = index * 2 + 2;
            if (right < length && cmp(container[right], container[exchange])) {
                exchange = right;
            }
            if (!cmp(container[exchange], container[index])) {
                break;
            }
            swap(container, exchange, index);
            index = exchange;
            exchange = index * 2 + 1;
        }

        return res;
    }

    top() {
        if (this.container.length) return this.container[0];
        return null;
    }
}

相关的算法手册网址