Skip to content

Latest commit

 

History

History

2289.Steps-to-Make-Array-Non-decreasing

2289.Steps-to-Make-Array-Non-decreasing

本题的关键点是,如果某个位置i满足条件nums[i-1]>nums[i]需要被删除,那么导致的后果就是,在下一个回合,如果i后面的那个元素j(不见得是i+1,有可能i+1在这个回合已经被删除了)满足nums[i-1]>nums[j]的话,那么j会在“下一个回合”删除。那么如果确保j不是在这个回合就已经被删除了的呢?我们只需要倒序遍历。

举个例子,如下图,如果元素j被判定在本回合不会被删除,继续往前遍历,且i+1,i+2,...,j-1这些元素也在本回合删除,那么在考察元素i的时候就有next(i) = j,那么我们就可以安心地把j作为“下一个回合”待删除的对象。

i-1, i, i+1, i+2, ..., j-1, j
     X    X   X         X   O

以上的关键就是如何高效地维护next和prev,即如何快速某个元素后面一个/前面一个尚未被删除的元素(或者说idx)。

解法1:暴力模拟

LRU CacheLFU Cache一样,将链表和关于“元素->链表地址”的Hash结合起来用,是一个大杀器,可以保证在o(1)时间内的查找、删除。本题中,我们定义

list<int> List;
unordered_map<int,list<int>::iterator>idx2iter;

链表List里面初始节点是一串编号{0,1,2,...,n-1},idx2iter则代表着链表每个节点的指针。

我们从后往前遍历,如果某个idx满足被删除的条件,令iter表示该idx的指针,那么就意味着next(iter)直接就是下一个节点的指针(无论两者之间已经删除了多少结果)。我们将*next(iter)这个后续编号放入一个candidates集合里,此时可以安心的将iter本身删除,而List的数据结构会自动将前后节点“接合”在一起。

我们倒序走完一遍后,剩下的candidates里面的编号,还需要再考察一遍是否满足nums[*prev(idx2iter[idx])] > nums[idx],将那些符合条件的再进行下一遍的倒序遍历。

显然,这种结构就是层级遍历的BFS。答案就是看走了几个回合。

解法2:高效模拟

在解法1中我们考察的是点,所以需要依靠List和Hash来额外维护next和prev。一个更好的解法是考察一对pair。同时用removed数组来标记节点的删除,而不用List。

我们在层级遍历BFS的过程中,加入的元素记做{l,r}表示此时一对相邻的编号且满足nums[l]>nums[r]. 那么在处理这对pair的时候,我们会标记removed[r]=1,同时根据之前的思路,我们要找到r此时的右邻元素编号next[r]。但是这里有一个问题,next[r]可能恰好在这个回合里面被删除了(因为我们是倒序遍历的),且此时我们必须找到再右邻的元素(不能放弃)。显然我们需要不断跳转来寻找:

r2 = r;
while (r2!=n && removed[r2])
  r2 = next[r2];

递归结束后所找到的r2肯定是当前回合未被删除的,如果满足大小关系,那么[l, r2]就是下一个回合需要考察的pair。记得别忘了更新next[r] = r2,这样能加快今后的跳转效率。

此外还需要特别说明的是,虽然在这个回合里面,我们已经判定[l,r2]是下一个回合需要考察的pair,但注意到编号l的元素在本回合中尚未被遍历到(因为它是r左边的元素)。等到了下一个回合时,有可能我们发现l已经被删除了,那么此时的这个pair就作废了,跳过即可。但这没有关系,不意味着我们漏掉了r2,因为r2可能还会因为和其他的l2配对在一起。

需要提醒的是,这两种模拟的算法都是o(n),因为每删除一个数字,只会用o(1)引进一个新的candidate。

解法3:单调栈

我们从右往左遍历元素,维护一个递减的单调栈。任何一个新元素M,如果它大于栈顶的若干个元素,那么这些元素(包括之前被这些元素弹出的元素)本质上都是因为M而退栈。这个性质与本题的题意非常类似:对于M而言,它右邻的、连续的比M小的元素,都会被M所“吃掉”。

我们令count[i]表示元素i吃掉它所“影响”的元素(即i右邻的、连续的比i小的元素)需要多少步。我们画这样一张图模拟单调栈,横轴表示先后顺序,纵轴表示大小关系。

                                                       q
i
                                 p
                k                    (..P..)
    j               (..K..)
       (..J..)

从右往左看,先入栈的是q;然后是一系列(..P..),但它们接着会因为p的入栈而弹出;然后是一系列(..K..),同样它们会因为k的入栈而弹出。再接下来是(..J..)的入栈,随后因为j的入栈而弹出。当考察元素i的时候,栈内从顶至底是[j,k,p,q]

此时我们考虑如何计算count[i],即将[j, q-1]范围内的元素都吃掉需要花多少步。我们知道,最后一个被i吃掉的元素一定是p,在此之前,(..P..)已经被p吃掉,所需要的步数就是count[p];同时[j,p-1]这部分已经被i吃掉,所需要的步数我们记做f(k),因为k是这个区间里的最大值,也就是最后一个被吃掉的。所以我们就有一个重要的结论:

count[i] = max(f(k)+1, count[p])

怎么解释这个公式?总的来说,p有两种被吃掉的途径。如果f(k)比较大,那么count[p]耗完之后还需要等若干个回合,等f(k)结束之后,p此时才能与i相邻,故需要再加一步将p吃掉。如果count[p]较大,那么f(k)耗完之后,p已经与i相邻了,故p被i吃掉的这个步骤可以早于count[p],故总的回合数的瓶颈依然是count[p]。

那么上式里面的f(k)又从哪来呢?其实类似地发现:

f(k) = max(f(j)+1, count[k])

于是我们看出来,f()的计算是一个递归的过程。对于count[i],我们需要依次得到f(j),f(k),f(p),而j,k,p也就是在单调栈中被i弹出的元素。最终count[i]也就是f(p)。所以我们利用退栈的过程更新f

int f = 0;
while (!Stack.empty() && nums[i]>nums[Stack.top()])
{                
     f = max(f+1, count[Stack.top()]);
     Stack.pop();
}
count[i] = f;

因为最终是一个非递减序列,意味着在原序列里,任意一个元素i都需要将右邻的、连续的比i小的元素都吃掉。所以最终的答案就是所有count[i]里最大的一个值。