Skip to content
On this page

双指针

  • 大部分双指针解法,都是快慢指针。
  • 原地修改数组,基本都是快慢指针。
  • 数组有序,大概率是双指针技巧。

快慢指针

原地去重

26. 删除有序数组中的重复项 - 力扣(LeetCode)83. 删除排序链表中的重复元素 - 力扣(LeetCode)

删除元素

27. 移除元素 - 力扣(LeetCode)283. 移动零 - 力扣(LeetCode)

左右指针

二分查找

js
var binarySearch = function(nums, target) {
    // 一左一右两个指针相向而行
    var left = 0, right = nums.length - 1;
    while (left <= right) { // 闭区间
        var mid = Math.floor((right + left) / 2);
        if (nums[mid] === target) {
            return mid;
        } else if (nums[mid] < target) {
            left = mid + 1;
        } else if (nums[mid] > target) {
            right = mid - 1;
        }
    }
    return -1;
};

两数之和

167. 两数之和 II - 输入有序数组 - 力扣(LeetCode)

js
var twoSum = function(numbers, target) {
	let left = 0
	let right = numbers.length - 1
	while(left < right) {
		const total = numbers[left] + numbers[right]
		if (total === target) {
			return [left + 1, right + 1]
		} else if (total < target) {
			left++
		} else if (total > target) {
			right--
		}
	}
	return []
};

反转数组

344. 反转字符串 - 力扣(LeetCode)

js
var reverseString = function(s) {
	let left = 0
	let right = s.length - 1
	while(left < right) {
		swap(s, left, right)
		left++
		right--
	}
	return s
};

151. 反转字符串中的单词 - 力扣(LeetCode) 全部翻转,再区间翻转

回文串判断

js
var isPalindrome = function(s) {
    // 一左一右两个指针相向而行
    let left = 0
    let right = s.length-1;
    while (left < right) {
        if (s.charAt(left) !== s.charAt(right)) {
            return false;
        }
        left++;
        right--;
    }
    return true;
};

最长的回文串

判断回文串是从两端向中心,求最长回文串是从中心向两端。

  • 如果回文串的长度为偶数,则可以认为它有两个中心字符。
  • 如果回文串的长度为奇数,则它有一个中心字符;
js
function palindrome(s, l, r) {
	while (0 <= l && r < s.length && s[l] === s[r]) {
		l--
		r++
	}
	return s.substring(l + 1, r)
}
var longestPalindrome = function(s) {
	let ret = ''
	for (let i = 0; i < s.length; i++) {
		// 假设回文串长度为偶数,两个中心点分别传入 `l`、`r`。
		const even = palindrome(s, i, i + 1)
		// 假设回文串长度为奇数,一个中心点传入 `l`、`r`。
		const odd = palindrome(s, i, i)
		const max = even.length > odd.length? even: odd
		ret = ret.length > max.length? ret: max
	}
	return ret
};

前缀和

前缀和:原始数组不会被修改的情况下,适用于快速、频繁地计算一个索引区间内的元素之和 动态规划的一种,当前值与前值有关系,或者可以拆分成若干个前值。只不过题目类型都是与和有关,比动态规划的范畴小。

注意点:

  • 新建数组,数组默认为 0,等同初始化 baseCase
  • 定义: preSum[i] 记录了 [0, i - 1] 区间的元素和,下标 i 前缀和不包含本身 nums[i],熟记这一点思路会清晰很多。

一维数组

