写在前面
本文讲述leetcode第239题:滑动窗口最大值的常见解法,以及由此引起的一些思考。使用的语言是PHP。
题目
滑动窗口最大值是leetcode上的一道题目,题号为239。
附上链接:leetcode-cn.com/problems/sl…
题目描述
给定一个数组nums,有一个大小为k的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的k个数字。滑动窗口每次只向右移动一位。返回滑动窗口中的最大值。
用一个例子来解释以下题意:假如有数组[1, 3, -1, -3, 5, 3, 6, 7],滑动窗口大小为3,那么应该返回[3, 3, 5, 5, 6, 7]。
暴力法
这是最简单也是最容易想到的解法,代码如下:
/**
* @param Integer[] $nums
* @param Integer $k
* @return Integer[]
*/
function maxSlidingWindow($nums, $k) {
$res = [];
$len = count($nums);
if ($k <= 0 || $len < $k) {
return $res;
}
for ($i = 0; $i <= $len - $k; $i++) {
$max = null;
for ($j = $i; $j < $i + $k; $j++) {
$max = is_null($max) || $nums[$j] > $max ? $nums[$j] : $max;
}
$res[] = $max;
}
return $res;
}
复杂度分析
有两层循环,外层循环n - k次,内层循环k次,所以时间复杂度 = O((n - k) * k) = O(kn - k^2) = O(kn)。
因为不需要额外的存储空间,所以空间复杂度为O(1)。
实际上,这种解法在leetcode上的提交结果是:超出时间限制。
大顶堆解法
用大顶堆存放当前滑窗中的数据,在堆顶中的数据就是当前滑窗的最大值。
大顶堆常见操作的时间复杂度
- 查找最大值的时间复杂度:O(1)
- 插入操作的时间复杂度:O(logn)或O(1)
复杂度分析
因为数组中每个元素需要访问一遍,而插入操作的时间复杂度是O(logn),查找最大值的时间复杂度是O(1),所以总体的时间复杂度是O(nlogk)。
需要额外维护一个堆来作为滑窗,所以空间复杂度是O(k)。
双端队列解法
双端队列是普通队列的一个升级版。普通队列支持数据先进先出(FIFO),有两种操作:入队,把数据从尾部插入;出队,从队列头部取出数据。在普通队列的基础上,双端队列支持更多的操作:
- 从尾部入队 insertLast
- 从尾部出队 deleteLast
- 从头部入队 insertFront
- 从头部出队 deleteFront
- 获取尾部元素 getRear
- 获取头部元素 getFront
在很多高级语言种,都提供了双端队列的实现。例如在PHP种,array就可以简单地实现一个双端队列,需要用到下面的函数:
- array_push()对应insertLast
- array_pop()对应deleteLast
- array_unshift()对应insertFront
- array_shift()对应deleteFront
- end()对应getRear
- 访问数组中下标为0的数据,对应getFront
那为什么双端队列可以解决滑动窗口最大值这一道题呢?我们先假定队头位置里存放的数据永远都是最大值。只要我们维护好这个特性,在每一个滑动窗口中,最大值就是队头元素。那我们怎麽维护双端队列,才能维持这个特性呢?这个我们放到后面讲。
用PHP内置的array函数实现双端队列
现在我们先利用PHP的array类型的这些函数来解决一下这个题目。先实现一个双端队列:
class CircularDeque {
private $arr = []; //用来存放数据
private $n = 0; //双端队列大小
/**
* Initialize your data structure here. Set the size of the deque to be k.
* @param Integer $k
*/
function __construct($k) {
$this->n = $k;
}
/**
* Adds an item at the front of Deque. Return true if the operation is successful.
* @param Integer $value
* @return Boolean
*/
function insertFront($value) {
if ($this->isFull()) {
return false;
}
array_unshift($this->arr, $value);
return true;
}
/**
* Adds an item at the rear of Deque. Return true if the operation is successful.
* @param Integer $value
* @return Boolean
*/
function insertLast($value) {
if ($this->isFull()) {
return false;
}
array_push($this->arr, $value);
return true;
}
/**
* Deletes an item from the front of Deque. Return true if the operation is successful.
* @return Boolean
*/
function deleteFront() {
if ($this->isEmpty()) {
return false;
}
array_shift($this->arr);
return true;
}
/**
* Deletes an item from the rear of Deque. Return true if the operation is successful.
* @return Boolean
*/
function deleteLast() {
if ($this->isEmpty()) {
return false;
}
array_pop($this->arr);
return true;
}
/**
* Get the front item from the deque.
* @return Integer
*/
function getFront() {
if ($this->isEmpty()) {
return -1;
}
return $this->arr[0];
}
/**
* Get the last item from the deque.
* @return Integer
*/
function getRear() {
if ($this->isEmpty()) {
return -1;
}
return end($this->arr);
}
/**
* Checks whether the circular deque is empty or not.
* @return Boolean
*/
function isEmpty() {
if (empty($this->arr)) {
return true;
}
return false;
}
/**
* Checks whether the circular deque is full or not.
* @return Boolean
*/
function isFull() {
if (count($this->arr) == $this->n) {
return true;
}
return false;
}
}
双端队列解题代码
我们再看看怎麽用双端队列来解决这道题:
/**
* @param Integer[] $nums
* @param Integer $k 滑动窗口大小
* @return Integer[]
*/
function maxSlidingWindow($nums, $k) {
$len = count($nums);
if ($k <= 0 || $len <= 0) {
return [];
}
if ($k == 1) {
return $nums;
}
$res = []; //结果集
$deque = new CircularDeque($k + 1);
for ($i = 0; $i < $len; $i++) {
// 保证从大到小 如果前面数小则需要依次弹出,直至满足要求
while (!$deque->isEmpty() && $nums[$deque->getRear()] <= $nums[$i]) {
$deque->deleteLast();
}
// 添加当前值对应的数组下标
$deque->insertLast($i);
// 判断当前队列中队首的值是否有效
if ($deque->getFront() <= $i - $k) {
$deque->deleteFront();
}
// 当窗口长度为k时 保存当前窗口中最大值
if ($i + 1 >= $k) {
$res[] = $nums[$deque->getFront()];
}
}
return $res;
}
但是,在leetcode上提交上述代码,执行时间为2604ms,仅击败了6.02%的PHP用户。为什么性能不够好呢?我感觉问题出在PHP的array及相关函数的实现上。
PHP array的底层实现
PHP array既可以作为数组使用,也可以作为散列表使用。它的底层实现是散列表(HashTable),通过数组下标来访问数据的时间复杂度是O(1)。但是这样会有一个问题,通过散列函数计算出存放数据的位置,这个位置是具有随机性的,是无序的。但是PHP数组有一个特性,就是它必须是有序的,数组中各元素的顺序与其插入顺序一致。这是怎么实现的呢?
为了实现散列表的有序性,PHP中的散列表在散列函数与元素数组之间加了一层映射表,这个映射表也是一个数组,大小与存储元素的数组相同,它存储的元素类型为整型,用于保存元素在实际存储的有序数组中的下标:元素按照先后顺序依次插入实即存储数组,然后将其数组下标按散列函数散列出来的位置存储在新加的映射表中。
PHP array_shift()
array_shift()的作用是将数组开头的单元移出数组。现在我们来看看这个函数的底层实现是怎么样的。我下载了php-7.4.13的源码,打开文件ext/standard/array.c,能看到array_shift的源码。
/* {{{ proto mixed array_shift(array stack)
Pops an element off the beginning of the array */
PHP_FUNCTION(array_shift)
{
zval *stack, /* Input stack */
*val; /* Value to be popped */
uint32_t idx;
Bucket *p;
ZEND_PARSE_PARAMETERS_START(1, 1)
Z_PARAM_ARRAY_EX(stack, 0, 1)
ZEND_PARSE_PARAMETERS_END();
if (zend_hash_num_elements(Z_ARRVAL_P(stack)) == 0) {
return;
}
/* Get the first value and copy it into the return value */
idx = 0;
while (1) {
if (idx == Z_ARRVAL_P(stack)->nNumUsed) {
return;
}
p = Z_ARRVAL_P(stack)->arData + idx;
val = &p->val;
if (Z_TYPE_P(val) == IS_INDIRECT) {
val = Z_INDIRECT_P(val);
}
if (Z_TYPE_P(val) != IS_UNDEF) {
break;
}
idx++;
}
ZVAL_COPY_DEREF(return_value, val);
/* Delete the first value */
if (p->key && Z_ARRVAL_P(stack) == &EG(symbol_table)) {
zend_delete_global_variable(p->key);
} else {
zend_hash_del_bucket(Z_ARRVAL_P(stack), p);
}
/* re-index like it did before */
if (HT_FLAGS(Z_ARRVAL_P(stack)) & HASH_FLAG_PACKED) {
uint32_t k = 0;
if (EXPECTED(!HT_HAS_ITERATORS(Z_ARRVAL_P(stack)))) {
for (idx = 0; idx < Z_ARRVAL_P(stack)->nNumUsed; idx++) {
p = Z_ARRVAL_P(stack)->arData + idx;
if (Z_TYPE(p->val) == IS_UNDEF) continue;
if (idx != k) {
Bucket *q = Z_ARRVAL_P(stack)->arData + k;
q->h = k;
q->key = NULL;
ZVAL_COPY_VALUE(&q->val, &p->val);
ZVAL_UNDEF(&p->val);
}
k++;
}
} else {
uint32_t iter_pos = zend_hash_iterators_lower_pos(Z_ARRVAL_P(stack), 0);
for (idx = 0; idx < Z_ARRVAL_P(stack)->nNumUsed; idx++) {
p = Z_ARRVAL_P(stack)->arData + idx;
if (Z_TYPE(p->val) == IS_UNDEF) continue;
if (idx != k) {
Bucket *q = Z_ARRVAL_P(stack)->arData + k;
q->h = k;
q->key = NULL;
ZVAL_COPY_VALUE(&q->val, &p->val);
ZVAL_UNDEF(&p->val);
if (idx == iter_pos) {
zend_hash_iterators_update(Z_ARRVAL_P(stack), idx, k);
iter_pos = zend_hash_iterators_lower_pos(Z_ARRVAL_P(stack), iter_pos + 1);
}
}
k++;
}
}
Z_ARRVAL_P(stack)->nNumUsed = k;
Z_ARRVAL_P(stack)->nNextFreeElement = k;
} else {
uint32_t k = 0;
int should_rehash = 0;
for (idx = 0; idx < Z_ARRVAL_P(stack)->nNumUsed; idx++) {
p = Z_ARRVAL_P(stack)->arData + idx;
if (Z_TYPE(p->val) == IS_UNDEF) continue;
if (p->key == NULL) {
if (p->h != k) {
p->h = k++;
should_rehash = 1;
} else {
k++;
}
}
}
Z_ARRVAL_P(stack)->nNextFreeElement = k;
if (should_rehash) {
zend_hash_rehash(Z_ARRVAL_P(stack));
}
}
zend_hash_internal_pointer_reset(Z_ARRVAL_P(stack));
}
可以看到,在获取并删除第一个元素后,有一个re-index操作,个人认为是这里带来了性能损耗。在删除第一个元素后,必须把原来下标为1(或者说key是1)的元素移动到下标为0的位置,下标为2的元素移动到下标为1的位置,以此类推。
自己实现一个双端队列
一般来说,双端队列有两种实现方式:
- 用数组实现
- 用链表实现
下面我们主要介绍数组实现,这里我们用一个循环队列来实现。为什么要用循环队列,是因为循环队列可以避免入队时的数据搬迁操作,提高了性能。
循环队列,我们可以把它想象成一个环。数组是有头有尾的,现在我们把数组“首尾相连”,连成一个环。
数组实现的循环队列,重点是两个指针的使用:head和tail,head指向队头,tail指向队尾。刚刚我们提到循环队列可以提高性能,那普通队列有什么问题呢?当head等于tail的时候,我们认为队列为空。随着指针的不断往后移动,当tail移动到最右,即使队列中还有空间,也无法继续插入数据了。为了解决这个问题,只能进行数据迁移,性能不好。
因此,我们使用循环队列。需要注意:
- 队空条件:tail == head
- 队满条件:(tail + 1) % n == head
- 为了制造队满条件,循环队列会浪费数组的一个元素空间。如下图所示,队满时,tail指向的位置是没有数据的。
代码如下:
class CircularDeque {
private $arr = [];
private $n = 0;
private $front = 0;
private $rear = 0;
/**
* Initialize your data structure here. Set the size of the deque to be k.
* @param Integer $k
*/
function __construct($k) {
$this->n = $k + 1;
}
/**
* Adds an item at the front of Deque. Return true if the operation is successful.
* @param Integer $value
* @return Boolean
*/
function insertFront($value) {
if ($this->isFull()) {
return false;
}
$this->front = ($this->front + $this->n - 1) % $this->n;
$this->arr[$this->front] = $value;
return true;
}
/**
* Adds an item at the rear of Deque. Return true if the operation is successful.
* @param Integer $value
* @return Boolean
*/
function insertLast($value) {
if ($this->isFull()) {
return false;
}
$this->arr[$this->rear] = $value;
$this->rear = ($this->rear + 1) % $this->n;
return true;
}
/**
* Deletes an item from the front of Deque. Return true if the operation is successful.
* @return Boolean
*/
function deleteFront() {
if ($this->isEmpty()) {
return false;
}
$this->front = ($this->front + 1) % $this->n;
return true;
}
/**
* Deletes an item from the rear of Deque. Return true if the operation is successful.
* @return Boolean
*/
function deleteLast() {
if ($this->isEmpty()) {
return false;
}
$this->rear = ($this->rear + $this->n - 1) % $this->n;
return true;
}
/**
* Get the front item from the deque.
* @return Integer
*/
function getFront() {
if ($this->isEmpty()) {
return -1;
}
return $this->arr[$this->front];
}
/**
* Get the last item from the deque.
* @return Integer
*/
function getRear() {
if ($this->isEmpty()) {
return -1;
}
return $this->arr[(($this->rear - 1) + $this->n) % $this->n];
}
/**
* Checks whether the circular deque is empty or not.
* @return Boolean
*/
function isEmpty() {
if ($this->front == $this->rear) {
return true;
}
return false;
}
/**
* Checks whether the circular deque is full or not.
* @return Boolean
*/
function isFull() {
if (($this->rear + 1) % $this->n == $this->front) {
return true;
}
return false;
}
}
自己实现了一个双端队列后,在leetcode上提交,执行时间为968ms,仅击败了54.81%的PHP用户,速度比之前快了接近两倍。
复杂度分析
因为在当前实现下,双端队列的各个操作的时间复杂度都是O(1),对入参数组循环一遍,所以总的时间复杂度是O(n)。
因为需要额外维护一个双端队列来存放窗口数据,所以空间复杂度是O(k)。
参考
《php7内核剖析》