数据结构视角下的数组
数组的随机访问
数组是一种线性表数据结构。它用一组连续的内存空间,来存储一组具有相同类型的数据。
数组内存空间的申请还是需要固定大小的,如果不是后续的内存无法分配,因为你不确定这个连续的内存地址该什么时候结束。
线性表,就好比一条绳,绳上存储了数据,绳的方向只有头和尾,线性表的方向同样也只有向前和向后。队列、栈、链表都属于线性表。堆、二叉树、图等都属于非线性表。
由于是连续的内存空间存储相同类型的数据,所以我们可以通过计算内存地址,去随机访问数组。
a[i]_address = base_address + i * data_type_size
数组的插入和删除相对低效
因为在数组中插入元素的话,由于内存空间是连续的,需要将插入位置后的所有元素向后移动一个空间,去维持这种连续性。删除同样也是,要移动剩余元素补齐前一个空间。这种特性,导致数组的插入和删除需要做很多数据搬迁的操作。有意思的是,删除可以先标记,后统一删除,再去移动空间。效率会高很多。
如果你了解 JVM,你会发现,这不就是 JVM 标记清除垃圾回收算法的核心思想吗?没错,数据结构和算法的魅力就在于此,很多时候我们并不是要去死记硬背某个数据结构或者算法,而是要学习它背后的思想和处理技巧,这些东西才是最有价值的。如果你细心留意,不管是在软件开发还是架构设计中,总能找到某些算法和数据结构的影子。
数组的插入还会涉及内容扩容的问题,如果声明的数组空间满了,插入新数据,就需要内存的申请、可能还有数据的搬迁。
JavaScript视角下的数组
回想我们平时使用的数组,好像没有这些奇奇怪怪的设定啊?又是连续又是相同类型,还扩容?
其实js中的数组并不是数据结构意义上的数组,存储的空间也不一定是连续的,所以随机访问也不是根据内存地址来的,初始化空间大小也失去了意义。
总的来说,JS 的数组看似与传统数组不一样,其实只是 V8 在底层实现上做了一层封装,使用两种数据结构(数组和哈希表)实现数组,通过时间和空间纬度的取舍,优化数组的性能。
在JavaScript的范畴内,我们直接使用array就好了,性能的优化交给引擎处理就好。
详细的内容可以看👇
探究JS V8引擎下的“数组”底层实现
leetcode实战
做题思路:
- 依据自己对数据结构、算法的理解先尝试用对应的套路解题。
- 如果5到10分钟,完全没有思路,想不出的。可以直接看答案解析,借鉴套路或者小技巧。
- 理解后再重新写一遍。
- 如果自己思路能顺利提交,再看解析,看看有没有更好的方案和优化,并且总结特点和技巧。
- 反复如此
数组相关的套路和算法比较广泛,处理的方式最基本的为js数组的API。如slice、splice、pop、shitf、循环for、map等。需要多⚠️注意的就是数组的边界(空数组,循环到底)等情况。
但是往往复杂的题目,还需要应用二分查找、动态规划,深度优先、广度优先遍历等算法去处理(这些题目会配合相应的章节出现)。
下面通过一些仅与数组相关、代表性强的、出现频率高的题目刻意练习几遍,深刻印象。
867. 转置矩阵👈
问题:
给定一个矩阵
A, 返回A的转置矩阵。 矩阵的转置是指将矩阵的主对角线翻转,交换矩阵的行索引与列索引。
示例 :
输入:[[1,2,3],[4,5,6],[7,8,9]]
输出:[[1,4,7],[2,5,8],[3,6,9]]
输入:[[1,2,3],[4,5,6]]
输出:[[1,4],[2,5],[3,6]]
思路:
根据题目表示,交换矩阵的行索引,和列索引。相当于行变成列,列变成行。
画图更好理解👇
👌,看图所示,我们知道新矩阵的长度是旧矩阵的列数,新矩阵单行的长度是旧矩阵的列数。
代码:
var transpose = function (A) {
// 新建矩阵,长度为A的列数,即A[0]长度
const arr = new Array(A[0].length)
// 遍历新矩阵,一个一个赋值
for (let i = 0; i < A[0].length; i++) {
// 新矩阵列数等于A的行数,即A长度
arr[i] = [] // new Array(A.length)
for (let j = 0; j < A.length; j++) {
arr[i][j] = A[j][i]
}
}
return arr
};
面试题 17.10. 主要元素👈
问题:
数组中占比超过一半的元素称之为主要元素。给定一个整数数组,找到它的主要元素。若没有,返回-1。 说明: 你有办法在时间复杂度为 O(N),空间复杂度为 O(1) 内完成吗?
示例:
输入:[1,2,5,9,5,9,5,5,5]
输出:5
输入:[3,2]
输出:-1
输入:[2,2,1,1,1,2,2]
输出:2
思路:
根据题目,我们下意识可能会想到通过对象或者map结构记录每个元素的出现次数,比较获取最大值。这是最直观的解法,但是题目建议在空间复杂度O(1)内,我们就只能申请常数的空间。
这题提供两种解决思路:
- 摩尔投票法
我们基于一个事实,这个数组中存在一个数字,出现次数超过一半。则我们可以选择这个数组中不相同的两个,进行抵消。最后剩下来的就是一个或多个就是我们要找的数字。相当于翻版消消乐,不同的数字抵消。
理解为给数字投票,保存数字,以及出现的次数即可。遇到不同的就-1(抵消)没有票数了就更换数字,遇到相同就+1。遍历后返回最后剩下的就可以了。
⚠️注意的是:这题目中,存在超过半数的数字不一定是事实。所以我们获取最后的数字后,还需要验证。
代码:
var majorityElement = function (nums) {
if(nums.length===0)return -1
let flag = nums[0]
let count = 1
// 投票算法
// 同一数字,投票++1,
// 出现不同的,--1,如果减到0,则更换被投数字
// 因为总数大于一半,同一个数 最终到票数肯定>0
for(let i = 1;i<nums.length;i++){
if(count === 0)flag = nums[i]
if(nums[i]===flag)count++
else count--
}
// 结尾校验,获得的数字是否大于一半
if(count>0){
count = 0
for(let i=0;i<nums.length;i++){
if(nums[i]=== flag)count++
}
if(count>Math.floor(nums.length/2))return flag
}
return -1
};
- 排序
我们首先将数组排序,这样相同数字就会连续出现。我们设置一个窗口,长度刚好超过数组的一半。我们滑动这个窗口,首尾如果相同,证明出现次数满足了。
代码:
var majorityElement = function (nums) {
nums.sort((a, b) => a - b)
const step = Math.floor(nums.length / 2)
// 从头滑动窗口到底
for(let i=0;i+step<nums.length;i++){
// 窗口起点 i,结束点 i+step,总长度刚好超过半数
if(nums[i]===nums[i+step])return nums[i]
}
return -1
};
724. 寻找数组的中心索引👈
问题:
给定一个整数类型的数组 nums,请编写一个能够返回数组 “中心索引” 的方法。 我们是这样定义数组 中心索引 的:数组中心索引的左侧所有元素相加的和等于右侧所有元素相加的和。 如果数组不存在中心索引,那么我们应该返回 -1。如果数组有多个中心索引,那么我们应该返回最靠近左边的那一个。
示例:
输入:
nums = [1, 7, 3, 6, 5, 6]
输出:3
解释:
索引 3 (nums[3] = 6) 的左侧数之和 (1 + 7 + 3 = 11),与右侧数之和 (5 + 6 = 11) 相等。
同时, 3 也是第一个符合要求的中心索引。
思路:
我们只需要知道当前index,之前的总和与之后的总和是否相等即可。关键之后的总和怎么求,看👇
代码:
var pivotIndex = function (nums) {
const sum = nums.reduce((pre, n) => pre + n, 0)
let count = 0
for (let i = 0; i < nums.length; i++) {
if (i > 0) count += nums[i - 1]
if (count === sum - nums[i] - count) return i
}
return -1
};
面试题 01.08. 零矩阵👈
问题:
编写一种算法,若M × N矩阵中某个元素为0,则将其所在的行与列清零。
示例:
输入:
[
[1,1,1],
[1,0,1],
[1,1,1]
]
输出:
[
[1,0,1],
[0,0,0],
[1,0,1]
]
思路:
其实很容易理清过程,我们只需要**标记需要置0的行和列,在后续的遍历中置0即可。**为此我们可能需要新建空间去保存这些标记。先看代码。👇
代码:
var setZeroes = function (matrix) {
// 保存需要置0的行号
const row = new Map()
// 保存需要置0的列号
const column = new Map()
function func(sign = true) { // sign用于表示属于什么阶段
for (let i = 0; i < matrix.length; i++) {
for (j = 0; j < matrix[i].length; j++) {
if (matrix[i][j] === 0 && sign) {
// 标记阶段
row.set(i, true)
column.set(j, true)
}
// 清除阶段
if ((row.has(i) || column.has(j)) && !sign) matrix[i][j] = 0
}
}
}
func()
func(false)
};
当然如果想优化,不额外申请与数据规模相对应的空间,把空间复杂度从O(n)降至O(1)也是可以的。我们只能借助原本的矩阵去记录标记。
可以**借助第一行去保存每一列是否需要标记,第一列去保存每一行是否需要标记。**当然,这样会破坏第一行第一列的内容,所以在此之前,先用两个变量去保存第一行和第一列是否要置0。
代码:
var setZeroes = function (matrix) {
let row1 = false
let column = false
// 第一行第一列的标记清除方法
function rc(list, is_r = true, clear = false) {
for (let i = 0; i < list.length; i++) {
if (is_r && !clear) {
if (list[i] === 0) return true
} else if (!is_r && !clear) {
if (list[i][0] === 0) return true
}
if (clear && is_r) list[i] = 0
if (clear && !is_r) list[i][0] = 0
}
// 标记为false,不需要置0
return false
}
// 获取第一行第一列是否要置0
row1 = rc(matrix[0])
column1 = rc(matrix, false)
// 矩阵的标记清除方法
function other(clear = false) {
for (let i = 1; i < matrix.length; i++) {
for (j = 1; j < matrix[i].length; j++) {
if (matrix[i][j] === 0 && !clear) {
// 标记阶段
matrix[0][j] = 0
matrix[i][0] = 0
}
if ((matrix[0][j] === 0 || matrix[i][0] === 0) && clear) matrix[i][j] = 0
}
}
}
// 标记剩余矩阵
other()
// 清理矩阵
other(true)
// 根据第一行第一列标记清除
if (row1) rc(matrix[0], true, true)
if (column1) rc(matrix, false, true)
};
总结
单纯的数组问题,可以分为,一维数组,多维数组(常见的二维数组),大多都是寻找、排序、替换等问题。先熟悉这些简单的处理感觉,后续如果遇到更复杂的情况,才能利用这种感觉配合其他算法进行处理。学习之路开始了,希望能坚持✊下去。