303. 区域和检索 - 数组不可变 - 力扣(LeetCode)

  1. 新建数组 preSum `,长度为目标数组长度 + 1。
  2. preSum 定义为当前下标前的所有值之和,数组第一个值为 0 (BaseCase)。每个下标都不包含自身值,值包含下标前所有值之和。

所以有以下定义

js
this.preSum = [0]
// 添加了 BaseCase ,是为了动态规划表达式边界成立。
// 留意 i <= nums.length  
for (let i = 1; i <= nums.length; i++) {
	this.preSum[i] = this.preSum[i - 1] + nums[i - 1]
}

[left, right] 区域内的和定义为

js
// 前缀和的定义是当前下标前所有值之和,不包含本身下标值
// 所以要取到 right 坐标本身的值,便需要 this.preSum[right + 1]
sumRange = function(left, right) {
	return this.preSum[right + 1] - this.preSum[left]
}

二位数组

304. 二维区域和检索 - 矩阵不可变 - 力扣(LeetCode)

如果需要求两个坐标内区间的值之和,可以通过分解矩阵,转换成都跟原点相关的矩阵。

这样就把问题降级了,接下来只需要求从原点到坐标区间和。

preSum[i][j] 的定义: 从原点 [0, 0][i - 1, j - 1] 所有值之和 所以我们要求区间原点至 (x,y) 之和,需要的是 preSum[x+1][y+1]

js
var NumMatrix = function(matrix) {
    const m = matrix.length
    const n = matrix[0].length
    // `preSum[i][j]` 的定义: 从原点 `[0, 0]` 到 `[i - 1, j - 1]` 所有值之和
    this.preSum = new Array(m + 1).fill(null).map(() => new Array(n + 1).fill(0)) // base case

	// 计算每个矩阵 [0, 0, i, j] 的元素和
    for (let i = 1; i <= m; i++) { // 从 1 开始
        for (let j = 1; j <= n; j++) { // 从 1 开始
            this.preSum[i][j] = this.preSum[i - 1][j] + this.preSum[i][j - 1] + matrix[i - 1][j - 1] - this.preSum[i - 1][j - 1]
        }
    }
};

NumMatrix.prototype.sumRegion = function(row1, col1, row2, col2) {
	// 所以我们要求区间原点至 `(x,y)` 之和,需要的是 `preSum[x+1][y+1]`
	// 区间内矩阵和通过分解矩阵,转换成都跟原点相关的矩阵。
    return this.preSum[row2 + 1][col2 + 1] - this.preSum[row1][col2 + 1] - this.preSum[row2 + 1][col1] + this.preSum[row1][col1] 
};

差分数组

差分数组的主要适用场景是频繁对原始数组的某个区间的元素进行增减。O (1) 复杂度修改。

定义:diff[i] : nums[i] 和 nums[i-1] 之差

js
var diff = new Array(nums.length);
// 构造差分数组
diff[0] = nums[0];
for (var i = 1; i < nums.length; i++) {
    diff[i] = nums[i] - nums[i - 1];
}

通过这个 diff 差分数组是可以反推出原始数组 nums。 当前值等于上一个值加上当前值与上值差值。 res[i]res[i - 1] + diff[i]

js
var nums = new Array(diff.length);
// 根据差分数组构造结果数组
nums[0] = diff[0];
for (var i = 1; i < diff.length; i++) {
    nums[i] = nums[i - 1] + diff[i];
}

差分数组可以快速进行区间增减的操作,如果想对区间 nums[i..j] 的元素全部加 3,那么只需要让 diff[i] += 3,然后再让 diff[j+1] -= 3 即可

原理很简单,回想 diff 数组反推 nums 数组的过程,diff[i] += 3 意味着给 nums[i..] 所有的元素都加了 3,然后 diff[j+1] -= 3 又意味着对于 nums[j+1..] 所有元素再减 3,那综合起来对 nums[i..j] 中的所有元素都加 3

1109. 航班预订统计 - 力扣(LeetCode)1094. 拼车 - 力扣(LeetCode)

js
class Difference {
    constructor (nums) {
        this.diff = []
        const { length } = nums
        if(!length) return
        this.diff[0] = nums[0]
        for(let i = 1; i < length; i++) {
            this.diff[i] = nums[i] - nums[i - 1]
        }
    }
    increment(i, j, val) { /* 给闭区间 [i, j] 增加 val(可以是负数)*/
        this.diff[l] += val
        if (j + 1  > this.diff.length - 1) return // 如果超过范围,说明是对 nums[i] 及以后的整个数组都进行修改,不需要减了
        this.diff[j + 1] -= val
    }
    result() { /* 返回结果数组 */
        const nums = []
        const { length } = this.diff
        if (!length) return nums
        nums[0] = this.diff[0]

        for (let i = 1; i < length; i++) {
            nums[i] = nums[i - 1] + this.diff[i]
        }
        return nums
    }
}

二维数组遍历-技巧

记技巧,会者不难,不会者难的类型

顺/逆时针旋转矩阵

48. 旋转图像 - 力扣(LeetCode)

解法:

  1. 按照左上到右下的对角线进行镜像对称
  2. 再对矩阵的每一行进行反转 这样顺时针旋转 90 度完成。 旋转二维矩阵的难点在于将「行」变成「列」,将「列」变成「行」,而只有按照对角线的对称操作是可以轻松完成这一点的,对称操作之后就很容易发现规律了。
js
const n = matrix.length
// 左上到右下对角线镜像对称
for (let i = 0; i < n; i++) {
	for (let j = i; j < n; j++) {
		swap(matrix, i, j, j, i)
	}
}

同样也可以实现逆时针选择 90 度,从右上到左下对角线镜像对称,再行翻转。

js
const n = matrix.length
// 右上到左下对角线镜像对称
for (let i = n; i >= 0; i--) {
	for (let j = n - 1 - i; j >= 0; j--) {
		swap(matrix, i, j, n - 1 - j, n - 1 - i)
	}
}

矩阵的螺旋遍历

54. 螺旋矩阵 - 力扣(LeetCode)

js
var spiralOrder = function(matrix) {
	    let left = 0, top = 0, right = matrix[0].length - 1, bottom = matrix.length - 1
	    let dire = 0
	    const ret = []
	    while (left <= right && top <= bottom) {
	        if (dire === 0) { // 向右
	            let j = left
	            while( j <= right) { // 不超过右边缘
	                ret.push(matrix[top][j])
	                j++
	            }
	            top++ // 上边界都处理过了,上边界往中间缩
	        } else if (dire === 1) {
	            let i = top
	            while ( i <= bottom) {
	                ret.push(matrix[i][right])
	                i++
	            }
	            right--
	        } else if (dire === 2) {
	            let j = right
	            while ( j >= left) {
	                ret.push(matrix[bottom][j])
	                j--
	            }
	            bottom--
	        } else if (dire === 3) {
	            let i = bottom
	            while ( i >= top) {
	                ret.push(matrix[i][left])
	                i--
	            }
	            left++
	        }
	        
	        dire = ++dire % 4
	    }
	    return ret
};

滑动窗口

基础

解决子串问题。左右指针轮流前进,窗口大小增增减减,窗口不断向右滑动,这就是「滑动窗口」

左右指针维护一个窗口,不断滑动,同时更新答案。 时间复杂度: O(N)

需要留意

  • 移动 right 前,应该更新进入窗口 (right) 的数据,后移动 right 扩大窗口。
  • 满足条件时窗口应该暂停扩大,缩小窗口前应该更新离开窗口(left)的数据,后移动 left 缩小窗口。
  • 结果应该在扩大窗口时更新,并在缩小窗口时进行反向更新。

思路:

  1. 在字符串 S 中使用双指针中的左右指针技巧,初始化 left = right = 0,把索引左闭右开区间 [left, right) 称为一个「窗口」。
  2. 不断地增加 right 指针扩大窗口 [left, right),直到窗口中的字符串符合要求(包含了 T 中的所有字符)。
  3. 此时,我们停止增加 right,转而不断增加 left 指针缩小窗口 [left, right),直到窗口中的字符串不再符合要求(不包含 T 中的所有字符了)。同时,每次增加 left,我们都要更新一轮结果。
  4. 重复第 2 和第 3 步,直到 right 到达字符串 S 的尽头。

理论上你可以设计两端都开或者两端都闭的区间,但设计为左闭右开区间是最方便处理的。因为这样初始化 left = right = 0 时区间 [0, 0) 中没有元素,但只要让 right 向右移动(扩大)一位,区间 [0, 1) 就包含一个元素 0 了。 如果你设置为两端都开的区间,那么让 right 向右移动一位后开区间 (0, 1) 仍然没有元素; 如果你设置为两端都闭的区间,那么初始区间 [0, 0] 就包含了一个元素。这两种情况都会给边界处理带来不必要的麻烦。

第 2 步相当于在寻找一个「可行解」,然后第 3 步在优化这个「可行解」,欲找到一个「最优解」。

js
var slidingWindow = function(s) {
    // 用合适的数据结构记录窗口中的数据
    const map = new Map();
    
    let left = 0, right = 0;
    while (right < s.length) {
        // c 是将移入窗口的字符
        let c = s[right];
        map.set(c, (map.get(c) || 0) + 1);
         // 进行窗口内数据的一系列更新
        ...
        
        // 增大窗口,且是左闭右开
        right++; 
       

        console.log("window: [" + left + ", " + right + ")");
        /********************/
        
        // 判断左侧窗口是否要收缩
        while (left < right && window needs shrink) { // 如果是固定长窗口(固定长窗口例如判断 right - left === xx.size ),由于只移除1个字符,可以将 while 改成 if
            // d 是将移出窗口的字符
            let d = s[left];
            map.set(d, map.get(d) - 1);
            // 进行窗口内数据的一系列更新
            ...
            
            // 缩小窗口
            left++;
            
        }
    }
}

这两个 ... 处的操作分别是扩大和缩小窗口的更新操作,它们操作是完全对称的。为了避免弯弯绕绕,请记得反操作代码顺序也要对称。

虽然滑动窗口代码框架中有一个嵌套的 while 循环,但算法的时间复杂度依然是 O(N)

指针 left, right 不会回退(它们的值只增不减),所以字符串/数组中的每个元素都只会进入窗口一次,然后被移出窗口一次,不会有某些元素多次进入和离开窗口,所以算法的时间复杂度就和字符串/数组的长度成正比。

76. 最小覆盖子串 - 力扣(LeetCode)

该题思路图

  • needs 计数器:记录 T 中字符出现次数。
  • window 计数器:「窗口」中的相应字符的出现次数。

增加 right,直到窗口 [left, right) 包含了 T 中所有字符

此时,开始增加 left,缩小窗口 [left, right)

直到窗口中的字符串不再符合要求,left 不再继续移动。重复上述过程,先移动 right,再移动 left …… 直到 right 指针到达字符串 S 的末端,算法结束。

js
var minWindow = function(s, t) {
    let left = 0, right = 0, start = 0, length = Infinity
    const tMap = new Map()
    for (let i = 0; i < t.length; i++) {
        const str = t[i]
        tMap.set(str, (tMap.get(str) || 0) + 1)
    }

    const map = new Map()
    while(right < s.length) {
        const r = s[right]
        map.set(r, (map.get(r) || 0) + 1)
        right++

        while (left < right && isInWindow({map, tMap})) {
            const l = s[left]
            map.set(l, (map.get(l) || 0) - 1)
            
            const len = right - left
            left++

            if (length < len) {
                continue
            }

            length = len
            start = left - 1
        }
    }
    if (length === Infinity) return ''

    return s.substr(start, length)
};

// 当然可以优化掉该方法, 用一个计数器变量记录已满足字符串的数量
const isInWindow = ({map, tMap}) => {
    if (map.size < tMap.size) return false
    const entires = tMap.entries()
    for (let entry of entires) {
        const [s, tNum] = entry
        const num = map.get(s) || 0
        if (num < tNum) {
            return false
        }
    }
    return true
}

567. 字符串的排列 - 力扣(LeetCode) 这道题与上一道的区别是,连续的字符。 留意反操作,是先判断计数器,再修改 needs。

因为当前元素离开前,如果 needs 中为 0,意味着该字符原本是满足数量的,但即将被打破,所以计数器减一。 如果顺序颠倒先更新 needs 再更新计数器,那么需要修改判断条件为 if (needs.get(d) === 1) 因为 left 移开窗口后,当前字符缺少了 1,移开之前该字符是处于满足状态,满足状态被打破了,计数器也要同步减一。为了避免弯弯绕绕,请记得反操作代码顺序也要对称。

js
/**
 * @param {string} s1
 * @param {string} s2
 * @return {boolean}
 */
var checkInclusion = function(s1, s2) {
    const needs = new Map()
    for (let s of s1) {
        needs.set(s, (needs.get(s) || 0) + 1)
    }
    let left = 0, right = 0, validate = 0
    const {length} = s2
    while (right < length) {
        const s = s2[right] // 因为左闭右开,此时的right是进入window的元素
        if (needs.has(s)) {
            needs.set(s, needs.get(s) - 1)
            if (needs.get(s) === 0) {
                validate++
            }
        }
        right++ // 左必右开

        while (validate === needs.size) { // 固定长,也可以用 if
            // 满足条件的子串,长度肯定跟s1一致
            const d = s2[left] // 即将离开window的元素
            /* 留意这里 */
            if (needs.has(d)) { // 反操作,留意是先判断计数器,再修改 needs
                if (needs.get(d) === 0) {
                    validate--
                }
                needs.set(d, needs.get(d) + 1)
            }
            if (right - left === s1.length) return true

            left++

        }
    }

    return false
};

如果维护的是一个固定长的窗口,窗口大小为 needs.size()。因为固定长的窗口每次向前滑动时只会移出一个字符,所以可以把内层的 while 改成 if,效果是一样的。

438. 找到字符串中所有字母异位词 - 力扣(LeetCode)

3. 无重复字符的最长子串 - 力扣(LeetCode)

这里有个特殊点,维护数据在哪个阶段?或者说哪一个阶段可以保证窗口中的字符串是没有重复?

js
/**
 * @param {string} s
 * @return {number}
 */
var lengthOfLongestSubstring = function(s) {
    const map = new Map()
    let left = 0, right = 0, len = 0 
    const {length} = s
    while (right < length){
        const r = s[right]
        map.set(r, (map.get(r) || 0) + 1)
        right++

        while(map.get(r) > 1) { // 重复了,长度不在里面计算
            const l = s[left]
            map.set(l, map.get(l) - 1)
            
            left++
        }
        // 只有在跳出重复这个循环后,才能确保是不重复的,
        // 范围是[left, right),计算长度: right - left 
        // 不用 + 1.  [left, right] 才需要 + 1. 可以参考 [0, 0) 和 [0, 0]
        len = Math.max(len, right - left)
    }
    if (!left) return length

    return len
};

扩展-Rabin-Karp

在滑动窗口中快速计算窗口中元素的哈希值,叫做滑动哈希技巧

原理

提供给你源数字 number ,请你在最低位添加一个数字 appendVal。例如 number= 8264,appendVal = 3,返回 82643。

js
let number = 8264
const appendVal = 3
const R = 10 // 进制
number = number * R + appendVal // 82643

提供给你源数字 number ,请你在最高位删除一个数字。例如 number= 8264,返回 264。

js
let number = 8264
const removeVal = 3
const R = 10 // 进制
const L = 4 // number 位数
number = number - removeVal * R^(L - 1) // 264

Rabin-Karp 便是基于以上两个原理

例子

187. 重复的DNA序列 - 力扣(LeetCode) 子串问题,长度为 10,即代入滑动窗口模版

js
var slidingWindow = function(s) {
    // 用合适的数据结构记录窗口中的数据
    const map = new Map();
    
    let left = 0, right = 0;
    while (right < s.length) {
        // c 是将移入窗口的字符
        let c = s[right];
         // 进行窗口内数据的一系列更新
        ...
        
        // 增大窗口,且是左闭右开
        right++; 
       

        console.log("window: [" + left + ", " + right + ")");
        /********************/
        
        // 判断左侧窗口是否要收缩
        if (right - left === 10) { // 如果是固定长窗口(固定长窗口例如判断 right - left === xx.size ),由于只移除1个字符,可以将 while 改成 if
            // d 是将移出窗口的字符
            let d = s[left];
            // 进行窗口内数据的一系列更新
            ...
            
            // 缩小窗口
            left++;
        }
    }
}

如果是暴力解,字符串记录读取后 10 个需要额外时间。用滑动窗口使得窗口长度为 10,通过移动窗口,窗口进一个字节出一个字节,可以避免截取子串,节省每次读 10 个字符的时间。 为了校验重复,还需要记录占用大量额外空间,如果可以将这 10 个字符串通过一个类似一个哈希函数的方法转换成一个数字,那么将节省大量空间。

基于上面两个原理,在最低位增加数字,在最高位删除数字,这便是对应滑动窗口的挪动。 至于进制,便需要将题目抽象成对应数字状态值(例如题目中的 'A''C''G' 'T' 对应 0 - 3 )。

js
const getNum = (s) => {
    switch(s) {
        case 'A':
            return 0
        case 'C':
            return 1
        case 'G':
            return 2
        case 'T':
            return 3
    }
}
/**
 * @param {string} s
 * @return {string[]}
 */
var findRepeatedDnaSequences = function(s) {
    let left = 0, right = 0, nums = 0
    const map = new Map() // 计数器
    const set = new Set() // 重复的返回值
    const {length} = s
    const R = 4 // 进制
    const L = 10 // 长度
    const RL = Math.pow(R, L - 1) // 减去最高位所需倍数值 R^(L - 1)
    while (right < length) {
        const r = s[right]
        nums = nums * R + getNum(r)
        right++
        
        if (right - left === L) {
            const l = s[left]
            // 离开之前记录
            if (map.has(nums)) {
                set.add(s.slice(left, right))
            } else {
                map.set(nums, 1)
            }
            nums = nums - getNum(l) * RL
            left++
        }
    }
    
    return [...set.values()]
};

模版

js
/*
	@params txt 文本串(原文本)
	@params pat 模式串(待匹配文本)
*/
function (txt, pat) {
	// 需要寻找的子串长度为模式串 pat 的长度  
	const L = pat.length();  
	// 仅处理 ASCII 码字符串,可以理解为 256 进制的数字  
	const R = 256;  
	// 存储 R^(L - 1) 的结果  
	const RL = Math.pow(R, L - 1);  
	// 维护滑动窗口中字符串的哈希值  
	let windowHash = 0;  
	// 计算模式串的哈希值  
	let patHash = 0;  
	for (let i = 0; i < pat.length(); i++{  
	    patHash = R * patHash + pat.charCodeAt(i);  
	}  
	  
	// 滑动窗口代码框架  
	let left = 0, right = 0;  
	while (right < txt.length{  
	    // 扩大窗口,移入字符(在最低位添加数字)  
	    windowHash = R * windowHash + txt.charCodeAt(right);  
	    right++;  
	  
	    // 当子串的长度达到要求  
	    if (right - left === L{  
	        // 根据哈希值判断窗口中的子串是否匹配模式串 pat  
	        if (patHash === windowHash{  
	            // 找到模式串  
	            console.log("找到模式串,起始索引为", left);  
	            return left;  
	        }  
	          
	        // 缩小窗口,移出字符(删除最高位数字)  
	        windowHash = windowHash - txt.charCodeAt(left* RL;  
	        left++;  
	    }  
	}  
	// 没有找到模式串  
	return -1;
}

ASCII 码其实就是 0~255 这 256 个数字,分别对应所有英文字符和英文符号。一个长度为 L 的 ASCII 字符串可以等价理解成一个 L 位的 256 进制的数字,作为哈希值。

不过这段代码有严重的问题,整型溢出。相同位数下,256 进制包含的数字数量远大于十进制包含的数字数量。求模(余数) 可以解决该问题。

无论一个数字多大,你让它除以 Q,余数一定会落在 [0, Q-1] 的范围内。所以设置一个 Q,用求模的方式让 windowHashpatHash 保持在 [0, Q-1] 之间,把这个余数作为该字符串的哈希值,可以避免整型溢出。 但求模之后的哈希值不能和原始字符串一一对应了,可能出现一对多的情况,即哈希冲突。(10 % 7 等于 3,而 17 % 7 也等于 3)。

拉链法和线性探查法解决了哈希表的哈希冲突,但此处不扩展。

只要 Q 设置合理且尽可能大且尽可能是素数,哈希冲突出现的概率比较小,偶尔用一下暴力匹配算法不影响总体的时间复杂度。

模运算法则:

js
(X + Y) % Q === (X % Q + Y % Q) % Q
% Q === (X + Q) % Q  

在代码中但凡涉及到乘法加法都可能产生很大的结果,都要运用上述法则对结果进行求模,以避免造成溢出。

js
/*
	@params txt 文本串(原文本)
	@params pat 模式串(待匹配文本)
*/
function (txt, pat) {
	// 需要寻找的子串长度为模式串 pat 的长度  
	const L = pat.length();  
	// 仅处理 ASCII 码字符串,可以理解为 256 进制的数字  
	const R = 256;  
	// 取一个比较大的素数作为求模的除数  
    const Q = 1658598167;
    // 存储 R^(L - 1) 的结果  
	let RL = 1;  
    for (let i = 1; i <= L - 1; i++{  
        // 计算过程中不断求模,避免溢出  
        RL = (RL * R% Q;  
    }
	// 维护滑动窗口中字符串的哈希值  
	let windowHash = 0;  
	// 计算模式串的哈希值  
	let patHash = 0;  
	for (let i = 0; i < pat.length(); i++{  
		// 计算过程中不断求模,避免溢出
	    patHash = (R * patHash + pat.charCodeAt(i)) % Q;  
	}  
	  
	// 滑动窗口代码框架  
	let left = 0, right = 0;  
	while (right < txt.length{  
	    // 扩大窗口,移入字符(在最低位添加数字)  
	    windowHash = ((R * windowHash  % Q+ txt.charCodeAt(right)) % Q;  
	    right++;  
	  
	    // 当子串的长度达到要求  
	    if (right - left === L{  
	        // 根据哈希值判断窗口中的子串是否匹配模式串 pat  
	        if (patHash === windowHash{  
	            // 当前窗口中的子串哈希值等于模式串的哈希值  
                // 还需进一步确认窗口子串是否真的和模式串相同,避免哈希冲突  
                if (pat === txt.slice(left, right)) {  
	                // 找到模式串  
		            console.log("找到模式串,起始索引为", left);  
                    return left;  
                }
	        }  
	          
	        // 缩小窗口,移出字符(删除最高位数字)  
	        windowHash = windowHash - txt.charCodeAt(left* RL;
	        windowHash = ((windowHash - (txt.charCodeAt(left* RL% Q+   Q% Q
	        // X % Q === (X + Q) % Q 
            // 因为 windowHash - (txt[left] * RL) % Q 可能是负数  
            // 所以额外再加一个 Q,保证 windowHash 不会是负数
	        left++;  
	    }  
	}  
	// 没有找到模式串  
	return -1;
}

二分搜索

有序数组查找,用二分搜索。

从搜索区间的思维去写代码,会顺畅许多

  • 区间两端闭 (推荐)

    • right 初始化下标边界 length - 1
    • while 带等号
    • if 相等返回
    • mid 需要加减一
    • while 结束则不满足。
  • 区间左闭右开

    • right 初始化下标边界 length
    • while 用小于
    • if 相等别返回,利用 mid 锁边界
    • mid 加减一,看区间开或闭
    • while 结束,还需要 if 判断边界

left + (right - left) / 2 等同 (left + right) / 2 ,避免出现直接相加导致溢出的情况。

js
function binarySearch(nums, target) {
    let left = 0, right = ...;

    while(...) {
        let mid = left + (right - left) / 2;
        if (nums[mid] === target) { // 编写这一块时,要理清楚下一个要搜索的区间。
            ...
        } else if (nums[mid] < target) {
            left = ...
        } else if (nums[mid] > target) {
            right = ...
        }
    }
    return ...;
}

... 标记的部分,就是可能出现细节问题的地方  - right 初始化为何值?  - while 条件要用等号吗?  - if 相等时,要做什么?  - leftright 赋值要加减一吗?  - while 结束后,要打补丁判断边界吗?

问题

right 初始化为何值?

取决你要使用闭区间还是左闭右开区间

  • 闭区间:right = length - 1 (推荐)
  • 左闭右开区间: right = length

while 条件要用等号吗?

答案是:取决于 right 初始化,决定了闭区间、左闭右开区间。 right 初始为 length - 1,非越界用闭区间 [left, right],体现 :while(left <= right)right 初始化为 lengthright 越界了将其视为开区间 [left, right),体现 :while(left < right)

为了理解上面结论,我们先搞清楚一个概念:我们的遍历范围,用闭区间 [left, right],还是左闭右开区间 [left, right) 有什么区别?

如果是闭区间 [left, right],意味着 leftright 都不能越界,例如不能出现 nums[-1]nums[length],否则代码会报错。所以闭区间 [left, right] 需要 left = 0 right = length - 1

闭区间如果存在越界值 [-1, length] ,当在其中搜索不到目标时,会将区间缩减到 [-1, -1][length, length],那时便报错了。所以闭区间一定要初始化限制,不要出现越界下标。

while(left <= right) 带等号,意味着 left === right 是满足条件的,例如 left = 1, right = 1 会进入循环,这等效于闭区间 [1, 1] 区间不为空,有 1 这个下标进入循环。所以结论:带等号等效于闭区间,闭区间范围内的下标都会进入循环。

理解了闭区间,再来讲左闭右开区间 [left, right) 就容易了 while(left < right),意味着 left === right 不会进入循环,例如 left = 1, right = 1 不会进入循环,这等效于闭区间 [1, 1) 区间为空,没有下标进入循环。所以结论:不带等号等效于左闭右开区间,left === right 时不会进入循环。 此时 right = length 才能确保进入循环的范围是完整的范围 [0, length),且需要在后续打补丁判断例如 [1, 1) 此时 1 遗漏了没进入循环。

if 相等时,要做什么?

视题目类型而定

  • 搜索值,相等即是找到,直接返回
  • 搜边界,相等根据题目要求缩小边界,不能返回

leftright 赋值要加减一吗?

闭区间 [left, right]mid 已经判断过了,后续要搜索的区间是 [left, mid - 1][mid + 1, right],所以不能包含 mid 本身,需要加减一。 左闭右开区间 [left, right)mid 已经判断过了,后续要搜索的区间是 [left, mid)[mid + 1, right),所以 right = mid ,而 left = mid + 1

所以,还是闭区间比较容易统一。但只要理解区间的特性,也能很快写出,。

while 结束后,要打补丁判断边界吗?

搜索元素

  • 闭区间 [left, right]left === right 时也会进入循环,没有遗漏,不需要在 while 结束后打补丁。(推荐)
  • 左闭右开区间 [left, right)left === right 时不会进入循环,需要在 while 结束后打补丁判断。 搜索区间
  • 都需要打补丁,判断是否越界,判断是否找到。

while 结束后,要返回什么?

本次只将闭区间,因为统一闭区间会大大降低负担。

搜索元素:因为没找到,直接返回 -1

搜索边界:通过 if 相等时推断 搜索左侧边界:

js
	if (nums[mid] === target) {
		right = mid - 1 // [left, mid - 1] !! 重点,向左靠拢
		// 等同于 mid === right + 1 === left
	} 

mid = right + 1,而闭区间 while (left <= right) 的结束条件是 left === right + 1 === mid 此时用来判断边界和返回都是 left

搜索右侧边界:

js
	if (nums[mid] === target) {
		left = mid + 1 // [mid + 1, right]  !! 重点,向右靠拢
		// 等同于 mid === left - 1 === right
	} 

mid = left - 1,而闭区间 while (left <= right) 的结束条件是 left === right + 1 或者 right === left - 1 === mid 此时用来判断边界和返回都是 left - 1 或者用 right

搜索元素

  • 区间两端闭
    • right 初始化下标边界 length - 1
    • while 带等号
    • if 相等返回
    • mid 需要加减一
    • while 结束则不满足。
js
var search = function(nums, target) {
    let left = 0, right = nums.length - 1
    // 搜索区间 [left, right] 闭区间
    while (left <= right) { // [0, 0] 也算有值,不会遗漏不用打补丁
        const mid = left + Math.floor((right - left) / 2)
        if (nums[mid] === target) { // if 相等意味着找到
            return mid
        } else if (nums[mid] < target) { // 目标比中值大,在右区间,左边界缩小
            left = mid + 1 // [mid + 1, right] 由于是闭区间,mid 已经比较过了,需要 + 1
        } else if (nums[mid] > target) { // 目标比中值小,在左区间,右边界缩小
            right = mid - 1 // [left, mid - 1] 由于是闭区间,mid 已经比较过了,需要 - 1
        }
    }
    // while 结束代表整个区间都没找到,不满足返回 -1
    return -1
};
因为我们初始化 right = nums.length - 1
所以决定了我们的「搜索区间」是 [left, right]
所以决定了 while (left <= right)
同时也决定了 left = mid + 1 和 right = mid - 1

因为我们只需找到一个 target 的索引即可
所以当 nums[mid] == target 时可以立即返回

寻找左侧边界

js
var searchRange = function(nums, target) {
    let left = 0, right = nums.length - 1
    // 搜索区间 [left, right] 闭区间
    while (left <= right) {
        const mid = left + Math.floor((right - left) / 2)
        if (nums[mid] === target) {
            right = mid - 1 // [left, mid - 1] !! 重点,向左靠拢,继续缩小搜索区间
            // 等同于 mid === right + 1 === left
        } else if (nums[mid] < target) { // 目标比中值大,在右区间,左边界缩小
            left = mid + 1 // [mid + 1, right]
        } else if (nums[mid] > target) { // 目标比中值小,在左区间,右边界缩小
            right = mid - 1 // [left, mid - 1]
        } 
    }

    // 判断 target 是否存在于 nums 中
    // 如果越界,target 肯定不存在,返回 -1
    if (left < 0 || left >= nums.length) {
        return -1;
    }
    // 判断一下 nums[left] 是不是 target
    return nums[left] === target ? left : -1;
};

通过 if 相等时推断,例如搜索左侧边界

js
	if (nums[mid] === target) {
		right = mid - 1 // [left, mid - 1] !! 重点,向左靠拢,继续缩小搜索区间
		// 等同于 mid === right + 1 === left
	} 

mid = right + 1,而闭区间 while (left <= right) 的结束条件是 left === right + 1 === mid 此时用来判断边界和返回都是 left

因为我们初始化 right = nums.length - 1
所以决定了我们的「搜索区间」是 [left, right]
所以决定了 while (left <= right)
同时也决定了 left = mid + 1 和 right = mid - 1

因为我们需找到 target 的最左侧索引
所以当 nums[mid] === target 时不要立即返回
而要收紧右侧边界以锁定左侧边界

因为 mid === right + 1 === left, 判断边界和返回 left

寻找右侧边界

js
var searchRange = function(nums, target) {
    let left = 0, right = nums.length - 1
    // 搜索区间 [left, right] 闭区间
    while (left <= right) {
        const mid = left + Math.floor((right - left) / 2)
        if (nums[mid] === target) {
            left = mid + 1 // [mid + 1, right]  !! 重点,向右靠拢,继续缩小搜索区间
            // 等同于 mid === left - 1 === right
        } else if (nums[mid] < target) { // 目标比中值大,在右区间,左边界缩小
            left = mid + 1 // [mid + 1, right]
        } else if (nums[mid] > target) { // 目标比中值小,在左区间,右边界缩小
            right = mid - 1 // [left, mid - 1]
        } 
    }
    // 判断 target 是否存在于 nums 中
    // 如果越界,target 肯定不存在,返回 -1
    if (left - 1 < 0 || left - 1 >= nums.length) {
        return -1;
    }
    // 判断一下 nums[left - 1] 是不是 target
    return nums[left - 1] === target ? left - 1 : -1;
};

通过 if 相等时推断 例如搜索右侧边界:

js
	if (nums[mid] === target) {
		left = mid + 1 // [mid + 1, right]  !! 重点,向右靠拢,继续缩小搜索区间
		// 等同于 mid === left - 1 === right
	} 

mid = left - 1,而闭区间 while (left <= right) 的结束条件是 left === right + 1 或者 right === left - 1 === mid 此时用来判断边界和返回都是 left - 1 或者用 right

因为我们初始化 right = nums.length - 1
所以决定了我们的「搜索区间」是 [left, right]
所以决定了 while (left <= right)
同时也决定了 left = mid + 1 和 right = mid - 1

因为我们需找到 target 的最右侧索引
所以当 nums[mid] === target 时不要立即返回
而要收紧左侧边界以锁定右侧边界

因为 mid === left - 1 === right, 判断边界和返回 left - 1 或 right

寻找左右边界范围

34. 在排序数组中查找元素的第一个和最后一个位置 - 力扣(LeetCode)

js
/**
 * @param {number[]} nums
 * @param {number} target
 * @return {number[]}
 */
var searchRange = function(nums, target) {
    let left = 0, right = nums.length - 1
    while (left <= right) {
        const mid = left + Math.floor((right - left) / 2)

        if (nums[mid] === target) {
            right = mid - 1 // [left, mid - 1]
        } else if (nums[mid] > target) {
            right = mid - 1 // [left, mid - 1]
        } else if (nums[mid] < target) {
            left = mid + 1 // [mid + 1, right]
        }
    }

    if (left < 0 || left >= nums.length || nums[left] !== target) return [-1, -1]

    const retLeft = left

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

};

二分搜索-分析运用

分析

算法题一般都让你求最值,常用到的是「搜索左侧边界」和「搜索右侧边界」这两种场景

如果写出暴力解,可以看出连续的空间线性搜索,便可以用二分搜索进行优化。

js
// 满足该模版
for (let i = 0; i < n; i++)  {
    if (fn(i)) {
        return answer;
    }
}

fn(i) 是单调函数,其返回值随着 i 的增加而增加,或者随着 i 的增加而减小,满足递增或递减有规律。

这个逻辑和「在有序数组中查找一个元素」一样,将 num[i] 当成函数,这便和 fn(i) 一样了

js
for (let i = 0; i < n; i++)  {
    if (nums[i] === target) {
        return answer;
    }
}

如果要求最小值就是搜索左侧边界的二分,要求最大值就用搜索右侧边界的二分。

如果你发现 O(NlogN) 这样存在对数的复杂度,一般都要往二分查找的方向上靠,这也算是个小套路。

总结

求最值

  1. 满足连续的空间线性搜索
    1. 有连续的 i
    2. fn(i) 是在 x 上的单调函数(单调增单调减)
  2. 定义好以下意义:
    1. i :自变量,或者横坐标,一般都是所求值。并定好其 left (最小) 和 right(最大)。
    2. fn(i):满足条件,如何编写。
    3. target:目标值,用于 fn(i) 判断。
  3. 求的是左侧边界(最小)还是右侧边界(最大)。

抽象成图像

例题

例如 875. 爱吃香蕉的珂珂 - 力扣(LeetCode)

js
// 暴力
var minEatingSpeed = function(piles, h) {
	const max = getMax(piles)
	for (let speed = 1; i <= max; speed++) { // 连续的线性搜索
		if (canFinish(speed, piles, h)) {
			return speed
		}
	}
	return max
};

用一个搜索左侧边界的二分查找来代替线性搜索,提升效率:

js
var minEatingSpeed = function(piles, h) {
	let left = 1, right = getMax(piles)
	while (left <= right) {
		const mid = left + Math.floor((right - left) / 2)
		// if (piles[mid] === h) {
		//	    right = mid - 1 // [left, mid - 1]
		//} else if (piles[mid] < h) {
		//  	left = mid + 1 // [mid + 1, right]
		//} else if (piles[mid] > h) {
		//  	right = mid - 1 // [left, mid - 1]
		//}
		if (canFinish(mid, piles, h)) { // 相等和速度快都需要搜索左区间,缩小右边以达到找左边界的目的
			right = mid - 1 // [left, mid - 1]
		} else {
			left = mid + 1 // [mid + 1, right]
		}
	}
	// 题意速度无限大,不会越界,不进行额外判断
	return left
};

1011. 在 D 天内送达包裹的能力 - 力扣(LeetCode)

js
/**
 * @param {number[]} weights
 * @param {number} days
 * @return {number}
 */
var shipWithinDays = function(weights, days) {
    // const max = getMax(weights)
    // for (let speed = 1; speed <= max; speed++) {
    //     if (canFinish(speed, weights, days)) {
    //         return speed
    //     }
    // }
    // return max
    
    let left = 1, right = getMax(weights)

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

const getMax = (weights) => {
    let total = 0
    for(let i = 0; i < weights.length; i++) {
        total += weights[i]
    }
    return total
}

const canFinish = (speed, weights, D) => {
    let i = 0
    for(let day = 1; day <= F; day++) {
        let remain = speed
        while (remain >= weights[i]) {
            remain -= weights[i]
            i++
            if (i === weights.length) return true
        }
    }
    return false
}

410. 分割数组的最大值 - 力扣(LeetCode) 思路:我们要求的是分组和最大值的最小值,将最小值作为横坐标暴力穷举,反向去求 k。如果有 k 满足条件,再找左边界去求最小值。

js
/**
 * @param {number[]} nums
 * @param {number} k
 * @return {number}
 */
var splitArray = function(nums, k) {
    const max = getMax(nums)
    // for (let maxVal = 0; maxVal <= max; maxVal++) { // 连续
    //     const dealK = getK(maxVal, nums) // 线性,nums 不变,maxVal 越大,分组 k 越小
    //     if (dealK === k) {
    //         return val
    //     }
    // }
    // 换个角度,只关心能不能满足条件,不关心 k 值
    // for (let maxVal = 0; maxVal <= max; maxVal++) { // 连续
    //     if (canFinish(maxVal, nums, k)) {  // 线性,nums 不变,maxVal 越大,分组 k 越小
    //         return val
    //     }
    // }
    
    // 当然,要优化可以优化 left,他是数组里最小的那个值。
    let left = 0, right = max
    while (left <= right) {
        const mid = left + Math.floor((right - left) / 2)
        if (canFinish(mid, nums, k)) { // 线性,nums 不变,maxVal 越大,分组 k 越小
            // 横坐标是和最大值,从小到大,我们要找最大值最小,所以是找左边界
            // 满足时,下一个搜索区间是左区间,继续缩小搜索范围 [left, mid - 1]
            right = mid -1
        } else {
            left = mid + 1
        }
    }
    return left
};

const getMax= (nums) =>{
    return nums.reduce((prev, now) => prev + now, 0) // 一个数组和最大,虽然会扩大搜索范围,但对于二分搜索没关系
}

const canFinish = (maxVal, nums, K) => {
    let i = 0
    for (let k = 1; k <= K; k++) {
        let remain = maxVal
        while(remain >= nums[i]) {
            remain -= nums[i]
            i++
            if (i === nums.length) {
                return true
            }
        }
    }
    return false
}

带权重随机选择

【前缀和技巧】和【二分搜索技巧】。 528. 按权重随机选择 - 力扣(LeetCode)

原理

假设输入的权重数组是 w = [1,3,2,1],想让概率符合权重,那么可以抽象一下,根据权重画出这么一条彩色的线 扔个石头,落在哪个点上,就选择该点上颜色对应的权重索引,每个索引被选中的概率,便和权重相关联。 此时能够发现 W 是个差分数组,可以通过这个相差值推到出各个点的原值,这个原值代表的意义是:落到该标记及之前的概率分子,分母则为整断线的长度。 同时也有另一个角度的理解,将差分数组视为一个普通数组,这个原值数组是通过差分数组依次累加计算得出的,所以原值数组是差分数组的前缀和。

原值数组是差分数组的前缀和数组,这个新奇的概念挺好玩的。

当我们将其概率在坐标轴上与原点距离体现,这条彩色线段是前缀和数组

当我们扔石子时,在 preSum 的随机数的范围是从 [1, 7],不包含 0,因为 0 只是个前缀和数组的占位符,并不存在原本的数组中。

当石子取值为 5 时,此时 preSum 并没有与之匹配的,但从题意可知我们要搜索的是,在 preSum z 中大于等于这个随机数的最小元素索引(左边界)。

题解

js
/**
 * @param {number[]} w
 */
var Solution = function(w) {
    this.w = w
    this.preSum = [0]
    for (let i = 1; i <= w.length; i++) {
        this.preSum[i] = this.preSum[i - 1] + w[i - 1]
    }
    return null
};

/**
 * @return {number}
 */
Solution.prototype.pickIndex = function() {
    const {length} = this.preSum
    // 在闭区间 [1, preSum[n - 1]] 中随机选择一个数字
    const target = Math.floor(Math.random() * this.preSum[length - 1]) + 1 
    let left = 1, right = length - 1
    while (left <= right) {
        const mid = left + Math.floor((right - left) / 2)
        // 获取 target 在前缀和数组 preSum 中的索引的左边界
        if (this.preSum[mid] === target) {
            right = mid - 1
        } else if (this.preSum[mid] > target) {
            right = mid - 1
        } else if (this.preSum[mid] < target) {
            left = mid + 1
        }
    }
    // mid = right + 1 = left
    // left 便是 this.preSum的目标左边界下标

    // 由于target是我们生成,找的是左边界,不会超出边界的,此处可以不判断
    // if (left < 0 || left >= length) return 

    // 由于this.preSum 比原数组还多了个0下标,所以需要转换原下标时 - 1
    return left - 1
};

常数时间删除/查找数组中的任意元素

  1. 想高效地,等概率地随机获取元素,就要使用数组作为底层容器。
  2. 要保持数组元素的紧凑性,可以把待删除元素换到最后,然后 pop 掉末尾的元素,这样时间复杂度就是 O(1) 了。当然,我们需要额外的哈希表记录值到索引的映射,便于交换待删除元素。
  3. 对于数组中含有「空洞」(黑名单数字),可以利用哈希表巧妙处理映射关系,让数组在逻辑上是紧凑的,方便随机取元素。

常数时间插入删除随机取

结合哈希表和数组,使得数组的删除操作时间复杂度变成 O(1)

380. O(1) 时间插入、删除和获取随机元素 - 力扣(LeetCode) 增删 O(1) 复杂度想到的是对象,但对象随机取值没法做到 O(1) ,想要 O(1) 随机取值,就要使用紧凑的数据结构数组作为底层容器。 数组删除:可以把待删除元素换到最后,然后 pop 掉末尾的元素,这样时间复杂度就是 O (1) 了。当然,需要额外的哈希表记录值到索引的映射 {val: index} ,这样删除某个值,就能找到其索引跟末端交换并推出,这个过程要维护好哈希表。

js
var RandomizedSet = function() {
    this.map = {}
    this.arr = []
};

/** 
 * @param {number} val
 * @return {boolean}
 */
RandomizedSet.prototype.insert = function(val) {
    if (typeof this.map[val] === 'number') {
     return false   
    }
    this.map[val] = this.arr.length
    this.arr.push(val) 
    return true
};

/** 
 * @param {number} val
 * @return {boolean}
 */
RandomizedSet.prototype.remove = function(val) {
    if (typeof this.map[val] !== 'number') {
        return false
    }
    const index = this.map[val]
    this.arr[index] = this.arr[this.arr.length - 1]
    this.arr.pop()
    delete this.map[val]
    this.map[this.arr[index]] = index
    return true
};

/**
 * @return {number}
 */
RandomizedSet.prototype.getRandom = function() {
    const r = Math.floor(Math.random() * this.arr.length)
    return this.arr[r]
};

避开黑名单的随机数

710. 黑名单中的随机数 - 力扣(LeetCode) 按照上道题的思路,需要 O(1) 随机取值,所以需要紧凑的数据结构数组作为底层容器。 上道题是删除操作,需要哈希表记录待删除的数据的值到索引,方便与数组尾部交换并推出。 本题是黑名单数组,把黑名单理解成被删除的数据,两题基本相似。但本题只有黑名单数组,并不像上道题一样需要有个完整的数组去做实际操作推出,如果此处去维护一个长度 n 的完整数组,所占内存过高。 我们需要的是,将黑名单维护在哈希表中,映射着黑名单至 n 末端的正常值,一但命中哈希表,则读取哈希表中的正常值。这样不需要维护一个完整数组,思路跟上道题的删除类似,但不用真正的交换,只要命中黑名单时,给他一个唯一对应的 n 末端正常值即可。 接下来,我们来维护这个的 n 末端正常值

  • 将黑名单移动到虚拟 n 数组的最后,所以真实可以随机取值的长度 sizen - 黑名单长度
  • 当黑名单值大于等于 size 时,本身在末端,不需要再映射。
  • 当映射的值在黑名单里,需要跳过该值,继续
ts
class Solution {
    map: {[n: number]: number}
    size: number
    constructor(n: number, blacklist: number[]) {
        let last = n - 1
        this.size = n - blacklist.length
        this.map = {}
        for (let i = 0; i < blacklist.length; i++) {
            const val = blacklist[i]
            // 大于等于 `size` 时,本身在末端,不需要再映射
            if (val >= this.size) { 
                continue;
            }
            
			// 要保证 last 一定不在 blacklist 中。因为需要映射的是正常值
            while (blacklist.includes(last)) { 
                last--
            }
            this.map[val] = last
            last--
        }
    }

    pick(): number {
        const val: number = Math.floor(Math.random() * this.size)
        // 有定义过,代表是黑名单,需要返回正常值。
        // 当然这里可以不用typeof,因为值不可能为0。
        // if(this.map[val]) 即可
        if (typeof this.map[val] === 'number') { 
            return this.map[val]
        }
        return val
    }
}

数组去重-单调栈

普通去重可以往对象设置。 添加限制,有序原地去重,可以参考快慢指针。增加更多限制,难度就会更大。

316. 去除重复字母 - 力扣(LeetCode) 该题增加了 2个限制

  1. 去重不打乱相对顺序
  2. 去重后字典序最小(字典序最小即按照字母顺序返回)

要满足限制 2,想到的是先排序,但这跟限制 1 矛盾。此时可以利用单调栈实现。

我们先实现一个普通栈去重

ts
function removeDuplicateLetters(s: string): string {
    const stack:string[] = []
    const map:{[property: string]: boolean} = {} // 标记,出现过则标记true
    
    for (let str of s) {
        if (map[str]) { // 如果出现过的,不推入
            continue;
        }
        stack.push(str) 
        map[str] = true // 未出现过的,推入并标记
    }
    return stack.join('') // 返回去重后文本
};

此时去重了,但没有做到去重后字典序最小。 所以在推入前,需要先判断栈顶元素是否比当前元素大,如果比当前元素大则将其弹出不要了,这样我们能保证栈内是单调递增的。虽然此时还没达到我们要求,但离得更近了

ts
function removeDuplicateLetters(s: string): string {
    const stack:string[] = []
    const map:{[property: string]: boolean} = {} // 标记,出现过或者说在栈内,标记true
    
    for (let str of s) {
        if (map[str]) { // 如果出现过的,不推入。或者说在栈内,不推入
            continue;
        }
        
        /* 多出这一部分 ---START--- */
        // 在推入前,先判断栈顶元素是否比当前元素大,如果比当前元素大则将其弹出不要了
        while(stack.length && stack[stack.length - 1] > str) {
            tack.pop()
        }
         /* 多出这一部分 ---END--- */

        stack.push(str) 
        map[str] = true // 未出现过的,推入并标记
    }
    return stack.join('') // 返回去重后文本
};

此时还有个问题,栈的弹出只要比当前元素大就会被弹出,如果像 bcac,我们是不希望 a 推入时,没重复出现的 b 被弹出的,因为后续没有 b 了。 所以我们需要知道目前被弹出的这个元素,后续是否还有,我们需要个计数器。

  • 后续还有,可以弹出
  • 后续没有,不可以弹出
ts
function removeDuplicateLetters(s: string): string {
    const stack:string[] = []
    const map:{[property: string]: boolean} = {} // 标记,栈内标记true
    const count:{ [property: string]: number } = {} // 计数器

    for (let str of s) {
        count[str] = (count[str] || 0) + 1
    }
    
    for (let str of s) {
        count[str]--
        if (map[str]) { // 如果在栈中存在,不推入
            continue;
        }

        // 在推入前,先判断栈顶元素是否比当前元素大
        // 如果栈顶比当前元素大,且栈顶的元素后续没有该重复元素,则将栈顶弹出不要了
        while(stack.length && stack[stack.length - 1] > str) {
            if(!count[stack[stack.length - 1]]) { // 如果后续没有该重复元素,不需要弹出,可以推入了。
                break
            }
            map[stack.pop()] = false // 栈内有且只会有一个,弹出就没了,弹出的标记 false
        }

        stack.push(str)
        map[str] = true
    }
    return stack.join('') // 返回去重后文本
};

田忌赛马,优势对比

870. 优势洗牌 - 力扣(LeetCode) 自己最大值 M1,对方最大值 H1 如果 M1 > H1 则自己上,否则拿最低的值与对方优势抵消

js
/**
 * @param {number[]} nums1
 * @param {number[]} nums2
 * @return {number[]}
 */
var advantageCount = function(nums1, nums2) {
    nums1.sort((a, b) => b - a)
    const maxpq = new PriorityQueue({
        compare: (a, b) => {
            return b[1] - a[1];
        }
    });

    for (let i = 0; i < nums2.length; i++) {
        maxpq.enqueue([i, nums2[i]])
    }
    // 最大值 M1 和 H1
    // 如果 M1 > H1 则自己上,否则拿最低的值与对方优势抵消
    const ret = []
    let left = 0, right = nums1.length - 1
    while (left <= right) {
        const [i, H1] = maxpq.dequeue()
        if (nums1[left] > H1) {
            ret[i] = nums1[left]
            left++
        } else {
            ret[i] = nums1[right]
            right--
        }
    }
    return ret
};

二叉堆和排序的复杂度 O(nlogn)